@modootoday/pmcp 0.9.1 → 0.10.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,1198 @@
1
+ import {
2
+ readConfig
3
+ } from "./chunk-MSTKY33C.js";
4
+ import {
5
+ actorIdSchema,
6
+ bindingSchema,
7
+ credentialSchema,
8
+ cursorSchema,
9
+ inboxSchema,
10
+ messageSchema,
11
+ openSqlite,
12
+ operationSchema,
13
+ runtimeSchema,
14
+ sendSchema,
15
+ sessionsSchema
16
+ } from "./chunk-XOH2CW6P.js";
17
+
18
+ // src/mailbox/types.ts
19
+ var MailboxError = class extends Error {
20
+ constructor(code, message, retryable = false) {
21
+ super(message);
22
+ this.code = code;
23
+ this.retryable = retryable;
24
+ this.name = "MailboxError";
25
+ }
26
+ code;
27
+ retryable;
28
+ };
29
+ function failure(error) {
30
+ if (error instanceof MailboxError) {
31
+ return {
32
+ ok: false,
33
+ error: {
34
+ code: error.code,
35
+ message: error.message,
36
+ retryable: error.retryable
37
+ }
38
+ };
39
+ }
40
+ return {
41
+ ok: false,
42
+ error: {
43
+ code: "internal_error",
44
+ message: "Mailbox operation failed",
45
+ retryable: false
46
+ }
47
+ };
48
+ }
49
+
50
+ // src/mailbox/config.ts
51
+ import { randomUUID } from "crypto";
52
+ import {
53
+ closeSync,
54
+ constants,
55
+ existsSync,
56
+ fstatSync,
57
+ mkdirSync,
58
+ openSync,
59
+ readFileSync,
60
+ renameSync,
61
+ rmSync,
62
+ writeFileSync
63
+ } from "fs";
64
+ import { homedir } from "os";
65
+ import { dirname, join, resolve } from "path";
66
+ import { z } from "zod";
67
+ var sectionSchema = z.object({
68
+ version: z.literal(1),
69
+ enabled: z.boolean(),
70
+ project_id: z.uuid(),
71
+ state_dir: z.string().min(1).optional()
72
+ }).strict();
73
+ function mailboxConfigFrom(project, env = process.env) {
74
+ if (project.raw.mailbox === void 0) return void 0;
75
+ const parsed = sectionSchema.safeParse(project.raw.mailbox);
76
+ if (!parsed.success)
77
+ throw new MailboxError("invalid_config", "Invalid [mailbox] configuration");
78
+ const section = parsed.data;
79
+ const stateRoot = env.XDG_STATE_HOME ?? join(homedir(), ".local/state");
80
+ const stateDir = section.state_dir ? resolve(project.dir, section.state_dir) : join(stateRoot, "pmcp/projects", section.project_id);
81
+ return {
82
+ projectId: section.project_id,
83
+ enabled: section.enabled,
84
+ databasePath: join(stateDir, "mailbox.sqlite")
85
+ };
86
+ }
87
+ function initializeMailboxConfig(file, stateDirectory) {
88
+ const path = resolve(file);
89
+ const original = existsSync(path) ? readFileSync(path, "utf8") : void 0;
90
+ if (original !== void 0 && readConfig(path).raw.mailbox !== void 0) {
91
+ throw new MailboxError("mailbox_exists", "Mailbox is already configured");
92
+ }
93
+ const lines = [
94
+ "[mailbox]",
95
+ "version = 1",
96
+ "enabled = true",
97
+ `project_id = ${JSON.stringify(randomUUID())}`
98
+ ];
99
+ if (stateDirectory)
100
+ lines.push(`state_dir = ${JSON.stringify(stateDirectory)}`);
101
+ const text = `${original ?? ""}
102
+ ${lines.join("\n")}
103
+ `;
104
+ mkdirSync(dirname(path), { recursive: true });
105
+ if (original === void 0) {
106
+ writeFileSync(path, text, { flag: "wx", mode: 384 });
107
+ } else {
108
+ const temporary = `${path}.${randomUUID()}.tmp`;
109
+ try {
110
+ writeFileSync(temporary, text, { flag: "wx", mode: 384 });
111
+ if (readFileSync(path, "utf8") !== original) {
112
+ throw new MailboxError(
113
+ "config_conflict",
114
+ "Configuration changed during initialization"
115
+ );
116
+ }
117
+ renameSync(temporary, path);
118
+ } finally {
119
+ rmSync(temporary, { force: true });
120
+ }
121
+ }
122
+ return mailboxConfigFrom(readConfig(path));
123
+ }
124
+ function readPrivateJson(file) {
125
+ const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
126
+ try {
127
+ const stat = fstatSync(descriptor);
128
+ if (!stat.isFile() || (stat.mode & 63) !== 0 || stat.size > 4096) {
129
+ throw new MailboxError(
130
+ "unsafe_credential_file",
131
+ "Use a private regular credential file of at most 4096 bytes"
132
+ );
133
+ }
134
+ if (process.getuid && stat.uid !== process.getuid()) {
135
+ throw new MailboxError(
136
+ "unsafe_credential_file",
137
+ "Credential file must belong to the current user"
138
+ );
139
+ }
140
+ return JSON.parse(readFileSync(descriptor, "utf8"));
141
+ } finally {
142
+ closeSync(descriptor);
143
+ }
144
+ }
145
+ function readCredential(file) {
146
+ return credentialSchema.parse(readPrivateJson(file));
147
+ }
148
+ function readBinding(file) {
149
+ return bindingSchema.parse(readPrivateJson(file));
150
+ }
151
+ function writePrivateJson(file, value) {
152
+ mkdirSync(dirname(resolve(file)), { recursive: true, mode: 448 });
153
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}
154
+ `, {
155
+ flag: "wx",
156
+ mode: 384
157
+ });
158
+ }
159
+ function writeBindingFile(file, binding) {
160
+ if (existsSync(file)) {
161
+ const previous = readBinding(file);
162
+ if (previous.projectId !== binding.projectId || previous.actorId !== binding.actorId) {
163
+ throw new MailboxError(
164
+ "binding_scope_conflict",
165
+ "Binding file belongs to another actor or project"
166
+ );
167
+ }
168
+ }
169
+ const temporary = `${file}.${randomUUID()}.tmp`;
170
+ try {
171
+ writePrivateJson(temporary, binding);
172
+ renameSync(temporary, file);
173
+ } finally {
174
+ rmSync(temporary, { force: true });
175
+ }
176
+ }
177
+
178
+ // src/mailbox/store.ts
179
+ import { createHash as createHash2, randomBytes, randomUUID as randomUUID2 } from "crypto";
180
+ import {
181
+ chmodSync as chmodSync2,
182
+ copyFileSync,
183
+ constants as constants2,
184
+ existsSync as existsSync2,
185
+ unlinkSync
186
+ } from "fs";
187
+
188
+ // src/storage/schema.ts
189
+ var SCHEMA_VERSION = 3;
190
+ var SCHEMA_SQL = `
191
+ CREATE TABLE mailbox_project (
192
+ id TEXT PRIMARY KEY,
193
+ created_at_ms INTEGER NOT NULL
194
+ );
195
+ CREATE TABLE mailbox_actors (
196
+ id TEXT PRIMARY KEY,
197
+ credential_hash TEXT NOT NULL,
198
+ parent_actor_id TEXT REFERENCES mailbox_actors(id),
199
+ generation INTEGER NOT NULL DEFAULT 0,
200
+ active_session_id TEXT REFERENCES mailbox_sessions(id) DEFERRABLE INITIALLY DEFERRED,
201
+ created_at_ms INTEGER NOT NULL
202
+ );
203
+ CREATE TABLE mailbox_sessions (
204
+ id TEXT PRIMARY KEY,
205
+ actor_id TEXT NOT NULL REFERENCES mailbox_actors(id),
206
+ generation INTEGER NOT NULL,
207
+ secret_hash TEXT NOT NULL,
208
+ runtime TEXT NOT NULL,
209
+ native_session_id TEXT,
210
+ created_at_ms INTEGER NOT NULL,
211
+ heartbeat_at_ms INTEGER NOT NULL,
212
+ expires_at_ms INTEGER NOT NULL,
213
+ closed_at_ms INTEGER,
214
+ UNIQUE(actor_id, generation)
215
+ );
216
+ CREATE TABLE mailbox_messages (
217
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
218
+ id TEXT NOT NULL UNIQUE,
219
+ sender_actor_id TEXT NOT NULL REFERENCES mailbox_actors(id),
220
+ sender_session_id TEXT NOT NULL REFERENCES mailbox_sessions(id),
221
+ idempotency_key TEXT NOT NULL,
222
+ payload_json TEXT NOT NULL,
223
+ body TEXT NOT NULL,
224
+ thread_id TEXT NOT NULL REFERENCES mailbox_messages(id) DEFERRABLE INITIALLY DEFERRED,
225
+ reply_to_id TEXT REFERENCES mailbox_messages(id),
226
+ created_at_ms INTEGER NOT NULL,
227
+ UNIQUE(sender_actor_id, idempotency_key)
228
+ );
229
+ CREATE TABLE mailbox_receipts (
230
+ message_id TEXT NOT NULL REFERENCES mailbox_messages(id),
231
+ target_key TEXT NOT NULL,
232
+ actor_id TEXT NOT NULL REFERENCES mailbox_actors(id),
233
+ session_id TEXT REFERENCES mailbox_sessions(id),
234
+ read_at_ms INTEGER,
235
+ ack_at_ms INTEGER,
236
+ PRIMARY KEY(message_id, target_key),
237
+ CHECK(ack_at_ms IS NULL OR read_at_ms IS NOT NULL)
238
+ );
239
+ CREATE INDEX receipts_actor_session ON mailbox_receipts(actor_id, session_id, message_id);
240
+ CREATE INDEX messages_thread_seq ON mailbox_messages(thread_id, seq);
241
+ CREATE INDEX sessions_actor_presence ON mailbox_sessions(actor_id, closed_at_ms, expires_at_ms);
242
+ PRAGMA user_version = 1;
243
+ `;
244
+
245
+ // src/storage/migrations.ts
246
+ import { createHash } from "crypto";
247
+ var MIGRATION_2 = `
248
+ ALTER TABLE mailbox_actors ADD COLUMN credential_revision INTEGER NOT NULL DEFAULT 1;
249
+ ALTER TABLE mailbox_actors ADD COLUMN parent_generation INTEGER;
250
+ UPDATE mailbox_actors SET parent_generation = (SELECT generation FROM mailbox_actors p WHERE p.id = mailbox_actors.parent_actor_id) WHERE parent_actor_id IS NOT NULL;
251
+ CREATE TABLE mailbox_migrations (version INTEGER PRIMARY KEY, checksum TEXT NOT NULL);
252
+ CREATE TABLE mailbox_notifications (
253
+ message_id TEXT NOT NULL REFERENCES mailbox_messages(id),
254
+ actor_id TEXT NOT NULL REFERENCES mailbox_actors(id),
255
+ session_id TEXT NOT NULL REFERENCES mailbox_sessions(id),
256
+ generation INTEGER NOT NULL,
257
+ native_target TEXT NOT NULL,
258
+ state TEXT NOT NULL CHECK(state IN ('pending', 'leased', 'accepted', 'uncertain', 'failed', 'stale')),
259
+ attempts INTEGER NOT NULL DEFAULT 0,
260
+ lease_token TEXT,
261
+ lease_until_ms INTEGER,
262
+ next_attempt_ms INTEGER NOT NULL,
263
+ outcome_code TEXT,
264
+ PRIMARY KEY(message_id, actor_id, session_id)
265
+ );
266
+ CREATE INDEX notifications_pending ON mailbox_notifications(state, next_attempt_ms);
267
+ `;
268
+ function migrateDatabase(database) {
269
+ let version = database.get("PRAGMA user_version")?.user_version;
270
+ if (version === 1) {
271
+ database.exec(MIGRATION_2);
272
+ database.run(
273
+ "INSERT INTO mailbox_migrations(version, checksum) VALUES (?, ?)",
274
+ 2,
275
+ createHash("sha256").update(MIGRATION_2).digest("hex")
276
+ );
277
+ database.exec("PRAGMA user_version = 2");
278
+ version = 2;
279
+ }
280
+ if (version === 2) {
281
+ database.exec(MIGRATION_3);
282
+ database.run(
283
+ "INSERT INTO mailbox_migrations(version, checksum) VALUES (?, ?)",
284
+ 3,
285
+ createHash("sha256").update(MIGRATION_3).digest("hex")
286
+ );
287
+ database.exec("PRAGMA user_version = 3");
288
+ }
289
+ if (database.all("PRAGMA foreign_key_check").length > 0) {
290
+ throw new MailboxError(
291
+ "storage_corrupt",
292
+ "Migration failed foreign key validation"
293
+ );
294
+ }
295
+ }
296
+ var MIGRATION_3 = `
297
+ ALTER TABLE mailbox_actors ADD COLUMN authority_expires_at_ms INTEGER;
298
+ UPDATE mailbox_actors SET authority_expires_at_ms = created_at_ms + 60000 WHERE parent_actor_id IS NOT NULL;
299
+ `;
300
+ function validateMigrations(database) {
301
+ for (const [version, sql] of [
302
+ [2, MIGRATION_2],
303
+ [3, MIGRATION_3]
304
+ ]) {
305
+ const expected = createHash("sha256").update(sql).digest("hex");
306
+ if (database.get(
307
+ "SELECT checksum FROM mailbox_migrations WHERE version = ?",
308
+ version
309
+ )?.checksum !== expected) {
310
+ throw new MailboxError(
311
+ "schema_incompatible",
312
+ "Migration checksum does not match the supported schema"
313
+ );
314
+ }
315
+ }
316
+ }
317
+
318
+ // src/mailbox/sqlite.ts
319
+ import { chmodSync, mkdirSync as mkdirSync2 } from "fs";
320
+ import { dirname as dirname2 } from "path";
321
+ import { setTimeout as delay } from "timers/promises";
322
+ function storageError(error) {
323
+ const detail = error instanceof Error ? error.message : "";
324
+ if (/busy|locked/i.test(detail)) {
325
+ return new MailboxError("storage_locked", "Mailbox storage is busy", true);
326
+ }
327
+ if (/corrupt|malformed|not a database/i.test(detail)) {
328
+ return new MailboxError("storage_corrupt", "Mailbox storage is corrupt");
329
+ }
330
+ return new MailboxError(
331
+ "storage_unavailable",
332
+ "Mailbox storage cannot be accessed"
333
+ );
334
+ }
335
+ async function openDatabase(file) {
336
+ let native;
337
+ try {
338
+ if (file !== ":memory:")
339
+ mkdirSync2(dirname2(file), { recursive: true, mode: 448 });
340
+ native = (await openSqlite(file)).db;
341
+ if (file !== ":memory:") chmodSync(file, 384);
342
+ } catch (error) {
343
+ throw storageError(error);
344
+ }
345
+ const invoke = (operation) => {
346
+ try {
347
+ return operation();
348
+ } catch (error) {
349
+ throw storageError(error);
350
+ }
351
+ };
352
+ return {
353
+ exec: (sql) => invoke(() => native.exec(sql)),
354
+ get: (sql, ...values) => invoke(() => native.prepare(sql).get(...values)),
355
+ all: (sql, ...values) => invoke(() => native.prepare(sql).all(...values)),
356
+ run: (sql, ...values) => {
357
+ invoke(() => native.prepare(sql).run(...values));
358
+ },
359
+ close: () => native.close()
360
+ };
361
+ }
362
+ async function retryLocked(database, operation) {
363
+ const deadline = performance.now() + 5e3;
364
+ while (true) {
365
+ const remaining = Math.max(1, Math.floor(deadline - performance.now()));
366
+ database.exec(`PRAGMA busy_timeout = ${Math.min(1e3, remaining)}`);
367
+ try {
368
+ return operation();
369
+ } catch (error) {
370
+ if (!(error instanceof MailboxError) || error.code !== "storage_locked") {
371
+ throw error;
372
+ }
373
+ if (performance.now() >= deadline) {
374
+ throw error;
375
+ }
376
+ await delay(Math.min(25, Math.max(1, deadline - performance.now())));
377
+ }
378
+ }
379
+ }
380
+ function transaction(database, operation, write = true) {
381
+ return retryLocked(database, () => {
382
+ database.exec(write ? "BEGIN IMMEDIATE" : "BEGIN");
383
+ try {
384
+ const result = operation();
385
+ database.exec("COMMIT");
386
+ return result;
387
+ } catch (error) {
388
+ database.exec("ROLLBACK");
389
+ throw error;
390
+ }
391
+ });
392
+ }
393
+
394
+ // src/mailbox/store.ts
395
+ var PRESENCE_TTL_MS = 12e4;
396
+ var HEARTBEAT_INTERVAL_MS = 3e4;
397
+ function hash(secret2) {
398
+ return createHash2("sha256").update(secret2).digest("hex");
399
+ }
400
+ function secret() {
401
+ return randomBytes(32).toString("hex");
402
+ }
403
+ var MailboxStore = class _MailboxStore {
404
+ pending = Promise.resolve();
405
+ closed = false;
406
+ config;
407
+ database;
408
+ now;
409
+ constructor(config, database, now) {
410
+ this.config = config;
411
+ this.database = database;
412
+ this.now = now;
413
+ }
414
+ static async open(config, now = Date.now) {
415
+ if (!config.databasePath || !credentialSchema.shape.projectId.safeParse(config.projectId).success) {
416
+ throw new MailboxError(
417
+ "invalid_config",
418
+ "Provide a project UUID and database path"
419
+ );
420
+ }
421
+ if (!config.enabled) {
422
+ throw new MailboxError("mailbox_disabled", "Mailbox is disabled");
423
+ }
424
+ const database = await openDatabase(config.databasePath);
425
+ const store = new _MailboxStore(config, database, now);
426
+ try {
427
+ database.exec("PRAGMA busy_timeout = 1000");
428
+ database.exec("PRAGMA foreign_keys = ON");
429
+ await retryLocked(
430
+ database,
431
+ () => database.exec("PRAGMA journal_mode = WAL")
432
+ );
433
+ await transaction(database, () => {
434
+ const version = database.get("PRAGMA user_version")?.user_version;
435
+ if (version === 0) {
436
+ database.exec(SCHEMA_SQL);
437
+ database.run(
438
+ "INSERT INTO mailbox_project(id, created_at_ms) VALUES (?, ?)",
439
+ config.projectId,
440
+ now()
441
+ );
442
+ }
443
+ migrateDatabase(database);
444
+ store.checkSchema();
445
+ validateMigrations(database);
446
+ });
447
+ return store;
448
+ } catch (error) {
449
+ database.close();
450
+ throw error;
451
+ }
452
+ }
453
+ checkSchema() {
454
+ if (this.database.get("PRAGMA user_version")?.user_version !== SCHEMA_VERSION) {
455
+ throw new MailboxError(
456
+ "schema_incompatible",
457
+ "Mailbox schema version is not supported"
458
+ );
459
+ }
460
+ if (this.database.get("SELECT id FROM mailbox_project")?.id !== this.config.projectId) {
461
+ throw new MailboxError(
462
+ "project_mismatch",
463
+ "Mailbox database belongs to another project"
464
+ );
465
+ }
466
+ }
467
+ run(operation, write = true) {
468
+ if (this.closed) {
469
+ return Promise.reject(
470
+ new MailboxError("storage_unavailable", "Mailbox storage is closed")
471
+ );
472
+ }
473
+ const next = this.pending.then(
474
+ () => transaction(
475
+ this.database,
476
+ () => {
477
+ this.checkSchema();
478
+ return operation();
479
+ },
480
+ write
481
+ )
482
+ );
483
+ this.pending = next.catch(() => void 0);
484
+ return next;
485
+ }
486
+ registerActor(actorId, parentActorId, persist, authorityTtlMs = 6e4, parentBinding) {
487
+ actorIdSchema.parse(actorId);
488
+ if (parentActorId) {
489
+ actorIdSchema.parse(parentActorId);
490
+ }
491
+ return this.run(() => {
492
+ if (parentBinding) {
493
+ this.assertBinding(parentBinding);
494
+ if (parentActorId !== parentBinding.actorId) {
495
+ throw new MailboxError(
496
+ "unauthorized",
497
+ "Delegation parent does not match the issuer"
498
+ );
499
+ }
500
+ }
501
+ if (this.database.get("SELECT id FROM mailbox_actors WHERE id = ?", actorId)) {
502
+ throw new MailboxError("actor_exists", "Actor is already registered");
503
+ }
504
+ if (parentActorId && !this.database.get(
505
+ "SELECT id FROM mailbox_actors WHERE id = ?",
506
+ parentActorId
507
+ )) {
508
+ throw new MailboxError(
509
+ "unknown_actor",
510
+ "Parent actor is not registered"
511
+ );
512
+ }
513
+ if (parentActorId) {
514
+ const parent = this.database.get(
515
+ "SELECT a.parent_actor_id, s.closed_at_ms, s.expires_at_ms FROM mailbox_actors a LEFT JOIN mailbox_sessions s ON s.id = a.active_session_id WHERE a.id = ?",
516
+ parentActorId
517
+ );
518
+ if (parent?.parent_actor_id || parent?.closed_at_ms !== null || Number(parent?.expires_at_ms ?? 0) <= this.now()) {
519
+ throw new MailboxError(
520
+ "stale_parent",
521
+ "Delegation requires a live root parent attachment"
522
+ );
523
+ }
524
+ if (!Number.isSafeInteger(authorityTtlMs) || authorityTtlMs < 1 || authorityTtlMs > 3e5) {
525
+ throw new MailboxError(
526
+ "invalid_ttl",
527
+ "Child authority TTL must be between 1 and 300000 milliseconds"
528
+ );
529
+ }
530
+ }
531
+ const credential = {
532
+ projectId: this.config.projectId,
533
+ actorId,
534
+ secret: secret()
535
+ };
536
+ this.database.run(
537
+ "INSERT INTO mailbox_actors(id, credential_hash, parent_actor_id, parent_generation, created_at_ms, authority_expires_at_ms) VALUES (?, ?, ?, ?, ?, ?)",
538
+ actorId,
539
+ hash(credential.secret),
540
+ parentActorId ?? null,
541
+ parentActorId ? Number(
542
+ this.database.get(
543
+ "SELECT generation FROM mailbox_actors WHERE id = ?",
544
+ parentActorId
545
+ )?.generation
546
+ ) : null,
547
+ this.now(),
548
+ parentActorId ? this.now() + authorityTtlMs : null
549
+ );
550
+ persist?.(credential);
551
+ return credential;
552
+ });
553
+ }
554
+ registerChild(parent, actorId, ttlMs = 6e4) {
555
+ return this.registerActor(
556
+ actorId,
557
+ parent.actorId,
558
+ void 0,
559
+ ttlMs,
560
+ parent
561
+ );
562
+ }
563
+ attach(credential, runtime, replaceSessionId, nativeSessionId) {
564
+ credentialSchema.parse(credential);
565
+ runtimeSchema.parse(runtime);
566
+ if (nativeSessionId !== void 0 && (nativeSessionId.length < 1 || nativeSessionId.length > 256 || /[\x00-\x1f]/u.test(nativeSessionId))) {
567
+ throw new MailboxError(
568
+ "invalid_target",
569
+ "Native session ID must be a bounded nonempty identifier"
570
+ );
571
+ }
572
+ return this.run(() => {
573
+ const actor = this.database.get(
574
+ "SELECT * FROM mailbox_actors WHERE id = ?",
575
+ credential.actorId
576
+ );
577
+ if (credential.projectId !== this.config.projectId || actor?.credential_hash !== hash(credential.secret)) {
578
+ throw new MailboxError("unauthorized", "Actor credential is invalid");
579
+ }
580
+ if (actor.authority_expires_at_ms !== null && Number(actor.authority_expires_at_ms) <= this.now()) {
581
+ throw new MailboxError(
582
+ "authority_expired",
583
+ "Child authority has expired"
584
+ );
585
+ }
586
+ if (actor.parent_actor_id) {
587
+ const parent = this.database.get(
588
+ "SELECT a.generation, s.expires_at_ms, s.closed_at_ms FROM mailbox_actors a LEFT JOIN mailbox_sessions s ON s.id = a.active_session_id WHERE a.id = ?",
589
+ actor.parent_actor_id
590
+ );
591
+ if (!parent || parent.generation !== actor.parent_generation || parent.closed_at_ms !== null || Number(parent.expires_at_ms ?? 0) <= this.now()) {
592
+ throw new MailboxError(
593
+ "stale_parent",
594
+ "Parent attachment is no longer current"
595
+ );
596
+ }
597
+ }
598
+ const activeId = actor.active_session_id;
599
+ const active = activeId ? this.database.get(
600
+ "SELECT * FROM mailbox_sessions WHERE id = ?",
601
+ activeId
602
+ ) : void 0;
603
+ if (replaceSessionId && replaceSessionId !== activeId) {
604
+ throw new MailboxError(
605
+ "session_conflict",
606
+ "Expected attachment is no longer current"
607
+ );
608
+ }
609
+ if (active && Number(active.expires_at_ms) > this.now() && !replaceSessionId) {
610
+ throw new MailboxError(
611
+ "session_conflict",
612
+ "Actor already has a live attachment"
613
+ );
614
+ }
615
+ const generation = Number(actor.generation) + 1;
616
+ if (!Number.isSafeInteger(generation)) {
617
+ throw new MailboxError(
618
+ "sequence_overflow",
619
+ "Attachment generation exceeds safe integer range"
620
+ );
621
+ }
622
+ if (activeId) {
623
+ this.database.run(
624
+ "UPDATE mailbox_sessions SET closed_at_ms = ? WHERE id = ?",
625
+ this.now(),
626
+ activeId
627
+ );
628
+ }
629
+ const binding = {
630
+ projectId: this.config.projectId,
631
+ actorId: credential.actorId,
632
+ sessionId: randomUUID2(),
633
+ generation,
634
+ secret: secret()
635
+ };
636
+ this.database.run(
637
+ "INSERT INTO mailbox_sessions(id, actor_id, generation, secret_hash, runtime, native_session_id, created_at_ms, heartbeat_at_ms, expires_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
638
+ binding.sessionId,
639
+ binding.actorId,
640
+ generation,
641
+ hash(binding.secret),
642
+ runtime,
643
+ nativeSessionId ?? null,
644
+ this.now(),
645
+ this.now(),
646
+ this.now() + PRESENCE_TTL_MS
647
+ );
648
+ this.database.run(
649
+ "UPDATE mailbox_actors SET generation = ?, active_session_id = ? WHERE id = ?",
650
+ generation,
651
+ binding.sessionId,
652
+ binding.actorId
653
+ );
654
+ return binding;
655
+ });
656
+ }
657
+ assertBinding(binding) {
658
+ bindingSchema.parse(binding);
659
+ const session = this.database.get(
660
+ "SELECT s.*, a.active_session_id, a.parent_actor_id, a.parent_generation, a.authority_expires_at_ms FROM mailbox_sessions s JOIN mailbox_actors a ON a.id = s.actor_id WHERE s.id = ?",
661
+ binding.sessionId
662
+ );
663
+ if (binding.projectId !== this.config.projectId || !session || session.actor_id !== binding.actorId || session.secret_hash !== hash(binding.secret) || session.generation !== binding.generation || session.active_session_id !== binding.sessionId || session.closed_at_ms !== null || Number(session.expires_at_ms) <= this.now()) {
664
+ throw new MailboxError(
665
+ "stale_session",
666
+ "Attachment is invalid, expired or superseded"
667
+ );
668
+ }
669
+ if (session.parent_actor_id) {
670
+ if (Number(session.authority_expires_at_ms ?? 0) <= this.now()) {
671
+ throw new MailboxError(
672
+ "authority_expired",
673
+ "Child authority has expired"
674
+ );
675
+ }
676
+ const parent = this.database.get(
677
+ "SELECT a.generation, s.closed_at_ms, s.expires_at_ms FROM mailbox_actors a LEFT JOIN mailbox_sessions s ON s.id = a.active_session_id WHERE a.id = ?",
678
+ session.parent_actor_id
679
+ );
680
+ if (parent?.generation !== session.parent_generation || parent?.closed_at_ms !== null || Number(parent?.expires_at_ms ?? 0) <= this.now()) {
681
+ throw new MailboxError(
682
+ "stale_parent",
683
+ "Parent attachment is no longer current"
684
+ );
685
+ }
686
+ }
687
+ return session;
688
+ }
689
+ rotateActor(actorId, persist) {
690
+ actorIdSchema.parse(actorId);
691
+ return this.run(() => {
692
+ if (!this.database.get(
693
+ "SELECT id FROM mailbox_actors WHERE id = ?",
694
+ actorId
695
+ )) {
696
+ throw new MailboxError("unknown_actor", "Actor is not registered");
697
+ }
698
+ const credential = {
699
+ projectId: this.config.projectId,
700
+ actorId,
701
+ secret: secret()
702
+ };
703
+ this.database.run(
704
+ "UPDATE mailbox_sessions SET closed_at_ms = COALESCE(closed_at_ms, ?) WHERE actor_id = ?",
705
+ this.now(),
706
+ actorId
707
+ );
708
+ this.database.run(
709
+ "UPDATE mailbox_actors SET credential_hash = ?, credential_revision = credential_revision + 1, generation = generation + 1, active_session_id = NULL WHERE id = ?",
710
+ hash(credential.secret),
711
+ actorId
712
+ );
713
+ this.database.run(
714
+ "UPDATE mailbox_notifications SET state = 'stale', lease_token = NULL, lease_until_ms = NULL WHERE actor_id = ? AND state IN ('pending', 'leased')",
715
+ actorId
716
+ );
717
+ persist?.(credential);
718
+ return credential;
719
+ });
720
+ }
721
+ heartbeat(binding) {
722
+ return this.run(() => {
723
+ this.assertBinding(binding);
724
+ this.database.run(
725
+ "UPDATE mailbox_sessions SET heartbeat_at_ms = ?, expires_at_ms = ? WHERE id = ?",
726
+ this.now(),
727
+ this.now() + PRESENCE_TTL_MS,
728
+ binding.sessionId
729
+ );
730
+ });
731
+ }
732
+ detach(binding) {
733
+ return this.run(() => {
734
+ const session = this.database.get(
735
+ "SELECT secret_hash FROM mailbox_sessions WHERE id = ?",
736
+ binding.sessionId
737
+ );
738
+ if (binding.projectId !== this.config.projectId || session?.secret_hash !== hash(binding.secret)) {
739
+ throw new MailboxError(
740
+ "unauthorized",
741
+ "Attachment credential is invalid"
742
+ );
743
+ }
744
+ this.database.run(
745
+ "UPDATE mailbox_sessions SET closed_at_ms = COALESCE(closed_at_ms, ?) WHERE id = ?",
746
+ this.now(),
747
+ binding.sessionId
748
+ );
749
+ this.database.run(
750
+ "UPDATE mailbox_actors SET active_session_id = NULL WHERE id = ? AND active_session_id = ?",
751
+ binding.actorId,
752
+ binding.sessionId
753
+ );
754
+ });
755
+ }
756
+ exportRecords(actorId) {
757
+ if (actorId) {
758
+ actorIdSchema.parse(actorId);
759
+ }
760
+ return this.run(() => {
761
+ const where = actorId ? "WHERE m.sender_actor_id = ? OR EXISTS (SELECT 1 FROM mailbox_receipts r WHERE r.message_id = m.id AND r.actor_id = ?)" : "";
762
+ const values = actorId ? [actorId, actorId] : [];
763
+ const messages = this.database.all(
764
+ `SELECT m.* FROM mailbox_messages m ${where} ORDER BY m.seq`,
765
+ ...values
766
+ );
767
+ const records = [
768
+ {
769
+ type: "metadata",
770
+ version: 1,
771
+ projectId: this.config.projectId,
772
+ exportedAtMs: this.now(),
773
+ actorId: actorId ?? null
774
+ }
775
+ ];
776
+ for (const message of messages) {
777
+ const { payload_json: _payload, ...data } = message;
778
+ records.push({ type: "message", ...data });
779
+ for (const receipt of this.database.all(
780
+ "SELECT * FROM mailbox_receipts WHERE message_id = ? ORDER BY target_key",
781
+ message.id
782
+ )) {
783
+ records.push({ type: "receipt", ...receipt });
784
+ }
785
+ }
786
+ return records;
787
+ }, false);
788
+ }
789
+ async backup(destination) {
790
+ if (this.closed) {
791
+ throw new MailboxError(
792
+ "storage_unavailable",
793
+ "Mailbox storage is closed"
794
+ );
795
+ }
796
+ if (existsSync2(destination)) {
797
+ throw new MailboxError(
798
+ "destination_exists",
799
+ "Backup destination already exists"
800
+ );
801
+ }
802
+ const next = this.pending.then(() => {
803
+ this.checkSchema();
804
+ this.database.run("VACUUM INTO ?", destination);
805
+ chmodSync2(destination, 384);
806
+ });
807
+ this.pending = next.catch(() => void 0);
808
+ await next;
809
+ }
810
+ async close() {
811
+ if (this.closed) {
812
+ return;
813
+ }
814
+ this.closed = true;
815
+ await this.pending;
816
+ this.database.close();
817
+ }
818
+ };
819
+ async function restoreBackup(source, config) {
820
+ copyFileSync(source, config.databasePath, constants2.COPYFILE_EXCL);
821
+ let store;
822
+ try {
823
+ chmodSync2(config.databasePath, 384);
824
+ const probe = await openDatabase(config.databasePath);
825
+ try {
826
+ if (Number(probe.get("PRAGMA user_version")?.user_version) < 1 || Number(probe.get("PRAGMA user_version")?.user_version) > SCHEMA_VERSION || probe.get("SELECT id FROM mailbox_project")?.id !== config.projectId) {
827
+ throw new MailboxError(
828
+ "schema_incompatible",
829
+ "Backup schema or project is not compatible"
830
+ );
831
+ }
832
+ } finally {
833
+ probe.close();
834
+ }
835
+ store = await MailboxStore.open(config);
836
+ const integrity = store.database.get("PRAGMA integrity_check");
837
+ if (integrity?.integrity_check !== "ok" || store.database.all("PRAGMA foreign_key_check").length > 0) {
838
+ throw new MailboxError(
839
+ "storage_corrupt",
840
+ "Restored backup failed integrity checks"
841
+ );
842
+ }
843
+ } catch (error) {
844
+ await store?.close();
845
+ unlinkSync(config.databasePath);
846
+ throw error;
847
+ }
848
+ await store.close();
849
+ }
850
+
851
+ // src/mailbox/service.ts
852
+ import { randomUUID as randomUUID3 } from "crypto";
853
+ var MailboxService = class {
854
+ store;
855
+ binding;
856
+ constructor(store, binding) {
857
+ this.store = store;
858
+ this.binding = binding;
859
+ }
860
+ async call(operation, input) {
861
+ try {
862
+ operationSchema.parse(operation);
863
+ let data;
864
+ if (operation === "send") {
865
+ data = await this.send(sendSchema.parse(input));
866
+ } else if (operation === "inbox") {
867
+ data = await this.inbox(inboxSchema.parse(input));
868
+ } else if (operation === "read" || operation === "ack") {
869
+ data = await this.receipt(
870
+ messageSchema.parse(input).messageId,
871
+ operation
872
+ );
873
+ } else {
874
+ sessionsSchema.parse(input);
875
+ data = await this.sessions();
876
+ }
877
+ return { ok: true, data };
878
+ } catch (error) {
879
+ if (error instanceof Error && error.name === "ZodError") {
880
+ return failure(
881
+ new MailboxError(
882
+ "invalid_input",
883
+ "Mailbox input does not match the operation schema"
884
+ )
885
+ );
886
+ }
887
+ return failure(error);
888
+ }
889
+ }
890
+ send(input) {
891
+ return this.store.run(() => {
892
+ const session = this.store.assertBinding(this.binding);
893
+ const recipients = [
894
+ ...new Map(
895
+ input.recipients.map((recipient) => {
896
+ const sessionId = recipient.sessionId ?? null;
897
+ const key = `${recipient.actorId}/${sessionId ?? ""}`;
898
+ return [
899
+ key,
900
+ { actorId: recipient.actorId, sessionId, key }
901
+ ];
902
+ })
903
+ ).values()
904
+ ].sort((a, b) => a.key.localeCompare(b.key, "en"));
905
+ const payload = JSON.stringify({
906
+ body: input.body,
907
+ replyToId: input.replyToId ?? null,
908
+ recipients: recipients.map(({ key: _key, ...recipient }) => recipient)
909
+ });
910
+ const previous = this.store.database.get(
911
+ "SELECT id, seq, thread_id, payload_json FROM mailbox_messages WHERE sender_actor_id = ? AND idempotency_key = ?",
912
+ this.binding.actorId,
913
+ input.idempotencyKey
914
+ );
915
+ if (previous) {
916
+ if (previous.payload_json !== payload) {
917
+ throw new MailboxError(
918
+ "idempotency_conflict",
919
+ "Idempotency key was used for a different payload"
920
+ );
921
+ }
922
+ return {
923
+ messageId: previous.id,
924
+ seq: previous.seq,
925
+ threadId: previous.thread_id,
926
+ duplicate: true
927
+ };
928
+ }
929
+ for (const recipient of recipients) {
930
+ if (session.parent_actor_id && recipient.actorId !== session.parent_actor_id) {
931
+ throw new MailboxError(
932
+ "recipient_forbidden",
933
+ "Child actor may send only to its parent"
934
+ );
935
+ }
936
+ if (!this.store.database.get(
937
+ "SELECT id FROM mailbox_actors WHERE id = ?",
938
+ recipient.actorId
939
+ )) {
940
+ throw new MailboxError(
941
+ "unknown_actor",
942
+ "Recipient actor is not registered"
943
+ );
944
+ }
945
+ if (recipient.sessionId && !this.store.database.get(
946
+ "SELECT id FROM mailbox_sessions WHERE id = ? AND actor_id = ?",
947
+ recipient.sessionId,
948
+ recipient.actorId
949
+ )) {
950
+ throw new MailboxError(
951
+ "unknown_session",
952
+ "Recipient session does not belong to the actor"
953
+ );
954
+ }
955
+ }
956
+ const id = randomUUID3();
957
+ let threadId = id;
958
+ if (input.replyToId) {
959
+ const original = this.visibleMessage(input.replyToId);
960
+ threadId = original.thread_id;
961
+ if (original.read_at_ms === null) {
962
+ throw new MailboxError(
963
+ "reply_requires_read",
964
+ "Read the original message before replying"
965
+ );
966
+ }
967
+ }
968
+ this.store.database.run(
969
+ "INSERT INTO mailbox_messages(id, sender_actor_id, sender_session_id, idempotency_key, payload_json, body, thread_id, reply_to_id, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
970
+ id,
971
+ this.binding.actorId,
972
+ this.binding.sessionId,
973
+ input.idempotencyKey,
974
+ payload,
975
+ input.body,
976
+ threadId,
977
+ input.replyToId ?? null,
978
+ this.store.now()
979
+ );
980
+ const seq = this.store.database.get(
981
+ "SELECT seq FROM mailbox_messages WHERE id = ?",
982
+ id
983
+ )?.seq;
984
+ if (!Number.isSafeInteger(seq)) {
985
+ throw new MailboxError(
986
+ "sequence_overflow",
987
+ "Message sequence exceeds safe integer range"
988
+ );
989
+ }
990
+ for (const recipient of recipients) {
991
+ this.store.database.run(
992
+ "INSERT INTO mailbox_receipts(message_id, target_key, actor_id, session_id) VALUES (?, ?, ?, ?)",
993
+ id,
994
+ recipient.key,
995
+ recipient.actorId,
996
+ recipient.sessionId
997
+ );
998
+ }
999
+ return { messageId: id, seq, threadId, duplicate: false };
1000
+ });
1001
+ }
1002
+ visibleMessage(messageId) {
1003
+ const message = this.store.database.get(
1004
+ "SELECT m.*, r.target_key, r.read_at_ms, r.ack_at_ms FROM mailbox_messages m JOIN mailbox_receipts r ON r.message_id = m.id WHERE m.id = ? AND r.actor_id = ? AND (r.session_id IS NULL OR r.session_id = ?) ORDER BY r.target_key LIMIT 1",
1005
+ messageId,
1006
+ this.binding.actorId,
1007
+ this.binding.sessionId
1008
+ );
1009
+ if (!message) {
1010
+ throw new MailboxError(
1011
+ "unknown_message",
1012
+ "Message is not available to this attachment"
1013
+ );
1014
+ }
1015
+ return message;
1016
+ }
1017
+ inbox(input) {
1018
+ return this.store.run(() => {
1019
+ this.store.assertBinding(this.binding);
1020
+ let after = 0;
1021
+ let upper = Number(
1022
+ this.store.database.get(
1023
+ "SELECT COALESCE(MAX(seq), 0) AS upper FROM mailbox_messages"
1024
+ )?.upper
1025
+ );
1026
+ if (!Number.isSafeInteger(upper)) {
1027
+ throw new MailboxError(
1028
+ "sequence_overflow",
1029
+ "Message sequence exceeds safe integer range"
1030
+ );
1031
+ }
1032
+ if (input.cursor) {
1033
+ let decoded;
1034
+ try {
1035
+ decoded = cursorSchema.parse(
1036
+ JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"))
1037
+ );
1038
+ } catch {
1039
+ throw new MailboxError("invalid_cursor", "Cursor is malformed");
1040
+ }
1041
+ if (decoded.projectId !== this.binding.projectId || decoded.actorId !== this.binding.actorId || decoded.sessionId !== this.binding.sessionId || decoded.state !== input.state || decoded.threadId !== (input.threadId ?? null) || decoded.after > decoded.upper || decoded.upper > upper) {
1042
+ throw new MailboxError(
1043
+ "invalid_cursor",
1044
+ "Cursor does not match the current inbox scope"
1045
+ );
1046
+ }
1047
+ after = decoded.after;
1048
+ upper = decoded.upper;
1049
+ }
1050
+ const stateSql = input.state === "unread" ? "AND r.read_at_ms IS NULL" : "";
1051
+ const ackSql = input.state === "unacknowledged" ? "AND r.ack_at_ms IS NULL" : "";
1052
+ const threadSql = input.threadId ? "AND m.thread_id = ?" : "";
1053
+ const values = [
1054
+ this.binding.actorId,
1055
+ this.binding.sessionId,
1056
+ after,
1057
+ upper
1058
+ ];
1059
+ if (input.threadId) {
1060
+ values.push(input.threadId);
1061
+ }
1062
+ values.push(input.limit + 1);
1063
+ const rows = this.store.database.all(
1064
+ `SELECT DISTINCT m.seq, m.id AS messageId, m.sender_actor_id AS senderActorId, m.thread_id AS threadId, m.reply_to_id AS replyToId, m.created_at_ms AS createdAtMs FROM mailbox_messages m JOIN mailbox_receipts r ON r.message_id = m.id WHERE r.actor_id = ? AND (r.session_id IS NULL OR r.session_id = ?) AND m.seq > ? AND m.seq <= ? ${stateSql} ${ackSql} ${threadSql} ORDER BY m.seq LIMIT ?`,
1065
+ ...values
1066
+ );
1067
+ const items = rows.slice(0, input.limit);
1068
+ let nextCursor = null;
1069
+ if (rows.length > input.limit) {
1070
+ const cursor = {
1071
+ version: 1,
1072
+ projectId: this.binding.projectId,
1073
+ actorId: this.binding.actorId,
1074
+ sessionId: this.binding.sessionId,
1075
+ state: input.state,
1076
+ threadId: input.threadId ?? null,
1077
+ after: items.at(-1)?.seq,
1078
+ upper
1079
+ };
1080
+ nextCursor = Buffer.from(JSON.stringify(cursor)).toString("base64url");
1081
+ }
1082
+ return { items, nextCursor };
1083
+ }, false);
1084
+ }
1085
+ receipt(messageId, operation) {
1086
+ return this.store.run(() => {
1087
+ this.store.assertBinding(this.binding);
1088
+ const message = this.visibleMessage(messageId);
1089
+ if (operation === "ack" && message.read_at_ms === null) {
1090
+ throw new MailboxError(
1091
+ "ack_requires_read",
1092
+ "Read the message before acknowledging it"
1093
+ );
1094
+ }
1095
+ const column = operation === "read" ? "read_at_ms" : "ack_at_ms";
1096
+ this.store.database.run(
1097
+ `UPDATE mailbox_receipts SET ${column} = COALESCE(${column}, ?) WHERE message_id = ? AND actor_id = ? AND (session_id IS NULL OR session_id = ?)`,
1098
+ this.store.now(),
1099
+ messageId,
1100
+ this.binding.actorId,
1101
+ this.binding.sessionId
1102
+ );
1103
+ const current = this.visibleMessage(messageId);
1104
+ const data = {
1105
+ messageId,
1106
+ readAtMs: current.read_at_ms,
1107
+ ackAtMs: current.ack_at_ms
1108
+ };
1109
+ if (operation === "ack") {
1110
+ return data;
1111
+ }
1112
+ return {
1113
+ ...data,
1114
+ body: current.body,
1115
+ senderActorId: current.sender_actor_id,
1116
+ threadId: current.thread_id,
1117
+ replyToId: current.reply_to_id
1118
+ };
1119
+ });
1120
+ }
1121
+ sessions() {
1122
+ return this.store.run(() => {
1123
+ const session = this.store.assertBinding(this.binding);
1124
+ const scope = session.parent_actor_id ? "AND (s.actor_id = ? OR s.actor_id = ?)" : "";
1125
+ const values = session.parent_actor_id ? [
1126
+ this.store.now(),
1127
+ this.binding.actorId,
1128
+ session.parent_actor_id
1129
+ ] : [this.store.now()];
1130
+ const items = this.store.database.all(
1131
+ `SELECT s.id AS sessionId, s.actor_id AS actorId, s.runtime, s.native_session_id AS nativeSessionId, s.expires_at_ms AS expiresAtMs FROM mailbox_sessions s JOIN mailbox_actors a ON a.active_session_id = s.id WHERE s.closed_at_ms IS NULL AND s.expires_at_ms > ? ${scope} ORDER BY s.actor_id`,
1132
+ ...values
1133
+ );
1134
+ return { items };
1135
+ }, false);
1136
+ }
1137
+ };
1138
+
1139
+ // src/mailbox/attachment.ts
1140
+ async function attachMailbox(options) {
1141
+ const store = await MailboxStore.open(options.config);
1142
+ let binding;
1143
+ try {
1144
+ binding = await store.attach(
1145
+ readCredential(options.actorFile),
1146
+ options.runtime,
1147
+ options.replaceSessionId,
1148
+ options.nativeSessionId
1149
+ );
1150
+ if (options.bindingFile) writeBindingFile(options.bindingFile, binding);
1151
+ } catch (error) {
1152
+ if (binding) await store.detach(binding);
1153
+ await store.close();
1154
+ throw error;
1155
+ }
1156
+ const current = binding;
1157
+ let shutdown;
1158
+ const close = () => {
1159
+ if (shutdown) return shutdown;
1160
+ clearInterval(heartbeat);
1161
+ shutdown = (async () => {
1162
+ try {
1163
+ await store.detach(current);
1164
+ } finally {
1165
+ await store.close();
1166
+ }
1167
+ })();
1168
+ return shutdown;
1169
+ };
1170
+ const heartbeat = setInterval(() => {
1171
+ void store.heartbeat(current).catch(() => {
1172
+ options.onExpired?.();
1173
+ void close().catch(() => void 0);
1174
+ });
1175
+ }, HEARTBEAT_INTERVAL_MS);
1176
+ heartbeat.unref();
1177
+ return { service: new MailboxService(store, current), close };
1178
+ }
1179
+
1180
+ export {
1181
+ MailboxError,
1182
+ failure,
1183
+ mailboxConfigFrom,
1184
+ initializeMailboxConfig,
1185
+ readCredential,
1186
+ readBinding,
1187
+ writePrivateJson,
1188
+ writeBindingFile,
1189
+ SCHEMA_VERSION,
1190
+ openDatabase,
1191
+ PRESENCE_TTL_MS,
1192
+ HEARTBEAT_INTERVAL_MS,
1193
+ MailboxStore,
1194
+ restoreBackup,
1195
+ MailboxService,
1196
+ attachMailbox
1197
+ };
1198
+ //# sourceMappingURL=chunk-HNAVQG6R.js.map