@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.
- package/README.md +64 -68
- package/bin/index.mjs +366 -526
- package/package.json +1 -1
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +200 -178
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +304 -153
- package/templates/express-cross-border-route.ts +94 -259
- package/templates/express-dpia-route.ts +267 -222
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +88 -234
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +3 -3
- package/templates/nextjs-breach-route.ts +117 -183
- package/templates/nextjs-consent-route.ts +312 -104
- package/templates/nextjs-cross-border-route.ts +239 -250
- package/templates/nextjs-dpia-route.ts +434 -214
- package/templates/nextjs-dsr-route.ts +70 -116
- package/templates/nextjs-lawful-basis-route.ts +207 -221
- package/templates/nextjs-layout.tsx +120 -57
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +89 -77
- package/templates/express-setup.ts +0 -509
|
@@ -1,239 +1,173 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Next.js App Router —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* NDPA §40 — data controllers must notify the NDPC within 72 hours of
|
|
6
|
-
* discovering a breach that poses a risk to data subject rights and freedoms.
|
|
7
|
-
*
|
|
8
|
-
* Endpoints:
|
|
9
|
-
* GET /api/breach — List breach reports (optional ?status= filter)
|
|
10
|
-
* POST /api/breach — Create a new breach report
|
|
2
|
+
* Next.js App Router — tenant-scoped breach register for {{ORG_NAME_COMMENT}}.
|
|
3
|
+
* All operations require verified staff context. Readiness is advisory; it is
|
|
4
|
+
* not evidence that a regulator or affected subject was notified.
|
|
11
5
|
*/
|
|
12
|
-
|
|
13
6
|
import { NextRequest, NextResponse } from 'next/server';
|
|
14
7
|
import { assessBreachNotification } from '@tantainnovative/ndpr-toolkit/server';
|
|
8
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
15
9
|
// {{#if ORM=prisma}}
|
|
16
10
|
import { PrismaClient } from '@prisma/client';
|
|
17
|
-
|
|
18
11
|
const prisma = new PrismaClient();
|
|
19
12
|
// {{/if}}
|
|
20
13
|
// {{#if ORM=drizzle}}
|
|
21
|
-
import { db } from '
|
|
22
|
-
import { breachReports, complianceAuditLog } from '
|
|
23
|
-
import {
|
|
14
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
15
|
+
import { breachReports, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
16
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
24
17
|
// {{/if}}
|
|
25
18
|
// {{#if ORM=none}}
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
category: string;
|
|
36
|
-
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
37
|
-
status: string;
|
|
38
|
-
discoveredAt: Date;
|
|
39
|
-
occurredAt: Date | null;
|
|
40
|
-
reportedAt: Date;
|
|
41
|
-
reporterName: string;
|
|
42
|
-
reporterEmail: string;
|
|
43
|
-
reporterDepartment: string | null;
|
|
44
|
-
affectedSystems: string[];
|
|
45
|
-
dataTypes: string[];
|
|
46
|
-
estimatedAffected: number | null;
|
|
19
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
20
|
+
interface BreachRow {
|
|
21
|
+
id: string; tenantId: string; title: string; description: string; category: string;
|
|
22
|
+
severity: Severity; status: string; discoveredAt: Date; occurredAt: Date | null;
|
|
23
|
+
reportedAt: Date; reporterName: string; reporterEmail: string; reporterDepartment: string | null;
|
|
24
|
+
affectedSystems: string[]; dataTypes: string[]; estimatedAffected: number | null;
|
|
25
|
+
approximateRecordCount: number | null; dataSubjectCategories: string[];
|
|
26
|
+
likelyConsequences: string | null; mitigationMeasures: string | null;
|
|
27
|
+
involvesSensitiveData: boolean; isPhasedReport: boolean; supplementsReportId: string | null;
|
|
47
28
|
initialActions: string | null;
|
|
48
29
|
}
|
|
49
|
-
const breachStore = new Map<string,
|
|
50
|
-
const auditLog: Array<{ id: string;
|
|
51
|
-
function newId() { return crypto.randomUUID(); }
|
|
30
|
+
const breachStore = new Map<string, BreachRow>();
|
|
31
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; at: Date }> = [];
|
|
52
32
|
// {{/if}}
|
|
53
33
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
const highRisk = ['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft'];
|
|
59
|
-
if (highRisk.includes(category)) {
|
|
60
|
-
return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
|
|
61
|
-
}
|
|
34
|
+
type Severity = 'critical' | 'high' | 'medium' | 'low';
|
|
35
|
+
function calculateSeverity(category: string, estimatedAffected: number | null): Severity {
|
|
36
|
+
const highRisk = new Set(['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft']);
|
|
37
|
+
if (highRisk.has(category)) return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
|
|
62
38
|
if ((estimatedAffected ?? 0) > 500) return 'high';
|
|
63
39
|
if ((estimatedAffected ?? 0) > 50) return 'medium';
|
|
64
40
|
return 'low';
|
|
65
41
|
}
|
|
42
|
+
function finiteNonNegative(value: unknown): number | null {
|
|
43
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
44
|
+
}
|
|
66
45
|
|
|
67
|
-
// GET /api/breach?status=ongoing
|
|
68
46
|
export async function GET(req: NextRequest) {
|
|
47
|
+
const context = await resolveNDPRRequestContext(req);
|
|
48
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
49
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
69
50
|
const status = req.nextUrl.searchParams.get('status');
|
|
70
51
|
|
|
71
52
|
// {{#if ORM=prisma}}
|
|
72
53
|
const reports = await prisma.breachReport.findMany({
|
|
73
|
-
where: status ? { status } :
|
|
54
|
+
where: { tenantId: context.tenantId, ...(status ? { status } : {}) },
|
|
74
55
|
orderBy: { reportedAt: 'desc' },
|
|
75
56
|
});
|
|
76
57
|
// {{/if}}
|
|
77
58
|
// {{#if ORM=drizzle}}
|
|
78
59
|
const reports = status
|
|
79
|
-
? await db.select().from(breachReports).where(eq(breachReports.status, status)).orderBy(desc(breachReports.reportedAt))
|
|
80
|
-
: await db.select().from(breachReports).orderBy(desc(breachReports.reportedAt));
|
|
60
|
+
? await db.select().from(breachReports).where(and(eq(breachReports.tenantId, context.tenantId), eq(breachReports.status, status))).orderBy(desc(breachReports.reportedAt))
|
|
61
|
+
: await db.select().from(breachReports).where(eq(breachReports.tenantId, context.tenantId)).orderBy(desc(breachReports.reportedAt));
|
|
81
62
|
// {{/if}}
|
|
82
63
|
// {{#if ORM=none}}
|
|
83
64
|
const reports = [...breachStore.values()]
|
|
84
|
-
.filter((
|
|
65
|
+
.filter((row) => row.tenantId === context.tenantId && (!status || row.status === status))
|
|
85
66
|
.sort((a, b) => b.reportedAt.getTime() - a.reportedAt.getTime());
|
|
86
67
|
// {{/if}}
|
|
87
|
-
|
|
88
68
|
return NextResponse.json(reports);
|
|
89
69
|
}
|
|
90
70
|
|
|
91
|
-
// POST /api/breach
|
|
92
71
|
export async function POST(req: NextRequest) {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
occurredAt,
|
|
100
|
-
reporterName,
|
|
101
|
-
reporterEmail,
|
|
102
|
-
reporterDepartment,
|
|
103
|
-
affectedSystems,
|
|
104
|
-
dataTypes,
|
|
105
|
-
estimatedAffected,
|
|
106
|
-
initialActions,
|
|
107
|
-
} = body;
|
|
108
|
-
|
|
109
|
-
if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
|
|
110
|
-
return NextResponse.json(
|
|
111
|
-
{ error: 'title, description, category, discoveredAt, reporterName, and reporterEmail are required' },
|
|
112
|
-
{ status: 400 },
|
|
113
|
-
);
|
|
72
|
+
const context = await resolveNDPRRequestContext(req);
|
|
73
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
74
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
75
|
+
const actor = context.actor;
|
|
76
|
+
if (!actor) {
|
|
77
|
+
return NextResponse.json({ error: 'Verified staff profile is required' }, { status: 401 });
|
|
114
78
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
79
|
+
const actorId = actor.id;
|
|
80
|
+
const body: unknown = await req.json().catch(() => null);
|
|
81
|
+
if (!body || typeof body !== 'object') return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
82
|
+
const input = body as Record<string, unknown>;
|
|
83
|
+
const required = ['title', 'description', 'category', 'discoveredAt'] as const;
|
|
84
|
+
if (required.some((field) => typeof input[field] !== 'string' || !(input[field] as string).trim())) {
|
|
85
|
+
return NextResponse.json({ error: `${required.join(', ')} are required strings` }, { status: 400 });
|
|
121
86
|
}
|
|
122
|
-
|
|
123
|
-
|
|
87
|
+
if (!Array.isArray(input.affectedSystems) || !input.affectedSystems.every((v) => typeof v === 'string') ||
|
|
88
|
+
!Array.isArray(input.dataTypes) || !input.dataTypes.every((v) => typeof v === 'string')) {
|
|
89
|
+
return NextResponse.json({ error: 'affectedSystems and dataTypes must be string arrays' }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
const discoveredAt = new Date(input.discoveredAt as string);
|
|
92
|
+
const occurredAt = typeof input.occurredAt === 'string' ? new Date(input.occurredAt) : null;
|
|
93
|
+
if (!Number.isFinite(discoveredAt.getTime()) || discoveredAt.getTime() > Date.now() ||
|
|
94
|
+
(occurredAt && (!Number.isFinite(occurredAt.getTime()) || occurredAt > discoveredAt))) {
|
|
95
|
+
return NextResponse.json({ error: 'Breach dates are invalid or inconsistent' }, { status: 400 });
|
|
96
|
+
}
|
|
97
|
+
const estimatedAffected = input.estimatedAffected == null ? null : finiteNonNegative(input.estimatedAffected);
|
|
98
|
+
const approximateRecordCount = input.approximateRecordCount == null ? null : finiteNonNegative(input.approximateRecordCount);
|
|
99
|
+
if ((input.estimatedAffected != null && estimatedAffected === null) ||
|
|
100
|
+
(input.approximateRecordCount != null && approximateRecordCount === null)) {
|
|
101
|
+
return NextResponse.json({ error: 'Affected-subject and record counts must be finite non-negative numbers' }, { status: 400 });
|
|
102
|
+
}
|
|
103
|
+
const dataSubjectCategories = Array.isArray(input.dataSubjectCategories) && input.dataSubjectCategories.every((v) => typeof v === 'string')
|
|
104
|
+
? input.dataSubjectCategories as string[] : [];
|
|
105
|
+
const severity = calculateSeverity(input.category as string, estimatedAffected);
|
|
106
|
+
const reportedAt = new Date();
|
|
107
|
+
const data = {
|
|
108
|
+
tenantId: context.tenantId,
|
|
109
|
+
title: input.title as string, description: input.description as string,
|
|
110
|
+
category: input.category as string, severity, status: 'ongoing', discoveredAt, occurredAt,
|
|
111
|
+
reporterName: actor.displayName, reporterEmail: actor.email,
|
|
112
|
+
reporterDepartment: actor.department ?? null,
|
|
113
|
+
affectedSystems: input.affectedSystems as string[], dataTypes: input.dataTypes as string[],
|
|
114
|
+
estimatedAffected, approximateRecordCount, dataSubjectCategories,
|
|
115
|
+
likelyConsequences: typeof input.likelyConsequences === 'string' ? input.likelyConsequences : null,
|
|
116
|
+
mitigationMeasures: typeof input.mitigationMeasures === 'string' ? input.mitigationMeasures : null,
|
|
117
|
+
involvesSensitiveData: input.involvesSensitiveData === true,
|
|
118
|
+
isPhasedReport: input.isPhasedReport === true,
|
|
119
|
+
supplementsReportId: typeof input.supplementsReportId === 'string' ? input.supplementsReportId : null,
|
|
120
|
+
initialActions: typeof input.initialActions === 'string' ? input.initialActions : null,
|
|
121
|
+
};
|
|
124
122
|
|
|
125
123
|
// {{#if ORM=prisma}}
|
|
126
|
-
const report = await prisma
|
|
127
|
-
data
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
severity,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
135
|
-
reporterName,
|
|
136
|
-
reporterEmail,
|
|
137
|
-
reporterDepartment: reporterDepartment ?? null,
|
|
138
|
-
affectedSystems,
|
|
139
|
-
dataTypes,
|
|
140
|
-
estimatedAffected: estimatedAffected ?? null,
|
|
141
|
-
initialActions: initialActions ?? null,
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
await prisma.complianceAuditLog.create({
|
|
146
|
-
data: {
|
|
147
|
-
module: 'breach',
|
|
148
|
-
action: 'reported',
|
|
149
|
-
entityId: report.id,
|
|
150
|
-
entityType: 'BreachReport',
|
|
151
|
-
changes: { title, category, severity, status: 'ongoing' },
|
|
152
|
-
},
|
|
124
|
+
const report = await prisma.$transaction(async (tx) => {
|
|
125
|
+
const created = await tx.breachReport.create({ data });
|
|
126
|
+
await tx.complianceAuditLog.create({ data: {
|
|
127
|
+
tenantId: context.tenantId, module: 'breach', action: 'reported',
|
|
128
|
+
entityId: created.id, entityType: 'BreachReport', performedBy: actorId,
|
|
129
|
+
changes: { title: data.title, category: data.category, severity, status: 'ongoing' },
|
|
130
|
+
} });
|
|
131
|
+
return created;
|
|
153
132
|
});
|
|
154
133
|
// {{/if}}
|
|
155
134
|
// {{#if ORM=drizzle}}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
reporterName,
|
|
165
|
-
reporterEmail,
|
|
166
|
-
reporterDepartment: reporterDepartment ?? null,
|
|
167
|
-
affectedSystems,
|
|
168
|
-
dataTypes,
|
|
169
|
-
estimatedAffected: estimatedAffected ?? null,
|
|
170
|
-
initialActions: initialActions ?? null,
|
|
171
|
-
}).returning();
|
|
172
|
-
|
|
173
|
-
await db.insert(complianceAuditLog).values({
|
|
174
|
-
module: 'breach',
|
|
175
|
-
action: 'reported',
|
|
176
|
-
entityId: report.id,
|
|
177
|
-
entityType: 'BreachReport',
|
|
178
|
-
changes: { title, category, severity, status: 'ongoing' },
|
|
135
|
+
const report = await db.transaction(async (tx) => {
|
|
136
|
+
const [created] = await tx.insert(breachReports).values(data).returning();
|
|
137
|
+
await tx.insert(complianceAuditLog).values({
|
|
138
|
+
tenantId: context.tenantId, module: 'breach', action: 'reported',
|
|
139
|
+
entityId: created.id, entityType: 'BreachReport', performedBy: actorId,
|
|
140
|
+
changes: { title: data.title, category: data.category, severity, status: 'ongoing' },
|
|
141
|
+
});
|
|
142
|
+
return created;
|
|
179
143
|
});
|
|
180
144
|
// {{/if}}
|
|
181
145
|
// {{#if ORM=none}}
|
|
182
|
-
const report:
|
|
183
|
-
id: newId(),
|
|
184
|
-
title,
|
|
185
|
-
description,
|
|
186
|
-
category,
|
|
187
|
-
severity,
|
|
188
|
-
status: 'ongoing',
|
|
189
|
-
discoveredAt: new Date(discoveredAt),
|
|
190
|
-
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
191
|
-
reportedAt: new Date(),
|
|
192
|
-
reporterName,
|
|
193
|
-
reporterEmail,
|
|
194
|
-
reporterDepartment: reporterDepartment ?? null,
|
|
195
|
-
affectedSystems,
|
|
196
|
-
dataTypes,
|
|
197
|
-
estimatedAffected: estimatedAffected ?? null,
|
|
198
|
-
initialActions: initialActions ?? null,
|
|
199
|
-
};
|
|
146
|
+
const report: BreachRow = { id: crypto.randomUUID(), ...data, reportedAt };
|
|
200
147
|
breachStore.set(report.id, report);
|
|
201
|
-
auditLog.push({ id:
|
|
148
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'reported', entityId: report.id, performedBy: actorId, at: reportedAt });
|
|
202
149
|
// {{/if}}
|
|
203
150
|
|
|
204
|
-
// NDPC readiness — NDPA S.40 / GAID 2025 Art. 33(5). Advisory and
|
|
205
|
-
// non-blocking: you must record every incident, but this surfaces which
|
|
206
|
-
// mandated notification fields are still missing (and how long is left on
|
|
207
|
-
// the 72-hour clock) before you file with the Commission. Collect the
|
|
208
|
-
// missing fields — likely consequences, mitigation measures, data-subject
|
|
209
|
-
// categories, record count — as your investigation progresses.
|
|
210
151
|
const ndpcReadiness = assessBreachNotification({
|
|
211
|
-
id: report.id,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
occurredAt: occurredAt ? new Date(occurredAt).getTime() : undefined,
|
|
217
|
-
reportedAt: Date.now(),
|
|
218
|
-
reporter: { name: reporterName, email: reporterEmail, department: reporterDepartment ?? '' },
|
|
219
|
-
affectedSystems,
|
|
220
|
-
dataTypes,
|
|
152
|
+
id: report.id, title: data.title, description: data.description, category: data.category,
|
|
153
|
+
discoveredAt: discoveredAt.getTime(), occurredAt: occurredAt?.getTime(), reportedAt: reportedAt.getTime(),
|
|
154
|
+
reporter: { name: data.reporterName, email: data.reporterEmail, department: data.reporterDepartment ?? '' },
|
|
155
|
+
affectedSystems: data.affectedSystems, dataTypes: data.dataTypes,
|
|
156
|
+
involvesSensitiveData: data.involvesSensitiveData,
|
|
221
157
|
estimatedAffectedSubjects: estimatedAffected ?? undefined,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
158
|
+
approximateRecordCount: approximateRecordCount ?? undefined,
|
|
159
|
+
dataSubjectCategories, likelyConsequences: data.likelyConsequences ?? undefined,
|
|
160
|
+
mitigationMeasures: data.mitigationMeasures ?? undefined,
|
|
161
|
+
isPhasedReport: data.isPhasedReport, supplementsReportId: data.supplementsReportId ?? undefined,
|
|
162
|
+
initialActions: data.initialActions ?? undefined,
|
|
163
|
+
dpoContact: { name: `{{ORG_NAME_TEMPLATE}} DPO`, email: `{{DPO_EMAIL_TEMPLATE}}` }, status: 'ongoing',
|
|
225
164
|
});
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
hoursRemaining: ndpcReadiness.timing.hoursRemaining,
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
{ status: 201 },
|
|
238
|
-
);
|
|
165
|
+
return NextResponse.json({ ...report, ndpcReadiness: {
|
|
166
|
+
complete: ndpcReadiness.complete, ready: ndpcReadiness.ready, valid: ndpcReadiness.valid,
|
|
167
|
+
completeness: ndpcReadiness.completeness, missing: ndpcReadiness.missing,
|
|
168
|
+
validationErrors: ndpcReadiness.validationErrors,
|
|
169
|
+
notificationRequired: ndpcReadiness.notificationRequired,
|
|
170
|
+
hoursRemaining: ndpcReadiness.timing.hoursRemaining,
|
|
171
|
+
evidenceRecorded: ndpcReadiness.evidence.notificationProvided,
|
|
172
|
+
} }, { status: 201 });
|
|
239
173
|
}
|