From c08d0fcdc12cfcfb6f02ff07e8556116d2678678 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 6 Nov 2026 10:28:14 -0800 Subject: [PATCH] Give native apps readable Open and Share URLs --- cloudflare/artifact-routes.ts | 2 +- cloudflare/auth.ts | 4 +-- cloudflare/links.ts | 35 +++++++++++++++++++++++++ cloudflare/managed-service.ts | 33 +++++++++++++++++------- cloudflare/native-worker.test.ts | 41 +++++++++++++++++++++++++++-- cloudflare/native-worker/urls.ts | 29 +++++++++++++++++++++ cloudflare/worker.ts | 44 ++++++++++++++++++++++++-------- docs/native-workers.md | 9 +++++-- e2e/gallery-native-apps.test.ts | 28 ++++++++++++++++---- src/gallery/native-apps.tsx | 34 ++++++++++++++++-------- 10 files changed, 216 insertions(+), 43 deletions(-) create mode 100644 cloudflare/native-worker/urls.ts diff --git a/cloudflare/artifact-routes.ts b/cloudflare/artifact-routes.ts index c8a92d1..f6b63a5 100644 --- a/cloudflare/artifact-routes.ts +++ b/cloudflare/artifact-routes.ts @@ -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 { diff --git a/cloudflare/auth.ts b/cloudflare/auth.ts index a7d57b0..f6aaf1a 100644 --- a/cloudflare/auth.ts +++ b/cloudflare/auth.ts @@ -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(); @@ -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 }); } diff --git a/cloudflare/links.ts b/cloudflare/links.ts index f87dba8..1a9ea81 100644 --- a/cloudflare/links.ts +++ b/cloudflare/links.ts @@ -40,6 +40,40 @@ export class ArtifactLinks extends DurableObject { 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); } @@ -87,6 +121,7 @@ export class ArtifactLinks extends DurableObject { // 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; } diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index b35549e..3594991 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -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"; @@ -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; } @@ -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 { @@ -266,7 +267,7 @@ export class ManagedArtifactService { return this.env.NATIVE_APPS.getByName("deployment"); } private async nativeTool(tool: string, args: Record): Promise { - 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//?workspace=&library= and require library authentication. artifact_link({kind:'app',name,slug,access}) adds a root / 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/// or /users////. These require library authentication and preserve app query parameters. artifact_link({kind:'app',name,slug,access}) adds a root / 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) { @@ -284,8 +285,8 @@ export class ManagedArtifactService { default: throw new Error("Unknown app tool"); } const payload = result as Record; - 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 }); @@ -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); @@ -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 }; } } diff --git a/cloudflare/native-worker.test.ts b/cloudflare/native-worker.test.ts index 01bf2f0..5796630 100644 --- a/cloudflare/native-worker.test.ts +++ b/cloudflare/native-worker.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; import { Miniflare, Response as RuntimeResponse } from "miniflare"; import { CelldOperator } from "../scripts/native-worker/operator"; +import { legacyUsername } from "./native-worker/urls"; test.skipIf(process.env.NATIVE_WORKER_CELLD !== "1")("authenticated app management retains native resources, isolates owners/secrets and recovers interrupted updates", async () => { const directory = await mkdtemp(join(tmpdir(), "native-controller-")); @@ -16,9 +17,9 @@ test.skipIf(process.env.NATIVE_WORKER_CELLD !== "1")("authenticated app manageme const server = operator.serve({ port: 0 }); const { publicKey, privateKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "native-controller", alg: "RS256", use: "sig" }; - const assertion = async (subject: string) => new SignJWT({}).setProtectedHeader({ alg: "RS256", kid: jwk.kid }).setSubject(subject) + const assertion = async (subject: string, email: string) => new SignJWT({ email }).setProtectedHeader({ alg: "RS256", kid: jwk.kid }).setSubject(subject) .setIssuer("https://fixture.cloudflareaccess.com").setAudience("native-app-test").setIssuedAt().setExpirationTime("1h").sign(privateKey); - const alice = await assertion("alice"), bob = await assertion("bob"); + const alice = await assertion("alice", "Nico@first.example"), bob = await assertion("bob", "nico@second.example"); const modulesRoot = join(import.meta.dir, "../dist/worker-app"); const modules: Record = {}; for (const name of await readdir(modulesRoot)) { @@ -71,13 +72,36 @@ test.skipIf(process.env.NATIVE_WORKER_CELLD !== "1")("authenticated app manageme expect(inspection.url).toBe(`${origin}/inspect`); for (const header of ["cookie", "authorization", "cf-access-jwt-assertion", "x-artifacts-gateway-token", "x-artifacts-target-url", "x-artifacts-app-selection"]) expect(inspection.headers[header]).toBeUndefined(); const read = (await tool("app_read", { name: "fixture" })).payload; + const generatedUrl = read.management_url; + expect(generatedUrl).toBe(origin + "/users/nico/test/fixture/"); + const legacy = legacyUsername(JSON.stringify(["private", "fixture.cloudflareaccess.com", "alice"])); + const legacyUrl = origin + "/users/" + legacy + "/test/fixture/"; + const claim = (username: string, token: string) => fetch(origin + "/api/account/username", { + method: "POST", headers: { "cf-access-jwt-assertion": token, "content-type": "application/json" }, body: JSON.stringify({ username }), + }); + expect((await claim("alice", alice)).status).toBe(200); + expect((await claim("nico", bob)).status).toBe(409); + expect((await claim("alice", bob)).status).toBe(409); + expect((await claim("renamed", alice)).status).toBe(409); + const personalUrl = (await tool("app_read", { name: "fixture" })).payload.management_url; + expect(personalUrl).toBe(origin + "/users/alice/test/fixture/"); + for (const url of [generatedUrl, personalUrl, legacyUrl]) { + expect((await fetch(url + "inspect", { headers: { "cf-access-jwt-assertion": alice } })).status).toBe(200); + } expect(JSON.stringify(read)).not.toContain("app-fixture-secret"); expect(JSON.stringify(read)).not.toContain(operatorToken); const original = read.active_revision; expect((await tool("app_read", { name: "fixture" }, bob)).response.status).toBe(404); expect((await invoke("fixture", bob)).status).toBe(404); await tool("app_write", { name: "fixture", source, manifest, provider: "celld-local" }, bob); + const bobUrl = (await tool("app_read", { name: "fixture" }, bob)).payload.management_url; + expect(bobUrl).toBe(origin + "/users/nico2/test/fixture/"); + const newEmail = await assertion("bob", "different@second.example"); + expect((await tool("app_read", { name: "fixture" }, newEmail)).payload.management_url).toBe(bobUrl); await tool("app_secrets", { name: "fixture", secrets: { API_TOKEN: "bob-fixture-secret" } }, bob); expect(await (await invoke("fixture", bob)).json()).toMatchObject({ count: 1, visits: 1 }); + for (const url of [generatedUrl, personalUrl, legacyUrl]) { + expect((await fetch(url, { headers: { "cf-access-jwt-assertion": bob } })).status).toBe(404); + } const renamed = structuredClone(manifest); renamed.bindings.COUNTERS.class_name = "RenamedCounter"; expect((await tool("app_write", { name: "fixture", source: source.replaceAll("Counter", "RenamedCounter"), manifest: renamed, expected_revision: read.revision_token })).result.isError).toBe(false); expect(await (await invoke()).json()).toMatchObject({ count: 2, visits: 2 }); @@ -106,6 +130,19 @@ test.skipIf(process.env.NATIVE_WORKER_CELLD !== "1")("authenticated app manageme expect((await tool("app_read", { name: "fixture" })).response.status).toBe(404); expect((await invoke()).status).toBe(404); expect(await (await invoke("fixture", bob, "/", "team")).json()).toMatchObject({ count: 6, visits: 6 }); + const teamUrl = `${origin}/team/test/fixture`; + expect((await tool("app_read", { name: "fixture" }, bob, "team")).payload.management_url).toBe(teamUrl + "/"); + for (const slug of ["team", "users"]) { + expect((await tool("artifact_link", { kind: "app", name: "fixture", slug }, bob, "team")).response.status).toBe(400); + } + expect((await fetch(teamUrl + "/")).status).toBe(401); + const teamHeaders = { "cf-access-jwt-assertion": bob }; + const redirect = await fetch(teamUrl, { redirect: "manual", headers: teamHeaders }); + expect(redirect.status).toBe(308); + expect(new URL(redirect.headers.get("location")!, origin).href).toBe(teamUrl + "/"); + const query = "?library=app-filter&workspace=app-view"; + const nested = await fetch(teamUrl + "/inspect" + query, { headers: teamHeaders }); + expect(await nested.json()).toMatchObject({ url: origin + "/inspect" + query }); expect((await tool("app_secrets", { name: "fixture" }, bob, "team")).payload.names).toEqual(["API_TOKEN"]); // Reusing the former logical name must allocate a new physical app and fresh storage. await tool("app_write", { name: "fixture", source, manifest, provider: "celld-local", expected_revision: null }); diff --git a/cloudflare/native-worker/urls.ts b/cloudflare/native-worker/urls.ts new file mode 100644 index 0000000..1e8043e --- /dev/null +++ b/cloudflare/native-worker/urls.ts @@ -0,0 +1,29 @@ +import { createHash } from "node:crypto"; + +// Older generated links remain valid but are never issued for new URLs. +export function legacyUsername(owner: string) { + return "user-" + createHash("sha256").update(owner).digest("hex").slice(0, 32); +} + +export function emailUsername(email?: string) { + const at = email?.lastIndexOf("@") ?? -1; + const prefix = email && at > 0 ? email.slice(0, at).toLowerCase() : ""; + const base = prefix.replace(/[^a-z0-9._+-]+/g, "-").slice(0, 64); + return /[a-z0-9]/.test(base) ? base : "user"; +} + +export function nativeAppPath(username: string | null, workspace: string, name: string) { + // Browsers collapse even percent-encoded dot segments. Escape the prefix too. + const segment = /^(?:\.{1,2}$|~)/.test(workspace) ? "~" + workspace : workspace; + const prefix = username === null ? "/team" : "/users/" + username; + return `${prefix}/${encodeURIComponent(segment)}/${encodeURIComponent(name)}/`; +} + +export function matchNativeAppPath(pathname: string) { + return /^\/(?:team|users\/(?[^/]+))\/(?[^/]+)\/(?[^/]+)(?\/.*)?$/.exec(pathname)?.groups; +} + +export function workspaceFromPath(segment: string) { + const workspace = decodeURIComponent(segment); + return /^~(?:\.{1,2}$|~)/.test(workspace) ? workspace.slice(1) : workspace; +} diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 231437c..4fc58ff 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -2,7 +2,7 @@ import { publicOrigin } from "./public-origin"; import { Hono } from "hono"; import type { DurableObjectNamespace, Fetcher } from "@cloudflare/workers-types"; import { ArtifactLibrary } from "./library"; -import { authenticate, type AuthEnvironment } from "./auth"; +import { authenticate, type AuthEnvironment, type Identity } from "./auth"; // esbuild 0.28.1 must initialize Better Auth's shared Zod module before // the MCP SDK constructs its top-level schemas, including through managed // dispatch's tool registry (Zod 4.5.4). @@ -26,6 +26,7 @@ import { galleryEvents } from "./gallery-events"; import { ScriptLibrary } from "./scripts"; import { ScriptBackend } from "./script-backend"; import { NativeApps, type NativeEnvironment } from "./native-worker/controller"; +import { matchNativeAppPath, workspaceFromPath } from "./native-worker/urls"; import { createHash, timingSafeEqual } from "node:crypto"; import { FLEET_PUBLICATION_VAR } from "./native-worker/fleet-contract"; import { artifactRoute } from "./artifact-routes"; @@ -88,13 +89,16 @@ for (const path of ["/api/artifact/files/transfer/:object/:token", "/api/canvas/ return c.env.FILE_BACKENDS.get(id).fetch(c.req.raw); }); app.use("*", async (c, next) => { - const artifactOperation = artifactRoute(c.req.raw, c.env); - c.executionCtx.waitUntil(artifactOperation); - const artifact = await artifactOperation; - if (artifact) return artifact; + const url = new URL(c.req.url); + const appPath = matchNativeAppPath(url.pathname); + if (!appPath) { + const artifactOperation = artifactRoute(c.req.raw, c.env); + c.executionCtx.waitUntil(artifactOperation); + const artifact = await artifactOperation; + if (artifact) return artifact; + } c.header("Cache-Control", "private, no-store"); c.header("X-Content-Type-Options", "nosniff"); - const url = new URL(c.req.url); const origin = c.req.header("Origin"); if (origin && origin !== url.origin) return c.json({ error: "Origin is not allowed" }, 403); if (c.env.AUTH_MODE && !["access", "better-auth"].includes(c.env.AUTH_MODE)) return c.json({ error: "AUTH_MODE must be access or better-auth" }, 503); @@ -103,13 +107,13 @@ app.use("*", async (c, next) => { const serviceAccount = c.env.AUTH_MODE === "better-auth" && url.pathname === "/mcp" ? servicePrincipal(c.req.header("Authorization"), c.env) : null; const user = c.env.AUTH_MODE === "better-auth" && !serviceAccount ? await authenticateBetterAuth(c.req.raw, c.env) : null; if (user instanceof Response) return user; - const identity = serviceAccount ? { subject: serviceAccount.name, authority: SERVICE_TOKEN_AUTHORITY } - : user ? { subject: user.id, authority: "better-auth" } : await authenticate(c.req.raw, c.env); + const identity: Identity | Response = serviceAccount ? { subject: serviceAccount.name, authority: SERVICE_TOKEN_AUTHORITY } + : user ? { subject: user.id, authority: "better-auth", email: user.email } : await authenticate(c.req.raw, c.env); if (identity instanceof Response) return identity; c.set("user", user); - const workspace = (url.searchParams.get("workspace") ?? c.env.DEFAULT_WORKSPACE ?? "default").trim(); + const workspace = (appPath ? workspaceFromPath(appPath.workspace!) : url.searchParams.get("workspace") ?? c.env.DEFAULT_WORKSPACE ?? "default").trim(); if (!workspace || workspace.includes("\0") || new TextEncoder().encode(workspace).byteLength > 4096) return c.json({ error: "Invalid workspace" }, 400); - const libraryScope = url.searchParams.get("library") ?? "private"; + const libraryScope = appPath ? (appPath.username ? "private" : "team") : url.searchParams.get("library") ?? "private"; if (libraryScope !== "private" && libraryScope !== "team") return c.json({ error: "Library must be private or team" }, 400); // Nobody can open a service account's personal library, so it has none: it // manages the team library, and a "private" move destination resolves there @@ -122,7 +126,18 @@ app.use("*", async (c, next) => { : JSON.stringify(["private", c.env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]); const libraryKey = libraryScope === "team" ? "team" : privateKey; c.set("libraryScope", libraryScope); - c.set("service", new ManagedArtifactService(c.env, workspace, libraryKey, privateKey, url.origin, publicOrigin(c.req.url, c.env.ARTIFACTS_PUBLIC_ORIGIN), { user: identity, env: c.env })); + const service = new ManagedArtifactService(c.env, workspace, libraryKey, privateKey, url.origin, publicOrigin(c.req.url, c.env.ARTIFACTS_PUBLIC_ORIGIN), + { user: { subject: identity.subject, authority: identity.authority }, env: c.env }, identity.email); + if (appPath) { + if (appPath.username && !await service.ownsUsername(appPath.username)) return c.notFound(); + if (!appPath.path) { + url.pathname += "/"; + return c.redirect(url.pathname + url.search, 308); + } + url.pathname = appPath.path; + return service.nativeFetch(decodeURIComponent(appPath.name!), new Request(url, c.req.raw)); + } + c.set("service", service); // Compilation uses shared isolate resources. Let an admitted operation finish // after a client disconnects rather than abandoning its native compiler I/O. const operation = next(); @@ -133,6 +148,13 @@ app.get("/api/session", c => { const user = c.get("user"); return c.json({ authMode: user ? "better-auth" : "access", user: user ? { id: user.id, name: user.name, email: user.email } : null }); }); +app.post("/api/account/username", async c => { + if (!c.req.header("content-type")?.startsWith("application/json")) return c.json({ error: "Expected JSON" }, 415); + const input = JSON.parse(await readRequestText(c.req.raw, 512)); + if (typeof input?.username !== "string") return c.json({ error: "Username is required" }, 400); + const claimed = await c.get("service").claimUsername(input.username); + return claimed ? c.json({ username: input.username }) : c.json({ error: "Username is taken or your account already has one" }, 409); +}); for (const path of ["/apps/:name", "/apps/:name/*"]) app.all(path, c => { const url = new URL(c.req.url); const base = `/apps/${encodeURIComponent(c.req.param("name")!)}`; diff --git a/docs/native-workers.md b/docs/native-workers.md index 9e6fb84..f3a06fc 100644 --- a/docs/native-workers.md +++ b/docs/native-workers.md @@ -22,10 +22,15 @@ Give a native Worker a root URL like any other artifact: use **Link** under its Every app also keeps its management URL, which is always private: ```text -/apps/my-app/?workspace=default&library=private +/users/nico/default/my-app/ +/team/research/my-app/ ``` -Both routes remove management credentials/cookies before proxying and strip response cookies. The app receives its app-relative path and deployment origin. HTTP bodies stream and have no browser/MCP envelope limit. Management URLs must include their workspace/library selectors on subsequent requests; clients should preserve these selectors when constructing links. Native Workers have no iframe application viewer. +Open and **Settings → Link** use the saved slug when one exists. Otherwise they use the app's authenticated `management_url`, also returned by `app_read`, `app_write`, and `app_list`. A team link works for signed-in teammates. A personal link works only for its owner, even if the viewer has an app with the same name. Personal usernames default to the lowercase part of the sign-in email before the @: for example, `nico@example.com` gets `nico`, then another Nico gets `nico2` if that name is free. If the sign-in provides no usable email prefix, the base is `user`. The assigned name stays with the account if the email changes. You can choose a different permanent username in a personal app's **Settings → Link**; previously copied links still work. + +Relative links keep the same workspace and library, and query parameters belong to the app. Older `/apps//?workspace=…&library=…` URLs still work. The `users` and `team` root slugs can no longer be claimed. On deployments that already use either slug, the root link remains, but paths matching the new app URL shapes now route to authenticated apps. + +Both routes remove management credentials/cookies before proxying and strip response cookies. The app receives its app-relative path and deployment origin. HTTP bodies stream and have no browser/MCP envelope limit. Native Workers have no iframe application viewer. ## Manifest and source diff --git a/e2e/gallery-native-apps.test.ts b/e2e/gallery-native-apps.test.ts index fe605d3..f39eeb4 100644 --- a/e2e/gallery-native-apps.test.ts +++ b/e2e/gallery-native-apps.test.ts @@ -8,6 +8,8 @@ test("Workers share the library folders, search, selection and mobile navigation const js = await build.outputs[0]!.text(); const calls: { workspace: string; name: string; arguments: Record }[] = []; const workers = ["default", "Jobs"].map(workspace => ({ + management_url: `/team/${workspace}/example/`, + ...(workspace === "default" ? { slug: "runner-status", url: "/runner-status" } : {}), key: JSON.stringify(["worker", workspace, "example"]), workspace, kind: "worker", name: "example", provider: "cloudflare", status: "active", revision_token: "a".repeat(64), })); const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { @@ -33,6 +35,7 @@ test("Workers share the library folders, search, selection and mobile navigation manifest: { main: "worker.ts", compatibility_date: "2026-09-06" }, project: { files: {}, dependencies: {}, lock: {} }, } }); if (call.name === "app_history") return Response.json({ structuredContent: { revisions: [], next_offset: null } }); + if (call.name === "app_secrets" && !call.arguments.secrets) return Response.json({ structuredContent: { names: [] } }); throw new Error(`Unexpected tool ${call.name}`); } return new Response(galleryHtml(), { headers: { "content-type": "text/html" } }); @@ -43,7 +46,7 @@ test("Workers share the library folders, search, selection and mobile navigation page.setDefaultTimeout(5000); const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); - await page.goto(server.url.href); + await page.goto(new URL("?library=team", server.url).href); const library = page.getByRole("complementary"); const worker = library.getByTitle("Jobs/example", { exact: true }); const otherWorker = library.getByTitle("default/example", { exact: true }); @@ -72,6 +75,10 @@ test("Workers share the library folders, search, selection and mobile navigation const source = panel.getByRole("textbox", { name: "worker.ts", exact: true }); await source.waitFor(); expect(await source.innerText()).toContain('"Jobs"'); + expect(await panel.getByRole("link", { name: "Open app" }).getAttribute("href")).toBe("/team/Jobs/example/"); + await panel.getByRole("tab", { name: "Settings", exact: true }).click(); + expect(await panel.getByLabel("Share link").inputValue()).toBe(new URL("/team/Jobs/example/", server.url).href); + await panel.getByRole("tab", { name: "Source", exact: true }).click(); expect(await library.isVisible()).toBe(true); expect(await worker.getAttribute("aria-current")).toBe("true"); expect(await otherWorker.getAttribute("aria-current")).toBeNull(); @@ -105,9 +112,9 @@ test("Workers share the library folders, search, selection and mobile navigation await otherWorker.click(); await panel.waitFor(); expect(await source.innerText()).toContain("mobile draft"); - expect(await panel.getByRole("link", { name: "Open private app" }).isVisible()).toBe(true); + expect(await panel.getByRole("link", { name: "Open app" }).getAttribute("href")).toBe("/runner-status"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - expect(calls.every(call => ["app_read", "app_history"].includes(call.name))).toBe(true); + expect(calls.every(call => ["app_read", "app_history", "app_secrets"].includes(call.name))).toBe(true); expect(errors).toEqual([]); } finally { await browser.close(); server.stop(true); } }, 30000); @@ -135,6 +142,11 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi const url = new URL(request.url); if (url.pathname === "/gallery.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/account/username" && saved) { + saved.username = (await request.json()).username; + saved.management_url = `/users/${saved.username}/test/example/`; + return Response.json({ username: saved.username }); + } if (url.pathname === "/api/gallery") return Response.json({ workspace: "test", @@ -158,6 +170,7 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi true, ); saved = { + username: saved?.username ?? null, management_url: saved?.management_url ?? "/users/user-generated/test/example/", name: call.arguments.name, provider: "cloudflare", status: "active", @@ -287,8 +300,8 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi await library.getByTitle("test/example", { exact: true }).waitFor(); expect(await library.getByTitle("test/example", { exact: true }).getAttribute("aria-current")).toBe("true"); expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken); - expect(await panel.getByRole("link", { name: "Open private app" }).getAttribute("href")).toBe( - "/apps/example/?workspace=test", + expect(await panel.getByRole("link", { name: "Open app" }).getAttribute("href")).toBe( + "/users/user-generated/test/example/", ); // Another editor saves a newer project after this panel loaded the first token. saved!.revision_token = newerToken; @@ -317,6 +330,11 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi expect(new URL(page.url()).searchParams.get("appTab")).toBe("deployments"); await panel.getByRole("tab", { name: "Settings", exact: true }).click(); await panel.getByLabel("Secret name", { exact: true }).fill("TOKEN"); + await panel.getByLabel("Personal username").fill("nico"); + await panel.getByRole("button", { name: "Save username" }).click(); + await panel.getByLabel("Personal username").waitFor({ state: "hidden" }); + expect(await panel.getByLabel("Share link").inputValue()).toBe(new URL("/users/nico/test/example/", server.url).href); + expect(await panel.getByRole("link", { name: "Open app" }).getAttribute("href")).toBe("/users/nico/test/example/"); await panel.getByLabel("Secret value", { exact: true }).fill("secret-fixture-value"); const writesBeforeSecret = calls.filter((call) => call.name === "app_write").length; await panel.getByRole("button", { name: "Save secret", exact: true }).click(); diff --git a/src/gallery/native-apps.tsx b/src/gallery/native-apps.tsx index 372afab..8c27cb2 100644 --- a/src/gallery/native-apps.tsx +++ b/src/gallery/native-apps.tsx @@ -18,6 +18,8 @@ type App = { slug?: string; access?: "private" | "public"; url?: string; + management_url?: string; + username?: string | null; }; type Snapshot = App & { source: string; manifest: AppManifest; project: EditableProject }; const initialManifest = { @@ -76,6 +78,7 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS const [revisions, setRevisions] = useState<{ id: string; created_at: string }[]>([]), [restoreId, setRestoreId] = useState(""); const [editorKey, setEditorKey] = useState(0); + const [username, setUsername] = useState(""); const library = new URLSearchParams(window.location.search).get("library") === "team" ? "team" : "private"; const allowLeave = () => !dirty || window.confirm("Discard unsaved native Worker changes?"); useImperativeHandle(ref, () => ({ canLeave: () => !busy && allowLeave() })); @@ -164,15 +167,9 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS setRevisions(revisions); setRestoreId(revisions[0]?.id ?? ""); } - const appUrl = - selected?.status === "active" - ? (() => { - const params = new URLSearchParams({ workspace }); - const library = new URLSearchParams(window.location.search).get("library"); - if (library) params.set("library", library); - return `/apps/${encodeURIComponent(selected.name)}/?${params}`; - })() - : null; + const appUrl = selected?.status === "active" + ? selected.url ?? selected.management_url + : undefined; let parsed: AppManifest | null = null; try { const value = JSON.parse(manifest); @@ -235,7 +232,7 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS {detail ? {workerSections.map(tab => {tab[0]!.toUpperCase() + tab.slice(1)})} : null} - {appUrl ? : null} + {appUrl ? : null}
{detail ? ( @@ -585,9 +582,24 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS }} />

Link

+ {library === "private" && selected.username === null ?
{ + event.preventDefault(); + void perform(async () => { + const response = await fetch("/api/account/username", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username }), + }); + if (!response.ok) throw new Error((await response.json()).error || "Could not save username"); + setSelected(decode(await galleryTool(workspace, "app_read", { name }))); + await onSaved(); + }); + }}> + +

Choose once for all your personal app URLs. Previously copied links will still work.

+ + : null} { await onSaved(); setSelected(decode(await galleryTool(workspace, "app_read", { name })));