@staix/agent-hub 0.20.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 +59 -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 +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- 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 +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -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/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 +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 +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 +32 -5
- 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 +1150 -171
- 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 +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 +6 -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/tasks.ts +910 -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,114 @@
|
|
|
1
|
+
/** Encrypted X4 application payloads. Transport signatures/epochs remain in wire.ts. */
|
|
2
|
+
import { MAX_HOP, type Priority } from "../hub/envelope.ts";
|
|
3
|
+
import { decodeBase64url } from "./crypto.ts";
|
|
4
|
+
import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
|
|
5
|
+
|
|
6
|
+
export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
|
|
7
|
+
export interface RoomChatEnvelope {
|
|
8
|
+
id: string; trace: string; hop: number; from: string; to: string;
|
|
9
|
+
kind: "chat" | "status"; priority: Priority; body: string; ts: number;
|
|
10
|
+
}
|
|
11
|
+
export type RoomReceiptReason = "admitted" | "inbox_admitted" | "inbox_full" | "inbox_oversize" | "inbox_expired" | "inbox_age_unavailable" | "not_exposed" | "boundary_refused" | "invalid_message" | "rate_limited" | "hop" | "person_inbox_unsupported";
|
|
12
|
+
export type RoomMessage =
|
|
13
|
+
| { v: 1; type: "chat"; envelope: RoomChatEnvelope }
|
|
14
|
+
| { v: 1; type: "receipt"; frame: string; application: string; peer: string; outcome: "completed" | "failed_safe"; reason: RoomReceiptReason };
|
|
15
|
+
export class RoomApplicationError extends Error {
|
|
16
|
+
constructor(readonly code: "invalid_message" | "limit") { super(`Room application: ${code}`); this.name = "RoomApplicationError"; }
|
|
17
|
+
}
|
|
18
|
+
function fail(code: "invalid_message" | "limit" = "invalid_message"): never { throw new RoomApplicationError(code); }
|
|
19
|
+
const LOCAL_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
20
|
+
const REMOTE_ID = /^([a-z][a-z0-9-]{0,31})@([a-z][a-z0-9-]{0,31})$/;
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
22
|
+
const REASON_TEXT: Readonly<Record<RoomReceiptReason, string>> = Object.freeze({
|
|
23
|
+
admitted: "admitted", inbox_admitted: "admitted to person inbox", inbox_full: "person inbox full",
|
|
24
|
+
inbox_oversize: "person inbox message too large", inbox_expired: "person inbox message expired",
|
|
25
|
+
inbox_age_unavailable: "person inbox admission age unavailable", not_exposed: "peer not exposed", boundary_refused: "room boundary refused",
|
|
26
|
+
invalid_message: "invalid room message", rate_limited: "room rate limit", hop: "hop limit",
|
|
27
|
+
person_inbox_unsupported: "person inbox not supported yet (X5)",
|
|
28
|
+
});
|
|
29
|
+
const REASONS: readonly RoomReceiptReason[] = ["admitted", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported"];
|
|
30
|
+
/** Fixed operator text only; a diagnostic or remote field can never become a rendered reason. */
|
|
31
|
+
export function roomReceiptReasonText(reason: RoomReceiptReason): string {
|
|
32
|
+
return Object.hasOwn(REASON_TEXT, reason) ? REASON_TEXT[reason] : "room receipt unavailable";
|
|
33
|
+
}
|
|
34
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
|
|
37
|
+
export function isRoomLocalPeerId(value: unknown): value is string {
|
|
38
|
+
return typeof value === "string" && LOCAL_ID.exec(value)?.[0] === value && value !== "hub" && value !== "digest";
|
|
39
|
+
}
|
|
40
|
+
/** Remote humans are user@label, never local console authority. Labels come from the signed private roster. */
|
|
41
|
+
export function parseRemotePeerId(value: unknown): { peer: string; label: string } | undefined {
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const match = REMOTE_ID.exec(value);
|
|
44
|
+
if (!match || match[0] !== value || !isRoomLocalPeerId(match[1])) return undefined;
|
|
45
|
+
return { peer: match[1], label: match[2]! };
|
|
46
|
+
}
|
|
47
|
+
function object(value: unknown, fields: readonly string[]): Record<string, unknown> {
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) fail();
|
|
49
|
+
const row = value as Record<string, unknown>;
|
|
50
|
+
if (Reflect.ownKeys(row).length !== fields.length || fields.some(key => !Object.hasOwn(row, key))) fail();
|
|
51
|
+
return row;
|
|
52
|
+
}
|
|
53
|
+
function uuid(value: unknown): string {
|
|
54
|
+
if (typeof value !== "string" || value.length !== 36 || !UUID.test(value)) fail();
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function localPeer(value: unknown): string { if (!isRoomLocalPeerId(value)) fail(); return value; }
|
|
58
|
+
function integer(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): number {
|
|
59
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || Object.is(value, -0) || value < min || value > max) fail();
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function text(value: unknown): string {
|
|
63
|
+
if (typeof value !== "string" || !value.isWellFormed()) fail();
|
|
64
|
+
if (encoder.encode(value).length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function parseEnvelope(value: unknown): RoomChatEnvelope {
|
|
68
|
+
const row = object(value, ["id", "trace", "hop", "from", "to", "kind", "priority", "body", "ts"]);
|
|
69
|
+
if (row.kind !== "chat" && row.kind !== "status") fail();
|
|
70
|
+
if (row.priority !== "important" && row.priority !== "status" && row.priority !== "fyi") fail();
|
|
71
|
+
return { id: uuid(row.id), trace: uuid(row.trace), hop: integer(row.hop, 0, MAX_HOP), from: localPeer(row.from), to: localPeer(row.to), kind: row.kind, priority: row.priority, body: text(row.body), ts: integer(row.ts, 0) };
|
|
72
|
+
}
|
|
73
|
+
function frameDigest(value: unknown): string {
|
|
74
|
+
if (typeof value !== "string" || value.length !== 43) fail();
|
|
75
|
+
try { decodeBase64url(value, 32); } catch { fail(); }
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function parseMessage(value: unknown): RoomMessage {
|
|
79
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
|
|
80
|
+
const type = (value as Record<string, unknown>).type;
|
|
81
|
+
if (type === "chat") {
|
|
82
|
+
const row = object(value, ["v", "type", "envelope"]);
|
|
83
|
+
if (row.v !== 1) fail();
|
|
84
|
+
return { v: 1, type, envelope: parseEnvelope(row.envelope) };
|
|
85
|
+
}
|
|
86
|
+
if (type === "receipt") {
|
|
87
|
+
const row = object(value, ["v", "type", "frame", "application", "peer", "outcome", "reason"]);
|
|
88
|
+
if (row.v !== 1 || row.outcome !== "completed" && row.outcome !== "failed_safe" || !REASONS.includes(row.reason as RoomReceiptReason)
|
|
89
|
+
|| row.outcome === "completed" && row.reason !== "admitted" && row.reason !== "inbox_admitted"
|
|
90
|
+
|| row.outcome === "failed_safe" && (row.reason === "admitted" || row.reason === "inbox_admitted")) fail();
|
|
91
|
+
return { v: 1, type, frame: frameDigest(row.frame), application: uuid(row.application), peer: localPeer(row.peer), outcome: row.outcome, reason: row.reason as RoomReceiptReason };
|
|
92
|
+
}
|
|
93
|
+
return fail();
|
|
94
|
+
}
|
|
95
|
+
function applicationError(error: unknown): never {
|
|
96
|
+
if (error instanceof RoomApplicationError) throw error;
|
|
97
|
+
return fail();
|
|
98
|
+
}
|
|
99
|
+
export function encodeRoomMessage(message: RoomMessage): Uint8Array {
|
|
100
|
+
try {
|
|
101
|
+
const wire = canonicalBytes(parseMessage(message));
|
|
102
|
+
if (wire.length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
103
|
+
return wire;
|
|
104
|
+
} catch (error) { return applicationError(error); }
|
|
105
|
+
}
|
|
106
|
+
export function decodeRoomMessage(wire: Uint8Array): RoomMessage {
|
|
107
|
+
try {
|
|
108
|
+
if (!(wire instanceof Uint8Array) || wire.length === 0) fail();
|
|
109
|
+
if (wire.length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
110
|
+
const bytes = Uint8Array.from(wire), message = parseMessage(JSON.parse(decoder.decode(bytes))), canonical = canonicalBytes(message);
|
|
111
|
+
if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) fail();
|
|
112
|
+
return message;
|
|
113
|
+
} catch (error) { return applicationError(error); }
|
|
114
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** The one X4 outbound view. A proof never carries refs, private task text or untrusted authority. */
|
|
2
|
+
import { looksSecret } from "@staix/agent-mem";
|
|
3
|
+
import { HUB, DIGEST, MAX_HOP, USER, sanitize, type Envelope } from "../hub/envelope.ts";
|
|
4
|
+
import { mentionsPrivatePath } from "../hub/memory-policy.ts";
|
|
5
|
+
import { detectSignals, type Routing } from "../hub/routing.ts";
|
|
6
|
+
import type { PiiVerdict } from "../hub/inference.ts";
|
|
7
|
+
import { encodeRoomMessage, isRoomLocalPeerId, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
|
|
8
|
+
|
|
9
|
+
export interface RoomBoundaryContext {
|
|
10
|
+
cwd: string; target: string; current: () => boolean;
|
|
11
|
+
senderExposed: (id: string) => boolean;
|
|
12
|
+
senderPrivate: (id: string) => boolean | undefined;
|
|
13
|
+
taskPrivate: (id: number) => boolean | undefined;
|
|
14
|
+
routing: () => Routing; piiPaths: () => readonly string[];
|
|
15
|
+
modelScreenOn: () => boolean;
|
|
16
|
+
/** The existing host-bound screenPii port owns device/campus admission, deadlines and fixed models. */
|
|
17
|
+
screen?: (text: string) => Promise<PiiVerdict>;
|
|
18
|
+
}
|
|
19
|
+
export type RoomBoundaryCode = "invalid_message" | "not_exposed" | "boundary_refused" | "hop";
|
|
20
|
+
declare const proofBrand: unique symbol;
|
|
21
|
+
export interface RoomOutboundProof { readonly [proofBrand]: true }
|
|
22
|
+
export type RoomBoundaryResult = { status: "refused"; code: RoomBoundaryCode } | { status: "ready"; proof: RoomOutboundProof; withheld: boolean };
|
|
23
|
+
const proofs = new WeakMap<RoomOutboundProof, { envelope: Readonly<RoomChatEnvelope>; current: () => boolean }>();
|
|
24
|
+
const ENVELOPE_FIELDS = new Set(["id", "trace", "hop", "from", "to", "kind", "priority", "body", "refs", "private", "ts"]);
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const refused = (code: RoomBoundaryCode): RoomBoundaryResult => ({ status: "refused", code });
|
|
27
|
+
|
|
28
|
+
interface BodyPolicy { routing: Routing; paths: string[]; model: boolean; signature: string }
|
|
29
|
+
function bodyPolicy(context: RoomBoundaryContext): BodyPolicy | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const routing = context.routing(), paths = [...context.piiPaths()], model = context.modelScreenOn();
|
|
32
|
+
if (typeof model !== "boolean" || paths.some(path => typeof path !== "string") || !Array.isArray(routing.signals.pii_patterns) || routing.signals.pii_patterns.some(pattern => typeof pattern !== "string")) return undefined;
|
|
33
|
+
const patterns = [...routing.signals.pii_patterns];
|
|
34
|
+
return { routing: { ...routing, signals: { ...routing.signals, pii_patterns: patterns } }, paths, model, signature: JSON.stringify([patterns, paths, model]) };
|
|
35
|
+
} catch { return undefined; }
|
|
36
|
+
}
|
|
37
|
+
function clearBody(body: string, paths: readonly string[], context: RoomBoundaryContext, policy: BodyPolicy | undefined): boolean {
|
|
38
|
+
if (!policy) return false;
|
|
39
|
+
try {
|
|
40
|
+
return !looksSecret(body) && !mentionsPrivatePath(body, paths.map(path => `path:${path}`), policy.paths, context.cwd)
|
|
41
|
+
&& !detectSignals({ title: "", detail: body, refs: {} }, policy.routing, context.cwd).includes("pii");
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
/** Snapshot before any await; host ports remain captured, while their decisions are read fresh. */
|
|
45
|
+
export async function roomBoundary(input: Envelope, ctx: RoomBoundaryContext): Promise<RoomBoundaryResult> {
|
|
46
|
+
try {
|
|
47
|
+
const env = structuredClone(input), context: RoomBoundaryContext = { ...ctx };
|
|
48
|
+
const target = parseRemotePeerId(context.target);
|
|
49
|
+
if (!target || !env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).some(key => !ENVELOPE_FIELDS.has(key))) return refused("invalid_message");
|
|
50
|
+
if (env.from === HUB || env.from === DIGEST || !isRoomLocalPeerId(env.from)) return refused("boundary_refused");
|
|
51
|
+
if (env.kind !== "chat" && env.kind !== "status" || env.private === true) return refused("boundary_refused");
|
|
52
|
+
if (env.private !== undefined && typeof env.private !== "boolean") return refused("invalid_message");
|
|
53
|
+
if (!Array.isArray(env.to) || !env.to.includes(context.target)) return refused("boundary_refused");
|
|
54
|
+
if (env.to.length > 64 || env.to.some(id => !isRoomLocalPeerId(id) && !parseRemotePeerId(id))) return refused("invalid_message");
|
|
55
|
+
if (!Number.isSafeInteger(env.hop) || env.hop < 0 || env.hop > MAX_HOP) return refused("hop");
|
|
56
|
+
if (env.refs !== undefined && (!env.refs || typeof env.refs !== "object" || Array.isArray(env.refs))) return refused("invalid_message");
|
|
57
|
+
const paths = env.refs?.paths ?? [];
|
|
58
|
+
if (!Array.isArray(paths) || paths.length > 64 || paths.some(path => typeof path !== "string" || encoder.encode(path).length > 4096)) return refused("invalid_message");
|
|
59
|
+
const task = env.refs?.task;
|
|
60
|
+
if (task !== undefined && (typeof task !== "string" || !Number.isSafeInteger(Number(task)) || Number(task) < 1 || String(Number(task)) !== task)) return refused("invalid_message");
|
|
61
|
+
const taskId = task === undefined ? undefined : Number(task);
|
|
62
|
+
const authorized = (): RoomBoundaryCode | undefined => {
|
|
63
|
+
try {
|
|
64
|
+
if (context.current() !== true) return "boundary_refused";
|
|
65
|
+
if (env.from !== USER && context.senderExposed(env.from) !== true) return "not_exposed";
|
|
66
|
+
if (context.senderPrivate(env.from) !== false || taskId !== undefined && context.taskPrivate(taskId) !== false) return "boundary_refused";
|
|
67
|
+
return context.current() === true ? undefined : "boundary_refused";
|
|
68
|
+
} catch { return "boundary_refused"; }
|
|
69
|
+
};
|
|
70
|
+
const initial = authorized(); if (initial) return refused(initial);
|
|
71
|
+
const envelope: RoomChatEnvelope = { id: env.id, trace: env.trace, hop: env.hop, from: env.from, to: target.peer, kind: env.kind, priority: env.priority === "important" ? "status" : env.priority, body: env.body, ts: env.ts };
|
|
72
|
+
encodeRoomMessage({ v: 1, type: "chat", envelope }); // Validate metadata/body budget before any model sees it.
|
|
73
|
+
const policy = bodyPolicy(context);
|
|
74
|
+
let withheld = !clearBody(env.body, paths, context, policy);
|
|
75
|
+
if (!withheld && policy?.model) {
|
|
76
|
+
const before = authorized(); if (before) return refused(before);
|
|
77
|
+
let verdict: PiiVerdict | undefined;
|
|
78
|
+
try { verdict = await context.screen?.(env.body); } catch { /* unknown fails closed to the fixed stub */ }
|
|
79
|
+
const after = authorized(); if (after) return refused(after);
|
|
80
|
+
withheld = verdict?.label !== "clear";
|
|
81
|
+
const fresh = bodyPolicy(context);
|
|
82
|
+
if (!fresh || fresh.signature !== policy.signature) return refused("boundary_refused");
|
|
83
|
+
withheld ||= !clearBody(env.body, paths, context, fresh);
|
|
84
|
+
}
|
|
85
|
+
const final = authorized(); if (final) return refused(final);
|
|
86
|
+
const outgoing = Object.freeze({ ...envelope, body: withheld ? `[room message ${envelope.id} withheld]` : sanitize(envelope.body) });
|
|
87
|
+
encodeRoomMessage({ v: 1, type: "chat", envelope: outgoing });
|
|
88
|
+
const current = () => authorized() === undefined && (withheld || (() => {
|
|
89
|
+
const latest = bodyPolicy(context);
|
|
90
|
+
return latest !== undefined && latest.signature === policy?.signature && clearBody(env.body, paths, context, latest);
|
|
91
|
+
})());
|
|
92
|
+
const proof = Object.freeze({}) as RoomOutboundProof;
|
|
93
|
+
proofs.set(proof, { envelope: outgoing, current });
|
|
94
|
+
return { status: "ready", proof, withheld };
|
|
95
|
+
} catch { return refused("invalid_message"); }
|
|
96
|
+
}
|
|
97
|
+
export function readRoomOutbound(proof: RoomOutboundProof): RoomChatEnvelope | undefined {
|
|
98
|
+
if (!proof || typeof proof !== "object") return undefined;
|
|
99
|
+
const state = proofs.get(proof);
|
|
100
|
+
if (!state) return undefined;
|
|
101
|
+
try { return state.current() === true ? { ...state.envelope } : undefined; }
|
|
102
|
+
catch { return undefined; }
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|