diff --git a/app/api/keys/apps/route.ts b/app/api/keys/apps/route.ts new file mode 100644 index 000000000..04f0445b5 --- /dev/null +++ b/app/api/keys/apps/route.ts @@ -0,0 +1,40 @@ +import { KeysError } from "@/lib/server/keys"; +import { linkAppKey, listAppKeys } from "@/lib/server/app-keys"; +import { notReady } from "@/lib/server/ready"; +import { fromBopsWindow } from "@/lib/server/ui-token"; + +export const dynamic = "force-dynamic"; + +/** + * Which apps use which of the workspace's keys from Keys (lib/server/app-keys.ts): names only, never a value. Only from + * the Bops window (ui-token.ts), as every keys route: a page a bot's Chrome reached this server with mustn't see or change + * them. Under /api/keys, so it goes in a shared workspace too (components/app/view-only.ts), where orgo-web checks Keys. + * + * GET ?app=|* → { links, can, old?, said? } + * PUT { app, name?, key | null } → the same, for that app + */ +const notFromWindow = (request: Request) => (fromBopsWindow(request) ? null : Response.json({ error: "Keys can only be changed in the Bops app." }, { status: 403 })); + +const failed = (e: unknown) => + e instanceof KeysError ? Response.json({ error: e.message }, { status: e.status }) : Response.json({ error: "That didn't go. Try again." }, { status: 500 }); + +export async function GET(request: Request) { + const unready = notReady() ?? notFromWindow(request); + if (unready) return unready; + try { + return Response.json(await listAppKeys(new URL(request.url).searchParams.get("app") ?? undefined)); + } catch (e) { + return failed(e); + } +} + +export async function PUT(request: Request) { + const unready = notReady() ?? notFromWindow(request); + if (unready) return unready; + const b = (await request.json().catch(() => ({}))) as { app?: unknown; name?: unknown; key?: unknown }; + try { + return Response.json(await linkAppKey({ app: b.app, name: b.name, key: b.key })); + } catch (e) { + return failed(e); + } +} diff --git a/components/app/app-key-links.tsx b/components/app/app-key-links.tsx new file mode 100644 index 000000000..d58f6887e --- /dev/null +++ b/components/app/app-key-links.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { rowTrashBtn, TrashIcon } from "./apps"; +import { undash } from "./side-group"; + +/* + * Apps that use a key from Keys (lib/server/app-keys.ts): an app variable that points at one of This workspace's keys by + * name, and orgo-web fills the value in at each build. Two places: + * - EveryAppSwitch, on a key's row in Keys: "Every app in this workspace can use it", one switch a key. It needs no app + * row, so a brand new app gets the key with nobody acting and the owner's Mac off. + * - KeyVariables, beside an app's Variables (the owner's, and a member's with Keys and Your Apps at Write): its keys + * from Keys, one app's own under a name of its choosing, and every app's. + * Only names reach the window. Each reads GET /api/keys/apps itself, so its mount is one line wherever it shows; an Orgo + * that can't link keys yet (`old`) gets a line instead of the switches. + */ + +export const APP_KEYS_WORDS = { + every: "Every app in this workspace can use it", + everyTip: "Each app gets it as a variable of the same name when it's built, new apps too. Anyone who can change an app's code can read it.", + everyOn: "Every app gets it when it's built. A new value reaches Previews at once and Published at its next Publish.", + title: "From Keys", + forEvery: "every app", + missing: "Keys has none now", + use: "Use a key from Keys", + pick: "Pick a key", + as: "As", + add: "Add", + cancel: "Cancel", + none: "This workspace has no keys of its own yet. Add one in Keys first.", + reach: "Same for its Preview and Published. A new value in Keys reaches its Preview at once and Published at its next Publish.", + readable: "Anyone who can change this app's code can read these keys.", + failed: "That didn't change. Try again.", + offline: "Couldn't reach Bops. Try again.", +}; +const W = APP_KEYS_WORDS; + +/** A link as GET /api/keys/apps lists it: the app ("*": every app), the variable, the key; `missing`: Keys has none now. */ +export type AppKeyLink = { app: string; name: string; key: string; missing?: true }; +export type Answer = { links: AppKeyLink[]; can: { every: boolean; app: boolean }; old?: true; said?: string }; + +const readers = new Set<() => void>(); +/** Every list reads again (a link changed here). */ +const linksChanged = () => readers.forEach((f) => f()); +/** One read of a list at a time, whoever asks: every key's row on the Keys page reads the same list. */ +const reading = new Map>(); + +function readLinks(query: string): Promise { + const was = reading.get(query); + if (was) return was; + const p = fetch(`/api/keys/apps${query}`, { cache: "no-store" }) + .then(async (r) => { + const j = (await r.json().catch(() => null)) as Answer | null; + return r.ok && j && Array.isArray(j.links) ? j : null; + }) + .catch(() => null) + .finally(() => reading.delete(query)); + reading.set(query, p); + return p; +} + +/** The links of one app and every app's (`app`), or every link; null until read, or when this user can't see them. */ +function useAppKeyLinks(app?: string) { + const query = app ? `?app=${encodeURIComponent(app)}` : ""; + const [answer, setAnswer] = useState(null); + const read = useCallback(() => void readLinks(query).then(setAnswer), [query]); + useEffect(() => { + read(); + readers.add(read); + return () => void readers.delete(read); + }, [read]); + return answer; +} + +/** Link or unlink, then every list reads again: null, or why not in plain words. */ +async function putLink(body: { app: string; name?: string; key: string | null }): Promise { + try { + const res = await fetch("/api/keys/apps", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const j = (await res.json().catch(() => ({}))) as { error?: string }; + linksChanged(); + return res.ok ? null : undash(j.error ?? W.failed); + } catch { + return W.offline; + } +} + +const HINT = "text-[12px] leading-4 text-[#9A9A98]"; +const ALERT = "text-[12px] leading-4 text-[#B42318]"; + +/** + * On a key's row in Keys: whether every app in the workspace gets it. Only for This workspace's own keys (never the + * owner's for all their workspaces, nor another workspace's switched on here), for someone with Keys. + */ +export function EveryAppSwitch({ k }: { k: { name: string; scope: string; owner?: true; from?: string } }) { + const answer = useAppKeyLinks(); + return ; +} + +/** EveryAppSwitch, once the links are read (null: not yet, or this user can't see them). */ +export function EveryAppRow({ k, answer }: { k: { name: string; scope: string; owner?: true; from?: string }; answer: Answer | null }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + if (k.scope !== "workspace" || k.owner || k.from || !answer) return null; + if (answer.old) return answer.said ? {undash(answer.said)} : null; + const on = answer.links.some((l) => l.app === "*" && l.key === k.name); + const flip = async () => { + setBusy(true); + setError(await putLink(on ? { app: "*", name: k.name, key: null } : { app: "*", key: k.name })); + setBusy(false); + }; + return ( +
+ + {on && {W.everyOn}} + {error && ( + + {error} + + )} +
+ ); +} + +/** This workspace's own keys, to pick one from (GET /api/keys, as the Keys page lists them). */ +function useWorkspaceKeys(on: boolean) { + const [names, setNames] = useState(null); + useEffect(() => { + if (!on) return; + void fetch("/api/keys", { cache: "no-store" }) + .then((r) => r.json()) + .then((j: { keys?: { name: string; scope: string; owner?: true; from?: string }[] }) => + setNames((j.keys ?? []).filter((x) => x.scope === "workspace" && !x.owner && !x.from).map((x) => x.name)), + ) + .catch(() => setNames([])); + }, [on]); + return names; +} + +/** + * Beside an app's Variables: the keys it uses from Keys, its own (with Remove) and every app's, and "Use a key from Keys". + * Nothing for someone who can't see them (no Keys); the rows alone for someone who can't change this app's. + */ +export function KeyVariables({ app }: { app: string }) { + const answer = useAppKeyLinks(app); + return ; +} + +/** KeyVariables, once the links are read (null: not yet, or this user can't see them). */ +export function KeyVariablesList({ app, answer }: { app: string; answer: Answer | null }) { + const [adding, setAdding] = useState(false); + const [key, setKey] = useState(""); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const keys = useWorkspaceKeys(adding); + if (!answer) return null; + if (answer.old) return answer.said ? {undash(answer.said)} : null; + const links = answer.links.filter((l) => l.app === app || l.app === "*"); + // A variable set by this app's own link isn't every app's here. + const shown = links.filter((l) => l.app === app || !links.some((x) => x.app === app && x.name === l.name)); + if (!shown.length && !answer.can.app) return null; + const send = async (body: { app: string; name?: string; key: string | null }) => { + setBusy(true); + const no = await putLink(body); + setError(no); + setBusy(false); + if (!no) { + setAdding(false); + setKey(""); + setName(""); + } + }; + return ( +
+ {W.title} +
+ {shown.map((l) => ( +
+ + {l.name} + + + {[`from Keys (${l.key})`, l.app === "*" && W.forEvery, l.missing && W.missing].filter(Boolean).join(", ")} + + + {l.app === app && answer.can.app && ( + + )} +
+ ))} +
+ {answer.can.app && + (adding ? ( +
{ + e.preventDefault(); + if (key) void send({ app, key, ...(name.trim() && name.trim() !== key ? { name: name.trim() } : {}) }); + }} + > + {keys && !keys.length ? ( + {W.none} + ) : ( + <> + + setName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, "_"))} + className="h-7 w-44 rounded-lg bg-white px-2 font-mono text-[12px] shadow-[0_0_0_1px_#E6E6E3]" + /> + + + )} + +
+ ) : ( + + ))} + {!!shown.length && {W.reach}} + {(adding || !!shown.length) && {W.readable}} + {error && ( + + {error} + + )} +
+ ); +} diff --git a/components/app/app-settings.tsx b/components/app/app-settings.tsx index 882b4e288..8e4fa7f4d 100644 --- a/components/app/app-settings.tsx +++ b/components/app/app-settings.tsx @@ -37,6 +37,7 @@ import { PencilIcon, rowIconBtn, rowTrashBtn, TrashIcon } from "./apps"; import { act, AppEnvCtx, AppSite, CopyLink, envQuery, isSending, PILL, QUIET, removeApp, short, siteQuery, STATES, UrlLink, useSending } from "./hosted-apps"; import { InlineConfirm, undash } from "./side-group"; import { ago, post } from "./ui"; +import { KeyVariables } from "./app-key-links"; /* * An app on the web's own page, beyond what hosted-apps.tsx shows of it (lib/hosted-apps.ts): Add app on the @@ -1400,6 +1401,7 @@ export function AppSettingsSection({ ws, name, answer, onSaved }: { ws: string; onSaved({ ...answer, env: names })} first={first} lacks={answer.prodEnv?.filter((x) => !answer.env.includes(x))} /> + ); } diff --git a/components/app/keys.tsx b/components/app/keys.tsx index cdc6b67b3..176a06cc7 100644 --- a/components/app/keys.tsx +++ b/components/app/keys.tsx @@ -14,6 +14,8 @@ import { KeyIcon } from "./screen-cards"; import { DiffRow } from "./skill-proposals"; import { Chevron, undash } from "./side-group"; import { post, teamOf } from "./ui"; +import { appsShown } from "@/lib/apps-access"; +import { EveryAppSwitch } from "./app-key-links"; import { readAllowed } from "./view-only"; /* @@ -672,6 +674,7 @@ function KeyLine({ )} {routines} + {appsShown(state) && } {sure && (
diff --git a/components/app/shared-apps.tsx b/components/app/shared-apps.tsx index 83f3df712..f2ff99518 100644 --- a/components/app/shared-apps.tsx +++ b/components/app/shared-apps.tsx @@ -8,6 +8,7 @@ import type { AppState } from "@/lib/types"; import { PasswordShown } from "./preview-password"; import { memberAct } from "./shared-act"; import { ago } from "./ui"; +import { KeyVariables } from "./app-key-links"; /* * Your Apps in a workspace someone shared with the user, when its owner gave them Your Apps (lib/shared.ts SharedOpen.apps, @@ -317,6 +318,7 @@ function SettingsPart({ app: a, disabled, onSave }: { app: SharedApp; disabled: {W.noVariables} + ); } diff --git a/lib/hosted-apps.ts b/lib/hosted-apps.ts index af0da231c..3387d5b37 100644 --- a/lib/hosted-apps.ts +++ b/lib/hosted-apps.ts @@ -595,7 +595,11 @@ export type AppSettingsAnswer = { said?: string; /** Of the Preview's (env=preview): Published's variables' names, so the window says which the Preview doesn't have. */ prodEnv?: string[]; + /** The variables it gets from Keys at each build (lib/server/app-keys.ts), names only; `missing`: Keys has none now. */ + keyed?: AppKeyed[]; }; +/** A variable an app gets from Keys: its name, the key's, and whether the link is the app's own or every app's. */ +export type AppKeyed = { name: string; key: string; from: "app" | "workspace"; missing?: true }; export type AppSettingsChange = { ws: string; site?: string; env?: "preview" | "prod"; port?: number | string; install?: string; build?: string; start?: string; health?: string }; export type AppEnvChange = { ws: string; site?: string; where?: AppEnv; env: Record }; diff --git a/lib/server/app-keys.ts b/lib/server/app-keys.ts new file mode 100644 index 000000000..167b3a9cd --- /dev/null +++ b/lib/server/app-keys.ts @@ -0,0 +1,111 @@ +import "server-only"; +import { KEY_NAME } from "@/cloud/protocol"; +import { appsOpen } from "@/lib/apps-access"; +import { ENV_NAME, ENV_RESERVED, isAppName } from "@/lib/hosted-apps"; +import { KEYS_GIVEN_LINE } from "@/lib/shared"; +import { KeysError, keyName, keysProject } from "./keys"; +import { sharedKeysOpen, sharedOpen } from "./shared"; +import { APP_KEYS_NOT_ON_ORGO, KEYS_NOT_ON_ORGO, projectKeys } from "./shared-keys"; +import { getState } from "./store"; + +/* + * An app can use a key from Keys (orgo-web's bops-keys/apps, lib/deploys/key-links.ts there): an app variable that points + * at one of the workspace's own keys (This workspace) by name, so nobody copies ANTHROPIC_API_KEY into each new app. A link + * is for one app, or for every app in the workspace ("*": today's and every new one, with nobody acting and the owner's + * Mac off). orgo-web fills the value in at each build from the owner's vault row for the workspace, whoever deployed it + * (the owner's Mac, a member, a runner of any version), and builds the live Previews that use a key again when it's saved; + * Published gets it at its next Publish. + * + * Only names pass through here: a link is (app, variable, key's name), and no value is sent, read, kept or logged. Who may: + * someone with Keys (the owner, an Admin, a Member given Keys), as orgo-web decides it again with their own Orgo key; one + * app's link also needs Your Apps at Write in a shared workspace. The owner's keys for all their workspaces are never + * linked: orgo-web takes This workspace's only. The Orgo project is the open workspace's (keysProject, or the shared + * copy's), never one from the request. + * + * An orgo-web from before links answers 405 (bops-keys/apps falls into bops-keys/{name}) or 503 APP_KEYS_UNAVAILABLE: the + * list then says `old`, and the window hides the switches with APP_KEYS_NOT_ON_ORGO. + */ + +/** One link as the window shows it: the app ("*": every app), the variable, the key, and `missing` when Keys has no such key now. */ +export type AppKeyLink = { app: string; name: string; key: string; missing?: true }; +/** + * GET /api/keys/apps: the links (one app's and every app's, or all), and what the user may change here (`can.every`: a + * link for every app; `can.app`: one app's). `old`: orgo-web can't link keys to apps yet (`said` says so). + */ +export type AppKeysAnswer = { links: AppKeyLink[]; can: { every: boolean; app: boolean }; old?: true; said?: string }; + +export const EVERY_APP = "*"; + +/** Where the open workspace's links are, and what the user may change there. Refused without Keys, or where apps aren't. */ +function linksHome(): { projectId: string; can: AppKeysAnswer["can"] } { + if (sharedOpen()) { + const open = sharedKeysOpen(); + if (!open) throw new KeysError("Open the workspace again.", 409); + if (!open.keys) throw new KeysError(KEYS_GIVEN_LINE, 403); + return { projectId: open.projectId, can: { every: true, app: open.appsWrite } }; + } + if (!appsOpen(getState())) throw new KeysError("Not found", 404); + const projectId = keysProject(); + if (!projectId) throw new KeysError("This workspace isn't on Orgo yet, so its keys can't go to its apps.", 409); + return { projectId, can: { every: true, app: true } }; +} + +/** The links as orgo-web answered them, names only: anything else it sent is dropped. */ +function linksOf(v: unknown): AppKeyLink[] { + if (!Array.isArray(v)) return []; + const out: AppKeyLink[] = []; + for (const x of v) { + if (!x || typeof x !== "object") continue; + const { app, name, key, missing } = x as Record; + if (typeof app !== "string" || (app !== EVERY_APP && !isAppName(app))) continue; + if (typeof name !== "string" || !ENV_NAME.test(name) || typeof key !== "string" || !KEY_NAME.test(key)) continue; + out.push({ app, name, key, ...(missing === true ? { missing: true as const } : {}) }); + } + return out; +} + +/** An orgo-web that can't link keys to apps yet. */ +const isOld = (e: unknown) => e instanceof KeysError && e.status === 503 && (e.message === KEYS_NOT_ON_ORGO || e.message === APP_KEYS_NOT_ON_ORGO); + +/** The app asked for: "*" or an app's name; anything else is refused. */ +function appArg(v: unknown): string { + if (v === EVERY_APP || isAppName(v)) return v; + throw new KeysError("Pick an app.", 400); +} + +/** The links of the open workspace: one app's and every app's (`app`), or all of them. */ +export async function listAppKeys(app?: unknown): Promise { + const a = app === undefined || app === null || app === "" ? undefined : appArg(app); + const home = linksHome(); + try { + const got = await projectKeys<{ links?: unknown }>(home.projectId, "GET", `/apps${a ? `?app=${encodeURIComponent(a)}` : ""}`); + return { links: linksOf(got.links), can: home.can }; + } catch (e) { + if (isOld(e)) return { links: [], can: { every: false, app: false }, old: true, said: APP_KEYS_NOT_ON_ORGO }; + throw e; + } +} + +/** + * Link a variable of one app, or of every app ("*"), to a key of This workspace, or take the link away (`key` null). + * `name`: the variable (default: the key's name; for every app always the key's). Answers that app's links as listAppKeys. + */ +export async function linkAppKey(b: { app?: unknown; name?: unknown; key?: unknown }): Promise { + const app = appArg(b.app); + const key = b.key === null ? null : typeof b.key === "string" ? keyName(b.key) : ""; + if (key !== null && !KEY_NAME.test(key)) throw new KeysError("Pick a key from Keys.", 400); + const name = typeof b.name === "string" && b.name.trim() ? b.name.trim() : key; + if (!name) throw new KeysError("Which variable?", 400); + if (ENV_RESERVED.has(name)) throw new KeysError(`${name} is set by Orgo. Pick another name.`, 400); + if (!ENV_NAME.test(name)) throw new KeysError("Use capital letters, digits and underscores for its name.", 400); + if (app === EVERY_APP && key !== null && name !== key) throw new KeysError("A key for every app keeps its own name.", 400); + const home = linksHome(); + if (app !== EVERY_APP && !home.can.app) throw new KeysError("You can link a key to an app once you have Write on Your Apps.", 403); + try { + const got = await projectKeys<{ links?: unknown }>(home.projectId, "PUT", "/apps", { app, var: name, key }); + return { links: linksOf(got.links), can: home.can }; + } catch (e) { + if (isOld(e)) throw new KeysError(APP_KEYS_NOT_ON_ORGO, 503); + throw e; + } +} diff --git a/lib/server/hosted-apps.ts b/lib/server/hosted-apps.ts index 70953244e..6d1be7883 100644 --- a/lib/server/hosted-apps.ts +++ b/lib/server/hosted-apps.ts @@ -22,6 +22,7 @@ import type { AppDomainRemoved, AppDomainsAnswer, AppHosting, + AppKeyed, AppProjectBehind, AppRenewal, AppRestoreConfirm, @@ -2413,6 +2414,14 @@ const deployOf = (d: OrgoDeployment): AppDeploy => ({ canRollback: !!d.can_rollback && d.state === "succeeded", }); +/** What a project gets from Keys, as orgo-web names it (names only; anything else it sent is dropped). */ +const keyedOf = (v: unknown[]): AppKeyed[] => + v.flatMap((x) => { + const { name, key, from, missing } = (x ?? {}) as Record; + if (typeof name !== "string" || !ENV_NAME.test(name) || typeof key !== "string" || !key || (from !== "app" && from !== "workspace")) return []; + return [{ name, key, from, ...(missing === true ? { missing: true as const } : {}) }]; + }); + /** A project's settings as the window shows them; `url`: its address (a Preview's link; Published's first active domain, or null). */ const settingsOf = (p: OrgoDeployProject, url: string | null): AppSettingsAnswer => ({ url, @@ -2424,6 +2433,7 @@ const settingsOf = (p: OrgoDeployProject, url: string | null): AppSettingsAnswer ...(typeof p.health_path === "string" ? { health: p.health_path } : {}), }, env: [...new Set((p.env_keys ?? []).filter((k) => typeof k === "string"))].sort(), + ...(Array.isArray(p.env_from_keys) ? { keyed: keyedOf(p.env_from_keys) } : {}), deploys: [...(p.deployments ?? [])] .sort((a, b) => (Date.parse(b.created_at) || 0) - (Date.parse(a.created_at) || 0)) .slice(0, DEPLOYS_SHOWN) @@ -4849,7 +4859,7 @@ export function appWork(botId: string, a: { app: string | null; name: string | n : `Work on ${owner}'s app ${name}. It's the Ingot repo ${repo} (Ingot is Orgo's source control; name it in full wherever a command takes a repo), and its working folder on this computer is ${dir}: Bops cloned it there, or pulled the newest into it, as this task started.`, `What to do: ${change}`, WHERE_THINGS_ARE(owner), - "1. Make the change in that folder. An app that runs a server listens on the port in $PORT, which Orgo sets when it deploys it and bops-local sets for its Local; a plain site needs only an index.html. Keep secrets out of the folder: .env files are never committed. Read its own address from AUTH_URL (or NEXTAUTH_URL) rather than writing one in: Bops sets it to its Local's address for its Local, its preview link for its Preview, and its domain once published.", + "1. Make the change in that folder. An app that runs a server listens on the port in $PORT, which Orgo sets when it deploys it and bops-local sets for its Local; a plain site needs only an index.html. Keep secrets out of the folder: .env files are never committed. Read its own address from AUTH_URL (or NEXTAUTH_URL) rather than writing one in: Bops sets it to its Local's address for its Local, its preview link for its Preview, and its domain once published. A key from the workspace's Keys (ANTHROPIC_API_KEY, say) comes to it as a variable on every deploy, and to its Local after its first Preview: read it from the environment, and never write a key into the folder, a commit or a chat.", `2. Run it as its Local and leave it running: bops-local start ${word} ${site} (it runs its dev script, else its start script, with hot reload, and prints a BOPS_LOCAL line whose fourth word is its port). ${owner} watches it live in Bops beside this chat while you save. Check each change with curl -i http://127.0.0.1:/. If it fails, bops-local status ${word} and /root/.bops/local/${word}--${site}.log say why. Don't run another server on that port, and don't stop its Local when you're done.`, `3. Commit it from the folder: ingot commit -m "" (or the Ingot tools' repo_commit). ${SYNC_WORDS}`, ...(others ? [OTHERS_WORDS(name)] : []), diff --git a/lib/server/orgo.ts b/lib/server/orgo.ts index df484223a..464b6a59a 100644 --- a/lib/server/orgo.ts +++ b/lib/server/orgo.ts @@ -1250,6 +1250,11 @@ export type OrgoDeployProject = { health_path?: string; /** Its variables' names (never their values). */ env_keys?: string[]; + /** + * The variables it gets from Keys at each build (an orgo-web with bops-keys/apps: lib/server/app-keys.ts), names only: + * the app's own link or every app's, and `missing` when Keys has no such key now. An older orgo-web leaves it out. + */ + env_from_keys?: { name: string; key: string; from: "app" | "workspace"; missing?: boolean }[]; current?: OrgoDeployment | null; deployments?: OrgoDeployment[]; }; diff --git a/lib/server/shared-keys.ts b/lib/server/shared-keys.ts index 5f2c1c03c..783637823 100644 --- a/lib/server/shared-keys.ts +++ b/lib/server/shared-keys.ts @@ -25,6 +25,8 @@ export const KEYS_UNAVAILABLE = "KEYS_UNAVAILABLE"; /** What the Keys page says when orgo-web has no bops-keys routes yet (an older orgo-web). */ export const KEYS_NOT_ON_ORGO = "Orgo can't keep this workspace's keys yet."; +/** What the window says when orgo-web can't link keys to apps yet (no bops-keys/apps route, or no table for it: lib/server/app-keys.ts). */ +export const APP_KEYS_NOT_ON_ORGO = "Orgo can't link keys to apps yet."; const NOT_SIGNED_IN = "Sign in to Orgo to use Keys: they're kept in your Orgo vault."; /** The owner's keys for all their workspaces, as the window's rows say whose they are. */ export const OWNERS_KEY = "That's the owner's key. Only they can see, replace or remove it."; @@ -41,10 +43,17 @@ const SAID: Record = { value_too_long: { status: 400, words: "That key is too long." }, app_key_required: { status: 403, words: "Orgo shows a key only to a Bops signed in with Orgo. Sign out, then sign in again." }, audit_unavailable: { status: 503, words: "Orgo couldn't note who looked just now, so it didn't send the key. Try again in a minute." }, + // The links of keys to apps (bops-keys/apps, lib/server/app-keys.ts). + APP_KEYS_UNAVAILABLE: { status: 503, words: APP_KEYS_NOT_ON_ORGO }, + every_app_name: { status: 400, words: "A key for every app keeps its own name." }, + too_many: { status: 400, words: "An app can use at most 64 keys from Keys." }, }; -/** One call to the shared workspace's bops-keys routes on orgo-web, with the user's own Orgo key. Never logs what it sent or got. */ -async function projectKeys(projectId: string, method: "GET" | "POST" | "DELETE", path = "", body?: unknown): Promise { +/** + * One call to a workspace's bops-keys routes on orgo-web, with the user's own Orgo key (a Keys holder's in a shared + * workspace; the owner's own for app links, lib/server/app-keys.ts). Never logs what it sent or got. + */ +export async function projectKeys(projectId: string, method: "GET" | "POST" | "PUT" | "DELETE", path = "", body?: unknown): Promise { let key: string; try { key = await apiKey(); @@ -73,6 +82,8 @@ async function projectKeys(projectId: string, method: "GET" | "POST" | "DELET void sharedKeysOpen()?.refresh().catch(() => {}); throw new KeysError(KEYS_GIVEN_LINE, 403); } + // A method the route doesn't have: an orgo-web from before it (bops-keys/apps falls into bops-keys/{name}, DELETE only). + if (res.status === 405) throw new KeysError(KEYS_NOT_ON_ORGO, 503); // No such route (an orgo-web before it answers a page, not JSON), or not a member of it any more. if (res.status === 404) throw json && typeof json.error === "string" ? new KeysError("This workspace isn't shared with you anymore.", 404) : new KeysError(KEYS_NOT_ON_ORGO, 503); if (res.status === 429) throw new KeysError("You've looked at a lot of keys this hour. Try again later.", 429); diff --git a/lib/server/shared.ts b/lib/server/shared.ts index 10a08d6cd..7da930dca 100644 --- a/lib/server/shared.ts +++ b/lib/server/shared.ts @@ -241,6 +241,8 @@ export async function sharedFileTarget(botId: string, { screen = false } = {}): export function sharedKeysOpen(): { projectId: string; keys: boolean; + /** Your Apps at Write: who may link a key to one app (lib/server/app-keys.ts). */ + appsWrite: boolean; keyActs: boolean; keyNames: SharedKey[]; bots: { id: string; keys: string[] }[]; @@ -253,6 +255,7 @@ export function sharedKeysOpen(): { return { projectId: open.at.projectId, keys: s.keys === true, + appsWrite: s.apps?.level === "write", keyActs: s.keyActs === true, keyNames: s.keys && Array.isArray(s.keyNames) ? s.keyNames : [], bots: s.bots.flatMap((b) => (typeof b.id === "string" ? [{ id: b.id, keys: s.keys ? switchedKeys(b, acts) : [] }] : [])), diff --git a/scripts/test-hosted-apps-ui.mjs b/scripts/test-hosted-apps-ui.mjs index 7685aecfc..0f544e9c6 100644 --- a/scripts/test-hosted-apps-ui.mjs +++ b/scripts/test-hosted-apps-ui.mjs @@ -3309,5 +3309,52 @@ await test("production on GitHub (AppHosting.github): Published says where on Gi assert.deepEqual(E.ghMirrorOf({ repo: "a/b", branch: "main", path: "", keep: "", workflow: "", inputs: "" }), { repo: "a/b", branch: "main", path: "" }); }); +await test("apps that use a key from Keys (app-key-links.tsx): an app's keys from Keys by name, its own with Remove and every app's, the picker for who may; the Keys row's Every app switch for This workspace's own keys; a line for an older Orgo; plain words", async () => { + const K = await import(pathToFileURL(`${root}/components/app/app-key-links.tsx`).href); + const can = { every: true, app: true }; + /** What it says, its HTML entities read back. */ + const said = (html) => text(html).replace(/'/g, "'").replace(/"/g, '"').replace(/&/g, "&").replace(/\s+/g, " ").trim(); + const links = [ + { app: "*", name: "ANTHROPIC_API_KEY", key: "ANTHROPIC_API_KEY" }, + { app: "*", name: "OPENAI_API_KEY", key: "OPENAI_API_KEY" }, + { app: "notes", name: "OPENAI_API_KEY", key: "OPENAI_PROJECT_KEY", missing: true }, + { app: "other", name: "GITHUB_TOKEN", key: "GITHUB_TOKEN" }, + ]; + const vars = (answer) => renderToStaticMarkup(h(K.KeyVariablesList, { app: "notes", answer })); + let html = vars({ links, can }); + assert.match(said(html), /ANTHROPIC_API_KEY\s*from Keys \(ANTHROPIC_API_KEY\), every app/); + assert.match(said(html), /OPENAI_API_KEY\s*from Keys \(OPENAI_PROJECT_KEY\), Keys has none now/, "its own link over every app's of that name"); + assert.ok(!/from Keys \(OPENAI_API_KEY\)/.test(said(html)), "every app's link of the name its own link sets isn't shown"); + assert.ok(!html.includes("GITHUB_TOKEN"), "another app's isn't"); + assert.ok(html.includes('aria-label="Stop OPENAI_API_KEY using OPENAI_PROJECT_KEY"'), "its own link has Remove"); + assert.ok(!html.includes("Stop ANTHROPIC_API_KEY"), "every app's is changed in Keys, not here"); + for (const w of [K.APP_KEYS_WORDS.use, K.APP_KEYS_WORDS.readable, K.APP_KEYS_WORDS.reach]) assert.ok(said(html).includes(w), w); + // Someone who may see them but not change this app's: the rows alone. + html = vars({ links, can: { every: true, app: false } }); + assert.ok(html.includes("ANTHROPIC_API_KEY") && !said(html).includes(K.APP_KEYS_WORDS.use) && !html.includes("aria-label=\"Stop ")); + assert.equal(vars({ links: [], can: { every: true, app: false } }), "", "nothing to show, nothing to change"); + assert.equal(vars(null), "", "not read yet, or no Keys"); + assert.ok(said(vars({ links: [], can })).includes(K.APP_KEYS_WORDS.use), "none yet: the picker"); + assert.equal(said(vars({ links: [], can: { every: false, app: false }, old: true, said: "Orgo can't link keys to apps yet." })), "Orgo can't link keys to apps yet."); + // The Keys row's switch. + const row = (k, answer = { links, can }) => renderToStaticMarkup(h(K.EveryAppRow, { k, answer })); + html = row({ name: "ANTHROPIC_API_KEY", scope: "workspace" }); + assert.ok(html.includes('role="switch"') && html.includes('aria-checked="true"') && said(html).includes(K.APP_KEYS_WORDS.every) && said(html).includes(K.APP_KEYS_WORDS.everyOn)); + assert.ok(row({ name: "STRIPE_SECRET_KEY", scope: "workspace" }).includes('aria-checked="false"')); + for (const k of [{ name: "ANTHROPIC_API_KEY", scope: "all" }, { name: "ANTHROPIC_API_KEY", scope: "workspace", owner: true }, { name: "ANTHROPIC_API_KEY", scope: "workspace", from: "ws_other" }]) { + assert.equal(row(k), "", `never for ${JSON.stringify(k)}: the owner's for all their workspaces stay theirs`); + } + assert.equal(row({ name: "ANTHROPIC_API_KEY", scope: "workspace" }, null), ""); + assert.equal(said(row({ name: "ANTHROPIC_API_KEY", scope: "workspace" }, { links: [], can: { every: false, app: false }, old: true, said: "Orgo can't link keys to apps yet." })), "Orgo can't link keys to apps yet."); + for (const w of Object.values(K.APP_KEYS_WORDS)) { + assert.ok(!NO_DASHES.test(w), `no dashes: ${w}`); + assert.ok(!/orbit/i.test(w), `Knowledge, never Orbit: ${w}`); + } + // Each mount is one line where #186 rewrites Your Apps, and keeps working there. + for (const [file, mount] of [["app-settings.tsx", ""], ["shared-apps.tsx", ""], ["keys.tsx", ""]]) { + assert.ok(readFileSync(`${root}/components/app/${file}`, "utf8").includes(mount), `${file} mounts it`); + } +}); + console.log(`\n${passed} passed${failed ? `, ${failed} failed` : ""}`); process.exit(failed ? 1 : 0); diff --git a/scripts/test-hosted-apps.mjs b/scripts/test-hosted-apps.mjs index 2513d1a94..058f07e23 100644 --- a/scripts/test-hosted-apps.mjs +++ b/scripts/test-hosted-apps.mjs @@ -680,6 +680,8 @@ const projectView = (p) => ({ // (Said once it was changed: before, the fake is an orgo-web that doesn't say it, as Bops' first one didn't.) ...(p.health_path !== undefined ? { health_path: p.health_path } : {}), env_keys: [...(web.envs?.get(p.id)?.keys() ?? [])], + // What it gets from Keys, from an orgo-web that says it (a test sets it). + ...(web.keyed?.has(p.id) ? { env_from_keys: web.keyed.get(p.id) } : {}), current: newestFirst(p).find((d) => d.state === "succeeded") ?? null, deployments: newestFirst(p).map(({ log, ...d }) => (void log, d)), }); @@ -3228,6 +3230,28 @@ await check("Its variables: set and removed on its deploy project, the values go for (const f of files) assert.ok(!readFileSync(f).includes(SECRET), `not on disk: ${f}`); }); +await check("What it gets from Keys: orgo-web's env_from_keys, by name and where each comes from, into keyed; nothing else orgo-web sent; none from an orgo-web that doesn't say", async () => { + const p = recipeProject(); + assert.equal((await H.appSettings(WS, "recipe-box")).keyed, undefined, "an older orgo-web: not said"); + web.keyed = new Map([[p.id, [ + { name: "ANTHROPIC_API_KEY", key: "ANTHROPIC_API_KEY", from: "workspace" }, + { name: "AI_KEY", key: "OPENAI_API_KEY", from: "app", missing: true, value: "sk-made-up-never-shown" }, + { name: "bad name", key: "X", from: "app" }, + { name: "OTHER", key: "Y", from: "elsewhere" }, + ]]]); + try { + const got = await H.appSettings(WS, "recipe-box"); + assert.deepEqual(got.keyed, [ + { name: "ANTHROPIC_API_KEY", key: "ANTHROPIC_API_KEY", from: "workspace" }, + { name: "AI_KEY", key: "OPENAI_API_KEY", from: "app", missing: true }, + ]); + assert.ok(!JSON.stringify(got).includes("sk-made-up-never-shown"), "never a value"); + assert.deepEqual(got.env, (await H.appSettings(WS, "recipe-box")).env, "its own variables as before"); + } finally { + web.keyed = undefined; + } +}); + await check("Roll back: an earlier deploy of its own that went live and is still on the computer put back without a build, the app answered with it queued; the live one, a failed one, one that isn't its, or a made-up id refused before Orgo is asked; Orgo's refusals in words", async () => { const p = recipeProject(); const back = { id: randomUUID(), project_id: p.id, state: "succeeded", phase: null, error: null, source_version: sha("an older one"), created_at: iso(), finished_at: iso(), log: [], can_rollback: true, release_live: false }; diff --git a/scripts/test-keys.mjs b/scripts/test-keys.mjs index 1faede8b4..dd9b9ea9c 100644 --- a/scripts/test-keys.mjs +++ b/scripts/test-keys.mjs @@ -99,6 +99,9 @@ const orgoCalls = []; /** A shared workspace's own keys as orgo-web's bops-keys routes keep them, and what they answer next instead ("page": a route it doesn't have). */ const projectVault = new Map([["LINEAR_API_KEY", "lin_api_fake_shared"]]); const projectSays = []; +/** The links of keys to apps orgo-web keeps (bops-keys/apps), and what it answers next instead ("old": no such route yet). */ +const projectLinks = []; +const linksSay = []; /** Orgo's reveal answers 429 while this is set: its rate limit. */ let revealLimited = false; /** What the broker answers next for a path, else a small JSON answer. */ @@ -146,6 +149,22 @@ const server = createServer(async (req, res) => { res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify({ name: body.name, value: vault.get(body.name) })); } + // Which apps use which of a workspace's keys (orgo-web's bops-keys/apps), names only. "old": an orgo-web from before it, + // where the path falls into bops-keys/{name} (DELETE only) and answers 405. + const pl = url.pathname.match(/^\/api\/projects\/([^/]+)\/bops-keys\/apps$/); + if (pl) { + const say = linksSay.shift(); + if (say === "old") return res.writeHead(405), res.end(); + if (say) return send(say.status, say.body); + const shown = (app) => projectLinks.filter((l) => !app || l.app === app || l.app === "*"); + if (req.method === "GET") return send(200, { links: shown(url.searchParams.get("app")) }); + if (req.method === "PUT") { + const at = projectLinks.findIndex((l) => l.app === body.app && l.name === body.var); + if (at >= 0) projectLinks.splice(at, 1); + if (body.key !== null) projectLinks.push({ app: body.app, name: body.var, key: body.key, ...(body.key === "NOTION_API_KEY" ? { missing: true } : {}) }); + return send(200, { links: shown(body.app) }); + } + } // A shared workspace's own keys (orgo-web's bops-keys routes), as its Keys holder reaches them with their own key. const pk = url.pathname.match(/^\/api\/projects\/([^/]+)\/bops-keys(?:\/(reveal|[A-Z0-9_]+))?$/); if (pk) { @@ -918,6 +937,7 @@ const openShared = (o = {}) => { view: () => ({ projectId: SHARED_PROJECT, keys: o.keys ?? true, + appsWrite: o.appsWrite ?? false, keyActs: o.keyActs ?? true, keyNames: o.keyNames ?? [{ name: "GITHUB_TOKEN", scope: "all" }, { name: "LINEAR_API_KEY", scope: "workspace" }, { name: "PLAIN_API_KEY", scope: "workspace", from: true }], bots: [{ id: "max", keys: ["GITHUB_TOKEN", "LINEAR_API_KEY"] }, { id: "ivy", keys: [] }], @@ -1250,6 +1270,114 @@ await check("in a shared workspace a Member without Keys can't save from the car } }); +/* ---------------- Apps that use a key from Keys (lib/server/app-keys.ts) ---------------- */ + +const AppKeysRoute = await import(`${root}/app/api/keys/apps/route.ts`); +const linkCalls = (project) => orgoCalls.filter((c) => c.path === `/api/projects/${project}/bops-keys/apps`); +const LINK_VALUE = "sk-ant-made-up-never-sent-0001"; +const answered = async (res) => [res.status, await res.json()]; + +await check("apps that use a key, in the user's own workspace: the workspace's Orgo project, with their own key, names only; every app's or one app's; only from the window, only where apps are open", async () => { + const PROJ = "4b4b4b4b-1111-4222-8333-944455556666"; + const had = { workspaces: S.getState().workspaces, workspace: S.getState().workspace, account: S.getState().account }; + S.update((st) => { + st.workspaces = [{ id: "ws_main", name: "Main", createdAt: 0, orgoProjectId: PROJ }]; + st.workspace = "ws_main"; + st.account = { user: { id: "u_alex", email: "alex@orgo.ai", name: "Alex" }, signedInAt: 1 }; + }); + projectLinks.length = 0; + try { + let n = linkCalls(PROJ).length; + let [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "*", key: "anthropic-api-key", value: LINK_VALUE, projectId: SHARED_PROJECT }))); + assert.equal(status, 200, JSON.stringify(got)); + assert.deepEqual(got, { links: [{ app: "*", name: "ANTHROPIC_API_KEY", key: "ANTHROPIC_API_KEY" }], can: { every: true, app: true } }); + let calls = linkCalls(PROJ).slice(n); + assert.deepEqual(calls.map((c) => [c.method, c.auth, c.body]), [["PUT", `Bearer ${ORGO_KEY}`, { app: "*", var: "ANTHROPIC_API_KEY", key: "ANTHROPIC_API_KEY" }]], "the open workspace's project, never one from the request; no value"); + [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "notes", name: "AI_KEY", key: "NOTION_API_KEY" }))); + assert.equal(status, 200); + assert.deepEqual(got.links, [{ app: "*", name: "ANTHROPIC_API_KEY", key: "ANTHROPIC_API_KEY" }, { app: "notes", name: "AI_KEY", key: "NOTION_API_KEY", missing: true }]); + [status, got] = await answered(await AppKeysRoute.GET(req("GET", "/api/keys/apps?app=notes"))); + assert.equal(status, 200); + assert.equal(linkCalls(PROJ).at(-1).search, "?app=notes"); + assert.equal(got.links.length, 2); + [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "*", name: "ANTHROPIC_API_KEY", key: null }))); + assert.deepEqual([status, got.links], [200, []]); + assert.deepEqual(linkCalls(PROJ).at(-1).body, { app: "*", var: "ANTHROPIC_API_KEY", key: null }); + // Refused here, Orgo not asked. + n = linkCalls(PROJ).length; + for (const [body, want] of [ + [{ app: "*", name: "CLAUDE_KEY", key: "ANTHROPIC_API_KEY" }, "A key for every app keeps its own name."], + [{ app: "notes", name: "PORT", key: "ANTHROPIC_API_KEY" }, "PORT is set by Orgo. Pick another name."], + [{ app: "notes", name: "ai-key", key: "ANTHROPIC_API_KEY" }, "Use capital letters, digits and underscores for its name."], + [{ app: "Not An App", key: "ANTHROPIC_API_KEY" }, "Pick an app."], + [{ app: "notes", key: "" }, "Pick a key from Keys."], + ]) { + [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", body))); + assert.deepEqual([status, got.error], [400, want], JSON.stringify(body)); + noDashes(got.error, "a refusal"); + } + assert.equal((await AppKeysRoute.GET(req("GET", "/api/keys/apps", undefined, false))).status, 403, "only from the Bops window"); + assert.equal((await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "*", key: "ANTHROPIC_API_KEY" }, false))).status, 403); + S.update((st) => void (st.account = { user: { id: "u_x", email: "someone@example.com", name: "Someone" }, signedInAt: 1 })); + assert.equal((await AppKeysRoute.GET(req("GET", "/api/keys/apps"))).status, 404, "where apps aren't open"); + assert.equal(linkCalls(PROJ).length, n); + } finally { + S.update((st) => Object.assign(st, had)); + } +}); + +await check("apps that use a key, in a shared workspace: the copy's Orgo project with the user's own key; every app's with Keys, one app's only with Your Apps at Write too; without Keys refused before Orgo is asked", async () => { + projectLinks.length = 0; + openShared(); + try { + let n = linkCalls(SHARED_PROJECT).length; + let [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "*", key: "LINEAR_API_KEY" }))); + assert.equal(status, 200, JSON.stringify(got)); + assert.deepEqual(got.can, { every: true, app: false }); + assert.equal(linkCalls(SHARED_PROJECT).length, n + 1); + [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "notes", key: "LINEAR_API_KEY" }))); + assert.deepEqual([status, got.error], [403, "You can link a key to an app once you have Write on Your Apps."]); + assert.equal(linkCalls(SHARED_PROJECT).length, n + 1, "not asked"); + openShared({ appsWrite: true }); + [status, got] = await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "notes", name: "TRACKER_KEY", key: "LINEAR_API_KEY" }))); + assert.equal(status, 200); + assert.deepEqual(linkCalls(SHARED_PROJECT).at(-1).body, { app: "notes", var: "TRACKER_KEY", key: "LINEAR_API_KEY" }); + openShared({ keys: false, appsWrite: true }); + n = linkCalls(SHARED_PROJECT).length; + for (const res of [await AppKeysRoute.GET(req("GET", "/api/keys/apps")), await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "*", key: "LINEAR_API_KEY" }))]) { + assert.deepEqual(await answered(res), [403, { error: "The owner or an Admin can give you Keys." }]); + } + assert.equal(linkCalls(SHARED_PROJECT).length, n); + // What orgo-web refuses, in its words: Keys taken away meanwhile. + openShared({ appsWrite: true }); + linksSay.push({ status: 403, body: { error: "x", code: "KEYS_NOT_ALLOWED" } }); + assert.deepEqual(await answered(await AppKeysRoute.GET(req("GET", "/api/keys/apps"))), [403, { error: "The owner or an Admin can give you Keys." }]); + } finally { + closeShared(); + } +}); + +await check("apps that use a key, with an orgo-web from before links: the list says so and offers nothing, a link is refused in plain words; anything but names orgo-web sends is dropped", async () => { + openShared({ appsWrite: true }); + try { + // A 405 (the path falls into bops-keys/{name}), its database without the table (503), or a 404 that isn't orgo-web's own. + for (const say of ["old", { status: 503, body: { error: "x", code: "APP_KEYS_UNAVAILABLE" } }, { status: 404, body: null }]) { + linksSay.push(say); + const [status, got] = await answered(await AppKeysRoute.GET(req("GET", "/api/keys/apps?app=notes"))); + assert.deepEqual([status, got], [200, { links: [], can: { every: false, app: false }, old: true, said: "Orgo can't link keys to apps yet." }], JSON.stringify(say)); + } + linksSay.push("old"); + assert.deepEqual(await answered(await AppKeysRoute.PUT(req("PUT", "/api/keys/apps", { app: "*", key: "LINEAR_API_KEY" }))), [503, { error: "Orgo can't link keys to apps yet." }]); + linksSay.push({ status: 200, body: { links: [{ app: "*", name: "LINEAR_API_KEY", key: "LINEAR_API_KEY", value: LINK_VALUE }, { app: "../x", name: "A", key: "B" }, { app: "notes", name: "lower", key: "B" }] } }); + const [, got] = await answered(await AppKeysRoute.GET(req("GET", "/api/keys/apps"))); + assert.deepEqual(got.links, [{ app: "*", name: "LINEAR_API_KEY", key: "LINEAR_API_KEY" }]); + assert.ok(!JSON.stringify(got).includes(LINK_VALUE)); + } finally { + closeShared(); + } + assert.ok(!orgoCalls.some((c) => JSON.stringify(c.body ?? "").includes(LINK_VALUE)), "no value ever went to Orgo"); +}); + await check("no dashes in anything Bops said or told the model", () => { for (const line of [...spoken, ...messages().map((m) => m.text)]) noDashes(line, "a line"); });