Public: anyone can read this; the crew can change it.
A used sign-up link says who joined with it and when, on the terminal's Sign-ups screen and Settings › Limits #8172
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8080-a-used-sign-up-link-says-who-joined-with → main 2 commits fork 1 thread · 1 unresolved
7 files
changed +82
−10
README.md +2
| @@ -192,6 +192,8 @@ will actually browse it. | ||
| 192 | 192 | Who may sign up (open, invite or closed) is shown on the terminal's Invite |
| 193 | 193 | links screen, and a forge admin who is a person changes it there with `s`, |
| 194 | 194 | which shows from → to and changes it only after a confirm (as MCP `signup` does). |
| 195 | A used link reads `used by <name> on <yyyy-mm-dd>` there and in Settings › | |
| 196 | Limits, where the name links to the person's profile. | |
| 195 | 197 | |
| 196 | 198 | No sign-up makes the admin, not even the forge's first, and that first one is |
| 197 | 199 | held to the signup setting and Turnstile like any other. Accounts sign up with |
src/core/invites.ts +13 −3
| @@ -24,6 +24,10 @@ export interface Invite { | ||
| 24 | 24 | readonly createdAt: string; |
| 25 | 25 | readonly expiresAt: string; |
| 26 | 26 | readonly state: InviteState; |
| 27 | /** When it was spent, or null. */ | |
| 28 | readonly usedAt: string | null; | |
| 29 | /** The username it let in; null when unused or the account is gone. */ | |
| 30 | readonly usedBy: string | null; | |
| 27 | 31 | } |
| 28 | 32 | |
| 29 | 33 | interface Row { |
| @@ -33,8 +37,12 @@ interface Row { | ||
| 33 | 37 | expires_at: string; |
| 34 | 38 | revoked_at: string | null; |
| 35 | 39 | used_at: string | null; |
| 40 | used_by_name: string | null; | |
| 36 | 41 | } |
| 37 | 42 | |
| 43 | /** An invite with its redeemer's username, resolved in the one query. */ | |
| 44 | const SELECT = "SELECT invite.*, user.username AS used_by_name FROM invite LEFT JOIN user ON user.id = invite.used_by"; | |
| 45 | ||
| 38 | 46 | const stateOf = (r: Row, now: number): InviteState => |
| 39 | 47 | r.revoked_at !== null ? "revoked" : r.used_at !== null ? "used" : Date.parse(r.expires_at) <= now ? "expired" : "open"; |
| 40 | 48 | |
| @@ -44,6 +52,8 @@ const view = (r: Row, now: number): Invite => ({ | ||
| 44 | 52 | createdAt: r.created_at, |
| 45 | 53 | expiresAt: r.expires_at, |
| 46 | 54 | state: stateOf(r, now), |
| 55 | usedAt: r.used_at, | |
| 56 | usedBy: r.used_at === null ? null : r.used_by_name, | |
| 47 | 57 | }); |
| 48 | 58 | |
| 49 | 59 | /** A whole number of days from 1 to 30, or the default when absent. */ |
| @@ -64,7 +74,7 @@ export function mintInvite( | ||
| 64 | 74 | db.query( |
| 65 | 75 | "INSERT INTO invite (id, token_hash, created_by, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)", |
| 66 | 76 | ).run(inviteId, sha256(token), createdBy, new Date(now).toISOString(), new Date(now + days * DAY).toISOString()); |
| 67 | const row = db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!; | |
| 77 | const row = db.query<Row, [string]>(`${SELECT} WHERE invite.id = ?1`).get(inviteId)!; | |
| 68 | 78 | return { invite: view(row, now), token }; |
| 69 | 79 | } |
| 70 | 80 | |
| @@ -103,7 +113,7 @@ export function reopenInvite(db: Database, userId: string): void { | ||
| 103 | 113 | /** Every invite, newest first, never with its token. */ |
| 104 | 114 | export function listInvites(db: Database, now: number = Date.now()): Invite[] { |
| 105 | 115 | return db |
| 106 | .query<Row, []>("SELECT * FROM invite ORDER BY id DESC LIMIT 500") | |
| 116 | .query<Row, []>(`${SELECT} ORDER BY invite.id DESC LIMIT 500`) | |
| 107 | 117 | .all() |
| 108 | 118 | .map((r) => view(r, now)); |
| 109 | 119 | } |
| @@ -118,7 +128,7 @@ export function revokeInvite( | ||
| 118 | 128 | .query("UPDATE invite SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL AND used_at IS NULL") |
| 119 | 129 | .run(inviteId, new Date(now).toISOString()).changes; |
| 120 | 130 | if (changed === 0) return err("not_found"); |
| 121 | return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now)); | |
| 131 | return ok(view(db.query<Row, [string]>(`${SELECT} WHERE invite.id = ?1`).get(inviteId)!, now)); | |
| 122 | 132 | } |
| 123 | 133 | |
| 124 | 134 | /** The id of the invite an account spent, if one let it in. */ |
src/tui/screens/invites.ts +6 −2
| @@ -13,7 +13,7 @@ import { inviteLink, list, mint, revoke } from "../../actions/invites.ts"; | ||
| 13 | 13 | import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts"; |
| 14 | 14 | import { changeSignup, readSignup } from "../../actions/signup.ts"; |
| 15 | 15 | import { SIGNUP_MODES, type SignupMode } from "../../settings/signup.ts"; |
| 16 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts"; | |
| 16 | import { DEFAULT_DAYS, MAX_DAYS, type Invite } from "../../core/invites.ts"; | |
| 17 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 18 | import { binding } from "../app/keys.ts"; |
| 19 | 19 | import { style } from "../term/style.ts"; |
| @@ -23,6 +23,10 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 23 | 23 | import { ListState, confirmScreen, drawList, pickerScreen, promptScreen, type ListRow } from "./common.ts"; |
| 24 | 24 | import type { PaletteEntry } from "./entries.ts"; |
| 25 | 25 | |
| 26 | /** `used by ada on 2026-10-01`; the name is left out when the account is gone. */ | |
| 27 | export const usedWords = (i: Invite): string => | |
| 28 | `used${i.usedBy === null ? "" : ` by ${i.usedBy}`}${i.usedAt === null ? "" : ` on ${i.usedAt.slice(0, 10)}`}`; | |
| 29 | ||
| 26 | 30 | export function invitesScreen(f: Session, ctx: Ctx): Screen { |
| 27 | 31 | const state = new ListState(); |
| 28 | 32 | let note = ""; |
| @@ -70,7 +74,7 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 70 | 74 | const rows = (): ListRow[] => |
| 71 | 75 | items().map((i) => ({ |
| 72 | 76 | title: i.id, |
| 73 | secondary: i.state, | |
| 77 | secondary: i.state === "used" ? usedWords(i) : i.state, | |
| 74 | 78 | meta: `made ${i.createdAt.slice(0, 16)} expires ${i.expiresAt.slice(0, 16)}`, |
| 75 | 79 | })); |
| 76 | 80 |
src/web/pages/admin-limits.ts +7 −1
| @@ -161,7 +161,13 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 161 | 161 | const inviteRows = (invites.ok ? invites.value : []).map((i) => |
| 162 | 162 | row({ |
| 163 | 163 | lead: icon("activity"), |
| 164 | title: html`<span class="mono">${i.id}</span> <span class="muted small">${i.state}</span>`, | |
| 164 | title: html`<span class="mono">${i.id}</span> <span class="muted small">${ | |
| 165 | i.state !== "used" | |
| 166 | ? i.state | |
| 167 | : html`used${i.usedBy === null ? "" : html` by <a href="/${i.usedBy}">${i.usedBy}</a>`}${ | |
| 168 | i.usedAt === null ? "" : ` on ${i.usedAt.slice(0, 10)}` | |
| 169 | }` | |
| 170 | }</span>`, | |
| 165 | 171 | meta: html`Made ${time(i.createdAt)} · expires ${time(i.expiresAt)}`, |
| 166 | 172 | trail: |
| 167 | 173 | i.state === "open" |
test/admin-limits-timing.test.ts +28 −3
| @@ -1,9 +1,13 @@ | ||
| 1 | /** `GET /admin/limits` names session, limits, quotas, signup, invites and render in Server-Timing (#3443). */ | |
| 1 | /** | |
| 2 | * `GET /admin/limits` names session, limits, quotas, signup, invites and render in Server-Timing (#3443), | |
| 3 | * and a used invite says who joined with it and when, linked to their profile (#8080). | |
| 4 | */ | |
| 2 | 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 6 | import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | 7 | import { tmpdir } from "node:os"; |
| 5 | 8 | import { join } from "node:path"; |
| 6 | 9 | import { createApp, type App } from "../src/app.ts"; |
| 10 | import { mintInvite, redeemInvite } from "../src/core/invites.ts"; | |
| 7 | 11 | import { createUser } from "../src/core/users.ts"; |
| 8 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 13 | import { proveAll } from "./helpers/verified.ts"; |
| @@ -36,14 +40,18 @@ afterAll(() => { | ||
| 36 | 40 | rmSync(root, { recursive: true, force: true }); |
| 37 | 41 | }); |
| 38 | 42 | |
| 39 | test("GET /admin/limits names its phases", async () => { | |
| 43 | const signedIn = async (): Promise<string> => { | |
| 40 | 44 | const signin = await fetch(`${servers.url}/signin`, { |
| 41 | 45 | method: "POST", |
| 42 | 46 | headers: { origin: servers.url }, |
| 43 | 47 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), |
| 44 | 48 | redirect: "manual", |
| 45 | 49 | }); |
| 46 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 50 | return (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 51 | }; | |
| 52 | ||
| 53 | test("GET /admin/limits names its phases", async () => { | |
| 54 | const cookie = await signedIn(); | |
| 47 | 55 | const res = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } }); |
| 48 | 56 | const body = await res.text(); |
| 49 | 57 | expect(res.status).toBe(200); |
| @@ -52,3 +60,20 @@ test("GET /admin/limits names its phases", async () => { | ||
| 52 | 60 | for (const name of ["app", "session", "limits", "quotas", "signup", "invites", "render"]) |
| 53 | 61 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 54 | 62 | }); |
| 63 | ||
| 64 | test("a used invite says who joined with it and when", async () => { | |
| 65 | const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!; | |
| 66 | const { token } = mintInvite(app.db, alice.id); | |
| 67 | expect(redeemInvite(app.db, token, alice.id, Date.parse("2026-10-03T12:00:00Z") - 1)).toBe(true); | |
| 68 | const body = await (await fetch(`${servers.url}/admin/limits`, { headers: { cookie: await signedIn() } })).text(); | |
| 69 | expect(body).toContain('used by <a href="/alice">alice</a> on 2026-10-03'); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a used invite whose account is gone says when, with no name or link", async () => { | |
| 73 | const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!; | |
| 74 | const { token } = mintInvite(app.db, alice.id); | |
| 75 | expect(redeemInvite(app.db, token, "gone-account", Date.parse("2026-10-04T12:00:00Z"))).toBe(true); | |
| 76 | const body = await (await fetch(`${servers.url}/admin/limits`, { headers: { cookie: await signedIn() } })).text(); | |
| 77 | expect(body).toContain(">used on 2026-10-04<"); | |
| 78 | expect(body).not.toContain('used by <a href="/gone-account"'); | |
| 79 | }); |
test/invites-core.test.ts +15
| @@ -38,6 +38,7 @@ describe("an invite's life", () => { | ||
| 38 | 38 | beforeEach(() => { |
| 39 | 39 | db = new Database(":memory:"); |
| 40 | 40 | db.exec(invite); |
| 41 | db.exec("CREATE TABLE user (id TEXT PRIMARY KEY, username TEXT NOT NULL)"); | |
| 41 | 42 | }); |
| 42 | 43 | |
| 43 | 44 | test("open before its expiry, not at or after it", () => { |
| @@ -95,4 +96,18 @@ describe("an invite's life", () => { | ||
| 95 | 96 | reopenInvite(db, "nobody"); |
| 96 | 97 | expect(inviteSpentBy(db, "cy")).toBe(a.invite.id); |
| 97 | 98 | }); |
| 99 | ||
| 100 | test("a redeemed invite lists who joined with it and when; null when unused or the account is gone", () => { | |
| 101 | db.query("INSERT INTO user (id, username) VALUES ('u1', 'ada')").run(); | |
| 102 | const used = mintInvite(db, "owner", 2, T0); | |
| 103 | const gone = mintInvite(db, "owner", 2, T0); | |
| 104 | const fresh = mintInvite(db, "owner", 2, T0); | |
| 105 | redeemInvite(db, used.token, "u1", T0 + 1000); | |
| 106 | redeemInvite(db, gone.token, "u-deleted", T0 + 2000); | |
| 107 | const byId = new Map(listInvites(db, T0 + 3000).map((i) => [i.id, i])); | |
| 108 | expect(byId.get(used.invite.id)).toMatchObject({ state: "used", usedBy: "ada", usedAt: new Date(T0 + 1000).toISOString() }); | |
| 109 | expect(byId.get(gone.invite.id)).toMatchObject({ state: "used", usedBy: null, usedAt: new Date(T0 + 2000).toISOString() }); | |
| 110 | expect(byId.get(fresh.invite.id)).toMatchObject({ state: "open", usedBy: null, usedAt: null }); | |
| 111 | expect(fresh.invite).toMatchObject({ usedBy: null, usedAt: null }); | |
| 112 | }); | |
| 98 | 113 | }); |
test/tui-invites.test.ts +11 −1
| @@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 11 | import { listInvites } from "../src/core/invites.ts"; | |
| 11 | import { listInvites, mintInvite, redeemInvite } from "../src/core/invites.ts"; | |
| 12 | 12 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 13 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 14 | 14 | import { invitesScreen } from "../src/tui/screens/invites.ts"; |
| @@ -155,4 +155,14 @@ describe("invite links in the terminal", () => { | ||
| 155 | 155 | } |
| 156 | 156 | expect(listInvites(forge.db).length).toBe(before); |
| 157 | 157 | }); |
| 158 | ||
| 159 | test("a used link says who joined with it and when", async () => { | |
| 160 | const bob = getUserByName(forge.db, "bob")!; | |
| 161 | const { token } = mintInvite(forge.db, bob.id); | |
| 162 | expect(redeemInvite(forge.db, token, bob.id, Date.parse("2026-10-05T09:00:00Z"))).toBe(true); | |
| 163 | const h = boot("alice"); | |
| 164 | await open(h); | |
| 165 | expect(h.text()).toContain("used by bob on 2026-10-05"); | |
| 166 | h.stop(); | |
| 167 | }); | |
| 158 | 168 | }); |
new above
offline
The usedBy===null branch (account deleted) has no page-level test; add one in admin-limits-timing.test.ts asserting no link is rendered.