feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s

Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.

Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):

- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
  `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
  test against an unseeded dev instance, captured before the theming work, and
  five of the fourteen are two-factor prompts. Shipping them would break D4.
  Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
  app on a validated deployment address, so a tester needs somewhere to point it.
  The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
  cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
  `androidApk.serviceable` is false because the published v0.5.0 build does not
  work. The panel stays and states that plainly rather than being removed.

Three mechanisms the plan did not anticipate:

- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
  so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
  takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
  source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires
  when a route has been built, and an on-demand route never produces a file, so
  the entry could never rot out.
- `npm test` — the five existing checks all read built output, and none of this
  logic appears there. A honeypot can stop working and leave the build identical.

Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.

Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:50:51 -05:00
parent fbd7bbe6fd
commit 1313e748ae
21 changed files with 3366 additions and 22 deletions

645
src/pages/beta.astro Normal file
View File

@@ -0,0 +1,645 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import { liveBrand } from '../lib/brand.mjs';
import { isFull, liveCount } from '../lib/betaStore.mjs';
import { issueFormToken, OUTCOME, submit, isSuccess } from '../lib/betaSignup.mjs';
import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/beta.mjs';
/**
* `/beta/` — the closed-beta signup. PLAN.md §8, phase 5.
*
* ---------------------------------------------------------------------------------------
* THE SECOND ROUTE THAT EXECUTES PER REQUEST — AND IT HANDLES ITS OWN POST (D28)
* ---------------------------------------------------------------------------------------
* §6 lists the dynamic surface as `GET /brand/*` and `POST /api/beta-signup`. The org lead
* amended that on 2026-08-24: this page is the endpoint, and there is no `/api/` route.
*
* The reason is the one thing the endpoint shape cannot do. A separate API route has to
* answer a browser somehow — as JSON, which means the form only works with JavaScript, or
* as a redirect, which means an invalid address returns the person to a blank form with no
* explanation of what went wrong. Both are worse than they sound on a page whose entire job
* is conversion (§8 says to write it to convert), and the first is worse still on a site
* that has no analytics and no third-party anything: a form that silently does nothing for
* a reader with scripts off is a form that has no way of telling anyone it is broken.
*
* Handling the POST here costs one on-demand route and buys a form that works with
* JavaScript disabled, renders every outcome in the real layout, and needs no client-side
* code at all — so nothing on this page has to argue with the strict CSP either.
*
* ---------------------------------------------------------------------------------------
* TWO GATES, BOTH STATED, NEITHER HIDDEN (D27)
* ---------------------------------------------------------------------------------------
* The beta cannot start yet for two independent reasons — no Play track, and nowhere for a
* tester to point the app (see `beta.mjs` for both in full). The page collects addresses
* anyway, because the list is what makes the first batch possible on day one, and says
* plainly that it is a list rather than a queue that is moving. The demo is rendered
* through `NotBuilt` so the absence appears in the same shape it takes everywhere else on
* the site rather than as an apology invented for this page.
*
* ---------------------------------------------------------------------------------------
* WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT
* ---------------------------------------------------------------------------------------
* When `betaOptInUrl` is mounted, the confirmation screen prints the Play opt-in link. That
* is only safe because of how Play's closed testing works: the link admits addresses that
* are already on the tester list and refuses everyone else. It is what lets D7's "the site
* sends no email" hold — Google does not notify testers on the email-list path either, so
* something has to carry the link, and a page the person is already looking at is a better
* channel than an email nobody can send.
*/
export const prerender = false;
const brand = liveBrand();
/**
* A POST is a submission; anything else is somebody arriving. `Astro.request.formData()`
* parses both `application/x-www-form-urlencoded` and `multipart/form-data`, and this form
* is the former — no file input, nothing to stream.
*
* The `try` is not defensive dressing. A malformed body throws here, and the person who
* would see that stack trace is somebody whose browser or proxy mangled a request, not an
* attacker — they should get the form back with a message, the same as a stale token.
*/
let result = null;
if (Astro.request.method === 'POST') {
try {
const form = await Astro.request.formData();
result = submit({
form,
// `x-forwarded-for` is whatever the proxy in front of this container puts there, and
// its first entry is the client as that proxy saw it. It is trusted only as far as
// rate limiting, and it is hashed before it is stored — see betaStore.mjs. Behind a
// proxy that does not set it, everyone shares one bucket, which fails toward refusing
// signups rather than toward accepting abuse.
ip:
Astro.request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
Astro.clientAddress,
userAgent: Astro.request.headers.get('user-agent'),
});
} catch (error) {
console.error('[beta] could not read the submitted form:', error);
result = { outcome: OUTCOME.ERROR };
}
}
/**
* The cap is read per render so the form closes the moment it is reached, and so a store
* that cannot be opened at all does not take the page down with it — a `/beta` that shows
* the argument and admits the form is unavailable is worth more than a 500.
*/
let full = false;
let signed = 0;
let storeDown = false;
try {
full = isFull();
signed = liveCount();
} catch (error) {
console.error('[beta] the signup store is not available:', error);
storeDown = true;
}
const showForm = !storeDown && !full && !isSuccess(result?.outcome);
/**
* The message for each outcome. One object rather than a chain of conditionals in the
* markup, so a new outcome added to `OUTCOME` without a message here is visibly missing
* rather than silently rendering an empty box.
*
* The duplicate case says exactly what the added case says, on purpose. §8's rule: an
* answer that distinguished them would turn this form into a way of asking whether any
* given address is in the beta.
*/
const NOTICES = {
[OUTCOME.ADDED]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.DUPLICATE]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.DECOY]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.STALE]: {
tone: 'warn',
title: 'This form had been open a while.',
body: 'Nothing was submitted. Here it is again — the details you typed were not kept.',
},
[OUTCOME.TOO_FAST]: {
tone: 'warn',
title: 'That was submitted faster than the page could be read.',
body:
'Nothing was recorded. If you are a person and not a script, wait a moment and send ' +
'it again — the check is a crude one and it is occasionally wrong about people.',
},
[OUTCOME.LIMITED]: {
tone: 'warn',
title: 'Too many attempts from your connection.',
body:
'Try again later. The limit counts attempts rather than signups, so a few mistyped ' +
'addresses can reach it — nothing has gone wrong with your place on the list.',
},
[OUTCOME.FULL]: {
tone: 'warn',
title: 'The list is closed for now.',
body: 'It has reached its cap. Discord is the place to hear when it reopens.',
},
[OUTCOME.INVALID_EMAIL]: {
tone: 'warn',
title: "That address doesn't look right.",
body: 'Check it and send it again. It has to be the Google account you use on your phone.',
},
[OUTCOME.NO_CONSENT]: {
tone: 'warn',
title: 'The consent box was not ticked.',
body: 'The address cannot be stored without it, so nothing was recorded.',
},
[OUTCOME.ERROR]: {
tone: 'warn',
title: 'Something went wrong at our end.',
body:
'Your address was not recorded. This is worth reporting in Discord if it keeps ' +
'happening — it means the site has a problem, not that you do.',
},
};
const notice = result ? NOTICES[result.outcome] : null;
/** Only ever shown on a success screen, and only when the track exists. */
const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : '';
const title = 'The closed beta';
const description =
'Join the list for the Runic Gateway Android app closed test. No email is ever sent.';
const formToken = issueFormToken();
---
<Base title={title} description={description}>
<PageHeader eyebrow="Android" title="Join the closed test">
<p>
The <a href="/app/">Android app</a> is heading for Google Play by way of a closed
test. This is the list of people who want a place on it.
</p>
<p>
It has not opened yet, and the two reasons are below rather than behind a
&ldquo;coming soon&rdquo;. Adding your address now means you are in the first batch
rather than hearing about it afterwards.
</p>
</PageHeader>
{
notice && (
<section class="page section beta-notice-wrap">
<div class={`panel beta-notice beta-notice--${notice.tone}`} role="status">
<h2>{notice.title}</h2>
<p>{notice.body}</p>
{isSuccess(result?.outcome) && (
<div class="beta-next">
<h3>What happens next</h3>
<ol>
<li>
Batches are added to the tester list by hand — there is no way to automate
it, so it happens when a person sits down to do it.
</li>
<li>
{optInUrl ? (
<>
Open the opt-in link with the same Google account once you have been
added. It only works for addresses already on the list, so it is safe
to share this page but not useful to.
<br />
<a class="btn btn--primary beta-next__optin" href={optInUrl} rel="noopener noreferrer">
The Play opt-in link
</a>
</>
) : (
<>
When the test track exists you will need to open its opt-in link with
the same Google account. It is not created yet, so there is nothing to
link here — this page will show it as soon as there is.
</>
)}
</li>
<li>
<a href={brand.discordInvite} rel="noopener noreferrer">Discord</a> carries
the announcement for each batch. It has to: this site sends no email, to
you or to anyone, ever.
</li>
</ol>
</div>
)}
</div>
</section>
)
}
<section class="page section">
<h2 class="beta-h2">What it is waiting on</h2>
<p class="prose beta-lede">
Two things, neither of which is a date. Both are visible from outside, so there is no
reason to be vague about them.
</p>
<ol class="beta-gates">
<li class="panel beta-gate">
<p class="eyebrow">Gate one</p>
<h3>The test track</h3>
<p>
The developer account exists; the closed test does not yet. Google needs
{' '}{playPolicy.testersRequired} testers opted in continuously for
{' '}{playPolicy.testerDays} days before the app can be put forward for a
production release, which is exactly why the list is being built before the track
opens rather than after.
</p>
<p class="beta-gate__foot">
Play's testing rules as published on {playPolicy.verifiedOn}, and they have changed
before.
</p>
</li>
<li class="panel beta-gate">
<p class="eyebrow">Gate two</p>
<h3>Somewhere to point it</h3>
<p>
The app is a client and ships pointed at nothing — its first screen asks for the
address of a site running this platform. So a tester needs a deployment, and the
public demo is the one being built for that. Until it is running, a place on the
test would be a place to install an app with nothing behind it.
</p>
<p class="beta-gate__foot">
If you already run a Runic Gateway deployment, this gate does not apply to you —
say so in Discord.
</p>
</li>
</ol>
</section>
<section class="page section">
<h2 class="beta-h2">What a tester needs</h2>
<ul class="beta-reqs">
{
requirements.map((requirement) => (
<li class="panel beta-req">
<h3>{requirement.title}</h3>
<p>{requirement.body}</p>
</li>
))
}
</ul>
</section>
<section class="page section" id="form">
<h2 class="beta-h2">The list</h2>
{
storeDown && (
<div class="panel beta-notice beta-notice--warn">
<h3>The form is unavailable.</h3>
<p>
Signups cannot be recorded at the moment — this is a fault at our end and it has
been logged. Everything else on this page is still true.
</p>
</div>
)
}
{
!storeDown && full && !notice && (
<div class="panel beta-notice beta-notice--warn">
<h3>The list is closed for now.</h3>
<p>
It has reached its cap of {limits.totalCap}. Discord is the place to hear when it
reopens.
</p>
</div>
)
}
{
showForm && (
<div class="beta-formwrap">
{/*
Posts to itself with no fragment. `#form` was the obvious thing to write and it
is wrong twice: Chrome does not honour a fragment on a POST response anyway, and
if it did it would scroll past the notice — which renders under the page header
and is the thing the person needs to read. Landing at the top is the behaviour,
so the markup should say so rather than ask for something else and get it.
*/}
<form class="panel beta-form" method="post" action="/beta/">
<p class="beta-form__intro">
One field and a box to tick. The address has to be the Google account you use
on the phone you would test with — Play matches the tester list against the
account, not the device.
</p>
<label class="beta-form__label" for="beta-email">
Google account email
</label>
<input
class="beta-form__input"
id="beta-email"
type="email"
name={fields.EMAIL}
autocomplete="email"
inputmode="email"
required
maxlength="254"
placeholder="you@example.com"
/>
{/*
The honeypot. Hidden from people in three independent ways because any one of
them alone is a browser quirk away from being visible to somebody using a
screen reader or a text browser: off-screen, removed from the accessibility
tree, and excluded from tab order. `autocomplete="off"` matters most of all —
a browser that helpfully fills this in would fail a real person's signup.
*/}
<div class="beta-form__decoy" aria-hidden="true">
<label for="beta-website">Website</label>
<input
id="beta-website"
type="text"
name={fields.HONEYPOT}
tabindex="-1"
autocomplete="off"
/>
</div>
<input type="hidden" name={fields.ISSUED} value={formToken} />
<label class="beta-form__consent">
<input type="checkbox" name={fields.CONSENT} value="yes" required />
<span>{CONSENT_TEXT}</span>
</label>
<button class="btn btn--primary beta-form__submit" type="submit">
Add me to the list
</button>
<p class="beta-form__foot">
Stored: the address, the wording above, the date, and a one-way hash of your
connection used only to rate-limit this form. Never your IP address itself.
Ask in Discord to have it deleted and it will be.
</p>
</form>
<aside class="beta-count">
<p class="beta-count__n">{signed}</p>
<p class="beta-count__label">
on the list &middot; cap {limits.totalCap}
</p>
<p class="beta-count__note">
Published because a number nobody can see is a number people assume. Play needs
{' '}{playPolicy.testersRequired} to actually opt in, which is a different and
harder number than this one.
</p>
</aside>
</div>
)
}
</section>
<NotBuilt scope="beta" title="What is not in place yet" />
</Base>
<style>
.beta-h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.beta-lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
/* The result of a submission, directly under the header where a person's eye already is
after a page reload. `role="status"` so it is announced rather than silently replacing
the form for anyone not looking at the screen. */
.beta-notice-wrap {
padding-top: 0;
}
.beta-notice h2,
.beta-notice h3 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.beta-notice p {
margin: 0;
max-width: var(--measure);
color: var(--text);
}
.beta-notice--ok {
border-color: var(--mode-live);
}
.beta-notice--ok h2 {
color: var(--mode-live);
}
.beta-notice--warn {
border-color: var(--gold-deep);
}
.beta-notice--warn h2,
.beta-notice--warn h3 {
color: var(--gold);
}
.beta-next {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line-soft);
}
.beta-next h3 {
margin: 0 0 0.75rem;
color: var(--head);
font-size: 1.05rem;
}
.beta-next ol {
margin: 0;
padding-left: 1.25rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.beta-next li + li {
margin-top: 0.75rem;
}
.beta-next__optin {
margin-top: 0.85rem;
}
.beta-gates,
.beta-reqs {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.beta-gate h3,
.beta-req h3 {
margin: 0.35rem 0 0.6rem;
color: var(--gold);
font-size: 1.1rem;
}
.beta-gate p,
.beta-req p {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.beta-gate__foot {
margin-top: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.86rem;
}
/* The form and the counter. The counter is deliberately narrow and secondary — it is
context for the decision, not the reason to make it. */
.beta-formwrap {
display: grid;
gap: 1rem;
align-items: start;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
@media (max-width: 780px) {
.beta-formwrap {
grid-template-columns: 1fr;
}
}
.beta-form__intro {
margin: 0 0 1.5rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.beta-form__label {
display: block;
margin-bottom: 0.4rem;
color: var(--head);
font-size: 0.9rem;
font-weight: 600;
}
.beta-form__input {
display: block;
width: 100%;
max-width: 26rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--ink);
font-family: var(--sans);
font-size: 1rem;
}
.beta-form__input:focus-visible {
border-color: var(--portal);
outline: 2px solid var(--portal-bright);
outline-offset: 1px;
}
/* Off-screen rather than `display: none`: a bot that reads CSS skips a hidden field, and
one that does not read CSS fills this in. Kept in the layout and out of everything
else — see the markup for why all three of these are needed. */
.beta-form__decoy {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.beta-form__consent {
display: flex;
gap: 0.7rem;
align-items: flex-start;
margin: 1.5rem 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
cursor: pointer;
}
.beta-form__consent input {
flex: none;
margin-top: 0.2rem;
width: 1.05rem;
height: 1.05rem;
accent-color: var(--portal);
}
.beta-form__submit {
margin-bottom: 1.5rem;
}
.beta-form__foot {
margin: 0;
padding-top: 1rem;
border-top: 1px solid var(--line-soft);
max-width: var(--measure);
color: var(--dim);
font-size: 0.85rem;
}
.beta-count {
padding: 1.5rem;
border: 1px solid var(--line-soft);
border-radius: var(--radius-panel);
text-align: center;
}
.beta-count__n {
margin: 0;
color: var(--gold);
font-family: var(--display);
font-size: 3rem;
line-height: 1;
}
.beta-count__label {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.beta-count__note {
margin: 1rem 0 0;
padding-top: 0.9rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.82rem;
text-align: left;
}
</style>