Skip to content
Merged
14 changes: 10 additions & 4 deletions app/api/hosted-apps/[name]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions app/api/keys/apps/route.ts
Original file line number Diff line number Diff line change
@@ -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=<name>|* → { 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);
}
}
79 changes: 79 additions & 0 deletions cloud/github-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
/** (`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<GithubMirror, "last"> } | { why: string } {
const m = v as Partial<GithubMirror> | 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<GithubMirror, "repo" | "branch" | "path" | "keep">;

/**
* 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<GithubMirror, "last"> | null | undefined, next: Omit<GithubMirror, "last">): 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";
5 changes: 3 additions & 2 deletions cloud/member-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -357,7 +358,7 @@ export const POWER_WORDS: Readonly<Record<Power, { name: string; tip: string }>>
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." },
};
Expand Down
Loading
Loading