Public: anyone can read this; the crew can change it.
A new forge comes with a crew that builds at once: agents made, autonomy on, the plan's included credit to start, and one obvious place to add your own model key #11165
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11154-a-new-forge-comes-with-a-crew-that-build → main 6 commits fork
26 files
changed +758
−34
docs/MCP.md +1 −1
| @@ -133,7 +133,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 133 | 133 | | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 | |
| 134 | 134 | | `lift` | — | Lift an issue out from behind the Later gate | 214 | |
| 135 | 135 | | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 | |
| 136 | | `limits` | — | Every limit the owner can hit, value and bounds | 949 | | |
| 136 | | `limits` | — | Every limit the owner can hit, value and bounds | 977 | | |
| 137 | 137 | | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 | |
| 138 | 138 | | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 | |
| 139 | 139 | | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 | |
docs/features/starter-crew.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # A new forge builds at once (#11154) | |
| 2 | ||
| 3 | A hosted forge is made with a crew: a `builder` and a `reviewer`, seated on every app its owner makes, with autonomy on (`src/spawn/starter_crew.ts`). A forge handed a crew by its host keeps that one. | |
| 4 | ||
| 5 | **Bring your own key** is the main road. The forge's home shows the one field when the crew has nothing to run on: paste a model API key or a Claude setup token. Every other page (a new app, an app, Subscriptions) says it in a line and links to the home. The key is checked at the boundary (one line of printable text, at most 512 bytes), sealed in that forge (`POST /settings/crew/key`, `src/web/studio/crew_key.ts`, saved by `src/actions/crew_key.ts`), offered to the owner's crew and the spawned organisation's, and never leaves it. Admin only: anyone else gets the not-found page. | |
| 6 | ||
| 7 | **Both doors.** The terminal's Crew key screen (`src/tui/screens/crewkey.ts`, palette entry "Crew key") shows what the crew runs on and takes the key with `a`, typed masked, through the same `saveCrewKey`. It is offered to a forge admin who is a person, as the page is. | |
| 8 | ||
| 9 | **Included credit** is the on-ramp. When no key was handed over, the host lends the new forge `spawn.included_credit_usd` dollars (default 2, once; 0 turns it off) through the child credit (`src/spawn/included_credit.ts`). The crew runs on the host's model door up to that amount; past it the door answers 402 and the forge says: add your own key to keep building, or wait for next month. The forge never holds the host's key, only a capped bearer. Settings → Limits shows the worst case a day: new accounts an hour × 24 × forges a person may own × the credit. | |
| 10 | ||
| 11 | Try: open `/` on a new forge, paste a key, write one ask. |
src/actions/crew_key.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * The owner's own model key, saved from either door (#11154): the web form | |
| 3 | * (`POST /settings/crew/key`) and the terminal screen. A forge admin only; | |
| 4 | * the key is checked at the boundary, sealed by `addSubscription`, and | |
| 5 | * offered to the crew of the admin's apps and of the spawned organisation. | |
| 6 | * Never logged, never echoed. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { addSubscription, getSubscriptionByName, setGrants } from "../core/subscriptions.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { spawnedOrg } from "../spawn/claim.ts"; | |
| 13 | ||
| 14 | const KEY_NAME = "my-key"; | |
| 15 | /** The most bytes a key may be; the longest real one is well under it. */ | |
| 16 | export const KEY_MAX_BYTES = 512; | |
| 17 | ||
| 18 | export type CrewKeyError = "not_found" | "invalid_token"; | |
| 19 | ||
| 20 | /** A key is one line of printable text, within {@link KEY_MAX_BYTES}. */ | |
| 21 | export function keyShapeOk(raw: string): boolean { | |
| 22 | const token = raw.trim(); | |
| 23 | return token !== "" && Buffer.byteLength(token) <= KEY_MAX_BYTES && !/[\u0000-\u001f\u007f-\u009f]/.test(token); | |
| 24 | } | |
| 25 | ||
| 26 | export async function saveCrewKey( | |
| 27 | app: App, | |
| 28 | user: User | undefined, | |
| 29 | raw: string, | |
| 30 | ): Promise<{ ok: true } | { ok: false; error: CrewKeyError }> { | |
| 31 | if (user === undefined || !user.isAdmin || user.kind === "agent") return { ok: false, error: "not_found" }; | |
| 32 | if (!keyShapeOk(raw)) return { ok: false, error: "invalid_token" }; | |
| 33 | const name = getSubscriptionByName(app.db, KEY_NAME) === undefined ? KEY_NAME : `${KEY_NAME}-${Date.now().toString(36)}`; | |
| 34 | const added = addSubscription(app.db, app.config.dataDir, { name, token: raw.trim(), createdBy: user.id }); | |
| 35 | if (!added.ok) return { ok: false, error: "invalid_token" }; | |
| 36 | const spawned = await spawnedOrg(app); | |
| 37 | for (const scope of [user.id, ...(spawned === undefined ? [] : [spawned.org.id])]) { | |
| 38 | const held = app.db | |
| 39 | .query<{ subscription_id: string }, [string]>("SELECT subscription_id FROM subscription_grant WHERE scope_id = ?1 AND agent_id = ''") | |
| 40 | .all(scope) | |
| 41 | .map((r) => r.subscription_id); | |
| 42 | setGrants(app.db, scope, "", [...held, added.value.id]); | |
| 43 | } | |
| 44 | return { ok: true }; | |
| 45 | } |
src/actions/repos.ts +2
| @@ -78,6 +78,7 @@ import { | ||
| 78 | 78 | import { validPolishShare } from "../crew/polish_share.ts"; |
| 79 | 79 | import { removeInstallCache } from "../crew/workdir.ts"; |
| 80 | 80 | import type { SettingsChanged } from "../core/settings_changed.ts"; |
| 81 | import { seatStarterCrew } from "../spawn/starter_crew.ts"; | |
| 81 | 82 | |
| 82 | 83 | export interface CreateRepoInput { |
| 83 | 84 | /** Owner name: the actor themselves (default) or an org they can write to. */ |
| @@ -180,6 +181,7 @@ export async function createRepo( | ||
| 180 | 181 | }); |
| 181 | 182 | if (!created.ok) return created; |
| 182 | 183 | storageChanged(app, ownerId); |
| 184 | seatStarterCrew(app.db, created.value, user.value.id); | |
| 183 | 185 | app.bus.append({ |
| 184 | 186 | kind: "repo.created", |
| 185 | 187 | repoId: created.value.id, |
src/actions/spawn.ts +8
| @@ -39,6 +39,8 @@ import { runGit } from "../git/env.ts"; | ||
| 39 | 39 | import { repoDir } from "../git/repos.ts"; |
| 40 | 40 | import { commitFiles } from "../git/write.ts"; |
| 41 | 41 | import { adoptForgeCode, forkForgeCode } from "../spawn/forge_code.ts"; |
| 42 | import { lendIncludedCredit } from "../spawn/included_credit.ts"; | |
| 43 | import { seedStarterCrew } from "../spawn/starter_crew.ts"; | |
| 42 | 44 | import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts"; |
| 43 | 45 | import { sha256 } from "../kernel/hash.ts"; |
| 44 | 46 | import { secret } from "../kernel/id.ts"; |
| @@ -324,6 +326,8 @@ async function make( | ||
| 324 | 326 | const copied = await copyInto(app, owner, plan, ledger, deps.modelKey, deps.modelEndpoint); |
| 325 | 327 | if (!copied.ok) return failed(app, deps, "copy", copied.error); |
| 326 | 328 | const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value); |
| 329 | // A plan carries its own credit (grantPlanCredit); the included credit is for a spawn on no plan. | |
| 330 | if (plan.plan === undefined) lendIncludedCredit(app, plan.name, childDirOf(app, ledger.bodyId), deps.modelKey !== undefined); | |
| 327 | 331 | |
| 328 | 332 | grantPlanCredit(app, plan, ledger.bodyId); |
| 329 | 333 | ledger.done = true; |
| @@ -503,6 +507,10 @@ async function copyInto( | ||
| 503 | 507 | setCrewRole(child.db, repo.id, theirs.user.id, role); |
| 504 | 508 | } |
| 505 | 509 | |
| 510 | // A forge with no crew of its own comes with a builder and a reviewer, | |
| 511 | // seated on its apps with autonomy on (#11154). | |
| 512 | await seedStarterCrew(child.db, org.id, founder.id); | |
| 513 | ||
| 506 | 514 | // The fork is the child's own source: its `forgeRepo`, and the crew seated on it. |
| 507 | 515 | adoptForgeCode(child, org.username, founder.id); |
| 508 | 516 |
src/http/settings_routes.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * in the pattern list the usage page reads, so this module never imports it. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { crewKeyAdd } from "../web/studio/crew_key.ts"; | |
| 10 | 11 | import type { Handler } from "./context.ts"; |
| 11 | 12 | import type { Router } from "./router.ts"; |
| 12 | 13 | import { parentLinkStart } from "./rp.ts"; |
| @@ -174,6 +175,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 174 | 175 | // The crew: a forge admin's section, hidden from everyone else. |
| 175 | 176 | router.add("GET", "/settings/subscriptions", subscriptionsPage); |
| 176 | 177 | router.add("POST", "/settings/subscriptions", subscriptionAdd); |
| 178 | router.add("POST", "/settings/crew/key", crewKeyAdd); | |
| 177 | 179 | router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete); |
| 178 | 180 | router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake); |
| 179 | 181 | router.add( |
src/settings/limits.ts +9
| @@ -26,6 +26,7 @@ export type LimitKey = | ||
| 26 | 26 | | "crew.concurrency" |
| 27 | 27 | | "checks.concurrency" |
| 28 | 28 | | "agent.budget_max_usd" |
| 29 | | "spawn.included_credit_usd" | |
| 29 | 30 | | "subscription.budget_max_usd" |
| 30 | 31 | | "app.members_max" |
| 31 | 32 | | "front.pinned_max" |
| @@ -95,6 +96,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 95 | 96 | max: 1_000_000, |
| 96 | 97 | reason: "A crew member's per-run budget is spent unattended, so what one run may cost has a top.", |
| 97 | 98 | }, |
| 99 | "spawn.included_credit_usd": { | |
| 100 | label: "Crew credit a new forge comes with", | |
| 101 | unit: "dollars", | |
| 102 | default: 2, | |
| 103 | min: 0, | |
| 104 | max: 1_000, | |
| 105 | reason: "A new forge's crew runs on this forge's model access until its owner adds a key; 0 turns it off. Worst case a day is the visitor limit times this.", | |
| 106 | }, | |
| 98 | 107 | "subscription.budget_max_usd": { |
| 99 | 108 | label: "Pool account budget ceiling", |
| 100 | 109 | unit: "dollars", |
src/spawn/included_credit.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * The crew credit a new forge comes with (#11154): the `spawn.included_credit_usd` | |
| 3 | * limit, lent through the existing child credit (`spawn/credit.ts`) when the | |
| 4 | * forge was not handed a model key of its own. Lent once: a retry or a | |
| 5 | * restart finds the credit and leaves it, spent or not. The child never sees | |
| 6 | * the host's key, only a bearer good for the host's model door, capped. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { limitValue, type LimitKey } from "../settings/limits.ts"; | |
| 11 | import { readCredit, setCap } from "./credit.ts"; | |
| 12 | import { keepFunding } from "./funding.ts"; | |
| 13 | ||
| 14 | /** The dollars lent, or 0 when none (off, already lent, or the forge brought its own key). */ | |
| 15 | export function lendIncludedCredit(app: App, name: string, childDir: string, hasOwnKey: boolean): number { | |
| 16 | if (hasOwnKey || readCredit(app, name) !== undefined) return 0; | |
| 17 | const usd = limitValue(app.db, "spawn.included_credit_usd"); | |
| 18 | if (!(usd > 0)) return 0; | |
| 19 | const set = setCap(app, name, usd, false, (token, credit) => | |
| 20 | keepFunding(childDir, { token, capUsd: credit.capUsd, spentUsd: credit.spentUsd }), | |
| 21 | ); | |
| 22 | return typeof set === "object" ? set.capUsd : 0; | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * The most the included credit can cost in a day: new accounts an hour for | |
| 27 | * the whole forge, over 24 hours, each with the forges one person may own, | |
| 28 | * each lent the credit. Every term is a limit an admin sets here. | |
| 29 | */ | |
| 30 | export function worstCaseDailyUsd(app: App): number { | |
| 31 | return worstCaseOf((key) => limitValue(app.db, key)); | |
| 32 | } | |
| 33 | ||
| 34 | /** The same figure from limit values already read (the limits page has them all). */ | |
| 35 | export function worstCaseOf(value: (key: LimitKey) => number): number { | |
| 36 | return value("signup.per_forge_hour") * 24 * value("forges.per_person") * value("spawn.included_credit_usd"); | |
| 37 | } |
src/spawn/starter_crew.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The crew a new forge comes with (#11154): a builder and a reviewer, seated | |
| 3 | * on every app the owner makes, with autonomy on, so the first ask is taken | |
| 4 | * with no setup. Written into a child's own database by the parent at spawn | |
| 5 | * (`seedStarterCrew`); the owner's organisation carries a mark, and a | |
| 6 | * repository made later under it is seated the same way (`seatStarterCrew`, | |
| 7 | * called by `createRepo`). A forge that already has crew is left as it is. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { addCrew, createAgentRow, getAgentByName, listAgents, setCrewRole, type CrewRole } from "../core/agents.ts"; | |
| 12 | import { getRepoById, listReposOwnedBy, updateRepoSettings, type Repo } from "../core/repos.ts"; | |
| 13 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; | |
| 14 | import { createUser } from "../core/users.ts"; | |
| 15 | import { FORGE_CODE_NAME } from "./forge_code.ts"; | |
| 16 | ||
| 17 | const MARK = "starter_crew"; | |
| 18 | ||
| 19 | interface Seat { | |
| 20 | readonly name: string; | |
| 21 | readonly role: CrewRole; | |
| 22 | readonly brief: string; | |
| 23 | } | |
| 24 | ||
| 25 | export const STARTER_SEATS: readonly Seat[] = [ | |
| 26 | { | |
| 27 | name: "builder", | |
| 28 | role: "builder", | |
| 29 | brief: | |
| 30 | "You build what the issue asks, in the smallest change that works. Read the repository first, keep its shape, add a test for every behaviour you add, and run the tests before you push.", | |
| 31 | }, | |
| 32 | { | |
| 33 | name: "reviewer", | |
| 34 | role: "reviewer", | |
| 35 | brief: | |
| 36 | "You review pulls against the issue they answer. Check that behaviour is tested, that inputs are validated and outputs escaped, and that nothing beyond the ask slipped in. Say plainly what to change.", | |
| 37 | }, | |
| 38 | ]; | |
| 39 | ||
| 40 | /** Whether this organisation was given the starter crew, so what it makes is seated on creation. */ | |
| 41 | export const hasStarterCrew = (db: Database, orgId: string): boolean => getUserSetting(db, orgId, MARK) === "on"; | |
| 42 | ||
| 43 | /** | |
| 44 | * Make the builder and reviewer when the forge has no crew at all, and mark | |
| 45 | * the organisation. Returns whether the crew exists afterwards. | |
| 46 | */ | |
| 47 | export async function seedStarterCrew(db: Database, orgId: string, founderId: string): Promise<boolean> { | |
| 48 | if (listAgents(db).length === 0) { | |
| 49 | for (const seat of STARTER_SEATS) { | |
| 50 | const made = await createUser(db, { | |
| 51 | username: seat.name, | |
| 52 | email: `${seat.name}@agents.spawn.forge.invalid`, | |
| 53 | agent: true, | |
| 54 | origin: "spawn", | |
| 55 | }); | |
| 56 | if (!made.ok) return false; | |
| 57 | createAgentRow(db, { userId: made.value.id, brief: seat.brief, roles: [seat.role], createdBy: founderId }); | |
| 58 | } | |
| 59 | } | |
| 60 | if (STARTER_SEATS.some((s) => getAgentByName(db, s.name) === undefined)) return false; | |
| 61 | setUserSetting(db, orgId, MARK, "on"); | |
| 62 | for (const repo of listReposOwnedBy(db, orgId)) seatStarterCrew(db, repo, founderId); | |
| 63 | return true; | |
| 64 | } | |
| 65 | ||
| 66 | /** Seat the starter crew on one repository and turn autonomy on, unless a person already chose either. */ | |
| 67 | export function seatStarterCrew(db: Database, repo: Repo, founderId: string): void { | |
| 68 | // The forge's own code is not an app the owner made: it is seated by `adoptForgeCode`, autonomy as the owner sets it. | |
| 69 | if (!hasStarterCrew(db, repo.ownerId) || repo.name === FORGE_CODE_NAME) return; | |
| 70 | for (const seat of STARTER_SEATS) { | |
| 71 | const agent = getAgentByName(db, seat.name); | |
| 72 | if (agent === undefined) continue; | |
| 73 | addCrew(db, repo.id, agent.user.id, founderId, seat.role); | |
| 74 | setCrewRole(db, repo.id, agent.user.id, seat.role); | |
| 75 | } | |
| 76 | const fresh = getRepoById(db, repo.id) ?? repo; | |
| 77 | if (fresh.autonomy === "off" && fresh.autonomyBy === null) | |
| 78 | updateRepoSettings(db, fresh, { autonomy: "autonomous", autonomyBy: founderId }); | |
| 79 | } |
src/tui/screens/crewkey.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * The crew's model key in the terminal (#11154), the second door of | |
| 3 | * `POST /settings/crew/key`: what the crew runs on, and `a` to paste a key | |
| 4 | * (typed masked, never shown again), through the same `saveCrewKey` the page | |
| 5 | * uses. A forge admin who is a person only, as the page is. | |
| 6 | */ | |
| 7 | ||
| 8 | import { saveCrewKey } from "../../actions/crew_key.ts"; | |
| 9 | import { crewHasCredential } from "../../core/subscriptions.ts"; | |
| 10 | import { usdWords } from "../../spawn/credit.ts"; | |
| 11 | import { includedCredit } from "../../web/studio/crew_key.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { promptScreen } from "./common.ts"; | |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | ||
| 20 | const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user"; | |
| 21 | ||
| 22 | /** What the crew runs on, in one line. */ | |
| 23 | export function crewKeyLine(f: Session): string { | |
| 24 | if (crewHasCredential(f.app.db)) return "The crew runs on your key. a adds another."; | |
| 25 | const credit = includedCredit(f.app); | |
| 26 | if (credit !== undefined && credit.leftUsd > 0) | |
| 27 | return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit. a adds your own key.`; | |
| 28 | if (credit !== undefined) return "The included credit is spent. a adds your own key to keep building."; | |
| 29 | return "The crew needs a model key to start. a adds yours."; | |
| 30 | } | |
| 31 | ||
| 32 | export function crewKeyScreen(f: Session, ctx: Ctx): Screen { | |
| 33 | let note = ""; | |
| 34 | const screen: Screen = { | |
| 35 | title: "Crew key", | |
| 36 | keys: [binding(["a"], "add your key"), binding(["esc"], "back")], | |
| 37 | render(area, s) { | |
| 38 | const c = ctx.theme.c; | |
| 39 | s.text(area.x, area.y, truncate(note !== "" ? note : crewKeyLine(f), area.w), style(c.mutedFg, c.bg), area.x + area.w); | |
| 40 | }, | |
| 41 | key(e) { | |
| 42 | if (e.ctrl || e.alt || e.key !== "a" || !isAdmin(f)) return false; | |
| 43 | ctx.push( | |
| 44 | promptScreen(ctx, screen, { | |
| 45 | title: "Your model key", | |
| 46 | label: "API key or Claude token (never shown again)", | |
| 47 | mask: true, | |
| 48 | onSubmit: (token) => | |
| 49 | saveCrewKey(f.app, f.user, token).then((saved) => { | |
| 50 | note = saved.ok ? "Key saved. The crew can work now." : saved.error === "not_found" ? "Only a forge admin adds a key." : "That key could not be saved. Check that it is the whole key."; | |
| 51 | ctx.invalidate(); | |
| 52 | }), | |
| 53 | }), | |
| 54 | ); | |
| 55 | return true; | |
| 56 | }, | |
| 57 | }; | |
| 58 | return screen; | |
| 59 | } | |
| 60 | ||
| 61 | /** The crew's key: what it runs on, and add yours. */ | |
| 62 | export const entry: PaletteEntry = { | |
| 63 | id: "crewkey", | |
| 64 | title: "Crew key", | |
| 65 | hint: "what the crew runs on; add your own model key", | |
| 66 | after: ["subscriptions"], | |
| 67 | offered: isAdmin, | |
| 68 | run: (session, ctx) => ctx.push(crewKeyScreen(session, ctx)), | |
| 69 | }; |
src/tui/screens/entries.ts +2
| @@ -77,6 +77,7 @@ import { entry as securityControls } from "./controls.ts"; | ||
| 77 | 77 | import { entry as secrets } from "./secrets.ts"; |
| 78 | 78 | import { entry as sentinel } from "./sentinel.ts"; |
| 79 | 79 | import { entry as subscriptions } from "./subscriptions.ts"; |
| 80 | import { entry as crewKey } from "./crewkey.ts"; | |
| 80 | 81 | import { entry as system } from "./system.ts"; |
| 81 | 82 | import { entry as teams } from "./teams.ts"; |
| 82 | 83 | import { entry as telemetry } from "./telemetry.ts"; |
| @@ -150,6 +151,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 150 | 151 | requests, |
| 151 | 152 | bulk, |
| 152 | 153 | subscriptions, |
| 154 | crewKey, | |
| 153 | 155 | cloud, |
| 154 | 156 | pinned, |
| 155 | 157 | bets, |
src/web/pages/admin-limits.ts +3
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | * who, when, from what to what. Anyone else gets the not-found page. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { usdWords } from "../../spawn/credit.ts"; | |
| 9 | import { worstCaseOf } from "../../spawn/included_credit.ts"; | |
| 8 | 10 | import { QUOTA_INVALID } from "../../actions/admin_limits_words.ts"; |
| 9 | 11 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 10 | 12 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| @@ -201,6 +203,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 201 | 203 | ${historyBlock("Invite links made", inviteRows)} |
| 202 | 204 | <section class="settings-block mt"> |
| 203 | 205 | <h2>Limits</h2> |
| 206 | <p class="small muted mb" id="included-credit-worst">Worst case a day for the crew credit new forges come with: ${usdWords(worstCaseOf((k) => limits.find((l) => l.key === k)?.value ?? 0))} (new accounts an hour × 24 × forges a person may own × the credit).</p> | |
| 204 | 207 | <div class="table-scroll"> |
| 205 | 208 | <table class="table setting-table" data-dense> |
| 206 | 209 | <caption class="sr-only">Limits, their values and how to change them</caption> |
src/web/pages/apps.ts +2
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * 404 for everyone. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { crewKeyCard } from "../studio/crew_key.ts"; | |
| 15 | 16 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | 17 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 17 | 18 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| @@ -328,6 +329,7 @@ function newAppForm( | ||
| 328 | 329 | ${pageHead("New app", { |
| 329 | 330 | sub: "Describe what it should do. You get a repository, a first issue, a crew member on it, a live app, and a preview for every change.", |
| 330 | 331 | })} |
| 332 | ${crewKeyCard(ctx, true)} | |
| 331 | 333 | ${html`<form method="post" action="/apps/new"> |
| 332 | 334 | ${values.template |
| 333 | 335 | ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true }) |
src/web/pages/claim.ts +5 −2
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | * so before anyone submits, and where to remove it. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { crewCanRun, hasCrew } from "../studio/crew_key.ts"; | |
| 21 | 22 | import { PASSWORD_TOO_LONG, withinPasswordCap } from "../../core/hash-gate.ts"; |
| 22 | 23 | import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts"; |
| 23 | 24 | import type { Credential } from "../../auth/webauthn.ts"; |
| @@ -152,8 +153,10 @@ function claimForm( | ||
| 152 | 153 | ${pageHead("This forge is yours")} |
| 153 | 154 | <section class="settings-block"> |
| 154 | 155 | <p class="mb"> |
| 155 | ${spawned.org.username} was made for you, with its apps and a crew | |
| 156 | ready to build them. Make your account to take it: this first account | |
| 156 | ${spawned.org.username} was made for you, with its apps and | |
| 157 | ${crewCanRun(ctx.app) && hasCrew(ctx.app) | |
| 158 | ? "a crew ready to build them" | |
| 159 | : "a crew that builds them once you add a model key"}. Make your account to take it: this first account | |
| 157 | 160 | is the forge's admin, so it can change everything here. |
| 158 | 161 | </p> |
| 159 | 162 | <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p> |
src/web/pages/subscriptions.ts +12 −2
| @@ -60,6 +60,8 @@ import { whenText } from "../../crew/limits.ts"; | ||
| 60 | 60 | import { accountSections } from "./origins.ts"; |
| 61 | 61 | import { liveRegionsJs } from "../client.ts"; |
| 62 | 62 | import { markOf } from "../../core/marks.ts"; |
| 63 | import { crewKeyCard, includedCredit, isHostedForge } from "../studio/crew_key.ts"; | |
| 64 | import { usdWords } from "../../spawn/credit.ts"; | |
| 63 | 65 | import { limitValue } from "../../settings/limits.ts"; |
| 64 | 66 | |
| 65 | 67 | const POOL = "/settings/subscriptions"; |
| @@ -108,8 +110,11 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 108 | 110 | const denied = requireAdmin(ctx); |
| 109 | 111 | if (denied) return denied; |
| 110 | 112 | const now = new Date(); |
| 113 | const included = includedCredit(ctx.app); | |
| 114 | // On a hosted forge the platform row is the host's included credit, said | |
| 115 | // as what is left of it, or it is not shown (#11154). | |
| 111 | 116 | const pool = timePhase(ctx.request, "credentials", () => |
| 112 | listCredentials(ctx.app.db), | |
| 117 | listCredentials(ctx.app.db).filter((s) => s.id !== PLATFORM || !isHostedForge(ctx.app)), | |
| 113 | 118 | ); |
| 114 | 119 | const places = timePhase(ctx.request, "places", () => |
| 115 | 120 | listGrantPlaces(ctx.app.db), |
| @@ -270,6 +275,11 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 270 | 275 | { extra: "setting-list" }, |
| 271 | 276 | ); |
| 272 | 277 | |
| 278 | const own = pool.some((s) => s.id !== PLATFORM) ? null : crewKeyCard(ctx, true); | |
| 279 | const lent = | |
| 280 | included === undefined | |
| 281 | ? null | |
| 282 | : html`<p class="small muted" id="included-credit">${icon("key")} Included crew credit: ${usdWords(included.leftUsd)} of ${usdWords(included.capUsd)} left. When it is spent the crew stops until you add your own key.</p>`; | |
| 273 | 283 | const add = html`<section class="settings-block"><h2>Add one</h2> |
| 274 | 284 | <form class="stack" method="post" action="${POOL}"> |
| 275 | 285 | <div class="field"> |
| @@ -336,7 +346,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 336 | 346 | |
| 337 | 347 | const response = shell( |
| 338 | 348 | ctx, |
| 339 | html`${list} ${none === null ? null : html`<div class="mt">${none}</div>`} | |
| 349 | html`${lent} ${own} ${isHostedForge(ctx.app) && sealed.length === 0 ? null : list} ${none === null || isHostedForge(ctx.app) ? null : html`<div class="mt">${none}</div>`} | |
| 340 | 350 | <p class="mt muted small">What they spent: <a href="/settings/spend">Usage → Spend</a>.</p> |
| 341 | 351 | <div class="mt">${add}</div> |
| 342 | 352 | ${used === null ? null : html`<div class="mt">${used}</div>`}`, |
src/web/studio/app.ts +3 −3
| @@ -61,7 +61,7 @@ import { notesCard } from "./notes.ts"; | ||
| 61 | 61 | import { adapterCard } from "./adapter.ts"; |
| 62 | 62 | import { appPanels } from "./previews.ts"; |
| 63 | 63 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| 64 | import { crewKeyNote } from "./crew.ts"; | |
| 64 | import { crewKeyCard } from "./crew_key.ts"; | |
| 65 | 65 | import { |
| 66 | 66 | notePhase, |
| 67 | 67 | takeSessionTime, |
| @@ -500,7 +500,7 @@ export async function studioApp( | ||
| 500 | 500 | ? archivedParentLine(ctx, repo) |
| 501 | 501 | : followingCard(ctx, repo, following)} |
| 502 | 502 | <div id="app-head">${head}</div> |
| 503 | ${ownerFacts ? crewKeyNote(ctx) : null} | |
| 503 | ${ownerFacts ? crewKeyCard(ctx, true) : null} | |
| 504 | 504 | <div class="stack"> |
| 505 | 505 | ${adapter} |
| 506 | 506 | ${tellRegion} |
| @@ -520,7 +520,7 @@ export async function studioApp( | ||
| 520 | 520 | <div id="app-head">${head}</div> |
| 521 | 521 | ${askingLine(ctx, repo)} |
| 522 | 522 | ${policy} |
| 523 | ${ownerFacts ? crewKeyNote(ctx) : null} | |
| 523 | ${ownerFacts ? crewKeyCard(ctx, true) : null} | |
| 524 | 524 | ${ownerFacts |
| 525 | 525 | ? egressNote( |
| 526 | 526 | ctx.app.config.dataDir, |
src/web/studio/crew.ts −16
| @@ -30,7 +30,6 @@ import { listCrewRoles, type Agent, type CrewRole } from "../../core/agents.ts"; | ||
| 30 | 30 | import { getAppByRepo } from "../../core/apps.ts"; |
| 31 | 31 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 32 | 32 | import { seatsOf, type Seat } from "../../crew/seats.ts"; |
| 33 | import { crewHasCredential } from "../../core/subscriptions.ts"; | |
| 34 | 33 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 35 | 34 | import { governorSeen } from "../../crew/governed.ts"; |
| 36 | 35 | import { crewOffInPreview } from "../../crew/preview.ts"; |
| @@ -415,18 +414,3 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html { | ||
| 415 | 414 | ${admin ? fileHoldsCard(ctx, repo, base) : null} |
| 416 | 415 | </div>`; |
| 417 | 416 | } |
| 418 | ||
| 419 | /** | |
| 420 | * One sentence when the crew has no model credential, so it cannot start: | |
| 421 | * what is missing and where it is added — for the admin, a link to it; for | |
| 422 | * anyone else, who can. Nothing when the crew can run. | |
| 423 | */ | |
| 424 | export function crewKeyNote(ctx: Ctx): Html | null { | |
| 425 | if (crewHasCredential(ctx.app.db)) return null; | |
| 426 | return html`<p class="small muted" id="crew-key-note" role="status"> | |
| 427 | ${icon("alert")} The crew cannot start work yet: it needs a subscription. | |
| 428 | ${ctx.auth.user?.isAdmin | |
| 429 | ? html`<a href="/settings/subscriptions">Add one in Subscriptions</a>.` | |
| 430 | : "Ask this forge's admin to add one."} | |
| 431 | </p>`; | |
| 432 | } |
src/web/studio/crew_key.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * Bring your own key (#11154): the one field that makes a forge's crew work, | |
| 3 | * the words for each state of the crew's means (own key, included credit | |
| 4 | * with money left, credit spent, nothing), and the POST that seals the key. | |
| 5 | * The key is sealed in this forge's database by `addSubscription`, offered to | |
| 6 | * the crew of the owner's apps, and goes nowhere else: never logged, never | |
| 7 | * echoed, never sent to the host. | |
| 8 | */ | |
| 9 | ||
| 10 | import { existsSync } from "node:fs"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import type { App } from "../../app.ts"; | |
| 13 | import { listAgents } from "../../core/agents.ts"; | |
| 14 | import { saveCrewKey } from "../../actions/crew_key.ts"; | |
| 15 | import { crewHasCredential } from "../../core/subscriptions.ts"; | |
| 16 | import { leftUsd, usdWords } from "../../spawn/credit.ts"; | |
| 17 | import { fundingOf } from "../../spawn/funding.ts"; | |
| 18 | import type { Ctx } from "../../http/context.ts"; | |
| 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 20 | import { field, readForm } from "../form.ts"; | |
| 21 | import { html, type Html } from "../html.ts"; | |
| 22 | import { icon } from "../icons.ts"; | |
| 23 | import { flashTo } from "../layout.ts"; | |
| 24 | import { btn } from "../ui.ts"; | |
| 25 | ||
| 26 | export const KEY_ACTION = "/settings/crew/key"; | |
| 27 | /** The form is the key and little else: far under the general body limit. */ | |
| 28 | const KEY_FORM_MAX = 2048; | |
| 29 | ||
| 30 | export interface Included { | |
| 31 | readonly capUsd: number; | |
| 32 | readonly leftUsd: number; | |
| 33 | } | |
| 34 | ||
| 35 | /** The credit this forge came with and what is left of it (last heard from its host), or undefined when it has none. */ | |
| 36 | export function includedCredit(app: App): Included | undefined { | |
| 37 | const f = fundingOf(app.config.dataDir); | |
| 38 | if (f === undefined || f.capUsd === null) return undefined; | |
| 39 | return { capUsd: f.capUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd ?? 0 }) }; | |
| 40 | } | |
| 41 | ||
| 42 | /** Whether a forge is a hosted child: its host's file is in its data directory. */ | |
| 43 | export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json")); | |
| 44 | ||
| 45 | /** Whether the crew has anything to run on: a key of the owner's, or included credit with money left. */ | |
| 46 | export function crewCanRun(app: App): boolean { | |
| 47 | if (crewHasCredential(app.db)) return true; | |
| 48 | const c = includedCredit(app); | |
| 49 | return c !== undefined && c.leftUsd > 0; | |
| 50 | } | |
| 51 | ||
| 52 | /** Whether the forge has a crew at all, so a line about it is true. */ | |
| 53 | export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0; | |
| 54 | ||
| 55 | const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form"> | |
| 56 | <div class="field"> | |
| 57 | <label for="crew-key">Your Anthropic API key or Claude login</label> | |
| 58 | <input class="input" id="crew-key" name="token" type="password" required maxlength="512" autocomplete="off" spellcheck="false" placeholder="sk-ant-…" /> | |
| 59 | </div> | |
| 60 | <div>${btn("Save key", { variant: "primary", size: "sm", icon: "key" })}</div> | |
| 61 | </form>`; | |
| 62 | ||
| 63 | /** What the crew runs on, said once, with the field when a key would change anything (`compact`: a line and a link, no field). */ | |
| 64 | export function crewKeyCard(ctx: Ctx, compact = false): Html | null { | |
| 65 | if (crewHasCredential(ctx.app.db)) return null; | |
| 66 | const admin = ctx.auth.user?.isAdmin === true; | |
| 67 | // On a repository page: one line and a link, the field lives on the home page. | |
| 68 | if (compact) | |
| 69 | return html`<p class="small muted" id="crew-key-note" role="status">${icon("alert")} The crew needs a model key to start. | |
| 70 | ${admin ? html`<a href="/#crew-key-note">Add one on the home page</a>.` : "Ask this forge's admin to add one."}</p>`; | |
| 71 | const credit = includedCredit(ctx.app); | |
| 72 | if (credit !== undefined && credit.leftUsd > 0) | |
| 73 | return html`<section class="settings-block" id="crew-key-note" role="status"> | |
| 74 | <p class="small muted">${icon("key")} The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit. | |
| 75 | ${admin ? "Add your own key any time to keep building after it:" : "Its admin can add a key to keep building after it."}</p> | |
| 76 | ${admin ? keyField() : null} | |
| 77 | </section>`; | |
| 78 | const said = | |
| 79 | credit !== undefined | |
| 80 | ? "The included credit is spent. Add your own key to keep building, or wait for next month." | |
| 81 | : "The crew needs a model key to start. Paste yours."; | |
| 82 | return html`<section class="settings-block" id="crew-key-note" role="status"> | |
| 83 | <p class="small muted">${icon("alert")} ${said}${admin ? "" : " Ask this forge's admin."}</p> | |
| 84 | ${admin ? keyField() : null} | |
| 85 | </section>`; | |
| 86 | } | |
| 87 | ||
| 88 | // POST /settings/crew/key | |
| 89 | export async function crewKeyAdd(ctx: Ctx): Promise<Response> { | |
| 90 | const user = ctx.auth.user; | |
| 91 | if (!user) return signInFirst(ctx); | |
| 92 | if (!user.isAdmin) return notFoundPage(ctx); | |
| 93 | const form = await readForm(ctx, "admin", KEY_FORM_MAX); | |
| 94 | if (form instanceof Response) return form; | |
| 95 | const saved = await saveCrewKey(ctx.app, user, field(form, "token")); | |
| 96 | if (!saved.ok) | |
| 97 | return saved.error === "not_found" | |
| 98 | ? notFoundPage(ctx) | |
| 99 | : flashTo(ctx, "/", { tone: "danger", message: "That key could not be saved. Check that it is the whole key and try again." }); | |
| 100 | return flashTo(ctx, "/", { message: "Key saved. The crew can work now." }); | |
| 101 | } |
src/web/studio/home.ts +3 −3
| @@ -80,7 +80,7 @@ import { pinnedPanels, pinnedSlot } from "./pinned.ts"; | ||
| 80 | 80 | import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts"; |
| 81 | 81 | import { sinceYouWere } from "./since.ts"; |
| 82 | 82 | import { homeTabs, unshippedAsks } from "./home_tabs.ts"; |
| 83 | import { crewKeyNote } from "./crew.ts"; | |
| 83 | import { crewKeyCard } from "./crew_key.ts"; | |
| 84 | 84 | import { everydayCard } from "../pages/everyday.ts"; |
| 85 | 85 | import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; |
| 86 | 86 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| @@ -317,7 +317,7 @@ export async function studioHome( | ||
| 317 | 317 | ) { |
| 318 | 318 | const apps = await clientApps(ctx, user); |
| 319 | 319 | const lean = html`<h1 class="sr-only">Home</h1> |
| 320 | ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps} | |
| 320 | ${firstSteps} ${address} ${crewKeyCard(ctx)} ${heldLine(ctx, user)} ${apps} | |
| 321 | 321 | <div class="stack"> |
| 322 | 322 | ${composer(ctx, user)} |
| 323 | 323 | <p class="small muted"> |
| @@ -420,7 +420,7 @@ export async function feedPage(ctx: Ctx): Promise<Response> { | ||
| 420 | 420 | ${await regionOf("feed")} |
| 421 | 421 | </div> |
| 422 | 422 | <aside class="stack"> |
| 423 | ${user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null} | |
| 423 | ${user.isAdmin || appRepos.length > 0 ? crewKeyCard(ctx) : null} | |
| 424 | 424 | ${appsRegion(ctx, user)} |
| 425 | 425 | ${terminalCard(ctx)} ${downloadsCard(ctx, user)} |
| 426 | 426 | ${orgsCard(ctx, user)} |
test/child-credit.test.ts +3
| @@ -41,6 +41,7 @@ import { openBeatDoor, parentLocal, type BeatDoor } from "../src/spawn/beat-door | ||
| 41 | 41 | import { readCredit } from "../src/spawn/credit.ts"; |
| 42 | 42 | import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts"; |
| 43 | 43 | import { listPriceUsd } from "../src/spawn/list_price.ts"; |
| 44 | import { setLimit } from "../src/settings/limits.ts"; | |
| 44 | 45 | import { quietLogs } from "./helpers/globals.ts"; |
| 45 | 46 | |
| 46 | 47 | quietLogs(); |
| @@ -105,6 +106,8 @@ describe("a parent funds a child's crew", () => { | ||
| 105 | 106 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true }); |
| 106 | 107 | const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" }); |
| 107 | 108 | if (!alice.ok || !bob.ok) throw new Error("users"); |
| 109 | // These tests lend credit by hand: the credit a new forge comes with (#11154) is off here. | |
| 110 | setLimit(parent.db, "spawn.included_credit_usd", 0, alice.value.id); | |
| 108 | 111 | adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token; |
| 109 | 112 | bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token; |
| 110 | 113 | const repo = await createRepo(parent, alice.value, { name: "bakery" }); |
test/crew-key.test.ts added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | /** | |
| 2 | * The owner's model key (#11154), at both doors: the page's POST | |
| 3 | * /settings/crew/key (admin only, same-origin, bounded) and the terminal's | |
| 4 | * Crew key screen, both through `saveCrewKey`. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { keyShapeOk, saveCrewKey, KEY_MAX_BYTES } from "../src/actions/crew_key.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { listGrants, listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const GOOD = "sk-ant-api03-abcdefghijklmnop"; | |
| 25 | ||
| 26 | describe("adding the crew's key", () => { | |
| 27 | let dir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let admin = ""; | |
| 31 | let plain = ""; | |
| 32 | const post = (cookie: string | undefined, token: string, headers: Record<string, string> = {}) => | |
| 33 | fetch(`${servers.url}/settings/crew/key`, { | |
| 34 | method: "POST", | |
| 35 | redirect: "manual", | |
| 36 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, ...(cookie ? { cookie } : {}), ...headers }, | |
| 37 | body: new URLSearchParams({ token }), | |
| 38 | }); | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dir = await mkdtemp(join(tmpdir(), "forge-crew-key-")); | |
| 42 | app = createApp({ dataDir: dir, port: 0 }); | |
| 43 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 44 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-starter-passphrase", adminIfFirst }); | |
| 45 | if (!made.ok) throw new Error(made.error); | |
| 46 | } | |
| 47 | admin = `forge_session=${createSession(app.db, getUserByName(app.db, "alice")!.id).token}`; | |
| 48 | plain = `forge_session=${createSession(app.db, getUserByName(app.db, "bob")!.id).token}`; | |
| 49 | servers = await serve(app); | |
| 50 | }); | |
| 51 | afterAll(async () => { | |
| 52 | servers?.stop(); | |
| 53 | app?.close(); | |
| 54 | await rm(dir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a signed-out caller is sent to sign in, and nothing is saved", async () => { | |
| 58 | const res = await post(undefined, GOOD); | |
| 59 | expect([302, 303]).toContain(res.status); | |
| 60 | expect(res.headers.get("location") ?? "").toContain("/signin"); | |
| 61 | expect(listSubscriptions(app.db)).toEqual([]); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a non-admin gets the not-found page", async () => { | |
| 65 | expect((await post(plain, GOOD)).status).toBe(404); | |
| 66 | expect(listSubscriptions(app.db)).toEqual([]); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a cross-origin POST is refused", async () => { | |
| 70 | const res = await post(admin, GOOD, { origin: "https://evil.example" }); | |
| 71 | expect(res.status).toBe(403); | |
| 72 | expect(listSubscriptions(app.db)).toEqual([]); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a key with a control character or over the byte cap is refused before it is sealed", async () => { | |
| 76 | expect(keyShapeOk("sk-ant-api03-ab\ncd")).toBe(false); | |
| 77 | expect(keyShapeOk("sk-ant-api03-ab\u0000cd")).toBe(false); | |
| 78 | expect(keyShapeOk("é".repeat(KEY_MAX_BYTES / 2 + 1))).toBe(false); | |
| 79 | expect(keyShapeOk(GOOD)).toBe(true); | |
| 80 | const res = await post(admin, `sk-ant-api03-ab\u0007cd`); | |
| 81 | expect(res.status).toBe(303); | |
| 82 | expect(listSubscriptions(app.db)).toEqual([]); | |
| 83 | const flashed = await (await fetch(`${servers.url}/`, { headers: { cookie: `${admin}; ${(res.headers.getSetCookie()[0] ?? "").split(";")[0]}` } })).text(); | |
| 84 | expect(flashed).toContain("That key could not be saved"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a bad key flashes the error and saves nothing", async () => { | |
| 88 | const res = await post(admin, " "); | |
| 89 | expect(res.status).toBe(303); | |
| 90 | expect(listSubscriptions(app.db)).toEqual([]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a good key is sealed and granted to the admin; the page never echoes it", async () => { | |
| 94 | const res = await post(admin, GOOD); | |
| 95 | expect(res.status).toBe(303); | |
| 96 | const subs = listSubscriptions(app.db); | |
| 97 | expect(subs.map((s) => s.name)).toEqual(["my-key"]); | |
| 98 | expect(listGrants(app.db, getUserByName(app.db, "alice")!.id).map((g) => g.subscriptionId)).toEqual([subs[0]!.id]); | |
| 99 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie: admin } })).text(); | |
| 100 | expect(home).not.toContain(GOOD); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a person who is not an admin is refused by the action itself", async () => { | |
| 104 | expect(await saveCrewKey(app, getUserByName(app.db, "bob")!, GOOD)).toEqual({ ok: false, error: "not_found" }); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 108 | describe("the terminal door", () => { | |
| 109 | test("Crew key is offered to an admin, takes a masked key, and is not offered to anyone else", async () => { | |
| 110 | const dataDir = await mkdtemp(join(tmpdir(), "forge-tui-crewkey-")); | |
| 111 | const forge = createApp({ dataDir, port: 0 }); | |
| 112 | try { | |
| 113 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 114 | const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-starter-passphrase", adminIfFirst }); | |
| 115 | if (!made.ok) throw new Error(made.error); | |
| 116 | } | |
| 117 | const boot = (username: string) => { | |
| 118 | const terminal = headlessTerminal(140, 32); | |
| 119 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, username)! }; | |
| 120 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 121 | app.start(); | |
| 122 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 123 | return { | |
| 124 | app, | |
| 125 | open: async () => { | |
| 126 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 127 | await settleApp(app); | |
| 128 | for (const ch of "Crew key") key(ch); | |
| 129 | await settleApp(app); | |
| 130 | }, | |
| 131 | type: async (t: string) => { | |
| 132 | for (const ch of t) key(ch); | |
| 133 | await settleApp(app); | |
| 134 | }, | |
| 135 | press: async (k: string) => { | |
| 136 | key(k); | |
| 137 | await settleApp(app); | |
| 138 | }, | |
| 139 | }; | |
| 140 | }; | |
| 141 | const bob = boot("bob"); | |
| 142 | await bob.open(); | |
| 143 | expect(bob.app.screen.toText()).not.toContain("what the crew runs on"); | |
| 144 | bob.app.stop(); | |
| 145 | ||
| 146 | const h = boot("alice"); | |
| 147 | await h.open(); | |
| 148 | await settleUntil(h.app, "what the crew runs on", { timeoutMs: 4_000 }); | |
| 149 | await h.press("enter"); | |
| 150 | await settleUntil(h.app, "The crew needs a model key to start", { timeoutMs: 4_000 }); | |
| 151 | await h.press("a"); | |
| 152 | await settleUntil(h.app, "Your model key", { timeoutMs: 4_000 }); | |
| 153 | await h.type(GOOD); | |
| 154 | expect(h.app.screen.toText()).not.toContain("abcdefghijklmnop"); | |
| 155 | await h.press("enter"); | |
| 156 | await settleUntil(h.app, "Key saved", { timeoutMs: 4_000 }); | |
| 157 | expect(listSubscriptions(forge.db).map((s) => s.name)).toEqual(["my-key"]); | |
| 158 | h.app.stop(); | |
| 159 | } finally { | |
| 160 | forge.close(); | |
| 161 | await rm(dataDir, { recursive: true, force: true }); | |
| 162 | } | |
| 163 | }); | |
| 164 | }); |
test/palette-roster.test.ts +1
| @@ -155,6 +155,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 155 | 155 | ["requests", "Waiting", "strangers who asked for an account: allow or decline"], |
| 156 | 156 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 157 | 157 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 158 | ["crewkey", "Crew key", "what the crew runs on; add your own model key"], | |
| 158 | 159 | ["money", "Money", "this month's cost, revenue and gap, and the last 30 days"], |
| 159 | 160 | ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"], |
| 160 | 161 | ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"], |
test/signup-gate-parent.test.ts +1
| @@ -119,6 +119,7 @@ test("every caller of createUser says where its account comes from", async () => | ||
| 119 | 119 | "src/packs/mirror.ts": ["seed"], |
| 120 | 120 | "src/packs/up.ts": ["seed"], |
| 121 | 121 | "src/spawn/preview-seed.ts": ["seed", "seed"], |
| 122 | "src/spawn/starter_crew.ts": ["spawn"], | |
| 122 | 123 | "src/web/pages/claim.ts": ["claim"], |
| 123 | 124 | "src/web/pages/everyday.ts": ["everyday"], |
| 124 | 125 | "src/web/pages/start_claim.ts": ["claimer"], |
test/spawn-child-apps.test.ts +10 −7
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * credential, the home and the app page say so and where to add one. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { setLimit } from "../src/settings/limits.ts"; | |
| 8 | 9 | import { seatAs } from "./helpers/seat.ts"; |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -55,6 +56,8 @@ describe("a spawned child's apps", () => { | ||
| 55 | 56 | }); |
| 56 | 57 | if (!made.ok) throw new Error("user"); |
| 57 | 58 | const alice = made.value; |
| 59 | // This forge is made with no included credit (#11154), so it has neither credit nor key. | |
| 60 | setLimit(parent.db, "spawn.included_credit_usd", 0, alice.id); | |
| 58 | 61 | // One app (its forge.json says so) and one plain repository. |
| 59 | 62 | for (const [name, files] of [ |
| 60 | 63 | [ |
| @@ -175,18 +178,18 @@ describe("a spawned child's apps", () => { | ||
| 175 | 178 | ).replace(/\s+/g, " "); |
| 176 | 179 | expect(home).toContain("bakery"); |
| 177 | 180 | expect(home).not.toContain("No apps yet"); |
| 178 | expect(home).toContain("The crew cannot start work yet"); | |
| 179 | expect(home).toContain('href="/settings/subscriptions"'); | |
| 181 | expect(home).toContain("The crew needs a model key to start"); | |
| 182 | expect(home).toContain('action="/settings/crew/key"'); | |
| 180 | 183 | const appPage = await ( |
| 181 | 184 | await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } }) |
| 182 | 185 | ).text(); |
| 183 | expect(appPage).toContain("The crew cannot start work yet"); | |
| 186 | expect(appPage).toContain("The crew needs a model key to start"); | |
| 184 | 187 | }); |
| 185 | 188 | |
| 186 | 189 | test("on the app page the key note is an owner fact: not for a stranger or a plain reader", async () => { |
| 187 | 190 | const anonymous = await (await fetch(`${servers.url}/marias/bakery`)).text(); |
| 188 | 191 | expect(anonymous).toContain("bakery"); |
| 189 | expect(anonymous).not.toContain("The crew cannot start work yet"); | |
| 192 | expect(anonymous).not.toContain("The crew needs a model key to start"); | |
| 190 | 193 | const reader = await createUser(child.db, { origin: "seed", trusted: true, |
| 191 | 194 | username: "reader", |
| 192 | 195 | email: "[email protected]", |
| @@ -201,14 +204,14 @@ describe("a spawned child's apps", () => { | ||
| 201 | 204 | }) |
| 202 | 205 | ).text(); |
| 203 | 206 | expect(asReader).toContain("bakery"); |
| 204 | expect(asReader).not.toContain("The crew cannot start work yet"); | |
| 207 | expect(asReader).not.toContain("The crew needs a model key to start"); | |
| 205 | 208 | // Nor on the home: there the note is for an admin or a writer of an app. |
| 206 | 209 | const readerHome = await ( |
| 207 | 210 | await fetch(`${servers.url}/`, { |
| 208 | 211 | headers: { cookie: `forge_session=${session.token}` }, |
| 209 | 212 | }) |
| 210 | 213 | ).text(); |
| 211 | expect(readerHome).not.toContain("The crew cannot start work yet"); | |
| 214 | expect(readerHome).not.toContain("The crew needs a model key to start"); | |
| 212 | 215 | }); |
| 213 | 216 | |
| 214 | 217 | test("first boot registers apps as the founder, an account with no verified email", () => { |
| @@ -262,6 +265,6 @@ describe("a spawned child's apps", () => { | ||
| 262 | 265 | const home = await ( |
| 263 | 266 | await fetch(`${servers.url}/`, { headers: { cookie } }) |
| 264 | 267 | ).text(); |
| 265 | expect(home).not.toContain("The crew cannot start work yet"); | |
| 268 | expect(home).not.toContain("The crew needs a model key to start"); | |
| 266 | 269 | }); |
| 267 | 270 | }); |
test/spawn.test.ts +2
| @@ -90,6 +90,8 @@ describe("spawnForge", () => { | ||
| 90 | 90 | statusHash: expect.stringMatching(/^[0-9a-f]{64}$/), |
| 91 | 91 | // The child signs heartbeats to its parent with it (#425). |
| 92 | 92 | beatKey: expect.stringMatching(/^[A-Za-z0-9_-]{20,}$/), |
| 93 | // The plan's included credit, lent once (#11154). | |
| 94 | credit: { capUsd: 2, spentUsd: 0, token: expect.stringMatching(/^kid\./) }, | |
| 93 | 95 | }); |
| 94 | 96 | }); |
| 95 | 97 |
test/starter-crew.test.ts added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | /** | |
| 2 | * A new forge comes with a crew that builds at once (#11154): a builder and | |
| 3 | * a reviewer seated on its apps with autonomy on, the included credit lent | |
| 4 | * through the child credit, one field for the owner's own key, and no | |
| 5 | * promise of a crew when there is neither credit nor key. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { createAgentRow, listAgents, listCrewRoles } from "../src/core/agents.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { listTeamMembers, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts"; | |
| 18 | import { getUserByName } from "../src/core/users.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser } from "../src/core/users.ts"; | |
| 21 | import { listGrants, listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 22 | import { repoDir } from "../src/git/repos.ts"; | |
| 23 | import { commitFiles } from "../src/git/write.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { setLimit } from "../src/settings/limits.ts"; | |
| 26 | import { readCredit } from "../src/spawn/credit.ts"; | |
| 27 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 28 | import { fundingOf, keepFunding } from "../src/spawn/funding.ts"; | |
| 29 | import type { Ctx } from "../src/http/context.ts"; | |
| 30 | import { crewKeyCard, crewCanRun, includedCredit } from "../src/web/studio/crew_key.ts"; | |
| 31 | import { saveCrewKey } from "../src/actions/crew_key.ts"; | |
| 32 | import { FORGE_CODE_NAME } from "../src/spawn/forge_code.ts"; | |
| 33 | import { hasStarterCrew, seatStarterCrew, seedStarterCrew } from "../src/spawn/starter_crew.ts"; | |
| 34 | import { worstCaseDailyUsd } from "../src/spawn/included_credit.ts"; | |
| 35 | import { quietLogs } from "./helpers/globals.ts"; | |
| 36 | ||
| 37 | quietLogs(); | |
| 38 | ||
| 39 | describe("a new forge's starter crew", () => { | |
| 40 | let dir = ""; | |
| 41 | let parent: App; | |
| 42 | let child: App; | |
| 43 | let servers: Servers; | |
| 44 | let token = ""; | |
| 45 | let childDir = ""; | |
| 46 | ||
| 47 | const spawn = async (name: string): Promise<string> => { | |
| 48 | const made = await fetch(`${servers.url}/api/v1/forges`, { | |
| 49 | method: "POST", | |
| 50 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 51 | body: JSON.stringify({ name, apps: ["alice/bakery"], dryRun: false }), | |
| 52 | }); | |
| 53 | if (made.status !== 201) throw new Error(`spawn ${made.status}`); | |
| 54 | return ((await made.json()) as { bodyId: string }).bodyId; | |
| 55 | }; | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dir = await mkdtemp(join(tmpdir(), "forge-starter-crew-")); | |
| 59 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 60 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-starter-passphrase", isAdmin: true }); | |
| 61 | if (!alice.ok) throw new Error("user"); | |
| 62 | token = createToken(parent.db, alice.value.id, "rw", "rw").token; | |
| 63 | const repo = await createRepo(parent, alice.value, { name: "bakery" }); | |
| 64 | if (!repo.ok) throw new Error("repo"); | |
| 65 | const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), { | |
| 66 | branch: "main", | |
| 67 | message: "bakery", | |
| 68 | files: [ | |
| 69 | { path: "README.md", content: "# bakery\n" }, | |
| 70 | { path: "forge.json", content: JSON.stringify({ app: { run: "bun run index.ts" } }) }, | |
| 71 | ], | |
| 72 | author: { name: "a", email: "[email protected]" }, | |
| 73 | }); | |
| 74 | if (!c.ok) throw new Error("commit"); | |
| 75 | servers = await serve(parent); | |
| 76 | childDir = childDirOf(parent, await spawn("maria")); | |
| 77 | child = createApp({ dataDir: childDir, port: 0 }); | |
| 78 | await firstBoot(child); | |
| 79 | }); | |
| 80 | ||
| 81 | afterAll(async () => { | |
| 82 | servers?.stop(); | |
| 83 | child?.close(); | |
| 84 | parent?.close(); | |
| 85 | await rm(dir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("it has a builder and a reviewer, seated on its app, autonomy on", () => { | |
| 89 | expect(listAgents(child.db).map((a) => a.user.username).sort()).toEqual(["builder", "reviewer"]); | |
| 90 | const repo = getRepo(child.db, "maria", "bakery")!; | |
| 91 | expect(listCrewRoles(child.db, repo.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]); | |
| 92 | expect(repo.autonomy).toBe("autonomous"); | |
| 93 | expect(repo.autonomyBy).not.toBeNull(); | |
| 94 | }); | |
| 95 | ||
| 96 | test("it is lent the plan's included credit once, with no key of its own", () => { | |
| 97 | expect(readCredit(parent, "maria")?.capUsd).toBe(2); | |
| 98 | expect(fundingOf(childDir)?.capUsd).toBe(2); | |
| 99 | expect(listSubscriptions(child.db)).toEqual([]); | |
| 100 | expect(includedCredit(child)).toEqual({ capUsd: 2, leftUsd: 2 }); | |
| 101 | expect(crewCanRun(child)).toBe(true); | |
| 102 | }); | |
| 103 | ||
| 104 | test("spent credit and no key: the crew cannot run, and the page says what is needed", () => { | |
| 105 | keepFunding(childDir, { token: fundingOf(childDir)!.token, capUsd: 2, spentUsd: 2 }); | |
| 106 | expect(crewCanRun(child)).toBe(false); | |
| 107 | expect(includedCredit(child)?.leftUsd).toBe(0); | |
| 108 | }); | |
| 109 | ||
| 110 | test("the key card says what is needed, once, and never calls it a subscription", () => { | |
| 111 | const ctx = { app: child, auth: { user: { isAdmin: true } } } as unknown as Ctx; | |
| 112 | const spent = String(crewKeyCard(ctx)); | |
| 113 | expect(spent).toContain("included credit is spent"); | |
| 114 | expect(spent).toContain('name="token"'); | |
| 115 | expect(spent.toLowerCase()).not.toContain("subscription"); | |
| 116 | keepFunding(childDir, { token: fundingOf(childDir)!.token, capUsd: 2, spentUsd: 0.5 }); | |
| 117 | expect(String(crewKeyCard(ctx))).toContain("$1.50 of $2 included credit"); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a repository made later in the forge is seated and autonomous too", async () => { | |
| 121 | const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!; | |
| 122 | const founder = listTeamMembers(child.db, owners.id)[0]!; | |
| 123 | const made = await createRepo(child, founder, { name: "second", owner: "maria" }); | |
| 124 | if (!made.ok) throw new Error(`repo ${made.error}`); | |
| 125 | expect(listCrewRoles(child.db, made.value.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]); | |
| 126 | expect(getRepo(child.db, "maria", "second")!.autonomy).toBe("autonomous"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a forge brought its own key is lent nothing; 0 turns the credit off", async () => { | |
| 130 | setLimit(parent.db, "spawn.included_credit_usd", 0, parent.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id); | |
| 131 | await spawn("nolend"); | |
| 132 | expect(readCredit(parent, "nolend")).toBeUndefined(); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the worst case a day is the visitor limit times the credit", () => { | |
| 136 | expect(worstCaseDailyUsd(parent)).toBe(0); | |
| 137 | }); | |
| 138 | ||
| 139 | test("seatStarterCrew leaves the forge's own code to adoptForgeCode, and a person's autonomy choice alone", async () => { | |
| 140 | const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!; | |
| 141 | const founder = listTeamMembers(child.db, owners.id)[0]!; | |
| 142 | // The forge's own code: named FORGE_CODE_NAME, so not seated by the starter crew. | |
| 143 | const code = await createRepo(child, founder, { name: "third", owner: "maria" }); | |
| 144 | if (!code.ok) throw new Error(`repo ${code.error}`); | |
| 145 | child.db.query("DELETE FROM repo_crew WHERE repo_id = ?1").run(code.value.id); | |
| 146 | seatStarterCrew(child.db, { ...code.value, name: FORGE_CODE_NAME }, founder.id); | |
| 147 | expect(listCrewRoles(child.db, code.value.id)).toEqual([]); | |
| 148 | // A person chose autonomy off: it stays off though the crew is seated. | |
| 149 | const chosen = await createRepo(child, founder, { name: "fourth", owner: "maria" }); | |
| 150 | if (!chosen.ok) throw new Error(`repo ${chosen.error}`); | |
| 151 | child.db.query("UPDATE repo SET autonomy = 'off', autonomy_by = ?2 WHERE id = ?1").run(chosen.value.id, founder.id); | |
| 152 | seatStarterCrew(child.db, getRepo(child.db, "maria", "fourth")!, founder.id); | |
| 153 | expect(getRepo(child.db, "maria", "fourth")!.autonomy).toBe("off"); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a forge that already has crew keeps it, and the starter pair is not added", async () => { | |
| 157 | const dataDir = await mkdtemp(join(tmpdir(), "forge-has-crew-")); | |
| 158 | const own = createApp({ dataDir, port: 0 }); | |
| 159 | try { | |
| 160 | const made = await createUser(own.db, { origin: "seed", trusted: true, username: "zed", email: "[email protected]", password: "a-long-starter-passphrase", isAdmin: true }); | |
| 161 | const scribe = await createUser(own.db, { username: "scribe", email: "[email protected]", agent: true, origin: "seed" }); | |
| 162 | if (!made.ok || !scribe.ok) throw new Error("user"); | |
| 163 | createAgentRow(own.db, { userId: scribe.value.id, roles: ["builder"], createdBy: made.value.id }); | |
| 164 | expect(await seedStarterCrew(own.db, made.value.id, made.value.id)).toBe(false); | |
| 165 | expect(listAgents(own.db).map((a) => a.user.username)).toEqual(["scribe"]); | |
| 166 | expect(hasStarterCrew(own.db, made.value.id)).toBe(false); | |
| 167 | } finally { | |
| 168 | own.close(); | |
| 169 | await rm(dataDir, { recursive: true, force: true }); | |
| 170 | } | |
| 171 | }); | |
| 172 | ||
| 173 | test("a key saved by the admin is sealed and granted to the owner and the spawned organisation", async () => { | |
| 174 | const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!; | |
| 175 | const founder = listTeamMembers(child.db, owners.id)[0]!; | |
| 176 | const saved = await saveCrewKey(child, { ...founder, isAdmin: true, kind: "user" }, " sk-ant-api03-abcdefghij "); | |
| 177 | expect(saved).toEqual({ ok: true }); | |
| 178 | const subs = listSubscriptions(child.db); | |
| 179 | expect(subs.map((x) => x.name)).toEqual(["my-key"]); | |
| 180 | const org = getUserByName(child.db, "maria")!; | |
| 181 | for (const scope of [founder.id, org.id]) expect(listGrants(child.db, scope).map((g) => g.subscriptionId)).toEqual([subs[0]!.id]); | |
| 182 | }); | |
| 183 | }); |