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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8080-a-used-sign-up-link-says-who-joined-with → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/8080-a-used-sign-up-link-says-who-joined-with was merged into main as d4bdbeb.

Review

Sign in to review this change.
7 files changed +82 −10
README.md +2
@@ -192,6 +192,8 @@ will actually browse it.
192192 Who may sign up (open, invite or closed) is shown on the terminal's Invite
193193 links screen, and a forge admin who is a person changes it there with `s`,
194194 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.
195197
196198 No sign-up makes the admin, not even the forge's first, and that first one is
197199 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 {
2424 readonly createdAt: string;
2525 readonly expiresAt: string;
2626 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;
2731 }
2832
2933 interface Row {
@@ -33,8 +37,12 @@ interface Row {
3337 expires_at: string;
3438 revoked_at: string | null;
3539 used_at: string | null;
40 used_by_name: string | null;
3641 }
3742
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
3846 const stateOf = (r: Row, now: number): InviteState =>
3947 r.revoked_at !== null ? "revoked" : r.used_at !== null ? "used" : Date.parse(r.expires_at) <= now ? "expired" : "open";
4048
@@ -44,6 +52,8 @@ const view = (r: Row, now: number): Invite => ({
4452 createdAt: r.created_at,
4553 expiresAt: r.expires_at,
4654 state: stateOf(r, now),
55 usedAt: r.used_at,
56 usedBy: r.used_at === null ? null : r.used_by_name,
4757 });
4858
4959 /** A whole number of days from 1 to 30, or the default when absent. */
@@ -64,7 +74,7 @@ export function mintInvite(
6474 db.query(
6575 "INSERT INTO invite (id, token_hash, created_by, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)",
6676 ).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)!;
6878 return { invite: view(row, now), token };
6979 }
7080
@@ -103,7 +113,7 @@ export function reopenInvite(db: Database, userId: string): void {
103113 /** Every invite, newest first, never with its token. */
104114 export function listInvites(db: Database, now: number = Date.now()): Invite[] {
105115 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`)
107117 .all()
108118 .map((r) => view(r, now));
109119 }
@@ -118,7 +128,7 @@ export function revokeInvite(
118128 .query("UPDATE invite SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL AND used_at IS NULL")
119129 .run(inviteId, new Date(now).toISOString()).changes;
120130 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));
122132 }
123133
124134 /** 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";
1313 import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts";
1414 import { changeSignup, readSignup } from "../../actions/signup.ts";
1515 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";
1717 import type { Ctx, Screen } from "../app/app.ts";
1818 import { binding } from "../app/keys.ts";
1919 import { style } from "../term/style.ts";
@@ -23,6 +23,10 @@ import { fill, len, split } from "../ui/layout.ts";
2323 import { ListState, confirmScreen, drawList, pickerScreen, promptScreen, type ListRow } from "./common.ts";
2424 import type { PaletteEntry } from "./entries.ts";
2525
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
2630 export function invitesScreen(f: Session, ctx: Ctx): Screen {
2731 const state = new ListState();
2832 let note = "";
@@ -70,7 +74,7 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
7074 const rows = (): ListRow[] =>
7175 items().map((i) => ({
7276 title: i.id,
73 secondary: i.state,
77 secondary: i.state === "used" ? usedWords(i) : i.state,
7478 meta: `made ${i.createdAt.slice(0, 16)} expires ${i.expiresAt.slice(0, 16)}`,
7579 }));
7680
src/web/pages/admin-limits.ts +7 −1
@@ -161,7 +161,13 @@ export function limitsPage(ctx: Ctx): Response {
161161 const inviteRows = (invites.ok ? invites.value : []).map((i) =>
162162 row({
163163 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>`}${

The usedBy===null branch (account deleted) has no page-level test; add one in admin-limits-timing.test.ts asserting no link is rendered.

168 i.usedAt === null ? "" : ` on ${i.usedAt.slice(0, 10)}`
169 }`
170 }</span>`,
165171 meta: html`Made ${time(i.createdAt)} · expires ${time(i.expiresAt)}`,
166172 trail:
167173 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 */
25 import { afterAll, beforeAll, expect, test } from "bun:test";
36 import { mkdtempSync, rmSync } from "node:fs";
47 import { tmpdir } from "node:os";
58 import { join } from "node:path";
69 import { createApp, type App } from "../src/app.ts";
10 import { mintInvite, redeemInvite } from "../src/core/invites.ts";
711 import { createUser } from "../src/core/users.ts";
812 import { serve, type Servers } from "../src/http/server.ts";
913 import { proveAll } from "./helpers/verified.ts";
@@ -36,14 +40,18 @@ afterAll(() => {
3640 rmSync(root, { recursive: true, force: true });
3741 });
3842
39 test("GET /admin/limits names its phases", async () => {
43 const signedIn = async (): Promise<string> => {
4044 const signin = await fetch(`${servers.url}/signin`, {
4145 method: "POST",
4246 headers: { origin: servers.url },
4347 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
4448 redirect: "manual",
4549 });
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();
4755 const res = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } });
4856 const body = await res.text();
4957 expect(res.status).toBe(200);
@@ -52,3 +60,20 @@ test("GET /admin/limits names its phases", async () => {
5260 for (const name of ["app", "session", "limits", "quotas", "signup", "invites", "render"])
5361 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
5462 });
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", () => {
3838 beforeEach(() => {
3939 db = new Database(":memory:");
4040 db.exec(invite);
41 db.exec("CREATE TABLE user (id TEXT PRIMARY KEY, username TEXT NOT NULL)");
4142 });
4243
4344 test("open before its expiry, not at or after it", () => {
@@ -95,4 +96,18 @@ describe("an invite's life", () => {
9596 reopenInvite(db, "nobody");
9697 expect(inviteSpentBy(db, "cy")).toBe(a.invite.id);
9798 });
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 });
98113 });
test/tui-invites.test.ts +11 −1
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 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";
1212 import { createUser, getUserByName } from "../src/core/users.ts";
1313 import { createForgeApp } from "../src/tui/forge.ts";
1414 import { invitesScreen } from "../src/tui/screens/invites.ts";
@@ -155,4 +155,14 @@ describe("invite links in the terminal", () => {
155155 }
156156 expect(listInvites(forge.db).length).toBe(before);
157157 });
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 });
158168 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs