@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
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { BackgroundProviderWorkItemSchema,
|
|
1
|
+
import { BackgroundProviderWorkItemSchema, PO_BACKGROUND_WORK_CHANGED_EVENT, PO_BACKGROUND_WORK_SERVICE } from "@agimon-ai/po-core/backgroundWork";
|
|
2
2
|
import { Check } from "typebox/value";
|
|
3
3
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import fs from "node:fs";
|
|
5
4
|
import path from "node:path";
|
|
6
|
-
import {
|
|
5
|
+
import { getOrThrow, nodeHost, readTextIfExists } from "@agimon-ai/po-core/platform";
|
|
7
6
|
import { poHomeDirectory } from "@agimon-ai/po-core/piSettings";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import { clearInterval, setInterval, setTimeout } from "node:timers";
|
|
8
10
|
//#region src/services/backgroundWorkService/index.ts
|
|
9
11
|
/** Creates the generation-bound, pull-based coordinator shared by session extensions. */
|
|
10
12
|
function createBackgroundWorkService(ctx) {
|
|
11
13
|
const registrations = /* @__PURE__ */ new Map();
|
|
12
|
-
const generation = `
|
|
14
|
+
const generation = `po-background-work:${crypto.randomUUID()}`;
|
|
13
15
|
const changed = (provider, providerGeneration, kind) => {
|
|
14
|
-
ctx.emit(
|
|
16
|
+
ctx.emit(PO_BACKGROUND_WORK_CHANGED_EVENT, {
|
|
15
17
|
provider,
|
|
16
18
|
generation: providerGeneration,
|
|
17
19
|
kind
|
|
@@ -75,11 +77,8 @@ function createBackgroundWorkService(ctx) {
|
|
|
75
77
|
}
|
|
76
78
|
/** Installs one coordinator in the current session service context. */
|
|
77
79
|
function provideBackgroundWorkService(context) {
|
|
78
|
-
context.provide(
|
|
80
|
+
context.provide(PO_BACKGROUND_WORK_SERVICE, createBackgroundWorkService(context));
|
|
79
81
|
}
|
|
80
|
-
//#endregion
|
|
81
|
-
//#region src/services/peerTransport/index.ts
|
|
82
|
-
const PEER_CONFIG_VERSION = 1;
|
|
83
82
|
const PEER_CONFIG_FILE = "peers.json";
|
|
84
83
|
const PEER_ROUTE = "/api/plugins/session-peer/inbox";
|
|
85
84
|
const PEER_HEADER = "x-po-session-peer";
|
|
@@ -87,7 +86,8 @@ const TIMESTAMP_HEADER = "x-po-session-timestamp";
|
|
|
87
86
|
const SIGNATURE_HEADER = "x-po-session-signature";
|
|
88
87
|
const MAX_CLOCK_SKEW_MS = 3e5;
|
|
89
88
|
const MAX_REPLAY_ENTRIES = 1e4;
|
|
90
|
-
|
|
89
|
+
/** File the replay guard database lives in, beside the peer config. */
|
|
90
|
+
const PEER_REPLAY_DATABASE_FILE = "peer-replay.sqlite";
|
|
91
91
|
function validId(value) {
|
|
92
92
|
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
|
|
93
93
|
}
|
|
@@ -116,29 +116,144 @@ function peerOf(value) {
|
|
|
116
116
|
function sessionPeerConfigPath(homeDirectory) {
|
|
117
117
|
return path.join(poHomeDirectory(homeDirectory), "session", PEER_CONFIG_FILE);
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
120
|
-
function
|
|
119
|
+
/** Group or other permission bits on the peer config make it untrusted. */
|
|
120
|
+
function peerConfigModeIsPrivate(mode) {
|
|
121
|
+
return (mode & 63) === 0;
|
|
122
|
+
}
|
|
123
|
+
/** Validates peer config text. Invalid config fails closed. */
|
|
124
|
+
function parseSessionPeerConfig(text) {
|
|
121
125
|
let parsed;
|
|
122
126
|
try {
|
|
123
|
-
|
|
124
|
-
if ((fs.statSync(file).mode & 63) !== 0) return void 0;
|
|
125
|
-
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
127
|
+
parsed = JSON.parse(text);
|
|
126
128
|
} catch {
|
|
127
129
|
return;
|
|
128
130
|
}
|
|
129
131
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
130
132
|
const record = parsed;
|
|
131
|
-
if (record.version !==
|
|
133
|
+
if (record.version !== 1 || !validId(record.hostId) || !Array.isArray(record.peers)) return void 0;
|
|
132
134
|
const peers = record.peers.map(peerOf);
|
|
133
135
|
if (peers.some((peer) => peer === void 0)) return void 0;
|
|
134
136
|
const resolved = peers;
|
|
135
137
|
if (new Set(resolved.map((peer) => peer.hostId)).size !== resolved.length || resolved.some((peer) => peer.hostId === record.hostId)) return void 0;
|
|
136
138
|
return {
|
|
137
|
-
version:
|
|
139
|
+
version: 1,
|
|
138
140
|
hostId: record.hostId,
|
|
139
141
|
peers: resolved
|
|
140
142
|
};
|
|
141
143
|
}
|
|
144
|
+
/** Replay guard SQL shared by the async guard and the Node-only sync shim. */
|
|
145
|
+
const PEER_REPLAY_SQL = Object.freeze({
|
|
146
|
+
schema: `
|
|
147
|
+
CREATE TABLE IF NOT EXISTS peer_request_replays (
|
|
148
|
+
signature TEXT PRIMARY KEY,
|
|
149
|
+
expires_at INTEGER NOT NULL
|
|
150
|
+
);
|
|
151
|
+
`,
|
|
152
|
+
removeExpired: "DELETE FROM peer_request_replays WHERE expires_at < ?",
|
|
153
|
+
insert: "INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)",
|
|
154
|
+
insertReturning: "INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?) RETURNING signature",
|
|
155
|
+
count: "SELECT COUNT(*) AS count FROM peer_request_replays",
|
|
156
|
+
trim: `
|
|
157
|
+
DELETE FROM peer_request_replays
|
|
158
|
+
WHERE signature IN (
|
|
159
|
+
SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?
|
|
160
|
+
)
|
|
161
|
+
`
|
|
162
|
+
});
|
|
163
|
+
function peerRequestSignature(request) {
|
|
164
|
+
return request.headers.get(SIGNATURE_HEADER);
|
|
165
|
+
}
|
|
166
|
+
function peerReplayExpiry(now) {
|
|
167
|
+
return now + MAX_CLOCK_SKEW_MS;
|
|
168
|
+
}
|
|
169
|
+
function peerReplayOverflow(total) {
|
|
170
|
+
return total > MAX_REPLAY_ENTRIES ? total - MAX_REPLAY_ENTRIES : 0;
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/services/peerTransport/sync.ts
|
|
174
|
+
/**
|
|
175
|
+
* Reads operator-provisioned peers. Invalid or unreadable config fails closed.
|
|
176
|
+
*
|
|
177
|
+
* @deprecated Node-only sync shim; use readSessionPeerConfigAsync. Removed in M2.
|
|
178
|
+
*/
|
|
179
|
+
function readSessionPeerConfig(homeDirectory) {
|
|
180
|
+
let text;
|
|
181
|
+
try {
|
|
182
|
+
const file = sessionPeerConfigPath(homeDirectory);
|
|
183
|
+
if (!peerConfigModeIsPrivate(fs.statSync(file).mode)) return void 0;
|
|
184
|
+
text = fs.readFileSync(file, "utf8");
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
return parseSessionPeerConfig(text);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window.
|
|
192
|
+
*
|
|
193
|
+
* @deprecated Node-only sync shim; use createPeerReplayGuardAsync. Removed in M2.
|
|
194
|
+
*/
|
|
195
|
+
function createPeerReplayGuard(homeDirectory) {
|
|
196
|
+
const directory = path.dirname(sessionPeerConfigPath(homeDirectory));
|
|
197
|
+
fs.mkdirSync(directory, {
|
|
198
|
+
recursive: true,
|
|
199
|
+
mode: 448
|
|
200
|
+
});
|
|
201
|
+
const database = new DatabaseSync(path.join(directory, PEER_REPLAY_DATABASE_FILE));
|
|
202
|
+
database.exec(`PRAGMA journal_mode = WAL;${PEER_REPLAY_SQL.schema}`);
|
|
203
|
+
const removeExpired = database.prepare(PEER_REPLAY_SQL.removeExpired);
|
|
204
|
+
const insert = database.prepare(PEER_REPLAY_SQL.insert);
|
|
205
|
+
const count = database.prepare(PEER_REPLAY_SQL.count);
|
|
206
|
+
const trim = database.prepare(PEER_REPLAY_SQL.trim);
|
|
207
|
+
return {
|
|
208
|
+
admit(request, now = Date.now()) {
|
|
209
|
+
const supplied = peerRequestSignature(request);
|
|
210
|
+
if (!supplied) return false;
|
|
211
|
+
database.exec("BEGIN IMMEDIATE");
|
|
212
|
+
try {
|
|
213
|
+
removeExpired.run(now);
|
|
214
|
+
const accepted = insert.run(supplied, peerReplayExpiry(now)).changes === 1;
|
|
215
|
+
const overflow = peerReplayOverflow(Number(count.get().count));
|
|
216
|
+
if (overflow > 0) trim.run(overflow);
|
|
217
|
+
database.exec("COMMIT");
|
|
218
|
+
return accepted;
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
database.exec("ROLLBACK");
|
|
221
|
+
throw cause;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
close: () => database.close()
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/services/peerTransport/index.ts
|
|
229
|
+
/** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */
|
|
230
|
+
async function readSessionPeerConfigAsync(fs, homeDirectory, context) {
|
|
231
|
+
try {
|
|
232
|
+
const file = sessionPeerConfigPath(homeDirectory);
|
|
233
|
+
const mode = await nodeHost.fileMode(file);
|
|
234
|
+
if (mode !== void 0 && !peerConfigModeIsPrivate(mode)) return void 0;
|
|
235
|
+
const text = await readTextIfExists(fs, file, context);
|
|
236
|
+
return text === void 0 ? void 0 : parseSessionPeerConfig(text);
|
|
237
|
+
} catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Creates a directory tree, giving each directory it creates owner-only access, the way
|
|
243
|
+
* `mkdirSync(directory, { recursive: true, mode: 0o700 })` does. Existing directories keep their mode.
|
|
244
|
+
*/
|
|
245
|
+
async function ensurePrivateDirectory(fs, directory, context) {
|
|
246
|
+
const missing = [];
|
|
247
|
+
for (let current = path.resolve(directory); !getOrThrow(await fs.exists(current, context));) {
|
|
248
|
+
missing.unshift(current);
|
|
249
|
+
const parent = path.dirname(current);
|
|
250
|
+
if (parent === current) break;
|
|
251
|
+
current = parent;
|
|
252
|
+
}
|
|
253
|
+
if (missing.length === 0) return;
|
|
254
|
+
getOrThrow(await fs.createDir(directory, { recursive: true }, context));
|
|
255
|
+
for (const created of missing) await nodeHost.setMode(created, 448);
|
|
256
|
+
}
|
|
142
257
|
/** `peer/<host-id>/<session-id>` is the only remote target spelling. */
|
|
143
258
|
function parseRemoteSessionReference(value) {
|
|
144
259
|
const match = /^peer\/([^/]+)\/([^/]+)$/u.exec(value);
|
|
@@ -165,46 +280,24 @@ function peerRequestHeaders(sourceHostId, peer, method, pathname, body, now = Da
|
|
|
165
280
|
[SIGNATURE_HEADER]: signature(peer.secret, method, pathname, timestamp, body)
|
|
166
281
|
});
|
|
167
282
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
});
|
|
175
|
-
const database = new DatabaseSync(path.join(directory, REPLAY_DATABASE_FILE));
|
|
176
|
-
database.exec(`
|
|
177
|
-
PRAGMA journal_mode = WAL;
|
|
178
|
-
CREATE TABLE IF NOT EXISTS peer_request_replays (
|
|
179
|
-
signature TEXT PRIMARY KEY,
|
|
180
|
-
expires_at INTEGER NOT NULL
|
|
181
|
-
);
|
|
182
|
-
`);
|
|
183
|
-
const removeExpired = database.prepare("DELETE FROM peer_request_replays WHERE expires_at < ?");
|
|
184
|
-
const insert = database.prepare("INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)");
|
|
185
|
-
const count = database.prepare("SELECT COUNT(*) AS count FROM peer_request_replays");
|
|
186
|
-
const trim = database.prepare(`
|
|
187
|
-
DELETE FROM peer_request_replays
|
|
188
|
-
WHERE signature IN (
|
|
189
|
-
SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?
|
|
190
|
-
)
|
|
191
|
-
`);
|
|
283
|
+
/**
|
|
284
|
+
* Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window.
|
|
285
|
+
* Takes over the database and closes it in `close()`.
|
|
286
|
+
*/
|
|
287
|
+
async function createPeerReplayGuardAsync(database) {
|
|
288
|
+
await database.exec(PEER_REPLAY_SQL.schema);
|
|
192
289
|
return {
|
|
193
|
-
admit(request, now = Date.now()) {
|
|
194
|
-
const supplied = request
|
|
290
|
+
async admit(request, now = Date.now()) {
|
|
291
|
+
const supplied = peerRequestSignature(request);
|
|
195
292
|
if (!supplied) return false;
|
|
196
|
-
database.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
202
|
-
database.exec("COMMIT");
|
|
293
|
+
return database.transaction(async (transaction) => {
|
|
294
|
+
await transaction.run(PEER_REPLAY_SQL.removeExpired, now);
|
|
295
|
+
const accepted = await transaction.get(PEER_REPLAY_SQL.insertReturning, supplied, peerReplayExpiry(now)) !== void 0;
|
|
296
|
+
const row = await transaction.get(PEER_REPLAY_SQL.count);
|
|
297
|
+
const overflow = peerReplayOverflow(Number(row?.count ?? 0));
|
|
298
|
+
if (overflow > 0) await transaction.run(PEER_REPLAY_SQL.trim, overflow);
|
|
203
299
|
return accepted;
|
|
204
|
-
}
|
|
205
|
-
database.exec("ROLLBACK");
|
|
206
|
-
throw cause;
|
|
207
|
-
}
|
|
300
|
+
});
|
|
208
301
|
},
|
|
209
302
|
close: () => database.close()
|
|
210
303
|
};
|
|
@@ -215,7 +308,7 @@ function verifyPeerRequest(config, request, body, now = Date.now()) {
|
|
|
215
308
|
const supplied = request.headers.get(SIGNATURE_HEADER);
|
|
216
309
|
if (!hostId || !timestamp || !supplied || !/^\d{1,16}$/u.test(timestamp)) return void 0;
|
|
217
310
|
const sentAt = Number(timestamp);
|
|
218
|
-
if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) >
|
|
311
|
+
if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > 3e5) return void 0;
|
|
219
312
|
const peer = config.peers.find((candidate) => candidate.hostId === hostId);
|
|
220
313
|
if (!peer) return void 0;
|
|
221
314
|
const expected = Buffer.from(signature(peer.secret, request.method, new URL(request.url).pathname, timestamp, body));
|
|
@@ -237,7 +330,7 @@ function parseSessionPeerEnvelope(value) {
|
|
|
237
330
|
}
|
|
238
331
|
/** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */
|
|
239
332
|
function createPeerCommunication(options) {
|
|
240
|
-
const config =
|
|
333
|
+
const config = options.config;
|
|
241
334
|
const send = options.fetch ?? fetch;
|
|
242
335
|
return {
|
|
243
336
|
sessionId: options.local.sessionId,
|
|
@@ -305,19 +398,26 @@ function createSessionDirectory(options) {
|
|
|
305
398
|
const observations = /* @__PURE__ */ new Map();
|
|
306
399
|
const snapshotRequired = /* @__PURE__ */ new Set();
|
|
307
400
|
const subscribers = /* @__PURE__ */ new Set();
|
|
308
|
-
const discover = (callerReference, allowedReferences) => {
|
|
401
|
+
const discover = async (callerReference, allowedReferences) => {
|
|
309
402
|
const result = [];
|
|
310
|
-
for (const observation of observations.values()) {
|
|
403
|
+
for (const observation of Array.from(observations.values())) {
|
|
311
404
|
const reference = keyOf(observation);
|
|
312
405
|
if (allowedReferences && !allowedReferences.has(reference)) continue;
|
|
313
|
-
const grant = options.authorizeDiscovery(callerReference, observation);
|
|
406
|
+
const grant = await options.authorizeDiscovery(callerReference, observation);
|
|
314
407
|
if (!grant) continue;
|
|
315
408
|
result.push(publicEntry(observation, now(), grant.label ?? observation.label, grant.capabilities));
|
|
316
409
|
}
|
|
317
410
|
return result.sort((left, right) => left.reference.localeCompare(right.reference));
|
|
318
411
|
};
|
|
412
|
+
const push = (subscriber) => {
|
|
413
|
+
subscriber.queue = subscriber.queue.then(async () => {
|
|
414
|
+
if (!subscribers.has(subscriber)) return;
|
|
415
|
+
const sessions = await discover(subscriber.callerReference);
|
|
416
|
+
if (subscribers.has(subscriber)) subscriber.listener(sessions);
|
|
417
|
+
}).catch(() => void 0);
|
|
418
|
+
};
|
|
319
419
|
const notify = () => {
|
|
320
|
-
for (const subscriber of subscribers)
|
|
420
|
+
for (const subscriber of subscribers) push(subscriber);
|
|
321
421
|
};
|
|
322
422
|
return {
|
|
323
423
|
observe(observation) {
|
|
@@ -366,17 +466,18 @@ function createSessionDirectory(options) {
|
|
|
366
466
|
notify();
|
|
367
467
|
},
|
|
368
468
|
discover,
|
|
369
|
-
resolveDeliveryTarget(callerReference, reference) {
|
|
469
|
+
async resolveDeliveryTarget(callerReference, reference) {
|
|
370
470
|
const observation = observations.get(reference);
|
|
371
|
-
return observation && options.authorizeDiscovery(callerReference, observation) ? observation.deliveryTarget : void 0;
|
|
471
|
+
return observation && await options.authorizeDiscovery(callerReference, observation) ? observation.deliveryTarget : void 0;
|
|
372
472
|
},
|
|
373
473
|
subscribe(callerReference, listener) {
|
|
374
474
|
const subscriber = {
|
|
375
475
|
callerReference,
|
|
376
|
-
listener
|
|
476
|
+
listener,
|
|
477
|
+
queue: Promise.resolve()
|
|
377
478
|
};
|
|
378
479
|
subscribers.add(subscriber);
|
|
379
|
-
|
|
480
|
+
push(subscriber);
|
|
380
481
|
return () => subscribers.delete(subscriber);
|
|
381
482
|
}
|
|
382
483
|
};
|
|
@@ -399,100 +500,96 @@ function validateGroupId(value) {
|
|
|
399
500
|
function validateReference(value) {
|
|
400
501
|
if (!REFERENCE_PATTERN.test(value)) throw new TypeError(`Session group member '${value}' is not a qualified reference.`);
|
|
401
502
|
}
|
|
503
|
+
const SCHEMA$1 = `
|
|
504
|
+
CREATE TABLE IF NOT EXISTS session_groups (
|
|
505
|
+
group_id TEXT PRIMARY KEY,
|
|
506
|
+
owner_authority TEXT NOT NULL,
|
|
507
|
+
revision INTEGER NOT NULL
|
|
508
|
+
);
|
|
509
|
+
CREATE TABLE IF NOT EXISTS session_group_members (
|
|
510
|
+
group_id TEXT NOT NULL REFERENCES session_groups(group_id) ON DELETE CASCADE,
|
|
511
|
+
member_reference TEXT NOT NULL,
|
|
512
|
+
PRIMARY KEY (group_id, member_reference)
|
|
513
|
+
);
|
|
514
|
+
`;
|
|
515
|
+
async function readGroup(database, groupId) {
|
|
516
|
+
const row = await database.get("SELECT * FROM session_groups WHERE group_id = ?", groupId);
|
|
517
|
+
if (!row) return void 0;
|
|
518
|
+
const members = await database.all("SELECT member_reference FROM session_group_members WHERE group_id = ? ORDER BY member_reference", groupId);
|
|
519
|
+
return Object.freeze({
|
|
520
|
+
id: row.group_id,
|
|
521
|
+
ownerAuthority: row.owner_authority,
|
|
522
|
+
revision: Number(row.revision),
|
|
523
|
+
members: Object.freeze(members.map((member) => member.member_reference))
|
|
524
|
+
});
|
|
525
|
+
}
|
|
402
526
|
/** Opens a revisioned SQLite registry for communication-only group membership. */
|
|
403
|
-
function createSessionGroupStore(options) {
|
|
404
|
-
const database =
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
owner_authority TEXT NOT NULL,
|
|
411
|
-
revision INTEGER NOT NULL
|
|
412
|
-
);
|
|
413
|
-
CREATE TABLE IF NOT EXISTS session_group_members (
|
|
414
|
-
group_id TEXT NOT NULL REFERENCES session_groups(group_id) ON DELETE CASCADE,
|
|
415
|
-
member_reference TEXT NOT NULL,
|
|
416
|
-
PRIMARY KEY (group_id, member_reference)
|
|
417
|
-
);
|
|
418
|
-
`);
|
|
419
|
-
const read = (groupId) => {
|
|
420
|
-
const row = database.prepare("SELECT * FROM session_groups WHERE group_id = ?").get(groupId);
|
|
421
|
-
if (!row) return void 0;
|
|
422
|
-
const members = database.prepare("SELECT member_reference FROM session_group_members WHERE group_id = ? ORDER BY member_reference").all(groupId);
|
|
423
|
-
return Object.freeze({
|
|
424
|
-
id: row.group_id,
|
|
425
|
-
ownerAuthority: row.owner_authority,
|
|
426
|
-
revision: row.revision,
|
|
427
|
-
members: Object.freeze(members.map((member) => member.member_reference))
|
|
428
|
-
});
|
|
429
|
-
};
|
|
430
|
-
const enroll = (groupId, members) => {
|
|
431
|
-
const insert = database.prepare("INSERT OR IGNORE INTO session_group_members (group_id, member_reference) VALUES (?, ?)");
|
|
527
|
+
async function createSessionGroupStore(options) {
|
|
528
|
+
const { database } = options;
|
|
529
|
+
try {
|
|
530
|
+
await database.exec("PRAGMA foreign_keys = ON");
|
|
531
|
+
} catch {}
|
|
532
|
+
await database.exec(SCHEMA$1);
|
|
533
|
+
const enroll = async (transaction, groupId, members) => {
|
|
432
534
|
for (const member of new Set(members)) {
|
|
433
535
|
validateReference(member);
|
|
434
536
|
if (options.validateEnrollment?.(groupId, member) === false) throw new Error(`Session group enrollment policy rejected '${member}'.`);
|
|
435
|
-
|
|
537
|
+
await transaction.run("INSERT OR IGNORE INTO session_group_members (group_id, member_reference) VALUES (?, ?)", groupId, member);
|
|
436
538
|
}
|
|
437
539
|
};
|
|
438
540
|
return {
|
|
439
|
-
create(groupId, ownerAuthority, members) {
|
|
541
|
+
async create(groupId, ownerAuthority, members) {
|
|
440
542
|
validateGroupId(groupId);
|
|
441
543
|
if (!ownerAuthority.trim()) throw new TypeError("Session group owner authority is required.");
|
|
442
|
-
database.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
} catch (cause) {
|
|
448
|
-
database.exec("ROLLBACK");
|
|
449
|
-
throw cause;
|
|
450
|
-
}
|
|
451
|
-
return read(groupId);
|
|
544
|
+
return database.transaction(async (transaction) => {
|
|
545
|
+
await transaction.run("INSERT INTO session_groups (group_id, owner_authority, revision) VALUES (?, ?, 1)", groupId, ownerAuthority);
|
|
546
|
+
await enroll(transaction, groupId, members);
|
|
547
|
+
return await readGroup(transaction, groupId);
|
|
548
|
+
});
|
|
452
549
|
},
|
|
453
|
-
mutate(mutation) {
|
|
454
|
-
database.
|
|
455
|
-
|
|
456
|
-
const current = read(mutation.groupId);
|
|
550
|
+
async mutate(mutation) {
|
|
551
|
+
return database.transaction(async (transaction) => {
|
|
552
|
+
const current = await readGroup(transaction, mutation.groupId);
|
|
457
553
|
if (!current) throw new Error(`Session group '${mutation.groupId}' does not exist.`);
|
|
458
554
|
if (mutation.authority !== current.ownerAuthority && options.authorizeManagement?.(mutation.authority, current) !== true) throw new Error(`Authority '${mutation.authority}' cannot manage Session group '${mutation.groupId}'.`);
|
|
459
555
|
if (mutation.expectedRevision !== current.revision) throw new SessionGroupRevisionError(mutation.groupId, mutation.expectedRevision, current.revision);
|
|
460
|
-
enroll(mutation.groupId, mutation.add ?? []);
|
|
461
|
-
const remove = database.prepare("DELETE FROM session_group_members WHERE group_id = ? AND member_reference = ?");
|
|
556
|
+
await enroll(transaction, mutation.groupId, mutation.add ?? []);
|
|
462
557
|
for (const member of new Set(mutation.remove ?? [])) {
|
|
463
558
|
validateReference(member);
|
|
464
|
-
|
|
559
|
+
await transaction.run("DELETE FROM session_group_members WHERE group_id = ? AND member_reference = ?", mutation.groupId, member);
|
|
465
560
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
database.exec("ROLLBACK");
|
|
470
|
-
throw cause;
|
|
471
|
-
}
|
|
472
|
-
return read(mutation.groupId);
|
|
561
|
+
await transaction.run("UPDATE session_groups SET revision = revision + 1 WHERE group_id = ? AND revision = ?", mutation.groupId, current.revision);
|
|
562
|
+
return await readGroup(transaction, mutation.groupId);
|
|
563
|
+
});
|
|
473
564
|
},
|
|
474
|
-
get:
|
|
475
|
-
groupsFor(memberReference) {
|
|
565
|
+
get: (groupId) => readGroup(database, groupId),
|
|
566
|
+
async groupsFor(memberReference) {
|
|
476
567
|
validateReference(memberReference);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
568
|
+
const rows = await database.all(`SELECT g.group_id FROM session_groups g
|
|
569
|
+
INNER JOIN session_group_members m ON m.group_id = g.group_id
|
|
570
|
+
WHERE m.member_reference = ? ORDER BY g.group_id`, memberReference);
|
|
571
|
+
const groups = [];
|
|
572
|
+
for (const row of rows) {
|
|
573
|
+
const group = await readGroup(database, row.group_id);
|
|
574
|
+
if (group) groups.push(group);
|
|
575
|
+
}
|
|
576
|
+
return groups;
|
|
480
577
|
},
|
|
481
|
-
canMessage(groupId, sourceReference, targetReference) {
|
|
578
|
+
async canMessage(groupId, sourceReference, targetReference) {
|
|
482
579
|
validateReference(sourceReference);
|
|
483
580
|
validateReference(targetReference);
|
|
484
|
-
const row = database.
|
|
485
|
-
|
|
486
|
-
return Number(row
|
|
581
|
+
const row = await database.get(`SELECT COUNT(*) AS count FROM session_group_members
|
|
582
|
+
WHERE group_id = ? AND member_reference IN (?, ?)`, groupId, sourceReference, targetReference);
|
|
583
|
+
return Number(row?.count ?? 0) === (sourceReference === targetReference ? 1 : 2);
|
|
487
584
|
},
|
|
488
585
|
close: () => database.close()
|
|
489
586
|
};
|
|
490
587
|
}
|
|
491
588
|
//#endregion
|
|
492
589
|
//#region src/services/sessionIntercom/type.ts
|
|
493
|
-
const
|
|
590
|
+
const PO_SESSION_INTERCOM_SERVICE = "po/session-intercom";
|
|
494
591
|
function readSessionIntercom(context) {
|
|
495
|
-
return context.get(
|
|
592
|
+
return context.get(PO_SESSION_INTERCOM_SERVICE);
|
|
496
593
|
}
|
|
497
594
|
function requireSessionIntercom(context) {
|
|
498
595
|
const service = readSessionIntercom(context);
|
|
@@ -503,9 +600,9 @@ function requireSessionIntercom(context) {
|
|
|
503
600
|
//#region src/services/sessionIntercom/index.ts
|
|
504
601
|
const MESSAGE_KIND = "session-message";
|
|
505
602
|
const REPORT_KIND = "session-report";
|
|
506
|
-
function metadataFor(options, request, reportStatus) {
|
|
507
|
-
const group = options.groups.get(request.groupId);
|
|
508
|
-
if (!group || !options.groups.canMessage(request.groupId, options.selfReference, request.targetReference)) throw new Error(`Session group '${request.groupId}' does not authorize this message.`);
|
|
603
|
+
async function metadataFor(options, request, reportStatus) {
|
|
604
|
+
const group = await options.groups.get(request.groupId);
|
|
605
|
+
if (!group || !await options.groups.canMessage(request.groupId, options.selfReference, request.targetReference)) throw new Error(`Session group '${request.groupId}' does not authorize this message.`);
|
|
509
606
|
return {
|
|
510
607
|
sourceReference: options.selfReference,
|
|
511
608
|
targetReference: request.targetReference,
|
|
@@ -515,12 +612,21 @@ function metadataFor(options, request, reportStatus) {
|
|
|
515
612
|
...reportStatus === void 0 ? {} : { reportStatus }
|
|
516
613
|
};
|
|
517
614
|
}
|
|
615
|
+
/** The group IDs, among those given, whose current membership includes this session. */
|
|
616
|
+
async function groupMembership(options, groupIds) {
|
|
617
|
+
const memberOf = /* @__PURE__ */ new Set();
|
|
618
|
+
for (const groupId of new Set(groupIds)) {
|
|
619
|
+
if (groupId === void 0) continue;
|
|
620
|
+
if ((await options.groups.get(groupId))?.members.includes(options.selfReference) === true) memberOf.add(groupId);
|
|
621
|
+
}
|
|
622
|
+
return memberOf;
|
|
623
|
+
}
|
|
518
624
|
/** Agent-facing intercom operations. A composition may acquire this service without gaining session controls. */
|
|
519
625
|
function createSessionIntercom(options) {
|
|
520
626
|
const deliver = async (request, kind, status) => {
|
|
521
|
-
const recipientKey = options.directory.resolveDeliveryTarget(options.selfReference, request.targetReference);
|
|
627
|
+
const recipientKey = await options.directory.resolveDeliveryTarget(options.selfReference, request.targetReference);
|
|
522
628
|
if (!recipientKey) throw new Error(`Session '${request.targetReference}' is not discoverable.`);
|
|
523
|
-
const metadata = metadataFor(options, request, status);
|
|
629
|
+
const metadata = await metadataFor(options, request, status);
|
|
524
630
|
return options.delivery.deliver({
|
|
525
631
|
...request.deliveryId === void 0 ? {} : { deliveryId: request.deliveryId },
|
|
526
632
|
recipientKey,
|
|
@@ -530,16 +636,18 @@ function createSessionIntercom(options) {
|
|
|
530
636
|
});
|
|
531
637
|
};
|
|
532
638
|
return Object.freeze({
|
|
533
|
-
discover() {
|
|
534
|
-
const members = new Set(options.groups.groupsFor(options.selfReference).flatMap((group) => group.members).filter((reference) => reference !== options.selfReference));
|
|
639
|
+
async discover() {
|
|
640
|
+
const members = new Set((await options.groups.groupsFor(options.selfReference)).flatMap((group) => group.members).filter((reference) => reference !== options.selfReference));
|
|
535
641
|
return options.directory.discover(options.selfReference, members);
|
|
536
642
|
},
|
|
537
643
|
send: (request) => deliver(request, MESSAGE_KIND),
|
|
538
644
|
report: (request) => deliver(request, REPORT_KIND, request.status),
|
|
539
|
-
messages() {
|
|
540
|
-
|
|
645
|
+
async messages() {
|
|
646
|
+
const entries = (await options.delivery.inbox()).filter((entry) => entry.kind === MESSAGE_KIND || entry.kind === REPORT_KIND);
|
|
647
|
+
const memberOf = await groupMembership(options, entries.map((entry) => entry.metadata.groupId));
|
|
648
|
+
return entries.filter((entry) => {
|
|
541
649
|
const groupId = entry.metadata.groupId;
|
|
542
|
-
return groupId !== void 0 &&
|
|
650
|
+
return groupId !== void 0 && memberOf.has(groupId);
|
|
543
651
|
}).flatMap((entry) => {
|
|
544
652
|
const sourceReference = entry.metadata.sourceReference;
|
|
545
653
|
const targetReference = entry.metadata.targetReference;
|
|
@@ -559,38 +667,38 @@ function createSessionIntercom(options) {
|
|
|
559
667
|
}];
|
|
560
668
|
});
|
|
561
669
|
},
|
|
562
|
-
delivery(deliveryId) {
|
|
563
|
-
const entry = options.delivery.outbox(deliveryId);
|
|
670
|
+
async delivery(deliveryId) {
|
|
671
|
+
const entry = await options.delivery.outbox(deliveryId);
|
|
564
672
|
const groupId = entry?.metadata.groupId;
|
|
565
|
-
return entry && groupId !== void 0 && options
|
|
673
|
+
return entry && groupId !== void 0 && (await groupMembership(options, [groupId])).has(groupId) ? entry : void 0;
|
|
566
674
|
}
|
|
567
675
|
});
|
|
568
676
|
}
|
|
569
677
|
/** Builds the dynamic delivery check used before every send, retry, and recipient acceptance. */
|
|
570
678
|
function createSessionGroupDeliveryAuthorizer(options) {
|
|
571
|
-
return (peerKey, metadata) => {
|
|
679
|
+
return async (peerKey, metadata) => {
|
|
572
680
|
const sourceReference = metadata?.sourceReference;
|
|
573
681
|
const targetReference = metadata?.targetReference;
|
|
574
682
|
const groupId = metadata?.groupId;
|
|
575
683
|
if (sourceReference && targetReference && groupId) {
|
|
576
684
|
const peerReference = options.peerReference(peerKey);
|
|
577
685
|
if (!peerReference) return false;
|
|
578
|
-
return (sourceReference === options.selfReference && targetReference === peerReference || targetReference === options.selfReference && sourceReference === peerReference) && options.groups.canMessage(groupId, sourceReference, targetReference);
|
|
686
|
+
return (sourceReference === options.selfReference && targetReference === peerReference || targetReference === options.selfReference && sourceReference === peerReference) && await options.groups.canMessage(groupId, sourceReference, targetReference);
|
|
579
687
|
}
|
|
580
|
-
return options.fallback?.(peerKey, metadata) === true;
|
|
688
|
+
return await options.fallback?.(peerKey, metadata) === true;
|
|
581
689
|
};
|
|
582
690
|
}
|
|
583
691
|
//#endregion
|
|
584
692
|
//#region src/services/sessionDelivery/type.ts
|
|
585
|
-
const
|
|
693
|
+
const PO_SESSION_DELIVERY_SERVICE = "po/session-delivery";
|
|
586
694
|
/** Reads the currently active provider each time, without retaining a stale binding. */
|
|
587
|
-
function
|
|
588
|
-
return context.get(
|
|
695
|
+
function readPoSessionDelivery(context) {
|
|
696
|
+
return context.get(PO_SESSION_DELIVERY_SERVICE);
|
|
589
697
|
}
|
|
590
698
|
//#endregion
|
|
591
699
|
//#region src/services/sessionDelivery/index.ts
|
|
592
|
-
const DELIVERY_EVENT = "
|
|
593
|
-
const ACK_EVENT = "
|
|
700
|
+
const DELIVERY_EVENT = "po/session-delivery/envelope";
|
|
701
|
+
const ACK_EVENT = "po/session-delivery/ack";
|
|
594
702
|
const DEFAULT_DELIVERY = "prompt";
|
|
595
703
|
const INBOX_STATES = /* @__PURE__ */ new Set([
|
|
596
704
|
"accepted",
|
|
@@ -641,112 +749,127 @@ function matchesMetadata(entry, query) {
|
|
|
641
749
|
function sameMetadata(left, right) {
|
|
642
750
|
return matchesMetadata(left, right) && matchesMetadata(right, left);
|
|
643
751
|
}
|
|
752
|
+
const SCHEMA = `
|
|
753
|
+
CREATE TABLE IF NOT EXISTS session_delivery_outbox (
|
|
754
|
+
delivery_id TEXT PRIMARY KEY,
|
|
755
|
+
recipient_key TEXT NOT NULL,
|
|
756
|
+
kind TEXT NOT NULL,
|
|
757
|
+
prompt TEXT NOT NULL,
|
|
758
|
+
delivery_mode TEXT NOT NULL,
|
|
759
|
+
metadata_json TEXT NOT NULL,
|
|
760
|
+
state TEXT NOT NULL,
|
|
761
|
+
recipient_state TEXT,
|
|
762
|
+
created_at INTEGER NOT NULL,
|
|
763
|
+
acknowledged_at INTEGER
|
|
764
|
+
);
|
|
765
|
+
CREATE TABLE IF NOT EXISTS session_delivery_inbox (
|
|
766
|
+
delivery_id TEXT PRIMARY KEY,
|
|
767
|
+
sender_key TEXT NOT NULL,
|
|
768
|
+
recipient_key TEXT NOT NULL,
|
|
769
|
+
kind TEXT NOT NULL,
|
|
770
|
+
prompt TEXT NOT NULL,
|
|
771
|
+
delivery_mode TEXT NOT NULL,
|
|
772
|
+
metadata_json TEXT NOT NULL,
|
|
773
|
+
state TEXT NOT NULL,
|
|
774
|
+
created_at INTEGER NOT NULL,
|
|
775
|
+
updated_at INTEGER NOT NULL,
|
|
776
|
+
consumed_at INTEGER
|
|
777
|
+
);
|
|
778
|
+
`;
|
|
779
|
+
const SET_INBOX_STATE = "UPDATE session_delivery_inbox SET state = ?, updated_at = ? WHERE delivery_id = ?";
|
|
780
|
+
const UNSETTLED_OUTBOX = "(recipient_state IS NULL OR recipient_state NOT IN ('admitted', 'recovery_required'))";
|
|
644
781
|
/** Opens and starts one durable delivery endpoint. */
|
|
645
|
-
function createSessionDeliveryService(options) {
|
|
782
|
+
async function createSessionDeliveryService(options) {
|
|
646
783
|
if (!options.recipientKey.trim() || options.recipientKey !== options.recipientKey.trim()) throw new TypeError("Session delivery requires a trimmed, non-empty recipient key.");
|
|
647
|
-
const database =
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
delivery_mode TEXT NOT NULL,
|
|
656
|
-
metadata_json TEXT NOT NULL,
|
|
657
|
-
state TEXT NOT NULL,
|
|
658
|
-
recipient_state TEXT,
|
|
659
|
-
created_at INTEGER NOT NULL,
|
|
660
|
-
acknowledged_at INTEGER
|
|
661
|
-
);
|
|
662
|
-
CREATE TABLE IF NOT EXISTS session_delivery_inbox (
|
|
663
|
-
delivery_id TEXT PRIMARY KEY,
|
|
664
|
-
sender_key TEXT NOT NULL,
|
|
665
|
-
recipient_key TEXT NOT NULL,
|
|
666
|
-
kind TEXT NOT NULL,
|
|
667
|
-
prompt TEXT NOT NULL,
|
|
668
|
-
delivery_mode TEXT NOT NULL,
|
|
669
|
-
metadata_json TEXT NOT NULL,
|
|
670
|
-
state TEXT NOT NULL,
|
|
671
|
-
created_at INTEGER NOT NULL,
|
|
672
|
-
updated_at INTEGER NOT NULL,
|
|
673
|
-
consumed_at INTEGER
|
|
674
|
-
);
|
|
675
|
-
`);
|
|
676
|
-
database.prepare("UPDATE session_delivery_inbox SET state = 'recovery_required', updated_at = ? WHERE state = 'admitting'").run(Date.now());
|
|
784
|
+
const database = options.database;
|
|
785
|
+
try {
|
|
786
|
+
await database.exec(SCHEMA);
|
|
787
|
+
await database.run("UPDATE session_delivery_inbox SET state = 'accepted', updated_at = ? WHERE state = 'admitting'", Date.now());
|
|
788
|
+
} catch (error) {
|
|
789
|
+
await database.close();
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
677
792
|
let closed = false;
|
|
678
793
|
const scheduled = /* @__PURE__ */ new Set();
|
|
679
794
|
let admissionQueue = Promise.resolve();
|
|
795
|
+
const pending = /* @__PURE__ */ new Set();
|
|
796
|
+
/** Tracks fire-and-forget work so close() can wait for it before the database closes. */
|
|
797
|
+
const track = (work) => {
|
|
798
|
+
const settled = work.catch(() => void 0);
|
|
799
|
+
pending.add(settled);
|
|
800
|
+
settled.finally(() => pending.delete(settled));
|
|
801
|
+
};
|
|
802
|
+
const authorized = async (peerKey, metadata) => await options.authorizePeer(peerKey, metadata) === true;
|
|
680
803
|
const publishAck = (deliveryId, senderKey, state) => {
|
|
681
804
|
options.communication.publish(senderKey, ACK_EVENT, {
|
|
682
805
|
deliveryId,
|
|
683
806
|
state
|
|
684
807
|
});
|
|
685
808
|
};
|
|
686
|
-
const readInboxRow = (deliveryId) => database.
|
|
809
|
+
const readInboxRow = (deliveryId) => database.get("SELECT * FROM session_delivery_inbox WHERE delivery_id = ?", deliveryId);
|
|
687
810
|
const scheduleAdmission = (deliveryId) => {
|
|
688
811
|
if (closed || scheduled.has(deliveryId)) return;
|
|
689
812
|
scheduled.add(deliveryId);
|
|
690
813
|
admissionQueue = admissionQueue.then(async () => {
|
|
691
814
|
try {
|
|
692
815
|
if (closed) return;
|
|
693
|
-
const row =
|
|
694
|
-
if (!row
|
|
695
|
-
database.prepare("UPDATE session_delivery_inbox SET state = 'admitting', updated_at = ? WHERE delivery_id = ?").run(Date.now(), deliveryId);
|
|
816
|
+
const row = await database.get("UPDATE session_delivery_inbox SET state = 'admitting', updated_at = ? WHERE delivery_id = ? AND state = 'accepted' RETURNING *", Date.now(), deliveryId);
|
|
817
|
+
if (!row) return;
|
|
696
818
|
publishAck(deliveryId, row.sender_key, "admitting");
|
|
697
819
|
try {
|
|
698
|
-
await options.admitPrompt(row.prompt, row.delivery_mode);
|
|
820
|
+
await options.admitPrompt(row.prompt, row.delivery_mode, deliveryId);
|
|
699
821
|
} catch {
|
|
700
|
-
database.
|
|
822
|
+
await database.run(SET_INBOX_STATE, "recovery_required", Date.now(), deliveryId);
|
|
701
823
|
publishAck(deliveryId, row.sender_key, "recovery_required");
|
|
702
824
|
return;
|
|
703
825
|
}
|
|
704
|
-
database.
|
|
826
|
+
await database.run(SET_INBOX_STATE, "admitted", Date.now(), deliveryId);
|
|
705
827
|
publishAck(deliveryId, row.sender_key, "admitted");
|
|
706
828
|
} finally {
|
|
707
829
|
scheduled.delete(deliveryId);
|
|
708
830
|
}
|
|
709
|
-
}).catch(() => {
|
|
831
|
+
}).catch(async () => {
|
|
710
832
|
scheduled.delete(deliveryId);
|
|
711
833
|
try {
|
|
712
|
-
const row = readInboxRow(deliveryId);
|
|
834
|
+
const row = await readInboxRow(deliveryId);
|
|
713
835
|
if (row?.state !== "admitting") return;
|
|
714
|
-
database.
|
|
836
|
+
await database.run(SET_INBOX_STATE, "recovery_required", Date.now(), deliveryId);
|
|
715
837
|
try {
|
|
716
838
|
publishAck(deliveryId, row.sender_key, "recovery_required");
|
|
717
839
|
} catch {}
|
|
718
840
|
} catch {}
|
|
719
841
|
});
|
|
720
842
|
};
|
|
721
|
-
const receiveEnvelope = (senderKey, value) => {
|
|
843
|
+
const receiveEnvelope = async (senderKey, value) => {
|
|
722
844
|
if (closed) return void 0;
|
|
723
845
|
const envelope = envelopeOf(value);
|
|
724
|
-
if (!envelope || !
|
|
725
|
-
database.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const current = readInboxRow(envelope.deliveryId);
|
|
846
|
+
if (!envelope || !await authorized(senderKey, envelope.metadata) || closed) return void 0;
|
|
847
|
+
await database.run(`INSERT OR IGNORE INTO session_delivery_inbox
|
|
848
|
+
(delivery_id, sender_key, recipient_key, kind, prompt, delivery_mode, metadata_json, state, created_at, updated_at)
|
|
849
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?)`, envelope.deliveryId, senderKey, options.recipientKey, envelope.kind, envelope.prompt, envelope.delivery, JSON.stringify(envelope.metadata), Date.now(), Date.now());
|
|
850
|
+
const current = await readInboxRow(envelope.deliveryId);
|
|
729
851
|
if (!current) return void 0;
|
|
730
852
|
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;
|
|
731
853
|
publishAck(envelope.deliveryId, current.sender_key, current.state);
|
|
732
854
|
if (current.state === "accepted") scheduleAdmission(envelope.deliveryId);
|
|
733
855
|
return current.state;
|
|
734
856
|
};
|
|
735
|
-
const receiveAck = (recipientKey, value) => {
|
|
857
|
+
const receiveAck = async (recipientKey, value) => {
|
|
736
858
|
const ack = ackOf(value);
|
|
737
859
|
if (closed || !ack) return;
|
|
738
|
-
database.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
860
|
+
await database.run(`UPDATE session_delivery_outbox
|
|
861
|
+
SET state = 'acknowledged', recipient_state = ?, acknowledged_at = ?
|
|
862
|
+
WHERE delivery_id = ? AND recipient_key = ?
|
|
863
|
+
AND (recipient_state IS NULL
|
|
864
|
+
OR (recipient_state = 'accepted' AND ? != 'accepted')
|
|
865
|
+
OR (recipient_state = 'admitting' AND ? IN ('admitted', 'recovery_required')))`, ack.state, Date.now(), ack.deliveryId, recipientKey, ack.state, ack.state);
|
|
744
866
|
};
|
|
745
|
-
const publishOutbox = (deliveryId) => {
|
|
746
|
-
|
|
867
|
+
const publishOutbox = async (deliveryId) => {
|
|
868
|
+
if (closed) return;
|
|
869
|
+
const row = await database.get(`SELECT * FROM session_delivery_outbox WHERE delivery_id = ? AND ${UNSETTLED_OUTBOX}`, deliveryId);
|
|
747
870
|
if (!row) return;
|
|
748
871
|
const metadata = JSON.parse(row.metadata_json);
|
|
749
|
-
if (!
|
|
872
|
+
if (!await authorized(row.recipient_key, metadata) || closed) return;
|
|
750
873
|
options.communication.publish(row.recipient_key, DELIVERY_EVENT, {
|
|
751
874
|
deliveryId: row.delivery_id,
|
|
752
875
|
kind: row.kind,
|
|
@@ -755,20 +878,47 @@ function createSessionDeliveryService(options) {
|
|
|
755
878
|
metadata
|
|
756
879
|
});
|
|
757
880
|
};
|
|
758
|
-
const publishQueued = (recipientKey) => {
|
|
759
|
-
|
|
881
|
+
const publishQueued = async (recipientKey) => {
|
|
882
|
+
if (closed) return;
|
|
883
|
+
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);
|
|
884
|
+
};
|
|
885
|
+
let retryTimer;
|
|
886
|
+
const startRetry = () => {
|
|
887
|
+
if (closed || retryTimer) return;
|
|
888
|
+
retryTimer = setInterval(() => track(publishAllQueued()), 1e3);
|
|
889
|
+
retryTimer.unref?.();
|
|
890
|
+
};
|
|
891
|
+
const stopRetry = () => {
|
|
892
|
+
if (retryTimer) clearInterval(retryTimer);
|
|
893
|
+
retryTimer = void 0;
|
|
760
894
|
};
|
|
895
|
+
let retrying;
|
|
761
896
|
const publishAllQueued = () => {
|
|
762
|
-
|
|
897
|
+
retrying ??= (async () => {
|
|
898
|
+
try {
|
|
899
|
+
if (closed) return;
|
|
900
|
+
const rows = await database.all(`SELECT delivery_id FROM session_delivery_outbox WHERE ${UNSETTLED_OUTBOX}`);
|
|
901
|
+
if (rows.length === 0) stopRetry();
|
|
902
|
+
else startRetry();
|
|
903
|
+
for (const row of rows) await publishOutbox(row.delivery_id);
|
|
904
|
+
} finally {
|
|
905
|
+
retrying = void 0;
|
|
906
|
+
}
|
|
907
|
+
})();
|
|
908
|
+
return retrying;
|
|
763
909
|
};
|
|
764
|
-
const releaseEnvelope = options.communication.subscribe(DELIVERY_EVENT,
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
910
|
+
const releaseEnvelope = options.communication.subscribe(DELIVERY_EVENT, (senderKey, payload) => {
|
|
911
|
+
track(receiveEnvelope(senderKey, payload));
|
|
912
|
+
});
|
|
913
|
+
const releaseAck = options.communication.subscribe(ACK_EVENT, (recipientKey, payload) => {
|
|
914
|
+
track(receiveAck(recipientKey, payload));
|
|
915
|
+
});
|
|
916
|
+
const releaseReady = options.communication.onPeerReady((recipientKey) => {
|
|
917
|
+
track(publishQueued(recipientKey));
|
|
918
|
+
});
|
|
919
|
+
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");
|
|
920
|
+
for (const row of await database.all("SELECT delivery_id FROM session_delivery_inbox WHERE state = 'accepted'")) scheduleAdmission(row.delivery_id);
|
|
921
|
+
track(publishAllQueued());
|
|
772
922
|
return Object.freeze({
|
|
773
923
|
recipientKey: options.recipientKey,
|
|
774
924
|
async deliver(request) {
|
|
@@ -776,36 +926,38 @@ function createSessionDeliveryService(options) {
|
|
|
776
926
|
const deliveryId = request.deliveryId ?? crypto.randomUUID();
|
|
777
927
|
const mode = request.delivery ?? DEFAULT_DELIVERY;
|
|
778
928
|
if (!deliveryId.trim() || !request.recipientKey.trim() || !request.kind.trim() || !isMode(mode)) throw new TypeError("Delivery id, recipient key, kind, and mode must be valid.");
|
|
779
|
-
if (!
|
|
929
|
+
if (!await authorized(request.recipientKey, request.metadata)) throw new Error(`Session '${request.recipientKey}' is not an authorized communication peer.`);
|
|
930
|
+
if (closed) throw new Error("Session delivery service is closed.");
|
|
780
931
|
const metadata = normalizedMetadata(request.metadata);
|
|
781
|
-
database.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const current = database.
|
|
932
|
+
await database.run(`INSERT OR IGNORE INTO session_delivery_outbox
|
|
933
|
+
(delivery_id, recipient_key, kind, prompt, delivery_mode, metadata_json, state, created_at)
|
|
934
|
+
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?)`, deliveryId, request.recipientKey, request.kind, request.prompt, mode, JSON.stringify(metadata), Date.now());
|
|
935
|
+
const current = await database.get("SELECT * FROM session_delivery_outbox WHERE delivery_id = ?", deliveryId);
|
|
785
936
|
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.`);
|
|
786
|
-
|
|
937
|
+
startRetry();
|
|
938
|
+
await publishOutbox(deliveryId);
|
|
787
939
|
return { deliveryId };
|
|
788
940
|
},
|
|
789
941
|
async waitForAdmission(deliveryId, timeoutMs = 5e3) {
|
|
790
942
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
791
943
|
let state;
|
|
792
944
|
do {
|
|
793
|
-
state = database.
|
|
945
|
+
state = (await database.get("SELECT recipient_state FROM session_delivery_outbox WHERE delivery_id = ?", deliveryId))?.recipient_state ?? void 0;
|
|
794
946
|
if (state === "admitted" || state === "recovery_required") return state;
|
|
795
947
|
if (Date.now() >= deadline) return state;
|
|
796
948
|
await new Promise((resolve) => setTimeout(resolve, Math.min(10, deadline - Date.now())));
|
|
797
949
|
} while (!closed);
|
|
798
950
|
return state;
|
|
799
951
|
},
|
|
800
|
-
receive(senderKey, kind, payload) {
|
|
952
|
+
async receive(senderKey, kind, payload) {
|
|
801
953
|
if (kind === DELIVERY_EVENT) return receiveEnvelope(senderKey, payload);
|
|
802
954
|
if (kind === ACK_EVENT) {
|
|
803
|
-
receiveAck(senderKey, payload);
|
|
955
|
+
await receiveAck(senderKey, payload);
|
|
804
956
|
return "accepted";
|
|
805
957
|
}
|
|
806
958
|
},
|
|
807
|
-
inbox(query = {}) {
|
|
808
|
-
return database.
|
|
959
|
+
async inbox(query = {}) {
|
|
960
|
+
return (await database.all("SELECT * FROM session_delivery_inbox ORDER BY created_at, delivery_id")).map((row) => {
|
|
809
961
|
const metadata = JSON.parse(row.metadata_json);
|
|
810
962
|
return {
|
|
811
963
|
deliveryId: row.delivery_id,
|
|
@@ -820,11 +972,11 @@ function createSessionDeliveryService(options) {
|
|
|
820
972
|
};
|
|
821
973
|
}).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));
|
|
822
974
|
},
|
|
823
|
-
consume(deliveryId) {
|
|
824
|
-
return database.
|
|
975
|
+
async consume(deliveryId) {
|
|
976
|
+
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;
|
|
825
977
|
},
|
|
826
|
-
outbox(deliveryId) {
|
|
827
|
-
const row = database.
|
|
978
|
+
async outbox(deliveryId) {
|
|
979
|
+
const row = await database.get("SELECT delivery_id, recipient_key, kind, metadata_json, state, recipient_state FROM session_delivery_outbox WHERE delivery_id = ?", deliveryId);
|
|
828
980
|
if (!row) return void 0;
|
|
829
981
|
return {
|
|
830
982
|
deliveryId: row.delivery_id,
|
|
@@ -838,14 +990,15 @@ function createSessionDeliveryService(options) {
|
|
|
838
990
|
async close() {
|
|
839
991
|
if (closed) return;
|
|
840
992
|
closed = true;
|
|
841
|
-
|
|
993
|
+
stopRetry();
|
|
842
994
|
releaseEnvelope();
|
|
843
995
|
releaseAck();
|
|
844
996
|
releaseReady();
|
|
845
997
|
try {
|
|
846
998
|
await admissionQueue;
|
|
999
|
+
await Promise.all(pending);
|
|
847
1000
|
} finally {
|
|
848
|
-
database.close();
|
|
1001
|
+
await database.close();
|
|
849
1002
|
}
|
|
850
1003
|
}
|
|
851
1004
|
});
|
|
@@ -857,9 +1010,9 @@ function createSessionDeliveryService(options) {
|
|
|
857
1010
|
* `ctx.get` is strict and hides an implementation while its providing fiber is
|
|
858
1011
|
* LOADING, which is the whole of that fiber's own callback.
|
|
859
1012
|
*/
|
|
860
|
-
function provideSessionDeliveryService(context, options) {
|
|
861
|
-
const service = createSessionDeliveryService(options);
|
|
862
|
-
const unprovide = context.provide(
|
|
1013
|
+
async function provideSessionDeliveryService(context, options) {
|
|
1014
|
+
const service = await createSessionDeliveryService(options);
|
|
1015
|
+
const unprovide = context.provide(PO_SESSION_DELIVERY_SERVICE, service);
|
|
863
1016
|
context.effect(() => () => service.close(), "session delivery database");
|
|
864
1017
|
return {
|
|
865
1018
|
service,
|
|
@@ -870,6 +1023,6 @@ function provideSessionDeliveryService(context, options) {
|
|
|
870
1023
|
};
|
|
871
1024
|
}
|
|
872
1025
|
//#endregion
|
|
873
|
-
export {
|
|
1026
|
+
export { readSessionPeerConfig as C, provideBackgroundWorkService as D, createBackgroundWorkService as E, createPeerReplayGuard as S, sessionPeerConfigPath as T, parseSessionPeerEnvelope as _, createSessionGroupDeliveryAuthorizer as a, readSessionPeerConfigAsync as b, readSessionIntercom as c, SessionGroupRevisionError as d, createSessionDirectory as f, parseRemoteSessionReference as g, ensurePrivateDirectory as h, readPoSessionDelivery as i, requireSessionIntercom as l, createPeerReplayGuardAsync as m, provideSessionDeliveryService as n, createSessionIntercom as o, createPeerCommunication as p, PO_SESSION_DELIVERY_SERVICE as r, PO_SESSION_INTERCOM_SERVICE as s, createSessionDeliveryService as t, createSessionGroupStore as u, peerRequestHeaders as v, PEER_REPLAY_DATABASE_FILE as w, verifyPeerRequest as x, peerSessionReference as y };
|
|
874
1027
|
|
|
875
|
-
//# sourceMappingURL=sessionDelivery-
|
|
1028
|
+
//# sourceMappingURL=sessionDelivery-4svjzWUW.mjs.map
|