dpdp-erasure-cli 1.0.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.
Files changed (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,300 @@
1
+ import type { Sql } from "@/types";
2
+ import { getLogger, logError } from "@/utils";
3
+ import { CODE, fail } from "@/errors";
4
+ import { base64ToBytes, sha256HexDigest } from "@/lib";
5
+ import { decryptGCMBytes, unwrapKey } from "@modules/crypto";
6
+ import type { MockMailer } from "./types";
7
+ import { getVaultRecordByUserId } from "../vault";
8
+ import type { DispatchNoticeOptions, DispatchNoticeResult, WorkerSecrets } from "../types";
9
+ import { assertWorkerSecrets, enqueueOutboxEvent, resolveSchemas } from "../helpers";
10
+ import {
11
+ buildNoticeDryRunPlan,
12
+ buildNotificationIdempotencyKey,
13
+ resolveNoticeColumns,
14
+ resolveNotificationLeaseSeconds
15
+ } from "./config";
16
+ import { clearNoticeLease, reserveNotice } from "./reservation";
17
+ import { extractNoticeRecipient } from "./payload";
18
+
19
+ const logger = getLogger({ component: "notifier" });
20
+ const NOTICE_TEMPLATE_VERSION = "dpdp-pre-erasure-v1";
21
+ const NOTICE_TEMPLATE_CANONICAL =
22
+ "subject:Notice of Permanent Data Erasure\nbody:Dear {{full_name}},\\n\\nYour data will be permanently anonymized in 48 hours in compliance with the DPDP Act.";
23
+
24
+ /**
25
+ * Dispatches the pre-erasure notice for a vaulted subject.
26
+ *
27
+ * Execution model:
28
+ * - Reserves a short-lived notification lease on the vault row.
29
+ * - Decrypts vaulted PII in memory only.
30
+ * - Sends one deterministic idempotent email.
31
+ * - Emits `NOTIFICATION_SENT` to outbox only after successful mail delivery.
32
+ *
33
+ * @param sql - Postgres pool used for lease and state transitions.
34
+ * @param subjectId - Root identifier.
35
+ * @param secrets - Worker cryptographic keys used for DEK unwrap/decrypt.
36
+ * @param mailer - Injected mail transport.
37
+ * @param options - Schema and runtime overrides.
38
+ * @returns Notice dispatch result with lifecycle timestamps and outbox classification.
39
+ * @throws {WorkerError} When vault state is invalid, lease is lost, or crypto checks fail.
40
+ */
41
+ export async function dispatchPreErasureNotice(
42
+ sql: Sql,
43
+ subjectId: string | number,
44
+ secrets: WorkerSecrets,
45
+ mailer: MockMailer,
46
+ options: DispatchNoticeOptions = {}
47
+ ): Promise<DispatchNoticeResult> {
48
+ if (
49
+ (typeof subjectId !== "string" && typeof subjectId !== "number") ||
50
+ String(subjectId).trim().length === 0
51
+ ) {
52
+ fail({
53
+ code: `NOTIFIER_${CODE.USER_ID_INVALID}`
54
+ });
55
+ }
56
+
57
+ const normalizedSubjectId = String(subjectId);
58
+ const { appSchema, engineSchema } = resolveSchemas(options);
59
+ const rootTable = options.rootTable ?? "users";
60
+ const { kek } = assertWorkerSecrets(secrets);
61
+ const now = options.now ? new Date(options.now) : new Date();
62
+ const leaseSeconds = resolveNotificationLeaseSeconds(options.notificationLeaseSeconds);
63
+ const noticeColumns = resolveNoticeColumns(options);
64
+
65
+ const vault = await getVaultRecordByUserId(
66
+ sql,
67
+ engineSchema,
68
+ appSchema,
69
+ normalizedSubjectId,
70
+ rootTable
71
+ );
72
+ if (!vault) {
73
+ fail({
74
+ code: CODE.VAULT_NOT_FOUND,
75
+ data: { appSchema, rootTable, normalizedSubjectId }
76
+ });
77
+ }
78
+
79
+ if (options.dryRun) {
80
+ return {
81
+ action: "dry_run",
82
+ userHash: vault.user_uuid_hash,
83
+ dryRun: true,
84
+ retentionExpiry: vault.retention_expiry.toISOString(),
85
+ notificationDueAt: vault.notification_due_at.toISOString(),
86
+ notificationSentAt: vault.notification_sent_at
87
+ ? vault.notification_sent_at.toISOString()
88
+ : null,
89
+ outboxEventType: "NOTIFICATION_SENT",
90
+ plan: buildNoticeDryRunPlan(
91
+ appSchema,
92
+ engineSchema,
93
+ rootTable,
94
+ normalizedSubjectId,
95
+ vault.user_uuid_hash,
96
+ new Date(vault.notification_due_at),
97
+ new Date(vault.retention_expiry)
98
+ ),
99
+ };
100
+ }
101
+
102
+ let encryptedDek: Uint8Array | null = null;
103
+ let dek: Uint8Array | null = null;
104
+ let encryptedPayload: Uint8Array | null = null;
105
+ let decryptedPiiBytes: Uint8Array | null = null;
106
+ let lockId: string | null = null;
107
+
108
+ try {
109
+ const reservation = await reserveNotice(
110
+ sql,
111
+ engineSchema,
112
+ appSchema,
113
+ rootTable,
114
+ normalizedSubjectId,
115
+ now,
116
+ leaseSeconds
117
+ );
118
+ if (reservation.action === "already_sent") {
119
+ return {
120
+ action: "already_sent",
121
+ userHash: reservation.vault.user_uuid_hash,
122
+ dryRun: false,
123
+ retentionExpiry: reservation.vault.retention_expiry.toISOString(),
124
+ notificationDueAt: reservation.vault.notification_due_at.toISOString(),
125
+ notificationSentAt:
126
+ reservation.vault.notification_sent_at?.toISOString() ?? null,
127
+ outboxEventType: null,
128
+ };
129
+ }
130
+
131
+ if (reservation.action === "not_due") {
132
+ return {
133
+ action: "not_due",
134
+ userHash: reservation.vault.user_uuid_hash,
135
+ dryRun: false,
136
+ retentionExpiry: reservation.vault.retention_expiry.toISOString(),
137
+ notificationDueAt: new Date(
138
+ reservation.vault.notification_due_at
139
+ ).toISOString(),
140
+ notificationSentAt: null,
141
+ outboxEventType: null,
142
+ };
143
+ }
144
+
145
+ lockId = reservation.lockId!;
146
+ encryptedDek = reservation.encryptedDek!;
147
+ dek = await unwrapKey(encryptedDek, kek);
148
+
149
+ const payload = reservation.vault.encrypted_pii;
150
+ if (payload.destroyed || !payload.data) {
151
+ fail({
152
+ code: "NOTIFICATION_PAYLOAD_DESTROYED",
153
+ title: "Vault payload is no longer decryptable",
154
+ detail: `Vault payload for root row ${normalizedSubjectId} no longer contains decryptable PII.`,
155
+ category: "integrity",
156
+ retryable: false,
157
+ });
158
+ }
159
+
160
+ encryptedPayload = base64ToBytes(payload.data);
161
+ decryptedPiiBytes = await decryptGCMBytes(encryptedPayload, dek);
162
+ const { email, fullName } = extractNoticeRecipient(
163
+ decryptedPiiBytes,
164
+ noticeColumns,
165
+ normalizedSubjectId
166
+ );
167
+
168
+ const message = {
169
+ to: email,
170
+ subject: "Notice of Permanent Data Erasure",
171
+ body: `Dear ${fullName},\n\nYour data will be permanently anonymized in 48 hours in compliance with the DPDP Act.`,
172
+ idempotencyKey: buildNotificationIdempotencyKey(reservation.vault),
173
+ };
174
+ const templateHash = await sha256HexDigest(NOTICE_TEMPLATE_CANONICAL);
175
+ const deliveryReceipt = await mailer.sendEmail(message);
176
+
177
+ await sql.begin("isolation level repeatable read", async (tx) => {
178
+ await tx.unsafe("SET LOCAL lock_timeout = '5s'");
179
+
180
+ const updated = await tx`
181
+ UPDATE ${tx(engineSchema)}.pii_vault
182
+ SET notification_sent_at = ${now},
183
+ notification_lock_id = NULL,
184
+ notification_lock_expires_at = NULL,
185
+ updated_at = ${now}
186
+ WHERE user_uuid_hash = ${reservation.vault.user_uuid_hash}
187
+ AND notification_lock_id = ${lockId}
188
+ AND notification_sent_at IS NULL
189
+ RETURNING user_uuid_hash
190
+ `;
191
+
192
+ if (updated.length === 0) {
193
+ fail({
194
+ code: "NOTIFICATION_LEASE_LOST",
195
+ title: "Notification lease lost",
196
+ detail: `Notification lease for root row ${normalizedSubjectId} was lost before completion.`,
197
+ category: "concurrency",
198
+ retryable: true,
199
+ });
200
+ }
201
+
202
+ await tx`
203
+ INSERT INTO ${tx(engineSchema)}.notification_receipts (
204
+ user_uuid_hash,
205
+ request_id,
206
+ idempotency_key,
207
+ provider,
208
+ provider_message_id,
209
+ template_version,
210
+ template_hash,
211
+ sent_at,
212
+ metadata,
213
+ created_at
214
+ ) VALUES (
215
+ ${reservation.vault.user_uuid_hash},
216
+ ${reservation.vault.request_id},
217
+ ${message.idempotencyKey},
218
+ ${deliveryReceipt?.provider ?? "custom"},
219
+ ${deliveryReceipt?.providerMessageId ?? null},
220
+ ${NOTICE_TEMPLATE_VERSION},
221
+ ${templateHash},
222
+ ${now},
223
+ ${tx.json((deliveryReceipt?.metadata ?? {}) as import("postgres").JSONValue)},
224
+ ${now}
225
+ )
226
+ ON CONFLICT (idempotency_key) DO NOTHING
227
+ `;
228
+
229
+ await enqueueOutboxEvent(
230
+ tx,
231
+ engineSchema,
232
+ reservation.vault.user_uuid_hash,
233
+ "NOTIFICATION_SENT",
234
+ {
235
+ request_id: reservation.vault.request_id,
236
+ subject_opaque_id: reservation.vault.root_id,
237
+ tenant_id: reservation.vault.tenant_id || null,
238
+ trigger_source: reservation.vault.trigger_source,
239
+ legal_framework: reservation.vault.legal_framework,
240
+ actor_opaque_id: reservation.vault.actor_opaque_id,
241
+ applied_rule_name: reservation.vault.applied_rule_name,
242
+ applied_rule_citation: reservation.vault.applied_rule_citation,
243
+ event_timestamp: now.toISOString(),
244
+ root_schema: appSchema,
245
+ root_table: rootTable,
246
+ root_id: normalizedSubjectId,
247
+ sent_at: now.toISOString(),
248
+ },
249
+ reservation.vault.request_id
250
+ ? `notice:${reservation.vault.request_id}`
251
+ : `notice:${appSchema}:${rootTable}:${normalizedSubjectId}`,
252
+ now
253
+ );
254
+ });
255
+
256
+ logger.info(
257
+ {
258
+ userHash: reservation.vault.user_uuid_hash,
259
+ rootTable,
260
+ rootId: normalizedSubjectId,
261
+ },
262
+ "Pre-erasure notice sent"
263
+ );
264
+
265
+ return {
266
+ action: "sent",
267
+ userHash: reservation.vault.user_uuid_hash,
268
+ dryRun: false,
269
+ retentionExpiry: reservation.vault.retention_expiry.toISOString(),
270
+ notificationDueAt: reservation.vault.notification_due_at.toISOString(),
271
+ notificationSentAt: now.toISOString(),
272
+ outboxEventType: "NOTIFICATION_SENT",
273
+ };
274
+ } catch (error) {
275
+ if (lockId && vault.user_uuid_hash) {
276
+ try {
277
+ await clearNoticeLease(sql, engineSchema, vault.user_uuid_hash, lockId, now);
278
+ } catch (leaseError) {
279
+ logError(
280
+ logger,
281
+ leaseError,
282
+ "Failed to clear notification lease after notifier error",
283
+ {
284
+ userHash: vault.user_uuid_hash,
285
+ rootTable,
286
+ rootId: normalizedSubjectId,
287
+ }
288
+ );
289
+ }
290
+ }
291
+
292
+ throw error;
293
+ } finally {
294
+ encryptedDek?.fill(0);
295
+ dek?.fill(0);
296
+ encryptedPayload?.fill(0);
297
+ decryptedPiiBytes?.fill(0);
298
+ }
299
+ }
300
+
@@ -0,0 +1,3 @@
1
+ export * from "./config";
2
+ export * from "./dispatcher";
3
+ export * from "./types";
@@ -0,0 +1,51 @@
1
+ import { fail } from "@/errors";
2
+ import type { NoticeColumns } from "./config";
3
+
4
+ const textDecoder = new TextDecoder();
5
+
6
+ /**
7
+ * Extracts the recipient email and display name from decrypted vault JSON bytes.
8
+ *
9
+ * @param decryptedPiiBytes - Decrypted JSON payload bytes.
10
+ * @param noticeColumns - Configured email/name column mapping.
11
+ * @param subjectId - Subject identifier used in error details.
12
+ * @returns Normalized email and display name.
13
+ * @throws {WorkerError} When the configured email column is absent or empty.
14
+ */
15
+ export function extractNoticeRecipient(
16
+ decryptedPiiBytes: Uint8Array,
17
+ noticeColumns: NoticeColumns,
18
+ subjectId: string
19
+ ): { email: string; fullName: string } {
20
+ const parsed = JSON.parse(textDecoder.decode(decryptedPiiBytes)) as Record<
21
+ string,
22
+ unknown
23
+ >;
24
+ const emailCandidate = parsed[noticeColumns.emailColumn];
25
+ const email =
26
+ typeof emailCandidate === "string" && emailCandidate.trim().length > 0
27
+ ? emailCandidate.trim()
28
+ : null;
29
+ if (!email) {
30
+ fail({
31
+ code: "NOTIFICATION_EMAIL_MISSING",
32
+ title: "Notification email address missing",
33
+ detail: `Vault payload for root row ${subjectId} does not contain ${noticeColumns.emailColumn}.`,
34
+ category: "integrity",
35
+ retryable: false,
36
+ });
37
+ }
38
+
39
+ const nameCandidate = noticeColumns.nameColumn
40
+ ? parsed[noticeColumns.nameColumn]
41
+ : undefined;
42
+ const fullName =
43
+ typeof nameCandidate === "string" && nameCandidate.trim().length > 0
44
+ ? nameCandidate.trim()
45
+ : "User";
46
+
47
+ return {
48
+ email,
49
+ fullName,
50
+ };
51
+ }
@@ -0,0 +1,153 @@
1
+ import type { Sql } from "@/types";
2
+ import { CODE, fail } from "@/errors";
3
+ import type { NoticeReservation } from "./types";
4
+ import type { VaultRecord } from "../helpers";
5
+
6
+ /**
7
+ * Reserves a notification lease on a vault row and loads the wrapped DEK needed for decryption.
8
+ *
9
+ * @param sql - Postgres pool used for reservation.
10
+ * @param engineSchema - Worker engine schema.
11
+ * @param appSchema - Source application schema.
12
+ * @param rootTable - Root table name.
13
+ * @param subjectId - Root identifier.
14
+ * @param now - Reservation timestamp.
15
+ * @param leaseSeconds - Lease duration in seconds.
16
+ * @returns Reservation outcome plus wrapped DEK when the notice should be sent.
17
+ * @throws {WorkerError} When the vault is missing, already shredded, outside the notice window,
18
+ * or leased elsewhere.
19
+ */
20
+ export async function reserveNotice(
21
+ sql: Sql,
22
+ engineSchema: string,
23
+ appSchema: string,
24
+ rootTable: string,
25
+ subjectId: string | number,
26
+ now: Date,
27
+ leaseSeconds: number
28
+ ): Promise<NoticeReservation> {
29
+ const normalizedSubjectId = String(subjectId);
30
+
31
+ return sql.begin("isolation level repeatable read", async (tx) => {
32
+ await tx.unsafe("SET LOCAL lock_timeout = '5s'");
33
+
34
+ const [vault] = await tx<VaultRecord[]>`
35
+ SELECT *
36
+ FROM ${tx(engineSchema)}.pii_vault
37
+ WHERE root_schema = ${appSchema}
38
+ AND root_table = ${rootTable}
39
+ AND root_id = ${normalizedSubjectId}
40
+ FOR UPDATE
41
+ `;
42
+
43
+ if (!vault) {
44
+ fail({
45
+ code: CODE.VAULT_NOT_FOUND,
46
+ data: { appSchema, rootTable, normalizedSubjectId }
47
+ });
48
+ }
49
+
50
+ if (vault.shredded_at) {
51
+ fail({
52
+ code: "NOTIFICATION_SHREDDED",
53
+ title: "Notification cannot be sent after shredding",
54
+ detail: `Cannot dispatch notice for root row ${normalizedSubjectId}: the vault has already been shredded.`,
55
+ category: "validation",
56
+ retryable: false,
57
+ });
58
+ }
59
+
60
+ if (vault.notification_sent_at) {
61
+ return { action: "already_sent", vault };
62
+ }
63
+
64
+ if (now < new Date(vault.notification_due_at)) {
65
+ return { action: "not_due", vault };
66
+ }
67
+
68
+ if (now >= new Date(vault.retention_expiry)) {
69
+ fail({
70
+ code: "NOTIFICATION_WINDOW_MISSED",
71
+ title: "Notification window has closed",
72
+ detail: `Cannot dispatch notice for root row ${normalizedSubjectId}: the retention deadline has already expired.`,
73
+ category: "validation",
74
+ retryable: false,
75
+ });
76
+ }
77
+
78
+ if (
79
+ vault.notification_lock_expires_at &&
80
+ new Date(vault.notification_lock_expires_at) > now
81
+ ) {
82
+ fail({
83
+ code: "NOTIFICATION_ALREADY_LEASED",
84
+ title: "Notification is already leased",
85
+ detail: `Notification for root row ${normalizedSubjectId} is already leased by another worker.`,
86
+ category: "concurrency",
87
+ retryable: true,
88
+ });
89
+ }
90
+
91
+ const lockId = globalThis.crypto.randomUUID();
92
+ const lockExpiry = new Date(now.getTime() + leaseSeconds * 1000);
93
+
94
+ await tx`
95
+ UPDATE ${tx(engineSchema)}.pii_vault
96
+ SET notification_lock_id = ${lockId},
97
+ notification_lock_expires_at = ${lockExpiry},
98
+ updated_at = ${now}
99
+ WHERE user_uuid_hash = ${vault.user_uuid_hash}
100
+ `;
101
+
102
+ const [keyRow] = await tx<{ encrypted_dek: Uint8Array }[]>`
103
+ SELECT encrypted_dek
104
+ FROM ${tx(engineSchema)}.user_keys
105
+ WHERE user_uuid_hash = ${vault.user_uuid_hash}
106
+ FOR UPDATE
107
+ `;
108
+
109
+ if (!keyRow) {
110
+ fail({
111
+ code: "KEY_RING_NOT_FOUND",
112
+ title: "Key ring record not found",
113
+ detail: `Key ring record not found for user hash ${vault.user_uuid_hash}.`,
114
+ category: "integrity",
115
+ retryable: false,
116
+ fatal: true,
117
+ });
118
+ }
119
+
120
+ return {
121
+ action: "send",
122
+ vault,
123
+ encryptedDek: new Uint8Array(keyRow.encrypted_dek),
124
+ lockId,
125
+ };
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Clears a notification lease after a failed notice attempt.
131
+ *
132
+ * @param sql - Postgres pool.
133
+ * @param engineSchema - Worker engine schema.
134
+ * @param userHash - Subject hash key.
135
+ * @param lockId - Lease identifier to release.
136
+ * @param now - Update timestamp.
137
+ */
138
+ export async function clearNoticeLease(
139
+ sql: Sql,
140
+ engineSchema: string,
141
+ userHash: string,
142
+ lockId: string,
143
+ now: Date
144
+ ): Promise<void> {
145
+ await sql`
146
+ UPDATE ${sql(engineSchema)}.pii_vault
147
+ SET notification_lock_id = NULL,
148
+ notification_lock_expires_at = NULL,
149
+ updated_at = ${now}
150
+ WHERE user_uuid_hash = ${userHash}
151
+ AND notification_lock_id = ${lockId}
152
+ `;
153
+ }
@@ -0,0 +1,38 @@
1
+ import type { VaultRecord } from "../helpers";
2
+
3
+
4
+ /**
5
+ * Normalized mail message emitted by the pre-erasure notifier.
6
+ */
7
+ export interface MailMessage {
8
+ to: string;
9
+ subject: string;
10
+ body: string;
11
+ idempotencyKey: string;
12
+ }
13
+
14
+ export interface MailDeliveryReceipt {
15
+ provider?: string;
16
+ providerMessageId?: string;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+
20
+ /**
21
+ * Mail transport abstraction used by the worker.
22
+ *
23
+ * Implementations must honor `idempotencyKey` to prevent duplicate notices during retries.
24
+ */
25
+ export interface MockMailer {
26
+ sendEmail(message: MailMessage): Promise<MailDeliveryReceipt | void>;
27
+ }
28
+
29
+ /**
30
+ * Result of reserving a pre-erasure notice slot on a vault row.
31
+ */
32
+ export interface NoticeReservation {
33
+ action: "send" | "already_sent" | "not_due";
34
+ vault: VaultRecord;
35
+ encryptedDek?: Uint8Array;
36
+ lockId?: string;
37
+ }
38
+