Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cloudflare/artifact-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function identify(request: Request, env: Env): Promise<{ privateKey
return { privateKey: JSON.stringify(["private", "better-auth", user.id]), user: { subject: user.id, authority: "better-auth" } };
}
const identity = await authenticate(request, env);
return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]), user: identity };
return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]), user: { subject: identity.subject, authority: identity.authority } };
}
const scriptJson = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c");
function html(body: string, policy: string, head: boolean): Response {
Expand Down
4 changes: 2 additions & 2 deletions cloudflare/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type AuthEnvironment = {
ACCESS_TEAM_DOMAIN?: string;
ACCESS_AUD?: string;
};
export type Identity = { subject: string; authority: string };
export type Identity = { subject: string; authority: string; email?: string };

export function createAuthenticator(jwksFetch?: FetchImplementation) {
const keySets = new Map<string, JWTVerifyGetKey>();
Expand Down Expand Up @@ -35,7 +35,7 @@ export function createAuthenticator(jwksFetch?: FetchImplementation) {
requiredClaims: ["sub", "exp", "iat"],
});
if (typeof payload.sub !== "string" || !payload.sub) throw new Error("Missing identity");
return { subject: payload.sub, authority: issuer };
return { subject: payload.sub, authority: issuer, ...(typeof payload.email === "string" && { email: payload.email }) };
} catch {
return Response.json({ error: "Invalid Cloudflare Access assertion" }, { status: 401 });
}
Expand Down
35 changes: 35 additions & 0 deletions cloudflare/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ export class ArtifactLinks extends DurableObject<unknown> {
ctx.storage.sql.exec("create table if not exists links (slug text primary key, target text not null unique, value text not null)");
ctx.storage.sql.exec("create table if not exists generations (target text primary key, generation integer not null)");
ctx.storage.sql.exec("create table if not exists pending_links (target text primary key, value text not null)");
ctx.storage.sql.exec("create table if not exists usernames (owner text primary key, username text not null unique)");
ctx.storage.sql.exec("create table if not exists default_usernames (owner text primary key, username text not null unique)");
}
private username(owner: string) {
return this.ctx.storage.sql.exec<{ username: string }>("select username from usernames where owner=?", owner).toArray()[0]?.username ?? null;
}
private usernameOwner(username: string) {
return this.ctx.storage.sql.exec<{ owner: string }>("select owner from usernames where username=? union all select owner from default_usernames where username=? limit 1", username, username).toArray()[0]?.owner;
}
ownsUsername(owner: string, username: string) {
return this.usernameOwner(username) === owner;
}
personalUsername(owner: string, base: string) {
return this.ctx.storage.transactionSync(() => {
const chosen = this.username(owner);
if (chosen) return { chosen, current: chosen };
const prior = this.ctx.storage.sql.exec<{ username: string }>("select username from default_usernames where owner=?", owner).toArray()[0];
if (prior) return { chosen: null, current: prior.username };
let current = base;
for (let suffix = 2; /^user-[a-f0-9]{32}$/.test(current) || this.usernameOwner(current); suffix++) current = base + suffix;
this.ctx.storage.sql.exec("insert into default_usernames(owner,username) values (?,?)", owner, current);
return { chosen: null, current };
});
}
claimUsername(owner: string, username: string) {
if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(username) || /^user-[a-f0-9]{32}$/.test(username)) {
throw new Error("Use 1–32 lowercase letters, numbers or hyphens for your username");
}
return this.ctx.storage.transactionSync(() => {
const taken = this.usernameOwner(username);
if (taken && taken !== owner) return false;
this.ctx.storage.sql.exec("insert into usernames(owner,username) values (?,?) on conflict do nothing", owner, username);
return this.username(owner) === username;
});
}
owner(target: ArtifactTarget) { return this.ownership.owner(target); }
admit(selection: LibrarySelection, create = false) { return this.ownership.admit(selection, create); }
Expand Down Expand Up @@ -87,6 +121,7 @@ export class ArtifactLinks extends DurableObject<unknown> {
// Trashed links reserve their slug so restoring cannot hijack another URL.
const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where slug=?", slug).toArray()[0];
const taken = row ? readLink(row.value) : null;
if ((slug === "team" || slug === "users") && !taken) throw new Error("Slug is reserved for app URLs");
if (taken && key(taken) !== key(target)) throw new Error("Slug is already in use");
return slug;
}
Expand Down
33 changes: 24 additions & 9 deletions cloudflare/managed-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { Env } from "./worker";
import { nativeLinkTarget, type ArtifactTarget } from "./links";
import { emailUsername, legacyUsername, nativeAppPath } from "./native-worker/urls";
import type { LibrarySelection } from "./ownership";
import type { PluginInvocationContext } from "./plugins";
import type { PluginRequest } from "../src/plugins/types";
Expand Down Expand Up @@ -40,7 +41,7 @@ export class ManagedArtifactService {
private readonly links;
constructor(private readonly env: Env, readonly workspace: string, readonly libraryKey: string,
private readonly privateKey: string, private readonly origin: string, private readonly publicOrigin: string,
private readonly plugins: PluginInvocationContext) {
private readonly plugins: PluginInvocationContext, private readonly email?: string) {
this.links = env.LINKS.getByName("deployment");
this.fileStorage = env.FILE_BACKENDS ? { backends: env.FILE_BACKENDS, origin: publicOrigin } : undefined;
}
Expand All @@ -57,7 +58,7 @@ export class ManagedArtifactService {
if (scope === "private" && this.privateKey === "team") throw new Error("Service tokens manage the team library only");
const libraryKey = scope === "team" ? "team" : this.privateKey;
return libraryKey === this.libraryKey ? this : new ManagedArtifactService(this.env, this.workspace, libraryKey,
this.privateKey, this.origin, this.publicOrigin, this.plugins);
this.privateKey, this.origin, this.publicOrigin, this.plugins, this.email);
}

private selection(kind: "artifact" | "script", selection: Selection): LibrarySelection {
Expand Down Expand Up @@ -266,7 +267,7 @@ export class ManagedArtifactService {
return this.env.NATIVE_APPS.getByName("deployment");
}
private async nativeTool(tool: string, args: Record<string, unknown>): Promise<CallToolResult> {
if (tool === "app_guide") return text({ guide: "Native apps preserve the default Worker and named Durable Object exports. app_write accepts source, a strict app-owned manifest and optional project files/dependencies. Manifest fields: main (relative JS/TS path), compatibility_date, compatibility_flags, vars, secrets (names only), bindings (name -> {type:kv|r2|d1|queue|durable-object, resource:stable-name, class_name:DO export}), triggers:{crons:[],queues:[resource-name]}. Resource names permanently identify storage; binding/export renames preserve it and removed resources remain reserved. Set secrets with app_secrets. app_read/history/restore/reconcile manage persistent revisions and interrupted deployments. Restores retain current data and current secrets. Providers cannot change on an existing app. celld-local and celld-fleet accept trusted code only; celld-fleet uses the existing bucket-backed fleet and its coordinated publisher. Management URLs are private /apps/<name>/?workspace=<workspace>&library=<private|team> and require library authentication. artifact_link({kind:'app',name,slug,access}) adds a root /<slug> URL, private or public, that follows the app across library moves and passes slug-relative paths. Both stream HTTP and strip management credentials/cookies. Cloudflare uses native queue/cron delivery. celld-fleet uses native queues and host-coordinated cron dispatch into each app's own environment. celld-local operator does not support WebSockets. No destructive resource removal or schema/data rollback is provided." });
if (tool === "app_guide") return text({ guide: "Native apps preserve the default Worker and named Durable Object exports. app_write accepts source, a strict app-owned manifest and optional project files/dependencies. Manifest fields: main (relative JS/TS path), compatibility_date, compatibility_flags, vars, secrets (names only), bindings (name -> {type:kv|r2|d1|queue|durable-object, resource:stable-name, class_name:DO export}), triggers:{crons:[],queues:[resource-name]}. Resource names permanently identify storage; binding/export renames preserve it and removed resources remain reserved. Set secrets with app_secrets. app_read/history/restore/reconcile manage persistent revisions and interrupted deployments. Restores retain current data and current secrets. Providers cannot change on an existing app. celld-local and celld-fleet accept trusted code only; celld-fleet uses the existing bucket-backed fleet and its coordinated publisher. Open the management_url returned by app_read: /team/<workspace>/<name>/ or /users/<username>/<workspace>/<name>/. These require library authentication and preserve app query parameters. artifact_link({kind:'app',name,slug,access}) adds a root /<slug> URL, private or public, that follows the app across library moves and passes slug-relative paths. Both stream HTTP and strip management credentials/cookies. Cloudflare uses native queue/cron delivery. celld-fleet uses native queues and host-coordinated cron dispatch into each app's own environment. celld-local operator does not support WebSockets. No destructive resource removal or schema/data rollback is provided." });
const controller = this.nativeController(), input = { owner: this.libraryKey, workspace: this.workspace, name: args.name as string };
let result: unknown;
switch (tool) {
Expand All @@ -284,8 +285,8 @@ export class ManagedArtifactService {
default: throw new Error("Unknown app tool");
}
const payload = result as Record<string, unknown>;
if (tool === "app_read" || (tool === "app_write" && typeof payload.id === "string")) Object.assign(payload, await this.nativeLinkDetails(this.workspace, payload.id as string));
if (tool === "app_list") for (const app of payload.apps as { id: string; workspace: string }[]) Object.assign(app, await this.nativeLinkDetails(app.workspace, app.id));
if (tool === "app_read" || (tool === "app_write" && typeof payload.id === "string")) Object.assign(payload, await this.nativeLinkDetails(this.workspace, payload.id as string, input.name));
if (tool === "app_list") for (const app of payload.apps as { id: string; workspace: string; name: string }[]) Object.assign(app, await this.nativeLinkDetails(app.workspace, app.id, app.name));
if (["app_write", "app_restore", "app_reconcile", "app_move"].includes(tool) || (tool === "app_secrets" && args.secrets)) {
await this.links.nativeAppChanged({ libraryKey: this.libraryKey, workspace: this.workspace });
if (tool === "app_move") await this.links.nativeAppChanged({ libraryKey: args.library === "team" ? "team" : this.privateKey, workspace: this.workspace });
Expand All @@ -297,11 +298,25 @@ export class ManagedArtifactService {
const { id } = await this.nativeController().identity({ owner: this.libraryKey, workspace: this.workspace, name: args.name as string });
await this.links.set({ ...nativeLinkTarget(this.workspace, id), slug: args.slug as string, access: args.access as "private" | "public" | undefined });
await this.links.nativeAppChanged({ libraryKey: this.libraryKey, workspace: this.workspace });
return text({ ok: true, ...await this.nativeLinkDetails(this.workspace, id) });
return text({ ok: true, ...await this.nativeLinkDetails(this.workspace, id, args.name as string) });
}
private async nativeLinkDetails(workspace: string, id: string) {
private usernameLookup?: Promise<{ chosen: string | null; current: string }>;
private username() {
return this.usernameLookup ??= this.links.personalUsername(this.privateKey, emailUsername(this.email));
}
async ownsUsername(username: string) {
return username === legacyUsername(this.privateKey) || await this.links.ownsUsername(this.privateKey, username);
}
claimUsername(username: string) {
return this.links.claimUsername(this.privateKey, username);
}
private async nativeLinkDetails(workspace: string, id: string, name: string) {
const link = await this.links.find(nativeLinkTarget(workspace, id));
return link ? { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}` } : {};
const team = this.libraryKey === "team";
const username = team ? null : await this.username();
const path = nativeAppPath(username?.current ?? null, workspace, name);
return { management_url: this.publicOrigin + path, ...(!team && { username: username?.chosen }),
...(link && { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}` }) };
}
nativeFetch(name: string, request: Request) {
const headers = new Headers(request.headers);
Expand Down Expand Up @@ -368,7 +383,7 @@ export class ManagedArtifactService {
: null;
hasMore ||= workers?.next_offset != null;
return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, trash: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true, subscriptionTransport: "events" as const }, workspace: this.workspace,
...(workers ? { workerApps: await Promise.all(workers.apps.map(async app => ({ ...app, ...await this.nativeLinkDetails(app.workspace, app.id), kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) }))), nativeAppProviders: workers.providers } : {}),
...(workers ? { workerApps: await Promise.all(workers.apps.map(async app => ({ ...app, ...await this.nativeLinkDetails(app.workspace, app.id, app.name), kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) }))), nativeAppProviders: workers.providers } : {}),
artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null };
}
}
Loading
Loading