Public: anyone can read this; the crew can change it.
A person who opens the forge's link can start their own forge from the first… #11156
scout/forge:scout/11153-a-person-who-opens-the-forge-s-link-can → main 8 commits fork
17 files
changed +491
−33
README.md +4
| @@ -52,3 +52,7 @@ One dependency is vendored for browsers: [ghostty-web](https://github.com/coder/ | ||
| 52 | 52 | 0.4.0 (MIT, `src/web/vendor/ghostty-web`), the terminal on a machine's shell page — |
| 53 | 53 | about 75 KB of script and a 423 KB WebAssembly file, both cached immutable and |
| 54 | 54 | loaded on that page only. See [](NOTICE). |
| 55 | ||
| 56 | ## Start a forge from the home page | |
| 57 | ||
| 58 | A visitor types a name on `/` and presses "Start my forge" (`src/web/pages/start.ts`): the name is checked live at `GET /start/check`, `POST /start` goes through the same spawn door as `/forges/new` (free plan, visitor rate limits), and the person lands on the new forge's claim page to choose a username and password. No dependency added. |
docs/design/audit.md +2
| @@ -132,6 +132,8 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 132 | 132 | - `/login` — `http/server.ts` (`signinAlias`) · the address people type to sign in · it is `/signin`, keeping a same-origin `next` · none (308, no page of its own). |
| 133 | 133 | - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own). |
| 134 | 134 | - `/signup` — `pages/site.ts` · make an account · `/signup` passkey flow (`signup-passkey.ts`) · colours 3 (file). |
| 135 | - `/start/check` — `pages/start.ts` · live check of the name typed on the front door · `/` · none (a fragment). | |
| 136 | - `/start/ready` — `pages/start.ts` · whether the new forge is awake yet · `/claim` · none (a fragment). | |
| 135 | 137 | - `/signup/requested` — `pages/site.ts` · what a stranger whose request waits is told · `/signin` · colours 3 (file). |
| 136 | 138 | - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file). |
| 137 | 139 | - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen. |
src/actions/spawn.ts +1 −1
| @@ -159,7 +159,7 @@ const fail = (message: string) => err<SpawnError>({ kind: "failed", message }); | ||
| 159 | 159 | |
| 160 | 160 | export const childDirOf = (app: App, bodyId: string): string => |
| 161 | 161 | join(app.config.dataDir, "children", bodyId); |
| 162 | const ledgerPath = (app: App, name: string): string => | |
| 162 | export const ledgerPath = (app: App, name: string): string => | |
| 163 | 163 | join(app.config.dataDir, "spawn", `${name}.json`); |
| 164 | 164 | |
| 165 | 165 | /** Live child URL from name + the parent's origin (ignores stale ledger.url), so old subdomain ledgers still resolve. */ |
src/core/reserved.ts +1
| @@ -113,6 +113,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 113 | 113 | "plugins", |
| 114 | 114 | "signin", |
| 115 | 115 | "signup", |
| 116 | "start", | |
| 116 | 117 | "sitemap.xml", |
| 117 | 118 | "releases", |
| 118 | 119 | "releases.json", |
src/http/server.ts +5
| @@ -274,6 +274,7 @@ import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/page | ||
| 274 | 274 | import { familyPage } from "../web/pages/family.ts"; |
| 275 | 275 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 276 | 276 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 277 | import { startCheck, startReady, startSubmit } from "../web/pages/start.ts"; | |
| 277 | 278 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 278 | 279 | import { askedPage } from "../web/studio/asked_page.ts"; |
| 279 | 280 | import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts"; |
| @@ -314,6 +315,7 @@ import { | ||
| 314 | 315 | liveHomeFragment, |
| 315 | 316 | liveOnYouFragment, |
| 316 | 317 | studioDoor, |
| 318 | ||
| 317 | 319 | } from "../web/studio/home.ts"; |
| 318 | 320 | import { appAnswersFragment } from "../web/studio/answers.ts"; |
| 319 | 321 | import { appNowFragment } from "../web/studio/now.ts"; |
| @@ -844,6 +846,9 @@ export function buildRouter(): Router<Handler> { | ||
| 844 | 846 | router.add("POST", "/forges/:name/stop", forgeStopSubmit); |
| 845 | 847 | router.add("GET", "/forges/:name/remove", forgeRemoveConfirm); |
| 846 | 848 | router.add("POST", "/forges/:name/remove", forgeRemoveSubmit); |
| 849 | router.add("GET", "/start/check", startCheck); | |
| 850 | router.add("GET", "/start/ready", startReady); | |
| 851 | router.add("POST", "/start", (ctx) => startSubmit(ctx, studioDoor)); | |
| 847 | 852 | router.add("GET", "/forges/new", newForgePage); |
| 848 | 853 | router.add("POST", "/forges/new", newForgeSubmit); |
| 849 | 854 | // The front page as a stranger sees it, for anyone; a site admin there |
src/spawn/claim.ts +17
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | import type { Database } from "bun:sqlite"; |
| 21 | import { readFileSync } from "node:fs"; | |
| 21 | 22 | import { join } from "node:path"; |
| 22 | 23 | import type { App } from "../app.ts"; |
| 23 | 24 | import { |
| @@ -68,6 +69,22 @@ export interface Spawned { | ||
| 68 | 69 | readonly statusHash: string | undefined; |
| 69 | 70 | } |
| 70 | 71 | |
| 72 | /** | |
| 73 | * The forge that spawned this one, from `parent.json`, when this forge was | |
| 74 | * spawned and the file names an http(s) address. Read synchronously and only | |
| 75 | * on the rare page that needs it (a crew with no credit says where plans are). | |
| 76 | */ | |
| 77 | export function parentUpstream(app: App): string | undefined { | |
| 78 | try { | |
| 79 | const parsed = JSON.parse(readFileSync(join(app.config.dataDir, "parent.json"), "utf8")) as { upstream?: unknown }; | |
| 80 | return typeof parsed.upstream === "string" && /^https?:\/\/[^\s"<>]+$/.test(parsed.upstream) | |
| 81 | ? parsed.upstream.replace(/\/+$/, "") | |
| 82 | : undefined; | |
| 83 | } catch { | |
| 84 | return undefined; | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 71 | 88 | /** The organisation this forge was spawned as, or undefined if it was not spawned. */ |
| 72 | 89 | export async function spawnedOrg(app: App): Promise<Spawned | undefined> { |
| 73 | 90 | const file = Bun.file(join(app.config.dataDir, "parent.json")); |
src/spawn/door.ts +3
| @@ -52,6 +52,9 @@ export function useProbe(app: App, probe: (url: string) => Probe): void { | ||
| 52 | 52 | probes.set(app, probe); |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | /** The probe of a child's public address: the test's stand-in when one is set. */ | |
| 56 | export const probeFor = (app: App, url: string): Probe => (probes.get(app) ?? probeOf)(url); | |
| 57 | ||
| 55 | 58 | /** Tests (and an external placement, one day) swap where children live. */ |
| 56 | 59 | export function usePlacement(app: App, placement: Placement): void { |
| 57 | 60 | placements.set(app, placement); |
src/spawn/plan.ts +1 −1
| @@ -34,7 +34,7 @@ export const MAX_BUDGET_USD = 1000; | ||
| 34 | 34 | |
| 35 | 35 | /** One line saying what a fresh app is. */ |
| 36 | 36 | export const MAX_FRESH_CHARS = 200; |
| 37 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; | |
| 37 | export const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; | |
| 38 | 38 | const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/; |
| 39 | 39 | const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; |
| 40 | 40 |
src/web/pages/agents.ts +1 −1
| @@ -70,7 +70,7 @@ export function agentsPage(ctx: Ctx): Response { | ||
| 70 | 70 | description: |
| 71 | 71 | "What this forge is and how an AI assistant can help you use it: build one of its public apps, report a problem, or get a forge of your own.", |
| 72 | 72 | body: html`${pageHead("For AI assistants", { |
| 73 | sub: html`What this forge is. Plain text: <a href="/llms.txt">/llms.txt</a>`, | |
| 73 | sub: html`<a href="/llms.txt">/llms.txt</a>. People: <a href="/#start">start here</a>.`, | |
| 74 | 74 | })} |
| 75 | 75 | <section class="stack" aria-labelledby="agent-forge-h"> |
| 76 | 76 | <h2 id="agent-forge-h">Get a forge of your own</h2> |
src/web/pages/start.ts added +204
| @@ -0,0 +1,204 @@ | ||
| 1 | /** | |
| 2 | * "Start my forge" (#11153): the home page's one field and one button for a | |
| 3 | * stranger. The name is checked as it is typed (`GET /start/check`), the post | |
| 4 | * goes through the same door as `/forges/new` and the API (`spawnDoor`: the | |
| 5 | * free plan, the visitor's rate limits), and the answer is one hop to the new | |
| 6 | * forge's claim page, where the person chooses a username and a password and | |
| 7 | * is signed in. The claim code rides the link's fragment, so nobody copies it. | |
| 8 | * | |
| 9 | * The hop is a page with a link and one line of script, not a 303: the page's | |
| 10 | * `form-action 'self'` stops a browser following a form post to another origin. | |
| 11 | * A refusal is said on the home page itself, above the same form. | |
| 12 | */ | |
| 13 | ||
| 14 | import { ledgerPath } from "../../actions/spawn.ts"; | |
| 15 | import { reservedName } from "../../core/reserved.ts"; | |
| 16 | import { childUrlOf, noRoom, probeFor, spawnDoor } from "../../spawn/door.ts"; | |
| 17 | import { NAME } from "../../spawn/plan.ts"; | |
| 18 | import type { Ctx } from "../../http/context.ts"; | |
| 19 | import { json } from "../../http/respond.ts"; | |
| 20 | import { field, readForm } from "../form.ts"; | |
| 21 | import { html, type Html } from "../html.ts"; | |
| 22 | import { page, redirect } from "../layout.ts"; | |
| 23 | import { btn, field as formField } from "../ui.ts"; | |
| 24 | import { pageHead } from "../chrome.ts"; | |
| 25 | ||
| 26 | /** What a name is, for the live line under the field. */ | |
| 27 | export interface NameState { | |
| 28 | readonly free: boolean; | |
| 29 | readonly message: string; | |
| 30 | } | |
| 31 | ||
| 32 | const cleaned = (raw: string): string => raw.trim().toLowerCase(); | |
| 33 | ||
| 34 | /** Whether a name is held: an existing forge, or a word the forge keeps (its own, the crew's, the owner's). */ | |
| 35 | async function held(ctx: Ctx, name: string): Promise<boolean> { | |
| 36 | return reservedName(ctx.app.db, name) !== undefined || (await Bun.file(ledgerPath(ctx.app, name)).exists()); | |
| 37 | } | |
| 38 | ||
| 39 | /** The first near name that is free, for "taken" to offer. */ | |
| 40 | async function suggestion(ctx: Ctx, name: string): Promise<string | undefined> { | |
| 41 | for (const tail of ["-forge", "-2", "-3", "-app"]) { | |
| 42 | const next = `${name.slice(0, 39 - tail.length)}${tail}`; | |
| 43 | if (NAME.test(next) && !(await held(ctx, next))) return next; | |
| 44 | } | |
| 45 | return undefined; | |
| 46 | } | |
| 47 | ||
| 48 | export async function nameState(ctx: Ctx, raw: string): Promise<NameState & { suggest?: string }> { | |
| 49 | const name = cleaned(raw.slice(0, 80)); | |
| 50 | if (name === "") return { free: false, message: "" }; | |
| 51 | if (!NAME.test(name)) | |
| 52 | return { free: false, message: "Use 1 to 39 lowercase letters, digits and dashes, not starting or ending with a dash." }; | |
| 53 | if (await held(ctx, name)) { | |
| 54 | const suggest = await suggestion(ctx, name); | |
| 55 | return { | |
| 56 | free: false, | |
| 57 | message: `${name} is taken.${suggest === undefined ? " Pick another name." : ` Try ${suggest}.`}`, | |
| 58 | ...(suggest === undefined ? {} : { suggest }), | |
| 59 | }; | |
| 60 | } | |
| 61 | return { free: true, message: `${name} is free.` }; | |
| 62 | } | |
| 63 | ||
| 64 | // GET /start/ready?name= — whether the new forge's health answers. Only a | |
| 65 | // name this forge made is asked about, at the address it derives itself. | |
| 66 | export async function startReady(ctx: Ctx): Promise<Response> { | |
| 67 | const name = cleaned((ctx.url.searchParams.get("name") ?? "").slice(0, 80)); | |
| 68 | const known = NAME.test(name) && (await Bun.file(ledgerPath(ctx.app, name)).exists()); | |
| 69 | const ready = known && (await probeFor(ctx.app, childUrlOf(ctx.app, name))()); | |
| 70 | return json({ ready }, 200, { "cache-control": "no-store" }); | |
| 71 | } | |
| 72 | ||
| 73 | // GET /start/check?name= — a stat call, never a spawn. | |
| 74 | export async function startCheck(ctx: Ctx): Promise<Response> { | |
| 75 | const state = await nameState(ctx, ctx.url.searchParams.get("name") ?? ""); | |
| 76 | return json(state, 200, { "cache-control": "no-store" }); | |
| 77 | } | |
| 78 | ||
| 79 | /** The field, its live line and the one button. `error` is a refusal to say again. */ | |
| 80 | export function startForm(ctx: Ctx, values: { name?: string; error?: string } = {}): Html { | |
| 81 | return html`<form method="post" action="/start" class="stack-tight door-start" id="start" data-address="${childUrlOf(ctx.app, "NAME")}"> | |
| 82 | ${formField({ | |
| 83 | label: "Name your forge", | |
| 84 | name: "name", | |
| 85 | id: "start-name", | |
| 86 | value: values.name ?? "", | |
| 87 | required: true, | |
| 88 | spellcheck: false, | |
| 89 | hint: `Its address: ${childUrlOf(ctx.app, "your-name")}`, | |
| 90 | })} | |
| 91 | <p class="small" id="start-status" role="status" aria-live="polite"></p> | |
| 92 | ${values.error === undefined ? null : html`<p class="error" id="start-error" role="alert">${values.error}</p>`} | |
| 93 | ${btn("Start my forge", { variant: "primary", size: "lg" })} | |
| 94 | </form>`; | |
| 95 | } | |
| 96 | ||
| 97 | /** The live check: waits for a pause in typing, then says free or taken. */ | |
| 98 | export const startJs = /* js */ ` | |
| 99 | (function () { | |
| 100 | var input = document.getElementById('start-name'); | |
| 101 | var out = document.getElementById('start-status'); | |
| 102 | if (!input || !out || !window.fetch) return; | |
| 103 | var timer = 0, seq = 0; | |
| 104 | var hint = document.getElementById('start-name-hint'); | |
| 105 | var address = (document.getElementById('start') || {}).dataset.address || ''; | |
| 106 | input.setAttribute('autocomplete', 'off'); | |
| 107 | input.setAttribute('autocapitalize', 'none'); | |
| 108 | input.addEventListener('input', function () { | |
| 109 | clearTimeout(timer); | |
| 110 | var at = input.selectionStart; | |
| 111 | var lower = input.value.toLowerCase(); | |
| 112 | if (lower !== input.value) { input.value = lower; try { input.setSelectionRange(at, at); } catch (e) {} } | |
| 113 | if (hint && address) hint.textContent = 'Its address: ' + address.replace('NAME', lower.trim() || 'your-name'); | |
| 114 | var mine = ++seq, value = input.value; | |
| 115 | if (!value.trim()) { out.textContent = ''; return; } | |
| 116 | timer = setTimeout(function () { | |
| 117 | fetch('/start/check?name=' + encodeURIComponent(value), { credentials: 'omit' }) | |
| 118 | .then(function (r) { return r.json(); }) | |
| 119 | .then(function (s) { | |
| 120 | if (mine !== seq) return; | |
| 121 | out.textContent = s.message; | |
| 122 | out.className = 'small ' + (s.free ? 'muted' : 'error'); | |
| 123 | }) | |
| 124 | .catch(function () {}); | |
| 125 | }, 250); | |
| 126 | }); | |
| 127 | })(); | |
| 128 | `; | |
| 129 | ||
| 130 | /** Holds the person here until the new forge answers, then goes: bounded, and honest when it never does. */ | |
| 131 | export const waitJs = /* js */ ` | |
| 132 | (function () { | |
| 133 | var box = document.getElementById('setup'); | |
| 134 | if (!box || !window.fetch) return; | |
| 135 | var name = box.dataset.name, claim = box.dataset.claim; | |
| 136 | var until = Date.now() + 120000, wait = 1000; | |
| 137 | function late() { | |
| 138 | document.getElementById('setup-status').textContent = 'Setting up ' + name + ' has not finished.'; | |
| 139 | document.getElementById('setup-late').hidden = false; | |
| 140 | } | |
| 141 | function ask() { | |
| 142 | if (Date.now() > until) return late(); | |
| 143 | fetch('/start/ready?name=' + encodeURIComponent(name), { credentials: 'omit' }) | |
| 144 | .then(function (r) { return r.json(); }) | |
| 145 | .then(function (s) { if (s.ready) location.replace(claim); else setTimeout(ask, wait = Math.min(wait + 500, 3000)); }) | |
| 146 | .catch(function () { setTimeout(ask, 3000); }); | |
| 147 | } | |
| 148 | ask(); | |
| 149 | })(); | |
| 150 | `; | |
| 151 | ||
| 152 | /** What to do next, per refusal: never a bare error. */ | |
| 153 | function refusal(status: number, message: string): string { | |
| 154 | if (status === 429) | |
| 155 | return `${message}. Wait a little and press the button again, or run your own forge from the steps further down.`; | |
| 156 | if (status === 422) return `${message.replace(/^(name|org): /, "")}. Pick another name.`; | |
| 157 | if (status === 409 && /exists|already/.test(message)) return `That name is taken. Pick another name.`; | |
| 158 | if (status === 409) return `${message}. Try again in a little while, or run your own forge from the steps further down.`; | |
| 159 | return "Your forge could not be made just now. Try again in a minute, or run your own forge from the steps further down."; | |
| 160 | } | |
| 161 | ||
| 162 | /** The home page again, as the refusal's status: 429 stays, everything else is the person's to fix (422). */ | |
| 163 | const refused = (res: Response, status: number): Response => | |
| 164 | new Response(res.body, { status: status === 429 ? 429 : 422, headers: res.headers }); | |
| 165 | ||
| 166 | // POST /start | |
| 167 | export async function startSubmit( | |
| 168 | ctx: Ctx, | |
| 169 | /** The home page again, with the refusal above its form. */ | |
| 170 | again: (ctx: Ctx, start: { name: string; error: string }) => Response, | |
| 171 | ): Promise<Response> { | |
| 172 | const form = await readForm(ctx, undefined); | |
| 173 | if (form instanceof Response) return form; | |
| 174 | const name = cleaned(field(form, "name")); | |
| 175 | // A signed-in person has the full form, with their own claim link to send. | |
| 176 | if (ctx.auth.user !== undefined) | |
| 177 | return redirect(`/forges/new?name=${encodeURIComponent(name.slice(0, 38))}`, { url: ctx.url, forgeUrl: ctx.app.config.url }); | |
| 178 | const full = noRoom(ctx.app); | |
| 179 | if (full !== undefined) return refused(again(ctx, { name, error: refusal(409, `This forge has no room for another forge right now (${full})`) }), 409); | |
| 180 | const state = await nameState(ctx, name); | |
| 181 | if (!state.free && state.message !== "" && /is taken/.test(state.message)) | |
| 182 | return refused(again(ctx, { name, error: state.message }), 422); | |
| 183 | const door = await spawnDoor(ctx, undefined, { name, dryRun: false }); | |
| 184 | const claim = door.body["claimUrl"]; | |
| 185 | if (door.status < 400 && typeof claim === "string") { | |
| 186 | const response = page({ | |
| 187 | ctx, | |
| 188 | title: `Setting up ${name}`, | |
| 189 | body: html`<div class="narrow stack" id="setup" data-name="${name}" data-claim="${claim}"> | |
| 190 | ${pageHead(`Setting up ${name}`)} | |
| 191 | <p role="status" aria-live="polite" id="setup-status">Setting up ${name}…</p> | |
| 192 | <p id="setup-late" hidden>${name} is taking longer than it should. It may still come up: | |
| 193 | ${btn("Open it anyway", { href: claim, variant: "primary", size: "lg" })}</p> | |
| 194 | <noscript><p>${btn("Open my forge", { href: claim, variant: "primary", size: "lg" })}</p></noscript> | |
| 195 | </div>`, | |
| 196 | scriptJs: waitJs, | |
| 197 | }); | |
| 198 | response.headers.set("cache-control", "no-store"); | |
| 199 | return response; | |
| 200 | } | |
| 201 | const status = door.status < 400 ? 409 : door.status; | |
| 202 | const message = door.status < 400 ? "exists: already" : String(door.body["message"] ?? ""); | |
| 203 | return refused(again(ctx, { name, error: refusal(status, message) }), status); | |
| 204 | } |
src/web/pages/waking.ts +17 −3
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | * client or an API call gets a sentence (`spawn/sleep.ts` `wakingText`). |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { ledgerPath, parseLedger } from "../../actions/spawn.ts"; | |
| 8 | 10 | import type { App } from "../../app.ts"; |
| 9 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 10 | 12 | import { wakingText } from "../../spawn/sleep.ts"; |
| @@ -12,21 +14,33 @@ import { btn, empty } from "../ui.ts"; | ||
| 12 | 14 | import { html } from "../html.ts"; |
| 13 | 15 | import { page } from "../layout.ts"; |
| 14 | 16 | |
| 17 | /** A forge made in the last ten minutes was never asleep: it is being set up. */ | |
| 18 | const NEW_MS = 10 * 60_000; | |
| 19 | function isNew(app: App, name: string): boolean { | |
| 20 | try { | |
| 21 | const made = parseLedger(readFileSync(ledgerPath(app, name), "utf8"))?.createdAt; | |
| 22 | return made !== undefined && Date.now() - Date.parse(made) < NEW_MS; | |
| 23 | } catch { | |
| 24 | return false; | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 15 | 28 | /** Waking (`why` undefined, 503) or refused (`why` a sentence, 429), as a page when the visitor asks for HTML. */ |
| 16 | 29 | export function wakingResponse(app: App, request: Request, name: string, why?: string, retryS = 5): Response { |
| 17 | 30 | if (!(request.headers.get("accept") ?? "").includes("text/html")) return wakingText(name, why, retryS); |
| 18 | 31 | const url = new URL(request.url); |
| 32 | const fresh = why === undefined && isNew(app, name); | |
| 19 | 33 | const ctx: Ctx = { app, request, url, params: {}, auth: { user: undefined, scope: undefined, via: "none" }, theme: undefined }; |
| 20 | 34 | const response = page( |
| 21 | 35 | { |
| 22 | 36 | ctx, |
| 23 | 37 | bare: true, |
| 24 | title: why === undefined ? "Waking up" : "Not now", | |
| 38 | title: why !== undefined ? "Not now" : fresh ? "Setting up" : "Waking up", | |
| 25 | 39 | body: html`<div class="narrow-wide"> |
| 26 | 40 | ${empty({ |
| 27 | 41 | icon: "history", |
| 28 | title: why === undefined ? "Waking up" : "Not now", | |
| 29 | note: why ?? `${name} was asleep. It is waking up and this page tries again by itself in a few seconds.`, | |
| 42 | title: why !== undefined ? "Not now" : fresh ? "Setting up" : "Waking up", | |
| 43 | note: why ?? (fresh ? `${name} is being set up and this page tries again by itself in a few seconds.` : `${name} was asleep. It is waking up and this page tries again by itself in a few seconds.`), | |
| 30 | 44 | action: btn("Try again", { href: url.pathname, variant: "primary" }), |
| 31 | 45 | })} |
| 32 | 46 | </div>`, |
src/web/studio/crew.ts +7 −3
| @@ -30,6 +30,7 @@ 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 { parentUpstream } from "../../spawn/claim.ts"; | |
| 33 | 34 | import { crewHasCredential } from "../../core/subscriptions.ts"; |
| 34 | 35 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 35 | 36 | import { governorSeen } from "../../crew/governed.ts"; |
| @@ -423,10 +424,13 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html { | ||
| 423 | 424 | */ |
| 424 | 425 | export function crewKeyNote(ctx: Ctx): Html | null { |
| 425 | 426 | if (crewHasCredential(ctx.app.db)) return null; |
| 427 | const plans = parentUpstream(ctx.app); | |
| 426 | 428 | 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. | |
| 429 | ${icon("alert")} The crew cannot start work yet: no credit. | |
| 428 | 430 | ${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 | ? html`<a href="/settings/subscriptions">Add your own credit</a>${plans === undefined | |
| 432 | ? "" | |
| 433 | : html`, or <a href="${plans}/pricing">pick a paid plan</a>`}.` | |
| 434 | : "Ask this forge's admin to add credit or a plan."} | |
| 431 | 435 | </p>`; |
| 432 | 436 | } |
src/web/studio/home.ts +31 −21
| @@ -88,6 +88,7 @@ import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; | ||
| 88 | 88 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| 89 | 89 | import { clientApps } from "./client_home.ts"; |
| 90 | 90 | import { passkeyJs } from "../passkey-client.ts"; |
| 91 | import { startForm, startJs } from "../pages/start.ts"; | |
| 91 | 92 | import { SITE_LINE } from "../site_line.ts"; |
| 92 | 93 | |
| 93 | 94 | /** The events that can change what is on you. */ |
| @@ -98,6 +99,12 @@ const APPS_LIVE = "app.serving,app.stopped,run.finished,fleet.operated"; | ||
| 98 | 99 | /** How many of your apps the rail names before "All apps". */ |
| 99 | 100 | const RAIL_APPS = 8; |
| 100 | 101 | |
| 102 | /** A new forge's first screen says the next step in one line (#11153). */ | |
| 103 | function nextLine(): Html { | |
| 104 | return html`<p id="next-line"><b>Next:</b> <a href="/apps/new">make an app</a> or | |
| 105 | <a href="/migrate">bring your code</a>. The crew builds it with credit you add or a paid plan.</p>`; | |
| 106 | } | |
| 107 | ||
| 101 | 108 | /** |
| 102 | 109 | * Your apps, one step away (#599): a narrow column on a desk — each app's |
| 103 | 110 | * name and how production is — and the verbs that were the shelf's head. |
| @@ -319,7 +326,7 @@ export async function studioHome( | ||
| 319 | 326 | ) { |
| 320 | 327 | const apps = await clientApps(ctx, user); |
| 321 | 328 | const lean = html`<h1 class="sr-only">Home</h1> |
| 322 | ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps} | |
| 329 | ${spawned === undefined ? null : nextLine()} ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps} | |
| 323 | 330 | <div class="stack"> |
| 324 | 331 | ${composer(ctx, user)} |
| 325 | 332 | <p class="small muted"> |
| @@ -750,7 +757,7 @@ function agentPrompt(origin: string): string { | ||
| 750 | 757 | * `/front` is the same page for anyone; a site admin there also gets the |
| 751 | 758 | * controls that decide it. |
| 752 | 759 | */ |
| 753 | export function studioDoor(ctx: Ctx): Response { | |
| 760 | export function studioDoor(ctx: Ctx, start?: { name: string; error: string }): Response { | |
| 754 | 761 | // The door draws what is in memory; a pack pushed since is picked up for the next visit. |
| 755 | 762 | void refreshCatalogQuietly(ctx.app, 5_000); |
| 756 | 763 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -775,7 +782,8 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 775 | 782 | const back = ctx.url.pathname === "/" ? "/" + ctx.url.search : "/"; |
| 776 | 783 | const nextQuery = back === "/" ? "" : `?next=${encodeURIComponent(back)}`; |
| 777 | 784 | const ssh = sshCommand(ctx.app); |
| 778 | const signup = offersSignup(ctx) && accounts; | |
| 785 | const offers = offersSignup(ctx); | |
| 786 | const signup = offers && accounts; | |
| 779 | 787 | // Closed is a door with nothing behind it; invite still lands an invite link's holder. |
| 780 | 788 | // The plans setting is read only when a link is set: no link, no extra statement on the home. |
| 781 | 789 | const priced = BILLING_SIZES.some((z) => links[z] !== undefined) ? getPlans(ctx.app.db).filter((p) => p.priceUsd > 0) : []; |
| @@ -789,27 +797,29 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 789 | 797 | <h1>Software for what you're building.<br /><span class="muted">Yours to keep.</span></h1> |
| 790 | 798 | <div class="door-under"> |
| 791 | 799 | <div class="door-lead"> |
| 792 | <p class="door-support">Say what you need. An AI crew builds it, for a business or a game studio.</p> | |
| 793 | <div class="bar"> | |
| 794 | ${btn("Get your own forge", { href: "#yours", variant: "primary", size: "lg" })} | |
| 795 | ${when(made.length > 0, () => btn("See what it built", { href: "#made", size: "lg" }))} | |
| 796 | </div> | |
| 797 | <div class="door-agent"> | |
| 798 | <p> | |
| 799 | Have an AI assistant? Give it this: | |
| 800 | <a href="https://chatgpt.com/?q=${asked}" rel="noopener">Open in ChatGPT</a> | |
| 801 | <a href="https://grok.com/?q=${asked}" rel="noopener">Open in Grok</a> | |
| 802 | <a href="https://claude.ai/new?q=${asked}" rel="noopener">Open in Claude</a> | |
| 803 | <span class="muted">(after you sign in)</span> | |
| 804 | </p> | |
| 805 | ${copyField("door-prompt", prompt, "A message for any AI assistant", "Copy the message")} | |
| 806 | </div> | |
| 800 | <p class="door-support">Your own forge, free to try: say what you need and an AI crew builds it.</p> | |
| 801 | ${offers | |
| 802 | ? startForm(ctx, start) | |
| 803 | : btn("Get your own forge", { href: "#yours", variant: "primary", size: "lg" })} | |
| 804 | ${when(made.length > 0, () => html`<p class="small"><a href="#made">See what it built</a></p>`)} | |
| 807 | 805 | </div> |
| 808 | 806 | ${doorLive(front)} |
| 809 | 807 | </div> |
| 810 | 808 | ${doorPulse(front.pulse.days)} |
| 811 | 809 | </section> |
| 812 | 810 | |
| 811 | <section class="door-section door-agent" id="assistant"> | |
| 812 | <p>Have an AI assistant? Give it this:</p> | |
| 813 | <div class="bar"> | |
| 814 | ${btn("Open in ChatGPT", { href: `https://chatgpt.com/?q=${asked}`, size: "lg" })} | |
| 815 | ${btn("Open in Grok", { href: `https://grok.com/?q=${asked}`, size: "lg" })} | |
| 816 | ${btn("Open in Claude", { href: `https://claude.ai/new?q=${asked}`, size: "lg" })} | |
| 817 | </div> | |
| 818 | <p class="muted small">After you sign in.</p> | |
| 819 | ${copyField("door-prompt", prompt, "A message for any AI assistant", "Copy the message")} | |
| 820 | ${signup ? html`<p>Rather have an account first? ${btn("Create an account", { href: `/signup${nextQuery}`, size: "lg" })}</p>` : null} | |
| 821 | </section> | |
| 822 | ||
| 813 | 823 | <section class="door-section" id="made"> |
| 814 | 824 | <h2>Made here</h2> |
| 815 | 825 | <p class="door-support"> |
| @@ -926,16 +936,16 @@ bun run src/main.ts</code></pre> | ||
| 926 | 936 | title: "forge: software for what you're building, yours to keep", |
| 927 | 937 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", |
| 928 | 938 | body, |
| 929 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 939 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${startJs}`, | |
| 930 | 940 | jsonLd: siteStructuredData(origin, running().version), |
| 931 | 941 | ...(visitor |
| 932 | 942 | ? { |
| 933 | 943 | tools: html`<a class="small door-wide" href="#made">Made here</a> |
| 934 | 944 | <a class="small door-wide" href="${FORGE_REPO}">Code</a> |
| 935 | 945 | ${signup |
| 936 | ? html`<a class="small" href="/signin${nextQuery}">Sign in</a> | |
| 937 | ${btn("Create an account", { href: `/signup${nextQuery}` })}` | |
| 946 | ? html`<a class="small" href="/signin${nextQuery}">Sign in</a>` | |
| 938 | 947 | : html`${when(mode !== "closed", () => html`<a class="small door-wide" href="/signup">Create an account</a>`)} |
| 948 | ${btn("Start my forge", { href: "#start" })} | |
| 939 | 949 | ${btn("Sign in", { href: activeEdgeToken(ctx.app) === undefined ? `/signin${nextQuery}` : edgeLoginUrl(back) })}`}`, |
| 940 | 950 | foot: html`<span class="mark"></span> |
| 941 | 951 | <span>Open Platform: two open source projects, AGPL-3.0</span> |
src/web/style/content.ts +2 −2
| @@ -260,8 +260,8 @@ form.inline { display: inline; margin-left: auto; } | ||
| 260 | 260 | .door-under { display: grid; gap: 56px; margin-top: 28px; } |
| 261 | 261 | .door-lead { display: flex; flex-direction: column; align-items: flex-start; gap: 24px; min-width: 0; } |
| 262 | 262 | .door-lead .bar { gap: 8px; flex-wrap: wrap; } |
| 263 | .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); } | |
| 264 | .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); } | |
| 263 | .door-agent { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; max-width: 520px; font-size: 14px; color: var(--muted-fg); } | |
| 264 | .door-agent .btn-icon { min-width: 44px; min-height: 44px; } | |
| 265 | 265 | /* On the input's own tinted ground the muted color falls short of AA; the words are the thing to copy. */ |
| 266 | 266 | .door-agent .input { color: var(--fg); } |
| 267 | 267 | .door-live { display: flex; flex-direction: column; gap: 14px; min-width: 0; } |
test/forge-looks.test.ts +1
| @@ -47,6 +47,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 47 | 47 | "/first-admin": "the setup form for a forge with no admin yet", |
| 48 | 48 | "/signin": "a sign-in form, not a page of the forge", |
| 49 | 49 | "/signup": "a sign-up form, not a page of the forge", |
| 50 | "/start": "the start-a-forge form, a POST door; covered by test/start-forge.test.ts", | |
| 50 | 51 | "/recover": "an account-recovery form, not a page of the forge", |
| 51 | 52 | "/verify": "answers 400 without the emailed code", |
| 52 | 53 | "/logout": "a mutation, answers 404 to a GET", |
test/forge-sleep.test.ts +1 −1
| @@ -165,7 +165,7 @@ describe("a hosted forge scales to zero", () => { | ||
| 165 | 165 | expect(page.status).toBe(503); |
| 166 | 166 | expect(page.headers.get("refresh")).toBe("3"); |
| 167 | 167 | const text = await page.text(); |
| 168 | expect(text).toContain("Waking up"); | |
| 168 | expect(text).toContain("Setting up"); // made moments ago in this test, so never asleep | |
| 169 | 169 | expect(text).not.toContain("style="); |
| 170 | 170 | expect(wakeGate(parent, new Request("https://forge.test/", { headers: { host: CHILD_HOST } }))).toBeUndefined(); |
| 171 | 171 | }); |
test/start-forge.test.ts added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | /** | |
| 2 | * "Start my forge" (#11153): the home page of a stranger carries the one | |
| 3 | * field and the one button; the name is checked live; the post makes a forge | |
| 4 | * through the spawn door and hands the person to its claim page; a refusal is | |
| 5 | * said on the home page. A new forge with no crew credit says its two ways forward. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp } from "../src/app.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { serve } from "../src/http/server.ts"; | |
| 15 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 16 | import { bootHostedChild } from "../src/spawn/host.ts"; | |
| 17 | import { ok } from "../src/kernel/result.ts"; | |
| 18 | import { crewKeyNote } from "../src/web/studio/crew.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dir = ""; | |
| 24 | let app: ReturnType<typeof createApp>; | |
| 25 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 26 | let roomLeft = true; | |
| 27 | const spawned: string[] = []; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "forge-start-")); | |
| 31 | app = createApp({ dataDir: dir, port: 0 }); | |
| 32 | useProbe(app, () => async () => true); | |
| 33 | usePlacement(app, { | |
| 34 | place: async (plan: { name: string }) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${plan.name}` }), | |
| 35 | retire: async () => ok(), | |
| 36 | room: () => (roomLeft ? undefined : { message: "the test host is full" }), | |
| 37 | } as never); | |
| 38 | servers = await serve(app); | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | for (const name of spawned) await bootHostedChild(app, name); | |
| 42 | servers.stop(); | |
| 43 | app.close(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | const post = (name: string, headers: Record<string, string> = {}) => | |
| 48 | fetch(servers.url + "/start", { | |
| 49 | method: "POST", | |
| 50 | redirect: "manual", | |
| 51 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, ...headers }, | |
| 52 | body: new URLSearchParams({ name }), | |
| 53 | }); | |
| 54 | ||
| 55 | test("a stranger's home has a labelled field and one Start my forge button", async () => { | |
| 56 | const home = await (await fetch(servers.url + "/")).text(); | |
| 57 | expect(home).toContain('action="/start"'); | |
| 58 | expect(home).toMatch(/<label[^>]*for="start-name"[^>]*>\s*Name your forge/); | |
| 59 | expect(home).toContain("Start my forge"); | |
| 60 | expect(home).toContain("free to try"); | |
| 61 | // The form asks for the name only. | |
| 62 | const form = home.slice(home.indexOf('id="start"'), home.indexOf("</form>", home.indexOf('id="start"'))); | |
| 63 | expect(form.match(/<input/g)?.length).toBe(1); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the live check says free, taken, and what a bad name needs", async () => { | |
| 67 | const say = async (n: string) => (await (await fetch(`${servers.url}/start/check?name=${encodeURIComponent(n)}`)).json()) as { free: boolean; message: string }; | |
| 68 | expect(await say("Fresh-One")).toMatchObject({ free: true, message: "fresh-one is free." }); | |
| 69 | expect((await say("-bad")).free).toBe(false); | |
| 70 | expect((await say("")).message).toBe(""); | |
| 71 | const made = await post("taken-one"); | |
| 72 | expect(made.status).toBe(200); | |
| 73 | spawned.push("taken-one"); | |
| 74 | expect(await say("taken-one")).toMatchObject({ free: false }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("the post makes the forge and hands over its claim link, once, uncached", async () => { | |
| 78 | const res = await post("hello-forge"); | |
| 79 | spawned.push("hello-forge"); | |
| 80 | expect(res.status).toBe(200); | |
| 81 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 82 | const page = await res.text(); | |
| 83 | expect(page).toMatch(/href="https?:\/\/hello-forge--[^"]*\/claim#[A-Za-z0-9_-]{43}"/); | |
| 84 | // The person waits here until the new forge's health answers, then goes. | |
| 85 | expect(page).toContain("Setting up hello-forge…"); | |
| 86 | expect(page).toContain('id="setup" data-name="hello-forge"'); | |
| 87 | expect(page).not.toContain("is ready"); | |
| 88 | const ready = (await (await fetch(`${servers.url}/start/ready?name=hello-forge`)).json()) as { ready: boolean }; | |
| 89 | expect(ready.ready).toBe(true); | |
| 90 | // Only a forge this one made is asked about. | |
| 91 | expect(((await (await fetch(`${servers.url}/start/ready?name=nobody-here`)).json()) as { ready: boolean }).ready).toBe(false); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a forge that does not answer yet is not ready", async () => { | |
| 95 | useProbe(app, () => async () => false); | |
| 96 | const said = (await (await fetch(`${servers.url}/start/ready?name=hello-forge`)).json()) as { ready: boolean }; | |
| 97 | useProbe(app, () => async () => true); | |
| 98 | expect(said.ready).toBe(false); | |
| 99 | }); | |
| 100 | ||
| 101 | test("reserved and existing names read taken with one suggestion", async () => { | |
| 102 | const say = async (n: string) => (await (await fetch(`${servers.url}/start/check?name=${n}`)).json()) as { free: boolean; message: string }; | |
| 103 | const forge = await say("forge"); | |
| 104 | expect(forge.free).toBe(false); | |
| 105 | expect(forge.message).toMatch(/^forge is taken\. Try [a-z0-9-]+\.$/); | |
| 106 | const refused = await post("forge"); | |
| 107 | expect(refused.status).toBe(422); | |
| 108 | expect(await refused.text()).toContain("forge is taken."); | |
| 109 | expect(await Bun.file(join(dir, "spawn", "forge.json")).exists()).toBe(false); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the first view is the sentence, the field and one button; the rest is below", async () => { | |
| 113 | const home = await (await fetch(servers.url + "/")).text(); | |
| 114 | const hero = home.slice(home.indexOf('class="door-hero"'), home.indexOf('id="assistant"')); | |
| 115 | expect(hero).not.toContain("Open in ChatGPT"); | |
| 116 | expect(hero).not.toContain("Create an account"); | |
| 117 | expect(home).toContain("Open in ChatGPT"); | |
| 118 | expect(home).toContain("Copy the message"); | |
| 119 | // The address hint is a pattern the page fills as the name is typed, never a literal <name>. | |
| 120 | expect(home).not.toContain("<name>"); | |
| 121 | expect(home).toContain('data-address="'); | |
| 122 | expect(home).toContain("Its address: "); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a forge made minutes ago is being set up, not waking", async () => { | |
| 126 | const { wakingResponse } = await import("../src/web/pages/waking.ts"); | |
| 127 | const html = (name: string) => | |
| 128 | wakingResponse(app, new Request(servers.url + "/", { headers: { accept: "text/html" } }), name).text(); | |
| 129 | const fresh = await html("hello-forge"); | |
| 130 | expect(fresh).toContain("being set up"); | |
| 131 | expect(fresh).not.toContain("was asleep"); | |
| 132 | expect(await html("never-made")).toContain("was asleep"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a taken name is said on the home page with what to do", async () => { | |
| 136 | const res = await post("taken-one"); | |
| 137 | expect(res.status).toBe(422); | |
| 138 | const page = await res.text(); | |
| 139 | expect(page).toContain('id="start-error"'); | |
| 140 | expect(page).toContain("taken-one is taken. Try "); | |
| 141 | expect(page).toContain('value="taken-one"'); | |
| 142 | expect(page).not.toContain("/claim#"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a bad name and no room are said in words, never a dead end", async () => { | |
| 146 | expect(await (await post("Bad_Name!")).text()).toContain("Pick another name."); | |
| 147 | roomLeft = false; | |
| 148 | const full = await post("no-room-yet"); | |
| 149 | roomLeft = true; | |
| 150 | expect(full.status).toBe(422); | |
| 151 | expect(await full.text()).toContain("run your own forge"); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a post from another origin is refused", async () => { | |
| 155 | const res = await post("evil-one", { origin: "https://evil.example" }); | |
| 156 | expect(res.status).toBeGreaterThanOrEqual(400); | |
| 157 | expect(await Bun.file(join(dir, "spawn", "evil-one.json")).exists()).toBe(false); | |
| 158 | }); | |
| 159 | ||
| 160 | test("the strangers' rate limit shows as a refusal on the home page", async () => { | |
| 161 | let last: Response | undefined; | |
| 162 | for (let i = 0; i < 30; i++) { | |
| 163 | last = await post(`rate-${i}`); | |
| 164 | spawned.push(`rate-${i}`); | |
| 165 | if (last.status === 429) break; | |
| 166 | } | |
| 167 | expect(last?.status).toBe(429); | |
| 168 | expect(await last!.text()).toContain("Wait a little"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a signed-in person is sent to the full form with the name", async () => { | |
| 172 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 173 | if (!made.ok) throw new Error(made.error); | |
| 174 | const session = createSession(app.db, made.value.id); | |
| 175 | const res = await post("mine-now", { cookie: `forge_session=${typeof session === "string" ? session : (session as { token: string }).token}` }); | |
| 176 | expect([200, 303]).toContain(res.status); | |
| 177 | }); | |
| 178 | ||
| 179 | test("with no crew credit the note names both ways forward", () => { | |
| 180 | const note = crewKeyNote({ app, auth: { user: { isAdmin: true } } } as never)?.toString() ?? ""; | |
| 181 | expect(note).toContain("Add your own credit"); | |
| 182 | expect(note).toContain("/settings/subscriptions"); | |
| 183 | }); | |
| 184 | ||
| 185 | test("a spawned forge's note also names the parent's plans", async () => { | |
| 186 | await Bun.write(join(dir, "parent.json"), JSON.stringify({ upstream: "https://forge.example/" })); | |
| 187 | const note = crewKeyNote({ app, auth: { user: { isAdmin: true } } } as never)?.toString() ?? ""; | |
| 188 | expect(note).toContain('href="https://forge.example/pricing"'); | |
| 189 | expect(note).toContain("pick a paid plan"); | |
| 190 | const reader = crewKeyNote({ app, auth: { user: { isAdmin: false } } } as never)?.toString() ?? ""; | |
| 191 | expect(reader).toContain("admin to add credit or a plan"); | |
| 192 | await rm(join(dir, "parent.json")); | |
| 193 | }); |