From 02aa4833ed3029113202ee94058fbd5f062809a5 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:56:49 +0000 Subject: [PATCH] Apps can use a key from Keys: every app in the workspace, or one app at a time A key saved for This workspace in Keys now has a switch, "Every app in this workspace can use it". Each app gets the key as a variable of the same name when it's built, new apps too, with nobody acting and the owner's Mac off. An app's Variables gain "From Keys": its keys from Keys by name, every app's and its own, and "Use a key from Keys" to give it one under a name of its choosing. orgo-web keeps the links and fills the value in at each build from the owner's vault, whoever deployed it, so only names pass through Bops: the owner's Mac, a member's Bops, Bops Cloud and runners never see the value. The owner, an Admin or a Member given Keys may link; one app's link in a shared workspace also needs Your Apps at Write. The owner's keys for all their workspaces stay theirs and are never offered. When Keys gets a new value, Previews that use it are built again at once and Published gets it at its next Publish. The window says that anyone who can change an app's code can read its keys. An Orgo without the new route gets "Orgo can't link keys to apps yet." and no switches. A bot working on an app is told to read keys from the environment and never write one into the folder, a commit or a chat. --- app/api/keys/apps/route.ts | 40 +++++ components/app/app-key-links.tsx | 275 +++++++++++++++++++++++++++++++ components/app/app-settings.tsx | 2 + components/app/keys.tsx | 3 + components/app/shared-apps.tsx | 2 + lib/hosted-apps.ts | 4 + lib/server/app-keys.ts | 111 +++++++++++++ lib/server/hosted-apps.ts | 12 +- lib/server/orgo.ts | 5 + lib/server/shared-keys.ts | 15 +- lib/server/shared.ts | 3 + scripts/test-hosted-apps-ui.mjs | 47 ++++++ scripts/test-hosted-apps.mjs | 24 +++ scripts/test-keys.mjs | 128 ++++++++++++++ 14 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 app/api/keys/apps/route.ts create mode 100644 components/app/app-key-links.tsx create mode 100644 lib/server/app-keys.ts 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 d16e45134..9f29ffe63 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"; /* * Keys (lib/server/keys.ts): the user's API keys their bots may call services with. They're kept in the @@ -671,6 +673,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"); });