@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,292 +1,146 @@
1
- /**
2
- * Express — Lawful Basis Router
3
- * Generated by create-ndpr for: {{ORG_NAME}}
4
- * DPO: {{DPO_EMAIL}}
5
- *
6
- * NDPA §25 — data processing must have a lawful basis. This router
7
- * manages a register of processing activities and their lawful bases.
8
- *
9
- * Routes:
10
- * GET /lawful-basis — List records (optional ?lawfulBasis= filter)
11
- * GET /lawful-basis/:id — Get a single record
12
- * POST /lawful-basis — Create a new lawful basis record
13
- * PUT /lawful-basis/:id — Update an existing record
14
- * DELETE /lawful-basis/:id — Delete a record
15
- *
16
- * Usage:
17
- * import { lawfulBasisRouter } from './routes/lawful-basis';
18
- * app.use('/api/lawful-basis', lawfulBasisRouter);
19
- */
20
-
1
+ /** Express — staff-only, tenant-scoped lawful-basis register for {{ORG_NAME_COMMENT}}. */
21
2
  import { Router } from 'express';
3
+ import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
22
4
  // {{#if ORM=prisma}}
23
5
  import { PrismaClient } from '@prisma/client';
24
-
25
6
  const prisma = new PrismaClient();
26
7
  // {{/if}}
27
8
  // {{#if ORM=drizzle}}
28
- import { db } from '@/drizzle';
29
- import { lawfulBasisRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
30
- import { eq, desc } from 'drizzle-orm';
9
+ import { db } from '{{NDPR_DB_IMPORT}}';
10
+ import { complianceAuditLog, lawfulBasisRecords } from '{{NDPR_SCHEMA_IMPORT}}';
11
+ import { and, desc, eq, isNull } from 'drizzle-orm';
31
12
  // {{/if}}
13
+
14
+ const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
15
+ type LawfulBasis = (typeof LAWFUL_BASES)[number];
16
+ type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
17
+ interface BasisExisting { activityName: string; lawfulBasis: string; justification: string; dataCategories: unknown; purposes: unknown; reviewDate: Date | null }
18
+ interface BasisMutation { activityName: string; lawfulBasis: LawfulBasis; justification: string; dataCategories: string[]; purposes: string[]; assessedBy: string; assessedAt: Date; reviewDate: Date | null; updatedAt: Date }
19
+
32
20
  // {{#if ORM=none}}
33
- // TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
34
- // in-memory map below is enough to develop against locally but DOES NOT
35
- // satisfy NDPA Section 44 (record-keeping). Replace `lawfulBasisStore`/
36
- // `auditLog` with your DB / KV / API.
37
- interface LawfulBasisRecord {
38
- id: string;
39
- activityName: string;
40
- lawfulBasis: string;
41
- justification: string;
42
- dataCategories: string[];
43
- purposes: string[];
44
- assessedBy: string;
45
- reviewDate: Date | null;
46
- createdAt: Date;
47
- updatedAt: Date;
48
- }
49
- const lawfulBasisStore = new Map<string, LawfulBasisRecord>();
50
- const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
51
- function newId() { return crypto.randomUUID(); }
21
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
22
+ interface BasisRow extends BasisMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
23
+ const basisStore = new Map<string, BasisRow>();
24
+ const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
52
25
  // {{/if}}
53
26
 
27
+ function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }
28
+ function hasOwn(value: Record<string, unknown>, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
29
+ function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
30
+ const allowedSet = new Set(allowed); const unknown = Object.keys(value).filter((key) => !allowedSet.has(key)); return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
31
+ }
32
+ function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
33
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` };
34
+ return { ok: true, value: value.trim() };
35
+ }
36
+ function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
37
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
38
+ const result: string[] = [];
39
+ for (const item of value) { if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` }; if (!result.includes(item.trim())) result.push(item.trim()); }
40
+ return { ok: true, value: result };
41
+ }
42
+ function isLawfulBasis(value: unknown): value is LawfulBasis { return typeof value === 'string' && (LAWFUL_BASES as readonly string[]).includes(value); }
43
+ function parseReviewDate(value: unknown): Date | null | undefined {
44
+ if (value === null || value === '') return null;
45
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? new Date(value.getTime()) : undefined;
46
+ if (typeof value !== 'string') return undefined;
47
+ const input = value.trim(); const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input); const dateTime = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(input);
48
+ if (!dateOnly && !dateTime) return undefined;
49
+ const timestamp = Date.parse(input); if (!Number.isFinite(timestamp)) return undefined;
50
+ const parts = dateOnly ?? dateTime; const check = new Date(Date.UTC(Number(parts![1]), Number(parts![2]) - 1, Number(parts![3])));
51
+ if (check.getUTCFullYear() !== Number(parts![1]) || check.getUTCMonth() + 1 !== Number(parts![2]) || check.getUTCDate() !== Number(parts![3])) return undefined;
52
+ return new Date(timestamp);
53
+ }
54
+ function buildBasisMutation(input: Record<string, unknown>, actorId: string, existing?: BasisExisting, now = new Date()): Validation<BasisMutation> {
55
+ const activityName = hasOwn(input, 'activityName') ? nonEmptyText(input.activityName, 'activityName', 500) : existing ? nonEmptyText(existing.activityName, 'stored activityName', 500) : { ok: false as const, error: 'activityName is required' }; if (!activityName.ok) return activityName;
56
+ const basisValue = hasOwn(input, 'lawfulBasis') ? input.lawfulBasis : existing?.lawfulBasis; if (!isLawfulBasis(basisValue)) return { ok: false, error: 'lawfulBasis is not one of the six supported NDPA bases' };
57
+ const justification = hasOwn(input, 'justification') ? nonEmptyText(input.justification, 'justification') : existing ? nonEmptyText(existing.justification, 'stored justification') : { ok: false as const, error: 'justification is required' }; if (!justification.ok) return justification;
58
+ if (basisValue === 'legitimate_interests' && justification.value.length < 20) return { ok: false, error: 'legitimate_interests requires a detailed justification covering the interest, necessity, and balancing assessment' };
59
+ const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories'); if (!categories.ok) return categories;
60
+ const purposes = nonEmptyStringArray(hasOwn(input, 'purposes') ? input.purposes : existing?.purposes, 'purposes'); if (!purposes.ok) return purposes;
61
+ let reviewDate: Date | null;
62
+ if (hasOwn(input, 'reviewDate')) { const parsed = parseReviewDate(input.reviewDate); if (parsed === undefined) return { ok: false, error: 'reviewDate must be null, YYYY-MM-DD, or an ISO timestamp with a timezone' }; if (parsed !== null && parsed.getTime() <= now.getTime()) return { ok: false, error: 'reviewDate must be in the future' }; reviewDate = parsed; }
63
+ else { const parsed = parseReviewDate(existing?.reviewDate ?? null); if (parsed === undefined) return { ok: false, error: 'Stored reviewDate is invalid' }; reviewDate = parsed; }
64
+ return { ok: true, value: { activityName: activityName.value, lawfulBasis: basisValue, justification: justification.value, dataCategories: categories.value, purposes: purposes.value, assessedBy: actorId, assessedAt: now, reviewDate, updatedAt: now } };
65
+ }
66
+ function auditChanges(data: BasisMutation) { return { activityName: data.activityName, lawfulBasis: data.lawfulBasis, assessedBy: data.assessedBy, reviewDate: data.reviewDate?.toISOString() ?? null }; }
67
+
54
68
  export const lawfulBasisRouter = Router();
55
69
 
56
- // GET /lawful-basis?lawfulBasis=consent
57
70
  lawfulBasisRouter.get('/', async (req, res) => {
58
- const { lawfulBasis } = req.query;
59
- const basisFilter = typeof lawfulBasis === 'string' ? lawfulBasis : undefined;
60
-
71
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
72
+ const basis = typeof req.query.lawfulBasis === 'string' ? req.query.lawfulBasis : undefined; if (basis !== undefined && !isLawfulBasis(basis)) return res.status(400).json({ error: 'lawfulBasis filter is not supported' });
61
73
  // {{#if ORM=prisma}}
62
- const records = await prisma.lawfulBasisRecord.findMany({
63
- where: basisFilter ? { lawfulBasis: basisFilter } : undefined,
64
- orderBy: { createdAt: 'desc' },
65
- });
74
+ const records = await prisma.lawfulBasisRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(basis ? { lawfulBasis: basis } : {}) }, orderBy: { createdAt: 'desc' } });
66
75
  // {{/if}}
67
76
  // {{#if ORM=drizzle}}
68
- const records = basisFilter
69
- ? await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.lawfulBasis, basisFilter)).orderBy(desc(lawfulBasisRecords.createdAt))
70
- : await db.select().from(lawfulBasisRecords).orderBy(desc(lawfulBasisRecords.createdAt));
77
+ const records = basis ? await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt), eq(lawfulBasisRecords.lawfulBasis, basis))).orderBy(desc(lawfulBasisRecords.createdAt)) : await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).orderBy(desc(lawfulBasisRecords.createdAt));
71
78
  // {{/if}}
72
79
  // {{#if ORM=none}}
73
- const records = [...lawfulBasisStore.values()]
74
- .filter((r) => (basisFilter ? r.lawfulBasis === basisFilter : true))
75
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
80
+ const records = [...basisStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!basis || row.lawfulBasis === basis)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
76
81
  // {{/if}}
77
-
78
82
  return res.json(records);
79
83
  });
80
84
 
81
- // GET /lawful-basis/:id
82
85
  lawfulBasisRouter.get('/:id', async (req, res) => {
86
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
83
87
  // {{#if ORM=prisma}}
84
- const record = await prisma.lawfulBasisRecord.findUnique({ where: { id: req.params.id } });
88
+ const record = await prisma.lawfulBasisRecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
85
89
  // {{/if}}
86
90
  // {{#if ORM=drizzle}}
87
- const [record] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, req.params.id)).limit(1);
91
+ const [record] = await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, req.params.id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
88
92
  // {{/if}}
89
93
  // {{#if ORM=none}}
90
- const record = lawfulBasisStore.get(req.params.id) ?? null;
94
+ const candidate = basisStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
91
95
  // {{/if}}
92
-
93
- if (!record) {
94
- return res.status(404).json({ error: 'Lawful basis record not found' });
95
- }
96
-
97
- return res.json(record);
96
+ return record ? res.json(record) : res.status(404).json({ error: 'Lawful-basis record not found' });
98
97
  });
99
98
 
100
- // POST /lawful-basis
101
99
  lawfulBasisRouter.post('/', async (req, res) => {
102
- const {
103
- activityName,
104
- lawfulBasis,
105
- justification,
106
- dataCategories,
107
- purposes,
108
- assessedBy,
109
- reviewDate,
110
- } = req.body;
111
-
112
- if (!activityName || !lawfulBasis || !justification || !dataCategories || !purposes || !assessedBy) {
113
- return res.status(400).json({
114
- error: 'activityName, lawfulBasis, justification, dataCategories, purposes, and assessedBy are required',
115
- });
116
- }
117
-
118
- if (!Array.isArray(dataCategories) || !Array.isArray(purposes)) {
119
- return res.status(400).json({ error: 'dataCategories and purposes must be arrays' });
120
- }
121
-
100
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
101
+ if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
102
+ const unknown = rejectUnknownFields(req.body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']); if (unknown) return res.status(400).json({ error: unknown });
103
+ const normalized = buildBasisMutation(req.body, actorId); if (!normalized.ok) return res.status(400).json({ error: normalized.error }); const data = normalized.value;
122
104
  // {{#if ORM=prisma}}
123
- const record = await prisma.lawfulBasisRecord.create({
124
- data: {
125
- activityName,
126
- lawfulBasis,
127
- justification,
128
- dataCategories,
129
- purposes,
130
- assessedBy,
131
- reviewDate: reviewDate ? new Date(reviewDate) : null,
132
- },
133
- });
134
-
135
- await prisma.complianceAuditLog.create({
136
- data: {
137
- module: 'lawful-basis',
138
- action: 'created',
139
- entityId: record.id,
140
- entityType: 'LawfulBasisRecord',
141
- changes: { activityName, lawfulBasis, assessedBy },
142
- },
143
- });
105
+ const record = await prisma.$transaction(async (tx) => { const created = await tx.lawfulBasisRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } }); return created; });
144
106
  // {{/if}}
145
107
  // {{#if ORM=drizzle}}
146
- const [record] = await db.insert(lawfulBasisRecords).values({
147
- activityName,
148
- lawfulBasis,
149
- justification,
150
- dataCategories,
151
- purposes,
152
- assessedBy,
153
- reviewDate: reviewDate ? new Date(reviewDate) : null,
154
- }).returning();
155
-
156
- await db.insert(complianceAuditLog).values({
157
- module: 'lawful-basis',
158
- action: 'created',
159
- entityId: record.id,
160
- entityType: 'LawfulBasisRecord',
161
- changes: { activityName, lawfulBasis, assessedBy },
162
- });
108
+ const record = await db.transaction(async (tx) => { const [created] = await tx.insert(lawfulBasisRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) }); return created; });
163
109
  // {{/if}}
164
110
  // {{#if ORM=none}}
165
- const now = new Date();
166
- const record: LawfulBasisRecord = {
167
- id: newId(),
168
- activityName,
169
- lawfulBasis,
170
- justification,
171
- dataCategories,
172
- purposes,
173
- assessedBy,
174
- reviewDate: reviewDate ? new Date(reviewDate) : null,
175
- createdAt: now,
176
- updatedAt: now,
177
- };
178
- lawfulBasisStore.set(record.id, record);
179
- auditLog.push({ id: newId(), module: 'lawful-basis', action: 'created', entityId: record.id, at: new Date() });
111
+ const record: BasisRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null }; basisStore.set(record.id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
180
112
  // {{/if}}
181
-
182
113
  return res.status(201).json(record);
183
114
  });
184
115
 
185
- // PUT /lawful-basis/:id
186
116
  lawfulBasisRouter.put('/:id', async (req, res) => {
187
- const { id } = req.params;
188
-
189
- const data = { ...req.body };
190
- if (data.reviewDate) {
191
- data.reviewDate = new Date(data.reviewDate);
192
- }
193
-
117
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
118
+ if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
119
+ const unknown = rejectUnknownFields(req.body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']); if (unknown) return res.status(400).json({ error: unknown });
120
+ if (!['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate'].some((field) => hasOwn(req.body, field))) return res.status(400).json({ error: 'Provide at least one allowlisted lawful-basis field to update' });
121
+ const id = req.params.id; let validationError: string | null = null;
194
122
  // {{#if ORM=prisma}}
195
- const existing = await prisma.lawfulBasisRecord.findUnique({ where: { id } });
196
- if (!existing) {
197
- return res.status(404).json({ error: 'Lawful basis record not found' });
198
- }
199
-
200
- const record = await prisma.lawfulBasisRecord.update({
201
- where: { id },
202
- data,
203
- });
204
-
205
- await prisma.complianceAuditLog.create({
206
- data: {
207
- module: 'lawful-basis',
208
- action: 'updated',
209
- entityId: record.id,
210
- entityType: 'LawfulBasisRecord',
211
- changes: data,
212
- },
213
- });
123
+ const record = await prisma.$transaction(async (tx) => { const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return null; const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data }); const updated = await tx.lawfulBasisRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } }); return updated; });
214
124
  // {{/if}}
215
125
  // {{#if ORM=drizzle}}
216
- const [existing] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id)).limit(1);
217
- if (!existing) {
218
- return res.status(404).json({ error: 'Lawful basis record not found' });
219
- }
220
-
221
- const [record] = await db.update(lawfulBasisRecords).set(data).where(eq(lawfulBasisRecords.id, id)).returning();
222
-
223
- await db.insert(complianceAuditLog).values({
224
- module: 'lawful-basis',
225
- action: 'updated',
226
- entityId: record.id,
227
- entityType: 'LawfulBasisRecord',
228
- changes: data,
229
- });
126
+ const record = await db.transaction(async (tx) => { const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1); if (!existing) return null; const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; const [updated] = await tx.update(lawfulBasisRecords).set(data).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) }); return updated; });
230
127
  // {{/if}}
231
128
  // {{#if ORM=none}}
232
- const existing = lawfulBasisStore.get(id);
233
- if (!existing) {
234
- return res.status(404).json({ error: 'Lawful basis record not found' });
235
- }
236
- const record: LawfulBasisRecord = { ...existing, ...data, id, updatedAt: new Date() };
237
- lawfulBasisStore.set(id, record);
238
- auditLog.push({ id: newId(), module: 'lawful-basis', action: 'updated', entityId: record.id, at: new Date() });
129
+ const existing = basisStore.get(id); let record: BasisRow | null = null; if (existing?.tenantId === context.tenantId && existing.removedAt === null) { const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) validationError = normalized.error; else { record = { ...existing, ...normalized.value, id }; basisStore.set(id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt }); } }
239
130
  // {{/if}}
240
-
241
- return res.json(record);
131
+ if (validationError) return res.status(400).json({ error: validationError }); return record ? res.json(record) : res.status(404).json({ error: 'Lawful-basis record not found' });
242
132
  });
243
133
 
244
- // DELETE /lawful-basis/:id
245
134
  lawfulBasisRouter.delete('/:id', async (req, res) => {
246
- const { id } = req.params;
247
-
135
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string; const id = req.params.id; const archivedAt = new Date();
248
136
  // {{#if ORM=prisma}}
249
- const existing = await prisma.lawfulBasisRecord.findUnique({ where: { id } });
250
- if (!existing) {
251
- return res.status(404).json({ error: 'Lawful basis record not found' });
252
- }
253
-
254
- await prisma.lawfulBasisRecord.delete({ where: { id } });
255
-
256
- await prisma.complianceAuditLog.create({
257
- data: {
258
- module: 'lawful-basis',
259
- action: 'deleted',
260
- entityId: id,
261
- entityType: 'LawfulBasisRecord',
262
- changes: { activityName: existing.activityName },
263
- },
264
- });
137
+ const archived = await prisma.$transaction(async (tx) => { const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return false; const result = await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } }); if (result.count !== 1) return false; await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } } }); return true; });
265
138
  // {{/if}}
266
139
  // {{#if ORM=drizzle}}
267
- const [existing] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id)).limit(1);
268
- if (!existing) {
269
- return res.status(404).json({ error: 'Lawful basis record not found' });
270
- }
271
-
272
- await db.delete(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id));
273
-
274
- await db.insert(complianceAuditLog).values({
275
- module: 'lawful-basis',
276
- action: 'deleted',
277
- entityId: id,
278
- entityType: 'LawfulBasisRecord',
279
- changes: { activityName: existing.activityName },
280
- });
140
+ const archived = await db.transaction(async (tx) => { const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1); if (!existing) return false; const [updated] = await tx.update(lawfulBasisRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning(); if (!updated) return false; await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } }); return true; });
281
141
  // {{/if}}
282
142
  // {{#if ORM=none}}
283
- const existing = lawfulBasisStore.get(id);
284
- if (!existing) {
285
- return res.status(404).json({ error: 'Lawful basis record not found' });
286
- }
287
- lawfulBasisStore.delete(id);
288
- auditLog.push({ id: newId(), module: 'lawful-basis', action: 'deleted', entityId: id, at: new Date() });
143
+ const existing = basisStore.get(id); const archived = existing?.tenantId === context.tenantId && existing.removedAt === null; if (archived && existing) { basisStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt }); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() }, at: archivedAt }); }
289
144
  // {{/if}}
290
-
291
- return res.json({ success: true });
145
+ return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'Lawful-basis record not found' });
292
146
  });
@@ -0,0 +1,130 @@
1
+ import type { Request } from 'express';
2
+
3
+ export interface NDPRVerifiedActor {
4
+ id: string;
5
+ displayName: string;
6
+ email: string;
7
+ department?: string;
8
+ subjectId?: string;
9
+ roles: readonly string[];
10
+ }
11
+
12
+ export interface NDPRRequestContext {
13
+ tenantId: string;
14
+ actor: NDPRVerifiedActor | null;
15
+ actorId: string | null;
16
+ subjectId: string | null;
17
+ subjectSource: 'verified-account-subject' | 'anonymous-uuid-capability' | null;
18
+ roles: readonly string[];
19
+ }
20
+
21
+ export type NDPRContextRequirement = 'tenant' | 'subject' | 'staff';
22
+ export interface NDPRContextProblem { status: 401 | 403 | 503; error: string }
23
+ export type NDPRVerifiedActorResolver = (
24
+ request: Request,
25
+ ) => Promise<NDPRVerifiedActor | null>;
26
+
27
+ const ANONYMOUS_SUBJECT_PATTERN =
28
+ /^anon_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
29
+ const STAFF_ROLES = new Set(['ndpr:staff', 'ndpr:admin']);
30
+
31
+ /**
32
+ * Replace this safe default with a verified Express auth/session integration.
33
+ * Never trust actor, account subject, profile, role, or tenant fields from
34
+ * request-controlled input.
35
+ */
36
+ export async function resolveVerifiedNDPRActor(
37
+ _request: Request,
38
+ ): Promise<NDPRVerifiedActor | null> {
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * The tenant comes only from server configuration. The only client-provided
44
+ * identity accepted by default is a random anon_<UUID> subject capability;
45
+ * it never grants staff access.
46
+ */
47
+ export async function resolveNDPRRequestContext(
48
+ request: Request,
49
+ actorResolver: NDPRVerifiedActorResolver = resolveVerifiedNDPRActor,
50
+ ): Promise<NDPRRequestContext> {
51
+ const tenantId = process.env.NDPR_TENANT_ID?.trim() ?? '';
52
+ const candidate = request.get('x-ndpr-subject-id')?.trim() ?? '';
53
+ const anonymousSubjectId = ANONYMOUS_SUBJECT_PATTERN.test(candidate)
54
+ ? candidate
55
+ : null;
56
+ const actor = normalizeVerifiedActor(await actorResolver(request));
57
+ const subjectId = actor?.subjectId ?? anonymousSubjectId;
58
+ const subjectSource = actor?.subjectId
59
+ ? 'verified-account-subject' as const
60
+ : anonymousSubjectId
61
+ ? 'anonymous-uuid-capability' as const
62
+ : null;
63
+
64
+ return {
65
+ tenantId,
66
+ actor,
67
+ actorId: actor?.id ?? null,
68
+ subjectId,
69
+ subjectSource,
70
+ roles: actor?.roles ?? [],
71
+ };
72
+ }
73
+
74
+ export function getNDPRContextProblem(
75
+ context: NDPRRequestContext,
76
+ requirement: NDPRContextRequirement,
77
+ ): NDPRContextProblem | null {
78
+ if (!context.tenantId) {
79
+ return {
80
+ status: 503,
81
+ error: 'NDPR_TENANT_ID is not configured on the server',
82
+ };
83
+ }
84
+ if (requirement === 'subject' && !context.subjectId) {
85
+ return { status: 401, error: 'A verified data-subject identity is required' };
86
+ }
87
+ if (requirement === 'staff' && !context.actorId) {
88
+ return {
89
+ status: 401,
90
+ error: 'Connect resolveVerifiedNDPRActor to verified staff authentication',
91
+ };
92
+ }
93
+ if (requirement === 'staff'
94
+ && !context.roles.some((role) => STAFF_ROLES.has(role))) {
95
+ return { status: 403, error: 'NDPR staff or administrator role required' };
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function normalizeVerifiedActor(
101
+ actor: NDPRVerifiedActor | null,
102
+ ): NDPRVerifiedActor | null {
103
+ if (!actor
104
+ || typeof actor.id !== 'string'
105
+ || typeof actor.displayName !== 'string'
106
+ || typeof actor.email !== 'string'
107
+ || !Array.isArray(actor.roles)) {
108
+ return null;
109
+ }
110
+ const id = actor.id.trim();
111
+ const displayName = actor.displayName.trim();
112
+ const email = actor.email.trim();
113
+ if (!id || !displayName || !email) return null;
114
+
115
+ return {
116
+ id,
117
+ displayName,
118
+ email,
119
+ department: typeof actor.department === 'string'
120
+ ? actor.department.trim() || undefined
121
+ : undefined,
122
+ subjectId: typeof actor.subjectId === 'string'
123
+ ? actor.subjectId.trim() || undefined
124
+ : undefined,
125
+ roles: actor.roles
126
+ .filter((role): role is string => typeof role === 'string')
127
+ .map((role) => role.trim())
128
+ .filter(Boolean),
129
+ };
130
+ }
@@ -1,8 +1,8 @@
1
- # NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{ORG_NAME}}.
1
+ # NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{ORG_NAME_COMMENT}}.
2
2
  #
3
3
  # Runs `ndpr audit` on every push and pull request. The audit scores your
4
4
  # compliance config (consent, DSR, DPIA, breach, policy, lawful basis,
5
- # cross-border, RoPA) plus your GAID 2025 DCPMI registration tier, and exits
5
+ # cross-border, RoPA) plus your GAID 2025 DCPMI designation inputs, and exits
6
6
  # non-zero when the overall score drops below the threshold — so a regression
7
7
  # fails CI the same way a broken test would.
8
8
  #
@@ -26,6 +26,6 @@ jobs:
26
26
  # The `ndpr` CLI ships with the toolkit. --no-save keeps it out of your
27
27
  # package.json if you don't already depend on it directly.
28
28
  - name: Install the NDPR toolkit (provides the `ndpr` CLI)
29
- run: npm install --no-save @tantainnovative/ndpr-toolkit@^5.5.1
29
+ run: npm install --no-save @tantainnovative/ndpr-toolkit@{{TOOLKIT_VERSION}}
30
30
  - name: Run the NDPA compliance audit
31
31
  run: npx ndpr audit --min-score 70