Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-gatekeeper/src/worker/sidebar.ts

Snapshot at 34e3175

Containing directory

4,502 bytes 路 typescriptView raw
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