Public: anyone can read this; the crew can change it.
A stranger's first five minutes: five rough edges found by a private-tab and… #11950
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11948-a-stranger-s-first-five-minutes-five-rou → main 7 commits fork 7 threads · 7 unresolved
15 files
changed +338
−21
docs/features/claim-own-name.md added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | # Taking the forge's own name | |
| 2 | ||
| 3 | A forge made from a sent link or Start my forge has an organisation named after | |
| 4 | it (`probe`). A person claiming it may choose that same name for their account: | |
| 5 | the organisation row becomes the account (a person, an admin, with the password | |
| 6 | or passkey given), so the apps already under the name stay where they are. | |
| 7 | ||
| 8 | - **Where.** `/claim` and `/welcome` (`takesName` in `src/web/pages/claim.ts`, | |
| 9 | `becomeOrg` in `src/spawn/claim.ts`). One transaction, gated by the burned | |
| 10 | link code; any session the organisation row held is deleted. | |
| 11 | - **The link code field** is in the server render, visible and required, so the | |
| 12 | claim works with JavaScript off. The fragment script fills it from `#…` and | |
| 13 | then hides it. | |
| 14 | - **Terminal.** The terminal does not take the name: it ends at a link to finish | |
| 15 | in the browser. Its guest menu's Start my forge gives that link, and a closed | |
| 16 | sign-up there says to choose it. | |
| 17 | - **Credit.** A run on the host's included credit is budgeted at most what is | |
| 18 | left of it (`cappedByLent`), and the host is asked at the run's end | |
| 19 | (`runEnded`), so the budget and the credit line are one figure. |
src/crew/runner.ts +9 −2
| @@ -200,7 +200,7 @@ import { | ||
| 200 | 200 | skipsFeedback, |
| 201 | 201 | } from "./guard.ts"; |
| 202 | 202 | import { passportEnv } from "./passport_env.ts"; |
| 203 | import { fundedEnv } from "../spawn/funding.ts"; | |
| 203 | import { cappedByLent, fundedEnv, lentCreditLeft, runEnded } from "../spawn/funding.ts"; | |
| 204 | 204 | |
| 205 | 205 | const log = createLog("crew"); |
| 206 | 206 | |
| @@ -1394,12 +1394,16 @@ async function work( | ||
| 1394 | 1394 | } |
| 1395 | 1395 | // What this run would spend before the account's ceilings: its own |
| 1396 | 1396 | // budget, under the subscription's reservation or the grant's rest. |
| 1397 | const wanted = | |
| 1397 | const lentLeft = lentCreditLeft(picked.kind === "env" || picked.kind === "none", app.config.dataDir); | |
| 1398 | const wantedBy = | |
| 1398 | 1399 | picked.kind === "ready" && picked.capUsd !== undefined |
| 1399 | 1400 | ? Math.min(ownBudget, picked.capUsd) |
| 1400 | 1401 | : grantCap !== undefined |
| 1401 | 1402 | ? Math.min(ownBudget, grantCap) |
| 1402 | 1403 | : ownBudget; |
| 1404 | // On the host's included credit a run is budgeted at most what is left of it | |
| 1405 | // (#11948), so the budget line and the credit line say one number. | |
| 1406 | const wanted = cappedByLent(wantedBy, lentLeft); | |
| 1403 | 1407 | // The account's own ceilings, daily and per run, apart from the forge's: |
| 1404 | 1408 | // a run past either does not start and spends nothing. What it may |
| 1405 | 1409 | // spend is reserved against the day now, so a run started beside it is |
| @@ -1913,6 +1917,9 @@ async function work( | ||
| 1913 | 1917 | await workdir.release(); |
| 1914 | 1918 | deleteToken(app.db, agent.user.id, token.id); |
| 1915 | 1919 | if (runTmp !== undefined) rmSync(runTmp, { recursive: true, force: true }); |
| 1920 | // The host counted what the run cost: ask it now, so the credit line moves | |
| 1921 | // when a run spends (#11948). | |
| 1922 | void runEnded(app); | |
| 1916 | 1923 | } |
| 1917 | 1924 | } |
| 1918 | 1925 |
src/settings/plans.ts +1 −1
| @@ -38,7 +38,7 @@ export const MAX_USD = 100_000; | ||
| 38 | 38 | export const MAX_FORGES = 1000; |
| 39 | 39 | |
| 40 | 40 | export const DEFAULT_PLANS: readonly Plan[] = [ |
| 41 | { key: "free", name: "Free", says: "A small forge that sleeps when idle. Bring your own model key.", size: "small", priceUsd: 0, creditUsd: 0, forges: 3 }, | |
| 41 | { key: "free", name: "Free", says: "A small forge that sleeps when idle, with the crew on included credit.", size: "small", priceUsd: 0, creditUsd: 0, forges: 3 }, | |
| 42 | 42 | { key: "small", name: "Small", says: "A small forge, awake, with a crew credit each month.", size: "small", priceUsd: 19, creditUsd: 5, forges: 3 }, |
| 43 | 43 | { key: "medium", name: "Medium", says: "A medium forge for a busy app, with more crew credit.", size: "medium", priceUsd: 49, creditUsd: 15, forges: 3 }, |
| 44 | 44 | { key: "large", name: "Large", says: "A large forge for a working crew, with the most credit.", size: "large", priceUsd: 149, creditUsd: 50, forges: 3 }, |
src/spawn/claim.ts +40 −1
| @@ -345,6 +345,44 @@ export function takeFounder( | ||
| 345 | 345 | |
| 346 | 346 | class PasskeyRefused extends Error {} |
| 347 | 347 | |
| 348 | /** | |
| 349 | * Whether a username is the forge's own name, which the forge's organisation | |
| 350 | * holds: a person who starts "probe" and wants to be "probe" is not refused | |
| 351 | * (#11948), they become the namespace (`becomeOrg`). | |
| 352 | */ | |
| 353 | export const isForgeName = (spawned: Spawned, username: string): boolean => | |
| 354 | username.toLowerCase() === spawned.org.username.toLowerCase(); | |
| 355 | ||
| 356 | /** | |
| 357 | * The claimant takes the forge's own name: the organisation row becomes their | |
| 358 | * account (a person, admin, with the credential given), so the apps already | |
| 359 | * under that name stay where they are. One transaction; "gone" when the row is | |
| 360 | * no longer an organisation, "email_taken" when another account has the address. | |
| 361 | */ | |
| 362 | export function becomeOrg( | |
| 363 | db: Database, | |
| 364 | orgId: string, | |
| 365 | input: { readonly hash: string; readonly email: string; readonly credential?: Credential }, | |
| 366 | ): "ok" | "gone" | "email_taken" { | |
| 367 | const take = db.transaction((): boolean => { | |
| 368 | const changed = db | |
| 369 | .query("UPDATE user SET kind = 'user', password_hash = ?2, email = ?3, is_admin = 1 WHERE id = ?1 AND kind = 'org'") | |
| 370 | .run(orgId, input.hash, input.email).changes; | |
| 371 | if (changed !== 1) return false; | |
| 372 | // Nothing the organisation held as a namespace signs in as the person. | |
| 373 | db.query("DELETE FROM session WHERE user_id = ?1").run(orgId); | |
| 374 | if (input.credential !== undefined && !addPasskey(db, orgId, "Passkey", input.credential).ok) throw new PasskeyRefused(); | |
| 375 | return true; | |
| 376 | }); | |
| 377 | try { | |
| 378 | return take() ? "ok" : "gone"; | |
| 379 | } catch (error) { | |
| 380 | if (error instanceof PasskeyRefused) return "gone"; | |
| 381 | if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return "email_taken"; | |
| 382 | throw error; | |
| 383 | } | |
| 384 | } | |
| 385 | ||
| 348 | 386 | /** |
| 349 | 387 | * Give a spawned child's organisation to its first admin: they join its |
| 350 | 388 | * Owners and, unless the spawner asked to keep access, the spawner leaves. |
| @@ -354,7 +392,8 @@ export function handOver(db: Database, spawned: Spawned, userId: string): void { | ||
| 354 | 392 | const team = listTeams(db, spawned.org.id).find((t) => t.name === OWNERS_TEAM); |
| 355 | 393 | if (team === undefined) return; |
| 356 | 394 | const spawner = listTeamMembers(db, team.id)[0]; |
| 357 | addTeamMember(db, team, userId); | |
| 395 | // The person who took the forge's own name is the organisation row itself: no team to join. | |
| 396 | if (userId !== spawned.org.id) addTeamMember(db, team, userId); | |
| 358 | 397 | if (!spawned.keepAccess && spawner && spawner.id !== userId) |
| 359 | 398 | removeTeamMember(db, team, spawner.id); |
| 360 | 399 | } |
src/spawn/funding.ts +24
| @@ -90,3 +90,27 @@ export function fundingWords(f: Funding): string { | ||
| 90 | 90 | ? "funded by its parent" |
| 91 | 91 | : `funded by its parent: ${usdWords(leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd }))} of ${usdWords(f.capUsd)} left`; |
| 92 | 92 | } |
| 93 | ||
| 94 | /** | |
| 95 | * What is left of the credit the host lent this forge, when the run draws on | |
| 96 | * it (`draws`: no account of its own was picked); undefined otherwise. | |
| 97 | */ | |
| 98 | export function lentCreditLeft(draws: boolean, dataDir: string): number | undefined { | |
| 99 | if (!draws) return undefined; | |
| 100 | const f = fundingOf(dataDir); | |
| 101 | if (f === undefined || f.capUsd === null) return undefined; | |
| 102 | return Math.max(0, f.capUsd - (f.spentUsd ?? 0)); | |
| 103 | } | |
| 104 | ||
| 105 | /** A run's budget in USD: what it asked for, never more than the lent credit left (#11948). */ | |
| 106 | export const cappedByLent = (wanted: number, lentLeft: number | undefined): number => | |
| 107 | lentLeft === undefined ? wanted : Math.min(wanted, lentLeft); | |
| 108 | ||
| 109 | /** | |
| 110 | * A run ended: when this forge is funded, ask the host what it counted, so the | |
| 111 | * credit line moves. The answer's promise when asked, undefined when not funded. | |
| 112 | */ | |
| 113 | export function runEnded(app: App, fetcher?: FetchLike): Promise<Funding | undefined> | undefined { | |
| 114 | if (fundingOf(app.config.dataDir) === undefined) return undefined; | |
| 115 | return refreshFunding(app, fetcher); | |
| 116 | } |
src/tui/screens/signin.ts +7 −1
| @@ -55,6 +55,12 @@ const edgeSignupMessage = (f: Session): string => | ||
| 55 | 55 | ? "Sign up on the web page only." |
| 56 | 56 | : `Sign up on the web page only: ${f.app.config.url}/signup`; |
| 57 | 57 | |
| 58 | /** Where a stranger who cannot sign up here goes: the guest menu's own command. */ | |
| 59 | const GET_FORGE_HINT = "To get a forge of your own, press Esc and choose Start my forge."; | |
| 60 | ||
| 61 | /** What a closed sign-up says outside an edge: the refusal, then where to go instead. */ | |
| 62 | export const closedSignupText = (): string => `${SIGNUP_CLOSED_MESSAGE} ${GET_FORGE_HINT}`; | |
| 63 | ||
| 58 | 64 | export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { |
| 59 | 65 | // "Is this forge empty?" is a existence question, not a count: `COUNT(*)` |
| 60 | 66 | // walks the whole user table on the one screen every session paints. |
| @@ -67,7 +73,7 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 67 | 73 | // sign a platform user of the same name into. Sign-in is unchanged. |
| 68 | 74 | const edge = activeEdgeToken(f.app) !== undefined; |
| 69 | 75 | const closed = (): boolean => edge || (!invited() && signupClosed(f.app)); |
| 70 | const closedText = (): string => (edge ? edgeSignupMessage(f) : SIGNUP_CLOSED_MESSAGE); | |
| 76 | const closedText = (): string => (edge ? edgeSignupMessage(f) : closedSignupText()); | |
| 71 | 77 | let mode: "signin" | "create" = first && !closed() ? "create" : "signin"; |
| 72 | 78 | let values = ["", "", "", ""]; |
| 73 | 79 | let focus = 0; |
src/web/pages/claim.ts +21 −2
| @@ -43,7 +43,9 @@ import { createSession } from "../../core/sessions.ts"; | ||
| 43 | 43 | import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts"; |
| 44 | 44 | import { |
| 45 | 45 | burnClaim, |
| 46 | becomeOrg, | |
| 46 | 47 | founderToTake, |
| 48 | isForgeName, | |
| 47 | 49 | linkOf, |
| 48 | 50 | handOver, |
| 49 | 51 | isPlaceholderFounder, |
| @@ -390,7 +392,11 @@ function problems( | ||
| 390 | 392 | errors.username = "Usernames are letters, digits, dots, dashes."; |
| 391 | 393 | // The spawn-made founder holds the spawner's own name; the claim may take |
| 392 | 394 | // it over (`founderToTake`), so it is not "taken" to the claimant. |
| 393 | else if (getUserByName(ctx.app.db, username) && founderToTake(ctx.app, spawned, username) === undefined) | |
| 395 | else if ( | |
| 396 | getUserByName(ctx.app.db, username) && | |
| 397 | founderToTake(ctx.app, spawned, username) === undefined && | |
| 398 | !isForgeName(spawned, username) | |
| 399 | ) | |
| 394 | 400 | errors.username = USERNAME_TAKEN; |
| 395 | 401 | if (email !== "" ? !givenEmail(email) : needEmail) |
| 396 | 402 | errors.email = "That does not look like an email address."; |
| @@ -481,8 +487,9 @@ async function submit(ctx: Ctx, own: boolean): Promise<Response> { | ||
| 481 | 487 | // Hashed before the link is spent: nothing is awaited between the burn |
| 482 | 488 | // and the takeover. |
| 483 | 489 | const founder = founderToTake(ctx.app, spawned, values.username); |
| 490 | const takesName = founder === undefined && isForgeName(spawned, values.username); | |
| 484 | 491 | const founderHash = |
| 485 | founder === undefined || credential !== undefined ? "" : await Bun.password.hash(password, "argon2id"); | |
| 492 | (founder === undefined && !takesName) || credential !== undefined ? "" : await Bun.password.hash(password, "argon2id"); | |
| 486 | 493 | // Spent first, in one statement: of two people on one link, one wins. |
| 487 | 494 | const spent = burnClaim(ctx.app.db, orgId, token); |
| 488 | 495 | if (spent.state !== "ok") return dead(ctx, spent.state, undefined, undefined, own); |
| @@ -502,6 +509,18 @@ async function submit(ctx: Ctx, own: boolean): Promise<Response> { | ||
| 502 | 509 | } |
| 503 | 510 | return claimed(ctx, spawned, founder); |
| 504 | 511 | } |
| 512 | if (takesName) { | |
| 513 | // The forge's own name: its organisation row becomes the account, so the | |
| 514 | // apps under it stay put (#11948). | |
| 515 | const taken = becomeOrg(ctx.app.db, spawned.org.id, { hash: founderHash, email, ...(credential === undefined ? {} : { credential }) }); | |
| 516 | if (taken !== "ok") { | |
| 517 | unburnClaim(ctx.app.db, orgId, token); | |
| 518 | return again(taken === "email_taken" ? { email: EMAIL_TAKEN_MESSAGE } : { username: USERNAME_TAKEN }); | |
| 519 | } | |
| 520 | const person = getUserByName(ctx.app.db, values.username); | |
| 521 | if (person === undefined) return again({ username: USERNAME_TAKEN }); | |
| 522 | return claimed(ctx, spawned, person); | |
| 523 | } | |
| 505 | 524 | const made = await createUser(ctx.app.db, { |
| 506 | 525 | username: values.username, |
| 507 | 526 | email, |
src/web/pages/new-forge.ts +3 −3
| @@ -175,9 +175,9 @@ function freshError(errors: Errors, repoCount: number): string | undefined { | ||
| 175 | 175 | function hostingTerms(includedUsd: number): string { |
| 176 | 176 | const crew = |
| 177 | 177 | includedUsd > 0 |
| 178 | ? `its crew starts on the included $${includedUsd} of credit, and you can add your AI account key later` | |
| 179 | : "its crew starts once you add your AI account"; | |
| 180 | return `Free within limits: it sleeps when idle, and ${crew}.`; | |
| 178 | ? `The crew builds on the included $${includedUsd} of credit; add your own AI account key to keep going.` | |
| 179 | : "The crew starts once you add your AI account key."; | |
outdated
Principle 8, plain words. "model key" is on the jargon list and the other branch now says "AI account key". Use "AI account" in both. |
||
| 180 | return `Free within limits: it sleeps when idle. ${crew}`; | |
| 181 | 181 | } |
| 182 | 182 | |
| 183 | 183 | function formView( |
src/web/style/components.ts +6
| @@ -770,6 +770,12 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 770 | 770 | font-size: 16px; color: var(--fg); |
| 771 | 771 | } |
| 772 | 772 | .topbar-search kbd { flex: none; } |
| 773 | /* A phone's bar gives the search what is left: the sign-in buttons are never clipped (#11948). */ | |
| 774 | @media (max-width: 479px) { | |
The 96px flex-basis is a magic number that was never checked on a phone. Say how it was verified at 360px and 390px, and use a token or a named width. |
||
| 775 | .topbar-search { --topbar-search-basis: 6rem; width: auto; flex: 0 1 var(--topbar-search-basis); } | |
| 776 | .topbar-search kbd { display: none; } | |
| 777 | .topbar-tools .btn { flex: none; white-space: nowrap; } | |
| 778 | } | |
| 773 | 779 | |
| 774 | 780 | /* ---- theme swatch: the colour chip beside each theme in the menu's picker ---- */ |
| 775 | 781 | .swatch { |
src/web/ui.ts +1 −6
| @@ -595,12 +595,7 @@ export function terminalLine( | ||
| 595 | 595 | fingerprint?: string | undefined, |
| 596 | 596 | ): Html { |
| 597 | 597 | if (terminal.command === undefined) |
| 598 | return terminal.reason === "no_listener" | |
| 599 | ? html`<p class="muted small"> | |
| 600 | The terminal is off — this forge has no SSH listener. Set | |
| 601 | <span class="mono">FORGE_SSH_PORT</span> to turn it on. | |
| 602 | </p>` | |
| 603 | : html`<p class="muted small">The terminal is off.</p>`; | |
| 598 | return html`<p class="muted small">The terminal is off.</p>`; | |
| 604 | 599 | return terminalCommand(terminal.command, fingerprint); |
outdated
An empty template for one reason and a sentence for the other is dead-branch shape. Pick one rule for both reasons. |
||
| 605 | 600 | } |
| 606 | 601 |
test/credit-moves.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * The included credit moves when a run spends (#11948): the host is asked at | |
| 3 | * the run's end, not only at the next heartbeat, and a run on lent credit is | |
| 4 | * budgeted no more than what is left of it, so the budget line and the credit | |
| 5 | * line are one figure. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { fundingOf, keepFunding, refreshFunding } from "../src/spawn/funding.ts"; | |
| 13 | import { crewNote } from "../src/web/studio/crew_key.ts"; | |
| 14 | ||
| 15 | let dir = ""; | |
| 16 | let child: App; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dir = await mkdtemp(join(tmpdir(), "credit-moves-")); | |
| 20 | await mkdir(dir, { recursive: true }); | |
| 21 | await writeFile(join(dir, "parent.json"), "{}"); | |
| 22 | child = createApp({ dataDir: dir, port: 0 }); | |
| 23 | keepFunding(dir, { token: "maria.bearer-token", capUsd: 2, spentUsd: 0 }); | |
| 24 | process.env["FORGE_PARENT_LOCAL"] = "http://127.0.0.1:1"; | |
| 25 | }); | |
| 26 | afterAll(async () => { | |
| 27 | delete process.env["FORGE_PARENT_LOCAL"]; | |
| 28 | await rm(dir, { recursive: true, force: true }); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the credit line moves after the host counts a run", async () => { | |
| 32 | expect(crewNote(child)).toBe("The crew is building on $2 of $2 included credit."); | |
| 33 | await refreshFunding(child, async () => new Response(JSON.stringify({ capUsd: 2, spentUsd: 0.5 }))); | |
| 34 | expect(fundingOf(dir)?.spentUsd).toBe(0.5); | |
| 35 | expect(crewNote(child)).toBe("The crew is building on $1.50 of $2 included credit."); | |
| 36 | }); |
test/new-forge-language.test.ts +2 −2
| @@ -56,11 +56,11 @@ test("the hosting line says the crew starts on the included credit only when the | ||
| 56 | 56 | const id = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id; |
| 57 | 57 | setLimit(app.db, "spawn.included_credit_usd", 3, id); |
| 58 | 58 | expect(await say()).toBe( |
| 59 | "Free within limits: it sleeps when idle, and its crew starts on the included $3 of credit, and you can add your AI account key later.", | |
| 59 | "Free within limits: it sleeps when idle. The crew builds on the included $3 of credit; add your own AI account key to keep going.", | |
| 60 | 60 | ); |
| 61 | 61 | setLimit(app.db, "spawn.included_credit_usd", 0, id); |
| 62 | 62 | expect(await say()).toBe( |
| 63 | "Free within limits: it sleeps when idle, and its crew starts once you add your AI account.", | |
| 63 | "Free within limits: it sleeps when idle. The crew starts once you add your AI account key.", | |
| 64 | 64 | ); |
| 65 | 65 | }); |
| 66 | 66 |
test/stranger-first-fixes.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * The pieces of a stranger's first five minutes (#11948) that a page test | |
| 3 | * does not reach: taking the forge's own name, the credit a run is given, the | |
| 4 | * terminal's words and the phone's top bar. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import type { Credential, Jwk } from "../src/auth/webauthn.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createOrg } from "../src/core/orgs.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { becomeOrg } from "../src/spawn/claim.ts"; | |
| 15 | import { cappedByLent, keepFunding, lentCreditLeft, runEnded } from "../src/spawn/funding.ts"; | |
| 16 | import { closedSignupText } from "../src/tui/screens/signin.ts"; | |
| 17 | import { terminalLine } from "../src/web/ui.ts"; | |
| 18 | import { components as componentsCss } from "../src/web/style/components.ts"; | |
| 19 | import { startedForge, type StartedForge } from "./helpers/claimed-forge.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dir = ""; | |
| 25 | let app: App; | |
| 26 | let founderId = ""; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "first-five-")); | |
| 30 | await mkdir(dir, { recursive: true }); | |
| 31 | await writeFile(join(dir, "parent.json"), "{}"); | |
| 32 | app = createApp({ dataDir: dir, port: 0 }); | |
| 33 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "founder", email: "[email protected]", password: "a-long-enough-password" }); | |
| 34 | if (!made.ok) throw new Error("user"); | |
| 35 | founderId = made.value.id; | |
| 36 | }); | |
| 37 | afterAll(async () => { | |
| 38 | delete process.env["FORGE_PARENT_LOCAL"]; | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | const org = (name: string): string => { | |
| 43 | const made = createOrg(app.db, name, founderId); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | return made.value.id; | |
| 46 | }; | |
| 47 | const row = (id: string) => app.db.query<{ kind: string; is_admin: number; email: string }, [string]>("SELECT kind, is_admin, email FROM user WHERE id = ?").get(id)!; | |
| 48 | ||
| 49 | test("becomeOrg turns the organisation into an admin account and drops any session it held", () => { | |
| 50 | const id = org("marias"); | |
| 51 | app.db.query("INSERT INTO session (token_hash, user_id, expires_at) VALUES ('h1', ?1, '2999-01-01T00:00:00Z')").run(id); | |
| 52 | expect(becomeOrg(app.db, id, { hash: "x", email: "[email protected]" })).toBe("ok"); | |
| 53 | expect(row(id)).toEqual({ kind: "user", is_admin: 1, email: "[email protected]" }); | |
| 54 | expect(app.db.query("SELECT 1 FROM session WHERE user_id = ?").get(id)).toBeNull(); | |
| 55 | }); | |
| 56 | ||
| 57 | test("becomeOrg is gone once the row is no longer an organisation", () => { | |
| 58 | const id = org("bakery"); | |
| 59 | expect(becomeOrg(app.db, id, { hash: "x", email: "[email protected]" })).toBe("ok"); | |
| 60 | expect(becomeOrg(app.db, id, { hash: "y", email: "[email protected]" })).toBe("gone"); | |
| 61 | }); | |
| 62 | ||
| 63 | test("becomeOrg refuses an address another account holds, and changes nothing", () => { | |
| 64 | const id = org("shop"); | |
| 65 | expect(becomeOrg(app.db, id, { hash: "x", email: "[email protected]" })).toBe("email_taken"); | |
| 66 | expect(row(id).kind).toBe("org"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("becomeOrg keeps the passkey given, and a refused passkey leaves the organisation as it was", () => { | |
| 70 | const key: Jwk = { kty: "EC", crv: "P-256", x: "AAAA", y: "AAAA" }; | |
| 71 | const credential: Credential = { id: "cred-1", alg: "ES256", key, signCount: 0 }; | |
| 72 | const a = org("vault"); | |
| 73 | expect(becomeOrg(app.db, a, { hash: "", email: "[email protected]", credential })).toBe("ok"); | |
| 74 | expect(app.db.query("SELECT count(*) AS n FROM passkey WHERE user_id = ?").get(a)).toEqual({ n: 1 }); | |
| 75 | const b = org("lockbox"); | |
| 76 | expect(becomeOrg(app.db, b, { hash: "", email: "[email protected]", credential })).toBe("gone"); | |
| 77 | expect(row(b).kind).toBe("org"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a run on lent credit is budgeted at most what is left of it", () => { | |
| 81 | keepFunding(dir, { token: "maria.bearer-token", capUsd: 2, spentUsd: 0.5 }); | |
| 82 | expect(lentCreditLeft(true, dir)).toBe(1.5); | |
| 83 | expect(lentCreditLeft(false, dir)).toBeUndefined(); | |
| 84 | expect(cappedByLent(5, lentCreditLeft(true, dir))).toBe(1.5); | |
| 85 | expect(cappedByLent(1, lentCreditLeft(true, dir))).toBe(1); | |
| 86 | expect(cappedByLent(5, lentCreditLeft(false, dir))).toBe(5); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a run's end asks the host what it counted, only when this forge is funded", async () => { | |
| 90 | process.env["FORGE_PARENT_LOCAL"] = "http://127.0.0.1:1"; | |
| 91 | const asked: string[] = []; | |
| 92 | const fetcher = async (url: string) => { | |
| 93 | asked.push(url); | |
| 94 | return new Response(JSON.stringify({ capUsd: 2, spentUsd: 1 })); | |
| 95 | }; | |
| 96 | const asking = runEnded(app, fetcher); | |
| 97 | expect(asking).toBeDefined(); | |
| 98 | await asking; | |
| 99 | expect(asked).toEqual(["http://127.0.0.1:1/api/v1/credit"]); | |
| 100 | const bare = createApp({ dataDir: await mkdtemp(join(tmpdir(), "first-five-bare-")), port: 0 }); | |
| 101 | expect(runEnded(bare, fetcher)).toBeUndefined(); | |
| 102 | await rm(bare.config.dataDir, { recursive: true, force: true }); | |
| 103 | expect(asked.length).toBe(1); | |
| 104 | }); | |
| 105 | ||
| 106 | test("the terminal line has one rule: a command, or that the terminal is off", () => { | |
| 107 | expect(String(terminalLine({ reason: "no_listener" }))).toContain("The terminal is off."); | |
| 108 | expect(String(terminalLine({ reason: "no_tty" }))).toContain("The terminal is off."); | |
| 109 | expect(String(terminalLine({ command: "ssh -p 2222 x@localhost" }))).toContain("ssh -p 2222 x@localhost"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a closed sign-up in the terminal points at Start my forge", () => { | |
| 113 | expect(closedSignupText()).toContain("choose Start my forge"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("the phone's top bar names its search width and keeps the buttons whole", () => { | |
| 117 | const css = String(componentsCss); | |
| 118 | expect(css).toContain("--topbar-search-basis"); | |
| 119 | expect(css).not.toContain("flex: 0 1 96px"); | |
| 120 | expect(css).toMatch(/\.topbar-tools \.btn \{[^}]*flex: none/); | |
| 121 | }); | |
| 122 | ||
| 123 | let fresh: StartedForge; | |
| 124 | test("the sent link's /claim takes the forge's own name too, with the code field visible", async () => { | |
| 125 | fresh = await startedForge("forge-claim-name-"); | |
| 126 | try { | |
| 127 | const html = await (await fetch(`${fresh.base}/claim`)).text(); | |
| 128 | expect(html).not.toMatch(/<div id="claim-token-field"[^>]*hidden/); | |
| 129 | const name = fresh.child.db.query<{ username: string }, []>("SELECT username FROM user WHERE kind = 'org' LIMIT 1").get()!.username; | |
| 130 | const res = await fetch(`${fresh.base}/claim`, { | |
| 131 | method: "POST", | |
| 132 | headers: { origin: fresh.base }, | |
| 133 | body: new URLSearchParams({ token: fresh.token, username: name, email: "[email protected]", password: "a-long-enough-password" }), | |
| 134 | redirect: "manual", | |
| 135 | }); | |
| 136 | expect(res.status).toBe(303); | |
| 137 | expect(fresh.child.db.query("SELECT kind FROM user WHERE username = ?").get(name)).toEqual({ kind: "user" }); | |
| 138 | } finally { | |
| 139 | await fresh.stop(); | |
| 140 | } | |
| 141 | }, 60_000); |
test/studio-door.test.ts +3 −3
| @@ -971,14 +971,14 @@ describe("the studio app page's terminal", () => { | ||
| 971 | 971 | // asserts the line names FORGE_SSH_PORT instead, composed from the same |
| 972 | 972 | // sshPort field the other tests here already pin (#77's ground) rather |
| 973 | 973 | // than by shutting a real listener down. |
| 974 | test("a hosted forge with no SSH listener says why, and names FORGE_SSH_PORT", async () => { | |
| 974 | test("a hosted forge with no SSH listener says the terminal is off, with nothing an operator would act on (#11948)", async () => { | |
| 975 | 975 | putLive("bun run src/tty.ts"); |
| 976 | 976 | app.config.sshPort = 0; |
| 977 | 977 | try { |
| 978 | 978 | const mine = await appPage(writer); |
| 979 | expect(mine).toContain("FORGE_SSH_PORT"); | |
| 979 | expect(mine).not.toContain("FORGE_SSH_PORT"); | |
| 980 | expect(mine).toContain("The terminal is off."); | |
| 980 | 981 | expect(mine).not.toContain("ssh -p 2222 [email protected]"); |
| 981 | expect(mine).not.toContain("The terminal is off.</p>"); | |
| 982 | 982 | } finally { |
| 983 | 983 | app.config.sshPort = 2222; |
| 984 | 984 | } |
test/welcome.test.ts +25
| @@ -94,3 +94,28 @@ test("the home's scripts join without calling the one before", () => { | ||
| 94 | 94 | expect(() => new Function(joined)).not.toThrow(); |
| 95 | 95 | for (const part of [funnelJs, startNameJs]) expect(part.trimEnd().endsWith(";")).toBe(true); |
| 96 | 96 | }); |
| 97 | ||
| 98 | test("the link code field is visible and required in the server render, and the forge's own name is a name a person may take (#11948)", async () => { | |
| 99 | const started = await startedForge("forge-welcome-name-"); | |
| 100 | try { | |
| 101 | const html = await (await fetch(`${started.base}/welcome`)).text(); | |
| 102 | expect(html).not.toMatch(/<div id="claim-token-field"[^>]*hidden/); | |
| 103 | expect(html).toMatch(/<input[^>]*name="token"[^>]*required|<input[^>]*required[^>]*name="token"/); | |
| 104 | const org = started.child.db.query<{ username: string }, []>("SELECT username FROM user WHERE kind = 'org' LIMIT 1").get()!.username; | |
| 105 | const res = await fetch(`${started.base}/welcome`, { | |
| 106 | method: "POST", | |
| 107 | headers: { origin: started.base }, | |
| 108 | body: new URLSearchParams({ token: started.token, username: org, password: "a-long-enough-password" }), | |
| 109 | redirect: "manual", | |
| 110 | }); | |
| 111 | expect(res.status).toBe(303); | |
| 112 | expect(res.headers.get("set-cookie")).toContain("HttpOnly"); | |
| 113 | const row = started.child.db.query<{ kind: string; is_admin: number }, [string]>("SELECT kind, is_admin FROM user WHERE username = ?").get(org)!; | |
| 114 | expect(row.kind).toBe("user"); | |
| 115 | expect(row.is_admin).toBe(1); | |
| 116 | const home = await fetch(`${started.base}/`, { headers: { cookie: (res.headers.get("set-cookie") ?? "").split(";")[0]! } }); | |
| 117 | expect(home.status).toBe(200); | |
| 118 | } finally { | |
| 119 | await started.stop(); | |
| 120 | } | |
| 121 | }, 60_000); |
new above
offline
The plan's
sayswas one short sentence and is now two clauses. Principle 1: one confident sentence.