@agimon-ai/doompi-session 0.0.1-alpha.1

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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +23 -0
  4. package/dist/api-contracts.cjs +7 -0
  5. package/dist/api-contracts.d.cts +2 -0
  6. package/dist/api-contracts.d.mts +2 -0
  7. package/dist/api-contracts.mjs +2 -0
  8. package/dist/extensions/pi.cjs +20 -0
  9. package/dist/extensions/pi.cjs.map +1 -0
  10. package/dist/extensions/pi.d.cts +5 -0
  11. package/dist/extensions/pi.d.cts.map +1 -0
  12. package/dist/extensions/pi.d.mts +5 -0
  13. package/dist/extensions/pi.d.mts.map +1 -0
  14. package/dist/extensions/pi.mjs +15 -0
  15. package/dist/extensions/pi.mjs.map +1 -0
  16. package/dist/extensions/server.cjs +37 -0
  17. package/dist/extensions/server.cjs.map +1 -0
  18. package/dist/extensions/server.d.cts +5 -0
  19. package/dist/extensions/server.d.cts.map +1 -0
  20. package/dist/extensions/server.d.mts +5 -0
  21. package/dist/extensions/server.d.mts.map +1 -0
  22. package/dist/extensions/server.mjs +32 -0
  23. package/dist/extensions/server.mjs.map +1 -0
  24. package/dist/index.cjs +32 -0
  25. package/dist/index.d.cts +11 -0
  26. package/dist/index.d.mts +11 -0
  27. package/dist/index.mjs +10 -0
  28. package/dist/src/extensions/(backend)/api/session-peer/route.server.cjs +7 -0
  29. package/dist/src/extensions/(backend)/api/session-peer/route.server.cjs.map +1 -0
  30. package/dist/src/extensions/(backend)/api/session-peer/route.server.mjs +8 -0
  31. package/dist/src/extensions/(backend)/api/session-peer/route.server.mjs.map +1 -0
  32. package/dist/src/extensions/workspaces/sessions/(backend)/root.cli.cjs +10 -0
  33. package/dist/src/extensions/workspaces/sessions/(backend)/root.cli.cjs.map +1 -0
  34. package/dist/src/extensions/workspaces/sessions/(backend)/root.cli.mjs +11 -0
  35. package/dist/src/extensions/workspaces/sessions/(backend)/root.cli.mjs.map +1 -0
  36. package/dist/src/extensions/workspaces/sessions/(backend)/root.server.cjs +101 -0
  37. package/dist/src/extensions/workspaces/sessions/(backend)/root.server.cjs.map +1 -0
  38. package/dist/src/extensions/workspaces/sessions/(backend)/root.server.mjs +99 -0
  39. package/dist/src/extensions/workspaces/sessions/(backend)/root.server.mjs.map +1 -0
  40. package/dist/src/schemas/apiContracts.cjs +33 -0
  41. package/dist/src/schemas/apiContracts.cjs.map +1 -0
  42. package/dist/src/schemas/apiContracts.d.cts +52 -0
  43. package/dist/src/schemas/apiContracts.d.cts.map +1 -0
  44. package/dist/src/schemas/apiContracts.d.mts +52 -0
  45. package/dist/src/schemas/apiContracts.d.mts.map +1 -0
  46. package/dist/src/schemas/apiContracts.mjs +32 -0
  47. package/dist/src/schemas/apiContracts.mjs.map +1 -0
  48. package/dist/src/services/backgroundWorkService/index.cjs +79 -0
  49. package/dist/src/services/backgroundWorkService/index.cjs.map +1 -0
  50. package/dist/src/services/backgroundWorkService/index.d.cts +9 -0
  51. package/dist/src/services/backgroundWorkService/index.d.cts.map +1 -0
  52. package/dist/src/services/backgroundWorkService/index.d.mts +9 -0
  53. package/dist/src/services/backgroundWorkService/index.d.mts.map +1 -0
  54. package/dist/src/services/backgroundWorkService/index.mjs +78 -0
  55. package/dist/src/services/backgroundWorkService/index.mjs.map +1 -0
  56. package/dist/src/services/peerInbox/index.cjs +18 -0
  57. package/dist/src/services/peerInbox/index.cjs.map +1 -0
  58. package/dist/src/services/peerInbox/index.mjs +17 -0
  59. package/dist/src/services/peerInbox/index.mjs.map +1 -0
  60. package/dist/src/services/peerInboxApi/index.cjs +48 -0
  61. package/dist/src/services/peerInboxApi/index.cjs.map +1 -0
  62. package/dist/src/services/peerInboxApi/index.mjs +48 -0
  63. package/dist/src/services/peerInboxApi/index.mjs.map +1 -0
  64. package/dist/src/services/peerTransport/index.cjs +206 -0
  65. package/dist/src/services/peerTransport/index.cjs.map +1 -0
  66. package/dist/src/services/peerTransport/index.d.cts +49 -0
  67. package/dist/src/services/peerTransport/index.d.cts.map +1 -0
  68. package/dist/src/services/peerTransport/index.d.mts +49 -0
  69. package/dist/src/services/peerTransport/index.d.mts.map +1 -0
  70. package/dist/src/services/peerTransport/index.mjs +195 -0
  71. package/dist/src/services/peerTransport/index.mjs.map +1 -0
  72. package/dist/src/services/sessionDelivery/index.cjs +277 -0
  73. package/dist/src/services/sessionDelivery/index.cjs.map +1 -0
  74. package/dist/src/services/sessionDelivery/index.d.cts +10 -0
  75. package/dist/src/services/sessionDelivery/index.d.cts.map +1 -0
  76. package/dist/src/services/sessionDelivery/index.d.mts +10 -0
  77. package/dist/src/services/sessionDelivery/index.d.mts.map +1 -0
  78. package/dist/src/services/sessionDelivery/index.mjs +274 -0
  79. package/dist/src/services/sessionDelivery/index.mjs.map +1 -0
  80. package/dist/src/services/sessionDelivery/type.cjs +11 -0
  81. package/dist/src/services/sessionDelivery/type.cjs.map +1 -0
  82. package/dist/src/services/sessionDelivery/type.d.cts +73 -0
  83. package/dist/src/services/sessionDelivery/type.d.cts.map +1 -0
  84. package/dist/src/services/sessionDelivery/type.d.mts +73 -0
  85. package/dist/src/services/sessionDelivery/type.d.mts.map +1 -0
  86. package/dist/src/services/sessionDelivery/type.mjs +10 -0
  87. package/dist/src/services/sessionDelivery/type.mjs.map +1 -0
  88. package/dist/src/services/sessionDirectory/index.cjs +120 -0
  89. package/dist/src/services/sessionDirectory/index.cjs.map +1 -0
  90. package/dist/src/services/sessionDirectory/index.d.cts +7 -0
  91. package/dist/src/services/sessionDirectory/index.d.cts.map +1 -0
  92. package/dist/src/services/sessionDirectory/index.d.mts +7 -0
  93. package/dist/src/services/sessionDirectory/index.d.mts.map +1 -0
  94. package/dist/src/services/sessionDirectory/index.mjs +120 -0
  95. package/dist/src/services/sessionDirectory/index.mjs.map +1 -0
  96. package/dist/src/services/sessionDirectory/type.d.cts +54 -0
  97. package/dist/src/services/sessionDirectory/type.d.cts.map +1 -0
  98. package/dist/src/services/sessionDirectory/type.d.mts +54 -0
  99. package/dist/src/services/sessionDirectory/type.d.mts.map +1 -0
  100. package/dist/src/services/sessionGroups/index.cjs +105 -0
  101. package/dist/src/services/sessionGroups/index.cjs.map +1 -0
  102. package/dist/src/services/sessionGroups/index.d.cts +7 -0
  103. package/dist/src/services/sessionGroups/index.d.cts.map +1 -0
  104. package/dist/src/services/sessionGroups/index.d.mts +7 -0
  105. package/dist/src/services/sessionGroups/index.d.mts.map +1 -0
  106. package/dist/src/services/sessionGroups/index.mjs +104 -0
  107. package/dist/src/services/sessionGroups/index.mjs.map +1 -0
  108. package/dist/src/services/sessionGroups/type.cjs +11 -0
  109. package/dist/src/services/sessionGroups/type.cjs.map +1 -0
  110. package/dist/src/services/sessionGroups/type.d.cts +32 -0
  111. package/dist/src/services/sessionGroups/type.d.cts.map +1 -0
  112. package/dist/src/services/sessionGroups/type.d.mts +32 -0
  113. package/dist/src/services/sessionGroups/type.d.mts.map +1 -0
  114. package/dist/src/services/sessionGroups/type.mjs +11 -0
  115. package/dist/src/services/sessionGroups/type.mjs.map +1 -0
  116. package/dist/src/services/sessionIntercom/index.cjs +89 -0
  117. package/dist/src/services/sessionIntercom/index.cjs.map +1 -0
  118. package/dist/src/services/sessionIntercom/index.d.cts +11 -0
  119. package/dist/src/services/sessionIntercom/index.d.cts.map +1 -0
  120. package/dist/src/services/sessionIntercom/index.d.mts +11 -0
  121. package/dist/src/services/sessionIntercom/index.d.mts.map +1 -0
  122. package/dist/src/services/sessionIntercom/index.mjs +85 -0
  123. package/dist/src/services/sessionIntercom/index.mjs.map +1 -0
  124. package/dist/src/services/sessionIntercom/type.cjs +16 -0
  125. package/dist/src/services/sessionIntercom/type.cjs.map +1 -0
  126. package/dist/src/services/sessionIntercom/type.d.cts +60 -0
  127. package/dist/src/services/sessionIntercom/type.d.cts.map +1 -0
  128. package/dist/src/services/sessionIntercom/type.d.mts +60 -0
  129. package/dist/src/services/sessionIntercom/type.d.mts.map +1 -0
  130. package/dist/src/services/sessionIntercom/type.mjs +14 -0
  131. package/dist/src/services/sessionIntercom/type.mjs.map +1 -0
  132. package/llms.txt +3 -0
  133. package/package.json +115 -0
@@ -0,0 +1,48 @@
1
+ import { createPeerReplayGuard, parseSessionPeerEnvelope, peerSessionReference, readSessionPeerConfig, verifyPeerRequest } from "../peerTransport/index.mjs";
2
+ import { deliverSessionPeerEnvelope } from "../peerInbox/index.mjs";
3
+ //#region src/services/peerInboxApi/index.ts
4
+ const MAX_BODY_BYTES = 65536;
5
+ const DELIVERY_TYPES = /* @__PURE__ */ new Set(["doom/session-delivery/envelope", "doom/session-delivery/ack"]);
6
+ function response(status, error) {
7
+ return Response.json({ error }, { status });
8
+ }
9
+ /** Narrow HMAC-authenticated endpoint that a trusted tunnel may forward without device authentication. */
10
+ const peerInboxApi = {
11
+ basePath: "session-peer",
12
+ start(context) {
13
+ const config = context.homeDirectory === void 0 ? void 0 : readSessionPeerConfig(context.homeDirectory);
14
+ const replay = context.homeDirectory === void 0 ? void 0 : createPeerReplayGuard(context.homeDirectory);
15
+ return {
16
+ async fetch(request) {
17
+ if (request.method !== "POST" || new URL(request.url).pathname !== "/inbox") return response(404, "Not found.");
18
+ if (!config) return response(503, "Paired Session delivery is not configured.");
19
+ const length = request.headers.get("content-length");
20
+ if (length !== null && (!/^\d+$/u.test(length) || Number(length) > MAX_BODY_BYTES)) return response(413, "Request body is too large.");
21
+ const body = await request.text();
22
+ if (Buffer.byteLength(body) > MAX_BODY_BYTES) return response(413, "Request body is too large.");
23
+ const peer = verifyPeerRequest(config, request, body);
24
+ if (!peer) return response(401, "Paired Session request is unauthorized.");
25
+ if (!replay?.admit(request)) return response(409, "Paired Session request was already received.");
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(body);
29
+ } catch {
30
+ return response(400, "Paired Session request is malformed.");
31
+ }
32
+ const envelope = parseSessionPeerEnvelope(parsed);
33
+ if (!envelope || !DELIVERY_TYPES.has(envelope.type)) return response(400, "Paired Session envelope is invalid.");
34
+ if (!peer.allowedSessionIds.includes(envelope.targetSessionId)) return response(403, "Peer is not granted this session.");
35
+ const state = deliverSessionPeerEnvelope(envelope.targetSessionId, peerSessionReference(peer.hostId, envelope.sourceSessionId), envelope.type, envelope.payload);
36
+ return state === void 0 ? response(404, "Target Session is unavailable.") : Response.json({
37
+ accepted: true,
38
+ state
39
+ }, { status: 202 });
40
+ },
41
+ close: () => replay?.close()
42
+ };
43
+ }
44
+ };
45
+ //#endregion
46
+ export { peerInboxApi };
47
+
48
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../../../src/services/peerInboxApi/index.ts"],"sourcesContent":["import type { DoomApi, DoomApiHandler } from '@agimon-ai/doompi-core/package-api';\n\nimport { deliverSessionPeerEnvelope } from '../peerInbox';\nimport {\n createPeerReplayGuard,\n parseSessionPeerEnvelope,\n peerSessionReference,\n readSessionPeerConfig,\n verifyPeerRequest,\n} from '../peerTransport';\n\nconst MAX_BODY_BYTES = 64 * 1024;\nconst DELIVERY_TYPES = new Set(['doom/session-delivery/envelope', 'doom/session-delivery/ack']);\n\nfunction response(status: number, error: string): Response {\n return Response.json({ error }, { status });\n}\n\n/** Narrow HMAC-authenticated endpoint that a trusted tunnel may forward without device authentication. */\nexport const peerInboxApi: DoomApi = {\n basePath: 'session-peer',\n start(context): DoomApiHandler {\n const config = context.homeDirectory === undefined ? undefined : readSessionPeerConfig(context.homeDirectory);\n const replay = context.homeDirectory === undefined ? undefined : createPeerReplayGuard(context.homeDirectory);\n return {\n async fetch(request) {\n if (request.method !== 'POST' || new URL(request.url).pathname !== '/inbox') return response(404, 'Not found.');\n if (!config) return response(503, 'Paired Session delivery is not configured.');\n const length = request.headers.get('content-length');\n if (length !== null && (!/^\\d+$/u.test(length) || Number(length) > MAX_BODY_BYTES))\n return response(413, 'Request body is too large.');\n const body = await request.text();\n if (Buffer.byteLength(body) > MAX_BODY_BYTES) return response(413, 'Request body is too large.');\n const peer = verifyPeerRequest(config, request, body);\n if (!peer) return response(401, 'Paired Session request is unauthorized.');\n if (!replay?.admit(request)) return response(409, 'Paired Session request was already received.');\n let parsed: unknown;\n try {\n parsed = JSON.parse(body) as unknown;\n } catch {\n return response(400, 'Paired Session request is malformed.');\n }\n const envelope = parseSessionPeerEnvelope(parsed);\n if (!envelope || !DELIVERY_TYPES.has(envelope.type))\n return response(400, 'Paired Session envelope is invalid.');\n if (!peer.allowedSessionIds.includes(envelope.targetSessionId))\n return response(403, 'Peer is not granted this session.');\n const state = deliverSessionPeerEnvelope(\n envelope.targetSessionId,\n peerSessionReference(peer.hostId, envelope.sourceSessionId),\n envelope.type,\n envelope.payload,\n );\n return state === undefined\n ? response(404, 'Target Session is unavailable.')\n : Response.json({ accepted: true, state }, { status: 202 });\n },\n close: () => replay?.close(),\n };\n },\n};\n"],"mappings":";;;AAWA,MAAM,iBAAiB;AACvB,MAAM,iCAAiB,IAAI,IAAI,CAAC,kCAAkC,2BAA2B,CAAC;AAE9F,SAAS,SAAS,QAAgB,OAAyB;CACzD,OAAO,SAAS,KAAK,EAAE,MAAM,GAAG,EAAE,OAAO,CAAC;AAC5C;;AAGA,MAAa,eAAwB;CACnC,UAAU;CACV,MAAM,SAAyB;EAC7B,MAAM,SAAS,QAAQ,kBAAkB,KAAA,IAAY,KAAA,IAAY,sBAAsB,QAAQ,aAAa;EAC5G,MAAM,SAAS,QAAQ,kBAAkB,KAAA,IAAY,KAAA,IAAY,sBAAsB,QAAQ,aAAa;EAC5G,OAAO;GACL,MAAM,MAAM,SAAS;IACnB,IAAI,QAAQ,WAAW,UAAU,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,aAAa,UAAU,OAAO,SAAS,KAAK,YAAY;IAC9G,IAAI,CAAC,QAAQ,OAAO,SAAS,KAAK,4CAA4C;IAC9E,MAAM,SAAS,QAAQ,QAAQ,IAAI,gBAAgB;IACnD,IAAI,WAAW,SAAS,CAAC,SAAS,KAAK,MAAM,KAAK,OAAO,MAAM,IAAI,iBACjE,OAAO,SAAS,KAAK,4BAA4B;IACnD,MAAM,OAAO,MAAM,QAAQ,KAAK;IAChC,IAAI,OAAO,WAAW,IAAI,IAAI,gBAAgB,OAAO,SAAS,KAAK,4BAA4B;IAC/F,MAAM,OAAO,kBAAkB,QAAQ,SAAS,IAAI;IACpD,IAAI,CAAC,MAAM,OAAO,SAAS,KAAK,yCAAyC;IACzE,IAAI,CAAC,QAAQ,MAAM,OAAO,GAAG,OAAO,SAAS,KAAK,8CAA8C;IAChG,IAAI;IACJ,IAAI;KACF,SAAS,KAAK,MAAM,IAAI;IAC1B,QAAQ;KACN,OAAO,SAAS,KAAK,sCAAsC;IAC7D;IACA,MAAM,WAAW,yBAAyB,MAAM;IAChD,IAAI,CAAC,YAAY,CAAC,eAAe,IAAI,SAAS,IAAI,GAChD,OAAO,SAAS,KAAK,qCAAqC;IAC5D,IAAI,CAAC,KAAK,kBAAkB,SAAS,SAAS,eAAe,GAC3D,OAAO,SAAS,KAAK,mCAAmC;IAC1D,MAAM,QAAQ,2BACZ,SAAS,iBACT,qBAAqB,KAAK,QAAQ,SAAS,eAAe,GAC1D,SAAS,MACT,SAAS,OACX;IACA,OAAO,UAAU,KAAA,IACb,SAAS,KAAK,gCAAgC,IAC9C,SAAS,KAAK;KAAE,UAAU;KAAM;IAAM,GAAG,EAAE,QAAQ,IAAI,CAAC;GAC9D;GACA,aAAa,QAAQ,MAAM;EAC7B;CACF;AACF"}
@@ -0,0 +1,206 @@
1
+ const require_runtime = require("../../../_virtual/_rolldown/runtime.cjs");
2
+ let node_crypto = require("node:crypto");
3
+ let node_fs = require("node:fs");
4
+ node_fs = require_runtime.__toESM(node_fs, 1);
5
+ let node_path = require("node:path");
6
+ node_path = require_runtime.__toESM(node_path, 1);
7
+ let node_sqlite = require("node:sqlite");
8
+ //#region src/services/peerTransport/index.ts
9
+ const PEER_CONFIG_VERSION = 1;
10
+ const PEER_CONFIG_FILE = "peers.json";
11
+ const PEER_ROUTE = "/api/plugins/session-peer/inbox";
12
+ const PEER_HEADER = "x-doompi-session-peer";
13
+ const TIMESTAMP_HEADER = "x-doompi-session-timestamp";
14
+ const SIGNATURE_HEADER = "x-doompi-session-signature";
15
+ const MAX_CLOCK_SKEW_MS = 3e5;
16
+ const MAX_REPLAY_ENTRIES = 1e4;
17
+ const REPLAY_DATABASE_FILE = "peer-replay.sqlite";
18
+ function validId(value) {
19
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
20
+ }
21
+ function peerOf(value) {
22
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
23
+ const record = value;
24
+ if (!validId(record.hostId) || typeof record.url !== "string" || typeof record.secret !== "string") return void 0;
25
+ let url;
26
+ try {
27
+ url = new URL(record.url);
28
+ } catch {
29
+ return;
30
+ }
31
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
32
+ const allowedSessionIds = Array.isArray(record.allowedSessionIds) && record.allowedSessionIds.every(validId) ? record.allowedSessionIds : void 0;
33
+ const allowedVoiceSessionIds = Array.isArray(record.allowedVoiceSessionIds) && record.allowedVoiceSessionIds.every(validId) ? record.allowedVoiceSessionIds : [];
34
+ if (allowedSessionIds === void 0 || record.secret.length < 32) return void 0;
35
+ return {
36
+ hostId: record.hostId,
37
+ url: url.toString(),
38
+ secret: record.secret,
39
+ allowedSessionIds,
40
+ allowedVoiceSessionIds
41
+ };
42
+ }
43
+ function sessionPeerConfigPath(homeDirectory) {
44
+ return node_path.default.join(homeDirectory, ".pi", ".doom", "session", PEER_CONFIG_FILE);
45
+ }
46
+ /** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */
47
+ function readSessionPeerConfig(homeDirectory) {
48
+ let parsed;
49
+ try {
50
+ const file = sessionPeerConfigPath(homeDirectory);
51
+ if ((node_fs.default.statSync(file).mode & 63) !== 0) return void 0;
52
+ parsed = JSON.parse(node_fs.default.readFileSync(file, "utf8"));
53
+ } catch {
54
+ return;
55
+ }
56
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
57
+ const record = parsed;
58
+ if (record.version !== PEER_CONFIG_VERSION || !validId(record.hostId) || !Array.isArray(record.peers)) return void 0;
59
+ const peers = record.peers.map(peerOf);
60
+ if (peers.some((peer) => peer === void 0)) return void 0;
61
+ const resolved = peers;
62
+ if (new Set(resolved.map((peer) => peer.hostId)).size !== resolved.length || resolved.some((peer) => peer.hostId === record.hostId)) return void 0;
63
+ return {
64
+ version: PEER_CONFIG_VERSION,
65
+ hostId: record.hostId,
66
+ peers: resolved
67
+ };
68
+ }
69
+ /** `peer/<host-id>/<session-id>` is the only remote target spelling. */
70
+ function parseRemoteSessionReference(value) {
71
+ const match = /^peer\/([^/]+)\/([^/]+)$/u.exec(value);
72
+ return match && validId(match[1]) && validId(match[2]) ? {
73
+ hostId: match[1],
74
+ sessionId: match[2]
75
+ } : void 0;
76
+ }
77
+ function peerSessionReference(hostId, sessionId) {
78
+ if (!validId(hostId) || !validId(sessionId)) throw new TypeError("Remote host and session identifiers must be safe opaque IDs.");
79
+ return `peer/${hostId}/${sessionId}`;
80
+ }
81
+ function signature(secret, method, pathname, timestamp, body) {
82
+ const digest = (0, node_crypto.createHash)("sha256").update(body).digest("base64url");
83
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${method}\n${pathname}\n${timestamp}\n${digest}`).digest("base64url");
84
+ }
85
+ function peerRequestHeaders(sourceHostId, peer, method, pathname, body, now = Date.now()) {
86
+ if (!validId(sourceHostId)) throw new TypeError("Source host identity must be a safe opaque ID.");
87
+ const timestamp = String(now);
88
+ return new Headers({
89
+ "content-type": "application/json",
90
+ [PEER_HEADER]: sourceHostId,
91
+ [TIMESTAMP_HEADER]: timestamp,
92
+ [SIGNATURE_HEADER]: signature(peer.secret, method, pathname, timestamp, body)
93
+ });
94
+ }
95
+ /** Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window. */
96
+ function createPeerReplayGuard(homeDirectory) {
97
+ const directory = node_path.default.dirname(sessionPeerConfigPath(homeDirectory));
98
+ node_fs.default.mkdirSync(directory, {
99
+ recursive: true,
100
+ mode: 448
101
+ });
102
+ const database = new node_sqlite.DatabaseSync(node_path.default.join(directory, REPLAY_DATABASE_FILE));
103
+ database.exec(`
104
+ PRAGMA journal_mode = WAL;
105
+ CREATE TABLE IF NOT EXISTS peer_request_replays (
106
+ signature TEXT PRIMARY KEY,
107
+ expires_at INTEGER NOT NULL
108
+ );
109
+ `);
110
+ const removeExpired = database.prepare("DELETE FROM peer_request_replays WHERE expires_at < ?");
111
+ const insert = database.prepare("INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)");
112
+ const count = database.prepare("SELECT COUNT(*) AS count FROM peer_request_replays");
113
+ const trim = database.prepare(`
114
+ DELETE FROM peer_request_replays
115
+ WHERE signature IN (
116
+ SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?
117
+ )
118
+ `);
119
+ return {
120
+ admit(request, now = Date.now()) {
121
+ const supplied = request.headers.get(SIGNATURE_HEADER);
122
+ if (!supplied) return false;
123
+ database.exec("BEGIN IMMEDIATE");
124
+ try {
125
+ removeExpired.run(now);
126
+ const accepted = insert.run(supplied, now + MAX_CLOCK_SKEW_MS).changes === 1;
127
+ const total = Number(count.get().count);
128
+ if (total > MAX_REPLAY_ENTRIES) trim.run(total - MAX_REPLAY_ENTRIES);
129
+ database.exec("COMMIT");
130
+ return accepted;
131
+ } catch (cause) {
132
+ database.exec("ROLLBACK");
133
+ throw cause;
134
+ }
135
+ },
136
+ close: () => database.close()
137
+ };
138
+ }
139
+ function verifyPeerRequest(config, request, body, now = Date.now()) {
140
+ const hostId = request.headers.get(PEER_HEADER);
141
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
142
+ const supplied = request.headers.get(SIGNATURE_HEADER);
143
+ if (!hostId || !timestamp || !supplied || !/^\d{1,16}$/u.test(timestamp)) return void 0;
144
+ const sentAt = Number(timestamp);
145
+ if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > MAX_CLOCK_SKEW_MS) return void 0;
146
+ const peer = config.peers.find((candidate) => candidate.hostId === hostId);
147
+ if (!peer) return void 0;
148
+ const expected = Buffer.from(signature(peer.secret, request.method, new URL(request.url).pathname, timestamp, body));
149
+ const actual = Buffer.from(supplied);
150
+ return actual.byteLength === expected.byteLength && (0, node_crypto.timingSafeEqual)(actual, expected) ? peer : void 0;
151
+ }
152
+ function envelopeOf(value) {
153
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
154
+ const record = value;
155
+ return validId(record.sourceSessionId) && validId(record.targetSessionId) && typeof record.type === "string" && record.type !== "" ? {
156
+ sourceSessionId: record.sourceSessionId,
157
+ targetSessionId: record.targetSessionId,
158
+ type: record.type,
159
+ payload: record.payload
160
+ } : void 0;
161
+ }
162
+ function parseSessionPeerEnvelope(value) {
163
+ return envelopeOf(value);
164
+ }
165
+ /** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */
166
+ function createPeerCommunication(options) {
167
+ const config = readSessionPeerConfig(options.homeDirectory);
168
+ const send = options.fetch ?? fetch;
169
+ return {
170
+ sessionId: options.local.sessionId,
171
+ publish(target, type, payload) {
172
+ const remote = parseRemoteSessionReference(target);
173
+ if (!remote) return options.local.publish(target, type, payload);
174
+ const peer = config?.peers.find((candidate) => candidate.hostId === remote.hostId);
175
+ if (!config || !peer || !peer.allowedSessionIds.includes(remote.sessionId)) return false;
176
+ const body = JSON.stringify({
177
+ sourceSessionId: options.local.sessionId,
178
+ targetSessionId: remote.sessionId,
179
+ type,
180
+ payload
181
+ });
182
+ const url = new URL(PEER_ROUTE, peer.url);
183
+ send(url, {
184
+ method: "POST",
185
+ headers: peerRequestHeaders(config.hostId, peer, "POST", "/inbox", body),
186
+ body
187
+ }).catch(() => void 0);
188
+ return true;
189
+ },
190
+ subscribe: (type, listener) => options.local.subscribe(type, listener),
191
+ onPeerReady: (listener) => options.local.onPeerReady(listener),
192
+ close: () => options.local.close()
193
+ };
194
+ }
195
+ //#endregion
196
+ exports.createPeerCommunication = createPeerCommunication;
197
+ exports.createPeerReplayGuard = createPeerReplayGuard;
198
+ exports.parseRemoteSessionReference = parseRemoteSessionReference;
199
+ exports.parseSessionPeerEnvelope = parseSessionPeerEnvelope;
200
+ exports.peerRequestHeaders = peerRequestHeaders;
201
+ exports.peerSessionReference = peerSessionReference;
202
+ exports.readSessionPeerConfig = readSessionPeerConfig;
203
+ exports.sessionPeerConfigPath = sessionPeerConfigPath;
204
+ exports.verifyPeerRequest = verifyPeerRequest;
205
+
206
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["path","fs","createHash","createHmac","DatabaseSync","timingSafeEqual"],"sources":["../../../../src/services/peerTransport/index.ts"],"sourcesContent":["import { createHash, createHmac, timingSafeEqual } from 'node:crypto';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\n\nimport type { DoomSessionCommunicationEndpoint } from '@agimon-ai/doompi-core/hub-channel';\n\nconst PEER_CONFIG_VERSION = 1;\nconst PEER_CONFIG_FILE = 'peers.json';\nconst PEER_ROUTE = '/api/plugins/session-peer/inbox';\nconst PEER_HEADER = 'x-doompi-session-peer';\nconst TIMESTAMP_HEADER = 'x-doompi-session-timestamp';\nconst SIGNATURE_HEADER = 'x-doompi-session-signature';\nconst MAX_CLOCK_SKEW_MS = 5 * 60_000;\nconst MAX_REPLAY_ENTRIES = 10_000;\nconst REPLAY_DATABASE_FILE = 'peer-replay.sqlite';\n\nexport interface SessionPeer {\n readonly hostId: string;\n readonly url: string;\n readonly secret: string;\n /** Explicit remote durable-message targets. Empty means no delivery grant. */\n readonly allowedSessionIds: readonly string[];\n /** Explicit remote Voice media targets. Empty means no media grant. */\n readonly allowedVoiceSessionIds: readonly string[];\n}\n\nexport interface SessionPeerConfig {\n readonly version: typeof PEER_CONFIG_VERSION;\n readonly hostId: string;\n readonly peers: readonly SessionPeer[];\n}\n\nexport interface RemoteSessionReference {\n readonly hostId: string;\n readonly sessionId: string;\n}\n\nexport interface SessionPeerEnvelope {\n readonly sourceSessionId: string;\n readonly targetSessionId: string;\n readonly type: string;\n readonly payload: unknown;\n}\n\nexport interface SessionPeerReplayGuard {\n admit(request: Request, now?: number): boolean;\n close(): void;\n}\n\nfunction validId(value: unknown): value is string {\n return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);\n}\n\nfunction peerOf(value: unknown): SessionPeer | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (!validId(record.hostId) || typeof record.url !== 'string' || typeof record.secret !== 'string') return undefined;\n let url: URL;\n try {\n url = new URL(record.url);\n } catch {\n return undefined;\n }\n if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '')\n return undefined;\n const allowedSessionIds =\n Array.isArray(record.allowedSessionIds) && record.allowedSessionIds.every(validId)\n ? record.allowedSessionIds\n : undefined;\n const allowedVoiceSessionIds =\n Array.isArray(record.allowedVoiceSessionIds) && record.allowedVoiceSessionIds.every(validId)\n ? record.allowedVoiceSessionIds\n : [];\n if (allowedSessionIds === undefined || record.secret.length < 32) return undefined;\n return {\n hostId: record.hostId,\n url: url.toString(),\n secret: record.secret,\n allowedSessionIds,\n allowedVoiceSessionIds,\n };\n}\n\nexport function sessionPeerConfigPath(homeDirectory: string): string {\n return path.join(homeDirectory, '.pi', '.doom', 'session', PEER_CONFIG_FILE);\n}\n\n/** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */\nexport function readSessionPeerConfig(homeDirectory: string): SessionPeerConfig | undefined {\n let parsed: unknown;\n try {\n const file = sessionPeerConfigPath(homeDirectory);\n if ((fs.statSync(file).mode & 0o077) !== 0) return undefined;\n parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown;\n } catch {\n return undefined;\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;\n const record = parsed as Record<string, unknown>;\n if (record.version !== PEER_CONFIG_VERSION || !validId(record.hostId) || !Array.isArray(record.peers))\n return undefined;\n const peers = record.peers.map(peerOf);\n if (peers.some((peer) => peer === undefined)) return undefined;\n const resolved = peers as SessionPeer[];\n if (\n new Set(resolved.map((peer) => peer.hostId)).size !== resolved.length ||\n resolved.some((peer) => peer.hostId === record.hostId)\n )\n return undefined;\n return { version: PEER_CONFIG_VERSION, hostId: record.hostId, peers: resolved };\n}\n\n/** `peer/<host-id>/<session-id>` is the only remote target spelling. */\nexport function parseRemoteSessionReference(value: string): RemoteSessionReference | undefined {\n const match = /^peer\\/([^/]+)\\/([^/]+)$/u.exec(value);\n return match && validId(match[1]) && validId(match[2]) ? { hostId: match[1], sessionId: match[2] } : undefined;\n}\n\nexport function peerSessionReference(hostId: string, sessionId: string): string {\n if (!validId(hostId) || !validId(sessionId))\n throw new TypeError('Remote host and session identifiers must be safe opaque IDs.');\n return `peer/${hostId}/${sessionId}`;\n}\n\nfunction signature(secret: string, method: string, pathname: string, timestamp: string, body: string): string {\n const digest = createHash('sha256').update(body).digest('base64url');\n return createHmac('sha256', secret).update(`${method}\\n${pathname}\\n${timestamp}\\n${digest}`).digest('base64url');\n}\n\nexport function peerRequestHeaders(\n sourceHostId: string,\n peer: SessionPeer,\n method: string,\n pathname: string,\n body: string,\n now = Date.now(),\n): Headers {\n if (!validId(sourceHostId)) throw new TypeError('Source host identity must be a safe opaque ID.');\n const timestamp = String(now);\n return new Headers({\n 'content-type': 'application/json',\n [PEER_HEADER]: sourceHostId,\n [TIMESTAMP_HEADER]: timestamp,\n [SIGNATURE_HEADER]: signature(peer.secret, method, pathname, timestamp, body),\n });\n}\n\n/** Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window. */\nexport function createPeerReplayGuard(homeDirectory: string): SessionPeerReplayGuard {\n const directory = path.dirname(sessionPeerConfigPath(homeDirectory));\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n const database = new DatabaseSync(path.join(directory, REPLAY_DATABASE_FILE));\n database.exec(`\n PRAGMA journal_mode = WAL;\n CREATE TABLE IF NOT EXISTS peer_request_replays (\n signature TEXT PRIMARY KEY,\n expires_at INTEGER NOT NULL\n );\n `);\n const removeExpired = database.prepare('DELETE FROM peer_request_replays WHERE expires_at < ?');\n const insert = database.prepare('INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)');\n const count = database.prepare('SELECT COUNT(*) AS count FROM peer_request_replays');\n const trim = database.prepare(`\n DELETE FROM peer_request_replays\n WHERE signature IN (\n SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?\n )\n `);\n return {\n admit(request, now = Date.now()) {\n const supplied = request.headers.get(SIGNATURE_HEADER);\n if (!supplied) return false;\n database.exec('BEGIN IMMEDIATE');\n try {\n removeExpired.run(now);\n const accepted = insert.run(supplied, now + MAX_CLOCK_SKEW_MS).changes === 1;\n const total = Number((count.get() as { count: number | bigint }).count);\n if (total > MAX_REPLAY_ENTRIES) trim.run(total - MAX_REPLAY_ENTRIES);\n database.exec('COMMIT');\n return accepted;\n } catch (cause) {\n database.exec('ROLLBACK');\n throw cause;\n }\n },\n close: () => database.close(),\n };\n}\n\nexport function verifyPeerRequest(\n config: SessionPeerConfig,\n request: Request,\n body: string,\n now = Date.now(),\n): SessionPeer | undefined {\n const hostId = request.headers.get(PEER_HEADER);\n const timestamp = request.headers.get(TIMESTAMP_HEADER);\n const supplied = request.headers.get(SIGNATURE_HEADER);\n if (!hostId || !timestamp || !supplied || !/^\\d{1,16}$/u.test(timestamp)) return undefined;\n const sentAt = Number(timestamp);\n if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > MAX_CLOCK_SKEW_MS) return undefined;\n const peer = config.peers.find((candidate) => candidate.hostId === hostId);\n if (!peer) return undefined;\n const expected = Buffer.from(signature(peer.secret, request.method, new URL(request.url).pathname, timestamp, body));\n const actual = Buffer.from(supplied);\n return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected) ? peer : undefined;\n}\n\nfunction envelopeOf(value: unknown): SessionPeerEnvelope | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n return validId(record.sourceSessionId) &&\n validId(record.targetSessionId) &&\n typeof record.type === 'string' &&\n record.type !== ''\n ? {\n sourceSessionId: record.sourceSessionId,\n targetSessionId: record.targetSessionId,\n type: record.type,\n payload: record.payload,\n }\n : undefined;\n}\n\nexport function parseSessionPeerEnvelope(value: unknown): SessionPeerEnvelope | undefined {\n return envelopeOf(value);\n}\n\n/** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */\nexport function createPeerCommunication(options: {\n readonly local: DoomSessionCommunicationEndpoint;\n readonly homeDirectory: string;\n readonly fetch?: typeof fetch;\n}): DoomSessionCommunicationEndpoint {\n const config = readSessionPeerConfig(options.homeDirectory);\n const send = options.fetch ?? fetch;\n return {\n sessionId: options.local.sessionId,\n publish(target, type, payload) {\n const remote = parseRemoteSessionReference(target);\n if (!remote) return options.local.publish(target, type, payload);\n const peer = config?.peers.find((candidate) => candidate.hostId === remote.hostId);\n // Pairing is not a grant. Outbound delivery needs the same explicit target\n // allowlist that the receiver enforces for inbound envelopes.\n if (!config || !peer || !peer.allowedSessionIds.includes(remote.sessionId)) return false;\n const body = JSON.stringify({\n sourceSessionId: options.local.sessionId,\n targetSessionId: remote.sessionId,\n type,\n payload,\n });\n const url = new URL(PEER_ROUTE, peer.url);\n void send(url, {\n method: 'POST',\n headers: peerRequestHeaders(config.hostId, peer, 'POST', '/inbox', body),\n body,\n }).catch(() => undefined);\n return true;\n },\n subscribe: (type, listener) => options.local.subscribe(type, listener),\n onPeerReady: (listener) => options.local.onPeerReady(listener),\n close: () => options.local.close(),\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ import { DoomSessionCommunicationEndpoint } from "@agimon-ai/doompi-core/hub-channel";
2
+ //#region src/services/peerTransport/index.d.ts
3
+ declare const PEER_CONFIG_VERSION = 1;
4
+ export interface SessionPeer {
5
+ readonly hostId: string;
6
+ readonly url: string;
7
+ readonly secret: string;
8
+ /** Explicit remote durable-message targets. Empty means no delivery grant. */
9
+ readonly allowedSessionIds: readonly string[];
10
+ /** Explicit remote Voice media targets. Empty means no media grant. */
11
+ readonly allowedVoiceSessionIds: readonly string[];
12
+ }
13
+ export interface SessionPeerConfig {
14
+ readonly version: typeof PEER_CONFIG_VERSION;
15
+ readonly hostId: string;
16
+ readonly peers: readonly SessionPeer[];
17
+ }
18
+ export interface RemoteSessionReference {
19
+ readonly hostId: string;
20
+ readonly sessionId: string;
21
+ }
22
+ export interface SessionPeerEnvelope {
23
+ readonly sourceSessionId: string;
24
+ readonly targetSessionId: string;
25
+ readonly type: string;
26
+ readonly payload: unknown;
27
+ }
28
+ export interface SessionPeerReplayGuard {
29
+ admit(request: Request, now?: number): boolean;
30
+ close(): void;
31
+ }
32
+ export declare function sessionPeerConfigPath(homeDirectory: string): string;
33
+ /** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */
34
+ export declare function readSessionPeerConfig(homeDirectory: string): SessionPeerConfig | undefined;
35
+ /** `peer/<host-id>/<session-id>` is the only remote target spelling. */
36
+ export declare function parseRemoteSessionReference(value: string): RemoteSessionReference | undefined;
37
+ export declare function peerSessionReference(hostId: string, sessionId: string): string;
38
+ export declare function peerRequestHeaders(sourceHostId: string, peer: SessionPeer, method: string, pathname: string, body: string, now?: number): Headers;
39
+ /** Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window. */
40
+ export declare function createPeerReplayGuard(homeDirectory: string): SessionPeerReplayGuard;
41
+ export declare function verifyPeerRequest(config: SessionPeerConfig, request: Request, body: string, now?: number): SessionPeer | undefined;
42
+ /** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */
43
+ export declare function createPeerCommunication(options: {
44
+ readonly local: DoomSessionCommunicationEndpoint;
45
+ readonly homeDirectory: string;
46
+ readonly fetch?: typeof fetch;
47
+ }): DoomSessionCommunicationEndpoint;
48
+ //#endregion
49
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../../../src/services/peerTransport/index.ts"],"mappings":";;cAOM;iBAUW;WACN;WACA;WACA;;WAEA;;WAEA;;iBAGM;WACN,gBAAgB;WAChB;WACA,gBAAgB;;iBAGV;WACN;WACA;;iBAGM;WACN;WACA;WACA;WACA;;iBAGM;EACf,MAAM,SAAS,SAAS;EACxB;;wBAqCc,sBAAsB;;wBAKtB,sBAAsB,wBAAwB;;wBAyB9C,4BAA4B,gBAAgB;wBAK5C,qBAAqB,gBAAgB;wBAWrC,mBACd,sBACA,MAAM,aACN,gBACA,kBACA,cACA,eACC;;wBAYa,sBAAsB,wBAAwB;wBAyC9C,kBACd,QAAQ,mBACR,SAAS,SACT,cACA,eACC;;wBAmCa,wBAAwB;WAC7B,OAAO;WACP;WACA,eAAe;IACtB"}
@@ -0,0 +1,49 @@
1
+ import { DoomSessionCommunicationEndpoint } from "@agimon-ai/doompi-core/hub-channel";
2
+ //#region src/services/peerTransport/index.d.ts
3
+ declare const PEER_CONFIG_VERSION = 1;
4
+ export interface SessionPeer {
5
+ readonly hostId: string;
6
+ readonly url: string;
7
+ readonly secret: string;
8
+ /** Explicit remote durable-message targets. Empty means no delivery grant. */
9
+ readonly allowedSessionIds: readonly string[];
10
+ /** Explicit remote Voice media targets. Empty means no media grant. */
11
+ readonly allowedVoiceSessionIds: readonly string[];
12
+ }
13
+ export interface SessionPeerConfig {
14
+ readonly version: typeof PEER_CONFIG_VERSION;
15
+ readonly hostId: string;
16
+ readonly peers: readonly SessionPeer[];
17
+ }
18
+ export interface RemoteSessionReference {
19
+ readonly hostId: string;
20
+ readonly sessionId: string;
21
+ }
22
+ export interface SessionPeerEnvelope {
23
+ readonly sourceSessionId: string;
24
+ readonly targetSessionId: string;
25
+ readonly type: string;
26
+ readonly payload: unknown;
27
+ }
28
+ export interface SessionPeerReplayGuard {
29
+ admit(request: Request, now?: number): boolean;
30
+ close(): void;
31
+ }
32
+ export declare function sessionPeerConfigPath(homeDirectory: string): string;
33
+ /** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */
34
+ export declare function readSessionPeerConfig(homeDirectory: string): SessionPeerConfig | undefined;
35
+ /** `peer/<host-id>/<session-id>` is the only remote target spelling. */
36
+ export declare function parseRemoteSessionReference(value: string): RemoteSessionReference | undefined;
37
+ export declare function peerSessionReference(hostId: string, sessionId: string): string;
38
+ export declare function peerRequestHeaders(sourceHostId: string, peer: SessionPeer, method: string, pathname: string, body: string, now?: number): Headers;
39
+ /** Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window. */
40
+ export declare function createPeerReplayGuard(homeDirectory: string): SessionPeerReplayGuard;
41
+ export declare function verifyPeerRequest(config: SessionPeerConfig, request: Request, body: string, now?: number): SessionPeer | undefined;
42
+ /** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */
43
+ export declare function createPeerCommunication(options: {
44
+ readonly local: DoomSessionCommunicationEndpoint;
45
+ readonly homeDirectory: string;
46
+ readonly fetch?: typeof fetch;
47
+ }): DoomSessionCommunicationEndpoint;
48
+ //#endregion
49
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../../../src/services/peerTransport/index.ts"],"mappings":";;cAOM;iBAUW;WACN;WACA;WACA;;WAEA;;WAEA;;iBAGM;WACN,gBAAgB;WAChB;WACA,gBAAgB;;iBAGV;WACN;WACA;;iBAGM;WACN;WACA;WACA;WACA;;iBAGM;EACf,MAAM,SAAS,SAAS;EACxB;;wBAqCc,sBAAsB;;wBAKtB,sBAAsB,wBAAwB;;wBAyB9C,4BAA4B,gBAAgB;wBAK5C,qBAAqB,gBAAgB;wBAWrC,mBACd,sBACA,MAAM,aACN,gBACA,kBACA,cACA,eACC;;wBAYa,sBAAsB,wBAAwB;wBAyC9C,kBACd,QAAQ,mBACR,SAAS,SACT,cACA,eACC;;wBAmCa,wBAAwB;WAC7B,OAAO;WACP;WACA,eAAe;IACtB"}
@@ -0,0 +1,195 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ //#region src/services/peerTransport/index.ts
6
+ const PEER_CONFIG_VERSION = 1;
7
+ const PEER_CONFIG_FILE = "peers.json";
8
+ const PEER_ROUTE = "/api/plugins/session-peer/inbox";
9
+ const PEER_HEADER = "x-doompi-session-peer";
10
+ const TIMESTAMP_HEADER = "x-doompi-session-timestamp";
11
+ const SIGNATURE_HEADER = "x-doompi-session-signature";
12
+ const MAX_CLOCK_SKEW_MS = 3e5;
13
+ const MAX_REPLAY_ENTRIES = 1e4;
14
+ const REPLAY_DATABASE_FILE = "peer-replay.sqlite";
15
+ function validId(value) {
16
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
17
+ }
18
+ function peerOf(value) {
19
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
20
+ const record = value;
21
+ if (!validId(record.hostId) || typeof record.url !== "string" || typeof record.secret !== "string") return void 0;
22
+ let url;
23
+ try {
24
+ url = new URL(record.url);
25
+ } catch {
26
+ return;
27
+ }
28
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
29
+ const allowedSessionIds = Array.isArray(record.allowedSessionIds) && record.allowedSessionIds.every(validId) ? record.allowedSessionIds : void 0;
30
+ const allowedVoiceSessionIds = Array.isArray(record.allowedVoiceSessionIds) && record.allowedVoiceSessionIds.every(validId) ? record.allowedVoiceSessionIds : [];
31
+ if (allowedSessionIds === void 0 || record.secret.length < 32) return void 0;
32
+ return {
33
+ hostId: record.hostId,
34
+ url: url.toString(),
35
+ secret: record.secret,
36
+ allowedSessionIds,
37
+ allowedVoiceSessionIds
38
+ };
39
+ }
40
+ function sessionPeerConfigPath(homeDirectory) {
41
+ return path.join(homeDirectory, ".pi", ".doom", "session", PEER_CONFIG_FILE);
42
+ }
43
+ /** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */
44
+ function readSessionPeerConfig(homeDirectory) {
45
+ let parsed;
46
+ try {
47
+ const file = sessionPeerConfigPath(homeDirectory);
48
+ if ((fs.statSync(file).mode & 63) !== 0) return void 0;
49
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
50
+ } catch {
51
+ return;
52
+ }
53
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
54
+ const record = parsed;
55
+ if (record.version !== PEER_CONFIG_VERSION || !validId(record.hostId) || !Array.isArray(record.peers)) return void 0;
56
+ const peers = record.peers.map(peerOf);
57
+ if (peers.some((peer) => peer === void 0)) return void 0;
58
+ const resolved = peers;
59
+ if (new Set(resolved.map((peer) => peer.hostId)).size !== resolved.length || resolved.some((peer) => peer.hostId === record.hostId)) return void 0;
60
+ return {
61
+ version: PEER_CONFIG_VERSION,
62
+ hostId: record.hostId,
63
+ peers: resolved
64
+ };
65
+ }
66
+ /** `peer/<host-id>/<session-id>` is the only remote target spelling. */
67
+ function parseRemoteSessionReference(value) {
68
+ const match = /^peer\/([^/]+)\/([^/]+)$/u.exec(value);
69
+ return match && validId(match[1]) && validId(match[2]) ? {
70
+ hostId: match[1],
71
+ sessionId: match[2]
72
+ } : void 0;
73
+ }
74
+ function peerSessionReference(hostId, sessionId) {
75
+ if (!validId(hostId) || !validId(sessionId)) throw new TypeError("Remote host and session identifiers must be safe opaque IDs.");
76
+ return `peer/${hostId}/${sessionId}`;
77
+ }
78
+ function signature(secret, method, pathname, timestamp, body) {
79
+ const digest = createHash("sha256").update(body).digest("base64url");
80
+ return createHmac("sha256", secret).update(`${method}\n${pathname}\n${timestamp}\n${digest}`).digest("base64url");
81
+ }
82
+ function peerRequestHeaders(sourceHostId, peer, method, pathname, body, now = Date.now()) {
83
+ if (!validId(sourceHostId)) throw new TypeError("Source host identity must be a safe opaque ID.");
84
+ const timestamp = String(now);
85
+ return new Headers({
86
+ "content-type": "application/json",
87
+ [PEER_HEADER]: sourceHostId,
88
+ [TIMESTAMP_HEADER]: timestamp,
89
+ [SIGNATURE_HEADER]: signature(peer.secret, method, pathname, timestamp, body)
90
+ });
91
+ }
92
+ /** Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window. */
93
+ function createPeerReplayGuard(homeDirectory) {
94
+ const directory = path.dirname(sessionPeerConfigPath(homeDirectory));
95
+ fs.mkdirSync(directory, {
96
+ recursive: true,
97
+ mode: 448
98
+ });
99
+ const database = new DatabaseSync(path.join(directory, REPLAY_DATABASE_FILE));
100
+ database.exec(`
101
+ PRAGMA journal_mode = WAL;
102
+ CREATE TABLE IF NOT EXISTS peer_request_replays (
103
+ signature TEXT PRIMARY KEY,
104
+ expires_at INTEGER NOT NULL
105
+ );
106
+ `);
107
+ const removeExpired = database.prepare("DELETE FROM peer_request_replays WHERE expires_at < ?");
108
+ const insert = database.prepare("INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)");
109
+ const count = database.prepare("SELECT COUNT(*) AS count FROM peer_request_replays");
110
+ const trim = database.prepare(`
111
+ DELETE FROM peer_request_replays
112
+ WHERE signature IN (
113
+ SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?
114
+ )
115
+ `);
116
+ return {
117
+ admit(request, now = Date.now()) {
118
+ const supplied = request.headers.get(SIGNATURE_HEADER);
119
+ if (!supplied) return false;
120
+ database.exec("BEGIN IMMEDIATE");
121
+ try {
122
+ removeExpired.run(now);
123
+ const accepted = insert.run(supplied, now + MAX_CLOCK_SKEW_MS).changes === 1;
124
+ const total = Number(count.get().count);
125
+ if (total > MAX_REPLAY_ENTRIES) trim.run(total - MAX_REPLAY_ENTRIES);
126
+ database.exec("COMMIT");
127
+ return accepted;
128
+ } catch (cause) {
129
+ database.exec("ROLLBACK");
130
+ throw cause;
131
+ }
132
+ },
133
+ close: () => database.close()
134
+ };
135
+ }
136
+ function verifyPeerRequest(config, request, body, now = Date.now()) {
137
+ const hostId = request.headers.get(PEER_HEADER);
138
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
139
+ const supplied = request.headers.get(SIGNATURE_HEADER);
140
+ if (!hostId || !timestamp || !supplied || !/^\d{1,16}$/u.test(timestamp)) return void 0;
141
+ const sentAt = Number(timestamp);
142
+ if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > MAX_CLOCK_SKEW_MS) return void 0;
143
+ const peer = config.peers.find((candidate) => candidate.hostId === hostId);
144
+ if (!peer) return void 0;
145
+ const expected = Buffer.from(signature(peer.secret, request.method, new URL(request.url).pathname, timestamp, body));
146
+ const actual = Buffer.from(supplied);
147
+ return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected) ? peer : void 0;
148
+ }
149
+ function envelopeOf(value) {
150
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
151
+ const record = value;
152
+ return validId(record.sourceSessionId) && validId(record.targetSessionId) && typeof record.type === "string" && record.type !== "" ? {
153
+ sourceSessionId: record.sourceSessionId,
154
+ targetSessionId: record.targetSessionId,
155
+ type: record.type,
156
+ payload: record.payload
157
+ } : void 0;
158
+ }
159
+ function parseSessionPeerEnvelope(value) {
160
+ return envelopeOf(value);
161
+ }
162
+ /** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */
163
+ function createPeerCommunication(options) {
164
+ const config = readSessionPeerConfig(options.homeDirectory);
165
+ const send = options.fetch ?? fetch;
166
+ return {
167
+ sessionId: options.local.sessionId,
168
+ publish(target, type, payload) {
169
+ const remote = parseRemoteSessionReference(target);
170
+ if (!remote) return options.local.publish(target, type, payload);
171
+ const peer = config?.peers.find((candidate) => candidate.hostId === remote.hostId);
172
+ if (!config || !peer || !peer.allowedSessionIds.includes(remote.sessionId)) return false;
173
+ const body = JSON.stringify({
174
+ sourceSessionId: options.local.sessionId,
175
+ targetSessionId: remote.sessionId,
176
+ type,
177
+ payload
178
+ });
179
+ const url = new URL(PEER_ROUTE, peer.url);
180
+ send(url, {
181
+ method: "POST",
182
+ headers: peerRequestHeaders(config.hostId, peer, "POST", "/inbox", body),
183
+ body
184
+ }).catch(() => void 0);
185
+ return true;
186
+ },
187
+ subscribe: (type, listener) => options.local.subscribe(type, listener),
188
+ onPeerReady: (listener) => options.local.onPeerReady(listener),
189
+ close: () => options.local.close()
190
+ };
191
+ }
192
+ //#endregion
193
+ export { createPeerCommunication, createPeerReplayGuard, parseRemoteSessionReference, parseSessionPeerEnvelope, peerRequestHeaders, peerSessionReference, readSessionPeerConfig, sessionPeerConfigPath, verifyPeerRequest };
194
+
195
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../../../src/services/peerTransport/index.ts"],"sourcesContent":["import { createHash, createHmac, timingSafeEqual } from 'node:crypto';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\n\nimport type { DoomSessionCommunicationEndpoint } from '@agimon-ai/doompi-core/hub-channel';\n\nconst PEER_CONFIG_VERSION = 1;\nconst PEER_CONFIG_FILE = 'peers.json';\nconst PEER_ROUTE = '/api/plugins/session-peer/inbox';\nconst PEER_HEADER = 'x-doompi-session-peer';\nconst TIMESTAMP_HEADER = 'x-doompi-session-timestamp';\nconst SIGNATURE_HEADER = 'x-doompi-session-signature';\nconst MAX_CLOCK_SKEW_MS = 5 * 60_000;\nconst MAX_REPLAY_ENTRIES = 10_000;\nconst REPLAY_DATABASE_FILE = 'peer-replay.sqlite';\n\nexport interface SessionPeer {\n readonly hostId: string;\n readonly url: string;\n readonly secret: string;\n /** Explicit remote durable-message targets. Empty means no delivery grant. */\n readonly allowedSessionIds: readonly string[];\n /** Explicit remote Voice media targets. Empty means no media grant. */\n readonly allowedVoiceSessionIds: readonly string[];\n}\n\nexport interface SessionPeerConfig {\n readonly version: typeof PEER_CONFIG_VERSION;\n readonly hostId: string;\n readonly peers: readonly SessionPeer[];\n}\n\nexport interface RemoteSessionReference {\n readonly hostId: string;\n readonly sessionId: string;\n}\n\nexport interface SessionPeerEnvelope {\n readonly sourceSessionId: string;\n readonly targetSessionId: string;\n readonly type: string;\n readonly payload: unknown;\n}\n\nexport interface SessionPeerReplayGuard {\n admit(request: Request, now?: number): boolean;\n close(): void;\n}\n\nfunction validId(value: unknown): value is string {\n return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);\n}\n\nfunction peerOf(value: unknown): SessionPeer | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (!validId(record.hostId) || typeof record.url !== 'string' || typeof record.secret !== 'string') return undefined;\n let url: URL;\n try {\n url = new URL(record.url);\n } catch {\n return undefined;\n }\n if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '')\n return undefined;\n const allowedSessionIds =\n Array.isArray(record.allowedSessionIds) && record.allowedSessionIds.every(validId)\n ? record.allowedSessionIds\n : undefined;\n const allowedVoiceSessionIds =\n Array.isArray(record.allowedVoiceSessionIds) && record.allowedVoiceSessionIds.every(validId)\n ? record.allowedVoiceSessionIds\n : [];\n if (allowedSessionIds === undefined || record.secret.length < 32) return undefined;\n return {\n hostId: record.hostId,\n url: url.toString(),\n secret: record.secret,\n allowedSessionIds,\n allowedVoiceSessionIds,\n };\n}\n\nexport function sessionPeerConfigPath(homeDirectory: string): string {\n return path.join(homeDirectory, '.pi', '.doom', 'session', PEER_CONFIG_FILE);\n}\n\n/** Reads operator-provisioned peers. Invalid or unreadable config fails closed. */\nexport function readSessionPeerConfig(homeDirectory: string): SessionPeerConfig | undefined {\n let parsed: unknown;\n try {\n const file = sessionPeerConfigPath(homeDirectory);\n if ((fs.statSync(file).mode & 0o077) !== 0) return undefined;\n parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown;\n } catch {\n return undefined;\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;\n const record = parsed as Record<string, unknown>;\n if (record.version !== PEER_CONFIG_VERSION || !validId(record.hostId) || !Array.isArray(record.peers))\n return undefined;\n const peers = record.peers.map(peerOf);\n if (peers.some((peer) => peer === undefined)) return undefined;\n const resolved = peers as SessionPeer[];\n if (\n new Set(resolved.map((peer) => peer.hostId)).size !== resolved.length ||\n resolved.some((peer) => peer.hostId === record.hostId)\n )\n return undefined;\n return { version: PEER_CONFIG_VERSION, hostId: record.hostId, peers: resolved };\n}\n\n/** `peer/<host-id>/<session-id>` is the only remote target spelling. */\nexport function parseRemoteSessionReference(value: string): RemoteSessionReference | undefined {\n const match = /^peer\\/([^/]+)\\/([^/]+)$/u.exec(value);\n return match && validId(match[1]) && validId(match[2]) ? { hostId: match[1], sessionId: match[2] } : undefined;\n}\n\nexport function peerSessionReference(hostId: string, sessionId: string): string {\n if (!validId(hostId) || !validId(sessionId))\n throw new TypeError('Remote host and session identifiers must be safe opaque IDs.');\n return `peer/${hostId}/${sessionId}`;\n}\n\nfunction signature(secret: string, method: string, pathname: string, timestamp: string, body: string): string {\n const digest = createHash('sha256').update(body).digest('base64url');\n return createHmac('sha256', secret).update(`${method}\\n${pathname}\\n${timestamp}\\n${digest}`).digest('base64url');\n}\n\nexport function peerRequestHeaders(\n sourceHostId: string,\n peer: SessionPeer,\n method: string,\n pathname: string,\n body: string,\n now = Date.now(),\n): Headers {\n if (!validId(sourceHostId)) throw new TypeError('Source host identity must be a safe opaque ID.');\n const timestamp = String(now);\n return new Headers({\n 'content-type': 'application/json',\n [PEER_HEADER]: sourceHostId,\n [TIMESTAMP_HEADER]: timestamp,\n [SIGNATURE_HEADER]: signature(peer.secret, method, pathname, timestamp, body),\n });\n}\n\n/** Persists accepted peer signatures so a valid request cannot be replayed within its clock-skew window. */\nexport function createPeerReplayGuard(homeDirectory: string): SessionPeerReplayGuard {\n const directory = path.dirname(sessionPeerConfigPath(homeDirectory));\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n const database = new DatabaseSync(path.join(directory, REPLAY_DATABASE_FILE));\n database.exec(`\n PRAGMA journal_mode = WAL;\n CREATE TABLE IF NOT EXISTS peer_request_replays (\n signature TEXT PRIMARY KEY,\n expires_at INTEGER NOT NULL\n );\n `);\n const removeExpired = database.prepare('DELETE FROM peer_request_replays WHERE expires_at < ?');\n const insert = database.prepare('INSERT OR IGNORE INTO peer_request_replays (signature, expires_at) VALUES (?, ?)');\n const count = database.prepare('SELECT COUNT(*) AS count FROM peer_request_replays');\n const trim = database.prepare(`\n DELETE FROM peer_request_replays\n WHERE signature IN (\n SELECT signature FROM peer_request_replays ORDER BY expires_at ASC, signature ASC LIMIT ?\n )\n `);\n return {\n admit(request, now = Date.now()) {\n const supplied = request.headers.get(SIGNATURE_HEADER);\n if (!supplied) return false;\n database.exec('BEGIN IMMEDIATE');\n try {\n removeExpired.run(now);\n const accepted = insert.run(supplied, now + MAX_CLOCK_SKEW_MS).changes === 1;\n const total = Number((count.get() as { count: number | bigint }).count);\n if (total > MAX_REPLAY_ENTRIES) trim.run(total - MAX_REPLAY_ENTRIES);\n database.exec('COMMIT');\n return accepted;\n } catch (cause) {\n database.exec('ROLLBACK');\n throw cause;\n }\n },\n close: () => database.close(),\n };\n}\n\nexport function verifyPeerRequest(\n config: SessionPeerConfig,\n request: Request,\n body: string,\n now = Date.now(),\n): SessionPeer | undefined {\n const hostId = request.headers.get(PEER_HEADER);\n const timestamp = request.headers.get(TIMESTAMP_HEADER);\n const supplied = request.headers.get(SIGNATURE_HEADER);\n if (!hostId || !timestamp || !supplied || !/^\\d{1,16}$/u.test(timestamp)) return undefined;\n const sentAt = Number(timestamp);\n if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > MAX_CLOCK_SKEW_MS) return undefined;\n const peer = config.peers.find((candidate) => candidate.hostId === hostId);\n if (!peer) return undefined;\n const expected = Buffer.from(signature(peer.secret, request.method, new URL(request.url).pathname, timestamp, body));\n const actual = Buffer.from(supplied);\n return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected) ? peer : undefined;\n}\n\nfunction envelopeOf(value: unknown): SessionPeerEnvelope | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n return validId(record.sourceSessionId) &&\n validId(record.targetSessionId) &&\n typeof record.type === 'string' &&\n record.type !== ''\n ? {\n sourceSessionId: record.sourceSessionId,\n targetSessionId: record.targetSessionId,\n type: record.type,\n payload: record.payload,\n }\n : undefined;\n}\n\nexport function parseSessionPeerEnvelope(value: unknown): SessionPeerEnvelope | undefined {\n return envelopeOf(value);\n}\n\n/** Adds asynchronous tunnel publication to a host-bound local communication endpoint. */\nexport function createPeerCommunication(options: {\n readonly local: DoomSessionCommunicationEndpoint;\n readonly homeDirectory: string;\n readonly fetch?: typeof fetch;\n}): DoomSessionCommunicationEndpoint {\n const config = readSessionPeerConfig(options.homeDirectory);\n const send = options.fetch ?? fetch;\n return {\n sessionId: options.local.sessionId,\n publish(target, type, payload) {\n const remote = parseRemoteSessionReference(target);\n if (!remote) return options.local.publish(target, type, payload);\n const peer = config?.peers.find((candidate) => candidate.hostId === remote.hostId);\n // Pairing is not a grant. Outbound delivery needs the same explicit target\n // allowlist that the receiver enforces for inbound envelopes.\n if (!config || !peer || !peer.allowedSessionIds.includes(remote.sessionId)) return false;\n const body = JSON.stringify({\n sourceSessionId: options.local.sessionId,\n targetSessionId: remote.sessionId,\n type,\n payload,\n });\n const url = new URL(PEER_ROUTE, peer.url);\n void send(url, {\n method: 'POST',\n headers: peerRequestHeaders(config.hostId, peer, 'POST', '/inbox', body),\n body,\n }).catch(() => undefined);\n return true;\n },\n subscribe: (type, listener) => options.local.subscribe(type, listener),\n onPeerReady: (listener) => options.local.onPeerReady(listener),\n close: () => options.local.close(),\n };\n}\n"],"mappings":"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"}