diff --git a/app/api/hosted-apps/[name]/route.ts b/app/api/hosted-apps/[name]/route.ts index 729c1067e..ad2391bcd 100644 --- a/app/api/hosted-apps/[name]/route.ts +++ b/app/api/hosted-apps/[name]/route.ts @@ -9,6 +9,9 @@ import { MAIN_WORKSPACE } from "@/lib/types"; export const dynamic = "force-dynamic"; +/** What this route says to a GitHub change while a workspace someone shared is open: it's done in that workspace's Your Apps. */ +const SHARED_GITHUB = "Change it in this workspace's Your Apps."; + /** * Put an app on the web (HostedAppAction in lib/hosted-apps.ts): "deploy" changeset `at` (the newest on main * by default) on its Preview, on the computer it's worked on (or on `botId`'s the first time), every site of it @@ -82,10 +85,13 @@ export async function POST(request: Request, ctx: { params: Promise<{ name: stri if (b.action === "addSite") return Response.json(await addSite(wsId, name, b.site)); if (b.action === "removeSite") return Response.json(await removeSite(wsId, name, b.site)); if (b.action === "restore") return notOwner() ?? Response.json(await restoreApp(wsId, name, b.to)); - // Where its production is on GitHub (null: Publish on Orgo again), and linking it there: the owner's alone. - if (b.action === "github") return (sharedOpen() ? Response.json({ error: "Only the owner can change where an app is published." }, { status: 403 }) : null) ?? Response.json(await setGithubMirror(wsId, name, b.github ?? null)); - if (b.action === "githubLink") return (sharedOpen() ? Response.json({ error: "Only the owner can change where an app is published." }, { status: 403 }) : null) ?? Response.json(await linkGithubMirror(wsId, name)); - if (b.action === "githubPull") return (sharedOpen() ? Response.json({ error: "Only the owner can bring GitHub's changes in." }, { status: 403 }) : null) ?? Response.json(await pullGithubMirror(wsId, name)); + // Where its production is on GitHub (null: Publish on Orgo again), linking it and bringing GitHub's changes in: this + // Bops' own apps. In a workspace someone shared with the user, the shared Your Apps does it there (Bops Cloud checks + // who may: cloud/members.ts githubAct), never this Bops on an app of its own by that name. + const inShared = () => (sharedOpen() ? Response.json({ error: SHARED_GITHUB }, { status: 403 }) : null); + if (b.action === "github") return inShared() ?? Response.json(await setGithubMirror(wsId, name, b.github ?? null)); + if (b.action === "githubLink") return inShared() ?? Response.json(await linkGithubMirror(wsId, name)); + if (b.action === "githubPull") return inShared() ?? Response.json(await pullGithubMirror(wsId, name)); return Response.json({ error: "What should happen to it?" }, { status: 400 }); } catch (e) { return answer(e); 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/cloud/github-config.ts b/cloud/github-config.ts new file mode 100644 index 000000000..2ee47bffa --- /dev/null +++ b/cloud/github-config.ts @@ -0,0 +1,79 @@ +/* + * Where an app's production lives on GitHub (lib/hosted-apps.ts AppHosting.github), as both sides check it: the owner's + * Bops when the owner sets it from the Bops window (lib/server/hosted-apps.ts setGithubMirror), and Bops Cloud when an + * Admin, or a Member with App secrets, sets it from a shared workspace's Your Apps (cloud/members.ts githubHere). One + * rule for both: the same words for a repo, branch or folder that won't do, and the same point kept or dropped + * (keepsPoint). Imported by Bops Cloud, the window and the Mac server, so it imports nothing. + */ + +/** + * Where an app's production lives on GitHub (AppHosting.github). `path`: the folder of the repo the app's files are + * ("" the whole repo). `keep`: paths left alone either way (a path, or a folder ending in /): never sent to GitHub, + * never taken from it (a preview's own password file; another app's client in a shared folder). `dispatch`: a workflow + * to run after a push, for one that doesn't start on a push. `last`: where the two were last the same (Link, Publish, + * or Bring in): `sha`, the GitHub commit; `ingot`, the Ingot changeset that matches it, which a preview must be at + * or after to publish (lib/server/github-mirror.ts). + */ +export type GithubMirror = { + repo: string; + branch: string; + path: string; + keep?: string[]; + dispatch?: { workflow: string; inputs?: Record }; + /** (`keep`: the paths left alone when it was made: one taken off the list since needs a new Link.) */ + last?: { sha: string; ingot: string; keep?: string[]; when: number }; +}; + +export const GH_REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; +export const GH_BRANCH = /^(?!.*\.\.)(?!\/)(?!.*\/$)[A-Za-z0-9._/-]{1,200}$/; +export const GH_PATH = /^(?!.*\.\.)(?!\/)[A-Za-z0-9._/@ -]{0,300}$/; + +/** A GitHub mirror as the window sends it, cleaned, or why it won't do. `last` is the server's own (never taken from here). */ +export function cleanGithubMirror(v: unknown): { ok: Omit } | { why: string } { + const m = v as Partial | null; + const repo = typeof m?.repo === "string" ? m.repo.trim().replace(/^https:\/\/github\.com\//, "").replace(/\.git$/, "") : ""; + if (!GH_REPO.test(repo)) return { why: "Which GitHub repo? Like OrgoAI/orgo-brandlord." }; + const branch = typeof m?.branch === "string" && m.branch.trim() ? m.branch.trim() : "main"; + if (!GH_BRANCH.test(branch)) return { why: "That isn't a branch name." }; + const path = (typeof m?.path === "string" ? m.path.trim() : "").replace(/^\/+|\/+$/g, ""); + if (!GH_PATH.test(path)) return { why: "That isn't a folder in the repo." }; + const typed = Array.isArray(m?.keep) ? m.keep.filter((k): k is string => typeof k === "string").map((k) => k.trim().replace(/^\/+/, "")).filter(Boolean) : []; + const bad = typed.find((k) => !GH_PATH.test(k)); + if (bad) return { why: `"${bad.slice(0, 80)}" can't be a path left alone (letters, digits, . _ - @ / and spaces; a folder ends in /).` }; + const keep = [...new Set(typed)].slice(0, 50); + const w = m?.dispatch; + const workflow = typeof w?.workflow === "string" ? w.workflow.trim() : ""; + if (workflow && !/^[A-Za-z0-9_.-]{1,100}\.ya?ml$/.test(workflow)) return { why: "That isn't a workflow file, like deploy.yml." }; + const inputs = + w?.inputs && typeof w.inputs === "object" + ? Object.fromEntries(Object.entries(w.inputs).filter(([k, x]) => /^[A-Za-z0-9_-]{1,50}$/.test(k) && typeof x === "string" && x.length <= 200).slice(0, 10) as [string, string][]) + : undefined; + return { ok: { repo, branch, path, ...(keep.length ? { keep } : {}), ...(workflow ? { dispatch: { workflow, ...(inputs && Object.keys(inputs).length ? { inputs } : {}) } } : {}) } }; +} + +type Where = Pick; + +/** + * Whether a new setting keeps the point (GithubMirror.last) of the one before: the same repo, branch and folder, with + * every path the old one left alone still left alone (more is fine). Else it needs linking again. + */ +export const keepsPoint = (was: Where | null | undefined, next: Where): boolean => + !!was && was.repo === next.repo && was.branch === next.branch && was.path === next.path && (was.keep ?? []).every((k) => (next.keep ?? []).includes(k)); + +/** + * Whether a new setting writes to the same place as the one before: the same repo, branch, folder and workflow (with its + * inputs). What's left alone (`keep`) isn't part of it: it only ever sends less. Someone other than the owner changes + * where the GitHub key writes only with a GitHub key of the workspace's own (cloud/members.ts githubKeyFor). + */ +export const sameDestination = (was: Omit | null | undefined, next: Omit): boolean => + !!was && was.repo === next.repo && was.branch === next.branch && was.path === next.path && sameDispatch(was.dispatch, next.dispatch); + +const sameDispatch = (a: GithubMirror["dispatch"], b: GithubMirror["dispatch"]) => { + if (!a || !b) return !a && !b; + const ka = Object.entries(a.inputs ?? {}).sort(([x], [y]) => x.localeCompare(y)); + const kb = Object.entries(b.inputs ?? {}).sort(([x], [y]) => x.localeCompare(y)); + return a.workflow === b.workflow && JSON.stringify(ka) === JSON.stringify(kb); +}; + +/** The GitHub key's vault name (lib/server/github-mirror.ts GITHUB_KEY): what a bot is given in Keys to publish to GitHub. */ +export const GITHUB_KEY_NAME = "GITHUB_TOKEN"; diff --git a/cloud/member-access.ts b/cloud/member-access.ts index 307ce7e8f..55391e518 100644 --- a/cloud/member-access.ts +++ b/cloud/member-access.ts @@ -244,7 +244,8 @@ export const appsSummary = (access: { apps?: unknown }) => (!isAccessLevel(acces * - workspace: rename the workspace, and say where its chat is answered. * - wake: wake a bot's computer. * - publish: Publish an app, or roll back what's Published, from Your Apps (MemberAppAct env "prod"), with the owner's Bops - * at ADMIN_PUBLISH_MIN_VERSION. (An Admin's own words publish it in chat too: lib/server/chat.ts askedForProd.) + * at ADMIN_PUBLISH_MIN_VERSION. (An Admin's own words publish it in chat too: lib/server/chat.ts askedForProd.) Also where + * an app publishes on GitHub, linking it and bringing GitHub's changes in (MemberGithubAct), at GITHUB_ACTS_MIN_VERSION. * - instructions: change a bot's "How I work" (the "persona" act), with the owner's Bops at ADMIN_PUBLISH_MIN_VERSION too. * An Admin has every one (the owner's 11-03 rule: an Admin may do everything but delete the workspace). A Member has one * when its switch in Team is on (POWER_OF, MemberAccess.can), with the owner's Bops new enough to take switches (the @@ -357,7 +358,7 @@ export const POWER_WORDS: Readonly> bots: { name: "Bot settings", tip: "The settings of the bots they have Write on: name, job, model, what it works on, its phone, email and channels, routines." }, memory: { name: "Memory and skills", tip: "What the bots remember, and the skills they use." }, workspace: { name: "Workspace settings", tip: "Its name, where its chat is answered, and what each group of bots is called." }, - appSecrets: { name: "App secrets", tip: "Your Apps' variables and Preview passwords, Publish, going back on Published, and restoring an app." }, + appSecrets: { name: "App secrets", tip: "Your Apps' variables and Preview passwords, Publish, going back on Published, restoring an app, and where it publishes on GitHub." }, spend: { name: "Spending", tip: "What costs the plan more: a bot's own computer, a bigger computer, waking one billed by the hour." }, mac: { name: "The owner's Mac", tip: "Work on the owner's Mac and the owner's own apps and keys: answering what it asks, and telling it more." }, }; diff --git a/cloud/members.ts b/cloud/members.ts index 7b8880abe..0993d0aff 100644 --- a/cloud/members.ts +++ b/cloud/members.ts @@ -68,6 +68,14 @@ import { WS_NOT_SHAREABLE, WS_ROLES, isAppAct, + isGithubAct, + GITHUB_ACTS_MIN_VERSION, + GITHUB_KEY_LINE, + GITHUB_NO_KEY, + NOT_LINKED, + type GithubSetting, + type MemberGithubAct, + type TurnJob, type MemberAct, type MemberAppAct, type MemberActAnswer, @@ -84,7 +92,9 @@ import { announce, chatPage, chatsSeq, loadState, lockedState, lockUser, recentM import { isConnected, queueForMac } from "./tunnel.ts"; import { appBotsFor, appsFor, appsOpenFor, appThreadsFor, appThreadsOf, ownerAppsOpen, botsOf, chatIdsOf, handedByOthers, keyNamesOf, list, seesEveryBot, sharedHash, sharedMessages, sharedSlice, sliceOf, viewerAccess, computerUseOf, wsOfBot, type SliceViewer } from "./ws-slice.ts"; import { CHANGES_MIN_VERSION, checkChange, cleanChange } from "./change-routes.ts"; -import { cancelMember, chatAutoFor, computerFor, macNewEnough, ownerOf, recheckTurns, routedWorkspaces, routingOf, setRouting, takeMessage, wakeComputer, type WakeRefusal } from "./turns.ts"; +import { appActKept, cancelMember, chatAutoFor, computerFor, macNewEnough, ownerOf, recheckTurns, routedWorkspaces, routingOf, setRouting, takeAppJob, takeMessage, wakeComputer, withAppAct, withHosting, type WakeRefusal } from "./turns.ts"; +import { cleanGithubMirror, GITHUB_KEY_NAME, keepsPoint, sameDestination, type GithubMirror } from "./github-config.ts"; +import { appVersionOf, olderThan } from "./app-version.ts"; /** * Shared workspaces (protocol.ts "Shared workspaces"): someone the owner added to one of their @@ -94,9 +104,12 @@ import { cancelMember, chatAutoFor, computerFor, macNewEnough, ownerOf, recheckT * given), its threads with their notes and results, its routines and watches, what Needs you, the moods * as they happen. Never another bot's, nor the owner's vault, logins, keys, Macs or settings * (ws-slice.ts). Members never publish (POST /v1/live only ever goes into the caller's own account), and - * Bops Cloud edits the owner's state for an Admin in two places only: the bots a Member has (peopleWrite), entry by - * entry as the owner's Mac keeps them, which the owner's Macs merge in; and a bot's How I work while the owner's Mac is - * away, in a workspace whose chat goes through Bops Cloud, with the owner's Bops new enough to keep it (personaHere). + * Bops Cloud edits the owner's state for an Admin in three places only: the bots a Member has (peopleWrite), entry by + * entry as the owner's Mac keeps them, which the owner's Macs merge in; a bot's How I work while the owner's Mac is + * away, in a workspace whose chat goes through Bops Cloud, with the owner's Bops new enough to keep it (personaHere); and + * an app's GitHub production from Your Apps (an Admin's, or a Member's with App secrets), where it publishes (githubHere) + * and how a Link or Bring in its computer did went (turns.ts takeAppJob, finish), with the owner's Bops new enough to + * keep both (GITHUB_ACTS_MIN_VERSION). * * - **Who may**: orgo-web says, with the caller's own Orgo key (GET /api/projects//bops-role: the * owner, an admin or a member; 404 for anyone else; and, from an orgo-web that sends it, when their @@ -242,9 +255,10 @@ export async function watchable(user: CloudUser, key: string, projectId: string, * began (access set before isn't theirs), their Use its computer and Keys as orgo-web says them, and the owner's email (apps). * `adminActs` and `adminPublish`: the owner's Bops takes an Admin's acts, and publishes for them (adminGate). `keyActs`: it * takes the "key" act (KEYS_MIN_VERSION), said to someone with Keys. `changes`: it takes the "change" act (change-routes.ts - * CHANGES_MIN_VERSION), said to anyone but the owner. + * CHANGES_MIN_VERSION), said to anyone but the owner. `github`: the owner's last Mac takes the GitHub acts, and the + * viewer's own Bops knows them (GITHUB_ACTS_MIN_VERSION: SharedWsSlice.appGithub, and the acts shown on an app). */ -export const viewerOf = (user: CloudUser, access: WsAccess, adminActs = false, adminPublish = false, keyActs = false, changes = false): SliceViewer => ({ +export const viewerOf = (user: CloudUser, access: WsAccess, adminActs = false, adminPublish = false, keyActs = false, changes = false, github: { acts?: boolean; seen?: boolean } = {}): SliceViewer => ({ role: access.role, ...(adminActs && access.role !== "owner" ? { adminActs: true } : {}), ...(adminPublish && access.role === "admin" ? { adminPublish: true } : {}), @@ -256,6 +270,9 @@ export const viewerOf = (user: CloudUser, access: WsAccess, adminActs = false, a ...(access.keys !== undefined ? { keys: access.keys } : {}), ...(keyActs && access.role !== "owner" && holdsKeys(access.role, access.keys) ? { keyActs: true } : {}), ...(changes && access.role !== "owner" ? { changes: true } : {}), + // The GitHub acts (protocol.ts GITHUB_ACTS_MIN_VERSION): the owner's last Mac takes them (`acts`), the viewer's own Bops knows them (`seen`). + ...(github.acts && access.role !== "owner" ? { githubActs: true } : {}), + ...(github.seen ? { githubSeen: true } : {}), }); /** Whether the caller, someone other than the owner, has Keys (member-access.ts holdsKeys: as orgo-web says, an Admin when it doesn't). */ @@ -457,6 +474,8 @@ const readState: Route = { keysHeld(access) && (await adminGate(target.account, KEYS_MIN_VERSION)), // Whether it takes a "change" act (change-routes.ts), said to anyone but the owner. access.role !== "owner" && (await adminGate(target.account, CHANGES_MIN_VERSION)), + // The GitHub acts: the owner's last Mac takes them, and this caller's Bops knows them (an older one reads one as a domain's). + { acts: access.role !== "owner" && (await adminGate(target.account, GITHUB_ACTS_MIN_VERSION)), seen: knowsGithubActs(req) }, ); const slice = sharedSlice(blob, target.ws, viewer); const bots = viewer.role === "owner" ? undefined : botsOf(slice); @@ -572,6 +591,9 @@ export function cleanAct(v: unknown): { key: string; act: MemberAct } | null { case "rollback": case "settings": case "domain": + case "github": + case "githubLink": + case "githubPull": return appAct(key, x); default: return null; @@ -588,6 +610,17 @@ const command = (v: unknown) => typeof v === "string" && v.length <= 1000; /** An act on an app (Your Apps at Write), its fields checked as cleanAct's others are: the shapes only; what they name is checked against the slice (appsTouched). */ function appAct(key: string, x: Record): { key: string; act: MemberAct } | null { if (!appName(x.app) || (x.site !== undefined && !appName(x.site, 32))) return null; + // Its GitHub production: one folder is one app, so never a site. Where it publishes, cleaned as the owner's own is + // (github-config.ts cleanGithubMirror: one it won't take is said in its words, githubWhy), or null for Orgo again. + if (x.act === "github" || x.act === "githubLink" || x.act === "githubPull") { + if (x.site !== undefined) return null; + if (x.act !== "github") return { key, act: { act: x.act, app: x.app } }; + if (x.link !== undefined && x.link !== true) return null; + const link = x.link === true ? { link: true as const } : {}; + if (x.github === null) return { key, act: { act: "github", app: x.app, github: null, ...link } }; + const c = cleanGithubMirror(x.github); + return "ok" in c ? { key, act: { act: "github", app: x.app, github: c.ok, ...link } } : null; + } const site = x.site !== undefined ? { site: x.site as string } : {}; // Where a deploy or a rollback goes: the Preview (none), or Published ("prod": an Admin's, checked as it's taken). const envOk = x.env === undefined || x.env === "prod"; @@ -612,10 +645,24 @@ function appAct(key: string, x: Record): { key: string; act: Me return null; } +/** Why a "github" act's setting won't do, in the owner's own words for it (github-config.ts cleanGithubMirror), or null. */ +export function githubWhy(raw: unknown): string | null { + const a = raw && typeof raw === "object" ? (raw as { act?: unknown }).act : undefined; + if (!a || typeof a !== "object" || (a as { act?: unknown }).act !== "github" || (a as { github?: unknown }).github === null) return null; + const c = cleanGithubMirror((a as { github?: unknown }).github); + return "why" in c ? c.why : null; +} + +/** What a GitHub act on an app that isn't one folder says (lib/server/hosted-apps.ts setGithubMirror's words). */ +const ONE_FOLDER = (app: string) => `${app} runs more than one site, and one GitHub folder is one app. Publish it on Orgo.`; +export const GITHUB_NOT_SET = "Set where it publishes on GitHub first."; +export const GITHUB_NOT_LINKED = "Link it to GitHub first, then bring GitHub's changes in."; + /** * An act on an app, against the caller's own view of the apps (ws-slice.ts appsFor): an app they aren't shown is "no * such" (never saying whether it exists), Read is MEMBER_APPS_READ_ONLY, a domain the workspace doesn't own or a site - * the app doesn't have "no such", a deploy that can't be put back NOT_WAITING. The owner's Mac checks it all again. + * the app doesn't have "no such", a deploy that can't be put back NOT_WAITING. Its GitHub production: an app with one site + * alone, a Link once it has where to, Bring in once it's linked (NOT_LINKED). The owner's Mac checks it all again. */ function appsTouched(slice: ReturnType, viewer: SliceViewer, access: MemberAccess, act: MemberAppAct) { const apps = appsFor(slice.workspace, viewer, access, new Set(slice.bots.map((b) => b.id))); @@ -627,6 +674,12 @@ function appsTouched(slice: ReturnType, viewer: SliceViewer, acc if (![app.name, ...(app.siteNames ?? [])].includes(site)) throw new HttpError(404, "No such app."); if (act.act === "domain" && !apps.owned?.some((d) => d.id === act.zone)) throw new HttpError(404, "Pick a domain this workspace has."); if (act.act === "rollback" && !app.deploys?.some((d) => d.id === act.deployment && d.site === site && d.canRollback)) throw new HttpError(409, "That deploy can't be put back.", { code: NOT_WAITING }); + if (isGithubAct(act)) { + if (app.siteNames?.length && !(act.act === "github" && act.github === null)) throw new HttpError(409, ONE_FOLDER(app.name), { code: NOT_WAITING }); + if (act.act === "githubLink" && !app.github) throw new HttpError(409, GITHUB_NOT_SET, { code: NOT_LINKED }); + if (act.act === "githubPull" && !app.github?.linked) throw new HttpError(409, GITHUB_NOT_LINKED, { code: NOT_LINKED }); + } + return app; } /** Whether a thread of the owner's is an app thread left out of this view (ws-slice.ts appThreadsFor). */ @@ -901,6 +954,14 @@ let count: (userId: string, what?: "act" | "file") => Promise = countAct; let wake: typeof wakeComputer = (...a) => wakeComputer(...a); /** Bops Cloud answers a computer's runner's ask itself while the owner's Mac is away (answerOnComputer). */ let answerHere: (o: Parameters[0]) => Promise = (o) => answerOnComputer(o); +/** An app's GitHub job owed to its computer, or why it can't be (turns.ts takeAppJob). */ +let takeJob: typeof takeAppJob = (...a) => takeAppJob(...a); + +/** Whether a caller's Bops knows the GitHub acts (protocol.ts GITHUB_ACTS_MIN_VERSION): it says its version, at that or newer. */ +const knowsGithubActs = (req: IncomingMessage) => { + const v = appVersionOf(req); + return !!v && !olderThan(v, GITHUB_ACTS_MIN_VERSION); +}; /** * Who the member is on what they did, as the owner's Bops shows it: their Orgo name, else their email's name, made @@ -932,6 +993,9 @@ const actIn: Route = { raw = null; } const body = cleanAct(raw); + // A GitHub setting that won't do: said in the owner's own words for it. + const why = body ? null : githubWhy(raw); + if (why) throw new HttpError(400, why); if (!body) throw new HttpError(400, "Send {key, act}"); // Counted before orgo-web is asked, so a flood never reaches it. await count(user!.id); @@ -954,12 +1018,22 @@ const actIn: Route = { // Publish from Your Apps (env "prod"): an Admin's, with the owner's Bops new enough (an older one would put it on the Preview). // A Member's with App secrets on (actor.can: the owner's Bops takes switches, so it's new enough to publish for them). if ((body.act.act === "deploy" || body.act.act === "rollback") && body.act.env === "prod" && !actor.can?.appSecrets) adminOnly({ role: role.role, adminActs: await adminGate(target.account, ADMIN_PUBLISH_MIN_VERSION) }); + // Its GitHub production: the same power as Publish (an Admin always, a Member with App secrets), once the owner's last + // Mac takes it (GITHUB_ACTS_MIN_VERSION: an older one drops the job and can put an older setting back over it). + if (isGithubAct(body.act)) { + if (role.role !== "admin" && !actor.can?.appSecrets) throw new HttpError(403, `The owner or an Admin can let you change ${POWER_WORDS.appSecrets.name} in Team.`, { code: ADMIN_ONLY }); + if (!(await adminGate(target.account, GITHUB_ACTS_MIN_VERSION))) throw new HttpError(409, OWNER_OLD_LINE, { code: OWNER_BOPS_OLD }); + } const viewer = viewerOf(user!, role, actor.adminActs); // Their own view, every item whole and with the owner's ids: what they may act on is only what they're shown. const slice = sliceOf(blob, target.ws, viewer); if (!slice.workspace) throw new HttpError(404, "No such workspace"); const access = viewerAccess(slice.workspace, viewer); - if (isAppAct(body.act)) appsTouched(slice, viewer, access, body.act); + if (isAppAct(body.act)) { + appsTouched(slice, viewer, access, body.act); + // Done here (the setting) and by the app's own computer (Link, Bring in), the owner's Mac on or off (githubAct). + if (isGithubAct(body.act)) return githubAct(res, { user: user!, role, target, viewer, projectId, key: body.key, act: body.act, blob, bearer: bearer(req), still: () => (noticed.get(keyOf(user!.id, projectId))?.n ?? 0) <= asked }); + } // A change as the owner's window makes it: the power it needs (an Admin has every one), and only this workspace's // bots (at Write) and apps (Your Apps at Write), once the owner's Bops takes it (changeTouched). else if (body.act.act === "change") { @@ -1235,6 +1309,151 @@ async function cloudChat( sendJson(res, 202, { ok: true, macOn: true, stored: true, messageId: String(taken.message.id), ...(taken.turn ? { turn: taken.turn.id } : {}) } satisfies MemberActAnswer); } +/* ---------------- An app's GitHub production, from Your Apps ---------------- */ + +/** + * Whether the workspace has a GitHub key of its own (This workspace, in Keys): as orgo-web lists its own keys now, with + * the caller's key (liveWorkspaceKeys); when it can't say (they don't have Keys, or no answer), as the owner's Mac last + * said them (keyNamesOf: scope "workspace", not another workspace's switched on). Fails closed: neither, no. + */ +async function githubKeyFor(workspace: Record | null, key: string, projectId: string): Promise { + const live = await liveWorkspaceKeys(key, projectId); + if (live) return live.has(GITHUB_KEY_NAME); + return !!keyNamesOf(workspace)?.some((k) => k.name === GITHUB_KEY_NAME && k.scope === "workspace" && !k.from); +} + +/** A workspace's app record in the owner's state (Workspace.hostedApps[app]), or null. */ +const hostingIn = (blob: Record, ws: string, app: string): Record | null => { + const w = list(blob.workspaces).find((x) => x.id === ws); + const h = w && w.hostedApps && typeof w.hostedApps === "object" && !Array.isArray(w.hostedApps) && Object.hasOwn(w.hostedApps, app) ? (w.hostedApps as Record)[app] : null; + return h && typeof h === "object" && !Array.isArray(h) ? (h as Record) : null; +}; +const settingOf = (h: Record | null) => (h?.github && typeof h.github === "object" && !Array.isArray(h.github) ? (h.github as GithubMirror) : null); + +/** How a GitHub act on an app says where it publishes, in the app's bot's chat (the folder: "the whole repo" for ""). */ +const whereWords = (g: GithubSetting) => `GitHub ${g.repo}, ${g.branch}, ${g.path ? `folder ${g.path}` : "the whole repo"}`; +/** What a "githubLink" or "githubPull" act says in the app's bot's chat as it goes to its computer. */ +export const jobAsked = (name: string, bot: string, act: TurnJob["act"], app: string) => + act === "githubLink" ? `${name} asked ${bot}'s computer to link ${app} to GitHub.` : `${name} asked ${bot}'s computer to bring in GitHub's changes to ${app}.`; +/** 409 GITHUB_NO_KEY: the app's computer can't call GitHub (no bot on it was given the key), and the owner's Mac isn't on to do it. */ +export const noGithubKey = (bot: string, saved: boolean) => + `${bot}'s computer has no GitHub key, so it can't do this while the owner's Mac is off. Give ${bot} the GitHub key in Keys, then try again.${saved ? " Where it publishes is saved." : ""}`; + +/** + * A GitHub act on an app (protocol.ts MemberGithubAct), checked by actIn (the power of Publish, the owner's last Mac at + * GITHUB_ACTS_MIN_VERSION, the app at Your Apps Write with one site). "github": a new repo, branch, folder or workflow only + * with a GitHub key of the workspace's own (githubKeyFor; the owner's key for all their workspaces is theirs to point), + * then written here (githubHere). "githubLink" (or "github" with `link`) and "githubPull": owed to the app's own computer + * as a job (turns.ts takeAppJob) when its runner can do it, the owner's Mac on or off; else the owner's Mac does it, as a + * member's other app acts (a "member" event), unless only the key is missing and that Mac is off (GITHUB_NO_KEY). + */ +async function githubAct( + res: ServerResponse, + o: { user: CloudUser; role: WsAccess; target: LiveTarget; viewer: SliceViewer; projectId: string; key: string; act: MemberGithubAct; blob: Record; bearer: string; still: () => boolean }, +) { + const { user, role, target, viewer, projectId, key, act } = o; + const who = whoOf(user); + if (act.act === "github") { + const was = settingOf(hostingIn(o.blob, target.ws, act.app)); + // Asked of orgo-web before the lock; under it, a destination that moved meanwhile is checked again (githubHere). + const ownKey = act.github && !sameDestination(was, act.github) ? await githubKeyFor(list(o.blob.workspaces).find((w) => w.id === target.ws) ?? null, o.bearer, projectId) : true; + if (!ownKey) throw new HttpError(403, GITHUB_KEY_LINE, { code: MEMBER_OWNER_ONLY }); + await githubHere({ target, viewer, act, who, key, ownKey, still: o.still }); + if (!act.link) return sendJson(res, 202, { ok: true, macOn: true, stored: true } satisfies MemberActAnswer); + } + const job: TurnJob = { act: act.act === "github" ? "githubLink" : act.act, app: act.app }; + const taken = await takeJob({ + account: target.account, + wsId: target.ws, + job, + id: `msg_job_${user.id}_${key}`, + member: { + ...who, + ref: key, + ...(role.joinedAt !== undefined ? { joinedAt: role.joinedAt } : {}), + projectId, + ...(role.role === "admin" ? { role: "admin" as const } : {}), + job, + }, + words: (bot) => jobAsked(who.name, bot, job.act, job.app), + // Under the lock, on the state as it is then: still theirs to do (appsTouched), as actIn found it. + check: (locked) => { + const slice = sliceOf(locked, target.ws, viewer); + if (!slice.workspace) throw new HttpError(404, "No such workspace"); + appsTouched(slice, viewer, viewerAccess(slice.workspace, viewer), { act: job.act, app: job.app }); + }, + still: o.still, + }); + if (taken.where === "computer") return sendJson(res, 202, { ok: true, macOn: true, stored: true, computer: true, turn: taken.turn.id } satisfies MemberActAnswer); + // The app's computer can't do it now: the owner's Mac, which has the owner's own key, as a member's other app acts. Only + // the key missing, and that Mac off: said now, as it would wait for nothing. + if (taken.why === "key" && !isOn(target.account)) throw new HttpError(409, noGithubKey(taken.bot, act.act === "github"), { code: GITHUB_NO_KEY }); + const payload: MemberActPayload = { + projectId, + wsId: target.ws, + member: who, + ...(role.joinedAt !== undefined ? { joinedAt: role.joinedAt } : {}), + key, + at: Date.now(), + act: { act: job.act, app: job.app }, + ...(role.role === "admin" ? { role: "admin" as const } : {}), + }; + await queue(target.account, "member", payload, `member:${user.id}:${key}`, new Date(Date.now() + MEMBER_ACT_MS)); + if (!o.still()) { + await drop(projectId, user.id).catch((e: Error) => console.warn(`[members] couldn't drop what waited: ${e.message}`)); + throw new HttpError(404, "No such workspace"); + } + sendJson(res, 202, { ok: true, macOn: isOn(target.account) } satisfies MemberActAnswer); +} + +/** + * Where an app publishes on GitHub (the "github" act), written on the owner's state by Bops Cloud, the owner's Mac on or + * off: that Mac merges it field by field with its own (lib/server/state-merge.ts keeps the setting whole), from + * GITHUB_ACTS_MIN_VERSION. Checked again under the owner's lock, on the state as it is then (appsTouched: theirs to + * change, one site; a destination someone moved meanwhile needs the workspace's own key again), and only while orgo-web + * sent no notice for them. Its point (github.last) stays only when keepsPoint says (the same folder, every path left + * alone still left alone); null puts it back on Orgo. Kept on the app as theirs (appsForMembers acts, by their key: the + * same act again changes nothing) and said in the app's bot's chat. + */ +async function githubHere(o: { target: LiveTarget; viewer: SliceViewer; act: Extract; who: MemberWho; key: string; ownKey: boolean; still: () => boolean }) { + const { target, act, who, key } = o; + const out = await tx(async (c) => { + await lockUser(c, target.account); + const blob = await lockedState(c, target.account); + const slice = sliceOf(blob, target.ws, o.viewer); + if (!slice.workspace) throw new HttpError(404, "No such workspace"); + appsTouched(slice, o.viewer, viewerAccess(slice.workspace, o.viewer), act); + if (appActKept(blob, target.ws, act.app, key)) return null; + const h = hostingIn(blob, target.ws, act.app); + if (!h) throw new HttpError(404, "No such app."); + const was = settingOf(h); + if (act.github && !o.ownKey && !sameDestination(was, act.github)) throw new HttpError(403, GITHUB_KEY_LINE, { code: MEMBER_OWNER_ONLY }); + const next: Record = { ...h }; + if (act.github) next.github = { ...act.github, ...(keepsPoint(was, act.github) && was?.last ? { last: was.last } : {}) }; + else delete next.github; + const kept = withHosting(blob, target.ws, act.app, next); + const written = withAppAct(kept, target.ws, act.app, { key, userId: who.userId, name: who.name, act: "github", ok: true, at: now() }); + await c.query("UPDATE bops.app_state SET state = $2::jsonb, version = version + 1, writer = 'cloud', updated_at = now() WHERE user_id = $1", [target.account, toJson(written)]); + // After the write, before it's kept: an orgo-web notice for them that landed meanwhile (even during it) takes it back. + if (!o.still()) throw new HttpError(404, "No such workspace"); + return { written, botId: typeof h.botId === "string" ? h.botId : null }; + }); + if (!out) return; + announce(target.account); + stateWritten(target.account, out.written, "cloud"); + if (out.botId) + await writeMessages(target.account, [ + { + id: `msg_github_${who.userId}_${key}`, + chatId: `bot:${out.botId}`, + role: "system", + botId: out.botId, + text: act.github ? `${who.name} set ${act.app} to publish to ${whereWords(act.github)}.` : `${who.name} set ${act.app} to publish on Orgo again.`, + at: now(), + }, + ]); +} + const one = (v: string | string[] | undefined) => (Array.isArray(v) ? (v[0] ?? "") : (v ?? "")); const changed: Route = { @@ -1594,6 +1813,8 @@ export function resetMembers( answerHere?: (o: Parameters[0]) => Promise; /** How a computer is woken for an answer Bops Cloud wrote (unset: turns.ts wakeComputer). */ wake?: typeof wakeComputer; + /** An app's GitHub job (unset: turns.ts takeAppJob, on the owner's state in Postgres). */ + takeJob?: typeof takeAppJob; } = {}, ) { kept.clear(); @@ -1607,6 +1828,7 @@ export function resetMembers( // (A test that fakes the owner's state has no state in Postgres to answer on: the act waits for the Mac, unless it says.) answerHere = opts.answerHere ?? (opts.state ? async () => false : (o) => answerOnComputer(o)); wake = opts.wake ?? ((...a) => wakeComputer(...a)); + takeJob = opts.takeJob ?? ((...a) => takeAppJob(...a)); queue = opts.queue ?? realQueue; isOn = opts.isOn ?? realIsOn; drop = opts.drop ?? dropActs; diff --git a/cloud/protocol.ts b/cloud/protocol.ts index 5e884a1ef..e7c97d412 100644 --- a/cloud/protocol.ts +++ b/cloud/protocol.ts @@ -815,6 +815,13 @@ export type SharedWsSlice = { can?: string[]; /** With `can`: the owner's Bops takes the "change" act (CHANGES_MIN_VERSION). Part of the fingerprint. */ changes?: true; + /** + * With `apps` at Write, for an Admin or a Member with App secrets (cloud/member-access.ts POWER_OF.publish), once the + * owner's last Mac is at GITHUB_ACTS_MIN_VERSION: Your Apps sets where an app publishes on GitHub, links it, and brings + * GitHub's changes in (MemberAppAct "github", "githubLink", "githubPull"), which the app's own computer does with the + * owner's Mac off. Part of the fingerprint. + */ + appGithub?: true; /** * With `can`: what the owner's Mac did with their last changes (Workspace.changesDone): each by their key, done or why * not, so their window says one that didn't go. Only their own. @@ -872,8 +879,13 @@ export type SharedApp = { url?: string; /** Its computer went: it needs a new home (a deploy gives it one). */ lost?: true; - /** Its production is on GitHub (lib/hosted-apps.ts AppHosting.github): Publish sends what its Preview serves to this repo and branch, whose own deploy puts it live. */ - github?: { repo: string; branch: string }; + /** + * Its production is on GitHub (lib/hosted-apps.ts AppHosting.github): Publish sends what its Preview serves to this repo + * and branch, whose own deploy puts it live. At Write also its folder, the paths left alone, the workflow it runs, and + * where it was last the same as GitHub (`linked`: Link, Publish or Bring in; none: it isn't linked yet), so an Admin, or + * a Member with App secrets, links it and brings GitHub's changes in (SharedWsSlice.appGithub). + */ + github?: { repo: string; branch: string; path?: string; keep?: string[]; dispatch?: { workflow: string; inputs?: Record }; linked?: { sha: string; ingot: string; when: number } }; head?: { id: string; message: string; at: number } | null; /** * `url`: Published's address (none: no domain yet). Its Preview, where members deploy, from an owner's Bops that sends @@ -900,8 +912,12 @@ export type SharedApp = { deploys?: { id: string; state: string; at: string | null; rollback: boolean; when: number | null; live: boolean; canRollback: boolean; site: string; env?: "preview" | "prod" }[]; /** Each site's settings: Write only. */ settings?: Record; - /** `env` "prod": an Admin's Publish (a deploy) or Roll back on Published. */ - acts?: { key: string; userId: string; name: string; act: "deploy" | "rollback" | "settings" | "domain"; site?: string; domain?: string; env?: "prod"; ok: boolean; said?: string; at: number }[]; + /** + * `env` "prod": an Admin's Publish (a deploy) or Roll back on Published. The GitHub ones (MemberAppAct "github", + * "githubLink", "githubPull") only to a viewer whose Bops is at GITHUB_ACTS_MIN_VERSION: `going` while the app's + * computer works on it (Bops Cloud keeps how it went in its place: cloud/turns.ts finish). + */ + acts?: { key: string; userId: string; name: string; act: MemberAppAct["act"]; site?: string; domain?: string; env?: "prod"; ok: boolean; going?: true; said?: string; at: number }[]; }; export type SharedWsState = { projectId: string; @@ -975,8 +991,46 @@ export type MemberAppAct = | { act: "deploy"; app: string; site?: string; at?: string; env?: "prod" } | { act: "rollback"; app: string; deployment: string; site?: string; env?: "prod" } | { act: "settings"; app: string; site?: string; port?: number; install?: string; build?: string; start?: string; health?: string } - | { act: "domain"; app: string; site?: string; zone: string; subdomain?: string }; -export const isAppAct = (a: { act: string }): a is MemberAppAct => a.act === "deploy" || a.act === "rollback" || a.act === "settings" || a.act === "domain"; + | { act: "domain"; app: string; site?: string; zone: string; subdomain?: string } + | MemberGithubAct; +/** + * An app's production on GitHub, from a shared workspace's Your Apps (SharedWsSlice.appGithub): the owner's power of + * Publish (cloud/member-access.ts POWER_OF.publish), so an Admin always and a Member with App secrets, with the owner's + * last Mac at GITHUB_ACTS_MIN_VERSION (OWNER_BOPS_OLD), for an app with one site. Never `site`: one folder is one app. + * - "github": where it publishes (GithubSetting, cleaned as lib/hosted-apps.ts cleanGithubMirror cleans the owner's), or + * null for Publish on Orgo again. Bops Cloud writes it on the owner's state itself, Mac on or off (cloud/members.ts + * githubHere), keeping its point only when cloud/github-config.ts keepsPoint says. A repo, branch, folder or workflow + * other than it had is set only while the workspace has a GitHub key of its own (This workspace, in Keys): the owner's + * key for all their workspaces stays the owner's to point (MEMBER_OWNER_ONLY, GITHUB_KEY_LINE). `link`: then link it. + * - "githubLink": link it as both sides are now (lib/server/github-mirror.ts linkMirror). "githubPull": bring GitHub's + * changes into Ingot, GitHub's version winning where both changed a file (mirrorPull); it needs a link (NOT_LINKED). + * Both are done by the app's own computer (a turn owed to its runner: cloud/turns.ts takeAppJob, TurnMember.job), with + * the owner's Mac on or off; when no runner there can (none at GITHUB_ACTS_MIN_VERSION, or no bot on it given the + * GitHub key), by the owner's Mac as a member's other app acts are (lib/server/member-acts.ts). + */ +export type MemberGithubAct = + | { act: "github"; app: string; github: GithubSetting | null; link?: true; site?: undefined } + | { act: "githubLink"; app: string; site?: undefined } + | { act: "githubPull"; app: string; site?: undefined }; +/** Where an app publishes on GitHub (lib/hosted-apps.ts GithubMirror without its point, spelled out: this file imports nothing). */ +export type GithubSetting = { repo: string; branch: string; path: string; keep?: string[]; dispatch?: { workflow: string; inputs?: Record } }; +export const isAppAct = (a: { act: string }): a is MemberAppAct => a.act === "deploy" || a.act === "rollback" || a.act === "settings" || a.act === "domain" || isGithubAct(a); +export const isGithubAct = (a: { act: string }): a is MemberGithubAct => a.act === "github" || a.act === "githubLink" || a.act === "githubPull"; +/** + * The first Bops that takes the GitHub acts (MemberGithubAct): an owner's Mac whose merge keeps an app's GitHub setting Bops + * Cloud wrote (lib/server/state-merge.ts) and that links or brings GitHub's changes in for someone else (lib/server/ + * member-acts.ts); a runner that does it as a turn (lib/server/runner-chat.ts); a viewer's window that shows them. Bops + * Cloud refuses them (OWNER_BOPS_OLD) and shows no SharedWsSlice.appGithub while the owner's last Mac is older, owes a + * job only to a runner at it (else the owner's Mac does it), and shows the acts only to a viewer at it. Set to the release + * that carries it. + */ +export const GITHUB_ACTS_MIN_VERSION = "0.0.52"; +/** 403 MEMBER_OWNER_ONLY for a "github" act that points the GitHub key somewhere new without a key of the workspace's own. */ +export const GITHUB_KEY_LINE = "Only the owner can point the owner's own GitHub key somewhere new. Add a GitHub key for this workspace in Keys, then try again."; +/** 409: a "githubPull" of an app that isn't linked to GitHub yet. */ +export const NOT_LINKED = "not_linked"; +/** 409: no bot on the app's computer was given the GitHub key, and the owner's Mac (which has it) isn't on to do it instead. */ +export const GITHUB_NO_KEY = "github_no_key"; /** 403: the apps are shown to the caller at Read (Your Apps), not Write. */ export const MEMBER_APPS_READ_ONLY = "member_apps_read_only"; /** `key`: the member's own id for this act (8 to 64 of A-Z a-z 0-9 _ -): sent again on a retry, it's never done twice. */ @@ -1017,8 +1071,10 @@ export type MemberActPayload = { * and its turn's (`turn`, none when no answer is owed: the owner hasn't let them use the bot's computer), and `macOn` * true, so an app from before says "Sending…" until it lands (as it does at once). So is an Admin's "persona" act there * while the owner's Mac is away (cloud/members.ts personaHere): `stored`, and `macOn` true, so their window says Saved. + * A GitHub act (MemberGithubAct) Bops Cloud kept itself is `stored` too; `computer` when the app's own computer does the + * rest (`turn`, its job's), the owner's Mac on or off. */ -export type MemberActAnswer = { ok: true; macOn: boolean; stored?: true; messageId?: string; turn?: string }; +export type MemberActAnswer = { ok: true; macOn: boolean; stored?: true; messageId?: string; turn?: string; computer?: true }; /** 403: a bot the act touches is at Read for the caller (or a group has one that is). */ export const MEMBER_READ_ONLY = "member_read_only"; /** @@ -1203,7 +1259,14 @@ export type RunnerCancelEvent = { id: string }; /** Who said the message a turn answers, when a member did (Bops Cloud vouched): what the bot is told, and checked again as it claims. */ /** `computerUse`: what orgo-web said of their Use its computer when the message was taken (WsAccess.computerUse), which the turn goes by after. */ /** `role`: "admin" when orgo-web said so as the message was taken (an Admin's turn needs no entry: cloud/turns.ts memberStill). None: a member. */ -export type TurnMember = { userId: string; email?: string; name: string; ref: string; joinedAt?: number; projectId?: string; computerUse?: boolean; role?: "admin" }; +/** + * `job`: a turn that's an app's GitHub act (MemberGithubAct "githubLink" or "githubPull", cloud/turns.ts takeAppJob), not + * words to answer: its runner does it (lib/server/runner-chat.ts) and no model ever sees it. Checked by Your Apps at Write + * and the power of Publish, never by the bot's level or Use its computer (memberStill). A runner from before reads its + * message, a note of Bops', and answers nothing. + */ +export type TurnMember = { userId: string; email?: string; name: string; ref: string; joinedAt?: number; projectId?: string; computerUse?: boolean; role?: "admin"; job?: TurnJob }; +export type TurnJob = { act: "githubLink" | "githubPull"; app: string }; /** * POST /v1/runner/turns//claim, answered 200: the turn is this runner's until it stops beating. `now`: Bops Cloud's * clock, which the answer's times are taken from (a computer's own can lag after it wakes); `humanAt` the message's @@ -1212,8 +1275,12 @@ export type TurnMember = { userId: string; email?: string; name: string; ref: st export type RunnerClaim = { ok: true; now: number; turn: { id: string; messageId: string; chatId: string; botId: string; humanAt: number; seq: number; member?: TurnMember } }; /** POST /v1/runner/turns//beat: still running, every 30 seconds; `tools` once it did something that can't be done twice. 409: it isn't this runner's any more (stop). */ export type RunnerBeat = { phase: "model" | "tools" }; -/** POST /v1/runner/turns//done: how it ended. paused: waiting for an OK (`approvalId`); `react`: its tapback on the message (only Bops Cloud writes on a person's row). */ -export type RunnerDone = { state: "done" | "paused" | "failed"; approvalId?: string; note?: string; react?: string }; +/** + * POST /v1/runner/turns//done: how it ended. paused: waiting for an OK (`approvalId`); `react`: its tapback on the + * message (only Bops Cloud writes on a person's row). `job`: a job turn's (TurnMember.job) result, done or why not, in + * words, which Bops Cloud keeps on the app (SharedApp.acts); a job turn done without it went to a runner from before. + */ +export type RunnerDone = { state: "done" | "paused" | "failed"; approvalId?: string; note?: string; react?: string; job?: { ok: boolean; said: string } }; /** * POST /v1/runner/turns//resume {approvalId}: a turn paused for an OK goes on once the OK is answered, only for that * request (the one its pause named), only on the runner that paused it, and only while whoever said the message still diff --git a/cloud/test/core-members.test.ts b/cloud/test/core-members.test.ts index 16f7e7bcb..1964769de 100644 --- a/cloud/test/core-members.test.ts +++ b/cloud/test/core-members.test.ts @@ -5,10 +5,10 @@ import type { AddressInfo } from "node:net"; import { after, before, beforeEach, test } from "node:test"; import { canWatch, hub, LiveHub, liveScope, messagesWritten, scoped, stateWritten, type LiveSub } from "../live.ts"; import { accessSummary, adminMay, appsLevelOf, appsSummary, cleanAccess, cleanName, currentFor, inviteDefault, levelOf, MAX_ACCESS_BOTS, ownerOnlyAsk, sameAccess, withEntry, writesChat, type MemberAccess, type MemberAccessMap } from "../member-access.ts"; -import { accessFor, adminGate, cleanAct, memberStreams, membersChanged, NO_MS, resetMembers, ROLE_MS, UNSURE_LOOKS, viewerOf, watchable } from "../members.ts"; +import { accessFor, adminGate, cleanAct, githubWhy, GITHUB_NOT_LINKED, GITHUB_NOT_SET, memberStreams, membersChanged, NO_MS, resetMembers, ROLE_MS, UNSURE_LOOKS, viewerOf, watchable } from "../members.ts"; import { CHANGES_MIN_VERSION, checkChange, cleanChange, THIS_WS } from "../change-routes.ts"; import type { WakeRefusal } from "../turns.ts"; -import { ADMIN_POWERS_MIN_VERSION, KEYS_MIN_VERSION, MEMBER_FILES_MIN_VERSION, MEMBER_ACT_MS, MEMBER_MAX_TEXT, MEMBER_STOP_MAX, STORED_ACT_MS, type MemberActPayload, type WsAccess } from "../protocol.ts"; +import { ADMIN_POWERS_MIN_VERSION, GITHUB_ACTS_MIN_VERSION, GITHUB_KEY_LINE, KEYS_MIN_VERSION, MEMBER_FILES_MIN_VERSION, MEMBER_ACT_MS, MEMBER_MAX_TEXT, MEMBER_STOP_MAX, STORED_ACT_MS, type MemberActPayload, type WsAccess } from "../protocol.ts"; import { appBotsFor, appThreadsFor, appThreadsOf, botsOf, chatIdsOf, computerUseOf, handedByOthers, keyNamesOf, keysOf, OWNER_VIEW, ownerAppsOpen, pairChatsOf, sharedHash, sharedMessage, sharedMessages, sharedSlice, viewerAccess, type SliceViewer } from "../ws-slice.ts"; /** @@ -2335,6 +2335,238 @@ test("Publish from Your Apps (a deploy or a rollback with env prod) is an Admin' assert.deepEqual(cleanAct({ key: "k_12345678", act: { act: "settings", app: "shop", port: 3000, env: "prod" } }), { key: "k_12345678", act: { act: "settings", app: "shop", port: 3000 } }); }); +/* ---------------- An app's GitHub production, from Your Apps ---------------- */ + +/** A workspace of Orgo's team with one app on GitHub ("site": linked) and one not yet ("blog"), and one of two sites ("shop"). */ +const ghBlob = (more: { site?: Record; sam?: Record; keys?: unknown } = {}): Record => ({ + workspaces: [ + { + id: "ws_gh", + name: "Gh", + memberAccess: { + // Sam: Your Apps at Write and App secrets (Team). Ivy: Your Apps at Write, no switch. Rob: Your Apps at Read, App secrets. + "sam@example.com": { others: "none", bots: { max: "write" }, apps: "write", can: { appSecrets: true }, ...more.sam }, + "ivy@example.com": { others: "none", bots: { max: "write" }, apps: "write" }, + "rob@example.com": { others: "none", bots: { max: "read" }, apps: "read", can: { appSecrets: true } }, + }, + hostedApps: { + site: { + botId: "max", + computerId: "comp-1", + projectId: "proj-1", + url: "https://site.orgo.dev", + github: { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts"], dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } }, last: { sha: "a".repeat(40), ingot: "b".repeat(64), keep: ["proxy.ts"], when: 7 } }, + ...more.site, + }, + blog: { botId: "max", computerId: "comp-1", projectId: "proj-3", github: { repo: "example-org/web", branch: "main", path: "blog" } }, + shop: { botId: "max", computerId: "comp-1", projectId: "proj-2", sites: [{ name: "admin", projectId: "proj-4" }] }, + }, + appsForMembers: { + at: 10, + apps: { + site: { + head: null, + sites: [], + commits: [], + deploys: [], + acts: [ + { key: "k_gh_000a", userId: "u-sam", name: "Sam", act: "githubPull", ok: true, going: true, at: 9 }, + { key: "k_dep_001", userId: "u-sam", name: "Sam", act: "deploy", ok: true, at: 8 }, + ], + }, + }, + }, + ...(more.keys !== undefined ? { keysForMembers: more.keys } : {}), + }, + ], + bots: [{ id: "max", name: "Max", workspaceId: "ws_gh", computerId: "comp-1", keys: ["GITHUB_TOKEN"] }], + chats: [{ id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_gh" }], +}); +const ghViewer = (role: "admin" | "member", email: string, more: Partial = {}): SliceViewer => ({ role, email, id: `u-${email.split("@")[0]}`, ownerEmail: STAFF_OWNER, changes: true, ...more }); + +test("cleanAct: the GitHub acts take an app (never a site); where it publishes is cleaned as the owner's own, null for Orgo again, and link only as true", () => { + const key = "k_gh_00001"; + assert.deepEqual(cleanAct({ key, act: { act: "githubPull", app: "site", extra: 1 } }), { key, act: { act: "githubPull", app: "site" } }); + assert.deepEqual(cleanAct({ key, act: { act: "githubLink", app: "site" } }), { key, act: { act: "githubLink", app: "site" } }); + assert.deepEqual(cleanAct({ key, act: { act: "github", app: "site", github: null } }), { key, act: { act: "github", app: "site", github: null } }); + assert.deepEqual( + cleanAct({ key, act: { act: "github", app: "site", link: true, github: { repo: "https://github.com/example-org/web.git", branch: "", path: "/formula/", keep: ["proxy.ts", " clients/acme/ ", "proxy.ts"], last: { sha: "x", ingot: "y" } } } }), + { key, act: { act: "github", app: "site", link: true, github: { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts", "clients/acme/"] } } }, + "cleaned as lib/hosted-apps.ts cleanGithubMirror cleans it; never a point from the window", + ); + for (const bad of [ + { key, act: { act: "githubPull", app: "site", site: "admin" } }, + { key, act: { act: "githubLink", app: "Not An App" } }, + { key, act: { act: "github", app: "site" } }, + { key, act: { act: "github", app: "site", github: { repo: "nope" } } }, + { key, act: { act: "github", app: "site", github: { repo: "a/b", branch: "../x" } } }, + { key, act: { act: "github", app: "site", github: { repo: "a/b" }, link: "yes" } }, + { key, act: { act: "github", app: "site", site: "site", github: null } }, + ]) + assert.equal(cleanAct(bad), null, JSON.stringify(bad)); + // One that won't do is said in the owner's own words for it. + assert.match(githubWhy({ key, act: { act: "github", app: "site", github: { repo: "nope" } } }) ?? "", /Which GitHub repo/); + assert.match(githubWhy({ key, act: { act: "github", app: "site", github: { repo: "a/b", dispatch: { workflow: "rm -rf" } } } }) ?? "", /workflow/); + assert.equal(githubWhy({ key, act: { act: "github", app: "site", github: null } }), null); + assert.equal(githubWhy({ key, act: { act: "deploy", app: "site" } }), null); +}); + +test("the slice: GitHub's folder, paths left alone, workflow and point at Write only; appGithub to an Admin or a Member with App secrets, once the owner's Mac takes them; GitHub acts only to a Bops that knows them", () => { + const blob = ghBlob(); + const s = (v: SliceViewer) => sharedSlice(blob, "ws_gh", v); + const sam = s(ghViewer("member", "sam@example.com", { githubActs: true, githubSeen: true })); + const site = sam.apps!.list.find((a) => a.name === "site")!; + assert.deepEqual(site.github, { + repo: "example-org/web", + branch: "main", + path: "formula", + keep: ["proxy.ts"], + dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } }, + linked: { sha: "a".repeat(40), ingot: "b".repeat(64), when: 7 }, + }); + assert.deepEqual(sam.apps!.list.find((a) => a.name === "blog")!.github, { repo: "example-org/web", branch: "main", path: "blog" }, "set, not linked"); + assert.equal(sam.appGithub, true); + assert.deepEqual(site.acts?.map((a) => [a.act, a.going]), [["githubPull", true], ["deploy", undefined]]); + // At Read: where Publish goes, nothing more, and no GitHub buttons. + const rob = s(ghViewer("member", "rob@example.com", { githubActs: true, githubSeen: true })); + assert.deepEqual(rob.apps!.list.find((a) => a.name === "site")!.github, { repo: "example-org/web", branch: "main" }); + assert.equal(rob.appGithub, undefined); + // A Member at Write without App secrets, or anyone while the owner's last Mac is older: none. + assert.equal(s(ghViewer("member", "ivy@example.com", { githubActs: true })).appGithub, undefined); + assert.equal(s(ghViewer("member", "sam@example.com", { githubActs: false })).appGithub, undefined); + assert.equal(s(ghViewer("admin", "ada@example.com", { githubActs: true })).appGithub, true, "an Admin always has it"); + assert.equal(s(ghViewer("admin", "ada@example.com")).appGithub, undefined); + // A viewer's Bops from before the GitHub acts never gets one (its window would read it as a domain's). + const old = s(ghViewer("member", "sam@example.com", { githubActs: true })); + assert.deepEqual(old.apps!.list.find((a) => a.name === "site")!.acts?.map((a) => a.act), ["deploy"]); + // Each is part of the fingerprint. + assert.notEqual(sharedHash(sam), sharedHash(s(ghViewer("member", "sam@example.com", { githubActs: false, githubSeen: true })))); + assert.notEqual(sharedHash(sam), sharedHash(old)); + // viewerOf: the owner's gate is said to anyone but the owner; the viewer's own Bops is theirs. + const who = { id: "u-sam", email: "sam@example.com" } as Parameters[0]; + const wa = (role: WsAccess["role"]) => ({ role, ownerUserId: "gh-owner", bopsWsId: "ws_gh", name: "Gh" }) as WsAccess; + assert.deepEqual([viewerOf(who, wa("member"), false, false, false, false, { acts: true, seen: true }).githubActs, viewerOf(who, wa("owner"), false, false, false, false, { acts: true }).githubActs], [true, undefined]); +}); + +test("over HTTP: the GitHub acts are the power of Publish (an Admin, a Member with App secrets) at Your Apps Write, once the owner's last Mac takes them; one folder an app; a Link needs where to, Bring in a link", async () => { + const blob = ghBlob(); + let ghNew = true; + const jobs: Parameters[0]>["takeJob"] & {}>[0][] = []; + let jobSays: "computer" | "runner" | "key" = "computer"; + let macOn = true; + const fakes = () => + resetMembers({ + state: async () => blob, + queue: async (owner, kind, payload, dedupe, expires) => void queued.push({ owner, kind, payload: payload as MemberActPayload, dedupe, expires }), + isOn: () => macOn, + drop: async () => {}, + count: async () => {}, + adminGate: async (_owner: string, min: string = ADMIN_POWERS_MIN_VERSION) => (min === GITHUB_ACTS_MIN_VERSION ? ghNew : true), + takeJob: async (o) => { + jobs.push(o); + o.check(blob); + return jobSays === "computer" + ? { where: "computer", fresh: true, turn: { id: `turn_${o.id}` } as never } + : { where: "mac", why: jobSays, bot: "Max" }; + }, + }); + fakes(); + queued.length = 0; + for (const [u, role] of [["gh-ada", "admin"], ["gh-sam", "member"], ["gh-ivy", "member"], ["gh-rob", "member"]] as const) { + roles[`${u} proj-gh`] = { role, ownerUserId: "gh-owner", bopsWsId: "ws_gh", name: "Gh", ownerEmail: STAFF_OWNER }; + emails[u] = `${u.slice(3)}@example.com`; + } + const go = (user: string, a: unknown, key = `k_gh_${String(Math.random()).slice(2, 10)}`) => act(user, { key, act: a }, "proj-gh"); + const refused = async (user: string, a: unknown, status: number, code?: string, words?: RegExp) => { + const r = await go(user, a); + assert.deepEqual([r.status, r.json.code], [status, code], `${user} ${JSON.stringify(a)}`); + if (words) assert.match(String(r.json.error), words); + }; + // An Admin's Bring in: owed to the app's computer (takeAppJob), named as theirs with the role, nothing for the Mac. + const r = await go("gh-ada", { act: "githubPull", app: "site" }, "k_gh_pull_01"); + assert.equal(r.status, 202); + assert.deepEqual(r.json, { ok: true, macOn: true, stored: true, computer: true, turn: `turn_msg_job_gh-ada_k_gh_pull_01` }); + assert.deepEqual([jobs.at(-1)?.job, jobs.at(-1)?.member.role, jobs.at(-1)?.member.ref, jobs.at(-1)?.member.projectId, jobs.at(-1)?.wsId], [{ act: "githubPull", app: "site" }, "admin", "k_gh_pull_01", "proj-gh", "ws_gh"]); + assert.equal(jobs.at(-1)?.words("Max"), "ada asked Max's computer to bring in GitHub's changes to site."); + assert.equal(queued.length, 0); + // A Member with App secrets links; one without, or at Read, is refused, nothing done. + assert.equal((await go("gh-sam", { act: "githubLink", app: "site" })).status, 202); + assert.equal(jobs.at(-1)?.member.role, undefined); + const n = jobs.length; + await refused("gh-ivy", { act: "githubPull", app: "site" }, 403, "admin_only", /App secrets in Team/); + await refused("gh-rob", { act: "githubPull", app: "site" }, 403, "member_apps_read_only"); + // One folder is one app; a Link needs where to; Bring in needs a link. + await refused("gh-ada", { act: "githubLink", app: "shop" }, 409, "not_waiting", /one GitHub folder is one app/); + await refused("gh-ada", { act: "github", app: "shop", github: { repo: "example-org/web", path: "shop" } }, 409, "not_waiting"); + await refused("gh-ada", { act: "githubPull", app: "blog" }, 409, "not_linked", new RegExp(GITHUB_NOT_LINKED)); + await refused("gh-ada", { act: "githubLink", app: "nope" }, 404); + void GITHUB_NOT_SET; + // A setting that won't do: the owner's own words for it. + await refused("gh-ada", { act: "github", app: "site", github: { repo: "nope" } }, 400, undefined, /Which GitHub repo/); + assert.equal(jobs.length, n, "nothing refused reached a computer"); + // The owner's last Mac older than the GitHub acts: refused for everyone first. + ghNew = false; + fakes(); + await refused("gh-ada", { act: "githubPull", app: "site" }, 409, "owner_bops_old"); + await refused("gh-sam", { act: "githubLink", app: "site" }, 409, "owner_bops_old"); + ghNew = true; + // No runner on the app's computer that can: the owner's Mac does it, as a member's other app acts. + jobSays = "runner"; + fakes(); + const m = await go("gh-sam", { act: "githubPull", app: "site" }, "k_gh_mac_001"); + assert.deepEqual([m.status, m.json.macOn, m.json.computer], [202, true, undefined]); + assert.deepEqual([queued.at(-1)?.payload.act, queued.at(-1)?.dedupe], [{ act: "githubPull", app: "site" }, "member:gh-sam:k_gh_mac_001"]); + // No bot on that computer has the GitHub key: the owner's Mac, when it's on; with it off, said now. + jobSays = "key"; + macOn = false; + fakes(); + const before = queued.length; + await refused("gh-sam", { act: "githubPull", app: "site" }, 409, "github_no_key", /Max's computer has no GitHub key/); + assert.equal(queued.length, before, "never left to wait for nothing"); + macOn = true; + fakes(); + assert.equal((await go("gh-sam", { act: "githubPull", app: "site" })).status, 202); + assert.equal(queued.length, before + 1); +}); + +test("over HTTP: someone other than the owner points the GitHub key somewhere new only with a GitHub key of the workspace's own", async () => { + const keys = (names: string[]) => ({ at: 5, keys: names.map((name) => ({ name, scope: name.startsWith("OWN_") ? "all" : "workspace" })) }); + let blob = ghBlob({ keys: { at: 5, keys: [{ name: "GITHUB_TOKEN", scope: "all" }] } }); + resetMembers({ state: async () => blob, queue: async () => {}, isOn: () => true, drop: async () => {}, count: async () => {}, adminGate: async () => true }); + roles["gh-ada proj-gh"] = { role: "admin", ownerUserId: "gh-owner", bopsWsId: "ws_gh", name: "Gh", ownerEmail: STAFF_OWNER }; + emails["gh-ada"] = "ada@example.com"; + const go = (a: unknown) => act("gh-ada", { key: `k_gk_${String(Math.random()).slice(2, 10)}`, act: a }, "proj-gh"); + const elsewhere = { act: "github", app: "site", github: { repo: "example-org/other", branch: "main", path: "formula" } }; + // Only the owner's own GitHub key (for all their workspaces), as orgo-web lists the workspace's own keys now: refused. + liveKeys["proj-gh"] = ["STRIPE_SECRET_KEY"]; + let r = await go(elsewhere); + assert.deepEqual([r.status, r.json.code, r.json.error], [403, "member_owner_only", GITHUB_KEY_LINE]); + // orgo-web can't say (an older one, or no Keys for them): the owner's Mac's last word, which has it only as the owner's. + liveKeys["proj-gh"] = 403; + r = await go(elsewhere); + assert.equal(r.status, 403); + // A workspace and a different workflow, a folder, a branch: each a new destination. + for (const g of [{ repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts"], dispatch: { workflow: "other.yml" } }, { repo: "example-org/web", branch: "dev", path: "formula" }, { repo: "example-org/web", branch: "main", path: "elsewhere" }]) + assert.equal((await go({ act: "github", app: "site", github: g })).status, 403, JSON.stringify(g)); + // Past the check, it's written under the owner's lock (cloud/test/edge-turns.test.ts), which this file has no database for. + const passed = async (a: unknown) => { + const x = await go(a); + assert.notEqual(x.json.code, "member_owner_only", JSON.stringify(a)); + assert.notEqual(x.status, 202, "(no database here to write it)"); + }; + // The same destination with more left alone, or back to Orgo: no key needed. + await passed({ act: "github", app: "site", github: { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts", "clients/acme/"], dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } } } }); + await passed({ act: "github", app: "site", github: null }); + // The workspace's own GITHUB_TOKEN, as orgo-web lists it, or (when it can't say) as the owner's Mac said it: past the check. + liveKeys["proj-gh"] = ["GITHUB_TOKEN"]; + await passed(elsewhere); + liveKeys["proj-gh"] = 404; + blob = ghBlob({ keys: keys(["GITHUB_TOKEN"]) }); + await passed(elsewhere); + delete liveKeys["proj-gh"]; + +}); + /* ---------------- What someone may change: Team's switches and the "change" act ---------------- */ test("Team's switches are kept as sent (only known ones, only on), kept through a save of bots alone, and told apart from none", () => { diff --git a/cloud/test/edge-turns.test.ts b/cloud/test/edge-turns.test.ts index 03d8e11ea..9a346a89b 100644 --- a/cloud/test/edge-turns.test.ts +++ b/cloud/test/edge-turns.test.ts @@ -7,9 +7,9 @@ import { closeDb, query } from "../db.ts"; import { deviceIdOf } from "../device.ts"; import { closeAll } from "../live.ts"; import { resetMembers } from "../members.ts"; -import type { MemberActPayload } from "../protocol.ts"; +import { GITHUB_ACTS_MIN_VERSION, GITHUB_KEY_LINE, type MemberActPayload, type SharedWsState } from "../protocol.ts"; import { howIWork } from "../style.ts"; -import { closeRunners, resetTurns, sweepTurns, turnById, turnTiming } from "../turns.ts"; +import { closeRunners, JOB_OLD_RUNNER, resetTurns, sweepTurns, turnById, turnTiming } from "../turns.ts"; import { connectMac, dropUsers, keyOf, listen, newUserId, prepareDb, startCloud, until, type Listening } from "./edge-fakes.ts"; /** @@ -34,6 +34,8 @@ const orgoSays = (member: string, project: string, more: Record /** What the fake orgo-web answers a wake with, by desktop (default: running), and each wake it was asked for. */ const wakeAnswers = new Map Promise> | Record>(); const wakes: { userId: string; desktopId: string; allowHourly: boolean; signed: boolean }[] = []; +/** Each project's own keys as orgo-web's bops-keys lists them (names), or the status it answers; none: 404. */ +const liveKeys: Record = {}; /** A bops-role answer held until the test lets it go, by " ": what orgo-web said before a notice, back after it. */ const held = new Map; reached: boolean }>(); @@ -58,6 +60,11 @@ async function fakeOrgo(): Promise { const n = wakes.filter((w) => w.desktopId === b.desktopId).length; return json(res, 200, { desktopId: b.desktopId, ...(await (wakeAnswers.get(b.desktopId) ?? (() => ({ state: "running" })))(n)) }); } + const keysOf = /^\/api\/projects\/([^/]+)\/bops-keys$/.exec(req.url ?? "")?.[1]; + if (keysOf) { + const k = liveKeys[keysOf]; + return k === undefined || typeof k === "number" ? json(res, typeof k === "number" ? k : 404, {}) : json(res, 200, { keys: k.map((name) => ({ name, updated_at: null })) }); + } const project = /^\/api\/projects\/([^/]+)\/bops-role$/.exec(req.url ?? "")?.[1]; let m: RegExpExecArray | null; const hold = project && (m = /^key-(.+)$/.exec(key)) ? held.get(`${m[1]} ${project}`) : undefined; @@ -789,3 +796,222 @@ test("without Use its computer, a member can't tell work on the bot's computer m assert.equal((await act(s.noUse, s.project, "key-nc-stop001", { act: "stop", sessionIds: ["t1"] })).status, 202); assert.equal((await act(s.writer, s.project, "key-nc-reply02", { act: "reply", sessionId: "t1", text: "do more" })).status, 202, "with it, as before"); }); + +/* ---------------- An app's GitHub production, from a shared workspace's Your Apps ---------------- */ + +const SHA = "a".repeat(40); +const INGOT = "b".repeat(64); +/** The owner's state as their Mac reads it from Bops Cloud, whole (ownersState, with apps). */ +type Full = { version: number; state: { workspaces: { id: string; hostedApps?: Record>; appsForMembers?: { apps: Record[] }> } }[] } }; +const fullState = async (owner: string) => (await (await fetch(`${cloud.url}/v1/state`, { headers: own(owner, "mac-o", GITHUB_ACTS_MIN_VERSION) })).json()) as Full; +const acme = (f: Full) => f.state.workspaces.find((w) => w.id === "ws_acme")!; +const actsOn = async (owner: string, app = "site") => acme(await fullState(owner)).appsForMembers?.apps[app]?.acts ?? []; +/** What a member reads of the workspace, as a Bops that says its version (the GitHub acts are shown only to one at GITHUB_ACTS_MIN_VERSION). */ +const readAs = async (who: string, project: string, version = GITHUB_ACTS_MIN_VERSION) => + (await (await fetch(`${cloud.url}/v1/ws/${project}/state`, { headers: { Authorization: `Bearer ${keyOf(who)}`, "x-bops-version": version } })).json()) as SharedWsState; + +/** + * A workspace of Orgo's team (its owner's email, as orgo-web says it, opens Your Apps) whose app "site" publishes to GitHub + * (linked), at home on Max's computer, with a runner there the owner's Mac saw running (`runner`: its version; null: none), + * Max given the GitHub key (`keyed`), and the owner's last Mac at `mac`. An Admin; Sam, a Member with Your Apps at Write and + * App secrets; Ivy, at Write without the switch. The owner's Mac is shut (no tunnel). + */ +async function setupGithub(o: { runner?: string | null; keyed?: boolean; mac?: string } = {}) { + const owner = user("owner"); + const admin = user("admin"); + const sam = user("secrets"); + const ivy = user("noswitch"); + const project = randomUUID(); + const desktop = randomUUID(); + const said = { ownerUserId: owner, bopsWsId: "ws_acme", name: "Acme", ownerEmail: "owner@orgo.ai" }; + roles[`${owner} ${project}`] = { role: "owner", ...said }; + roles[`${admin} ${project}`] = { role: "admin", ...said }; + for (const m of [sam, ivy]) roles[`${m} ${project}`] = { role: "member", ...said }; + const DESKTOP = desktop.toUpperCase(); + const state = { + owner: { name: "Tara" }, + workspaces: [ + { id: "ws_main", name: "Main" }, + { + id: "ws_acme", + name: "Acme", + memberAccess: { + [`${sam}@example.com`]: { others: "none", bots: { max: "read" }, apps: "write", can: { appSecrets: true } }, + [`${ivy}@example.com`]: { others: "none", bots: { max: "read" }, apps: "write" }, + }, + hostedApps: { + site: { + botId: "max", + computerId: DESKTOP, + projectId: "proj-1", + url: "https://site.orgo.dev", + github: { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts"], last: { sha: SHA, ingot: INGOT, keep: ["proxy.ts"], when: 7 } }, + }, + }, + appsForMembers: { at: 10, apps: { site: { head: null, sites: [], commits: [], deploys: [] } } }, + }, + ], + bots: [{ id: "max", name: "Max", isMain: true, workspaceId: "ws_acme", computerId: DESKTOP, ...(o.keyed === false ? {} : { keys: ["GITHUB_TOKEN"] }) }], + chats: [{ id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme" }], + ...(o.runner === null ? {} : { runners: { [DESKTOP]: { status: "running", version: o.runner ?? GITHUB_ACTS_MIN_VERSION, buildId: "b1", by: "mac-o", at: 1 } } }), + }; + assert.equal((await fetch(`${cloud.url}/v1/state`, { method: "PUT", headers: own(owner, "mac-o", GITHUB_ACTS_MIN_VERSION), body: JSON.stringify({ base: 0, state }) })).status, 200); + await macSaid(owner, o.mac ?? GITHUB_ACTS_MIN_VERSION); + return { owner, admin, sam, ivy, project, desktop, device: deviceIdOf(owner, desktop) }; +} +const pendingFor = async (owner: string) => (await query<{ payload: MemberActPayload }>("SELECT payload FROM bops.cloud_pending WHERE user_id = $1 AND kind = 'member'", [owner])).rows.map((r) => r.payload); + +test("an Admin's Bring in with the owner's Mac shut: owed to the app's own computer as a job (nothing waits for the Mac), Your Apps shows it going, the runner claims it with the job and its result is kept on the app", async (t) => { + const s = await setupGithub(); + const box = await stream("/v1/runner/live", vm(s.owner, s.desktop), "hello"); + t.after(() => box.close()); + const r = await act(s.admin, s.project, "key-ghpull-01", { act: "githubPull", app: "site" }); + assert.equal(r.status, 202, await r.clone().text()); + const a = (await r.json()) as { turn: string }; + assert.deepEqual(a, { ok: true, macOn: true, stored: true, computer: true, turn: `turn_msg_job_${s.admin}_key-ghpull-01` }); + assert.deepEqual(await pendingFor(s.owner), [], "nothing waits for the owner's Mac"); + // Said in the app's bot's chat, as Bops' own; the turn carries the job and who asked, with the role. + assert.equal(await noteOf(s.owner, `msg_job_${s.admin}_key-ghpull-01`), `${s.admin} asked Max's computer to bring in GitHub's changes to site.`); + const row = (await query<{ member: { job?: unknown; role?: string; ref: string }; desktop_id: string }>("SELECT member, desktop_id FROM bops.chat_turns WHERE user_id = $1 AND id = $2", [s.owner, a.turn])).rows[0]; + assert.deepEqual([row.member.job, row.member.role, row.member.ref, row.desktop_id], [{ act: "githubPull", app: "site" }, "admin", "key-ghpull-01", s.desktop]); + // Your Apps: going, named as theirs; and the GitHub part is theirs to use. + const seen = await readAs(s.admin, s.project); + assert.equal(seen.state.appGithub, true); + const site = seen.state.apps!.list.find((x) => x.name === "site")!; + assert.deepEqual(site.github?.linked, { sha: SHA, ingot: INGOT, when: 7 }); + assert.deepEqual(site.acts?.map((x) => [x.key, x.act, x.going, x.name]), [["key-ghpull-01", "githubPull", true, s.admin]]); + // A Bops from before the GitHub acts is shown none of it. + const old = await readAs(s.admin, s.project, "0.0.51"); + assert.deepEqual([old.state.apps!.list[0].acts, old.state.appGithub], [[], true], "(the owner's Mac takes them: the flag stays; the old window never reads it)"); + // The runner hears it and claims it, with the job; its row in the chat (Bops' own, about an app) is the turn's. + await until(() => box.of("turn").some((e) => e.data.id === a.turn), "the job's turn event"); + const c = await runner(s.owner, s.desktop, `/v1/runner/turns/${a.turn}/claim`, {}); + assert.equal(c.status, 200, await c.clone().text()); + const claim = (await c.json()) as { turn: { member: { job: unknown; name: string } } }; + assert.deepEqual(claim.turn.member.job, { act: "githubPull", app: "site" }); + assert.equal((await runner(s.owner, s.desktop, `/v1/runner/turns/${a.turn}/beat`, { phase: "tools" })).status, 200); + const w = await writeAs(s.owner, s.desktop, [{ id: "job-row-1", chatId: "bot:max", role: "bot", botId: "max", text: "Brought 2 changes from GitHub into Ingot.", at: Date.now(), turn: a.turn, appNote: true, bops: true }]); + assert.equal(w.status, 200, await w.text()); + // Its own state write moves only the point (computer-scope.ts hostingChangeAllowed): never where it publishes. + const mine = (await (await runner(s.owner, s.desktop, "/v1/state")).json()) as Full; + const moved = JSON.parse(JSON.stringify(mine)) as Full; + acme(moved).hostedApps!.site.github = { ...(acme(moved).hostedApps!.site.github as Record), last: { sha: "c".repeat(40), ingot: "d".repeat(64), keep: ["proxy.ts"], when: 9 } }; + const putAs = (body: Full) => fetch(`${cloud.url}/v1/state`, { method: "PUT", headers: { Authorization: `Bearer ${vm(s.owner, s.desktop)}`, "x-bops-user": s.owner, "x-bops-protocol": "2", "content-type": "application/json" }, body: JSON.stringify({ base: body.version, state: body.state }) }); + const elsewhere = JSON.parse(JSON.stringify(mine)) as Full; + acme(elsewhere).hostedApps!.site.github = { ...(acme(elsewhere).hostedApps!.site.github as Record), repo: "example-org/other" }; + assert.equal((await putAs(elsewhere)).status, 403, "a runner never points it elsewhere"); + assert.equal((await putAs(moved)).status, 200, "its point, it may"); + // Its end, with how it went: kept on the app in place of going. + assert.equal((await runner(s.owner, s.desktop, `/v1/runner/turns/${a.turn}/done`, { state: "done", job: { ok: true, said: "Brought 2 changes from GitHub into Ingot." } })).status, 200); + assert.equal((await turnById(s.owner, a.turn))?.state, "done"); + const kept = (await actsOn(s.owner))[0]; + assert.deepEqual([kept.key, kept.act, kept.ok, kept.going, kept.said, kept.name], ["key-ghpull-01", "githubPull", true, undefined, "Brought 2 changes from GitHub into Ingot.", s.admin]); + assert.equal((acme(await fullState(s.owner)).hostedApps!.site.github as { last: { sha: string } }).last.sha, "c".repeat(40)); + // The same key again: the same turn, nothing new. + const again = (await (await act(s.admin, s.project, "key-ghpull-01", { act: "githubPull", app: "site" })).json()) as { turn: string }; + assert.equal(again.turn, a.turn); + assert.equal((await turnsOf(s.owner)).length, 1); +}); + +test("Save and link from a Member with App secrets: a new destination needs a GitHub key of the workspace's own; with it Bops Cloud keeps the setting (its point dropped, as Link must run again), says so, and owes the Link to the computer", async () => { + const s = await setupGithub(); + const elsewhere = { repo: "example-org/other", branch: "main", path: "clients/acme" }; + // The workspace sees GitHub only through the owner's own key: refused, nothing written. + liveKeys[s.project] = ["STRIPE_SECRET_KEY"]; + const before = (await fullState(s.owner)).version; + const no = await act(s.sam, s.project, "key-ghset-001", { act: "github", app: "site", github: elsewhere, link: true }); + assert.deepEqual([no.status, ((await no.json()) as { code: string; error: string }).error], [403, GITHUB_KEY_LINE]); + assert.equal((await fullState(s.owner)).version, before); + // A key of its own (This workspace, in Keys): kept now, by Bops Cloud, the owner's Mac shut. + liveKeys[s.project] = ["GITHUB_TOKEN"]; + const r = await act(s.sam, s.project, "key-ghset-002", { act: "github", app: "site", github: elsewhere, link: true }); + assert.equal(r.status, 202, await r.clone().text()); + assert.equal(((await r.json()) as { computer?: boolean }).computer, true, "its Link owed to the computer"); + const now = acme(await fullState(s.owner)).hostedApps!.site.github; + assert.deepEqual(now, { ...elsewhere }, "the new destination, its point gone (a new folder needs a new Link)"); + assert.equal((await query<{ writer: string }>("SELECT writer FROM bops.app_state WHERE user_id = $1", [s.owner])).rows[0].writer, "cloud"); + assert.equal(await noteOf(s.owner, `msg_github_${s.sam}_key-ghset-002`), `${s.sam} set site to publish to GitHub example-org/other, main, folder clients/acme.`); + assert.equal(await noteOf(s.owner, `msg_job_${s.sam}_key-ghset-002`), `${s.sam} asked Max's computer to link site to GitHub.`); + assert.deepEqual((await actsOn(s.owner)).map((x) => [x.key, x.act, x.going]), [["key-ghset-002", "githubLink", true]], "one row for the act, going"); + delete liveKeys[s.project]; +}); + +test("an Admin's setting with the same folder keeps its point, more left alone is fine without a key of the workspace's own, and null puts it back on Orgo", async () => { + const s = await setupGithub(); + const same = { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts", "clients/acme/"] }; + const r = await act(s.admin, s.project, "key-ghkeep-01", { act: "github", app: "site", github: same }); + assert.equal(r.status, 202, await r.clone().text()); + assert.deepEqual(await r.json(), { ok: true, macOn: true, stored: true }); + assert.deepEqual(acme(await fullState(s.owner)).hostedApps!.site.github, { ...same, last: { sha: SHA, ingot: INGOT, keep: ["proxy.ts"], when: 7 } }, "the point holds"); + assert.deepEqual((await actsOn(s.owner)).map((x) => [x.act, x.ok, x.going]), [["github", true, undefined]]); + // Sent again (a lost answer): nothing changes. + const v = (await fullState(s.owner)).version; + assert.equal((await act(s.admin, s.project, "key-ghkeep-01", { act: "github", app: "site", github: same })).status, 202); + assert.equal((await fullState(s.owner)).version, v); + // A path left alone taken off the list: its point goes (its Link was made with it left alone). + assert.equal((await act(s.admin, s.project, "key-ghkeep-02", { act: "github", app: "site", github: { ...same, keep: ["clients/acme/"] } })).status, 202); + assert.equal((acme(await fullState(s.owner)).hostedApps!.site.github as { last?: unknown }).last, undefined); + // Back to Publish on Orgo. + assert.equal((await act(s.admin, s.project, "key-ghkeep-03", { act: "github", app: "site", github: null })).status, 202); + assert.equal(acme(await fullState(s.owner)).hostedApps!.site.github, undefined); + assert.equal(await noteOf(s.owner, `msg_github_${s.admin}_key-ghkeep-03`), `${s.admin} set site to publish on Orgo again.`); + // Bring in with nothing linked now: refused, as their window says it. + const pull = await act(s.admin, s.project, "key-ghkeep-04", { act: "githubPull", app: "site" }); + assert.deepEqual([pull.status, ((await pull.json()) as { code: string }).code], [409, "not_linked"]); +}); + +test("who may: a Member without App secrets or the owner's last Mac older is refused; a runner older than the gate, or none, leaves it to the owner's Mac; only the key missing with that Mac off is said at once", async () => { + const s = await setupGithub(); + const r1 = await act(s.ivy, s.project, "key-ghwho-001", { act: "githubPull", app: "site" }); + assert.deepEqual([r1.status, ((await r1.json()) as { code: string }).code], [403, "admin_only"]); + assert.equal((await readAs(s.ivy, s.project)).state.appGithub, undefined, "no GitHub part for her"); + assert.equal((await readAs(s.sam, s.project)).state.appGithub, true); + // The owner's last Mac older than the GitHub acts. + const o = await setupGithub({ mac: "0.0.51" }); + const r2 = await act(o.admin, o.project, "key-ghwho-002", { act: "githubLink", app: "site" }); + assert.deepEqual([r2.status, ((await r2.json()) as { code: string }).code], [409, "owner_bops_old"]); + assert.equal((await readAs(o.admin, o.project)).state.appGithub, undefined); + // The app's computer's runner older than the gate: the owner's Mac does it when it opens (MEMBER_ACT_MS), as theirs. + const old = await setupGithub({ runner: "0.0.51" }); + const r3 = await act(old.sam, old.project, "key-ghwho-003", { act: "githubPull", app: "site" }); + assert.equal(r3.status, 202); + assert.deepEqual(await r3.json(), { ok: true, macOn: false }); + assert.deepEqual((await pendingFor(old.owner)).map((p) => [p.act, p.key, p.member.userId]), [[{ act: "githubPull", app: "site" }, "key-ghwho-003", old.sam]]); + assert.equal((await turnsOf(old.owner)).length, 0, "no job for a runner from before"); + // No runner there at all: the same. + const none = await setupGithub({ runner: null }); + assert.equal((await act(none.admin, none.project, "key-ghwho-004", { act: "githubLink", app: "site" })).status, 202); + assert.equal((await pendingFor(none.owner)).length, 1); + // A runner at the gate, but no bot on its computer was given the GitHub key, and the owner's Mac shut: said now. + const nokey = await setupGithub({ keyed: false }); + const r5 = await act(nokey.admin, nokey.project, "key-ghwho-005", { act: "githubPull", app: "site" }); + const b5 = (await r5.json()) as { code: string; error: string }; + assert.deepEqual([r5.status, b5.code], [409, "github_no_key"]); + assert.match(b5.error, /^Max's computer has no GitHub key, so it can't do this while the owner's Mac is off\. Give Max the GitHub key in Keys, then try again\.$/); + assert.equal((await pendingFor(nokey.owner)).length, 0); +}); + +test("a job's end its computer never says is kept on the app too: a runner from before (done, no result), a computer that can't wake, and App secrets taken away meanwhile", async () => { + // A runner that says it's at the gate but answers like one from before: done with no result is never a success. + const s = await setupGithub(); + const box = await stream("/v1/runner/live", vm(s.owner, s.desktop), "hello"); + const a = (await (await act(s.sam, s.project, "key-ghend-001", { act: "githubLink", app: "site" })).json()) as { turn: string }; + await until(() => box.of("turn").some((e) => e.data.id === a.turn), "the job's turn event"); + assert.equal((await runner(s.owner, s.desktop, `/v1/runner/turns/${a.turn}/claim`, {})).status, 200); + assert.equal((await runner(s.owner, s.desktop, `/v1/runner/turns/${a.turn}/done`, { state: "done" })).status, 200); + assert.deepEqual((await actsOn(s.owner)).map((x) => [x.ok, x.said, x.going]), [[false, JOB_OLD_RUNNER, undefined]]); + // App secrets turned off for Sam in Team before the computer took it: the job ends, and Your Apps says why. + const b = (await (await act(s.sam, s.project, "key-ghend-002", { act: "githubPull", app: "site" })).json()) as { turn: string }; + await ownerChanges(s.owner, (st) => void (st.workspaces.find((w) => w.id === "ws_acme")!.memberAccess![`${s.sam}@example.com`].can = {}), own(s.owner, "mac-o", GITHUB_ACTS_MIN_VERSION)); + await until(async () => (await turnById(s.owner, b.turn))?.state === "failed", "ended as they may no longer"); + await until(async () => (await actsOn(s.owner))[0]?.going === undefined, "kept on the app"); + assert.deepEqual((await actsOn(s.owner)).slice(0, 1).map((x) => [x.key, x.ok, x.said]), [["key-ghend-002", false, "Who can do this changed, so it didn't go."]]); + box.close(); + // A computer that can't wake (stopped): the chat's note, and the act says why. + const w = await setupGithub(); + wakeAnswers.set(w.desktop, () => ({ state: "stopped" })); + const c = (await (await act(w.admin, w.project, "key-ghend-003", { act: "githubPull", app: "site" })).json()) as { turn: string }; + await until(async () => (await turnById(w.owner, c.turn))?.state === "failed", "ended: it can't wake", 15_000); + await until(async () => (await actsOn(w.owner))[0]?.going === undefined, "kept on the app"); + assert.deepEqual((await actsOn(w.owner)).map((x) => [x.ok, x.said]), [[false, "Max's computer is stopped. Tara can start it again."]]); +}); diff --git a/cloud/turns.ts b/cloud/turns.ts index 5f39e4399..c93a90d72 100644 --- a/cloud/turns.ts +++ b/cloud/turns.ts @@ -10,13 +10,15 @@ import { deviceIdOf } from "./device.ts"; import { HttpError, readBody, readJson, sendJson, type Route } from "./http.ts"; import { planSigned } from "./plans.ts"; import { hub, sse, stateWritten } from "./live.ts"; -import { levelOf, noUseNote, permits } from "./member-access.ts"; +import { GITHUB_KEY_NAME } from "./github-config.ts"; +import { appsLevelOf, hasPower, levelOf, noUseNote, permits } from "./member-access.ts"; import { CHAT_AUTO_MIN_VERSION, CHAT_AUTO_OFF, CHAT_NEEDS_UPDATE, CHAT_NOT_IN_CLOUD, CLOUD_CHAT_MIN_VERSION, + GITHUB_ACTS_MIN_VERSION, MEMBER_MAX_TEXT, MESSAGE_TAKEN, STORED_ACT_MS, @@ -28,6 +30,7 @@ import { type RunnerClaim, type RunnerResume, type RunnerTurnEvent, + type TurnJob, type TurnMember, type TurnShown, } from "./protocol.ts"; @@ -490,6 +493,7 @@ type HandedBack = { members: TurnRow[]; owner: TurnRow[]; notes: string[] }; * note, and its message, written already, is queued for the Mac to answer (a stored member act, as members.ts * cloudChat queues one for a workspace on the Mac), with the chat saying the Mac answers it while that Mac is away. * The owner's own ends with a note to send it again: the Mac never had it to answer. One a runner holds goes on there. + * An app's GitHub job (takeAppJob) stays its computer's: it never was a chat answer, and goes there whichever answers chat. */ async function handBack(c: pg.PoolClient, userId: string, blob: Record, wsIds: string[]): Promise { const out: HandedBack = { members: [], owner: [], notes: [] }; @@ -497,7 +501,7 @@ async function handBack(c: pg.PoolClient, userId: string, blob: Record( `UPDATE bops.chat_turns SET state = CASE WHEN member IS NULL THEN 'failed' ELSE 'expired' END, note = CASE WHEN member IS NULL THEN 'toMac' ELSE NULL END, updated_at = now() - WHERE user_id = $1 AND ws_id = ANY($2::text[]) AND state IN ('owed', 'waking') RETURNING ${COLS}`, + WHERE user_id = $1 AND ws_id = ANY($2::text[]) AND state IN ('owed', 'waking') AND NOT COALESCE(member ? 'job', false) RETURNING ${COLS}`, [userId, wsIds], ); const away = !isConnected(userId); @@ -722,6 +726,187 @@ export async function takeMessage(o: { return taken; } +/* ---------------- An app's GitHub job ---------------- */ + +/** How many members' acts an app keeps (lib/hosted-apps.ts MEMBER_ACTS_KEPT). */ +const APP_ACTS_KEPT = 10; +/** A job's words as kept on the app: one line, at most 600 characters. */ +const cleanSaid = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 600) : null); +/** A job turn done with no result: a runner from before GitHub jobs took it, read a note of Bops' and answered nothing. */ +export const JOB_OLD_RUNNER = "The app's computer needs a newer Bops first. Try again once the owner's Mac has updated it."; +export const JOB_STOPPED = "That stopped before it was done. Try again."; +/** A job whose computer wrote its note but whose end never came back (sweepTurns): how it went isn't known. */ +const JOB_LOST = "The computer's answer got lost on the way. Look at the app before you try again."; + +const workspaceIn = (blob: Record, ws: string) => list(blob.workspaces).find((w) => w.id === ws); + +/** `blob` with workspace `ws`'s app `app` as `next` (Workspace.hostedApps[app]), everything else as it was. */ +export function withHosting(blob: Record, ws: string, app: string, next: Record): Record { + return { + ...blob, + workspaces: list(blob.workspaces).map((w) => (w.id === ws ? { ...w, hostedApps: { ...(isObject(w.hostedApps) ? w.hostedApps : {}), [app]: next } } : w)), + }; +} + +/** The members' acts kept on an app (Workspace.appsForMembers.apps[app].acts, lib/server/apps-for-members.ts noteMemberAct). */ +const actsOf = (blob: Record, ws: string, app: string): Record[] => { + const seen = workspaceIn(blob, ws)?.appsForMembers; + const apps = isObject(seen) && isObject(seen.apps) ? seen.apps : {}; + const a = Object.hasOwn(apps, app) && isObject(apps[app]) ? (apps[app] as Record) : {}; + return list(a.acts); +}; +/** Whether an act with this key is kept on the app already (done, refused, or going). */ +export const appActKept = (blob: Record, ws: string, app: string, key: string) => actsOf(blob, ws, app).some((x) => x.key === key); + +/** + * `blob` with a member's act kept on an app, newest first, in place of one by the same key (as the owner's Mac keeps one: + * lib/server/apps-for-members.ts noteMemberAct), APP_ACTS_KEPT at most. What members see of the app is the owner's Mac's + * to read: an app it hasn't read yet gets an empty record with only this. + */ +export function withAppAct(blob: Record, ws: string, app: string, act: Record): Record { + return { + ...blob, + workspaces: list(blob.workspaces).map((w) => { + if (w.id !== ws) return w; + const all = isObject(w.appsForMembers) ? w.appsForMembers : { at: 0, apps: {} }; + const apps = isObject(all.apps) ? all.apps : {}; + const a = Object.hasOwn(apps, app) && isObject(apps[app]) ? (apps[app] as Record) : { head: null, sites: [], commits: [], deploys: [] }; + const acts = [act, ...list(a.acts).filter((x) => x.key !== act.key)].slice(0, APP_ACTS_KEPT); + return { ...w, appsForMembers: { ...all, apps: { ...apps, [app]: { ...a, acts } } } }; + }), + }; +} + +/** `blob` with a job's act (its "going" row) said done or not, with words; null when it isn't going any more (said already, or gone). */ +function jobEnded(blob: Record, t: TurnRow, ok: boolean, said: string): Record | null { + const m = t.member; + if (!m?.job) return null; + const row = actsOf(blob, t.wsId, m.job.app).find((x) => x.key === m.ref); + if (!row || row.going !== true) return null; + return withAppAct(blob, t.wsId, m.job.app, { key: m.ref, userId: m.userId, name: m.name, act: m.job.act, ok, said, at: Date.now() }); +} + +/** Why a job ended that its computer never said, by its turn's note (NOTES' keys), as kept on the app. */ +const JOB_NOTES: Record string> = { + late: (_o, bot) => `${bot}'s computer didn't wake up in time. Try again.`, + stopped: (owner, bot) => `${bot}'s computer is stopped. ${owner} can start it again.`, + deleted: (_o, bot) => `${bot}'s computer was deleted.`, + noHours: (_o, bot) => `${bot}'s computer is out of Free hours for today.`, + hourly: (owner, bot) => `${bot}'s computer is paid by the hour, and ${owner} hasn't let it be woken for this.`, + noCredit: (owner, bot) => `${owner} is out of Orgo credit, so ${bot}'s computer can't wake up.`, + paused: (_o, bot) => `${bot}'s computer is paused.`, + cant: (_o, bot) => `${bot}'s computer couldn't wake up. Try again later.`, + noRunner: (owner, bot) => `${bot}'s computer is on, but its Bops isn't set up yet. ${owner} needs to open the updated Bops once.`, + moved: (_o, bot) => `${bot} moved to another computer before this was done. Try again.`, + midway: (_o, bot) => `${bot}'s computer stopped partway through. Look at the app, then try again.`, +}; +/** Why a job ended that its computer never said (it couldn't wake, went quiet partway, their access changed), as kept on the app. */ +const jobWords = (t: TurnRow, owner: string, bot: string): string => + t.note && Object.hasOwn(JOB_NOTES, t.note) ? JOB_NOTES[t.note](owner, bot) : "Who can do this changed, so it didn't go."; + +/** + * App jobs that ended without their computer saying how (a wake refused, too late, a lease lost partway, taken back): each + * one's act on the app said not done, and why, so Your Apps never shows it going for good. Never throws. + */ +async function jobsOver(turns: TurnRow[], why?: string) { + const jobs = turns.filter((t) => t.member?.job); + if (!jobs.length) return; + const userId = jobs[0].userId; + try { + const written = await tx(async (c) => { + await lockUser(c, userId); + let blob = await lockedState(c, userId); + let changed = false; + for (const t of jobs) { + const next = jobEnded(blob, t, false, why ?? jobWords(t, ownerOf(blob), nameOf(blob, t.botId))); + if (next) [blob, changed] = [next, true]; + } + if (!changed) return null; + await c.query("UPDATE bops.app_state SET state = $2::jsonb, version = version + 1, writer = 'cloud', updated_at = now() WHERE user_id = $1", [userId, toJson(blob)]); + return blob; + }); + if (written) { + announce(userId); + stateWritten(userId, written, "cloud"); + } + } catch (e) { + console.warn(`[turns] app jobs of ${userId}: ${(e as Error).message}`); + } +} + +/** Where a runner is on a computer as the owner's Mac keeps it (AppState.runners, by Orgo computer id, any case). */ +const runnerAt = (blob: Record, desktop: string): Record | undefined => { + const all = isObject(blob.runners) ? blob.runners : {}; + const k = Object.keys(all).find((x) => x.toLowerCase() === desktop); + return k && isObject(all[k]) ? (all[k] as Record) : undefined; +}; +const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,6}$/; + +export type AppJobTaken = { where: "computer"; turn: TurnRow; fresh: boolean } | { where: "mac"; why: "home" | "runner" | "key"; bot: string }; + +/** + * An app's GitHub job (protocol.ts MemberGithubAct "githubLink" or "githubPull") owed to the app's own computer, which + * has both what it needs there: Ingot (its runner's token for the workspace) and the GitHub key (its own sign in, which + * Orgo's broker lets call only the keys given to a bot on it). Under the owner's lock, on their state as it is then: + * `check` (throwing to refuse: still theirs to do), then the app's home (not lost, on the computer its bot works on), a + * runner the owner's Mac saw running there at GITHUB_ACTS_MIN_VERSION, and a bot of the workspace on it given the GitHub + * key. Any missing: "mac", and why, for the owner's Mac to do it instead. Else, once by its id (a retry finds it and + * changes nothing): a note in the app's bot's chat (Bops', with what `words` says), its "going" act on the app, and a turn + * owed to that computer with the job on its member (TurnMember.job), given out and woken as a chat turn is, one at a time + * in that chat. `still` false right before it's kept takes it all back (an orgo-web notice landed). + */ +export async function takeAppJob(o: { + account: string; + wsId: string; + job: TurnJob; + id: string; + member: TurnMember; + words: (bot: string) => string; + check: (blob: Record) => void; + still?: () => boolean; +}): Promise { + const taken = await tx(async (c): Promise }> => { + await lockUser(c, o.account); + let blob = await lockedState(c, o.account); + o.check(blob); + const was = (await c.query(`SELECT ${COLS} FROM bops.chat_turns WHERE user_id = $1 AND message_id = $2`, [o.account, o.id])).rows[0]; + if (was) return { where: "computer", turn: turnOf(was), fresh: false }; + const apps = workspaceIn(blob, o.wsId)?.hostedApps; + const h = isObject(apps) && Object.hasOwn(apps, o.job.app) && isObject(apps[o.job.app]) ? (apps[o.job.app] as Record) : null; + if (!h) throw new HttpError(404, "No such app."); + const botId = typeof h.botId === "string" ? h.botId : null; + const bot = botId ? nameOf(blob, botId) : "The bot"; + const desktop = botId ? computerFor(blob, botId) : null; + if (!botId || !desktop || h.lost !== undefined || typeof h.computerId !== "string" || h.computerId.toLowerCase() !== desktop) return { where: "mac", why: "home", bot }; + const r = runnerAt(blob, desktop); + if (!r || r.status !== "running" || r.down || typeof r.version !== "string" || !VERSION.test(r.version) || olderThan(r.version, GITHUB_ACTS_MIN_VERSION)) return { where: "mac", why: "runner", bot }; + const keyed = list(blob.bots).some((b) => typeof b.id === "string" && wsOfBot(b) === o.wsId && computerFor(blob, b.id) === desktop && Array.isArray(b.keys) && b.keys.includes(GITHUB_KEY_NAME)); + if (!keyed) return { where: "mac", why: "key", bot }; + const at = Date.now(); + const chatId = `bot:${botId}`; + const message = { id: o.id, chatId, role: "system", botId, text: o.words(bot), at }; + await c.query("INSERT INTO bops.chat_messages (user_id, id, chat_id, at, json) VALUES ($1, $2, $3, $4, $5::jsonb) ON CONFLICT (user_id, id) DO NOTHING", [o.account, o.id, chatId, at, toJson(message)]); + const ins = await c.query( + `INSERT INTO bops.chat_turns (user_id, id, message_id, chat_id, bot_id, ws_id, member, desktop_id, target_device, state, deadline, human_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, 'owed', now() + make_interval(secs => $10), $11) + RETURNING ${COLS}`, + [o.account, `turn_${o.id}`, o.id, chatId, botId, o.wsId, JSON.stringify({ ...o.member, job: o.job }), desktop, deviceIdOf(o.account, desktop), TURN_DEADLINE_MS / 1000, at], + ); + blob = withAppAct(blob, o.wsId, o.job.app, { key: o.member.ref, userId: o.member.userId, name: o.member.name, act: o.job.act, ok: true, going: true, at }); + await c.query("UPDATE bops.app_state SET state = $2::jsonb, version = version + 1, writer = 'cloud', updated_at = now() WHERE user_id = $1", [o.account, toJson(blob)]); + if (o.still && !o.still()) throw new HttpError(404, "No such workspace"); + return { where: "computer", turn: turnOf(ins.rows[0]), fresh: true, written: blob }; + }); + if (taken.where === "computer" && taken.fresh) { + announce(o.account, { chats: [taken.turn.chatId], by: "cloud" }); + if (taken.written) stateWritten(o.account, taken.written, "cloud"); + shown(taken.turn, "owed"); + void dispatch(taken.turn); + } + if (taken.where === "computer") return { where: "computer", turn: taken.turn, fresh: taken.fresh }; + return taken; +} + /* ---------------- Runners listening ---------------- */ type RunnerStream = { write: (type: string, data: unknown) => void; end: () => void }; @@ -937,6 +1122,7 @@ async function ended(turns: TurnRow[]) { shown(t, "failed", say); } if (rows.length) await writeMessages(turns[0].userId, rows).catch((e: Error) => console.warn(`[turns] notes: ${e.message}`)); + await jobsOver(turns); } /* ---------------- The runner's routes ---------------- */ @@ -1022,6 +1208,12 @@ function memberStill(blob: Record, t: TurnRow): "ok" | "gone" | ...(t.member.joinedAt !== undefined ? { joinedAt: t.member.joinedAt } : {}), ...(typeof t.member.computerUse === "boolean" ? { computerUse: t.member.computerUse } : {}), }; + // An app's GitHub job (takeAppJob): Your Apps at Write and the power of Publish (an Admin always, a Member with App + // secrets), as the act was taken; never the bot's level or Use its computer: it runs no words of theirs. + if (t.member.job) { + const access = viewerAccess(ws, viewer); + return appsLevelOf(access) === "write" && hasPower("appSecrets", viewer.role, access.can) ? "ok" : "gone"; + } if (!permits(levelOf(viewerAccess(ws, viewer), t.botId), "write")) return "gone"; return computerUseOf(ws, viewer) ? "ok" : "noUse"; } @@ -1082,7 +1274,10 @@ async function afterRevoked(t: TurnRow) { await writeMessages(t.userId, [noteRow(t, say)]).catch(() => {}); shown(t, "failed", say); } else if (t.note) await ended([t]); - else shown(t, "done"); + else { + shown(t, "done"); + await jobsOver([t]); + } void nextOf(t.userId, t.chatId).catch(() => {}); } @@ -1172,6 +1367,7 @@ async function finish(user: CloudUser, id: string, body: unknown) { if (held && held.claimedBy === user.computer!.device && held.state === "claimed" && !(await stillMay(held))) throw new HttpError(409, "That turn isn't to be answered any more.", { code: "over" }); } let wrote = false; + let jobWritten: Record | null = null; const t = await tx(async (db) => { await lockUser(db, user.id); const raw = (await db.query(`SELECT ${COLS} FROM bops.chat_turns WHERE user_id = $1 AND id = $2 FOR UPDATE`, [user.id, id])).rows[0]; @@ -1195,8 +1391,25 @@ async function finish(user: CloudUser, id: string, body: unknown) { await db.query("INSERT INTO bops.chat_messages (user_id, id, chat_id, at, json) VALUES ($1, $2, $3, $4, $5::jsonb) ON CONFLICT (user_id, id) DO NOTHING", [user.id, n.id, n.chatId, n.at, toJson(n)]); wrote = true; } + // An app's GitHub job: how it went, kept on the app as theirs (SharedApp.acts), in place of its "going" row. Done with + // no result is a runner from before (it read a note of Bops' and answered nothing): said so, never as done. + if (raw.member?.job && state !== "paused") { + const res = isObject(b.job) && typeof b.job.said === "string" ? b.job : null; + const blob = await lockedState(db, user.id); + const ok = state === "done" && res?.ok === true; + const said = cleanSaid(res?.said) ?? (note ? note : state === "done" ? JOB_OLD_RUNNER : JOB_STOPPED); + const next = jobEnded(blob, turnOf(raw), ok, said); + if (next) { + await db.query("UPDATE bops.app_state SET state = $2::jsonb, version = version + 1, writer = 'cloud', updated_at = now() WHERE user_id = $1", [user.id, toJson(next)]); + jobWritten = next; + } + } return turnOf(r.rows[0]); }); + if (jobWritten) { + announce(user.id); + stateWritten(user.id, jobWritten, "cloud"); + } if (wrote) announce(user.id, { chats: [t.chatId], by: "cloud" }); shown(t, state === "paused" ? "paused" : state === "failed" ? "failed" : "done", note ?? undefined); if (state !== "paused") void nextOf(user.id, t.chatId).catch(() => {}); @@ -1398,6 +1611,7 @@ export async function cancelMember(projectId: string, userId: string) { shown(t, "done"); void nextOf(t.userId, t.chatId).catch(() => {}); } + for (const t of r.rows.map(turnOf)) await jobsOver([t]); } /* ---------------- The sweep ---------------- */ @@ -1428,6 +1642,8 @@ export async function sweepTurns() { if (t.state === "failed") failed.push(t); else if (t.state === "done") { shown(t, "done"); + // (An app's job whose done was lost: its result with it. Said as not known, never as done.) + await jobsOver([t], JOB_LOST); void nextOf(t.userId, t.chatId).catch(() => {}); } else void dispatch(t).catch(() => {}); } diff --git a/cloud/ws-slice.ts b/cloud/ws-slice.ts index 154c990f7..4de97c378 100644 --- a/cloud/ws-slice.ts +++ b/cloud/ws-slice.ts @@ -246,6 +246,10 @@ export type SliceViewer = { keyActs?: boolean; /** Whether the owner's Bops takes the "change" act (change-routes.ts CHANGES_MIN_VERSION): SharedWsSlice.changes. */ changes?: boolean; + /** Whether the owner's last Mac takes the GitHub acts (protocol.ts GITHUB_ACTS_MIN_VERSION): SharedWsSlice.appGithub. */ + githubActs?: boolean; + /** Whether the viewer's own Bops knows the GitHub acts (GITHUB_ACTS_MIN_VERSION): else they're left out of what it's shown (appsFor). */ + githubSeen?: boolean; }; /** The owner, as sharedSlice takes them (who they are doesn't change what they see: all of it). */ export const OWNER_VIEW: SliceViewer = { role: "owner", id: "" }; @@ -424,11 +428,33 @@ function sitesFilled(name: string, host: Record, mac: unknown[] const COMMIT_SEEN: Spec = { id: true, message: true, author: true, at: true, changes: { added: true, modified: true, deleted: true } }; /** `env`: where it went, its Preview or Published (none: from a Bops before, Published's). */ const DEPLOY_SEEN: Spec = { id: true, state: true, at: true, rollback: true, when: true, live: true, canRollback: true, site: true, env: true }; -const ACT_SEEN: Spec = { key: true, userId: true, name: true, act: true, site: true, domain: true, env: true, ok: true, said: true, at: true }; +const ACT_SEEN: Spec = { key: true, userId: true, name: true, act: true, site: true, domain: true, env: true, ok: true, going: true, said: true, at: true }; +/** A member's act on an app a Bops from before GITHUB_ACTS_MIN_VERSION doesn't know (its window would read one as a domain's). */ +const GITHUB_ACT = new Set(["github", "githubLink", "githubPull"]); const SETTINGS_SEEN: Spec = { port: true, install: true, build: true, start: true, health: true }; -/** An app's GitHub production as shown (repo and branch, as the owner's Bops checked them when set), or none when malformed. */ -const githubOf = (g: unknown): { repo: string; branch: string } | undefined => - isObject(g) && typeof g.repo === "string" && typeof g.branch === "string" && g.repo.length <= 201 && g.branch.length <= 200 ? { repo: g.repo, branch: g.branch } : undefined; +/** + * An app's GitHub production as shown (repo and branch, as the owner's Bops or Bops Cloud checked them when set), or none + * when malformed. At Write (`write`) also its folder, the paths left alone, the workflow it runs and where it was last the + * same as GitHub (`linked`, of its point: never more than its commits' ids and when), so Your Apps links it and brings + * GitHub's changes in. + */ +const githubOf = (g: unknown, write = false): SharedApp["github"] | undefined => { + if (!isObject(g) || typeof g.repo !== "string" || typeof g.branch !== "string" || g.repo.length > 201 || g.branch.length > 200) return undefined; + if (!write) return { repo: g.repo, branch: g.branch }; + const words = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max; + const keep = Array.isArray(g.keep) ? g.keep.filter((k): k is string => words(k, 300)).slice(0, 50) : []; + const d = isObject(g.dispatch) && words(g.dispatch.workflow, 120) ? g.dispatch : undefined; + const inputs = d && isObject(d.inputs) ? Object.fromEntries(Object.entries(d.inputs).filter((e): e is [string, string] => e[0].length <= 50 && words(e[1], 200)).slice(0, 10)) : undefined; + const last = isObject(g.last) && words(g.last.sha, 64) && words(g.last.ingot, 64) && typeof g.last.when === "number" ? g.last : undefined; + return { + repo: g.repo, + branch: g.branch, + ...(words(g.path, 300) ? { path: g.path } : {}), + ...(keep.length ? { keep } : {}), + ...(d ? { dispatch: { workflow: d.workflow as string, ...(inputs && Object.keys(inputs).length ? { inputs } : {}) } } : {}), + ...(last ? { linked: { sha: last.sha as string, ingot: last.ingot as string, when: last.when as number } } : {}), + }; +}; /** * The most apps a member is shown: as many as a workspace's computers may make (computer-scope.ts MAX_APPS_FROM_COMPUTER), * as new ones come last. The owner's Mac reads the first 30 for members (lib/hosted-apps.ts MEMBER_APPS); each one after @@ -490,14 +516,15 @@ export function appsFor(workspace: Record | null, viewer: Slice ...(typeof host.botId === "string" && botsSeen.has(host.botId) ? { botId: host.botId } : {}), ...(typeof host.url === "string" ? { url: host.url } : {}), ...(typeof host.lost === "number" ? { lost: true } : {}), - // Its production on GitHub (lib/hosted-apps.ts AppHosting.github): where Publish sends it, its repo and branch only. - ...(githubOf(host.github) ? { github: githubOf(host.github) } : {}), + // Its production on GitHub (lib/hosted-apps.ts AppHosting.github): where Publish sends it, its repo and branch; at Write the rest (githubOf). + ...(githubOf(host.github) ? { github: githubOf(host.github, write) } : {}), ...(sites.length ? { siteNames: sites } : {}), ...(got.head === null ? { head: null } : isObject(got.head) ? { head: keep(got.head, { id: true, message: true, at: true }) } : {}), ...(rows ? { sites: write ? withCloudPasswords(name, keep(rows, [SITE_SEEN_WRITE]), passwords) : keep(rows, [SITE_SEEN]) } : {}), ...(Array.isArray(got.commits) ? { commits: keep(got.commits, [COMMIT_SEEN]) } : {}), ...(Array.isArray(got.deploys) ? { deploys: keep(got.deploys, [DEPLOY_SEEN]) } : {}), - ...(Array.isArray(got.acts) ? { acts: keep(got.acts, [ACT_SEEN]) } : {}), + // The GitHub ones only to a Bops that knows them (githubSeen). + ...(Array.isArray(got.acts) ? { acts: keep(viewer.githubSeen ? got.acts : got.acts.filter((x) => !isObject(x) || !GITHUB_ACT.has(String(x.act))), [ACT_SEEN]) } : {}), ...(settings && Object.keys(settings).length ? { settings } : {}), }; return app as SharedApp; @@ -732,6 +759,9 @@ export function sharedSlice(blob: Record, ws: string, viewer: S ...(access && viewer.adminActs ? { adminActs: true as const } : access && viewer.role === "admin" ? { ownerOld: true as const } : {}), // An Admin's Publish in Your Apps, once the owner's Bops takes it (members.ts adminGate). ...(access && viewer.role === "admin" && viewer.adminPublish ? { adminPublish: true as const } : {}), + // Where an app publishes on GitHub, its Link and Bring in, at Your Apps Write, for whoever has the power of Publish (an + // Admin, a Member with App secrets), once the owner's last Mac takes them (members.ts adminGate, GITHUB_ACTS_MIN_VERSION). + ...(apps?.level === "write" && viewer.githubActs && (viewer.role === "admin" || !!can?.includes("appSecrets")) ? { appGithub: true as const } : {}), // Keys (keysOf), what the workspace's bots see of keys, and whether the owner's Bops takes their "key" act: part of // the fingerprint, so being given Keys, a key added or the owner updating gives them a new one. ...(keys ? { keys: true as const, ...(keyNames ? { keyNames } : {}), ...(viewer.keyActs ? { keyActs: true as const } : {}) } : {}), diff --git a/components/app/app-envs.tsx b/components/app/app-envs.tsx index c5ebe611a..2701af97e 100644 --- a/components/app/app-envs.tsx +++ b/components/app/app-envs.tsx @@ -7,6 +7,7 @@ import { act, deploying, isSending, OpenHostedApp, PROBLEM, PULSE, short, UrlLin import { useDismiss } from "./new-thread"; import { OpenPreview } from "./panel-tabs"; import { PasswordBeside } from "./preview-password"; +import { GithubForm, ghFormOf, ghMirrorOf, NO_GH_FORM, type GhForm } from "./github-form"; import { InlineConfirm, undash } from "./side-group"; import { post } from "./ui"; @@ -171,72 +172,8 @@ export function whyNotEnv(answer: Pick, a: return undefined; } -/** The GitHub production form as typed: its lists as one line each ("a, b", "client=cclc"). */ -type GhForm = { repo: string; branch: string; path: string; keep: string; workflow: string; inputs: string }; - -/** The form as the route takes it (lib/hosted-apps.ts cleanGithubMirror checks it again). */ -export function ghMirrorOf(f: GhForm) { - const keep = f.keep.split(",").map((x) => x.trim()).filter(Boolean); - const inputs = Object.fromEntries( - f.inputs - .split(",") - .map((x) => x.split("=").map((y) => y.trim())) - .filter((kv) => kv.length === 2 && kv[0]) - .map((kv) => [kv[0], kv[1]] as const), - ); - return { - repo: f.repo.trim(), - branch: f.branch.trim() || "main", - path: f.path.trim(), - ...(keep.length ? { keep } : {}), - ...(f.workflow.trim() ? { dispatch: { workflow: f.workflow.trim(), ...(Object.keys(inputs).length ? { inputs } : {}) } } : {}), - }; -} - -/** - * Where an app's production is on GitHub: the repo, its branch and the app's folder in it, the files that stay in - * Ingot alone, and a workflow to run after a push (for one that doesn't start on a push). - */ -function GithubForm({ form, onChange, busy, onSave, onCancel, compact }: { form: GhForm; onChange: (f: GhForm) => void; busy: boolean; onSave: () => void; onCancel: () => void; compact?: boolean }) { - const field = (key: keyof GhForm, label: string, placeholder: string) => ( - - ); - return ( -
e.stopPropagation()}> -
- Publish to GitHub - - Ingot stays where it's worked on and previewed. Publish pushes what the Preview serves into this folder, with your GitHub key in Keys, and that repo's own workflow deploys it. - -
- {field("repo", "Repo", "OrgoAI/orgo-brandlord")} - {field("branch", "Branch", "main")} - {field("path", "Folder in it", "formula (empty: the whole repo)")} -
- {field("keep", "Left alone both ways (files, or folders ending in /)", "proxy.ts, clients/other/")} -
- {field("workflow", "Workflow to run after a push (optional)", "deploy.yml")} - {field("inputs", "Its inputs", "client=cclc")} -
-
- - -
-
-
- ); -} +/* Where its production is on GitHub, as a form: components/app/github-form.tsx (a shared workspace's Your Apps has it too). */ +export { ghMirrorOf } from "./github-form"; /** One line: its environment, state, where it is and what it serves; a Local's of a bot's copy (`bot`), with whose it is (`who`). */ type Line = { env: AppEnv; state: EnvState; where: ReactNode; at: string | null; bot?: string; who?: string }; @@ -508,7 +445,7 @@ export function AppEnvs({ Bring in GitHub's changes )} - + {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/github-form.tsx b/components/app/github-form.tsx new file mode 100644 index 000000000..479dcbf0d --- /dev/null +++ b/components/app/github-form.tsx @@ -0,0 +1,126 @@ +"use client"; + +import type { GithubSetting } from "@/cloud/protocol"; + +/* + * Where an app's production is on GitHub, as a form: the owner's own (components/app/app-envs.tsx, Publish's menu) and an + * Admin's or a Member's with App secrets in a shared workspace's Your Apps (components/app/shared-apps.tsx GithubPart). + * The same fields and the same cleaning for both: lib/hosted-apps.ts cleanGithubMirror (cloud/github-config.ts) checks + * what it sends again, on the owner's Mac and in Bops Cloud. Imports nothing of either page, so both take it. + */ + +/** The GitHub production form as typed: its lists as one line each ("a, b", "client=cclc"). */ +export type GhForm = { repo: string; branch: string; path: string; keep: string; workflow: string; inputs: string }; + +/** An empty form, for an app with none yet. */ +export const NO_GH_FORM: GhForm = { repo: "", branch: "main", path: "", keep: "", workflow: "", inputs: "" }; + +/** The form as the route takes it (lib/hosted-apps.ts cleanGithubMirror checks it again). */ +export function ghMirrorOf(f: GhForm): GithubSetting { + const keep = f.keep.split(",").map((x) => x.trim()).filter(Boolean); + const inputs = Object.fromEntries( + f.inputs + .split(",") + .map((x) => x.split("=").map((y) => y.trim())) + .filter((kv) => kv.length === 2 && kv[0]) + .map((kv) => [kv[0], kv[1]] as const), + ); + return { + repo: f.repo.trim(), + branch: f.branch.trim() || "main", + path: f.path.trim(), + ...(keep.length ? { keep } : {}), + ...(f.workflow.trim() ? { dispatch: { workflow: f.workflow.trim(), ...(Object.keys(inputs).length ? { inputs } : {}) } } : {}), + }; +} + +/** A setting as the form shows it, to change it (ghMirrorOf's other way). */ +export const ghFormOf = (g: Partial | null | undefined): GhForm => + g + ? { + repo: g.repo ?? "", + branch: g.branch ?? "main", + path: g.path ?? "", + keep: (g.keep ?? []).join(", "), + workflow: g.dispatch?.workflow ?? "", + inputs: Object.entries(g.dispatch?.inputs ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join(", "), + } + : { ...NO_GH_FORM }; + +/** What the form says: its title, the line under it, and its button (the owner's own, unless a page says otherwise). */ +export type GhFormWords = { title: string; blurb: string; save: string; saving: string }; +export const OWNER_GH_WORDS: GhFormWords = { + title: "Publish to GitHub", + blurb: "Ingot stays where it's worked on and previewed. Publish pushes what the Preview serves into this folder, with your GitHub key in Keys, and that repo's own workflow deploys it.", + save: "Save", + saving: "Saving…", +}; + +const DARK = "whitespace-nowrap rounded-full bg-ink px-2.5 py-1 text-[12px] font-semibold leading-4 text-white disabled:bg-[#E6E6E3] disabled:text-[#9A9A98]"; + +/** + * Where an app's production is on GitHub: the repo, its branch and the app's folder in it, the files that stay in + * Ingot alone, and a workflow to run after a push (for one that doesn't start on a push). + */ +export function GithubForm({ + form, + onChange, + busy, + onSave, + onCancel, + compact, + words = OWNER_GH_WORDS, +}: { + form: GhForm; + onChange: (f: GhForm) => void; + busy: boolean; + onSave: () => void; + onCancel: () => void; + compact?: boolean; + words?: GhFormWords; +}) { + const field = (key: keyof GhForm, label: string, placeholder: string) => ( + + ); + const why = form.repo.trim() ? undefined : "Which repo?"; + return ( +
e.stopPropagation()}> +
+ {words.title} + {words.blurb} +
+ {field("repo", "Repo", "OrgoAI/orgo-brandlord")} + {field("branch", "Branch", "main")} + {field("path", "Folder in it", "formula (empty: the whole repo)")} +
+ {field("keep", "Left alone both ways (files, or folders ending in /)", "proxy.ts, clients/other/")} +
+ {field("workflow", "Workflow to run after a push (optional)", "deploy.yml")} + {field("inputs", "Its inputs", "client=cclc")} +
+
+ {/* (Greyed with why in its tooltip, on what holds it: a disabled button gets no pointer events in some browsers.) */} + e.stopPropagation()} className="flex shrink-0"> + + + +
+
+
+ ); +} 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..2074b0ff6 100644 --- a/components/app/shared-apps.tsx +++ b/components/app/shared-apps.tsx @@ -5,9 +5,11 @@ import type { MemberAppAct, SharedApp, SharedApps } from "@/cloud/protocol"; import { DOMAIN_WORDS } from "@/lib/hosted-apps"; import { REPLY_WAITS_LINE } from "@/lib/shared"; import type { AppState } from "@/lib/types"; +import { GithubForm, ghFormOf, ghMirrorOf, NO_GH_FORM, type GhForm } from "./github-form"; 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, @@ -16,8 +18,10 @@ import { ago } from "./ui"; * history, and domains; Write also sees a Preview's password, deploys, goes back to an earlier Preview deploy, changes * a site's settings and uses one of the workspace's own domains, each sent to the owner's Bops (memberAct), which does * it and keeps it on the app under their name. An Admin, once the owner's Bops takes it (SharedOpen.adminPublish), also - * publishes (what its Preview serves goes on Published) and goes back on Published. Never its variables, logs or a domain - * to buy: the owner's. + * publishes (what its Preview serves goes on Published) and goes back on Published. An Admin, or a Member with App + * secrets, once the owner's Bops takes it (SharedOpen.appGithub), also sets where an app publishes on GitHub, links it, + * and brings GitHub's changes in (GithubPart): Bops Cloud keeps the setting, and the app's own computer does the rest, + * with the owner's Mac on or off. Never its variables, logs or a domain to buy: the owner's. */ /** Every word the shared page says (the tests read them for dashes and the old name). */ @@ -56,9 +60,65 @@ export const SHARED_APPS_WORDS = { /** Under an Admin's Publish: its domains aren't pointed at Orgo yet, so a published change isn't what their visitors see. */ publishWaiting: (domains: string, many = false) => `Visitors to ${domains} won't see a publish until ${many ? "their" : "its"} DNS points at Orgo.`, acted: (who: string, a: Pick & { domain?: string; env?: "prod" }) => - a.act === "deploy" ? (a.env === "prod" ? `${who} published it` : `${who} deployed it`) : a.act === "rollback" ? `${who} went back to an earlier deploy` : a.act === "settings" ? `${who} changed ${a.site ? `${a.site}'s` : "its"} settings` : `${who} pointed ${a.domain ?? "a domain"} at it`, + a.act === "deploy" + ? a.env === "prod" + ? `${who} published it` + : `${who} deployed it` + : a.act === "rollback" + ? `${who} went back to an earlier deploy` + : a.act === "settings" + ? `${who} changed ${a.site ? `${a.site}'s` : "its"} settings` + : a.act === "github" + ? `${who} changed where it publishes` + : a.act === "githubLink" + ? `${who} linked it to GitHub` + : a.act === "githubPull" + ? `${who} brought in GitHub's changes` + : `${who} pointed ${a.domain ?? "a domain"} at it`, tried: (who: string, act: MemberAppAct["act"] | "publish", said: string) => - `${who} tried to ${act === "publish" ? "publish it" : act === "deploy" ? "deploy it" : act === "rollback" ? "go back to an earlier deploy" : act === "settings" ? "change its settings" : "use a domain"}: ${said}`, + `${who} tried to ${ + act === "publish" + ? "publish it" + : act === "deploy" + ? "deploy it" + : act === "rollback" + ? "go back to an earlier deploy" + : act === "settings" + ? "change its settings" + : act === "github" + ? "change where it publishes" + : act === "githubLink" + ? "link it to GitHub" + : act === "githubPull" + ? "bring in GitHub's changes" + : "use a domain" + }: ${said}`, + /** A Link or Bring in its computer is doing now (SharedApp.acts `going`). */ + asked: (who: string, act: MemberAppAct["act"]) => (act === "githubLink" ? `${who} asked its computer to link it to GitHub…` : `${who} asked its computer to bring in GitHub's changes…`), + /** Its GitHub production (GithubPart), at Write, for whoever may change it (SharedOpen.appGithub). */ + github: "GitHub", + githubWhere: (g: { repo: string; branch: string; path?: string }) => `Publishes to GitHub ${g.repo}, ${g.branch}, ${g.path ? `folder ${g.path}` : "the whole repo"}.`, + githubLinked: (sha: string, when: string) => `Last the same as GitHub at ${sha} (${when}).`, + githubNotLinked: "It isn't linked yet. Link it before you publish.", + githubOnOrgo: "It publishes on Orgo.", + githubStart: "Publish to GitHub instead", + githubChange: "Change where it publishes", + githubLink: "Link to GitHub", + githubPull: "Bring in GitHub's changes", + githubPullAsk: "GitHub's version wins where both changed a file. Bring them in?", + githubPullYes: "Bring them in", + githubOrgo: "Publish on Orgo instead", + cancel: "Cancel", + githubOneFolder: "It runs more than one site, and one GitHub folder is one app, so it publishes on Orgo.", + githubKeyNote: "A new repo, branch, folder or workflow needs a GitHub key of this workspace's own in Keys.", + linkFirst: "Link it to GitHub first.", +}; +/** The GitHub form's words here (github-form.tsx GithubForm): Save links it too. */ +export const SHARED_GITHUB_FORM_WORDS = { + title: "Publish to GitHub", + blurb: "Ingot stays where it's worked on and previewed. Publish pushes what the Preview serves into this folder, and that repo's own workflow deploys it. Its computer links it as you save.", + save: "Save and link", + saving: "Saving…", }; const W = SHARED_APPS_WORDS; @@ -73,10 +133,13 @@ export function asOf(at: number, now = Date.now()) { return new Date(now).toDateString() === d.toDateString() ? time : `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${time}`; } -/** What a member's act on an app says, done or not, with when (the owner's rows and the member's own). */ +/** What a member's act on an app says, done or not (or still being done on its computer), with when (the owner's rows and the member's own). */ export function actLine(a: NonNullable[number]) { + if (a.going) return `${W.asked(a.name, a.act)} · ${asOf(a.at)}`; return `${a.ok ? W.acted(a.name, a) : W.tried(a.name, a.act === "deploy" && a.env === "prod" ? "publish" : a.act, a.said ?? W.failed)} · ${asOf(a.at)}`; } +/** What a GitHub act that went says of itself (how many changes came in, files that go up with the next Publish), under its line. */ +const isGithubKind = (act: string) => act === "github" || act === "githubLink" || act === "githubPull"; const BUTTON = "rounded-full px-3 py-1 text-[12px] font-medium leading-4 disabled:opacity-50"; @@ -87,6 +150,8 @@ export function SharedAppsPage({ state }: { state: AppState }) { const write = apps.level === "write"; // An Admin whose owner's Bops publishes for them (Bops Cloud says so only then: ADMIN_PUBLISH_MIN_VERSION). const publish = write && state.shared?.role === "admin" && state.shared.adminPublish === true; + // Where apps publish on GitHub, their Link and Bring in: an Admin's, or a Member's with App secrets (GITHUB_ACTS_MIN_VERSION). + const github = write && state.shared?.appGithub === true; return (
@@ -99,7 +164,7 @@ export function SharedAppsPage({ state }: { state: AppState }) { ) : (
{apps.list.map((a) => ( - x.act.app === a.name)} /> + x.act.app === a.name)} /> ))}
)} @@ -111,7 +176,7 @@ export function SharedAppsPage({ state }: { state: AppState }) { type Pending = NonNullable["appActs"]>[number]; /** One app: its state, address and Deploy (and an Admin's Publish); its sites, history, deploys, settings and domains open below. */ -function SharedAppCard({ app: a, apps, acts, publish }: { app: SharedApp; apps: SharedApps; acts: Pending[]; publish: boolean }) { +function SharedAppCard({ app: a, apps, acts, publish, github = false }: { app: SharedApp; apps: SharedApps; acts: Pending[]; publish: boolean; github?: boolean }) { const [open, setOpen] = useState(false); const [said, setSaid] = useState(null); const write = apps.level === "write"; @@ -128,6 +193,10 @@ function SharedAppCard({ app: a, apps, acts, publish }: { app: SharedApp; apps: if (!r.ok) setSaid(r.error ?? W.failed); }; const newest = a.acts?.[0]; + // A Link or Bring in its computer is doing now: nothing else on the app until it's said how it went. + const busy = !!going || !!a.acts?.some((x) => x.going); + // Production on GitHub not linked yet (a Bops Cloud that says so: its folder is there at Write): Publish would be refused. + const unlinked = !!a.github && a.github.path !== undefined && !a.github.linked; // Where an Admin's Publish goes, said under it: GitHub, or Published on domains that don't point at Orgo yet. const waitingOn = site?.domains?.length && site.domains.every((d) => d.status !== "active") && !site.url ? site.domains.map((d) => d.domain) : []; const where = !publish ? null : a.github ? W.publishGithub(a.github) : waitingOn.length ? W.publishWaiting(waitingOn.join(" and "), waitingOn.length > 1) : null; @@ -148,15 +217,17 @@ function SharedAppCard({ app: a, apps, acts, publish }: { app: SharedApp; apps: {/* An Admin's Publish, as the owner's: what its Preview serves goes on Published (the changeset seen here, for an app of one site). */} {publish && ( - + + + )} ) : ( @@ -187,7 +258,12 @@ function SharedAppCard({ app: a, apps, acts, publish }: { app: SharedApp; apps: {said} )} - {newest && {actLine(newest)}} + {newest && {actLine(newest)}} + {newest?.ok && !newest.going && newest.said && isGithubKind(newest.act) && ( + + {newest.said} + + )}
)} {!write && open && {W.readOnly}} @@ -246,6 +322,7 @@ function SharedAppCard({ app: a, apps, acts, publish }: { app: SharedApp; apps: ))} )} + {write && github && void send(act)} />} {write && a.settings && void send(act)} />} {write && !!apps.owned?.length && void send(act)} />}
@@ -280,6 +357,82 @@ function Part({ title, children }: { title: string; children: React.ReactNode }) ); } +/** + * Its production on GitHub, for an Admin or a Member with App secrets (SharedOpen.appGithub): where it publishes and where + * it was last the same as GitHub; Bring in GitHub's changes (asked first: GitHub's version wins where both changed a file) + * and Link, which its own computer does; and where it publishes, set or changed (Save and link) or back on Orgo, which + * Bops Cloud keeps (a new repo, branch, folder or workflow only with a GitHub key of the workspace's own). One folder is + * one app: an app of more sites stays on Orgo. + */ +function GithubPart({ app: a, busy, onSend }: { app: SharedApp; busy: boolean; onSend: (act: MemberAppAct) => void }) { + const [form, setForm] = useState(null); + const [asking, setAsking] = useState(false); + const g = a.github; + const btn = `${BUTTON} shadow-[0_0_0_1px_#E2E2DF]`; + if (a.siteNames?.length) + return ( + + {W.githubOneFolder} + + ); + return ( + +
+ {g ? W.githubWhere(g) : W.githubOnOrgo} + {g && {g.linked ? W.githubLinked(shortId(g.linked.sha), asOf(g.linked.when)) : W.githubNotLinked}} + {!form && + (asking ? ( + + {W.githubPullAsk} + + + + ) : ( + + {g?.linked && ( + + )} + {g && !g.linked && ( + + )} + + + ))} + {form && ( + <> + (setForm(null), onSend({ act: "github", app: a.name, github: ghMirrorOf(form), link: true }))} + onCancel={() => setForm(null)} + /> + + {W.githubKeyNote} + {g && ( + + )} + + + )} +
+
+ ); +} + const FIELD = "h-8 min-w-0 rounded-[10px] bg-[#F7F7F6] px-2.5 text-[12.5px] outline-none shadow-[0_0_0_1px_#E6E6E3] focus:shadow-[0_0_0_1.5px_#0A0A0A]"; /** A site's settings (port and the four commands), saved by the owner's Bops. Never its variables. */ @@ -317,6 +470,7 @@ function SettingsPart({ app: a, disabled, onSave }: { app: SharedApp; disabled: {W.noVariables}
+ ); } diff --git a/docs/cloud-runner.md b/docs/cloud-runner.md index ee633b81d..5778041b8 100644 --- a/docs/cloud-runner.md +++ b/docs/cloud-runner.md @@ -531,6 +531,17 @@ waits for the owner's Mac (routines, watches, skills, new bots, pages, pictures, teammate) and is told so. A runner from before this loop never hears a turn: its computer is woken, and the turn ends with "didn't wake up in time" until the owner's Mac has put the new runner on it. +**GitHub jobs on the runner.** An Admin, or a Member with App secrets, links an app to GitHub or brings +GitHub's changes in from a shared workspace's Your Apps with every Mac closed (`cloud/members.ts` githubAct). +Bops Cloud checks who asked, writes where the app publishes itself (a new repo, branch, folder or workflow +only while the workspace has a GitHub key of its own in Keys), and owes the Link or Bring in to the app's own +computer as a turn that carries the job (`cloud/turns.ts` takeAppJob, `TurnMember.job`), in any workspace, +whichever answers its chat. The runner does it (`runner-chat.ts` runJob, `hosted-apps.ts` githubJob) with its +Ingot token and its own sign in for the GitHub key, says it in the bot's chat as Bops' own, and its result is +kept on the app. It goes to the computer only when its runner is at `GITHUB_ACTS_MIN_VERSION` and a bot on it +was given GITHUB_TOKEN in Keys; otherwise the owner's Mac does it when it opens, or, when only the key is +missing and that Mac is off, the person is told to give the bot the key. + **The owner's Mac must open a Bops with this change once.** Runners are set up and upgraded only by an owner's Mac (this is a Mac loop, and the runner ships inside the Mac app), so no computer of a shared workspace gets its runner, and no runner gets a newer build, until the owner opens the updated Bops on one diff --git a/docs/releases/v0.0.52.md b/docs/releases/v0.0.52.md new file mode 100644 index 000000000..2f1b3a16c --- /dev/null +++ b/docs/releases/v0.0.52.md @@ -0,0 +1,3 @@ +- Every app can use a key from Keys, with no copying. +- Admins can link an app to GitHub from Your Apps. +- Admins can bring in GitHub's changes, with the owner's Mac off. diff --git a/lib/hosted-apps.ts b/lib/hosted-apps.ts index af0da231c..6a004bc14 100644 --- a/lib/hosted-apps.ts +++ b/lib/hosted-apps.ts @@ -12,6 +12,7 @@ * Imported by the window and the Mac server. */ +import type { GithubMirror } from "@/cloud/github-config"; import { siteOf, workBot, workspaceOf, type Bot, type BotSite } from "./types"; export type DeployState = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "superseded"; @@ -86,49 +87,11 @@ export type AppHosting = { }; /** - * Where an app's production lives on GitHub (AppHosting.github). `path`: the folder of the repo the app's files are - * ("" the whole repo). `keep`: paths left alone either way (a path, or a folder ending in /): never sent to GitHub, - * never taken from it (a preview's own password file; another app's client in a shared folder). `dispatch`: a workflow - * to run after a push, for one that doesn't start on a push. `last`: where the two were last the same (Link, Publish, - * or Bring in): `sha`, the GitHub commit; `ingot`, the Ingot changeset that matches it, which a preview must be at - * or after to publish (lib/server/github-mirror.ts). - */ -export type GithubMirror = { - repo: string; - branch: string; - path: string; - keep?: string[]; - dispatch?: { workflow: string; inputs?: Record }; - /** (`keep`: the paths left alone when it was made: one taken off the list since needs a new Link.) */ - last?: { sha: string; ingot: string; keep?: string[]; when: number }; -}; - -const GH_REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; -const GH_BRANCH = /^(?!.*\.\.)(?!\/)(?!.*\/$)[A-Za-z0-9._/-]{1,200}$/; -const GH_PATH = /^(?!.*\.\.)(?!\/)[A-Za-z0-9._/@ -]{0,300}$/; - -/** A GitHub mirror as the window sends it, cleaned, or why it won't do. `last` is the server's own (never taken from here). */ -export function cleanGithubMirror(v: unknown): { ok: Omit } | { why: string } { - const m = v as Partial | null; - const repo = typeof m?.repo === "string" ? m.repo.trim().replace(/^https:\/\/github\.com\//, "").replace(/\.git$/, "") : ""; - if (!GH_REPO.test(repo)) return { why: "Which GitHub repo? Like OrgoAI/orgo-brandlord." }; - const branch = typeof m?.branch === "string" && m.branch.trim() ? m.branch.trim() : "main"; - if (!GH_BRANCH.test(branch)) return { why: "That isn't a branch name." }; - const path = (typeof m?.path === "string" ? m.path.trim() : "").replace(/^\/+|\/+$/g, ""); - if (!GH_PATH.test(path)) return { why: "That isn't a folder in the repo." }; - const typed = Array.isArray(m?.keep) ? m.keep.filter((k): k is string => typeof k === "string").map((k) => k.trim().replace(/^\/+/, "")).filter(Boolean) : []; - const bad = typed.find((k) => !GH_PATH.test(k)); - if (bad) return { why: `"${bad.slice(0, 80)}" can't be a path left alone (letters, digits, . _ - @ / and spaces; a folder ends in /).` }; - const keep = [...new Set(typed)].slice(0, 50); - const w = m?.dispatch; - const workflow = typeof w?.workflow === "string" ? w.workflow.trim() : ""; - if (workflow && !/^[A-Za-z0-9_.-]{1,100}\.ya?ml$/.test(workflow)) return { why: "That isn't a workflow file, like deploy.yml." }; - const inputs = - w?.inputs && typeof w.inputs === "object" - ? Object.fromEntries(Object.entries(w.inputs).filter(([k, x]) => /^[A-Za-z0-9_-]{1,50}$/.test(k) && typeof x === "string" && x.length <= 200).slice(0, 10) as [string, string][]) - : undefined; - return { ok: { repo, branch, path, ...(keep.length ? { keep } : {}), ...(workflow ? { dispatch: { workflow, ...(inputs && Object.keys(inputs).length ? { inputs } : {}) } } : {}) } }; -} + * Where an app's production lives on GitHub (AppHosting.github), and the one check of what the window sends for it: in + * cloud/github-config.ts, which Bops Cloud checks an Admin's setting with too (cloud/members.ts githubHere). + */ +export type { GithubMirror } from "@/cloud/github-config"; +export { cleanGithubMirror, keepsPoint } from "@/cloud/github-config"; /** A site's deploy project left on a computer the app moved off (AppHosting.behind). */ export type AppProjectBehind = { site: string; computerId: string; projectId: string }; @@ -595,7 +558,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 }; @@ -829,8 +796,13 @@ export type AppForMembers = { acts?: MemberAppAct[]; }; -/** A member's act on an app, as the owner's Mac did it or refused it (lib/server/member-acts.ts). `env` "prod": an Admin's Publish or Roll back on Published. */ -export type MemberAppAct = { key: string; userId: string; name: string; act: "deploy" | "rollback" | "settings" | "domain"; site?: string; domain?: string; env?: "prod"; ok: boolean; said?: string; at: number }; +/** + * A member's act on an app, as the owner's Mac did it or refused it (lib/server/member-acts.ts). `env` "prod": an Admin's + * Publish or Roll back on Published. The GitHub ones (cloud/protocol.ts MemberGithubAct) are kept by Bops Cloud too: where + * it publishes ("github"), and a Link or Bring in while the app's computer does it (`going`), then how it went + * (cloud/turns.ts takeAppJob, finish). + */ +export type MemberAppAct = { key: string; userId: string; name: string; act: "deploy" | "rollback" | "settings" | "domain" | "github" | "githubLink" | "githubPull"; site?: string; domain?: string; env?: "prod"; ok: boolean; going?: true; said?: string; at: number }; /** How much members are shown: apps, sites of each, changesets, deploys a site, and acts kept. */ export const MEMBER_APPS = 30; 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/apps-for-members.ts b/lib/server/apps-for-members.ts index 376f41a25..0d0cef64f 100644 --- a/lib/server/apps-for-members.ts +++ b/lib/server/apps-for-members.ts @@ -290,5 +290,10 @@ export function noteMemberAct(wsId: string, app: string, act: MemberAppAct) { }); } -/** Whether a member's act with this key is kept already (done or refused): the same act again is done once. */ -export const actKept = (wsId: string, app: string, key: string) => !!getState().workspaces?.find((w) => w.id === wsId)?.appsForMembers?.apps[app]?.acts?.some((x) => x.key === key); +/** + * Whether a member's act with this key is kept already (done or refused): the same act again is done once. With `act`, only + * that act's, done or refused: a GitHub setting Bops Cloud kept under the key of a "Save and link" leaves its Link to do, + * and so does one it marked going on a computer that couldn't take it. + */ +export const actKept = (wsId: string, app: string, key: string, act?: MemberAppAct["act"]) => + !!getState().workspaces?.find((w) => w.id === wsId)?.appsForMembers?.apps[app]?.acts?.some((x) => x.key === key && (!act || (x.act === act && !x.going))); diff --git a/lib/server/github-mirror.ts b/lib/server/github-mirror.ts index b5fe38ebd..102da3fe1 100644 --- a/lib/server/github-mirror.ts +++ b/lib/server/github-mirror.ts @@ -26,7 +26,8 @@ import { userDir } from "./store"; * Never touched: anything outside the folder (a repo's secrets and workflows), the app's `keep` paths (either way), * GitHub's symbolic links and submodules (Ingot has none), .ingotignore. The push is one commit on top of the branch, * never forced. A folder gone from GitHub (FOLDER_GONE), or most of it deleted on either side (MASS_DELETE), stops it. - * Link (linkMirror) sets the point, only when both sides have the same files. + * Link (linkMirror) sets the point, only when GitHub has no file Ingot lacks or has otherwise: files only Ingot has + * (a new client's own folder in a shared repo) are Ingot's changes, and go up with the first Publish. * * Every GitHub call goes through Orgo's broker with the owner's GITHUB_TOKEN (keys.ts callKey): the token is in no * command, file, log or message. The broker takes 1 MB a request (a file up to BLOB_MAX goes up), answers 512 KB (a @@ -296,24 +297,43 @@ async function atOrAfter(wsId: string, name: string, at: string, mark: string): /* ---------------- Link, Publish, Bring in ---------------- */ /** - * Link an app to its GitHub folder: Ingot's newest changeset and the branch's newest commit, when they have the same - * files (but `keep` paths and GitHub's links). Else LINK_DIFFERS, with the paths that differ: make them the same (or - * keep them), then link. A folder not on GitHub yet links to an empty one: the first Publish puts it all there. + * Link an app to its GitHub folder: Ingot's newest changeset and the branch's newest commit, when GitHub has no file + * (but `keep` paths and GitHub's links) that Ingot lacks or has otherwise. Else LINK_DIFFERS, with those paths: make them + * the same (or keep them), then link. Files only Ingot has are Ingot's changes from the point on: the first Publish puts + * them there (`ingotOnly`), as it puts all of a folder not on GitHub yet, which links to an empty one. */ export async function linkMirror(wsId: string, name: string, cfg: GithubMirror): Promise> { + return (await linkAndSay(wsId, name, cfg)).last; +} + +/** Link (linkMirror), and what it found in words: where, and the files only Ingot has, which go up with the next Publish. */ +export async function linkAndSay(wsId: string, name: string, cfg: GithubMirror): Promise<{ last: NonNullable; ingotOnly: string[]; said: string }> { const head = await ingot.head(wsId, name); if (!head) throw new MirrorError(head === undefined ? `Ingot has no repo ${name}.` : `${name} has nothing committed yet.`, 409); const gh = github(wsId); const sha = await ghHead(gh, cfg.repo, cfg.branch); const theirs = await ghSide(gh, cfg.repo, sha, cfg.path); + let ingotOnly: string[] = []; if (theirs.present) { const skip = (p: string) => keptPath(p, cfg.keep) || theirs.links.has(p); const ours = await ingotSide(wsId, name, head, skip); - const differ = [...changes(new Map([...theirs.files].filter(([p]) => !skip(p))), ours.files).keys()].sort(); + const base = new Map([...theirs.files].filter(([p]) => !skip(p))); + const d = changes(base, ours.files); + // Only Ingot has it: Ingot's change, for the first Publish. GitHub has it (alone, or otherwise): it stops. + ingotOnly = [...d.keys()].filter((p) => !base.has(p)).sort(); + const differ = [...d.keys()].filter((p) => base.has(p)).sort(); if (differ.length) throw new MirrorError(`Ingot and GitHub differ in ${list(differ)}. Make them the same first (or add them to the files left alone), then link.`, 409, { code: "LINK_DIFFERS", paths: differ.slice(0, 50), count: differ.length }); } - return { sha, ingot: head, keep: cfg.keep ?? [], when: Date.now() }; + const where = `${cfg.repo} ${cfg.branch}${cfg.path ? `, folder ${cfg.path}` : ""}`; + const said = [ + `Linked ${name} to GitHub ${where} at ${short(sha)}.`, + ingotOnly.length ? `${ingotOnly.length === 1 ? "1 file only Ingot has" : `${ingotOnly.length} files only Ingot has`} (${list(ingotOnly)}) ${ingotOnly.length === 1 ? "goes" : "go"} to GitHub with the next Publish.` : "", + !theirs.present ? `GitHub has no ${cfg.path ? `${cfg.path}/ ` : ""}folder yet, so the next Publish puts all of it there.` : "", + ] + .filter(Boolean) + .join(" "); + return { last: { sha, ingot: head, keep: cfg.keep ?? [], when: Date.now() }, ingotOnly, said }; } export type MirrorDone = { diff --git a/lib/server/hosted-apps.ts b/lib/server/hosted-apps.ts index 70953244e..df24b5383 100644 --- a/lib/server/hosted-apps.ts +++ b/lib/server/hosted-apps.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { appsOpen } from "@/lib/apps-access"; import { botChatId, live as isLive, routineSent, siteBotOf, siteOf, workBot, workspaceOf, type Session, type Workspace } from "@/lib/types"; -import { appCoders, appEnvOf, copyBotOf, DEV_PORT, DNS_TYPES, dnsNeeds, ENV_NAME, ENV_RESERVED, isCopyName, isSiteName, localAddress, localsOf, localWords, LOG_LINE, LOG_LINES, MAX_SITES, offersInOrder, PASS_COOKIE, REGISTRANT_FIELDS, subdomainTyped, cleanGithubMirror} from "@/lib/hosted-apps"; +import { appCoders, appEnvOf, copyBotOf, DEV_PORT, DNS_TYPES, dnsNeeds, ENV_NAME, ENV_RESERVED, isCopyName, isSiteName, localAddress, localsOf, localWords, LOG_LINE, LOG_LINES, MAX_SITES, offersInOrder, PASS_COOKIE, REGISTRANT_FIELDS, subdomainTyped, cleanGithubMirror, keepsPoint } from "@/lib/hosted-apps"; import { screenMap } from "@/lib/screens"; import type { AddAppAnswer, @@ -22,6 +22,7 @@ import type { AppDomainRemoved, AppDomainsAnswer, AppHosting, + AppKeyed, AppProjectBehind, AppRenewal, AppRestoreConfirm, @@ -91,7 +92,7 @@ import { import { onRunner, selfHosted } from "./persist"; import { hostingChangeAllowed, hostingScope, type Blob } from "@/cloud/computer-scope"; import { isLabelOf, labelGiven, newLabel } from "./app-labels"; -import { GITHUB_KEY, linkMirror, MirrorError, mirrorPublish, mirrorPull } from "./github-mirror"; +import { GITHUB_KEY, linkAndSay, MirrorError, mirrorPublish, mirrorPull } from "./github-mirror"; import { appFolder, authorOf, byBots, changesTo, dropToken, ensureIngotCli, folderChanges, hasToken, ingot, INGOT_BIN, INGOT_MCP, IngotError, ingotWorkspace, isRepoName, msOf, NO_PROJECT, readyFolder, repoNameFor, stillOurs, SWAPPED, type IngotChange, type IngotChangeset, type IngotRepo } from "./ingot"; import { addMessage, bot, flushState, getState, ownerName, sameState, session, update } from "./store"; import { tailnetUp } from "./tailnet"; @@ -2191,47 +2192,89 @@ export async function setGithubMirror(wsId: string, name: string, v: unknown): P if (!x) return x; const was = x.github; // The same folder, with every path it left alone still left alone (more is fine): its point holds. Else Link again. - const keeps = (was?.keep ?? []).every((k) => (c.ok.keep ?? []).includes(k)); - const same = was && was.repo === c.ok.repo && was.branch === c.ok.branch && was.path === c.ok.path && keeps; - return { ...x, github: { ...c.ok, ...(same && was?.last ? { last: was.last } : {}) } }; + // (The rule Bops Cloud keeps an Admin's setting by too: cloud/github-config.ts keepsPoint.) + return { ...x, github: { ...c.ok, ...(keepsPoint(was, c.ok) && was?.last ? { last: was.last } : {}) } }; }); } return { app: await appNow(wsId, name, ours) }; }); } -/** Link an app to its GitHub folder as both are now (github-mirror.ts linkMirror: only when they have the same files). */ -export async function linkGithubMirror(wsId: string, name: string): Promise<{ app: HostedApp }> { +/** + * Link, or Bring in GitHub's changes, of an app (under withApp: nothing else under way on it), one way for everyone who + * may: the owner's own window (linkGithubMirror, pullGithubMirror), the owner's Mac for an Admin or a Member with App + * secrets (lib/server/member-acts.ts), and the app's own computer for one, the owner's Mac on or off (githubJob). Link + * (github-mirror.ts linkAndSay): GitHub has no file Ingot lacks or has otherwise; Bring in (mirrorPull): GitHub's version + * wins where both changed a file, Ingot's commit by `author`. Its point (github.last) is kept only while the setting it ran + * with is still the app's (sameMirror), the one change a computer's runner may make to it (cloud/computer-scope.ts). + */ +async function githubNow(wsId: string, name: string, act: "githubLink" | "githubPull", author: string): Promise { + const cfg = hostingOf(wsId, name)?.github; + if (!cfg) throw new HostedAppError("Set where it lives on GitHub first.", 409, { code: "NOT_SET" }); + const done = await (act === "githubLink" ? linkAndSay(wsId, name, cfg) : mirrorPull(wsId, name, cfg, author)).catch((e) => { + throw asAppError(e); + }); + keepHosting(wsId, name, (x) => (x?.github && sameMirror(x, cfg) ? { ...x, github: { ...x.github, last: done.last } } : x)); + return done.said; +} + +/** Ingot's author for a person's GitHub act done by this Mac (X-Ingot-Author: " via "); a computer's are its token's. */ +export const personAuthor = (name: string) => `bops; ${name.replace(/[\u0000-\u001f\u007f;]+/g, " ").trim().slice(0, 80) || "Bops"}`; + +/** Link an app to its GitHub folder as both are now (github-mirror.ts linkAndSay: GitHub has nothing Ingot lacks or has otherwise). */ +export async function linkGithubMirror(wsId: string, name: string): Promise<{ app: HostedApp; said: string }> { const h = isRepoName(name) ? hostingOf(wsId, name) : undefined; if (!h) throw new HostedAppError(NO_APP, 404); const ours = sameState(); return withApp(wsId, name, "deploy", async () => { - const cfg = hostingOf(wsId, name)?.github; - if (!cfg) throw new HostedAppError("Set where it lives on GitHub first.", 409); - const last = await linkMirror(wsId, name, cfg).catch((e) => { - throw asAppError(e); - }); - keepHosting(wsId, name, (x) => (x?.github && sameMirror(x, cfg) ? { ...x, github: { ...x.github, last } } : x)); - return { app: await appNow(wsId, name, ours) }; + const said = await githubNow(wsId, name, "githubLink", authorOf(undefined)); + return { app: await appNow(wsId, name, ours), said }; }); } -/** Bring in GitHub's changes (github-mirror.ts mirrorPull), GitHub's version winning where both changed a file: the owner's word, from the Bops window. */ -export async function pullGithubMirror(wsId: string, name: string): Promise<{ app: HostedApp; said: string }> { +/** Bring in GitHub's changes (github-mirror.ts mirrorPull), GitHub's version winning where both changed a file: the owner's word, from the Bops window; or a member's, as `author`. */ +export async function pullGithubMirror(wsId: string, name: string, author = authorOf(undefined)): Promise<{ app: HostedApp; said: string }> { const h = isRepoName(name) ? hostingOf(wsId, name) : undefined; if (!h) throw new HostedAppError(NO_APP, 404); const ours = sameState(); return withApp(wsId, name, "deploy", async () => { - const cfg = hostingOf(wsId, name)?.github; - if (!cfg) throw new HostedAppError("Set where it lives on GitHub first.", 409); - const done = await mirrorPull(wsId, name, cfg, authorOf(undefined)).catch((e) => { - throw asAppError(e); - }); - keepHosting(wsId, name, (x) => (x?.github && sameMirror(x, cfg) ? { ...x, github: { ...x.github, last: done.last } } : x)); - return { app: await appNow(wsId, name, ours), said: done.said }; + const said = await githubNow(wsId, name, "githubPull", author); + return { app: await appNow(wsId, name, ours), said }; }); } +/** Whether a bot of workspace `wsId` on this computer was given the GitHub key (Bot.keys): the only way Orgo's broker lets it call GitHub. */ +const githubKeyHere = (wsId: string) => { + const here = (process.env.BOPS_RUNNER_COMPUTER ?? "").toLowerCase(); + return getState().bots.some((b) => workspaceOf(b) === wsId && b.runsOn !== "mac" && (workBot(b, getState().bots).computerId ?? "").toLowerCase() === here && !!b.keys?.includes(GITHUB_KEY)); +}; + +/** + * An app's GitHub job (cloud/turns.ts takeAppJob: Link, or Bring in GitHub's changes), asked by `by` from a shared + * workspace's Your Apps and checked by Bops Cloud, done on the app's own computer by its runner (lib/server/runner-chat.ts), + * with the owner's Mac on or off: Ingot with this computer's token, GitHub with its own sign in through Orgo's broker + * (the key never here). Only an app whose home is this computer (onThisComputer), with a bot of the workspace on it given + * the GitHub key (else said, before anything is read). How it went in words, for Your Apps and the bot's chat; never + * throws. It reads nothing more of the app after (appNow: some of Orgo's reads take no computer's sign in). + */ +export async function githubJob(botId: string, job: { act: "githubLink" | "githubPull"; app: string }, by: string): Promise<{ ok: boolean; said: string }> { + const b = bot(botId); + if (!b) return { ok: false, said: "That bot is gone." }; + const wsId = workspaceOf(b); + const name = job.app; + try { + if (!isRepoName(name) || !hostingOf(wsId, name)) throw new HostedAppError(NO_APP, 404); + onThisComputer(wsId, name); + if (onRunner() && !githubKeyHere(wsId)) return { ok: false, said: `${b.name} has no GitHub key on this computer, so nothing was read from GitHub. Give it the GitHub key in Keys, then try again.` }; + const said = await withApp(wsId, name, "deploy", () => githubNow(wsId, name, job.act, personAuthor(by))); + return { ok: true, said }; + } catch (e) { + if (e instanceof HostedAppError || e instanceof MirrorError || e instanceof IngotError) return { ok: false, said: /[.!?]$/.test(e.message) ? e.message : `${e.message}.` }; + console.warn(`[hosted-apps] github job ${name}: ${(e as Error).message}`); + return { ok: false, said: "That didn't go. Try again." }; + } +} + /** A site's Published address: https://, or null. */ const firstDomain = (all: OrgoCustomDomain[], port: unknown): string | null => { const d = typeof port === "number" ? all.find((x) => x.target_port === port && x.status === "active") : undefined; @@ -2413,6 +2456,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 +2475,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 +4901,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/member-acts.ts b/lib/server/member-acts.ts index e9f85308c..25f0b144f 100644 --- a/lib/server/member-acts.ts +++ b/lib/server/member-acts.ts @@ -1,6 +1,6 @@ import "server-only"; import { cleanChange } from "@/cloud/change-routes"; -import { cleanMemberFiles, isAppAct, KEY_NAME, MEMBER_ACT_MS, MEMBER_MAX_NOTE, MEMBER_MAX_TEXT, MEMBER_STOP_MAX, STORED_ACT_MS, type MemberAct, type MemberActPayload, type MemberAppAct } from "@/cloud/protocol"; +import { cleanMemberFiles, isAppAct, isGithubAct, KEY_NAME, MEMBER_ACT_MS, MEMBER_MAX_NOTE, MEMBER_MAX_TEXT, MEMBER_STOP_MAX, STORED_ACT_MS, type MemberAct, type MemberActPayload, type MemberAppAct } from "@/cloud/protocol"; import { appsOpen } from "@/lib/apps-access"; import { accessKey, accessOf, adminAccess, adminMay, appsLevelOf, askOwnersOnly, cleanName, currentFor, levelOf, noUseNote, permits, threadOwnersOnly, writesChat, type MemberAccess, type MemberWho } from "@/lib/member-access"; import { isAppName, isSiteName, sitesOfHosting, type MemberAppAct as KeptAct } from "@/lib/hosted-apps"; @@ -72,7 +72,7 @@ function actOf(a: unknown): MemberAct | null { if (x.act === "persona") return short(x.botId, 120) && [x.text, x.was].every((v) => typeof v === "string" && v.length <= MEMBER_MAX_TEXT) ? { act: "persona", botId: x.botId as string, text: x.text as string, was: x.was as string } : null; if (x.act === "key") return short(x.botId, 120) && typeof x.name === "string" && KEY_NAME.test(x.name) && typeof x.on === "boolean" ? { act: "key", botId: x.botId as string, name: x.name, on: x.on } : null; - if (x.act === "deploy" || x.act === "rollback" || x.act === "settings" || x.act === "domain") return appActOf(x); + if (x.act === "deploy" || x.act === "rollback" || x.act === "settings" || x.act === "domain" || x.act === "githubLink" || x.act === "githubPull") return appActOf(x); if (x.act === "change") { const call = cleanChange(x); return call ? { act: "change", ...call } : null; @@ -84,6 +84,9 @@ const OPAQUE = /^[A-Za-z0-9_-]{1,80}$/; /** An act on an app (Your Apps at Write), as cloud/members.ts cleanAct checks it: only its own fields. */ function appActOf(x: Record): MemberAct | null { if (!isAppName(x.app) || (x.site !== undefined && !isSiteName(x.site))) return null; + // Link, or Bring in GitHub's changes: when the app's own computer couldn't (cloud/turns.ts takeAppJob). Never where it + // publishes ("github"): Bops Cloud writes that itself (cloud/members.ts githubHere), and this Mac's merge keeps it. + if (x.act === "githubLink" || x.act === "githubPull") return x.site === undefined ? { act: x.act, app: x.app } : null; const site = typeof x.site === "string" ? { site: x.site } : {}; // Published ("prod"): an Admin's alone (refused); none is the Preview's. if ((x.act === "deploy" || x.act === "rollback") && x.env !== undefined && x.env !== "prod") return null; @@ -218,6 +221,9 @@ function refused(p: MemberActPayload, act: MemberAct, who: MemberWho, admin = fa if (!h || !sitesOfHosting(h, act.app).includes(act.site ?? act.app)) return "no such app"; // Publish, or going back on Published: the owner's and an Admin's (member-access.ts ADMIN_POWERS). if (isPublish(act) && !adminMay("publish", me)) return "not an Admin"; + // Its GitHub production: the same power (an Admin, or a Member with App secrets), for an app of one folder. + if (isGithubAct(act) && !adminMay("publish", me)) return "no App secrets"; + if (isGithubAct(act) && h.sites?.length) return "more than one site"; return null; } // A change as the owner's window makes it (change-acts.ts): the power it needs, this workspace's bots and apps alone. @@ -292,7 +298,7 @@ export async function memberEvent(p: MemberActPayload, eventId: string): Promise if (!act || !who || typeof p.wsId !== "string" || typeof p.key !== "string") return void console.warn(`[members] event ${eventId}: not a member's act`); // The same act on an app again (an ack that was lost): done, or refused, once; or still being done. const doing = isAppAct(act) ? `${p.wsId}\n${act.app}\n${p.key}` : ""; - if (isAppAct(act) && (going.has(doing) || actKept(p.wsId, act.app, p.key))) return; + if (isAppAct(act) && (going.has(doing) || actKept(p.wsId, act.app, p.key, act.act))) return; const admin = await adminOf(p, who); const why = refused(p, act, who, admin); if (why && isAppAct(act) && why !== "too late" && why !== "no such workspace" && why !== "no such app") { @@ -505,6 +511,8 @@ const REFUSED: Record = { "access from before they joined": "The owner took away Write on Your Apps.", "apps aren't open": "The owner took away Write on Your Apps.", "not an Admin": "Only the owner or an Admin can publish.", + "no App secrets": "Only the owner, an Admin, or someone with App secrets can do this.", + "more than one site": "It runs more than one site, and one GitHub folder is one app.", }; /** Whether an act on an app is Published's: Publish (a deploy) or going back there (a rollback), an Admin's alone. */ @@ -516,7 +524,7 @@ const keptAct = (p: MemberActPayload, act: MemberAppAct, who: MemberWho, ok: boo name: who.name, act: act.act, ...(act.site ? { site: act.site } : {}), - ...(act.act === "domain" ? { domain: act.subdomain ? `${act.subdomain}.${act.zone}` : act.zone } : {}), + ...(act.act === "domain" && "zone" in act ? { domain: act.subdomain ? `${act.subdomain}.${act.zone}` : act.zone } : {}), ...(isPublish(act) ? { env: "prod" as const } : {}), ok, ...(said ? { said } : {}), @@ -537,6 +545,7 @@ async function appEvent(p: MemberActPayload, act: MemberAppAct, who: MemberWho, const h = await import("./hosted-apps"); let said: string | undefined; let domain: string | undefined; + let ok = false; try { // (A Member's deploy, rollback and settings are the Preview's. An Admin's Publish, refused for anyone else, is the owner's // Publish button's (promoteApp: what the Preview serves, `at` the changeset they saw there); going back on Published, its Roll back's.) @@ -549,16 +558,22 @@ async function appEvent(p: MemberActPayload, act: MemberAppAct, who: MemberWho, Promise.reject((e as { status?: unknown })?.status === 404 && /recent deploys/.test((e as Error)?.message ?? "") ? new Error(`Members roll back its preview only. Ask ${ownerName()} to roll back what's published.`) : e), ); else if (act.act === "settings") await h.changeSettings(p.wsId, act.app, { port: act.port, install: act.install, build: act.build, start: act.start, health: act.health }, act.site, "preview"); + // Link, or Bring in GitHub's changes, with the owner's own key here, Ingot's commit in their name (hosted-apps.ts githubNow). + else if (act.act === "githubLink") ({ said } = await h.linkGithubMirror(p.wsId, act.app)); + else if (act.act === "githubPull") ({ said } = await h.pullGithubMirror(p.wsId, act.app, h.personAuthor(who.name))); + else if (act.act === "github") throw new Error("Bops Cloud sets where it publishes."); else { const got = await h.connectDomain(p.wsId, act.app, { zone: act.zone, ...(act.subdomain ? { subdomain: act.subdomain } : {}) }, act.site); domain = got.domain?.domain; if (got.error) said = got.error; } + ok = true; } catch (e) { said = plain(e); console.warn(`[members] event ${eventId}: ${said}`); } - const kept = keptAct(p, act, who, !said, said); + // (A GitHub act says how it went either way; the rest say only why not.) + const kept = keptAct(p, act, who, ok && (isGithubAct(act) || !said), said); noteMemberAct(p.wsId, act.app, domain ? { ...kept, domain } : kept); void seeApps(p.wsId).catch(() => {}); } 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/runner-chat.ts b/lib/server/runner-chat.ts index a48ec0beb..4e8a5b0cc 100644 --- a/lib/server/runner-chat.ts +++ b/lib/server/runner-chat.ts @@ -1,11 +1,11 @@ import "server-only"; import { mkdir, stat, unlink, utimes, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { RUNNER_HOLD, RUNNER_TURNS_DIR, TURN_BEAT_MS, type RunnerClaim, type RunnerResume, type RunnerTurnEvent, type TurnMember } from "@/cloud/protocol"; +import { RUNNER_HOLD, RUNNER_TURNS_DIR, TURN_BEAT_MS, type RunnerClaim, type RunnerResume, type RunnerTurnEvent, type TurnJob, type TurnMember } from "@/cloud/protocol"; import type { AppApproval, MemberTurn } from "@/lib/types"; import { cloudFetch } from "./cloud"; import { loopHere, onRunner } from "./persist"; -import { getState, pullState, stateReady } from "./store"; +import { addMessage, getState, pullState, stateReady } from "./store"; import { runInTurn, type TurnContext } from "./turn-context"; /** @@ -30,6 +30,10 @@ import { runInTurn, type TurnContext } from "./turn-context"; * "turn-cancel" (its member removed or their access changed, its lease lost) stops a turn where it is. "state" reads * the state now (an OK answered, a change of who may). A turn whose message it can't read fails with a note. The Mac * never runs this. + * + * A turn that's an app's GitHub job (TurnMember.job: Link, or Bring in GitHub's changes, asked from a shared workspace's + * Your Apps and checked by Bops Cloud: cloud/turns.ts takeAppJob) isn't words to answer: it's done here (runJob, through + * hosted-apps.ts githubJob), said in the bot's chat as Bops' own, and its result told with its end. No model sees it. */ const RECONNECT_MIN_MS = 2_000; @@ -58,11 +62,14 @@ type Box = { marks?: Map; /** Tests: how often a resume looks again at an upgrade's hold. */ holdPollMs?: number; + /** Tests: what does an app's GitHub job (hosted-apps.ts githubJob). */ + job?: (botId: string, job: TurnJob, by: string) => Promise<{ ok: boolean; said: string }>; }; const g = globalThis as unknown as { __bopsRunnerChat?: Box }; const box: Box = (g.__bopsRunnerChat ??= { running: new Map() }); const answerOf = async () => box.answer ?? (await import("./chat")).answerMessage; +const jobOf = async () => box.job ?? (await import("./hosted-apps")).githubJob; const identityOf = async () => box.identity ?? (await import("./runner")).checkIdentity; /** A call to Bops Cloud as this computer: the answer's status and body (status 0 when it didn't answer). */ @@ -218,8 +225,8 @@ async function whileMarked(id: string, run: () => Promise, held?: () => T) } const holding = () => stat(holdOf()).then((h) => Date.now() - h.mtimeMs < HOLD_STALE_MS, () => false); -/** The turn's beats while `run` runs, then its end: done (with its tapback), paused for an OK, or nothing (taken back). */ -async function asTurn(ctx: TurnContext, run: () => Promise<{ paused?: boolean } | null>, after?: { messageId: string }) { +/** The turn's beats while `run` runs, then its end: done (with its tapback, or `result`: a job's), paused for an OK, or nothing (taken back). */ +async function asTurn(ctx: TurnContext, run: () => Promise<{ paused?: boolean } | null>, after?: { messageId: string }, result?: () => { ok: boolean; said: string }) { box.running.set(ctx.id, ctx); // The beats on the way: the end waits for them, so one that lands after it can't say the turn runs still. const beats = new Set>(); @@ -258,7 +265,7 @@ async function asTurn(ctx: TurnContext, run: () => Promise<{ paused?: boolean } } } const react = after ? getState().messages.find((m) => m.id === after.messageId)?.reactions?.find((r) => r.by === ctx.botId)?.type : undefined; - await postSure(path, { state: "done", ...(react ? { react } : {}) }); + await postSure(path, { state: "done", ...(react ? { react } : {}), ...(result ? { job: result() } : {}) }); } /** The OK request a chat turn just paused for: the one asked on this turn (AskResume.cloudTurn), unanswered, the newest. */ @@ -284,6 +291,8 @@ async function claimAndAnswer(ev: RunnerTurnEvent) { if (r.status !== 200 || !r.body?.turn) return; const t = r.body.turn; const offsetMs = r.body.now - Date.now(); + // An app's GitHub job: done here, never answered (its message is Bops' note of who asked, which it needs none of). + if (t.member?.job) return runJob({ id: t.id, chatId: t.chatId, botId: t.botId, offsetMs, humanAt: t.humanAt, acting: false, cancelled: false }, t.member.job, t.member.name); for (let i = 0; i < READS_FOR_MESSAGE && !getState().messages.some((m) => m.id === t.messageId); i++) await pullState().catch(() => {}); const path = `/v1/runner/turns/${encodeURIComponent(t.id)}/done`; if (!getState().messages.some((m) => m.id === t.messageId)) { @@ -295,6 +304,34 @@ async function claimAndAnswer(ev: RunnerTurnEvent) { await asTurn(ctx, () => answer(t.chatId, t.messageId, saidBy(t.member)), { messageId: t.messageId }); } +/** How a job's result reads in the bot's chat (Bops' own words, as the bot). */ +export const jobLine = (job: TurnJob, r: { ok: boolean; said: string }) => + r.ok ? r.said : `${job.act === "githubLink" ? `Couldn't link ${job.app} to GitHub` : `Couldn't bring in GitHub's changes to ${job.app}`}: ${r.said}`; + +/** + * An app's GitHub job (TurnMember.job) as this turn: said at once to be doing what can't be done twice (it writes to + * Ingot), so a runner gone quiet after never has it done again; then done by hosted-apps.ts githubJob in `by`'s name, + * its words in the chat as the bot's, marked Bops' own and about an app (Message.appNote, bops), and its result told + * with the turn's end, which Bops Cloud keeps on the app for Your Apps. Taken back first (409): nothing is done. + */ +async function runJob(ctx: TurnContext, job: TurnJob, by: string) { + ctx.acting = true; + const first = await post(`/v1/runner/turns/${encodeURIComponent(ctx.id)}/beat`, { phase: "tools" }); + if (first.status === 409) return; + const doJob = await jobOf(); + let result: { ok: boolean; said: string } = { ok: false, said: "That didn't go. Try again." }; + await asTurn( + ctx, + async () => { + result = await doJob(ctx.botId, job, by); + if (!ctx.cancelled) addMessage({ chatId: ctx.chatId, role: "bot", botId: ctx.botId, text: jobLine(job, result), appNote: true, bops: true }); + return null; + }, + undefined, + () => result, + ); +} + /** * A chat turn that paused for an OK goes on from the answer (chat.ts resumeChat, through permission.ts settleAsks). One * asked on a turn Bops Cloud owed (AskResume.cloudTurn) goes on only as that turn, once Bops Cloud says it may @@ -321,8 +358,9 @@ async function resumeMarked(a: AppApproval, id: } /** For tests: how the turn is run and how this runner makes sure it's on its own computer (unset: the real ones), and the beat. */ -export function runnerChatForTests(opts: { answer?: typeof box.answer; identity?: typeof box.identity; beatMs?: number; retryMs?: number[]; turnsDir?: string; holdPollMs?: number }) { +export function runnerChatForTests(opts: { answer?: typeof box.answer; identity?: typeof box.identity; beatMs?: number; retryMs?: number[]; turnsDir?: string; holdPollMs?: number; job?: typeof box.job }) { box.answer = opts.answer; + box.job = opts.job; box.identity = opts.identity; box.beatMs = opts.beatMs; box.turnsDir = opts.turnsDir; diff --git a/lib/server/shared-core.ts b/lib/server/shared-core.ts index a028fa5ac..6c4eda67d 100644 --- a/lib/server/shared-core.ts +++ b/lib/server/shared-core.ts @@ -716,6 +716,8 @@ export function viewState(own: AppState, open: SharedOpen, data: SharedWsState, ...(s.adminActs ? { adminActs: true as const } : {}), ...(s.ownerOld && open.role === "admin" ? { ownerOld: true as const } : {}), ...(s.adminPublish && open.role === "admin" ? { adminPublish: true as const } : {}), + // Its apps' GitHub production, at Your Apps Write, for whoever has the power of Publish (cloud/ws-slice.ts sharedSlice). + ...(s.appGithub && s.apps?.level === "write" ? { appGithub: true as const } : {}), // Keys (cloud/ws-slice.ts keysOf): whether they manage this workspace's keys, whether the owner's Bops takes their // bots' key switches, and the switches on their way or not gone (keys.tsx). ...(s.keys ? { keys: true as const, ...(s.keyActs ? { keyActs: true as const } : {}) } : {}), 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/lib/server/state-merge.ts b/lib/server/state-merge.ts index a172ea3b8..50bdbb066 100644 --- a/lib/server/state-merge.ts +++ b/lib/server/state-merge.ts @@ -230,10 +230,26 @@ function mergeEither(key: string, b: unknown, m: unknown, t: unknown, top: boole // A workspace's team chart (Workspace.chain) is one field, like its favorites: changed on both, the copy of the // Bops saving now goes whole (an older Bops's: theirs), never a mix of two charts. if (!top && key === "chain") return me?.older ? t : m; + // An app's GitHub setting (AppHosting.github) is one value too: its point (`last`) is of its own repo, branch and folder. + if (!top && key === "github" && (isObject(m) || isObject(t))) return githubWhole(b, m, t, me); if (isObject(m) && isObject(t)) return mergeObject(isObject(b) ? b : {}, m, t, false, me); return me?.older ? t : m; } +/** + * An app's GitHub setting changed on both sides (lib/hosted-apps.ts AppHosting.github), never mixed: a point (`last`) is + * only ever of the repo, branch and folder it was made for. The side that changed where it publishes (Bops Cloud keeping an + * Admin's setting while this Mac published or linked: cloud/members.ts githubHere) goes whole over one that only moved its + * point, as a new setting needs a new Link anyway; both moved it, or neither: this Mac's (an older Bops's: theirs). + */ +function githubWhole(base: unknown, mine: unknown, theirs: unknown, me?: Me): unknown { + const where = (x: unknown) => (isObject(x) ? canonical({ ...x, last: undefined }) : ""); + const moved = (x: unknown) => where(x) !== where(base); + if (moved(theirs) && !moved(mine)) return theirs; + if (moved(mine) && !moved(theirs)) return mine; + return me?.older ? theirs : mine; +} + /** A bot's "How I work" and who wrote it (Bot.persona, Bot.personaBy): one field, as one Save or an Admin's change writes both. */ const HOW_I_WORK = ["persona", "personaBy"] as const; diff --git a/lib/shared.ts b/lib/shared.ts index c836add30..05d76467d 100644 --- a/lib/shared.ts +++ b/lib/shared.ts @@ -57,6 +57,11 @@ export type SharedOpen = SharedWorkspace & { ownerOld?: true; /** For an Admin: the owner's Bops publishes for them (SharedWsSlice.adminPublish), so Your Apps shows Publish. */ adminPublish?: true; + /** + * For an Admin, or a Member with App secrets, at Your Apps Write: where an app publishes on GitHub, its Link and Bring in + * GitHub's changes, which the app's own computer does (SharedWsSlice.appGithub: the owner's Bops takes them). + */ + appGithub?: true; /** What the user did to them that the owner's Bops hasn't kept yet: on its way, waiting, or not gone and why. */ appActs?: { key: string; act: MemberAppAct; at: number; state: "sending" | "waiting" | "failed"; error?: string }[]; /** The same for a bot's How I work (bot-panel.tsx HowIWork). */ diff --git a/scripts/test-github-mirror.mjs b/scripts/test-github-mirror.mjs index b00228af3..9e2b6cb39 100644 --- a/scripts/test-github-mirror.mjs +++ b/scripts/test-github-mirror.mjs @@ -259,7 +259,7 @@ const reset = (ghFiles, igFiles) => { globalThis.__fake = { ingot: ig.api, callKey: (c) => gh.call(c), userDir: home }; }; reset({ a: "1" }, { a: "1" }); -const { mirrorPublish, mirrorPull, linkMirror, keptPath, gitSha: ourGitSha, MirrorError, BLOB_MAX } = await import(`${root}/lib/server/github-mirror.ts`); +const { mirrorPublish, mirrorPull, linkMirror, linkAndSay, keptPath, gitSha: ourGitSha, MirrorError, BLOB_MAX } = await import(`${root}/lib/server/github-mirror.ts`); const { cleanGithubMirror } = await import(`${root}/lib/hosted-apps.ts`); // The BrandLord shape: the app is the formula/ folder; the rest of the repo is the server's and its secrets'. @@ -296,13 +296,17 @@ await check("not linked yet: nothing happens, and it says to link it", async () assert.deepEqual(gh.calls, []); }); -await check("Link: only two sides with the same files (but kept paths, GitHub's links, .ingotignore), at both newest", async () => { +await check("Link: GitHub has nothing Ingot lacks or has otherwise (but kept paths, GitHub's links, .ingotignore), at both newest", async () => { reset(GH0, IG0); const last = await link(); assert.deepEqual({ ...last, when: 0 }, { sha: gh.head, ingot: ig.head, keep: KEEP, when: 0 }); assert.deepEqual(writes(), []); + // A file both have, otherwise: it stops, naming only that one (Ingot's own extra.ts is fine). reset(GH0, { ...IG0, "lib/look.ts": "teal", "extra.ts": "x" }); - await assert.rejects(link(), (e) => code("LINK_DIFFERS")(e) && e.extra.paths.join() === "extra.ts,lib/look.ts" && /Ingot and GitHub differ in extra\.ts, lib\/look\.ts/.test(e.message)); + await assert.rejects(link(), (e) => code("LINK_DIFFERS")(e) && e.extra.paths.join() === "lib/look.ts" && /Ingot and GitHub differ in lib\/look\.ts\./.test(e.message)); + // A file only GitHub has: it stops too. + reset({ ...GH0, "formula/theirs.ts": "only on GitHub" }, IG0); + await assert.rejects(link(), (e) => code("LINK_DIFFERS")(e) && e.extra.paths.join() === "theirs.ts"); // Not on GitHub yet: an empty folder, so the first Publish puts it all there. reset({ "README.md": "x" }, IG0); const l2 = await link(); @@ -312,6 +316,37 @@ await check("Link: only two sides with the same files (but kept paths, GitHub's assert.equal(gh.files().get("README.md"), "x"); }); +await check("Link takes files only Ingot has (a new client's own folder): they're Ingot's changes, and the first Publish puts them there", async () => { + // A new client of a shared formula/: its own clients/acme/ folder isn't on GitHub yet. + reset(GH0, { ...IG0, "clients/acme/client.json": '{"autoDeploy":false}', "clients/acme/deck.ts": "acme deck" }); + const linked = await linkAndSay("ws_1", "fourcorners", CFG()); + assert.deepEqual(linked.ingotOnly, ["clients/acme/client.json", "clients/acme/deck.ts"]); + assert.match(linked.said, /^Linked fourcorners to GitHub OrgoAI\/orgo-brandlord main, folder formula at [0-9a-f]{7}\. 2 files only Ingot has \(clients\/acme\/client\.json, clients\/acme\/deck\.ts\) go to GitHub with the next Publish\.$/); + assert.ok(!/[\u2013\u2014]| - /.test(linked.said), "no dashes in what it says"); + assert.deepEqual(writes(), [], "Link writes nothing to GitHub"); + const done = await publish(ig.head, linked.last); + assert.equal(gh.files().get("formula/clients/acme/deck.ts"), "acme deck", "the first Publish puts them there"); + assert.equal(gh.files().get("formula/clients/acme/client.json"), '{"autoDeploy":false}'); + assert.equal(gh.files().get("formula/clients/cclc/deck.ts"), "cclc deck", "another client's kept folder stays"); + assert.match(done.said, /Pushed 2 files/); + // Kept paths are still left out of the comparison: an Ingot-only kept file is neither a difference nor pushed. + reset(GH0, { ...IG0, "clients/cclc/mine.ts": "kept here" }); + const kept = await linkAndSay("ws_1", "fourcorners", CFG()); + assert.deepEqual(kept.ingotOnly, []); + assert.equal(kept.said.includes("only Ingot has"), false); +}); + +await check("Bring in after a Link with files only Ingot has: they stay Ingot's, GitHub's changes come in", async () => { + reset(GH0, { ...IG0, "clients/acme/deck.ts": "acme deck" }); + const linked = await linkAndSay("ws_1", "fourcorners", CFG()); + gh.push({ "formula/lib/look.ts": "teal on GitHub" }); + const done = await mirrorPull("ws_1", "fourcorners", CFG(linked.last), "bops; Ada"); + assert.equal(done.brought, 1); + assert.equal(ig.files().get("lib/look.ts"), "teal on GitHub"); + assert.equal(ig.files().get("clients/acme/deck.ts"), "acme deck", "Ingot's own file stays"); + assert.equal(ig.commits.at(-1).author, "bops; Ada", "Ingot's commit is in the person's name"); +}); + await check("Ingot's changes go to GitHub: one commit on top of the branch, its folder only; kept paths, links and the rest stay", async () => { reset(GH0, IG0); const last = await link(); diff --git a/scripts/test-hosted-apps-ui.mjs b/scripts/test-hosted-apps-ui.mjs index 7685aecfc..17b87b746 100644 --- a/scripts/test-hosted-apps-ui.mjs +++ b/scripts/test-hosted-apps-ui.mjs @@ -2770,7 +2770,8 @@ await test("Your Apps in a workspace shared with the user: the apps as the owner assert.match(actLine({ key: "k", userId: "u", name: "Juno", act: "deploy", ok: false, said: "That domain isn't this workspace's.", at: 1 }), /^Juno tried to deploy it: That domain isn't this workspace's\. · /); const owner = list({ enabled: true, apps: [app("shop")] }, { actOf: () => ({ key: "k", userId: "u", name: "Juno", act: "rollback", ok: true, at: Date.now() }) }); assert.match(owner, /data-member-act[^>]*>Juno went back to an earlier deploy · /); - for (const v of Object.values(SHARED_APPS_WORDS)) { + const { SHARED_GITHUB_FORM_WORDS } = await import(pathToFileURL(`${root}/components/app/shared-apps.tsx`).href); + for (const v of [...Object.values(SHARED_APPS_WORDS), ...Object.values(SHARED_GITHUB_FORM_WORDS)]) { const text = typeof v === "function" ? (v.length === 2 ? v("Juno", { act: "settings", site: "shop" }) : v.length === 3 ? v("Juno", "deploy", "No.") : v("Acme")) : v; assert.doesNotMatch(text, /[\u2010-\u2015\u2212]|[A-Za-z]-[A-Za-z]| - /, `no dash: ${text}`); assert.doesNotMatch(text, /orbit/i); @@ -2918,6 +2919,101 @@ await test("Your Apps shared with an Admin, once the owner's Bops takes it (Shar } }); +await test("Your Apps' GitHub part, for an Admin or a Member with App secrets once the owner's Bops takes it (SharedOpen.appGithub): where it publishes and when it was last the same; Bring in asked first; Link; Save and link sends where with link; a Link or Bring in its computer is doing holds the app and says so; never without the flag, nor for an app of more sites", async () => { + const { SharedAppsPage, actLine } = await import(pathToFileURL(`${root}/components/app/shared-apps.tsx`).href); + const site = { name: "shop", url: null, preview: "https://k7p2qxm4wr3d.orgo.dev", previewLive: { id: "dp2", state: "succeeded", when: 10 }, previewServing: "cs22222", serving: "cs11111", live: { id: "d1", state: "succeeded", when: 7 } }; + const linked = { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts"], dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } }, linked: { sha: "abcdef1234567890abcdef1234567890abcdef12", ingot: "f".repeat(64), when: Date.now() - 3 * 60_000 } }; + const app = (more = {}) => ({ name: "shop", head: { id: "cs22222", message: "New cart", at: 9 }, sites: [site], ...more }); + const state = (a, o = {}) => ({ + account: { user: { id: "u-ada", email: "ada@example.com" } }, + shared: { projectId: "p", name: "Acme", owner: "Ava", role: o.role ?? "admin", wsId: "shared_p", adminActs: true, adminPublish: true, ...(o.flag === false ? {} : { appGithub: true }), apps: { level: o.level ?? "write", at: Date.now(), list: [a] } }, + bots: [], + workspaces: [], + }); + const buttons = (el, text) => [...el.querySelectorAll("button")].filter((b) => b.textContent === text); + const sent = (from) => posts(from, "/api/workspaces/shared").map((c) => c.body.act); + routes["/api/workspaces/shared"] = { body: { ok: true } }; + try { + // Linked: where, when last the same, and Bring in, asked first. + const m = await mount(h(SharedAppsPage, { state: state(app({ github: linked })) })); + await click(m.box.querySelector("button[aria-expanded]")); + const part = m.box.querySelector("[data-shared-github]"); + assert.ok(part, "the GitHub part, at Write with the flag"); + assert.match(part.textContent, /Publishes to GitHub example-org\/web, main, folder formula\.Last the same as GitHub at abcdef1 \(([A-Z][a-z]{2} \d{1,2}, )?\d{1,2}:\d\d [AP]M\)\./); + let from = calls.length; + await click(buttons(part, "Bring in GitHub's changes")[0]); + assert.deepEqual(sent(from), [], "asked first"); + assert.match(part.querySelector("[data-gh-pull-ask]").textContent, /GitHub's version wins where both changed a file\. Bring them in\?/); + await click(buttons(part, "Bring them in")[0]); + assert.deepEqual(sent(from), [{ act: "githubPull", app: "shop" }]); + // Change where it publishes: the form as it is now, sent with link. + await click(buttons(part, "Change where it publishes")[0]); + const f = part.querySelector("[data-gh-form]"); + assert.deepEqual([...f.querySelectorAll("[data-gh-field]")].map((x) => [x.dataset.ghField, x.value]), [["repo", "example-org/web"], ["branch", "main"], ["path", "formula"], ["keep", "proxy.ts"], ["workflow", "deploy.yml"], ["inputs", "client=acme"]]); + assert.match(part.textContent, /A new repo, branch, folder or workflow needs a GitHub key of this workspace's own in Keys\./); + await type(f.querySelector('[data-gh-field="keep"]'), "proxy.ts, clients/acme/"); + from = calls.length; + await click(buttons(f, "Save and link")[0]); + assert.deepEqual(sent(from), [{ act: "github", app: "shop", github: { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts", "clients/acme/"], dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } } }, link: true }]); + // Back on Orgo, from the form. + await click(buttons(part, "Change where it publishes")[0]); + from = calls.length; + await click(part.querySelector("[data-gh-orgo]")); + assert.deepEqual(sent(from), [{ act: "github", app: "shop", github: null }]); + await m.done(); + // Set, not linked: Link, and Publish waits for it (said why). + const n = await mount(h(SharedAppsPage, { state: state(app({ github: { ...linked, linked: undefined } })) })); + const publish = n.box.querySelector("[data-shared-publish]"); + assert.deepEqual([publish.disabled, publish.parentElement.dataset.tip], [true, "Link it to GitHub first."]); + await click(n.box.querySelector("button[aria-expanded]")); + assert.match(n.box.querySelector("[data-shared-github]").textContent, /It isn't linked yet\. Link it before you publish\./); + from = calls.length; + await click(buttons(n.box, "Link to GitHub")[0]); + assert.deepEqual(sent(from), [{ act: "githubLink", app: "shop" }]); + await n.done(); + // Not on GitHub: Publish to GitHub instead opens an empty form. + const o = await mount(h(SharedAppsPage, { state: state(app()) })); + await click(o.box.querySelector("button[aria-expanded]")); + assert.match(o.box.querySelector("[data-shared-github]").textContent, /It publishes on Orgo\./); + await click(buttons(o.box, "Publish to GitHub instead")[0]); + assert.equal(o.box.querySelector('[data-gh-field="repo"]').value, ""); + assert.equal(buttons(o.box, "Save and link")[0].disabled, true, "which repo first"); + assert.equal(o.box.querySelector("[data-gh-orgo]"), null, "on Orgo already"); + await o.done(); + // Its computer doing a Bring in: said, and nothing else on the app meanwhile; then how it went, its words under it. + const going = app({ github: linked, acts: [{ key: "k1", userId: "u-ada", name: "Ada", act: "githubPull", ok: true, going: true, at: Date.now() }] }); + const g = await mount(h(SharedAppsPage, { state: state(going) })); + assert.match(g.box.querySelector("[data-act-line]").textContent, /^Ada asked its computer to bring in GitHub's changes… · /); + assert.equal(buttons(g.box, "Deploy")[0].disabled, false, "(a Preview deploy isn't the mirror's)"); + assert.equal(g.box.querySelector("[data-shared-publish]").disabled, true); + await click(g.box.querySelector("button[aria-expanded]")); + assert.equal(buttons(g.box, "Bring in GitHub's changes")[0].disabled, true); + await g.done(); + const done = app({ github: linked, acts: [{ key: "k1", userId: "u-ada", name: "Ada", act: "githubPull", ok: true, said: "Brought 2 changes from GitHub into Ingot.", at: Date.now() }] }); + const d = await mount(h(SharedAppsPage, { state: state(done) })); + assert.match(d.box.querySelector("[data-act-line]").textContent, /^Ada brought in GitHub's changes · /); + assert.equal(d.box.querySelector("[data-act-said]").textContent, "Brought 2 changes from GitHub into Ingot."); + await d.done(); + assert.match(actLine({ key: "k", userId: "u", name: "Sam", act: "githubLink", ok: false, said: "Ingot and GitHub differ in lib/look.ts.", at: 1 }), /^Sam tried to link it to GitHub: Ingot and GitHub differ in lib\/look\.ts\. · /); + assert.match(actLine({ key: "k", userId: "u", name: "Sam", act: "github", ok: true, at: 1 }), /^Sam changed where it publishes · /); + // No flag (a Member without App secrets, the owner's Bops older), or Read: no GitHub part. An app of more sites: one line why. + for (const [why, st] of [["no flag", state(app({ github: linked }), { flag: false })], ["at Read", state(app({ github: linked }), { level: "read" })]]) { + const x = await mount(h(SharedAppsPage, { state: st })); + await click(x.box.querySelector("button[aria-expanded]")); + assert.equal(x.box.querySelector("[data-shared-github]"), null, why); + assert.equal(buttons(x.box, "Link to GitHub").length + buttons(x.box, "Bring in GitHub's changes").length, 0, why); + await x.done(); + } + const many = await mount(h(SharedAppsPage, { state: state(app({ siteNames: ["admin"] })) })); + await click(many.box.querySelector("button[aria-expanded]")); + assert.match(many.box.textContent, /It runs more than one site, and one GitHub folder is one app, so it publishes on Orgo\./); + assert.equal(buttons(many.box, "Publish to GitHub instead").length, 0); + await many.done(); + } finally { + delete routes["/api/workspaces/shared"]; + } +}); + /* ---------------- Local, Staging and Prod, all on the app's computer ---------------- */ const E = await import(pathToFileURL(`${root}/components/app/app-envs.tsx`).href); @@ -3309,5 +3405,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..82ba7f483 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)), }); @@ -1143,6 +1145,16 @@ function vaultAndBroker(req, url, body) { return json({ secrets: web.secrets.map((s) => ({ name: s.name, workspace_id: s.workspace_id ?? null, created_at: "2026-10-20T00:00:00Z", updated_at: "2026-10-21T00:00:00Z" })) }); if (url.pathname === "/api/secrets/reveal") return json({ error: "No reveal in these tests." }, 403); if (url.pathname !== "/api/secrets/call" || req.method !== "POST") return null; + // GitHub behind the broker, when a check sets it up (web.github): one repo whose branch has no app/ folder yet. + if (body?.name === "GITHUB_TOKEN" && web.github) { + const g = web.github; + g.calls.push(`${body.method} ${body.path}`); + const answer = (status, b) => json({ status, contentType: "application/json", body: b, truncated: false, headers: {} }); + if (body.method === "GET" && body.path === `/repos/${g.repo}/git/ref/heads/main`) return answer(200, { object: { sha: g.head } }); + if (body.method === "GET" && body.path === `/repos/${g.repo}/git/commits/${g.head}`) return answer(200, { sha: g.head, tree: { sha: g.tree } }); + if (body.method === "GET" && body.path === `/repos/${g.repo}/git/trees/${g.tree}`) return answer(200, { sha: g.tree, tree: [{ path: "README.md", type: "blob", sha: "3".repeat(40), mode: "100644", size: 2 }], truncated: false }); + return answer(404, { message: "Not Found" }); + } // orgo-web's broker (app/api/secrets/call): a name with no provider, a workspace the user isn't in, a name not set. if (body?.name !== "CLOUDFLARE_API_TOKEN" || !web.cfBroker) return json({ error: `${body?.name} has no provider Orgo can call. GET /api/secrets/providers lists them.` }, 400); const ws = body.workspace_id ?? null; @@ -2144,6 +2156,78 @@ await check("On a bot's computer: Orgo's refusals of its computer's own sign in assert.equal(web.calls.length, n, "nothing asked of Orgo"); }); +await check("Link and Bring in asked from a shared workspace (githubJob): on a bot's computer only for an app at home there with a bot there given the GitHub key, said in words before anything is read; on this Mac a Link to a folder GitHub doesn't have yet keeps only the point, one the app's computer may keep too; the owner's own route says to use the shared Your Apps while one is open", async () => { + const { hostingChangeAllowed, hostingScope } = await import(`${root}/cloud/computer-scope.ts`); + const was = structuredClone(hostedApps()["recipe-box"]); + const cfg = { repo: "acme/site", branch: "main", path: "app" }; + const boppyBot = () => S.getState().bots.find((b) => b.id === "boppy"); + const keysWere = structuredClone(boppyBot().keys); + S.update((s) => (s.workspaces.find((w) => w.id === WS).hostedApps["recipe-box"].github = { ...cfg })); + const env = { runner: process.env.BOPS_RUNNER, computer: process.env.BOPS_RUNNER_COMPUTER, tokens: process.env.BOPS_INGOT_TOKENS }; + const n = web.calls.length; + const job = (act = "githubLink") => H.githubJob("boppy", { act, app: "recipe-box" }, "Ada"); + try { + process.env.BOPS_RUNNER = "1"; + // Another computer's app: nothing of it is touched. + process.env.BOPS_RUNNER_COMPUTER = "c_elsewhere"; + assert.deepEqual(await job(), { ok: false, said: "recipe-box lives on another computer, not this one, so nothing of it was changed here." }); + // Its own computer, with no bot there given the GitHub key: said before anything is read (Orgo's broker would refuse it). + process.env.BOPS_RUNNER_COMPUTER = was.computerId; + S.update((s) => (s.bots.find((b) => b.id === "boppy").keys = [])); + assert.deepEqual(hear(await job("githubPull")), { ok: false, said: "Boppy has no GitHub key on this computer, so nothing was read from GitHub. Give it the GitHub key in Keys, then try again." }); + // Given it: Ingot is this computer's own sign in (none here yet), said as Ingot says it, never thrown. + S.update((s) => (s.bots.find((b) => b.id === "boppy").keys = ["GITHUB_TOKEN"])); + process.env.BOPS_INGOT_TOKENS = join(scratch, "no-ingot-tokens-here"); + const r = await job(); + assert.equal(r.ok, false); + assert.ok(r.said.startsWith("This computer has no Ingot sign in for this workspace yet"), r.said); + assert.equal(web.calls.length, n, "nothing asked of Orgo"); + } finally { + for (const [k, v] of [["BOPS_RUNNER", env.runner], ["BOPS_RUNNER_COMPUTER", env.computer], ["BOPS_INGOT_TOKENS", env.tokens]]) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + S.update((s) => (s.bots.find((b) => b.id === "boppy").keys = keysWere)); + } + // On this Mac (the owner's Mac doing it for someone, with the owner's own key): a folder GitHub doesn't have yet links to an empty one. + web.github = { repo: "acme/site", head: "1".repeat(40), tree: "2".repeat(40), calls: [] }; + try { + const before = structuredClone(hostedApps()["recipe-box"]); + // Bring in before it's linked: said, nothing read. + assert.deepEqual(await job("githubPull"), { ok: false, said: "recipe-box isn't linked to GitHub yet. Link it first, in the menu beside Publish." }); + assert.deepEqual(web.github.calls, []); + const linked = hear(await job()); + assert.deepEqual(linked, { ok: true, said: "Linked recipe-box to GitHub acme/site main, folder app at 1111111. GitHub has no app/ folder yet, so the next Publish puts all of it there." }); + assert.ok(web.github.calls.every((c) => c.startsWith("GET ")), "a Link writes nothing to GitHub"); + const after = hostedApps()["recipe-box"]; + const last = after.github.last; + assert.deepEqual([last.sha, /^[0-9a-f]{64}$/.test(last.ingot), last.keep], ["1".repeat(40), true, []]); + assert.deepEqual({ ...after, github: { ...after.github, last: undefined } }, { ...before, github: { ...cfg, last: undefined } }, "only its point changed"); + // That change is one the app's own computer may keep (cloud/computer-scope.ts), so a runner's Link is kept too. + const sc = hostingScope(S.getState(), was.computerId); + assert.equal(hostingChangeAllowed(WS, before, after, sc, "recipe-box"), true); + assert.equal(hostingChangeAllowed(WS, before, { ...after, github: { ...after.github, repo: "acme/other" } }, sc, "recipe-box"), false, "never where it publishes"); + // (Bring in itself, GitHub's version winning, is test-github-mirror.mjs's: this fake Ingot has no file trees.) + } finally { + web.github = undefined; + } + // No setting: said, nothing read. + S.update((s) => void delete s.workspaces.find((w) => w.id === WS).hostedApps["recipe-box"].github); + assert.deepEqual(await job(), { ok: false, said: "Set where it lives on GitHub first." }); + // The owner's own route, while a workspace someone shared is open: those are done in that workspace's Your Apps. + const { orgoKey } = await import(`${root}/lib/server/orgo-auth.ts`); + globalThis.bopsShared = { user: S.stateUser(), key: orgoKey(), copy: { openNow: () => ({ id: "shared_p" }), data: () => ({}), close() {} }, subscriber: { stop() {} } }; + try { + for (const action of ["github", "githubLink", "githubPull"]) { + const r = await post("recipe-box", { ws: WS, action, github: cfg }); + assert.deepEqual([r.status, r.body.error], [403, "Change it in this workspace's Your Apps."], action); + } + } finally { + globalThis.bopsShared = null; + S.update((s) => (s.workspaces.find((w) => w.id === WS).hostedApps["recipe-box"] = was)); + } +}); + await check("deploy_app with the short id Ingot prints: the whole one looked up on main in Ingot and deployed, the computer asleep or not; the start of two, an unknown one, and one while Ingot is down are refused in words", async () => { // Publish names no commit of its own: one the Preview doesn't serve is refused, said how. let n = web.calls.length; @@ -3228,6 +3312,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"); }); diff --git a/scripts/test-member-acts.mjs b/scripts/test-member-acts.mjs index cb161e51b..db8d5c6cc 100644 --- a/scripts/test-member-acts.mjs +++ b/scripts/test-member-acts.mjs @@ -27,7 +27,7 @@ const FAKES = { "chat-summary": forward("chat-summary", ["compactChat"]), permission: forward("permission", ["replyToAsk"]), persist: forward("persist", ["onRunner"]), - "hosted-apps": forward("hosted-apps", ["deployApp", "promoteApp", "rollbackApp", "changeSettings", "connectDomain", "buyDomain", "changeEnv", "removeApp", "removeSite", "addSite", "setAutoRenew", "askDeployAgain"]), + "hosted-apps": forward("hosted-apps", ["deployApp", "promoteApp", "rollbackApp", "changeSettings", "connectDomain", "buyDomain", "changeEnv", "removeApp", "removeSite", "addSite", "setAutoRenew", "askDeployAgain", "linkGithubMirror", "pullGithubMirror", "personAuthor"]), "apps-for-members": forward("apps-for-members", ["noteMemberAct", "actKept", "seeApps"]), keys: forward("keys", ["keyName", "keyProvider", "listKeys", "keysOf", "keysProject", "grantKey"]), }; @@ -75,9 +75,9 @@ function fresh(o = {}) { name: "Acme", memberAccess: { "sam@example.com": { others: "none", bots: { max: o.maxLevel ?? "write", ivy: "read" }, at: JOINED + 1000, computer: o.samComputer ?? "use" }, - "tom@orgo.ai": { others: "none", bots: { max: "write" }, at: JOINED + 1000, apps: o.tomApps ?? "write", computer: "use" }, + "tom@orgo.ai": { others: "none", bots: { max: "write" }, at: JOINED + 1000, apps: o.tomApps ?? "write", computer: "use", ...(o.tomCan ? { can: o.tomCan } : {}) }, }, - hostedApps: { shop: { botId: "max", computerId: "c1", sites: [{ name: "admin" }] } }, + hostedApps: { shop: { botId: "max", computerId: "c1", sites: [{ name: "admin" }] }, site: { botId: "max", computerId: "c1", github: { repo: "example-org/web", branch: "main", path: "formula", last: { sha: "a".repeat(40), ingot: "b".repeat(64), when: 1 } } } }, }, ], bots: [ @@ -180,7 +180,8 @@ globalThis.__fake = { )), "apps-for-members": { noteMemberAct: (wsId, app, act) => kept.push([wsId, app, act]), - actKept: (wsId, app, key) => kept.some((k) => k[0] === wsId && k[1] === app && k[2].key === key), + // As apps-for-members.ts: with `act`, only that act's done or refused (never one Bops Cloud kept going, or its setting). + actKept: (wsId, app, key, act) => kept.some((k) => k[0] === wsId && k[1] === app && k[2].key === key && (!act || (k[2].act === act && !k[2].going))), seeApps: async () => void did.push(["seeApps"]), }, // The owner's vault, as keys.ts reads it: `vault` (null: Orgo didn't answer), each key by name and Orgo project; the @@ -217,6 +218,20 @@ let compactSays = { ok: true }; const kept = []; let appFails = null; +// Link and Bring in (hosted-apps.ts linkGithubMirror, pullGithubMirror): what they were asked, and what they say. +Object.assign(hosted, { + linkGithubMirror: async (...a) => { + did.push(["linkGithubMirror", ...a]); + if (appFails) throw new Error(appFails); + return { app: {}, said: "Linked site to GitHub example-org/web main, folder formula at aaaaaaa." }; + }, + pullGithubMirror: async (...a) => { + did.push(["pullGithubMirror", ...a]); + if (appFails) throw new Error(appFails); + return { app: {}, said: "Brought 1 change from GitHub into Ingot. Deploy the newest to the preview to see them there." }; + }, + personAuthor: (name) => `bops; ${name}`, +}); const { memberEvent, resetAdmins } = await import(`${root}/lib/server/member-acts.ts`); // `computer`: Bops Cloud vouched for their Use its computer (as orgo-web keeps it), unless `computer: false`. const event = (act, o = {}) => ({ @@ -661,6 +676,57 @@ await check("Your Apps: refused at Read, from before they joined, apps not open assert.ok(!did.some((d) => d[0] === "deployApp"), "an email apps aren't open to"); }); +await check("GitHub's Link and Bring in, when the app's computer can't: done here for an Admin or a Member with App secrets, Ingot's commit in their name, kept with how it went; never without the switch, at Read, for an app of more sites, nor where it publishes (Bops Cloud's)", async () => { + fresh({ ownerEmail: "owner@orgo.ai", tomCan: { appSecrets: true } }); + kept.length = 0; + await memberEvent(TOM_EV({ act: "githubPull", app: "site" }, { key: "key_ghp_0001" }), "e1"); + await memberEvent(TOM_EV({ act: "githubLink", app: "site" }, { key: "key_ghl_0001" }), "e2"); + await settleApps(); + assert.deepEqual(did.filter((d) => d[0] !== "seeApps"), [ + ["pullGithubMirror", "ws_acme", "site", "bops; Tom"], + ["linkGithubMirror", "ws_acme", "site"], + ]); + assert.deepEqual(kept.map((k) => [k[2].key, k[2].act, k[2].ok, k[2].name, k[2].said]), [ + ["key_ghp_0001", "githubPull", true, "Tom", "Brought 1 change from GitHub into Ingot. Deploy the newest to the preview to see them there."], + ["key_ghl_0001", "githubLink", true, "Tom", "Linked site to GitHub example-org/web main, folder formula at aaaaaaa."], + ]); + // Bops Cloud kept "Save and link"'s setting under the same key: its Link is still done here, once. + kept.push(["ws_acme", "site", { key: "key_ghs_0001", userId: "user_tom", name: "Tom", act: "github", ok: true, at: 1 }]); + await memberEvent(TOM_EV({ act: "githubLink", app: "site" }, { key: "key_ghs_0001" }), "e3"); + await memberEvent(TOM_EV({ act: "githubLink", app: "site" }, { key: "key_ghs_0001" }), "e4"); + await settleApps(); + assert.equal(did.filter((d) => d[0] === "linkGithubMirror").length, 2); + // What the mirror says when it won't: kept as said. + appFails = "Ingot and GitHub differ in lib/look.ts. Make them the same first (or add them to the files left alone), then link."; + await memberEvent(TOM_EV({ act: "githubLink", app: "site" }), "e5"); + await settleApps(); + appFails = null; + assert.deepEqual([kept.at(-1)[2].ok, kept.at(-1)[2].said], [false, "Ingot and GitHub differ in lib/look.ts. Make them the same first (or add them to the files left alone), then link."]); + // An Admin, with no entry at all. + fresh({ ownerEmail: "owner@orgo.ai" }); + kept.length = 0; + await memberEvent(event({ act: "githubPull", app: "site" }, { member: { userId: "user_ada", email: "ada@example.com", name: "Ada" }, role: "admin", key: "key_ghp_ada1" }), "e6"); + await settleApps(); + assert.deepEqual(did.filter((d) => d[0] === "pullGithubMirror"), [["pullGithubMirror", "ws_acme", "site", "bops; Ada"]]); + // Refused, said on the app: no switch, at Read, an app of more sites. Nothing done. + const no = async (o, act, said) => { + fresh({ ownerEmail: "owner@orgo.ai", ...o }); + kept.length = 0; + await memberEvent(TOM_EV(act), "e"); + await settleApps(); + assert.ok(!did.some((d) => d[0] === "linkGithubMirror" || d[0] === "pullGithubMirror"), said); + assert.deepEqual([kept[0]?.[2].ok, kept[0]?.[2].said], [false, said]); + }; + await no({}, { act: "githubPull", app: "site" }, "Only the owner, an Admin, or someone with App secrets can do this."); + await no({ tomApps: "read", tomCan: { appSecrets: true } }, { act: "githubPull", app: "site" }, "The owner took away Write on Your Apps."); + await no({ tomCan: { appSecrets: true } }, { act: "githubLink", app: "shop" }, "It runs more than one site, and one GitHub folder is one app."); + // Where it publishes is Bops Cloud's to write (cloud/members.ts githubHere): this Mac never does it from an event. + fresh({ ownerEmail: "owner@orgo.ai", tomCan: { appSecrets: true } }); + await memberEvent(TOM_EV({ act: "github", app: "site", github: { repo: "example-org/other", branch: "main", path: "" } }), "e7"); + await settleApps(); + assert.deepEqual(did, []); +}); + /* ---------------- An Admin (Orgo's role, MemberActPayload.role) ---------------- */ const ADA = { userId: "user_ada", email: "ada@example.com", name: "Ada" }; diff --git a/scripts/test-runner-chat.mjs b/scripts/test-runner-chat.mjs index 941835d93..289734a77 100644 --- a/scripts/test-runner-chat.mjs +++ b/scripts/test-runner-chat.mjs @@ -18,7 +18,7 @@ for (const k of Object.keys(process.env)) if (/^(BOPS|ORGO)_/.test(k)) delete pr process.env.BOPS_RUNNER = "1"; const root = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); -const FAKES = { store: ["getState", "pullState", "stateReady"], cloud: ["cloudFetch"], "free-hours": ["sayInUse"] }; +const FAKES = { store: ["getState", "pullState", "stateReady", "addMessage"], cloud: ["cloudFetch"], "free-hours": ["sayInUse"] }; registerHooks({ resolve(specifier, context, next) { if (specifier === "server-only") return { url: "data:text/javascript,", shortCircuit: true }; @@ -73,7 +73,8 @@ const answers = new Map(); let stream = null; const enc = new TextEncoder(); globalThis.__fake = { - store: { getState: () => state, pullState: async () => (pulls++, onPull()), stateReady: () => true }, + // addMessage as the store's own: its row stamped with the turn it's added in (turn-context.ts stamp). + store: { getState: () => state, pullState: async () => (pulls++, onPull()), stateReady: () => true, addMessage: (m) => (state.messages.push(globalThis.__stamp({ id: `msg_${state.messages.length}`, at: Date.now(), ...m })), state.messages.at(-1)) }, "free-hours": { sayInUse: async () => {} }, cloud: { cloudFetch: async (path, init = {}) => { @@ -100,6 +101,7 @@ globalThis.__fake = { }; const TC = await import(`${root}/lib/server/turn-context.ts`); +globalThis.__stamp = TC.stamp; const R = await import(`${root}/lib/server/runner-chat.ts`); /** Bops Cloud's answer to a claim: its clock `ahead` ms ahead of this computer's. */ @@ -447,5 +449,55 @@ await check("the stream gives it turns, turn-cancel stops one, and state reads t R.stopRunnerChat(); }); +/* ---------------- An app's GitHub job ---------------- */ + +await check("a turn that's an app's GitHub job runs the job, never the model: it says at once it's acting, its words go in the chat as Bops' own, and its result goes with its end", async () => { + reset(); + const T = Date.now(); + // Its message is Bops Cloud's note of who asked (role system): the job needs none of it. + state.messages.push({ id: "m_job", chatId: "bot:max", role: "system", botId: "max", text: "Ada asked Max's computer to bring in GitHub's changes to site.", at: T }); + answers.set("claim", claimed("t_job", "m_job", T, 0, { userId: "u-ada", name: "Ada", ref: "k_job", projectId: "p1", role: "admin", job: { act: "githubPull", app: "site" } })); + let answered = 0; + let asked; + R.runnerChatForTests({ + identity: async () => "same", + answer: async () => (answered++, { text: "never" }), + job: async (botId, job, by) => { + asked = { botId, job, by, turn: TC.currentTurn()?.id, beats: paths().filter((p) => p === "beat").length }; + return { ok: true, said: "Brought 2 changes from GitHub into Ingot. Deploy the newest to the preview to see them there." }; + }, + }); + await R.answerTurn(ev("t_job", "m_job")); + assert.equal(answered, 0, "no model saw it"); + assert.deepEqual(asked, { botId: "max", job: { act: "githubPull", app: "site" }, by: "Ada", turn: "t_job", beats: 1 }, "in Ada's name, as the turn, once it said it's acting"); + assert.deepEqual(sent.find((x) => x.path.endsWith("/beat"))?.body, { phase: "tools" }); + const rows = state.messages.filter((m) => m.role === "bot"); + assert.equal(rows.length, 1); + assert.deepEqual([rows[0].text, rows[0].appNote, rows[0].bops, rows[0].turn, rows[0].botId], ["Brought 2 changes from GitHub into Ingot. Deploy the newest to the preview to see them there.", true, true, "t_job", "max"]); + assert.ok(rows[0].at > T, "after the note that asked"); + assert.deepEqual(sent.at(-1), { path: "/v1/runner/turns/t_job/done", body: { state: "done", job: { ok: true, said: "Brought 2 changes from GitHub into Ingot. Deploy the newest to the preview to see them there." } } }); + assert.equal(TC.turnsRunning(), false); +}); + +await check("a job that doesn't go says why in the chat and with its end; one taken back before it starts does nothing, and says nothing more", async () => { + reset(); + const T = Date.now(); + answers.set("claim", claimed("t_job2", "m_job2", T, 0, { userId: "u-sam", name: "Sam", ref: "k_job2", job: { act: "githubLink", app: "site" } })); + R.runnerChatForTests({ identity: async () => "same", job: async () => ({ ok: false, said: "Ingot and GitHub differ in lib/look.ts. Make them the same first (or add them to the files left alone), then link." }) }); + await R.answerTurn(ev("t_job2", "m_job2")); + const row = state.messages.find((m) => m.role === "bot"); + assert.equal(row.text, "Couldn't link site to GitHub: Ingot and GitHub differ in lib/look.ts. Make them the same first (or add them to the files left alone), then link."); + assert.ok(!/[\u2013\u2014]| - /.test(row.text), "no dashes"); + assert.deepEqual(sent.at(-1).body, { state: "done", job: { ok: false, said: "Ingot and GitHub differ in lib/look.ts. Make them the same first (or add them to the files left alone), then link." } }); + // Taken back (its member's access changed) before it acts: nothing runs, and its end isn't said (Bops Cloud said it). + reset(); + let ran = 0; + answers.set("claim", claimed("t_job3", "m_job3", T, 0, { userId: "u-sam", name: "Sam", ref: "k_job3", job: { act: "githubPull", app: "site" } })); + answers.set("beat", { status: 409, body: { code: "over" } }); + R.runnerChatForTests({ identity: async () => "same", job: async () => (ran++, { ok: true, said: "x" }) }); + await R.answerTurn(ev("t_job3", "m_job3")); + assert.deepEqual([ran, paths(), state.messages.length], [0, ["claim", "beat"], 0]); +}); + console.log(`runner chat: ${passed} passed, ${failures} failed`); process.exit(failures ? 1 : 0); diff --git a/scripts/test-shared.mjs b/scripts/test-shared.mjs index 235a4fbab..7907decb6 100644 --- a/scripts/test-shared.mjs +++ b/scripts/test-shared.mjs @@ -1985,6 +1985,32 @@ await check("Your Apps in a shared workspace: shown with the apps the owner gave assert.equal(gone.status, 404); }); +await check("GitHub in Your Apps: the window has it (SharedOpen.appGithub) only at Write; a Link sent lands once Bops Cloud keeps it on the app as going, by its key; a GitHub act is refused from the copy at Read", async () => { + const shop = { name: "shop", github: { repo: "example-org/web", branch: "main", path: "formula" }, sites: [], commits: [], deploys: [] }; + const withApps = (level, flag, acts) => ({ ...cloudState(), hash: `h-gh-${level}-${flag}-${acts?.length ?? 0}`, state: { ...cloudState().state, ...(flag ? { appGithub: true } : {}), apps: { level, at: 10, list: [{ ...shop, ...(acts ? { acts } : {}) }] } } }); + let answer = { status: 200, body: withApps("write", true) }; + const copy = new SharedCopy(async () => answer); + await copy.open({ ...listed, owner: "dana@orgo.ai" }); + const team = () => ({ ...own(), account: { user: { id: "u-sam", name: "Sam", email: "sam@orgo.ai" } } }); + assert.equal(viewState(team(), copy.openNow(), copy.data(), {}, copy.acts()).shared.appGithub, true); + const sent = []; + await actWith(copy, { act: "githubLink", app: "shop" }, "key_ghlink_01", sam, async (body) => (sent.push(JSON.parse(body)), { status: 202, body: { ok: true, macOn: true, stored: true, computer: true, turn: "turn_x" } })); + assert.deepEqual(sent, [{ key: "key_ghlink_01", act: { act: "githubLink", app: "shop" } }]); + assert.deepEqual(viewState(team(), copy.openNow(), copy.data(), {}, copy.acts()).shared.appActs.map((a) => [a.key, a.state]), [["key_ghlink_01", "sending"]]); + answer = { status: 200, body: withApps("write", true, [{ key: "key_ghlink_01", userId: "u-sam", name: "Sam", act: "githubLink", ok: true, going: true, at: 11 }]) }; + await copy.refresh(); + assert.equal(viewState(team(), copy.openNow(), copy.data(), {}, copy.acts()).shared.appActs, undefined, "landed: its computer's going row shows now"); + // Without the flag (no power, or the owner's Bops older): none in the window. At Read: none, and the copy refuses one. + answer = { status: 200, body: withApps("write", false) }; + await copy.refresh(); + assert.equal(viewState(team(), copy.openNow(), copy.data(), {}, copy.acts()).shared.appGithub, undefined); + answer = { status: 200, body: withApps("read", true) }; + await copy.refresh(); + assert.equal(viewState(team(), copy.openNow(), copy.data(), {}, copy.acts()).shared.appGithub, undefined, "never at Read"); + const no = await actWith(copy, { act: "githubPull", app: "shop" }, "key_ghpull_02", sam, async () => assert.fail("not sent")); + assert.deepEqual([no.status, no.code], [403, "member_apps_read_only"]); +}); + await check("the bot's browser in a shared workspace: a Member with Write and Use its computer has it in place of the computer, an Admin beside it, anyone else one line saying why; its route goes out", async () => { const { browserBeside, browsesHere } = await import(pathToFileURL(`${root}/components/app/panel-tabs.tsx`).href); const at = (levels, computer, role = "member", screens) => ({ shared: { projectId: "p1", name: "Acme", owner: "o@example.com", role, wsId: "shared_p1", levels, computer, ...(screens ? { screens } : {}) } }); diff --git a/scripts/test-state-merge.mjs b/scripts/test-state-merge.mjs index cbcd92502..384840c50 100644 --- a/scripts/test-state-merge.mjs +++ b/scripts/test-state-merge.mjs @@ -577,6 +577,31 @@ check("an older Bops's kept copy keeps the team chart it doesn't know, and never assert.deepEqual(M.merge3(base, odd, newer, OLD).workspaces[0].chain, newer.workspaces[0].chain); }); +/* ---------------- An app's GitHub setting: one value, kept as Bops Cloud wrote it ---------------- */ + +check("an app's GitHub setting Bops Cloud kept (an Admin's: cloud/members.ts githubHere) stays through this Mac's save of other fields of the app; changed on both it's never a mix: the side that moved where it publishes goes whole over one that only moved its point", () => { + const G = { repo: "example-org/web", branch: "main", path: "formula", keep: ["proxy.ts"], last: { sha: "a1", ingot: "b1", keep: ["proxy.ts"], when: 1 } }; + const app = (more) => ({ botId: "max", computerId: "c1", previewLive: null, github: G, ...more }); + const blob = (shop) => ({ workspaces: [{ id: "ws_acme", name: "Acme", hostedApps: { shop } }] }); + const shopOf = (b) => b.workspaces[0].hostedApps.shop; + const base = blob(app()); + const elsewhere = { repo: "example-org/other", branch: "main", path: "" }; + // This Mac's Preview deploy moved; Bops Cloud pointed it elsewhere for an Admin (its point gone): both kept. + const merged = shopOf(M.merge3(base, blob(app({ previewLive: { id: "d2" } })), blob(app({ github: elsewhere })))); + assert.deepEqual([merged.previewLive, merged.github], [{ id: "d2" }, elsewhere]); + // This Mac published meanwhile (its point moved): the new destination goes whole, never with the old repo's point. + const published = { ...G, last: { sha: "c1", ingot: "d1", keep: ["proxy.ts"], when: 2 } }; + assert.deepEqual(shopOf(M.merge3(base, blob(app({ github: published })), blob(app({ github: elsewhere })))).github, elsewhere); + assert.deepEqual(shopOf(M.merge3(base, blob(app({ github: elsewhere })), blob(app({ github: published })))).github, elsewhere, "whichever side merges"); + // Back on Orgo (removed) while this Mac moved the point: removed. + assert.equal(shopOf(M.merge3(base, blob(app({ github: published })), blob(app({ github: undefined })))).github, undefined); + // Both moved only the point (this Mac published, the app's computer brought GitHub's changes in): one side's whole, + // never one's commit with the other's changeset: this Mac's, an older Bops's theirs. + const pulled = { ...G, last: { sha: "e1", ingot: "f1", keep: ["proxy.ts"], when: 3 } }; + assert.deepEqual(shopOf(M.merge3(base, blob(app({ github: published })), blob(app({ github: pulled })))).github, published); + assert.deepEqual(shopOf(M.merge3(base, blob(app({ github: published })), blob(app({ github: pulled })), OLD)).github, pulled); +}); + /* ---------------- A bot's settings page: kept on its workspace, by bot id ---------------- */ check("a bot's settings page settings (Workspace.botSettings): two Macs setting different bots keep both, field by field", () => {