@tantainnovative/create-ndpr 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,165 +1,373 @@
1
1
  /**
2
- * Next.js App Router — Consent API Route
3
- * Generated by create-ndpr for: {{ORG_NAME}}
4
- *
5
- * NDPA Section 26 (consent) and Section 25 (lawful basis). Records the
6
- * subject's affirmative choice with the audit metadata NDPC asks for.
7
- *
8
- * Endpoints:
9
- * GET /api/consent?subjectId=xxx — Load the active consent record
10
- * POST /api/consent — Save new consent (revokes previous)
11
- * DELETE /api/consent?subjectId=xxx — Revoke all active consent
2
+ * Next.js App Router — tenant-scoped consent persistence for {{ORG_NAME_COMMENT}}.
3
+ * Subject and tenant identity come only from the generated server context.
12
4
  */
13
-
14
5
  import { NextRequest, NextResponse } from 'next/server';
6
+ import {
7
+ getNDPRContextProblem,
8
+ resolveNDPRRequestContext,
9
+ } from '{{NDPR_CONTEXT_IMPORT}}';
15
10
  // {{#if ORM=prisma}}
16
- import { PrismaClient } from '@prisma/client';
17
-
11
+ import { Prisma, PrismaClient } from '@prisma/client';
18
12
  const prisma = new PrismaClient();
19
13
  // {{/if}}
20
14
  // {{#if ORM=drizzle}}
21
- import { db } from '@/drizzle';
22
- import { consentRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
23
- import { eq, and, isNull, desc } from 'drizzle-orm';
15
+ import { db } from '{{NDPR_DB_IMPORT}}';
16
+ import { consentRecords, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
17
+ import { and, desc, eq, isNull } from 'drizzle-orm';
24
18
  // {{/if}}
25
19
  // {{#if ORM=none}}
26
- // TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
27
- // in-memory map below is enough to develop against locally but DOES NOT
28
- // satisfy NDPA Section 44 (record-keeping) or the auditability NDPC asks
29
- // about. Replace `consentStore`/`auditLog` with your DB / KV / API.
20
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
30
21
  interface ConsentRecord {
31
- id: string;
32
- subjectId: string;
33
- consents: Record<string, boolean>;
34
- version: string;
35
- method: string;
36
- lawfulBasis: string | null;
37
- ipAddress: string | null;
38
- userAgent: string | null;
39
- createdAt: Date;
40
- revokedAt: Date | null;
22
+ id: string; tenantId: string; subjectId: string; activeSubjectKey: string | null;
23
+ consents: Record<string, boolean>; version: string; method: string;
24
+ hasInteracted: boolean; lawfulBasis: string | null;
25
+ ipAddress: string | null; userAgent: string | null; clientTimestamp: Date;
26
+ createdAt: Date; revokedAt: Date | null;
27
+ }
28
+ interface AuditRow {
29
+ id: string; tenantId: string; module: string; action: string;
30
+ entityId: string; performedBy: string | null; at: Date;
41
31
  }
42
32
  const consentStore = new Map<string, ConsentRecord>();
43
- const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
44
- function newId() { return crypto.randomUUID(); }
33
+ const auditLog: AuditRow[] = [];
45
34
  // {{/if}}
46
35
 
47
- // GET /api/consent?subjectId=xxx
48
- export async function GET(req: NextRequest) {
49
- const subjectId = req.nextUrl.searchParams.get('subjectId');
36
+ interface ConsentSettings {
37
+ consents: Record<string, boolean>;
38
+ version: string;
39
+ method: string;
40
+ hasInteracted: boolean;
41
+ lawfulBasis?: string;
42
+ timestamp: number;
43
+ }
50
44
 
51
- if (!subjectId) {
52
- return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
45
+ function validateConsentBody(body: unknown): string | null {
46
+ if (!body || typeof body !== 'object') return 'A JSON object is required';
47
+ const value = body as Record<string, unknown>;
48
+ if (!value.consents || typeof value.consents !== 'object' || Array.isArray(value.consents)) {
49
+ return 'consents must be an object';
50
+ }
51
+ if (Object.values(value.consents).some((consent) => typeof consent !== 'boolean')) {
52
+ return 'Every consent value must be boolean';
53
+ }
54
+ if (typeof value.version !== 'string' || !value.version.trim()) return 'version is required';
55
+ if (value.method !== undefined && (typeof value.method !== 'string' || !value.method.trim())) {
56
+ return 'method must be a non-empty string';
57
+ }
58
+ if (value.hasInteracted !== true && value.hasInteracted !== false) {
59
+ return 'hasInteracted must be boolean';
53
60
  }
61
+ if (typeof value.timestamp !== 'number' || !Number.isSafeInteger(value.timestamp) || value.timestamp < 0
62
+ || !Number.isFinite(new Date(value.timestamp).getTime())) {
63
+ return 'timestamp must be a non-negative safe-integer epoch value within the valid Date range';
64
+ }
65
+ if (value.lawfulBasis !== undefined && typeof value.lawfulBasis !== 'string') {
66
+ return 'lawfulBasis must be a string';
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export async function GET(req: NextRequest) {
72
+ const context = await resolveNDPRRequestContext(req);
73
+ const problem = getNDPRContextProblem(context, 'subject');
74
+ if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
75
+ const { tenantId, subjectId } = context as typeof context & { subjectId: string };
54
76
 
55
77
  // {{#if ORM=prisma}}
56
78
  const record = await prisma.consentRecord.findFirst({
57
- where: { subjectId, revokedAt: null },
79
+ where: { tenantId, subjectId, revokedAt: null },
58
80
  orderBy: { createdAt: 'desc' },
59
81
  });
60
82
  // {{/if}}
61
83
  // {{#if ORM=drizzle}}
62
- const [record] = await db
63
- .select()
64
- .from(consentRecords)
65
- .where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)))
66
- .orderBy(desc(consentRecords.createdAt))
67
- .limit(1);
84
+ const [record] = await db.select().from(consentRecords)
85
+ .where(and(
86
+ eq(consentRecords.tenantId, tenantId),
87
+ eq(consentRecords.subjectId, subjectId),
88
+ isNull(consentRecords.revokedAt),
89
+ ))
90
+ .orderBy(desc(consentRecords.createdAt)).limit(1);
68
91
  // {{/if}}
69
92
  // {{#if ORM=none}}
70
93
  const record = [...consentStore.values()]
71
- .filter((r) => r.subjectId === subjectId && r.revokedAt === null)
72
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
94
+ .filter((row) => row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null)
95
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null;
73
96
  // {{/if}}
74
-
75
- return NextResponse.json(record ?? null);
97
+ return NextResponse.json(record ? toConsentSettings(record) : null);
76
98
  }
77
99
 
78
- // POST /api/consent
79
100
  export async function POST(req: NextRequest) {
80
- const body = await req.json();
81
- const { subjectId, consents, version, method, lawfulBasis } = body;
101
+ const context = await resolveNDPRRequestContext(req);
102
+ const problem = getNDPRContextProblem(context, 'subject');
103
+ if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
104
+ const { tenantId, subjectId } = context as typeof context & { subjectId: string };
105
+ const body: unknown = await req.json().catch(() => null);
106
+ const validationError = validateConsentBody(body);
107
+ if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
108
+ const value = body as Omit<ConsentSettings, 'method'> & { method?: string };
109
+ const input: ConsentSettings = { ...value, method: value.method ?? 'api' };
82
110
 
83
- if (!subjectId || !consents || !version) {
84
- return NextResponse.json(
85
- { error: 'subjectId, consents, and version are required' },
86
- { status: 400 },
87
- );
88
- }
89
-
90
- const ipAddress = req.headers.get('x-forwarded-for');
111
+ const forwardedFor = req.headers.get('x-forwarded-for');
112
+ const ipAddress = forwardedFor?.split(',')[0]?.trim() || null;
91
113
  const userAgent = req.headers.get('user-agent');
114
+ const clientTimestamp = new Date(input.timestamp);
115
+ const activeSubjectKey = JSON.stringify([tenantId, subjectId]);
92
116
 
93
117
  // {{#if ORM=prisma}}
94
- // Revoke previously active records (immutable-audit pattern — Section 44).
95
- await prisma.consentRecord.updateMany({
96
- where: { subjectId, revokedAt: null },
97
- data: { revokedAt: new Date() },
98
- });
118
+ const transactionResult = await prisma.$transaction(async (tx) => {
119
+ const replay = await tx.consentRecord.findFirst({
120
+ where: {
121
+ tenantId,
122
+ subjectId,
123
+ clientTimestamp,
124
+ version: input.version,
125
+ method: input.method,
126
+ },
127
+ orderBy: { createdAt: 'desc' },
128
+ });
129
+ if (replay) {
130
+ return sameConsentMutation(replay, input)
131
+ ? { kind: 'record' as const, record: replay }
132
+ : { kind: 'collision' as const };
133
+ }
99
134
 
100
- const record = await prisma.consentRecord.create({
101
- data: { subjectId, consents, version, method: method ?? 'api', lawfulBasis: lawfulBasis ?? null, ipAddress, userAgent },
102
- });
103
-
104
- await prisma.complianceAuditLog.create({
105
- data: { module: 'consent', action: 'created', entityId: record.id, entityType: 'ConsentRecord', changes: { subjectId, version, consents } },
135
+ await tx.consentRecord.updateMany({
136
+ where: { tenantId, subjectId, revokedAt: null },
137
+ data: { revokedAt: new Date(), activeSubjectKey: null },
138
+ });
139
+ const created = await tx.consentRecord.create({ data: {
140
+ tenantId, subjectId, activeSubjectKey, consents: input.consents,
141
+ version: input.version, method: input.method,
142
+ hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null,
143
+ ipAddress, userAgent, clientTimestamp,
144
+ } });
145
+ await tx.complianceAuditLog.create({ data: {
146
+ tenantId, module: 'consent', action: 'created', entityId: created.id,
147
+ entityType: 'ConsentRecord', performedBy: context.actorId,
148
+ changes: {
149
+ subjectId, version: input.version, method: input.method,
150
+ hasInteracted: input.hasInteracted,
151
+ consentCategories: Object.keys(input.consents),
152
+ },
153
+ } });
154
+ return { kind: 'record' as const, record: created };
155
+ }, {
156
+ isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
157
+ }).catch((error: unknown) => {
158
+ if (isConcurrencyConflict(error)) return null;
159
+ throw error;
106
160
  });
161
+ if (!transactionResult) {
162
+ return NextResponse.json(
163
+ { error: 'Concurrent consent update conflict; retry the same idempotent request.' },
164
+ { status: 409 },
165
+ );
166
+ }
167
+ if (transactionResult.kind === 'collision') {
168
+ return NextResponse.json(
169
+ { error: 'Idempotency collision: timestamp/version/method identify different consent data.' },
170
+ { status: 409 },
171
+ );
172
+ }
173
+ const record = transactionResult.record;
107
174
  // {{/if}}
108
175
  // {{#if ORM=drizzle}}
109
- await db.update(consentRecords).set({ revokedAt: new Date() }).where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
176
+ const transactionResult = await db.transaction(async (tx) => {
177
+ const [replay] = await tx.select().from(consentRecords).where(and(
178
+ eq(consentRecords.tenantId, tenantId),
179
+ eq(consentRecords.subjectId, subjectId),
180
+ eq(consentRecords.clientTimestamp, clientTimestamp),
181
+ eq(consentRecords.version, input.version),
182
+ eq(consentRecords.method, input.method),
183
+ )).orderBy(desc(consentRecords.createdAt)).limit(1);
184
+ if (replay) {
185
+ return sameConsentMutation(replay, input)
186
+ ? { kind: 'record' as const, record: replay }
187
+ : { kind: 'collision' as const };
188
+ }
110
189
 
111
- const [record] = await db.insert(consentRecords).values({
112
- subjectId, consents, version, method: method ?? 'api', lawfulBasis: lawfulBasis ?? null, ipAddress, userAgent,
113
- }).returning();
114
-
115
- await db.insert(complianceAuditLog).values({
116
- module: 'consent', action: 'created', entityId: record.id, entityType: 'ConsentRecord', changes: { subjectId, version, consents },
190
+ await tx.update(consentRecords).set({
191
+ revokedAt: new Date(),
192
+ activeSubjectKey: null,
193
+ }).where(and(
194
+ eq(consentRecords.tenantId, tenantId),
195
+ eq(consentRecords.subjectId, subjectId),
196
+ isNull(consentRecords.revokedAt),
197
+ ));
198
+ const [created] = await tx.insert(consentRecords).values({
199
+ tenantId, subjectId, activeSubjectKey, consents: input.consents,
200
+ version: input.version, method: input.method,
201
+ hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null,
202
+ ipAddress, userAgent, clientTimestamp,
203
+ }).returning();
204
+ await tx.insert(complianceAuditLog).values({
205
+ tenantId, module: 'consent', action: 'created', entityId: created.id,
206
+ entityType: 'ConsentRecord', performedBy: context.actorId,
207
+ changes: {
208
+ subjectId, version: input.version, method: input.method,
209
+ hasInteracted: input.hasInteracted,
210
+ consentCategories: Object.keys(input.consents),
211
+ },
212
+ });
213
+ return { kind: 'record' as const, record: created };
214
+ }, { isolationLevel: 'serializable' }).catch((error: unknown) => {
215
+ if (isConcurrencyConflict(error)) return null;
216
+ throw error;
117
217
  });
218
+ if (!transactionResult) {
219
+ return NextResponse.json(
220
+ { error: 'Concurrent consent update conflict; retry the same idempotent request.' },
221
+ { status: 409 },
222
+ );
223
+ }
224
+ if (transactionResult.kind === 'collision') {
225
+ return NextResponse.json(
226
+ { error: 'Idempotency collision: timestamp/version/method identify different consent data.' },
227
+ { status: 409 },
228
+ );
229
+ }
230
+ const record = transactionResult.record;
118
231
  // {{/if}}
119
232
  // {{#if ORM=none}}
120
- // Revoke previously active records for this subject
121
- for (const r of consentStore.values()) {
122
- if (r.subjectId === subjectId && r.revokedAt === null) r.revokedAt = new Date();
233
+ const replay = [...consentStore.values()].find((row) =>
234
+ row.tenantId === tenantId
235
+ && row.subjectId === subjectId
236
+ && row.clientTimestamp.getTime() === input.timestamp
237
+ && row.version === input.version
238
+ && row.method === input.method,
239
+ );
240
+ if (replay) {
241
+ if (!sameConsentMutation(replay, input)) {
242
+ return NextResponse.json(
243
+ { error: 'Idempotency collision: timestamp/version/method identify different consent data.' },
244
+ { status: 409 },
245
+ );
246
+ }
247
+ return NextResponse.json(toConsentSettings(replay), { status: 201 });
248
+ }
249
+ const now = new Date();
250
+ for (const row of consentStore.values()) {
251
+ if (row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null) {
252
+ row.revokedAt = now;
253
+ row.activeSubjectKey = null;
254
+ }
123
255
  }
124
256
  const record: ConsentRecord = {
125
- id: newId(), subjectId, consents, version, method: method ?? 'api',
126
- lawfulBasis: lawfulBasis ?? null, ipAddress, userAgent,
127
- createdAt: new Date(), revokedAt: null,
257
+ id: crypto.randomUUID(), tenantId, subjectId, activeSubjectKey,
258
+ consents: input.consents, version: input.version, method: input.method,
259
+ hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null,
260
+ ipAddress, userAgent, clientTimestamp, createdAt: now, revokedAt: null,
128
261
  };
129
262
  consentStore.set(record.id, record);
130
- auditLog.push({ id: newId(), module: 'consent', action: 'created', entityId: record.id, at: new Date() });
263
+ auditLog.push({
264
+ id: crypto.randomUUID(), tenantId, module: 'consent', action: 'created',
265
+ entityId: record.id, performedBy: context.actorId, at: now,
266
+ });
131
267
  // {{/if}}
132
-
133
- return NextResponse.json(record, { status: 201 });
268
+ return NextResponse.json(toConsentSettings(record), { status: 201 });
134
269
  }
135
270
 
136
- // DELETE /api/consent?subjectId=xxx — Section 26 withdrawal
137
271
  export async function DELETE(req: NextRequest) {
138
- const subjectId = req.nextUrl.searchParams.get('subjectId');
139
-
140
- if (!subjectId) {
141
- return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
142
- }
272
+ const context = await resolveNDPRRequestContext(req);
273
+ const problem = getNDPRContextProblem(context, 'subject');
274
+ if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
275
+ const { tenantId, subjectId } = context as typeof context & { subjectId: string };
276
+ const now = new Date();
143
277
 
144
278
  // {{#if ORM=prisma}}
145
- await prisma.consentRecord.updateMany({
146
- where: { subjectId, revokedAt: null },
147
- data: { revokedAt: new Date() },
148
- });
149
- await prisma.complianceAuditLog.create({
150
- data: { module: 'consent', action: 'revoked', entityId: subjectId, entityType: 'ConsentRecord' },
279
+ const revoked = await prisma.$transaction(async (tx) => {
280
+ const result = await tx.consentRecord.updateMany({
281
+ where: { tenantId, subjectId, revokedAt: null },
282
+ data: { revokedAt: now, activeSubjectKey: null },
283
+ });
284
+ if (result.count > 0) {
285
+ await tx.complianceAuditLog.create({ data: {
286
+ tenantId, module: 'consent', action: 'revoked', entityId: subjectId,
287
+ entityType: 'ConsentRecord', performedBy: context.actorId,
288
+ changes: { subjectId, revokedRecords: result.count },
289
+ } });
290
+ }
291
+ return result.count;
151
292
  });
152
293
  // {{/if}}
153
294
  // {{#if ORM=drizzle}}
154
- await db.update(consentRecords).set({ revokedAt: new Date() }).where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
155
- await db.insert(complianceAuditLog).values({ module: 'consent', action: 'revoked', entityId: subjectId, entityType: 'ConsentRecord' });
295
+ const revoked = await db.transaction(async (tx) => {
296
+ const rows = await tx.update(consentRecords).set({
297
+ revokedAt: now,
298
+ activeSubjectKey: null,
299
+ }).where(and(
300
+ eq(consentRecords.tenantId, tenantId),
301
+ eq(consentRecords.subjectId, subjectId),
302
+ isNull(consentRecords.revokedAt),
303
+ )).returning({ id: consentRecords.id });
304
+ if (rows.length > 0) {
305
+ await tx.insert(complianceAuditLog).values({
306
+ tenantId, module: 'consent', action: 'revoked', entityId: subjectId,
307
+ entityType: 'ConsentRecord', performedBy: context.actorId,
308
+ changes: { subjectId, revokedRecords: rows.length },
309
+ });
310
+ }
311
+ return rows.length;
312
+ });
156
313
  // {{/if}}
157
314
  // {{#if ORM=none}}
158
- for (const r of consentStore.values()) {
159
- if (r.subjectId === subjectId && r.revokedAt === null) r.revokedAt = new Date();
315
+ let revoked = 0;
316
+ for (const row of consentStore.values()) {
317
+ if (row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null) {
318
+ row.revokedAt = now;
319
+ row.activeSubjectKey = null;
320
+ revoked += 1;
321
+ }
322
+ }
323
+ if (revoked > 0) {
324
+ auditLog.push({
325
+ id: crypto.randomUUID(), tenantId, module: 'consent', action: 'revoked',
326
+ entityId: subjectId, performedBy: context.actorId, at: now,
327
+ });
160
328
  }
161
- auditLog.push({ id: newId(), module: 'consent', action: 'revoked', entityId: subjectId, at: new Date() });
162
329
  // {{/if}}
330
+ return NextResponse.json({ success: true, revoked });
331
+ }
332
+
333
+ function toConsentSettings(record: {
334
+ consents: unknown;
335
+ version: string;
336
+ method: string;
337
+ hasInteracted: boolean;
338
+ lawfulBasis: string | null;
339
+ clientTimestamp: Date | null;
340
+ createdAt: Date;
341
+ }): ConsentSettings {
342
+ return {
343
+ consents: record.consents as Record<string, boolean>,
344
+ timestamp: record.clientTimestamp?.getTime() ?? record.createdAt.getTime(),
345
+ version: record.version,
346
+ method: record.method,
347
+ hasInteracted: record.hasInteracted,
348
+ lawfulBasis: record.lawfulBasis ?? undefined,
349
+ };
350
+ }
351
+
352
+ function sameConsentMutation(
353
+ record: { consents: unknown; hasInteracted: boolean; lawfulBasis: string | null },
354
+ input: ConsentSettings,
355
+ ): boolean {
356
+ const storedConsents = record.consents as Record<string, boolean>;
357
+ return JSON.stringify(sortedConsentEntries(storedConsents))
358
+ === JSON.stringify(sortedConsentEntries(input.consents))
359
+ && record.hasInteracted === input.hasInteracted
360
+ && (record.lawfulBasis ?? undefined) === input.lawfulBasis;
361
+ }
362
+
363
+ function sortedConsentEntries(consents: Record<string, boolean>) {
364
+ return Object.entries(consents).sort(([left], [right]) => left.localeCompare(right));
365
+ }
163
366
 
164
- return NextResponse.json({ success: true });
367
+ function isConcurrencyConflict(error: unknown): boolean {
368
+ if (!error || typeof error !== 'object') return false;
369
+ const candidate = error as { code?: unknown; cause?: unknown };
370
+ const code = typeof candidate.code === 'string' ? candidate.code : '';
371
+ if (['P2002', 'P2034', '23505', '40001', '40P01'].includes(code)) return true;
372
+ return candidate.cause !== error && isConcurrencyConflict(candidate.cause);
165
373
  }