@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,239 +1,173 @@
1
1
  /**
2
- * Next.js App Router — Breach Notification Route
3
- * Generated by create-ndpr for: {{ORG_NAME}}
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 '@/drizzle';
22
- import { breachReports, complianceAuditLog } from '@/drizzle/ndpr-schema';
23
- import { eq, desc } from 'drizzle-orm';
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
- // 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 72-hour NDPC notification
29
- // requirement in Section 40. Replace `breachStore`/`auditLog` with your real
30
- // store and connect it to your incident-response tooling.
31
- interface BreachReport {
32
- id: string;
33
- title: string;
34
- description: string;
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, BreachReport>();
50
- const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
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
- function calculateSeverity(
55
- category: string,
56
- estimatedAffected?: number,
57
- ): 'critical' | 'high' | 'medium' | 'low' {
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 } : undefined,
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((r) => (status ? r.status === status : true))
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 body = await req.json();
94
- const {
95
- title,
96
- description,
97
- category,
98
- discoveredAt,
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
- if (!Array.isArray(affectedSystems) || !Array.isArray(dataTypes)) {
117
- return NextResponse.json(
118
- { error: 'affectedSystems and dataTypes must be arrays' },
119
- { status: 400 },
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
- const severity = calculateSeverity(category, estimatedAffected);
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.breachReport.create({
127
- data: {
128
- title,
129
- description,
130
- category,
131
- severity,
132
- status: 'ongoing',
133
- discoveredAt: new Date(discoveredAt),
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 [report] = await db.insert(breachReports).values({
157
- title,
158
- description,
159
- category,
160
- severity,
161
- status: 'ongoing',
162
- discoveredAt: new Date(discoveredAt),
163
- occurredAt: occurredAt ? new Date(occurredAt) : null,
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: BreachReport = {
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: newId(), module: 'breach', action: 'reported', entityId: report.id, at: new Date() });
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
- title,
213
- description,
214
- category,
215
- discoveredAt: new Date(discoveredAt).getTime(),
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
- initialActions: initialActions ?? undefined,
223
- dpoContact: { name: '{{ORG_NAME}} DPO', email: '{{DPO_EMAIL}}' },
224
- status: 'ongoing',
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
- return NextResponse.json(
228
- {
229
- ...report,
230
- ndpcReadiness: {
231
- complete: ndpcReadiness.complete,
232
- completeness: ndpcReadiness.completeness,
233
- missing: ndpcReadiness.missing,
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
  }