@agimon-ai/po-session 0.0.0-alpha.2 → 0.0.0-alpha.21
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/LICENSE +149 -21
- package/dist/apiContracts.cjs.map +1 -1
- package/dist/apiContracts.mjs.map +1 -1
- package/dist/extensions/server.cjs +104 -67
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.d.mts +2 -2
- package/dist/extensions/server.d.mts.map +1 -1
- package/dist/extensions/server.mjs +103 -65
- package/dist/extensions/server.mjs.map +1 -1
- package/dist/index.cjs +6 -4
- package/dist/index.d.mts +132 -94
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -2
- package/dist/{sessionDelivery-DpLWlaum.mjs → sessionDelivery-4svjzWUW.mjs} +394 -241
- package/dist/sessionDelivery-4svjzWUW.mjs.map +1 -0
- package/dist/{sessionDelivery-CkuX6jlw.cjs → sessionDelivery-CGWuQyLV.cjs} +424 -247
- package/dist/sessionDelivery-CGWuQyLV.cjs.map +1 -0
- package/package.json +13 -13
- package/dist/sessionDelivery-CkuX6jlw.cjs.map +0 -1
- package/dist/sessionDelivery-DpLWlaum.mjs.map +0 -1
|
@@ -23,19 +23,21 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
23
23
|
let _agimon_ai_po_core_backgroundWork = require("@agimon-ai/po-core/backgroundWork");
|
|
24
24
|
let typebox_value = require("typebox/value");
|
|
25
25
|
let node_crypto = require("node:crypto");
|
|
26
|
-
let node_fs = require("node:fs");
|
|
27
|
-
node_fs = __toESM(node_fs, 1);
|
|
28
26
|
let node_path = require("node:path");
|
|
29
27
|
node_path = __toESM(node_path, 1);
|
|
30
|
-
let
|
|
28
|
+
let _agimon_ai_po_core_platform = require("@agimon-ai/po-core/platform");
|
|
31
29
|
let _agimon_ai_po_core_piSettings = require("@agimon-ai/po-core/piSettings");
|
|
30
|
+
let node_fs = require("node:fs");
|
|
31
|
+
node_fs = __toESM(node_fs, 1);
|
|
32
|
+
let node_sqlite = require("node:sqlite");
|
|
33
|
+
let node_timers = require("node:timers");
|
|
32
34
|
//#region src/services/backgroundWorkService/index.ts
|
|
33
35
|
/** Creates the generation-bound, pull-based coordinator shared by session extensions. */
|
|
34
36
|
function createBackgroundWorkService(ctx) {
|
|
35
37
|
const registrations = /* @__PURE__ */ new Map();
|
|
36
|
-
const generation = `
|
|
38
|
+
const generation = `po-background-work:${crypto.randomUUID()}`;
|
|
37
39
|
const changed = (provider, providerGeneration, kind) => {
|
|
38
|
-
ctx.emit(_agimon_ai_po_core_backgroundWork.
|
|
40
|
+
ctx.emit(_agimon_ai_po_core_backgroundWork.PO_BACKGROUND_WORK_CHANGED_EVENT, {
|
|
39
41
|
provider,
|
|
40
42
|
generation: providerGeneration,
|
|
41
43
|
kind
|
|
@@ -99,11 +101,8 @@ function createBackgroundWorkService(ctx) {
|
|
|
99
101
|
}
|
|
100
102
|
/** Installs one coordinator in the current session service context. */
|
|
101
103
|
function provideBackgroundWorkService(context) {
|
|
102
|
-
context.provide(_agimon_ai_po_core_backgroundWork.
|
|
104
|
+
context.provide(_agimon_ai_po_core_backgroundWork.PO_BACKGROUND_WORK_SERVICE, createBackgroundWorkService(context));
|
|
103
105
|
}
|
|
104
|
-
//#endregion
|
|
105
|
-
//#region src/services/peerTransport/index.ts
|
|
106
|
-
const PEER_CONFIG_VERSION = 1;
|
|
107
106
|
const PEER_CONFIG_FILE = "peers.json";
|
|
108
107
|
const PEER_ROUTE = "/api/plugins/session-peer/inbox";
|
|
109
108
|
const PEER_HEADER = "x-po-session-peer";
|
|
@@ -111,7 +110,8 @@ const TIMESTAMP_HEADER = "x-po-session-timestamp";
|
|
|
111
110
|
const SIGNATURE_HEADER = "x-po-session-signature";
|
|
112
111
|
const MAX_CLOCK_SKEW_MS = 3e5;
|
|
113
112
|
const MAX_REPLAY_ENTRIES = 1e4;
|
|
114
|
-
|
|
113
|
+
/** File the replay guard database lives in, beside the peer config. */
|
|
114
|
+
const PEER_REPLAY_DATABASE_FILE = "peer-replay.sqlite";
|
|
115
115
|
function validId(value) {
|
|
116
116
|
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
|
|
117
117
|
}
|
|
@@ -140,29 +140,144 @@ function peerOf(value) {
|
|
|
140
140
|
function sessionPeerConfigPath(homeDirectory) {
|
|
141
141
|
return node_path.default.join((0, _agimon_ai_po_core_piSettings.poHomeDirectory)(homeDirectory), "session", PEER_CONFIG_FILE);
|
|
142
142
|
}
|
|
143
|
-
/**
|
|
144
|
-
function
|
|
143
|
+
/** Group or other permission bits on the peer config make it untrusted. */
|
|
144
|
+
function peerConfigModeIsPrivate(mode) {
|
|
145
|
+
return (mode & 63) === 0;
|
|
146
|
+
}
|
|
147
|
+
/** Validates peer config text. Invalid config fails closed. */
|
|
148
|
+
function parseSessionPeerConfig(text) {
|
|
145
149
|
let parsed;
|
|
146
150
|
try {
|
|
147
|
-
|
|
148
|
-
if ((node_fs.default.statSync(file).mode & 63) !== 0) return void 0;
|
|
149
|
-
parsed = JSON.parse(node_fs.default.readFileSync(file, "utf8"));
|
|
151
|
+
parsed = JSON.parse(text);
|
|
150
152
|
} catch {
|
|
151
153
|
return;
|
|
152
154
|
}
|
|
153
155
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
154
156
|
const record = parsed;
|
|
155
|
-
if (record.version !==
|
|
157
|
+
if (record.version !== 1 || !validId(record.hostId) || !Array.isArray(record.peers)) return void 0;
|
|
156
158
|
const peers = record.peers.map(peerOf);
|
|
157
159
|
if (peers.some((peer) => peer === void 0)) return void 0;
|
|
158
160
|
const resolved = peers;
|
|
159
161
|
if (new Set(resolved.map((peer) => peer.hostId)).size !== resolved.length || resolved.some((peer) => peer.hostId === record.hostId)) return void 0;
|
|
160
162
|
return {
|
|
161
|
-
version:
|
|
163
|
+
version: 1,
|
|
162
164
|
hostId: record.hostId,
|
|
163
165
|
peers: resolved
|
|
164
166
|
};
|
|
165
167
|
}
|
|
168
|
+
/** Replay guard SQL shared by the async guard and the Node-only sync shim. */
|
|
169
|
+
const PEER_REPLAY_SQL = Object.freeze({
|
|
170
|
+
schema: `
|
|
171
|
+
CREATE TABLE IF NOT EXISTS peer_request_replays (
|
|
172
|
+
signature TEXT PRIMARY KEY,
|
|
173
|
+
expires_at INTEGER NOT NULL
|
|
174
|
+
);
|
|
175
|
+
`,
|
|
176
|
+
removeExpired: "DELETE FROM peer_request_replays WHERE expires_at < ?",
|
|
177
|
+
insert: "INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)",
|
|
178
|
+
insertReturning: "INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?) RETURNING signature",
|
|
179
|
+
count: "SELECT COUNT(*) AS count FROM peer_request_replays",
|
|
180
|
+
trim: `
|
|
181
|
+
DELETE FROM peer_request_replays
|
|
182
|
+
WHERE signature IN (
|
|
183
|
+
SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?
|
|
184
|
+
)
|
|
185
|
+
`
|
|
186
|
+
});
|
|
187
|
+
function peerRequestSignature(request) {
|
|
188
|
+
return request.headers.get(SIGNATURE_HEADER);
|
|
189
|
+
}
|
|
190
|
+
function peerReplayExpiry(now) {
|
|
191
|
+
return now + MAX_CLOCK_SKEW_MS;
|
|
192
|
+
}
|
|
193
|
+
function peerReplayOverflow(total) {
|
|
194
|
+
return total > MAX_REPLAY_ENTRIES ? total - MAX_REPLAY_ENTRIES : 0;
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/services/peerTransport/sync.ts
|
|
198
|
+
/**
|
|
199
|
+
* Reads operator-provisioned peers. Invalid or unreadable config fails closed.
|
|
200
|
+
*
|
|
201
|
+
* @deprecated Node-only sync shim; use readSessionPeerConfigAsync. Removed in M2.
|
|
202
|
+
*/
|
|
203
|
+
function readSessionPeerConfig(homeDirectory) {
|
|
204
|
+
let text;
|
|
205
|
+
try {
|
|
206
|
+
const file = sessionPeerConfigPath(homeDirectory);
|
|
207
|
+
if (!peerConfigModeIsPrivate(node_fs.default.statSync(file).mode)) return void 0;
|
|
208
|
+
text = node_fs.default.readFileSync(file, "utf8");
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
return parseSessionPeerConfig(text);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window.
|
|
216
|
+
*
|
|
217
|
+
* @deprecated Node-only sync shim; use createPeerReplayGuardAsync. Removed in M2.
|
|
218
|
+
*/
|
|
219
|
+
function createPeerReplayGuard(homeDirectory) {
|
|
220
|
+
const directory = node_path.default.dirname(sessionPeerConfigPath(homeDirectory));
|
|
221
|
+
node_fs.default.mkdirSync(directory, {
|
|
222
|
+
recursive: true,
|
|
223
|
+
mode: 448
|
|
224
|
+
});
|
|
225
|
+
const database = new node_sqlite.DatabaseSync(node_path.default.join(directory, PEER_REPLAY_DATABASE_FILE));
|
|
226
|
+
database.exec(`PRAGMA journal_mode = WAL;${PEER_REPLAY_SQL.schema}`);
|
|
227
|
+
const removeExpired = database.prepare(PEER_REPLAY_SQL.removeExpired);
|
|
228
|
+
const insert = database.prepare(PEER_REPLAY_SQL.insert);
|
|
229
|
+
const count = database.prepare(PEER_REPLAY_SQL.count);
|
|
230
|
+
const trim = database.prepare(PEER_REPLAY_SQL.trim);
|
|
231
|
+
return {
|
|
232
|
+
admit(request, now = Date.now()) {
|
|
233
|
+
const supplied = peerRequestSignature(request);
|
|
234
|
+
if (!supplied) return false;
|
|
235
|
+
database.exec("BEGIN IMMEDIATE");
|
|
236
|
+
try {
|
|
237
|
+
removeExpired.run(now);
|
|
238
|
+
const accepted = insert.run(supplied, peerReplayExpiry(now)).changes === 1;
|
|
239
|
+
const overflow = peerReplayOverflow(Number(count.get().count));
|
|
240
|
+
if (overflow > 0) trim.run(overflow);
|
|
241
|
+
database.exec("COMMIT");
|
|
242
|
+
return accepted;
|
|
243
|
+
} catch (cause) {
|
|
244
|
+
database.exec("ROLLBACK");
|
|
245
|
+
throw cause;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
close: () => database.close()
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/services/peerTransport/index.ts
|
|
253
|
+
/** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */
|
|
254
|
+
async function readSessionPeerConfigAsync(fs, homeDirectory, context) {
|
|
255
|
+
try {
|
|
256
|
+
const file = sessionPeerConfigPath(homeDirectory);
|
|
257
|
+
const mode = await _agimon_ai_po_core_platform.nodeHost.fileMode(file);
|
|
258
|
+
if (mode !== void 0 && !peerConfigModeIsPrivate(mode)) return void 0;
|
|
259
|
+
const text = await (0, _agimon_ai_po_core_platform.readTextIfExists)(fs, file, context);
|
|
260
|
+
return text === void 0 ? void 0 : parseSessionPeerConfig(text);
|
|
261
|
+
} catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Creates a directory tree, giving each directory it creates owner-only access, the way
|
|
267
|
+
* `mkdirSync(directory, { recursive: true, mode: 0o700 })` does. Existing directories keep their mode.
|
|
268
|
+
*/
|
|
269
|
+
async function ensurePrivateDirectory(fs, directory, context) {
|
|
270
|
+
const missing = [];
|
|
271
|
+
for (let current = node_path.default.resolve(directory); !(0, _agimon_ai_po_core_platform.getOrThrow)(await fs.exists(current, context));) {
|
|
272
|
+
missing.unshift(current);
|
|
273
|
+
const parent = node_path.default.dirname(current);
|
|
274
|
+
if (parent === current) break;
|
|
275
|
+
current = parent;
|
|
276
|
+
}
|
|
277
|
+
if (missing.length === 0) return;
|
|
278
|
+
(0, _agimon_ai_po_core_platform.getOrThrow)(await fs.createDir(directory, { recursive: true }, context));
|
|
279
|
+
for (const created of missing) await _agimon_ai_po_core_platform.nodeHost.setMode(created, 448);
|
|
280
|
+
}
|
|
166
281
|
/** `peer/<host-id>/<session-id>` is the only remote target spelling. */
|
|
167
282
|
function parseRemoteSessionReference(value) {
|
|
168
283
|
const match = /^peer\/([^/]+)\/([^/]+)$/u.exec(value);
|
|
@@ -189,46 +304,24 @@ function peerRequestHeaders(sourceHostId, peer, method, pathname, body, now = Da
|
|
|
189
304
|
[SIGNATURE_HEADER]: signature(peer.secret, method, pathname, timestamp, body)
|
|
190
305
|
});
|
|
191
306
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
});
|
|
199
|
-
const database = new node_sqlite.DatabaseSync(node_path.default.join(directory, REPLAY_DATABASE_FILE));
|
|
200
|
-
database.exec(`
|
|
201
|
-
PRAGMA journal_mode = WAL;
|
|
202
|
-
CREATE TABLE IF NOT EXISTS peer_request_replays (
|
|
203
|
-
signature TEXT PRIMARY KEY,
|
|
204
|
-
expires_at INTEGER NOT NULL
|
|
205
|
-
);
|
|
206
|
-
`);
|
|
207
|
-
const removeExpired = database.prepare("DELETE FROM peer_request_replays WHERE expires_at < ?");
|
|
208
|
-
const insert = database.prepare("INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)");
|
|
209
|
-
const count = database.prepare("SELECT COUNT(*) AS count FROM peer_request_replays");
|
|
210
|
-
const trim = database.prepare(`
|
|
211
|
-
DELETE FROM peer_request_replays
|
|
212
|
-
WHERE signature IN (
|
|
213
|
-
SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?
|
|
214
|
-
)
|
|
215
|
-
`);
|
|
307
|
+
/**
|
|
308
|
+
* Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window.
|
|
309
|
+
* Takes over the database and closes it in `close()`.
|
|
310
|
+
*/
|
|
311
|
+
async function createPeerReplayGuardAsync(database) {
|
|
312
|
+
await database.exec(PEER_REPLAY_SQL.schema);
|
|
216
313
|
return {
|
|
217
|
-
admit(request, now = Date.now()) {
|
|
218
|
-
const supplied = request
|
|
314
|
+
async admit(request, now = Date.now()) {
|
|
315
|
+
const supplied = peerRequestSignature(request);
|
|
219
316
|
if (!supplied) return false;
|
|
220
|
-
database.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
226
|
-
database.exec("COMMIT");
|
|
317
|
+
return database.transaction(async (transaction) => {
|
|
318
|
+
await transaction.run(PEER_REPLAY_SQL.removeExpired, now);
|
|
319
|
+
const accepted = await transaction.get(PEER_REPLAY_SQL.insertReturning, supplied, peerReplayExpiry(now)) !== void 0;
|
|
320
|
+
const row = await transaction.get(PEER_REPLAY_SQL.count);
|
|
321
|
+
const overflow = peerReplayOverflow(Number(row?.count ?? 0));
|
|
322
|
+
if (overflow > 0) await transaction.run(PEER_REPLAY_SQL.trim, overflow);
|
|
227
323
|
return accepted;
|
|
228
|
-
}
|
|
229
|
-
database.exec("ROLLBACK");
|
|
230
|
-
throw cause;
|
|
231
|
-
}
|
|
324
|
+
});
|
|
232
325
|
},
|
|
233
326
|
close: () => database.close()
|
|
234
327
|
};
|
|
@@ -239,7 +332,7 @@ function verifyPeerRequest(config, request, body, now = Date.now()) {
|
|
|
239
332
|
const supplied = request.headers.get(SIGNATURE_HEADER);
|
|
240
333
|
if (!hostId || !timestamp || !supplied || !/^\d{1,16}$/u.test(timestamp)) return void 0;
|
|
241
334
|
const sentAt = Number(timestamp);
|
|
242
|
-
if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) >
|
|
335
|
+
if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > 3e5) return void 0;
|
|
243
336
|
const peer = config.peers.find((candidate) => candidate.hostId === hostId);
|
|
244
337
|
if (!peer) return void 0;
|
|
245
338
|
const expected = Buffer.from(signature(peer.secret, request.method, new URL(request.url).pathname, timestamp, body));
|
|
@@ -261,7 +354,7 @@ function parseSessionPeerEnvelope(value) {
|
|
|
261
354
|
}
|
|
262
355
|
/** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */
|
|
263
356
|
function createPeerCommunication(options) {
|
|
264
|
-
const config =
|
|
357
|
+
const config = options.config;
|
|
265
358
|
const send = options.fetch ?? fetch;
|
|
266
359
|
return {
|
|
267
360
|
sessionId: options.local.sessionId,
|
|
@@ -329,19 +422,26 @@ function createSessionDirectory(options) {
|
|
|
329
422
|
const observations = /* @__PURE__ */ new Map();
|
|
330
423
|
const snapshotRequired = /* @__PURE__ */ new Set();
|
|
331
424
|
const subscribers = /* @__PURE__ */ new Set();
|
|
332
|
-
const discover = (callerReference, allowedReferences) => {
|
|
425
|
+
const discover = async (callerReference, allowedReferences) => {
|
|
333
426
|
const result = [];
|
|
334
|
-
for (const observation of observations.values()) {
|
|
427
|
+
for (const observation of Array.from(observations.values())) {
|
|
335
428
|
const reference = keyOf(observation);
|
|
336
429
|
if (allowedReferences && !allowedReferences.has(reference)) continue;
|
|
337
|
-
const grant = options.authorizeDiscovery(callerReference, observation);
|
|
430
|
+
const grant = await options.authorizeDiscovery(callerReference, observation);
|
|
338
431
|
if (!grant) continue;
|
|
339
432
|
result.push(publicEntry(observation, now(), grant.label ?? observation.label, grant.capabilities));
|
|
340
433
|
}
|
|
341
434
|
return result.sort((left, right) => left.reference.localeCompare(right.reference));
|
|
342
435
|
};
|
|
436
|
+
const push = (subscriber) => {
|
|
437
|
+
subscriber.queue = subscriber.queue.then(async () => {
|
|
438
|
+
if (!subscribers.has(subscriber)) return;
|
|
439
|
+
const sessions = await discover(subscriber.callerReference);
|
|
440
|
+
if (subscribers.has(subscriber)) subscriber.listener(sessions);
|
|
441
|
+
}).catch(() => void 0);
|
|
442
|
+
};
|
|
343
443
|
const notify = () => {
|
|
344
|
-
for (const subscriber of subscribers)
|
|
444
|
+
for (const subscriber of subscribers) push(subscriber);
|
|
345
445
|
};
|
|
346
446
|
return {
|
|
347
447
|
observe(observation) {
|
|
@@ -390,17 +490,18 @@ function createSessionDirectory(options) {
|
|
|
390
490
|
notify();
|
|
391
491
|
},
|
|
392
492
|
discover,
|
|
393
|
-
resolveDeliveryTarget(callerReference, reference) {
|
|
493
|
+
async resolveDeliveryTarget(callerReference, reference) {
|
|
394
494
|
const observation = observations.get(reference);
|
|
395
|
-
return observation && options.authorizeDiscovery(callerReference, observation) ? observation.deliveryTarget : void 0;
|
|
495
|
+
return observation && await options.authorizeDiscovery(callerReference, observation) ? observation.deliveryTarget : void 0;
|
|
396
496
|
},
|
|
397
497
|
subscribe(callerReference, listener) {
|
|
398
498
|
const subscriber = {
|
|
399
499
|
callerReference,
|
|
400
|
-
listener
|
|
500
|
+
listener,
|
|
501
|
+
queue: Promise.resolve()
|
|
401
502
|
};
|
|
402
503
|
subscribers.add(subscriber);
|
|
403
|
-
|
|
504
|
+
push(subscriber);
|
|
404
505
|
return () => subscribers.delete(subscriber);
|
|
405
506
|
}
|
|
406
507
|
};
|
|
@@ -423,100 +524,96 @@ function validateGroupId(value) {
|
|
|
423
524
|
function validateReference(value) {
|
|
424
525
|
if (!REFERENCE_PATTERN.test(value)) throw new TypeError(`Session group member '${value}' is not a qualified reference.`);
|
|
425
526
|
}
|
|
527
|
+
const SCHEMA$1 = `
|
|
528
|
+
CREATE TABLE IF NOT EXISTS session_groups (
|
|
529
|
+
group_id TEXT PRIMARY KEY,
|
|
530
|
+
owner_authority TEXT NOT NULL,
|
|
531
|
+
revision INTEGER NOT NULL
|
|
532
|
+
);
|
|
533
|
+
CREATE TABLE IF NOT EXISTS session_group_members (
|
|
534
|
+
group_id TEXT NOT NULL REFERENCES session_groups(group_id) ON DELETE CASCADE,
|
|
535
|
+
member_reference TEXT NOT NULL,
|
|
536
|
+
PRIMARY KEY (group_id, member_reference)
|
|
537
|
+
);
|
|
538
|
+
`;
|
|
539
|
+
async function readGroup(database, groupId) {
|
|
540
|
+
const row = await database.get("SELECT * FROM session_groups WHERE group_id = ?", groupId);
|
|
541
|
+
if (!row) return void 0;
|
|
542
|
+
const members = await database.all("SELECT member_reference FROM session_group_members WHERE group_id = ? ORDER BY member_reference", groupId);
|
|
543
|
+
return Object.freeze({
|
|
544
|
+
id: row.group_id,
|
|
545
|
+
ownerAuthority: row.owner_authority,
|
|
546
|
+
revision: Number(row.revision),
|
|
547
|
+
members: Object.freeze(members.map((member) => member.member_reference))
|
|
548
|
+
});
|
|
549
|
+
}
|
|
426
550
|
/** Opens a revisioned SQLite registry for communication-only group membership. */
|
|
427
|
-
function createSessionGroupStore(options) {
|
|
428
|
-
const database =
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
owner_authority TEXT NOT NULL,
|
|
435
|
-
revision INTEGER NOT NULL
|
|
436
|
-
);
|
|
437
|
-
CREATE TABLE IF NOT EXISTS session_group_members (
|
|
438
|
-
group_id TEXT NOT NULL REFERENCES session_groups(group_id) ON DELETE CASCADE,
|
|
439
|
-
member_reference TEXT NOT NULL,
|
|
440
|
-
PRIMARY KEY (group_id, member_reference)
|
|
441
|
-
);
|
|
442
|
-
`);
|
|
443
|
-
const read = (groupId) => {
|
|
444
|
-
const row = database.prepare("SELECT * FROM session_groups WHERE group_id = ?").get(groupId);
|
|
445
|
-
if (!row) return void 0;
|
|
446
|
-
const members = database.prepare("SELECT member_reference FROM session_group_members WHERE group_id = ? ORDER BY member_reference").all(groupId);
|
|
447
|
-
return Object.freeze({
|
|
448
|
-
id: row.group_id,
|
|
449
|
-
ownerAuthority: row.owner_authority,
|
|
450
|
-
revision: row.revision,
|
|
451
|
-
members: Object.freeze(members.map((member) => member.member_reference))
|
|
452
|
-
});
|
|
453
|
-
};
|
|
454
|
-
const enroll = (groupId, members) => {
|
|
455
|
-
const insert = database.prepare("INSERT OR IGNORE INTO session_group_members (group_id, member_reference) VALUES (?, ?)");
|
|
551
|
+
async function createSessionGroupStore(options) {
|
|
552
|
+
const { database } = options;
|
|
553
|
+
try {
|
|
554
|
+
await database.exec("PRAGMA foreign_keys = ON");
|
|
555
|
+
} catch {}
|
|
556
|
+
await database.exec(SCHEMA$1);
|
|
557
|
+
const enroll = async (transaction, groupId, members) => {
|
|
456
558
|
for (const member of new Set(members)) {
|
|
457
559
|
validateReference(member);
|
|
458
560
|
if (options.validateEnrollment?.(groupId, member) === false) throw new Error(`Session group enrollment policy rejected '${member}'.`);
|
|
459
|
-
|
|
561
|
+
await transaction.run("INSERT OR IGNORE INTO session_group_members (group_id, member_reference) VALUES (?, ?)", groupId, member);
|
|
460
562
|
}
|
|
461
563
|
};
|
|
462
564
|
return {
|
|
463
|
-
create(groupId, ownerAuthority, members) {
|
|
565
|
+
async create(groupId, ownerAuthority, members) {
|
|
464
566
|
validateGroupId(groupId);
|
|
465
567
|
if (!ownerAuthority.trim()) throw new TypeError("Session group owner authority is required.");
|
|
466
|
-
database.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
} catch (cause) {
|
|
472
|
-
database.exec("ROLLBACK");
|
|
473
|
-
throw cause;
|
|
474
|
-
}
|
|
475
|
-
return read(groupId);
|
|
568
|
+
return database.transaction(async (transaction) => {
|
|
569
|
+
await transaction.run("INSERT INTO session_groups (group_id, owner_authority, revision) VALUES (?, ?, 1)", groupId, ownerAuthority);
|
|
570
|
+
await enroll(transaction, groupId, members);
|
|
571
|
+
return await readGroup(transaction, groupId);
|
|
572
|
+
});
|
|
476
573
|
},
|
|
477
|
-
mutate(mutation) {
|
|
478
|
-
database.
|
|
479
|
-
|
|
480
|
-
const current = read(mutation.groupId);
|
|
574
|
+
async mutate(mutation) {
|
|
575
|
+
return database.transaction(async (transaction) => {
|
|
576
|
+
const current = await readGroup(transaction, mutation.groupId);
|
|
481
577
|
if (!current) throw new Error(`Session group '${mutation.groupId}' does not exist.`);
|
|
482
578
|
if (mutation.authority !== current.ownerAuthority && options.authorizeManagement?.(mutation.authority, current) !== true) throw new Error(`Authority '${mutation.authority}' cannot manage Session group '${mutation.groupId}'.`);
|
|
483
579
|
if (mutation.expectedRevision !== current.revision) throw new SessionGroupRevisionError(mutation.groupId, mutation.expectedRevision, current.revision);
|
|
484
|
-
enroll(mutation.groupId, mutation.add ?? []);
|
|
485
|
-
const remove = database.prepare("DELETE FROM session_group_members WHERE group_id = ? AND member_reference = ?");
|
|
580
|
+
await enroll(transaction, mutation.groupId, mutation.add ?? []);
|
|
486
581
|
for (const member of new Set(mutation.remove ?? [])) {
|
|
487
582
|
validateReference(member);
|
|
488
|
-
|
|
583
|
+
await transaction.run("DELETE FROM session_group_members WHERE group_id = ? AND member_reference = ?", mutation.groupId, member);
|
|
489
584
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
database.exec("ROLLBACK");
|
|
494
|
-
throw cause;
|
|
495
|
-
}
|
|
496
|
-
return read(mutation.groupId);
|
|
585
|
+
await transaction.run("UPDATE session_groups SET revision = revision + 1 WHERE group_id = ? AND revision = ?", mutation.groupId, current.revision);
|
|
586
|
+
return await readGroup(transaction, mutation.groupId);
|
|
587
|
+
});
|
|
497
588
|
},
|
|
498
|
-
get:
|
|
499
|
-
groupsFor(memberReference) {
|
|
589
|
+
get: (groupId) => readGroup(database, groupId),
|
|
590
|
+
async groupsFor(memberReference) {
|
|
500
591
|
validateReference(memberReference);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
592
|
+
const rows = await database.all(`SELECT g.group_id FROM session_groups g
|
|
593
|
+
INNER JOIN session_group_members m ON m.group_id = g.group_id
|
|
594
|
+
WHERE m.member_reference = ? ORDER BY g.group_id`, memberReference);
|
|
595
|
+
const groups = [];
|
|
596
|
+
for (const row of rows) {
|
|
597
|
+
const group = await readGroup(database, row.group_id);
|
|
598
|
+
if (group) groups.push(group);
|
|
599
|
+
}
|
|
600
|
+
return groups;
|
|
504
601
|
},
|
|
505
|
-
canMessage(groupId, sourceReference, targetReference) {
|
|
602
|
+
async canMessage(groupId, sourceReference, targetReference) {
|
|
506
603
|
validateReference(sourceReference);
|
|
507
604
|
validateReference(targetReference);
|
|
508
|
-
const row = database.
|
|
509
|
-
|
|
510
|
-
return Number(row
|
|
605
|
+
const row = await database.get(`SELECT COUNT(*) AS count FROM session_group_members
|
|
606
|
+
WHERE group_id = ? AND member_reference IN (?, ?)`, groupId, sourceReference, targetReference);
|
|
607
|
+
return Number(row?.count ?? 0) === (sourceReference === targetReference ? 1 : 2);
|
|
511
608
|
},
|
|
512
609
|
close: () => database.close()
|
|
513
610
|
};
|
|
514
611
|
}
|
|
515
612
|
//#endregion
|
|
516
613
|
//#region src/services/sessionIntercom/type.ts
|
|
517
|
-
const
|
|
614
|
+
const PO_SESSION_INTERCOM_SERVICE = "po/session-intercom";
|
|
518
615
|
function readSessionIntercom(context) {
|
|
519
|
-
return context.get(
|
|
616
|
+
return context.get(PO_SESSION_INTERCOM_SERVICE);
|
|
520
617
|
}
|
|
521
618
|
function requireSessionIntercom(context) {
|
|
522
619
|
const service = readSessionIntercom(context);
|
|
@@ -527,9 +624,9 @@ function requireSessionIntercom(context) {
|
|
|
527
624
|
//#region src/services/sessionIntercom/index.ts
|
|
528
625
|
const MESSAGE_KIND = "session-message";
|
|
529
626
|
const REPORT_KIND = "session-report";
|
|
530
|
-
function metadataFor(options, request, reportStatus) {
|
|
531
|
-
const group = options.groups.get(request.groupId);
|
|
532
|
-
if (!group || !options.groups.canMessage(request.groupId, options.selfReference, request.targetReference)) throw new Error(`Session group '${request.groupId}' does not authorize this message.`);
|
|
627
|
+
async function metadataFor(options, request, reportStatus) {
|
|
628
|
+
const group = await options.groups.get(request.groupId);
|
|
629
|
+
if (!group || !await options.groups.canMessage(request.groupId, options.selfReference, request.targetReference)) throw new Error(`Session group '${request.groupId}' does not authorize this message.`);
|
|
533
630
|
return {
|
|
534
631
|
sourceReference: options.selfReference,
|
|
535
632
|
targetReference: request.targetReference,
|
|
@@ -539,12 +636,21 @@ function metadataFor(options, request, reportStatus) {
|
|
|
539
636
|
...reportStatus === void 0 ? {} : { reportStatus }
|
|
540
637
|
};
|
|
541
638
|
}
|
|
639
|
+
/** The group IDs, among those given, whose current membership includes this session. */
|
|
640
|
+
async function groupMembership(options, groupIds) {
|
|
641
|
+
const memberOf = /* @__PURE__ */ new Set();
|
|
642
|
+
for (const groupId of new Set(groupIds)) {
|
|
643
|
+
if (groupId === void 0) continue;
|
|
644
|
+
if ((await options.groups.get(groupId))?.members.includes(options.selfReference) === true) memberOf.add(groupId);
|
|
645
|
+
}
|
|
646
|
+
return memberOf;
|
|
647
|
+
}
|
|
542
648
|
/** Agent-facing intercom operations. A composition may acquire this service without gaining session controls. */
|
|
543
649
|
function createSessionIntercom(options) {
|
|
544
650
|
const deliver = async (request, kind, status) => {
|
|
545
|
-
const recipientKey = options.directory.resolveDeliveryTarget(options.selfReference, request.targetReference);
|
|
651
|
+
const recipientKey = await options.directory.resolveDeliveryTarget(options.selfReference, request.targetReference);
|
|
546
652
|
if (!recipientKey) throw new Error(`Session '${request.targetReference}' is not discoverable.`);
|
|
547
|
-
const metadata = metadataFor(options, request, status);
|
|
653
|
+
const metadata = await metadataFor(options, request, status);
|
|
548
654
|
return options.delivery.deliver({
|
|
549
655
|
...request.deliveryId === void 0 ? {} : { deliveryId: request.deliveryId },
|
|
550
656
|
recipientKey,
|
|
@@ -554,16 +660,18 @@ function createSessionIntercom(options) {
|
|
|
554
660
|
});
|
|
555
661
|
};
|
|
556
662
|
return Object.freeze({
|
|
557
|
-
discover() {
|
|
558
|
-
const members = new Set(options.groups.groupsFor(options.selfReference).flatMap((group) => group.members).filter((reference) => reference !== options.selfReference));
|
|
663
|
+
async discover() {
|
|
664
|
+
const members = new Set((await options.groups.groupsFor(options.selfReference)).flatMap((group) => group.members).filter((reference) => reference !== options.selfReference));
|
|
559
665
|
return options.directory.discover(options.selfReference, members);
|
|
560
666
|
},
|
|
561
667
|
send: (request) => deliver(request, MESSAGE_KIND),
|
|
562
668
|
report: (request) => deliver(request, REPORT_KIND, request.status),
|
|
563
|
-
messages() {
|
|
564
|
-
|
|
669
|
+
async messages() {
|
|
670
|
+
const entries = (await options.delivery.inbox()).filter((entry) => entry.kind === MESSAGE_KIND || entry.kind === REPORT_KIND);
|
|
671
|
+
const memberOf = await groupMembership(options, entries.map((entry) => entry.metadata.groupId));
|
|
672
|
+
return entries.filter((entry) => {
|
|
565
673
|
const groupId = entry.metadata.groupId;
|
|
566
|
-
return groupId !== void 0 &&
|
|
674
|
+
return groupId !== void 0 && memberOf.has(groupId);
|
|
567
675
|
}).flatMap((entry) => {
|
|
568
676
|
const sourceReference = entry.metadata.sourceReference;
|
|
569
677
|
const targetReference = entry.metadata.targetReference;
|
|
@@ -583,38 +691,38 @@ function createSessionIntercom(options) {
|
|
|
583
691
|
}];
|
|
584
692
|
});
|
|
585
693
|
},
|
|
586
|
-
delivery(deliveryId) {
|
|
587
|
-
const entry = options.delivery.outbox(deliveryId);
|
|
694
|
+
async delivery(deliveryId) {
|
|
695
|
+
const entry = await options.delivery.outbox(deliveryId);
|
|
588
696
|
const groupId = entry?.metadata.groupId;
|
|
589
|
-
return entry && groupId !== void 0 && options
|
|
697
|
+
return entry && groupId !== void 0 && (await groupMembership(options, [groupId])).has(groupId) ? entry : void 0;
|
|
590
698
|
}
|
|
591
699
|
});
|
|
592
700
|
}
|
|
593
701
|
/** Builds the dynamic delivery check used before every send, retry, and recipient acceptance. */
|
|
594
702
|
function createSessionGroupDeliveryAuthorizer(options) {
|
|
595
|
-
return (peerKey, metadata) => {
|
|
703
|
+
return async (peerKey, metadata) => {
|
|
596
704
|
const sourceReference = metadata?.sourceReference;
|
|
597
705
|
const targetReference = metadata?.targetReference;
|
|
598
706
|
const groupId = metadata?.groupId;
|
|
599
707
|
if (sourceReference && targetReference && groupId) {
|
|
600
708
|
const peerReference = options.peerReference(peerKey);
|
|
601
709
|
if (!peerReference) return false;
|
|
602
|
-
return (sourceReference === options.selfReference && targetReference === peerReference || targetReference === options.selfReference && sourceReference === peerReference) && options.groups.canMessage(groupId, sourceReference, targetReference);
|
|
710
|
+
return (sourceReference === options.selfReference && targetReference === peerReference || targetReference === options.selfReference && sourceReference === peerReference) && await options.groups.canMessage(groupId, sourceReference, targetReference);
|
|
603
711
|
}
|
|
604
|
-
return options.fallback?.(peerKey, metadata) === true;
|
|
712
|
+
return await options.fallback?.(peerKey, metadata) === true;
|
|
605
713
|
};
|
|
606
714
|
}
|
|
607
715
|
//#endregion
|
|
608
716
|
//#region src/services/sessionDelivery/type.ts
|
|
609
|
-
const
|
|
717
|
+
const PO_SESSION_DELIVERY_SERVICE = "po/session-delivery";
|
|
610
718
|
/** Reads the currently active provider each time, without retaining a stale binding. */
|
|
611
|
-
function
|
|
612
|
-
return context.get(
|
|
719
|
+
function readPoSessionDelivery(context) {
|
|
720
|
+
return context.get(PO_SESSION_DELIVERY_SERVICE);
|
|
613
721
|
}
|
|
614
722
|
//#endregion
|
|
615
723
|
//#region src/services/sessionDelivery/index.ts
|
|
616
|
-
const DELIVERY_EVENT = "
|
|
617
|
-
const ACK_EVENT = "
|
|
724
|
+
const DELIVERY_EVENT = "po/session-delivery/envelope";
|
|
725
|
+
const ACK_EVENT = "po/session-delivery/ack";
|
|
618
726
|
const DEFAULT_DELIVERY = "prompt";
|
|
619
727
|
const INBOX_STATES = /* @__PURE__ */ new Set([
|
|
620
728
|
"accepted",
|
|
@@ -665,112 +773,127 @@ function matchesMetadata(entry, query) {
|
|
|
665
773
|
function sameMetadata(left, right) {
|
|
666
774
|
return matchesMetadata(left, right) && matchesMetadata(right, left);
|
|
667
775
|
}
|
|
776
|
+
const SCHEMA = `
|
|
777
|
+
CREATE TABLE IF NOT EXISTS session_delivery_outbox (
|
|
778
|
+
delivery_id TEXT PRIMARY KEY,
|
|
779
|
+
recipient_key TEXT NOT NULL,
|
|
780
|
+
kind TEXT NOT NULL,
|
|
781
|
+
prompt TEXT NOT NULL,
|
|
782
|
+
delivery_mode TEXT NOT NULL,
|
|
783
|
+
metadata_json TEXT NOT NULL,
|
|
784
|
+
state TEXT NOT NULL,
|
|
785
|
+
recipient_state TEXT,
|
|
786
|
+
created_at INTEGER NOT NULL,
|
|
787
|
+
acknowledged_at INTEGER
|
|
788
|
+
);
|
|
789
|
+
CREATE TABLE IF NOT EXISTS session_delivery_inbox (
|
|
790
|
+
delivery_id TEXT PRIMARY KEY,
|
|
791
|
+
sender_key TEXT NOT NULL,
|
|
792
|
+
recipient_key TEXT NOT NULL,
|
|
793
|
+
kind TEXT NOT NULL,
|
|
794
|
+
prompt TEXT NOT NULL,
|
|
795
|
+
delivery_mode TEXT NOT NULL,
|
|
796
|
+
metadata_json TEXT NOT NULL,
|
|
797
|
+
state TEXT NOT NULL,
|
|
798
|
+
created_at INTEGER NOT NULL,
|
|
799
|
+
updated_at INTEGER NOT NULL,
|
|
800
|
+
consumed_at INTEGER
|
|
801
|
+
);
|
|
802
|
+
`;
|
|
803
|
+
const SET_INBOX_STATE = "UPDATE session_delivery_inbox SET state = ?, updated_at = ? WHERE delivery_id = ?";
|
|
804
|
+
const UNSETTLED_OUTBOX = "(recipient_state IS NULL OR recipient_state NOT IN ('admitted', 'recovery_required'))";
|
|
668
805
|
/** Opens and starts one durable delivery endpoint. */
|
|
669
|
-
function createSessionDeliveryService(options) {
|
|
806
|
+
async function createSessionDeliveryService(options) {
|
|
670
807
|
if (!options.recipientKey.trim() || options.recipientKey !== options.recipientKey.trim()) throw new TypeError("Session delivery requires a trimmed, non-empty recipient key.");
|
|
671
|
-
const database =
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
delivery_mode TEXT NOT NULL,
|
|
680
|
-
metadata_json TEXT NOT NULL,
|
|
681
|
-
state TEXT NOT NULL,
|
|
682
|
-
recipient_state TEXT,
|
|
683
|
-
created_at INTEGER NOT NULL,
|
|
684
|
-
acknowledged_at INTEGER
|
|
685
|
-
);
|
|
686
|
-
CREATE TABLE IF NOT EXISTS session_delivery_inbox (
|
|
687
|
-
delivery_id TEXT PRIMARY KEY,
|
|
688
|
-
sender_key TEXT NOT NULL,
|
|
689
|
-
recipient_key TEXT NOT NULL,
|
|
690
|
-
kind TEXT NOT NULL,
|
|
691
|
-
prompt TEXT NOT NULL,
|
|
692
|
-
delivery_mode TEXT NOT NULL,
|
|
693
|
-
metadata_json TEXT NOT NULL,
|
|
694
|
-
state TEXT NOT NULL,
|
|
695
|
-
created_at INTEGER NOT NULL,
|
|
696
|
-
updated_at INTEGER NOT NULL,
|
|
697
|
-
consumed_at INTEGER
|
|
698
|
-
);
|
|
699
|
-
`);
|
|
700
|
-
database.prepare("UPDATE session_delivery_inbox SET state = 'recovery_required', updated_at = ? WHERE state = 'admitting'").run(Date.now());
|
|
808
|
+
const database = options.database;
|
|
809
|
+
try {
|
|
810
|
+
await database.exec(SCHEMA);
|
|
811
|
+
await database.run("UPDATE session_delivery_inbox SET state = 'accepted', updated_at = ? WHERE state = 'admitting'", Date.now());
|
|
812
|
+
} catch (error) {
|
|
813
|
+
await database.close();
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
701
816
|
let closed = false;
|
|
702
817
|
const scheduled = /* @__PURE__ */ new Set();
|
|
703
818
|
let admissionQueue = Promise.resolve();
|
|
819
|
+
const pending = /* @__PURE__ */ new Set();
|
|
820
|
+
/** Tracks fire-and-forget work so close() can wait for it before the database closes. */
|
|
821
|
+
const track = (work) => {
|
|
822
|
+
const settled = work.catch(() => void 0);
|
|
823
|
+
pending.add(settled);
|
|
824
|
+
settled.finally(() => pending.delete(settled));
|
|
825
|
+
};
|
|
826
|
+
const authorized = async (peerKey, metadata) => await options.authorizePeer(peerKey, metadata) === true;
|
|
704
827
|
const publishAck = (deliveryId, senderKey, state) => {
|
|
705
828
|
options.communication.publish(senderKey, ACK_EVENT, {
|
|
706
829
|
deliveryId,
|
|
707
830
|
state
|
|
708
831
|
});
|
|
709
832
|
};
|
|
710
|
-
const readInboxRow = (deliveryId) => database.
|
|
833
|
+
const readInboxRow = (deliveryId) => database.get("SELECT * FROM session_delivery_inbox WHERE delivery_id = ?", deliveryId);
|
|
711
834
|
const scheduleAdmission = (deliveryId) => {
|
|
712
835
|
if (closed || scheduled.has(deliveryId)) return;
|
|
713
836
|
scheduled.add(deliveryId);
|
|
714
837
|
admissionQueue = admissionQueue.then(async () => {
|
|
715
838
|
try {
|
|
716
839
|
if (closed) return;
|
|
717
|
-
const row =
|
|
718
|
-
if (!row
|
|
719
|
-
database.prepare("UPDATE session_delivery_inbox SET state = 'admitting', updated_at = ? WHERE delivery_id = ?").run(Date.now(), deliveryId);
|
|
840
|
+
const row = await database.get("UPDATE session_delivery_inbox SET state = 'admitting', updated_at = ? WHERE delivery_id = ? AND state = 'accepted' RETURNING *", Date.now(), deliveryId);
|
|
841
|
+
if (!row) return;
|
|
720
842
|
publishAck(deliveryId, row.sender_key, "admitting");
|
|
721
843
|
try {
|
|
722
|
-
await options.admitPrompt(row.prompt, row.delivery_mode);
|
|
844
|
+
await options.admitPrompt(row.prompt, row.delivery_mode, deliveryId);
|
|
723
845
|
} catch {
|
|
724
|
-
database.
|
|
846
|
+
await database.run(SET_INBOX_STATE, "recovery_required", Date.now(), deliveryId);
|
|
725
847
|
publishAck(deliveryId, row.sender_key, "recovery_required");
|
|
726
848
|
return;
|
|
727
849
|
}
|
|
728
|
-
database.
|
|
850
|
+
await database.run(SET_INBOX_STATE, "admitted", Date.now(), deliveryId);
|
|
729
851
|
publishAck(deliveryId, row.sender_key, "admitted");
|
|
730
852
|
} finally {
|
|
731
853
|
scheduled.delete(deliveryId);
|
|
732
854
|
}
|
|
733
|
-
}).catch(() => {
|
|
855
|
+
}).catch(async () => {
|
|
734
856
|
scheduled.delete(deliveryId);
|
|
735
857
|
try {
|
|
736
|
-
const row = readInboxRow(deliveryId);
|
|
858
|
+
const row = await readInboxRow(deliveryId);
|
|
737
859
|
if (row?.state !== "admitting") return;
|
|
738
|
-
database.
|
|
860
|
+
await database.run(SET_INBOX_STATE, "recovery_required", Date.now(), deliveryId);
|
|
739
861
|
try {
|
|
740
862
|
publishAck(deliveryId, row.sender_key, "recovery_required");
|
|
741
863
|
} catch {}
|
|
742
864
|
} catch {}
|
|
743
865
|
});
|
|
744
866
|
};
|
|
745
|
-
const receiveEnvelope = (senderKey, value) => {
|
|
867
|
+
const receiveEnvelope = async (senderKey, value) => {
|
|
746
868
|
if (closed) return void 0;
|
|
747
869
|
const envelope = envelopeOf(value);
|
|
748
|
-
if (!envelope || !
|
|
749
|
-
database.
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const current = readInboxRow(envelope.deliveryId);
|
|
870
|
+
if (!envelope || !await authorized(senderKey, envelope.metadata) || closed) return void 0;
|
|
871
|
+
await database.run(`INSERT OR IGNORE INTO session_delivery_inbox
|
|
872
|
+
(delivery_id, sender_key, recipient_key, kind, prompt, delivery_mode, metadata_json, state, created_at, updated_at)
|
|
873
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?)`, envelope.deliveryId, senderKey, options.recipientKey, envelope.kind, envelope.prompt, envelope.delivery, JSON.stringify(envelope.metadata), Date.now(), Date.now());
|
|
874
|
+
const current = await readInboxRow(envelope.deliveryId);
|
|
753
875
|
if (!current) return void 0;
|
|
754
876
|
if (!(current.sender_key === senderKey && current.recipient_key === options.recipientKey && current.kind === envelope.kind && current.prompt === envelope.prompt && current.delivery_mode === envelope.delivery && sameMetadata(JSON.parse(current.metadata_json), envelope.metadata))) return void 0;
|
|
755
877
|
publishAck(envelope.deliveryId, current.sender_key, current.state);
|
|
756
878
|
if (current.state === "accepted") scheduleAdmission(envelope.deliveryId);
|
|
757
879
|
return current.state;
|
|
758
880
|
};
|
|
759
|
-
const receiveAck = (recipientKey, value) => {
|
|
881
|
+
const receiveAck = async (recipientKey, value) => {
|
|
760
882
|
const ack = ackOf(value);
|
|
761
883
|
if (closed || !ack) return;
|
|
762
|
-
database.
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
884
|
+
await database.run(`UPDATE session_delivery_outbox
|
|
885
|
+
SET state = 'acknowledged', recipient_state = ?, acknowledged_at = ?
|
|
886
|
+
WHERE delivery_id = ? AND recipient_key = ?
|
|
887
|
+
AND (recipient_state IS NULL
|
|
888
|
+
OR (recipient_state = 'accepted' AND ? != 'accepted')
|
|
889
|
+
OR (recipient_state = 'admitting' AND ? IN ('admitted', 'recovery_required')))`, ack.state, Date.now(), ack.deliveryId, recipientKey, ack.state, ack.state);
|
|
768
890
|
};
|
|
769
|
-
const publishOutbox = (deliveryId) => {
|
|
770
|
-
|
|
891
|
+
const publishOutbox = async (deliveryId) => {
|
|
892
|
+
if (closed) return;
|
|
893
|
+
const row = await database.get(`SELECT * FROM session_delivery_outbox WHERE delivery_id = ? AND ${UNSETTLED_OUTBOX}`, deliveryId);
|
|
771
894
|
if (!row) return;
|
|
772
895
|
const metadata = JSON.parse(row.metadata_json);
|
|
773
|
-
if (!
|
|
896
|
+
if (!await authorized(row.recipient_key, metadata) || closed) return;
|
|
774
897
|
options.communication.publish(row.recipient_key, DELIVERY_EVENT, {
|
|
775
898
|
deliveryId: row.delivery_id,
|
|
776
899
|
kind: row.kind,
|
|
@@ -779,20 +902,47 @@ function createSessionDeliveryService(options) {
|
|
|
779
902
|
metadata
|
|
780
903
|
});
|
|
781
904
|
};
|
|
782
|
-
const publishQueued = (recipientKey) => {
|
|
783
|
-
|
|
905
|
+
const publishQueued = async (recipientKey) => {
|
|
906
|
+
if (closed) return;
|
|
907
|
+
for (const row of await database.all(`SELECT delivery_id FROM session_delivery_outbox WHERE recipient_key = ? AND ${UNSETTLED_OUTBOX}`, recipientKey)) await publishOutbox(row.delivery_id);
|
|
908
|
+
};
|
|
909
|
+
let retryTimer;
|
|
910
|
+
const startRetry = () => {
|
|
911
|
+
if (closed || retryTimer) return;
|
|
912
|
+
retryTimer = (0, node_timers.setInterval)(() => track(publishAllQueued()), 1e3);
|
|
913
|
+
retryTimer.unref?.();
|
|
914
|
+
};
|
|
915
|
+
const stopRetry = () => {
|
|
916
|
+
if (retryTimer) (0, node_timers.clearInterval)(retryTimer);
|
|
917
|
+
retryTimer = void 0;
|
|
784
918
|
};
|
|
919
|
+
let retrying;
|
|
785
920
|
const publishAllQueued = () => {
|
|
786
|
-
|
|
921
|
+
retrying ??= (async () => {
|
|
922
|
+
try {
|
|
923
|
+
if (closed) return;
|
|
924
|
+
const rows = await database.all(`SELECT delivery_id FROM session_delivery_outbox WHERE ${UNSETTLED_OUTBOX}`);
|
|
925
|
+
if (rows.length === 0) stopRetry();
|
|
926
|
+
else startRetry();
|
|
927
|
+
for (const row of rows) await publishOutbox(row.delivery_id);
|
|
928
|
+
} finally {
|
|
929
|
+
retrying = void 0;
|
|
930
|
+
}
|
|
931
|
+
})();
|
|
932
|
+
return retrying;
|
|
787
933
|
};
|
|
788
|
-
const releaseEnvelope = options.communication.subscribe(DELIVERY_EVENT,
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
|
|
934
|
+
const releaseEnvelope = options.communication.subscribe(DELIVERY_EVENT, (senderKey, payload) => {
|
|
935
|
+
track(receiveEnvelope(senderKey, payload));
|
|
936
|
+
});
|
|
937
|
+
const releaseAck = options.communication.subscribe(ACK_EVENT, (recipientKey, payload) => {
|
|
938
|
+
track(receiveAck(recipientKey, payload));
|
|
939
|
+
});
|
|
940
|
+
const releaseReady = options.communication.onPeerReady((recipientKey) => {
|
|
941
|
+
track(publishQueued(recipientKey));
|
|
942
|
+
});
|
|
943
|
+
for (const row of await database.all("SELECT delivery_id, sender_key FROM session_delivery_inbox WHERE state = 'recovery_required'")) publishAck(row.delivery_id, row.sender_key, "recovery_required");
|
|
944
|
+
for (const row of await database.all("SELECT delivery_id FROM session_delivery_inbox WHERE state = 'accepted'")) scheduleAdmission(row.delivery_id);
|
|
945
|
+
track(publishAllQueued());
|
|
796
946
|
return Object.freeze({
|
|
797
947
|
recipientKey: options.recipientKey,
|
|
798
948
|
async deliver(request) {
|
|
@@ -800,36 +950,38 @@ function createSessionDeliveryService(options) {
|
|
|
800
950
|
const deliveryId = request.deliveryId ?? crypto.randomUUID();
|
|
801
951
|
const mode = request.delivery ?? DEFAULT_DELIVERY;
|
|
802
952
|
if (!deliveryId.trim() || !request.recipientKey.trim() || !request.kind.trim() || !isMode(mode)) throw new TypeError("Delivery id, recipient key, kind, and mode must be valid.");
|
|
803
|
-
if (!
|
|
953
|
+
if (!await authorized(request.recipientKey, request.metadata)) throw new Error(`Session '${request.recipientKey}' is not an authorized communication peer.`);
|
|
954
|
+
if (closed) throw new Error("Session delivery service is closed.");
|
|
804
955
|
const metadata = normalizedMetadata(request.metadata);
|
|
805
|
-
database.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const current = database.
|
|
956
|
+
await database.run(`INSERT OR IGNORE INTO session_delivery_outbox
|
|
957
|
+
(delivery_id, recipient_key, kind, prompt, delivery_mode, metadata_json, state, created_at)
|
|
958
|
+
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?)`, deliveryId, request.recipientKey, request.kind, request.prompt, mode, JSON.stringify(metadata), Date.now());
|
|
959
|
+
const current = await database.get("SELECT * FROM session_delivery_outbox WHERE delivery_id = ?", deliveryId);
|
|
809
960
|
if (!current || current.recipient_key !== request.recipientKey || current.kind !== request.kind || current.prompt !== request.prompt || current.delivery_mode !== mode || !sameMetadata(JSON.parse(current.metadata_json), metadata)) throw new TypeError(`Delivery id '${deliveryId}' is already bound to another envelope.`);
|
|
810
|
-
|
|
961
|
+
startRetry();
|
|
962
|
+
await publishOutbox(deliveryId);
|
|
811
963
|
return { deliveryId };
|
|
812
964
|
},
|
|
813
965
|
async waitForAdmission(deliveryId, timeoutMs = 5e3) {
|
|
814
966
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
815
967
|
let state;
|
|
816
968
|
do {
|
|
817
|
-
state = database.
|
|
969
|
+
state = (await database.get("SELECT recipient_state FROM session_delivery_outbox WHERE delivery_id = ?", deliveryId))?.recipient_state ?? void 0;
|
|
818
970
|
if (state === "admitted" || state === "recovery_required") return state;
|
|
819
971
|
if (Date.now() >= deadline) return state;
|
|
820
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(10, deadline - Date.now())));
|
|
972
|
+
await new Promise((resolve) => (0, node_timers.setTimeout)(resolve, Math.min(10, deadline - Date.now())));
|
|
821
973
|
} while (!closed);
|
|
822
974
|
return state;
|
|
823
975
|
},
|
|
824
|
-
receive(senderKey, kind, payload) {
|
|
976
|
+
async receive(senderKey, kind, payload) {
|
|
825
977
|
if (kind === DELIVERY_EVENT) return receiveEnvelope(senderKey, payload);
|
|
826
978
|
if (kind === ACK_EVENT) {
|
|
827
|
-
receiveAck(senderKey, payload);
|
|
979
|
+
await receiveAck(senderKey, payload);
|
|
828
980
|
return "accepted";
|
|
829
981
|
}
|
|
830
982
|
},
|
|
831
|
-
inbox(query = {}) {
|
|
832
|
-
return database.
|
|
983
|
+
async inbox(query = {}) {
|
|
984
|
+
return (await database.all("SELECT * FROM session_delivery_inbox ORDER BY created_at, delivery_id")).map((row) => {
|
|
833
985
|
const metadata = JSON.parse(row.metadata_json);
|
|
834
986
|
return {
|
|
835
987
|
deliveryId: row.delivery_id,
|
|
@@ -844,11 +996,11 @@ function createSessionDeliveryService(options) {
|
|
|
844
996
|
};
|
|
845
997
|
}).filter((entry) => (query.kind === void 0 || entry.kind === query.kind) && (query.state === void 0 || entry.state === query.state) && (query.consumed === void 0 || entry.consumed === query.consumed) && matchesMetadata(entry.metadata, query.metadata));
|
|
846
998
|
},
|
|
847
|
-
consume(deliveryId) {
|
|
848
|
-
return database.
|
|
999
|
+
async consume(deliveryId) {
|
|
1000
|
+
return await database.get("UPDATE session_delivery_inbox SET consumed_at = ? WHERE delivery_id = ? AND state IN ('admitted', 'recovery_required') AND consumed_at IS NULL RETURNING delivery_id", Date.now(), deliveryId) !== void 0;
|
|
849
1001
|
},
|
|
850
|
-
outbox(deliveryId) {
|
|
851
|
-
const row = database.
|
|
1002
|
+
async outbox(deliveryId) {
|
|
1003
|
+
const row = await database.get("SELECT delivery_id, recipient_key, kind, metadata_json, state, recipient_state FROM session_delivery_outbox WHERE delivery_id = ?", deliveryId);
|
|
852
1004
|
if (!row) return void 0;
|
|
853
1005
|
return {
|
|
854
1006
|
deliveryId: row.delivery_id,
|
|
@@ -862,14 +1014,15 @@ function createSessionDeliveryService(options) {
|
|
|
862
1014
|
async close() {
|
|
863
1015
|
if (closed) return;
|
|
864
1016
|
closed = true;
|
|
865
|
-
|
|
1017
|
+
stopRetry();
|
|
866
1018
|
releaseEnvelope();
|
|
867
1019
|
releaseAck();
|
|
868
1020
|
releaseReady();
|
|
869
1021
|
try {
|
|
870
1022
|
await admissionQueue;
|
|
1023
|
+
await Promise.all(pending);
|
|
871
1024
|
} finally {
|
|
872
|
-
database.close();
|
|
1025
|
+
await database.close();
|
|
873
1026
|
}
|
|
874
1027
|
}
|
|
875
1028
|
});
|
|
@@ -881,9 +1034,9 @@ function createSessionDeliveryService(options) {
|
|
|
881
1034
|
* `ctx.get` is strict and hides an implementation while its providing fiber is
|
|
882
1035
|
* LOADING, which is the whole of that fiber's own callback.
|
|
883
1036
|
*/
|
|
884
|
-
function provideSessionDeliveryService(context, options) {
|
|
885
|
-
const service = createSessionDeliveryService(options);
|
|
886
|
-
const unprovide = context.provide(
|
|
1037
|
+
async function provideSessionDeliveryService(context, options) {
|
|
1038
|
+
const service = await createSessionDeliveryService(options);
|
|
1039
|
+
const unprovide = context.provide(PO_SESSION_DELIVERY_SERVICE, service);
|
|
887
1040
|
context.effect(() => () => service.close(), "session delivery database");
|
|
888
1041
|
return {
|
|
889
1042
|
service,
|
|
@@ -894,16 +1047,22 @@ function provideSessionDeliveryService(context, options) {
|
|
|
894
1047
|
};
|
|
895
1048
|
}
|
|
896
1049
|
//#endregion
|
|
897
|
-
Object.defineProperty(exports, "
|
|
1050
|
+
Object.defineProperty(exports, "PEER_REPLAY_DATABASE_FILE", {
|
|
1051
|
+
enumerable: true,
|
|
1052
|
+
get: function() {
|
|
1053
|
+
return PEER_REPLAY_DATABASE_FILE;
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
Object.defineProperty(exports, "PO_SESSION_DELIVERY_SERVICE", {
|
|
898
1057
|
enumerable: true,
|
|
899
1058
|
get: function() {
|
|
900
|
-
return
|
|
1059
|
+
return PO_SESSION_DELIVERY_SERVICE;
|
|
901
1060
|
}
|
|
902
1061
|
});
|
|
903
|
-
Object.defineProperty(exports, "
|
|
1062
|
+
Object.defineProperty(exports, "PO_SESSION_INTERCOM_SERVICE", {
|
|
904
1063
|
enumerable: true,
|
|
905
1064
|
get: function() {
|
|
906
|
-
return
|
|
1065
|
+
return PO_SESSION_INTERCOM_SERVICE;
|
|
907
1066
|
}
|
|
908
1067
|
});
|
|
909
1068
|
Object.defineProperty(exports, "SessionGroupRevisionError", {
|
|
@@ -936,6 +1095,12 @@ Object.defineProperty(exports, "createPeerReplayGuard", {
|
|
|
936
1095
|
return createPeerReplayGuard;
|
|
937
1096
|
}
|
|
938
1097
|
});
|
|
1098
|
+
Object.defineProperty(exports, "createPeerReplayGuardAsync", {
|
|
1099
|
+
enumerable: true,
|
|
1100
|
+
get: function() {
|
|
1101
|
+
return createPeerReplayGuardAsync;
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
939
1104
|
Object.defineProperty(exports, "createSessionDeliveryService", {
|
|
940
1105
|
enumerable: true,
|
|
941
1106
|
get: function() {
|
|
@@ -966,6 +1131,12 @@ Object.defineProperty(exports, "createSessionIntercom", {
|
|
|
966
1131
|
return createSessionIntercom;
|
|
967
1132
|
}
|
|
968
1133
|
});
|
|
1134
|
+
Object.defineProperty(exports, "ensurePrivateDirectory", {
|
|
1135
|
+
enumerable: true,
|
|
1136
|
+
get: function() {
|
|
1137
|
+
return ensurePrivateDirectory;
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
969
1140
|
Object.defineProperty(exports, "parseRemoteSessionReference", {
|
|
970
1141
|
enumerable: true,
|
|
971
1142
|
get: function() {
|
|
@@ -1002,10 +1173,10 @@ Object.defineProperty(exports, "provideSessionDeliveryService", {
|
|
|
1002
1173
|
return provideSessionDeliveryService;
|
|
1003
1174
|
}
|
|
1004
1175
|
});
|
|
1005
|
-
Object.defineProperty(exports, "
|
|
1176
|
+
Object.defineProperty(exports, "readPoSessionDelivery", {
|
|
1006
1177
|
enumerable: true,
|
|
1007
1178
|
get: function() {
|
|
1008
|
-
return
|
|
1179
|
+
return readPoSessionDelivery;
|
|
1009
1180
|
}
|
|
1010
1181
|
});
|
|
1011
1182
|
Object.defineProperty(exports, "readSessionIntercom", {
|
|
@@ -1020,6 +1191,12 @@ Object.defineProperty(exports, "readSessionPeerConfig", {
|
|
|
1020
1191
|
return readSessionPeerConfig;
|
|
1021
1192
|
}
|
|
1022
1193
|
});
|
|
1194
|
+
Object.defineProperty(exports, "readSessionPeerConfigAsync", {
|
|
1195
|
+
enumerable: true,
|
|
1196
|
+
get: function() {
|
|
1197
|
+
return readSessionPeerConfigAsync;
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1023
1200
|
Object.defineProperty(exports, "requireSessionIntercom", {
|
|
1024
1201
|
enumerable: true,
|
|
1025
1202
|
get: function() {
|
|
@@ -1039,4 +1216,4 @@ Object.defineProperty(exports, "verifyPeerRequest", {
|
|
|
1039
1216
|
}
|
|
1040
1217
|
});
|
|
1041
1218
|
|
|
1042
|
-
//# sourceMappingURL=sessionDelivery-
|
|
1219
|
+
//# sourceMappingURL=sessionDelivery-CGWuQyLV.cjs.map
|