--- 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(); ---

The Android app is heading for Google Play by way of a closed test. This is the list of people who want a place on it.

It has not opened yet, and the two reasons are below rather than behind a “coming soon”. Adding your address now means you are in the first batch rather than hearing about it afterwards.

{ notice && (

{notice.title}

{notice.body}

{isSuccess(result?.outcome) && (

What happens next

  1. 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.
  2. {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.
    The Play opt-in link ) : ( <> 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. )}
  3. Discord carries the announcement for each batch. It has to: this site sends no email, to you or to anyone, ever.
)}
) }

What it is waiting on

Two things, neither of which is a date. Both are visible from outside, so there is no reason to be vague about them.

  1. Gate one

    The test track

    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.

    Play's testing rules as published on {playPolicy.verifiedOn}, and they have changed before.

  2. Gate two

    Somewhere to point it

    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.

    If you already run a Runic Gateway deployment, this gate does not apply to you — say so in Discord.

What a tester needs

The list

{ storeDown && (

The form is unavailable.

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.

) } { !storeDown && full && !notice && (

The list is closed for now.

It has reached its cap of {limits.totalCap}. Discord is the place to hear when it reopens.

) } { showForm && (
{/* 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. */}

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.

{/* 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. */}

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 to have it deleted and it will be erased. The{' '} privacy page says all of this in full, including how to ask.

) }