@nelsonlongxiang/dsh-open-a2a-net 0.6.31 → 0.6.32

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 (88) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +126 -74
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +1 -1
  75. package/nexus-stage/src/federation.ts +1 -0
  76. package/nexus-stage/src/main.ts +41 -18
  77. package/nexus-stage/src/planning-member-handles.ts +34 -0
  78. package/nexus-stage/src/planning-mirror.ts +47 -3
  79. package/nexus-stage/src/planning-reachability.ts +28 -0
  80. package/nexus-stage/src/planning-types.ts +1 -1
  81. package/nexus-stage/src/planning-view.ts +29 -26
  82. package/nexus-stage/src/planning.css +4 -0
  83. package/nexus-stage/src/wb-directory.ts +13 -0
  84. package/nexus-stage/src/workbench.css +3 -0
  85. package/nexus-stage/src/workbench.ts +5 -1
  86. package/package.json +1 -1
  87. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
  88. package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
@@ -30,7 +30,7 @@ import { MAX_LAYOUT_BODY_BYTES } from "../transport-caps.js";
30
30
  * @param d - the captures listed on {@link AdminFaceDeps}.
31
31
  */
32
32
  export function registerAdminFaces(webServer, d) {
33
- const { ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, indexUrl, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, } = d;
33
+ const { ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, indexUrl, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, authorizationView, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, } = d;
34
34
  ctx.effect(() => webServer.register({
35
35
  kind: 'exact',
36
36
  path: '/__dsh_a2a/state',
@@ -56,20 +56,25 @@ export function registerAdminFaces(webServer, d) {
56
56
  const tFacts0 = Date.now();
57
57
  const sessions = [...liveRoots.values()]
58
58
  .filter(agent => isArchived?.(String(agent.id)) !== true)
59
- .map(agent => ({
60
- id: String(agent.id),
61
- label: sessionLabelOf(agent),
62
- team: sessionTeamOf(agent),
63
- ...nodeMetadataOf(agent),
64
- joined: sessionNodes.has(String(agent.id)),
65
- live: true,
66
- teams: [...teamMemberships.teamsOf(String(agent.id))],
67
- // #19 D3: the session's exposure tier (linked floor /
68
- // fleet opt-up) — readable on the state face.
69
- exposure: exposureStore.tierOf(String(agent.id)),
70
- ...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
71
- ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
72
- }));
59
+ .map(agent => {
60
+ // gh#152: canvas teams through the local lens — a handle-stored
61
+ // membership (gh#131) shows here exactly as routing reads it.
62
+ const canvasTeams = canvasTeamsOfSession(String(agent.id));
63
+ return {
64
+ id: String(agent.id),
65
+ label: sessionLabelOf(agent),
66
+ team: sessionTeamOf(agent),
67
+ ...nodeMetadataOf(agent),
68
+ joined: sessionNodes.has(String(agent.id)),
69
+ live: true,
70
+ teams: [...teamMemberships.teamsOf(String(agent.id))],
71
+ // #19 D3: the session's exposure tier (linked floor /
72
+ // fleet opt-up) — readable on the state face.
73
+ exposure: exposureStore.tierOf(String(agent.id)),
74
+ ...(canvasTeams.length > 0 ? { canvasTeams } : {}),
75
+ ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
76
+ };
77
+ });
73
78
  // Cold joined sessions: a remembered intent whose Agent is not
74
79
  // back yet (host restarted, session not opened). The row carries
75
80
  // no facts — the Web client merges the session title from its
@@ -96,34 +101,51 @@ export function registerAdminFaces(webServer, d) {
96
101
  joined: true,
97
102
  live: false,
98
103
  teams: [...teamMemberships.teamsOf(id)],
99
- ...(canvasStore.teamsOf(id).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(id)] } : {}),
104
+ // gh#152: same local lens as the live rows (handle-stored counts).
105
+ ...(canvasTeamsOfSession(id).length > 0 ? { canvasTeams: canvasTeamsOfSession(id) } : {}),
100
106
  ...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
101
107
  });
102
108
  }
103
109
  const body = JSON.stringify({
104
110
  nodes: true,
105
111
  version: PLUGIN_VERSION,
112
+ // gh#193: a dark host says so — an empty remote list is not an outage.
113
+ ...(config.isolated ? { isolated: true } : {}),
106
114
  sessions,
107
115
  groups: groupStore.list(),
108
116
  canvas: {
109
117
  teams: canvasStore.list().map(name => ({
110
118
  name,
111
119
  team: canvasTeamOf(name),
112
- members: canvasStore.membersOf(name).map(id => {
113
- // Dual shape: local members report this zone's join/live
114
- // facts; remote refs are opaque team handles — the state
115
- // face passes them through with remote:true for the
116
- // workbench's member chips and pickers.
117
- if (isRemoteMemberRef(id)) {
118
- return { id, team: id, remote: true };
120
+ // gh#152: members as the local tiers read them (gh#131). One
121
+ // of this host's own handles renders as the local session it
122
+ // names — join/live facts instead of an opaque remote chip — and
123
+ // the two stored shapes of one session collapse to ONE row (the
124
+ // store keeps both). Genuinely remote refs pass through with
125
+ // remote:true for the workbench's member chips and pickers.
126
+ members: (() => {
127
+ const rows = [];
128
+ const seenLocal = new Set();
129
+ for (const id of canvasStore.membersOf(name)) {
130
+ if (isRemoteMemberRef(id)) {
131
+ const localId = localSessionOfHandle(id);
132
+ if (localId !== undefined) {
133
+ if (seenLocal.has(localId))
134
+ continue;
135
+ seenLocal.add(localId);
136
+ rows.push({ id: localId, team: `${config.team}/${id8(localId)}`, joined: sessionNodes.has(localId) || joinedSessions.has(localId), live: sessionNodes.has(localId) });
137
+ continue;
138
+ }
139
+ rows.push({ id, team: id, remote: true });
140
+ continue;
141
+ }
142
+ if (seenLocal.has(id))
143
+ continue;
144
+ seenLocal.add(id);
145
+ rows.push({ id, team: `${config.team}/${id8(id)}`, joined: sessionNodes.has(id) || joinedSessions.has(id), live: sessionNodes.has(id) });
119
146
  }
120
- return {
121
- id,
122
- team: `${config.team}/${id8(id)}`,
123
- joined: sessionNodes.has(id) || joinedSessions.has(id),
124
- live: sessionNodes.has(id),
125
- };
126
- }),
147
+ return rows;
148
+ })(),
127
149
  })),
128
150
  },
129
151
  host: lanIp === '' ? {} : { lanIp },
@@ -168,6 +190,10 @@ export function registerAdminFaces(webServer, d) {
168
190
  // say nothing about WHAT is owed — supervision cannot tell
169
191
  // a nudge from an abandon without it.
170
192
  ...(task.brief !== undefined ? { brief: task.brief } : {}),
193
+ // #114 S3: the real sender, so supervision buckets by seat, not "home".
194
+ ...(task.sender !== undefined ? { sender: task.sender } : {}),
195
+ // #114 S2: the authority an inbound row was born with; a pre-S2 inbound row says so (S4).
196
+ ...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}),
171
197
  // C-ruling bucket split: a pending row is settleable while
172
198
  // it is young, expected, and answerable; past the stale
173
199
  // hour it is conversational noise waiting on the dead TTL.
@@ -178,13 +204,18 @@ export function registerAdminFaces(webServer, d) {
178
204
  tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
179
205
  tasksDead: taskLedger.list()
180
206
  .filter(task => task.status === 'dead')
181
- .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}) })),
207
+ .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}), ...(task.sender !== undefined ? { sender: task.sender } : {}), ...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}) })),
182
208
  // gh#31: per-task outcomes for rows that LEFT the ledger — the
183
209
  // chat drawer used to guess (settled, abandoned or cancelled).
184
210
  // Bounded to the archive (≤64), newest first, with the
185
211
  // controlled-vocabulary verdict each row settled by.
186
212
  // gh#31 follow-up: archive() is already newest first (settles
187
213
  // prepend), so the projection keeps that order — the earlier
214
+ // gh#154: the conservation verdict rides the state face — lifetime
215
+ // counters (restart-safe), duplicates checked before the count
216
+ // identity, delta naming any gap. L8 can read this directly instead
217
+ // of cross-round bookkeeping.
218
+ conservation: taskLedger.conservation(),
188
219
  // .reverse() served it oldest first. Each row also says HOW it
189
220
  // ended (endedBy: abandon and cancel both record outcome
190
221
  // 'abandoned', and so does a peer's own 'abandoned' receipt),
@@ -204,8 +235,33 @@ export function registerAdminFaces(webServer, d) {
204
235
  ...(row.lateReceiptAt !== undefined ? { late: true } : {}),
205
236
  ...(row.brief !== undefined ? { brief: row.brief } : {}),
206
237
  ...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
238
+ ...(row.sender !== undefined ? { sender: row.sender } : {}),
239
+ ...(row.auth !== undefined ? { auth: row.auth } : {}),
240
+ // #114 S4: who settled it and how they stood on the chain.
241
+ ...(row.settledBy !== undefined ? { settledBy: row.settledBy } : {}),
207
242
  })),
208
243
  archivedCount: taskLedger.archive().length,
244
+ // #114 S4: settlement authorization. The team verifier holds
245
+ // `refusalsTotal` (monotonic) to zero; `offChain`, `legacy` and
246
+ // `compat` count the bounded archive window (ARCHIVE_CAP rows), so
247
+ // they are a window, not a lifetime total.
248
+ settlement: (() => {
249
+ const archive = taskLedger.archive();
250
+ const roles = archive.flatMap(row => (row.settledBy !== undefined ? [row.settledBy.chain_role] : []));
251
+ return {
252
+ mode: config.settlementAuth,
253
+ offChain: roles.filter(r => r === 'off-chain').length,
254
+ legacy: roles.filter(r => r === 'legacy').length,
255
+ compat: roles.filter(r => r === 'compat').length,
256
+ refusals: taskLedger.settlementRefusals(),
257
+ // Monotonic: every refusal noted, including repeats folded into one ring entry.
258
+ refusalsTotal: taskLedger.settlementRefusalsTotal(),
259
+ };
260
+ })(),
261
+ // #114 S6: allowlist version history and the affected-task entries
262
+ // (observe mode: a record of what an allowlist change put on another
263
+ // footing — nothing was cancelled).
264
+ authorization: authorizationView(),
209
265
  // Ledger density face: the settled archive split by verdict, so
210
266
  // the workbench can say WHICH way those rows settled instead of
211
267
  // just how many. Additive and bounded (outcome vocabulary is a
@@ -230,18 +286,31 @@ export function registerAdminFaces(webServer, d) {
230
286
  // board renders REMOTE team frames from this. Absent until a
231
287
  // linked fetch warmed the card.
232
288
  peerOrg: peerStore.list().map(url => {
233
- const card = d.cardCache.get(url)?.card;
289
+ const entry = d.cardCache.get(url);
290
+ const card = entry?.card;
234
291
  const org = card?.canvasOrg;
292
+ // gh#106: a kept card's org is the peer's last known plan, not a live one.
293
+ const unreachable = card !== undefined && entry?.missAt !== undefined ? { unreachable: true } : {};
235
294
  return Array.isArray(org)
236
- ? { url, org: org.filter((e) => e !== null && typeof e === 'object')
295
+ ? { url, ...unreachable, org: org.filter((e) => e !== null && typeof e === 'object')
237
296
  .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
238
297
  .filter(e => e.team !== '') }
239
- : { url, org: [] };
298
+ : { url, ...unreachable, org: [] };
240
299
  }),
241
300
  // gh#9+gh#10 M1: envelope-absent deliveries during the
242
301
  // OriginClaim window — the operator watches this drain to
243
302
  // zero before closing the window.
244
303
  legacyInbound: d.legacyInboundCount,
304
+ // Accepted unbound (no-audience) OriginClaim envelopes — the
305
+ // drain gauge for closing originClaimUnboundWindow.
306
+ unboundInbound: d.unboundInboundCount,
307
+ // Of those, envelopes whose bound form missed here: the sender
308
+ // dials an origin missing from originClaimAudiences.
309
+ boundMissInbound: d.boundMissInboundCount,
310
+ // gh#113: CLI mailbox loss, all handles — mail the 64-row cap
311
+ // pushed out, and mail cut at 4000 chars. The team verifier
312
+ // holds `dropped` to zero.
313
+ cliMailbox: cliDeposits.mailStats(),
245
314
  // F-observability: the boot prewarm's lifecycle — a dead or
246
315
  // skipped prewarm is the root shape behind "cold rows pile up
247
316
  // after restart" and must be readable, not a console rumor.
@@ -276,6 +345,7 @@ export function registerAdminFaces(webServer, d) {
276
345
  host: String(row.origin ?? ''),
277
346
  ...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
278
347
  ...(row.legacy === true ? { legacy: true } : {}),
348
+ ...(row.unreachable === true ? { unreachable: true } : {}),
279
349
  teams: [...(row.teams ?? [])],
280
350
  remote: true,
281
351
  })),
@@ -341,13 +411,18 @@ export function registerAdminFaces(webServer, d) {
341
411
  handler: controlRoute((req, res) => {
342
412
  readJsonBody(req, res, (body) => {
343
413
  const id = typeof body.id === 'string' ? body.id : '';
414
+ // gh#131: read while the session is still joined — its own handle reads as it only then.
415
+ const ownHandle = id !== '' ? ownCanvasHandleOf(id) : undefined;
344
416
  if (id !== '')
345
417
  sessionNodes.delete(id);
346
418
  joinedSessions.remove(id);
347
419
  // Leaving the network leaves every canvas team too: membership
348
420
  // without join consent would be a routing backdoor. Team
349
- // roster declarations die the same way (S2 consistency).
421
+ // roster declarations die the same way (S2 consistency) — and so
422
+ // does a membership a fleet edit stored by the session's handle.
350
423
  canvasStore.dropMember(id);
424
+ if (ownHandle !== undefined)
425
+ canvasStore.dropMember(ownHandle);
351
426
  teamMemberships.dropSession(id);
352
427
  const payload = JSON.stringify({ id });
353
428
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
@@ -367,7 +442,16 @@ export function registerAdminFaces(webServer, d) {
367
442
  let payload;
368
443
  if (action === 'publish') {
369
444
  const card = typeof body.card === 'string' ? body.card : '';
370
- const out = cliDeposits.deposit(card);
445
+ // Review of PR #135: a CLI may not be named after this host's zone —
446
+ // its handle would read as one of the zone's own faces.
447
+ const name = (() => { try {
448
+ const team = JSON.parse(card).team;
449
+ return typeof team === 'string' ? team.split('/')[0] : '';
450
+ }
451
+ catch {
452
+ return '';
453
+ } })();
454
+ const out = name === config.team ? { ok: false, error: `a CLI identity may not be named after this host's zone "${config.team}" — choose another agent name` } : cliDeposits.deposit(card);
371
455
  payload = JSON.stringify(out.ok
372
456
  ? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${d.ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
373
457
  : { ok: false, error: out.error });
@@ -377,7 +461,10 @@ export function registerAdminFaces(webServer, d) {
377
461
  }
378
462
  else if (action === 'inbox') {
379
463
  const handle = typeof body.handle === 'string' ? body.handle : '';
380
- payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
464
+ // gh#113: seq bounds and the loss counter ride beside the rows; after_seq trims to newer rows.
465
+ const after = body.after_seq;
466
+ const box = cliDeposits.readMailbox(handle, typeof after === 'number' && Number.isInteger(after) && after >= 0 ? after : undefined);
467
+ payload = JSON.stringify({ ok: true, ...box });
381
468
  }
382
469
  else {
383
470
  payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
@@ -405,8 +492,8 @@ export function registerAdminFaces(webServer, d) {
405
492
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
406
493
  res.end(payload);
407
494
  };
408
- if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
409
- send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
495
+ if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member', 'remove'].includes(opV) || (opV !== 'create' && nameV === '')) {
496
+ send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member | remove), and name are required' }));
410
497
  return;
411
498
  }
412
499
  if (!peerStore.list().includes(peerUrl)) {
@@ -415,12 +502,12 @@ export function registerAdminFaces(webServer, d) {
415
502
  }
416
503
  try {
417
504
  const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
418
- const reply = await globalThis.fetch(target, {
505
+ const reply = await d.peerFetch(target, {
419
506
  method: 'POST',
420
- headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
507
+ headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
421
508
  // A local session id means nothing on the peer: send its zone/id8 handle (the workbench
422
509
  // drops a local card into a remote frame with its raw session id, which the peer refused).
423
- body: JSON.stringify({ op: opV, name: nameV, ...(opV !== 'create' ? { id: idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}` : idV } : {}) }),
510
+ body: JSON.stringify({ op: opV, name: nameV, ...(opV === 'add-member' || opV === 'remove-member' ? { id: idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}` : idV } : {}) }),
424
511
  signal: AbortSignal.timeout(15_000),
425
512
  });
426
513
  const raw = await reply.text();
@@ -602,7 +689,10 @@ export function registerAdminFaces(webServer, d) {
602
689
  payload = JSON.stringify({ ok: false, error: 'name and id are required' });
603
690
  }
604
691
  else {
605
- payload = JSON.stringify({ ok: canvasStore.removeMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
692
+ // gh#131: removing a local session removes its handle-stored slot too.
693
+ const ownHandle = isRemoteMemberRef(id) ? undefined : ownCanvasHandleOf(id);
694
+ const removed = [id, ...(ownHandle !== undefined ? [ownHandle] : [])].map(each => canvasStore.removeMember(name, each)).some(Boolean);
695
+ payload = JSON.stringify({ ok: removed, teams: canvasStore.list(), members: canvasStore.membersOf(name) });
606
696
  }
607
697
  }
608
698
  else {
@@ -36,12 +36,21 @@ export function registerCanvasNotify(webServer, d) {
36
36
  // signs it): only a peer that is served the full card ever sees canvasOrg.
37
37
  return card !== undefined && callerLinkedToThisHost(card.team, url, card);
38
38
  });
39
+ // Who was linked at the last flush. A change can end a link (dissolving
40
+ // the only team that names a peer's handle), and that peer must still
41
+ // hear about it — its next card read is what drops the mirror.
42
+ let lastRecipients = [];
39
43
  const flush = () => {
40
44
  timer = undefined;
41
- for (const url of recipients()) {
42
- void globalThis.fetch(url.replace(/\/$/, '') + '/a2a/canvas-changed', {
45
+ const now = recipients();
46
+ const tracked = new Set(peerStore.list()); // an untracked peer gets no more envelopes
47
+ const targets = new Set([...now, ...lastRecipients.filter(url => tracked.has(url))]);
48
+ lastRecipients = now;
49
+ for (const url of targets) {
50
+ const target = url.replace(/\/$/, '') + '/a2a/canvas-changed';
51
+ void d.peerFetch(target, {
43
52
  method: 'POST',
44
- headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
53
+ headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
45
54
  body: '{}',
46
55
  signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
47
56
  }).then(async (reply) => {
@@ -0,0 +1,155 @@
1
+ import { BLOB_FILE_MAX } from "../blob-store.js";
2
+ import { isRootOriginUrl } from "../origin-claim.js";
3
+ import { claimCovers } from "../task-auth.js";
4
+ /** Pulls per proven signer (host zone or CLI handle) in a sliding minute (design §8; the FloodGuard figure). */
5
+ export const BLOB_PULLS_PER_MINUTE = 16;
6
+ /** Distinct signers the pull limiter remembers; a full table refuses new signers rather than forgetting live ones. */
7
+ const PULL_CALLERS_MAX = 1024;
8
+ /**
9
+ * Whether a grant's target lets this proven caller pull (design §3.2). For a HOST signer: the
10
+ * caller's own label; the signer host itself (a grant to the whole zone); one of the signer host's
11
+ * canvas teams (`<signer>/canvas/<name>` — that host answers for its own membership); or a canvas
12
+ * team of THIS host (`<zone>/canvas/<name>`; another host's same-named team is a different team) of
13
+ * which the caller ITSELF is a member. A grant to one session is not another session's, even on the
14
+ * same host (#91 F2: the honest host's tool names the pulling session, so a mistake cannot cross
15
+ * sessions). A CLI handle is an identity, never a zone (review of PR #208 R1): only its own grant.
16
+ * `caller` is already proven by `signer`.
17
+ */
18
+ export function grantAdmits(to, caller, signer, zone, localCanvasMembers, cli = false) {
19
+ if (to === caller)
20
+ return true;
21
+ if (cli)
22
+ return false;
23
+ if (to === signer)
24
+ return true;
25
+ const own = `${signer}/canvas/`;
26
+ if (to.startsWith(own) && to.length > own.length)
27
+ return true;
28
+ const prefix = `${zone}/canvas/`;
29
+ return to.startsWith(prefix) && to.length > prefix.length && localCanvasMembers(to.slice(prefix.length)).includes(caller);
30
+ }
31
+ /**
32
+ * Register the pull face and the stage face on the shared web server.
33
+ * @param d - the captures listed on {@link FileFaceDeps}.
34
+ */
35
+ export function registerFileFaces(webServer, d) {
36
+ const { ctx, config, blobStore, canvasStore, verifyOriginClaim, secretEqual, controlRoute, recordActivity } = d;
37
+ const now = d.now ?? (() => Date.now());
38
+ const pulls = new Map();
39
+ const json = (res, status, body) => {
40
+ const payload = JSON.stringify(body);
41
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
42
+ res.end(payload);
43
+ };
44
+ /** One more pull for a proven signer inside the minute, or false when it is over budget (or the table is full). */
45
+ const takePull = (signer) => {
46
+ const at = now();
47
+ for (const [key, times] of pulls)
48
+ if (times.every(t => at - t >= 60_000))
49
+ pulls.delete(key);
50
+ const recent = (pulls.get(signer) ?? []).filter(t => at - t < 60_000);
51
+ if (recent.length >= BLOB_PULLS_PER_MINUTE)
52
+ return false;
53
+ if (!pulls.has(signer) && pulls.size >= PULL_CALLERS_MAX)
54
+ return false;
55
+ pulls.set(signer, [...recent, at]);
56
+ return true;
57
+ };
58
+ ctx.effect(() => webServer.register({
59
+ kind: 'exact',
60
+ path: '/a2a/blob',
61
+ handler: async (req, res) => {
62
+ if (req.method !== 'GET') {
63
+ json(res, 405, { ok: false, error: 'GET is the only method on this face' });
64
+ return;
65
+ }
66
+ if (config.apiKey !== '' && !secretEqual(String(req.headers['x-api-key'] ?? ''), config.apiKey)) {
67
+ json(res, 401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)' });
68
+ return;
69
+ }
70
+ const url = new URL(req.url ?? '/', 'http://x');
71
+ const sha256 = url.searchParams.get('sha256') ?? '';
72
+ const caller = url.searchParams.get('caller_session') ?? '';
73
+ const refuse = (status, code, error) => {
74
+ recordActivity('in', 'blob', caller === '' ? 'anonymous' : caller, false);
75
+ json(res, status, { ok: false, code, error });
76
+ };
77
+ if (caller === '') {
78
+ refuse(403, 'untrusted-origin', 'caller_session is required');
79
+ return;
80
+ }
81
+ const claim = await verifyOriginClaim(req, { cli: true });
82
+ if (!claim.ok) {
83
+ refuse(403, 'untrusted-origin', `OriginClaim verification failed (${claim.reason}) — a file is pulled only with a signed envelope`);
84
+ return;
85
+ }
86
+ // A deposited CLI handle proves exactly itself (R1: `H/child` is not H); a host proves its zone.
87
+ const proven = claim.cli === true
88
+ ? caller === claim.caller
89
+ : claim.bound === true && isRootOriginUrl(claim.callerUrl) && claimCovers(caller, claim.caller);
90
+ if (!proven) {
91
+ refuse(403, 'untrusted-origin', 'the envelope does not prove this caller (it must be the audience-bound form, signed from a root card URL whose zone covers caller_session)');
92
+ return;
93
+ }
94
+ const grant = blobStore.list().find(g => g.sha256 === sha256 && grantAdmits(g.to, caller, claim.caller, config.team, name => canvasStore.membersOf(name), claim.cli === true));
95
+ // One answer for "no such blob", "expired" and "not yours": a refusal is no existence oracle.
96
+ if (grant === undefined) {
97
+ refuse(403, 'not-granted', 'no live grant lets this caller pull that file');
98
+ return;
99
+ }
100
+ // Budgeted by the PROVEN signer (a host zone or a CLI handle), never the self-described label (R1).
101
+ if (!takePull(claim.caller)) {
102
+ refuse(429, 'rate-limited', `at most ${String(BLOB_PULLS_PER_MINUTE)} pulls per signer per minute`);
103
+ return;
104
+ }
105
+ const bytes = blobStore.read(grant);
106
+ if (bytes === undefined) {
107
+ refuse(410, 'gone', 'the file is no longer stored on this host');
108
+ return;
109
+ }
110
+ recordActivity('in', 'blob', caller, true);
111
+ res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': bytes.byteLength, 'X-A2A-Blob-Sha256': grant.sha256, 'X-A2A-Blob-Name': encodeURIComponent(grant.name) });
112
+ res.end(bytes);
113
+ },
114
+ }), 'a2a: file-channel pull face');
115
+ ctx.effect(() => webServer.register({
116
+ kind: 'exact',
117
+ path: '/__dsh_a2a/blob',
118
+ handler: controlRoute((req, res) => {
119
+ if (req.method !== 'POST') {
120
+ json(res, 405, { ok: false, error: 'POST raw bytes with ?to=<team>&name=<file>' });
121
+ return;
122
+ }
123
+ const url = new URL(req.url ?? '/', 'http://x');
124
+ const to = url.searchParams.get('to') ?? '';
125
+ const name = url.searchParams.get('name') ?? 'file';
126
+ const by = url.searchParams.get('by') ?? 'control';
127
+ const declared = Number(req.headers['content-length'] ?? NaN);
128
+ if (Number.isFinite(declared) && declared > BLOB_FILE_MAX) {
129
+ json(res, 413, { ok: false, code: 'too-large', error: `the file channel takes at most ${String(BLOB_FILE_MAX)} bytes` });
130
+ req.resume();
131
+ return;
132
+ }
133
+ const chunks = [];
134
+ let size = 0;
135
+ let over = false;
136
+ req.on('data', (chunk) => {
137
+ size += chunk.byteLength;
138
+ if (size > BLOB_FILE_MAX) {
139
+ over = true;
140
+ return;
141
+ }
142
+ if (!over)
143
+ chunks.push(chunk);
144
+ });
145
+ req.on('end', () => {
146
+ if (over) {
147
+ json(res, 413, { ok: false, code: 'too-large', error: `the file channel takes at most ${String(BLOB_FILE_MAX)} bytes` });
148
+ return;
149
+ }
150
+ const staged = blobStore.stage(Buffer.concat(chunks), { name, to, by });
151
+ json(res, staged.ok ? 200 : staged.code === 'too-large' ? 413 : staged.code === 'full' ? 507 : 400, { ...staged });
152
+ });
153
+ }),
154
+ }), 'a2a: file-channel stage face');
155
+ }