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.
- package/.env.example +55 -0
- package/Dockerfile +33 -0
- package/compliance.worker.yaml +64 -0
- package/package.json +41 -0
- package/src/constants/index.ts +1 -0
- package/src/errors/fail.ts +110 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/inferer.ts +166 -0
- package/src/errors/registry.ts +122 -0
- package/src/errors/types.ts +65 -0
- package/src/errors/worker.ts +161 -0
- package/src/index.ts +328 -0
- package/src/lib/crypto/digest.ts +22 -0
- package/src/lib/crypto/encoding.ts +78 -0
- package/src/lib/crypto/index.ts +2 -0
- package/src/lib/index.ts +1 -0
- package/src/modules/bootstrap/index.ts +2 -0
- package/src/modules/bootstrap/integrity.ts +38 -0
- package/src/modules/bootstrap/preflight.ts +296 -0
- package/src/modules/cli/check-integrity.ts +48 -0
- package/src/modules/cli/dry-run.ts +90 -0
- package/src/modules/cli/graph.ts +87 -0
- package/src/modules/cli/index.ts +184 -0
- package/src/modules/cli/init.ts +115 -0
- package/src/modules/cli/inspect.ts +86 -0
- package/src/modules/cli/introspector.ts +117 -0
- package/src/modules/cli/keygen.ts +38 -0
- package/src/modules/cli/scan.ts +126 -0
- package/src/modules/cli/sign.ts +50 -0
- package/src/modules/cli/ui.ts +61 -0
- package/src/modules/cli/verify-schema.ts +31 -0
- package/src/modules/cli/verify.ts +85 -0
- package/src/modules/config/compatibility.ts +271 -0
- package/src/modules/config/index.ts +4 -0
- package/src/modules/config/reader.ts +149 -0
- package/src/modules/config/signature.ts +69 -0
- package/src/modules/config/validation.ts +658 -0
- package/src/modules/crypto/aes.ts +158 -0
- package/src/modules/crypto/envelope.ts +48 -0
- package/src/modules/crypto/hmac.ts +60 -0
- package/src/modules/crypto/index.ts +3 -0
- package/src/modules/db/drift.ts +36 -0
- package/src/modules/db/graph.ts +203 -0
- package/src/modules/db/index.ts +4 -0
- package/src/modules/db/migrations.ts +254 -0
- package/src/modules/db/sql-debug.ts +61 -0
- package/src/modules/engine/blob/index.ts +3 -0
- package/src/modules/engine/blob/s3.ts +455 -0
- package/src/modules/engine/blob/store.ts +236 -0
- package/src/modules/engine/blob/types.ts +44 -0
- package/src/modules/engine/helpers/identity.ts +47 -0
- package/src/modules/engine/helpers/index.ts +4 -0
- package/src/modules/engine/helpers/outbox.ts +118 -0
- package/src/modules/engine/helpers/runtime.ts +115 -0
- package/src/modules/engine/helpers/types.ts +61 -0
- package/src/modules/engine/index.ts +6 -0
- package/src/modules/engine/notifier/config.ts +147 -0
- package/src/modules/engine/notifier/dispatcher.ts +300 -0
- package/src/modules/engine/notifier/index.ts +3 -0
- package/src/modules/engine/notifier/payload.ts +51 -0
- package/src/modules/engine/notifier/reservation.ts +153 -0
- package/src/modules/engine/notifier/types.ts +38 -0
- package/src/modules/engine/shredder.ts +254 -0
- package/src/modules/engine/types.ts +146 -0
- package/src/modules/engine/vault/compiled-targets.ts +562 -0
- package/src/modules/engine/vault/context.ts +254 -0
- package/src/modules/engine/vault/dry-run.ts +94 -0
- package/src/modules/engine/vault/execution.ts +485 -0
- package/src/modules/engine/vault/index.ts +3 -0
- package/src/modules/engine/vault/purge.ts +82 -0
- package/src/modules/engine/vault/retention.ts +124 -0
- package/src/modules/engine/vault/satellite-mutation.ts +193 -0
- package/src/modules/engine/vault/satellite.ts +103 -0
- package/src/modules/engine/vault/shadow.ts +36 -0
- package/src/modules/engine/vault/static-plan.ts +116 -0
- package/src/modules/engine/vault/store.ts +34 -0
- package/src/modules/engine/vault/vault.ts +84 -0
- package/src/modules/introspector/classifier.ts +502 -0
- package/src/modules/introspector/dag.ts +276 -0
- package/src/modules/introspector/index.ts +7 -0
- package/src/modules/introspector/naming.ts +75 -0
- package/src/modules/introspector/report.ts +153 -0
- package/src/modules/introspector/run.ts +123 -0
- package/src/modules/introspector/s3-sampler.ts +227 -0
- package/src/modules/introspector/types.ts +131 -0
- package/src/modules/introspector/yaml.ts +101 -0
- package/src/modules/network/api/control-plane.ts +275 -0
- package/src/modules/network/api/index.ts +1 -0
- package/src/modules/network/api/validation.ts +71 -0
- package/src/modules/network/index.ts +4 -0
- package/src/modules/network/object-store/aws/client.ts +444 -0
- package/src/modules/network/object-store/aws/credentials.ts +271 -0
- package/src/modules/network/object-store/aws/index.ts +2 -0
- package/src/modules/network/object-store/aws/sigv4.ts +190 -0
- package/src/modules/network/object-store/aws/type.ts +6 -0
- package/src/modules/network/object-store/index.ts +1 -0
- package/src/modules/network/outbox/dispatcher.ts +183 -0
- package/src/modules/network/outbox/index.ts +3 -0
- package/src/modules/network/outbox/process.ts +133 -0
- package/src/modules/network/outbox/shared.ts +56 -0
- package/src/modules/network/outbox/store.ts +346 -0
- package/src/modules/network/outbox/types.ts +54 -0
- package/src/modules/network/request-signing.ts +61 -0
- package/src/modules/worker/index.ts +2 -0
- package/src/modules/worker/tasks.ts +58 -0
- package/src/modules/worker/types.ts +89 -0
- package/src/modules/worker/worker.ts +243 -0
- package/src/secrets/index.ts +4 -0
- package/src/secrets/kms/index.ts +2 -0
- package/src/secrets/kms/signature.ts +82 -0
- package/src/secrets/kms/validation.ts +64 -0
- package/src/secrets/reader.ts +42 -0
- package/src/secrets/repository/crypto.ts +89 -0
- package/src/secrets/repository/index.ts +2 -0
- package/src/secrets/repository/methods.ts +37 -0
- package/src/secrets/resolvers.ts +247 -0
- package/src/secrets/signature.ts +78 -0
- package/src/types/index.ts +1 -0
- package/src/types/types.ts +23 -0
- package/src/utils/identifiers.ts +48 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/json.ts +35 -0
- package/src/utils/logger.ts +161 -0
- package/src/validation/zod.ts +70 -0
- package/tests/adversarial.test.ts +464 -0
- package/tests/blob-s3.test.ts +216 -0
- package/tests/config.test.ts +395 -0
- package/tests/control-plane-client.test.ts +108 -0
- package/tests/crypto.test.ts +106 -0
- package/tests/errors.test.ts +69 -0
- package/tests/fetch-dispatcher.test.ts +213 -0
- package/tests/graph.test.ts +84 -0
- package/tests/helpers/index.ts +101 -0
- package/tests/index-preflight.test.ts +168 -0
- package/tests/introspector-classifier.test.ts +62 -0
- package/tests/introspector-report.test.ts +85 -0
- package/tests/introspector.test.ts +394 -0
- package/tests/kms.test.ts +124 -0
- package/tests/logger.test.ts +61 -0
- package/tests/notifier.test.ts +303 -0
- package/tests/outbox.test.ts +478 -0
- package/tests/purge-policy.test.ts +124 -0
- package/tests/retention.test.ts +103 -0
- package/tests/s3-client.test.ts +110 -0
- package/tests/satellite.test.ts +119 -0
- package/tests/schema-compatibility.test.ts +237 -0
- package/tests/schema-integrity.test.ts +64 -0
- package/tests/shredder.test.ts +163 -0
- package/tests/vault.compiled-targets.test.ts +243 -0
- package/tests/vault.replica.test.ts +59 -0
- package/tests/vault.test.ts +279 -0
- package/tests/worker.retry.test.ts +291 -0
- package/tests/worker.test.ts +200 -0
- package/tsconfig.json +19 -0
- 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,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
|
+
|