@agentcomms/events-daemon 0.0.0-stage → 0.17.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,1143 @@
1
+ import { t as canonicalSecretWebhookUrl } from "./webhook-target-DyDTwWhV.mjs";
2
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
3
+ import "node:fs/promises";
4
+ import { FileSecretStore, KeychainSecretStore, canonicalJson, keychainNamespace, loadKeyringModule, sha256Hex, withFileLock } from "@agentcomms/core";
5
+ import { join } from "node:path";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ //#region src/store/aad.ts
8
+ const AAD_PREFIX = Buffer.from("aec-v1", "ascii");
9
+ const PRIMARY_KEY_COMPONENT_COUNTS = {
10
+ "source_scan_state.encryptedRecord": 1,
11
+ "rule_activation_points.encryptedPosition": 5,
12
+ "activation_baselines.encryptedPosition": 4,
13
+ "ingest_rules.encryptedProjection": 3,
14
+ "decisions.encryptedRecord": 1,
15
+ "deliveries.encryptedRecord": 1,
16
+ "dryrun_log.encryptedRecord": 1,
17
+ "stream_log.encryptedRecord": 1,
18
+ "event_secret_generations.encryptedReference": 5,
19
+ "system_reset_outbox.encryptedRecord": 1
20
+ };
21
+ var AadError = class extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "AadError";
25
+ }
26
+ };
27
+ function lengthPrefixed(value) {
28
+ if (value.byteLength > 4294967295) throw new AadError("AAD value is too large to encode");
29
+ const length = Buffer.allocUnsafe(4);
30
+ length.writeUInt32BE(value.byteLength);
31
+ return Buffer.concat([length, value]);
32
+ }
33
+ function integerBytes(value) {
34
+ const integer = typeof value === "bigint" ? value : BigInt(value);
35
+ if (typeof value === "number" && (!Number.isSafeInteger(value) || !Number.isInteger(value))) throw new AadError("AAD INTEGER must be a safe integer or bigint");
36
+ if (integer < -(1n << 63n) || integer > (1n << 63n) - 1n) throw new AadError("AAD INTEGER is outside SQLite’s signed 64-bit range");
37
+ const encoded = Buffer.allocUnsafe(8);
38
+ encoded.writeBigInt64BE(integer);
39
+ return encoded;
40
+ }
41
+ function componentBytes(component) {
42
+ switch (component.type) {
43
+ case "integer": return Buffer.concat([Buffer.from([1]), integerBytes(component.value)]);
44
+ case "text": return Buffer.concat([Buffer.from([2]), lengthPrefixed(Buffer.from(component.value, "utf8"))]);
45
+ case "blob": return Buffer.concat([Buffer.from([3]), lengthPrefixed(Buffer.from(component.value))]);
46
+ }
47
+ }
48
+ /**
49
+ * Encodes the one AAD form every encrypted event record uses.
50
+ *
51
+ * Components must arrive in the primary-key order declared by the migration. The table/column map intentionally
52
+ * lives here as well as in the schema, so a caller cannot silently encrypt an unsupported column or omit one part
53
+ * of its row identity.
54
+ */
55
+ function encodeAad(table, column, components) {
56
+ const id = `${table}.${column}`;
57
+ const expected = PRIMARY_KEY_COMPONENT_COUNTS[id];
58
+ if (expected === void 0) throw new AadError(`no encrypted event column is named ${id}`);
59
+ if (components.length !== expected) throw new AadError(`${id} needs ${expected} primary-key components, got ${components.length}`);
60
+ return Buffer.concat([
61
+ AAD_PREFIX,
62
+ lengthPrefixed(Buffer.from(table, "utf8")),
63
+ lengthPrefixed(Buffer.from(column, "utf8")),
64
+ Buffer.from([components.length]),
65
+ ...components.map(componentBytes)
66
+ ]);
67
+ }
68
+ Object.freeze({ ...PRIMARY_KEY_COMPONENT_COUNTS });
69
+ //#endregion
70
+ //#region src/store/crypto.ts
71
+ const FORMAT_VERSION = 1;
72
+ const NONCE_BYTES = 12;
73
+ const TAG_BYTES = 16;
74
+ var PackedRecordError = class extends Error {
75
+ code;
76
+ constructor(code, message) {
77
+ super(message);
78
+ this.name = "PackedRecordError";
79
+ this.code = code;
80
+ }
81
+ };
82
+ function checkedKey(key) {
83
+ if (key.byteLength !== 32) throw new PackedRecordError("PACKED_RECORD_INVALID", "AES-256-GCM needs a 32-byte key");
84
+ return Buffer.from(key);
85
+ }
86
+ function checkedKeyId(keyId) {
87
+ const encoded = Buffer.from(keyId, "ascii");
88
+ if (keyId.length === 0 || keyId.length > 255 || encoded.toString("ascii") !== keyId) throw new PackedRecordError("PACKED_RECORD_INVALID", "packed record key identifiers are non-empty ASCII of at most 255 bytes");
89
+ return encoded;
90
+ }
91
+ function unpack(record) {
92
+ const bytes = Buffer.from(record);
93
+ if (bytes.byteLength < 31 || bytes[0] !== FORMAT_VERSION) throw new PackedRecordError("PACKED_RECORD_INVALID", "encrypted record is not packed record format v1");
94
+ const keyIdLength = bytes[1] ?? 0;
95
+ const keyStart = 2;
96
+ const nonceStart = keyStart + keyIdLength;
97
+ const ciphertextStart = nonceStart + NONCE_BYTES;
98
+ const tagStart = bytes.byteLength - TAG_BYTES;
99
+ if (keyIdLength === 0 || ciphertextStart > tagStart) throw new PackedRecordError("PACKED_RECORD_INVALID", "encrypted record has invalid packed boundaries");
100
+ const keyIdBytes = bytes.subarray(keyStart, nonceStart);
101
+ const keyId = keyIdBytes.toString("ascii");
102
+ if (keyIdBytes.toString("ascii") !== keyId || !/^[\x21-\x7e]+$/.test(keyId)) throw new PackedRecordError("PACKED_RECORD_INVALID", "encrypted record has a non-ASCII key identifier");
103
+ return {
104
+ keyId,
105
+ nonce: bytes.subarray(nonceStart, ciphertextStart),
106
+ ciphertext: bytes.subarray(ciphertextStart, tagStart),
107
+ tag: bytes.subarray(tagStart)
108
+ };
109
+ }
110
+ /** Packs AES-256-GCM ciphertext exactly as D8 record format v1. */
111
+ function encryptPackedRecord(input) {
112
+ const keyId = checkedKeyId(input.keyId);
113
+ const key = checkedKey(input.key);
114
+ const nonce = input.nonce === void 0 ? randomBytes(NONCE_BYTES) : Buffer.from(input.nonce);
115
+ if (nonce.byteLength !== NONCE_BYTES) throw new PackedRecordError("PACKED_RECORD_INVALID", "packed record nonces must be 96 bits");
116
+ const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_BYTES });
117
+ cipher.setAAD(Buffer.from(input.aad));
118
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(input.plaintext)), cipher.final()]);
119
+ return Buffer.concat([
120
+ Buffer.from([FORMAT_VERSION, keyId.byteLength]),
121
+ keyId,
122
+ nonce,
123
+ ciphertext,
124
+ cipher.getAuthTag()
125
+ ]);
126
+ }
127
+ /** Opens one packed record and refuses a missing key, malformed record, or an authentication mismatch. */
128
+ function decryptPackedRecord(input) {
129
+ const parsed = unpack(input.record);
130
+ const selected = input.keyForId(parsed.keyId);
131
+ if (selected === null) throw new PackedRecordError("UNKNOWN_KEY", "encrypted record names an unavailable master key");
132
+ try {
133
+ const decipher = createDecipheriv("aes-256-gcm", checkedKey(selected), parsed.nonce, { authTagLength: TAG_BYTES });
134
+ decipher.setAAD(Buffer.from(input.aad));
135
+ decipher.setAuthTag(parsed.tag);
136
+ return Buffer.concat([decipher.update(parsed.ciphertext), decipher.final()]);
137
+ } catch (error) {
138
+ if (error instanceof PackedRecordError) throw error;
139
+ throw new PackedRecordError("AUTHENTICATION_FAILED", "encrypted record did not authenticate for its declared row");
140
+ }
141
+ }
142
+ /** Reads only the identifier from a syntactically valid packed record, before its master is looked up asynchronously. */
143
+ function packedRecordKeyId(record) {
144
+ return unpack(record).keyId;
145
+ }
146
+ /** Derives the prescribed table subkey from one installation master without putting the key id in HKDF info. */
147
+ function deriveTableKey(master, table) {
148
+ return Buffer.from(hkdfSync("sha256", checkedKey(master), Buffer.alloc(0), `agentcomms-events/${table}/v1`, 32));
149
+ }
150
+ //#endregion
151
+ //#region src/store/event-secrets.ts
152
+ var EventSecretError = class extends Error {
153
+ code;
154
+ constructor(code, message) {
155
+ super(message);
156
+ this.name = "EventSecretError";
157
+ this.code = code;
158
+ }
159
+ };
160
+ const lockScope = new AsyncLocalStorage();
161
+ /** Shares the one process- and filesystem-wide lock across master, generation and migration work. */
162
+ async function withEventSecretsLock(paths, work) {
163
+ const lockPath = join(paths.root, "secrets.lock");
164
+ if (lockScope.getStore()?.has(lockPath)) return work();
165
+ return withFileLock(lockPath, async () => lockScope.run(/* @__PURE__ */ new Set([...lockScope.getStore() ?? [], lockPath]), work), {
166
+ timeoutMs: 5e3,
167
+ staleMs: 3e4
168
+ });
169
+ }
170
+ function generationLocation(owner, purpose, generation) {
171
+ return {
172
+ table: "event_secret_generations",
173
+ column: "encryptedReference",
174
+ key: [
175
+ {
176
+ type: "text",
177
+ value: owner.kind
178
+ },
179
+ {
180
+ type: "text",
181
+ value: owner.id
182
+ },
183
+ {
184
+ type: "integer",
185
+ value: owner.version
186
+ },
187
+ {
188
+ type: "text",
189
+ value: purpose
190
+ },
191
+ {
192
+ type: "integer",
193
+ value: generation
194
+ }
195
+ ]
196
+ };
197
+ }
198
+ function assertPurpose(owner, purpose) {
199
+ if (!(owner.kind === "target" && (purpose === "secret-url" || purpose === "webhook-signing") || owner.kind === "subscriber" && purpose === "sse-bearer" || owner.kind === "judge" && purpose === "judge-key")) throw new EventSecretError("EVENT_SECRET_REFERENCE", "the secret purpose does not belong to the exact object version");
200
+ }
201
+ function asStoredGeneration(row) {
202
+ const ownerKind = row.owner_kind;
203
+ const purpose = row.purpose;
204
+ const lifecycle = row.lifecycle;
205
+ if (ownerKind !== "target" && ownerKind !== "subscriber" && ownerKind !== "judge" || purpose !== "secret-url" && purpose !== "webhook-signing" && purpose !== "sse-bearer" && purpose !== "judge-key" || lifecycle !== "current" && lifecycle !== "overlap" && lifecycle !== "retired" || typeof row.owner_id !== "string" || typeof row.owner_version !== "number" || typeof row.owner_digest !== "string" || typeof row.generation !== "number" || typeof row.secret_digest !== "string" || !(row.encrypted_ref instanceof Uint8Array)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation ledger has an invalid row");
206
+ return {
207
+ owner: {
208
+ kind: ownerKind,
209
+ id: row.owner_id,
210
+ version: row.owner_version,
211
+ digest: row.owner_digest
212
+ },
213
+ purpose,
214
+ generation: row.generation,
215
+ lifecycle,
216
+ expiresAt: typeof row.expires_at === "number" ? row.expires_at : null,
217
+ secretDigest: row.secret_digest,
218
+ encryptedRef: row.encrypted_ref
219
+ };
220
+ }
221
+ async function referencesFor(database, cipher) {
222
+ const masters = database.prepare("SELECT secret_ref FROM event_secret_masters").all().map((row) => row.secret_ref);
223
+ const rows = database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
224
+ encrypted_ref, lifecycle, expires_at
225
+ FROM event_secret_generations WHERE lifecycle IN ('current', 'overlap')`).all();
226
+ if (rows.length === 0) return [...new Set(masters)].sort();
227
+ if (cipher === void 0) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generations need the record cipher for reference reconciliation");
228
+ const references = [...masters];
229
+ for (const row of rows) {
230
+ const generation = asStoredGeneration(row);
231
+ const value = (await cipher.decrypt(generationLocation(generation.owner, generation.purpose, generation.generation), generation.encryptedRef)).toString("utf8");
232
+ if (!/^event-secret:[a-f0-9]{24}$/.test(value)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an invalid opaque reference");
233
+ references.push(value);
234
+ }
235
+ return [...new Set(references)].sort();
236
+ }
237
+ function selectedKind(database) {
238
+ const row = database.prepare("SELECT value FROM meta WHERE key = 'event_secret_store'").get();
239
+ if (row === void 0) return null;
240
+ if (row.value === "keychain" || row.value === "file") return row.value;
241
+ throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret selector is not a recognised backend");
242
+ }
243
+ function setSelector(database, kind) {
244
+ database.prepare("INSERT INTO meta (key, value) VALUES ('event_secret_store', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(kind);
245
+ }
246
+ /** A synchronous transaction-safe representation of every reference migration is allowed to move. */
247
+ function referenceSnapshot(database) {
248
+ const masters = database.prepare("SELECT key_id, secret_ref, retired_at FROM event_secret_masters ORDER BY key_id").all();
249
+ const generations = database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
250
+ hex(encrypted_ref) AS encrypted_ref, lifecycle, expires_at
251
+ FROM event_secret_generations ORDER BY owner_kind, owner_id, owner_version, purpose, generation`).all();
252
+ return canonicalJson({
253
+ masters,
254
+ generations
255
+ });
256
+ }
257
+ /** The core keychain namespace deliberately gains an events-only suffix. */
258
+ function eventKeychainNamespace(configDir) {
259
+ return `${keychainNamespace(configDir)}:events`;
260
+ }
261
+ async function storeFor(options, kind) {
262
+ const injected = options.stores?.[kind];
263
+ if (injected !== void 0) return injected;
264
+ if (kind === "file") return new FileSecretStore(options.paths.secretsDir);
265
+ const keyring = options.keyring === void 0 ? await loadKeyringModule() : options.keyring;
266
+ if (keyring === null) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secrets default to the system keychain, which is unavailable");
267
+ return new KeychainSecretStore(keyring, eventKeychainNamespace(options.configDir));
268
+ }
269
+ function parseMaster(ref, value) {
270
+ if (value === null) throw new EventSecretError("EVENT_MASTER_LOST", `event master ${ref} is unavailable`);
271
+ const key = Buffer.from(value, "base64");
272
+ if (key.byteLength !== 32 || key.toString("base64") !== value) throw new EventSecretError("EVENT_MASTER_LOST", `event master ${ref} is invalid`);
273
+ return key;
274
+ }
275
+ /**
276
+ * The daemon's independent master-key ledger. It uses core's hardened physical backends but owns selection,
277
+ * references, locking and migration boundaries itself.
278
+ */
279
+ var EventSecretStore = class {
280
+ #database;
281
+ #paths;
282
+ #kind;
283
+ #store;
284
+ #selectorWasAbsent;
285
+ #openStore;
286
+ constructor(database, paths, kind, store, selectorWasAbsent, openStore) {
287
+ this.#database = database;
288
+ this.#paths = paths;
289
+ this.#kind = kind;
290
+ this.#store = store;
291
+ this.#selectorWasAbsent = selectorWasAbsent;
292
+ this.#openStore = openStore;
293
+ }
294
+ get kind() {
295
+ return this.#kind;
296
+ }
297
+ async references(cipher) {
298
+ return this.withLock(() => referencesFor(this.#database, cipher));
299
+ }
300
+ /** The owner alone opens the other events-only backend; no core selector or namespace participates. */
301
+ async migrateTo(to, referenceCipher) {
302
+ const target = to === this.#kind ? this.#store : await this.#openStore(to);
303
+ const result = await migrateEventSecrets({
304
+ database: this.#database,
305
+ paths: this.#paths,
306
+ from: this.#kind,
307
+ to,
308
+ source: this.#store,
309
+ target,
310
+ referenceCipher
311
+ });
312
+ if (selectedKind(this.#database) === to) {
313
+ this.#kind = to;
314
+ this.#store = target;
315
+ }
316
+ return result;
317
+ }
318
+ async currentMaster() {
319
+ return this.withLock(async () => {
320
+ this.assertSelectedBackend();
321
+ const current = this.#database.prepare("SELECT key_id, secret_ref FROM event_secret_masters WHERE retired_at IS NULL ORDER BY created_at DESC LIMIT 1").get();
322
+ if (current !== void 0) return {
323
+ keyId: current.key_id,
324
+ key: parseMaster(current.secret_ref, await readBackend(this.#store, current.secret_ref))
325
+ };
326
+ if (this.#selectorWasAbsent) await this.probeInitialBackend();
327
+ return this.createMaster(void 0);
328
+ });
329
+ }
330
+ async master(keyId) {
331
+ return this.withLock(async () => {
332
+ this.assertSelectedBackend();
333
+ const row = this.#database.prepare("SELECT secret_ref FROM event_secret_masters WHERE key_id = ?").get(keyId);
334
+ if (row === void 0) throw new EventSecretError("EVENT_MASTER_UNKNOWN", `event master ${keyId} is unknown`);
335
+ return parseMaster(row.secret_ref, await readBackend(this.#store, row.secret_ref));
336
+ });
337
+ }
338
+ async rotateMaster() {
339
+ return this.withLock(async () => {
340
+ this.assertSelectedBackend();
341
+ const current = this.#database.prepare("SELECT key_id FROM event_secret_masters WHERE retired_at IS NULL ORDER BY created_at DESC LIMIT 1").get();
342
+ return this.createMaster(current?.key_id);
343
+ });
344
+ }
345
+ /**
346
+ * A retired master may be physically deleted only once every packed record
347
+ * and encrypted opaque-reference column proves it no longer names that key.
348
+ * The caller owns the writer-quiescer exclusive lease; this method retains
349
+ * the secrets lock for the entire scan and deletion.
350
+ */
351
+ async destroyRetiredMaster(input) {
352
+ input.quiescer.assertExclusive();
353
+ return this.withLock(async () => {
354
+ this.assertSelectedBackend();
355
+ const row = this.#database.prepare("SELECT secret_ref, retired_at FROM event_secret_masters WHERE key_id = ?").get(input.keyId);
356
+ if (row === void 0 || row.retired_at === null) throw new EventSecretError("EVENT_MASTER_UNKNOWN", "only a known retired event master may be destroyed");
357
+ const { RECORD_LAYOUTS } = await import("./records-DjE5a4_H.mjs");
358
+ for (const layout of RECORD_LAYOUTS) {
359
+ const records = this.#database.prepare(`SELECT ${layout.sqlColumn} AS record FROM ${layout.table} WHERE ${layout.sqlColumn} IS NOT NULL`).all();
360
+ for (const record of records) {
361
+ if (!(record.record instanceof Uint8Array)) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "a packed record column is not a BLOB");
362
+ let keyId;
363
+ try {
364
+ keyId = packedRecordKeyId(record.record);
365
+ } catch {
366
+ throw new EventSecretError("RETIRED_MASTER_REFERENCED", "a packed record cannot be proven independent");
367
+ }
368
+ if (keyId === input.keyId) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "a packed record still names the retired event master");
369
+ }
370
+ }
371
+ await referencesFor(this.#database, input.cipher);
372
+ this.#database.exec("BEGIN IMMEDIATE");
373
+ try {
374
+ const current = this.#database.prepare("SELECT retired_at FROM event_secret_masters WHERE key_id = ?").get(input.keyId);
375
+ if (current === void 0 || current.retired_at === null) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "the retired master changed during its destruction scan");
376
+ this.#database.prepare("DELETE FROM nonce_counters WHERE key_id = ?").run(input.keyId);
377
+ this.#database.prepare("DELETE FROM event_secret_masters WHERE key_id = ? AND retired_at IS NOT NULL").run(input.keyId);
378
+ this.#database.exec("COMMIT");
379
+ } catch (error) {
380
+ this.#database.exec("ROLLBACK");
381
+ throw error;
382
+ }
383
+ if (!await this.#store.delete(row.secret_ref)) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "the retired event master could not be deleted");
384
+ });
385
+ }
386
+ /**
387
+ * Resolves the current and still-valid overlap generations for one exact immutable owner. The opaque backend
388
+ * reference stays inside this store; only the byte-boundary adapter receives material, and only in memory.
389
+ */
390
+ async readLiveGenerations(input) {
391
+ assertPurpose(input.owner, input.purpose);
392
+ return this.withLock(async () => {
393
+ this.assertSelectedBackend();
394
+ this.assertLiveOwner(input.owner);
395
+ const now = input.now ?? Date.now();
396
+ const rows = this.#database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
397
+ encrypted_ref, lifecycle, expires_at
398
+ FROM event_secret_generations
399
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ?
400
+ AND (lifecycle = 'current' OR (lifecycle = 'overlap' AND expires_at > ?))
401
+ ORDER BY generation DESC`).all(input.owner.kind, input.owner.id, input.owner.version, input.purpose, now);
402
+ const resolved = [];
403
+ for (const row of rows) {
404
+ const generation = asStoredGeneration(row);
405
+ if (generation.owner.digest !== input.owner.digest) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the secret generation owner digest is no longer exact");
406
+ const ref = (await input.cipher.decrypt(generationLocation(generation.owner, generation.purpose, generation.generation), generation.encryptedRef)).toString("utf8");
407
+ if (!/^event-secret:[a-f0-9]{24}$/.test(ref)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an invalid opaque reference");
408
+ const material = await readBackend(this.#store, ref);
409
+ if (material === null || sha256Hex(material) !== generation.secretDigest) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation material is unavailable or changed");
410
+ resolved.push({
411
+ owner: generation.owner,
412
+ purpose: generation.purpose,
413
+ generation: generation.generation,
414
+ lifecycle: generation.lifecycle,
415
+ expiresAt: generation.expiresAt,
416
+ secretDigest: generation.secretDigest,
417
+ material
418
+ });
419
+ }
420
+ return resolved;
421
+ });
422
+ }
423
+ /** Writes a concealed backend value, verifies it, then commits only the exact immutable owner/generation reference. */
424
+ async storeGeneration(options) {
425
+ const { owner, purpose, material, expectedPriorGeneration, overlapExpiresAt, retirePrevious = false, requireEmpty = false } = options;
426
+ assertPurpose(owner, purpose);
427
+ if (typeof material !== "string" || material.length === 0) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation material is non-empty");
428
+ if (!Number.isSafeInteger(owner.version) || owner.version < 1 || !/^[a-f0-9]{64}$/.test(owner.digest) || expectedPriorGeneration !== null && (!Number.isSafeInteger(expectedPriorGeneration) || expectedPriorGeneration < 1)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an exact immutable owner");
429
+ return this.withLock(async () => {
430
+ this.assertSelectedBackend();
431
+ if (requireEmpty) {
432
+ if (this.#database.prepare(`SELECT 1 AS present FROM event_secret_generations
433
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? LIMIT 1`).get(owner.kind, owner.id, owner.version, purpose) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the exact immutable secret slot is not empty");
434
+ }
435
+ if ((this.currentGeneration(owner, purpose)?.generation ?? null) !== expectedPriorGeneration) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the expected prior secret generation is no longer current");
436
+ const generation = (this.#database.prepare(`SELECT MAX(generation) AS maximum FROM event_secret_generations
437
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ?`).get(owner.kind, owner.id, owner.version, purpose)?.maximum ?? 0) + 1;
438
+ const ref = `event-secret:${randomBytes(12).toString("hex")}`;
439
+ const materialDigest = sha256Hex(material);
440
+ await this.#store.set(ref, material);
441
+ try {
442
+ if (await readBackend(this.#store, ref) !== material) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation did not read back from the selected backend");
443
+ const encryptedRef = await options.cipher.encrypt(generationLocation(owner, purpose, generation), Buffer.from(ref, "utf8"));
444
+ this.#database.exec("BEGIN IMMEDIATE");
445
+ try {
446
+ this.assertSelectedBackend();
447
+ this.assertLiveOwner(owner);
448
+ if (requireEmpty) {
449
+ if (this.#database.prepare(`SELECT 1 AS present FROM event_secret_generations
450
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? LIMIT 1`).get(owner.kind, owner.id, owner.version, purpose) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the exact immutable secret slot changed while writing");
451
+ }
452
+ const current = this.currentGeneration(owner, purpose);
453
+ if ((current?.generation ?? null) !== expectedPriorGeneration || current !== null && current.owner.digest !== owner.digest) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the expected prior secret generation changed while writing");
454
+ const now = Date.now();
455
+ if (current !== null) {
456
+ if (retirePrevious) this.#database.prepare(`UPDATE event_secret_generations SET lifecycle = 'retired', expires_at = ?
457
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND generation = ?
458
+ AND lifecycle = 'current'`).run(now, owner.kind, owner.id, owner.version, purpose, current.generation);
459
+ else {
460
+ const overlap = overlapExpiresAt ?? now + 3e5;
461
+ if (!Number.isSafeInteger(overlap) || overlap <= now || overlap > now + 3e5) throw new EventSecretError("EVENT_SECRET_REFERENCE", "a rotating event secret generation has an overlap expiry within five minutes");
462
+ if ((this.#database.prepare(`SELECT COUNT(*) AS count FROM event_secret_generations
463
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND lifecycle = 'overlap'`).get(owner.kind, owner.id, owner.version, purpose)?.count ?? 0) !== 0) throw new EventSecretError("EVENT_SECRET_REFERENCE", "an event secret slot already has its bounded overlap generation");
464
+ this.#database.prepare(`UPDATE event_secret_generations SET lifecycle = 'overlap', expires_at = ?
465
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND generation = ?
466
+ AND lifecycle = 'current'`).run(overlap, owner.kind, owner.id, owner.version, purpose, current.generation);
467
+ }
468
+ }
469
+ this.#database.prepare(`INSERT INTO event_secret_generations
470
+ (owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest, encrypted_ref, lifecycle, expires_at, created_at)
471
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'current', NULL, ?)`).run(owner.kind, owner.id, owner.version, purpose, generation, owner.digest, materialDigest, encryptedRef, now);
472
+ this.#database.exec("COMMIT");
473
+ } catch (error) {
474
+ this.#database.exec("ROLLBACK");
475
+ throw error;
476
+ }
477
+ } catch (error) {
478
+ await this.#store.delete(ref).catch(() => false);
479
+ throw error;
480
+ }
481
+ return {
482
+ owner,
483
+ purpose,
484
+ generation,
485
+ lifecycle: "current",
486
+ expiresAt: null,
487
+ secretDigest: materialDigest
488
+ };
489
+ });
490
+ }
491
+ /**
492
+ * The only factory that can turn hidden URL bytes into a new immutable target
493
+ * version. It writes and verifies physical material before one compensated
494
+ * SQLite commit; an existing version is never an update target.
495
+ */
496
+ async createSecretUrlTargetVersion(options) {
497
+ if (options.document.url.kind !== "secret") throw new EventSecretError("EVENT_SECRET_REFERENCE", "a new secret URL target needs a secret URL descriptor");
498
+ const canonical = canonicalSecretWebhookUrl(options.completeUrl);
499
+ if (canonical.descriptor.scheme !== options.document.url.scheme || canonical.descriptor.host !== options.document.url.host || canonical.descriptor.port !== options.document.url.port || canonical.descriptor.sha256 !== options.document.url.sha256) throw new EventSecretError("EVENT_SECRET_REFERENCE", "the hidden URL does not match the new immutable descriptor");
500
+ const document = canonicalJson(options.document);
501
+ const owner = {
502
+ kind: "target",
503
+ id: options.document.targetId,
504
+ version: options.document.version,
505
+ digest: sha256Hex(document)
506
+ };
507
+ return this.withLock(async () => {
508
+ this.assertSelectedBackend();
509
+ if (this.#database.prepare("SELECT 1 AS present FROM target_versions WHERE target_id = ? AND version = ?").get(owner.id, owner.version) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the requested new target version already exists");
510
+ const ref = `event-secret:${randomBytes(12).toString("hex")}`;
511
+ await this.#store.set(ref, canonical.value);
512
+ try {
513
+ if (await readBackend(this.#store, ref) !== canonical.value) throw new EventSecretError("EVENT_SECRET_REFERENCE", "the new target URL did not read back from the selected backend");
514
+ const encryptedRef = await options.cipher.encrypt(generationLocation(owner, "secret-url", 1), Buffer.from(ref, "utf8"));
515
+ this.#database.exec("BEGIN IMMEDIATE");
516
+ try {
517
+ this.assertSelectedBackend();
518
+ if (this.#database.prepare("SELECT 1 AS present FROM target_versions WHERE target_id = ? AND version = ?").get(owner.id, owner.version) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the requested new target version already exists");
519
+ this.#database.prepare("INSERT INTO target_versions (id, target_id, version, document, digest) VALUES (?, ?, ?, ?, ?)").run(`${owner.id}@${owner.version}`, owner.id, owner.version, document, owner.digest);
520
+ this.#database.prepare(`INSERT INTO event_secret_generations
521
+ (owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest, encrypted_ref, lifecycle, expires_at, created_at)
522
+ VALUES ('target', ?, ?, 'secret-url', 1, ?, ?, ?, 'current', NULL, ?)`).run(owner.id, owner.version, owner.digest, sha256Hex(canonical.value), encryptedRef, Date.now());
523
+ this.#database.exec("COMMIT");
524
+ } catch (error) {
525
+ this.#database.exec("ROLLBACK");
526
+ throw error;
527
+ }
528
+ } catch (error) {
529
+ await this.#store.delete(ref).catch(() => false);
530
+ throw error;
531
+ }
532
+ return {
533
+ owner,
534
+ purpose: "secret-url",
535
+ generation: 1,
536
+ lifecycle: "current",
537
+ expiresAt: null,
538
+ secretDigest: sha256Hex(canonical.value)
539
+ };
540
+ });
541
+ }
542
+ /** Reconciles only proven-retired opaque references after their database rows are no longer reachable. */
543
+ async reconcileRetiredGenerations(cipher, now = Date.now()) {
544
+ return this.withLock(async () => {
545
+ this.assertSelectedBackend();
546
+ this.#database.prepare("UPDATE event_secret_generations SET lifecycle = 'retired' WHERE lifecycle = 'overlap' AND expires_at <= ?").run(now);
547
+ const rows = this.#database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
548
+ encrypted_ref, lifecycle, expires_at
549
+ FROM event_secret_generations WHERE lifecycle = 'retired'`).all();
550
+ const stale = await Promise.all(rows.map(async (row) => {
551
+ const generation = asStoredGeneration(row);
552
+ const value = (await cipher.decrypt(generationLocation(generation.owner, generation.purpose, generation.generation), generation.encryptedRef)).toString("utf8");
553
+ if (!/^event-secret:[a-f0-9]{24}$/.test(value)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an invalid opaque reference");
554
+ return {
555
+ generation,
556
+ ref: value
557
+ };
558
+ }));
559
+ const liveReferences = new Set(await referencesFor(this.#database, cipher));
560
+ this.#database.exec("BEGIN IMMEDIATE");
561
+ try {
562
+ this.assertSelectedBackend();
563
+ for (const entry of stale) this.#database.prepare(`DELETE FROM event_secret_generations
564
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND generation = ?
565
+ AND lifecycle = 'retired'`).run(entry.generation.owner.kind, entry.generation.owner.id, entry.generation.owner.version, entry.generation.purpose, entry.generation.generation);
566
+ this.#database.exec("COMMIT");
567
+ } catch (error) {
568
+ this.#database.exec("ROLLBACK");
569
+ throw error;
570
+ }
571
+ const leftovers = [];
572
+ for (const entry of stale) {
573
+ if (liveReferences.has(entry.ref)) continue;
574
+ try {
575
+ if (!await this.#store.delete(entry.ref)) leftovers.push(entry.ref);
576
+ } catch {
577
+ leftovers.push(entry.ref);
578
+ }
579
+ }
580
+ return leftovers;
581
+ });
582
+ }
583
+ async probeInitialBackend() {
584
+ const ref = `event-master-probe:${randomBytes(12).toString("hex")}`;
585
+ const value = randomBytes(32).toString("base64");
586
+ await this.#store.set(ref, value);
587
+ try {
588
+ if (await readBackend(this.#store, ref) !== value) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event keychain probe did not read back its value");
589
+ } finally {
590
+ await this.#store.delete(ref).catch(() => void 0);
591
+ }
592
+ }
593
+ async createMaster(retire) {
594
+ const keyId = `evt_${randomBytes(12).toString("hex")}`;
595
+ const ref = `event-master:${keyId}`;
596
+ const key = randomBytes(32);
597
+ const encoded = key.toString("base64");
598
+ await this.#store.set(ref, encoded);
599
+ try {
600
+ if (await readBackend(this.#store, ref) !== encoded) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event master did not read back from its selected backend");
601
+ this.#database.exec("BEGIN IMMEDIATE");
602
+ try {
603
+ const now = Date.now();
604
+ this.#database.prepare("INSERT INTO event_secret_masters (key_id, secret_ref, created_at) VALUES (?, ?, ?)").run(keyId, ref, now);
605
+ if (retire !== void 0) this.#database.prepare("UPDATE event_secret_masters SET retired_at = ? WHERE key_id = ?").run(now, retire);
606
+ if (this.#selectorWasAbsent) setSelector(this.#database, this.#kind);
607
+ this.#database.exec("COMMIT");
608
+ } catch (error) {
609
+ this.#database.exec("ROLLBACK");
610
+ throw error;
611
+ }
612
+ } catch (error) {
613
+ await this.#store.delete(ref).catch(() => void 0);
614
+ throw error;
615
+ }
616
+ return {
617
+ keyId,
618
+ key
619
+ };
620
+ }
621
+ currentGeneration(owner, purpose) {
622
+ const row = this.#database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
623
+ encrypted_ref, lifecycle, expires_at
624
+ FROM event_secret_generations
625
+ WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND lifecycle = 'current'`).get(owner.kind, owner.id, owner.version, purpose);
626
+ if (row === void 0) return null;
627
+ const generation = asStoredGeneration(row);
628
+ return {
629
+ owner: generation.owner,
630
+ purpose: generation.purpose,
631
+ generation: generation.generation,
632
+ lifecycle: generation.lifecycle,
633
+ expiresAt: generation.expiresAt,
634
+ secretDigest: generation.secretDigest
635
+ };
636
+ }
637
+ assertLiveOwner(owner) {
638
+ const table = owner.kind === "target" ? "target_versions" : owner.kind === "subscriber" ? "subscriber_versions" : "judge_versions";
639
+ const idColumn = owner.kind === "target" ? "target_id" : owner.kind === "subscriber" ? "subscriber_id" : "judge_id";
640
+ const row = this.#database.prepare(`SELECT digest, revoked_at FROM ${table} WHERE ${idColumn} = ? AND version = ?`).get(owner.id, owner.version);
641
+ if (row === void 0 || row.revoked_at !== null || row.digest !== owner.digest) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the exact secret owner version is no longer live");
642
+ }
643
+ /** A queued stale store may never write/delete after migration selected the other independent backend. */
644
+ assertSelectedBackend() {
645
+ const selected = selectedKind(this.#database);
646
+ if (selected !== null && selected !== this.#kind) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret backend changed; reopen the selected backend");
647
+ }
648
+ async withLock(work) {
649
+ return withEventSecretsLock(this.#paths, work);
650
+ }
651
+ };
652
+ /**
653
+ * Reads what the backend holds now. Core's keychain store answers `get` from a cache its own `set` fills, so a read-back
654
+ * or a migration check through it would otherwise see what this process last wrote rather than the keychain item, which
655
+ * another process, or a keychain reset, may have changed since.
656
+ */
657
+ async function readBackend(store, ref) {
658
+ store.invalidate(ref);
659
+ return store.get(ref);
660
+ }
661
+ /** Opens only the backend selected by SQLite; an unselected installation tries keychain and never falls back. */
662
+ async function openEventSecretStore(options) {
663
+ const recorded = selectedKind(options.database);
664
+ const kind = recorded ?? "keychain";
665
+ return new EventSecretStore(options.database, options.paths, kind, await storeFor(options, kind), recorded === null, (requested) => storeFor(options, requested));
666
+ }
667
+ /**
668
+ * Moves every reference the event database names, under the events-only lock. No core selector, lock or reference
669
+ * discovery participates. Before the selector changes, every copy is verified and any attempted target value is
670
+ * removed again on failure; after it changes, failed source cleanup is returned as a retryable leftover.
671
+ */
672
+ async function migrateEventSecrets(options) {
673
+ if (options.from === options.to) return {
674
+ moved: 0,
675
+ leftovers: []
676
+ };
677
+ if (options.source === options.target || options.source.kind !== options.from || options.target.kind !== options.to) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret migration stores do not match its backends");
678
+ return withEventSecretsLock(options.paths, async () => {
679
+ const selected = selectedKind(options.database);
680
+ if (selected === options.to) {
681
+ const references = await referencesForSelectedTarget(options.database, options.target);
682
+ const snapshot = referenceSnapshot(options.database);
683
+ const present = [];
684
+ for (const ref of references) {
685
+ const selectedValue = await readBackend(options.target, ref);
686
+ if (selectedValue === null) throw new EventSecretError("EVENT_MASTER_LOST", "a selected event secret is unavailable; old migration leftovers are retained");
687
+ const leftover = await readBackend(options.source, ref);
688
+ if (leftover !== null && leftover !== selectedValue) throw new EventSecretError("EVENT_SECRET_SELECTOR", "a selected event secret no longer matches its migration source; old migration leftovers are retained");
689
+ if (leftover !== null) present.push(ref);
690
+ }
691
+ options.database.exec("BEGIN IMMEDIATE");
692
+ try {
693
+ if (referenceSnapshot(options.database) !== snapshot || selectedKind(options.database) !== options.to) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret references changed before leftover cleanup");
694
+ options.database.exec("COMMIT");
695
+ } catch (error) {
696
+ options.database.exec("ROLLBACK");
697
+ throw error;
698
+ }
699
+ const leftovers = [];
700
+ for (const ref of present) try {
701
+ if (!await options.source.delete(ref)) leftovers.push(ref);
702
+ } catch {
703
+ leftovers.push(ref);
704
+ }
705
+ return {
706
+ moved: 0,
707
+ leftovers
708
+ };
709
+ }
710
+ if (selected !== options.from) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret migration source is not the selected backend");
711
+ const references = await referencesFor(options.database, options.referenceCipher);
712
+ const snapshot = referenceSnapshot(options.database);
713
+ const copied = [];
714
+ try {
715
+ for (const ref of references) {
716
+ const value = await readBackend(options.source, ref);
717
+ if (value === null) throw new EventSecretError("EVENT_MASTER_LOST", "a referenced event secret is unavailable");
718
+ copied.push({
719
+ ref,
720
+ value
721
+ });
722
+ await options.target.set(ref, value);
723
+ if (await readBackend(options.target, ref) !== value) throw new EventSecretError("EVENT_SECRET_SELECTOR", `event secret ${ref} did not verify in the destination backend`);
724
+ }
725
+ options.database.exec("BEGIN IMMEDIATE");
726
+ try {
727
+ if (referenceSnapshot(options.database) !== snapshot) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret references changed during migration");
728
+ if (selectedKind(options.database) !== options.from) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret selector changed during migration");
729
+ setSelector(options.database, options.to);
730
+ options.database.exec("COMMIT");
731
+ } catch (error) {
732
+ options.database.exec("ROLLBACK");
733
+ throw error;
734
+ }
735
+ } catch (error) {
736
+ await Promise.all(copied.map(({ ref }) => options.target.delete(ref).catch(() => false)));
737
+ throw error;
738
+ }
739
+ if (!await copiedTargetValuesMatch(options.target, copied)) return {
740
+ moved: references.length,
741
+ leftovers: references,
742
+ code: "SELECTED_COPY_CHANGED_AFTER_MIGRATION"
743
+ };
744
+ const leftovers = [];
745
+ for (const ref of references) try {
746
+ if (!await options.source.delete(ref)) leftovers.push(ref);
747
+ } catch {
748
+ leftovers.push(ref);
749
+ }
750
+ return {
751
+ moved: references.length,
752
+ leftovers
753
+ };
754
+ });
755
+ }
756
+ /** A post-selector cleanup may delete a source only if its selected counterpart still has the verified exact bytes. */
757
+ async function copiedTargetValuesMatch(target, copied) {
758
+ try {
759
+ for (const { ref, value } of copied) if (await readBackend(target, ref) !== value) return false;
760
+ return true;
761
+ } catch {
762
+ return false;
763
+ }
764
+ }
765
+ /** Resolves opaque generation references from the selected migration target without retaining an old-store handle. */
766
+ async function referencesForSelectedTarget(database, target) {
767
+ const masters = database.prepare("SELECT key_id, secret_ref FROM event_secret_masters ORDER BY key_id").all();
768
+ const masterByKey = /* @__PURE__ */ new Map();
769
+ for (const master of masters) {
770
+ const encoded = await readBackend(target, master.secret_ref);
771
+ if (encoded === null) throw new EventSecretError("EVENT_MASTER_LOST", "a selected event master is unavailable during leftover cleanup");
772
+ const key = Buffer.from(encoded, "base64");
773
+ if (key.length !== 32) throw new EventSecretError("EVENT_MASTER_LOST", "a selected event master is malformed during leftover cleanup");
774
+ masterByKey.set(master.key_id, key);
775
+ }
776
+ const rows = database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
777
+ encrypted_ref, lifecycle, expires_at
778
+ FROM event_secret_generations
779
+ ORDER BY owner_kind, owner_id, owner_version, purpose, generation`).all();
780
+ const references = masters.map((master) => master.secret_ref);
781
+ for (const row of rows) {
782
+ const generation = asStoredGeneration(row);
783
+ const keyId = packedRecordKeyId(generation.encryptedRef);
784
+ const master = masterByKey.get(keyId);
785
+ if (master === void 0) throw new EventSecretError("EVENT_MASTER_LOST", "an event secret reference names an unavailable selected master");
786
+ let reference;
787
+ try {
788
+ reference = decryptPackedRecord({
789
+ record: generation.encryptedRef,
790
+ keyForId: (selectedKeyId) => selectedKeyId === keyId ? deriveTableKey(master, "event_secret_generations") : null,
791
+ aad: encodeAad("event_secret_generations", "encryptedReference", generationLocation(generation.owner, generation.purpose, generation.generation).key)
792
+ });
793
+ } catch {
794
+ throw new EventSecretError("EVENT_SECRET_REFERENCE", "an event secret reference is unreadable during leftover cleanup");
795
+ }
796
+ const value = reference.toString("utf8");
797
+ if (!/^event-secret:[a-f0-9]{24}$/.test(value)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "an event secret reference is not an opaque event reference");
798
+ references.push(value);
799
+ }
800
+ return [...new Set(references)].sort();
801
+ }
802
+ //#endregion
803
+ //#region src/runtime/writer-quiescer.ts
804
+ const leaseContext = new AsyncLocalStorage();
805
+ /**
806
+ * Stops new encrypted-state writers while a rare owner-wide rekey drains the
807
+ * writers already between their cipher work and durable commit. It is purely
808
+ * in-memory: a failed operation never leaves persistent pause state behind.
809
+ */
810
+ var WriterQuiescer = class {
811
+ #shared = 0;
812
+ #exclusive = false;
813
+ #exclusiveWaiting = 0;
814
+ #waiters = [];
815
+ async withShared(work) {
816
+ if (leaseContext.getStore()?.quiescer === this) return work();
817
+ await this.acquireShared();
818
+ try {
819
+ return await leaseContext.run({
820
+ quiescer: this,
821
+ kind: "shared"
822
+ }, work);
823
+ } finally {
824
+ this.#shared -= 1;
825
+ this.wake();
826
+ }
827
+ }
828
+ async withExclusive(work) {
829
+ const held = leaseContext.getStore();
830
+ if (held?.quiescer === this && held.kind === "exclusive") return work();
831
+ if (held?.quiescer === this) throw new Error("an event writer cannot upgrade its shared lease to an exclusive lease");
832
+ this.#exclusiveWaiting += 1;
833
+ try {
834
+ await this.waitFor(() => !this.#exclusive && this.#shared === 0);
835
+ this.#exclusive = true;
836
+ return await leaseContext.run({
837
+ quiescer: this,
838
+ kind: "exclusive"
839
+ }, work);
840
+ } finally {
841
+ if (this.#exclusive) this.#exclusive = false;
842
+ this.#exclusiveWaiting -= 1;
843
+ this.wake();
844
+ }
845
+ }
846
+ /** Fails closed when a destructive key operation escaped the owner-wide rekey lease. */
847
+ assertExclusive() {
848
+ const held = leaseContext.getStore();
849
+ if (held?.quiescer !== this || held.kind !== "exclusive") throw new Error("the event writer quiescer exclusive lease is required");
850
+ }
851
+ async acquireShared() {
852
+ await this.waitFor(() => !this.#exclusive && this.#exclusiveWaiting === 0);
853
+ this.#shared += 1;
854
+ }
855
+ async waitFor(predicate) {
856
+ while (!predicate()) await new Promise((resolve) => this.#waiters.push(resolve));
857
+ }
858
+ wake() {
859
+ const waiters = this.#waiters.splice(0);
860
+ for (const wake of waiters) wake();
861
+ }
862
+ };
863
+ /** Required shared lease wrapper for every B3 writer-inventory entrypoint. */
864
+ async function withEventWriterLease(quiescer, work) {
865
+ return quiescer.withShared(work);
866
+ }
867
+ //#endregion
868
+ //#region src/store/records.ts
869
+ const MAX_INVOCATIONS_PER_TABLE_KEY = 4294967295;
870
+ var RecordStorageError = class extends Error {
871
+ code;
872
+ constructor(code, message) {
873
+ super(message);
874
+ this.name = "RecordStorageError";
875
+ this.code = code;
876
+ }
877
+ };
878
+ /** Maps ciphertext/key failures to the reset path; callers must never substitute plaintext for these records. */
879
+ function unreadableRecordReason(error) {
880
+ if (error instanceof EventSecretError) {
881
+ if (error.code === "EVENT_MASTER_LOST") return "master-lost";
882
+ if (error.code === "EVENT_MASTER_UNKNOWN") return "unknown-key";
883
+ }
884
+ if (error instanceof PackedRecordError) {
885
+ if (error.code === "AUTHENTICATION_FAILED") return "authentication-failed";
886
+ if (error.code === "UNKNOWN_KEY") return "unknown-key";
887
+ }
888
+ return null;
889
+ }
890
+ /** Each encrypted column, with its table's primary key in the order the v1 migration declares it — the AAD order. */
891
+ const RECORD_LAYOUTS = [
892
+ {
893
+ table: "source_scan_state",
894
+ column: "encryptedRecord",
895
+ sqlColumn: "encrypted_record",
896
+ keyColumns: ["id"]
897
+ },
898
+ {
899
+ table: "rule_activation_points",
900
+ column: "encryptedPosition",
901
+ sqlColumn: "encrypted_position",
902
+ keyColumns: [
903
+ "activation_id",
904
+ "rule_id",
905
+ "rule_version",
906
+ "account_id",
907
+ "position_scope"
908
+ ]
909
+ },
910
+ {
911
+ table: "activation_baselines",
912
+ column: "encryptedPosition",
913
+ sqlColumn: "encrypted_position",
914
+ keyColumns: [
915
+ "intent_id",
916
+ "source",
917
+ "account_id",
918
+ "position_scope"
919
+ ]
920
+ },
921
+ {
922
+ table: "ingest_rules",
923
+ column: "encryptedProjection",
924
+ sqlColumn: "encrypted_projection",
925
+ keyColumns: [
926
+ "event_id",
927
+ "rule_id",
928
+ "rule_version"
929
+ ]
930
+ },
931
+ {
932
+ table: "decisions",
933
+ column: "encryptedRecord",
934
+ sqlColumn: "encrypted_record",
935
+ keyColumns: ["id"]
936
+ },
937
+ {
938
+ table: "deliveries",
939
+ column: "encryptedRecord",
940
+ sqlColumn: "encrypted_record",
941
+ keyColumns: ["id"]
942
+ },
943
+ {
944
+ table: "dryrun_log",
945
+ column: "encryptedRecord",
946
+ sqlColumn: "encrypted_record",
947
+ keyColumns: ["delivery_id"]
948
+ },
949
+ {
950
+ table: "stream_log",
951
+ column: "encryptedRecord",
952
+ sqlColumn: "encrypted_record",
953
+ keyColumns: ["id"]
954
+ },
955
+ {
956
+ table: "event_secret_generations",
957
+ column: "encryptedReference",
958
+ sqlColumn: "encrypted_ref",
959
+ keyColumns: [
960
+ "owner_kind",
961
+ "owner_id",
962
+ "owner_version",
963
+ "purpose",
964
+ "generation"
965
+ ]
966
+ },
967
+ {
968
+ table: "system_reset_outbox",
969
+ column: "encryptedRecord",
970
+ sqlColumn: "encrypted_record",
971
+ keyColumns: ["id"]
972
+ }
973
+ ];
974
+ /** Encrypts and decrypts B1 table records while reserving the D8 nonce-invocation budget in SQLite. */
975
+ /** A rotation pass that still meets an older key this many times is refused, never looped. */
976
+ const MAX_ROTATION_PASSES = 32;
977
+ var EventRecordCipher = class {
978
+ #database;
979
+ #secrets;
980
+ #quiescer;
981
+ #tableKeys = /* @__PURE__ */ new Map();
982
+ constructor(database, secrets, quiescer) {
983
+ this.#database = database;
984
+ this.#secrets = secrets;
985
+ this.#quiescer = quiescer;
986
+ }
987
+ /** The B3 secret service must share this exact owner-wide lease, never a look-alike lock. */
988
+ get writerQuiescer() {
989
+ return this.#quiescer;
990
+ }
991
+ async encrypt(location, plaintext) {
992
+ return withEventWriterLease(this.#quiescer, () => this.encryptHeld(location, plaintext));
993
+ }
994
+ async encryptHeld(location, plaintext) {
995
+ const master = await this.#secrets.currentMaster();
996
+ this.reserveInvocation(master.keyId, location.table);
997
+ return encryptPackedRecord({
998
+ keyId: master.keyId,
999
+ key: this.tableKey(master.keyId, master.key, location.table),
1000
+ aad: encodeAad(location.table, location.column, location.key),
1001
+ plaintext
1002
+ });
1003
+ }
1004
+ async decrypt(location, stored) {
1005
+ return withEventWriterLease(this.#quiescer, () => this.decryptHeld(location, stored));
1006
+ }
1007
+ async decryptHeld(location, stored) {
1008
+ if (!(stored instanceof Uint8Array)) throw new RecordStorageError("RECORD_STORAGE_CLASS", "encrypted event records must be SQLite BLOB values");
1009
+ const record = Buffer.from(stored);
1010
+ try {
1011
+ const keyId = packedRecordKeyId(record);
1012
+ const master = await this.#secrets.master(keyId);
1013
+ return decryptPackedRecord({
1014
+ record,
1015
+ keyForId: (selected) => selected === keyId ? this.tableKey(keyId, master, location.table) : null,
1016
+ aad: encodeAad(location.table, location.column, location.key)
1017
+ });
1018
+ } catch (error) {
1019
+ const reason = unreadableRecordReason(error);
1020
+ if (reason === null) throw error;
1021
+ throw new RecordStorageError("RECORD_UNREADABLE_RESET_REQUIRED", `encrypted event record is unreadable (${reason}); installation reset is required`);
1022
+ }
1023
+ }
1024
+ /**
1025
+ * Re-encrypts every B1 packed column in small SQLite write batches under a newly generated master.
1026
+ *
1027
+ * Precondition, owed by its caller: no writer may hold ciphertext between `encrypt()` and its own write across the
1028
+ * rotation. A record a worker encrypted under the old master before `rotateMaster()` and wrote after the last pass
1029
+ * would stay under the retired key (still readable: retired masters are kept). B1 exposes no caller; the B3 operation
1030
+ * that does must stop the owner's writers and await their in-flight work first, and destroying a retired master must
1031
+ * refuse while any record still names it (plan, "PR #59 review (Blocks)" row).
1032
+ */
1033
+ async rotateAndReencrypt(options = {}) {
1034
+ return this.#quiescer.withExclusive(() => this.rotateAndReencryptHeld(options));
1035
+ }
1036
+ async rotateAndReencryptHeld(options = {}) {
1037
+ const batchSize = options.batchSize ?? 100;
1038
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new RecordStorageError("NONCE_LIMIT", "rotation batch size must be positive");
1039
+ const master = await this.#secrets.rotateMaster();
1040
+ let rewritten = 0;
1041
+ for (const layout of RECORD_LAYOUTS) rewritten += await this.reencryptLayout(layout, master, batchSize);
1042
+ return {
1043
+ keyId: master.keyId,
1044
+ rewritten
1045
+ };
1046
+ }
1047
+ reserveInvocation(keyId, table) {
1048
+ this.#database.exec("BEGIN IMMEDIATE");
1049
+ try {
1050
+ if ((this.#database.prepare("SELECT invocations FROM nonce_counters WHERE key_id = ? AND table_name = ?").get(keyId, table)?.invocations ?? 0) >= MAX_INVOCATIONS_PER_TABLE_KEY) throw new RecordStorageError("NONCE_LIMIT", "the event table key reached its nonce limit; rotate the master key before encrypting again");
1051
+ this.#database.prepare(`INSERT INTO nonce_counters (key_id, table_name, invocations) VALUES (?, ?, 1)
1052
+ ON CONFLICT(key_id, table_name) DO UPDATE SET invocations = nonce_counters.invocations + 1`).run(keyId, table);
1053
+ this.#database.exec("COMMIT");
1054
+ } catch (error) {
1055
+ this.#database.exec("ROLLBACK");
1056
+ throw error;
1057
+ }
1058
+ }
1059
+ tableKey(keyId, master, table) {
1060
+ const cacheKey = `${keyId}\u0000${table}`;
1061
+ const cached = this.#tableKeys.get(cacheKey);
1062
+ if (cached !== void 0) return cached;
1063
+ const derived = deriveTableKey(master, table);
1064
+ this.#tableKeys.set(cacheKey, derived);
1065
+ return derived;
1066
+ }
1067
+ async reencryptLayout(layout, master, batchSize) {
1068
+ let rewritten = 0;
1069
+ const selectedColumns = [...layout.keyColumns, layout.sqlColumn].join(", ");
1070
+ const orderedColumns = layout.keyColumns.join(", ");
1071
+ const first = this.#database.prepare(`SELECT ${selectedColumns} FROM ${layout.table} WHERE ${layout.sqlColumn} IS NOT NULL ORDER BY ${orderedColumns} LIMIT ?`);
1072
+ const next = this.#database.prepare(`SELECT ${selectedColumns} FROM ${layout.table}
1073
+ WHERE ${layout.sqlColumn} IS NOT NULL AND (${orderedColumns}) > (${layout.keyColumns.map(() => "?").join(", ")})
1074
+ ORDER BY ${orderedColumns} LIMIT ?`);
1075
+ const where = layout.keyColumns.map((column) => `${column} = ?`).join(" AND ");
1076
+ const update = this.#database.prepare(`UPDATE ${layout.table} SET ${layout.sqlColumn} = ? WHERE ${where} AND ${layout.sqlColumn} = ?`);
1077
+ for (let pass = 0;; pass += 1) {
1078
+ if (pass === MAX_ROTATION_PASSES) throw new RecordStorageError("ROTATION_INCOMPLETE", `${layout.table}.${layout.sqlColumn} still held a record under an older key after ${MAX_ROTATION_PASSES} passes`);
1079
+ let sawOlder = false;
1080
+ let last;
1081
+ for (;;) {
1082
+ const rows = last === void 0 ? first.all(batchSize) : next.all(...last, batchSize);
1083
+ if (rows.length === 0) break;
1084
+ last = this.keyValues(layout, rows[rows.length - 1]);
1085
+ const updates = [];
1086
+ for (const row of rows) {
1087
+ const stored = row[layout.sqlColumn];
1088
+ if (!(stored instanceof Uint8Array)) throw new RecordStorageError("RECORD_STORAGE_CLASS", `${layout.table}.${layout.sqlColumn} is not a BLOB`);
1089
+ if (packedRecordKeyId(stored) === master.keyId) continue;
1090
+ sawOlder = true;
1091
+ const location = this.locationFor(layout, row);
1092
+ const plaintext = await this.decrypt(location, stored);
1093
+ updates.push({
1094
+ record: await this.encrypt(location, plaintext),
1095
+ read: stored,
1096
+ keys: this.keyValues(layout, row)
1097
+ });
1098
+ }
1099
+ if (updates.length === 0) continue;
1100
+ this.#database.exec("BEGIN IMMEDIATE");
1101
+ try {
1102
+ for (const item of updates) if (Number(update.run(item.record, ...item.keys, item.read).changes) === 1) rewritten += 1;
1103
+ this.#database.exec("COMMIT");
1104
+ } catch (error) {
1105
+ this.#database.exec("ROLLBACK");
1106
+ throw error;
1107
+ }
1108
+ }
1109
+ if (!sawOlder) return rewritten;
1110
+ }
1111
+ }
1112
+ locationFor(layout, row) {
1113
+ return {
1114
+ table: layout.table,
1115
+ column: layout.column,
1116
+ key: layout.keyColumns.map((column) => this.component(row[column], `${layout.table}.${column}`))
1117
+ };
1118
+ }
1119
+ keyValues(layout, row) {
1120
+ return layout.keyColumns.map((column) => this.sqlValue(row[column], `${layout.table}.${column}`));
1121
+ }
1122
+ component(value, name) {
1123
+ if (typeof value === "string") return {
1124
+ type: "text",
1125
+ value
1126
+ };
1127
+ if (typeof value === "number" || typeof value === "bigint") return {
1128
+ type: "integer",
1129
+ value
1130
+ };
1131
+ if (value instanceof Uint8Array) return {
1132
+ type: "blob",
1133
+ value
1134
+ };
1135
+ throw new RecordStorageError("RECORD_STORAGE_CLASS", `${name} is not a valid primary-key storage class`);
1136
+ }
1137
+ sqlValue(value, name) {
1138
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || value instanceof Uint8Array || value === null) return value;
1139
+ throw new RecordStorageError("RECORD_STORAGE_CLASS", `${name} is not a SQLite value`);
1140
+ }
1141
+ };
1142
+ //#endregion
1143
+ export { WriterQuiescer as a, openEventSecretStore as c, unreadableRecordReason as i, RECORD_LAYOUTS as n, withEventWriterLease as o, RecordStorageError as r, migrateEventSecrets as s, EventRecordCipher as t };