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

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

Snapshot at 34e3175

Containing directory

13,210 bytes · typescriptView raw
1import { forkAddress, storedAgentKeyProvider } from "../../../forkhorse-stable/src/index.ts";
2import type { WorkflowWrite } from "./workflow.ts";
3import { DurableObject, RpcStub, RpcTarget, WorkerEntrypoint } from "cloudflare:workers";
4import type {
5  AccountDescription, AppUiContext, ConnectHandoff, Gatekeeper, GatekeeperConnectCallback,
6  GatekeeperUiFrame, GatekeeperUser, GatekeeperUserVerifier, ResourceConfiguratorFrame, SupportedResource,
7} from "@gadgets/workshop-shared/gatekeeper";
8import { generateNonce, isLiveNonce, type TimedNonce } from "@gadgets/gatekeeper-kit/connect-nonce";
9import { isConnectAttempted, markConnectAttempted } from "@gadgets/gatekeeper-kit/connect-handshake";
10import { stageCredentials, commitStagedCredentials } from "@gadgets/gatekeeper-kit/credential-stage";
11import { pullGitObjectsIntoCache, pushGitRefUpdate } from "@gadgets/gatekeeper-kit/git-transport";
12import type { GitCache, GitOid, GitPullHints } from "@gadgets/workshop-shared/gatekeeper";
13import { validateRpc } from "capnweb-validate";
14import { CoreClient } from "./core-client.ts";
15import { checkConnectionDraft, type ConnectionDraft } from "../configuration.ts";
16import { createSidebarFrame, sidebarAccountDescription } from "./sidebar.ts";
17import { sidebarWithCoreOrigin } from "../../app/owner-origin.ts";
18import type { ProjectState } from "../../../forkhorse-dashboard/src/contracts.ts";
19import SIDEBAR_HTML from "../../dist/sidebar.html";
20import PICKER_HTML from "../../dist/picker.html";
21import brand from "../../../../brand.json";
22import { markIconUrl } from "../../../forkhorse-dashboard/src/mark.ts";
23
24export interface Env extends Cloudflare.Env { BASE_URL: string }
25interface Credentials extends ConnectionDraft { agent: string }
26interface AccountProps { accountId: string }
27export const RESOURCE: SupportedResource = {
28  urlPattern: "https://:host/projects/:project/forks/:repoName",
29  title: "Agent fork", description: "A Git fork owned by the connected agent.",
30  icon: { url: markIconUrl(brand.mark) },
31};
32
33declare global {
34  namespace Cloudflare {
35    interface GlobalProps {
36      mainModule: typeof import("./index.ts");
37      durableNamespaces: "UserAccount" | "ForkGatekeeper";
38    }
39  }
40}
41
42/** Private credential store. Only account/fork capabilities carry its unguessable object id. */
43export class UserAccount extends DurableObject<Env> {
44  #generation = 0;
45  #client(): CoreClient {
46    const generation = this.#generation;
47    const creds = this.ctx.storage.kv.get<Credentials>("credentials");
48    if (!creds) throw new Error("Agent connection unavailable.");
49    return new CoreClient(creds, creds.agent, () => {
50      if (generation !== this.#generation || !this.ctx.storage.kv.get("credentials")) {
51        throw new Error("Agent connection unavailable.");
52      }
53    }, storedAgentKeyProvider(() => this.ctx.storage.kv.get<Credentials>("credentials")));
54  }
55  async initiate(callback: Fetcher<GatekeeperConnectCallback>, nonce: string): Promise<void> {
56    if (this.ctx.storage.kv.get("callback")) throw new Error("Connection already initiated.");
57    this.ctx.storage.kv.put("callback", callback);
58    this.ctx.storage.kv.put("nonce", { value: nonce, expiresAt: Date.now() + 600_000 } satisfies TimedNonce);
59    await this.ctx.storage.setAlarm(Date.now() + 3_600_000);
60  }
61  async validNonce(nonce: string): Promise<boolean> {
62    return isLiveNonce(this.ctx.storage.kv.get<TimedNonce>("nonce"), nonce, Date.now());
63  }
64  async completeConnection(nonce: string, draft: ConnectionDraft): Promise<ConnectHandoff> {
65    // Consume before network I/O: concurrent submissions and retries cannot complete twice.
66    if (!isLiveNonce(this.ctx.storage.kv.get<TimedNonce>("nonce"), nonce, Date.now())) {
67      throw new Error("Connection link expired. Start again.");
68    }
69    this.ctx.storage.kv.delete("nonce");
70    const generation = this.#generation;
71    const checked = checkConnectionDraft(draft);
72    const normalized = { ...draft, coreOrigin: checked.coreOrigin };
73    const identity = await new CoreClient(normalized).identity();
74    if (generation !== this.#generation) throw new Error("Connection revoked.");
75    const credentials: Credentials = { ...normalized, agent: identity.agent };
76    const callback = this.ctx.storage.kv.get<Fetcher<GatekeeperConnectCallback>>("callback");
77    if (!callback) throw new Error("Connection callback unavailable.");
78    const existing = this.ctx.storage.kv.get<Credentials>("credentials");
79    if (existing) {
80      if (existing.agent !== credentials.agent || existing.project !== credentials.project ||
81          existing.coreOrigin !== credentials.coreOrigin) throw new Error("Reconnect must retain the same agent and project.");
82      const stageId = stageCredentials(this.ctx.storage.kv, credentials, Date.now());
83      return callback.reconnectComplete(stageId);
84    }
85    if (isConnectAttempted(this.ctx.storage.kv)) throw new Error("Connection already attempted.");
86    markConnectAttempted(this.ctx.storage.kv);
87    this.ctx.storage.kv.put("credentials", credentials);
88    try {
89      const handoff = await callback.complete(this.ctx.exports.AgentAccount({ props: { accountId: this.ctx.id.toString() } }));
90      if (generation !== this.#generation) throw new Error("Connection revoked.");
91      await this.ctx.storage.deleteAlarm();
92      return handoff;
93    } catch {
94      this.#generation++;
95      this.ctx.storage.kv.delete("credentials");
96      throw new Error("Connection completion failed. Start again.");
97    }
98  }
99  async reconnect(): Promise<string> {
100    if (!this.ctx.storage.kv.get("credentials")) throw new Error("Agent connection unavailable.");
101    const nonce = generateNonce();
102    this.ctx.storage.kv.put("nonce", { value: nonce, expiresAt: Date.now() + 600_000 } satisfies TimedNonce);
103    return nonce;
104  }
105  async commitReconnect(stageId: string): Promise<void> {
106    if (!this.ctx.storage.kv.get("credentials")) throw new Error("Agent connection unavailable.");
107    const staged = commitStagedCredentials<Credentials>(this.ctx.storage.kv, Date.now(), stageId);
108    if (!staged) throw new Error("Reconnect stage unavailable.");
109    this.#generation++;
110    this.ctx.storage.kv.put("credentials", staged);
111  }
112  async revoke(): Promise<void> {
113    this.#generation++;
114    this.ctx.storage.kv.delete("credentials");
115    await this.ctx.storage.deleteAll();
116  }
117  async alarm(): Promise<void> { await this.revoke(); }
118  async workflowContext() { return this.#client().context(); }
119  async workflowSubmission(operation: WorkflowWrite) { return this.#client().workflowSubmission(operation); }
120  async executeWorkflow(operation: WorkflowWrite): Promise<void> { await this.#client().executeWorkflow(operation); }
121  async readReview(review: string) { return this.#client().readReview(review); }
122  async readReceipts(review: string) { return this.#client().readReceipts(review); }
123  async identity() { return this.#client().identity(); }
124  async state(): Promise<ProjectState> { return this.#client().state(); }
125  async fork(repoName: string) { return this.#client().fork(repoName); }
126  async metadata(): Promise<{ coreOrigin: string; project: string; agent: string }> {
127    const identity = await this.identity();
128    const creds = this.ctx.storage.kv.get<Credentials>("credentials");
129    if (!creds) throw new Error("Agent connection unavailable.");
130    return { coreOrigin: creds.coreOrigin, project: creds.project, agent: identity.agent };
131  }
132  async pull(repoName: string, oids: GitOid[], hints: GitPullHints, cache: RpcStub<GitCache>): Promise<void> {
133    const client = this.#client();
134    try {
135      await pullGitObjectsIntoCache(body => client.git(repoName, "upload-pack", body), oids, hints, cache);
136    } catch { throw new Error("Git pull failed."); }
137  }
138  async push(repoName: string, branch: string, oldSha: string, newSha: string, pack: ReadableStream<Uint8Array>): Promise<void> {
139    const client = this.#client();
140    try {
141      await pushGitRefUpdate(body => client.git(repoName, "receive-pack", body), { branch, oldSha, newSha }, pack);
142    } catch { throw new Error("Git push failed or was rejected. Refresh the fork head."); }
143  }
144}
145
146function account(exports: Cloudflare.Exports, id: string) {
147  return exports.UserAccount.get(exports.UserAccount.idFromString(id));
148}
149
150@validateRpc()
151class ForkPicker extends RpcTarget {
152  constructor(private readonly source: DurableObjectStub<UserAccount>) { super(); }
153  async list(): Promise<{ url: string; repoName: string; head: string }[]> {
154    const meta = await this.source.metadata();
155    return (await this.source.identity()).forks.map(f => ({
156      url: forkAddress({ origin: meta.coreOrigin, project: meta.project, repoName: f.repoName }),
157      repoName: f.repoName, head: f.head,
158    }));
159  }
160}
161
162export class AgentAccount extends WorkerEntrypoint<Env, AccountProps> implements GatekeeperUser {
163  #account() { return account(this.ctx.exports, this.ctx.props.accountId); }
164  async describe(): Promise<AccountDescription> {
165    const meta = await this.#account().metadata();
166    return { ...sidebarAccountDescription(meta.agent), uniqueName: `${meta.agent} · ${meta.project}` };
167  }
168  async getSupportedResources(): Promise<SupportedResource[]> {
169    const meta = await this.#account().metadata();
170    const address = forkAddress({ origin: meta.coreOrigin, project: meta.project, repoName: `${meta.project}--repo-name` });
171    return [{ ...RESOURCE, description: `An agent-owned Git fork. Attach its exact address: ${address.slice(0, address.lastIndexOf("/") + 1)}<repoName>. Choose one of the connected agent's forks in the resource picker.` }];
172  }
173  async getGatekeeperClassFor(url: string): Promise<{ class: DurableObjectClass<Gatekeeper<any>>; resource: SupportedResource }> {
174    const source = this.#account();
175    const meta = await source.metadata();
176    const prefix = `${meta.coreOrigin}/projects/${meta.project}/forks/`;
177    if (!url.startsWith(prefix)) throw new Error("Fork is outside the connected project.");
178    const repoName = url.slice(prefix.length);
179    if (encodeURIComponent(repoName) !== repoName) throw new Error("Invalid fork URL.");
180    if (forkAddress({ origin: meta.coreOrigin, project: meta.project, repoName }) !== url) throw new Error("Invalid fork URL.");
181    await source.fork(repoName);
182    return { class: this.ctx.exports.ForkGatekeeper({ props: { accountId: this.ctx.props.accountId, repoName } }), resource: RESOURCE };
183  }
184  async startResourceConfigurator(pattern: string): Promise<ResourceConfiguratorFrame> {
185    if (pattern !== RESOURCE.urlPattern) throw new Error("Unsupported resource type.");
186    await this.#account().identity();
187    return { iframeHtml: PICKER_HTML, ui: new RpcStub(new ForkPicker(this.#account())) };
188  }
189  async startAppUi(_context: AppUiContext): Promise<GatekeeperUiFrame> {
190    const source = this.#account();
191    // Existing authenticated metadata; serialize only the public origin, never the credential.
192    // A failed read only omits the owner link; the frame keeps its existing error and retry path.
193    const coreOrigin = await source.metadata().then((meta) => meta.coreOrigin, () => undefined);
194    return createSidebarFrame({
195      authorize: async () => { await source.identity(); },
196      read: () => source.state(),
197      watch(changed, revoked) {
198        let stopped = false;
199        let timer: ReturnType<typeof setTimeout>;
200        let previous = "";
201        const poll = async () => {
202          try {
203            const state = await source.state();
204            if (stopped) return;
205            const key = JSON.stringify(state);
206            if (key !== previous) { previous = key; changed(state); }
207            if (!stopped) timer = setTimeout(() => { void poll(); }, 250);
208          } catch { if (!stopped) revoked(); }
209        };
210        void poll();
211        return () => { stopped = true; clearTimeout(timer); };
212      },
213    }, sidebarWithCoreOrigin(SIDEBAR_HTML, coreOrigin));
214  }
215  async revoke(): Promise<void> { await this.#account().revoke(); }
216  async reconnect(): Promise<{ url: string }> {
217    const nonce = await this.#account().reconnect();
218    return { url: `${this.env.BASE_URL}/${this.ctx.props.accountId}/${nonce}` };
219  }
220  async commitReconnect(stageId: string): Promise<void> { await this.#account().commitReconnect(stageId); }
221  async getAuthenticatedEmail(): Promise<null> { return null; }
222  async ensureResources(patterns: string[]): Promise<{}> {
223    if (patterns.some(p => p !== RESOURCE.urlPattern)) throw new Error("Unsupported resource type.");
224    await this.#account().identity(); return {};
225  }
226  async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {
227    return this.ctx.exports.AgentVerifier({ props: this.ctx.props });
228  }
229}
230
231export class AgentVerifier extends WorkerEntrypoint<Env, AccountProps> implements GatekeeperUserVerifier {
232  async owns(coreOrigin: string, project: string, repoName: string): Promise<boolean> {
233    const source = account(this.ctx.exports, this.ctx.props.accountId);
234    try {
235      const meta = await source.metadata();
236      if (meta.coreOrigin !== coreOrigin || meta.project !== project) return false;
237      await source.fork(repoName); return true;
238    } catch { return false; }
239  }
240}
241