@staix/agent-hub 0.19.0 → 0.21.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 +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- 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 +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- 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/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 +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- 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 +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -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 +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- 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 +33 -0
- package/src/cli/smoke-pty.ts +145 -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 +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -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 +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- 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 +85 -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 +76 -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 +46 -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 +7 -1
- 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/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- 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,294 @@
|
|
|
1
|
+
/** Host-only room administration. No remote envelope enters the local bus in X3. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, signBytes, verifyBytes } from "./crypto.ts";
|
|
5
|
+
import { acceptRoomEpoch, brokerUrl, canonicalBytes, createInvite, createJoin, createRoomEpoch, decodeInvite, decodeJoin, decodeRoster, roomMembers } from "./wire.ts";
|
|
6
|
+
import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "./store.ts";
|
|
7
|
+
import { RoomLink } from "./link.ts";
|
|
8
|
+
import { exposedPeers, roomName, RoomPolicy } from "./policy.ts";
|
|
9
|
+
import type { RoomChat } from "./service.ts";
|
|
10
|
+
import { projectRoomStatus, type RoomStatusView } from "./status.ts";
|
|
11
|
+
|
|
12
|
+
export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
|
|
13
|
+
export interface RoomPreview { owner: string; member: string; commit: () => Promise<RoomCommandResult> }
|
|
14
|
+
const FP = /^[0-9a-f]{64}$/;
|
|
15
|
+
function refuse(code: string): never { throw new Error(`room: ${code}`); }
|
|
16
|
+
function fields(input: unknown, keys: string[], optional: string[] = []): Record<string, any> {
|
|
17
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) refuse("invalid_input");
|
|
18
|
+
const row = input as Record<string, any>;
|
|
19
|
+
if (Object.keys(row).some(key => !keys.includes(key) && !optional.includes(key)) || keys.some(key => !Object.hasOwn(row, key))) refuse("invalid_input");
|
|
20
|
+
return row;
|
|
21
|
+
}
|
|
22
|
+
function artifact(value: unknown, max: number): Uint8Array {
|
|
23
|
+
if (typeof value !== "string" || value.length > Math.ceil(max * 4 / 3)) refuse("invalid_artifact");
|
|
24
|
+
try { const wire = decodeBase64url(value); if (wire.length > max) refuse("invalid_artifact"); return wire; }
|
|
25
|
+
catch { return refuse("invalid_artifact"); }
|
|
26
|
+
}
|
|
27
|
+
function audience(raw: unknown): string {
|
|
28
|
+
const value = brokerUrl(raw), url = new URL(value);
|
|
29
|
+
if (url.pathname !== "/" || url.search) refuse("invalid_broker");
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function exposureBytes(request: Omit<ExposureRequest, "signature">): Uint8Array {
|
|
33
|
+
return canonicalBytes(["agent-hub.rooms.exposure.v1", request]);
|
|
34
|
+
}
|
|
35
|
+
interface ExposureRequest { v: 1; type: "exposure"; room: string; owner: string; member: string; digest: string; peers: string[]; signature: string }
|
|
36
|
+
function exposureRequest(wire: Uint8Array): ExposureRequest | undefined {
|
|
37
|
+
let raw: unknown; try { raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(wire)); } catch { return undefined; }
|
|
38
|
+
if ((raw as any)?.type !== "exposure") return undefined;
|
|
39
|
+
const row = fields(raw, ["v", "type", "room", "owner", "member", "digest", "peers", "signature"]);
|
|
40
|
+
if (row.v !== 1 || !FP.test(row.owner) || !FP.test(row.member) || typeof row.room !== "string" || typeof row.digest !== "string" || typeof row.signature !== "string") refuse("invalid_artifact");
|
|
41
|
+
exposedPeers(row.peers);
|
|
42
|
+
const normalized = canonicalBytes(row);
|
|
43
|
+
if (normalized.length !== wire.length || normalized.some((byte, i) => byte !== wire[i])) refuse("invalid_artifact");
|
|
44
|
+
return row as ExposureRequest;
|
|
45
|
+
}
|
|
46
|
+
export class RoomCommands {
|
|
47
|
+
private pending?: Promise<RoomStore>;
|
|
48
|
+
private store?: RoomStore;
|
|
49
|
+
private stopped = false;
|
|
50
|
+
private readonly links = new Map<string, RoomLink>();
|
|
51
|
+
private readonly policy: RoomPolicy;
|
|
52
|
+
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
|
|
53
|
+
private async open(): Promise<RoomStore> {
|
|
54
|
+
if (this.stopped) refuse("stopped");
|
|
55
|
+
this.pending ??= RoomStore.open({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
|
|
56
|
+
if (this.stopped) { store.close(); return refuse("stopped"); }
|
|
57
|
+
this.store = store; return store;
|
|
58
|
+
});
|
|
59
|
+
return this.pending;
|
|
60
|
+
}
|
|
61
|
+
async start(): Promise<void> {
|
|
62
|
+
if (!existsSync(join(this.options.stateDir, "rooms"))) return;
|
|
63
|
+
const store = await this.open();
|
|
64
|
+
if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
|
|
65
|
+
}
|
|
66
|
+
stop(): void { this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop(); for (const link of this.links.values()) link.stop(); this.links.clear(); this.store?.close(); }
|
|
67
|
+
private link(name: string): void {
|
|
68
|
+
if (this.stopped || !this.store || this.store.get(name)?.left) return;
|
|
69
|
+
let link = this.links.get(name);
|
|
70
|
+
if (!link) {
|
|
71
|
+
const store = this.store;
|
|
72
|
+
const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application,
|
|
73
|
+
onPresence: value => this.options.chat?.presence(store, name, created, value),
|
|
74
|
+
onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
|
|
75
|
+
const allowed = new Set(this.policy.get(name));
|
|
76
|
+
return (this.options.presence?.() ?? []).filter(row => allowed.has(row.id));
|
|
77
|
+
} });
|
|
78
|
+
this.links.set(name, created); link = created;
|
|
79
|
+
this.options.chat?.bind(store, name, created);
|
|
80
|
+
void created.start().catch(() => undefined);
|
|
81
|
+
} else { this.options.chat?.bind(this.store, name, link); link.refresh(); }
|
|
82
|
+
}
|
|
83
|
+
private presenceTimer?: ReturnType<typeof setTimeout>;
|
|
84
|
+
changed(): void {
|
|
85
|
+
if (this.stopped || this.presenceTimer) return;
|
|
86
|
+
// Coalesce peer state bursts; payloads remain the signed ids/state-only presence shape.
|
|
87
|
+
this.presenceTimer = setTimeout(() => {
|
|
88
|
+
this.presenceTimer = undefined;
|
|
89
|
+
if (!this.stopped) for (const link of this.links.values()) void link.publishPresence().catch(() => undefined);
|
|
90
|
+
}, 100);
|
|
91
|
+
this.presenceTimer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
private current(check: () => boolean): void { if (this.stopped || !check()) refuse("state_changed"); }
|
|
94
|
+
private active(store: RoomStore, name: string): RoomState {
|
|
95
|
+
const state = store.get(name); if (!state || state.left) refuse("unavailable"); return state;
|
|
96
|
+
}
|
|
97
|
+
private async inboxStore(check: () => boolean): Promise<RoomStore | undefined> {
|
|
98
|
+
this.current(check);
|
|
99
|
+
if (!this.store && !this.pending && !existsSync(join(this.options.stateDir, "rooms", "rooms.db"))) return undefined;
|
|
100
|
+
const store = await this.open(); this.current(check); return store;
|
|
101
|
+
}
|
|
102
|
+
async inboxRows(name: unknown, check: () => boolean): Promise<RoomPersonInboxMeta[]> {
|
|
103
|
+
this.current(check); if (name !== undefined) roomName(name);
|
|
104
|
+
const store = await this.inboxStore(check); this.current(check);
|
|
105
|
+
const rows = store?.listPersonInbox(name as string | undefined) ?? []; this.current(check); return rows;
|
|
106
|
+
}
|
|
107
|
+
async inboxShow(id: unknown, check: () => boolean): Promise<RoomPersonInboxItem | undefined> {
|
|
108
|
+
this.current(check);
|
|
109
|
+
if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)) refuse("invalid_input");
|
|
110
|
+
const store = await this.inboxStore(check); this.current(check);
|
|
111
|
+
const item = store?.readPersonInbox(id, () => { this.current(check); return true; }); this.current(check); return item;
|
|
112
|
+
}
|
|
113
|
+
async inboxDispose(id: unknown, action: "done" | "discard", check: () => boolean): Promise<RoomPersonInboxMeta | undefined> {
|
|
114
|
+
this.current(check);
|
|
115
|
+
if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)) refuse("invalid_input");
|
|
116
|
+
const store = await this.inboxStore(check); this.current(check);
|
|
117
|
+
const item = store?.disposePersonInbox(id, action, () => { this.current(check); return true; }); this.current(check);
|
|
118
|
+
this.current(check); return item;
|
|
119
|
+
}
|
|
120
|
+
/** Host calls this after replying to a committed person disposition. */
|
|
121
|
+
retryInboxPending(): void {
|
|
122
|
+
queueMicrotask(() => {
|
|
123
|
+
if (this.stopped) return;
|
|
124
|
+
try {
|
|
125
|
+
this.store?.expirePersonInbox(); // Consume cleanup signals together with this explicit opportunity.
|
|
126
|
+
void this.options.chat?.retryPending().catch(() => undefined);
|
|
127
|
+
} catch { /* A retry failure cannot change an already committed and acknowledged disposition. */ }
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Loaded-state housekeeping only, including rooms that have been left. */
|
|
131
|
+
expireInbox(): void {
|
|
132
|
+
if (this.stopped || !this.store?.expirePersonInbox()) return;
|
|
133
|
+
// Only a real local purge creates this opportunity; idle ticks never poll plaintext.
|
|
134
|
+
queueMicrotask(() => { if (!this.stopped) void this.options.chat?.retryPending().catch(() => undefined); });
|
|
135
|
+
}
|
|
136
|
+
async rows(name?: string, details = name !== undefined): Promise<Record<string, unknown>[]> {
|
|
137
|
+
if (!this.store && !existsSync(join(this.options.stateDir, "rooms"))) return [];
|
|
138
|
+
const store = await this.open(); if (name !== undefined) roomName(name);
|
|
139
|
+
return store.list().filter(row => name === undefined || row.name === name).map(row => ({ name: row.name, room: row.current.room, owner: this.owner(row), member: store.identity.public.fingerprint, epoch: row.current.epoch, state: row.left ? "left" : this.links.get(row.name)?.status().state ?? "offline", ...(details && this.options.chat ? { deliveries: this.options.chat.rows(row.name), remotePeers: this.options.chat.peers(row.name) } : {}) }));
|
|
140
|
+
}
|
|
141
|
+
/** Polling uses only already-loaded public metadata; it never opens room state or reads message rows. */
|
|
142
|
+
statusView(): RoomStatusView[] | null {
|
|
143
|
+
if (this.stopped) return null;
|
|
144
|
+
if (!this.store) return this.pending ? null : [];
|
|
145
|
+
try {
|
|
146
|
+
return projectRoomStatus(this.store.statusMetadata().map(row => ({
|
|
147
|
+
name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch,
|
|
148
|
+
state: row.left ? "left" : this.links.get(row.name)?.connectionState ?? "offline", members: row.members,
|
|
149
|
+
remotePeers: this.options.chat?.peersForStatus(row.name) ?? null,
|
|
150
|
+
relays: this.options.chat?.metrics(row.name, row.room) ?? null,
|
|
151
|
+
})));
|
|
152
|
+
} catch { return null; }
|
|
153
|
+
}
|
|
154
|
+
async preview(action: unknown, raw: unknown, check: () => boolean): Promise<RoomPreview> {
|
|
155
|
+
if (typeof action !== "string") refuse("invalid_operation");
|
|
156
|
+
await assertRoomCryptoReady(); this.current(check);
|
|
157
|
+
const store = await this.open(); this.current(check);
|
|
158
|
+
const own = store.identity.public.fingerprint;
|
|
159
|
+
const completed = (state: RoomState): RoomCommandResult => { this.link(state.name); return { name: state.name, room: state.current.room, owner: this.owner(state), member: own }; };
|
|
160
|
+
if (action === "create") {
|
|
161
|
+
const row = fields(raw, ["name", "broker"], ["label"]), name = roomName(row.name), broker = audience(row.broker), label = roomName(row.label ?? `hub-${own.slice(0, 8)}`);
|
|
162
|
+
if (store.get(name)) refuse("already_exists");
|
|
163
|
+
return { owner: own, member: own, commit: async () => {
|
|
164
|
+
this.current(check);
|
|
165
|
+
const candidate = await createRoomEpoch({ owner: store.identity, members: [{ ...store.identity.public, label, exposedPeers: [] }] });
|
|
166
|
+
this.current(check);
|
|
167
|
+
const state = await store.install({ name, broker, wire: candidate.wire, expectedDigest: null, current: check });
|
|
168
|
+
return completed(state);
|
|
169
|
+
} };
|
|
170
|
+
}
|
|
171
|
+
if (action === "join" && Object.hasOwn(fields(raw, [], ["token", "name", "label", "roster"]), "token")) {
|
|
172
|
+
const row = fields(raw, ["token"], ["name", "label"]), inviteWire = artifact(row.token, 16384), invite = decodeInvite(inviteWire);
|
|
173
|
+
const name = roomName(row.name ?? `room-${invite.room.slice(0, 8).toLowerCase().replace(/[^a-z0-9-]/g, "a")}`), label = roomName(row.label ?? `hub-${own.slice(0, 8)}`);
|
|
174
|
+
const peers = this.policy.get(name), selection = JSON.stringify({ label, peers }), saved = store.pendingJoin(name);
|
|
175
|
+
if (Date.now() > invite.expiresAt + 600000) refuse("expired");
|
|
176
|
+
if (saved && (encodeBase64url(saved.invite) !== row.token || saved.selection !== selection)) refuse("pending_join_changed");
|
|
177
|
+
return { owner: invite.owner, member: own, commit: async () => {
|
|
178
|
+
this.current(check);
|
|
179
|
+
if (saved) {
|
|
180
|
+
const latest = store.pendingJoin(name);
|
|
181
|
+
if (!latest || encodeBase64url(latest.request) !== encodeBase64url(saved.request)) refuse("state_changed");
|
|
182
|
+
return { name, owner: invite.owner, member: own, kind: "join-request", artifact: encodeBase64url(saved.request) };
|
|
183
|
+
}
|
|
184
|
+
const request = await createJoin({ invite: inviteWire, identity: store.identity, label, exposedPeers: peers });
|
|
185
|
+
this.current(check); store.savePendingJoin(name, { invite: inviteWire, request, selection });
|
|
186
|
+
return { name, owner: invite.owner, member: own, kind: "join-request", artifact: encodeBase64url(request) };
|
|
187
|
+
} };
|
|
188
|
+
}
|
|
189
|
+
if (action === "join") {
|
|
190
|
+
const row = fields(raw, ["name", "roster"]), name = roomName(row.name), wire = artifact(row.roster, 524288), pending = store.pendingJoin(name), existing = store.get(name);
|
|
191
|
+
if (!pending && !existing) refuse("no_pending_invitation");
|
|
192
|
+
const invitation = pending ? decodeInvite(pending.invite) : undefined;
|
|
193
|
+
const owner = existing ? this.owner(existing) : invitation!.owner;
|
|
194
|
+
const broker = existing?.broker ?? invitation!.broker;
|
|
195
|
+
await acceptRoomEpoch({ wire, identity: store.identity, pin: existing
|
|
196
|
+
? { room: existing.current.room, owner, minEpoch: existing.current.epoch, digest: existing.current.digest }
|
|
197
|
+
: { room: invitation!.room, owner, minEpoch: "0" } });
|
|
198
|
+
this.current(check);
|
|
199
|
+
return { owner, member: own, commit: async () => {
|
|
200
|
+
this.current(check);
|
|
201
|
+
const state = await store.install({ name, broker, wire, expectedDigest: existing?.current.digest ?? null, current: check, reactivate: true });
|
|
202
|
+
return completed(state);
|
|
203
|
+
} };
|
|
204
|
+
}
|
|
205
|
+
if (action === "resolve") {
|
|
206
|
+
const row = fields(raw, ["name", "frame", "action"]), name = roomName(row.name);
|
|
207
|
+
if (typeof row.frame !== "string" || row.frame.length !== 43 || (row.action !== "accept" && row.action !== "discard")) refuse("invalid_resolution");
|
|
208
|
+
try { decodeBase64url(row.frame, 32); } catch { refuse("invalid_resolution"); }
|
|
209
|
+
// Disposition is metadata-only and remains available after leave or sender revocation.
|
|
210
|
+
const state = store.get(name), incoming = store.incomingApplication(name, row.frame);
|
|
211
|
+
if (!state || !incoming || incoming.state !== "needs_review" || !FP.test(incoming.sender)) refuse("resolution_unavailable");
|
|
212
|
+
const frame = row.frame as string, choice = row.action as "accept" | "discard", owner = this.owner(state), member = incoming.sender;
|
|
213
|
+
const binding = JSON.stringify([incoming.digest, incoming.sender, incoming.application, incoming.peer, incoming.state]);
|
|
214
|
+
const current = () => {
|
|
215
|
+
this.current(check);
|
|
216
|
+
const latest = store.incomingApplication(name, frame);
|
|
217
|
+
return !!latest && JSON.stringify([latest.digest, latest.sender, latest.application, latest.peer, latest.state]) === binding;
|
|
218
|
+
};
|
|
219
|
+
return { owner, member, commit: async () => {
|
|
220
|
+
if (!current()) refuse("state_changed");
|
|
221
|
+
store.disposeIncoming(name, frame, choice, current);
|
|
222
|
+
await this.options.chat?.retryPending();
|
|
223
|
+
return { name, room: state.current.room, owner, member, kind: "disposed" };
|
|
224
|
+
} };
|
|
225
|
+
}
|
|
226
|
+
const row = fields(raw, ["name"], action === "invite" ? ["request"] : action === "revoke" ? ["member"] : action === "expose" ? ["peers"] : []);
|
|
227
|
+
const name = roomName(row.name);
|
|
228
|
+
const pending = action === "leave" && !store.get(name) ? store.pendingJoin(name) : undefined;
|
|
229
|
+
if (pending) return { owner: decodeInvite(pending.invite).owner, member: own, commit: async () => {
|
|
230
|
+
this.current(check);
|
|
231
|
+
const latest = store.pendingJoin(name);
|
|
232
|
+
if (!latest || encodeBase64url(latest.invite) !== encodeBase64url(pending.invite) || encodeBase64url(latest.request) !== encodeBase64url(pending.request)) refuse("state_changed");
|
|
233
|
+
store.cancelPendingJoin(name); return { name, owner: decodeInvite(pending.invite).owner, member: own };
|
|
234
|
+
} };
|
|
235
|
+
const state = this.active(store, name), owner = this.owner(state), digest = state.current.digest;
|
|
236
|
+
if (action === "leave") return { owner, member: own, commit: async () => {
|
|
237
|
+
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
238
|
+
store.leave(name); this.links.get(name)?.stop(); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
|
|
239
|
+
} };
|
|
240
|
+
if (action === "invite") {
|
|
241
|
+
if (owner !== own) refuse("owner_only");
|
|
242
|
+
if (row.request === undefined) return { owner, member: own, commit: async () => {
|
|
243
|
+
this.current(check);
|
|
244
|
+
const wire = await createInvite({ owner: store.identity, epoch: state.current, broker: state.broker, expiresAt: Date.now() + 15 * 60_000 });
|
|
245
|
+
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
246
|
+
await store.saveInvite(name, wire, check);
|
|
247
|
+
return { name, owner, member: own, kind: "invitation", artifact: encodeBase64url(wire) };
|
|
248
|
+
} };
|
|
249
|
+
const request = artifact(row.request, 16384), exposure = exposureRequest(request);
|
|
250
|
+
const member = exposure?.member ?? decodeJoin(request).member.fingerprint;
|
|
251
|
+
if (exposure) {
|
|
252
|
+
const { signature, ...body } = exposure;
|
|
253
|
+
const sender = roomMembers(state.current).find(m => m.fingerprint === member);
|
|
254
|
+
if (!sender || exposure.room !== state.current.room || exposure.owner !== owner || exposure.digest !== digest || !await verifyBytes({ fingerprint: sender.fingerprint, signingKey: sender.signingKey, agreementKey: sender.agreementKey }, exposureBytes(body), decodeBase64url(signature, 64))) refuse("untrusted");
|
|
255
|
+
}
|
|
256
|
+
this.current(check);
|
|
257
|
+
return { owner, member, commit: async () => {
|
|
258
|
+
this.current(check);
|
|
259
|
+
const next = exposure ? await store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, check) : await store.enroll(name, request, check);
|
|
260
|
+
this.current(check); completed(next);
|
|
261
|
+
return { name, owner, member, kind: "roster", artifact: encodeBase64url(next.wire) };
|
|
262
|
+
} };
|
|
263
|
+
}
|
|
264
|
+
if (action === "revoke") {
|
|
265
|
+
if (owner !== own) refuse("owner_only");
|
|
266
|
+
if (typeof row.member !== "string" || !FP.test(row.member) || row.member === own || !roomMembers(state.current).some(m => m.fingerprint === row.member)) refuse("invalid_member");
|
|
267
|
+
return { owner, member: row.member, commit: async () => {
|
|
268
|
+
this.current(check); const next = await store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, check); return completed(next);
|
|
269
|
+
} };
|
|
270
|
+
}
|
|
271
|
+
if (action === "expose") {
|
|
272
|
+
const peers = exposedPeers(row.peers ?? []);
|
|
273
|
+
this.policy.assertWritable(name, peers, check);
|
|
274
|
+
return { owner, member: own, commit: async () => {
|
|
275
|
+
this.current(check); this.policy.assertWritable(name, peers, check);
|
|
276
|
+
if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
277
|
+
if (owner === own) {
|
|
278
|
+
const next = await store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, check);
|
|
279
|
+
this.current(check); this.policy.set(name, peers, check); return completed(next);
|
|
280
|
+
}
|
|
281
|
+
const body: Omit<ExposureRequest, "signature"> = { v: 1, type: "exposure", room: state.current.room, owner, member: own, digest, peers };
|
|
282
|
+
const request = canonicalBytes({ ...body, signature: encodeBase64url(await signBytes(store.identity, exposureBytes(body))) });
|
|
283
|
+
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
284
|
+
this.policy.set(name, peers, check); this.changed();
|
|
285
|
+
return { name, owner, member: own, kind: "exposure-request", artifact: encodeBase64url(request) };
|
|
286
|
+
} };
|
|
287
|
+
}
|
|
288
|
+
return refuse("invalid_operation");
|
|
289
|
+
}
|
|
290
|
+
private owner(state: RoomState): string {
|
|
291
|
+
// The installed, verified signed roster is the authority; never infer owner from array order.
|
|
292
|
+
return decodeRoster(state.wire).owner;
|
|
293
|
+
}
|
|
294
|
+
}
|