Version: 1.0.0
Compliance: DPDPA 2023 (India), GDPR-ready
Encryption: AES-256-GCM
Auth: Better Auth with RBAC
1. Token Encryption#
Encryption Implementation#
// src/lib/crypto.ts
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
function getKey(): Buffer {
return scryptSync(process.env.ENCRYPTION_KEY!, "rankflow-salt", 32);
}
export function encrypt(value: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString("base64")}:${authTag.toString("base64")}:${encrypted.toString("base64")}`;
}
export function decrypt(encryptedValue: string): string {
const key = getKey();
const [ivB64, authTagB64, encryptedB64] = encryptedValue.split(":");
const iv = Buffer.from(ivB64, "base64");
const authTag = Buffer.from(authTagB64, "base64");
const encrypted = Buffer.from(encryptedB64, "base64");
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
Encrypted Fields#
| Field |
Model |
Encryption |
accessToken |
GbpAccount |
At rest |
refreshToken |
GbpAccount |
At rest |
accessToken |
SocialAccount |
At rest |
refreshToken |
SocialAccount |
At rest |
passwordEncrypted |
Citation |
At rest (directory credentials) |
Environment Variable#
# 32-byte key, base64 encoded
ENCRYPTION_KEY=xxx...xxx
2. Authentication & Authorization#
Role-Based Access Control#
enum UserRole {
ADMIN // Full system access
CLIENT // Practice owner
EDITOR // Can edit content, approve posts
VIEWER // Read-only dashboard
}
Middleware Stack#
Request → Rate Limit → Request ID → Auth → Practice Resolve → Role Check → Handler
Procedure Types#
| Procedure |
Auth |
Practice |
Role |
Use Case |
publicProcedure |
❌ |
❌ |
Any |
Health, webhooks |
protectedProcedure |
✅ |
❌ |
Any |
User profile |
practiceProcedure |
✅ |
✅ |
CLIENT+ |
Practice data |
adminProcedure |
✅ |
❌ |
ADMIN |
System-wide |
Session Management#
// Better Auth configuration
export const auth = betterAuth({
database: prismaAdapter(db),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh every 24h
},
rateLimit: {
window: 60, // 1 minute
max: 100, // 100 requests per minute
},
});
3. DPDPA Compliance#
Data Processing Principles#
| Principle |
Implementation |
| Consent |
Explicit opt-in during onboarding |
| Purpose limitation |
Data used only for SEO services |
| Data minimization |
Only collect necessary business data |
| Accuracy |
NAP monitoring ensures accuracy |
| Storage limitation |
Auto-delete after 1 year post-cancellation |
| Security |
Encryption + access controls |
| Accountability |
Full audit log of all data access |
Consent Flow#
1. Client signs up → sees data usage terms
2. Must check "I consent to data processing"
3. Consent recorded with timestamp + IP
4. Can withdraw consent from settings
5. Withdrawal triggers data deletion workflow
Sensitive Data Handling#
| Data Type |
Classification |
Handling |
| Business NAP |
Personal data (business) |
Encrypted, shared with directories only |
| Patient reviews |
Sensitive personal data |
Never stored long-term, only processed for replies |
| Reviewer names |
Personal data |
Anonymized in analytics |
| Doctor photos |
Personal data |
Stored with consent, deleted on cancellation |
| Payment info |
Financial data |
Tokenized (Stripe/Razorpay), never stored |
Data Processing Agreement#
// src/server/lib/compliance/dpdpa.ts
export async function recordConsent(
userId: string,
practiceId: string,
consentType: string,
ipAddress: string,
userAgent: string
) {
await db.consentLog.create({
data: {
userId,
practiceId,
consentType,
granted: true,
ipAddress,
userAgent,
grantedAt: new Date(),
},
});
}
export async function withdrawConsent(userId: string, practiceId: string) {
await db.consentLog.create({
data: {
userId,
practiceId,
consentType: "data_processing",
granted: false,
withdrawnAt: new Date(),
},
});
// Trigger data deletion workflow
await inngest.send({
name: "compliance/data-deletion",
data: { practice_id: practiceId },
});
}
4. Audit Logging#
Audit Log Schema#
model AuditLog {
id String @id @default(cuid())
practiceId String?
userId String?
action String // CREATE, UPDATE, DELETE, LOGIN, SKILL_EXECUTE, etc.
entityType String // table name or resource type
entityId String?
oldValue Json?
newValue Json?
metadata Json @default("{}")
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
practice Practice? @relation(fields: [practiceId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([practiceId, createdAt])
@@index([userId, createdAt])
@@index([action, createdAt])
@@map("audit_logs")
}
Logged Events#
| Event |
Action |
Data Captured |
| User login |
LOGIN |
IP, user agent, timestamp |
| Practice created |
CREATE |
Full practice data |
| Practice updated |
UPDATE |
Old + new values |
| GBP connected |
CREATE |
Account email (not tokens) |
| Post published |
SKILL_EXECUTE |
Skill ID, cost, duration |
| Content approved |
UPDATE |
Content ID, approver |
| Billing event |
UPDATE |
Invoice status, amount |
| Data export |
EXPORT |
What was exported |
| Data deletion |
DELETE |
What was deleted |
Audit Logger#
// src/server/harness/audit.ts
export async function logAudit(event: {
practiceId?: string;
userId?: string;
action: string;
entityType: string;
entityId?: string;
oldValue?: any;
newValue?: any;
metadata?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}) {
await db.auditLog.create({
data: {
practiceId: event.practiceId,
userId: event.userId,
action: event.action,
entityType: event.entityType,
entityId: event.entityId,
oldValue: event.oldValue,
newValue: event.newValue,
metadata: event.metadata || {},
ipAddress: event.ipAddress,
userAgent: event.userAgent,
},
});
logger.info({
event: "audit",
practiceId: event.practiceId,
userId: event.userId,
action: event.action,
entityType: event.entityType,
entityId: event.entityId,
}, "Audit event");
}
5. Data Export & Deletion#
Data Export (DPDPA Right to Access)#
export async function exportPracticeData(practiceId: string) {
const [
practice,
locations,
members,
gbpAccounts,
socialAccounts,
citations,
contentPieces,
reviews,
jobs,
reports,
auditLogs,
] = await Promise.all([
db.practice.findUnique({ where: { id: practiceId } }),
db.location.findMany({ where: { practiceId } }),
db.practiceMember.findMany({ where: { practiceId }, include: { user: true } }),
db.gbpAccount.findMany({ where: { practiceId } }),
db.socialAccount.findMany({ where: { practiceId } }),
db.citation.findMany({ where: { practiceId } }),
db.contentPiece.findMany({ where: { practiceId } }),
db.review.findMany({ where: { location: { practiceId } } }),
db.job.findMany({ where: { practiceId } }),
db.report.findMany({ where: { practiceId } }),
db.auditLog.findMany({ where: { practiceId } }),
]);
return {
personalData: {
practice,
locations,
members: members.map(m => ({
role: m.role,
joinedAt: m.acceptedAt,
user: { name: m.user.name, email: m.user.email },
})),
gbpAccounts: gbpAccounts.map(a => ({
accountEmail: a.accountEmail,
connectedAt: a.createdAt,
})),
socialAccounts: socialAccounts.map(a => ({
platform: a.platform,
accountName: a.accountName,
})),
citations,
contentPieces,
reviews: reviews.map(r => ({
rating: r.rating,
comment: r.comment,
reviewDate: r.reviewDate,
})),
jobs: jobs.map(j => ({ type: j.skillId, status: j.status, createdAt: j.createdAt })),
reports,
auditLogs,
},
generatedAt: new Date().toISOString(),
format: "JSON",
};
}
Data Deletion (DPDPA Right to Erasure)#
export async function deletePracticeData(practiceId: string) {
await db.$transaction([
// Soft delete practice
db.practice.update({
where: { id: practiceId },
data: {
name: `[DELETED] ${Date.now()}`,
status: "CANCELLED",
deletedAt: new Date(),
customDomain: null,
subdomain: `deleted-${Date.now()}`,
stripeCustomerId: null,
razorpayCustomerId: null,
},
}),
// Wipe tokens
db.gbpAccount.updateMany({
where: { practiceId },
data: {
accessToken: "[DELETED]",
refreshToken: "[DELETED]",
isActive: false
},
}),
db.socialAccount.updateMany({
where: { practiceId },
data: {
accessToken: "[DELETED]",
refreshToken: "[DELETED]",
isActive: false
},
}),
// Delete citations from directories
db.citation.updateMany({
where: { practiceId },
data: { status: "REMOVED", directoryUrl: null },
}),
// Anonymize reviews
db.review.updateMany({
where: { location: { practiceId } },
data: {
reviewerName: "[REDACTED]",
comment: "[REDACTED]",
},
}),
]);
// Trigger external deletions
await inngest.send({
name: "compliance/external-deletion",
data: { practice_id: practiceId },
});
}
6. API Security#
Rate Limiting#
| Route Pattern |
Limit |
Window |
skill.execute |
10 |
1 minute |
content.generate |
5 |
1 minute |
gbp.createPost |
30 |
1 minute |
social.createPost |
30 |
1 minute |
| All other |
100 |
1 minute |
Request ID Propagation#
// Every request gets x-request-id
// Propagated to: audit logs, job records, external APIs
const requestId = crypto.randomUUID();
response.headers.set("x-request-id", requestId);
// External API calls include:
headers: {
"X-RankFlow-Request-ID": requestId,
}
// All inputs validated with Zod
const createPracticeSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string().regex(/^[a-z0-9-]+$/).optional(),
type: z.enum(["CLINIC", "HOSPITAL", /* ... */]),
});
// Sanitization
import DOMPurify from "isomorphic-dompurify";
const cleanContent = DOMPurify.sanitize(rawContent);
7. Infrastructure Security#
Docker Security#
# Dockerfile
FROM node:20-alpine
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
USER nextjs
# ... rest of Dockerfile
Environment Variables#
# Required secrets
ENCRYPTION_KEY= # 32-byte AES key
BETTER_AUTH_SECRET= # Auth signing secret
ANTHROPIC_API_KEY= # Claude API
OPENAI_API_KEY= # OpenAI API
GOOGLE_CLIENT_SECRET= # Google OAuth
STRIPE_SECRET_KEY= # Stripe
RAZORPAY_KEY_SECRET= # Razorpay
AWS_SECRET_ACCESS_KEY= # S3
CLOUDFLARE_API_TOKEN= # DNS
HYPERBROWSER_API_KEY= # Browser automation
FIRECRAWL_API_KEY= # Scraping
COMPOSIO_API_KEY= # Social auth
ZERNIO_API_KEY= # Social scheduling
RESEND_API_KEY= # Email
DATAFORSEO_PASSWORD= # SEO data
SERPAPI_KEY= # Rank tracking
Network Security#
| Layer |
Config |
| VPC |
AWS VPC with private subnets |
| Security Groups |
Only 80/443 inbound, restricted outbound |
| SSL |
Cloudflare wildcard cert + force HTTPS |
| CORS |
Strict origin whitelist |
| CSP |
Content Security Policy headers |
Health Check#
// src/app/api/health/route.ts
export async function GET() {
const checks = {
database: await checkDatabase(),
redis: await checkRedis(),
inngest: await checkInngest(),
};
const overall = Object.values(checks).every(c => c.status === "pass")
? "healthy"
: Object.values(checks).some(c => c.status === "fail")
? "unhealthy"
: "degraded";
return NextResponse.json({ status: overall, checks });
}
End of Security & Compliance Documentation