@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,73 +1,136 @@
1
1
  'use client';
2
2
 
3
- /**
4
- * NDPRLayout generated by create-ndpr
5
- *
6
- * Wraps your app with the NDPA-compliant consent banner and provider.
7
- *
8
- * Usage in app/layout.tsx:
9
- *
10
- * import NDPRLayout from '@/app/ndpr-layout';
11
- *
12
- * export default async function RootLayout({ children }) {
13
- * const session = await getServerSession();
14
- * return (
15
- * <html lang="en">
16
- * <body>
17
- * <NDPRLayout userId={session?.user?.id}>
18
- * {children}
19
- * </NDPRLayout>
20
- * </body>
21
- * </html>
22
- * );
23
- * }
24
- */
25
-
26
- import React from 'react';
27
- import { NDPRProvider } from '@tantainnovative/ndpr-toolkit/core';
3
+ import React, { useEffect, useMemo, useState } from 'react';
4
+ import { NDPRProvider } from '@tantainnovative/ndpr-toolkit';
28
5
  import { NDPRConsent } from '@tantainnovative/ndpr-toolkit/presets';
29
6
  import { apiAdapter } from '@tantainnovative/ndpr-toolkit/adapters';
30
- import type { ConsentSettings } from '@tantainnovative/ndpr-toolkit/core';
7
+ import type {
8
+ StorageAdapter,
9
+ ConsentSettings,
10
+ } from '@tantainnovative/ndpr-toolkit/core';
31
11
 
32
- interface NDPRLayoutProps {
33
- children: React.ReactNode;
34
- /**
35
- * Authenticated user ID, or a stable anonymous identifier (e.g. UUID from
36
- * a cookie) for unauthenticated visitors. NDPA requires consent to be
37
- * recorded even for anonymous users.
38
- */
39
- userId?: string;
12
+ const SUBJECT_STORAGE_KEY = 'ndpr_anonymous_subject_id';
13
+ const SUBJECT_COOKIE = 'ndpr_subject_id';
14
+ const ANONYMOUS_SUBJECT_PATTERN =
15
+ /^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;
16
+
17
+ interface StoredConsent {
18
+ consents: Record<string, boolean>;
19
+ version: string;
20
+ method: string;
21
+ lawfulBasis?: ConsentSettings['lawfulBasis'] | null;
22
+ hasInteracted?: boolean;
23
+ createdAt: string;
40
24
  }
41
25
 
42
- export default function NDPRLayout({ children, userId }: NDPRLayoutProps) {
43
- const subjectId = userId ?? 'anonymous';
26
+ function getOrCreateAnonymousSubjectId(): string {
27
+ try {
28
+ const stored = window.localStorage.getItem(SUBJECT_STORAGE_KEY);
29
+ if (stored && ANONYMOUS_SUBJECT_PATTERN.test(stored)) return stored;
30
+ } catch {
31
+ // Storage can be disabled; the first-party cookie remains a fallback.
32
+ }
33
+
34
+ const cookie = document.cookie
35
+ .split(';')
36
+ .map((part) => part.trim())
37
+ .find((part) => part.startsWith(`${SUBJECT_COOKIE}=`));
38
+ if (cookie) {
39
+ try {
40
+ const existing = decodeURIComponent(cookie.slice(cookie.indexOf('=') + 1));
41
+ if (ANONYMOUS_SUBJECT_PATTERN.test(existing)) return existing;
42
+ } catch {
43
+ // Ignore malformed percent encoding and replace the invalid cookie below.
44
+ }
45
+ }
46
+
47
+ const subjectId = `anon_${crypto.randomUUID()}`;
48
+ try {
49
+ window.localStorage.setItem(SUBJECT_STORAGE_KEY, subjectId);
50
+ } catch {
51
+ // Cookie persistence below may still succeed.
52
+ }
53
+ document.cookie = [
54
+ `${SUBJECT_COOKIE}=${encodeURIComponent(subjectId)}`,
55
+ 'path=/',
56
+ 'max-age=31536000',
57
+ 'samesite=lax',
58
+ location.protocol === 'https:' ? 'secure' : '',
59
+ ].filter(Boolean).join('; ');
60
+ return subjectId;
61
+ }
62
+
63
+ function createConsentAdapter(
64
+ subjectId: string,
65
+ ): StorageAdapter<ConsentSettings> {
66
+ const remote = apiAdapter<StoredConsent | ConsentSettings>('/api/consent', {
67
+ credentials: 'same-origin',
68
+ headers: { 'X-NDPR-Subject-Id': subjectId },
69
+ idempotencyKey: ({ method, payload }) => {
70
+ const timestamp = payload && 'timestamp' in payload
71
+ ? payload.timestamp
72
+ : crypto.randomUUID();
73
+ return `${subjectId}:${method}:${timestamp}`;
74
+ },
75
+ });
44
76
 
45
- // apiAdapter connects the consent banner to the /api/consent route.
46
- // Typed as <ConsentSettings> so the banner's onSave payload matches.
47
- // For server-side usage in Server Components or Route Handlers, see
48
- // the recipes under /docs/recipes/.
49
- const consentStorageAdapter = apiAdapter<ConsentSettings>(
50
- `/api/consent?subjectId=${subjectId}`,
51
- {
52
- credentials: 'same-origin',
53
- // Add a CSRF header here if your app requires one:
54
- // headers: () => ({ 'X-CSRF-Token': getCsrfToken() }),
77
+ return {
78
+ capabilities: remote.capabilities,
79
+ async load() {
80
+ const record = await remote.load();
81
+ if (!record) return null;
82
+ if (
83
+ 'timestamp' in record
84
+ && typeof record.timestamp === 'number'
85
+ && Number.isSafeInteger(record.timestamp)
86
+ ) {
87
+ return record;
88
+ }
89
+ if (!('createdAt' in record)) return null;
90
+ const timestamp = Date.parse(record.createdAt);
91
+ if (!Number.isFinite(timestamp)) return null;
92
+ return {
93
+ consents: record.consents,
94
+ version: record.version,
95
+ method: record.method,
96
+ lawfulBasis: record.lawfulBasis ?? undefined,
97
+ timestamp,
98
+ hasInteracted: record.hasInteracted !== false,
99
+ };
55
100
  },
101
+ save(settings) {
102
+ return remote.save(settings);
103
+ },
104
+ remove() {
105
+ return remote.remove();
106
+ },
107
+ };
108
+ }
109
+
110
+ interface NDPRClientProviderProps { children: React.ReactNode }
111
+
112
+ /**
113
+ * Client boundary for {{ORG_NAME_COMMENT}}. Authenticated identity is resolved by
114
+ * the server-side request-context hook; never pass account IDs from the
115
+ * browser as trusted identity.
116
+ */
117
+ export default function NDPRClientProvider({ children }: NDPRClientProviderProps) {
118
+ const [anonymousSubjectId, setAnonymousSubjectId] = useState<string | null>(null);
119
+ useEffect(() => {
120
+ setAnonymousSubjectId(getOrCreateAnonymousSubjectId());
121
+ }, []);
122
+
123
+ const consentAdapter = useMemo(
124
+ () => anonymousSubjectId
125
+ ? createConsentAdapter(anonymousSubjectId)
126
+ : null,
127
+ [anonymousSubjectId],
56
128
  );
57
129
 
58
130
  return (
59
- <NDPRProvider
60
- organizationName="{{ORG_NAME}}"
61
- dpoEmail="{{DPO_EMAIL}}"
62
- >
131
+ <NDPRProvider organizationName={`{{ORG_NAME_TEMPLATE}}`} dpoEmail={`{{DPO_EMAIL_TEMPLATE}}`}>
63
132
  {children}
64
-
65
- {/*
66
- * NDPA-compliant consent banner.
67
- * The adapter persists decisions server-side so the banner is hidden
68
- * on return visits without flicker or double-prompting.
69
- */}
70
- <NDPRConsent adapter={consentStorageAdapter} />
133
+ {consentAdapter ? <NDPRConsent adapter={consentAdapter} /> : null}
71
134
  </NDPRProvider>
72
135
  );
73
136
  }
@@ -0,0 +1,137 @@
1
+ import type { NextRequest } from 'next/server';
2
+
3
+ export interface NDPRVerifiedActor {
4
+ /** Stable identity and profile fields from a verified server session. */
5
+ id: string;
6
+ displayName: string;
7
+ email: string;
8
+ department?: string;
9
+ /** Stable data-subject identifier for this account, when applicable. */
10
+ subjectId?: string;
11
+ /** Map application roles to ndpr:staff or ndpr:admin server-side. */
12
+ roles: readonly string[];
13
+ }
14
+
15
+ export interface NDPRRequestContext {
16
+ /** Verified tenant boundary. Never take this value from request input. */
17
+ tenantId: string;
18
+ /** Normalized verified actor; null for public/anonymous requests. */
19
+ actor: NDPRVerifiedActor | null;
20
+ actorId: string | null;
21
+ /** Verified account subject ID or high-entropy anonymous browser ID. */
22
+ subjectId: string | null;
23
+ subjectSource: 'verified-account-subject' | 'anonymous-uuid-capability' | null;
24
+ /** Verified application roles used by your authorization policy. */
25
+ roles: readonly string[];
26
+ }
27
+
28
+ export type NDPRContextRequirement = 'tenant' | 'subject' | 'staff';
29
+ export interface NDPRContextProblem { status: 401 | 403 | 503; error: string }
30
+ export type NDPRVerifiedActorResolver = (
31
+ request: NextRequest,
32
+ ) => Promise<NDPRVerifiedActor | null>;
33
+
34
+ const ANONYMOUS_SUBJECT_PATTERN =
35
+ /^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;
36
+ const STAFF_ROLES = new Set(['ndpr:staff', 'ndpr:admin']);
37
+
38
+ /**
39
+ * Authentication integration seam. Replace this safe default with your
40
+ * verified server session provider. Never trust actor, account subject,
41
+ * profile, role, or tenant fields from request-controlled input.
42
+ */
43
+ export async function resolveVerifiedNDPRActor(
44
+ _request: NextRequest,
45
+ ): Promise<NDPRVerifiedActor | null> {
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * The tenant comes only from server configuration. The only client-provided
51
+ * identity accepted by default is a random anon_<UUID> subject capability;
52
+ * it never grants staff access.
53
+ */
54
+ export async function resolveNDPRRequestContext(
55
+ request: NextRequest,
56
+ actorResolver: NDPRVerifiedActorResolver = resolveVerifiedNDPRActor,
57
+ ): Promise<NDPRRequestContext> {
58
+ const tenantId = process.env.NDPR_TENANT_ID?.trim() ?? '';
59
+ const candidate = request.headers.get('x-ndpr-subject-id')?.trim() ?? '';
60
+ const anonymousSubjectId = ANONYMOUS_SUBJECT_PATTERN.test(candidate)
61
+ ? candidate
62
+ : null;
63
+ const actor = normalizeVerifiedActor(await actorResolver(request));
64
+ const subjectId = actor?.subjectId ?? anonymousSubjectId;
65
+ const subjectSource = actor?.subjectId
66
+ ? 'verified-account-subject' as const
67
+ : anonymousSubjectId
68
+ ? 'anonymous-uuid-capability' as const
69
+ : null;
70
+
71
+ return {
72
+ tenantId,
73
+ actor,
74
+ actorId: actor?.id ?? null,
75
+ subjectId,
76
+ subjectSource,
77
+ roles: actor?.roles ?? [],
78
+ };
79
+ }
80
+
81
+ export function getNDPRContextProblem(
82
+ context: NDPRRequestContext,
83
+ requirement: NDPRContextRequirement,
84
+ ): NDPRContextProblem | null {
85
+ if (!context.tenantId) {
86
+ return {
87
+ status: 503,
88
+ error: 'NDPR_TENANT_ID is not configured on the server',
89
+ };
90
+ }
91
+ if (requirement === 'subject' && !context.subjectId) {
92
+ return { status: 401, error: 'A verified data-subject identity is required' };
93
+ }
94
+ if (requirement === 'staff' && !context.actorId) {
95
+ return {
96
+ status: 401,
97
+ error: 'Connect resolveVerifiedNDPRActor to verified staff authentication',
98
+ };
99
+ }
100
+ if (requirement === 'staff'
101
+ && !context.roles.some((role) => STAFF_ROLES.has(role))) {
102
+ return { status: 403, error: 'NDPR staff or administrator role required' };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function normalizeVerifiedActor(
108
+ actor: NDPRVerifiedActor | null,
109
+ ): NDPRVerifiedActor | null {
110
+ if (!actor
111
+ || typeof actor.id !== 'string'
112
+ || typeof actor.displayName !== 'string'
113
+ || typeof actor.email !== 'string'
114
+ || !Array.isArray(actor.roles)) {
115
+ return null;
116
+ }
117
+ const id = actor.id.trim();
118
+ const displayName = actor.displayName.trim();
119
+ const email = actor.email.trim();
120
+ if (!id || !displayName || !email) return null;
121
+
122
+ return {
123
+ id,
124
+ displayName,
125
+ email,
126
+ department: typeof actor.department === 'string'
127
+ ? actor.department.trim() || undefined
128
+ : undefined,
129
+ subjectId: typeof actor.subjectId === 'string'
130
+ ? actor.subjectId.trim() || undefined
131
+ : undefined,
132
+ roles: actor.roles
133
+ .filter((role): role is string => typeof role === 'string')
134
+ .map((role) => role.trim())
135
+ .filter(Boolean),
136
+ };
137
+ }
@@ -1,18 +1,5 @@
1
- // NDPA Compliance Schema — generated by create-ndpr
2
- // Organisation: {{ORG_NAME}}
3
- // DPO: {{DPO_EMAIL}}
4
- //
5
- // Covers:
6
- // - ConsentRecord — NDPA §25–26 (consent & withdrawal)
7
- // - DSRRequest — NDPA Part IV §34–38 (data subject rights)
8
- // - BreachReport — NDPA §40 (72-hour breach notification)
9
- // - ProcessingRecord — ROPA (accountability principle)
10
- // - DPIARecord — NDPA §34(3) (impact assessments)
11
- // - LawfulBasisRecord — NDPA §25 (lawful basis register)
12
- // - CrossBorderTransferRecord — NDPA §41–42 (cross-border transfers)
13
- // - ComplianceAuditLog — §44 audit trail
14
- //
15
- // Run `prisma migrate dev --name ndpr-init` to apply.
1
+ // NDPA compliance schema — generated by create-ndpr for {{ORG_NAME_COMMENT}}
2
+ // Every row is tenant-scoped. Derive tenantId only from verified server context.
16
3
 
17
4
  generator client {
18
5
  provider = "prisma-client-js"
@@ -24,25 +11,32 @@ datasource db {
24
11
  }
25
12
 
26
13
  model ConsentRecord {
27
- id String @id @default(cuid())
28
- subjectId String
29
- consents Json
30
- version String
31
- method String
32
- lawfulBasis String?
33
- ipAddress String?
34
- userAgent String?
35
- createdAt DateTime @default(now())
36
- revokedAt DateTime?
37
-
38
- @@index([subjectId])
14
+ id String @id @default(cuid())
15
+ tenantId String
16
+ subjectId String
17
+ activeSubjectKey String? @unique
18
+ consents Json
19
+ version String
20
+ method String
21
+ hasInteracted Boolean
22
+ lawfulBasis String?
23
+ ipAddress String?
24
+ userAgent String?
25
+ clientTimestamp DateTime
26
+ createdAt DateTime @default(now())
27
+ revokedAt DateTime?
28
+
29
+ @@index([tenantId, subjectId, revokedAt])
30
+ @@index([tenantId, subjectId, clientTimestamp, version, method])
39
31
  @@map("ndpr_consent_records")
40
32
  }
41
33
 
42
34
  model DSRRequest {
43
- id String @id @default(cuid())
35
+ id String @id @default(cuid())
36
+ tenantId String
37
+ subjectId String
44
38
  type String
45
- status String @default("pending")
39
+ status String @default("pending")
46
40
  subjectName String
47
41
  subjectEmail String
48
42
  subjectPhone String?
@@ -51,43 +45,53 @@ model DSRRequest {
51
45
  description String?
52
46
  internalNotes String?
53
47
  assignedTo String?
54
- submittedAt DateTime @default(now())
48
+ submittedAt DateTime @default(now())
55
49
  acknowledgedAt DateTime?
56
50
  completedAt DateTime?
57
51
  dueAt DateTime
58
52
 
59
- @@index([status])
60
- @@index([subjectEmail])
53
+ @@index([tenantId, status])
54
+ @@index([tenantId, subjectId])
55
+ @@index([tenantId, subjectEmail])
61
56
  @@map("ndpr_dsr_requests")
62
57
  }
63
58
 
64
59
  model BreachReport {
65
- id String @id @default(cuid())
66
- title String
67
- description String
68
- category String
69
- severity String
70
- status String @default("ongoing")
71
- discoveredAt DateTime
72
- occurredAt DateTime?
73
- reportedAt DateTime @default(now())
74
- ndpcNotifiedAt DateTime?
75
- reporterName String
76
- reporterEmail String
77
- reporterDepartment String?
78
- affectedSystems Json
79
- dataTypes Json
80
- estimatedAffected Int?
81
- initialActions String?
82
- ndpcNotificationSent Boolean @default(false)
83
-
84
- @@index([status])
85
- @@index([severity])
60
+ id String @id @default(cuid())
61
+ tenantId String
62
+ title String
63
+ description String
64
+ category String
65
+ severity String
66
+ status String @default("ongoing")
67
+ discoveredAt DateTime
68
+ occurredAt DateTime?
69
+ reportedAt DateTime @default(now())
70
+ ndpcNotifiedAt DateTime?
71
+ reporterName String
72
+ reporterEmail String
73
+ reporterDepartment String?
74
+ affectedSystems Json
75
+ dataTypes Json
76
+ involvesSensitiveData Boolean @default(false)
77
+ estimatedAffected Int?
78
+ approximateRecordCount Int?
79
+ dataSubjectCategories Json
80
+ likelyConsequences String?
81
+ mitigationMeasures String?
82
+ isPhasedReport Boolean @default(false)
83
+ supplementsReportId String?
84
+ initialActions String?
85
+ ndpcNotificationSent Boolean @default(false)
86
+
87
+ @@index([tenantId, status])
88
+ @@index([tenantId, severity])
86
89
  @@map("ndpr_breach_reports")
87
90
  }
88
91
 
89
92
  model ProcessingRecord {
90
93
  id String @id @default(cuid())
94
+ tenantId String
91
95
  purpose String
92
96
  lawfulBasis String
93
97
  dataCategories Json
@@ -102,29 +106,32 @@ model ProcessingRecord {
102
106
  createdAt DateTime @default(now())
103
107
  updatedAt DateTime @updatedAt
104
108
 
109
+ @@index([tenantId, status])
105
110
  @@map("ndpr_processing_records")
106
111
  }
107
112
 
108
113
  model DPIARecord {
109
- id String @id @default(cuid())
110
- projectName String
111
- description String
112
- dpiaData Json
113
- overallRisk String
114
- score Int
115
- status String @default("draft")
116
- conductedBy String
117
- approvedBy String?
118
- createdAt DateTime @default(now())
119
- updatedAt DateTime @updatedAt
120
-
121
- @@index([status])
122
- @@index([conductedBy])
114
+ id String @id @default(cuid())
115
+ tenantId String
116
+ projectName String
117
+ description String
118
+ dpiaData Json
119
+ overallRisk String
120
+ score Int
121
+ status String @default("draft")
122
+ conductedBy String
123
+ approvedBy String?
124
+ createdAt DateTime @default(now())
125
+ updatedAt DateTime @updatedAt
126
+ removedAt DateTime?
127
+
128
+ @@index([tenantId, removedAt, status])
123
129
  @@map("ndpr_dpia_records")
124
130
  }
125
131
 
126
132
  model LawfulBasisRecord {
127
133
  id String @id @default(cuid())
134
+ tenantId String
128
135
  activityName String
129
136
  lawfulBasis String
130
137
  justification String
@@ -135,33 +142,38 @@ model LawfulBasisRecord {
135
142
  reviewDate DateTime?
136
143
  createdAt DateTime @default(now())
137
144
  updatedAt DateTime @updatedAt
145
+ removedAt DateTime?
138
146
 
139
- @@index([lawfulBasis])
140
- @@index([assessedBy])
147
+ @@index([tenantId, removedAt, lawfulBasis])
141
148
  @@map("ndpr_lawful_basis_records")
142
149
  }
143
150
 
144
151
  model CrossBorderTransferRecord {
145
- id String @id @default(cuid())
152
+ id String @id @default(cuid())
153
+ tenantId String
146
154
  destinationCountry String
147
155
  recipientName String
148
156
  transferMechanism String
149
157
  safeguards String
150
158
  dataCategories Json
151
159
  adequacyStatus String
152
- ndpcApprovalRequired Boolean @default(false)
160
+ ndpcApprovalRequired Boolean @default(false)
153
161
  ndpcApprovalReference String?
154
162
  riskLevel String
155
- createdAt DateTime @default(now())
156
- updatedAt DateTime @updatedAt
157
-
158
- @@index([destinationCountry])
159
- @@index([riskLevel])
163
+ status String @default("active")
164
+ createdAt DateTime @default(now())
165
+ updatedAt DateTime @updatedAt
166
+ removedAt DateTime?
167
+
168
+ @@index([tenantId, removedAt, destinationCountry])
169
+ @@index([tenantId, removedAt, riskLevel])
170
+ @@index([tenantId, removedAt, status])
160
171
  @@map("ndpr_cross_border_transfer_records")
161
172
  }
162
173
 
163
174
  model ComplianceAuditLog {
164
175
  id String @id @default(cuid())
176
+ tenantId String
165
177
  module String
166
178
  action String
167
179
  entityId String
@@ -170,6 +182,6 @@ model ComplianceAuditLog {
170
182
  performedBy String?
171
183
  createdAt DateTime @default(now())
172
184
 
173
- @@index([module, entityId])
185
+ @@index([tenantId, module, entityId])
174
186
  @@map("ndpr_audit_log")
175
187
  }