@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.
- package/README.md +177 -8
- package/dist/attachment-tU5FZrgZ.d.ts +17 -0
- package/dist/chunk-HNAVQG6R.js +1198 -0
- package/dist/chunk-JRSR4JBR.js +88 -0
- package/dist/chunk-MSTKY33C.js +147 -0
- package/dist/{chunk-VQHNG3KM.js → chunk-VRZFM2M5.js} +53 -75
- package/dist/chunk-XEH4ZGGJ.js +336 -0
- package/dist/chunk-XOH2CW6P.js +297 -0
- package/dist/{chunk-QCOJ3WCM.js → chunk-ZUFFVIHQ.js} +166 -117
- package/dist/cli.js +507 -74
- package/dist/config-CJ43_oBS.d.ts +49 -0
- package/dist/embed-BBhv3y5x.d.ts +53 -0
- package/dist/encoder-DGRy9skC.d.ts +36 -0
- package/dist/http.d.ts +7 -38
- package/dist/http.js +18 -7
- package/dist/index.d.ts +25 -44
- package/dist/index.js +31 -11
- package/dist/mailbox/index.d.ts +44 -0
- package/dist/mailbox/index.js +387 -0
- package/dist/{server-BBZDjzWZ.d.ts → server-CwWcRjqz.d.ts} +135 -15
- package/dist/service-qpCsOXkQ.d.ts +116 -0
- package/dist/stdio.d.ts +10 -4
- package/dist/stdio.js +9 -4
- package/package.json +5 -1
- package/dist/catalog-BU5VLLyd.d.ts +0 -121
- package/dist/chunk-7DD2ADL2.js +0 -160
- package/dist/chunk-AJYMOFVX.js +0 -41
- package/dist/chunk-R2QPKEI4.js +0 -134
|
@@ -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
|