Public: anyone can read this; the crew can change it.
Invites, part 3: Settings lists invite links, mints one with a chosen expiry and revokes one #2377
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2337-invites-part-3-settings-lists-invite-lin → main 4 commits fork 4 threads · 4 unresolved
README.md +1 −1
| @@ -749,7 +749,7 @@ one the provider rests hands its runs to the other until the reset. | ||
| 749 | 749 | |
| 750 | 750 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); `guard_guests` in Capacity switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included). |
| 751 | 751 | |
| 752 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting and revoking are events (`invite.minted`, `invite.revoked`). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. Signup does not read invites yet. | |
| 752 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting and revoking are events (`invite.minted`, `invite.revoked`). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. Signup does not read invites yet. | |
| 753 | 753 | |
| 754 | 754 | **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent. |
| 755 | 755 | **Sandbox.** An agent's shell is held to a policy — the network is the forge |
src/http/server.ts +3 −1
| @@ -21,7 +21,7 @@ import { flagsPage, flagsSet } from "../web/pages/flags.ts"; | ||
| 21 | 21 | import { usagePage } from "../web/pages/usage.ts"; |
| 22 | 22 | import { domainsPage } from "../web/pages/domains.ts"; |
| 23 | 23 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 24 | import { limitsPage, limitsSet, quotasSet, signupSet } from "../web/pages/admin-limits.ts"; | |
| 24 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; | |
| 25 | 25 | import { boardsPage } from "../web/pages/boards.ts"; |
| 26 | 26 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; |
| 27 | 27 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| @@ -798,6 +798,8 @@ export function buildRouter(): Router<Handler> { | ||
| 798 | 798 | router.add("POST", "/admin/limits", limitsSet); |
| 799 | 799 | router.add("POST", "/admin/quotas", quotasSet); |
| 800 | 800 | router.add("POST", "/admin/signup", signupSet); |
| 801 | router.add("POST", "/admin/invites", inviteMint); | |
| 802 | router.add("POST", "/admin/invites/:id/revoke", inviteRevoke); | |
| 801 | 803 | router.add("GET", "/settings/backups", backupsPage); |
| 802 | 804 | router.add("POST", "/settings/backups/now", backupsNow); |
| 803 | 805 | router.add("POST", "/settings/backups/drill", backupsDrill); |
src/tui/forge.ts +9
| @@ -51,6 +51,7 @@ import { canScreen } from "./screens/can.ts"; | ||
| 51 | 51 | import { peopleScreen } from "./screens/people.ts"; |
| 52 | 52 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; |
| 53 | 53 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 54 | import { invitesScreen } from "./screens/invites.ts"; | |
| 54 | 55 | import { quotasScreen } from "./screens/quotas.ts"; |
| 55 | 56 | import { domainsScreen } from "./screens/domains.ts"; |
| 56 | 57 | import { ownerScreen } from "./screens/owner.ts"; |
| @@ -420,6 +421,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 420 | 421 | hint: "what one account may own, and who changed it", |
| 421 | 422 | run: () => ctx.push(quotasScreen(session, ctx)), |
| 422 | 423 | }); |
| 424 | // Invite links, as /admin/limits has them: an admin who is a person (#2337). | |
| 425 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 426 | commands.push({ | |
| 427 | id: "invites", | |
| 428 | title: "Invite links", | |
| 429 | hint: "make a single-use link, see which are open, revoke one", | |
| 430 | run: () => ctx.push(invitesScreen(session, ctx)), | |
| 431 | }); | |
| 423 | 432 | // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199). |
| 424 | 433 | if (session.user?.isAdmin === true) |
| 425 | 434 | commands.push({ |
src/tui/screens/invites.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * Invite links in the terminal, a forge admin's (#2337): every link with its | |
| 3 | * state, when it was made and when it expires, as Invite links on /admin/limits | |
| 4 | * shows them. `m` mints one from a number of days (1 to 30, 7 by default) and | |
| 5 | * prints the link once, on this screen, until the next key of any kind; `x` revokes the | |
| 6 | * selected open one. Both go through the actions, so the gate is theirs: an | |
| 7 | * admin who is a person; anyone else sees nothing and mints nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { inviteLink, list, mint, revoke } from "../../actions/invites.ts"; | |
| 11 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.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 { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 19 | ||
| 20 | export function invitesScreen(f: Session, ctx: Ctx): Screen { | |
| 21 | const state = new ListState(); | |
| 22 | let note = ""; | |
| 23 | const read = () => list(f.app, f.user); | |
| 24 | const items = () => { | |
| 25 | const r = read(); | |
| 26 | return r.ok ? r.value : []; | |
| 27 | }; | |
| 28 | ||
| 29 | const make = (raw: string): string | undefined => { | |
| 30 | const text = raw.trim(); | |
| 31 | const days = text === "" ? DEFAULT_DAYS : /^\d{1,3}$/.test(text) ? Number(text) : -1; | |
| 32 | const made = mint(f.app, f.user, days); | |
| 33 | if (!made.ok) | |
| 34 | return made.error === "invalid_days" | |
| 35 | ? `Days is a whole number from 1 to ${MAX_DAYS}.` | |
| 36 | : "You cannot make invite links."; | |
| 37 | const origin = f.app.config.url ?? `http://localhost:${f.app.config.port}`; | |
| 38 | note = `Shown once: ${inviteLink(origin, made.value.token)}`; | |
| 39 | ctx.invalidate(); | |
| 40 | return undefined; | |
| 41 | }; | |
| 42 | ||
| 43 | const rows = (): ListRow[] => | |
| 44 | items().map((i) => ({ | |
| 45 | title: i.id, | |
| 46 | secondary: i.state, | |
| 47 | meta: `made ${i.createdAt.slice(0, 16)} expires ${i.expiresAt.slice(0, 16)}`, | |
| 48 | })); | |
| 49 | ||
| 50 | const screen: Screen = { | |
| 51 | title: "Invite links", | |
| 52 | keys: [binding(["j", "k"], "move cursor"), binding(["m"], "make a link"), binding(["x"], "revoke")], | |
| 53 | render(area, s) { | |
| 54 | const c = ctx.theme.c; | |
| 55 | const r = read(); | |
| 56 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 57 | const text = | |
| 58 | note !== "" | |
| 59 | ? note | |
| 60 | : !r.ok | |
| 61 | ? "Invite links are made by a forge admin." | |
| 62 | : r.value.length === 0 | |
| 63 | ? "No invite link yet. m makes one." | |
| 64 | : "Each link is good for one signup. m makes one, x revokes."; | |
| 65 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 66 | drawList(s, body!, c, state, rows()); | |
| 67 | }, | |
| 68 | key(e) { | |
| 69 | note = ""; | |
| 70 | const all = items(); | |
outdated
j/k return here before |
||
| 71 | if (state.key(e, all.length)) return true; | |
| 72 | if (e.ctrl || e.alt || !read().ok) return false; | |
| 73 | if (e.key === "m") { | |
| 74 | ctx.push( | |
| 75 | promptScreen(ctx, screen, { | |
| 76 | title: "Invite link", | |
| 77 | label: `days until it expires, 1 to ${MAX_DAYS}`, | |
| 78 | value: String(DEFAULT_DAYS), | |
| 79 | onSubmit: make, | |
| 80 | }), | |
| 81 | ); | |
| 82 | } else if (e.key === "x") { | |
| 83 | const row = all[state.index]; | |
| 84 | if (row === undefined) return false; | |
| 85 | note = revoke(f.app, f.user, row.id).ok ? `Revoked ${row.id}.` : "That link is not open."; | |
| 86 | ctx.invalidate(); | |
| 87 | } else return false; | |
| 88 | return true; | |
| 89 | }, | |
| 90 | }; | |
| 91 | return screen; | |
| 92 | } |
src/web/pages/admin-limits.ts +83 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 9 | 9 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| 10 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; | |
| 11 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts"; | |
| 10 | 12 | import { changeSignup, readSignup } from "../../actions/signup.ts"; |
| 11 | 13 | import { SIGNUP_MODES } from "../../settings/signup.ts"; |
| 12 | 14 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; |
| @@ -150,7 +152,51 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 150 | 152 | pad: false, |
| 151 | 153 | }, |
| 152 | 154 | ); |
| 153 | const body = html`${signupCard}${quotaCard}${card( | |
| 155 | const invites = listInviteLinks(ctx.app, ctx.auth.user); | |
| 156 | const inviteRows = (invites.ok ? invites.value : []).map((i) => | |
| 157 | row({ | |
| 158 | lead: icon("activity"), | |
| 159 | title: html`<span class="mono">${i.id}</span> <span class="muted small">${i.state}</span>`, | |
| 160 | meta: html`Made ${time(i.createdAt)} · expires ${time(i.expiresAt)}`, | |
| 161 | trail: | |
| 162 | i.state === "open" | |
| 163 | ? html`<form class="row-actions" method="post" action="/admin/invites/${i.id}/revoke"> | |
| 164 | ${btn("Revoke", { size: "sm", label: `Revoke invite ${i.id}` })} | |
| 165 | </form>` | |
| 166 | : html``, | |
| 167 | }), | |
| 168 | ); | |
| 169 | const inviteCard = card( | |
| 170 | html`<div class="card-body"> | |
| 171 | <p class="muted small"> | |
| 172 | A link is shown once, when it is made, and is good for one signup. | |
| 173 | Nothing reads these yet. | |
| 174 | </p> | |
| 175 | <form class="row-actions" method="post" action="/admin/invites"> | |
| 176 | <label class="small">expires in (days) | |
| 177 | <input | |
| 178 | class="input mono" | |
| 179 | type="number" | |
| 180 | name="days" | |
| 181 | min="1" | |
| 182 | max="${String(MAX_DAYS)}" | |
| 183 | step="1" | |
| 184 | value="${String(DEFAULT_DAYS)}" | |
| 185 | /></label> | |
| 186 | ${btn("Make a link", { size: "sm", variant: "primary", label: "Make an invite link" })} | |
| 187 | </form> | |
| 188 | </div> | |
| 189 | ${when( | |
| 190 | inviteRows.length > 0, | |
| 191 | () => html`<ul class="list">${inviteRows}</ul>`, | |
| 192 | )}`, | |
| 193 | { | |
| 194 | head: html`${icon("activity")} | |
| 195 | <h2>Invite links</h2>`, | |
| 196 | pad: false, | |
| 197 | }, | |
| 198 | ); | |
| 199 | const body = html`${signupCard}${inviteCard}${quotaCard}${card( | |
| 154 | 200 | html`<div class="card-body"> |
| 155 | 201 | <p class="muted small"> |
| 156 | 202 | Every limit you can hit, and why it is there. A change governs |
| @@ -266,3 +312,39 @@ export async function signupSet(ctx: Ctx): Promise<Response> { | ||
| 266 | 312 | : { tone: "danger", message: "Signup is open, invite or closed." }, |
| 267 | 313 | ); |
| 268 | 314 | } |
| 315 | ||
| 316 | // POST /admin/invites | |
| 317 | export async function inviteMint(ctx: Ctx): Promise<Response> { | |
| 318 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") | |
| 319 | return notFoundPage(ctx); | |
| 320 | const form = await readForm(ctx, "admin"); | |
| 321 | if (form instanceof Response) return form; | |
| 322 | const raw = field(form, "days").trim(); | |
| 323 | const made = mintInviteLink(ctx.app, ctx.auth.user, /^\d{1,3}$/.test(raw) ? Number(raw) : -1); | |
| 324 | if (!made.ok && made.error === "not_found") return notFoundPage(ctx); | |
| 325 | if (!made.ok) | |
| 326 | return flashTo(ctx, "/admin/limits", { | |
| 327 | tone: "danger", | |
| 328 | message: `Days is a whole number from 1 to ${MAX_DAYS}.`, | |
| 329 | }); | |
| 330 | return flashTo(ctx, "/admin/limits", { | |
outdated
This duplicates |
||
| 331 | message: "Invite link made. It is shown only now:", | |
| 332 | code: inviteLink(ctx.app.config.url ?? ctx.url.origin, made.value.token), | |
| 333 | }); | |
| 334 | } | |
| 335 | ||
| 336 | // POST /admin/invites/:id/revoke | |
| 337 | export async function inviteRevoke(ctx: Ctx): Promise<Response> { | |
| 338 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") | |
| 339 | return notFoundPage(ctx); | |
| 340 | const form = await readForm(ctx, "admin"); | |
| 341 | if (form instanceof Response) return form; | |
| 342 | const done = revokeInviteLink(ctx.app, ctx.auth.user, ctx.params.id ?? ""); | |
| 343 | return flashTo( | |
| 344 | ctx, | |
| 345 | "/admin/limits", | |
| 346 | done.ok | |
| 347 | ? { message: "Invite link revoked." } | |
| 348 | : { tone: "danger", message: "That link is not open." }, | |
| 349 | ); | |
| 350 | } |
test/invites.test.ts +63
| @@ -5,6 +5,7 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { signUp } from "../src/actions/users.ts"; |
| 7 | 7 | import { listEvents } from "../src/core/events.ts"; |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 8 | 9 | import { createToken } from "../src/core/tokens.ts"; |
| 9 | 10 | import { listInvites, mintInvite, revokeInvite } from "../src/core/invites.ts"; |
| 10 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -96,3 +97,65 @@ describe("invites: an admin mints, lists and revokes", () => { | ||
| 96 | 97 | expect(revokeInvite(app.db, used.invite.id).ok).toBe(false); |
| 97 | 98 | }); |
| 98 | 99 | }); |
| 100 | ||
| 101 | describe("invites: the settings form", () => { | |
| 102 | let dir: string; | |
| 103 | let app: App; | |
| 104 | let servers: Servers; | |
| 105 | let alice: string; | |
| 106 | let bob: string; | |
| 107 | ||
| 108 | beforeAll(async () => { | |
| 109 | dir = await mkdtemp(join(tmpdir(), "forge-invites-form-")); | |
| 110 | app = createApp({ dataDir: dir, port: 0 }); | |
| 111 | servers = await serve(app); | |
| 112 | const a = await signUp(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" }); | |
| 113 | const b = await signUp(app, { username: "ada", email: "[email protected]", password: "guest-passphrase" }); | |
| 114 | if (!a.ok || !b.ok) throw new Error("signup"); | |
| 115 | proveAll(app); | |
| 116 | alice = `forge_session=${createSession(app.db, a.value.id).token}`; | |
| 117 | bob = `forge_session=${createSession(app.db, b.value.id).token}`; | |
| 118 | }); | |
| 119 | afterAll(async () => { | |
| 120 | servers.stop(); | |
| 121 | app.close(); | |
| 122 | await rm(dir, { recursive: true, force: true }); | |
| 123 | }); | |
| 124 | ||
| 125 | const post = (cookie: string, path: string, fields: Record<string, string>, origin = servers.url) => | |
| 126 | fetch(`${servers.url}${path}`, { | |
| 127 | method: "POST", | |
| 128 | redirect: "manual", | |
| 129 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin }, | |
| 130 | body: new URLSearchParams(fields), | |
| 131 | }); | |
| 132 | ||
| 133 | test("mint shows the link once, the list shows the row, revoke closes it", async () => { | |
| 134 | const res = await post(alice, "/admin/invites", { days: "3" }); | |
| 135 | expect(res.status).toBe(303); | |
| 136 | const flash = res.headers.get("set-cookie") ?? ""; | |
| 137 | expect(decodeURIComponent(flash)).toContain("/signup?invite=invite_"); | |
| 138 | const page = await (await fetch(`${servers.url}/admin/limits`, { headers: { cookie: alice } })).text(); | |
| 139 | expect(page).toContain("Invite links"); | |
| 140 | expect(page).not.toContain("invite_"); | |
| 141 | const [item] = listInvites(app.db); | |
| 142 | expect(item!.state).toBe("open"); | |
| 143 | expect(page).toContain(`/admin/invites/${item!.id}/revoke`); | |
| 144 | expect((await post(alice, `/admin/invites/${item!.id}/revoke`, {})).status).toBe(303); | |
| 145 | expect(listInvites(app.db)[0]!.state).toBe("revoked"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("days outside 1 to 30 mint nothing", async () => { | |
| 149 | const before = listInvites(app.db).length; | |
| 150 | await post(alice, "/admin/invites", { days: "31" }); | |
| 151 | await post(alice, "/admin/invites", { days: "abc" }); | |
| 152 | expect(listInvites(app.db).length).toBe(before); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a cross-origin post is refused and a non-admin gets the not-found page", async () => { | |
| 156 | const before = listInvites(app.db).length; | |
| 157 | expect((await post(alice, "/admin/invites", { days: "3" }, "https://evil.example")).status).toBe(403); | |
| 158 | expect((await post(bob, "/admin/invites", { days: "3" })).status).toBe(404); | |
| 159 | expect(listInvites(app.db).length).toBe(before); | |
| 160 | }); | |
| 161 | }); |
test/tui-invites.test.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * Invite links in the terminal (#2337): an admin who is a person lists the | |
| 3 | * links, makes one (shown once) and revokes one; anyone else has no command and, driving the screen directly, | |
| 4 | * can neither read nor write. | |
| 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 { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { listInvites } from "../src/core/invites.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { invitesScreen } from "../src/tui/screens/invites.ts"; | |
| 15 | import { Session, type TuiIdentity } from "../src/tui/session.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | describe("invite links in the terminal", () => { | |
| 23 | let dataDir: string; | |
| 24 | let forge: Forge; | |
| 25 | ||
| 26 | function boot(username: string) { | |
| 27 | const terminal = headlessTerminal(140, 32); | |
| 28 | const user = getUserByName(forge.db, username)!; | |
| 29 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 30 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 31 | app.start(); | |
| 32 | const key = (k: string) => | |
| 33 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 34 | return { | |
| 35 | text: () => app.screen.toText(), | |
| 36 | press: async (...keys: string[]) => { | |
| 37 | keys.forEach(key); | |
| 38 | await settleApp(app); | |
| 39 | }, | |
| 40 | ctrl: async (k: string) => { | |
| 41 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 42 | await settleApp(app); | |
| 43 | }, | |
| 44 | type: async (t: string) => { | |
| 45 | for (const ch of t) key(ch); | |
| 46 | await settleApp(app); | |
| 47 | }, | |
| 48 | stop: () => app.stop(), | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-invites-")); | |
| 54 | forge = createApp({ dataDir, port: 0 }); | |
| 55 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 56 | const made = await createUser(forge.db, { | |
| 57 | trusted: true, | |
| 58 | username, | |
| 59 | email: `${username}@forge.local`, | |
| 60 | password: "a-good-passphrase", | |
| 61 | adminIfFirst, | |
| 62 | }); | |
| 63 | if (!made.ok) throw new Error(made.error); | |
| 64 | } | |
| 65 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 66 | }); | |
| 67 | afterAll(async () => { | |
| 68 | forge.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | const open = async (h: ReturnType<typeof boot>) => { | |
| 73 | await h.ctrl("k"); | |
| 74 | await h.type("Invite links"); | |
| 75 | await h.press("enter"); | |
| 76 | }; | |
| 77 | ||
| 78 | test("an admin makes a link, sees it once, lists it and revokes it", async () => { | |
| 79 | const h = boot("alice"); | |
| 80 | await open(h); | |
| 81 | expect(h.text()).toContain("No invite link yet"); | |
| 82 | await h.press("m"); | |
| 83 | await h.press("enter"); | |
| 84 | expect(forge.config.url).toBeUndefined(); | |
| 85 | expect(h.text()).toMatch(/Shown once: http:\/\/localhost:\d+\/signup\?invite=invite_/); | |
| 86 | const [made] = listInvites(forge.db); | |
| 87 | expect(made!.state).toBe("open"); | |
| 88 | expect(h.text()).toContain(made!.id); | |
| 89 | expect(h.text()).toContain("open"); | |
| 90 | await h.press("j"); | |
| 91 | expect(h.text()).not.toContain("invite_"); | |
| 92 | await h.press("x"); | |
| 93 | expect(listInvites(forge.db)[0]!.state).toBe("revoked"); | |
| 94 | expect(h.text()).not.toContain("invite_"); | |
| 95 | h.stop(); | |
| 96 | }); | |
| 97 | ||
| 98 | test("days outside 1 to 30 make nothing", async () => { | |
| 99 | const before = listInvites(forge.db).length; | |
| 100 | const h = boot("alice"); | |
| 101 | await open(h); | |
| 102 | await h.press("m"); | |
| 103 | await h.press("backspace"); | |
| 104 | await h.type("0"); | |
| 105 | await h.press("enter"); | |
| 106 | expect(h.text()).toContain("1 to 30"); | |
This assertion passes even when the error never shows, because the prompt label already reads "days until it expires, 1 to 30". The count check on the next line is what actually proves nothing was minted. Asserting the error text itself ("Days is a whole number") would make this line mean something. Not blocking. |
||
| 107 | expect(listInvites(forge.db).length).toBe(before); | |
| 108 | h.stop(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a non-admin and an admin agent have no command and mint nothing", async () => { | |
| 112 | const before = listInvites(forge.db).length; | |
| 113 | for (const who of ["bob", "carol"]) { | |
| 114 | const h = boot(who); | |
| 115 | await h.ctrl("k"); | |
| 116 | await h.type("Invite links"); | |
| 117 | expect(h.text()).not.toContain("single-use link"); | |
| 118 | h.stop(); | |
| 119 | const user = getUserByName(forge.db, who)!; | |
| 120 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 121 | const pushed: unknown[] = []; | |
| 122 | const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never; | |
| 123 | const screen = invitesScreen(session, ctx); | |
| 124 | const key = (k: string) => screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never); | |
| 125 | key("m"); | |
| 126 | key("x"); | |
| 127 | expect(pushed).toEqual([]); | |
| 128 | } | |
| 129 | expect(listInvites(forge.db).length).toBe(before); | |
| 130 | }); | |
| 131 | }); |
High (a broken link shown once): with no
config.urlthis prints/signup?invite=…, which is useless in a terminal. UseinviteLink(f.app.config.url ??http://localhost:${f.app.config.port}, made.value.token)fromactions/invites.ts, the same fallback as verify.ts and health.ts, and add a test withconfig.urlunset.