@ganglion/xacpx-relay 0.9.18 → 0.10.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 (78) hide show
  1. package/dist/cli.d.ts +7 -0
  2. package/dist/cli.js +207 -20
  3. package/dist/maintenance.d.ts +2 -1
  4. package/dist/relay-web/assets/DashboardView-EVK-kyc5.js +430 -0
  5. package/dist/relay-web/assets/InviteView-D6_fuQ6u.js +1 -0
  6. package/dist/relay-web/assets/InviteView-hJ3Eqm6F.css +1 -0
  7. package/dist/relay-web/assets/{LoginView-C6CwOivl.js → LoginView-B270qavX.js} +1 -1
  8. package/dist/relay-web/assets/{SettingsView-BiPxFqyx.js → SettingsView-04bWB9fl.js} +2 -2
  9. package/dist/relay-web/assets/{abnfDiagram-VRR7QNED-D3eynLH4.js → abnfDiagram-VRR7QNED-Cv28PqIA.js} +1 -1
  10. package/dist/relay-web/assets/{arc-DUs51LXM.js → arc-B3Mx49EP.js} +1 -1
  11. package/dist/relay-web/assets/{architectureDiagram-ZJ3FMSHR-CnBlByS3.js → architectureDiagram-ZJ3FMSHR-CK-Lsk_U.js} +1 -1
  12. package/dist/relay-web/assets/{blockDiagram-677ZJIJ3-CVXJAHk4.js → blockDiagram-677ZJIJ3-C5aBMzqI.js} +1 -1
  13. package/dist/relay-web/assets/{c4Diagram-LMCZKHZV-JM82ZYu8.js → c4Diagram-LMCZKHZV-BlQI5wLu.js} +1 -1
  14. package/dist/relay-web/assets/channel-1MbVMhlH.js +1 -0
  15. package/dist/relay-web/assets/{chunk-2Q5K7J3B-drrGPFvX.js → chunk-2Q5K7J3B-CrCOkSE9.js} +1 -1
  16. package/dist/relay-web/assets/{chunk-32BRIVSS-B9yf1_gn.js → chunk-32BRIVSS-Bd6OcIKX.js} +1 -1
  17. package/dist/relay-web/assets/{chunk-5VM5RSS4-Do9WVyKQ.js → chunk-5VM5RSS4-CrBYK6bf.js} +1 -1
  18. package/dist/relay-web/assets/{chunk-EX3LRPZG-DDzz-ACk.js → chunk-EX3LRPZG-CfQdQUT8.js} +1 -1
  19. package/dist/relay-web/assets/{chunk-JWPE2WC7-UI6iUoHI.js → chunk-JWPE2WC7-3IOarpnO.js} +1 -1
  20. package/dist/relay-web/assets/{chunk-MOJQB5TN-ByIrBez3.js → chunk-MOJQB5TN-DtdyRAam.js} +1 -1
  21. package/dist/relay-web/assets/{chunk-RYQCIY6F-6y_9H1Ib.js → chunk-RYQCIY6F-BMymW-Ns.js} +1 -1
  22. package/dist/relay-web/assets/{chunk-V7JOEXUC-CqNKiWQ8.js → chunk-V7JOEXUC-Bm52mzv8.js} +1 -1
  23. package/dist/relay-web/assets/{chunk-VR4S4FIN-C2pH6FSw.js → chunk-VR4S4FIN-C7QLObJe.js} +1 -1
  24. package/dist/relay-web/assets/{chunk-XXDRQBXY-DFAqoBna.js → chunk-XXDRQBXY-DdwwHyIL.js} +1 -1
  25. package/dist/relay-web/assets/classDiagram-OUVF2IWQ-BcNmbj1M.js +1 -0
  26. package/dist/relay-web/assets/classDiagram-v2-EOCWNBFH-BcNmbj1M.js +1 -0
  27. package/dist/relay-web/assets/{cose-bilkent-JH36ORCC-Cpf6uf3d.js → cose-bilkent-JH36ORCC-PpMhQH69.js} +1 -1
  28. package/dist/relay-web/assets/{cynefin-VYW2F7L2-IXQUEi0W.js → cynefin-VYW2F7L2-CUBqRnnQ.js} +1 -1
  29. package/dist/relay-web/assets/{cynefinDiagram-TSTJHNR4-BC9f1Fi3.js → cynefinDiagram-TSTJHNR4-BC_TJ62y.js} +1 -1
  30. package/dist/relay-web/assets/{dagre-VKFMJZFB-BxXN216n.js → dagre-VKFMJZFB-B_of3-kV.js} +1 -1
  31. package/dist/relay-web/assets/{diagram-FQU43EPY-CkmHSEIt.js → diagram-FQU43EPY-DwufAj8L.js} +1 -1
  32. package/dist/relay-web/assets/{diagram-G47NLZAW-CbIEa_OU.js → diagram-G47NLZAW-B1Wh2BCB.js} +1 -1
  33. package/dist/relay-web/assets/{diagram-NH7WQ7WH-4qbaTukk.js → diagram-NH7WQ7WH-B8wmK-4H.js} +1 -1
  34. package/dist/relay-web/assets/{diagram-OA4YK3LP-yeg3CVfp.js → diagram-OA4YK3LP-DsSFS7pG.js} +1 -1
  35. package/dist/relay-web/assets/{diagram-WEI45ONY-Dgsr9As5.js → diagram-WEI45ONY-Dzxv-wo4.js} +1 -1
  36. package/dist/relay-web/assets/{ebnfDiagram-CCIWWBDH-CFtS6ZSp.js → ebnfDiagram-CCIWWBDH-D5pQGrHB.js} +1 -1
  37. package/dist/relay-web/assets/{erDiagram-Q63AITRT-MppHd6Ui.js → erDiagram-Q63AITRT-CKrm8sAE.js} +1 -1
  38. package/dist/relay-web/assets/{flowDiagram-23GEKE2U-DHKJxooJ.js → flowDiagram-23GEKE2U-qB2FERkS.js} +1 -1
  39. package/dist/relay-web/assets/{ganttDiagram-NO4QXBWP-B6MraPUE.js → ganttDiagram-NO4QXBWP-CQU1ZcEC.js} +1 -1
  40. package/dist/relay-web/assets/{ghostty-web-KHKgWovV.js → ghostty-web-Cib0xsSw.js} +1 -1
  41. package/dist/relay-web/assets/{gitGraphDiagram-IHSO6WYX-B61DehtB.js → gitGraphDiagram-IHSO6WYX-d6OQNr9X.js} +1 -1
  42. package/dist/relay-web/assets/index-D6SM2DoZ.js +98 -0
  43. package/dist/relay-web/assets/{index-DbQzlB4Y.css → index-DtbxKCKB.css} +1 -1
  44. package/dist/relay-web/assets/{infoDiagram-FWYZ7A6U-B7LgrKBn.js → infoDiagram-FWYZ7A6U-BgH2uoid.js} +1 -1
  45. package/dist/relay-web/assets/{ishikawaDiagram-FXEZZL3T-Bt1aT3yu.js → ishikawaDiagram-FXEZZL3T-DzEM5nGH.js} +1 -1
  46. package/dist/relay-web/assets/{journeyDiagram-5HDEW3XC-Cze-GQu3.js → journeyDiagram-5HDEW3XC-DITafq2J.js} +1 -1
  47. package/dist/relay-web/assets/{kanban-definition-HUTT4EX6-Dv5rVbgW.js → kanban-definition-HUTT4EX6-D-tHpErm.js} +1 -1
  48. package/dist/relay-web/assets/{linear-BpCIiHGU.js → linear-BYtx3Cpi.js} +1 -1
  49. package/dist/relay-web/assets/{mermaid.core-hW6nrk5d.js → mermaid.core-BK5xScER.js} +4 -4
  50. package/dist/relay-web/assets/{mindmap-definition-LN4V7U3C-CR9uIKLT.js → mindmap-definition-LN4V7U3C-BL-IGWZe.js} +1 -1
  51. package/dist/relay-web/assets/{pegDiagram-2B236MQR-BBmEWqWF.js → pegDiagram-2B236MQR-cfZauhKw.js} +1 -1
  52. package/dist/relay-web/assets/{pieDiagram-ENE6RG2P-BE3C-3Pw.js → pieDiagram-ENE6RG2P-CNfeQ62O.js} +1 -1
  53. package/dist/relay-web/assets/{quadrantDiagram-ABIIQ3AL-CvZX0jzO.js → quadrantDiagram-ABIIQ3AL-BFB6jOnt.js} +1 -1
  54. package/dist/relay-web/assets/{railroadDiagram-RFXS5EU6-CwOncWTj.js → railroadDiagram-RFXS5EU6-Cln7vzNZ.js} +1 -1
  55. package/dist/relay-web/assets/{requirementDiagram-TGXJPOKE-D81d73C3.js → requirementDiagram-TGXJPOKE-B5_FNmJB.js} +1 -1
  56. package/dist/relay-web/assets/{sankeyDiagram-HTMAVEWB-DcKcptSO.js → sankeyDiagram-HTMAVEWB-BZ1RWUL6.js} +1 -1
  57. package/dist/relay-web/assets/{sequenceDiagram-DBY2YBRQ-BLA_BsI9.js → sequenceDiagram-DBY2YBRQ-7IBw-dCX.js} +1 -1
  58. package/dist/relay-web/assets/{sizeCapture-X5ZJPWSS-Bf1kuTrb.js → sizeCapture-X5ZJPWSS-DcX5jLpS.js} +1 -1
  59. package/dist/relay-web/assets/{stateDiagram-2N3HPSRC-DwitPAKb.js → stateDiagram-2N3HPSRC-mqNdl_tU.js} +1 -1
  60. package/dist/relay-web/assets/stateDiagram-v2-6OUMAXLB-8SM3ysz0.js +1 -0
  61. package/dist/relay-web/assets/{swimlanes-5IMT3BWC-Cu2lv2-l.js → swimlanes-5IMT3BWC-B_zIEJ-q.js} +2 -2
  62. package/dist/relay-web/assets/swimlanesDiagram-G3AALYLV-BD1iVVgC.js +8 -0
  63. package/dist/relay-web/assets/{theme-tJlCLrde.js → theme-D3iqo32T.js} +1 -1
  64. package/dist/relay-web/assets/{timeline-definition-FHXFAJF6-3TdgbJ9h.js → timeline-definition-FHXFAJF6-DtIH7yoe.js} +1 -1
  65. package/dist/relay-web/assets/{vennDiagram-L72KCM5P-CGzOG85H.js → vennDiagram-L72KCM5P-BWW7oGjc.js} +1 -1
  66. package/dist/relay-web/assets/{wardleyDiagram-EHGQE667-Djz14Pom.js → wardleyDiagram-EHGQE667-Oj3I-rxo.js} +1 -1
  67. package/dist/relay-web/assets/{xychartDiagram-FW5EYKEG-ByN2Qux6.js → xychartDiagram-FW5EYKEG-BB8IDwkp.js} +1 -1
  68. package/dist/relay-web/index.html +2 -2
  69. package/dist/relay-web/sw.js +1 -1
  70. package/dist/stores/accounts.d.ts +36 -0
  71. package/package.json +2 -2
  72. package/dist/relay-web/assets/DashboardView-3omD0mEP.js +0 -419
  73. package/dist/relay-web/assets/channel-Y_xckp_8.js +0 -1
  74. package/dist/relay-web/assets/classDiagram-OUVF2IWQ-BMcCziZn.js +0 -1
  75. package/dist/relay-web/assets/classDiagram-v2-EOCWNBFH-BMcCziZn.js +0 -1
  76. package/dist/relay-web/assets/index-CMGeKM1T.js +0 -98
  77. package/dist/relay-web/assets/stateDiagram-v2-6OUMAXLB-K0Mc3sNE.js +0 -1
  78. package/dist/relay-web/assets/swimlanesDiagram-G3AALYLV-CF7Iyv4J.js +0 -8
package/dist/cli.d.ts CHANGED
@@ -14,6 +14,13 @@ export declare function defaultDbPath(): string;
14
14
  * mis-resolve. The `cliJsPath` param exists only for tests.
15
15
  */
16
16
  export declare function resolveBundledWebRoot(cliJsPath?: string): string | undefined;
17
+ /**
18
+ * Parses an invite TTL like "30m", "12h", "7d" into milliseconds.
19
+ * Returns null for malformed input; undefined input yields the 7-day default.
20
+ * Capped at 10 years — beyond that Date arithmetic overflows the ECMAScript
21
+ * time range and toISOString() throws instead of printing usage.
22
+ */
23
+ export declare function parseTtlMs(raw: string | undefined): number | null;
17
24
  export interface StartOptions {
18
25
  dbPath: string;
19
26
  httpPort: number;
package/dist/cli.js CHANGED
@@ -86,6 +86,15 @@ function initSchema(db) {
86
86
  expires_at TEXT NOT NULL,
87
87
  used_at TEXT
88
88
  );
89
+ CREATE TABLE IF NOT EXISTS invite_codes (
90
+ id TEXT PRIMARY KEY,
91
+ code_hash TEXT NOT NULL UNIQUE,
92
+ label TEXT,
93
+ created_at TEXT NOT NULL,
94
+ expires_at TEXT NOT NULL,
95
+ used_at TEXT,
96
+ used_account_id TEXT REFERENCES accounts(id)
97
+ );
89
98
  CREATE TABLE IF NOT EXISTS instances (
90
99
  id TEXT PRIMARY KEY,
91
100
  account_id TEXT NOT NULL REFERENCES accounts(id),
@@ -276,6 +285,71 @@ class AccountStore {
276
285
  this.db.run("DELETE FROM web_sessions WHERE expires_at <= ?", [iso]);
277
286
  return ws?.n ?? 0;
278
287
  }
288
+ issueInviteCode(label, ttlMs) {
289
+ const id = randomUUID();
290
+ const code = generateToken();
291
+ const nowMs = this.now().getTime();
292
+ const expiresAt = new Date(nowMs + ttlMs).toISOString();
293
+ this.db.run("INSERT INTO invite_codes (id, code_hash, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", [id, hashToken(code), label ?? null, new Date(nowMs).toISOString(), expiresAt]);
294
+ return { id, code, expiresAt };
295
+ }
296
+ redeemInviteCode(code) {
297
+ const codeHash = hashToken(code);
298
+ const row = this.db.get("SELECT id, label, expires_at, used_at FROM invite_codes WHERE code_hash = ?", [codeHash]);
299
+ if (!row || row.used_at !== null || new Date(row.expires_at).getTime() <= this.now().getTime()) {
300
+ return null;
301
+ }
302
+ const nowIso = this.now().toISOString();
303
+ const accountId = randomUUID();
304
+ const username = `u-${randomUUID()}`;
305
+ const loginTokenId = randomUUID();
306
+ const token = generateToken();
307
+ this.db.exec("BEGIN");
308
+ try {
309
+ this.db.run("UPDATE invite_codes SET used_at = ?, used_account_id = ? WHERE code_hash = ? AND used_at IS NULL", [nowIso, accountId, codeHash]);
310
+ this.db.run("INSERT INTO accounts (id, username, created_at) VALUES (?, ?, ?)", [accountId, username, nowIso]);
311
+ this.db.run("INSERT INTO login_tokens (id, token_hash, account_id, label, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, NULL)", [loginTokenId, hashToken(token), accountId, row.label, nowIso]);
312
+ this.db.exec("COMMIT");
313
+ } catch (e) {
314
+ this.db.exec("ROLLBACK");
315
+ throw e;
316
+ }
317
+ return { token, username, accountId, loginTokenId };
318
+ }
319
+ listInviteCodes() {
320
+ return this.db.all("SELECT id, label, created_at, expires_at, used_at FROM invite_codes ORDER BY created_at").map((row) => ({
321
+ id: row.id,
322
+ label: row.label,
323
+ createdAt: row.created_at,
324
+ expiresAt: row.expires_at,
325
+ usedAt: row.used_at
326
+ }));
327
+ }
328
+ inviteIdFor(valueOrId) {
329
+ const byValue = this.db.get("SELECT id FROM invite_codes WHERE code_hash = ?", [hashToken(valueOrId)]);
330
+ if (byValue)
331
+ return byValue.id;
332
+ const byId = this.db.get("SELECT id FROM invite_codes WHERE id = ?", [valueOrId]);
333
+ if (byId)
334
+ return byId.id;
335
+ const byPrefix = this.db.all("SELECT id FROM invite_codes WHERE id LIKE ? || '%'", [valueOrId]);
336
+ if (byPrefix.length === 1)
337
+ return byPrefix[0].id;
338
+ return null;
339
+ }
340
+ removeInviteCode(id) {
341
+ const existing = this.db.get("SELECT id FROM invite_codes WHERE id = ?", [id]);
342
+ if (!existing)
343
+ return false;
344
+ this.db.run("DELETE FROM invite_codes WHERE id = ?", [id]);
345
+ return true;
346
+ }
347
+ pruneInviteCodes(now) {
348
+ const iso = now.toISOString();
349
+ const row = this.db.get("SELECT COUNT(*) AS n FROM invite_codes WHERE expires_at <= ? OR used_at IS NOT NULL", [iso]);
350
+ this.db.run("DELETE FROM invite_codes WHERE expires_at <= ? OR used_at IS NOT NULL", [iso]);
351
+ return row?.n ?? 0;
352
+ }
279
353
  }
280
354
 
281
355
  // packages/relay/src/stores/instances.ts
@@ -1009,13 +1083,16 @@ var MAX_ATTACHMENT_FIELD_LEN = 256;
1009
1083
  function boundField(v) {
1010
1084
  return typeof v === "string" ? v.slice(0, MAX_ATTACHMENT_FIELD_LEN) : v;
1011
1085
  }
1012
- function rpcSessionAlias(type, payload) {
1086
+ function rpcSessionLocks(type, payload) {
1013
1087
  const value = payload;
1014
1088
  if (type === MSG3.prompt || type === MSG3.commandExecute) {
1015
- return typeof value.sessionAlias === "string" && value.sessionAlias ? value.sessionAlias : undefined;
1089
+ return typeof value.sessionAlias === "string" && value.sessionAlias ? { alias: value.sessionAlias, lifecycle: false, turn: true } : undefined;
1090
+ }
1091
+ if (type === MSG3.sessionsRename) {
1092
+ return typeof value.alias === "string" && value.alias ? { alias: value.alias, lifecycle: true, turn: false } : undefined;
1016
1093
  }
1017
- if (type === MSG3.sessionsCreate || type === MSG3.sessionsRemove || type === MSG3.sessionsArchive || type === MSG3.sessionsUnarchive || type === MSG3.sessionsRename) {
1018
- return typeof value.alias === "string" && value.alias ? value.alias : undefined;
1094
+ if (type === MSG3.sessionsCreate || type === MSG3.sessionsRemove || type === MSG3.sessionsArchive || type === MSG3.sessionsUnarchive) {
1095
+ return typeof value.alias === "string" && value.alias ? { alias: value.alias, lifecycle: true, turn: true } : undefined;
1019
1096
  }
1020
1097
  return;
1021
1098
  }
@@ -1046,7 +1123,8 @@ function createApp(deps) {
1046
1123
  const pairingTtlMs = deps.pairingTtlMs ?? 10 * 60 * 1000;
1047
1124
  const trustProxy = deps.trustProxy ?? false;
1048
1125
  const now = deps.now ?? (() => new Date);
1049
- const acquireSessionRpcLock = createKeyedRpcLock();
1126
+ const acquireSessionLifecycleRpcLock = createKeyedRpcLock();
1127
+ const acquireSessionTurnRpcLock = createKeyedRpcLock();
1050
1128
  const loginFailures = new Map;
1051
1129
  let globalFailures = { count: 0, windowStart: 0 };
1052
1130
  function sweepPerIpLoginFailures(nowMs) {
@@ -1111,8 +1189,28 @@ function createApp(deps) {
1111
1189
  });
1112
1190
  app.post("/api/register", (c) => c.json({ error: "not-found" }, 404));
1113
1191
  app.post("/api/invites", (c) => c.json({ error: "not-found" }, 404));
1192
+ app.post("/api/invites/redeem", async (c) => {
1193
+ if (!requireJson(c.req.header("content-type")))
1194
+ return c.json({ error: "unsupported-media-type" }, 415);
1195
+ const body = await c.req.json().catch(() => ({}));
1196
+ const nowMs = now().getTime();
1197
+ sweepPerIpLoginFailures(nowMs);
1198
+ const ip = clientIp(c, trustProxy);
1199
+ if (isRateLimited(ip, nowMs)) {
1200
+ deps.logger?.info("relay.invite.rejected", "invite redeem rejected", { reason: "rate-limited", ip });
1201
+ return c.json({ error: "too-many-attempts" }, 429);
1202
+ }
1203
+ const r = deps.accounts.redeemInviteCode(typeof body.code === "string" ? body.code : "");
1204
+ if (!r) {
1205
+ recordFailure(ip, nowMs);
1206
+ deps.logger?.info("relay.invite.rejected", "invite redeem rejected", { reason: "invalid-code", ip });
1207
+ return c.json({ error: "invalid-code" }, 401);
1208
+ }
1209
+ deps.logger?.info("relay.invite.redeemed", "invite redeemed", { accountId: r.accountId });
1210
+ return c.json({ token: r.token, username: r.username });
1211
+ });
1114
1212
  app.use("/api/*", async (c, next) => {
1115
- if (c.req.path === "/api/login")
1213
+ if (c.req.path === "/api/login" || c.req.path === "/api/invites/redeem")
1116
1214
  return next();
1117
1215
  const token = getCookie(c, SESSION_COOKIE);
1118
1216
  const account = token ? deps.accounts.getSessionAccount(token) : null;
@@ -1229,7 +1327,7 @@ function createApp(deps) {
1229
1327
  isOwner: true
1230
1328
  };
1231
1329
  }
1232
- let releaseSessionRpcLock;
1330
+ const releaseSessionRpcLocks = [];
1233
1331
  let persistedPromptId;
1234
1332
  try {
1235
1333
  if (body.type === MSG3.prompt && !parseControlPayload(MSG3.prompt, payload)) {
@@ -1251,9 +1349,15 @@ function createApp(deps) {
1251
1349
  if (approxBytes > 10 * 1024 * 1024)
1252
1350
  return c.json({ error: "file-too-large" }, 413);
1253
1351
  }
1254
- const sessionAlias = rpcSessionAlias(body.type, payload);
1255
- if (sessionAlias) {
1256
- releaseSessionRpcLock = await acquireSessionRpcLock(`${instance.id}\x00${sessionAlias}`);
1352
+ const sessionLocks = rpcSessionLocks(body.type, payload);
1353
+ if (sessionLocks) {
1354
+ const key = `${instance.id}\x00${sessionLocks.alias}`;
1355
+ if (sessionLocks.lifecycle) {
1356
+ releaseSessionRpcLocks.push(await acquireSessionLifecycleRpcLock(key));
1357
+ }
1358
+ if (sessionLocks.turn) {
1359
+ releaseSessionRpcLocks.push(await acquireSessionTurnRpcLock(key));
1360
+ }
1257
1361
  }
1258
1362
  if (body.type === MSG3.prompt || body.type === MSG3.commandExecute) {
1259
1363
  const p = payload;
@@ -1299,7 +1403,9 @@ function createApp(deps) {
1299
1403
  return c.json({ error: message }, 504);
1300
1404
  return c.json({ error: message }, 500);
1301
1405
  } finally {
1302
- releaseSessionRpcLock?.();
1406
+ for (let i = releaseSessionRpcLocks.length - 1;i >= 0; i--) {
1407
+ releaseSessionRpcLocks[i]?.();
1408
+ }
1303
1409
  }
1304
1410
  });
1305
1411
  if (deps.webRoot) {
@@ -1329,7 +1435,8 @@ function runMaintenance(stores, opts) {
1329
1435
  });
1330
1436
  const sessionsDeleted = stores.accounts.pruneExpired(now);
1331
1437
  const pairingTokensDeleted = stores.instances.prunePairingTokens(now);
1332
- return { messagesDeleted, sessionsDeleted, pairingTokensDeleted };
1438
+ const inviteCodesDeleted = stores.accounts.pruneInviteCodes(now);
1439
+ return { messagesDeleted, sessionsDeleted, pairingTokensDeleted, inviteCodesDeleted };
1333
1440
  }
1334
1441
  function startMaintenanceLoop(stores, opts, intervalMs, onError) {
1335
1442
  const tick = () => {
@@ -1721,8 +1828,10 @@ var USAGE = [
1721
1828
  " start [--db <path>] [--web-root <dir>] [--host 0.0.0.0] [--http-port 8787] [--ws-port <n>] [--history-retention-days 30] [--request-timeout-ms 120000] [--trust-proxy]",
1722
1829
  " (--ws-port omitted = gateway merged onto the HTTP port; pass it only for a dedicated gateway port)",
1723
1830
  " add token [--label <note>] [--db <path>]",
1831
+ " add invite [--label <note>] [--ttl <n>{m|h|d}] [--url <base>] [--db <path>]",
1724
1832
  " ls [--db <path>]",
1725
1833
  " rm token <value-or-id> [--db <path>]",
1834
+ " rm invite <code-or-id> [--db <path>]",
1726
1835
  " update [--check] (self-update @ganglion/xacpx-relay; --check only reports)",
1727
1836
  "",
1728
1837
  " Defaults: --db ~/.xacpx-relay/relay.db --web-root auto-detects the bundled dashboard"
@@ -1736,6 +1845,20 @@ function flag(args, name) {
1736
1845
  function hasFlag(args, name) {
1737
1846
  return args.includes(name);
1738
1847
  }
1848
+ function parseTtlMs(raw) {
1849
+ if (raw === undefined)
1850
+ return 7 * 24 * 60 * 60 * 1000;
1851
+ const match = /^(\d+)([mhd])$/.exec(raw);
1852
+ if (!match)
1853
+ return null;
1854
+ const n = Number(match[1]);
1855
+ if (n <= 0)
1856
+ return null;
1857
+ const unit = match[2] === "m" ? 60000 : match[2] === "h" ? 3600000 : 86400000;
1858
+ const ms = n * unit;
1859
+ const maxMs = 10 * 365 * 86400000;
1860
+ return ms > maxMs ? null : ms;
1861
+ }
1739
1862
  function parseStartOptions(args) {
1740
1863
  const dbPath = flag(args, "--db") ?? defaultDbPath();
1741
1864
  const retentionRaw = flag(args, "--history-retention-days");
@@ -1793,21 +1916,64 @@ async function runRelayCli(args, io) {
1793
1916
  runtime.close();
1794
1917
  }
1795
1918
  }
1919
+ if (args[0] === "add" && args[1] === "invite") {
1920
+ const label = flag(args, "--label");
1921
+ const ttlRaw = flag(args, "--ttl");
1922
+ if (hasFlag(args, "--ttl") && ttlRaw === undefined) {
1923
+ io.print(USAGE);
1924
+ return 1;
1925
+ }
1926
+ const ttlMs = parseTtlMs(ttlRaw);
1927
+ if (ttlMs === null) {
1928
+ io.print(USAGE);
1929
+ return 1;
1930
+ }
1931
+ const runtime = await createRelayRuntime(dbPath);
1932
+ try {
1933
+ const { code, expiresAt } = runtime.accounts.issueInviteCode(label, ttlMs);
1934
+ io.print(`invite code: ${code}`);
1935
+ const baseUrl = flag(args, "--url");
1936
+ if (baseUrl) {
1937
+ io.print(`redeem link: ${baseUrl.replace(/\/+$/, "")}/invite/${code}`);
1938
+ } else {
1939
+ io.print(`redeem path: /invite/${code} (append to your hub URL)`);
1940
+ }
1941
+ io.print(`(share it now — single-use, not shown again; expires ${expiresAt.slice(0, 16).replace("T", " ")} UTC)`);
1942
+ return 0;
1943
+ } finally {
1944
+ runtime.close();
1945
+ }
1946
+ }
1796
1947
  if (args[0] === "ls") {
1797
1948
  const runtime = await createRelayRuntime(dbPath);
1798
1949
  try {
1799
1950
  const tokens = runtime.accounts.listTokens();
1800
1951
  if (tokens.length === 0) {
1801
1952
  io.print("(no tokens)");
1802
- return 0;
1953
+ } else {
1954
+ io.print("id label created #instances");
1955
+ io.print("-------- -------------------- -------------------- ----------");
1956
+ for (const t of tokens) {
1957
+ const shortId = t.id.slice(0, 8);
1958
+ const label = (t.label ?? "").slice(0, 20).padEnd(20);
1959
+ const created = t.createdAt.slice(0, 19).replace("T", " ");
1960
+ io.print(`${shortId} ${label} ${created} ${String(t.instanceCount).padStart(10)}`);
1961
+ }
1803
1962
  }
1804
- io.print("id label created #instances");
1805
- io.print("-------- -------------------- -------------------- ----------");
1806
- for (const t of tokens) {
1807
- const shortId = t.id.slice(0, 8);
1808
- const label = (t.label ?? "").slice(0, 20).padEnd(20);
1809
- const created = t.createdAt.slice(0, 19).replace("T", " ");
1810
- io.print(`${shortId} ${label} ${created} ${String(t.instanceCount).padStart(10)}`);
1963
+ const invites = runtime.accounts.listInviteCodes();
1964
+ if (invites.length > 0) {
1965
+ const nowMs = Date.now();
1966
+ io.print("");
1967
+ io.print("invites");
1968
+ io.print("id label expires (UTC) status");
1969
+ io.print("-------- -------------------- -------------------- -------");
1970
+ for (const inv of invites) {
1971
+ const shortId = inv.id.slice(0, 8);
1972
+ const label = (inv.label ?? "").slice(0, 20).padEnd(20);
1973
+ const expires = inv.expiresAt.slice(0, 19).replace("T", " ");
1974
+ const status = inv.usedAt !== null ? "used" : new Date(inv.expiresAt).getTime() <= nowMs ? "expired" : "unused";
1975
+ io.print(`${shortId} ${label} ${expires} ${status}`);
1976
+ }
1811
1977
  }
1812
1978
  return 0;
1813
1979
  } finally {
@@ -1834,6 +2000,26 @@ async function runRelayCli(args, io) {
1834
2000
  runtime.close();
1835
2001
  }
1836
2002
  }
2003
+ if (args[0] === "rm" && args[1] === "invite") {
2004
+ const valueOrId = args[2];
2005
+ if (!valueOrId || valueOrId.startsWith("--")) {
2006
+ io.print(USAGE);
2007
+ return 1;
2008
+ }
2009
+ const runtime = await createRelayRuntime(dbPath);
2010
+ try {
2011
+ const inviteId = runtime.accounts.inviteIdFor(valueOrId);
2012
+ if (!inviteId) {
2013
+ io.print(`invite not found: ${valueOrId}`);
2014
+ return 1;
2015
+ }
2016
+ runtime.accounts.removeInviteCode(inviteId);
2017
+ io.print("removed");
2018
+ return 0;
2019
+ } finally {
2020
+ runtime.close();
2021
+ }
2022
+ }
1837
2023
  if (args[0] === "update") {
1838
2024
  return await handleRelayUpdate(args.slice(1), { print: io.print });
1839
2025
  }
@@ -1849,6 +2035,7 @@ if (__require.main == __require.module) {
1849
2035
  export {
1850
2036
  runRelayCli,
1851
2037
  resolveBundledWebRoot,
2038
+ parseTtlMs,
1852
2039
  parseStartOptions,
1853
2040
  defaultDbPath
1854
2041
  };
@@ -15,8 +15,9 @@ export interface MaintenanceSummary {
15
15
  messagesDeleted: number;
16
16
  sessionsDeleted: number;
17
17
  pairingTokensDeleted: number;
18
+ inviteCodesDeleted: number;
18
19
  }
19
- /** Runs one maintenance pass: prune old/excess messages, GC expired sessions/pairing tokens. */
20
+ /** Runs one maintenance pass: prune old/excess messages, GC expired sessions/pairing tokens/invite codes. */
20
21
  export declare function runMaintenance(stores: MaintenanceStores, opts: MaintenanceOptions): MaintenanceSummary;
21
22
  /** Starts a periodic maintenance loop. Returns a stop function. */
22
23
  export declare function startMaintenanceLoop(stores: MaintenanceStores, opts: MaintenanceOptions, intervalMs: number, onError?: (err: unknown) => void): () => void;