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