Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-gatekeeper/src/worker/sidebar.ts
Snapshot at 34e3175
1import { RpcStub, RpcTarget } from "cloudflare:workers";
2import { validateRpc } from "capnweb-validate";
3import type { AccountDescription, GatekeeperUiFrame } from "@gadgets/workshop-shared/gatekeeper";
4import type { ProjectState } from "../../../forkhorse-dashboard/src/contracts.ts";
5import type { SidebarBatch } from "../ui-types.ts";
6import { SnapshotFeed } from "../feed.ts";
7import brand from "../../../../brand.json";
8import { railIconUrl } from "../rail-icon.ts";
9import { markIconUrl } from "../../../forkhorse-dashboard/src/mark.ts";
10
11const icon = { url: markIconUrl(brand.mark) };
12
13/** Only an authenticated account may use this metadata; it grants no owner authority. */
14export function sidebarAccountDescription(agent?: string): AccountDescription {
15 return { displayName: agent ? `${brand.name} 路 ${agent}` : brand.name, avatar: icon, providesUi: { title: brand.name, icon: { url: railIconUrl(brand.railIcon) } } };
16}
17
18/** Trusted host seam supplied by the authenticated, project-bound account. */
19export interface SidebarSource {
20 authorize(): Promise<void>;
21 read(): Promise<ProjectState>;
22 /** Replay current state on registration; invoke revoked on auth loss or source failure. */
23 watch(changed: (state: ProjectState) => void, revoked: () => void): () => void;
24}
25
26export interface WorkerUpdateReceiver extends RpcTarget {
27 receive(batch: SidebarBatch): Promise<void>;
28}
29
30@validateRpc()
31export class SidebarSubscription extends RpcTarget {
32 readonly #release: () => void;
33 #closed = false;
34 constructor(release: () => void) { super(); this.#release = release; }
35 async close(): Promise<void> {
36 if (!this.#closed) { this.#closed = true; this.#release(); }
37 }
38 [Symbol.dispose](): void { void this.close(); }
39}
40
41/** Worker-native, read-only UI surface; publishing and credentials are never RPC methods. */
42@validateRpc()
43export class SidebarUiImpl extends RpcTarget {
44 readonly #feed: SnapshotFeed;
45 readonly #authorize: () => Promise<void>;
46 readonly #close: () => void;
47 constructor(feed: SnapshotFeed, authorize: () => Promise<void>, close: () => void) {
48 super(); this.#feed = feed; this.#authorize = authorize; this.#close = close;
49 }
50
51 async snapshot(): Promise<SidebarBatch> {
52 await this.#authorize();
53 return this.#feed.snapshot();
54 }
55
56 async subscribe(receiver: RpcStub<WorkerUpdateReceiver>): Promise<SidebarSubscription> {
57 await this.#authorize();
58 const retained = receiver.dup();
59 let disposed = false;
60 const dispose = () => {
61 if (!disposed) { disposed = true; retained[Symbol.dispose](); }
62 };
63 try {
64 return new SidebarSubscription(this.#feed.subscribe({
65 receive: async (batch) => {
66 await this.#authorize();
67 await retained.receive(batch);
68 },
69 dispose,
70 }));
71 } catch (error) {
72 dispose();
73 throw error;
74 }
75 }
76
77 async waitForChanges(after: number): Promise<SidebarBatch> {
78 await this.#authorize();
79 const batch = await this.#feed.waitForChanges(after);
80 // A poll can span revocation; authorize again immediately before disclosure.
81 await this.#authorize();
82 return batch;
83 }
84
85 [Symbol.dispose](): void { this.#close(); }
86}
87
88/** Compose startAppUi only after account authorization. */
89export async function createSidebarFrame(source: SidebarSource, iframeHtml: string): Promise<GatekeeperUiFrame> {
90 let initial: ProjectState;
91 try {
92 await source.authorize();
93 initial = await source.read();
94 await source.authorize();
95 } catch { throw new Error("Sidebar session unavailable."); }
96 const feed = new SnapshotFeed(initial);
97 let stop: (() => void) | undefined;
98 let closed = false;
99 const close = () => {
100 if (closed) return;
101 closed = true;
102 try { stop?.(); } finally { feed.dispose(); }
103 };
104 const authorize = async () => {
105 if (closed) throw new Error("Sidebar session unavailable.");
106 try { await source.authorize(); } catch { close(); throw new Error("Sidebar session unavailable."); }
107 if (closed) throw new Error("Sidebar session unavailable.");
108 };
109 try {
110 stop = source.watch((state) => {
111 if (closed) return;
112 try { feed.publish(state); } catch { close(); }
113 }, close);
114 // Handle a source that synchronously revokes while registering the watch.
115 if (closed) { stop(); throw new Error("Sidebar session unavailable."); }
116 return { iframeHtml, ui: new RpcStub(new SidebarUiImpl(feed, authorize, close)) };
117 } catch (error) { close(); throw error; }
118}
119