@tantainnovative/create-ndpr 0.1.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.
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ # NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{ORG_NAME_COMMENT}}.
2
+ #
3
+ # Runs `ndpr audit` on every push and pull request. The audit scores your
4
+ # compliance config (consent, DSR, DPIA, breach, policy, lawful basis,
5
+ # cross-border, RoPA) plus your GAID 2025 DCPMI designation inputs, and exits
6
+ # non-zero when the overall score drops below the threshold — so a regression
7
+ # fails CI the same way a broken test would.
8
+ #
9
+ # Edit ndpr.audit.json to reflect your real posture, then raise --min-score
10
+ # over time as you close gaps. Docs: https://ndprtoolkit.com.ng/docs/guides/audit-cli
11
+ name: NDPA compliance audit
12
+
13
+ on:
14
+ push:
15
+ branches: [main]
16
+ pull_request:
17
+
18
+ jobs:
19
+ ndpr-audit:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: 20
26
+ # The `ndpr` CLI ships with the toolkit. --no-save keeps it out of your
27
+ # package.json if you don't already depend on it directly.
28
+ - name: Install the NDPR toolkit (provides the `ndpr` CLI)
29
+ run: npm install --no-save @tantainnovative/ndpr-toolkit@{{TOOLKIT_VERSION}}
30
+ - name: Run the NDPA compliance audit
31
+ run: npx ndpr audit --min-score 70
@@ -0,0 +1,14 @@
1
+ {
2
+ "minScore": 70,
3
+ "compliance": {
4
+ "consent": { "hasConsentMechanism": true, "hasPurposeSpecification": true, "hasWithdrawalMechanism": true, "hasMinorProtection": false, "consentRecordsRetained": true },
5
+ "dsr": { "hasRequestMechanism": true, "supportsAccess": true, "supportsRectification": true, "supportsErasure": false, "supportsPortability": false, "supportsObjection": false, "responseTimelineDays": 30 },
6
+ "dpia": { "conductedForHighRisk": true, "documentedRisks": true, "mitigationMeasures": true },
7
+ "breach": { "hasNotificationProcess": true, "notifiesWithin72Hours": true, "hasRiskAssessment": true, "hasRecordKeeping": true },
8
+ "policy": { "hasPrivacyPolicy": true, "isPubliclyAccessible": true, "lastUpdated": "2026-01-01", "coversAllSections": true },
9
+ "lawfulBasis": { "documentedForAllProcessing": true, "hasLegitimateInterestAssessment": false },
10
+ "crossBorder": { "hasTransferMechanisms": true, "adequacyAssessed": true, "ndpcApprovalObtained": false },
11
+ "ropa": { "maintained": true, "includesAllProcessing": true, "lastReviewed": "2026-01-01" }
12
+ },
13
+ "dcpmi": { "dataSubjectsInSixMonths": 0 }
14
+ }
@@ -1,107 +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';
7
+ import { assessBreachNotification } from '@tantainnovative/ndpr-toolkit/server';
8
+ import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
9
+ // {{#if ORM=prisma}}
14
10
  import { PrismaClient } from '@prisma/client';
15
-
16
11
  const prisma = new PrismaClient();
12
+ // {{/if}}
13
+ // {{#if ORM=drizzle}}
14
+ import { db } from '{{NDPR_DB_IMPORT}}';
15
+ import { breachReports, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
16
+ import { and, desc, eq } from 'drizzle-orm';
17
+ // {{/if}}
18
+ // {{#if ORM=none}}
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;
28
+ initialActions: string | null;
29
+ }
30
+ const breachStore = new Map<string, BreachRow>();
31
+ const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; at: Date }> = [];
32
+ // {{/if}}
17
33
 
18
- function calculateSeverity(
19
- category: string,
20
- estimatedAffected?: number,
21
- ): 'critical' | 'high' | 'medium' | 'low' {
22
- const highRisk = ['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft'];
23
- if (highRisk.includes(category)) {
24
- return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
25
- }
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';
26
38
  if ((estimatedAffected ?? 0) > 500) return 'high';
27
39
  if ((estimatedAffected ?? 0) > 50) return 'medium';
28
40
  return 'low';
29
41
  }
42
+ function finiteNonNegative(value: unknown): number | null {
43
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
44
+ }
30
45
 
31
- // GET /api/breach?status=ongoing
32
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 });
33
50
  const status = req.nextUrl.searchParams.get('status');
34
51
 
52
+ // {{#if ORM=prisma}}
35
53
  const reports = await prisma.breachReport.findMany({
36
- where: status ? { status } : undefined,
54
+ where: { tenantId: context.tenantId, ...(status ? { status } : {}) },
37
55
  orderBy: { reportedAt: 'desc' },
38
56
  });
39
-
57
+ // {{/if}}
58
+ // {{#if ORM=drizzle}}
59
+ const reports = status
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));
62
+ // {{/if}}
63
+ // {{#if ORM=none}}
64
+ const reports = [...breachStore.values()]
65
+ .filter((row) => row.tenantId === context.tenantId && (!status || row.status === status))
66
+ .sort((a, b) => b.reportedAt.getTime() - a.reportedAt.getTime());
67
+ // {{/if}}
40
68
  return NextResponse.json(reports);
41
69
  }
42
70
 
43
- // POST /api/breach
44
71
  export async function POST(req: NextRequest) {
45
- const body = await req.json();
46
- const {
47
- title,
48
- description,
49
- category,
50
- discoveredAt,
51
- occurredAt,
52
- reporterName,
53
- reporterEmail,
54
- reporterDepartment,
55
- affectedSystems,
56
- dataTypes,
57
- estimatedAffected,
58
- initialActions,
59
- } = body;
60
-
61
- if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
62
- return NextResponse.json(
63
- { error: 'title, description, category, discoveredAt, reporterName, and reporterEmail are required' },
64
- { status: 400 },
65
- );
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 });
66
78
  }
67
-
68
- if (!Array.isArray(affectedSystems) || !Array.isArray(dataTypes)) {
69
- return NextResponse.json(
70
- { error: 'affectedSystems and dataTypes must be arrays' },
71
- { status: 400 },
72
- );
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 });
73
86
  }
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
+ };
74
122
 
75
- const severity = calculateSeverity(category, estimatedAffected);
76
-
77
- const report = await prisma.breachReport.create({
78
- data: {
79
- title,
80
- description,
81
- category,
82
- severity,
83
- status: 'ongoing',
84
- discoveredAt: new Date(discoveredAt),
85
- occurredAt: occurredAt ? new Date(occurredAt) : null,
86
- reporterName,
87
- reporterEmail,
88
- reporterDepartment: reporterDepartment ?? null,
89
- affectedSystems,
90
- dataTypes,
91
- estimatedAffected: estimatedAffected ?? null,
92
- initialActions: initialActions ?? null,
93
- },
123
+ // {{#if ORM=prisma}}
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;
94
132
  });
95
-
96
- await prisma.complianceAuditLog.create({
97
- data: {
98
- module: 'breach',
99
- action: 'reported',
100
- entityId: report.id,
101
- entityType: 'BreachReport',
102
- changes: { title, category, severity, status: 'ongoing' },
103
- },
133
+ // {{/if}}
134
+ // {{#if ORM=drizzle}}
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;
104
143
  });
144
+ // {{/if}}
145
+ // {{#if ORM=none}}
146
+ const report: BreachRow = { id: crypto.randomUUID(), ...data, reportedAt };
147
+ breachStore.set(report.id, report);
148
+ auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'reported', entityId: report.id, performedBy: actorId, at: reportedAt });
149
+ // {{/if}}
105
150
 
106
- return NextResponse.json(report, { status: 201 });
151
+ const ndpcReadiness = assessBreachNotification({
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,
157
+ estimatedAffectedSubjects: estimatedAffected ?? undefined,
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',
164
+ });
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 });
107
173
  }