@staix/agent-hub 0.20.0 → 0.22.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/CHANGELOG.md +66 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +82 -0
- package/src/cli/smoke-pty.ts +153 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +34 -6
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +117 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +154 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +27 -2
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +987 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/** Rooms wire v1. Persistence, publication, transport and envelope policy belong to X3/X4. */
|
|
2
|
+
import {
|
|
3
|
+
assertRoomCryptoReady, decodeBase64url, encodeBase64url, randomBytes,
|
|
4
|
+
encryptEpoch, decryptEpoch, importEpochKey, generateEpochKey, sha256,
|
|
5
|
+
signBytes, verifyBytes, hmac, verifyHmac, wrapEpochKey, unwrapEpochKey, assertAgreementKeyUsable,
|
|
6
|
+
type HubIdentity, type PublicHubIdentity, type EpochKey, type WrappedEpochKey,
|
|
7
|
+
} from "./crypto.ts";
|
|
8
|
+
|
|
9
|
+
export const ROOM_WIRE_LIMITS = Object.freeze({
|
|
10
|
+
plaintext: 65536, frame: 131072, roster: 524288, enrollment: 16384,
|
|
11
|
+
members: 64, recipients: 64, exposedPeers: 64,
|
|
12
|
+
});
|
|
13
|
+
export type RoomWireCode = "invalid_wire" | "limit" | "untrusted" | "wrong_epoch" |
|
|
14
|
+
"authentication_failed" | "state_unavailable" | "state_changed" | "expired";
|
|
15
|
+
export class RoomWireError extends Error {
|
|
16
|
+
constructor(readonly code: RoomWireCode) { super(`Room wire: ${code}`); this.name = "RoomWireError"; }
|
|
17
|
+
}
|
|
18
|
+
function fail(code: RoomWireCode = "invalid_wire"): never { throw new RoomWireError(code); }
|
|
19
|
+
const enc = new TextEncoder();
|
|
20
|
+
const U64_MAX = 18446744073709551615n;
|
|
21
|
+
const ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
22
|
+
const FP = /^[0-9a-f]{64}$/;
|
|
23
|
+
export interface RoomMember extends PublicHubIdentity { label: string; exposedPeers: string[] }
|
|
24
|
+
export interface FrameWire {
|
|
25
|
+
v: 1; type: "frame"; room: string; epoch: string; sender: string;
|
|
26
|
+
recipients: string[]; seq: string; nonce: string; ciphertext: string; signature: string;
|
|
27
|
+
}
|
|
28
|
+
export interface RosterWire {
|
|
29
|
+
v: 1; type: "roster"; room: string; epoch: string; owner: string;
|
|
30
|
+
members: PublicHubIdentity[]; wraps: WrappedEpochKey[];
|
|
31
|
+
privateNonce: string; privateCiphertext: string; signature: string;
|
|
32
|
+
}
|
|
33
|
+
export interface InviteWire {
|
|
34
|
+
v: 1; type: "invite"; room: string; owner: string; broker: string;
|
|
35
|
+
inviteId: string; expiresAt: number; secret: string;
|
|
36
|
+
}
|
|
37
|
+
export interface JoinWire {
|
|
38
|
+
v: 1; type: "join"; room: string; owner: string; inviteId: string;
|
|
39
|
+
member: PublicHubIdentity; nonce: string; privateNonce: string;
|
|
40
|
+
privateCiphertext: string; signature: string; mac: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Lexicographic ASCII object keys, preserved array order, no optional/unknown values. */
|
|
44
|
+
export function canonicalBytes(value: unknown): Uint8Array {
|
|
45
|
+
function visit(x: unknown, depth: number): unknown {
|
|
46
|
+
if (depth > 12) fail("limit");
|
|
47
|
+
if (x === null || typeof x === "boolean" || typeof x === "string") return x;
|
|
48
|
+
if (typeof x === "number" && Number.isSafeInteger(x) && !Object.is(x, -0)) return x;
|
|
49
|
+
if (Array.isArray(x)) return x.map(item => visit(item, depth + 1));
|
|
50
|
+
if (typeof x !== "object" || x === null || Object.getPrototypeOf(x) !== Object.prototype) fail();
|
|
51
|
+
const out: Record<string, unknown> = {};
|
|
52
|
+
for (const key of Object.keys(x).sort()) {
|
|
53
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") fail();
|
|
54
|
+
out[key] = visit((x as Record<string, unknown>)[key], depth + 1);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
return enc.encode(JSON.stringify(visit(value, 0)));
|
|
59
|
+
}
|
|
60
|
+
function object(x: unknown, keys: string[]): Record<string, unknown> {
|
|
61
|
+
if (typeof x !== "object" || x === null || Array.isArray(x)) fail();
|
|
62
|
+
const o = x as Record<string, unknown>;
|
|
63
|
+
if (Object.keys(o).length !== keys.length || keys.some(key => !Object.hasOwn(o, key))) fail();
|
|
64
|
+
return o;
|
|
65
|
+
}
|
|
66
|
+
function text(x: unknown): string { if (typeof x !== "string") fail(); return x; }
|
|
67
|
+
function fingerprint(x: unknown): string { const s = text(x); if (!FP.test(s)) fail(); return s; }
|
|
68
|
+
function id(x: unknown): string { const s = text(x); if (!ID.test(s)) fail(); return s; }
|
|
69
|
+
function b64(x: unknown, length?: number, max: number = ROOM_WIRE_LIMITS.roster): string {
|
|
70
|
+
const s = text(x);
|
|
71
|
+
if (s.length > Math.ceil(max * 4 / 3) || !/^[A-Za-z0-9_-]*$/.test(s)) fail("limit");
|
|
72
|
+
try { decodeBase64url(s, length); } catch { fail(); }
|
|
73
|
+
return s;
|
|
74
|
+
}
|
|
75
|
+
export function positiveU64(x: unknown): string {
|
|
76
|
+
const s = text(x);
|
|
77
|
+
if (!/^[1-9][0-9]{0,19}$/.test(s) || BigInt(s) > U64_MAX) fail();
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
80
|
+
function sorted<T>(values: T[], key: (x: T) => string): T[] {
|
|
81
|
+
for (let i = 1; i < values.length; i++) if (key(values[i - 1]!) >= key(values[i]!)) fail();
|
|
82
|
+
return values;
|
|
83
|
+
}
|
|
84
|
+
function array<T>(x: unknown, max: number, parse: (x: unknown) => T, nonempty = false): T[] {
|
|
85
|
+
if (!Array.isArray(x) || x.length > max || (nonempty && x.length === 0)) fail("limit");
|
|
86
|
+
return x.map(parse);
|
|
87
|
+
}
|
|
88
|
+
function publicIdentity(x: unknown): PublicHubIdentity {
|
|
89
|
+
const o = object(x, ["fingerprint", "signingKey", "agreementKey"]);
|
|
90
|
+
return { fingerprint: fingerprint(o.fingerprint), signingKey: b64(o.signingKey, 32), agreementKey: b64(o.agreementKey, 32) };
|
|
91
|
+
}
|
|
92
|
+
function exposed(x: unknown): string[] { return sorted(array(x, 64, id), x => x); }
|
|
93
|
+
function privateMember(x: unknown): RoomMember {
|
|
94
|
+
const o = object(x, ["fingerprint", "signingKey", "agreementKey", "label", "exposedPeers"]);
|
|
95
|
+
return { ...publicIdentity({ fingerprint: o.fingerprint, signingKey: o.signingKey, agreementKey: o.agreementKey }), label: id(o.label), exposedPeers: exposed(o.exposedPeers) };
|
|
96
|
+
}
|
|
97
|
+
function base(o: Record<string, unknown>, type: string): void { if (o.v !== 1 || o.type !== type) fail(); }
|
|
98
|
+
function ciphertext(x: unknown, max: number, exact?: number): string {
|
|
99
|
+
const s = b64(x, exact, max);
|
|
100
|
+
const n = decodeBase64url(s).length;
|
|
101
|
+
if (n < 16 || n > max) fail("limit");
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
function wrap(x: unknown): WrappedEpochKey {
|
|
105
|
+
const o = object(x, ["v", "owner", "recipient", "ephemeralKey", "salt", "nonce", "ciphertext", "signature"]);
|
|
106
|
+
if (o.v !== 1) fail();
|
|
107
|
+
return { v: 1, owner: fingerprint(o.owner), recipient: fingerprint(o.recipient), ephemeralKey: b64(o.ephemeralKey, 32), salt: b64(o.salt, 32), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 48, 48), signature: b64(o.signature, 64) };
|
|
108
|
+
}
|
|
109
|
+
function parseFrame(x: unknown): FrameWire {
|
|
110
|
+
const o = object(x, ["v", "type", "room", "epoch", "sender", "recipients", "seq", "nonce", "ciphertext", "signature"]); base(o, "frame");
|
|
111
|
+
return { v: 1, type: "frame", room: b64(o.room, 32), epoch: positiveU64(o.epoch), sender: fingerprint(o.sender), recipients: sorted(array(o.recipients, 64, fingerprint, true), x => x), seq: positiveU64(o.seq), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 65552), signature: b64(o.signature, 64) };
|
|
112
|
+
}
|
|
113
|
+
function parseRoster(x: unknown): RosterWire {
|
|
114
|
+
const o = object(x, ["v", "type", "room", "epoch", "owner", "members", "wraps", "privateNonce", "privateCiphertext", "signature"]); base(o, "roster");
|
|
115
|
+
const members = sorted(array(o.members, 64, publicIdentity, true), x => x.fingerprint);
|
|
116
|
+
const wraps = sorted(array(o.wraps, 64, wrap, true), x => x.recipient);
|
|
117
|
+
const owner = fingerprint(o.owner);
|
|
118
|
+
if (!members.some(m => m.fingerprint === owner) || wraps.length !== members.length || wraps.some((w, i) => w.owner !== owner || w.recipient !== members[i]!.fingerprint)) fail();
|
|
119
|
+
return { v: 1, type: "roster", room: b64(o.room, 32), epoch: positiveU64(o.epoch), owner, members, wraps, privateNonce: b64(o.privateNonce, 12), privateCiphertext: ciphertext(o.privateCiphertext, 524288), signature: b64(o.signature, 64) };
|
|
120
|
+
}
|
|
121
|
+
export function brokerUrl(x: unknown): string {
|
|
122
|
+
const s = text(x); if (s.length > 2048 || !/^[\x21-\x7e]+$/.test(s)) fail();
|
|
123
|
+
let u: URL; try { u = new URL(s); } catch { fail(); }
|
|
124
|
+
if (u.username || u.password || u.hash || !u.hostname || (u.protocol !== "wss:" && !(u.protocol === "ws:" && (u.hostname === "127.0.0.1" || u.hostname === "[::1]")))) fail();
|
|
125
|
+
if (u.href !== s) fail(); return s;
|
|
126
|
+
}
|
|
127
|
+
function timestamp(x: unknown): number { if (typeof x !== "number" || !Number.isSafeInteger(x) || x <= 0) fail(); return x; }
|
|
128
|
+
function parseInvite(x: unknown): InviteWire {
|
|
129
|
+
const o = object(x, ["v", "type", "room", "owner", "broker", "inviteId", "expiresAt", "secret"]); base(o, "invite");
|
|
130
|
+
return { v: 1, type: "invite", room: b64(o.room, 32), owner: fingerprint(o.owner), broker: brokerUrl(o.broker), inviteId: b64(o.inviteId, 32), expiresAt: timestamp(o.expiresAt), secret: b64(o.secret, 32) };
|
|
131
|
+
}
|
|
132
|
+
function parseJoin(x: unknown): JoinWire {
|
|
133
|
+
const o = object(x, ["v", "type", "room", "owner", "inviteId", "member", "nonce", "privateNonce", "privateCiphertext", "signature", "mac"]); base(o, "join");
|
|
134
|
+
return { v: 1, type: "join", room: b64(o.room, 32), owner: fingerprint(o.owner), inviteId: b64(o.inviteId, 32), member: publicIdentity(o.member), nonce: b64(o.nonce, 32), privateNonce: b64(o.privateNonce, 12), privateCiphertext: ciphertext(o.privateCiphertext, 4096), signature: b64(o.signature, 64), mac: b64(o.mac, 32) };
|
|
135
|
+
}
|
|
136
|
+
function decode<T>(input: Uint8Array, max: number, parse: (x: unknown) => T): T {
|
|
137
|
+
if (!(input instanceof Uint8Array) || input.length > max) fail("limit");
|
|
138
|
+
let raw: unknown;
|
|
139
|
+
try { raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input)); } catch { fail(); }
|
|
140
|
+
const result = parse(raw), bytes = canonicalBytes(result);
|
|
141
|
+
if (bytes.length !== input.length || bytes.some((b, i) => input[i] !== b)) fail();
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
export const decodeFrame = (input: Uint8Array): FrameWire => decode(input, ROOM_WIRE_LIMITS.frame, parseFrame);
|
|
145
|
+
export const decodeRoster = (input: Uint8Array): RosterWire => decode(input, ROOM_WIRE_LIMITS.roster, parseRoster);
|
|
146
|
+
export const decodeInvite = (input: Uint8Array): InviteWire => decode(input, ROOM_WIRE_LIMITS.enrollment, parseInvite);
|
|
147
|
+
export const decodeJoin = (input: Uint8Array): JoinWire => decode(input, ROOM_WIRE_LIMITS.enrollment, parseJoin);
|
|
148
|
+
function unsigned<T extends { signature: string }>(x: T): Omit<T, "signature"> { const { signature: _, ...rest } = x; return rest; }
|
|
149
|
+
const domain = (purpose: string, value: unknown): Uint8Array => canonicalBytes(["agent-hub.rooms.v1", purpose, value]);
|
|
150
|
+
const digest = async (bytes: Uint8Array): Promise<string> => encodeBase64url(await sha256(bytes));
|
|
151
|
+
async function authenticateIdentity(identity: PublicHubIdentity): Promise<void> {
|
|
152
|
+
const hash = await sha256(decodeBase64url(identity.signingKey, 32));
|
|
153
|
+
if (Array.from(hash, b => b.toString(16).padStart(2, "0")).join("") !== identity.fingerprint) fail("untrusted");
|
|
154
|
+
}
|
|
155
|
+
async function verifyRosterSignature(roster: RosterWire): Promise<void> {
|
|
156
|
+
const owner = roster.members.find(m => m.fingerprint === roster.owner);
|
|
157
|
+
if (!owner) fail("untrusted");
|
|
158
|
+
if (!await verifyBytes(owner, domain("roster-signature", unsigned(roster)), decodeBase64url(roster.signature, 64))) fail("authentication_failed");
|
|
159
|
+
}
|
|
160
|
+
async function verifyFrameSignature(frame: FrameWire, sender: PublicHubIdentity): Promise<void> {
|
|
161
|
+
if (!await verifyBytes(sender, domain("frame-signature", unsigned(frame)), decodeBase64url(frame.signature, 64))) fail("authentication_failed");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Only instances minted by verification carry an epoch key. */
|
|
165
|
+
export interface VerifiedEpoch { readonly room: string; readonly epoch: string; readonly digest: string }
|
|
166
|
+
interface EpochState { roster: RosterWire; members: RoomMember[]; key: EpochKey }
|
|
167
|
+
const epochs = new WeakMap<VerifiedEpoch, EpochState>();
|
|
168
|
+
function state(epoch: VerifiedEpoch): EpochState { const s = epochs.get(epoch); if (!s) fail("untrusted"); return s; }
|
|
169
|
+
function verified(roster: RosterWire, members: RoomMember[], key: EpochKey, hash: string): VerifiedEpoch {
|
|
170
|
+
const result = Object.freeze({ room: roster.room, epoch: roster.epoch, digest: hash });
|
|
171
|
+
epochs.set(result, { roster, members, key }); return result;
|
|
172
|
+
}
|
|
173
|
+
export function roomMembers(epoch: VerifiedEpoch): RoomMember[] { return structuredClone(state(epoch).members); }
|
|
174
|
+
function rosterContext(roster: Pick<RosterWire, "room" | "epoch" | "owner" | "members">): Uint8Array {
|
|
175
|
+
return domain("epoch", { room: roster.room, epoch: roster.epoch, owner: roster.owner, members: roster.members });
|
|
176
|
+
}
|
|
177
|
+
/** minEpoch="0" is initial enrollment only; an installed epoch requires its digest. */
|
|
178
|
+
export interface OwnerPin { room: string; owner: string; minEpoch: string; digest?: string }
|
|
179
|
+
|
|
180
|
+
async function verifyPinnedRosterEnvelope(input: { wire: Uint8Array; pin: OwnerPin }): Promise<{
|
|
181
|
+
wire: Uint8Array; roster: RosterWire; rosterDigest: string;
|
|
182
|
+
}> {
|
|
183
|
+
if (!(input.wire instanceof Uint8Array)) fail();
|
|
184
|
+
const wire = Uint8Array.from(input.wire);
|
|
185
|
+
await assertRoomCryptoReady();
|
|
186
|
+
const roster = decodeRoster(wire), pin = input.pin;
|
|
187
|
+
if (roster.room !== pin.room || roster.owner !== pin.owner) fail("untrusted");
|
|
188
|
+
const min = pin.minEpoch === "0" ? "0" : positiveU64(pin.minEpoch);
|
|
189
|
+
const rosterDigest = await digest(canonicalBytes(roster));
|
|
190
|
+
if (BigInt(roster.epoch) < BigInt(min)) fail("wrong_epoch");
|
|
191
|
+
if (roster.epoch === min && (pin.digest === undefined || rosterDigest !== pin.digest)) fail("untrusted");
|
|
192
|
+
for (const member of roster.members) await authenticateIdentity(member);
|
|
193
|
+
await verifyRosterSignature(roster);
|
|
194
|
+
return { wire, roster, rosterDigest };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Public proof products intentionally contain no epoch key, wrap, or private metadata. */
|
|
198
|
+
export interface VerifiedPublicRoster {
|
|
199
|
+
readonly room: string;
|
|
200
|
+
readonly epoch: string;
|
|
201
|
+
readonly owner: string;
|
|
202
|
+
readonly digest: string;
|
|
203
|
+
readonly members: readonly Readonly<PublicHubIdentity>[];
|
|
204
|
+
}
|
|
205
|
+
interface PublicRosterState { wire: Uint8Array; members: readonly Readonly<PublicHubIdentity>[] }
|
|
206
|
+
const publicRosters = new WeakMap<VerifiedPublicRoster, PublicRosterState>();
|
|
207
|
+
function publicRosterState(proof: VerifiedPublicRoster): PublicRosterState {
|
|
208
|
+
const state = publicRosters.get(proof);
|
|
209
|
+
if (!state) fail("untrusted");
|
|
210
|
+
return state;
|
|
211
|
+
}
|
|
212
|
+
export function publicRosterWire(proof: VerifiedPublicRoster): Uint8Array {
|
|
213
|
+
return Uint8Array.from(publicRosterState(proof).wire);
|
|
214
|
+
}
|
|
215
|
+
export async function verifyPublicRoster(input: { wire: Uint8Array; pin: OwnerPin }): Promise<VerifiedPublicRoster> {
|
|
216
|
+
const { wire, roster, rosterDigest } = await verifyPinnedRosterEnvelope(input);
|
|
217
|
+
const members = Object.freeze(roster.members.map(member => Object.freeze({ ...member })));
|
|
218
|
+
const proof = Object.freeze({ room: roster.room, epoch: roster.epoch, owner: roster.owner, digest: rosterDigest, members });
|
|
219
|
+
publicRosters.set(proof, { wire, members });
|
|
220
|
+
return proof;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface VerifiedPublicFrame {
|
|
224
|
+
readonly room: string;
|
|
225
|
+
readonly epoch: string;
|
|
226
|
+
readonly sender: string;
|
|
227
|
+
readonly seq: string;
|
|
228
|
+
readonly digest: string;
|
|
229
|
+
readonly rosterDigest: string;
|
|
230
|
+
readonly recipients: readonly string[];
|
|
231
|
+
}
|
|
232
|
+
interface PublicFrameState { wire: Uint8Array }
|
|
233
|
+
const publicFrames = new WeakMap<VerifiedPublicFrame, PublicFrameState>();
|
|
234
|
+
function publicFrameState(proof: VerifiedPublicFrame): PublicFrameState {
|
|
235
|
+
const state = publicFrames.get(proof);
|
|
236
|
+
if (!state) fail("untrusted");
|
|
237
|
+
return state;
|
|
238
|
+
}
|
|
239
|
+
export function publicFrameWire(proof: VerifiedPublicFrame): Uint8Array {
|
|
240
|
+
return Uint8Array.from(publicFrameState(proof).wire);
|
|
241
|
+
}
|
|
242
|
+
export async function verifyPublicFrame(input: { wire: Uint8Array; roster: VerifiedPublicRoster }): Promise<VerifiedPublicFrame> {
|
|
243
|
+
if (!(input.wire instanceof Uint8Array)) fail();
|
|
244
|
+
const wire = Uint8Array.from(input.wire);
|
|
245
|
+
await assertRoomCryptoReady();
|
|
246
|
+
const roster = input.roster;
|
|
247
|
+
publicRosterState(roster);
|
|
248
|
+
const frame = decodeFrame(wire);
|
|
249
|
+
if (frame.room !== roster.room || frame.epoch !== roster.epoch) fail("wrong_epoch");
|
|
250
|
+
const sender = roster.members.find(member => member.fingerprint === frame.sender);
|
|
251
|
+
if (!sender || frame.recipients.some(recipient => !roster.members.some(member => member.fingerprint === recipient))) fail("untrusted");
|
|
252
|
+
await verifyFrameSignature(frame, sender);
|
|
253
|
+
const frameDigest = await digest(canonicalBytes(frame));
|
|
254
|
+
const proof = Object.freeze({ room: frame.room, epoch: frame.epoch, sender: frame.sender, seq: frame.seq,
|
|
255
|
+
digest: frameDigest, rosterDigest: roster.digest, recipients: Object.freeze([...frame.recipients]) });
|
|
256
|
+
publicFrames.set(proof, { wire });
|
|
257
|
+
return proof;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function acceptRoomEpoch(input: { wire: Uint8Array; identity: HubIdentity; pin: OwnerPin }): Promise<VerifiedEpoch> {
|
|
261
|
+
const { roster, rosterDigest } = await verifyPinnedRosterEnvelope(input);
|
|
262
|
+
const owner = roster.members.find(m => m.fingerprint === roster.owner)!;
|
|
263
|
+
const own = roster.members.find(m => m.fingerprint === input.identity.public.fingerprint);
|
|
264
|
+
if (!own || own.signingKey !== input.identity.public.signingKey || own.agreementKey !== input.identity.public.agreementKey) fail("untrusted");
|
|
265
|
+
const context = rosterContext(roster);
|
|
266
|
+
const key = await unwrapEpochKey({ recipient: input.identity, owner, context, wrapped: roster.wraps.find(w => w.recipient === own.fingerprint)! });
|
|
267
|
+
const plain = await decryptEpoch({ key, purpose: "roster", context, nonce: decodeBase64url(roster.privateNonce, 12), ciphertext: decodeBase64url(roster.privateCiphertext) });
|
|
268
|
+
const privateRoster = decode(plain, ROOM_WIRE_LIMITS.roster, x => {
|
|
269
|
+
const o = object(x, ["room", "epoch", "members"]);
|
|
270
|
+
return { room: b64(o.room, 32), epoch: positiveU64(o.epoch), members: sorted(array(o.members, 64, privateMember, true), m => m.fingerprint) };
|
|
271
|
+
});
|
|
272
|
+
if (privateRoster.room !== roster.room || privateRoster.epoch !== roster.epoch || privateRoster.members.length !== roster.members.length || privateRoster.members.some((m, i) => canonicalBytes({ fingerprint: m.fingerprint, signingKey: m.signingKey, agreementKey: m.agreementKey }).toString() !== canonicalBytes(roster.members[i]).toString()) || new Set(privateRoster.members.map(m => m.label)).size !== privateRoster.members.length) fail("untrusted");
|
|
273
|
+
return verified(roster, privateRoster.members, key, rosterDigest);
|
|
274
|
+
}
|
|
275
|
+
export async function createRoomEpoch(input: { owner: HubIdentity; members: RoomMember[]; previous?: VerifiedEpoch }): Promise<{ wire: Uint8Array; epoch: VerifiedEpoch }> {
|
|
276
|
+
await assertRoomCryptoReady();
|
|
277
|
+
const members = sorted(input.members.map(privateMember).sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)), m => m.fingerprint);
|
|
278
|
+
if (!members.length || members.length > 64 || new Set(members.map(m => m.label)).size !== members.length) fail();
|
|
279
|
+
const owner = input.owner.public.fingerprint;
|
|
280
|
+
const own = members.find(m => m.fingerprint === owner);
|
|
281
|
+
if (!own || own.signingKey !== input.owner.public.signingKey || own.agreementKey !== input.owner.public.agreementKey) fail("untrusted");
|
|
282
|
+
if (input.previous && state(input.previous).roster.owner !== owner) fail("untrusted");
|
|
283
|
+
for (const member of members) await authenticateIdentity(member);
|
|
284
|
+
const room = input.previous?.room ?? encodeBase64url(randomBytes(32));
|
|
285
|
+
const epoch = positiveU64(input.previous ? String(BigInt(input.previous.epoch) + 1n) : "1");
|
|
286
|
+
const publicMembers = members.map(({ label: _, exposedPeers: __, ...member }) => member);
|
|
287
|
+
const header = { room, epoch, owner, members: publicMembers }, context = rosterContext(header);
|
|
288
|
+
const key = await generateEpochKey(), wraps: WrappedEpochKey[] = [];
|
|
289
|
+
for (const recipient of publicMembers) wraps.push(await wrapEpochKey({ owner: input.owner, recipient, context, key }));
|
|
290
|
+
const nonce = randomBytes(12);
|
|
291
|
+
const encrypted = await encryptEpoch({ key, purpose: "roster", context, nonce, plaintext: canonicalBytes({ room, epoch, members }) });
|
|
292
|
+
const body = { v: 1 as const, type: "roster" as const, ...header, wraps, privateNonce: encodeBase64url(nonce), privateCiphertext: encodeBase64url(encrypted) };
|
|
293
|
+
const roster = { ...body, signature: encodeBase64url(await signBytes(input.owner, domain("roster-signature", body))) };
|
|
294
|
+
const wire = canonicalBytes(roster); decodeRoster(wire);
|
|
295
|
+
return { wire, epoch: verified(roster, members, key, await digest(wire)) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface SequencePort {
|
|
299
|
+
/** Atomically persist a strictly increasing room/sender counter, never reset on epoch changes.
|
|
300
|
+
* Refuse unless expectedCurrentDigest is still the installed current roster. */
|
|
301
|
+
reserve(input: SequenceBinding): Promise<string>;
|
|
302
|
+
/** Synchronous final fence after encryption/signing. X3 must additionally fence outbox publication. */
|
|
303
|
+
isCurrent(input: SequenceBinding): boolean;
|
|
304
|
+
}
|
|
305
|
+
export interface SequenceBinding { room: string; sender: string; epoch: string; rosterDigest: string }
|
|
306
|
+
export interface ReplayCommit {
|
|
307
|
+
room: string; sender: string; recipient: string; seq: string; frameDigest: string;
|
|
308
|
+
frameEpoch: string; expectedCurrentEpoch: string; expectedCurrentDigest: string;
|
|
309
|
+
selectedRosterDigest: string;
|
|
310
|
+
previousExpiresAt: number | null;
|
|
311
|
+
plaintext: Uint8Array;
|
|
312
|
+
}
|
|
313
|
+
export interface ReplayPort {
|
|
314
|
+
/** One durable transaction: recheck installed current roster and persisted grace deadline,
|
|
315
|
+
* reject revoked sender/recipient, compare+advance high-water, and record accepted digest.
|
|
316
|
+
* Duplicate means a previously accepted identical digest, never an unseen lower seq.
|
|
317
|
+
* The transaction uses its own local clock, not a remote timestamp or caller's nowMs.
|
|
318
|
+
* X3 also commits inbox delivery here before any success receipt is created. */
|
|
319
|
+
accept(input: ReplayCommit): Promise<
|
|
320
|
+
{ status: "accepted"; gap: string } | { status: "duplicate" | "stale" | "conflict" | "trust_changed" }
|
|
321
|
+
>;
|
|
322
|
+
}
|
|
323
|
+
async function portCall<T>(call: () => Promise<T>): Promise<T> {
|
|
324
|
+
try { return await call(); } catch { fail("state_unavailable"); }
|
|
325
|
+
}
|
|
326
|
+
function frameContext(epoch: VerifiedEpoch, sender: string): Uint8Array {
|
|
327
|
+
return domain("sender-key", { room: epoch.room, epoch: epoch.epoch, sender, rosterDigest: epoch.digest });
|
|
328
|
+
}
|
|
329
|
+
function frameAad(frame: Pick<FrameWire, "room" | "epoch" | "sender" | "seq">): Uint8Array {
|
|
330
|
+
return enc.encode(`${frame.room}|${frame.epoch}|${frame.sender}|${frame.seq}`);
|
|
331
|
+
}
|
|
332
|
+
export async function sealFrame(input: {
|
|
333
|
+
epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort;
|
|
334
|
+
}): Promise<Uint8Array> {
|
|
335
|
+
await assertRoomCryptoReady();
|
|
336
|
+
const epoch = input.epoch, identity = input.identity, sequence = input.sequence;
|
|
337
|
+
const current = state(epoch), sender = identity.public.fingerprint;
|
|
338
|
+
const member = current.roster.members.find(m => m.fingerprint === sender);
|
|
339
|
+
if (!member || member.signingKey !== input.identity.public.signingKey || member.agreementKey !== input.identity.public.agreementKey) fail("untrusted");
|
|
340
|
+
if (!(input.plaintext instanceof Uint8Array) || input.plaintext.length > ROOM_WIRE_LIMITS.plaintext) fail("limit");
|
|
341
|
+
const recipients = sorted(array(input.recipients, 64, fingerprint, true), x => x);
|
|
342
|
+
if (recipients.some(fp => !current.roster.members.some(m => m.fingerprint === fp))) fail("untrusted");
|
|
343
|
+
const plaintext = input.plaintext.slice();
|
|
344
|
+
if (!sequence || typeof sequence.reserve !== "function" || typeof sequence.isCurrent !== "function") fail("state_unavailable");
|
|
345
|
+
const binding = { room: epoch.room, sender, epoch: epoch.epoch, rosterDigest: epoch.digest };
|
|
346
|
+
const seq = positiveU64(await portCall(() => sequence.reserve({ ...binding })));
|
|
347
|
+
const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq };
|
|
348
|
+
const nonce = randomBytes(12);
|
|
349
|
+
const encrypted = await encryptEpoch({ key: current.key, purpose: "frame", context: frameContext(epoch, sender), aad: frameAad(header), nonce, plaintext });
|
|
350
|
+
const body = { ...header, nonce: encodeBase64url(nonce), ciphertext: encodeBase64url(encrypted) };
|
|
351
|
+
const wire = canonicalBytes({ ...body, signature: encodeBase64url(await signBytes(identity, domain("frame-signature", body))) });
|
|
352
|
+
decodeFrame(wire);
|
|
353
|
+
let fresh: boolean; try { fresh = sequence.isCurrent({ ...binding }); } catch { fail("state_unavailable"); }
|
|
354
|
+
if (fresh !== true) fail("state_changed");
|
|
355
|
+
return wire;
|
|
356
|
+
}
|
|
357
|
+
export type FrameOpenResult = { status: "accepted"; plaintext: Uint8Array; gap: string } | { status: "duplicate" | "stale" };
|
|
358
|
+
export async function openFrame(input: {
|
|
359
|
+
wire: Uint8Array; identity: HubIdentity; current: VerifiedEpoch;
|
|
360
|
+
previous?: { epoch: VerifiedEpoch; expiresAt: number }; replay: ReplayPort; nowMs?: number;
|
|
361
|
+
}): Promise<FrameOpenResult> {
|
|
362
|
+
await assertRoomCryptoReady();
|
|
363
|
+
const currentEpoch = input.current, identity = input.identity, replay = input.replay;
|
|
364
|
+
const frame = decodeFrame(input.wire), current = state(currentEpoch), recipient = identity.public.fingerprint;
|
|
365
|
+
if (frame.room !== currentEpoch.room || !frame.recipients.includes(recipient)) fail("untrusted");
|
|
366
|
+
const receiver = current.roster.members.find(m => m.fingerprint === recipient);
|
|
367
|
+
const sender = current.roster.members.find(m => m.fingerprint === frame.sender);
|
|
368
|
+
if (!sender || !receiver || receiver.agreementKey !== input.identity.public.agreementKey || receiver.signingKey !== input.identity.public.signingKey) fail("untrusted");
|
|
369
|
+
let selected = currentEpoch;
|
|
370
|
+
let previousExpiresAt: number | null = null;
|
|
371
|
+
if (frame.epoch !== currentEpoch.epoch) {
|
|
372
|
+
const previous = input.previous;
|
|
373
|
+
if (!previous || !Number.isSafeInteger(previous.expiresAt) || (input.nowMs ?? Date.now()) >= previous.expiresAt || previous.epoch.room !== frame.room || previous.epoch.epoch !== frame.epoch || BigInt(previous.epoch.epoch) + 1n !== BigInt(currentEpoch.epoch)) fail("wrong_epoch");
|
|
374
|
+
const prior = state(previous.epoch);
|
|
375
|
+
const priorSender = prior.roster.members.find(m => m.fingerprint === frame.sender);
|
|
376
|
+
const priorReceiver = prior.roster.members.find(m => m.fingerprint === recipient);
|
|
377
|
+
if (!priorSender || !priorReceiver || priorSender.signingKey !== sender.signingKey || priorSender.agreementKey !== sender.agreementKey || priorReceiver.signingKey !== receiver.signingKey || priorReceiver.agreementKey !== receiver.agreementKey || prior.roster.owner !== current.roster.owner) fail("untrusted");
|
|
378
|
+
selected = previous.epoch; previousExpiresAt = previous.expiresAt;
|
|
379
|
+
}
|
|
380
|
+
const selectedState = state(selected);
|
|
381
|
+
if (frame.recipients.some(fp => !selectedState.roster.members.some(m => m.fingerprint === fp))) fail("untrusted");
|
|
382
|
+
await verifyFrameSignature(frame, sender);
|
|
383
|
+
const nonce = decodeBase64url(frame.nonce, 12);
|
|
384
|
+
const plaintext = await decryptEpoch({ key: selectedState.key, purpose: "frame", context: frameContext(selected, frame.sender), aad: frameAad(frame), nonce, ciphertext: decodeBase64url(frame.ciphertext) });
|
|
385
|
+
if (plaintext.length > ROOM_WIRE_LIMITS.plaintext) fail("limit");
|
|
386
|
+
if (!replay || typeof replay.accept !== "function") fail("state_unavailable");
|
|
387
|
+
const frameDigest = await digest(canonicalBytes(frame));
|
|
388
|
+
const result = await portCall(() => replay.accept({ room: frame.room, sender: frame.sender, recipient, seq: frame.seq, frameDigest, frameEpoch: frame.epoch, expectedCurrentEpoch: currentEpoch.epoch, expectedCurrentDigest: currentEpoch.digest, selectedRosterDigest: selected.digest, previousExpiresAt, plaintext: plaintext.slice() }));
|
|
389
|
+
if (!result || typeof result !== "object") fail("state_unavailable");
|
|
390
|
+
if (result.status === "accepted") {
|
|
391
|
+
if (typeof result.gap !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(result.gap) || BigInt(result.gap) > U64_MAX) fail("state_unavailable");
|
|
392
|
+
return { status: "accepted", plaintext, gap: result.gap };
|
|
393
|
+
}
|
|
394
|
+
if (result.status === "duplicate" || result.status === "stale") return { status: result.status };
|
|
395
|
+
fail("state_changed");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function inviteClaims(invite: InviteWire): Omit<InviteWire, "secret"> { const { secret: _, ...claims } = invite; return claims; }
|
|
399
|
+
export async function createInvite(input: {
|
|
400
|
+
epoch: VerifiedEpoch; owner: HubIdentity; broker: string; expiresAt: number; nowMs?: number;
|
|
401
|
+
}): Promise<Uint8Array> {
|
|
402
|
+
await assertRoomCryptoReady();
|
|
403
|
+
const s = state(input.epoch);
|
|
404
|
+
if (s.roster.owner !== input.owner.public.fingerprint) fail("untrusted");
|
|
405
|
+
const expiresAt = timestamp(input.expiresAt);
|
|
406
|
+
if (expiresAt <= (input.nowMs ?? Date.now())) fail("expired");
|
|
407
|
+
const token: InviteWire = { v: 1, type: "invite", room: input.epoch.room, owner: s.roster.owner, broker: brokerUrl(input.broker), inviteId: encodeBase64url(randomBytes(32)), expiresAt, secret: encodeBase64url(randomBytes(32)) };
|
|
408
|
+
const wire = canonicalBytes(token); decodeInvite(wire); return wire;
|
|
409
|
+
}
|
|
410
|
+
function joinCore(join: JoinWire): Omit<JoinWire, "signature" | "mac"> { const { signature: _, mac: __, ...core } = join; return core; }
|
|
411
|
+
function joinContext(invite: InviteWire, member: PublicHubIdentity, nonce: string): Uint8Array {
|
|
412
|
+
return domain("join-private", { invite: inviteClaims(invite), member, nonce });
|
|
413
|
+
}
|
|
414
|
+
export async function createJoin(input: {
|
|
415
|
+
invite: Uint8Array; identity: HubIdentity; label: string; exposedPeers: string[]; nowMs?: number;
|
|
416
|
+
}): Promise<Uint8Array> {
|
|
417
|
+
await assertRoomCryptoReady();
|
|
418
|
+
const invite = decodeInvite(input.invite);
|
|
419
|
+
// Joiner preflight tolerates a 10 minute remote-clock difference. Owner expiry is exact.
|
|
420
|
+
if ((input.nowMs ?? Date.now()) > invite.expiresAt + 600000) fail("expired");
|
|
421
|
+
const privateData = { label: id(input.label), exposedPeers: exposed(input.exposedPeers) };
|
|
422
|
+
const member = publicIdentity(input.identity.public), nonce = encodeBase64url(randomBytes(32));
|
|
423
|
+
await authenticateIdentity(member);
|
|
424
|
+
const privateNonce = randomBytes(12), secret = decodeBase64url(invite.secret, 32);
|
|
425
|
+
const key = await importEpochKey(secret);
|
|
426
|
+
const encrypted = await encryptEpoch({ key, purpose: "invite", context: joinContext(invite, member, nonce), nonce: privateNonce, plaintext: canonicalBytes(privateData) });
|
|
427
|
+
const core = { v: 1 as const, type: "join" as const, room: invite.room, owner: invite.owner, inviteId: invite.inviteId, member, nonce, privateNonce: encodeBase64url(privateNonce), privateCiphertext: encodeBase64url(encrypted) };
|
|
428
|
+
const signature = encodeBase64url(await signBytes(input.identity, domain("join-signature", { invite: inviteClaims(invite), request: core })));
|
|
429
|
+
const body = { ...core, signature };
|
|
430
|
+
const wire = canonicalBytes({ ...body, mac: encodeBase64url(await hmac(secret, domain("join-mac", { invite: inviteClaims(invite), request: body }))) });
|
|
431
|
+
decodeJoin(wire); return wire;
|
|
432
|
+
}
|
|
433
|
+
export interface EnrollmentPort {
|
|
434
|
+
/** Transactionally compare original stored invite digest/expiry against the owner clock,
|
|
435
|
+
* consume its one-time secret, bind admitted identity + request digest, and persist outcome.
|
|
436
|
+
* Only the exact same request digest may return duplicate; changed context refuses.
|
|
437
|
+
* X3 must bind membership publication to this durable enrollment outcome. */
|
|
438
|
+
consume(input: {
|
|
439
|
+
room: string; owner: string; inviteId: string; inviteDigest: string; requestDigest: string;
|
|
440
|
+
expiresAt: number; member: RoomMember;
|
|
441
|
+
}): Promise<"accepted" | "duplicate" | "refused">;
|
|
442
|
+
}
|
|
443
|
+
export async function acceptJoin(input: {
|
|
444
|
+
issuedInvite: Uint8Array; wire: Uint8Array; owner: HubIdentity; enrollment: EnrollmentPort; nowMs?: number;
|
|
445
|
+
}): Promise<{ status: "accepted"; member: RoomMember } | { status: "duplicate" }> {
|
|
446
|
+
await assertRoomCryptoReady();
|
|
447
|
+
const invite = decodeInvite(input.issuedInvite), join = decodeJoin(input.wire);
|
|
448
|
+
if (invite.owner !== input.owner.public.fingerprint || join.room !== invite.room || join.owner !== invite.owner || join.inviteId !== invite.inviteId) fail("untrusted");
|
|
449
|
+
if ((input.nowMs ?? Date.now()) >= invite.expiresAt) fail("expired");
|
|
450
|
+
const secret = decodeBase64url(invite.secret, 32), core = joinCore(join);
|
|
451
|
+
if (!await verifyHmac(secret, domain("join-mac", { invite: inviteClaims(invite), request: { ...core, signature: join.signature } }), decodeBase64url(join.mac, 32))) fail("authentication_failed");
|
|
452
|
+
await authenticateIdentity(join.member);
|
|
453
|
+
if (!await verifyBytes(join.member, domain("join-signature", { invite: inviteClaims(invite), request: core }), decodeBase64url(join.signature, 64))) fail("authentication_failed");
|
|
454
|
+
await assertAgreementKeyUsable(join.member);
|
|
455
|
+
const key = await importEpochKey(secret);
|
|
456
|
+
const plain = await decryptEpoch({ key, purpose: "invite", context: joinContext(invite, join.member, join.nonce), nonce: decodeBase64url(join.privateNonce, 12), ciphertext: decodeBase64url(join.privateCiphertext) });
|
|
457
|
+
const privateData = decode(plain, 4096, x => { const o = object(x, ["label", "exposedPeers"]); return { label: id(o.label), exposedPeers: exposed(o.exposedPeers) }; });
|
|
458
|
+
if (!input.enrollment || typeof input.enrollment.consume !== "function") fail("state_unavailable");
|
|
459
|
+
const inviteDigest = await digest(canonicalBytes(invite)), requestDigest = await digest(canonicalBytes(join));
|
|
460
|
+
const member = { ...join.member, ...privateData };
|
|
461
|
+
const result = await portCall(() => input.enrollment.consume({ room: invite.room, owner: invite.owner, inviteId: invite.inviteId, inviteDigest, requestDigest, expiresAt: invite.expiresAt, member: structuredClone(member) }));
|
|
462
|
+
if (result === "accepted") return { status: "accepted", member };
|
|
463
|
+
if (result === "duplicate") return { status: "duplicate" };
|
|
464
|
+
fail("state_changed");
|
|
465
|
+
}
|
package/src/ui/index.html
CHANGED
|
@@ -194,6 +194,8 @@ progress.critical::-moz-progress-bar{background:var(--red)}
|
|
|
194
194
|
.peer-fold>summary{padding:var(--space-1) 0;color:var(--muted);font-weight:500;font-size:var(--text-xs)}
|
|
195
195
|
.peer-more{padding:var(--space-1) 0 0 var(--space-3);border-left:2px solid var(--line)}
|
|
196
196
|
.notice-warn{color:var(--amber)}
|
|
197
|
+
#rooms-section[data-empty="true"]{display:none}
|
|
198
|
+
.room-metadata{overflow-wrap:anywhere}
|
|
197
199
|
/* Stream: aligned rows. The filter chips and Follow latest sit in the section head, outside the scrolling list, so they stay in view while it scrolls. */
|
|
198
200
|
.stream{padding:0}
|
|
199
201
|
.event{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-1);align-items:baseline}
|
|
@@ -453,6 +455,8 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
|
|
|
453
455
|
</div>
|
|
454
456
|
<div class="stack">
|
|
455
457
|
<section id="projects-panel" aria-labelledby="projects-title" data-view="agents" hidden><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
|
|
458
|
+
<section id="schedules-section" aria-labelledby="schedules-title" data-view="agents" hidden><div class="section-head"><h2 id="schedules-title">Schedules</h2><span id="schedule-count" class="meta"></span></div><div id="schedules"></div></section>
|
|
459
|
+
<section id="rooms-section" aria-labelledby="rooms-title" data-view="agents" data-empty="true" hidden><div class="section-head"><h2 id="rooms-title">Rooms</h2><span id="room-count" class="meta"></span></div><div id="rooms"></div></section>
|
|
456
460
|
<section aria-labelledby="peers-title" data-view="agents" hidden><div class="section-head"><h2 id="peers-title">Peers & queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"><div class="skeleton" aria-hidden="true"><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span></div><p class="empty">Loading peers...</p></div></section>
|
|
457
461
|
<section aria-labelledby="permissions-title" data-view="agents" hidden><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
|
|
458
462
|
<section aria-labelledby="budget-title" data-view="agents" hidden><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div>
|
|
@@ -1160,10 +1164,10 @@ function renderPlanning(snapshot) {
|
|
|
1160
1164
|
});
|
|
1161
1165
|
}
|
|
1162
1166
|
|
|
1163
|
-
function peerMark(peer, reviewer = false) {
|
|
1167
|
+
function peerMark(peer, reviewer = false, role) {
|
|
1164
1168
|
const key = Object.hasOwn(peerPalette, peer || '') ? peer : 'other';
|
|
1165
1169
|
const mark = el('span', undefined, 'peer-mark peer-' + key), color = el('span', undefined, 'peer-color-dot' + (reviewer ? ' reviewer' : ''));
|
|
1166
|
-
color.setAttribute('aria-hidden', 'true'); mark.append(color, el('span', (reviewer ? 'reviewer: '
|
|
1170
|
+
color.setAttribute('aria-hidden', 'true'); mark.append(color, el('span', (role || (reviewer ? 'reviewer' : 'owner')) + ': ' + (peer || 'unassigned'))); return mark;
|
|
1167
1171
|
}
|
|
1168
1172
|
// A sprite icon in the page (#469 R4). Decorative unless a label is given; the text beside it carries the meaning otherwise.
|
|
1169
1173
|
const icon = (id, label) => {
|
|
@@ -1288,7 +1292,11 @@ function taskDetail(task, card, stage, ids, overdue) {
|
|
|
1288
1292
|
if(overdue)status.append(badge('Overdue','warn'));
|
|
1289
1293
|
row.append(status);
|
|
1290
1294
|
const people = el('section', undefined, 'detail-group'); people.append(el('h4', 'People', 'detail-heading'));
|
|
1291
|
-
|
|
1295
|
+
const holder = (role, peer, reason) => { const mark = peerMark(peer, role === 'reviewer', role); if (reason) mark.append(el('span', ' (' + reason + ')', 'meta')); people.append(mark); };
|
|
1296
|
+
if (Object.prototype.hasOwnProperty.call(task, 'planner')) holder('planner', task.planner, task.plannerReason);
|
|
1297
|
+
const ownerReason = [task.ownerReason, task.reserved && (!task.owner || task.reserved === task.owner) ? 'reserved' : undefined].filter(Boolean).join('; ');
|
|
1298
|
+
holder('owner', task.owner || task.reserved, ownerReason);
|
|
1299
|
+
if (task.reviewer) holder('reviewer', task.reviewer, task.reviewerReason || (task.sameFamilyFallback === true ? 'same-family fallback' : undefined));
|
|
1292
1300
|
if(task.role){const guidance='Task guidance: '+task.role+' (grants no authority)';people.append(el('p',guidance,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+guidance);}
|
|
1293
1301
|
row.append(people);
|
|
1294
1302
|
if (task.detail) row.append(el('p', task.detail, 'body'));
|
|
@@ -1489,6 +1497,75 @@ function formatCounter(c, exact) {
|
|
|
1489
1497
|
}
|
|
1490
1498
|
const USAGE_LIMIT_ROWS = 20;
|
|
1491
1499
|
const USAGE_COVERAGE_NOTE = 'Coverage: Jcode and Kimi (prior to #285) report hub-counted increments; unrecorded sources are noted as unknown.';
|
|
1500
|
+
/** #335: what a schedule row says about its next run. The hub classifies (scheduled, due, waiting, held, paused); the page only words it and never invents a time. */
|
|
1501
|
+
function scheduleNextLabel(row) {
|
|
1502
|
+
const at = Number.isSafeInteger(row?.next) && row.next > 0 ? new Date(row.next).toLocaleString() : 'unknown';
|
|
1503
|
+
if (row?.state === 'paused') return 'Paused (would be ' + at + ')';
|
|
1504
|
+
if (row?.state === 'scheduled') return at;
|
|
1505
|
+
if (row?.state === 'due') return 'Due now';
|
|
1506
|
+
if (row?.state === 'waiting') return 'Due, waiting for task #' + row.task + ' to close';
|
|
1507
|
+
if (row?.state === 'held') return 'Due, held (the hub is stopping or recovering)';
|
|
1508
|
+
return 'unknown';
|
|
1509
|
+
}
|
|
1510
|
+
function renderSchedules(schedules) {
|
|
1511
|
+
const section = $('schedules-section'), target = $('schedules');
|
|
1512
|
+
const rows = Array.isArray(schedules) ? schedules : [];
|
|
1513
|
+
section.dataset.empty = rows.length ? 'false' : 'true';
|
|
1514
|
+
$('schedule-count').textContent = rows.length ? rows.length + (rows.length === 1 ? ' schedule' : ' schedules') : '';
|
|
1515
|
+
target.replaceChildren();
|
|
1516
|
+
if (!rows.length) { empty(target, 'No schedules configured.'); return; }
|
|
1517
|
+
const wrap = el('div', undefined, 'usage-table-wrap'), table = el('table', undefined, 'usage-table'), head = el('tr');
|
|
1518
|
+
for (const name of ['Schedule', 'Every', 'Class', 'State', 'Next run', 'Last run', 'Task']) head.append(el('th', name));
|
|
1519
|
+
table.append(head);
|
|
1520
|
+
for (const row of rows) {
|
|
1521
|
+
const line = el('tr');
|
|
1522
|
+
line.append(el('td', String(row.id)), el('td', String(row.every)), el('td', String(row.class) + ' / ' + String(row.queue)), el('td', String(row.state)),
|
|
1523
|
+
el('td', scheduleNextLabel(row)), el('td', Number.isSafeInteger(row.last) ? new Date(row.last).toLocaleString() : 'never'), el('td', Number.isSafeInteger(row.task) ? '#' + row.task : '-'));
|
|
1524
|
+
if (row.error) line.setAttribute('title', 'Last run failed: ' + String(row.error));
|
|
1525
|
+
table.append(line);
|
|
1526
|
+
}
|
|
1527
|
+
wrap.append(table); target.append(wrap);
|
|
1528
|
+
}
|
|
1529
|
+
function roomMetadataText(value, pattern, limit) {
|
|
1530
|
+
if (typeof value !== 'string' || value.length > limit) return 'unknown';
|
|
1531
|
+
const match = pattern.exec(value);
|
|
1532
|
+
return match && match[0] === value ? value : 'unknown';
|
|
1533
|
+
}
|
|
1534
|
+
function roomMetadataCount(value) { return Number.isSafeInteger(value) && value >= 0 ? String(value) : 'unknown'; }
|
|
1535
|
+
function renderRooms(rooms) {
|
|
1536
|
+
const section = $('rooms-section'), target = $('rooms');
|
|
1537
|
+
const rows = Array.isArray(rooms) ? rooms : [], unavailable = rooms !== undefined && !Array.isArray(rooms);
|
|
1538
|
+
section.dataset.empty = !unavailable && !rows.length ? 'true' : 'false';
|
|
1539
|
+
$('room-count').textContent = rows.length ? rows.length + (rows.length === 1 ? ' room' : ' rooms') : '';
|
|
1540
|
+
target.replaceChildren();
|
|
1541
|
+
if (unavailable) { empty(target, 'Room metadata unavailable.'); return; }
|
|
1542
|
+
for (const room of rows) {
|
|
1543
|
+
const row = el('div', undefined, 'row room-metadata'), head = el('div', undefined, 'row-head');
|
|
1544
|
+
const name = roomMetadataText(room.name, /^[a-z][a-z0-9-]{0,31}$/, 32);
|
|
1545
|
+
const link = ['online', 'connecting', 'offline', 'left'].includes(room.link) ? room.link : 'unknown';
|
|
1546
|
+
head.append(el('span', name, 'label'), badge(link, link === 'online' ? 'ok' : ''));
|
|
1547
|
+
row.append(head, el('p', 'Room: ' + roomMetadataText(room.room, /^[A-Za-z0-9_-]{43}$/, 43) + ' / epoch ' + roomMetadataText(room.epoch, /^[1-9][0-9]{0,19}$/, 20), 'meta'));
|
|
1548
|
+
row.append(el('p', 'Owner fingerprint: ' + roomMetadataText(room.owner, /^[a-f0-9]{64}$/, 64), 'meta'));
|
|
1549
|
+
row.append(el('p', 'Self fingerprint: ' + roomMetadataText(room.member, /^[a-f0-9]{64}$/, 64) + ' / members: ' + roomMetadataCount(room.memberCount), 'meta'));
|
|
1550
|
+
if (!Array.isArray(room.remotePeers)) row.append(el('p', 'Remote peers: unknown', 'meta'));
|
|
1551
|
+
else if (!room.remotePeers.length) row.append(el('p', 'No exposed remote peers.', 'meta'));
|
|
1552
|
+
else {
|
|
1553
|
+
const peers = el('div', undefined, 'peer-summary');
|
|
1554
|
+
for (const peer of room.remotePeers) {
|
|
1555
|
+
const id = roomMetadataText(peer.id, /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/, 65);
|
|
1556
|
+
const state = ['idle', 'busy', 'paused', 'offline'].includes(peer.state) ? peer.state : 'unknown';
|
|
1557
|
+
peers.append(el('span', id + ': ' + state, 'meta'));
|
|
1558
|
+
}
|
|
1559
|
+
row.append(peers);
|
|
1560
|
+
}
|
|
1561
|
+
const stats = room.relays, since = Number.isSafeInteger(stats?.since) && stats.since > 0 ? new Date(stats.since) : undefined;
|
|
1562
|
+
const start = since && Number.isFinite(since.getTime()) ? since.toISOString() : 'unknown';
|
|
1563
|
+
row.append(el('p', 'Relay counters since daemon start (start: ' + start + ')', 'meta'));
|
|
1564
|
+
const counters = el('div', undefined, 'peer-summary');
|
|
1565
|
+
counters.append(el('span', 'Outbound handoffs: ' + roomMetadataCount(stats?.outbound), 'meta'), el('span', 'Inbound admitted: ' + roomMetadataCount(stats?.inbound), 'meta'), el('span', 'Withheld outbound (subset): ' + roomMetadataCount(stats?.withheld), 'meta'));
|
|
1566
|
+
row.append(counters); target.append(row);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1492
1569
|
function renderUsage(usage, tasks, budget) {
|
|
1493
1570
|
if (!usage) return;
|
|
1494
1571
|
// A failed read is not a hidden view: replace the old figures with an explicit state so stale totals are never shown as current.
|
|
@@ -1642,9 +1719,14 @@ function renderUsage(usage, tasks, budget) {
|
|
|
1642
1719
|
}
|
|
1643
1720
|
// Mirrors windowKind in src/hub/window-kind.ts (the page imports nothing): a window from an older hub has no kind. test/ui.test.ts keeps the two equal.
|
|
1644
1721
|
function windowKindOf(w) { return w.kind || (w.id === 'tokens' ? 'count' : w.source === 'ahub budget set' ? 'manual' : 'native'); }
|
|
1722
|
+
function appendRoleDetails(target, peer) {
|
|
1723
|
+
if (typeof peer.form === 'string') target.append(el('p', 'Form: ' + peer.form, 'meta'));
|
|
1724
|
+
if (Array.isArray(peer.capableRoles)) target.append(el('p', 'Capable roles: ' + (peer.capableRoles.join(', ') || 'none'), 'meta'));
|
|
1725
|
+
if (typeof peer.ownerIsolation === 'boolean') target.append(el('p', 'Owner isolation: ' + (peer.ownerIsolation ? 'isolated' : 'not isolated: ' + (peer.ownerIsolationNote || 'execution root not verified')), 'meta'));
|
|
1726
|
+
}
|
|
1645
1727
|
function render(snapshot) {
|
|
1646
1728
|
renderNeedsAttention(snapshot);
|
|
1647
|
-
const peers = Object.fromEntries(Object.entries(snapshot.status.peers || {}).map(([id, peer]) => [id, {...peer, ...(snapshot.status.roleInfo || {})[id]}])), ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
|
|
1729
|
+
const peers = Object.fromEntries(Object.entries(snapshot.status.peers || {}).map(([id, peer]) => [id, {...peer, ...(snapshot.status.roleInfo || {})[id], ...(typeof snapshot.status.ownerIsolation?.[id] === 'boolean' ? {ownerIsolation:snapshot.status.ownerIsolation[id],ownerIsolationNote:snapshot.status.ownerIsolationNotes?.[id]} : {})}])), ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
|
|
1648
1730
|
$('peer-count').textContent = ids.length + ' peers';
|
|
1649
1731
|
syncStarts(snapshot);
|
|
1650
1732
|
update('peers', [peers, starts, starts.map(start => { const pending = pendingStarts.get(startKey(start.peer)); return pending ? [pending.phase, pending.retryAt || null] : null; })], (target) => {
|
|
@@ -1660,11 +1742,14 @@ function render(snapshot) {
|
|
|
1660
1742
|
// Summary row (#469 R4): name, state, current task and queue. The diagnostic lines below fold into one disclosure.
|
|
1661
1743
|
const current = (snapshot.boardLegend || []).find(item => item.peer === id)?.activeTasks, summary = el('p', undefined, 'peer-summary');
|
|
1662
1744
|
summary.append(el('span', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'), el('span', Array.isArray(current) && current.length ? 'Working on ' + current.map(task => '#' + task).join(', ') : 'No active task', 'meta'));
|
|
1745
|
+
// #878: the session title or current plan item the hub observed, already screened (a stub while private).
|
|
1746
|
+
if (typeof peer.doing?.text === 'string' && peer.doing.text) summary.append(el('span', 'Doing: ' + peer.doing.text, 'meta'));
|
|
1663
1747
|
row.append(head, summary);
|
|
1664
1748
|
// A status notice asks a person to act (for example, reattach a native session): it stays visible, never folded away.
|
|
1665
1749
|
if (typeof peer.statusNotice?.message === 'string' && peer.statusNotice.message) row.append(el('p', peer.statusNotice.message, 'meta notice-warn'));
|
|
1666
1750
|
const more = el('div', undefined, 'peer-more');
|
|
1667
1751
|
if (peer.flowRoles) more.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
|
|
1752
|
+
appendRoleDetails(more, peer);
|
|
1668
1753
|
if (peer.catalogRoles) { more.append(el('p', 'Catalog guidance: ' + (Array.isArray(peer.catalogRoles.value) ? peer.catalogRoles.value.join(', ') || 'none' : 'unconfirmed'), 'meta')); if (peer.catalogRoles.pending !== undefined) more.append(el('p', 'Pending at next start: ' + (peer.catalogRoles.pending.join(', ') || 'none'), 'meta')); }
|
|
1669
1754
|
if (peer.needsReview) more.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
|
|
1670
1755
|
if (peer.oldestQueuedAt !== undefined) more.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
|
|
@@ -1726,6 +1811,8 @@ function render(snapshot) {
|
|
|
1726
1811
|
renderMemory(snapshot.memory);
|
|
1727
1812
|
renderSettings(snapshot.settings, snapshot.roster);
|
|
1728
1813
|
renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
|
|
1814
|
+
renderSchedules(snapshot.schedules);
|
|
1815
|
+
renderRooms(snapshot.rooms);
|
|
1729
1816
|
const stream = $('stream');
|
|
1730
1817
|
if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
|
|
1731
1818
|
for (const entry of snapshot.events || []) {
|