@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24

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 (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>dsh-a2a-munder-difflin · A2A Floor</title>
7
+ <script type="module" crossorigin src="./assets/index-C-28k_o5.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-Bj7S2pA1.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/lib/a2a-client.js CHANGED
@@ -6,8 +6,15 @@
6
6
  * @module @nelsonlongxiang/dsh-open-a2a-net/a2a-client
7
7
  */
8
8
  import { verifyCard } from "./card.js";
9
+ import { MAX_ROUTE_BODY_BYTES, WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "./transport-caps.js";
9
10
  /** Direct-route HTTP budget. */
10
11
  const HTTP_TIMEOUT_MS = 15_000;
12
+ /** The native-teams bridge marker, when the peer's answer carries one: its
13
+ * rounds emit no A2A receipt in this slice, so the caller must not book the
14
+ * task as receipt-owed. */
15
+ function wireBridge(value) {
16
+ return value === 'native-teams' ? 'native-teams' : undefined;
17
+ }
11
18
  /** Wire strings are absent-or-string; anything else degrades to ''. */
12
19
  function wireText(value) {
13
20
  return typeof value === 'string' ? value : '';
@@ -28,13 +35,26 @@ export class A2aClient {
28
35
  headers['Content-Type'] = 'application/json';
29
36
  if (this.options.apiKey !== '')
30
37
  headers['X-API-Key'] = this.options.apiKey;
38
+ // GETs are card fetches: name ourselves so the peer serves the full
39
+ // card (gh#7 G1). POSTs carry caller_session in the body already.
40
+ if (!json && this.options.cardCaller !== '')
41
+ headers['X-A2A-Caller'] = this.options.cardCaller;
42
+ // gh#9+gh#10: the OriginClaim envelope — a verifiable origin on every
43
+ // peer request when the signer is wired (full-card reads and delivery
44
+ // verification both ride it).
45
+ if (this.options.originClaim !== undefined) {
46
+ for (const [key, value] of Object.entries(this.options.originClaim()))
47
+ headers[key] = value;
48
+ }
31
49
  return headers;
32
50
  }
33
51
  async http(path, init, baseUrl) {
34
52
  if (init.signal?.aborted)
35
53
  throw new Error('A2A request aborted before dispatch');
36
54
  const controller = new AbortController();
55
+ let ownBudgetFired = false;
37
56
  const timer = this.options.schedule(() => {
57
+ ownBudgetFired = true;
38
58
  controller.abort();
39
59
  }, HTTP_TIMEOUT_MS);
40
60
  const abort = () => {
@@ -49,10 +69,26 @@ export class A2aClient {
49
69
  signal: controller.signal,
50
70
  });
51
71
  const text = await response.text();
52
- if (!response.ok)
53
- throw new Error(`A2A HTTP ${String(response.status)}: ${text.slice(0, 200)}`);
72
+ if (!response.ok) {
73
+ // Carry the status and raw body on the error so callers can read the
74
+ // peer's structured wire code (idempotency verdicts) without parsing
75
+ // the prose of the message.
76
+ const error = new Error(`A2A HTTP ${String(response.status)}: ${text.slice(0, 200)}`);
77
+ error.statusCode = response.status;
78
+ error.bodyText = text;
79
+ throw error;
80
+ }
54
81
  return JSON.parse(text);
55
82
  }
83
+ catch (error) {
84
+ // Tag the escapee so the caller can tell our budget firing from an
85
+ // external signal — the abortElapsedMs telemetry needs the author.
86
+ if (ownBudgetFired && error instanceof Error) {
87
+ ;
88
+ error.ownBudgetExhausted = true;
89
+ }
90
+ throw error;
91
+ }
56
92
  finally {
57
93
  timer();
58
94
  init.signal?.removeEventListener('abort', abort);
@@ -67,16 +103,28 @@ export class A2aClient {
67
103
  * @returns the verified card, or `undefined` when unreachable, malformed, or rejected.
68
104
  */
69
105
  async fetchCard(baseUrl) {
106
+ const outcome = await this.fetchCardDetail(baseUrl);
107
+ return outcome.ok ? outcome.card : undefined;
108
+ }
109
+ /**
110
+ * The diagnosing half of {@link fetchCard}: the same fetch and verification
111
+ * with the failure stage preserved, so a probe can tell a down node from a
112
+ * distrusted card.
113
+ * @param baseUrl - the peer's base URL.
114
+ * @returns the verified card, or the failure's stage and detail.
115
+ */
116
+ async fetchCardDetail(baseUrl) {
70
117
  try {
71
118
  const candidate = await this.http('/.well-known/agent-card.json', { method: 'GET' }, baseUrl);
72
119
  const verified = verifyCard(candidate, Date.now());
73
120
  if (!verified.ok)
74
- return undefined;
75
- return verified.card;
121
+ return { ok: false, stage: 'rejected', reason: verified.reason };
122
+ return { ok: true, card: verified.card };
76
123
  }
77
- catch {
78
- // An unreachable or malformed peer leaves discovery to the other peers.
79
- return undefined;
124
+ catch (error) {
125
+ // An unreachable or malformed peer leaves discovery to the other peers;
126
+ // the detail names what actually happened for the probe.
127
+ return { ok: false, stage: 'unreachable', detail: String(error).slice(0, 200) };
80
128
  }
81
129
  }
82
130
  /**
@@ -106,7 +154,7 @@ export class A2aClient {
106
154
  * @param callerSession - caller label overriding the node's label (the calling session's node label).
107
155
  * @returns the canonical route result (success or explicit failure).
108
156
  */
109
- async routeDirect(baseUrl, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller) {
157
+ async routeDirect(baseUrl, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress) {
110
158
  const args = { team, message, caller_session: callerSession ?? this.options.sessionId };
111
159
  if (contextId !== undefined)
112
160
  args.context_id = contextId;
@@ -120,16 +168,80 @@ export class A2aClient {
120
168
  // own (pre-0.5.3 peers).
121
169
  if (taskIdFromCaller !== undefined)
122
170
  args.task_id = taskIdFromCaller;
171
+ // The callback address (P2): where the peer routes THIS caller's
172
+ // receipt — distinct from the caller label when the submitting session
173
+ // has its own node team (the transport face's callbackTarget mapping
174
+ // rides this). Old peers ignore the extra body field harmlessly.
175
+ if (callbackAddress !== undefined && callbackAddress !== '')
176
+ args.callback = callbackAddress;
177
+ // Outbound half of the same B5 ruling: refuse to put an oversized
178
+ // payload on the wire. Rejection is local and instant — a doomed upload
179
+ // would burn the 15s HTTP budget and end in the peer's 413 anyway.
180
+ const body = JSON.stringify(args);
181
+ if (!withinRouteBodyCap(Buffer.byteLength(body, 'utf8'))) {
182
+ return {
183
+ ok: false,
184
+ error: `payload exceeds the ${String(MAX_ROUTE_BODY_BYTES)}-byte direct-route transport cap`,
185
+ code: WIRE_ERROR_PAYLOAD_TOO_LARGE,
186
+ };
187
+ }
123
188
  let raw;
189
+ const dispatchedAt = Date.now();
124
190
  try {
125
191
  raw = await this.http('/a2a/direct', {
126
192
  method: 'POST',
127
- body: JSON.stringify(args),
193
+ body,
128
194
  ...(signal !== undefined ? { signal } : {}),
129
195
  }, baseUrl);
130
196
  }
131
197
  catch (error) {
132
- return { ok: false, error: `direct route to peer failed: ${String(error)}`, code: -32000 };
198
+ const ownBudgetExhausted = error.ownBudgetExhausted === true;
199
+ // F1 half-transaction fix (t-mth5gmxy): the 15s budget firing on a SYNC
200
+ // route does NOT mean the peer failed — the delivery landed and the
201
+ // peer keeps executing. The structured -32005 code marks the
202
+ // DELIVERED-UNSETTLED shape; the caller side (index.ts, which owns the
203
+ // fingerprint and the peer candidates) runs the query compensation and
204
+ // upgrades or confirms the honest result there. The budget gate checks
205
+ // BOTH the flag and the error KIND: an immediate transport rejection
206
+ // (ECONNREFUSED) racing a scheduled timer is a transport failure, not
207
+ // a wait timeout — only a genuine abort-class error is the budget.
208
+ const abortClass = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError' || /abort/i.test(error.message));
209
+ if (ownBudgetExhausted && abortClass && !asyncMode && !signal?.aborted) {
210
+ return {
211
+ ok: false,
212
+ error: `route wait timed out after ${String(HTTP_TIMEOUT_MS)}ms but the delivery was dispatched and may still complete — reconcile via the task query (do NOT re-dispatch blindly)`,
213
+ code: -32005,
214
+ task_id: taskIdFromCaller,
215
+ abortElapsedMs: Date.now() - dispatchedAt,
216
+ ownBudgetExhausted: true,
217
+ };
218
+ }
219
+ // The peer's structured wire code rides the HTTP rejection body
220
+ // (idempotency verdicts -32002/-32003 among others): surface it, so a
221
+ // caller can tell a terminal verdict from an ordinary transport miss
222
+ // without parsing prose.
223
+ let code = -32000;
224
+ const bodyText = error.bodyText;
225
+ if (typeof bodyText === 'string') {
226
+ try {
227
+ const parsed = JSON.parse(bodyText);
228
+ if (typeof parsed.code === 'number')
229
+ code = parsed.code;
230
+ }
231
+ catch {
232
+ /* prose body — keep the generic code */
233
+ }
234
+ }
235
+ const failure = {
236
+ ok: false,
237
+ error: `direct route to peer failed: ${String(error)}`,
238
+ code,
239
+ // Wait-window telemetry (envelope-v2 §4): measured patience plus
240
+ // whose budget ended it. Pure observation, zero semantics.
241
+ abortElapsedMs: Date.now() - dispatchedAt,
242
+ ...(ownBudgetExhausted ? { ownBudgetExhausted: true } : {}),
243
+ };
244
+ return failure;
133
245
  }
134
246
  if (raw.error !== undefined && raw.error !== null) {
135
247
  const failure = {
@@ -141,26 +253,91 @@ export class A2aClient {
141
253
  return failure;
142
254
  }
143
255
  // The delivered shape (wait:false): no result member — the reply rides
144
- // the receipt contract back to the caller's team instead.
256
+ // the receipt contract back to the caller's team instead. A bridged
257
+ // native round carries the peer's `bridge` marker and promises NO
258
+ // receipt: the text must not claim one the slice will never send.
145
259
  if (raw.routed === true && raw.delivered === true) {
146
260
  const taskId = wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? '';
261
+ const bridge = wireBridge(raw.bridge);
262
+ // F1 client half (async-stall §三): the peer's consumed probe rides
263
+ // through — a delivered-but-unconsumed message is queued (busy turn)
264
+ // or latched, and the caller should follow up, not assume arrival.
265
+ const consumed = typeof raw.consumed === 'boolean' ? raw.consumed : undefined;
147
266
  return {
148
267
  ok: true,
149
268
  team,
150
- reply: `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.`,
269
+ reply: bridge !== undefined
270
+ ? `Delivered to ${team} (async native-teams round). It settles through the team's own routing chain and routes no A2A receipt in this slice — reconcile via context_id follow-ups.`
271
+ : consumed === false
272
+ ? `Delivered to ${team} (async), but the target had not started a turn on it yet — the message is queued behind the session's current turn. Watch a2a_status activity or follow up with the context id; do not resend.`
273
+ : `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.`,
151
274
  task_id: taskId,
152
275
  context_id: wireText(raw.context_id),
153
276
  task_status: wireText(raw.task_status) === '' ? 'TASK_STATE_DELIVERED' : wireText(raw.task_status),
277
+ ...(consumed !== undefined ? { consumed } : {}),
278
+ ...(bridge !== undefined ? { bridge } : {}),
154
279
  };
155
280
  }
156
281
  const reply = this.replyText(raw.result, '');
282
+ const bridge = wireBridge(raw.bridge);
157
283
  return {
158
284
  ok: true,
159
285
  team,
160
286
  reply,
161
- task_id: wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? wireText(raw.task_id),
287
+ // Same echo-first rule as the delivered branch: the peer's echo wins,
288
+ // else the caller-born id, else ''.
289
+ task_id: wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? '',
162
290
  context_id: wireText(raw.context_id),
163
291
  task_status: wireText(raw.task_status),
292
+ ...(bridge !== undefined ? { bridge } : {}),
164
293
  };
165
294
  }
295
+ /**
296
+ * Query a peer's `/a2a/query` outcome surface (W7 slice 2): read-only
297
+ * lookup of one claimed task's settled outcome. The fingerprint is the
298
+ * authorization — the caller recomputes it from the exact submit fields
299
+ * with the shared `peerPayloadFingerprint`. Every transport-level failure
300
+ * (network miss, HTTP error, unparseable or malformed body, empty fields)
301
+ * answers `undefined`: a query that cannot complete carries no increment
302
+ * of information, never a verdict.
303
+ */
304
+ async queryOutcome(baseUrl, taskId, fingerprint, signal) {
305
+ if (taskId === '' || fingerprint === '')
306
+ return undefined;
307
+ try {
308
+ const raw = await this.http('/a2a/query', {
309
+ method: 'POST',
310
+ body: JSON.stringify({ task_id: taskId, fingerprint }),
311
+ ...(signal !== undefined ? { signal } : {}),
312
+ }, baseUrl);
313
+ return parseQueryAnswer(raw);
314
+ }
315
+ catch {
316
+ return undefined;
317
+ }
318
+ }
319
+ }
320
+ /** Coerce one raw wire body into an {@link A2aQueryAnswer}; anything
321
+ * malformed degrades to `undefined` (no verdict), never to a guess. */
322
+ function parseQueryAnswer(raw) {
323
+ if (raw.found !== true && raw.found !== false)
324
+ return undefined;
325
+ if (raw.found === false) {
326
+ if (raw.reason !== 'unknown-task' && raw.reason !== 'payload-mismatch')
327
+ return undefined;
328
+ return { found: false, reason: raw.reason };
329
+ }
330
+ if (raw.status === 'pending')
331
+ return { found: true, status: 'pending' };
332
+ const settledAt = typeof raw.settled_at === 'string' && raw.settled_at !== '' ? raw.settled_at : undefined;
333
+ if (settledAt === undefined)
334
+ return undefined;
335
+ const truncated = raw.truncated === true ? { truncated: true } : {};
336
+ if (raw.status === 'completed' && typeof raw.reply === 'string') {
337
+ return { found: true, status: 'completed', reply: raw.reply, settledAt, ...truncated };
338
+ }
339
+ if (raw.status === 'failed' && typeof raw.error === 'string') {
340
+ return { found: true, status: 'failed', error: raw.error, settledAt, ...truncated };
341
+ }
342
+ return undefined;
166
343
  }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Persisted canvas teams: user-composed multi-member routing groups under
3
+ * the a2a home (`a2a/canvas.json`). A canvas team is a named set of member
4
+ * references in one of two shapes: a local session id (a joined session
5
+ * node on this host) or a remote team handle (`<zone>/<id8>`, a session
6
+ * node published by a peer — one member may sit in many teams). Routing to
7
+ * `<team>/canvas/<name>` resolves the first live local member, wakes the
8
+ * first cold local one, then falls through to the remote members in order
9
+ * (the directory resolves their handles; remote nodes never wake from
10
+ * here). Membership order is the routing priority (member list order),
11
+ * preserved verbatim on disk. Plain JSON, one writer (the control routes),
12
+ * read fresh by the state route.
13
+ * @module @nelsonlongxiang/dsh-open-a2a-net/canvas-store
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ /** Upper bounds: a canvas taxonomy, not a database. */
18
+ export const CANVAS_TEAM_CAP = 64;
19
+ export const CANVAS_MEMBER_CAP = 32;
20
+ /** Longest accepted team name (same budget as group names). */
21
+ export const CANVAS_NAME_MAX = 40;
22
+ /**
23
+ * Whether one member entry references a remote node rather than a local
24
+ * session id. Remote refs are zone-qualified team handles (`<zone>/<id8>`),
25
+ * so the '/'-test is deterministic: local session ids (`session-<uuid>`,
26
+ * `import-<uuid>`) never contain one, and canvas team names — the only other
27
+ * strings in a member slot — are barred from '/' at creation.
28
+ */
29
+ export function isRemoteMemberRef(id) {
30
+ return id.includes('/');
31
+ }
32
+ /**
33
+ * Shape-gate for a remote member reference at the control-route seam: at
34
+ * least one '/'-delimited segment on each side, no whitespace. The registry
35
+ * cross-check lives in the UI (the picker offers discovered rows only);
36
+ * this gate keeps malformed strings out of the persisted file.
37
+ */
38
+ export function validRemoteMemberRef(id) {
39
+ const trimmed = id.trim();
40
+ return trimmed === id && isRemoteMemberRef(id) && !/\s/.test(id)
41
+ && !id.startsWith('/') && !id.endsWith('/') && !id.includes('//');
42
+ }
43
+ function validName(raw) {
44
+ const clean = raw.trim();
45
+ if (clean === '' || clean.length > CANVAS_NAME_MAX)
46
+ return undefined;
47
+ // '/' would nest the wire namespace (<team>/canvas/a/b); pure digits would
48
+ // reorder under JSON.parse's integer-like key ordering if the store ever
49
+ // migrated to a keyed map — both stay out now.
50
+ if (clean.includes('/') || /^\d+$/.test(clean))
51
+ return undefined;
52
+ return clean;
53
+ }
54
+ /**
55
+ * Bounded canvas-team store with last-write-wins persistence. Pure of any
56
+ * registry knowledge: callers validate join state and resolve members.
57
+ */
58
+ export class CanvasStore {
59
+ /** Team entries in creation order; members in add order (routing priority). */
60
+ teams = [];
61
+ path;
62
+ /**
63
+ * @param path - the snapshot file; an empty path keeps the store in memory.
64
+ */
65
+ constructor(path) {
66
+ this.path = path;
67
+ this.restore();
68
+ }
69
+ /** Load a persisted snapshot, if present and well-formed. */
70
+ restore() {
71
+ if (this.path === '' || !existsSync(this.path))
72
+ return;
73
+ try {
74
+ const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
75
+ if (!Array.isArray(snapshot?.teams))
76
+ return;
77
+ const kept = [];
78
+ for (const entry of snapshot.teams) {
79
+ if (entry === null || typeof entry !== 'object')
80
+ continue;
81
+ if (validName(String(entry.name)) === undefined)
82
+ continue;
83
+ const members = Array.isArray(entry.members)
84
+ ? entry.members.filter((id) => typeof id === 'string' && id !== '').slice(0, CANVAS_MEMBER_CAP)
85
+ : [];
86
+ if (kept.some(t => t.name === entry.name))
87
+ continue;
88
+ kept.push({ name: String(entry.name), members });
89
+ }
90
+ this.teams = kept.slice(0, CANVAS_TEAM_CAP);
91
+ }
92
+ catch {
93
+ // A corrupt store is not fatal: the user re-creates teams; routing
94
+ // names simply stop resolving until then.
95
+ }
96
+ }
97
+ /** Persist the current snapshot (no-op without a path). */
98
+ persist() {
99
+ if (this.path === '')
100
+ return;
101
+ try {
102
+ mkdirSync(dirname(this.path), { recursive: true });
103
+ const snapshot = { teams: this.teams.map(t => ({ name: t.name, members: [...t.members] })) };
104
+ writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
105
+ }
106
+ catch {
107
+ // An unwritable home must not break the canvas; the store degrades to
108
+ // memory-only.
109
+ }
110
+ }
111
+ /** Every team name, in creation order. */
112
+ list() {
113
+ return this.teams.map(t => t.name);
114
+ }
115
+ /** Whether the named team exists. */
116
+ hasTeam(name) {
117
+ return this.teams.some(t => t.name === name);
118
+ }
119
+ /** The named team's member ids in routing-priority order (a copy). */
120
+ membersOf(name) {
121
+ const entry = this.teams.find(t => t.name === name);
122
+ return entry === undefined ? [] : [...entry.members];
123
+ }
124
+ /**
125
+ * The named team's remote member refs (zone-qualified team handles) in
126
+ * routing-priority order — the failover tier after the local live/cold
127
+ * tiers miss. Shape test only: whether the handle currently resolves is
128
+ * the directory's question at route time, not the store's.
129
+ */
130
+ remoteMembersOf(name) {
131
+ return this.membersOf(name).filter(isRemoteMemberRef);
132
+ }
133
+ /** Every team name the id is a member of, in team creation order. */
134
+ teamsOf(id) {
135
+ return this.teams.filter(t => t.members.includes(id)).map(t => t.name);
136
+ }
137
+ /**
138
+ * Create a named team; whitespace trims, duplicates are idempotent, and
139
+ * the cap rejects a team beyond {@link CANVAS_TEAM_CAP}.
140
+ * @param name - the raw user-supplied name.
141
+ * @returns the stored name, or undefined when rejected.
142
+ */
143
+ create(name) {
144
+ const clean = validName(name);
145
+ if (clean === undefined)
146
+ return undefined;
147
+ if (this.hasTeam(clean))
148
+ return clean;
149
+ if (this.teams.length >= CANVAS_TEAM_CAP)
150
+ return undefined;
151
+ this.teams.push({ name: clean, members: [] });
152
+ this.persist();
153
+ return clean;
154
+ }
155
+ /**
156
+ * Delete a team (with its memberships).
157
+ * @param name - the team name.
158
+ */
159
+ remove(name) {
160
+ const index = this.teams.findIndex(t => t.name === name);
161
+ if (index < 0)
162
+ return false;
163
+ this.teams.splice(index, 1);
164
+ this.persist();
165
+ return true;
166
+ }
167
+ /**
168
+ * Add one member (idempotent; duplicates keep the original priority slot).
169
+ * The team must exist — callers create it first so the cap applies once.
170
+ * @param name - the existing team name.
171
+ * @param id - the member session id.
172
+ */
173
+ addMember(name, id) {
174
+ if (id === '')
175
+ return false;
176
+ const entry = this.teams.find(t => t.name === name);
177
+ if (entry === undefined)
178
+ return false;
179
+ if (entry.members.includes(id))
180
+ return true;
181
+ if (entry.members.length >= CANVAS_MEMBER_CAP)
182
+ return false;
183
+ entry.members.push(id);
184
+ this.persist();
185
+ return true;
186
+ }
187
+ /**
188
+ * Remove one member from one team (a no-op when absent).
189
+ */
190
+ removeMember(name, id) {
191
+ const entry = this.teams.find(t => t.name === name);
192
+ if (entry === undefined)
193
+ return false;
194
+ const index = entry.members.indexOf(id);
195
+ if (index < 0)
196
+ return false;
197
+ entry.members.splice(index, 1);
198
+ this.persist();
199
+ return true;
200
+ }
201
+ /**
202
+ * Drop one id from every team — the leave and archive paths: a session
203
+ * leaving the network keeps no canvas membership anywhere.
204
+ * @param id - the departing session id.
205
+ */
206
+ dropMember(id) {
207
+ let dirty = false;
208
+ for (const entry of this.teams) {
209
+ const index = entry.members.indexOf(id);
210
+ if (index >= 0) {
211
+ entry.members.splice(index, 1);
212
+ dirty = true;
213
+ }
214
+ }
215
+ if (dirty)
216
+ this.persist();
217
+ }
218
+ }
package/lib/card.js CHANGED
@@ -104,6 +104,28 @@ function sessionTeams(wire) {
104
104
  }
105
105
  return entries;
106
106
  }
107
+ /**
108
+ * The unsigned `teamMemberships` listing passes through only as an array of
109
+ * well-formed node→team entries; any other shape drops the field instead of
110
+ * rejecting the card — same posture as `sessionTeams`, because cards from
111
+ * nodes that declared no roster teams (or pre-roster versions) carry none.
112
+ * @param wire - the shape-checked card candidate.
113
+ * @returns the membership entries, or `undefined` when absent or malformed.
114
+ */
115
+ function teamMemberships(wire) {
116
+ if (!Array.isArray(wire.teamMemberships))
117
+ return undefined;
118
+ const entries = [];
119
+ for (const entry of wire.teamMemberships) {
120
+ const membership = entry;
121
+ if (typeof membership?.node !== 'string' || membership.node === '')
122
+ continue;
123
+ if (typeof membership.team !== 'string' || membership.team === '')
124
+ continue;
125
+ entries.push({ node: membership.node, team: membership.team });
126
+ }
127
+ return entries;
128
+ }
107
129
  /**
108
130
  * Verify a parsed card: shape, signature, and expiry, in that order.
109
131
  * @param candidate - the JSON value fetched from a peer's agent-card URL.
@@ -144,6 +166,7 @@ export function verifyCard(candidate, now) {
144
166
  }
145
167
  const peers = referrals(wire);
146
168
  const teams = sessionTeams(wire);
169
+ const memberships = teamMemberships(wire);
147
170
  return {
148
171
  ok: true,
149
172
  card: {
@@ -151,7 +174,9 @@ export function verifyCard(candidate, now) {
151
174
  expiresAt: wire.expiresAt,
152
175
  ...(peers !== undefined ? { peers } : {}),
153
176
  ...(teams !== undefined && teams.length > 0 ? { sessionTeams: teams } : {}),
177
+ ...(memberships !== undefined && memberships.length > 0 ? { teamMemberships: memberships } : {}),
154
178
  ...(typeof wire.lanIp === 'string' && wire.lanIp !== '' ? { lanIp: wire.lanIp } : {}),
179
+ ...(typeof wire.version === 'string' && wire.version !== '' ? { version: wire.version } : {}),
155
180
  publicKey: wire.publicKey,
156
181
  signature: wire.signature,
157
182
  },