@nelsonlongxiang/dsh-open-a2a-net 0.6.43 → 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.
- package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/cli/a2a.mjs +19 -3
- package/lib/a2a-client.js +10 -2
- package/lib/admitted-bookkeeping.js +7 -0
- package/lib/canvas-discovery.js +11 -1
- package/lib/canvas-member.js +43 -0
- package/lib/canvas-store.js +70 -4
- package/lib/direct-forward.js +9 -7
- package/lib/faces/admin.js +50 -7
- package/lib/faces/files.js +2 -1
- package/lib/faces/wire.js +566 -468
- package/lib/followup-delivery.js +89 -0
- package/lib/idempotency-store.js +8 -0
- package/lib/inbound-verification.js +23 -0
- package/lib/index.js +478 -156
- package/lib/nudge-observability.js +34 -0
- package/lib/origin-claim.js +36 -9
- package/lib/outbound-scope.js +4 -2
- package/lib/peer-directory.js +15 -5
- package/lib/peer-store.js +31 -17
- package/lib/queue-guard.js +4 -3
- package/lib/receipt-ladder.js +1 -1
- package/lib/receipt-retry.js +58 -0
- package/lib/reconciliation-reminders.js +148 -0
- package/lib/reconciliation-state.js +27 -0
- package/lib/session-transcript.js +46 -18
- package/lib/skill/SKILL.md +5 -0
- package/lib/skill/references/cli.md +4 -1
- package/lib/skill/references/routing.md +31 -1
- package/lib/task-auth.js +3 -3
- package/lib/task-delivery.js +23 -0
- package/lib/task-ledger-persistence.js +51 -0
- package/lib/task-ledger-rows.js +5 -0
- package/lib/task-ledger.js +219 -20
- package/lib/tools/info.js +37 -7
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/admitted-bookkeeping.d.ts +5 -0
- package/lib/types/canvas-member.d.ts +10 -0
- package/lib/types/canvas-store.d.ts +8 -0
- package/lib/types/faces/admin.d.ts +19 -0
- package/lib/types/faces/files.d.ts +1 -1
- package/lib/types/faces/wire.d.ts +21 -14
- package/lib/types/followup-delivery.d.ts +25 -0
- package/lib/types/idempotency-store.d.ts +2 -0
- package/lib/types/inbound-verification.d.ts +14 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/nudge-observability.d.ts +17 -0
- package/lib/types/origin-claim.d.ts +7 -1
- package/lib/types/outbound-scope.d.ts +1 -0
- package/lib/types/peer-store.d.ts +4 -0
- package/lib/types/queue-guard.d.ts +9 -2
- package/lib/types/receipt-ladder.d.ts +2 -0
- package/lib/types/receipt-retry.d.ts +22 -0
- package/lib/types/reconciliation-reminders.d.ts +41 -0
- package/lib/types/reconciliation-state.d.ts +32 -0
- package/lib/types/session-transcript.d.ts +6 -1
- package/lib/types/task-auth.d.ts +7 -0
- package/lib/types/task-delivery.d.ts +17 -0
- package/lib/types/task-ledger-persistence.d.ts +3 -0
- package/lib/types/task-ledger-rows.d.ts +19 -2
- package/lib/types/task-ledger.d.ts +48 -1
- package/lib/types/tools/info.d.ts +3 -0
- package/lib/types/types.d.ts +3 -0
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +10 -2
- package/nexus-stage/src/member-provenance-picker.ts +13 -0
- package/nexus-stage/src/planning-member-handles.ts +4 -1
- package/nexus-stage/src/planning-view.ts +3 -3
- package/nexus-stage/src/workbench.ts +28 -6
- package/nexus-stage/src/world.ts +1 -0
- package/package.json +3 -2
- package/assets/nexusDist/assets/index-CaA76j0F.js +0 -9
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Session-member provenance (#334). fp8 selects an owner; only a full key anchors it. */
|
|
2
|
+
import { createPublicKey } from 'node:crypto';
|
|
3
|
+
import { canvasOwnerFingerprint } from "./canvas-id.js";
|
|
4
|
+
import { CLI_HANDLE_RE } from "./cli-deposit.js";
|
|
5
|
+
/** CLI handles keep their existing home/deposit rules and are never parsed as session members. */
|
|
6
|
+
export function splitQualifiedMember(ref) {
|
|
7
|
+
if (CLI_HANDLE_RE.test(ref))
|
|
8
|
+
return undefined;
|
|
9
|
+
const match = /^([^/\s@]+\/[0-9a-f]{8})@([0-9a-f]{8})$/.exec(ref);
|
|
10
|
+
return match ? { handle: match[1], fingerprint: match[2] } : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function validMemberOwner(ref, key) {
|
|
13
|
+
const member = splitQualifiedMember(ref);
|
|
14
|
+
if (!member || !key || canvasOwnerFingerprint(key) !== member.fingerprint)
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const bytes = Buffer.from(key, 'base64'), publicKey = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
|
|
18
|
+
return publicKey.asymmetricKeyType === 'ed25519' && publicKey.export({ format: 'der', type: 'spki' }).equals(bytes) && bytes.toString('base64') === key;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function qualifiedSessionMember(handle, key) {
|
|
25
|
+
const ref = handle + '@' + canvasOwnerFingerprint(key);
|
|
26
|
+
return validMemberOwner(ref, key) ? ref : undefined;
|
|
27
|
+
}
|
|
28
|
+
export function memberProvenance(ref, ownerKey, ownKey) {
|
|
29
|
+
if (CLI_HANDLE_RE.test(ref))
|
|
30
|
+
return 'cli';
|
|
31
|
+
if (!ref.includes('/'))
|
|
32
|
+
return 'local-id';
|
|
33
|
+
const qualified = splitQualifiedMember(ref);
|
|
34
|
+
if (!qualified)
|
|
35
|
+
return ref.includes('@') ? 'conflict' : 'unknown';
|
|
36
|
+
if (ownerKey === undefined)
|
|
37
|
+
return qualified.fingerprint === canvasOwnerFingerprint(ownKey) ? 'confirmed-local' : 'unknown';
|
|
38
|
+
if (!validMemberOwner(ref, ownerKey))
|
|
39
|
+
return 'conflict';
|
|
40
|
+
if (qualified.fingerprint === canvasOwnerFingerprint(ownKey) && ownerKey !== ownKey)
|
|
41
|
+
return 'conflict';
|
|
42
|
+
return ownerKey === ownKey ? 'confirmed-local' : 'confirmed-remote';
|
|
43
|
+
}
|
package/lib/canvas-store.js
CHANGED
|
@@ -21,6 +21,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
21
21
|
import { hostname } from 'node:os';
|
|
22
22
|
import { dirname } from 'node:path';
|
|
23
23
|
import { CLI_HANDLE_RE } from "./cli-deposit.js";
|
|
24
|
+
import { splitQualifiedMember, validMemberOwner, memberProvenance } from "./canvas-member.js";
|
|
24
25
|
/** A bare session handle is not a CLI identity or a qualified owner selector. */
|
|
25
26
|
export function legacyBareMember(id) {
|
|
26
27
|
return isRemoteMemberRef(id) && !CLI_HANDLE_RE.test(id) && !/@[0-9a-f]{8}$/.test(id);
|
|
@@ -135,8 +136,15 @@ export class CanvasStore {
|
|
|
135
136
|
members.add(member);
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
|
-
if (snapshot.memberOwners !== undefined && (snapshot.memberOwners === null || typeof snapshot.memberOwners !== 'object' || Array.isArray(snapshot.memberOwners) || Object.
|
|
139
|
+
if (snapshot.memberOwners !== undefined && (snapshot.memberOwners === null || typeof snapshot.memberOwners !== 'object' || Array.isArray(snapshot.memberOwners) || Object.entries(snapshot.memberOwners).some(([ref, key]) => typeof key !== 'string' || !validMemberOwner(ref, key))))
|
|
139
140
|
throw Error('invalid canvas v2 owners');
|
|
141
|
+
const fpKeys = new Map();
|
|
142
|
+
for (const [ref, key] of Object.entries(snapshot.memberOwners ?? {})) {
|
|
143
|
+
const fp = splitQualifiedMember(ref).fingerprint;
|
|
144
|
+
if (fpKeys.has(fp) && fpKeys.get(fp) !== key)
|
|
145
|
+
throw Error('conflicting canvas member fingerprints');
|
|
146
|
+
fpKeys.set(fp, key);
|
|
147
|
+
}
|
|
140
148
|
if (snapshot.cliHome !== undefined && (snapshot.cliHome === null || typeof snapshot.cliHome !== 'object' || Array.isArray(snapshot.cliHome) || Object.entries(snapshot.cliHome).some(([handle, home]) => !CLI_HANDLE_RE.test(handle) || typeof home !== 'string' || !validHomeOrigin(home) || !snapshot.teams.some(t => t.members.includes(handle)))))
|
|
141
149
|
throw Error('invalid canvas v2 CLI homes');
|
|
142
150
|
if (snapshot.cliHomeCleared !== undefined && (!Array.isArray(snapshot.cliHomeCleared) || new Set(snapshot.cliHomeCleared).size !== snapshot.cliHomeCleared.length || snapshot.cliHomeCleared.some(handle => typeof handle !== 'string' || !CLI_HANDLE_RE.test(handle) || !snapshot.teams.some(t => t.members.includes(handle)))))
|
|
@@ -207,6 +215,7 @@ export class CanvasStore {
|
|
|
207
215
|
const previous = JSON.parse(this.committed);
|
|
208
216
|
this.teams = previous.teams.map(t => ({ name: t.name, members: [...t.members] }));
|
|
209
217
|
this.suspended = [...previous.suspendedMembers ?? []];
|
|
218
|
+
this.memberOwners = { ...previous.memberOwners };
|
|
210
219
|
this.cliHome = { ...previous.cliHome };
|
|
211
220
|
this.cliHomeCleared = new Set(previous.cliHomeCleared);
|
|
212
221
|
this.revision = previous.revision ?? 0;
|
|
@@ -220,7 +229,7 @@ export class CanvasStore {
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
serialize() {
|
|
223
|
-
return JSON.stringify({ teams: this.teams, suspendedMembers: this.suspended, cliHome: this.cliHome, cliHomeCleared: [...this.cliHomeCleared], revision: this.revision });
|
|
232
|
+
return JSON.stringify({ teams: this.teams, memberOwners: this.memberOwners, suspendedMembers: this.suspended, cliHome: this.cliHome, cliHomeCleared: [...this.cliHomeCleared], revision: this.revision });
|
|
224
233
|
}
|
|
225
234
|
write() {
|
|
226
235
|
if (this.readOnlyReason)
|
|
@@ -328,7 +337,15 @@ export class CanvasStore {
|
|
|
328
337
|
const entry = this.teams.find(t => t.name === name);
|
|
329
338
|
if (this.readOnlyReason || this.failedSuspension)
|
|
330
339
|
return [];
|
|
331
|
-
return entry === undefined ? [] : entry.members.filter(id =>
|
|
340
|
+
return entry === undefined ? [] : entry.members.filter(id => {
|
|
341
|
+
if (this.isSuspended(name, id) || (this.options.legacyBareMembers === false && legacyBareMember(id)))
|
|
342
|
+
return false;
|
|
343
|
+
if (this.options.ownPublicKey && id.includes('@') && !CLI_HANDLE_RE.test(id)) {
|
|
344
|
+
const provenance = memberProvenance(id, this.memberOwners[id], this.options.ownPublicKey);
|
|
345
|
+
return provenance === 'confirmed-local' || provenance === 'confirmed-remote';
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
});
|
|
332
349
|
}
|
|
333
350
|
/** Inspection only: never use raw rows for routing, authorization or counts. */
|
|
334
351
|
rawMembersOf(name) { return [...(this.teams.find(t => t.name === name)?.members ?? [])]; }
|
|
@@ -341,6 +358,48 @@ export class CanvasStore {
|
|
|
341
358
|
...(this.readOnlyReason || this.failedSuspension || this.lastWriteError ? { error: this.readOnlyReason || (this.failedSuspension ? 'canvas suspension persistence failed; restart after repair' : this.lastWriteError) } : {}) };
|
|
342
359
|
}
|
|
343
360
|
ownerKeyOf(member) { return this.memberOwners[member]; }
|
|
361
|
+
ownerConflict(member, key) {
|
|
362
|
+
const fp = splitQualifiedMember(member)?.fingerprint;
|
|
363
|
+
return Object.entries(this.memberOwners).some(([ref, pinned]) => (ref === member || splitQualifiedMember(ref)?.fingerprint === fp) && pinned !== key);
|
|
364
|
+
}
|
|
365
|
+
/** Owner control only: CAS the complete slot and preserve suspension plus routing priority. */
|
|
366
|
+
confirmMember(team, member, replacement, ownerKey, revision, expectedOwner) {
|
|
367
|
+
if (this.readOnlyReason || this.failedSuspension)
|
|
368
|
+
return { ok: false, error: this.state().error };
|
|
369
|
+
const entry = this.teams.find(t => t.name === team), index = entry?.members.indexOf(member) ?? -1;
|
|
370
|
+
if (!entry || index < 0 || revision !== this.revision || expectedOwner !== (this.memberOwners[member] ?? null))
|
|
371
|
+
return { ok: false, error: 'canvas version, member slot or owner changed; refresh first' };
|
|
372
|
+
const sameId8 = (value) => value.replace(/^session-/, '').slice(0, 8);
|
|
373
|
+
const originalHandle = splitQualifiedMember(member)?.handle ?? member;
|
|
374
|
+
const replacementHandle = splitQualifiedMember(replacement)?.handle ?? replacement;
|
|
375
|
+
if (member.includes('/') && (replacement.includes('/') ? originalHandle !== replacementHandle : !originalHandle.endsWith('/' + sameId8(replacement))))
|
|
376
|
+
return { ok: false, error: 'confirmation must identify the same original session handle' };
|
|
377
|
+
if (!member.includes('/') || !replacement || CLI_HANDLE_RE.test(member) || CLI_HANDLE_RE.test(replacement))
|
|
378
|
+
return { ok: false, error: 'session provenance confirmation only; CLI homes use their own controls' };
|
|
379
|
+
const qualified = splitQualifiedMember(replacement);
|
|
380
|
+
if ((replacement.includes('/') && !qualified) || (qualified && (!ownerKey || !validMemberOwner(replacement, ownerKey) || this.ownerConflict(replacement, ownerKey))))
|
|
381
|
+
return { ok: false, error: 'owner key/fingerprint conflict or unproved member' };
|
|
382
|
+
if (this.memberOwners[member] !== undefined && (replacement !== member || ownerKey !== this.memberOwners[member]))
|
|
383
|
+
return { ok: false, error: 'confirmed owner cannot be silently replaced' };
|
|
384
|
+
const equivalent = entry.members.indexOf(replacement);
|
|
385
|
+
if (equivalent >= 0 && equivalent !== index && this.suspensionOf(team, member).some(row => this.suspensionOf(team, replacement).some(other => other.reason === row.reason && other.localSessionId === row.localSessionId)))
|
|
386
|
+
return { ok: false, error: 'both slots have independent matching suspension reasons; handle them explicitly before merging' };
|
|
387
|
+
if (equivalent >= 0 && equivalent !== index)
|
|
388
|
+
entry.members.splice(index, 1);
|
|
389
|
+
else
|
|
390
|
+
entry.members[index] = replacement;
|
|
391
|
+
this.suspended = this.suspended.map(row => row.team === team && row.member === member ? { ...row, member: replacement } : row)
|
|
392
|
+
.filter((row, i, all) => all.findIndex(other => other.team === row.team && other.member === row.member && other.reason === row.reason && other.localSessionId === row.localSessionId) === i);
|
|
393
|
+
if (qualified)
|
|
394
|
+
this.memberOwners[replacement] = ownerKey;
|
|
395
|
+
try {
|
|
396
|
+
this.persist();
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
return { ok: false, error: String(error) };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
344
403
|
/** Durably revoke canvas consent BEFORE join/agent/roster state is removed. */
|
|
345
404
|
revokeLocal(id, ownQualified, inferredBare, reason, team) {
|
|
346
405
|
if (this.readOnlyReason || this.failedSuspension)
|
|
@@ -353,7 +412,7 @@ export class CanvasStore {
|
|
|
353
412
|
this.suspended.push({ team: entry.name, member: inferredBare, localSessionId: id, reason, at: new Date().toISOString(), token: randomUUID() });
|
|
354
413
|
changed = true;
|
|
355
414
|
}
|
|
356
|
-
const kept = entry.members.filter(m => m !== id && m !== ownQualified);
|
|
415
|
+
const kept = entry.members.filter(m => (m !== id && m !== ownQualified) || this.isSuspended(entry.name, m));
|
|
357
416
|
if (kept.length !== entry.members.length) {
|
|
358
417
|
entry.members = kept;
|
|
359
418
|
changed = true;
|
|
@@ -462,10 +521,15 @@ export class CanvasStore {
|
|
|
462
521
|
return false;
|
|
463
522
|
if (home !== undefined && this.cliHome[id] !== undefined && this.cliHome[id] !== home && options.replaceHome !== true)
|
|
464
523
|
return false;
|
|
524
|
+
const qualified = splitQualifiedMember(id);
|
|
525
|
+
if (qualified && (!options.ownerKey || !validMemberOwner(id, options.ownerKey) || this.ownerConflict(id, options.ownerKey)))
|
|
526
|
+
return false;
|
|
465
527
|
const entry = this.teams.find(t => t.name === name);
|
|
466
528
|
if (entry === undefined)
|
|
467
529
|
return false;
|
|
468
530
|
if (entry.members.includes(id)) {
|
|
531
|
+
if (qualified && this.memberOwners[id] === undefined)
|
|
532
|
+
return false; // Existing unknown slots require explicit confirmation, not re-add.
|
|
469
533
|
if (home !== undefined && this.cliHome[id] !== home) {
|
|
470
534
|
this.cliHome[id] = home;
|
|
471
535
|
this.cliHomeCleared.delete(id);
|
|
@@ -476,6 +540,8 @@ export class CanvasStore {
|
|
|
476
540
|
if (entry.members.length >= CANVAS_MEMBER_CAP)
|
|
477
541
|
return false;
|
|
478
542
|
entry.members.push(id);
|
|
543
|
+
if (qualified)
|
|
544
|
+
this.memberOwners[id] = options.ownerKey;
|
|
479
545
|
if (home !== undefined) {
|
|
480
546
|
this.cliHome[id] = home;
|
|
481
547
|
this.cliHomeCleared.delete(id);
|
package/lib/direct-forward.js
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_HOP_BUDGET, guardHeader, mintGuard } from "./recursion-guard.js
|
|
|
3
3
|
import { briefOf } from "./task-ledger.js";
|
|
4
4
|
import { parseReceipt } from "./receipt.js";
|
|
5
5
|
import { splitCanvasOwner } from "./canvas-id.js";
|
|
6
|
+
import { splitQualifiedMember } from "./canvas-member.js";
|
|
6
7
|
import { claimCovers } from "./task-auth.js";
|
|
7
8
|
/** An early receipt waits at most this long for its forward row to be born (#298). */
|
|
8
9
|
export const BIRTH_WAIT_CAP_MS = 60_000;
|
|
@@ -46,8 +47,9 @@ export function createDirectForward(d) {
|
|
|
46
47
|
if (!request.localCliProof || !d.deposited(request.caller))
|
|
47
48
|
return refuse('caller is not a proven online local CLI deposit', publish);
|
|
48
49
|
// #282 S2: candidates honour an owner-qualified id; scope, the peer, the receivers and the ledger see the plain team.
|
|
49
|
-
const
|
|
50
|
-
const
|
|
50
|
+
const qualifiedMember = splitQualifiedMember(request.team);
|
|
51
|
+
const plainTeam = qualifiedMember?.handle ?? splitCanvasOwner(request.team).team;
|
|
52
|
+
const scope = await d.scope(request.caller, qualifiedMember ? request.team : plainTeam);
|
|
51
53
|
if (!scope.ok)
|
|
52
54
|
return refuse('target is outside sender scope: ' + scope.error, `ask this host's operator to link ${request.team} here: drag it (or one of its members) into a canvas team on this host`);
|
|
53
55
|
const failures = [];
|
|
@@ -83,9 +85,9 @@ export function createDirectForward(d) {
|
|
|
83
85
|
if (result.ok && result.bridge !== undefined)
|
|
84
86
|
return { ...result, receipt: 'none' };
|
|
85
87
|
const deliveredTo = result.ok ? result.deliveredTo : undefined;
|
|
86
|
-
const birth = { forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
|
|
88
|
+
const birth = { ...(result.ok && typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}), forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
|
|
87
89
|
// Exactly one outbound row. A completed sync response is receiptless bookkeeping.
|
|
88
|
-
if (result.ok && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
|
|
90
|
+
if (result.ok && result.receiverBookkeeping !== 'failed' && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
|
|
89
91
|
d.ledger.trackReceiptless(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
|
|
90
92
|
else
|
|
91
93
|
d.ledger.track(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
|
|
@@ -117,13 +119,13 @@ export function forwardReceiptBasis(row, caller, cliHandle, claim) {
|
|
|
117
119
|
// The signer's zone must cover the caller label: B never signs for another zone's member (review of #287 M4).
|
|
118
120
|
if (!claimCovers(caller, claim.caller))
|
|
119
121
|
return undefined;
|
|
120
|
-
if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle)
|
|
122
|
+
if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle || (row.auth !== undefined && row.auth.receipt_target !== cliHandle) || row.receiptExpected === false)
|
|
121
123
|
return undefined;
|
|
122
124
|
// Body-bound (Sig3) only: a stripped Sig3 would let a captured envelope carry another body (review of #287 P3-1).
|
|
123
125
|
if (!claim.bound || claim.bodyBound !== true || row.peerKey === undefined || claim.card.publicKey !== row.peerKey)
|
|
124
126
|
return undefined;
|
|
125
|
-
// A legacy
|
|
126
|
-
if (row.deliveredTo !== undefined &&
|
|
127
|
+
// A legacy canvas deliveredTo is never a receiver; a direct session target equal to row.team is valid.
|
|
128
|
+
if (row.deliveredTo !== undefined && row.deliveredTo.includes('/canvas/'))
|
|
127
129
|
return undefined;
|
|
128
130
|
return (row.deliveredTo !== undefined ? [row.deliveredTo] : row.receiverMembers ?? []).includes(caller) ? `peer-org:${row.team}` : undefined;
|
|
129
131
|
}
|
package/lib/faces/admin.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
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 { reminderView } from "../reconciliation-state.js";
|
|
21
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";
|
|
@@ -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)],
|
|
@@ -194,7 +197,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
194
197
|
};
|
|
195
198
|
}),
|
|
196
199
|
remote: (refreshRemoteRows(), (d.remoteRowsCache?.rows ?? []).map(row => {
|
|
197
|
-
const canvasTeams = canvasStore.teamsOf(row.team);
|
|
200
|
+
const canvasTeams = [...new Set([...canvasStore.teamsOf(row.team), ...(row.memberRef ? canvasStore.teamsOf(row.memberRef) : [])])];
|
|
198
201
|
return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
|
|
199
202
|
})),
|
|
200
203
|
activity: recentActivity.slice(),
|
|
@@ -207,12 +210,19 @@ export function registerAdminFaces(webServer, d) {
|
|
|
207
210
|
// in-flight ring cannot show. Dead-lettered rows ride their own
|
|
208
211
|
// list and settled ones just a count, all additive so the panel
|
|
209
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?.(),
|
|
210
217
|
tasks: taskLedger.list()
|
|
211
218
|
.filter(task => task.status === 'pending')
|
|
212
219
|
.map(task => ({
|
|
220
|
+
...reminderView(taskLedger.reminderState(task.taskId)),
|
|
213
221
|
taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
|
|
214
222
|
direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
|
|
215
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' } : {}),
|
|
216
226
|
...(task.forwarded === true ? { forwarded: true } : {}),
|
|
217
227
|
...(task.deliveredTo !== undefined ? { deliveredTo: task.deliveredTo } : {}),
|
|
218
228
|
...(task.receiverMembers !== undefined ? { receiverMembers: task.receiverMembers } : {}),
|
|
@@ -234,7 +244,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
234
244
|
tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
|
|
235
245
|
tasksDead: taskLedger.list()
|
|
236
246
|
.filter(task => task.status === 'dead')
|
|
237
|
-
.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 } : {}),
|
|
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 } : {}) })),
|
|
238
249
|
// gh#31: per-task outcomes for rows that LEFT the ledger — the
|
|
239
250
|
// chat drawer used to guess (settled, abandoned or cancelled).
|
|
240
251
|
// Bounded to the archive (≤64), newest first, with the
|
|
@@ -253,6 +264,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
253
264
|
// its direction when known.
|
|
254
265
|
archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
|
|
255
266
|
taskId: row.taskId,
|
|
267
|
+
...(row.withdrawalResult ? { withdrawalResult: row.withdrawalResult } : {}),
|
|
256
268
|
team: row.team,
|
|
257
269
|
peer: row.peer,
|
|
258
270
|
startedAt: row.startedAt,
|
|
@@ -345,6 +357,9 @@ export function registerAdminFaces(webServer, d) {
|
|
|
345
357
|
boundMissInbound: d.boundMissInboundCount,
|
|
346
358
|
// Who missed, by verified caller and the origin it dialled (newest first, ≤16).
|
|
347
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,
|
|
348
363
|
// gh#113: CLI mailbox loss, all handles — mail the 64-row cap
|
|
349
364
|
// pushed out, and mail cut at 4000 chars. The team verifier
|
|
350
365
|
// holds `dropped` to zero.
|
|
@@ -528,7 +543,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
528
543
|
path: '/__dsh_a2a/fleet-canvas-op',
|
|
529
544
|
handler: controlRoute((req, res) => {
|
|
530
545
|
readJsonBody(req, res, async (body) => {
|
|
531
|
-
const { peer, op, name, id } = body;
|
|
546
|
+
const { peer, op, name, id, memberPeer } = body;
|
|
532
547
|
const peerUrl = typeof peer === 'string' ? peer : '';
|
|
533
548
|
const opV = typeof op === 'string' ? op : '';
|
|
534
549
|
const nameV = typeof name === 'string' ? name : '';
|
|
@@ -547,7 +562,13 @@ export function registerAdminFaces(webServer, d) {
|
|
|
547
562
|
}
|
|
548
563
|
try {
|
|
549
564
|
const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
|
|
550
|
-
const
|
|
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 } : {}) });
|
|
551
572
|
const reply = await d.peerFetch(target, {
|
|
552
573
|
method: 'POST',
|
|
553
574
|
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body }) },
|
|
@@ -756,7 +777,18 @@ export function registerAdminFaces(webServer, d) {
|
|
|
756
777
|
const action = body.action;
|
|
757
778
|
let payload;
|
|
758
779
|
try {
|
|
759
|
-
if (action === '
|
|
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') {
|
|
760
792
|
const request = body;
|
|
761
793
|
const name = typeof request.name === 'string' ? request.name : '', id = typeof request.id === 'string' ? request.id : '';
|
|
762
794
|
const out = canvasStore.regulateSuspended(action === 'restore-suspended' ? 'restore' : 'delete', name, id, request.revision, request.expected);
|
|
@@ -802,15 +834,26 @@ export function registerAdminFaces(webServer, d) {
|
|
|
802
834
|
else if (name === '' || !memberValid) {
|
|
803
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' });
|
|
804
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
|
+
}
|
|
805
841
|
else {
|
|
806
|
-
const
|
|
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' }));
|
|
807
850
|
if (!pin.ok)
|
|
808
851
|
payload = JSON.stringify({ ok: false, error: pin.error });
|
|
809
852
|
else {
|
|
810
853
|
canvasStore.create(name);
|
|
811
854
|
const explicitHome = body.home !== undefined;
|
|
812
855
|
const home = explicitHome ? pin.home : canvasStore.cliHomeOf(id) ?? pin.home;
|
|
813
|
-
payload = JSON.stringify({ ok: canvasStore.addMember(name,
|
|
856
|
+
payload = JSON.stringify({ ok: canvasStore.addMember(name, proof.member, home, { replaceHome: explicitHome, ownerKey: proof.ownerKey }), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
814
857
|
}
|
|
815
858
|
}
|
|
816
859
|
}
|
package/lib/faces/files.js
CHANGED
|
@@ -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');
|