@nelsonlongxiang/dsh-open-a2a-net 0.6.31 → 0.6.33
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/README.md +11 -2
- package/README_zh.md +10 -1
- package/assets/nexusDist/assets/index-BACVZe0j.css +1 -0
- package/assets/nexusDist/assets/index-Bbxnpf2p.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +450 -59
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/canvas-store.js +6 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +176 -47
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +212 -43
- package/lib/index.js +819 -1725
- package/lib/joined-store.js +23 -0
- package/lib/layout-store.js +3 -2
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/roster-op.js +104 -0
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +83 -4
- package/lib/skill/references/collaboration.md +126 -74
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +73 -2
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/canvas-store.d.ts +4 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +15 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +51 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/roster-op.d.ts +21 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +16 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +3 -2
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/layout-doc.ts +6 -4
- package/nexus-stage/src/main.ts +68 -20
- package/nexus-stage/src/planning-frame-hue.ts +13 -0
- package/nexus-stage/src/planning-legend.ts +1 -0
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-menus.ts +28 -12
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-roster.ts +15 -0
- package/nexus-stage/src/planning-types.ts +2 -2
- package/nexus-stage/src/planning-view.ts +57 -52
- package/nexus-stage/src/planning.css +6 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +7 -1
- package/package.json +3 -2
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
- package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
package/lib/a2a-client.js
CHANGED
|
@@ -29,7 +29,7 @@ export class A2aClient {
|
|
|
29
29
|
constructor(options) {
|
|
30
30
|
this.options = options;
|
|
31
31
|
}
|
|
32
|
-
headers(json) {
|
|
32
|
+
headers(json, url, method) {
|
|
33
33
|
const headers = {};
|
|
34
34
|
if (json)
|
|
35
35
|
headers['Content-Type'] = 'application/json';
|
|
@@ -43,7 +43,7 @@ export class A2aClient {
|
|
|
43
43
|
// peer request when the signer is wired (full-card reads and delivery
|
|
44
44
|
// verification both ride it).
|
|
45
45
|
if (this.options.originClaim !== undefined) {
|
|
46
|
-
for (const [key, value] of Object.entries(this.options.originClaim()))
|
|
46
|
+
for (const [key, value] of Object.entries(this.options.originClaim({ url, method })))
|
|
47
47
|
headers[key] = value;
|
|
48
48
|
}
|
|
49
49
|
return headers;
|
|
@@ -62,9 +62,10 @@ export class A2aClient {
|
|
|
62
62
|
};
|
|
63
63
|
init.signal?.addEventListener('abort', abort, { once: true });
|
|
64
64
|
try {
|
|
65
|
-
const
|
|
65
|
+
const url = `${baseUrl}${path}`;
|
|
66
|
+
const response = await this.options.fetch(url, {
|
|
66
67
|
method: init.method,
|
|
67
|
-
headers: this.headers(init.body !== undefined),
|
|
68
|
+
headers: this.headers(init.body !== undefined, url, init.method),
|
|
68
69
|
...(init.body !== undefined ? { body: init.body } : {}),
|
|
69
70
|
signal: controller.signal,
|
|
70
71
|
});
|
|
@@ -256,6 +257,8 @@ export class A2aClient {
|
|
|
256
257
|
// the receipt contract back to the caller's team instead. A bridged
|
|
257
258
|
// native round carries the peer's `bridge` marker and promises NO
|
|
258
259
|
// receipt: the text must not claim one the slice will never send.
|
|
260
|
+
// gh#141: the peer's enforce verdict on a receipt this message carried (#139).
|
|
261
|
+
const settlementRefused = raw.settlement?.status === 'refused';
|
|
259
262
|
if (raw.routed === true && raw.delivered === true) {
|
|
260
263
|
const taskId = wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? '';
|
|
261
264
|
const bridge = wireBridge(raw.bridge);
|
|
@@ -276,6 +279,7 @@ export class A2aClient {
|
|
|
276
279
|
task_status: wireText(raw.task_status) === '' ? 'TASK_STATE_DELIVERED' : wireText(raw.task_status),
|
|
277
280
|
...(consumed !== undefined ? { consumed } : {}),
|
|
278
281
|
...(bridge !== undefined ? { bridge } : {}),
|
|
282
|
+
...(settlementRefused ? { settlementRefused: true } : {}),
|
|
279
283
|
};
|
|
280
284
|
}
|
|
281
285
|
const reply = this.replyText(raw.result, '');
|
|
@@ -290,6 +294,7 @@ export class A2aClient {
|
|
|
290
294
|
context_id: wireText(raw.context_id),
|
|
291
295
|
task_status: wireText(raw.task_status),
|
|
292
296
|
...(bridge !== undefined ? { bridge } : {}),
|
|
297
|
+
...(settlementRefused ? { settlementRefused: true } : {}),
|
|
293
298
|
};
|
|
294
299
|
}
|
|
295
300
|
/**
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist change observer (gh#114 S6, design docs/design/114-task-authorization.md §5).
|
|
3
|
+
*
|
|
4
|
+
* When the records admission reads change — joins, canvas membership, roster
|
|
5
|
+
* declarations, CLI deposits, peer seeds — the allowlist version (§2.3)
|
|
6
|
+
* moves. This module keeps the version history and, on every move, re-judges
|
|
7
|
+
* the unsettled tasks that carry an authorization record against the CURRENT
|
|
8
|
+
* records, producing an "affected tasks" list.
|
|
9
|
+
*
|
|
10
|
+
* OBSERVE MODE ONLY. Nothing here cancels a task, changes a row's state,
|
|
11
|
+
* nudges a session or blocks a receipt: the judgement is a pure read and its
|
|
12
|
+
* output is a record. Enforcing it (revalidate before resume, design §5 last
|
|
13
|
+
* paragraph) needs its own owner ruling.
|
|
14
|
+
*
|
|
15
|
+
* Persistence at `<dsh-home>/a2a/auth-versions.json`, two rings of
|
|
16
|
+
* {@link AUTH_HISTORY_CAP}: the version history `{version, at, cause, inputs}`
|
|
17
|
+
* and the affected-task entries. A corrupt file is dropped whole; malformed
|
|
18
|
+
* entries are dropped one by one. No timers: the host calls {@link observe}
|
|
19
|
+
* lazily and at its known change points.
|
|
20
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/auth-observer
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
/** Ring size of both the version history and the affected-task entries. */
|
|
25
|
+
export const AUTH_HISTORY_CAP = 32;
|
|
26
|
+
/** Longest refusal text kept on an affected task (the faces' texts fit; a longer one is cut, flagged by `…`). */
|
|
27
|
+
export const AFFECTED_REASON_CAP = 400;
|
|
28
|
+
/**
|
|
29
|
+
* Re-judge rows against today's records — pure: `judge` must be a read-only
|
|
30
|
+
* admission decision (task-auth.ts callerAdmission over the live records).
|
|
31
|
+
* A row is judged when it carries a record and either was born under another
|
|
32
|
+
* version than `version` or `cacheResting(row)` says its admission rests on
|
|
33
|
+
* the peer-card cache (`peer-org`, which the version does not hash; by
|
|
34
|
+
* default: the birth record's `via`).
|
|
35
|
+
* @returns the affected rows, in ledger order; unaffected rows are omitted.
|
|
36
|
+
*/
|
|
37
|
+
export function judgeAffected(rows, version, judge, cacheResting = bornOnCache) {
|
|
38
|
+
const affected = [];
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
const outcome = judgeRow(row, version, judge, cacheResting);
|
|
41
|
+
if (outcome?.task !== undefined)
|
|
42
|
+
affected.push(outcome.task);
|
|
43
|
+
}
|
|
44
|
+
return affected;
|
|
45
|
+
}
|
|
46
|
+
const bornOnCache = (row) => row.auth?.via === 'peer-org';
|
|
47
|
+
/** One row's re-judgement: undefined when the row is not judged; `task` only when it is affected. */
|
|
48
|
+
function judgeRow(row, version, judge, cacheResting) {
|
|
49
|
+
const auth = row.auth;
|
|
50
|
+
if (auth === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (auth.allowlist_version === version && !cacheResting(row))
|
|
53
|
+
return undefined;
|
|
54
|
+
// #142 review P2: a record is re-judged by the rule of the face that admitted it.
|
|
55
|
+
const verdict = judge(auth.caller, auth.target, auth.ingress);
|
|
56
|
+
const base = { taskId: row.taskId, direction: row.direction ?? 'inbound', caller: auth.caller, target: auth.target };
|
|
57
|
+
if (!verdict.ok) {
|
|
58
|
+
const reason = verdict.refusal.length <= AFFECTED_REASON_CAP ? verdict.refusal : `${verdict.refusal.slice(0, AFFECTED_REASON_CAP - 1)}…`;
|
|
59
|
+
return { admission: verdict, task: { ...base, verdict: 'would-revoke', reason } };
|
|
60
|
+
}
|
|
61
|
+
if (basisHolds(auth.basis, auth.admission_rule, verdict))
|
|
62
|
+
return { admission: verdict };
|
|
63
|
+
return { admission: verdict, task: { ...base, verdict: 'basis-changed', reason: `admitted by ${verdict.rule} on [${verdict.basis.join(', ')}], was ${auth.admission_rule} on [${auth.basis.join(', ')}]` } };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The dedupe key of a report: `would-revoke` is one state whatever the
|
|
67
|
+
* refusal text (one /leave writes three stores, each worded differently);
|
|
68
|
+
* `basis-changed` is keyed by the new rule and basis its reason names.
|
|
69
|
+
*/
|
|
70
|
+
const reportKey = (task) => (task.verdict === 'would-revoke' ? 'would-revoke' : `basis-changed\n${task.reason}`);
|
|
71
|
+
/**
|
|
72
|
+
* Whether today's admission still rests on the record's basis: the shared
|
|
73
|
+
* teams overlap, or — for basis-less rules (host-face, node-fact,
|
|
74
|
+
* cli-deposit, scope-off) — the same rule admits again.
|
|
75
|
+
*/
|
|
76
|
+
function basisHolds(was, wasRule, now) {
|
|
77
|
+
if (was.length === 0 && now.basis.length === 0)
|
|
78
|
+
return now.rule === wasRule;
|
|
79
|
+
return was.some(team => now.basis.includes(team));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The persisted observer. One instance per host; every entry point is
|
|
83
|
+
* total and never throws (an unwritable home degrades to memory-only).
|
|
84
|
+
*/
|
|
85
|
+
export class AuthObserver {
|
|
86
|
+
path;
|
|
87
|
+
now;
|
|
88
|
+
versions = [];
|
|
89
|
+
affected = [];
|
|
90
|
+
/**
|
|
91
|
+
* The last report per task ({@link reportKey}), so a task still affected
|
|
92
|
+
* the same way is not listed again — on a peer-org recheck poll, nor on a
|
|
93
|
+
* later unrelated version move. A task judged unaffected is forgotten, so
|
|
94
|
+
* a relapse reports again; a task gone from the rows is pruned.
|
|
95
|
+
* Boot-local by design: after a restart the next version change reports
|
|
96
|
+
* afresh.
|
|
97
|
+
*/
|
|
98
|
+
reported = new Map();
|
|
99
|
+
/**
|
|
100
|
+
* The rule of each task's latest passing judgement. Whether a row rests on
|
|
101
|
+
* the peer-card cache is read from here first, the birth record second: a
|
|
102
|
+
* row born remote-ref-samezone that today passes only through `peer-org`
|
|
103
|
+
* is rechecked like one born there (and stops being once a firm rule
|
|
104
|
+
* admits it again).
|
|
105
|
+
*/
|
|
106
|
+
lastRule = new Map();
|
|
107
|
+
constructor(path, now = Date.now) {
|
|
108
|
+
this.path = path;
|
|
109
|
+
this.now = now;
|
|
110
|
+
this.restore();
|
|
111
|
+
}
|
|
112
|
+
/** The version history, newest first. */
|
|
113
|
+
history() {
|
|
114
|
+
return [...this.versions];
|
|
115
|
+
}
|
|
116
|
+
/** The affected-task entries, newest first. */
|
|
117
|
+
entries() {
|
|
118
|
+
return [...this.affected];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Record the current version when it moved, and re-judge.
|
|
122
|
+
* - Version moved: append it to the history; judge every row born under
|
|
123
|
+
* another version and every cache-resting row.
|
|
124
|
+
* - Version unchanged: only cache-resting rows are re-judged.
|
|
125
|
+
* - Cause `boot`: cache-resting rows are not judged at all — the peer-card
|
|
126
|
+
* cache is empty at boot, so every one of them would read as refused.
|
|
127
|
+
* The first read after boot (`lazy`) rechecks them.
|
|
128
|
+
* Either way a task is reported only when its report differs from the
|
|
129
|
+
* last one for it ({@link reportKey}).
|
|
130
|
+
* @returns the new affected entry, or undefined when nothing was affected.
|
|
131
|
+
*/
|
|
132
|
+
observe(input) {
|
|
133
|
+
try {
|
|
134
|
+
const last = this.versions[0]?.version ?? null;
|
|
135
|
+
const moved = last !== input.version;
|
|
136
|
+
if (moved) {
|
|
137
|
+
this.versions = [{ version: input.version, at: this.now(), cause: input.cause, inputs: input.inputs() }, ...this.versions].slice(0, AUTH_HISTORY_CAP);
|
|
138
|
+
}
|
|
139
|
+
const all = input.rows();
|
|
140
|
+
this.prune(all);
|
|
141
|
+
const cacheResting = (row) => {
|
|
142
|
+
const rule = this.lastRule.get(row.taskId);
|
|
143
|
+
return rule !== undefined ? rule === 'peer-org' : bornOnCache(row);
|
|
144
|
+
};
|
|
145
|
+
const coldCache = input.cause === 'boot';
|
|
146
|
+
const rows = all.filter(row => (coldCache ? !cacheResting(row) : moved || cacheResting(row)));
|
|
147
|
+
const tasks = [];
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
const outcome = judgeRow(row, input.version, input.judge, cacheResting);
|
|
150
|
+
if (outcome === undefined)
|
|
151
|
+
continue;
|
|
152
|
+
if (outcome.admission.ok)
|
|
153
|
+
this.lastRule.set(row.taskId, outcome.admission.rule);
|
|
154
|
+
if (outcome.task === undefined) {
|
|
155
|
+
this.reported.delete(row.taskId);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const key = reportKey(outcome.task);
|
|
159
|
+
if (this.reported.get(row.taskId) === key)
|
|
160
|
+
continue;
|
|
161
|
+
this.reported.set(row.taskId, key);
|
|
162
|
+
tasks.push(outcome.task);
|
|
163
|
+
}
|
|
164
|
+
if (tasks.length === 0) {
|
|
165
|
+
if (moved)
|
|
166
|
+
this.persist();
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
const entry = { at: this.now(), from_version: moved ? last : input.version, to_version: input.version, cause: moved ? input.cause : 'peer-org-recheck', tasks };
|
|
170
|
+
this.affected = [entry, ...this.affected].slice(0, AUTH_HISTORY_CAP);
|
|
171
|
+
this.persist();
|
|
172
|
+
return entry;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Observation is bookkeeping: a failure here must never reach routing.
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Forget the per-task memory of tasks no longer among the unsettled rows. */
|
|
180
|
+
prune(rows) {
|
|
181
|
+
const live = new Set(rows.map(row => row.taskId));
|
|
182
|
+
for (const map of [this.reported, this.lastRule]) {
|
|
183
|
+
for (const taskId of map.keys())
|
|
184
|
+
if (!live.has(taskId))
|
|
185
|
+
map.delete(taskId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
restore() {
|
|
189
|
+
if (this.path === '' || !existsSync(this.path))
|
|
190
|
+
return;
|
|
191
|
+
try {
|
|
192
|
+
const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
193
|
+
const versions = Array.isArray(snapshot?.versions) ? snapshot.versions : [];
|
|
194
|
+
const affected = Array.isArray(snapshot?.affected) ? snapshot.affected : [];
|
|
195
|
+
this.versions = versions.map(parseVersion).filter((entry) => entry !== undefined).slice(0, AUTH_HISTORY_CAP);
|
|
196
|
+
this.affected = affected.map(parseAffected).filter((entry) => entry !== undefined).slice(0, AUTH_HISTORY_CAP);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// A corrupt file is dropped whole: the next observation starts a fresh history.
|
|
200
|
+
this.versions = [];
|
|
201
|
+
this.affected = [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
persist() {
|
|
205
|
+
if (this.path === '')
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
209
|
+
// tmp + rename: a crash mid-write leaves the previous file, never a torn one.
|
|
210
|
+
const tmp = `${this.path}.${String(process.pid)}.tmp`;
|
|
211
|
+
writeFileSync(tmp, JSON.stringify({ versions: this.versions, affected: this.affected }), { mode: 0o600 });
|
|
212
|
+
renameSync(tmp, this.path);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// An unwritable home must not break routing; the history degrades to memory-only.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const VERSION_SHAPE = /^al1:[0-9a-f]{16}$/;
|
|
220
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
221
|
+
const finiteTime = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
222
|
+
const shortString = (v, cap = 256) => typeof v === 'string' && v !== '' && v.length <= cap;
|
|
223
|
+
function parseVersion(raw) {
|
|
224
|
+
if (!isRecord(raw))
|
|
225
|
+
return undefined;
|
|
226
|
+
if (typeof raw.version !== 'string' || !VERSION_SHAPE.test(raw.version) || !finiteTime(raw.at) || !shortString(raw.cause, 64))
|
|
227
|
+
return undefined;
|
|
228
|
+
return { version: raw.version, at: raw.at, cause: raw.cause, inputs: raw.inputs ?? null };
|
|
229
|
+
}
|
|
230
|
+
function parseAffected(raw) {
|
|
231
|
+
if (!isRecord(raw) || !finiteTime(raw.at) || !shortString(raw.cause, 64))
|
|
232
|
+
return undefined;
|
|
233
|
+
if (typeof raw.to_version !== 'string' || !VERSION_SHAPE.test(raw.to_version))
|
|
234
|
+
return undefined;
|
|
235
|
+
if (raw.from_version !== null && (typeof raw.from_version !== 'string' || !VERSION_SHAPE.test(raw.from_version)))
|
|
236
|
+
return undefined;
|
|
237
|
+
if (!Array.isArray(raw.tasks))
|
|
238
|
+
return undefined;
|
|
239
|
+
const tasks = [];
|
|
240
|
+
for (const task of raw.tasks) {
|
|
241
|
+
if (!isRecord(task))
|
|
242
|
+
return undefined;
|
|
243
|
+
if (!shortString(task.taskId) || !shortString(task.caller) || !shortString(task.target))
|
|
244
|
+
return undefined;
|
|
245
|
+
if (task.direction !== 'inbound' && task.direction !== 'outbound')
|
|
246
|
+
return undefined;
|
|
247
|
+
if (task.verdict !== 'basis-changed' && task.verdict !== 'would-revoke')
|
|
248
|
+
return undefined;
|
|
249
|
+
if (!shortString(task.reason, AFFECTED_REASON_CAP + 200))
|
|
250
|
+
return undefined;
|
|
251
|
+
tasks.push({ taskId: task.taskId, direction: task.direction, caller: task.caller, target: task.target, verdict: task.verdict, reason: task.reason });
|
|
252
|
+
}
|
|
253
|
+
return { at: raw.at, from_version: raw.from_version, to_version: raw.to_version, cause: raw.cause, tasks };
|
|
254
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #91 file channel, slice F1 (docs/design/file-channel.md §3.1–§3.2, §5): the sender host's
|
|
3
|
+
* content-addressed blob store and its grants. A file never rides a session message: the sender
|
|
4
|
+
* stages it here (by sha256), a grant names who may pull it and until when, and the session
|
|
5
|
+
* message carries only a reference line ({@link formatFileRef}). This module is pure storage —
|
|
6
|
+
* the wire face (admission, OriginClaim) and the tools sit on top of it.
|
|
7
|
+
*
|
|
8
|
+
* - Blobs live at `<root>/blobs/<sha256>`, written tmp + rename; identical bytes are stored once.
|
|
9
|
+
* - Grants live in `<root>/blob-grants.json`, one per (sha256, to): re-sending the same blob to
|
|
10
|
+
* the same target only extends its expiry.
|
|
11
|
+
* - Capacity: one file ≤ {@link BLOB_FILE_MAX}; the store ≤ {@link BLOB_TOTAL_MAX} bytes and
|
|
12
|
+
* ≤ {@link BLOB_COUNT_MAX} blobs. A full store first drops expired grants and the blobs no
|
|
13
|
+
* live grant names, then refuses visibly — a live grant is never pushed out.
|
|
14
|
+
* - A grants file that does not parse is set aside and read as EMPTY (fail-closed: references
|
|
15
|
+
* already sent stop resolving, nothing is served by mistake).
|
|
16
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/blob-store
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/** One file's ceiling, in bytes (64 MiB; design §8). */
|
|
22
|
+
export const BLOB_FILE_MAX = 64 * 1024 * 1024;
|
|
23
|
+
/** The store's ceiling, in bytes (1 GiB). */
|
|
24
|
+
export const BLOB_TOTAL_MAX = 1024 * 1024 * 1024;
|
|
25
|
+
/** The store's ceiling, in blobs. */
|
|
26
|
+
export const BLOB_COUNT_MAX = 256;
|
|
27
|
+
/** How long a grant lets its target pull (24h). */
|
|
28
|
+
export const BLOB_GRANT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
/** A sanitized file name's length cap. */
|
|
30
|
+
export const BLOB_NAME_MAX = 120;
|
|
31
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
32
|
+
/** A file name that can only land inside the inbox: no path, no `..`, no control characters, bounded. */
|
|
33
|
+
export function sanitizeBlobName(name) {
|
|
34
|
+
const base = (name.split(/[\\/]/).pop() ?? '')
|
|
35
|
+
// eslint-disable-next-line no-control-regex
|
|
36
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
37
|
+
.replace(/\.\.+/g, '.')
|
|
38
|
+
.replace(/^[.\s]+/, '')
|
|
39
|
+
.trim();
|
|
40
|
+
const capped = base.slice(0, BLOB_NAME_MAX);
|
|
41
|
+
return capped === '' ? 'file' : capped;
|
|
42
|
+
}
|
|
43
|
+
/** The one-line reference a session message carries (design §3.1). */
|
|
44
|
+
export function formatFileRef(ref) {
|
|
45
|
+
return `[A2A file] v1 sha256=${ref.sha256} size=${String(ref.size)} name=${encodeURIComponent(sanitizeBlobName(ref.name))} from=${ref.from}`;
|
|
46
|
+
}
|
|
47
|
+
/** Parse a reference line (anywhere in a message); undefined when it is not a well-formed v1 reference. */
|
|
48
|
+
export function parseFileRef(text) {
|
|
49
|
+
const m = /\[A2A file\] v1 sha256=([0-9a-f]{64}) size=(\d{1,12}) name=(\S{1,512}) from=(https?:\/\/\S{1,2048})/.exec(text);
|
|
50
|
+
if (m === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
const size = Number(m[2]);
|
|
53
|
+
if (!Number.isSafeInteger(size) || size > BLOB_FILE_MAX)
|
|
54
|
+
return undefined;
|
|
55
|
+
let name;
|
|
56
|
+
try {
|
|
57
|
+
name = decodeURIComponent(m[3]);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
let from;
|
|
63
|
+
try {
|
|
64
|
+
from = new URL(m[4]);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
// Only the origin is a pull target: a path, query or credentials on the URL are refused.
|
|
70
|
+
if (from.username !== '' || from.password !== '' || (from.pathname !== '/' && from.pathname !== '') || from.search !== '' || from.hash !== '')
|
|
71
|
+
return undefined;
|
|
72
|
+
return { sha256: m[1], size, name: sanitizeBlobName(name), from: from.origin };
|
|
73
|
+
}
|
|
74
|
+
export class BlobStore {
|
|
75
|
+
root;
|
|
76
|
+
now;
|
|
77
|
+
grants = [];
|
|
78
|
+
/**
|
|
79
|
+
* @param root - the store's directory (`<home>/a2a`); '' = in-memory grants and no blob files (tests of the grant logic only).
|
|
80
|
+
* @param now - clock (tests).
|
|
81
|
+
*/
|
|
82
|
+
constructor(root, now = () => Date.now()) {
|
|
83
|
+
this.root = root;
|
|
84
|
+
this.now = now;
|
|
85
|
+
this.restore();
|
|
86
|
+
}
|
|
87
|
+
get grantsFile() { return join(this.root, 'blob-grants.json'); }
|
|
88
|
+
blobPath(sha256) { return join(this.root, 'blobs', sha256); }
|
|
89
|
+
/** Stage bytes for `to`; a repeat of the same (sha256, to) only extends the grant. */
|
|
90
|
+
stage(bytes, meta) {
|
|
91
|
+
if (typeof meta.to !== 'string' || meta.to === '' || typeof meta.by !== 'string' || meta.by === '')
|
|
92
|
+
return { ok: false, code: 'bad-request', error: 'to and by are required' };
|
|
93
|
+
if (bytes.byteLength > BLOB_FILE_MAX)
|
|
94
|
+
return { ok: false, code: 'too-large', error: `file is ${String(bytes.byteLength)} bytes; the file channel takes at most ${String(BLOB_FILE_MAX)}` };
|
|
95
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
96
|
+
const name = sanitizeBlobName(meta.name);
|
|
97
|
+
const at = this.now();
|
|
98
|
+
const expiresAt = at + BLOB_GRANT_TTL_MS;
|
|
99
|
+
const stored = this.hasBlob(sha256);
|
|
100
|
+
if (!stored) {
|
|
101
|
+
this.sweep();
|
|
102
|
+
const blobs = this.liveBlobs();
|
|
103
|
+
const total = blobs.reduce((sum, b) => sum + b.size, 0);
|
|
104
|
+
if (blobs.length + 1 > BLOB_COUNT_MAX || total + bytes.byteLength > BLOB_TOTAL_MAX) {
|
|
105
|
+
return { ok: false, code: 'full', error: `the file store is full (${String(blobs.length)} files, ${String(total)} bytes held by live grants); retry after some expire` };
|
|
106
|
+
}
|
|
107
|
+
if (this.root !== '') {
|
|
108
|
+
mkdirSync(join(this.root, 'blobs'), { recursive: true });
|
|
109
|
+
const tmp = `${this.blobPath(sha256)}.tmp-${String(process.pid)}`;
|
|
110
|
+
writeFileSync(tmp, bytes, { mode: 0o600 });
|
|
111
|
+
renameSync(tmp, this.blobPath(sha256));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const grant = { sha256, size: bytes.byteLength, name, to: meta.to, by: meta.by, grantedAt: at, expiresAt };
|
|
115
|
+
this.grants = [grant, ...this.grants.filter(g => !(g.sha256 === sha256 && g.to === meta.to))];
|
|
116
|
+
this.persist();
|
|
117
|
+
return { ok: true, sha256, size: bytes.byteLength, name, expiresAt };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The live grant that lets one of `callerTeams` pull `sha256`, if any. `callerTeams` are the
|
|
121
|
+
* teams the WIRE FACE proved for the caller (OriginClaim + membership) — never self-described.
|
|
122
|
+
*/
|
|
123
|
+
grantFor(sha256, callerTeams) {
|
|
124
|
+
if (!SHA256_RE.test(sha256))
|
|
125
|
+
return undefined;
|
|
126
|
+
const at = this.now();
|
|
127
|
+
return this.grants.find(g => g.sha256 === sha256 && g.expiresAt > at && callerTeams.includes(g.to));
|
|
128
|
+
}
|
|
129
|
+
/** The bytes of a granted blob (undefined when the file is gone or does not hash back). */
|
|
130
|
+
read(grant) {
|
|
131
|
+
if (this.root === '' || !this.hasBlob(grant.sha256))
|
|
132
|
+
return undefined;
|
|
133
|
+
const bytes = readFileSync(this.blobPath(grant.sha256));
|
|
134
|
+
if (bytes.byteLength !== grant.size || createHash('sha256').update(bytes).digest('hex') !== grant.sha256)
|
|
135
|
+
return undefined;
|
|
136
|
+
return bytes;
|
|
137
|
+
}
|
|
138
|
+
/** Live grants (for the state face). */
|
|
139
|
+
list() {
|
|
140
|
+
const at = this.now();
|
|
141
|
+
return this.grants.filter(g => g.expiresAt > at);
|
|
142
|
+
}
|
|
143
|
+
hasBlob(sha256) {
|
|
144
|
+
if (this.root === '')
|
|
145
|
+
return this.grants.some(g => g.sha256 === sha256);
|
|
146
|
+
return existsSync(this.blobPath(sha256));
|
|
147
|
+
}
|
|
148
|
+
/** Blobs some live grant still names, with their sizes. */
|
|
149
|
+
liveBlobs() {
|
|
150
|
+
const at = this.now();
|
|
151
|
+
const seen = new Map();
|
|
152
|
+
for (const g of this.grants)
|
|
153
|
+
if (g.expiresAt > at)
|
|
154
|
+
seen.set(g.sha256, g.size);
|
|
155
|
+
return [...seen].map(([sha256, size]) => ({ sha256, size }));
|
|
156
|
+
}
|
|
157
|
+
/** Drop expired grants and every blob file no live grant names. */
|
|
158
|
+
sweep() {
|
|
159
|
+
const at = this.now();
|
|
160
|
+
const before = this.grants.length;
|
|
161
|
+
this.grants = this.grants.filter(g => g.expiresAt > at);
|
|
162
|
+
if (this.root !== '' && existsSync(join(this.root, 'blobs'))) {
|
|
163
|
+
const keep = new Set(this.grants.map(g => g.sha256));
|
|
164
|
+
for (const file of readdirSync(join(this.root, 'blobs'))) {
|
|
165
|
+
if (keep.has(file) && statSync(join(this.root, 'blobs', file)).isFile())
|
|
166
|
+
continue;
|
|
167
|
+
rmSync(join(this.root, 'blobs', file), { force: true, recursive: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (this.grants.length !== before)
|
|
171
|
+
this.persist();
|
|
172
|
+
}
|
|
173
|
+
persist() {
|
|
174
|
+
if (this.root === '')
|
|
175
|
+
return;
|
|
176
|
+
mkdirSync(this.root, { recursive: true });
|
|
177
|
+
const tmp = `${this.grantsFile}.tmp-${String(process.pid)}`;
|
|
178
|
+
writeFileSync(tmp, JSON.stringify({ grants: this.grants }), { mode: 0o600 });
|
|
179
|
+
renameSync(tmp, this.grantsFile);
|
|
180
|
+
}
|
|
181
|
+
restore() {
|
|
182
|
+
if (this.root === '' || !existsSync(this.grantsFile))
|
|
183
|
+
return;
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = JSON.parse(readFileSync(this.grantsFile, 'utf8'));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
raw = undefined;
|
|
190
|
+
}
|
|
191
|
+
const list = raw?.grants;
|
|
192
|
+
const valid = (g) => {
|
|
193
|
+
const r = g;
|
|
194
|
+
return r !== null && typeof r === 'object' && typeof r.sha256 === 'string' && SHA256_RE.test(r.sha256)
|
|
195
|
+
&& Number.isSafeInteger(r.size) && r.size >= 0 && r.size <= BLOB_FILE_MAX
|
|
196
|
+
&& typeof r.name === 'string' && typeof r.to === 'string' && r.to !== '' && typeof r.by === 'string'
|
|
197
|
+
&& Number.isSafeInteger(r.grantedAt) && Number.isSafeInteger(r.expiresAt);
|
|
198
|
+
};
|
|
199
|
+
if (!Array.isArray(list) || !list.every(valid)) {
|
|
200
|
+
// Fail-closed: set the file aside and serve nothing from it.
|
|
201
|
+
try {
|
|
202
|
+
copyFileSync(this.grantsFile, `${this.grantsFile}.corrupt-${String(this.now())}`);
|
|
203
|
+
}
|
|
204
|
+
catch { /* the original stays in place */ }
|
|
205
|
+
this.grants = [];
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.grants = list;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/lib/canvas-store.js
CHANGED
|
@@ -240,3 +240,9 @@ export class CanvasStore {
|
|
|
240
240
|
this.persist();
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
|
+
/** One policy predicate for local and signed-remote canvas creation. */
|
|
244
|
+
export function canvasNameReserved(raw, zone, allowlist, roster) {
|
|
245
|
+
const name = raw.trim();
|
|
246
|
+
const team = `${zone}/${name}`;
|
|
247
|
+
return name.startsWith('roster:') || allowlist.includes(team) || roster.membersOf(team).length > 0;
|
|
248
|
+
}
|
package/lib/card.js
CHANGED
|
@@ -82,6 +82,8 @@ function referrals(wire) {
|
|
|
82
82
|
* The unsigned `sessionTeams` listing passes through only as an array of
|
|
83
83
|
* well-formed entries; any other shape drops the field instead of rejecting
|
|
84
84
|
* the card, because cards from nodes without joined sessions carry none.
|
|
85
|
+
* A missing description is the D2 `exposureExcerpt: off` form, not a
|
|
86
|
+
* malformed node: retain its handle and normalize its excerpt to empty.
|
|
85
87
|
* @param wire - the shape-checked card candidate.
|
|
86
88
|
* @returns the session-team entries, or `undefined` when absent or malformed.
|
|
87
89
|
*/
|
|
@@ -93,12 +95,12 @@ function sessionTeams(wire) {
|
|
|
93
95
|
const team = entry;
|
|
94
96
|
if (typeof team?.team !== 'string' || team.team === '')
|
|
95
97
|
continue;
|
|
96
|
-
if (typeof team.name !== 'string' || typeof team.description !== 'string')
|
|
98
|
+
if (typeof team.name !== 'string' || (team.description !== undefined && typeof team.description !== 'string'))
|
|
97
99
|
continue;
|
|
98
100
|
entries.push({
|
|
99
101
|
team: team.team,
|
|
100
102
|
name: team.name,
|
|
101
|
-
description: team.description,
|
|
103
|
+
description: team.description ?? '',
|
|
102
104
|
...(typeof team.workspace === 'string' && team.workspace !== '' ? { workspace: team.workspace } : {}),
|
|
103
105
|
});
|
|
104
106
|
}
|