(null)
@@ -103,6 +104,32 @@ async function confirm(member: Member): Promise {
}
}
+// What is filtered, said out loud and removable one at a time (0032).
+const activeFilters = computed(() => {
+ const active: ActiveFilter[] = []
+ if (search.value) {
+ active.push({ key: 'search', label: `Matching ${search.value}`, icon: 'i-lucide-search', clear: () => {
+ search.value = ''
+ } })
+ }
+ if (filter.value !== 'current') {
+ active.push({
+ key: 'filter',
+ label: FILTERS.find(option => option.value === filter.value)!.label,
+ icon: FILTERS.find(option => option.value === filter.value)!.icon,
+ clear: () => {
+ filter.value = 'current'
+ },
+ })
+ }
+ return active
+})
+
+function clearFilters(): void {
+ search.value = ''
+ filter.value = 'current'
+}
+
const exportUrl = computed(() => {
const query = new URLSearchParams({ filter: filter.value })
if (search.value) query.set('search', search.value)
@@ -183,56 +210,64 @@ onMounted(load)
description="This records what somebody bought and when it runs out. A membership counts from the moment it is recorded: checking it against the SU's own list happens afterwards and never holds up a member price."
/>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
- Record one
-
+
+
+ Record one
+
-
- Export
-
-
+
+ Export
+
+
+
+ >
+
+
+ {{ search || filter !== 'current'
+ ? 'No membership matches that.'
+ : 'No current memberships. One appears here as soon as it is recorded.' }}
+
+
+
{
}
}
+const activeFilters = computed(() => {
+ const active: ActiveFilter[] = []
+ if (search.value) {
+ active.push({
+ key: 'search',
+ label: `Matching ${search.value}`,
+ icon: 'i-lucide-search',
+ clear: () => {
+ search.value = ''
+ },
+ })
+ }
+ if (filter.value !== 'everyone') {
+ active.push({
+ key: 'filter',
+ label: FILTERS.find(option => option.value === filter.value)!.label,
+ icon: 'i-lucide-filter',
+ clear: () => {
+ show('everyone')
+ },
+ })
+ }
+ if (role.value) {
+ active.push({
+ key: 'role',
+ label: role.value,
+ icon: 'i-lucide-shield',
+ clear: () => {
+ role.value = undefined
+ },
+ })
+ }
+ return active
+})
+
+function clearFilters(): void {
+ search.value = ''
+ role.value = undefined
+ show('everyone')
+}
+
const seen = (at: number | null): string =>
at ? formatLondon(new Date(at * 1000), { dateStyle: 'medium' }) : 'Never'
@@ -190,53 +232,49 @@ onMounted(load)
/>
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
-
- Add someone
-
-
+
+
+ Add someone
+
+
+
+ >
+
+
+ {{ activeFilters.length ? 'Nobody matches that.' : 'No accounts yet.' }}
+
+
+
{
// The actor's name is joined rather than stored: an erased actor reads as its tombstone, which
// is the whole point of anonymising rather than deleting (0011).
+ const subject = alias(schema.users, 'subject')
+
const items = await db.select({
id: schema.auditLog.id,
actorId: schema.auditLog.actorId,
actorName: schema.users.name,
action: schema.auditLog.action,
target: schema.auditLog.target,
+ // An entry names its subject by id; the screen should not (0032). Joined on the id inside the
+ // `user:` prefix, so an entry about something else still shows what it says.
+ targetName: subject.name,
detail: schema.auditLog.detail,
createdAt: schema.auditLog.createdAt,
})
.from(schema.auditLog)
.leftJoin(schema.users, eq(schema.users.id, schema.auditLog.actorId))
+ .leftJoin(subject, eq(sql`'user:' || ${subject.id}`, schema.auditLog.target))
.where(where)
.orderBy(desc(schema.auditLog.createdAt), desc(schema.auditLog.id))
.limit(input.pageSize)
diff --git a/tests/e2e/audit.test.ts b/tests/e2e/audit.test.ts
index 7f863d8d..1cff6d42 100644
--- a/tests/e2e/audit.test.ts
+++ b/tests/e2e/audit.test.ts
@@ -322,7 +322,7 @@ describe.skipIf(skip !== null)('the audit screen (J-101 criterion 2, J-103)', ()
const view = await signedInView()
try {
await visit(view, `${app.baseURL}/admin/audit`, '[data-test="audit-table"]')
- await fill(view, 'input[data-test="audit-target"]', `user:${member.id}`)
+ await fill(view, 'input[data-test="toolbar-search"]', `user:${member.id}`)
await waitFor(view, `document.querySelector('[data-test="audit-table"]').innerText.includes('booking.refunded')`)
// Rendered under its own name rather than crashing the table it is one row of.
@@ -340,7 +340,7 @@ describe.skipIf(skip !== null)('the audit screen (J-101 criterion 2, J-103)', ()
const view = await signedInView()
try {
await visit(view, `${app.baseURL}/admin/audit`, '[data-test="audit-table"]')
- await fill(view, 'input[data-test="audit-target"]', `user:${member.id}`)
+ await fill(view, 'input[data-test="toolbar-search"]', `user:${member.id}`)
await waitFor(view, `document.querySelector('[data-test="audit-table"]').innerText.includes('Role granted')`)
expect(await textOf(view, '[data-test="audit-table"]')).toContain(officer.name)
diff --git a/tests/e2e/directory.test.ts b/tests/e2e/directory.test.ts
index a77de4fb..de4e01bc 100644
--- a/tests/e2e/directory.test.ts
+++ b/tests/e2e/directory.test.ts
@@ -296,22 +296,24 @@ describe.skipIf(skip !== null)('the directory screen', () => {
await fillPin(view, '[data-test="mfa-challenge"] input', await codeForStep(secret, stepFor(new Date())))
await waitFor(view, 'document.querySelector(\'[data-test="sign-out"]\')')
- await visit(view, `${app.baseURL}/admin/people`, '[data-test="directory-search"]')
+ await visit(view, `${app.baseURL}/admin/people`, '[data-test="toolbar-search"]')
await waitFor(view, `document.body.innerText.includes(${JSON.stringify(known)})`)
// Searching narrows to one, and the total below the table says so.
- await fill(view, '[data-test="directory-search"]', known)
+ await fill(view, 'input[data-test="toolbar-search"]', known)
await waitFor(view, 'document.querySelector(\'[data-test="directory-total"]\')?.innerText.startsWith("1 ")')
expect(await textOf(view)).not.toContain(officer.email)
- // A filter whose story is not built says which one rather than looking broken.
- await fill(view, '[data-test="directory-search"]', '')
- await view.evaluate(`(() => {
- const select = document.querySelector('[data-test="directory-filter"]')
- const setter = Object.getOwnPropertyDescriptor(select.constructor.prototype, 'value')?.set
- if (setter) { setter.call(select, 'members-current'); select.dispatchEvent(new Event('change', { bubbles: true })) }
- })()`)
- await Bun.sleep(1500)
+ // Searching is shown back as a chip that can be taken off again (0032).
+ await waitFor(view, `document.querySelector('[data-test="toolbar-active"]')?.innerText.includes(${JSON.stringify(known)})`)
+ await click(view, '[data-test="toolbar-clear"]')
+ await waitFor(view, 'document.querySelector(\'[data-test="toolbar-active"]\') === null')
+
+ // The filters live behind one button, which is what keeps the row from resizing.
+ await click(view, '[data-test="toolbar-filters"]')
+ await waitFor(view, 'document.querySelector(\'[data-test="directory-filter"]\')')
+ await view.evaluate(`document.querySelector('[data-test="toolbar-filters"]').click()`)
+ await Bun.sleep(500)
const invitee = registrableAddress('by-hand')
await click(view, '[data-test="invite"]')
diff --git a/tests/e2e/fellowships.test.ts b/tests/e2e/fellowships.test.ts
index 2cbe971a..40968ada 100644
--- a/tests/e2e/fellowships.test.ts
+++ b/tests/e2e/fellowships.test.ts
@@ -180,7 +180,7 @@ describe.skipIf(skip !== null)('the roll in a browser (A-127)', () => {
// The outcome rather than the notification: a toast dismisses itself, and racing one proves
// nothing. Searched for by name, because the roll pages and this suite fills it.
- await fill(view, 'input[data-test="fellows-search"]', alumna.email.split('@')[0]!)
+ await fill(view, 'input[data-test="toolbar-search"]', alumna.email.split('@')[0]!)
await waitFor(view, `document.querySelector('[data-test="fellows-table"]').innerText.includes('front of house')`, 20_000)
expect(await textOf(view, '[data-test="fellows-table"]')).toContain(alumna.name)
}
diff --git a/tests/helpers/webview.ts b/tests/helpers/webview.ts
index 6315f6be..2ba4c14b 100644
--- a/tests/helpers/webview.ts
+++ b/tests/helpers/webview.ts
@@ -180,7 +180,6 @@ export async function startApp(): Promise {
// The shard's server dies with the shard. Without this it outlives the run holding the port, and
// the next run talks to a database it did not create.
function shutdown(): void {
- Bun.write(Bun.stderr, `[e2e] shutdown hook fired, shared=${Boolean(shared)}\n`)
if (!shared) return
shared.controller.abort()
// SIGKILL, not SIGTERM: an exit handler cannot wait for a graceful stop, and a dev server that
diff --git a/tests/unit/admin-conventions.test.ts b/tests/unit/admin-conventions.test.ts
index 9f2e732e..c3e9d170 100644
--- a/tests/unit/admin-conventions.test.ts
+++ b/tests/unit/admin-conventions.test.ts
@@ -34,9 +34,29 @@ describe('a person is chosen, never typed (0032)', () => {
})
})
+describe('filters sit in a toolbar at a fixed width (0032)', () => {
+ test('no screen lays its filters out in a bare flex row', async () => {
+ expect(await offenders(source => /class="flex flex-wrap items-end gap-3"/.test(source))).toEqual([])
+ })
+
+ // One search of a fixed width and one button, with the filters behind it, is what stops a row
+ // resizing as its values change.
+ test('every list uses the shared toolbar', async () => {
+ const lists = (await screens()).filter(screen => screen.source.includes(' !screen.source.includes(' screen.path)).toEqual([])
+ })
+})
+
describe('feedback goes where it belongs (0032)', () => {
// A confirmation the reader does not have to act on is a toast, not something that sits on the
// page until it is dismissed.
+ test('every table says what would be there when it is empty', async () => {
+ const tables = (await screens()).filter(screen => screen.source.includes(' !screen.source.includes('#empty')).map(screen => screen.path)).toEqual([])
+ })
+
test('a screen that confirms an action uses a toast', async () => {
const confirming = (await screens()).filter(screen =>
/Recorded|Revoked\.|is on the (roll|trail)/.test(screen.source))
From e7f3dd818a5c67b25777d43846800eec108ee49b Mon Sep 17 00:00:00 2001
From: Matt Adcock <43476212+MattA-Official@users.noreply.github.com>
Date: Sun, 30 Aug 2026 18:21:22 +0100
Subject: [PATCH 3/5] Make fifty settings findable, and say counts in English
The settings screen was fifty full-width text boxes, a number in each and no way to find
one. Every key now gets the input for what it holds: a switch for a flag, a stepper for a
number, a currency input for anything in pence (entered in pounds, stored as pence, 0004),
a tags input for a list. A search box sits over the lot, and the workshop groups became
tabs carrying their counts, so one group is on screen at a time.
Counting things as "4 membership(s)" was in six places. `plural()` is the one place that
bothers now, and a test fails on any screen that goes back to the brackets.
Two smaller things the screenshots caught: the audit trail printed `expiresAt: 1817074799`
where a date belongs, and the sidebar footer wrapped the account name and Sign out onto
four lines at 210px.
---
app/components/AuthStatus.vue | 13 +-
app/layouts/admin.vue | 7 +-
app/pages/admin/audit.vue | 13 +-
app/pages/admin/config.vue | 290 +++++++++++++++++++--------
app/pages/admin/fellows.vue | 2 +-
app/pages/admin/members.vue | 2 +-
app/pages/admin/people/[id].vue | 4 +-
app/pages/admin/people/index.vue | 6 +-
shared/utils/text.ts | 6 +
tests/e2e/config-surface.test.ts | 24 ++-
tests/unit/admin-conventions.test.ts | 46 +++++
11 files changed, 310 insertions(+), 103 deletions(-)
create mode 100644 shared/utils/text.ts
diff --git a/app/components/AuthStatus.vue b/app/components/AuthStatus.vue
index 90772cdd..70463693 100644
--- a/app/components/AuthStatus.vue
+++ b/app/components/AuthStatus.vue
@@ -1,6 +1,9 @@
-
+
{{ account.user?.name }}
@@ -22,6 +30,9 @@ async function signOut(): Promise {
data-test="sign-out"
size="sm"
variant="ghost"
+ :block="stacked"
+ :class="stacked ? 'justify-start' : ''"
+ icon="i-lucide-log-out"
@click="signOut"
>
Sign out
diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue
index 78699be8..8e675a6f 100644
--- a/app/layouts/admin.vue
+++ b/app/layouts/admin.vue
@@ -8,7 +8,10 @@ const links = [[
{ label: 'Fellows', icon: 'i-lucide-award', to: '/admin/fellows' },
{ label: 'Audit trail', icon: 'i-lucide-scroll-text', to: '/admin/audit' },
{ label: 'Settings', icon: 'i-lucide-settings', to: '/admin/config' },
-]]
+], // Development only, and absent from a build because the page it points at is (K-124).
+...import.meta.dev
+ ? [[{ label: 'Developer tools', icon: 'i-lucide-flask-conical', to: '/dev' }]]
+ : []]
const route = useRoute()
@@ -26,7 +29,7 @@ const route = useRoute()
:items="links"
/>
-
+
diff --git a/app/pages/admin/audit.vue b/app/pages/admin/audit.vue
index 0363d5fb..0d1e3391 100644
--- a/app/pages/admin/audit.vue
+++ b/app/pages/admin/audit.vue
@@ -185,15 +185,24 @@ function describeDetail(detail: Record | null): string[] {
const parts: string[] = []
const changes = detail.changes as Record | undefined
for (const [field, change] of Object.entries(changes ?? {})) {
- parts.push(`${field}: ${JSON.stringify(change.from)} → ${JSON.stringify(change.to)}`)
+ parts.push(`${field}: ${readable(field, change.from)} \u2192 ${readable(field, change.to)}`)
}
for (const [key, value] of Object.entries(detail)) {
if (key === 'changes') continue
- parts.push(`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
+ parts.push(`${key}: ${readable(key, value)}`)
}
return parts
}
+// A key ending in At holds epoch seconds, which reads as a nine digit number unless it is turned
+// back into the date it is (0014).
+function readable(key: string, value: unknown): string {
+ if (key.endsWith('At') && typeof value === 'number' && Number.isInteger(value)) {
+ return formatLondon(new Date(value * 1000), { dateStyle: 'medium' })
+ }
+ return typeof value === 'string' ? value : JSON.stringify(value)
+}
+
const columns: TableColumn[] = [
{
id: 'createdAt',
diff --git a/app/pages/admin/config.vue b/app/pages/admin/config.vue
index e7f3ce0b..806bdcea 100644
--- a/app/pages/admin/config.vue
+++ b/app/pages/admin/config.vue
@@ -23,6 +23,7 @@ const WORKSHOPS: Record = {
'people-and-communications': 'People and communications',
}
+const search = ref('')
const settings = ref([])
const drafts = reactive>({})
const notices = reactive>({})
@@ -32,9 +33,28 @@ const failure = ref(null)
const grouped = computed(() => Object.keys(WORKSHOPS).map(workshop => ({
workshop,
title: WORKSHOPS[workshop]!,
- settings: settings.value.filter(setting => setting.workshop === workshop),
+ settings: settings.value.filter(setting => setting.workshop === workshop && matches(setting)),
})))
+// One group on screen at a time, and a search that reaches across all of them: a count on the tab
+// says where a match is without opening it.
+const tabs = computed(() => grouped.value.map(group => ({
+ label: group.title,
+ value: group.workshop,
+ badge: group.settings.length,
+ slot: 'group' as const,
+})))
+
+const tab = ref(Object.keys(WORKSHOPS)[0]!)
+const shown = computed(() => grouped.value.find(group => group.workshop === tab.value)?.settings ?? [])
+
+// A search that empties the tab you are on has found its match somewhere else.
+watch(search, () => {
+ if (shown.value.length) return
+ const found = grouped.value.find(group => group.settings.length)
+ if (found) tab.value = found.workshop
+})
+
// The value a key stands at: its override, or what it ships with.
function standing(setting: Setting): unknown {
return setting.set ? setting.value : (setting.hasDefault ? setting.default : null)
@@ -47,17 +67,39 @@ function asText(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
-function kind(setting: Setting): 'boolean' | 'number' | 'text' {
+// Money is entered in pounds and stored in pence, everywhere (0004, 0032). The key says which
+// keys those are, because the schema only knows it is an integer.
+function kind(setting: Setting): 'boolean' | 'money' | 'number' | 'list' | 'text' {
const value = standing(setting)
if (typeof value === 'boolean') return 'boolean'
+ if (setting.key.endsWith('_PENCE')) return 'money'
if (typeof value === 'number') return 'number'
+ if (Array.isArray(value) || Array.isArray(setting.default)) return 'list'
return 'text'
}
+// Held apart from the text drafts, because a number input round-trips a number and turning it
+// into a string and back is how a value picks up a decimal it never had.
+const numbers = reactive>({})
+const lists = reactive>({})
+
+// Fifty keys is too many to scroll for one. Matched on the name and on what it describes, because
+// somebody looking for the tab cap may not remember it is called BAR_TAB_CAP_PENCE.
+function matches(setting: Setting): boolean {
+ const term = search.value.trim().toLowerCase()
+ if (!term) return true
+ return setting.key.toLowerCase().includes(term) || setting.describes.toLowerCase().includes(term)
+}
+
async function load(): Promise {
const answer = await $fetch<{ settings: Setting[] }>('/api/admin/config')
settings.value = answer.settings
- for (const setting of answer.settings) drafts[setting.key] = asText(standing(setting))
+ for (const setting of answer.settings) {
+ drafts[setting.key] = asText(standing(setting))
+ const value = standing(setting)
+ if (typeof value === 'number') numbers[setting.key] = value
+ if (Array.isArray(value)) lists[setting.key] = value.map(String)
+ }
}
async function save(setting: Setting, value: unknown): Promise {
@@ -78,6 +120,10 @@ async function save(setting: Setting, value: unknown): Promise {
}
}
+// Pounds on the screen, pence in the database, converted here and nowhere else (0004).
+const pounds = (pence: number | undefined): number => (pence ?? 0) / 100
+const pence = (amount: number | undefined): number => Math.round((amount ?? 0) * 100)
+
// A key that holds a string keeps the text as typed: parsing it first would turn 08-01 into a
// number and true into a boolean, and the schema would refuse a value the officer typed correctly.
function saveText(setting: Setting): Promise {
@@ -97,7 +143,7 @@ onMounted(load)
-
+
Every operational number the system enforces. A change takes effect on the next request, so
committee decisions are settings changes rather than releases.
@@ -110,90 +156,164 @@ onMounted(load)
:description="failure"
/>
-
+
+
-
- {{ group.title }}
-
-
-
-
-
-
- {{ setting.key }}
-
-
- {{ setting.describes }}
+
+
+
+ No setting in this group matches that.
+
+
+
+
+
+
+ {{ setting.key }}
+
+
+ {{ setting.describes }}
+
+
+
+
+ Not enforced yet
+
+
+ Not set
+
+
+
+
+
+
+
+
+
+
+ Save
+
+
+
+
+
+
+ Save
+
+
+
+
+
+
+ Save
+
+
+
+
+
+
+ Save
+
+
+
+ {{ notices[setting.key] }}
+
+
+
+ Ships as {{ asText(setting.default) }}.
+
+ Changed by {{ setting.updatedBy.name }} on
+ {{ formatLondon(new Date(setting.updatedAt * 1000), { dateStyle: 'long' }) }}.
+
+ Never changed.
-
-
- Not enforced yet
-
-
- Not set
-
-
-
-
-
-
-
-
- Save
-
-
-
- {{ notices[setting.key] }}
-
-
-
- Ships as {{ asText(setting.default) }}.
-
- Changed by {{ setting.updatedBy.name }} on
- {{ formatLondon(new Date(setting.updatedAt * 1000), { dateStyle: 'long' }) }}.
-
- Never changed.
-
-
-
+
+
diff --git a/app/pages/admin/fellows.vue b/app/pages/admin/fellows.vue
index 60a4e1e3..0f675986 100644
--- a/app/pages/admin/fellows.vue
+++ b/app/pages/admin/fellows.vue
@@ -256,7 +256,7 @@ onMounted(load)
data-test="fellows-total"
class="text-sm text-muted"
>
- {{ listing?.total ?? 0 }} Fellow(s)
+ {{ plural(listing?.total ?? 0, 'Fellow') }}
- {{ listing?.total ?? 0 }} membership(s)
+ {{ plural(listing?.total ?? 0, 'membership') }}
{
return [
methods.password ? 'A password' : null,
methods.google ? 'Google' : null,
- methods.passkeys ? `${methods.passkeys} passkey(s)` : null,
- methods.factor ? `An authenticator, with ${methods.recoveryCodesRemaining} recovery code(s) left` : null,
+ methods.passkeys ? plural(methods.passkeys, 'passkey') : null,
+ methods.factor ? `An authenticator, with ${plural(methods.recoveryCodesRemaining, 'recovery code')} left` : null,
].filter(Boolean) as string[]
})
diff --git a/app/pages/admin/people/index.vue b/app/pages/admin/people/index.vue
index 249e3d5a..022a6ad4 100644
--- a/app/pages/admin/people/index.vue
+++ b/app/pages/admin/people/index.vue
@@ -216,7 +216,7 @@ onMounted(load)
color="warning"
variant="subtle"
icon="i-lucide-shield-alert"
- :title="`${listing.banners.privilegedWithoutFactor} privileged account(s) without an authenticator`"
+ :title="`${plural(listing.banners.privilegedWithoutFactor, 'privileged account')} without an authenticator`"
description="Their roles do not work until they enrol one."
:actions="[{ label: 'Show them', color: 'neutral', variant: 'subtle', onClick: () => show('privileged-without-mfa') }]"
/>
@@ -226,7 +226,7 @@ onMounted(load)
color="neutral"
variant="subtle"
icon="i-lucide-clock"
- :title="`${listing.banners.insideRetentionWindow} account(s) approaching retention`"
+ :title="`${plural(listing.banners.insideRetentionWindow, 'account')} approaching retention`"
description="Dormant for longer than the retention window allows."
:actions="[{ label: 'Show them', color: 'neutral', variant: 'subtle', onClick: () => show('retention-window') }]"
/>
@@ -312,7 +312,7 @@ onMounted(load)
data-test="directory-total"
class="text-sm text-muted"
>
- {{ listing?.total ?? 0 }} account(s)
+ {{ plural(listing?.total ?? 0, 'account') }}
{
expect(JSON.parse(entry!.detail)).toMatchObject({ key: 'BAR_TAB_CAP_PENCE', changes: { value: { from: 2000, to: 2500 } } })
}
finally {
- clearOverride('BAR_TAB_CAP_PENCE')
+ clearOverride('REFUND_UNPAID_CANCELLATION_FREE')
}
})
@@ -193,17 +193,29 @@ describe.skipIf(skip !== null)('the settings screen', () => {
await fillPin(view, '[data-test="mfa-challenge"] input', await unusedCode())
await waitFor(view, 'document.querySelector(\'[data-test="sign-out"]\')')
- await visit(view, `${app.baseURL}/admin/config`, '[data-test="setting-BAR_TAB_CAP_PENCE"]')
+ await visit(view, `${app.baseURL}/admin/config`, '[data-test="config-search"]')
+
+ // Fifty keys, found by searching for what the key decides rather than its name (0032).
+ await fill(view, 'input[data-test="config-search"]', 'bar tab')
+ await waitFor(view, 'document.querySelector(\'[data-test="setting-BAR_TAB_CAP_PENCE"]\')')
expect(await textOf(view)).toContain('Not enforced yet')
- await fill(view, '[data-test="input-BAR_TAB_CAP_PENCE"]', '3000')
- await click(view, '[data-test="save-BAR_TAB_CAP_PENCE"]')
+ // Money reads in pounds, which is what the officer types (0004). An input's value is not
+ // text on the page, so it is read rather than searched for.
+ const shown = await view.evaluate(
+ `document.querySelector('input[data-test="input-BAR_TAB_CAP_PENCE"]')?.value ?? ''`)
+ // 2500 pence is what this suite set it to, and £25.00 is what that should read as.
+ expect(shown).toBe('£25.00')
+
+ await fill(view, 'input[data-test="config-search"]', 'cancel an unpaid booking')
+ await waitFor(view, 'document.querySelector(\'[data-test="toggle-REFUND_UNPAID_CANCELLATION_FREE"]\')')
+ await click(view, '[data-test="toggle-REFUND_UNPAID_CANCELLATION_FREE"]')
await waitFor(view, 'document.body.innerText.includes("Changed by")')
- expect((await settingFor('BAR_TAB_CAP_PENCE')).value).toBe(3000)
+ expect((await settingFor('REFUND_UNPAID_CANCELLATION_FREE')).value).toBe(false)
}
finally {
- clearOverride('BAR_TAB_CAP_PENCE')
+ clearOverride('REFUND_UNPAID_CANCELLATION_FREE')
view.close()
}
}, CASE_TIMEOUT_MS)
diff --git a/tests/unit/admin-conventions.test.ts b/tests/unit/admin-conventions.test.ts
index c3e9d170..f284950b 100644
--- a/tests/unit/admin-conventions.test.ts
+++ b/tests/unit/admin-conventions.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
+import { plural } from '#shared/utils/text'
// The admin conventions are a test rather than a review habit (0032), the same way the design
// language is (0021). What review still judges is whether a screen says the right thing.
@@ -22,6 +23,10 @@ describe('an input is the component for its value (0032)', () => {
test('a date is UInputDate, never a native date input', async () => {
expect(await offenders(source => /type="date"/.test(source))).toEqual([])
})
+
+ test('a number is UInputNumber, never a native number input', async () => {
+ expect(await offenders(source => /type="number"|'number' \? 'number'/.test(source))).toEqual([])
+ })
})
describe('a person is chosen, never typed (0032)', () => {
@@ -57,6 +62,11 @@ describe('feedback goes where it belongs (0032)', () => {
expect(tables.filter(screen => !screen.source.includes('#empty')).map(screen => screen.path)).toEqual([])
})
+ test('nothing counts things as "account(s)"', async () => {
+ const lazy = (await screens()).filter(screen => screen.source.includes('(s)'))
+ expect(lazy.map(screen => screen.path)).toEqual([])
+ })
+
test('a screen that confirms an action uses a toast', async () => {
const confirming = (await screens()).filter(screen =>
/Recorded|Revoked\.|is on the (roll|trail)/.test(screen.source))
@@ -64,3 +74,39 @@ describe('feedback goes where it belongs (0032)', () => {
expect(confirming.filter(screen => !screen.source.includes('useToast')).map(screen => screen.path)).toEqual([])
})
})
+
+// Money is entered in pounds and stored in pence, and the settings screen is the only place that
+// converts (0004, 0032).
+describe('money reads in pounds and is stored in pence', () => {
+ const pounds = (pence: number | undefined): number => (pence ?? 0) / 100
+ const pence = (amount: number | undefined): number => Math.round((amount ?? 0) * 100)
+
+ test('a cap in pence reads as pounds', () => {
+ expect(pounds(2000)).toBe(20)
+ expect(pounds(2550)).toBe(25.5)
+ expect(pounds(undefined)).toBe(0)
+ })
+
+ test('pounds typed in come back as whole pence', () => {
+ expect(pence(30)).toBe(3000)
+ expect(pence(25.5)).toBe(2550)
+ // A third of a pound is not a number of pence, so it rounds rather than storing a fraction.
+ expect(pence(0.005)).toBe(1)
+ expect(Number.isInteger(pence(19.999))).toBe(true)
+ })
+})
+
+
+// Every screen that counts something says the count in words a reader would use.
+describe('a count reads as English', () => {
+ test('one is singular and everything else is not', () => {
+ expect(plural(1, 'account')).toBe('1 account')
+ expect(plural(0, 'account')).toBe('0 accounts')
+ expect(plural(4, 'membership')).toBe('4 memberships')
+ })
+
+ test('an irregular plural is given rather than guessed', () => {
+ expect(plural(2, 'person', 'people')).toBe('2 people')
+ expect(plural(1, 'person', 'people')).toBe('1 person')
+ })
+})
From ea51c87ec10d3a4d339f13b3c6807ec115950c7d Mon Sep 17 00:00:00 2001
From: Matt Adcock <43476212+MattA-Official@users.noreply.github.com>
Date: Sun, 30 Aug 2026 18:21:22 +0100
Subject: [PATCH 4/5] Add the developer tools, and keep them out of every build
Working on an admin screen locally started with registering an account, fetching a token
out of .data/mail, enrolling an authenticator and running grant-admin.ts. `/dev` replaces
that: a persona per role plus a plain member, a guest and a tombstone, one click to be any
of them, the local mailbox, and the permissions the current session actually resolves to.
This signs in without a password, so the guarantee that matters is that it is not in a
build at all. A guard inside a file still ships the file, so nuxt.config leaves the page
and its routes out of the bundle instead. The test that proves it earned its place
immediately: Nuxt's own `ignore` covers the app but Nitro scans server/ separately, so the
first build shipped the dev API. Both are excluded now and the built output is checked.
The tombstone persona could not be found after seeding, because anonymisation rewrites the
address it was seeded under, so every run made another one. The seeder now remembers which
account each persona became in .data/personas.json, next to the database it belongs to.
`bun run shots` is the harness used to review all of this, checked in: it seeds realistic
data, signs in, and writes a picture of every admin screen and modal at two widths into a
gitignored directory. It gates nothing and CI never runs it.
---
.gitignore | 5 +-
README.md | 12 ++
app/pages/dev.vue | 255 ++++++++++++++++++++++++++++++
docs/architecture.md | 5 +-
nuxt.config.ts | 10 ++
package.json | 3 +-
scripts/shots.ts | 171 ++++++++++++++++++++
server/api/dev/index.get.ts | 35 ++++
server/api/dev/seed.post.ts | 4 +
server/api/dev/sign-in-as.post.ts | 19 +++
server/utils/dev.ts | 94 +++++++++++
shared/utils/audit-coverage.ts | 8 +
shared/utils/personas.ts | 23 +++
tests/helpers/webview.ts | 6 +
tests/unit/dev-tools.test.ts | 46 ++++++
15 files changed, 693 insertions(+), 3 deletions(-)
create mode 100644 app/pages/dev.vue
create mode 100644 scripts/shots.ts
create mode 100644 server/api/dev/index.get.ts
create mode 100644 server/api/dev/seed.post.ts
create mode 100644 server/api/dev/sign-in-as.post.ts
create mode 100644 server/utils/dev.ts
create mode 100644 shared/utils/personas.ts
create mode 100644 tests/unit/dev-tools.test.ts
diff --git a/.gitignore b/.gitignore
index e54f9bd9..5ba221c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,7 @@ migration/out/
# The end-to-end run's database lives in /tmp, outside the tree the dev server watches (0029).
-../../../../../tmp/nnt-e2e-3101
\ No newline at end of file
+../../../../../tmp/nnt-e2e-3101
+
+# `bun run shots` writes here. The pictures are for looking at once, not for keeping.
+.shots/
diff --git a/README.md b/README.md
index c4303bf9..65ec77f6 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,18 @@ emptying the database between suites, and names each suite as it starts so a slo
suite is slow (0029). CI gates on the first; the second runs nightly and on demand.
`bun test ` still runs one suite on its own.
+### Developer tools
+
+Running locally, `/dev` seeds a persona per role plus a plain member, a guest and a tombstone,
+signs in as any of them without a password, and shows the local mailbox alongside the permissions
+the current session resolves to. It is an authentication bypass, so it does not exist in a build:
+`nuxt.config` leaves the page and its routes out of the bundle, and `tests/unit/dev-tools.test.ts`
+greps a built `.output` to prove it (K-124).
+
+`bun run shots` writes a picture of every admin screen at two widths into `.shots/`, which is
+gitignored. It gates nothing and CI never runs it; it is there so a visual change can be reviewed
+from the images rather than from the diff.
+
The migration tooling is standalone and has its own instructions in
[`migration/README.md`](migration/README.md); the application never imports from it.
diff --git a/app/pages/dev.vue b/app/pages/dev.vue
new file mode 100644
index 00000000..47181631
--- /dev/null
+++ b/app/pages/dev.vue
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+
+
+
+
+ Be somebody
+
+
+ Seed the personas
+
+
+
+
+
+ -
+
+
+ {{ row.name }}
+
+ {{ row.role }}
+
+
+ {{ row.shape }}
+
+
+
+ {{ row.describes }}
+
+
+
+
+ Be them
+
+
+ Nobody to be
+
+ Not seeded
+
+
+
+
+
+
+
+ This session
+
+
+
+
+
+ {{ tools.session.name }}
+ {{ tools.session.email }}
+
+
+
+ {{ role }}
+
+
+ {{ tools.session.factor ? 'authenticator enrolled' : 'no authenticator' }}
+
+
+
+
+ {{ permission }}
+
+ No permissions. Every admin screen refuses this session.
+
+
+
+ Nobody is signed in.
+
+
+
+
+
+
+ Mailbox
+
+
+
+
+ Nothing sent yet. Development never hands a message to a provider; it writes it to
+ .data/mail instead.
+
+
+ -
+
+
+ {{ letter.subject }}
+
+
+ {{ letter.to }}
+
+
+
+ Read
+
+
+
+
+
+
+
+ {{ reading?.body }}
+
+
+
+
diff --git a/docs/architecture.md b/docs/architecture.md
index f1dade6f..f1c06365 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -163,7 +163,10 @@ emailed link is built from `NUXT_PUBLIC_BASE_URL`, which defaults to the local p
so a verification link in `.data/mail` is one that works.
Seed scripts generate credentials at runtime, print once, and refuse to run against remote
-databases. There is a `/dev-login` guarded by `import.meta.dev` using `replaceUserSession`.
+databases. `/dev` is the local developer surface: it seeds personas and signs in as any of them
+without a password. It is kept out of a production build by `nuxt.config`'s `ignore` rather than by
+a runtime guard, because a guard still ships the file, and a test greps the built output to prove
+it (K-124).
## Deployment
diff --git a/nuxt.config.ts b/nuxt.config.ts
index a7823d43..3c000e47 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -76,6 +76,12 @@ export default defineNuxtConfig({
},
},
+ // The developer tools do not exist in a build (K-124). A guard inside a file would still ship
+ // the file; this keeps them out of the bundle entirely.
+ ignore: process.env.NODE_ENV === 'production'
+ ? ['app/pages/dev.vue', 'server/api/dev/**', 'server/utils/dev.ts']
+ : [],
+
experimental: {
// A deploy rotates every asset hash, so an open tab asks for chunks that no longer exist.
emitRouteChunkError: 'automatic-immediate',
@@ -86,6 +92,10 @@ export default defineNuxtConfig({
nitro: {
preset: 'cloudflare_module',
+ // Nuxt's own `ignore` covers the app; Nitro scans server/ separately, so the developer
+ // routes have to be excluded here too (K-124). A test on the built output proves it.
+ ignore: process.env.NODE_ENV === 'production' ? ['api/dev/**'] : [],
+
experimental: {
tasks: true,
wasm: true,
diff --git a/package.json b/package.json
index 2149368a..b46ab505 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,8 @@
"typecheck:bun": "tsc -p tests --noEmit && tsc -p scripts --noEmit && tsc -p migration --noEmit",
"grant-admin": "bun scripts/grant-admin.ts",
"check:notifications": "bun scripts/check-notifications.ts",
- "test:e2e": "bun scripts/run-tests.ts"
+ "test:e2e": "bun scripts/run-tests.ts",
+ "shots": "bun scripts/shots.ts"
},
"dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.2.5",
diff --git a/scripts/shots.ts b/scripts/shots.ts
new file mode 100644
index 00000000..11921653
--- /dev/null
+++ b/scripts/shots.ts
@@ -0,0 +1,171 @@
+#!/usr/bin/env bun
+// Screenshots of every admin screen, for reviewing how they look. Seeds realistic data, signs in,
+// and writes a PNG per screen at two widths. Nothing gates on this and CI never runs it (0032).
+
+import { Database } from 'bun:sqlite'
+import { click, fill, fillPin, openSignedOutView, startApp, visit, waitFor } from '../tests/helpers/webview'
+import { codeForStep, stepFor } from '../shared/utils/totp'
+import { londonDay } from '../shared/utils/membership'
+
+// Gitignored: the pictures are for looking at once, not for keeping.
+const OUT = process.env.SHOTS_OUT ?? '.shots'
+const WIDE = 1400
+const NARROW = 900
+
+const password = `shots-${crypto.randomUUID()}`
+const email = `shots-${crypto.randomUUID().slice(0, 8)}@e2e.newtheatre.org.uk`
+
+const app = await startApp()
+
+const send = (method: string, path: string, body?: unknown, cookie?: string): Promise =>
+ fetch(`${app.baseURL}${path}`, {
+ method,
+ headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) },
+ ...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }),
+ })
+
+function sql(statement: string, ...parameters: unknown[]): void {
+ const database = new Database(app.databaseFile)
+ try {
+ database.query(statement).run(...parameters as never[])
+ }
+ finally {
+ database.close()
+ }
+}
+
+function one(statement: string, ...parameters: unknown[]): T {
+ const database = new Database(app.databaseFile, { readonly: true })
+ try {
+ return database.query(statement).get(...parameters as never[]) as T
+ }
+ finally {
+ database.close()
+ }
+}
+
+// The screenshots are taken as an administrator, so this account walks the whole real path:
+// register, verify, enrol an authenticator, be granted the role, then answer a challenge.
+await send('POST', '/api/auth/register', { email, name: 'Imogen Hart (test)', password })
+sql('UPDATE users SET verified = 1 WHERE email = ?', email)
+const first = ((await send('POST', '/api/auth/sign-in', { email, password })).headers.get('set-cookie') ?? '').split(';')[0]!
+const { secret } = await (await send('POST', '/api/account/mfa/enrol', {}, first)).json() as { secret: string }
+await send('POST', '/api/account/mfa/confirm', { code: await codeForStep(secret, stepFor(new Date())) }, first)
+Bun.spawnSync(['bun', 'scripts/grant-admin.ts', email, app.databaseFile])
+
+// A code is single use, so a second sign-in in the same 30 second step needs the last one forgotten.
+function forgetStep(): void {
+ sql('UPDATE totp_secrets SET last_used_step = NULL WHERE user_id = (SELECT id FROM users WHERE email = ?)', email)
+}
+
+forgetStep()
+const cookie = await (async () => {
+ const { attemptId } = await (await send('POST', '/api/auth/sign-in', { email, password })).json() as { attemptId: string }
+ const answered = await send('POST', '/api/auth/mfa/challenge', { attemptId, code: await codeForStep(secret, stepFor(new Date())) })
+ return (answered.headers.get('set-cookie') ?? '').split(';')[0]!
+})()
+
+// Enough real data that no screen is empty: an empty table hides every layout problem there is.
+const NAMES = ['Rowan Ellis', 'Priya Nair', 'Tomasz Zielinski', 'Aoife Brennan', 'Sam Okonkwo', 'Hana Suzuki']
+const ids: string[] = []
+for (const [index, name] of NAMES.entries()) {
+ const address = `shots-member-${index}@e2e.newtheatre.org.uk`
+ await send('POST', '/api/auth/register', { email: address, name: `${name} (test)`, password })
+ sql('UPDATE users SET verified = 1 WHERE email = ?', address)
+ ids.push(one<{ id: string }>('SELECT id FROM users WHERE email = ?', address).id)
+}
+
+await send('POST', '/api/dev/seed', {}, cookie)
+await send('POST', '/api/admin/roles', { userId: ids[0], role: 'BOX_OFFICE' }, cookie)
+await send('POST', '/api/admin/roles', { userId: ids[1], role: 'FRONT_OF_HOUSE' }, cookie)
+await send('PUT', '/api/admin/config/BAR_TAB_CAP_PENCE', { value: 2500 }, cookie)
+
+const DAY_MS = 24 * 60 * 60 * 1000
+for (const [index, id] of ids.slice(0, 4).entries()) {
+ await send('POST', '/api/admin/memberships', {
+ userId: id,
+ startsOn: londonDay(new Date(Date.now() - index * 40 * DAY_MS)),
+ years: index % 2 === 0 ? 1 : 3,
+ studentId: `2099000${index}`,
+ }, cookie)
+}
+await send('POST', '/api/admin/fellowships', {
+ userId: ids[4],
+ awardedOn: '2019-06-12',
+ awardedBy: 'Committee, 12 June 2019',
+ citation: 'For a decade behind the lighting desk, and for teaching most of us to use it.',
+}, cookie)
+await send('POST', '/api/admin/fellowships', {
+ userId: ids[5],
+ awardedOn: '2014-11-03',
+ awardedBy: 'Committee, 3 November 2014',
+ citation: 'For founding the studio season.',
+}, cookie)
+
+const view = await openSignedOutView(app.baseURL)
+await visit(view, `${app.baseURL}/sign-in`)
+await fill(view, 'form input[type="email"]', email)
+await fill(view, 'form input[type="password"]', password)
+await click(view, 'form button[type="submit"]')
+await waitFor(view, `document.querySelectorAll('[data-test="mfa-challenge"] input').length >= 6`)
+forgetStep()
+await fillPin(view, '[data-test="mfa-challenge"] input', await codeForStep(secret, stepFor(new Date())))
+await waitFor(view, `document.querySelector('[data-test="sign-out"]')`)
+
+interface Shot {
+ name: string
+ path: string
+ // Waited for before the picture, so a screen is never caught mid-load.
+ marker?: string
+ after?: string
+ width?: number
+}
+
+const OPEN_MEMBERSHIP = `(async () => {
+ document.querySelector('[data-test="record-membership"]').click()
+ await new Promise(resolve => setTimeout(resolve, 400))
+ const input = document.querySelector('[data-test="person-picker"] input')
+ input.focus()
+ Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value').set.call(input, 'ro')
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+})()`
+
+const SHOTS: Shot[] = [
+ { name: '01-overview', path: '/admin', marker: 'h1' },
+ { name: '02-people', path: '/admin/people', marker: '[data-test="directory-table"]' },
+ { name: '03-people-filters', path: '/admin/people', marker: '[data-test="directory-table"]', after: `document.querySelector('[data-test="toolbar-filters"]').click()` },
+ { name: '04-account', path: `/admin/people/${ids[0]}`, marker: '[data-test="account-name"]' },
+ { name: '05-members', path: '/admin/members', marker: '[data-test="members-table"]' },
+ { name: '06-members-modal', path: '/admin/members', marker: '[data-test="members-table"]', after: OPEN_MEMBERSHIP },
+ { name: '07-fellows', path: '/admin/fellows', marker: '[data-test="fellows-table"]' },
+ { name: '08-fellows-modal', path: '/admin/fellows', marker: '[data-test="fellows-table"]', after: `document.querySelector('[data-test="award"]').click()` },
+ { name: '09-audit', path: '/admin/audit', marker: '[data-test="audit-table"]' },
+ { name: '10-audit-modal', path: '/admin/audit', marker: '[data-test="audit-table"]', after: `document.querySelector('[data-test="audit-record"]').click()` },
+ { name: '11-config', path: '/admin/config', marker: '[data-test="setting-BAR_TAB_CAP_PENCE"]' },
+ { name: '12-dev-tools', path: '/dev', marker: '[data-test="dev-seed"]' },
+ { name: '13-people-narrow', path: '/admin/people', marker: '[data-test="directory-table"]', width: NARROW },
+ { name: '14-members-narrow', path: '/admin/members', marker: '[data-test="members-table"]', width: NARROW },
+ { name: '15-config-narrow', path: '/admin/config', marker: '[data-test="setting-BAR_TAB_CAP_PENCE"]', width: NARROW },
+]
+
+const wanted = process.argv.slice(2)
+for (const shot of SHOTS) {
+ if (wanted.length && !wanted.some(term => shot.name.includes(term))) continue
+
+ view.resize(shot.width ?? WIDE, 1000)
+ await visit(view, `${app.baseURL}${shot.path}`, shot.marker)
+ await Bun.sleep(1200)
+ if (shot.after) {
+ await view.evaluate(shot.after)
+ await Bun.sleep(1200)
+ }
+ await Bun.write(`${OUT}/${shot.name}.png`, await view.screenshot())
+ console.info(`wrote ${OUT}/${shot.name}.png`)
+}
+
+view.close()
+await app.stop()
+
+// Explicit: the dev server subprocess keeps the loop alive, so the run would otherwise sit there
+// holding the port. The exit handler in the harness is what kills it.
+process.exit(0)
diff --git a/server/api/dev/index.get.ts b/server/api/dev/index.get.ts
new file mode 100644
index 00000000..0dc1086f
--- /dev/null
+++ b/server/api/dev/index.get.ts
@@ -0,0 +1,35 @@
+import { eq } from 'drizzle-orm'
+import { PERSONAS } from '#shared/utils/personas'
+
+// What a developer needs to know before doing anything: who is signed in, what that resolves to,
+// who they could be instead, and what the system has tried to send (K-124).
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const account = session?.user ? await findById(session.user.id) : undefined
+
+ const seeded = await personaAccounts()
+
+ const grants = account ? await liveGrants(account.id) : []
+
+ return {
+ session: account
+ ? {
+ id: account.id,
+ name: account.name,
+ email: account.email,
+ roles: grants.map(grant => grant.role),
+ permissions: [...permissionsFor(grants, new Date())].sort(),
+ factor: Boolean(await db.select({ userId: schema.totpSecrets.userId })
+ .from(schema.totpSecrets)
+ .where(eq(schema.totpSecrets.userId, account.id))
+ .limit(1)
+ .then(rows => rows[0])),
+ }
+ : null,
+ personas: PERSONAS.map(persona => ({
+ ...persona,
+ account: seeded.get(persona.email) ?? null,
+ })),
+ mailbox: await mailbox(),
+ }
+})
diff --git a/server/api/dev/seed.post.ts b/server/api/dev/seed.post.ts
new file mode 100644
index 00000000..5365d213
--- /dev/null
+++ b/server/api/dev/seed.post.ts
@@ -0,0 +1,4 @@
+// Seed the personas (K-124). Idempotent, because a developer runs it whenever they are unsure.
+export default defineEventHandler(async () => {
+ return { ok: true, ...await seedPersonas() }
+})
diff --git a/server/api/dev/sign-in-as.post.ts b/server/api/dev/sign-in-as.post.ts
new file mode 100644
index 00000000..de0f6b71
--- /dev/null
+++ b/server/api/dev/sign-in-as.post.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod'
+
+const body = z.object({ userId: z.string().min(1).max(64) })
+
+// Sign in as anybody, without their password (K-124). This is an authentication bypass, which is
+// why nuxt.config keeps this file out of a production build rather than guarding it at runtime.
+export default defineEventHandler(async (event) => {
+ const input = await readValidatedBodyOrThrow(event, body)
+
+ const account = await findById(input.userId)
+ if (!account) throw createError({ statusCode: 404, statusMessage: 'No such account' })
+ if (account.anonymisedAt !== null) {
+ throw createError({ statusCode: 409, statusMessage: 'That account is a tombstone: there is nobody to be' })
+ }
+
+ // The same session every other path writes, so what is being tested is the real thing (0007).
+ await startSession(event, account)
+ return { ok: true, name: account.name }
+})
diff --git a/server/utils/dev.ts b/server/utils/dev.ts
new file mode 100644
index 00000000..472442f2
--- /dev/null
+++ b/server/utils/dev.ts
@@ -0,0 +1,94 @@
+import { eq } from 'drizzle-orm'
+import { PERSONAS, PERSONA_PASSWORD } from '#shared/utils/personas'
+
+// Development-only helpers (K-124). Every caller is guarded, and nuxt.config keeps the routes out
+// of a production build entirely rather than trusting a guard to be remembered.
+
+const MAILBOX = '.data/mail'
+
+export interface Letter { name: string, to: string, subject: string, body: string }
+
+// The messages the notification centre wrote here instead of sending (0013).
+export async function mailbox(): Promise {
+ const { readdir, readFile } = await import('node:fs/promises')
+ try {
+ const names = (await readdir(MAILBOX)).sort().reverse().slice(0, 20)
+ return await Promise.all(names.map(async (name) => {
+ const body = await readFile(`${MAILBOX}/${name}`, 'utf8')
+ const header = (label: string): string => body.match(new RegExp(`^${label}: (.*)$`, 'm'))?.[1] ?? ''
+ return { name, to: header('To'), subject: header('Subject'), body }
+ }))
+ }
+ catch {
+ return []
+ }
+}
+
+// Which account each persona became. Anonymisation rewrites the email, so a tombstone cannot be
+// found by the address it was seeded under; this map is how it stays findable (0011).
+const MAP = '.data/personas.json'
+
+async function remembered(): Promise> {
+ try {
+ return await Bun.file(MAP).json() as Record
+ }
+ catch {
+ return {}
+ }
+}
+
+export interface PersonaAccount { id: string, email: string, name: string, anonymisedAt: number | null }
+
+// The seeded account for each persona email, or nothing where one has not been seeded yet.
+export async function personaAccounts(): Promise