@nelsonlongxiang/dsh-open-a2a-net 0.6.42 → 0.7.0

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 (79) hide show
  1. package/README.md +1 -0
  2. package/README_zh.md +1 -0
  3. package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/cli/a2a.mjs +43 -7
  6. package/lib/a2a-client.js +10 -2
  7. package/lib/admitted-bookkeeping.js +7 -0
  8. package/lib/canvas-discovery.js +11 -1
  9. package/lib/canvas-member.js +43 -0
  10. package/lib/canvas-store.js +315 -17
  11. package/lib/client.js +62 -72
  12. package/lib/client.js.map +1 -1
  13. package/lib/config.js +1 -0
  14. package/lib/direct-forward.js +9 -7
  15. package/lib/faces/admin.js +150 -67
  16. package/lib/faces/files.js +2 -1
  17. package/lib/faces/wire.js +569 -469
  18. package/lib/followup-delivery.js +89 -0
  19. package/lib/idempotency-store.js +8 -0
  20. package/lib/inbound-verification.js +23 -0
  21. package/lib/index.js +534 -166
  22. package/lib/nudge-observability.js +34 -0
  23. package/lib/origin-claim.js +36 -9
  24. package/lib/outbound-scope.js +4 -2
  25. package/lib/peer-directory.js +15 -5
  26. package/lib/peer-store.js +31 -17
  27. package/lib/queue-guard.js +4 -3
  28. package/lib/receipt-ladder.js +1 -1
  29. package/lib/receipt-retry.js +58 -0
  30. package/lib/reconciliation-reminders.js +148 -0
  31. package/lib/reconciliation-state.js +27 -0
  32. package/lib/session-transcript.js +46 -18
  33. package/lib/skill/SKILL.md +5 -0
  34. package/lib/skill/references/cli.md +4 -1
  35. package/lib/skill/references/routing.md +31 -1
  36. package/lib/task-auth.js +3 -3
  37. package/lib/task-delivery.js +23 -0
  38. package/lib/task-ledger-persistence.js +51 -0
  39. package/lib/task-ledger-rows.js +5 -0
  40. package/lib/task-ledger.js +219 -20
  41. package/lib/tools/info.js +37 -7
  42. package/lib/types/a2a-client.d.ts +1 -1
  43. package/lib/types/admitted-bookkeeping.d.ts +5 -0
  44. package/lib/types/canvas-member.d.ts +10 -0
  45. package/lib/types/canvas-store.d.ts +56 -1
  46. package/lib/types/config.d.ts +2 -0
  47. package/lib/types/faces/admin.d.ts +21 -0
  48. package/lib/types/faces/files.d.ts +1 -1
  49. package/lib/types/faces/wire.d.ts +21 -14
  50. package/lib/types/followup-delivery.d.ts +25 -0
  51. package/lib/types/idempotency-store.d.ts +2 -0
  52. package/lib/types/inbound-verification.d.ts +14 -0
  53. package/lib/types/index.d.ts +5 -0
  54. package/lib/types/nudge-observability.d.ts +17 -0
  55. package/lib/types/origin-claim.d.ts +7 -1
  56. package/lib/types/outbound-scope.d.ts +1 -0
  57. package/lib/types/peer-store.d.ts +4 -0
  58. package/lib/types/queue-guard.d.ts +9 -2
  59. package/lib/types/receipt-ladder.d.ts +2 -0
  60. package/lib/types/receipt-retry.d.ts +22 -0
  61. package/lib/types/reconciliation-reminders.d.ts +41 -0
  62. package/lib/types/reconciliation-state.d.ts +32 -0
  63. package/lib/types/session-transcript.d.ts +6 -1
  64. package/lib/types/task-auth.d.ts +7 -0
  65. package/lib/types/task-delivery.d.ts +17 -0
  66. package/lib/types/task-ledger-persistence.d.ts +3 -0
  67. package/lib/types/task-ledger-rows.d.ts +19 -2
  68. package/lib/types/task-ledger.d.ts +48 -1
  69. package/lib/types/tools/info.d.ts +3 -0
  70. package/lib/types/types.d.ts +3 -0
  71. package/nexus-stage/src/federation.ts +1 -0
  72. package/nexus-stage/src/main.ts +17 -2
  73. package/nexus-stage/src/member-provenance-picker.ts +13 -0
  74. package/nexus-stage/src/planning-member-handles.ts +4 -1
  75. package/nexus-stage/src/planning-view.ts +3 -3
  76. package/nexus-stage/src/workbench.ts +51 -7
  77. package/nexus-stage/src/world.ts +1 -0
  78. package/package.json +3 -2
  79. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
@@ -18,7 +18,8 @@
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
- import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
21
+ import { reminderView } from "../reconciliation-state.js";
22
+ import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef, legacyBareMember } from "../canvas-store.js";
22
23
  import { qualifiedCanvasId } from "../canvas-id.js";
23
24
  import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
24
25
  import { CLI_HANDLE_RE } from "../cli-deposit.js";
@@ -76,6 +77,7 @@ export function registerAdminFaces(webServer, d) {
76
77
  id: String(agent.id),
77
78
  label: sessionLabelOf(agent),
78
79
  team: sessionTeamOf(agent),
80
+ memberRef: `${sessionTeamOf(agent)}@${d.ownCanvasFingerprint}`,
79
81
  ...nodeMetadataOf(agent),
80
82
  joined: sessionNodes.has(String(agent.id)),
81
83
  live: true,
@@ -115,6 +117,7 @@ export function registerAdminFaces(webServer, d) {
115
117
  label: `${session}-${id8(id)}`,
116
118
  ...(d.ownerSeatNames[id] !== undefined ? { name: d.ownerSeatNames[id], seatName: d.ownerSeatNames[id] } : {}),
117
119
  team: `${config.team}/${id8(id)}`,
120
+ memberRef: `${config.team}/${id8(id)}@${d.ownCanvasFingerprint}`,
118
121
  joined: true,
119
122
  live: false,
120
123
  teams: [...teamMemberships.teamsOf(id)],
@@ -133,6 +136,9 @@ export function registerAdminFaces(webServer, d) {
133
136
  sessions,
134
137
  groups: groupStore.list(),
135
138
  canvas: {
139
+ ...d.canvasMigrationState(),
140
+ // Raw retained slots are an inspection view, never the effective team member list.
141
+ retainedMembers: canvasStore.list().flatMap(name => canvasStore.rawMembersOf(name).filter(id => !canvasStore.membersOf(name).includes(id)).map(id => ({ team: name, member: id, suspended: canvasStore.isSuspended(name, id), records: canvasStore.suspensionOf(name, id) }))),
136
142
  teams: canvasStore.list().map(name => ({
137
143
  name,
138
144
  team: canvasTeamOf(name),
@@ -148,16 +154,17 @@ export function registerAdminFaces(webServer, d) {
148
154
  const rows = [];
149
155
  const seenLocal = new Set();
150
156
  for (const id of canvasStore.membersOf(name)) {
157
+ const legacy = legacyBareMember(id);
151
158
  if (isRemoteMemberRef(id)) {
152
159
  const localId = localSessionOfHandle(id);
153
160
  if (localId !== undefined) {
154
161
  if (seenLocal.has(localId))
155
162
  continue;
156
163
  seenLocal.add(localId);
157
- rows.push({ id: localId, team: `${config.team}/${id8(localId)}`, joined: sessionNodes.has(localId) || joinedSessions.has(localId), live: sessionNodes.has(localId) });
164
+ rows.push({ ...(legacy ? { provenance: 'legacy-inferred' } : {}), id: localId, team: `${config.team}/${id8(localId)}`, joined: sessionNodes.has(localId) || joinedSessions.has(localId), live: sessionNodes.has(localId) });
158
165
  continue;
159
166
  }
160
- rows.push({ id, team: id, remote: true, ...(CLI_HANDLE_RE.test(id) ? { cliHome: canvasStore.cliHomeOf(id) ?? null, cliHeld: cliDeposits.directoryRows().some(row => row.team === id), ...(canvasStore.wasCliHomeCleared(id) ? { cliHomeCleared: true } : {}) } : {}) });
167
+ rows.push({ ...(legacy ? { provenance: 'unknown' } : {}), id, team: id, remote: true, ...(CLI_HANDLE_RE.test(id) ? { cliHome: canvasStore.cliHomeOf(id) ?? null, cliHeld: cliDeposits.directoryRows().some(row => row.team === id), ...(canvasStore.wasCliHomeCleared(id) ? { cliHomeCleared: true } : {}) } : {}) });
161
168
  continue;
162
169
  }
163
170
  if (seenLocal.has(id))
@@ -169,7 +176,8 @@ export function registerAdminFaces(webServer, d) {
169
176
  })(),
170
177
  })),
171
178
  },
172
- host: lanIp === '' ? {} : { lanIp },
179
+ // #332: home-controlled aliases, never inferred from peer cards or a file reference.
180
+ host: { ...(lanIp !== '' ? { lanIp } : {}), originClaimAudiences: [...config.originClaimAudiences ?? []] },
173
181
  // gh#20 W-C (host rail): each peer row also carries its display
174
182
  // name and version from the warm card (absent until a fetch),
175
183
  // and the last-contact timestamp for the health lamp. W-A's
@@ -189,7 +197,7 @@ export function registerAdminFaces(webServer, d) {
189
197
  };
190
198
  }),
191
199
  remote: (refreshRemoteRows(), (d.remoteRowsCache?.rows ?? []).map(row => {
192
- const canvasTeams = canvasStore.teamsOf(row.team);
200
+ const canvasTeams = [...new Set([...canvasStore.teamsOf(row.team), ...(row.memberRef ? canvasStore.teamsOf(row.memberRef) : [])])];
193
201
  return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
194
202
  })),
195
203
  activity: recentActivity.slice(),
@@ -202,12 +210,19 @@ export function registerAdminFaces(webServer, d) {
202
210
  // in-flight ring cannot show. Dead-lettered rows ride their own
203
211
  // list and settled ones just a count, all additive so the panel
204
212
  // keeps rendering plain owed rows until it learns the tiers.
213
+ ledgerPersistence: taskLedger.persistenceState(),
214
+ deliveryBookkeeping: taskLedger.deliveryBookkeepingState(),
215
+ nudgeEscalations: d.nudgeEscalations?.() ?? { queued: 0, unavailable: 0, failed: 0 },
216
+ reconciliationReminders: d.reminderDiagnostics?.(),
205
217
  tasks: taskLedger.list()
206
218
  .filter(task => task.status === 'pending')
207
219
  .map(task => ({
220
+ ...reminderView(taskLedger.reminderState(task.taskId)),
208
221
  taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
209
222
  direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
210
223
  ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}),
224
+ ...(task.receiptDelivery ? { receiptDelivery: task.receiptDelivery } : {}),
225
+ ...(task.consumed !== undefined ? { consumed: task.consumed, deliveryObservation: task.consumed ? 'consumed' : 'delivered-unconsumed' } : {}),
211
226
  ...(task.forwarded === true ? { forwarded: true } : {}),
212
227
  ...(task.deliveredTo !== undefined ? { deliveredTo: task.deliveredTo } : {}),
213
228
  ...(task.receiverMembers !== undefined ? { receiverMembers: task.receiverMembers } : {}),
@@ -229,7 +244,8 @@ export function registerAdminFaces(webServer, d) {
229
244
  tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
230
245
  tasksDead: taskLedger.list()
231
246
  .filter(task => task.status === 'dead')
232
- .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}), ...(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 } : {}) })),
247
+ .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}),
248
+ ...(task.receiptDelivery ? { receiptDelivery: task.receiptDelivery } : {}), ...(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 } : {}) })),
233
249
  // gh#31: per-task outcomes for rows that LEFT the ledger — the
234
250
  // chat drawer used to guess (settled, abandoned or cancelled).
235
251
  // Bounded to the archive (≤64), newest first, with the
@@ -248,6 +264,7 @@ export function registerAdminFaces(webServer, d) {
248
264
  // its direction when known.
249
265
  archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
250
266
  taskId: row.taskId,
267
+ ...(row.withdrawalResult ? { withdrawalResult: row.withdrawalResult } : {}),
251
268
  team: row.team,
252
269
  peer: row.peer,
253
270
  startedAt: row.startedAt,
@@ -340,6 +357,9 @@ export function registerAdminFaces(webServer, d) {
340
357
  boundMissInbound: d.boundMissInboundCount,
341
358
  // Who missed, by verified caller and the origin it dialled (newest first, ≤16).
342
359
  boundMissRecent: d.boundMissRecent,
360
+ // Present-but-unverifiable envelopes by claimed caller and reason (bad-ts = sender clock skew > ±5 min).
361
+ claimRejectsInbound: d.claimRejectsInboundCount,
362
+ claimRejectsRecent: d.claimRejectsRecent,
343
363
  // gh#113: CLI mailbox loss, all handles — mail the 64-row cap
344
364
  // pushed out, and mail cut at 4000 chars. The team verifier
345
365
  // holds `dropped` to zero.
@@ -445,7 +465,16 @@ export function registerAdminFaces(webServer, d) {
445
465
  readJsonBody(req, res, (body) => {
446
466
  const id = typeof body.id === 'string' ? body.id : '';
447
467
  // gh#131: read while the session is still joined — its own handle reads as it only then.
448
- const ownHandle = id !== '' ? ownCanvasHandleOf(id) : undefined;
468
+ try {
469
+ if (id !== '')
470
+ d.revokeCanvasMember(id, 'leave');
471
+ }
472
+ catch (error) {
473
+ const payload = JSON.stringify({ ok: false, error: String(error) });
474
+ res.writeHead(503, { 'Content-Type': 'application/json' });
475
+ res.end(payload);
476
+ return;
477
+ }
449
478
  if (id !== '')
450
479
  sessionNodes.delete(id);
451
480
  joinedSessions.remove(id);
@@ -453,9 +482,6 @@ export function registerAdminFaces(webServer, d) {
453
482
  // without join consent would be a routing backdoor. Team
454
483
  // roster declarations die the same way (S2 consistency) — and so
455
484
  // does a membership a fleet edit stored by the session's handle.
456
- canvasStore.dropMember(id);
457
- if (ownHandle !== undefined)
458
- canvasStore.dropMember(ownHandle);
459
485
  teamMemberships.dropSession(id);
460
486
  const payload = JSON.stringify({ id });
461
487
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
@@ -517,7 +543,7 @@ export function registerAdminFaces(webServer, d) {
517
543
  path: '/__dsh_a2a/fleet-canvas-op',
518
544
  handler: controlRoute((req, res) => {
519
545
  readJsonBody(req, res, async (body) => {
520
- const { peer, op, name, id } = body;
546
+ const { peer, op, name, id, memberPeer } = body;
521
547
  const peerUrl = typeof peer === 'string' ? peer : '';
522
548
  const opV = typeof op === 'string' ? op : '';
523
549
  const nameV = typeof name === 'string' ? name : '';
@@ -536,7 +562,13 @@ export function registerAdminFaces(webServer, d) {
536
562
  }
537
563
  try {
538
564
  const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
539
- const 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 } : {}) });
565
+ const proof = opV === 'add-member' ? await d.canvasMemberForWrite(idV, memberPeer) : undefined;
566
+ if (proof && !proof.ok) {
567
+ send(JSON.stringify(proof));
568
+ return;
569
+ }
570
+ const selectedId = proof?.ok ? (proof.member.includes('/') ? proof.member : `${config.team}/${id8(proof.member)}@${d.ownCanvasFingerprint}`) : idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}@${d.ownCanvasFingerprint}` : idV;
571
+ const body = JSON.stringify({ op: opV, name: nameV, ...(opV === 'add-member' || opV === 'remove-member' ? { id: selectedId } : {}) });
540
572
  const reply = await d.peerFetch(target, {
541
573
  method: 'POST',
542
574
  headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body }) },
@@ -545,6 +577,12 @@ export function registerAdminFaces(webServer, d) {
545
577
  body,
546
578
  signal: AbortSignal.timeout(15_000),
547
579
  });
580
+ // #320: a JSON body cannot turn a failed/redirected HTTP operation into success.
581
+ if (!reply.ok || reply.status < 200 || reply.status >= 300) {
582
+ void reply.body?.cancel().catch(() => { });
583
+ send(JSON.stringify({ ok: false, status: reply.status, error: `peer ${peerUrl} answered HTTP ${String(reply.status)} — fleet canvas operation failed` }));
584
+ return;
585
+ }
548
586
  const raw = await reply.text();
549
587
  try {
550
588
  const out = JSON.parse(raw);
@@ -738,72 +776,117 @@ export function registerAdminFaces(webServer, d) {
738
776
  readJsonBody(req, res, async (body) => {
739
777
  const action = body.action;
740
778
  let payload;
741
- if (action === 'cli-home-options') {
742
- const name = typeof body.name === 'string' ? body.name : '';
743
- const id = typeof body.id === 'string' ? body.id : '';
744
- if (!CLI_HANDLE_RE.test(id) || !canvasStore.membersOf(name).includes(id))
745
- payload = JSON.stringify({ ok: false, error: 'an existing canvas CLI member is required' });
746
- else {
747
- try {
748
- payload = JSON.stringify({ ok: true, ...await d.cliHomeOptions(id) });
749
- }
750
- catch {
751
- payload = JSON.stringify({ ok: false, error: 'fresh home card lookup failed' });
779
+ try {
780
+ if (action === 'confirm-member') {
781
+ const name = typeof body.name === 'string' ? body.name : '', id = typeof body.id === 'string' ? body.id : '';
782
+ const request = body;
783
+ const replacement = typeof request.replacement === 'string' ? request.replacement : '';
784
+ const originalHandle = id.replace(/@[0-9a-f]{8}$/, '');
785
+ const localChoice = replacement !== '' && !replacement.includes('/');
786
+ const proof = localChoice && originalHandle !== `${config.team}/${id8(replacement)}` ? { ok: false, error: 'local confirmation must match this host zone and the original handle' } : await d.canvasMemberForWrite(replacement, request.peer);
787
+ const out = proof.ok ? canvasStore.confirmMember(name, id, proof.member, proof.ownerKey, request.revision, request.expectedOwner) : proof;
788
+ logger.info('canvas provenance owner confirmation actor=owner-control at=%s team=%s old=%s new=%s ownerKey=%s ok=%s', new Date().toISOString(), name, id, proof.ok ? proof.member : replacement, proof.ok ? proof.ownerKey ?? 'local-id' : 'unconfirmed', out.ok);
789
+ payload = JSON.stringify({ ...out, ...canvasStore.state() });
790
+ }
791
+ else if (action === 'restore-suspended' || action === 'delete-suspended') {
792
+ const request = body;
793
+ const name = typeof request.name === 'string' ? request.name : '', id = typeof request.id === 'string' ? request.id : '';
794
+ const out = canvasStore.regulateSuspended(action === 'restore-suspended' ? 'restore' : 'delete', name, id, request.revision, request.expected);
795
+ logger.info('canvas suspended owner operation %s %s %s: %s', action, name, id, out.ok);
796
+ payload = JSON.stringify({ ...out, ...canvasStore.state() });
797
+ }
798
+ else if (action === 'cli-home-options') {
799
+ const name = typeof body.name === 'string' ? body.name : '';
800
+ const id = typeof body.id === 'string' ? body.id : '';
801
+ if (!CLI_HANDLE_RE.test(id) || !canvasStore.membersOf(name).includes(id))
802
+ payload = JSON.stringify({ ok: false, error: 'an existing canvas CLI member is required' });
803
+ else {
804
+ try {
805
+ payload = JSON.stringify({ ok: true, ...await d.cliHomeOptions(id) });
806
+ }
807
+ catch {
808
+ payload = JSON.stringify({ ok: false, error: 'fresh home card lookup failed' });
809
+ }
752
810
  }
753
811
  }
754
- }
755
- else if (action === 'create') {
756
- const name = typeof body.name === 'string' ? body.name : '';
757
- const stored = canvasNameReserved(name) ? undefined : canvasStore.create(name);
758
- payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid, roster-reserved name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
759
- }
760
- else if (action === 'remove') {
761
- const name = typeof body.name === 'string' ? body.name : '';
762
- payload = JSON.stringify({ ok: canvasStore.remove(name), teams: canvasStore.list() });
763
- }
764
- else if (action === 'add-member') {
765
- const name = typeof body.name === 'string' ? body.name : '';
766
- const id = typeof body.id === 'string' ? body.id : '';
767
- // Dual member shape: a local session id must be a joined node;
768
- // a remote ref is a zone-qualified team handle whose shape this
769
- // gate checks and whose existence the UI picker (directory
770
- // rows) vouches for. Humans compose both via the workbench.
771
- const memberValid = isRemoteMemberRef(id)
772
- ? validRemoteMemberRef(id)
773
- : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
774
- if (!canvasStore.hasTeam(name) && canvasNameReserved(name)) {
775
- payload = JSON.stringify({ ok: false, error: 'roster reserves this canvas name' });
812
+ else if (action === 'create') {
813
+ const name = typeof body.name === 'string' ? body.name : '';
814
+ const stored = canvasNameReserved(name) ? undefined : canvasStore.create(name);
815
+ payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid, roster-reserved name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
776
816
  }
777
- else if (name === '' || !memberValid) {
778
- payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
817
+ else if (action === 'remove') {
818
+ const name = typeof body.name === 'string' ? body.name : '';
819
+ payload = JSON.stringify({ ok: canvasStore.remove(name), teams: canvasStore.list() });
779
820
  }
780
- else {
781
- const pin = await d.cliHomeForAdd(id, body.home).catch(() => ({ ok: false, error: 'linked home card lookup failed' }));
782
- if (!pin.ok)
783
- payload = JSON.stringify({ ok: false, error: pin.error });
821
+ else if (action === 'add-member') {
822
+ const name = typeof body.name === 'string' ? body.name : '';
823
+ const id = typeof body.id === 'string' ? body.id : '';
824
+ // Dual member shape: a local session id must be a joined node;
825
+ // a remote ref is a zone-qualified team handle whose shape this
826
+ // gate checks and whose existence the UI picker (directory
827
+ // rows) vouches for. Humans compose both via the workbench.
828
+ const memberValid = isRemoteMemberRef(id)
829
+ ? validRemoteMemberRef(id)
830
+ : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
831
+ if (!canvasStore.hasTeam(name) && canvasNameReserved(name)) {
832
+ payload = JSON.stringify({ ok: false, error: 'roster reserves this canvas name' });
833
+ }
834
+ else if (name === '' || !memberValid) {
835
+ payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
836
+ }
837
+ else if (legacyBareMember(id) && canvasStore.rawMembersOf(name).includes(id)) {
838
+ // Re-adding a retained legacy slot is a no-op, never provenance confirmation or restore.
839
+ payload = JSON.stringify({ ok: true, members: canvasStore.membersOf(name), provenance: 'unknown' });
840
+ }
784
841
  else {
785
- canvasStore.create(name);
786
- const explicitHome = body.home !== undefined;
787
- const home = explicitHome ? pin.home : canvasStore.cliHomeOf(id) ?? pin.home;
788
- payload = JSON.stringify({ ok: canvasStore.addMember(name, id, home, { replaceHome: explicitHome }), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
842
+ const proof = await d.canvasMemberForWrite(id, body.peer);
843
+ if (!proof.ok) {
844
+ payload = JSON.stringify(proof);
845
+ res.writeHead(200, { 'Content-Type': 'application/json' });
846
+ res.end(payload);
847
+ return;
848
+ }
849
+ const pin = await d.cliHomeForAdd(proof.member, body.home).catch(() => ({ ok: false, error: 'linked home card lookup failed' }));
850
+ if (!pin.ok)
851
+ payload = JSON.stringify({ ok: false, error: pin.error });
852
+ else {
853
+ canvasStore.create(name);
854
+ const explicitHome = body.home !== undefined;
855
+ const home = explicitHome ? pin.home : canvasStore.cliHomeOf(id) ?? pin.home;
856
+ payload = JSON.stringify({ ok: canvasStore.addMember(name, proof.member, home, { replaceHome: explicitHome, ownerKey: proof.ownerKey }), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
857
+ }
789
858
  }
790
859
  }
791
- }
792
- else if (action === 'remove-member') {
793
- const name = typeof body.name === 'string' ? body.name : '';
794
- const id = typeof body.id === 'string' ? body.id : '';
795
- if (name === '' || id === '') {
796
- payload = JSON.stringify({ ok: false, error: 'name and id are required' });
860
+ else if (action === 'remove-member') {
861
+ const name = typeof body.name === 'string' ? body.name : '';
862
+ const id = typeof body.id === 'string' ? body.id : '';
863
+ if (name === '' || id === '') {
864
+ payload = JSON.stringify({ ok: false, error: 'name and id are required' });
865
+ }
866
+ else {
867
+ // gh#131: removing a local session removes its handle-stored slot too.
868
+ let removed = false;
869
+ try {
870
+ removed = isRemoteMemberRef(id) ? (!canvasStore.isSuspended(name, id) && canvasStore.removeMember(name, id)) : d.revokeCanvasMember(id, 'remove', name);
871
+ }
872
+ catch (error) {
873
+ payload = JSON.stringify({ ok: false, error: String(error) });
874
+ res.writeHead(503, { 'Content-Type': 'application/json' });
875
+ res.end(payload);
876
+ return;
877
+ }
878
+ payload = JSON.stringify({ ok: removed, teams: canvasStore.list(), members: canvasStore.membersOf(name) });
879
+ }
797
880
  }
798
881
  else {
799
- // gh#131: removing a local session removes its handle-stored slot too.
800
- const ownHandle = isRemoteMemberRef(id) ? undefined : ownCanvasHandleOf(id);
801
- const removed = [id, ...(ownHandle !== undefined ? [ownHandle] : [])].map(each => canvasStore.removeMember(name, each)).some(Boolean);
802
- payload = JSON.stringify({ ok: removed, teams: canvasStore.list(), members: canvasStore.membersOf(name) });
882
+ payload = JSON.stringify({ ok: false, error: 'unknown action' });
803
883
  }
804
884
  }
805
- else {
806
- payload = JSON.stringify({ ok: false, error: 'unknown action' });
885
+ catch (error) {
886
+ const failure = JSON.stringify({ ok: false, error: String(error) });
887
+ res.writeHead(503, { 'Content-Type': 'application/json' });
888
+ res.end(failure);
889
+ return;
807
890
  }
808
891
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
809
892
  res.end(payload);
@@ -1,6 +1,7 @@
1
1
  import { BLOB_FILE_MAX } from "../blob-store.js";
2
2
  import { isRootOriginUrl } from "../origin-claim.js";
3
3
  import { claimCovers } from "../task-auth.js";
4
+ import { splitQualifiedMember } from "../canvas-member.js";
4
5
  /** Pulls per proven signer (host zone or CLI handle) in a sliding minute (design §8; the FloodGuard figure). */
5
6
  export const BLOB_PULLS_PER_MINUTE = 16;
6
7
  /** Distinct signers the pull limiter remembers; a full table refuses new signers rather than forgetting live ones. */
@@ -91,7 +92,7 @@ export function registerFileFaces(webServer, d) {
91
92
  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
93
  return;
93
94
  }
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
+ const grant = blobStore.list().find(g => g.sha256 === sha256 && grantAdmits(g.to, caller, claim.caller, config.team, name => canvasStore.membersOf(name).flatMap(ref => { const member = splitQualifiedMember(ref); return member ? (canvasStore.ownerKeyOf?.(ref) === claim.card.publicKey ? [member.handle] : []) : [ref]; }), claim.cli === true));
95
96
  // One answer for "no such blob", "expired" and "not yours": a refusal is no existence oracle.
96
97
  if (grant === undefined) {
97
98
  refuse(403, 'not-granted', 'no live grant lets this caller pull that file');