Compare commits
10 Commits
bb06f1de44
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
| 084ee0bb6c | |||
| f499f2b72b | |||
| 971fa9c032 | |||
| a2faf07104 | |||
| 29c96d0b21 | |||
| 1313e748ae | |||
| fbd7bbe6fd | |||
| 2d19ee4220 | |||
| d9d7a8d47f | |||
| 556dee7355 |
@@ -38,12 +38,49 @@ jobs:
|
||||
# rewrite replaces is distinctive enough to replace blindly.
|
||||
run: npm run check:brand
|
||||
|
||||
- name: Play Data Safety declaration
|
||||
# PLAN.md §9 / D33 — PLAY_DATA_SAFETY.md is generated from the same
|
||||
# src/data/collection.mjs rows that /privacy section 2 renders, so the published
|
||||
# policy and the answers given to Google cannot drift apart. This re-runs the
|
||||
# generator and fails if the committed copy differs.
|
||||
#
|
||||
# It runs before the build because it needs neither one: it is the cheapest check
|
||||
# here and the one whose failure is easiest to act on.
|
||||
run: npm run check:datasafety
|
||||
|
||||
- name: Types
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
# PLAN.md §8 — the beta signup's decision path: honeypot, form token, timing, rate
|
||||
# limit, cap, validation, duplicate, removal.
|
||||
#
|
||||
# The first thing in this repository that the other checks cannot see. They all read
|
||||
# the built output, and none of this appears there: a honeypot that has stopped
|
||||
# working produces a build that is identical in every way to one where it works.
|
||||
#
|
||||
# The test file is NAMED rather than the directory passed. `node --test test/` fails
|
||||
# on Node 22 with MODULE_NOT_FOUND — directory mode is not portable across the
|
||||
# versions this org runs, and this workflow pins 22 while developers are on 24, so
|
||||
# the shorter form would pass locally and break only here.
|
||||
run: npm test
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
- name: Links
|
||||
# PLAN.md §12 — every internal link resolves, and every outbound link into a
|
||||
# RunicGateway repository points at a branch path rather than a commit permalink.
|
||||
#
|
||||
# It runs AFTER the build, and that ordering is the design rather than a
|
||||
# convenience: it reads the built HTML, so links assembled from data files and
|
||||
# template literals are checked as the strings they actually become. A source scan
|
||||
# would see an expression and skip most of what phase 4 added.
|
||||
#
|
||||
# No network: the outbound rule is about the shape of a URL, and a build that
|
||||
# fails because some other host is slow is a check people learn to ignore.
|
||||
run: npm run check:links
|
||||
|
||||
- name: Platform facts
|
||||
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
|
||||
# its authority over the Gitea API and must agree with src/data/platform.json.
|
||||
@@ -62,3 +99,19 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:facts
|
||||
|
||||
- name: Quickstart against website main
|
||||
# PLAN.md §12, phase 7 (D35). /docs/getting-started/install-the-site/ prints a
|
||||
# Compose file and an environment file the reader copies without leaving the page,
|
||||
# which is the one place this site knowingly keeps a copy of another repo's file.
|
||||
#
|
||||
# So the copy is checked in BOTH directions: every value it states must match
|
||||
# website's own docker-compose.yml and .env.example on main, and every service and
|
||||
# variable THEY have must be either included or listed as deliberately omitted with
|
||||
# a reason. A new variable upstream turns this repo red until someone decides
|
||||
# whether a first install needs it — the same intent as the facts check above.
|
||||
#
|
||||
# Same token, and for the same reason: it reads another repository in the org.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:quickstart
|
||||
|
||||
410
PLAN.md
410
PLAN.md
@@ -234,6 +234,19 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. |
|
||||
| **D13** | **Publish the existing address.** No mailbox has been created at the domain and the org lead elected not to wait for one: `whitlocktech@gmail.com` is the published contact wherever the site needs one — `/privacy`, `/terms`, `/community`, the Play listing — and `docs/SECURITY.md` keeps the address it already carries. | Taken 2026-08-19, superseding §14 N2 as a blocker. The address lives **only** in `brand.json` (§7), never in prose, so moving to `privacy@`/`security@` later is a file edit and a restart. Phase 0's PR 0.10 is withdrawn, not deferred. |
|
||||
|
||||
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
||||
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
||||
somewhere other than the thing it decided. The count of record is **thirty-seven**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
| D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
|
||||
| D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
|
||||
| D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links |
|
||||
| D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta |
|
||||
| D30–D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document |
|
||||
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
||||
|
||||
---
|
||||
|
||||
## 6. Runtime shape
|
||||
@@ -247,6 +260,9 @@ POST and write it somewhere (§8), and branding must be overridable by dropping
|
||||
mount **without rebuilding the image** (§7) — which means the bytes cannot be fingerprinted into
|
||||
the build output.
|
||||
|
||||
*Amended 2026-08-24 by D28: the signup is `/beta` itself rather than a `POST /api/beta-signup`
|
||||
endpoint. The dynamic surface is still two routes and the reasoning above is unchanged; see §8.*
|
||||
|
||||
Why not a separate API service: one container is one thing to deploy, one thing to patch, and one
|
||||
log to read. The dynamic surface is three endpoints.
|
||||
|
||||
@@ -256,7 +272,7 @@ log to read. The dynamic surface is three endpoints.
|
||||
│ Astro (Node adapter) │
|
||||
│ ├── prerendered pages ......... marketing, docs, legal — plain HTML │
|
||||
│ ├── GET /brand/* ............ reads the bind mount, falls back to defaults │
|
||||
│ └── POST /api/beta-signup ..... writes SQLite on the bind mount │
|
||||
│ └── GET + POST /beta/ ......... renders the form; writes SQLite on the mount │
|
||||
│ │
|
||||
│ /app/brand-default ..... baked into the image (stock logo, tokens, brand.json) │
|
||||
└─────────┬──────────────────────────────────────────────┬───────────────────────────┘
|
||||
@@ -425,6 +441,17 @@ identify. The salt lives in the container environment, so rotating it destroys t
|
||||
deliberately. `consent_text` stores the wording itself rather than a version number, so a record can
|
||||
always answer "what exactly did this person agree to" without archaeology.
|
||||
|
||||
Phase 5 added the second table this section describes in prose but does not draw — `attempts`
|
||||
(`ip_hash`, `at`, `outcome`), which is the token bucket below, persisted as the events themselves
|
||||
rather than as a counter that would need a decay schedule and a clock it trusts. It is pruned on
|
||||
write, so nothing has to remember to run.
|
||||
|
||||
One consequence of `remove` that follows from the promise rather than from a separate choice: the
|
||||
address is **overwritten**, not flagged, so afterwards the store cannot tell a removed address from
|
||||
one it has never seen. Somebody who left and signs up again is an ordinary new row. Keeping a hash
|
||||
so the form could say "you were removed" would mean retaining a derived identifier for the one
|
||||
person who explicitly asked not to be retained.
|
||||
|
||||
### Abuse resistance without a third party
|
||||
|
||||
D9 and §6's CSP forbid external requests, so no captcha service. Instead:
|
||||
@@ -447,6 +474,11 @@ docker compose exec site node scripts/beta.mjs remove <email> # deletion reques
|
||||
docker compose exec site node scripts/beta.mjs stats
|
||||
```
|
||||
|
||||
Phase 5 note: `export` writes **two** files, not one. The `.csv` is the record (id, address, date,
|
||||
status, the consent wording); the `.txt` beside it is one address per line, which is what Play's
|
||||
tester list actually wants pasted. Producing only the CSV would mean hand-editing it before every
|
||||
paste, which is where a mistake would come from.
|
||||
|
||||
Deliberately not an admin page. An authenticated HTTP surface on a marketing site is a login form,
|
||||
a session, a password to rotate and a thing to patch — for an operation performed by the one person
|
||||
who already has shell on the host, against a file already on their disk. The CSV lands in the bind
|
||||
@@ -454,6 +486,74 @@ mount and is opened locally.
|
||||
|
||||
`remove` exists because §9 promises deletion on request and a promise needs a mechanism.
|
||||
|
||||
### How phase 5 built the app and the beta
|
||||
|
||||
Four decisions taken before the pages were written (org lead, 2026-08-24), plus what the
|
||||
repositories said when the plan above was checked against them.
|
||||
|
||||
**Three things this section had assumed that turned out not to hold.** §10 promised `/app/` "the 14
|
||||
existing screenshots"; they exist and are the wrong fourteen (D26). §8 never said what a tester
|
||||
would point the app at, and the app points at nothing by default (D27). And `/app/` can offer a
|
||||
download today, because every `Android-app` release attaches a signed APK — which §8 and §10 both
|
||||
omitted, having been written as though Play were the only delivery path.
|
||||
|
||||
**D26 — the screenshots are reserved for phase 9, and the slot ships empty.** `docs/android/screenshots/`
|
||||
is a trusted-device and recovery-code smoke test from 2026-07-22: captured against a development
|
||||
instance with no seeded content, before the theming work changed how every screen looks, and five
|
||||
of the fourteen are two-factor prompts. Shipping them would break D4 and would show an app that no
|
||||
longer looks like that. Phase 9 already stands up the review stack and seeds content for the web
|
||||
screenshots, so it gains an emulator pass and the phone shots then show the same deployment on the
|
||||
same day. `src/components/app/Screenshots.astro` exists now, rendering nothing, so filling it is a
|
||||
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
|
||||
forward into phase 5.
|
||||
|
||||
**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on
|
||||
`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has
|
||||
been entered and validated — so an installed app with no deployment behind it is a text field. The
|
||||
alternative considered and declined was naming UOMysticmoon, which would have opened the beta to
|
||||
players immediately at the cost of publishing a private shard's address on a public page. The
|
||||
consequence is accepted rather than hidden: **the beta cannot start until §15's demo VM exists**,
|
||||
which is the second of the two gates `/beta` states outright. It costs nothing today, because D28's
|
||||
other gate — no closed test track — is open anyway.
|
||||
|
||||
**D28 — `/beta` handles its own POST; there is no `/api/beta-signup`.** §6 specified an endpoint,
|
||||
and an endpoint cannot report a validation error without JavaScript: it answers with JSON, which
|
||||
makes the form script-only, or with a redirect, which returns a person who mistyped an address to a
|
||||
blank form with no explanation. Both are poor on a page whose job is conversion, and the first is
|
||||
worse on a site with no analytics — a form that silently does nothing for a reader with scripts off
|
||||
has no way of telling anyone it is broken. Handling the POST in the page 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, so nothing on it argues with the CSP.
|
||||
|
||||
**D29 — the APK and the beta get equal billing, and the APK link is currently off.** Two panels of
|
||||
the same weight: sideload today, or join the closed test for Play delivery and automatic updates.
|
||||
The beta argues for itself on convenience rather than on being the only door. But the org lead
|
||||
reports that the published `v0.5.0` build does not work, so `platform.json`'s `androidApk.serviceable`
|
||||
is `false` and the panel renders a plain statement that the build is being replaced rather than a
|
||||
link. That flag is the one value in `platform.json` with no authority to check it against, and
|
||||
deliberately so — no fetch can tell whether an APK runs. `checkFacts.mjs` asserts the two assets
|
||||
still exist and that `minSdk` still says what "Android 10 or newer" claims, so the link is correct
|
||||
the moment a working build flips the boolean. The panel is not removed while the link is off: a page
|
||||
that omitted sideloading would read, to somebody who knows the APK exists, as a page hiding it.
|
||||
|
||||
**Three mechanisms this phase added that the plan did not anticipate.**
|
||||
|
||||
- **`liveBrand()`, because a server-rendered page cannot use the boot rewrite.** §7's mechanism
|
||||
rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading
|
||||
`brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an
|
||||
mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount
|
||||
takes effect on the **next request**, with no restart.
|
||||
- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*,
|
||||
and rule 1 resolves links against the build, where it has no file. The fix is not a
|
||||
`PLANNED_ROUTES` entry — that list's 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 and would become the
|
||||
permanent exemption the two-way check exists to prevent. Instead the routes are derived from the
|
||||
source: a page exporting `prerender = false` is one. Delete `beta.astro` and the links fail again.
|
||||
- **A test suite, for the first time in this repository.** The five checks of §12 all read built
|
||||
output, and none of this phase's logic appears there — a honeypot can stop working entirely and
|
||||
produce a build identical to one where it works. `node --test`, named file rather than directory
|
||||
(`node --test test/` fails on Node 22, which is what CI runs).
|
||||
|
||||
---
|
||||
|
||||
## 9. Legal pages
|
||||
@@ -495,6 +595,64 @@ because it cannot.
|
||||
|
||||
Both pages are linked from the footer on every page, and `/privacy` is the URL given to Play.
|
||||
|
||||
### How phase 6 built the legal pages
|
||||
|
||||
Four decisions taken before either page was written (org lead, 2026-08-24).
|
||||
|
||||
**D30 — One hop in front of the site, and the page says so.** §9 requires `/privacy` to state the
|
||||
access logs and their retention, which needed a fact rather than a guess. The domain's DNS is on
|
||||
Cloudflare but the records are **DNS-only**: no edge provider terminates the connection, so the
|
||||
reverse proxy on the org lead's own host keeps the only access log there is — IP, path, user agent,
|
||||
timestamp — read when something is broken or being attacked, rotated on the proxy's own schedule.
|
||||
The page describes it qualitatively rather than quoting a retention number, because the number
|
||||
belongs to the proxy's configuration and a policy that states one the deployment does not enforce is
|
||||
worse than one that does not. **If the record is ever proxied, this section is wrong and has to be
|
||||
rewritten** — an edge provider that terminates TLS is a processor, and D9's "no third-party
|
||||
requests" would still be true of the browser while ceasing to be the whole story.
|
||||
|
||||
**D31 — Eighteen or older.** Play asks, and the answer decides whether consent alone is a lawful
|
||||
basis in the EEA. Eighteen was chosen over thirteen (Google's own account minimum, but below the
|
||||
children's-consent threshold in several EEA states, so a 13–15 year old's consent would need a
|
||||
parent's — which this form cannot obtain) and over sixteen (sufficient, but no simpler to state).
|
||||
The number lives in `src/data/legal.mjs` because four surfaces render it: `/terms`, `/privacy`, the
|
||||
eligibility list on `/beta`, and the consent sentence itself. **Nothing verifies it and no surface
|
||||
implies otherwise** — the pages say in as many words that ticking the box is the whole of it, which
|
||||
is both accurate and the only claim the code supports.
|
||||
|
||||
Adding the clause changed `CONSENT_TEXT`, which is stored per row rather than versioned — so rows
|
||||
written from now on carry the new sentence and older ones keep theirs. `CONSENT_VERSION` gained a
|
||||
suffix rather than a new date, because the change landed on the day the original wording was
|
||||
written and two different sentences must not share the label an operator groups a CSV by.
|
||||
|
||||
**D32 — No governing-law clause.** Nothing of value is contracted for on this site: it sells
|
||||
nothing, the software is free under a licence that carries its own terms, and the beta is a list of
|
||||
addresses people asked to be on. A jurisdiction clause here would be decoration, and §9's standard
|
||||
for these pages is that accurate and specific beats boilerplate. It stays available: adding one
|
||||
later is a clause, not a rewrite.
|
||||
|
||||
**D33 — The Play Data Safety notes are a generated repository document.** §9 says the declaration is
|
||||
"filled from section 2, and section 2 is written knowing that is what it is for" — so the two are
|
||||
one array, `src/data/collection.mjs`, rendered by `/privacy` as prose and by
|
||||
`scripts/playDataSafety.mjs` as the console's own questions into a committed
|
||||
`PLAY_DATA_SAFETY.md`. `--check` regenerates and fails if the committed copy differs, and CI runs
|
||||
it, so a hand edit is a red build that names the data file to edit instead. The document is
|
||||
operator-facing rather than published: it is a form's worth of console vocabulary no visitor is
|
||||
looking for, and `/privacy` already says the same things in prose.
|
||||
|
||||
Two properties of that file are worth keeping. **It does not pretend to know Play's current
|
||||
definitions** — there is no API to read them from and the requirements have changed more than once
|
||||
(the same reason `playPolicy` carries a `verifiedOn` date), so it holds the facts arranged as the
|
||||
console arranges its questions, with the answer each fact supports and why; a person reads the
|
||||
console's wording against them. And **a test asserts that every mapped row answers "not collected,
|
||||
not shared"**, failing with the reason rather than a diff: "we operate no server the app talks to"
|
||||
is the premise of the whole section, and a telemetry endpoint added later must not be able to
|
||||
produce a row that quietly contradicts the lede three inches above it.
|
||||
|
||||
**What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in
|
||||
`checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what
|
||||
forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8
|
||||
under the same convention.
|
||||
|
||||
---
|
||||
|
||||
## 10. Information architecture
|
||||
@@ -511,22 +669,151 @@ Organised by what a reader is trying to do. A reader should never need to know t
|
||||
| `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it |
|
||||
| `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) |
|
||||
| `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list |
|
||||
| `/app/` | The Android app: what it does, the 14 existing screenshots, and the beta CTA |
|
||||
| `/beta/` | The closed-beta signup (§8) |
|
||||
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) |
|
||||
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
|
||||
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
|
||||
| `/privacy/`, `/terms/` | §9 |
|
||||
|
||||
**Feature grouping**, using project terminology:
|
||||
|
||||
- **Community** — Teams, Team forums, notifications, wiki, news and newsletter, player self-service
|
||||
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, player-vendor
|
||||
marketplace, houses and IDOCs, character sheets, spawn atlas, champion boards, points leaderboards
|
||||
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, character
|
||||
sheets, points and loyalty boards, player-vendor marketplace, houses and IDOCs, spawn atlas,
|
||||
champion boards, guilds, city governors
|
||||
- **Administration** — roles, moderation and appeals, content reports, audit log, bot scoring and IP
|
||||
bans, module management, the shard connection
|
||||
- **Integration** — modules, the sidecar bridge, Discord (slash commands, notifications, voice),
|
||||
mobile and push, SSO
|
||||
- **Infrastructure** — self-hosted, Docker, prebuilt pull-only images, branding as data, OpenAPI
|
||||
|
||||
Guilds and city governors were added to Game intelligence in phase 3: `module-uo` declares them as
|
||||
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
|
||||
than editorial — see D18.
|
||||
|
||||
**Community is core machinery, but two of its six need a module to fill them.** Teams and Team
|
||||
forums are marked as such (D24). Core owns every part of the Team machinery and cannot create a
|
||||
Team: they arrive from the installed module, so on a deployment with no module the feature is
|
||||
present and permanently empty. The group's summary says so; `/features/` says why.
|
||||
|
||||
### How phase 3 built the homepage
|
||||
|
||||
Three decisions taken before the page was written (org lead, 2026-08-20).
|
||||
|
||||
**D17 — the data path is drawn generically, and captioned specifically.** The diagram's nodes read
|
||||
"your game server", "sidecar", "Runic Gateway", "browser and app", because a reader should not have
|
||||
to know this org's repository layout to understand the picture, and because the tagline promises a
|
||||
platform. It does not hide what ships: the sub-labels and the caption name ServUO and uo-link
|
||||
outright, since there is exactly one implementation of the shape today and §1 says the technical
|
||||
truth wins. Rejected: naming the real components in the nodes (reads as a UO product), and omitting
|
||||
UO entirely (advertises a generality one module proves).
|
||||
|
||||
**D18 — all five groups on the homepage, named only.** Not three with a link out: Integration and
|
||||
Infrastructure carry the module and self-hosted arguments, which are the differentiators, and hiding
|
||||
them until phase 4 would have made the front page look smaller than the product. The per-capability
|
||||
argument stays `/features/`'s job so there is one copy of it.
|
||||
|
||||
The list is **data with a check behind it** (`src/data/capabilities.mjs`). Every Game-intelligence
|
||||
item names the `module-uo` capability slug it comes from, and the build fails if the page and
|
||||
`platform.json` disagree in either direction. Closing that loop needed a fifteenth fact in
|
||||
`checkFacts.mjs`: §12 listed the capability list as an externally-sourced fact and nothing re-read
|
||||
it, so the whole chain rested on someone remembering. Manifest → `platform.json` → page is now
|
||||
checked end to end.
|
||||
|
||||
**D19 — the hero leads with the emblem.** Chosen over a type-only hero: the mark is already the
|
||||
site logo, the Android launcher icon and the Play listing, and showing it large is what makes the
|
||||
three read as one product (D11). It costs what D16 already accepted — raster art a mounted
|
||||
`theme.css` cannot recolour — but every size is derived from whichever `logo.png` is in force
|
||||
(D14), so the hero, the header, the tab icon and the installed icon still change together from one
|
||||
file.
|
||||
|
||||
**A convention, not a decision:** the homepage links the final routes — `/features/`,
|
||||
`/modules/`, `/integrations/` — which phases 4 to 6 have not written yet. The header and footer
|
||||
already did this from phase 1. Nothing is deployed until phase 12, so no visitor meets a 404, and
|
||||
nothing has to be rewritten later. Links *into the documentation* are the exception: they point at
|
||||
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
|
||||
checks.
|
||||
|
||||
### How phase 4 built the marketing pages
|
||||
|
||||
Six decisions taken before coding (org lead, 2026-08-20), plus two scope items the phase table had
|
||||
never assigned to anyone.
|
||||
|
||||
**D20 — `/features/` is the homepage's list with a `detail` line, not a second list.** Every
|
||||
capability in `src/data/capabilities.mjs` gained a sentence or two of argument; `/` renders the
|
||||
label, `/features/` renders the label and the detail. Rejected: slicing the page by reader
|
||||
(players / staff / operators / builders), which reads better but makes the same capability appear
|
||||
twice and breaks the one-to-one mapping the coverage check depends on; and deep-diving only the
|
||||
differentiators, which would have left the page looking smaller than the homepage promised.
|
||||
`assertDetailCoverage()` fails the build on a capability with no detail — the homepage would still
|
||||
look right, and `/features/` would render a heading with nothing under it.
|
||||
|
||||
**D21 — `/architecture/` draws reasons, not reference.** Three new inline SVGs, each drawing one
|
||||
boundary: two hosts and two installs, the public/staff allowlist, and the core/module seam. It
|
||||
carries no endpoint tables, no configuration keys, no schema and no event catalog — phase 8 owns
|
||||
those, they are canonical in `docs/`, and a second copy here is a copy that goes stale (§1).
|
||||
Rejected: reusing the homepage's data-path diagram larger (a visitor arriving from `/` meets the
|
||||
same picture twice), and adding a component/version table (starts becoming the Reference section).
|
||||
The vocabulary the four diagrams now share moved to `src/styles/diagram.css`.
|
||||
|
||||
**D22 — the deliberate absences are one data file.** `src/data/notBuilt.mjs`, each entry tagged with
|
||||
the pages that render it, because the homepage already promises a reader they will find the list on
|
||||
both `/features/` and `/integrations/` and two hand-written copies is how the inconvenient half
|
||||
stops appearing on one of them. Every entry carries a `resolvedBy`: an absence with an exit
|
||||
condition is a position, an absence without one is a hole. That generalises what D8 already required
|
||||
of the Integration Kit's draft badge.
|
||||
|
||||
**D23 — phase 4 absorbs `/community/` and `checkLinks.mjs`.** Neither had a phase. §10 specifies the
|
||||
page and §14 N3 specifies its contents, and the header and footer have linked it since phase 1 — a
|
||||
page the site pointed at that no phase built. `checkLinks.mjs` is specified in §12 and phase 4 is
|
||||
what makes it load-bearing: it roughly quadrupled the internal link count and added the first
|
||||
outbound links into the repositories.
|
||||
|
||||
**D24 — `needsModule`, because "core" and "module-supplied" were not enough.** Writing the
|
||||
`/features/` detail for Teams exposed a claim phase 3 had shipped: the Community group said
|
||||
"everything here works on a deployment with no game module installed at all", and that is false.
|
||||
`teams.module_id` is `NOT NULL` on `website` `main`, there is no create route anywhere under
|
||||
`/api/v1/admin/teams`, and sync is gated on `teamProvider.providerModuleId()`. Core owns the whole
|
||||
Team machinery — tables, roster resolver, forums, notification streams, Discord bridge, voice,
|
||||
activity feed, `/admin/teams` — and deliberately cannot *originate* a Team, because core does not
|
||||
own the word for one. On a bare core the feature is present, correct and permanently empty. Teams
|
||||
and Team forums are marked; the group summary was requalified; the homepage changed by one sentence
|
||||
and kept D18's five named groups. Rejected: a sixth group for Teams (says it loudest, costs the
|
||||
five-group grid phase 3 tuned), and fixing only the wording (leaves the distinction one sentence
|
||||
deep and unguarded).
|
||||
|
||||
**D25 — the demo affordance on `/features/` is a per-capability deep link.** `brand.json` had
|
||||
promised one since phase 2 without defining it. Capabilities with a stable public route carry a link
|
||||
appended to the mounted `demoUrl`; the rest carry nothing, and that asymmetry is honest — a
|
||||
character sheet is reachable only by the account it belongs to, and a Team forum lives behind an id
|
||||
no static page can know. Paths are read from the real route tables on `main`, never guessed, which
|
||||
also means they are the *module's* routes: a deployment running a different module deep-links
|
||||
somewhere else.
|
||||
|
||||
That needed the branding pipeline extended, because the phase-3 slot could not express it. The slot
|
||||
is a literal swap of a whole URL, so it can only ever put the demo's root in an `href`, and
|
||||
reversing it would not even find a deep link — whose `href` is the root plus a path, matching no
|
||||
literal the script knows. `applyBrand.mjs` gained a second pass that **recomputes** all three
|
||||
attributes from the immutable `data-demo-path`, making it idempotent and exactly reversible, and
|
||||
`checkBrand.mjs` gained a guard that lifts the pattern out of `applyBrand.mjs` and runs it against
|
||||
the stock markup, so the two cannot drift. Both directions were proved against a real mount.
|
||||
|
||||
**One thing the checks caught about each other.** A scoped `:has([data-demo-url=''])` rule, added to
|
||||
hide the wrapper around a hidden demo link, made `checkBrand.mjs` fail: it cannot tell a CSS selector
|
||||
from an attribute, and it should not have to. The right fix was to delete the wrapper and let the
|
||||
link be the flex item, so the existing hide rule takes the margin with it — a case where the check
|
||||
being blunt pointed at simpler markup rather than at a needed exemption.
|
||||
|
||||
**And one thing no check caught.** `[data-demo-url=''] { display: none }` is specificity 0,1,0, and
|
||||
so is the scoped class Astro puts on the same element — so a component that sets `display` wins on
|
||||
source order, because component styles are emitted after `global.css`. `/features/`'s `.demo-link`
|
||||
set `display: inline-flex` for its arrow, and twelve links to a demo that does not exist rendered on
|
||||
the page, each pointing at `href=""` — which a browser resolves to the page it is already on.
|
||||
`checkBrand.mjs` was green throughout: the attributes were perfect and the defect was three files
|
||||
away, in the cascade. It was found by looking at the rendered page at 390px, which is not a
|
||||
mechanism, and it is the argument for keeping the live browser pass in every phase. The rule is now
|
||||
`!important` and says why in the stylesheet: while there is no demo these elements do not render,
|
||||
and no component may overrule that by accident.
|
||||
|
||||
### Documentation
|
||||
|
||||
```
|
||||
@@ -535,7 +822,7 @@ Getting started What is Runic Gateway? · Requirements · Install the site ·
|
||||
Verify the whole stack
|
||||
|
||||
Administration Configuration · Branding and theming · Navigation and pages ·
|
||||
Users and roles · Authentication · Teams · Moderation ·
|
||||
Content · Users and roles · Authentication · Teams · Moderation ·
|
||||
Notifications and email · Managing modules ·
|
||||
The shard connection · Maintenance and upgrades · Troubleshooting
|
||||
|
||||
@@ -550,7 +837,7 @@ Reference Environment variables · Installer CLI · sidecar.toml ·
|
||||
Bridge.cfg · HTTP API · Event catalog · Canonical documents
|
||||
```
|
||||
|
||||
Roughly 37 pages. Every Reference page is a **navigable summary plus a link to the canonical
|
||||
Roughly 38 pages — 37 planned, plus the Content page D37 added in phase 7. Every Reference page is a **navigable summary plus a link to the canonical
|
||||
document** — never a re-specification, per §1.
|
||||
|
||||
### The installation path
|
||||
@@ -565,13 +852,85 @@ website; the website is a separate Docker deployment.
|
||||
3. First run — first admin, maintenance → live
|
||||
4. Install a game module — admin panel, `MODULES` env, or by hand
|
||||
5. Connect a game server — the installer binary on the shard host (ServUO-specific today)
|
||||
6. Paste the four values into Admin → Shard — **protocol 4**, per §2
|
||||
6. Paste the four values into **Shard (uo-link)**, `/admin/uo/link` — **protocol 4**, per §2.
|
||||
(Not `/admin/shard`: the screen belongs to the module now, and the installer still prints the
|
||||
old path — see "How phase 7 built the documentation journey" below)
|
||||
7. Verify — `[bridge status` in game, `/health` reporting `plugin_connected: true`, then `doctor`
|
||||
8. Configure authentication and integrations
|
||||
|
||||
Each step states what the operator should expect to see, and links the failure modes to
|
||||
Troubleshooting.
|
||||
|
||||
### How phase 7 built the documentation journey
|
||||
|
||||
Four decisions taken before a page was written (org lead, 2026-08-24), and three things the live
|
||||
site disproved while it was being written.
|
||||
|
||||
**D34 — one PR for all twenty pages.** Twenty, not nineteen: see D37. The alternative on the table
|
||||
was splitting Getting started from Administration so the installation path could land first; the
|
||||
org lead kept the phase whole, as every phase before it has been.
|
||||
|
||||
**D35 — the install page is self-contained.** `/docs/getting-started/install-the-site/` prints a
|
||||
complete Compose file and a complete `.env` that an operator copies without going to another
|
||||
repository first. §1 argues at length against exactly this — it is a second copy of somebody else's
|
||||
file, free to rot — so the copy is not trusted, it is checked. `src/data/quickstart.mjs` holds both
|
||||
files and the page renders them; `scripts/checkQuickstart.mjs` re-reads `website`'s own
|
||||
`docker-compose.yml` and `.env.example` from `main` over the Gitea API and fails the build on any
|
||||
disagreement, in **both** directions:
|
||||
|
||||
- every value the quickstart states must match upstream's;
|
||||
- every service and variable upstream has must be **either included or listed as deliberately
|
||||
omitted, with a reason**, so a new variable in `.env.example` turns this repo red until someone
|
||||
decides whether a first install needs it;
|
||||
- and an entry in either omission list that upstream no longer has fails too, so the lists cannot
|
||||
rot into permanent exemptions.
|
||||
|
||||
Same mechanism and same intent as `checkFacts.mjs`. It caught two stale entries on its first run —
|
||||
`TOTP_ISSUER` and `MODULES`, which are commented *suggestions* upstream rather than keys — which is
|
||||
the check earning its place before the page had shipped.
|
||||
|
||||
**D36 — every Administration screen was walked before it was described.** Not read from source:
|
||||
opened, in a browser, on a real deployment. The rig was the quickstart itself — the exact two files
|
||||
from D35, against the published image — so one run proved the install page and produced the
|
||||
screenshots' worth of detail the admin pages needed. Three of the four defects below came from that
|
||||
walk, and no check could have found any of them.
|
||||
|
||||
**D37 — a thirteenth Administration page.** §10's planned twelve named no home for Posts, Pages,
|
||||
Wiki, Activity, Invites, the Hero editor or Web Bot Activity, all of which are real admin nav rows.
|
||||
Rather than mirror the panel one page per row — which would organise the docs by the app's menu,
|
||||
against this section's own principle — content authoring became one page, **Content**, and the
|
||||
other four folded into the page that already owned their subject: Invites into Users and roles, the
|
||||
Hero editor into Branding and theming, Web Bot Activity into Authentication.
|
||||
|
||||
**What the live deployment disproved.**
|
||||
|
||||
- **The documented Compose deploy does not boot.** `SECRET_ENC_KEY` is required in production —
|
||||
`utils/secretBox.js` throws at require time, so the container crash-loops before it listens — and
|
||||
it is **missing from website's root `.env.example`**, the file Compose actually reads. It is
|
||||
present in `server/.env.example`, which is the file local development copies, which is why this
|
||||
has never bitten anyone in dev. The quickstart carries it, declared as an upstream omission so the
|
||||
check fails the day it is fixed — **fixed in website#163**, which also adds `BOT_INTERNAL_KEY` to
|
||||
the README's "set at least" list for the same reason. When that merges, `checkQuickstart` goes red
|
||||
here by design and the declaration is deleted in a one-line follow-up.
|
||||
- **The installer points operators at a screen that no longer exists.** It prints
|
||||
`<site>/admin/shard`, and INSTALL.md §5 repeats it. Since the module-system cutover a module owns
|
||||
one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. The old path
|
||||
does not even 404 — the SPA sends the operator to the dashboard, so the link looks like it worked
|
||||
and the four values have nowhere to go. **Fixed in installer#22** (the path is a named constant and
|
||||
both handoff tests assert it) **and docs#174**; the journey names the real path and pins the note to
|
||||
v0.1.0, which is what operators download until the next release.
|
||||
- **The admin "Restart the server" button opens a `window.confirm`.** Its text is the honest
|
||||
warning that a deployment with no supervisor does not come back — which is exactly why
|
||||
`restart: unless-stopped` is called out as load-bearing on the install page rather than left as
|
||||
boilerplate.
|
||||
|
||||
**And the fourth defect, the one only a look found — three phases running.** The `.env` block's
|
||||
prose says *every highlighted line must be changed*, and `mark` given the variable **names**
|
||||
highlighted the names alone, leaving the values a reader has to replace unmarked. The build passed,
|
||||
every check passed, and the page was quietly wrong about its own highlighting. Marking the whole
|
||||
`KEY=value` string fixed it. See phase 4 (cascade), phase 5 (literal backticks)
|
||||
and phase 6 (the card void) for the same lesson.
|
||||
|
||||
---
|
||||
|
||||
## 11. Visual direction
|
||||
@@ -636,6 +995,7 @@ a mechanism rather than diligence:
|
||||
| Protocol version | `link` `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` |
|
||||
| Overlay protocol | `servuo-plugins` `main:overlay.toml` → `protocol` |
|
||||
| Module API | `website` `main:server/src/modules/version.js` → `MODULE_API_VERSION` |
|
||||
| Module capability list | `Module-uo` `main:module.json` → `capabilities` (added in phase 3) |
|
||||
| Bundle + component pins | `installer` branch `bundles`, **root** `current.json` |
|
||||
| Release versions | Gitea releases API per repo |
|
||||
|
||||
@@ -648,7 +1008,29 @@ a mechanism rather than diligence:
|
||||
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
||||
mechanism, diligence is not.
|
||||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
||||
`RunicGateway` repo points at a branch path, not a commit permalink.
|
||||
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
||||
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
||||
data files and template literals, and a source scan sees an expression rather than a URL. It runs
|
||||
after the build for that reason, in `verify` and in CI. It fetches nothing — the outbound rule is
|
||||
about the shape of a URL, and a check that fails when someone else's host is slow is a check
|
||||
people learn to ignore.
|
||||
|
||||
It carries one exemption list, `PLANNED_ROUTES`, because §10's convention is that the header,
|
||||
footer and homepage link the *final* routes rather than growing links phase by phase. That is safe
|
||||
only because the list is checked in both directions: a link to a route that is neither built nor
|
||||
listed fails, **and an entry whose route has since been built also fails**, so the list cannot rot
|
||||
into a permanent exemption once the page arrives.
|
||||
|
||||
- **`scripts/checkBrand.mjs`** also guards the demo slot and, since phase 4, the per-capability deep
|
||||
links (D25) — lifting the pattern out of `applyBrand.mjs` and running it against the stock markup,
|
||||
so a template and a script that share no code cannot drift apart. Both are invisible in a stock
|
||||
build, which is exactly why they need a check rather than a look.
|
||||
- **`scripts/checkQuickstart.mjs`** — added in phase 7 for D35. The install page prints a Compose
|
||||
file and an `.env` verbatim, which is the one place this site knowingly copies another repo's
|
||||
file; this re-reads `website` `main:docker-compose.yml` and `main:.env.example` and fails on any
|
||||
disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service
|
||||
or variable that appears upstream fails until it is either included or recorded as deliberately
|
||||
omitted with a reason. Its own first run found two stale entries.
|
||||
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
|
||||
- `astro check` plus a production build, in CI on every PR.
|
||||
|
||||
@@ -662,14 +1044,14 @@ a mechanism rather than diligence:
|
||||
| **1** | Foundation: Astro + Node adapter scaffold, the token file, typography, layout shell, header/footer, docs theming and sidebar, `platform.json` + `checkFacts.mjs` + `checkTokens.mjs` |
|
||||
| **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring |
|
||||
| **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot |
|
||||
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/` |
|
||||
| **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) |
|
||||
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20–D25 |
|
||||
| **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) |
|
||||
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
|
||||
| **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** |
|
||||
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components |
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||||
| **11** | Validation: `astro check`, production build, all four check scripts, mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **11** | Validation: `astro check`, production build, **all six check scripts** (tokens, brand, links, facts, quickstart, data safety), mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||
|
||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||
|
||||
119
PLAY_DATA_SAFETY.md
Normal file
119
PLAY_DATA_SAFETY.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
GENERATED FILE — do not edit.
|
||||
|
||||
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs
|
||||
Generator: scripts/playDataSafety.mjs
|
||||
|
||||
Edit the data file and run `npm run play:datasafety`. CI runs the same
|
||||
generator with --check, so a hand edit here fails the build rather than
|
||||
quietly disagreeing with the published privacy policy.
|
||||
-->
|
||||
|
||||
# Google Play Data Safety — the answers, and what they are based on
|
||||
|
||||
The Play Console asks, for every category of data, whether the app **collects** it, whether it is **shared**, whether collection is **required or optional**, and *why*. This file holds the answers for the Runic Gateway Android app, generated from the same inventory the published privacy policy renders — see `/privacy`, section 2.
|
||||
|
||||
> **This is not a filled-in form.** Play’s definitions change and no check here can read them. Every answer below is a fact about the code with the reasoning attached; read the console’s current wording against them when you fill the form. What this file exists to prevent is somebody answering from memory about what the app stores.
|
||||
|
||||
## The premise every answer rests on
|
||||
|
||||
We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none.
|
||||
|
||||
That is why nearly every answer below is "not collected", and it is also the answer most likely to be questioned in a review. The supporting facts are in the table: each row names the file it was read out of.
|
||||
|
||||
Where the console offers free text about security practices, two things are worth saying: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content** — a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection.
|
||||
|
||||
## Data types
|
||||
|
||||
| Category | Data type | Collected by us | Shared by us | Answer |
|
||||
|---|---|---|---|---|
|
||||
| Personal info | User IDs | No | No | Not collected by us. |
|
||||
| Personal info | User IDs | No | No | Not collected by us. |
|
||||
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
||||
| Messages | Other user-generated content | No | No | Not collected by us. |
|
||||
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||
|
||||
## Each answer, and why it is the truthful one
|
||||
|
||||
### Your sign-in tokens
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
|
||||
When you sign in to a deployment, the app keeps the access and refresh tokens it was issued, plus the username, role and account id they belong to. They are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them.
|
||||
|
||||
- **Why that answer:** The credentials are issued by, and returned to, a server the user nominated. Nothing reaches an endpoint under our control, because we run none.
|
||||
- **Retention:** On the device until you sign out
|
||||
- **In detail:** Signing out clears them; uninstalling the app removes them with it.
|
||||
- **Read from:** `core/auth/EncryptedTokenStore.kt`
|
||||
|
||||
### The trusted-device token, if you asked for one
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
|
||||
Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one.
|
||||
|
||||
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment.
|
||||
- **Retention:** On the device until it expires or you revoke it
|
||||
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost.
|
||||
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt`
|
||||
|
||||
### The address of the deployment you chose
|
||||
|
||||
**App info and performance → Other app data.** Not collected by us. Stored on the device only.
|
||||
|
||||
The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to.
|
||||
|
||||
- **Why that answer:** It never leaves the phone. It is the destination of requests, not the contents of one.
|
||||
- **Retention:** On the device until you change it or uninstall
|
||||
- **Read from:** `core/prefs/ServerPreferences.kt`
|
||||
|
||||
### Push registration, if you turn notifications on
|
||||
|
||||
**Messages → Other in-app messages.** Not collected by us. Declare the relay hop in the console’s free-text security section if it asks.
|
||||
|
||||
Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you.
|
||||
|
||||
- **Why that answer:** The notification passes through a relay chosen by the deployment, and it carries no content — the app pulls the content itself, authenticated. Neither hop reaches a server we operate.
|
||||
- **Retention:** Until you turn push off, sign out, or uninstall
|
||||
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
|
||||
- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt`
|
||||
|
||||
### Everything you read and post in the app
|
||||
|
||||
**Messages → Other user-generated content.** Not collected by us.
|
||||
|
||||
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives.
|
||||
|
||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **Read from:** `PLAN.md §9 section 2`
|
||||
|
||||
### No analytics, no crash reporting, no advertising
|
||||
|
||||
**Device or other IDs → Device or other IDs.** Not collected.
|
||||
|
||||
There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it.
|
||||
|
||||
- **Why that answer:** No advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
|
||||
- **Retention:** Nothing to retain
|
||||
- **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml`
|
||||
|
||||
## The rest of the listing
|
||||
|
||||
- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically.
|
||||
- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification.
|
||||
- **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask.
|
||||
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13).
|
||||
|
||||
## What the website collects, for the same reviewer
|
||||
|
||||
Not part of the Data Safety form — that form is about the app — but a reviewer who follows the privacy policy URL lands on a page covering three things, so it is worth knowing which of them the site itself is responsible for:
|
||||
|
||||
- **Your email address** — Until the beta ends, or until you ask.
|
||||
- **The wording you agreed to, and when** — For the life of the row.
|
||||
- **A one-way hash of your IP address — never the address** — With the row; the rate-limit log is pruned after 48 hours.
|
||||
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
||||
- **The web server’s access log** — Short-term operational retention, then rotated away.
|
||||
|
||||
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
70
README.md
70
README.md
@@ -11,10 +11,11 @@ closed beta: **players**, who want the app.
|
||||
platform state, the org lead's decisions, the information architecture, and the build phases. Read
|
||||
it before changing anything here.
|
||||
|
||||
**Status: phase 1 of 12 — the foundation.** The scaffold, the token file, the typography, the layout
|
||||
shell and the two build-time checks are in place. The homepage is phase 3, the marketing pages
|
||||
phase 4, and the documentation — the installation path, which is the priority of the whole project —
|
||||
phase 7.
|
||||
**Status: phase 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the
|
||||
homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed
|
||||
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next
|
||||
are the legal pages (phase 6) and then the documentation — the installation path, which is the
|
||||
priority of the whole project — in phases 7 and 8.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,9 +40,13 @@ makes that would otherwise decay quietly.
|
||||
|
||||
```bash
|
||||
npm run check:tokens # no colour literal outside the token file
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
npm run check:brand # the branding pipeline's two quiet failures
|
||||
npm run check:datasafety # the Play declaration still matches /privacy
|
||||
npm run check # astro check
|
||||
npm run verify # all of the above, then a production build
|
||||
npm test # the beta signup's decision path, and the policy data
|
||||
npm run build && npm run check:links # every internal link resolves (reads the build)
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
npm run verify # all of the above, in that order
|
||||
```
|
||||
|
||||
**`checkFacts.mjs`** re-reads every version, protocol number and bundle tag in
|
||||
@@ -71,6 +76,44 @@ every literal `/brand/...` URL in the source through the route's own classifier,
|
||||
asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
|
||||
it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
|
||||
|
||||
**`checkLinks.mjs`** reads `dist/client` rather than `src/`, because half the links these pages
|
||||
carry are assembled from data files and template literals and a source scan sees an expression. It
|
||||
also refuses a commit permalink into any org repository — those stop tracking the document they name
|
||||
without ever 404ing, which is the failure a link checker would otherwise call healthy.
|
||||
|
||||
**`npm test`** is the one check that reads none of the above. Everything else inspects built output,
|
||||
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
|
||||
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
|
||||
window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and
|
||||
removal. Run the file by name — `node --test test/` fails on Node 22, which is what CI uses.
|
||||
|
||||
## The closed-beta signup
|
||||
|
||||
`/beta` is the only page that renders per request and the only one that writes anything. It handles
|
||||
its own POST, so the form works with JavaScript disabled and every outcome renders in the real
|
||||
layout. The store is SQLite on the `data/` bind mount; **the raw IP address is never recorded**,
|
||||
only a salted hash used to rate-limit.
|
||||
|
||||
There is no admin page, by design — the tester list is managed from a shell:
|
||||
|
||||
```bash
|
||||
npm run beta -- stats # counts, and where the store lives
|
||||
npm run beta -- export # a CSV record + a .txt to paste into Play; marks rows exported
|
||||
npm run beta -- export -- --all # everything, including already-exported rows
|
||||
npm run beta -- remove someone@example.com
|
||||
```
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `DATA_DIR` | `./data` | The bind mount holding `beta.sqlite` and `exports/` |
|
||||
| `BETA_IP_SALT` | random per process | Salts `ip_hash`. Unset means rate limits reset on restart |
|
||||
| `BETA_FORM_KEY` | random per process | Signs the form token, so a script must fetch the page before posting |
|
||||
| `BETA_TOTAL_CAP` | `500` | Rows above which the form closes and says so |
|
||||
| `BETA_PER_HOUR` / `BETA_PER_DAY` | `3` / `24` | Attempts one connection may make |
|
||||
|
||||
Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make
|
||||
every deployment's hashes identical and therefore reversible by anyone holding this repository.
|
||||
|
||||
## Branding is bind-mounted data
|
||||
|
||||
`brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be
|
||||
@@ -109,19 +152,30 @@ committed so that CI never needs either.
|
||||
```
|
||||
src/
|
||||
data/platform.json Every externally-sourced fact. No version is written in prose.
|
||||
data/collection.mjs What is collected, in three scopes. /privacy renders it and the
|
||||
Play Data Safety notes are generated from it — one inventory.
|
||||
data/legal.mjs The values /terms, /privacy and the consent sentence must share.
|
||||
styles/tokens.css THE token file — the only place a colour literal may appear.
|
||||
styles/global.css The layout shell, built entirely from tokens.
|
||||
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
|
||||
layouts/, components/ The marketing chrome.
|
||||
pages/ Marketing routes.
|
||||
pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
|
||||
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
|
||||
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
|
||||
lib/brand.mjs The single accessor for brand text.
|
||||
lib/brand.mjs The single accessor for brand text, plus liveBrand() for the two
|
||||
routes that render per request and so miss the boot rewrite.
|
||||
lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
|
||||
lib/betaStore.mjs The SQLite store: schema, dedupe, rate-limit window, cap, removal.
|
||||
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
|
||||
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
||||
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
||||
brand-default/ The stock brand, baked into the image and always complete.
|
||||
scripts/ The build-time checks, plus applyBrand (boot) and brand:assets (manual).
|
||||
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
|
||||
and beta.mjs (the tester-list CLI).
|
||||
test/ node --test. The logic the other checks cannot see.
|
||||
PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from
|
||||
src/data/collection.mjs. Edit the data, run npm run play:datasafety.
|
||||
```
|
||||
|
||||
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
||||
|
||||
@@ -28,5 +28,20 @@
|
||||
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
|
||||
"file — no rebuild, consistent with §7."
|
||||
],
|
||||
"demoUrl": ""
|
||||
"demoUrl": "",
|
||||
|
||||
"$comment_beta": [
|
||||
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
|
||||
"exists, and /beta renders a waiting state rather than a broken link while it is.",
|
||||
"",
|
||||
"It is safe to publish once it is filled in, and that is the whole reason the beta can",
|
||||
"work with a site that sends no email (D7): the opt-in link only works for addresses",
|
||||
"already on the tester list, so anyone else who opens it is refused. Google does not",
|
||||
"notify testers on the email-list path either — Discord carries the announcement.",
|
||||
"",
|
||||
"/beta is one of the two routes that render per request, so unlike every other field",
|
||||
"here this one is read from the mounted copy on the NEXT REQUEST rather than at the",
|
||||
"next restart. Paste the URL in and reload the page."
|
||||
],
|
||||
"betaOptInUrl": ""
|
||||
}
|
||||
|
||||
423
package-lock.json
generated
423
package-lock.json
generated
@@ -14,12 +14,14 @@
|
||||
"@fontsource-variable/cinzel": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"astro": "^7.2.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"opentype.js": "^2.0.0",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@@ -3123,6 +3125,26 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcp-47": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz",
|
||||
@@ -3148,12 +3170,70 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -3235,6 +3315,12 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
||||
@@ -3508,6 +3594,30 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
@@ -3703,6 +3813,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -3928,6 +4047,15 @@
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expressive-code": {
|
||||
"version": "0.44.1",
|
||||
"resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz",
|
||||
@@ -4011,6 +4139,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-process": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz",
|
||||
@@ -4064,6 +4198,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -4116,6 +4256,12 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/github-slugger": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||
@@ -4593,12 +4739,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -6164,6 +6336,33 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -6204,6 +6403,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/neotraverse": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz",
|
||||
@@ -6226,6 +6431,18 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.94.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
|
||||
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
@@ -6301,6 +6518,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/oniguruma-parser": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
|
||||
@@ -6547,6 +6773,33 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
@@ -6591,6 +6844,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/radix3": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
||||
@@ -6610,6 +6873,35 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||
@@ -7044,6 +7336,26 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/satteri": {
|
||||
"version": "0.9.5",
|
||||
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz",
|
||||
@@ -7194,6 +7506,51 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
@@ -7274,6 +7631,15 @@
|
||||
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
@@ -7321,6 +7687,15 @@
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -7385,6 +7760,34 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
@@ -7461,6 +7864,18 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typesafe-path": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
|
||||
@@ -8285,6 +8700,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xxhash-wasm": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -17,8 +17,14 @@
|
||||
"check:facts": "node scripts/checkFacts.mjs",
|
||||
"check:tokens": "node scripts/checkTokens.mjs",
|
||||
"check:brand": "node scripts/checkBrand.mjs",
|
||||
"check:links": "node scripts/checkLinks.mjs",
|
||||
"check:datasafety": "node scripts/playDataSafety.mjs --check",
|
||||
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||
"beta": "node scripts/beta.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"verify": "npm run check:tokens && npm run check:brand && npm run check:facts && npm run check && npm run build"
|
||||
"verify": "npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.4",
|
||||
@@ -26,11 +32,13 @@
|
||||
"@fontsource-variable/cinzel": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"astro": "^7.2.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"opentype.js": "^2.0.0",
|
||||
"yaml": "^2.8.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,34 @@ if (demoFrom !== demoTo) {
|
||||
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo's DEEP links (§15 / D25), which `/features/` writes one of per capability that
|
||||
* has a stable public route:
|
||||
*
|
||||
* <a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market">see it live</a>
|
||||
*
|
||||
* The slot above cannot express these. It is a literal string swap of a whole URL, so it
|
||||
* can only ever put the demo's root in an `href` — and reversing it would not even find a
|
||||
* deep link, whose `href` is the root plus a path and therefore matches no literal the
|
||||
* script knows.
|
||||
*
|
||||
* This pass is a different shape on purpose: it does not replace a previous value, it
|
||||
* RECOMPUTES both attributes from `data-demo-path`, which never changes. That makes it
|
||||
* idempotent and exactly reversible, so it runs unconditionally in the loop below rather
|
||||
* than only when the demo URL moved. `data-demo-url` is still filled with the bare root
|
||||
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
|
||||
* both kinds of link, and only the `href` differs.
|
||||
*/
|
||||
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
|
||||
|
||||
const deepLinkTo = (demoPath) => {
|
||||
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
|
||||
return (
|
||||
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
|
||||
`data-demo-path="${demoPath}"`
|
||||
);
|
||||
};
|
||||
|
||||
if (!replacements.length) {
|
||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||
process.exit(0);
|
||||
@@ -198,6 +226,7 @@ function* walk(dir) {
|
||||
}
|
||||
|
||||
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||
counts.set('demoDeep', 0);
|
||||
let filesTouched = 0;
|
||||
|
||||
for (const file of walk(CLIENT)) {
|
||||
@@ -210,6 +239,15 @@ for (const file of walk(CLIENT)) {
|
||||
after = after.split(from).join(to);
|
||||
}
|
||||
|
||||
// After the literal swaps, never before: the plain-slot replacement also matches the
|
||||
// first two attributes of a deep link, so it runs first and this pass corrects the
|
||||
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
|
||||
after = after.replace(DEEP_LINK, (whole, demoPath) => {
|
||||
const rebuilt = deepLinkTo(demoPath);
|
||||
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
|
||||
return rebuilt;
|
||||
});
|
||||
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
filesTouched++;
|
||||
@@ -226,6 +264,11 @@ for (const { field, from, to } of replacements) {
|
||||
if (demoFrom !== demoTo) {
|
||||
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
||||
}
|
||||
if (counts.get('demoDeep')) {
|
||||
console.log(
|
||||
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
|
||||
);
|
||||
}
|
||||
|
||||
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||
|
||||
169
scripts/beta.mjs
Normal file
169
scripts/beta.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* beta.mjs — the closed-beta tester list, from the shell. PLAN.md §8, phase 5.
|
||||
*
|
||||
* node scripts/beta.mjs export → data/exports/<date>.csv, marks rows exported
|
||||
* node scripts/beta.mjs export --all → everything, including already-exported rows
|
||||
* node scripts/beta.mjs remove <email> → a deletion request
|
||||
* node scripts/beta.mjs stats
|
||||
*
|
||||
* In the container, with the compose file of §6:
|
||||
*
|
||||
* docker compose exec site node scripts/beta.mjs export
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS A CLI AND NOT AN ADMIN PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §8 is explicit, and the argument is worth restating where somebody might be tempted to
|
||||
* "improve" it. An authenticated HTTP surface on a marketing site is a login form, a
|
||||
* session, a password to rotate, a lockout policy and a thing to patch — brought into
|
||||
* existence for an operation performed by the one person who already has shell on the host,
|
||||
* against a file already on their disk. Adding it would mean this site had an attack
|
||||
* surface where it currently has none, and the only thing gained is not having to type a
|
||||
* command.
|
||||
*
|
||||
* The CSV lands in the bind mount and is opened locally. Google Play has no API for adding
|
||||
* an individual tester — every route into a closed test ends with a human pasting a list —
|
||||
* so the last step is manual no matter how this is built.
|
||||
*
|
||||
* `remove` exists because §9 promises deletion on request, and a promise with no mechanism
|
||||
* behind it is a sentence.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
EXPORT_DIR,
|
||||
close,
|
||||
markExported,
|
||||
pending,
|
||||
removeSignup,
|
||||
stats,
|
||||
} from '../src/lib/betaStore.mjs';
|
||||
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
const USAGE = `
|
||||
node scripts/beta.mjs export [--all] write a CSV of the tester list
|
||||
node scripts/beta.mjs remove <email> honour a deletion request
|
||||
node scripts/beta.mjs stats counts, and where the store lives
|
||||
`;
|
||||
|
||||
/**
|
||||
* RFC 4180 quoting. Overkill for addresses that have already been validated against a
|
||||
* regex that admits no commas or quotes — and worth having anyway, because the day this
|
||||
* function is wrong is the day somebody pastes a corrupted list into a system that emails
|
||||
* strangers, and nothing about that failure would be visible in the CSV.
|
||||
*/
|
||||
const csvCell = (value) => {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
};
|
||||
|
||||
function doExport(all) {
|
||||
const rows = pending({ all });
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(
|
||||
all
|
||||
? 'Nothing to export — the list is empty.'
|
||||
: 'Nothing new to export. Use --all to re-export rows already marked exported.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(EXPORT_DIR, { recursive: true });
|
||||
|
||||
// Dated rather than sequential, and suffixed only if a second export happens the same
|
||||
// day: the file name should say when the list was taken, because that is the question
|
||||
// being asked when somebody finds three of these in a directory next year.
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
let file = path.join(EXPORT_DIR, `${day}.csv`);
|
||||
for (let n = 2; fs.existsSync(file); n += 1) {
|
||||
file = path.join(EXPORT_DIR, `${day}-${n}.csv`);
|
||||
}
|
||||
|
||||
// Two files, deliberately. Play's tester list wants addresses and nothing else — one per
|
||||
// line, ready to paste — while the CSV is the record: when they signed up, what they
|
||||
// agreed to, what state the row is in. Producing only the CSV would mean hand-editing it
|
||||
// before every paste, which is where a mistake would come from.
|
||||
const csv = [
|
||||
['id', 'email', 'created_at', 'status', 'consent_text'].join(','),
|
||||
...rows.map((row) =>
|
||||
[row.id, row.email, row.created_at, row.status, row.consent_text].map(csvCell).join(',')
|
||||
),
|
||||
].join('\r\n');
|
||||
|
||||
const listFile = file.replace(/\.csv$/, '.txt');
|
||||
fs.writeFileSync(file, `${csv}\r\n`, 'utf8');
|
||||
fs.writeFileSync(listFile, `${rows.map((row) => row.email).join('\n')}\n`, 'utf8');
|
||||
|
||||
const marked = markExported(rows.map((row) => row.id));
|
||||
|
||||
console.log(`Wrote ${rows.length} row(s):`);
|
||||
console.log(` ${file} the record`);
|
||||
console.log(` ${listFile} paste this into Play`);
|
||||
console.log(`Marked ${marked} row(s) exported.`);
|
||||
console.log(
|
||||
'\nPlay Console → Testing → Closed testing → your track → Testers → paste the list.\n' +
|
||||
'Testers still have to open the opt-in link themselves; being on the list is not enough.'
|
||||
);
|
||||
}
|
||||
|
||||
function doRemove(email) {
|
||||
if (!email) {
|
||||
console.error('remove needs an address: node scripts/beta.mjs remove someone@example.com');
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = removeSignup(email.trim().toLowerCase());
|
||||
|
||||
if (result.removed) {
|
||||
console.log(`Removed #${result.id}. The address is overwritten, not just flagged.`);
|
||||
console.log(
|
||||
'If that row was already exported, remove the address from the Play tester list too — ' +
|
||||
'this store is not the only copy once a CSV has been pasted.'
|
||||
);
|
||||
} else if (result.alreadyRemoved) {
|
||||
console.log(`#${result.id} was already removed. Nothing to do.`);
|
||||
} else {
|
||||
console.log('No such address on the list. Nothing to do.');
|
||||
}
|
||||
}
|
||||
|
||||
function doStats() {
|
||||
const s = stats();
|
||||
const rows = [
|
||||
['store', s.path],
|
||||
['total rows', s.total],
|
||||
['new (not yet exported)', s.new],
|
||||
['exported', s.exported],
|
||||
['removed', s.removed],
|
||||
['counting toward the cap', `${s.live} / ${s.cap}`],
|
||||
['attempts, last 24h', s.attemptsLastDay],
|
||||
];
|
||||
|
||||
const width = Math.max(...rows.map(([label]) => label.length));
|
||||
for (const [label, value] of rows) console.log(` ${String(label).padEnd(width)} ${value}`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'export':
|
||||
doExport(rest.includes('--all'));
|
||||
break;
|
||||
case 'remove':
|
||||
doRemove(rest[0]);
|
||||
break;
|
||||
case 'stats':
|
||||
doStats();
|
||||
break;
|
||||
default:
|
||||
console.log(USAGE);
|
||||
process.exitCode = command ? 2 : 0;
|
||||
}
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
@@ -148,6 +148,7 @@ if (brand) {
|
||||
'discordInvite',
|
||||
'giteaOrg',
|
||||
'demoUrl',
|
||||
'betaOptInUrl',
|
||||
];
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
@@ -213,9 +214,175 @@ if (brand) {
|
||||
' be string-replaced. It is handled by the data-attribute gate instead.'
|
||||
);
|
||||
}
|
||||
|
||||
// betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the
|
||||
// only page that reads it renders per request, so it never passes through the boot
|
||||
// rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting
|
||||
// it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches.
|
||||
if (rewritable.includes('betaOptInUrl')) {
|
||||
fail(
|
||||
'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' +
|
||||
' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
4. The demo slot's markup contract (§15 / D12)
|
||||
=======================================================================================
|
||||
|
||||
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
|
||||
attributes in the built HTML. That is a contract between a script and a template that
|
||||
share no code, and it fails in the quietest possible way: an attribute inserted between
|
||||
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
|
||||
set in the mount, the boot log says nothing, and the link is simply never there.
|
||||
|
||||
Both halves are checked, and neither is retyped from memory — the literal is derived from
|
||||
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
|
||||
|
||||
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
|
||||
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
|
||||
: '';
|
||||
|
||||
const attrTemplate = applyForCheck.match(
|
||||
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
|
||||
);
|
||||
|
||||
if (!attrTemplate) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
|
||||
' Update the expected pair below to match, and re-check every template that writes it.'
|
||||
);
|
||||
} else {
|
||||
// What the script will look for when the applied value is the stock empty string.
|
||||
const EMPTY_PAIR = 'href="" data-demo-url=""';
|
||||
|
||||
let slots = 0;
|
||||
const strays = [];
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Comments discuss the contract at length, including in the template that implements
|
||||
// it. Scanning them would make the check fail on its own documentation.
|
||||
// Blanked rather than removed: keeping every newline and every offset means the line
|
||||
// numbers reported below are the ones in the file, not the ones in a shortened copy.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
slots += source.split(EMPTY_PAIR).length - 1;
|
||||
|
||||
for (const match of source.matchAll(/data-demo-url/g)) {
|
||||
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
|
||||
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
|
||||
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slots) {
|
||||
fail(
|
||||
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
|
||||
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
|
||||
' brand.json. Removing it makes that a rebuild.'
|
||||
);
|
||||
}
|
||||
|
||||
for (const site of strays) {
|
||||
fail(
|
||||
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
|
||||
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
|
||||
' the slot will never appear.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
5. The demo DEEP-link contract (§15 / D25)
|
||||
=======================================================================================
|
||||
|
||||
`/features/` links individual capabilities into the demo, which the slot in §4 cannot
|
||||
express — it swaps a whole URL, so it can only ever produce the demo's root. Those links
|
||||
carry a third attribute and `applyBrand.mjs` recomputes all three from it.
|
||||
|
||||
Same failure mode as §4 and the same reason to check it: a template and a script with no
|
||||
shared code, agreeing on an exact byte sequence, where disagreement is silent. This one
|
||||
is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the
|
||||
stock build hides every demo link. It would first appear on the day the org lead sets
|
||||
`demoUrl` and finds the new links pointing at the demo's front page, or at nothing.
|
||||
|
||||
The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run
|
||||
against the stock literal, so this fails if the script's pattern stops matching what the
|
||||
templates write — whichever side moved. */
|
||||
|
||||
const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/);
|
||||
const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" ';
|
||||
let deepLinkCount = 0;
|
||||
|
||||
if (!deepPattern) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' +
|
||||
' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' +
|
||||
' match the new shape rather than deleting it.'
|
||||
);
|
||||
} else {
|
||||
// Does the script's own pattern still match what a template writes in a stock build?
|
||||
const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`;
|
||||
let matches = false;
|
||||
try {
|
||||
matches = new RegExp(deepPattern[1]).test(sample);
|
||||
} catch (error) {
|
||||
fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!matches) {
|
||||
fail(
|
||||
`applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` +
|
||||
' Every per-capability demo link would be left empty and hidden, on a deployment\n' +
|
||||
' that has a demo configured — which is the one place nobody would look.'
|
||||
);
|
||||
}
|
||||
|
||||
const deepStrays = [];
|
||||
let deepLinks = 0;
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Blanked, not stripped — same reason as §4: the line numbers reported have to be the
|
||||
// ones in the file.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
for (const match of source.matchAll(/data-demo-path/g)) {
|
||||
deepLinks++;
|
||||
const start = match.index - EMPTY_DEEP_PREFIX.length;
|
||||
if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) {
|
||||
deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const site of deepStrays) {
|
||||
fail(
|
||||
`${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` +
|
||||
' applyBrand.mjs matches all three attributes together and in that order; anything\n' +
|
||||
' else is invisible to it and the link will never point anywhere.'
|
||||
);
|
||||
}
|
||||
|
||||
deepLinkCount = deepLinks;
|
||||
}
|
||||
|
||||
/* ======================================================================================= */
|
||||
|
||||
if (failures.length) {
|
||||
@@ -227,5 +394,6 @@ if (failures.length) {
|
||||
|
||||
console.log(
|
||||
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
||||
`and every rewritable string is safe to replace.`
|
||||
`every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` +
|
||||
`deep link(s) match their contracts.`
|
||||
);
|
||||
|
||||
@@ -117,7 +117,29 @@ async function checkModuleApi() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The current bundle
|
||||
// 4. The capabilities the installed module actually declares
|
||||
//
|
||||
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
|
||||
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
|
||||
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
|
||||
// asserts at build time that every declared slug is claimed by a named capability on the
|
||||
// page and vice versa. Without this check that assertion was anchored to a local copy
|
||||
// nobody was verifying, so the whole chain rested on someone remembering.
|
||||
//
|
||||
// Sorted before comparing: the manifest's order is the module's business, and a reordered
|
||||
// array is not a changed capability set. A slug appearing or disappearing is.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkModuleCapabilities() {
|
||||
const authority = 'Module-uo main:module.json';
|
||||
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
|
||||
const declared = [...(manifest.capabilities || [])].sort();
|
||||
const expected = [...platform.moduleUoCapabilities].sort();
|
||||
|
||||
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The current bundle
|
||||
//
|
||||
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||
@@ -141,7 +163,7 @@ async function checkBundle() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Release versions, per repo
|
||||
// 6. Release versions, per repo
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkReleases() {
|
||||
for (const [repo, expected] of Object.entries(platform.releases)) {
|
||||
@@ -152,7 +174,7 @@ async function checkReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. `website` still publishes nothing
|
||||
// 7. `website` still publishes nothing
|
||||
//
|
||||
// It ships as container images and is never tagged, so the site refers to the platform by
|
||||
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
||||
@@ -165,7 +187,48 @@ async function checkWebsiteHasNoReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. D13 — the contact address lives in exactly one file
|
||||
// 8. The Android APK `/app/` offers, and the Android version it claims to need
|
||||
//
|
||||
// The app is on no store, so the download block on `/app/` links straight at a release
|
||||
// asset — the one kind of link on this site that 404s the moment a filename changes,
|
||||
// because the filename carries the version. Both asset names are asserted against
|
||||
// releases/latest, so a release that renames or drops either turns this repo red before a
|
||||
// visitor finds a dead link.
|
||||
//
|
||||
// `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a
|
||||
// number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising
|
||||
// the minimum in the app would otherwise leave this site telling people with Android 10
|
||||
// that it works for them. The mapping from API level to the marketing version is a fixed
|
||||
// table, so checking the number is enough to protect the sentence.
|
||||
//
|
||||
// What is NOT checked is `serviceable` — see the comment beside it in platform.json. No
|
||||
// fetch can tell whether a build works, so that value is a person's word, and the site
|
||||
// treats it as the gate on the link rather than the link as the gate on itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkAndroidApk() {
|
||||
const authority = 'Android-app releases/latest assets';
|
||||
const release = await json('Android-app/releases/latest');
|
||||
const names = new Set((release.assets || []).map((asset) => asset.name));
|
||||
|
||||
const apk = platform.androidApk;
|
||||
record(`apk asset`, true, names.has(apk.asset), `${authority} → ${apk.asset}`);
|
||||
record(`apk checksums`, true, names.has(apk.checksums), `${authority} → ${apk.checksums}`);
|
||||
|
||||
// The asset name carries the version, so it has to agree with the release this site
|
||||
// already quotes — a mismatch here means one of the two was updated alone.
|
||||
const tag = String(release.tag_name || '').replace(/^v/, '');
|
||||
record('apk names the release', true, apk.asset.includes(tag), `${authority} → ${release.tag_name}`);
|
||||
|
||||
const gradleAuthority = 'Android-app main:app/build.gradle.kts';
|
||||
const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main');
|
||||
const minSdk = Number(
|
||||
extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority)
|
||||
);
|
||||
record('android minSdk', apk.minSdk, minSdk, gradleAuthority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. D13 — the contact address lives in exactly one file
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONTACT_CHECK = 'contact address (D13)';
|
||||
|
||||
@@ -179,6 +242,25 @@ const SCAN_EXT = new Set([
|
||||
// every commit, and the noreply Gitea uses for the bot identity.
|
||||
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
|
||||
|
||||
/**
|
||||
* Domains reserved by RFC 2606 and RFC 6761 for documentation and examples.
|
||||
*
|
||||
* Phase 5 is what needed this, and the exemption is principled rather than a concession.
|
||||
* The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so
|
||||
* that changing the published address stays a file copy. An `example.com` address cannot be
|
||||
* a contact address — the domain is reserved precisely so that documentation can use it and
|
||||
* it can never route to anybody — so exempting these weakens nothing.
|
||||
*
|
||||
* Without it the rule would have forbidden the signup form's `placeholder="you@example.com"`
|
||||
* and the CLI's usage line, which is the check telling somebody to write a worse page in
|
||||
* order to satisfy a rule about a different problem. A check people have to work around is
|
||||
* one they eventually switch off.
|
||||
*
|
||||
* Matched on the domain, not on the exact address, because these appear with whatever local
|
||||
* part reads best in context.
|
||||
*/
|
||||
const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i;
|
||||
|
||||
async function* walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
@@ -204,6 +286,7 @@ async function checkContactAddressIsIsolated() {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const match of text.matchAll(EMAIL_RE)) {
|
||||
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
|
||||
if (RESERVED_DOMAINS.test(match[0])) continue;
|
||||
const line = text.slice(0, match.index).split('\n').length;
|
||||
offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`);
|
||||
}
|
||||
@@ -244,9 +327,11 @@ async function main() {
|
||||
checkProtocol,
|
||||
checkOverlayProtocol,
|
||||
checkModuleApi,
|
||||
checkModuleCapabilities,
|
||||
checkBundle,
|
||||
checkReleases,
|
||||
checkWebsiteHasNoReleases,
|
||||
checkAndroidApk,
|
||||
];
|
||||
|
||||
for (const check of network) {
|
||||
|
||||
364
scripts/checkLinks.mjs
Normal file
364
scripts/checkLinks.mjs
Normal file
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkLinks.mjs — PLAN.md §12
|
||||
*
|
||||
* Two rules, both of which §12 states and neither of which had a check until phase 4:
|
||||
*
|
||||
* 1. Every internal link resolves.
|
||||
* 2. Every outbound link into a RunicGateway repository points at a BRANCH path, never a
|
||||
* commit permalink.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE BUILD AND NOT THE SOURCE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The obvious implementation greps `href="…"` out of `src/**` and resolves it against the
|
||||
* file tree. It would have missed most of what phase 4 added. Half the links on these pages
|
||||
* are built from data — `capabilityGroups`, `notBuilt.mjs`, a template literal over
|
||||
* `platform.gitea.base` — and a source scan sees an expression rather than a URL. A link
|
||||
* that is wrong in a data file is exactly as broken as one that is wrong in markup, and it
|
||||
* is harder to spot by eye, so it is the one that most needs checking.
|
||||
*
|
||||
* So this runs against `dist/client` after a build, where every link is a real string. The
|
||||
* cost is that the check needs a build first, which is why it sits after `npm run build` in
|
||||
* `verify` and in CI. A stale `dist` would check stale links, and that is the one failure
|
||||
* mode worth knowing about — running it by hand after editing a page means building first.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT DELIBERATELY DOES NOT CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `/brand/*` — those URLs are served by a route that derives them on request from whatever
|
||||
* is mounted, so nothing corresponding exists in `dist/client` to point at. They are not
|
||||
* unchecked: `scripts/checkBrand.mjs` already resolves every one of them against that
|
||||
* route's own allowlist, which is a stronger check than file existence.
|
||||
*
|
||||
* Off-site URLs are not fetched. A build that fails because gnu.org is slow is a build
|
||||
* that teaches people to ignore this check. The one outbound rule here is about the SHAPE
|
||||
* of a URL, which is decidable without the network.
|
||||
*
|
||||
* In-page fragments (`#main`) are not resolved against the ids on the page. It would be a
|
||||
* fair check to add; it is not one §12 asks for, and the site has exactly one of them.
|
||||
*
|
||||
* node scripts/checkLinks.mjs [--dist <path>]
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const distArg = process.argv.indexOf('--dist');
|
||||
const DIST =
|
||||
distArg !== -1 && process.argv[distArg + 1]
|
||||
? path.resolve(process.argv[distArg + 1])
|
||||
: path.join(ROOT, 'dist', 'client');
|
||||
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
/** `gitea.whitlocktech.com`, from the same place every page reads it. */
|
||||
const GITEA_HOST = new URL(platform.gitea.base).host;
|
||||
|
||||
/**
|
||||
* Prefixes served by a route rather than by a file in the build. A link starting with one
|
||||
* of these is somebody else's check — see the header.
|
||||
*/
|
||||
const RUNTIME_PREFIXES = ['/brand/'];
|
||||
|
||||
/**
|
||||
* Pages that render per request, and therefore have no file in `dist/client` to resolve
|
||||
* against — discovered from the source rather than listed here.
|
||||
*
|
||||
* Phase 5 is what made this necessary. Until then the only on-demand route was `/brand/*`,
|
||||
* which is an asset route with its own checker and is skipped by prefix above; `/beta/` is
|
||||
* the first on-demand PAGE, and it is linked from `/app/`, the header and the footer like
|
||||
* any other. Rule 1 read `dist/client`, saw nothing at `beta/index.html`, and failed a link
|
||||
* that is perfectly good.
|
||||
*
|
||||
* The tempting fix — an entry in `PLANNED_ROUTES` — would be wrong, and wrong in the exact
|
||||
* way that list's own comment warns about. 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. It
|
||||
* would become the permanent exemption the two-way check exists to prevent.
|
||||
*
|
||||
* So the route is derived instead: a file under `src/pages/` that exports `prerender =
|
||||
* false` IS an on-demand route, and its path maps to a URL by Astro's own file-routing
|
||||
* rules. That is a fact about the source, checkable at the same moment, and it cannot go
|
||||
* stale — delete `beta.astro` and the links to `/beta/` start failing again immediately,
|
||||
* which is the behaviour rule 1 is there to provide.
|
||||
*
|
||||
* Dynamic segments (`[...file].ts`) are deliberately not handled: the only one is the brand
|
||||
* route, already covered by prefix, and inventing a matcher for a case that does not exist
|
||||
* would be guessing at a shape nobody has written yet.
|
||||
*/
|
||||
async function findOnDemandRoutes() {
|
||||
const pagesDir = path.join(ROOT, 'src', 'pages');
|
||||
const routes = new Set();
|
||||
|
||||
for await (const file of walk(pagesDir, ['.astro', '.ts', '.js'])) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue;
|
||||
|
||||
const relative = path.relative(pagesDir, file).split(path.sep).join('/');
|
||||
if (relative.includes('[')) continue;
|
||||
|
||||
const withoutExt = relative.replace(/\.(astro|ts|js)$/, '');
|
||||
const name = withoutExt.replace(/(^|\/)index$/, '');
|
||||
routes.add(name ? `/${name}/` : '/');
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the site links today that a later phase builds.
|
||||
*
|
||||
* This exists because of a convention phase 3 recorded and phase 1 started: the header,
|
||||
* the footer and the homepage link the FINAL routes of §10 rather than growing links phase
|
||||
* by phase. Nothing is deployed until phase 12, so no visitor ever meets one of these
|
||||
* 404s, and no page has to be revisited later to add a link that was always going to be
|
||||
* there. That convention and rule 1 of this check are in direct tension, and this is where
|
||||
* the tension is resolved — explicitly, with a phase against each entry, rather than by
|
||||
* weakening the rule.
|
||||
*
|
||||
* It is self-cleaning in both directions, which is the only reason it is safe to have:
|
||||
*
|
||||
* - a link to a route that is neither built nor listed here FAILS, so the list cannot be
|
||||
* used by accident;
|
||||
* - an entry here whose route HAS since been built also fails, so the list cannot rot
|
||||
* into a permanent exemption after the page arrives.
|
||||
*
|
||||
* Adding to it is a deliberate act. If a route is not in §10, it does not belong here.
|
||||
*/
|
||||
const PLANNED_ROUTES = new Map([
|
||||
// Empty as of phase 6, which built `/privacy/` and `/terms/` — the last two routes §10
|
||||
// named that no page served. The Map stays because §10 is not finished: phases 7 and 8
|
||||
// add the documentation journey, and the convention above (link the final route, not the
|
||||
// route that exists today) is what the list exists to make safe.
|
||||
//
|
||||
// An empty list is not a dormant one. Rule 3 below still runs, so adding an entry for a
|
||||
// route that has since been built fails immediately rather than sitting here unread.
|
||||
]);
|
||||
|
||||
/** Planned routes actually linked from somewhere, so the reverse check can be reported. */
|
||||
const plannedSeen = new Set();
|
||||
|
||||
const failures = [];
|
||||
let linksChecked = 0;
|
||||
let outboundChecked = 0;
|
||||
|
||||
function fail(file, line, message) {
|
||||
failures.push({ file, line, message });
|
||||
}
|
||||
|
||||
async function* walk(dir, extensions = ['.html']) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full, extensions);
|
||||
else if (extensions.includes(path.extname(entry.name))) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
||||
|
||||
/**
|
||||
* Does a site-absolute path correspond to something the build will serve?
|
||||
*
|
||||
* Astro is configured with `format: 'directory'`, so `/features/` is
|
||||
* `dist/client/features/index.html`. The other shapes are accepted because a route can
|
||||
* legitimately be a file — `/manifest.webmanifest` is one, and `/404.html` is another.
|
||||
*/
|
||||
function resolvesInBuild(pathname) {
|
||||
const clean = pathname.replace(/[?#].*$/, '');
|
||||
const relative = decodeURIComponent(clean).replace(/^\/+/, '');
|
||||
const base = path.join(DIST, relative);
|
||||
|
||||
const candidates = [
|
||||
path.join(base, 'index.html'),
|
||||
`${base.replace(/[\\/]+$/, '')}.html`,
|
||||
base.replace(/[\\/]+$/, ''),
|
||||
];
|
||||
|
||||
return candidates.some((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
// A bare directory that has no index.html is not a page anybody can open.
|
||||
return statSync(candidate).isFile();
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(DIST)) {
|
||||
console.error(
|
||||
`\ncheckLinks: no build at ${path.relative(ROOT, DIST)}.\n\n` +
|
||||
' This check reads the built HTML rather than the source, so that links written by\n' +
|
||||
' data files and template literals are checked as the strings they become. Run\n' +
|
||||
' `npm run build` first — `npm run verify` already does.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const onDemandRoutes = await findOnDemandRoutes();
|
||||
|
||||
/* =======================================================================================
|
||||
1. Internal links resolve
|
||||
======================================================================================= */
|
||||
|
||||
for await (const file of walk(DIST)) {
|
||||
const relative = path.relative(ROOT, file);
|
||||
const source = readFileSync(file, 'utf8');
|
||||
|
||||
for (const match of source.matchAll(/(?:href|src)="([^"]*)"/g)) {
|
||||
const value = match[1];
|
||||
|
||||
// Off-site, protocol-relative, and the non-navigational schemes. `mailto:` addresses
|
||||
// are checkFacts.mjs's business (D13) and are not links to anywhere on this site.
|
||||
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value)) continue;
|
||||
|
||||
// Fragments and query-only links stay on the page they are already on.
|
||||
if (!value || value.startsWith('#') || value.startsWith('?')) continue;
|
||||
|
||||
// Relative links. Astro emits site-absolute paths for everything the site itself
|
||||
// writes; a relative one is almost certainly a mistake, but resolving it correctly
|
||||
// needs the emitting page's directory, so it is reported rather than guessed at.
|
||||
if (!value.startsWith('/')) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`relative link "${value}" — write it site-absolute, starting with "/", so it means ` +
|
||||
`the same thing from every page that renders the component`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RUNTIME_PREFIXES.some((prefix) => value.startsWith(prefix))) continue;
|
||||
|
||||
// The demo slot and its deep links ship empty and hidden in a stock build (§15/D25);
|
||||
// `href=""` is the contract, not a broken link. checkBrand.mjs owns their shape.
|
||||
if (value === '') continue;
|
||||
|
||||
linksChecked++;
|
||||
|
||||
if (resolvesInBuild(value)) continue;
|
||||
|
||||
// A page that renders per request has no file to find. Checked here rather than as a
|
||||
// prefix skip, so an on-demand route still has to EXIST — see findOnDemandRoutes.
|
||||
if (onDemandRoutes.has(value.replace(/[?#].*$/, ''))) continue;
|
||||
|
||||
const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, ''));
|
||||
if (planned) {
|
||||
plannedSeen.add(value.replace(/[?#].*$/, ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`"${value}" does not resolve — nothing in the build serves it.\n` +
|
||||
` If a later phase builds it, add it to PLANNED_ROUTES in this script with the\n` +
|
||||
` phase that does. If not, the link is wrong.`
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
2. Outbound repository links point at a branch, not a commit
|
||||
=====================================================================================
|
||||
|
||||
§12's rule, and the reason for it: a commit permalink is a fact frozen at a sha while
|
||||
the document it names keeps moving. Every link on this site into one of these
|
||||
repositories is meant to show a reader the CURRENT state of something — the module
|
||||
contract, the operator guide, the protocol — and a permalink quietly stops doing that
|
||||
the day after it is written, without ever 404ing. It is the failure mode a link
|
||||
checker would otherwise call healthy.
|
||||
|
||||
Gitea writes both shapes as `/<owner>/<repo>/src/<kind>/<ref>/…`, so the kind segment
|
||||
is what decides it, and a 40-character hex ref is caught even when the kind segment
|
||||
says branch — which is what a "branch" named after a sha actually is. */
|
||||
|
||||
for (const match of source.matchAll(/https?:\/\/[^\s"'<>)]+/g)) {
|
||||
const raw = match[1] ?? match[0];
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.host !== GITEA_HOST) continue;
|
||||
|
||||
outboundChecked++;
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
// <owner>/<repo>/<kind>/<refkind>/<ref>/…
|
||||
const kind = segments[2];
|
||||
const refKind = segments[3];
|
||||
const ref = segments[4];
|
||||
|
||||
if (!['src', 'raw', 'media'].includes(kind)) continue;
|
||||
|
||||
if (refKind === 'commit' || refKind === 'tag') {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n points at a ${refKind}, not a branch. §12 requires branch paths, so a ` +
|
||||
`reader always\n sees the document as it is now rather than as it was.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n names a commit sha as its ref. Use a branch name — "main" for anything ` +
|
||||
`canonical.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
3. The planned-route list has not rotted
|
||||
=======================================================================================
|
||||
|
||||
The half that makes an exemption list safe. Once a phase builds one of these, the entry
|
||||
stops being a promise and starts being a hole in rule 1 — so the build fails until it is
|
||||
deleted. Reported per route, with the phase that was waiting for it, because the person
|
||||
who just built the page is the person who should remove the line. */
|
||||
|
||||
const selfSource = readFileSync(path.join(ROOT, 'scripts/checkLinks.mjs'), 'utf8');
|
||||
|
||||
for (const [route, owner] of PLANNED_ROUTES) {
|
||||
if (!resolvesInBuild(route)) continue;
|
||||
const entry = selfSource.indexOf(`['${route}'`);
|
||||
fail(
|
||||
'scripts/checkLinks.mjs',
|
||||
entry === -1 ? 1 : lineOf(selfSource, entry),
|
||||
`PLANNED_ROUTES still lists "${route}" (${owner}), but the build now serves it.\n` +
|
||||
` Delete the entry: every link to it is checked properly from here on.`
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\ncheckLinks: broken or non-canonical links.\n');
|
||||
for (const failure of failures) {
|
||||
console.error(` ${failure.file}:${failure.line}\n ${failure.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pending = [...plannedSeen].sort();
|
||||
|
||||
console.log(
|
||||
`checkLinks: ${linksChecked} internal link(s) resolve and ${outboundChecked} repository ` +
|
||||
`link(s) point at a branch.`
|
||||
);
|
||||
|
||||
if (pending.length) {
|
||||
console.log(
|
||||
` ${pending.length} link(s) point at a planned route: ` +
|
||||
`${pending.join(', ')} — allowed until the phase that builds it.`
|
||||
);
|
||||
}
|
||||
216
scripts/checkQuickstart.mjs
Normal file
216
scripts/checkQuickstart.mjs
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkQuickstart.mjs — PLAN.md §12, added in phase 7 for D35.
|
||||
*
|
||||
* The org lead chose a self-contained quickstart: `/docs/getting-started/install-the-site/`
|
||||
* prints a Compose file and an environment file the reader can copy without going to
|
||||
* another repository first. That is the one place this site knowingly keeps a copy of
|
||||
* somebody else's file, and §1 is a long argument about why copies rot.
|
||||
*
|
||||
* So the copy is checked rather than trusted. Every service, image, published port, mount
|
||||
* and environment key in `src/data/quickstart.mjs` is re-read from `website`'s own
|
||||
* `docker-compose.yml` and `.env.example` on `main`, over the Gitea API — never from a
|
||||
* working tree, per §1's process rule — and any disagreement fails the build.
|
||||
*
|
||||
* It checks in BOTH directions, which is the property that keeps it honest:
|
||||
*
|
||||
* - every value the quickstart states must match upstream's;
|
||||
* - every service and variable upstream has must be either included or listed as
|
||||
* deliberately omitted, WITH a reason. A new variable in `.env.example` therefore turns
|
||||
* this repo red until someone decides whether a first install needs it — the same
|
||||
* intent as checkFacts.mjs and the Integration Kit's checkCoreApi.js;
|
||||
* - and an entry in either omission list that upstream no longer has fails too, so the
|
||||
* lists cannot rot into permanent exemptions.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkQuickstart.mjs
|
||||
*
|
||||
* Anonymous raw fetches fail on this instance, so the token is required. A check that
|
||||
* silently skips itself is worse than no check.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
import {
|
||||
compose,
|
||||
services,
|
||||
omittedServices,
|
||||
env,
|
||||
envOmitted,
|
||||
notInUpstreamEnvExample,
|
||||
} from '../src/data/quickstart.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
const BASE = platform.gitea.base;
|
||||
const ORG = platform.gitea.org;
|
||||
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||
|
||||
const failures = [];
|
||||
const checked = [];
|
||||
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
|
||||
async function raw(repo, filePath, ref) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* `KEY=value` lines from a dotenv file. Commented-out suggestions (`# MODULES=…`) are NOT
|
||||
* keys: they are prose about a variable, and treating them as declared would make the
|
||||
* omission list argue with documentation rather than with configuration.
|
||||
*/
|
||||
function envKeys(text) {
|
||||
const out = new Map();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) out.set(m[1], m[2].replace(/\s+#.*$/, '').trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Published host:container port pairs, as written. */
|
||||
const portsOf = (svc) => (svc.ports ?? []).map(String);
|
||||
|
||||
/** Container-side paths of every volume entry, which is what a reader's site depends on. */
|
||||
const mountTargets = (svc) => (svc.volumes ?? []).map((v) => String(v).split(':')[1]);
|
||||
|
||||
async function run() {
|
||||
if (!TOKEN) {
|
||||
console.error('checkQuickstart: GITEA_TOKEN is not set. This check cannot run anonymously.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const upstreamComposeText = await raw('website', 'docker-compose.yml', 'main');
|
||||
const upstreamEnvText = await raw('website', '.env.example', 'main');
|
||||
|
||||
const upstream = parseYaml(upstreamComposeText);
|
||||
const ours = parseYaml(compose);
|
||||
|
||||
if (!upstream?.services) throw new Error('website main:docker-compose.yml has no services block — the file shape changed.');
|
||||
|
||||
// ── 1. The services we ship ───────────────────────────────────────────────
|
||||
for (const name of services) {
|
||||
const mine = ours.services?.[name];
|
||||
const theirs = upstream.services?.[name];
|
||||
if (!mine) { fail(`service ${name}`, 'declared in quickstart.mjs but absent from its own compose text'); continue; }
|
||||
if (!theirs) { fail(`service ${name}`, 'no longer exists in website main:docker-compose.yml'); continue; }
|
||||
|
||||
if (String(mine.image) !== String(theirs.image)) {
|
||||
fail(`service ${name}: image`, `quickstart "${mine.image}" vs upstream "${theirs.image}"`);
|
||||
} else ok(`service ${name}: image`);
|
||||
|
||||
const minePorts = portsOf(mine).join(', ');
|
||||
const theirPorts = portsOf(theirs).join(', ');
|
||||
if (minePorts !== theirPorts) {
|
||||
fail(`service ${name}: ports`, `quickstart [${minePorts}] vs upstream [${theirPorts}]`);
|
||||
} else ok(`service ${name}: ports`);
|
||||
|
||||
// Every mount we keep must land where upstream lands it. Upstream may have mounts we
|
||||
// dropped (the schema bind, which needs a checkout); dropping one is safe, moving one
|
||||
// is not.
|
||||
for (const target of mountTargets(mine)) {
|
||||
if (!mountTargets(theirs).includes(target)) {
|
||||
fail(`service ${name}: mount ${target}`, 'upstream mounts nothing at that container path');
|
||||
} else ok(`service ${name}: mount ${target}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(mine.environment ?? {})) {
|
||||
const theirValue = theirs.environment?.[key];
|
||||
if (theirValue === undefined) {
|
||||
fail(`service ${name}: ${key}`, 'upstream no longer sets it in the compose file');
|
||||
} else if (String(theirValue) !== String(value)) {
|
||||
fail(`service ${name}: ${key}`, `quickstart "${value}" vs upstream "${theirValue}"`);
|
||||
} else ok(`service ${name}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. The services we left out, and any that appeared ────────────────────
|
||||
const upstreamServiceNames = Object.keys(upstream.services);
|
||||
for (const [name, reason] of Object.entries(omittedServices)) {
|
||||
if (!upstreamServiceNames.includes(name)) {
|
||||
fail(`omitted service ${name}`, 'upstream no longer has this service — drop it from omittedServices');
|
||||
} else if (!reason?.trim()) {
|
||||
fail(`omitted service ${name}`, 'listed without a reason');
|
||||
} else ok(`omitted service ${name}`);
|
||||
}
|
||||
for (const name of upstreamServiceNames) {
|
||||
if (!services.includes(name) && !(name in omittedServices)) {
|
||||
fail(`service ${name}`, 'is new in website main:docker-compose.yml — include it in the quickstart or record why not');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. The environment file ───────────────────────────────────────────────
|
||||
const theirEnv = envKeys(upstreamEnvText);
|
||||
const mineEnv = new Map(env.map((e) => [e.key, e]));
|
||||
|
||||
for (const entry of env) {
|
||||
const theirValue = theirEnv.get(entry.key);
|
||||
const excused = notInUpstreamEnvExample[entry.key];
|
||||
|
||||
if (theirValue === undefined) {
|
||||
if (excused) {
|
||||
ok(`env ${entry.key} (absent upstream, declared: ${excused})`);
|
||||
} else {
|
||||
fail(`env ${entry.key}`, 'not in website main:.env.example — either it is gone, or it needs a reason in notInUpstreamEnvExample');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excused) {
|
||||
fail(
|
||||
`env ${entry.key}`,
|
||||
'is now in website main:.env.example — remove it from notInUpstreamEnvExample, and re-read the prose that describes it as missing',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A value an operator is told to replace is a placeholder on both sides; comparing two
|
||||
// placeholders would only ever assert that two people picked the same filler words.
|
||||
if (!entry.fill && theirValue !== String(entry.value)) {
|
||||
fail(`env ${entry.key}`, `quickstart "${entry.value}" vs upstream "${theirValue}"`);
|
||||
} else ok(`env ${entry.key}`);
|
||||
}
|
||||
|
||||
for (const [key, reason] of Object.entries(envOmitted)) {
|
||||
if (!theirEnv.has(key)) {
|
||||
fail(`omitted env ${key}`, 'upstream .env.example no longer sets it — drop it from envOmitted');
|
||||
} else if (!reason?.trim()) {
|
||||
fail(`omitted env ${key}`, 'listed without a reason');
|
||||
} else ok(`omitted env ${key}`);
|
||||
}
|
||||
|
||||
for (const key of theirEnv.keys()) {
|
||||
if (!mineEnv.has(key) && !(key in envOmitted)) {
|
||||
fail(`env ${key}`, 'is new in website main:.env.example — add it to the quickstart or record why a first install does not need it');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkQuickstart: ${checked.length} checks passed against website main.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`checkQuickstart: ${failures.length} disagreement(s) with website main:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(
|
||||
'\nThe quickstart on /docs/getting-started/install-the-site/ is a copy of website\'s own\n'
|
||||
+ 'deployment files (D35). Either update src/data/quickstart.mjs to match, or record the\n'
|
||||
+ 'difference with a reason. Do not "fix" the check.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`checkQuickstart: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
231
scripts/playDataSafety.mjs
Normal file
231
scripts/playDataSafety.mjs
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* playDataSafety.mjs — PLAN.md §9, phase 6 (D33).
|
||||
*
|
||||
* Writes `PLAY_DATA_SAFETY.md`: the answers to Google Play's Data Safety form, generated
|
||||
* from the same `src/data/collection.mjs` rows that `/privacy` section 2 renders.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS GENERATED AND CHECKED RATHER THAN WRITTEN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 says the declaration is "filled from section 2, and section 2 is written knowing that
|
||||
* is what it is for". Two documents describing the same code drift — that is the premise of
|
||||
* `capabilities.mjs` (D18) and `notBuilt.mjs` (D22) — and this pair drifts worse than
|
||||
* either, because one half is a published legal page and the other is a form at Google that
|
||||
* cannot be corrected without a review round. The app gaining a crash reporter must not be
|
||||
* able to leave a "not collected" answer standing in a file nobody re-reads.
|
||||
*
|
||||
* So the markdown is an output, not a source. `--check` recomputes it and fails if the
|
||||
* committed copy differs, which is what puts it in `verify` and in CI: editing the doc by
|
||||
* hand fails the build and names the data file to edit instead.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS DOCUMENT IS NOT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It is not a filled-in form and it does not claim to know Play's current definitions.
|
||||
* Play's testing and disclosure requirements have changed more than once — §8 says so, and
|
||||
* `playPolicy.verifiedOn` exists for the same reason — and there is no API to read them
|
||||
* from. What this generates is the FACTS, arranged as the console arranges its questions,
|
||||
* with the answer each fact supports and why. Whoever fills the form reads the console's
|
||||
* own definitions against these, which is a job for a person; what they must never do is
|
||||
* answer from memory about what the app stores.
|
||||
*
|
||||
* node scripts/playDataSafety.mjs # write PLAY_DATA_SAFETY.md
|
||||
* node scripts/playDataSafety.mjs --check # fail if the committed copy is stale
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { collectedIn, playRows } from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const OUT = path.join(ROOT, 'PLAY_DATA_SAFETY.md');
|
||||
const CHECK = process.argv.includes('--check');
|
||||
|
||||
const GENERATOR = 'scripts/playDataSafety.mjs';
|
||||
|
||||
/** Cell text: the table is markdown, so a pipe would end the column early. */
|
||||
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
|
||||
|
||||
const yesNo = (value) => (value ? 'Yes' : 'No');
|
||||
|
||||
function render() {
|
||||
const rows = playRows();
|
||||
const site = collectedIn('site');
|
||||
|
||||
const lines = [];
|
||||
|
||||
lines.push('<!--');
|
||||
lines.push(' GENERATED FILE — do not edit.');
|
||||
lines.push('');
|
||||
lines.push(` Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs`);
|
||||
lines.push(` Generator: ${GENERATOR}`);
|
||||
lines.push('');
|
||||
lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same');
|
||||
lines.push(' generator with --check, so a hand edit here fails the build rather than');
|
||||
lines.push(' quietly disagreeing with the published privacy policy.');
|
||||
lines.push('-->');
|
||||
lines.push('');
|
||||
lines.push('# Google Play Data Safety — the answers, and what they are based on');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'The Play Console asks, for every category of data, whether the app **collects** it, ' +
|
||||
'whether it is **shared**, whether collection is **required or optional**, and *why*. ' +
|
||||
'This file holds the answers for the Runic Gateway Android app, generated from the ' +
|
||||
'same inventory the published privacy policy renders — see `/privacy`, section 2.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'> **This is not a filled-in form.** Play’s definitions change and no check here can ' +
|
||||
'read them. Every answer below is a fact about the code with the reasoning attached; ' +
|
||||
'read the console’s current wording against them when you fill the form. What this ' +
|
||||
'file exists to prevent is somebody answering from memory about what the app stores.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* ---------------------------------------------------------------------------------
|
||||
The one answer that shapes every other one.
|
||||
--------------------------------------------------------------------------------- */
|
||||
lines.push('## The premise every answer rests on');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'We operate **no server the app talks to.** The app ships pointed at nothing: its ' +
|
||||
'first screen asks for the address of a Runic Gateway deployment and validates it ' +
|
||||
'before anything else in the app runs. That deployment belongs to whoever runs that ' +
|
||||
'community. Data therefore travels from the device to *their* server, and there is ' +
|
||||
'no endpoint of ours anywhere in the path — not for content, not for telemetry, and ' +
|
||||
'not for crash reports, of which there are none.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'That is why nearly every answer below is "not collected", and it is also the answer ' +
|
||||
'most likely to be questioned in a review. The supporting facts are in the table: ' +
|
||||
'each row names the file it was read out of.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'Where the console offers free text about security practices, two things are worth ' +
|
||||
'saying: credentials are held in Android’s encrypted storage (AES-256-GCM via ' +
|
||||
'Jetpack Security), and push notifications carry **no content** — a relay receives a ' +
|
||||
'stream name and a reference, and the app fetches the actual message over its own ' +
|
||||
'authenticated connection.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## Data types');
|
||||
lines.push('');
|
||||
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
`| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` +
|
||||
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Each answer, and why it is the truthful one');
|
||||
lines.push('');
|
||||
for (const row of rows) {
|
||||
lines.push(`### ${row.title}`);
|
||||
lines.push('');
|
||||
lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`);
|
||||
lines.push('');
|
||||
lines.push(cell(row.body));
|
||||
lines.push('');
|
||||
lines.push(`- **Why that answer:** ${cell(row.play.because)}`);
|
||||
lines.push(`- **Retention:** ${cell(row.retention.summary)}`);
|
||||
if (row.retention.detail) lines.push(`- **In detail:** ${cell(row.retention.detail)}`);
|
||||
lines.push(`- **Read from:** \`${row.source}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## The rest of the listing');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`- **Privacy policy URL:** \`/privacy\` on this site. It is the URL Play is given, and ` +
|
||||
'section 2 of it is about the app specifically.'
|
||||
);
|
||||
lines.push(
|
||||
`- **Target audience:** adults. The beta is stated as **${legal.minimumAge} or older** ` +
|
||||
'(D31); the app contains no content directed at children and no age verification.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Account deletion:** the app creates no account with us — an account belongs to ' +
|
||||
'the deployment the user chose, and is deleted there. The only list we hold is the ' +
|
||||
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Data deletion request URL:** the contact address published on `/privacy`, which ' +
|
||||
'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## What the website collects, for the same reviewer');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'Not part of the Data Safety form — that form is about the app — but a reviewer who ' +
|
||||
'follows the privacy policy URL lands on a page covering three things, so it is ' +
|
||||
'worth knowing which of them the site itself is responsible for:'
|
||||
);
|
||||
lines.push('');
|
||||
for (const row of site) {
|
||||
lines.push(`- **${cell(row.title)}** — ${cell(row.retention.summary)}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`Last generated from data dated ${legal.lastUpdated}. Regenerate with ` +
|
||||
'`npm run play:datasafety` after any change to what the app stores.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const rendered = render();
|
||||
|
||||
if (!CHECK) {
|
||||
writeFileSync(OUT, rendered, 'utf8');
|
||||
console.log(`playDataSafety: wrote ${path.relative(ROOT, OUT)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Line endings are normalised before comparing, and that is not fussiness.
|
||||
*
|
||||
* The repository has no `.gitattributes` and Windows checkouts run with
|
||||
* `core.autocrlf=true`, so this file is stored with LF and lands on a Windows disk with
|
||||
* CRLF. A byte comparison would then fail for every developer on Windows while passing in
|
||||
* CI — the worst shape a check can have, because the fix people reach for is to stop
|
||||
* running it. What is being asserted is that the CONTENT agrees, and a line ending is not
|
||||
* content.
|
||||
*/
|
||||
const normalise = (text) => text.split('\r\n').join('\n');
|
||||
|
||||
let committed = null;
|
||||
try {
|
||||
committed = readFileSync(OUT, 'utf8');
|
||||
} catch {
|
||||
/* handled below */
|
||||
}
|
||||
|
||||
if (committed !== null && normalise(committed) === normalise(rendered)) {
|
||||
console.log('playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\nplayDataSafety: ${path.relative(ROOT, OUT)} is ${committed === null ? 'missing' : 'stale'}.\n\n` +
|
||||
' It is generated from src/data/collection.mjs — the same rows /privacy renders —\n' +
|
||||
' so that the published policy and the Data Safety declaration cannot disagree\n' +
|
||||
' (§9, D33). Run:\n\n' +
|
||||
' npm run play:datasafety\n\n' +
|
||||
' and commit the result. If the change came from editing the markdown by hand,\n' +
|
||||
' make it in the data file instead: the page has to move with it.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
@@ -7,9 +8,10 @@ import platform from '../data/platform.json';
|
||||
* memory. The version chip reads `platform.json` (§12); the contact address and the links
|
||||
* read `brand.json` (§7, D13).
|
||||
*
|
||||
* `/privacy` and `/terms` are linked from every page (§9) — those pages land in phase 6,
|
||||
* which is why they are the only two entries deliberately left out of the columns below
|
||||
* until then.
|
||||
* `/privacy` and `/terms` are linked from every page (§9). Phase 6 built them and put them
|
||||
* in the legal bar at the foot rather than in the columns: a legal link is not a thing a
|
||||
* reader browses to alongside Features, it is a thing they go looking for, and the line
|
||||
* that already carries the licence and the copyright is where people look.
|
||||
*/
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
@@ -71,9 +73,12 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
<div class="site-footer__legal">
|
||||
<p>
|
||||
{brand.siteName} is free software under the{' '}
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noopener noreferrer"
|
||||
>GPL-3.0-or-later</a
|
||||
>. © {year}.
|
||||
<a href={legal.licence.url} rel="noopener noreferrer">{legal.licence.id}</a>. ©
|
||||
{' '}{year}.
|
||||
<span class="site-footer__links">
|
||||
<a href="/privacy/">Privacy</a>
|
||||
<a href="/terms/">Terms</a>
|
||||
</span>
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
@@ -84,6 +89,16 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/* Sits on the licence line rather than in a column of its own — see the header. The
|
||||
separator is a border so it never appears at the start of a wrapped line. */
|
||||
.site-footer__links {
|
||||
display: inline-flex;
|
||||
gap: 0.9rem;
|
||||
margin-left: 0.9rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.site-footer__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
132
src/components/NotBuilt.astro
Normal file
132
src/components/NotBuilt.astro
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import { notBuiltFor, assertScopeNonEmpty } from '../data/notBuilt.mjs';
|
||||
|
||||
/**
|
||||
* The deliberate absences (PLAN.md §2, D22), rendered for one page's scope.
|
||||
*
|
||||
* §2 describes its absent-features list as "as load-bearing as the rest", and this is the
|
||||
* component that makes that true on a page rather than in a plan. It reads the shared list
|
||||
* so `/features/`, `/integrations/` and `/modules/` cannot drift into telling three
|
||||
* different stories about the same six things.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT LOOKS LIKE THE REST OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not a warning box, not a muted footnote, not an accordion. D8's "understated honesty" is
|
||||
* a house style with a specific consequence here: a section that is visually apologetic
|
||||
* teaches a reader that absences are embarrassing, and a section that is visually hidden
|
||||
* teaches them to go looking for the ones you did not mention. These are decisions with
|
||||
* reasons, so they are set as decisions with reasons — the same panels as everything else,
|
||||
* in the same place in the rhythm.
|
||||
*
|
||||
* The one visual difference is the `resolvedBy` line, which every entry carries. An absence
|
||||
* with an exit condition is a position; an absence without one is a hole. D8 gives the
|
||||
* Integration Kit's draft status a defined removal condition and this generalises it.
|
||||
*/
|
||||
interface Props {
|
||||
/** Which page is asking: `features`, `integrations` or `modules`. */
|
||||
scope: string;
|
||||
/** Section heading. Each page frames the same list for its own reader. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { scope, title } = Astro.props;
|
||||
|
||||
assertScopeNonEmpty(scope);
|
||||
const entries = notBuiltFor(scope);
|
||||
---
|
||||
|
||||
<section class="page section notbuilt">
|
||||
<p class="eyebrow">Not built</p>
|
||||
<h2>{title}</h2>
|
||||
<p class="prose notbuilt__lede">
|
||||
Every one of these is a decision rather than a backlog item, so each says why. Where the
|
||||
reasoning was written down in the open, it is linked.
|
||||
</p>
|
||||
|
||||
<ul class="notbuilt__grid">
|
||||
{
|
||||
entries.map((entry) => (
|
||||
<li class="panel notbuilt__item">
|
||||
<h3>{entry.title}</h3>
|
||||
<p class="notbuilt__body">{entry.body}</p>
|
||||
<p class="notbuilt__resolved">
|
||||
<span class="notbuilt__resolved-label">What would change it</span>
|
||||
{entry.resolvedBy}
|
||||
</p>
|
||||
{entry.link && (
|
||||
<p class="notbuilt__link">
|
||||
<a href={entry.link.href} rel="noopener noreferrer">
|
||||
{entry.link.label}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.notbuilt h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.notbuilt__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notbuilt__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.notbuilt__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notbuilt__item h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
/* Takes the slack, so the exit condition sits at the foot of every card in a row
|
||||
rather than immediately under a body of whatever length — the same kind of
|
||||
statement in the same place on each, which is what makes them readable as a row. */
|
||||
.notbuilt__body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notbuilt__link {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
55
src/components/PageHeader.astro
Normal file
55
src/components/PageHeader.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/**
|
||||
* The opening of every marketing page except the homepage — eyebrow, `<h1>`, lede.
|
||||
*
|
||||
* A component rather than four copies of the same three elements, because phase 4 writes
|
||||
* five pages and phases 5 and 6 write four more. The homepage is deliberately not one of
|
||||
* them: its `<h1>` is the tagline inside the hero, set against the emblem, and pulling that
|
||||
* into a shared header would either flatten the hero or push its layout in here (D19).
|
||||
*
|
||||
* The `<h1>` is the page's own name, not the product's, and `Base` appends the site name to
|
||||
* the document title — so a page sets a short `title` and gets "Features — Runic Gateway"
|
||||
* in the tab and "Features" on the page.
|
||||
*/
|
||||
interface Props {
|
||||
/** Small uppercase line above the title. What kind of page this is. */
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { eyebrow, title } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="page section pagehead">
|
||||
<p class="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<div class="prose pagehead__lede">
|
||||
<slot />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The section rhythm gives generous space below; the header wants less, because the
|
||||
first section under it is part of the same thought. */
|
||||
.pagehead {
|
||||
padding-bottom: clamp(1rem, 2.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
.pagehead h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2rem, 5vw, 2.9rem);
|
||||
}
|
||||
|
||||
.pagehead__lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p) {
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
124
src/components/app/Screenshots.astro
Normal file
124
src/components/app/Screenshots.astro
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
/**
|
||||
* The app's screenshot strip — defined now, empty until phase 9 (D26).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist —
|
||||
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen:
|
||||
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a
|
||||
* development instance with no seeded content, before the theming work that changed how
|
||||
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
|
||||
*
|
||||
* Shipping them would break two things at once: D4, which says real screenshots from the
|
||||
* review stack rather than placeholders, and §1, because they would show an app that no
|
||||
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
|
||||
* it, and phase 9 is already the phase that stands up the review stack and seeds the
|
||||
* content the web screenshots need. Adding an emulator pass to a rig that is being built
|
||||
* anyway is most of the work already done, and it has the property that the phone shots
|
||||
* and the browser shots then show the same deployment on the same day.
|
||||
*
|
||||
* The component exists rather than the page carrying a `TODO` because a defined shape is
|
||||
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
|
||||
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT PHASE 9 SHOULD PUT HERE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the
|
||||
* seeded review stack, one per idea rather than one per screen: the shard hub with live
|
||||
* data, the marketplace, a character sheet, the news list, the notification settings, and
|
||||
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
|
||||
* target — it was the number that happened to exist.
|
||||
*
|
||||
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
|
||||
* the image, not branding an operator overrides (§7).
|
||||
*/
|
||||
|
||||
/**
|
||||
* One capture. `width` and `height` are the real pixel dimensions and are required rather
|
||||
* than optional: without them the page reflows as each image decodes, and a strip of six
|
||||
* phone screenshots is the worst possible place for that.
|
||||
*
|
||||
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
|
||||
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
|
||||
* annotated by inference is `any[]` and `astro check` is right to refuse it.
|
||||
*/
|
||||
interface Shot {
|
||||
/** Site-absolute path under `/screens/`. */
|
||||
src: string;
|
||||
/** What the screen shows, for somebody who cannot see it. */
|
||||
alt: string;
|
||||
caption: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const shots: Shot[] = [];
|
||||
---
|
||||
|
||||
{
|
||||
shots.length > 0 && (
|
||||
<section class="page section shots">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="prose shots__lede">
|
||||
Captured against a real deployment with real content, not mocked up. The app takes
|
||||
its colours, type and navigation from the site it is connected to, so these show one
|
||||
community's app rather than a neutral one.
|
||||
</p>
|
||||
|
||||
<ul class="shots__grid">
|
||||
{shots.map((shot) => (
|
||||
<li class="shots__item">
|
||||
<img
|
||||
src={shot.src}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="shots__caption">{shot.caption}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.shots h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.shots__lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shots__grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
}
|
||||
|
||||
.shots__item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shots__caption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
142
src/components/architecture/Allowlist.astro
Normal file
142
src/components/architecture/Allowlist.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/**
|
||||
* "What reaches the public" — the second of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* The homepage states the split in one sentence inside the data-path walk ("a public one
|
||||
* carrying an allowlist of safe events, and a staff-only one carrying the rest… that split
|
||||
* is a security boundary, not a preference"). This is the page where that sentence has to
|
||||
* become a picture, because it is the single design decision a technical evaluator is most
|
||||
* entitled to be suspicious of: a live feed of a game world contains things that must never
|
||||
* be published, and "we filter it" is a claim, not a mechanism.
|
||||
*
|
||||
* So the diagram draws the shape of the mechanism — one stream in, one decision, two streams
|
||||
* out — and the notes say where the decision lives and what happens when it is wrong in
|
||||
* either direction. What it deliberately does NOT do is enumerate event kinds: that is the
|
||||
* catalog's job in the docs, it changes with the protocol, and a marketing page holding a
|
||||
* copy of it would be a copy that goes stale (§1).
|
||||
*
|
||||
* The rings sit behind the filter rather than behind the whole picture, on the phase-3
|
||||
* principle that they mark the one place the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="allowlist">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What reaches the public</p>
|
||||
<h2>One feed in, two feeds out</h2>
|
||||
<p class="prose">
|
||||
A live game world emits things that are fine on a front page and things that are not:
|
||||
who logged in from which address, what the cheat detector flagged, what a staff member
|
||||
did to whom. Both arrive on the same connection, so something has to divide them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 470" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Centred on the filter: the one place in the picture where the argument is. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="178" r="96" />
|
||||
<circle cx="190" cy="178" r="136" />
|
||||
<circle cx="190" cy="178" r="176" />
|
||||
</g>
|
||||
|
||||
<rect class="node" x="20" y="12" width="340" height="60" rx="10" />
|
||||
<text class="node-title" x="40" y="38">Everything the game emits</text>
|
||||
<text class="node-sub" x="40" y="58">one authenticated stream, from the sidecar</text>
|
||||
|
||||
<path class="spine spine--live" d="M190 80 V132" />
|
||||
<path class="arrow arrow--live" d="M190 140 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node node--self" x="20" y="142" width="340" height="72" rx="10" />
|
||||
<text class="node-title" x="40" y="172">Your site decides</text>
|
||||
<text class="node-sub" x="40" y="192">one allowlist, in one place, on your server</text>
|
||||
|
||||
<!-- Diverging: the public leg in cyan because it is still a live feed; the staff
|
||||
leg in gold because it is the privileged one. -->
|
||||
<path class="spine spine--live" d="M120 222 C120 268 96 268 96 306" />
|
||||
<path class="arrow arrow--live" d="M96 314 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M260 222 C260 268 284 268 284 306" />
|
||||
<path class="arrow" d="M284 314 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node" x="8" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="26" y="344">Public pages</text>
|
||||
<text class="node-sub" x="26" y="366">an allowlist of event</text>
|
||||
<text class="node-sub" x="26" y="382">kinds, and nothing</text>
|
||||
<text class="node-sub" x="26" y="398">outside it</text>
|
||||
<text class="node-audience" x="26" y="424">anyone at all</text>
|
||||
|
||||
<rect class="node" x="196" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="214" y="344">Staff console</text>
|
||||
<text class="node-sub" x="214" y="366">the rest: audit trail,</text>
|
||||
<text class="node-sub" x="214" y="382">login attempts,</text>
|
||||
<text class="node-sub" x="214" y="398">addresses, cheat flags</text>
|
||||
<text class="node-audience" x="214" y="424">signed-in staff only</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
The allowlist is the security boundary. A new kind of event is invisible to the public
|
||||
until somebody adds it, which is the safe direction to fail in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>It is an allowlist, not a blocklist</h3>
|
||||
<p>
|
||||
The public stream carries the kinds of event that are named as safe; everything else
|
||||
goes to the staff stream by default. That ordering is the whole point. A blocklist
|
||||
fails open — the day the game emits something new, it is already published — and an
|
||||
allowlist fails closed, so the worst case is a page that is missing something rather
|
||||
than a page that has published an address.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>The decision lives on your server</h3>
|
||||
<p>
|
||||
Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder:
|
||||
it moves what the game emits and makes no judgements about audience. Everything
|
||||
about who may see what is decided by the site you run, in one place, where you can
|
||||
read it — and where changing it does not mean redeploying anything on the game host.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>More than two audiences, in practice</h3>
|
||||
<p>
|
||||
Two streams is the transport. Above it sits a configurable audience model — logged
|
||||
out, signed in, linked to a game account, staff — that decides how much of a given
|
||||
surface each of those sees. The public stream is the floor of that, and it is the
|
||||
one that is a boundary rather than a setting.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>When the game is down</h3>
|
||||
<p>
|
||||
Nothing arrives, and the site carries on. Live surfaces say the server is offline
|
||||
and everything that does not depend on it — the wiki, the news, accounts, the forums
|
||||
— is unaffected. A site that goes down with the game it reports on is not much of a
|
||||
status page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Each outcome node ends with a line naming its audience, set apart from the
|
||||
description above it rather than reading as another line of it.
|
||||
|
||||
Its own class, not `:nth-last-of-type`: an index into a list of `<text>`
|
||||
siblings is correct only until somebody adds a label, and it fails by
|
||||
styling the wrong words rather than by failing. */
|
||||
.node-audience {
|
||||
fill: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 11.5px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
166
src/components/architecture/ModuleSeam.astro
Normal file
166
src/components/architecture/ModuleSeam.astro
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams
|
||||
* (D21).
|
||||
*
|
||||
* The other two draw runtime shapes. This one draws a code boundary, and it is here because
|
||||
* it is the claim the whole project rests on: that a community platform can be built once
|
||||
* and pointed at any game. An evaluator has every reason to read that as marketing, so the
|
||||
* page draws the seam and then says plainly what does and does not prove it — one module
|
||||
* exists, the second is a paper exercise, and the exit criterion for calling the contract
|
||||
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
|
||||
*
|
||||
* The Module API version is read from `platform.json` like every other number on this site
|
||||
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is
|
||||
* literally a version check, and a module whose declared range does not match refuses to
|
||||
* load rather than half-loading.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="module-seam">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">Where the game stops</p>
|
||||
<h2>A seam, with a version on it</h2>
|
||||
<p class="prose">
|
||||
The core site does not know what a shard is, what a guild is, or that Ultima Online
|
||||
exists. Everything that does lives in an installable module on the other side of a
|
||||
declared interface — which is what makes "put your game on it" a shape rather than a
|
||||
slogan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Core: what ships in the image, on every deployment, module or not. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Runic Gateway core</text>
|
||||
<text class="host-sub" x="28" y="62">game-agnostic; the same image everywhere</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="108">Accounts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="108">Teams</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="162">Wiki and posts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="162">Admin and API</text>
|
||||
|
||||
<!-- The seam. Both boundary lines and the label between them: this is the one
|
||||
thing in the picture that is neither core nor module. -->
|
||||
<path class="boundary" d="M8 224 H372" />
|
||||
<text class="seam-label" x="190" y="252" text-anchor="middle">
|
||||
Module API {platform.moduleApi}
|
||||
</text>
|
||||
<path class="boundary" d="M8 272 H372" />
|
||||
|
||||
<!-- Registers upward; is asked downward. Two arrows, opposite directions, because
|
||||
the traffic across a seam is not one-way and drawing it as one-way is what
|
||||
makes people think a module is a plugin that only listens. -->
|
||||
<path class="spine" d="M120 300 V206" />
|
||||
<path class="arrow" d="M120 198 l-6 10 h12 Z" />
|
||||
<text class="seam-arrow" x="136" y="216">registers</text>
|
||||
|
||||
<path class="spine" d="M260 200 V294" />
|
||||
<path class="arrow" d="M260 302 l-6 -10 h12 Z" />
|
||||
<text class="seam-arrow" x="244" y="290" text-anchor="end">calls</text>
|
||||
|
||||
<!-- The module: everything that knows a game exists. -->
|
||||
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="340">Game module</text>
|
||||
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
|
||||
|
||||
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="406">Routes</text>
|
||||
|
||||
<rect class="node" x="196" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="406">Screens</text>
|
||||
|
||||
<rect class="node" x="28" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="460">Its own tables</text>
|
||||
|
||||
<rect class="node" x="196" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="460">Nav rows</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
A module declares which versions of the interface it speaks. If that does not match
|
||||
what the site offers, it refuses to load and the site comes up without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The module brings its own everything</h3>
|
||||
<p>
|
||||
Not just screens: its routes, its database tables, its navigation rows, its slice of
|
||||
the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the
|
||||
admin panel or a line in your environment — never a build step, because production
|
||||
runs an image you pulled, and an operator who has to compile something has been
|
||||
handed a maintenance job rather than a feature.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Failure is contained by design</h3>
|
||||
<p>
|
||||
A module that will not load is marked as failed and the site starts without it.
|
||||
Disabling one is a kill switch, not a visibility flag — its routes stop answering
|
||||
and its live connections close. Uninstalling keeps the data, and destroying the data
|
||||
is a separate, deliberate choice made in its own dialog.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Teams is the shape of the contract</h3>
|
||||
<p>
|
||||
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
|
||||
channel — and does not own the <em>word</em>. A Team cannot be created in core at
|
||||
all; it arrives from the module, which is why the UO module calls them guilds and
|
||||
builds those pages itself. That is the pattern the whole interface is built on: core
|
||||
supplies the machinery, the module supplies the meaning.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What this does not yet prove</h3>
|
||||
<p>
|
||||
One module exists and it is Ultima Online. A second, for a different game, is a
|
||||
written dry-run that was deliberately never implemented — it exists to test whether
|
||||
the contract generalises on paper. Until somebody builds the second one, the seam is
|
||||
a well-argued design rather than a demonstrated one, and this site says so wherever
|
||||
it comes up.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The seam label sits between the two boundary rules rather than beside them: it is
|
||||
the name of the gap, not an annotation on either side of it. Gold, because it is
|
||||
the one contract in the picture. */
|
||||
.seam-label {
|
||||
fill: var(--gold);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Two words, because two arrows crossing a boundary in opposite directions is
|
||||
ambiguous without them — and the ambiguity is the exact misreading this diagram
|
||||
exists to prevent, that a module is something core talks at. */
|
||||
.seam-arrow {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
122
src/components/architecture/TwoHosts.astro
Normal file
122
src/components/architecture/TwoHosts.astro
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
/**
|
||||
* "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* This one exists because of a specific, repeated misunderstanding that §10 names and the
|
||||
* homepage's CTA already spends two sentences on: a Runic Gateway install is two
|
||||
* independent installs, on two machines, and neither installs the other. The homepage says
|
||||
* it; this page draws it, because an evaluator deciding whether to run the software is
|
||||
* doing capacity planning, and "how many machines is this" is the first question they have.
|
||||
*
|
||||
* Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
|
||||
* not "your ServUO box" — with the prose beside it naming the real components. The boundary
|
||||
* is the one drawn argument: everything above it is reachable because you published it, and
|
||||
* everything below it is not reachable at all.
|
||||
*
|
||||
* The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="two-hosts">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What you deploy</p>
|
||||
<h2>Two hosts, two installs</h2>
|
||||
<p class="prose">
|
||||
Almost everyone gets this wrong once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other — so a "Runic
|
||||
Gateway install" is really two, done in that order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 546" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The web host, and everything that runs on it. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="232" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Your web host</text>
|
||||
<text class="host-sub" x="28" y="62">a VPS, a home server, anything running Docker</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="106">Runic Gateway</text>
|
||||
<text class="node-sub" x="46" y="126">one container, pulled not built</text>
|
||||
|
||||
<rect class="node" x="28" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="176">Game module</text>
|
||||
<text class="node-sub" x="46" y="196">installed, not built</text>
|
||||
|
||||
<rect class="node" x="196" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="214" y="176">Database</text>
|
||||
<text class="node-sub" x="214" y="196">your data, your disk</text>
|
||||
|
||||
<!-- The one hop between them, and the only one. Two arrowheads because the traffic
|
||||
genuinely goes both ways: the site calls the sidecar for point-in-time reads,
|
||||
and the sidecar pushes the live feed back up. -->
|
||||
<path class="spine spine--live" d="M190 248 V312" />
|
||||
<path class="arrow arrow--live" d="M190 240 l-6 10 h12 Z" />
|
||||
<path class="arrow arrow--live" d="M190 320 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="boundary" d="M8 280 H372" />
|
||||
<text class="boundary-label" x="372" y="273" text-anchor="end">the network</text>
|
||||
|
||||
<!-- The game host. Nothing here is reachable from outside except the sidecar. -->
|
||||
<rect class="host" x="8" y="320" width="364" height="214" rx="14" />
|
||||
<text class="host-title" x="28" y="354">Your game host</text>
|
||||
<text class="host-sub" x="28" y="374">where the game server already runs</text>
|
||||
|
||||
<rect class="node" x="28" y="392" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="418">Sidecar</text>
|
||||
<text class="node-sub" x="46" y="438">the only part of this with a port open</text>
|
||||
|
||||
<rect class="node" x="28" y="462" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="488">Game server</text>
|
||||
<text class="node-sub" x="46" y="508">dials out over loopback; listens for nothing</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is
|
||||
the minimum and also the maximum — nothing here scales by adding a third.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The web host</h3>
|
||||
<p>
|
||||
A Docker Compose deployment: the site, its database, and whichever game module you
|
||||
installed. Images are pulled rather than built, so nothing compiles here and an
|
||||
upgrade is a pull and a restart. This is the only machine anybody points a browser
|
||||
at, and the only one that needs a certificate.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>The game host</h3>
|
||||
<p>
|
||||
The machine your game server is already on. One installer binary puts the plugin
|
||||
into the server's tree, installs the sidecar beside it and registers the service —
|
||||
then prints four values. It never contacts your website; you paste those four
|
||||
values into the admin panel yourself, and that is the moment the two halves meet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Why they share a host</h3>
|
||||
<p>
|
||||
The game talks to the sidecar over loopback, on the same machine, and dials
|
||||
<em>out</em> to do it. That is what lets the game server open no port at all — and it
|
||||
is also why there is no macOS installer build. The pair has to sit together, and no
|
||||
game server anybody runs is on one.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What crosses between them</h3>
|
||||
<p>
|
||||
One authenticated connection, in both directions: a WebSocket carrying the live feed
|
||||
up, and REST calls going down for point-in-time questions. Nothing else on either
|
||||
machine talks to the other, and the sidecar answers your site and nobody else.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
167
src/components/home/Capabilities.astro
Normal file
167
src/components/home/Capabilities.astro
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
|
||||
* each one is `/features/`'s job in phase 4, and repeating it here would create a second
|
||||
* copy to keep true.
|
||||
*
|
||||
* The call below is the point of the exercise: it throws, and therefore fails the build, if
|
||||
* the "Game intelligence" list and the module's own declared capabilities have drifted
|
||||
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
|
||||
* this makes the page honest against `platform.json`, which is the half that was missing.
|
||||
*
|
||||
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
|
||||
* list is described there as "as load-bearing as the rest", and a homepage that lists only
|
||||
* what exists while quietly omitting the well-known things that do not is the exact failure
|
||||
* §1 is written to prevent.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
---
|
||||
|
||||
<section class="page section caps">
|
||||
<p class="eyebrow">What it does</p>
|
||||
<h2>A community site, and a window into the game</h2>
|
||||
<p class="prose caps__lede">
|
||||
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
|
||||
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
|
||||
a different game without a fork.
|
||||
</p>
|
||||
|
||||
<div class="caps__grid">
|
||||
{
|
||||
capabilityGroups.map((group) => (
|
||||
<section class:list={['panel', 'caps__group', group.items.length > 8 && 'caps__group--wide']}>
|
||||
<header class="caps__group-head">
|
||||
<h3>{group.title}</h3>
|
||||
{group.moduleSupplied && <span class="chip">Module-supplied</span>}
|
||||
</header>
|
||||
|
||||
<p class="caps__summary">{group.summary}</p>
|
||||
|
||||
<ul class="caps__items">
|
||||
{group.items.map((item) => (
|
||||
<li>{item.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="caps__foot prose">
|
||||
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
|
||||
integration, more than one game module active at once, a second game module. They are
|
||||
listed rather than left out, on <a href="/features/">features</a> and{' '}
|
||||
<a href="/integrations/">integrations</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.caps h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.caps__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caps__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.caps__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.caps__group-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.caps__group h3 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.caps__summary {
|
||||
margin: 0.6rem 0 1rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.caps__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.caps__items li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.caps__items li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* A drawn marker rather than a list bullet: it takes the portal colour, so it
|
||||
tracks a mounted theme the way a `list-style` glyph would not. */
|
||||
.caps__items li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--portal);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Five groups in a three-column grid leaves a hole, and the one group that is
|
||||
twice the length of the others is the obvious thing to put in it. Game
|
||||
intelligence takes both remaining slots on the top row and sets its items
|
||||
in two columns, which fills the row and gives the module-supplied group the
|
||||
prominence it has earned by being the only one that is module-supplied.
|
||||
|
||||
The width is read from the content — a group long enough to need it gets it
|
||||
— rather than named, so a future group of that size lands the same way.
|
||||
|
||||
Guarded by a width query because `span 2` in a grid that is only one column
|
||||
wide is an overflow, not a layout. */
|
||||
@media (min-width: 62rem) {
|
||||
.caps__group--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.caps__group--wide .caps__items {
|
||||
columns: 2;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* `columns` would otherwise break an item across the column boundary, and a
|
||||
capability split over two columns reads as two capabilities. */
|
||||
.caps__group--wide .caps__items li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.caps__foot {
|
||||
margin: 2rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
235
src/components/home/DataPath.astro
Normal file
235
src/components/home/DataPath.astro
Normal file
@@ -0,0 +1,235 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
|
||||
* geometry, used where it explains something, and no raster anywhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
|
||||
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
|
||||
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||
* repositories in order to connect a game server — and because the tagline promises a
|
||||
* platform, not a UO product.
|
||||
*
|
||||
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
|
||||
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
|
||||
* implementation of this shape. An operator running a shard has to see themselves in the
|
||||
* picture on the first screen.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SVG IS aria-hidden
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not because it is decorative — it is the opposite — but because the steps beside it carry
|
||||
* the same four stages in full prose, at real font sizes, in reading order. A `role="img"`
|
||||
* with a `<desc>` would make a screen reader read the same path twice, and the second
|
||||
* telling would be the worse one. The picture is for people who can see it; the list is the
|
||||
* canonical version and everyone gets it.
|
||||
*
|
||||
* That also means the diagram must never gain a fact the list does not have.
|
||||
*
|
||||
* The concentric rings behind the nodes are the emblem's own geometry, centred on the
|
||||
* boundary line — the one place in the picture where the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section datapath">
|
||||
<div class="datapath__head">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>One path, one direction</h2>
|
||||
<p class="prose">
|
||||
Everything the website knows about your game arrives the same way. There is no second
|
||||
route in, and nothing on the internet can reach the game to ask.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="datapath__body">
|
||||
<div class="datapath__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The emblem's concentric rings, centred on the boundary. Drawn first so the
|
||||
panels sit over them. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="252" r="112" />
|
||||
<circle cx="190" cy="252" r="158" />
|
||||
<circle cx="190" cy="252" r="204" />
|
||||
</g>
|
||||
|
||||
<!-- Loopback hop: same host, no network involved. -->
|
||||
<path class="spine" d="M190 92 V140" />
|
||||
<path class="arrow" d="M190 148 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The network hop, and the only one. Drawn in the portal colour because this is
|
||||
the live feed, and the live signal is cyan everywhere on the site. -->
|
||||
<path class="spine spine--live" d="M190 224 V272" />
|
||||
<path class="arrow arrow--live" d="M190 280 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M190 356 V404" />
|
||||
<path class="arrow" d="M190 412 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The boundary the whole design exists to draw. -->
|
||||
<path class="boundary" d="M8 252 H372" />
|
||||
<text class="boundary-label" x="372" y="245" text-anchor="end">the network</text>
|
||||
|
||||
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="50">Your game server</text>
|
||||
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
|
||||
|
||||
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="182">Sidecar</text>
|
||||
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
|
||||
|
||||
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="314">Runic Gateway</text>
|
||||
<text class="node-sub" x="42" y="336">your public website</text>
|
||||
|
||||
<rect class="node" x="20" y="412" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="446">Browser and app</text>
|
||||
<text class="node-sub" x="42" y="468">anyone you choose to let in</text>
|
||||
</svg>
|
||||
|
||||
<p class="datapath__caption">
|
||||
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
|
||||
contract; the implementations are what plug into it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="datapath__steps">
|
||||
<li>
|
||||
<h3>Your game server</h3>
|
||||
<p>
|
||||
A plugin inside the server dials <strong>out</strong> to the sidecar over loopback.
|
||||
The game never listens for anything, so there is nothing on it to find. Events go
|
||||
onto a bounded queue and the game moves on — a sidecar that is wedged or missing
|
||||
cannot slow the world down.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>The sidecar</h3>
|
||||
<p>
|
||||
A small service beside the game, and the only piece of the bridge anything else can
|
||||
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
|
||||
a mismatched pair is refused rather than misread, and it answers only your website's
|
||||
backend, over an authenticated WebSocket and REST.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Runic Gateway</h3>
|
||||
<p>
|
||||
Your site ingests the live feed and fans it back out on two streams: a public one
|
||||
carrying an allowlist of safe events, and a staff-only one carrying the rest. That
|
||||
split is a security boundary, not a preference. When the game is down the site stays
|
||||
up and shows it as offline.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Browser and app</h3>
|
||||
<p>
|
||||
The web client reads same-origin JSON and server-sent events. The Android app talks
|
||||
to the same documented API with bearer tokens. Neither has any idea where the game
|
||||
server is, because neither is ever told.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.datapath__head h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.datapath__head .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.datapath__body {
|
||||
display: grid;
|
||||
gap: clamp(1.75rem, 4vw, 3rem);
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.datapath__figure {
|
||||
position: sticky;
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.datapath__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
|
||||
.boundary, .rings -- now lives in src/styles/diagram.css, shared with
|
||||
/architecture/'s three. It was duplicated in four files the moment the
|
||||
second diagram existed, and the rules it holds are decisions about what a
|
||||
diagram on this site looks like rather than about this one.
|
||||
|
||||
The layout below stays here: the right-hand column is a numbered walk,
|
||||
not the notes column .diagram__body assumes. */
|
||||
|
||||
/* ---- The list ---------------------------------------------------------- */
|
||||
.datapath__steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.datapath__steps li {
|
||||
position: relative;
|
||||
padding-left: 3.25rem;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.datapath__steps li + li {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.datapath__steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.datapath__steps h3 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.datapath__steps p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.datapath__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Sticky is a wide-screen affordance: the figure should scroll away with
|
||||
everything else once it is above the list rather than beside it. */
|
||||
.datapath__figure {
|
||||
position: static;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/home/GetStarted.astro
Normal file
117
src/components/home/GetStarted.astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
|
||||
* "installation path": a "Runic Gateway install" is two independent installs. The installer
|
||||
* binary sets up the shard side only and never contacts the website; the website is a
|
||||
* separate Docker deployment.
|
||||
*
|
||||
* That belongs on the homepage rather than being saved for the docs. It is the single
|
||||
* misunderstanding most likely to make an evaluator think the software is broken, it costs
|
||||
* two sentences to prevent, and §13 calls the installation path the priority of the whole
|
||||
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
|
||||
*
|
||||
* The two halves are ordered site-first because that is the order they must be done in: the
|
||||
* shard side ends by pasting four values into the site's admin panel, which has to exist.
|
||||
*
|
||||
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
|
||||
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
|
||||
* this file that nothing checks and that a later phase would have to remember to fix.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section start">
|
||||
<div class="panel start__panel">
|
||||
<p class="eyebrow">Getting started</p>
|
||||
<h2>An install is two installs</h2>
|
||||
<p class="start__lede prose">
|
||||
This trips up almost everyone once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other. Doing them in
|
||||
order takes an evening.
|
||||
</p>
|
||||
|
||||
<div class="start__halves">
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">1</span> The site</h3>
|
||||
<p>
|
||||
A Docker Compose deployment on whatever host serves your community — a small VPS is
|
||||
plenty. Pull the images, bring it up, create the first admin, then install a game
|
||||
module from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">2</span> The game side</h3>
|
||||
<p>
|
||||
One binary, run on the machine the game server already lives on. It syncs the plugin,
|
||||
installs the sidecar as a service, and prints four values. You paste those into
|
||||
Admin → Shard, and the two halves find each other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Read the install guide</a>
|
||||
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">Ask on Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.start__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.start h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.start__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.start__halves {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.start__half h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start__num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.start__half p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.start__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
</style>
|
||||
183
src/components/home/Hero.astro
Normal file
183
src/components/home/Hero.astro
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The hero (PLAN.md §13 phase 3).
|
||||
*
|
||||
* The org lead chose an emblem hero over a type-only one: the mark carries recognition
|
||||
* across the site, the Android launcher icon and the Play listing, and showing it large is
|
||||
* what makes those three read as one product (D11, §11).
|
||||
*
|
||||
* It costs what D16 already accepted — the emblem is raster illustration, so a mounted
|
||||
* `theme.css` recolours everything around it and not the mark itself. Replacing the mark
|
||||
* means replacing `logo.png`, and because every size here is derived on request from
|
||||
* whichever `logo.png` is in force (D14), that one file changes the hero, the header, the
|
||||
* tab icon and the installed app icon together.
|
||||
*
|
||||
* The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted
|
||||
* theme. That is deliberate: the part that can track the operator's palette does.
|
||||
*
|
||||
* The <h1> is the tagline rather than the product name. The name is in the header, in the
|
||||
* page title and in the footer; a visitor who has just arrived needs the sentence more than
|
||||
* the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15).
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="page hero__inner">
|
||||
<div class="hero__copy">
|
||||
<p class="eyebrow">Self-hosted community platform</p>
|
||||
|
||||
<h1>{brand.tagline}</h1>
|
||||
|
||||
<p class="hero__lede">
|
||||
{brand.siteName} is a community website for a game server — accounts, teams, forums, a
|
||||
wiki, news and a full admin panel — with a one-way bridge that puts the server's live
|
||||
world on the public site. The game itself never listens on the internet.
|
||||
</p>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Install it</a>
|
||||
<a class="btn btn--ghost" href="/features/">See what it does</a>
|
||||
|
||||
{/*
|
||||
The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock
|
||||
build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a
|
||||
mounted `brand.json` sets `demoUrl`, and the link appears.
|
||||
|
||||
The attribute pair is a literal contract with that script — `href` immediately
|
||||
followed by `data-demo-url`, both empty, in this order. Astro preserves attribute
|
||||
order, so what is written here is what ends up in the HTML it searches for. Do not
|
||||
insert an attribute between them.
|
||||
*/}
|
||||
<a class="btn demo-cta" href="" data-demo-url="">See it running</a>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero__mark">
|
||||
{/*
|
||||
`alt=""` because the emblem is the product's mark sitting beside the product's own
|
||||
sentence — announcing it would add nothing a reader of the <h1> does not have.
|
||||
|
||||
Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below
|
||||
through the route's own classifier, so a plausible-but-underivable size fails the
|
||||
build rather than 404ing in production.
|
||||
*/}
|
||||
<picture>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="/brand/logo-256.avif 256w, /brand/logo-384.avif 384w, /brand/logo-512.avif 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
/>
|
||||
<img
|
||||
src="/brand/logo-384.webp"
|
||||
srcset="/brand/logo-256.webp 256w, /brand/logo-384.webp 384w, /brand/logo-512.webp 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
width="384"
|
||||
height="384"
|
||||
alt=""
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-block: clamp(2.5rem, 7vw, 5rem) clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: clamp(1.5rem, 5vw, 3.5rem);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.hero__copy {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: clamp(2.1rem, 5.2vw, 3.35rem);
|
||||
}
|
||||
|
||||
.hero__lede {
|
||||
margin: 1.15rem 0 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.13rem);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.9rem;
|
||||
}
|
||||
|
||||
.hero .chips {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
/* ---- The mark ---------------------------------------------------------
|
||||
The glow is a radial gradient mixed from the portal tokens rather than a
|
||||
literal, so a mounted theme.css moves it with the rest of the palette.
|
||||
It is behind the emblem and outside the flow, so it costs no layout. */
|
||||
.hero__mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hero__mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 50% auto auto 50%;
|
||||
translate: -50% -50%;
|
||||
width: 150%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-pill);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--portal-deep) 34%, transparent) 0%,
|
||||
color-mix(in srgb, var(--portal-deep) 8%, transparent) 45%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__mark img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: clamp(176px, 26vw, 320px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero__inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* The mark leads on a narrow screen: it is the fastest thing to recognise,
|
||||
and stacking it under the copy would push it below the fold entirely. */
|
||||
.hero__mark {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
src/components/home/SelfHosted.astro
Normal file
107
src/components/home/SelfHosted.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* The self-hosted argument (PLAN.md §10, the `/` row).
|
||||
*
|
||||
* Every claim below is from §2's verified state, and each is deliberately the kind of thing
|
||||
* that can be checked by running the software rather than by trusting the page. Where a
|
||||
* claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8)
|
||||
* is a house style, and a hedge in small print is the opposite of it.
|
||||
*
|
||||
* Nothing here is a version or a number, so nothing here needs `platform.json`. If a card
|
||||
* ever gains one, it reads it from there like everything else (§12).
|
||||
*/
|
||||
|
||||
const points = [
|
||||
{
|
||||
title: 'It runs on your box',
|
||||
body:
|
||||
'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' +
|
||||
'compiles on your server. One command up, one command back.',
|
||||
},
|
||||
{
|
||||
title: 'The game stays off the internet',
|
||||
body:
|
||||
'The game host opens no inbound port. The sidecar beside it is the only exposed ' +
|
||||
'part of the bridge, and it answers exactly one caller: your website.',
|
||||
},
|
||||
{
|
||||
title: 'Branding is data, not a rebuild',
|
||||
body:
|
||||
'Name, colours, logo and contact address live in a mounted file. The same image ' +
|
||||
'runs as any community — including this site, which is built the same way.',
|
||||
},
|
||||
{
|
||||
title: 'No analytics, anywhere',
|
||||
body:
|
||||
'This site has no trackers, no third-party requests and no cookie banner, because ' +
|
||||
'it collects nothing. Your deployment talks to the services you configure, and to ' +
|
||||
'nothing you did not.',
|
||||
},
|
||||
{
|
||||
title: 'Documented, not just working',
|
||||
body:
|
||||
'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' +
|
||||
'API you build against is the API that is actually there.',
|
||||
},
|
||||
{
|
||||
title: 'Free software',
|
||||
body:
|
||||
'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' +
|
||||
'are running does not.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="page section selfhosted">
|
||||
<p class="eyebrow">Why self-hosted</p>
|
||||
<h2>Your server, your data, your rules</h2>
|
||||
<p class="prose selfhosted__lede">
|
||||
There is no hosted tier and no account with us. The whole thing is software you run,
|
||||
which is the only arrangement under which "the game is not on the internet" can mean
|
||||
anything.
|
||||
</p>
|
||||
|
||||
<ul class="selfhosted__grid">
|
||||
{
|
||||
points.map((point) => (
|
||||
<li class="panel">
|
||||
<h3>{point.title}</h3>
|
||||
<p>{point.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selfhosted h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.selfhosted__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selfhosted__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.selfhosted__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.selfhosted__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,33 @@
|
||||
export const docsSidebar = [
|
||||
{
|
||||
label: 'Getting started',
|
||||
items: [{ label: 'What is Runic Gateway?', slug: 'docs' }],
|
||||
items: [
|
||||
{ label: 'What is Runic Gateway?', slug: 'docs' },
|
||||
{ label: 'Requirements', slug: 'docs/getting-started/requirements' },
|
||||
{ label: 'Install the site', slug: 'docs/getting-started/install-the-site' },
|
||||
{ label: 'First run', slug: 'docs/getting-started/first-run' },
|
||||
{ label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' },
|
||||
{ label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' },
|
||||
{ label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administration',
|
||||
items: [
|
||||
{ label: 'Configuration', slug: 'docs/administration/configuration' },
|
||||
{ label: 'Branding and theming', slug: 'docs/administration/branding-and-theming' },
|
||||
{ label: 'Navigation and pages', slug: 'docs/administration/navigation-and-pages' },
|
||||
{ label: 'Content', slug: 'docs/administration/content' },
|
||||
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' },
|
||||
{ label: 'Authentication', slug: 'docs/administration/authentication' },
|
||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
||||
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
||||
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
82
src/content/docs/docs/administration/authentication.mdx
Normal file
82
src/content/docs/docs/administration/authentication.mdx
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Local accounts and two-factor, SSO providers and the link-only policy, and the layer that keeps automated traffic out.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom
|
||||
Providers. One session model sits behind all of them — a web cookie, a mobile bearer token
|
||||
and an SSO sign-in all produce the same session.
|
||||
|
||||
## Local accounts
|
||||
|
||||
Username and password sign-in is **always enabled and cannot be turned off**. It is how you
|
||||
manage accounts and how SSO identities get linked in the first place, so there is no
|
||||
configuration on this tab beyond that statement.
|
||||
|
||||
**Two-factor** is a per-account, opt-in TOTP code, set up by each person under **Account**
|
||||
in the sidebar. Nobody can enable it on someone else's behalf, and staff accounts are the
|
||||
ones worth insisting on.
|
||||
|
||||
## SSO providers
|
||||
|
||||
Google and Discord each need a client ID and secret from that provider's developer console;
|
||||
Custom Providers takes any OAuth2/OIDC issuer. Secrets are encrypted at rest with
|
||||
`SECRET_ENC_KEY` and are never returned to any client.
|
||||
|
||||
<Aside type="caution" title="SSO is link-only, by policy">
|
||||
An external identity can only sign in to an account it is **already linked to**. Signing in
|
||||
with Google does not create an account, ever. People link a provider themselves from their
|
||||
own account screen, and that link is what grants the access — so a stranger with a Google
|
||||
account is still a stranger.
|
||||
</Aside>
|
||||
|
||||
Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client
|
||||
— see [Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
## Trusted devices
|
||||
|
||||
A second factor that asks on every sign-in on the same laptop trains people to click
|
||||
through it. A device can be remembered after a successful two-factor challenge, and the
|
||||
trust rides the browser's own cookie jar — including the in-app browser tab the Android app
|
||||
opens for SSO, which is why signing in there does not ask again.
|
||||
|
||||
Trust is per device and revocable, and it survives signing out: signing out ends a session,
|
||||
not the statement that this machine is yours.
|
||||
|
||||
## What keeps the automated traffic out
|
||||
|
||||
Four layers, all on by default:
|
||||
|
||||
- **Rate limiting and backoff** on the login routes, so a password guess costs time.
|
||||
- **A honeypot field** that a human never fills in and a naive bot always does.
|
||||
- **Bot scoring**, which accumulates points against an address for behaviour no human
|
||||
produces, and bans it automatically past a threshold.
|
||||
- **IP bans** from that scoring.
|
||||
|
||||
**Admin → Web Bot Activity** shows the live state: currently banned addresses with their
|
||||
score and expiry, and the recent events with the reason, path and points that produced
|
||||
them. It is deliberately **read-only apart from an emergency unban** — there is nothing to
|
||||
tune here, and the panel exists so that a legitimate user locked out by their office's
|
||||
shared address can be let back in.
|
||||
|
||||
<Aside type="note" title="The scoring state is in memory, and resets when the server restarts">
|
||||
So a restart clears every automatic ban. That is a reasonable escape hatch when you have
|
||||
locked yourself out, and a reason not to treat this list as a permanent record.
|
||||
</Aside>
|
||||
|
||||
## Getting locked out
|
||||
|
||||
Two situations worth knowing before they happen at three in the morning:
|
||||
|
||||
- **Your address is banned.** Restart the app container — the in-memory state goes with it.
|
||||
- **You lost your second factor.** Use one of the recovery codes issued when you enabled
|
||||
it. If those are gone too, another administrator opens **Users → View** on your account
|
||||
and presses **Reset two-factor**, which turns TOTP off, revokes your trusted devices and
|
||||
clears your recovery codes so a password sign-in works again. That is the practical
|
||||
argument for a site never having exactly one admin.
|
||||
|
||||
The same screen lists an account's trusted devices and revokes them individually or all at
|
||||
once — the right response to a lost or stolen laptop, and something to reach for before
|
||||
resetting the whole second factor.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Branding and theming
|
||||
description: Colours, fonts and corners from the Appearance screen; logo, hero and favicon from a mounted directory; the portal hero from its own editor.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
One prebuilt image runs as any community's site. Nothing about your identity is compiled
|
||||
in — it is a theme row in the database, three image files on a mount, and a few environment
|
||||
variables for the values that must exist before the database does.
|
||||
|
||||
## Appearance
|
||||
|
||||
**Admin → Appearance** themes the public site, the admin panel and the player portal
|
||||
together.
|
||||
|
||||
**Presets** — *Runic Gateway*, *Modern*, *Fantasy*, *Custom* — set a whole palette at once.
|
||||
Anything you set below a preset overrides it field by field, and a colour you never set
|
||||
keeps following the preset. That is the useful property: pick the preset closest to what
|
||||
you want, change the two colours that are wrong, and the rest still moves with it.
|
||||
|
||||
| Group | What is in it |
|
||||
|---|---|
|
||||
| **Colors** | Background, deep background, panel top and bottom, accent, bright accent, ink/headings, body text |
|
||||
| **Fonts** | Body serif, display/headings, interface sans — each with a "follow the preset" default |
|
||||
| **Corners & depth** | Radius for pills and buttons, flat panels, cards, inputs; and card shadow |
|
||||
|
||||
Two things the screen tells you that are easy to miss:
|
||||
|
||||
- **Live and maintenance status colours are never themed.** Green has to keep meaning live.
|
||||
- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves
|
||||
from this site's public branding. Changing it here changes them.
|
||||
|
||||
## Brand assets
|
||||
|
||||
The same screen uploads three images, and each applies as soon as the upload finishes —
|
||||
there is nothing to save.
|
||||
|
||||
| Asset | Where it shows | Limit |
|
||||
|---|---|---|
|
||||
| **Logo** | Site header, admin sidebar, player portal, and link previews when a page is shared | 1 MB |
|
||||
| **Hero image** | Behind the portal hero, unless the hero editor has its own background | 8 MB |
|
||||
| **Favicon** | The browser tab. PNG only; 32×32 or 64×64 works everywhere | 512 KB |
|
||||
|
||||
Underneath, these are files on the `./brand` bind mount from
|
||||
[Install the site](/docs/getting-started/install-the-site/), pointed at by `BRAND_LOGO`,
|
||||
`BRAND_HERO` and `BRAND_FAVICON`. An upload writes there; so does copying a file in by
|
||||
hand. Both are supported, and the mount is why replacing a logo never means rebuilding an
|
||||
image.
|
||||
|
||||
<Aside type="note" title="The “powered by Runic Gateway” mark in the footer is not yours to theme">
|
||||
It is the project's badge rather than your instance's, and it does not change with the
|
||||
theme.
|
||||
</Aside>
|
||||
|
||||
## The text that comes from the environment
|
||||
|
||||
A few identity values are read before the database is available — the server templates them
|
||||
into `index.html` at boot so that link previews and the tab title are right on the very
|
||||
first request:
|
||||
|
||||
`BRAND_NAME`, `BRAND_SHORT_NAME`, `BRAND_TAGLINE`, `BRAND_DESCRIPTION`,
|
||||
`BRAND_ACCENT_COLOR`, `BRAND_URL`, `BRAND_CONTACT_EMAIL`.
|
||||
|
||||
Where an admin-editable setting exists for the same thing — site title, contact email — the
|
||||
**setting wins**. The variable is the value a fresh deployment starts from.
|
||||
|
||||
## The portal hero
|
||||
|
||||
**Admin → Hero Editor** composes the front page's hero directly: drag elements to place
|
||||
them, drag the corner handle to resize (text scales with the box), Delete removes the
|
||||
selected one. The palette adds text, buttons, the moon, a badge or an image.
|
||||
|
||||
Its own background image and overlay darkness are set at the bottom of the editor, and a
|
||||
background set here **wins over** the Appearance screen's hero image.
|
||||
|
||||
Work is not live until you press **Publish**; **Preview** opens it in a new tab, and
|
||||
**Revert to live** throws away an unpublished draft. Until anything is published at all,
|
||||
the portal renders the shipped hero with the homepage teaser from
|
||||
[Settings](/docs/administration/configuration/) underneath it.
|
||||
|
||||
<Aside type="caution" title="Check a hero on a phone before publishing it">
|
||||
The editor is a canvas, and a layout that reads well at desktop width can put text over a
|
||||
face or off the edge on a narrow screen. Preview it there.
|
||||
</Aside>
|
||||
99
src/content/docs/docs/administration/configuration.mdx
Normal file
99
src/content/docs/docs/administration/configuration.mdx
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: What is set in the environment file, what is set in the admin panel, and why the split is where it is.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Two places hold configuration, and the line between them is not arbitrary.
|
||||
|
||||
| | Environment (`.env`) | Admin panel |
|
||||
|---|---|---|
|
||||
| **What** | How the process runs: ports, database, secrets, proxy trust, log level | How the site behaves: titles, registration, forums, integrations |
|
||||
| **Changing it** | Edit the file, `docker compose up -d` | Save the form; effective immediately |
|
||||
| **Who** | Whoever has the host | Whoever has an admin account |
|
||||
| **Where it lives** | A file on the host | The database |
|
||||
|
||||
The rule behind the split: **anything that needs a restart or a shell is environment;
|
||||
anything an administrator should be able to change without either is in the panel.** That
|
||||
is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not*
|
||||
environment variables — they are entered in the panel and stored encrypted.
|
||||
|
||||
## Settings
|
||||
|
||||
**Admin → Settings**, the screen most of a new deployment's decisions live on.
|
||||
|
||||
| Field | What it does |
|
||||
|---|---|
|
||||
| **Site title** | Overrides `BRAND_NAME` in the page title, the header and link previews. |
|
||||
| **Homepage teaser** | Rich text under the hero heading, when no custom hero layout is published. |
|
||||
| **Maintenance message** | What visitors see while the site is in maintenance mode. |
|
||||
| **Status message** | A short line for announcements — a maintenance window, an outage. |
|
||||
| **Contact email** | Where the contact form delivers, and the address it falls back to as a `mailto:` link while email is unconfigured. |
|
||||
| **Player registration** | Disabled, password, SSO, or both. **Off by default.** |
|
||||
|
||||
### Player registration is off until you turn it on
|
||||
|
||||
A new site accepts no self-registration at all. The three ways to let people in:
|
||||
|
||||
- **Password** — a normal sign-up form.
|
||||
- **SSO** — sign-up through a linked provider, which needs a provider configured first.
|
||||
- **Invites** — leave registration off entirely and issue invitations from
|
||||
**Admin → Invites**. See [Users and roles](/docs/administration/users-and-roles/).
|
||||
|
||||
## Team forums
|
||||
|
||||
The same screen carries the forum switches, because they are site-wide policy rather than
|
||||
per-Team settings:
|
||||
|
||||
- **Enable team forums** — off by default. Switching them off hides them completely (every
|
||||
forum route answers *not found*) but **deletes nothing**: threads, posts, access grants
|
||||
and notification preferences all survive and come back exactly as they were.
|
||||
- **Images in forum posts** — disabled, remote URLs only, or uploads to your server.
|
||||
Enabling uploads means content stored on infrastructure you are responsible for, and the
|
||||
screen says so at some length before you can agree to it.
|
||||
- **Post edit window** — how long an author may edit their own post. Staff are not bound by
|
||||
it. Zero makes posts permanent once written; some bound is what stops a post being
|
||||
rewritten out from under someone quoting it.
|
||||
|
||||
## Email
|
||||
|
||||
Configured on the same screen and covered in
|
||||
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
|
||||
OAuth2, it reuses the Google authentication client, and it must be set up on the
|
||||
[Authentication](/docs/administration/authentication/) page first.
|
||||
|
||||
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
|
||||
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
||||
setting is doing real work on a site that has never configured delivery, and an unset one
|
||||
leaves a contact form that goes nowhere.
|
||||
</Aside>
|
||||
|
||||
## The environment file, in three groups
|
||||
|
||||
You wrote these in [Install the site](/docs/getting-started/install-the-site/); this is
|
||||
what they mean when you come back to them.
|
||||
|
||||
**Identity and process** — `NODE_ENV`, `PORT`, `INTERNAL_PORT`, `IMAGE_TAG`. `INTERNAL_PORT`
|
||||
is the server-to-bot channel and must never be published or proxied.
|
||||
|
||||
**Data and secrets** — the `DB_*` group, `JWT_SECRET`, `SECRET_ENC_KEY`, `BOT_INTERNAL_KEY`.
|
||||
The last two are required in production, and `SECRET_ENC_KEY` is the key everything else
|
||||
encrypted at rest is keyed by: change it and the stored secrets become unreadable.
|
||||
|
||||
**Behaviour at the edge** — `TRUST_PROXY`, `COOKIE_SECURE`, `COOKIE_NAME`,
|
||||
`JWT_EXPIRES_IN`. `COOKIE_NAME` is worth one warning: changing it on a live site logs
|
||||
everybody out.
|
||||
|
||||
<Aside type="caution" title="`MODULE_SOURCE_HOSTS` is bootstrap only">
|
||||
It seeds the module install allowlist the first time a site boots without one. After that
|
||||
the **setting** is authoritative and is edited in Admin → Modules — changing the variable on
|
||||
an existing deployment does nothing, deliberately, so a redeploy cannot silently undo an
|
||||
administrator's choice.
|
||||
</Aside>
|
||||
|
||||
## Branding is data, not configuration
|
||||
|
||||
The `BRAND_*` variables and the `/brand` mount are how one prebuilt image runs as any
|
||||
community's site. They get their own page:
|
||||
[Branding and theming](/docs/administration/branding-and-theming/).
|
||||
62
src/content/docs/docs/administration/content.mdx
Normal file
62
src/content/docs/docs/administration/content.mdx
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Content
|
||||
description: Posts and their categories, the wiki and its sections, and the activity log that records who changed what.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Three content surfaces, one for each shape of writing a community does.
|
||||
|
||||
| Surface | For | Lives at |
|
||||
|---|---|---|
|
||||
| **Posts** | Dated writing: news, the newsletter, screenshots | `/site/news` and friends |
|
||||
| **Pages** | Standing pages: About, Rules, Donate — see [Navigation and pages](/docs/administration/navigation-and-pages/) | its own slug |
|
||||
| **Wiki** | Reference the community maintains: guides, lore, systems | `/wiki` |
|
||||
|
||||
## Posts
|
||||
|
||||
**Admin → Posts**, filtered by category. A new deployment seeds four:
|
||||
|
||||
- **News** — the default, and the one wired to announcements.
|
||||
- **Five on Friday** — a recurring short-form format.
|
||||
- **Newsletter** — longer, periodic.
|
||||
- **Screenshots** — image posts.
|
||||
|
||||
Each post is a draft until it is published, and the Posts list shows status and date at a
|
||||
glance.
|
||||
|
||||
<Aside type="caution" title="Publishing a news post announces it">
|
||||
Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg
|
||||
an installed module adds, such as the `uo` module's in-game town crier. It fires on
|
||||
publication, so an accidental publish is an accidental announcement. See
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
</Aside>
|
||||
|
||||
## The wiki
|
||||
|
||||
**Admin → Wiki** lists every page with its section and status, and **Manage sections**
|
||||
edits the grouping itself. A new site starts with eight pages in four sections — Guides,
|
||||
World & Lore, Systems & Gameplay, Community & Rules — as a skeleton to write into.
|
||||
|
||||
They are placeholders. None of them describes your game, and leaving them published means
|
||||
publishing an empty guide to it; either write them or unpublish them before you go live.
|
||||
|
||||
## Who may write what
|
||||
|
||||
Roles decide it, and the split is the useful part:
|
||||
|
||||
- **Editor** — the content roles. Posts, pages, wiki, and the activity log.
|
||||
- **Moderator** — moderation and Teams, not content authoring.
|
||||
- **Admin** — everything, including the system screens.
|
||||
|
||||
Full table in [Users and roles](/docs/administration/users-and-roles/).
|
||||
|
||||
## The activity log
|
||||
|
||||
**Admin → Activity** records what staff did: the action, a detail line, who did it, from
|
||||
which address, and when. Module installs, logins, content changes and moderation all land
|
||||
here.
|
||||
|
||||
Two things it is good for beyond curiosity: reconstructing what changed just before
|
||||
something broke, and confirming that an account which should not have done something did
|
||||
not. It is a record, not a workflow — nothing is actioned from this screen.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Maintenance and upgrades
|
||||
description: Upgrading the image, pinning a build, what to back up and how, where the logs are, and the reverse proxy.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
## Upgrading the site
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That is the whole routine. The image carries the server and the built client together;
|
||||
schema changes are applied on boot, and installed modules are on a volume the upgrade does
|
||||
not touch.
|
||||
|
||||
**Pin a build when you want a deploy you can reproduce.** `IMAGE_TAG` defaults to `latest`;
|
||||
every merge also publishes `sha-<7>`, so
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
deploys an exact build, and putting that value in `.env` makes it the one this host runs
|
||||
until you change it. Rolling back is the same command with the previous tag — with one
|
||||
caveat that decides whether it works.
|
||||
|
||||
<Aside type="caution" title="A rollback is only safe if the schema did not move">
|
||||
Upgrades apply schema changes on boot; nothing un-applies them. Rolling the image back to a
|
||||
build that predates a schema change leaves the older code looking at a newer database.
|
||||
Restore the backup you took first, or stay forward.
|
||||
</Aside>
|
||||
|
||||
## Back up before you upgrade
|
||||
|
||||
Two volumes and one directory hold everything that cannot be re-downloaded: the database,
|
||||
the uploads, and `./modules`.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Dump the database.** From the deployment directory, while the stack is up:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db sh -c \
|
||||
'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --routines runic_gateway' \
|
||||
> backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
`--single-transaction` is what makes it consistent without locking the site.
|
||||
|
||||
2. **Copy the uploads volume.**
|
||||
|
||||
```bash
|
||||
docker run --rm -v <deployment>_uploads:/from -v "$PWD":/to alpine \
|
||||
tar czf /to/uploads-$(date +%F).tgz -C /from .
|
||||
```
|
||||
|
||||
The volume is named after the directory Compose runs in — `docker volume ls` shows the
|
||||
exact names.
|
||||
|
||||
3. **Keep `./modules`, `./brand` and your two files.** They are ordinary host directories;
|
||||
whatever backs up the rest of the host covers them.
|
||||
|
||||
</Steps>
|
||||
|
||||
Restoring the database is the same command inverted — `mariadb … < backup.sql` — into a
|
||||
stack whose image is the one the dump came from.
|
||||
|
||||
## Logs
|
||||
|
||||
`./logs/app.log` on the host, because the Compose file bind-mounts it there. `docker compose
|
||||
logs -f app` shows the same stream live.
|
||||
|
||||
`LOG_LEVEL` sets console verbosity and `FILE_LOG_LEVEL` the file's — the file keeps the
|
||||
fuller record on purpose. Nothing rotates them for you.
|
||||
|
||||
## Restarting
|
||||
|
||||
`docker compose restart app` is the ordinary restart, and it is what the admin panel's
|
||||
**Restart the server** button amounts to. Restarts are needed after installing, enabling or
|
||||
uninstalling a module, and are harmless otherwise.
|
||||
|
||||
`docker compose down` stops everything and keeps the data. **`docker compose down -v` also
|
||||
deletes the volumes** — the database and every upload. There is no undo.
|
||||
|
||||
## The reverse proxy
|
||||
|
||||
The app publishes port 3000 and binds all interfaces, so any proxy that can reach the host
|
||||
can serve it. Two settings make it correct rather than merely working, both covered in
|
||||
[Install the site](/docs/getting-started/install-the-site/): `TRUST_PROXY`, so the address
|
||||
your rate limiting and IP bans act on is the visitor's rather than the proxy's, and
|
||||
`COOKIE_SECURE=auto`.
|
||||
|
||||
Three rules for whatever proxy you use:
|
||||
|
||||
- **Forward only 3000.** `INTERNAL_PORT` (3001) is the server-to-bot channel and must never
|
||||
be reachable from outside; the Compose file deliberately does not publish it.
|
||||
- **Deny `/api/v1/internal` at the proxy** as well. Belt and braces: that route no longer
|
||||
rides the public listener, and an explicit deny costs nothing.
|
||||
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
|
||||
certificate.
|
||||
|
||||
## Upgrading the shard side
|
||||
|
||||
A different deployment on a different host, and it moves on its own schedule:
|
||||
|
||||
```bash
|
||||
sudo runicgateway update # re-resolves the bundle; --verify to see it first
|
||||
sudo runicgateway doctor # confirm afterwards
|
||||
```
|
||||
|
||||
`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you
|
||||
when ServUO needs restarting — it never restarts your shard itself. Because it resolves a
|
||||
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
|
||||
protocol.
|
||||
|
||||
<Aside type="note" title="Update the two sides in either order, but verify after each">
|
||||
They are independent deployments joined by a version-checked contract: a mismatch is
|
||||
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
|
||||
pair until both sides are current — visible on
|
||||
[the shard connection screen](/docs/administration/the-shard-connection/), and not silent
|
||||
corruption.
|
||||
</Aside>
|
||||
104
src/content/docs/docs/administration/managing-modules.mdx
Normal file
104
src/content/docs/docs/administration/managing-modules.mdx
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Managing modules
|
||||
description: The five states a module can be in, installing and upgrading, disable versus uninstall versus purge, and what to do when one fails to start.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
|
||||
This is what the screen means afterwards.
|
||||
|
||||
## The five states
|
||||
|
||||
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
|
||||
states.
|
||||
|
||||
| State | Means |
|
||||
|---|---|
|
||||
| **Installed** | Files are on the volume; it mounts at the next restart |
|
||||
| **Enabled** | Allowed to run, and about to be loaded. Every boot resets each non-disabled module to this, then records the outcome |
|
||||
| **Started** | Running: routes mounted, schema applied |
|
||||
| **Disabled** | An operator switched it off. Its routes answer *not found* |
|
||||
| **Startup failed** | It tried and could not. The site came up without it |
|
||||
|
||||
**A module that fails to load never takes the site down.** Failure is caught across the
|
||||
whole lifecycle — require, schema, routes, registration, boot hook — and the site starts
|
||||
with that module's routes and navigation absent, and the reason recorded on this screen.
|
||||
|
||||
Two consequences of how boots work:
|
||||
|
||||
- **A failed module is retried on every restart.** Fix the underlying cause and restart; you
|
||||
do not need to touch the panel. A deterministically broken module re-records its failure
|
||||
each boot, which is the honest thing for it to do.
|
||||
- **Disabled is the only state a boot leaves alone.** Disabling is an operator's decision
|
||||
rather than an outcome, so it survives restarts untouched.
|
||||
|
||||
## Upgrading
|
||||
|
||||
Paste the new release's install-manifest URL and press Install. The bundle is verified
|
||||
against its `sha256`, unpacked over the old one, and takes effect at the restart.
|
||||
|
||||
An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not
|
||||
silently switch it off, and re-installing a disabled one must not silently switch it on.
|
||||
|
||||
<Aside type="caution" title="Check the Module API version before upgrading">
|
||||
A module declares which core API versions it accepts. If a module release requires a newer
|
||||
core than your image, upgrade the site first — see
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
|
||||
</Aside>
|
||||
|
||||
## Disable, uninstall, purge
|
||||
|
||||
Three different actions, in increasing order of destruction.
|
||||
|
||||
**Disable** flips the row and dispatches that module's shutdown hook, so it actually stops
|
||||
— releases its sockets, closes its streams — rather than merely becoming unreachable. Enable
|
||||
is deliberately not the mirror image: there is no boot hook re-dispatch, so enabling offers
|
||||
a restart.
|
||||
|
||||
**Uninstall** is non-destructive by default: the row goes to `disabled`, the directory is
|
||||
removed, and **the module's tables and data are retained**.
|
||||
|
||||
**Purge** runs the module's own `purge.sql` and destroys its data. It is never implied by
|
||||
an uninstall, and it is offered in two places — as a standalone action on an installed
|
||||
module, and as an opt-in checkbox in the uninstall dialog.
|
||||
|
||||
<Aside type="caution" title="Purge only works while the files are still there">
|
||||
`purge.sql` lives inside the directory an uninstall deletes. Uninstalling without ticking
|
||||
the box keeps the tables, and getting rid of them later means **reinstalling the module
|
||||
first**. Decide at the uninstall, not afterwards.
|
||||
</Aside>
|
||||
|
||||
## Where modules may be installed from
|
||||
|
||||
The allowlist at the bottom of the screen. Installing a module runs its code inside your
|
||||
server, so only listed hosts are permitted, over HTTPS, re-checked on every redirect. An
|
||||
empty list forbids every install.
|
||||
|
||||
`MODULE_SOURCE_HOSTS` seeds this list on a site's first boot and is ignored afterwards —
|
||||
the setting is authoritative, so a redeploy cannot silently undo your choice.
|
||||
|
||||
## The declarative path
|
||||
|
||||
`MODULES` in `.env` declares the set this deployment runs, resolved at every container
|
||||
start, each entry `<id>@<version>=<install manifest URL>`.
|
||||
|
||||
The division of ownership is the thing to remember: **the variable owns what is on the
|
||||
volume; the panel owns whether a module runs.** Uninstall a declared module from the panel
|
||||
and its files come back at the next start — disabled.
|
||||
|
||||
A module already unpacked at the declared version is a no-op that makes **no network call
|
||||
at all**, so a restart with no route to the internet comes up unchanged. A version that
|
||||
cannot be fetched is logged, shown on this screen, and never stops the site starting.
|
||||
|
||||
## Placing one by hand
|
||||
|
||||
Unpacking a module tarball into `./modules/<id>/` and restarting is a supported install —
|
||||
it is why that path is a bind mount rather than a named volume. The row it produces has no
|
||||
provenance columns, because nothing downloaded it.
|
||||
|
||||
<Aside type="note" title="Do not delete the `modules` directory itself">
|
||||
Docker recreates a missing bind-mount source as `root`, and the container user can then no
|
||||
longer write it — which breaks installing from the panel. If that happens,
|
||||
`chown 1000:1000 modules` on the host.
|
||||
</Aside>
|
||||
68
src/content/docs/docs/administration/moderation.mdx
Normal file
68
src/content/docs/docs/administration/moderation.mdx
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Moderation
|
||||
description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The **Moderation** group in the sidebar holds three screens that are easy to confuse and do
|
||||
not overlap.
|
||||
|
||||
| Screen | Is about | Comes from |
|
||||
|---|---|---|
|
||||
| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
|
||||
| **Reports** | **Team forum content** members have reported | the site |
|
||||
| **Appeals** | Sanctions people are asking you to reverse | the site |
|
||||
|
||||
## Moderation (Discord)
|
||||
|
||||
Counts across a window you choose — 24 hours, 7 days, 30 days — for bans, kicks, mutes,
|
||||
warnings, joins, leaves, filter hits and spam hits, with a filterable list of recent
|
||||
actions and a tabbed event log (members, filter hits, spam hits).
|
||||
|
||||
Everything here arrives from the Discord bot, so a site with no bot configured shows zeros
|
||||
and empty lists rather than an error. Setting the bot up is
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
**Look up** takes you to a per-user view when you are investigating one account rather than
|
||||
browsing the window.
|
||||
|
||||
## Reports
|
||||
|
||||
Reports raised by members about Team forum content. Two design decisions show through in
|
||||
how this screen behaves:
|
||||
|
||||
- **They come to site staff, and a Team's own leaders never see them.** A leader moderates
|
||||
their own forum, so a report *about a leader* has to reach someone above them.
|
||||
- **Handling a report records a decision about the report.** It does not touch the content:
|
||||
hiding or removing a post is done in the forum, or as a sanction against the account.
|
||||
|
||||
The filters are *Open*, *Reviewing*, *Actioned*, *Dismissed* and *All*, and the count of
|
||||
open reports sits at the top so the screen is glanceable.
|
||||
|
||||
<Aside type="note" title="Dismissing is a real outcome, not a failure to act">
|
||||
A report that was not a problem should be dismissed rather than left open — an open queue
|
||||
that never empties stops being read, and the reporter's next report is the one that
|
||||
matters.
|
||||
</Aside>
|
||||
|
||||
## Appeals
|
||||
|
||||
An appeal is a request to reverse a sanction, filtered by *Open*, *Pending*, *Under
|
||||
review*, *Approved*, *Denied*, *Withdrawn* or *All*. Each row carries the target, the
|
||||
action being appealed, the appeal itself, who submitted it, its age and whether a reversal
|
||||
happened.
|
||||
|
||||
Two things worth building a habit around:
|
||||
|
||||
- **Age is the column that matters.** An appeal that nobody has looked at for three weeks
|
||||
is a worse outcome than a denial.
|
||||
- **The decision is recorded either way.** Approving an appeal records the reversal, so the
|
||||
history explains itself later without anyone having to remember.
|
||||
|
||||
## What is recorded, and where
|
||||
|
||||
Every staff action lands in **Admin → Activity** — who did what, from which address, when.
|
||||
That log is the thing to read when reconstructing a disputed decision, and it is a record
|
||||
rather than a workflow: nothing is actioned from it. See
|
||||
[Content](/docs/administration/content/).
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Navigation and pages
|
||||
description: Renaming, reordering and hiding navigation entries in three navs, and composing standalone pages from blocks.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
## Navigation
|
||||
|
||||
**Admin → Navigation** edits three separate navigations — **Public site**, **Admin** and
|
||||
**Player portal** — each with the same tools: rename an entry, reorder it, hide it, group
|
||||
entries into a dropdown section, or add a link of your own.
|
||||
|
||||
A fresh site's public nav is the seeded one: the portal, News, Screenshots, Five on Friday,
|
||||
Newsletter, the wiki, and About. Until you change anything, the nav "renders exactly as
|
||||
coded" — there is no stored copy to drift from the code.
|
||||
|
||||
Two properties are worth understanding before you rely on this screen.
|
||||
|
||||
**It advertises; it does not authorise.** Renaming or hiding an entry changes what is
|
||||
listed, never what exists or who may reach it. Hiding *Wiki* does not close the wiki. Access
|
||||
is decided by roles and by a module's visibility settings, and this screen "can never show
|
||||
anyone a link their role, or the visibility settings of an installed module, would hide".
|
||||
|
||||
**You only edit what you can see.** Entries hidden from *you* — by your role, or by a
|
||||
module's visibility rules — are not listed, and they keep whatever setting they already
|
||||
had. So an administrator's view of this screen is not necessarily the whole nav, and
|
||||
editing it cannot damage the parts you cannot see.
|
||||
|
||||
<Aside type="note" title="A module's pages appear here like anything else">
|
||||
An installed module adds its own entries, and they can be renamed, reordered, grouped and
|
||||
hidden exactly like core's. What you cannot do is *reach past* the module's own visibility
|
||||
settings — those are set with the module, not here.
|
||||
</Aside>
|
||||
|
||||
**Reset to default** discards your customisation for that nav and goes back to the coded
|
||||
one. It is per-nav, not global.
|
||||
|
||||
## Pages
|
||||
|
||||
**Admin → Pages** composes standalone pages from blocks. A published page is live at its
|
||||
slug — `/about`, `/rules`, `/donate` — and a draft is visible only to staff.
|
||||
|
||||
This is the right tool for content that is not news and not a wiki article: the pages a
|
||||
navigation entry points at. A page you create is not linked from anywhere until you add it
|
||||
in **Navigation** — deliberately, because the two are separate decisions.
|
||||
|
||||
For everything else — news posts, the newsletter, screenshots, the wiki — see
|
||||
[Content](/docs/administration/content/).
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Notifications and email
|
||||
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Four separate delivery paths, each optional, each off until you configure it. A site that
|
||||
configures none of them still works — it just never reaches anyone who is not looking at
|
||||
it.
|
||||
|
||||
## Email
|
||||
|
||||
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
|
||||
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
|
||||
|
||||
It reuses the **Google authentication client**, so the order is fixed: configure Google on
|
||||
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
|
||||
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
|
||||
|
||||
The refresh token it stores is encrypted at rest like every other secret.
|
||||
|
||||
<Aside type="note" title="There is no SMTP option">
|
||||
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
|
||||
contact form falls back to a `mailto:` link to the contact address — which works, and puts
|
||||
the message in the visitor's own mail client rather than in your logs.
|
||||
</Aside>
|
||||
|
||||
## Announcements
|
||||
|
||||
Publishing a **news** post fans it out to every registered delivery leg. The dispatcher is
|
||||
an in-process poller, tuned by `ANNOUNCE_POLL_MS` (15 seconds by default), and the links in
|
||||
an announcement are built from `APP_BASE_URL` — so set that in production or the links point
|
||||
at the wrong host.
|
||||
|
||||
Which legs exist depends on what has registered one:
|
||||
|
||||
- **Discord `#news`** is core's, and needs the bot below.
|
||||
- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post
|
||||
is announced to players who are logged into the game and never visit the site.
|
||||
|
||||
A leg brings its own settings with it — the town crier's duration is a module setting, not
|
||||
a core one — which is why they are documented with the module rather than here.
|
||||
|
||||
## The Discord bot
|
||||
|
||||
**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and
|
||||
save. The token is stored **encrypted in the database** and is never an environment
|
||||
variable.
|
||||
|
||||
The bot is a separate container. On the quickstart deployment from
|
||||
[Install the site](/docs/getting-started/install-the-site/) it is not running at all, and
|
||||
the panel says so — *bot unreachable* is the honest state of a site that never started one,
|
||||
not a failure. Add the `bot` service from the project's shipped Compose file when you want
|
||||
it.
|
||||
|
||||
What it does once connected: posts announcements, captures the moderation events on the
|
||||
[Moderation](/docs/administration/moderation/) screen, serves slash commands, and — if you
|
||||
switch them on — the Team notification bridge and per-Team voice channels from
|
||||
[Teams](/docs/administration/teams/).
|
||||
|
||||
## Push notifications
|
||||
|
||||
Opt-in push to the Android app, over a **self-hosted ntfy relay** — the `ntfy` service in
|
||||
the project's Compose file, plus `NTFY_BASE_URL` and friends.
|
||||
|
||||
Two properties matter for what you have to trust:
|
||||
|
||||
- **The relay only ever carries a content-free tickle.** The message says something
|
||||
happened; the app then fetches the actual content from the site over its own
|
||||
authenticated connection. So the relay never sees notification text.
|
||||
- **A device may only register an endpoint on an allowed origin**, derived from
|
||||
`NTFY_BASE_URL`. That is what stops a device pointing your server at somebody else's.
|
||||
|
||||
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
||||
unavailable for your instance — nothing breaks.
|
||||
|
||||
## Who receives what
|
||||
|
||||
The per-person side of this lives in the player portal, not the admin panel: each member
|
||||
chooses which Team and forum notifications they want, and how. Two defaults are worth
|
||||
knowing because they are not symmetrical:
|
||||
|
||||
- **Push is opt-out** once a device is registered.
|
||||
- **Email is opt-in.**
|
||||
|
||||
<Aside type="caution" title="Nothing here retries">
|
||||
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
||||
a message is sent once and not retried. If Discord is down when a post is published, that
|
||||
announcement is gone — the post is still on the site, which is the thing that matters.
|
||||
</Aside>
|
||||
77
src/content/docs/docs/administration/teams.mdx
Normal file
77
src/content/docs/docs/administration/teams.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Teams
|
||||
description: Core owns the Team machinery and cannot create a Team. What that means in practice, and what the admin screen controls.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Teams are a core platform primitive: membership, roles, forums, notifications, moderation
|
||||
and the Discord integrations are all core's, and none of it knows what a Team *is* in your
|
||||
game.
|
||||
|
||||
**Core cannot create a Team.** Teams arrive from the installed module — with the `uo`
|
||||
module, they are the shard's guilds. On a deployment with no module, the Team machinery is
|
||||
present and permanently empty. That is not a bug to work around; it is the contract that
|
||||
lets the same forum, notification and moderation code serve any game.
|
||||
|
||||
<Aside type="note" title="What that means when you are looking at an empty screen">
|
||||
*No Teams in the projection yet* on a site with no module installed is the correct and
|
||||
final state. Install a module, connect its game server, and Teams appear as that module
|
||||
reconciles them.
|
||||
</Aside>
|
||||
|
||||
## The projection, and why it can be stale
|
||||
|
||||
**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive
|
||||
failures and the last error, with **Sync now** and **Resync now**.
|
||||
|
||||
The wording on that panel is exact and worth reading:
|
||||
|
||||
> Core has never had an answer it could trust. What is shown below is not a confirmed empty
|
||||
> shard.
|
||||
|
||||
An empty list therefore means one of two very different things — there are no Teams, or
|
||||
nobody could ask. The panel tells you which, and a *last success: never* with a *last
|
||||
error* of `no uo-link configured` is the second. Fix
|
||||
[the shard connection](/docs/administration/the-shard-connection/) and sync again.
|
||||
|
||||
## Forums
|
||||
|
||||
Team forums are switched on site-wide in **Settings**, along with whether images are
|
||||
allowed and how long an author may edit a post — see
|
||||
[Configuration](/docs/administration/configuration/).
|
||||
|
||||
Two rules are structural rather than settings:
|
||||
|
||||
- **A Team's leaders moderate their own forum.** That is the point of a Team forum.
|
||||
- **Reports about that forum do not go to them.** They go to site staff, because a report
|
||||
about a leader has to reach someone above them. See
|
||||
[Moderation](/docs/administration/moderation/).
|
||||
|
||||
## The Discord bridges
|
||||
|
||||
Two integrations, both optional, both configured from **Admin → Teams**.
|
||||
|
||||
**Notification bridge** — sends Team notifications to a Discord channel: a default for
|
||||
every Team, overridable per Team. A message is sent once and never retried; the bridge is a
|
||||
courtesy, and nothing on the site depends on it arriving. With nothing configured, no Team
|
||||
event leaves the site.
|
||||
|
||||
**Voice channels** — gives each Team a Discord voice channel of its own, with access
|
||||
granted by a per-Team role, so a Team's members can see and join theirs and nobody else
|
||||
can. It needs the bot reachable, and members need a linked Discord account and guild
|
||||
membership.
|
||||
|
||||
Its three settings deserve a thought each:
|
||||
|
||||
| Setting | What it decides |
|
||||
|---|---|
|
||||
| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. |
|
||||
| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. |
|
||||
| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. |
|
||||
|
||||
<Aside type="caution" title="Voice channels are a per-guild ceiling, not a per-Team one">
|
||||
Discord's role and channel limits apply to the whole guild, so a site with many small Teams
|
||||
can exhaust them. The minimum-members setting is the lever that keeps the count sane, and
|
||||
it is easier to raise it before provisioning than to unpick channels afterwards.
|
||||
</Aside>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: The shard connection
|
||||
description: The module's shard screen — connection settings, what the status line means, game-account creation, the town crier, and what reaches the public.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at
|
||||
`/admin/uo/link`. It is the site's half of the bridge: the connection to the sidecar, and
|
||||
the controls that ride on it.
|
||||
|
||||
Setting it up for the first time is
|
||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
|
||||
## Connection
|
||||
|
||||
Four fields, all four printed by the installer, plus the switch that turns the integration
|
||||
on:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Base URL (REST)** | `http://<shard host>:8080` — point-in-time queries |
|
||||
| **WebSocket URL (feed)** | `ws://<shard host>:8080/ws` — the live event feed |
|
||||
| **Auth token** | The sidecar's token |
|
||||
| **Protocol** | {platform.protocol} today |
|
||||
|
||||
Saving restarts the ingest client, so a change takes effect immediately.
|
||||
|
||||
**The token is write-only.** It is encrypted at rest and never returned to any client, so
|
||||
the field is blank when you come back to the screen — losing it means reading it back from
|
||||
`sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
## Reading the status line
|
||||
|
||||
The header carries the connection state, *Shard link*, *WS ingest*, *Reconnects* and *SSE
|
||||
clients*. Together they say **which** link is broken:
|
||||
|
||||
| Reading | Means |
|
||||
|---|---|
|
||||
| Disconnected, shard link down | The site cannot reach the sidecar at all — URL, firewall, or the service is not running |
|
||||
| Connected, but shard link down | The sidecar is up and the *game* is not talking to it |
|
||||
| Reconnects climbing | An unstable path between site and sidecar |
|
||||
| Live feed silent, everything else green | The bridge is fine and the shard is quiet |
|
||||
|
||||
A `409` in the logs is a protocol mismatch — set the Protocol field to what the sidecar's
|
||||
`/health` reports rather than guessing; it rejects rather than mis-parsing. A `401` is the
|
||||
token.
|
||||
|
||||
<Aside type="note" title="The site is designed to look normal while this is broken">
|
||||
Every read through the sidecar returns a result rather than throwing, so the public site
|
||||
renders with the shard shown offline. That is deliberate graceful degradation, and it is
|
||||
also why a broken bridge can go unnoticed — this screen, or `runicgateway doctor` on the
|
||||
shard host, is how you find out.
|
||||
</Aside>
|
||||
|
||||
## Game-account creation
|
||||
|
||||
Whether players can create a **game** account (for the game client) from the website. The
|
||||
game server's own `SignupMode` in `Bridge.cfg` has to agree.
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| **Disabled** | Players may only link an account that already exists |
|
||||
| **Website** | The site creates game accounts |
|
||||
| **Hybrid** | Site or in-game — the recommended setting |
|
||||
| **Game only** | Created in the game client; the site only links |
|
||||
|
||||
With creation enabled, a *Create a game account* form appears in the player portal and
|
||||
after an invite is accepted.
|
||||
|
||||
## Town crier
|
||||
|
||||
Broadcast a message every in-game town crier announces until it expires: an id, one or more
|
||||
lines, and a duration in seconds. Re-posting the same id **replaces** that message, and
|
||||
**Remove by id** takes it down early.
|
||||
|
||||
The id is the useful part — give a recurring announcement a stable one and you can update or
|
||||
withdraw it without waiting for it to expire.
|
||||
|
||||
## What reaches the public
|
||||
|
||||
Events from the shard fan out over two separate streams, and the split is a security
|
||||
boundary rather than a preference:
|
||||
|
||||
- **The public stream** carries an allowlist of event kinds.
|
||||
- **The admin stream** adds staff audit events, cheat detection, login attempts and IP
|
||||
addresses.
|
||||
|
||||
The live feed at the bottom of this screen is the admin one — everything, as it arrives.
|
||||
Treat it accordingly: it is the screen you do not put in a screenshot.
|
||||
|
||||
<Aside type="caution" title="Visibility is decided on the website, not on the sidecar">
|
||||
The sidecar is a dumb forwarder. What is public, what is staff-only and what is off is
|
||||
decided on the site, so changing your mind is a settings change rather than a shard
|
||||
redeploy — and it also means an unreviewed default is a decision you have made by not
|
||||
making it.
|
||||
</Aside>
|
||||
118
src/content/docs/docs/administration/troubleshooting.mdx
Normal file
118
src/content/docs/docs/administration/troubleshooting.mdx
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: The failures a deployment actually hits, what each one looks like, and the fix.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Grouped by where the problem is, because the first useful question is always *which half is
|
||||
broken*.
|
||||
|
||||
## The site will not start
|
||||
|
||||
Read the log first — `docker compose logs app` — because the server says exactly why.
|
||||
|
||||
| What the log says | What it means |
|
||||
|---|---|
|
||||
| `SECRET_ENC_KEY must be set in production` | The key that encrypts stored secrets is missing. Set it in `.env` and start again. The container crash-loops until you do. |
|
||||
| A `BOT_INTERNAL_KEY` complaint | Blank, still a placeholder, or shorter than 16 characters. Required in production even when the bot is not running. |
|
||||
| A database connection error, repeatedly | The app came up before the database was ready, or `DB_*` is wrong. The Compose file's health check handles the first case; check the values for the second. |
|
||||
| Nothing at all, container restarting | The image did not pull. `docker compose pull` on its own shows the error. |
|
||||
|
||||
<Aside type="caution" title="Both of those key errors happen on the FIRST boot, not later">
|
||||
They are checked at require time, before the server listens. A deployment that has ever
|
||||
served a request has both of them set.
|
||||
</Aside>
|
||||
|
||||
## Nobody can sign in
|
||||
|
||||
- **Your address is rate-limited or bot-banned.** Both are working as designed. Check
|
||||
**Admin → Web Bot Activity** from another network, or restart the app container — the
|
||||
scoring state is in memory and resets with it.
|
||||
- **The password is right and the form still fails.** Check the log for the actual status:
|
||||
a `429` is the rate limiter, a `403` is usually the honeypot, and a `401` really is the
|
||||
password.
|
||||
- **SSO returns to the login page.** SSO is link-only: an identity that is not already
|
||||
linked to an account cannot sign in, and that is the expected outcome rather than a
|
||||
misconfiguration. Link it from the account screen first.
|
||||
- **Two-factor is lost.** Recovery codes, or another admin's **Reset two-factor** on
|
||||
**Users → View**. See [Authentication](/docs/administration/authentication/).
|
||||
|
||||
## A module will not start
|
||||
|
||||
**Admin → Modules** names the stage and the reason. The usual three:
|
||||
|
||||
| Reason | Fix |
|
||||
|---|---|
|
||||
| `module directory not present on the volume` | The row exists and the files do not — someone deleted the directory by hand. Reinstall, or remove the row with an uninstall. |
|
||||
| A schema failure | The module's schema fragment could not be applied. The log carries the SQL error. |
|
||||
| A version refusal | The module wants a newer core API than this image. Upgrade the site. |
|
||||
|
||||
Whatever the reason, **the site is up and the module's routes are absent** — that is by
|
||||
design, and it is why a broken module is an inconvenience rather than an outage. Fix the
|
||||
cause and restart: failed modules are retried on every boot.
|
||||
|
||||
**The install button rejects a URL.** The host must be in the allowlist on the same screen,
|
||||
and the URL must be HTTPS. An empty allowlist forbids every install.
|
||||
|
||||
**The install succeeds and nothing appears.** It needs a restart. The banner says so, and
|
||||
the row reads *Restart to start* until then.
|
||||
|
||||
## The Restart button did not bring the site back
|
||||
|
||||
The button exits the process and relies on a supervisor to start it again. If your
|
||||
deployment has nothing supervising it — `npm start` in a terminal, a container without
|
||||
`restart:` — the site stays down until you start it yourself. Compose with
|
||||
`restart: unless-stopped` is the supported shape.
|
||||
|
||||
## The game screens are empty or say offline
|
||||
|
||||
Work outwards from the game, and stop at the first check that fails.
|
||||
|
||||
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the
|
||||
sidecar.
|
||||
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true`
|
||||
is the value that matters.
|
||||
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay
|
||||
file hash, the service, and that the sidecar and overlay agree on a protocol.
|
||||
4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/)
|
||||
— its four indicators say which link is broken.
|
||||
|
||||
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol
|
||||
field to what `/health` reports), and **`401`** is the auth token (read the live one back
|
||||
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
|
||||
|
||||
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update">
|
||||
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in
|
||||
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
|
||||
looks clean. `doctor` catches it by comparing file hashes against the install record.
|
||||
</Aside>
|
||||
|
||||
## Teams are missing
|
||||
|
||||
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
|
||||
`no uo-link configured` means the shard connection, not the Team machinery. And on a site
|
||||
with **no module installed**, an empty Team list is correct and final — core cannot create
|
||||
a Team. See [Teams](/docs/administration/teams/).
|
||||
|
||||
## Email and announcements never arrive
|
||||
|
||||
- **The contact form opens a mail client.** Email delivery is not connected; that is the
|
||||
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
|
||||
the Google provider, which it reuses.
|
||||
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
||||
Discord Bot screen says *bot unreachable*, it is not running.
|
||||
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
||||
on the site.
|
||||
|
||||
## Uploads and modules fail with permission errors
|
||||
|
||||
Docker created a bind-mount source that the container user cannot write — usually because
|
||||
the directory was deleted and recreated by Docker as `root`. `chown 1000:1000 modules` (or
|
||||
`logs`, or `brand`) on the host fixes it. Do not delete those directories.
|
||||
|
||||
## When you need to ask for help
|
||||
|
||||
Bring three things: the relevant lines from `docker compose logs app`, the output of
|
||||
`runicgateway doctor` if a game server is involved, and what you changed last. The
|
||||
[community page](/community/) has where to ask.
|
||||
67
src/content/docs/docs/administration/users-and-roles.mdx
Normal file
67
src/content/docs/docs/administration/users-and-roles.mdx
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Users and roles
|
||||
description: The four roles and what each one reaches, creating accounts, and inviting people to a site that is not open for registration.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
## The four roles
|
||||
|
||||
| Role | Reaches |
|
||||
|---|---|
|
||||
| **Player** | The player portal: their own profile, their own characters and game account links, their Teams, forum access, notification preferences |
|
||||
| **Moderator** | Everything a player has, plus Moderation, Appeals, Reports and the Teams admin screen |
|
||||
| **Editor** | Everything a player has, plus Posts, Pages, Wiki and the Activity log |
|
||||
| **Admin** | All of it, including Users, Invites, Settings, Modules, Appearance, Navigation, Authentication and the module's own admin screens |
|
||||
|
||||
<Aside type="note" title="Staff are players too">
|
||||
Every self-service screen in the player portal is role-agnostic: it serves whoever is signed
|
||||
in. An administrator has characters and Teams like anyone else, and reaches them through the
|
||||
same portal. Nothing about being staff removes the player half of an account.
|
||||
</Aside>
|
||||
|
||||
Admin routes are re-validated against the database on **every request**, not just at sign-in.
|
||||
Demoting an account takes effect at once — the open session does not keep its access until
|
||||
it expires.
|
||||
|
||||
## Creating an account
|
||||
|
||||
**Admin → Users → + Add user** creates one directly: username, password, role, and it is
|
||||
active immediately. That is the right path for staff, and for the handful of accounts you
|
||||
create yourself.
|
||||
|
||||
The list shows each account's role, status and last login, with **View** and **Edit** on
|
||||
every row.
|
||||
|
||||
## Invites
|
||||
|
||||
**Admin → Invites** is the way to let a specific person in when self-registration is off —
|
||||
which is how every deployment starts.
|
||||
|
||||
Enter an email, pick the access level (player, moderator, editor or admin), and either
|
||||
**create and email** the invitation or generate a link to share yourself. The table tracks
|
||||
status, expiry and creation date, so an unaccepted invite is visible rather than forgotten.
|
||||
|
||||
This is worth preferring over creating accounts by hand for real people: the recipient sets
|
||||
their own password, and you never handle it.
|
||||
|
||||
## Opening registration
|
||||
|
||||
When you do want a public sign-up, that is **Settings → Player registration**: password,
|
||||
SSO, or both. See [Configuration](/docs/administration/configuration/).
|
||||
|
||||
Before opening it, know what is protecting the door: rate limiting, login backoff, a
|
||||
honeypot, bot scoring and automatic IP bans — all covered in
|
||||
[Authentication](/docs/administration/authentication/), along with two-factor and the SSO
|
||||
policy that an external identity can only ever sign in to an account it is already linked
|
||||
to.
|
||||
|
||||
## Status, and why deleting is the last resort
|
||||
|
||||
Editing an account sets its **status** as well as its role: *active*, *disabled*, *banned*
|
||||
or *pending*. Disabled and banned both stop the account being used; the difference is what
|
||||
you are recording — an account switched off versus an account sanctioned.
|
||||
|
||||
Prefer either to the **Delete** button. Content, moderation history and Team membership all
|
||||
reference the account, and a disabled one keeps those records readable while a deleted one
|
||||
leaves the history to explain itself.
|
||||
134
src/content/docs/docs/getting-started/connect-a-game-server.mdx
Normal file
134
src/content/docs/docs/getting-started/connect-a-game-server.mdx
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Connect a game server
|
||||
description: The installer binary on the shard host — what it deploys, what it asks, and the four values it prints for the website.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This is the second of the two installs, and it happens on the machine that runs your game
|
||||
server. One binary deploys the plugin, installs the sidecar, registers its service, and
|
||||
prints four values for you to paste into the website.
|
||||
|
||||
It never contacts your website, and it never starts or stops your shard.
|
||||
|
||||
## What gets deployed
|
||||
|
||||
| # | Component | Where it goes |
|
||||
|---|---|---|
|
||||
| 1 | **The plugin overlay** — C# source ServUO compiles at boot | into your ServUO tree |
|
||||
| 2 | **The uo-link sidecar** — a small Rust service | a system directory, plus a service |
|
||||
| 3 | **A record of the run** | `install.json`, with per-file hashes and backups |
|
||||
|
||||
```
|
||||
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
|
||||
```
|
||||
|
||||
The shard **dials out**. It never listens for the website and is never reachable from the
|
||||
internet; only the sidecar is exposed, and only to your site.
|
||||
|
||||
## Install
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Download the binary for your OS, and `SHA256SUMS`**, from the
|
||||
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases)
|
||||
({platform.releases.installer}).
|
||||
|
||||
Releases are **unsigned** — there is no code-signing certificate, so that checksum file
|
||||
is the whole trust anchor. Check it:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
chmod +x runicgateway-installer-linux-x86_64
|
||||
```
|
||||
|
||||
On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash`
|
||||
and compare. Windows will also show a SmartScreen prompt on first run, for the same
|
||||
reason.
|
||||
|
||||
2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so
|
||||
the installer refuses to deploy under a running server.
|
||||
|
||||
3. **Run it, elevated.**
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install
|
||||
```
|
||||
|
||||
Add `--verify` first if you want to see every change it would make and write nothing.
|
||||
|
||||
It asks four things: your ServUO root, whether to apply the optional patch tier, the
|
||||
hostname your website should use to reach this machine, and your site's URL (used only
|
||||
to print a link at the end).
|
||||
|
||||
4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and
|
||||
its verified hash, the config and database paths, and the service state. Then it says
|
||||
what you must do next — restart ServUO yourself, because it will not do that for you.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note" title="It installs a bundle, not “latest of each”">
|
||||
The three components version independently but must agree on one wire protocol, so what it
|
||||
resolves is a **bundle**: an exact, protocol-checked pair of sidecar and overlay versions
|
||||
({platform.bundle.tag} today — sidecar {platform.bundle.sidecar}, overlay {platform.bundle.overlay}).
|
||||
`--bundle <tag>` pins an exact past combination, so a reinstall in six months reproduces
|
||||
today's install rather than tomorrow's.
|
||||
</Aside>
|
||||
|
||||
## The patch tier is optional
|
||||
|
||||
Most of the plugin is *added* files, which is why the base install is a safe copy. Two
|
||||
features need edits to stock ServUO sources, and those are opt-in, off unless you say yes,
|
||||
and refused where the target lines are not stock. Skipping the tier costs you vendor-sale
|
||||
events and in-game moderation audit forwarding; everything else works.
|
||||
|
||||
The tier is written and tested against stock ServUO {platform.bundle.servuoMin}. On any
|
||||
other version it is unsupported and untested, and the prompt makes you answer past a
|
||||
warning.
|
||||
|
||||
## Paste the four values into the site
|
||||
|
||||
A successful run ends by printing the one step it cannot do for you:
|
||||
|
||||
```
|
||||
Base URL http://shard.example.com:8080
|
||||
WebSocket URL ws://shard.example.com:8080/ws
|
||||
Protocol version 4
|
||||
Auth token 4f9c… (also in sidecar.toml)
|
||||
```
|
||||
|
||||
Every value comes from asking the installed sidecar itself, so it cannot drift from what
|
||||
the service actually runs.
|
||||
|
||||
On the site, sign in as an administrator and open **Shard (uo-link)** in the admin
|
||||
sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**,
|
||||
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
|
||||
immediately.
|
||||
|
||||
<Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
|
||||
It prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and a
|
||||
module owns one path segment wherever it appears — the screen moved to **`/admin/uo/link`**.
|
||||
The old path does not fail visibly: the site sends you to the dashboard, which looks like the
|
||||
link worked. Use the sidebar, or the path above. Fixed for the next release.
|
||||
</Aside>
|
||||
|
||||
The token is encrypted at rest and **never returned to any client** — losing it means
|
||||
reading it back from `sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
## If the website is on a different machine
|
||||
|
||||
The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs
|
||||
elsewhere, widen the bind and then narrow the access:
|
||||
|
||||
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service.
|
||||
2. **Firewall that port to your website's address only.** The auth token is always on, but
|
||||
it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
|
||||
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
|
||||
proxy or a VPN link and give the website the `https://` / `wss://` URLs.
|
||||
|
||||
Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the
|
||||
game*, and being loopback-only is what makes that safe.
|
||||
|
||||
Next: [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — because a
|
||||
successful file copy is not a working bridge.
|
||||
78
src/content/docs/docs/getting-started/first-run.mdx
Normal file
78
src/content/docs/docs/getting-started/first-run.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: First run
|
||||
description: Signing in as the first admin, what the site does before anyone visits, and the switch from maintenance to live.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
The site is up and nobody can see it yet. That is the intended state: a new deployment
|
||||
**starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel
|
||||
stays reachable.
|
||||
|
||||
## Sign in
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Open `/admin/login`** — not `/`. The public site and the admin panel have separate
|
||||
sign-in screens, and in maintenance mode the public one is behind the coming-soon page.
|
||||
|
||||
2. **Use `ADMIN_USERNAME` and `ADMIN_PASSWORD` from your `.env`.**
|
||||
|
||||
That account was created on the first boot, and only because the `users` table was
|
||||
empty. The variables do nothing on later boots, so you can blank them once you are in.
|
||||
|
||||
3. **Set up two-factor**, under **Account** at the bottom of the sidebar. Optional,
|
||||
per-account, and the right moment is now rather than after the site is public.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="caution" title="If the login screen rejects a password you are sure about">
|
||||
Login is rate-limited and backs off after repeated failures from one address, and the
|
||||
bot-scoring layer can ban an address outright. Both are working as designed. Give it a
|
||||
minute, and see [Authentication](/docs/administration/authentication/) for what the
|
||||
**Web Bot Activity** screen shows and how to lift a ban.
|
||||
</Aside>
|
||||
|
||||
## What is already there
|
||||
|
||||
The first boot seeds a working site rather than an empty one:
|
||||
|
||||
- **A wiki with eight pages**, arranged in sections — Guides, World & Lore, Systems &
|
||||
Gameplay, Community & Rules — as a skeleton to write into, not as content to keep.
|
||||
- **Post categories**: News, Five on Friday, Newsletter, Screenshots.
|
||||
- **A public navigation** covering those, the wiki and an About page.
|
||||
- **A portal hero** with placeholder copy that names no game.
|
||||
|
||||
None of it mentions a specific game, because core does not know about one. That arrives
|
||||
with a [module](/docs/getting-started/install-a-game-module/).
|
||||
|
||||
## The three things to set before going live
|
||||
|
||||
All three are on **Settings**:
|
||||
|
||||
| Setting | Why now |
|
||||
|---|---|
|
||||
| **Site title** | Overrides `BRAND_NAME` for the page title, the header and link previews. |
|
||||
| **Contact email** | Where the contact form delivers. Until email is configured, the form falls back to a `mailto:` link to this address — so an unset one means a contact form that goes nowhere. |
|
||||
| **Player registration** | **Off by default**: nobody can create an account. Choose password, SSO, both, or leave it off and invite people individually from **Invites**. |
|
||||
|
||||
The maintenance message and the homepage teaser are on the same screen, and both are worth
|
||||
a minute before anyone reads them.
|
||||
|
||||
## Switch to live
|
||||
|
||||
**Dashboard → Switch to Live.** The public site opens immediately; nothing else changes.
|
||||
|
||||
You can flip back at any time, and an admin who is signed in can preview the live site
|
||||
while the rest of the world still sees the maintenance page — so there is no need to go
|
||||
live in order to check your work.
|
||||
|
||||
<Aside type="note" title="Going live is not the same as being reachable">
|
||||
Live mode only decides what visitors are shown. Whether anyone can reach the site at all is
|
||||
your DNS, TLS and reverse proxy — see
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
|
||||
</Aside>
|
||||
|
||||
Next: [Install a game module](/docs/getting-started/install-a-game-module/), or skip
|
||||
straight to [Administration](/docs/administration/configuration/) if this deployment is a
|
||||
community site with no game server behind it.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Install a game module
|
||||
description: Everything game-specific is a module. Installing one, what it adds, and the restart that makes it live.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
Core knows nothing about any game. Every game-specific screen — shard status, the map
|
||||
atlas, the player marketplace, character sheets — comes from a **module**, a directory on a
|
||||
mounted volume that the server loads at start.
|
||||
|
||||
Today there is one: **`uo`**, for ServUO shards, published as
|
||||
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) ({platform.releases['Module-uo']}).
|
||||
|
||||
## Install it
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Open Admin → Modules.**
|
||||
|
||||
2. **Paste the URL of a release's install manifest** into *Release install-manifest URL*
|
||||
and press **Install**.
|
||||
|
||||
For the current `uo` release that is the `module-uo-<version>.json` asset on
|
||||
[its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases).
|
||||
The site downloads the bundle, checks it against the `sha256` the manifest declares, and
|
||||
unpacks it onto the modules volume.
|
||||
|
||||
There is no catalog to browse, deliberately: a catalog would make core's release cadence
|
||||
decide which modules are allowed to exist.
|
||||
|
||||
3. **Restart when it asks.** A banner appears — *Modules are read from disk when the server
|
||||
starts* — with a **Restart the server** button. The row reads *Restart to start* until
|
||||
you do.
|
||||
|
||||
The button exits the process and lets your supervisor bring it back; on the Compose
|
||||
deployment from [Install the site](/docs/getting-started/install-the-site/), that is
|
||||
`restart: unless-stopped` doing its job. `docker compose restart app` is exactly
|
||||
equivalent.
|
||||
|
||||
4. **Confirm it started.** The module's row should read *Started*, and its screens should
|
||||
have appeared in the navigation.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note" title="Only listed hosts may be installed from">
|
||||
Installing a module runs its code inside your server, so the URL must be HTTPS and its host
|
||||
must be in the allowlist at the bottom of the same screen — re-checked on every redirect.
|
||||
It is seeded with `gitea.whitlocktech.com`, and an empty list forbids every install.
|
||||
</Aside>
|
||||
|
||||
## What the `uo` module adds
|
||||
|
||||
Watch the log at the restart and you will see exactly what it mounted:
|
||||
|
||||
```
|
||||
[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard
|
||||
[modules] schema ensured for module "uo"
|
||||
[modules] module "uo" started
|
||||
```
|
||||
|
||||
Its capabilities are {platform.moduleUoCapabilities.join(', ')} — the shard console, the
|
||||
map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion
|
||||
boards, and the cliloc strings that make item names readable.
|
||||
|
||||
A module owns **one path segment** wherever it appears, so its pages live under `/uo/…`,
|
||||
`/admin/uo/…` and `/player/uo/…`. That boundary is visible in the URL on purpose.
|
||||
|
||||
<Aside type="caution" title="A module with no game server behind it is empty, not broken">
|
||||
Installing `uo` does not connect anything. Its screens exist and report the shard as
|
||||
offline until you
|
||||
[connect a game server](/docs/getting-started/connect-a-game-server/) — which is the same
|
||||
thing the public site does when the shard goes down, and is designed to be unremarkable.
|
||||
</Aside>
|
||||
|
||||
## The declarative alternative
|
||||
|
||||
A host whose Compose file is version-controlled can skip the panel entirely: set `MODULES`
|
||||
in `.env`, one entry per module, `<id>@<version>=<install manifest URL>`. The container
|
||||
resolves that set at every start.
|
||||
|
||||
A module already unpacked at the declared version is left alone **without a single network
|
||||
call**, so a restart with no route to the internet comes up unchanged. A failure is logged
|
||||
and shown in Admin → Modules, and never stops the site from starting.
|
||||
|
||||
The two surfaces agree on a rule worth knowing: **the variable owns what is on the volume,
|
||||
the admin panel owns whether a module runs.** A module you disable in the panel stays
|
||||
disabled even though its files are put back at the next start.
|
||||
|
||||
More on both in [Managing modules](/docs/administration/managing-modules/).
|
||||
|
||||
Next: [Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
140
src/content/docs/docs/getting-started/install-the-site.mdx
Normal file
140
src/content/docs/docs/getting-started/install-the-site.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Install the site
|
||||
description: A complete Docker Compose deployment you can copy from this page — two files, two commands.
|
||||
---
|
||||
|
||||
import { Aside, Code, Steps } from '@astrojs/starlight/components';
|
||||
import { compose, env, omittedServices } from '../../../../data/quickstart.mjs';
|
||||
|
||||
export const envText = env.map((e) => `${e.key}=${e.value}`).join('\n');
|
||||
export const fillLines = env.filter((e) => e.fill).map((e) => `${e.key}=${e.value}`);
|
||||
|
||||
The site is a Docker deployment: a MariaDB container, the prebuilt application image, and
|
||||
two files you write. Nothing is compiled on your host, and there is no repository to clone
|
||||
— everything you need is on this page.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Make a directory for the deployment.**
|
||||
|
||||
Everything below is relative to it, and the bind mounts want to exist before the
|
||||
containers do — Docker creates a missing mount source as `root`, and the container user
|
||||
then cannot write it.
|
||||
|
||||
```bash
|
||||
mkdir -p runic-gateway/logs runic-gateway/brand runic-gateway/modules
|
||||
cd runic-gateway
|
||||
```
|
||||
|
||||
2. **Write `docker-compose.yml`.**
|
||||
|
||||
<Code code={compose} lang="yaml" title="docker-compose.yml" />
|
||||
|
||||
This file only ever *pulls*. There is no `build:` anywhere in it, which is deliberate:
|
||||
a production host should not be able to build an image by accident.
|
||||
|
||||
3. **Write `.env` beside it.**
|
||||
|
||||
Every highlighted line must be changed before this is a real deployment. The secrets
|
||||
want to be long random strings — `openssl rand -base64 36` three times is enough.
|
||||
|
||||
<Code code={envText} lang="ini" title=".env" mark={fillLines} />
|
||||
|
||||
4. **Pull and start.**
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The database comes up first; the app waits for its health check, creates its schema,
|
||||
seeds defaults, creates your first admin, and starts listening.
|
||||
|
||||
5. **Check that it is up.**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
If that answers, the site is running. Go to
|
||||
[First run](/docs/getting-started/first-run/).
|
||||
|
||||
</Steps>
|
||||
|
||||
## What you just deployed
|
||||
|
||||
```
|
||||
localhost:3000 ──► app (the website: API + the built React client, one process)
|
||||
│
|
||||
└──► db (MariaDB, no host port — only the app can reach it)
|
||||
```
|
||||
|
||||
Four host directories and two volumes hold everything that survives a container:
|
||||
|
||||
| Path | What is in it |
|
||||
|---|---|
|
||||
| `./logs/` | `app.log`, readable from the host without `docker exec` |
|
||||
| `./modules/` | Installed [modules](/docs/getting-started/install-a-game-module/). A bind mount, so placing one by hand is a supported install |
|
||||
| `./brand/` | Your logo, hero and favicon, if you replace the defaults ([Branding and theming](/docs/administration/branding-and-theming/)) |
|
||||
| `dbdata` volume | The database |
|
||||
| `uploads` volume | Everything uploaded through the site |
|
||||
|
||||
<Aside type="caution" title="`restart: unless-stopped` is load-bearing">
|
||||
It is not boilerplate. Installing a module needs a restart, and the admin panel offers a
|
||||
button for it — that button exits the process and lets the supervisor bring it back. On a
|
||||
deployment with nothing supervising the process, the button takes the site down and leaves
|
||||
it down. Docker Compose is the supervisor here, and this line is what makes it one.
|
||||
</Aside>
|
||||
|
||||
## Two variables worth reading twice
|
||||
|
||||
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot
|
||||
token, the shard's auth token. In production the server **refuses to start** without it.
|
||||
Changing it later does not re-encrypt anything: what was stored under the old key can no
|
||||
longer be read, and every stored secret has to be entered again.
|
||||
|
||||
**`BOT_INTERNAL_KEY`** authenticates the internal channel between the site and the Discord
|
||||
bot. The server also refuses to start in production if it is blank, left at a placeholder,
|
||||
or shorter than 16 characters — even when, as here, you are not running the bot yet.
|
||||
|
||||
<Aside type="note" title="Both of those are set once, before the first boot">
|
||||
They are not "fill in later" values. The first boot is when your admin account and the
|
||||
site's defaults are written, and it will not happen at all until both are set.
|
||||
</Aside>
|
||||
|
||||
## What this quickstart leaves out
|
||||
|
||||
The project's shipped Compose file has two more services. Neither is needed to boot, and
|
||||
each is introduced where it is configured:
|
||||
|
||||
<ul>
|
||||
{Object.entries(omittedServices).map(([name, why]) => (
|
||||
<li key={name}><strong><code>{name}</code></strong> — {why}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
It also leaves out the branding, logging and session variables, which have working
|
||||
defaults and their own admin screens. The full file and the full environment reference are
|
||||
in the [website repository](https://gitea.whitlocktech.com/RunicGateway/website).
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Not required to get started, and required before anyone else uses the site. Two settings
|
||||
here are what make it correct:
|
||||
|
||||
- **`TRUST_PROXY=1`** tells the app to read the client's address from `X-Forwarded-For`.
|
||||
Rate limiting, login backoff and the bot-scoring IP bans are all only as accurate as
|
||||
that. Set it to the number of proxies in front of the app, or pin it to the proxy's
|
||||
address; a blanket `true` is rejected on purpose, because it would let anyone spoof
|
||||
their address by sending a header.
|
||||
- **`COOKIE_SECURE=auto`** issues a `Secure` session cookie when the request arrives over
|
||||
HTTPS and a plain one otherwise, so logging in works both through the proxy and directly
|
||||
on the LAN while you are setting up.
|
||||
|
||||
Point the proxy at port 3000. Do not forward `INTERNAL_PORT` (3001) — it is the
|
||||
server-to-bot channel, it is deliberately not published by the Compose file, and it must
|
||||
never be reachable from outside.
|
||||
60
src/content/docs/docs/getting-started/requirements.mdx
Normal file
60
src/content/docs/docs/getting-started/requirements.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Requirements
|
||||
description: What you need on the website host, and what you need on the game server host, before you begin.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Two hosts, two lists. They can be the same machine, but they are separate deployments and
|
||||
have nothing in common except the four values you will paste between them.
|
||||
|
||||
## The website host
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| **Docker** with Compose v2 | `docker compose version` should print v2.x. The site ships as prebuilt images and pulls them; nothing is built on your host. |
|
||||
| **Outbound HTTPS** to `gitea.whitlocktech.com` | To pull the images, and later to install a module. Nothing inbound is required for the install itself. |
|
||||
| **~2 GB of disk to start** | Two images, a MariaDB volume, and an uploads volume. Uploads grow with what your community posts. |
|
||||
| **A hostname and TLS, eventually** | Not needed to boot — you can reach it on `http://localhost:3000` first. Needed before anyone else uses it: see [Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the reverse-proxy notes. |
|
||||
|
||||
There is no separate database to install: MariaDB comes up as a container beside the app,
|
||||
and the schema is created on first boot.
|
||||
|
||||
<Aside type="note" title="Windows and macOS are fine for trying it">
|
||||
The images are Linux containers, so Docker Desktop runs them. For a deployment other people
|
||||
depend on, a Linux host is the shape everything else assumes — the log paths, the bind
|
||||
mounts and the reverse-proxy notes all read that way.
|
||||
</Aside>
|
||||
|
||||
## The game server host
|
||||
|
||||
Only if you are connecting a game server. Today that means a ServUO shard, which is what
|
||||
the [`uo` module](/docs/getting-started/install-a-game-module/) and the installer support.
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
|
||||
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||
| **Administrator / root** | It writes into system directories and registers a service. |
|
||||
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
|
||||
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
|
||||
|
||||
<Aside type="caution" title="Back up before the shard install">
|
||||
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
|
||||
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
|
||||
between an experiment and a gamble. The installer keeps its own backups too — see
|
||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
</Aside>
|
||||
|
||||
## What you do not need
|
||||
|
||||
- **A Gitea account.** Everything the installers fetch is a public release asset.
|
||||
- **A build toolchain.** Not on either host. The site pulls images; the module arrives as a
|
||||
verified tarball; the shard plugin is C# source that ServUO itself compiles at boot.
|
||||
- **An inbound port on the game host** — for the *game*. The shard never listens for the
|
||||
website. If the website runs on a different machine from the shard, the **sidecar** needs
|
||||
to be reachable by the website, and that is the one hole you will open deliberately.
|
||||
|
||||
Next: [Install the site](/docs/getting-started/install-the-site/).
|
||||
103
src/content/docs/docs/getting-started/verify-the-whole-stack.mdx
Normal file
103
src/content/docs/docs/getting-started/verify-the-whole-stack.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Verify the whole stack
|
||||
description: Four checks, one per link in the chain, that distinguish "files copied" from "the bridge works".
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
A successful install is not a working bridge, and the failure is quiet in a specific way:
|
||||
**ServUO shells out to `dotnet build`, prints the output, ignores the exit code, and
|
||||
reloads the existing `Scripts.dll`.** A broken script build looks exactly like a clean boot.
|
||||
|
||||
So verify each link in the chain, in order. Each check tells you which one to fix.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **The plugin compiled — watch the boot output.**
|
||||
|
||||
Start your shard the way you always do. You want the build to succeed *and* the bridge
|
||||
to announce itself:
|
||||
|
||||
```
|
||||
Core: Compiling scripts...
|
||||
Build succeeded.
|
||||
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 …
|
||||
```
|
||||
|
||||
If you scrolled past it, force the question:
|
||||
|
||||
```bash
|
||||
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
|
||||
```
|
||||
|
||||
2. **The shard is connected — ask it in game.**
|
||||
|
||||
As an administrator:
|
||||
|
||||
```
|
||||
[bridge status
|
||||
```
|
||||
|
||||
It reports `connected=True depth=0 sent=… dropped=0`. `connected=False` means the shard
|
||||
cannot reach the sidecar. `dropped` climbing means the sidecar is wedged and the shard
|
||||
is shedding events rather than stalling — which is what it is designed to do, and why a
|
||||
broken bridge never freezes your game.
|
||||
|
||||
`[bridge reload` re-reads `Bridge.cfg` without a restart; `[bridge sweepnow` forces one
|
||||
pass of every stream.
|
||||
|
||||
3. **The sidecar is healthy — ask it over HTTP.**
|
||||
|
||||
`/health` needs no auth, so it is safe to curl on the shard host:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
```json
|
||||
{"status":"ok","protocol":4,"plugin_connected":true,"database":"ok","uptime":"2m"}
|
||||
```
|
||||
|
||||
**`plugin_connected: true` is the one that matters.** It is the only value in this whole
|
||||
sequence that distinguishes "files copied" from "the bridge works".
|
||||
|
||||
4. **The website is ingesting — look at the shard screen.**
|
||||
|
||||
On the site, open **Shard (uo-link)** (`/admin/uo/link`). The header should read
|
||||
**Connected**, with *Shard link: up* and *WS ingest: online*, and the live feed at the
|
||||
bottom should start showing events within seconds rather than sitting on
|
||||
*Waiting for shard events…*.
|
||||
|
||||
Then check the public side: the shard status page should stop reporting the game as
|
||||
offline.
|
||||
|
||||
</Steps>
|
||||
|
||||
## When one of them fails
|
||||
|
||||
| What you see | What it means |
|
||||
|---|---|
|
||||
| Shard boots clean, nothing reaches the site | The classic silent failure — a stale `Scripts.dll`. Run the `dotnet build` line above and read the errors. |
|
||||
| `[bridge` is not a command | The plugin did not compile, or the bridge is disabled in `Bridge.cfg`. |
|
||||
| `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service, and that `[shard] bind` matches `Host`/`Port` in `Bridge.cfg`. |
|
||||
| `/health` is fine locally, the site says offline | The website cannot reach port 8080 — bind address or firewall. The site is *designed* to render normally with the shard down, so this fails quietly. |
|
||||
| The site logs `409` from the sidecar | Protocol mismatch. Set the Protocol field to what `/health` reports rather than guessing; the sidecar rejects rather than mis-parsing. |
|
||||
| `401` from the sidecar | Wrong or missing token. Read the live one back with `uo-link-sidecar --print-config`; do not retype it from a screenshot. |
|
||||
|
||||
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
|
||||
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
|
||||
hash, the patch tier, the sidecar, its service, `/health`, and that the sidecar and overlay
|
||||
agree on a protocol. Its output is the first thing anyone helping you will ask for. It
|
||||
exits non-zero when a check failed, so a monitoring system can run it too.
|
||||
</Aside>
|
||||
|
||||
## What "working" looks like a week later
|
||||
|
||||
- The public shard page shows live status, and the admin dashboard shows events arriving.
|
||||
- `dropped` in `[bridge status` stays at zero. A climbing number means the sidecar is
|
||||
wedged, not that the shard is unhealthy.
|
||||
- `doctor` is still green after a shard update — that is what catches an overlay file
|
||||
reverted by hand or by a ServUO upgrade.
|
||||
|
||||
You have finished the installation path. From here,
|
||||
[Administration](/docs/administration/configuration/) covers running the site day to day.
|
||||
@@ -13,13 +13,6 @@ network-facing component, and only the website's backend is allowed to talk to i
|
||||
website degrades gracefully when the game is down, and sensitive events never reach the
|
||||
public event stream.
|
||||
|
||||
:::note[This documentation is being written in phases]
|
||||
The scaffold, theme and sidebar are in place. The pages themselves land in phases 7 and 8,
|
||||
starting with the installation path — which is the priority of the whole project, because
|
||||
the repositories treat the site and the shard as separate deployments and nothing today
|
||||
presents them as one sequence.
|
||||
:::
|
||||
|
||||
## What the platform is on today
|
||||
|
||||
<table>
|
||||
@@ -46,10 +39,30 @@ two independent deployments.
|
||||
2. **The shard side** is the installer binary, run on the game server's host. It sets up
|
||||
the plugin overlay and the sidecar, and it never contacts the website.
|
||||
|
||||
They meet at four values pasted into **Admin → Shard**, and at protocol {platform.protocol},
|
||||
which both sides check before they will pair.
|
||||
They meet at four values pasted into the module's shard screen, and at protocol
|
||||
{platform.protocol}, which both sides check before they will pair.
|
||||
|
||||
## Where to go next
|
||||
You can stop after the first one. A site with no game server attached is a complete
|
||||
community website — news, wiki, pages, Teams, forums, accounts and moderation are all core,
|
||||
and none of them knows a game exists. The second install is what fills the game screens.
|
||||
|
||||
## Start here
|
||||
|
||||
The seven pages of **Getting started** are that sequence, in order, and each one says what
|
||||
you should expect to see before you move on:
|
||||
|
||||
1. [Requirements](/docs/getting-started/requirements/) — what you need on both hosts
|
||||
2. [Install the site](/docs/getting-started/install-the-site/) — Docker Compose, pull-only
|
||||
3. [First run](/docs/getting-started/first-run/) — the first admin, and maintenance → live
|
||||
4. [Install a game module](/docs/getting-started/install-a-game-module/) — what makes the game screens exist
|
||||
5. [Connect a game server](/docs/getting-started/connect-a-game-server/) — the installer, on the shard host
|
||||
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
|
||||
|
||||
Then **Administration** covers running it: configuration, branding, content, users,
|
||||
authentication, Teams, moderation, notifications, modules, the shard connection, upgrades,
|
||||
and what to do when something is wrong.
|
||||
|
||||
## Where the truth lives
|
||||
|
||||
The canonical, normative documents live in the
|
||||
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs) and always win over
|
||||
|
||||
183
src/data/app.mjs
Normal file
183
src/data/app.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* app.mjs — what the Android client does, as data. PLAN.md §10 `/app/`, phase 5.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS NOT capabilities.mjs
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `capabilities.mjs` describes what a DEPLOYMENT does, and it is checked against
|
||||
* `module-uo`'s manifest because the module declares its capabilities in a machine-readable
|
||||
* file. The app declares nothing of the kind: what it does is a set of screens in
|
||||
* `Routes.kt`, and there is no manifest to diff against. So these are written from that
|
||||
* file and carry the route names, which is the closest thing to a citation available — a
|
||||
* reader who wants to check a claim here has a file to open.
|
||||
*
|
||||
* That is also why the app's list is shorter than the platform's rather than a mirror of
|
||||
* it. The app is a client for the parts of a deployment a person uses on a phone; the parts
|
||||
* it does not have are not missing, they are the parts nobody wants on a phone.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* "GATED BY WHAT THE DEPLOYMENT PUBLISHES" IS LOAD-BEARING, NOT A DISCLAIMER
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Almost everything here is conditional on the site the app is pointed at: shard screens
|
||||
* appear only when the deployment runs a game module and has that visibility feature
|
||||
* turned on, staff screens only for a staff account, push only where the operator runs an
|
||||
* ntfy. A features list that omitted that would be describing an app nobody will see,
|
||||
* because there is no default deployment — see `requiresDeployment` below.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The single most misunderstood thing about this app, stated once and rendered prominently.
|
||||
*
|
||||
* `ConnectScreen.kt` on `Android-app` `main`: "First-run 'Connect to your shard's website'
|
||||
* screen. Nothing else in the app runs until a valid Runic Gateway site is entered and
|
||||
* validated." Not a soft default that can be changed later in settings — the gate on
|
||||
* everything else. An install with no deployment behind it is a text field, and a page that
|
||||
* let somebody find that out after downloading would have wasted their time on purpose.
|
||||
*/
|
||||
export const requiresDeployment = {
|
||||
title: 'It is a client. It ships pointed at nothing.',
|
||||
body:
|
||||
'The first screen asks for the web address of a site running Runic Gateway and checks ' +
|
||||
'it before anything else in the app will open. There is no default server, no ' +
|
||||
'directory of servers, and no account with us — the app talks to the deployment you ' +
|
||||
'name and to nothing else. If you do not run one and are not a member of a community ' +
|
||||
'that does, the app has nothing to show you yet.',
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} AppFeature
|
||||
* @property {string} title
|
||||
* @property {string} body
|
||||
* @property {string} [gate] What the deployment must provide for this to appear at all.
|
||||
*/
|
||||
|
||||
/** @type {{ heading: string, blurb: string, items: AppFeature[] }[]} */
|
||||
export const appFeatures = [
|
||||
{
|
||||
heading: 'The shard, live',
|
||||
blurb:
|
||||
'The same feed the website shows, on a phone. Every one of these appears only when ' +
|
||||
'the deployment runs a game module and the operator has published that surface — ' +
|
||||
'the visibility settings are per-feature and default to off.',
|
||||
items: [
|
||||
{
|
||||
title: 'Status, and the boards',
|
||||
body:
|
||||
'Whether the game server is up, plus champion spawns, guilds, city governors and ' +
|
||||
'houses as the shard reports them.',
|
||||
gate: 'A game module, and the matching visibility feature',
|
||||
},
|
||||
{
|
||||
title: 'The player marketplace',
|
||||
body:
|
||||
'Player-run vendors and what is on their shelves, down to a single vendor. Item ' +
|
||||
"names arrive as the game's own string ids and are resolved against its string " +
|
||||
'table, so they read the way they read in the client.',
|
||||
gate: 'A game module publishing the market',
|
||||
},
|
||||
{
|
||||
title: 'Rules, leaderboards and the spawn atlas',
|
||||
body:
|
||||
"The deployment's published ruleset, its points and loyalty boards, and the " +
|
||||
'creature atlas — what spawns where, and what it drops.',
|
||||
gate: 'A game module, per feature',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "The site's content",
|
||||
blurb: 'News, wiki and pages, read natively rather than in a browser frame.',
|
||||
items: [
|
||||
{
|
||||
title: 'News, by category',
|
||||
body:
|
||||
"Announcements and posts in the categories the site defines, and a link from " +
|
||||
'anywhere on the web opens the matching tab rather than the top of the list.',
|
||||
},
|
||||
{
|
||||
title: 'The wiki and the site pages',
|
||||
body:
|
||||
'Wiki articles and whatever pages the operator has written in the admin panel, ' +
|
||||
'including the ones they added to the navigation themselves.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Your account',
|
||||
blurb:
|
||||
'Staff are players too — the self-service screens are role-agnostic, and an ' +
|
||||
'administrator sees their own characters on the same screen everybody else does.',
|
||||
items: [
|
||||
{
|
||||
title: 'Signing in, including two-factor',
|
||||
body:
|
||||
'A native sign-in with an authenticator code or a single-use recovery code, or ' +
|
||||
"single sign-on handed off to the deployment's own provider in a browser tab. " +
|
||||
'Trusting a device skips the code for thirty days, and that trust is revocable ' +
|
||||
'per device from the app.',
|
||||
},
|
||||
{
|
||||
title: 'Your characters, vendors and houses',
|
||||
body:
|
||||
'Character sheets, the vendors you run and the houses you own, visible to the ' +
|
||||
'account they belong to and to nobody else.',
|
||||
gate: 'A game module',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Notifications, on infrastructure the operator owns',
|
||||
blurb:
|
||||
'Opt-in, per stream, and delivered without a third party — which is unusual enough ' +
|
||||
'to be worth spelling out.',
|
||||
items: [
|
||||
{
|
||||
title: 'Self-hosted push',
|
||||
body:
|
||||
"Push arrives over the deployment's own ntfy server, not Firebase. The app has " +
|
||||
'no Google messaging dependency at all, which is why it works on a device with ' +
|
||||
'no Play Services and why no notification passes through anyone else on its way ' +
|
||||
'to the phone.',
|
||||
gate: 'An ntfy server the operator runs',
|
||||
},
|
||||
{
|
||||
title: 'The message carries no content',
|
||||
body:
|
||||
'What is pushed is which stream fired and an opaque reference — never the ' +
|
||||
'subject, the sender or the text. The app opens the right screen and fetches the ' +
|
||||
'actual content over the authenticated API, so a notification sitting on a lock ' +
|
||||
'screen discloses nothing.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Staff work, if you are staff',
|
||||
blurb:
|
||||
'Gated by role in the menu and re-checked against the database on every request, so ' +
|
||||
'a demoted account loses the screens immediately rather than at next sign-in.',
|
||||
items: [
|
||||
{
|
||||
title: 'Moderation, support and content',
|
||||
body:
|
||||
'The dashboard, the moderation queue, support requests and content editing — ' +
|
||||
'enough to answer a report from a phone. The surfaces that would let somebody ' +
|
||||
'reconfigure the deployment stay on the web.',
|
||||
gate: 'A staff role on the deployment',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'It looks like the deployment it is pointed at',
|
||||
blurb: '',
|
||||
items: [
|
||||
{
|
||||
title: 'The theme comes down the wire',
|
||||
body:
|
||||
"The palette, the type, the corner radii, the logo, the hero image and even the " +
|
||||
"navigation order are read from the site's own appearance settings. Two " +
|
||||
'communities running this app do not see the same app, and neither of them had ' +
|
||||
'to build one.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
203
src/data/beta.mjs
Normal file
203
src/data/beta.mjs
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* beta.mjs — the closed beta as data: the limits, the consent wording, and the two gates
|
||||
* that are not open yet. PLAN.md §8, built in phase 5.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE CONSENT TEXT LIVES HERE AND NOT IN THE MARKUP
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §8's schema stores `consent_text` — the exact wording somebody agreed to — rather than a
|
||||
* version number, so a row can always answer "what did this person actually consent to"
|
||||
* without going back through the git history of a template. That only works if the string
|
||||
* the page renders and the string the row records are the same object. One export, read by
|
||||
* the label on the checkbox and by the insert.
|
||||
*
|
||||
* Editing it is therefore a real act: every row written from that moment carries the new
|
||||
* wording, and the old rows keep the old one, which is the behaviour that makes the column
|
||||
* worth having. `CONSENT_VERSION` is not what the row stores — it exists so an operator
|
||||
* reading a CSV can group rows without diffing prose.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE TWO GATES (D26, D27)
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The page collects signups today and cannot invite anybody yet, for two independent
|
||||
* reasons, and it says both plainly rather than implying a queue that is moving:
|
||||
*
|
||||
* 1. THE PLAY TRACK. A closed test has an opt-in URL, and that URL only works for
|
||||
* addresses already on the tester list — which is the whole reason §8 can publish it
|
||||
* and still send no email (D7). The developer account exists; the track does not, so
|
||||
* there is no URL yet. It is a `brand.json` field for the same reason `demoUrl` is:
|
||||
* the day the track opens, the confirmation screen gains a working link for the cost
|
||||
* of a file copy and a restart (§7).
|
||||
*
|
||||
* 2. SOMEWHERE TO POINT IT. `ConnectScreen.kt` on `Android-app` `main` is blunt about
|
||||
* this — "nothing else in the app runs until a valid Runic Gateway site is entered and
|
||||
* validated". An installed app with no deployment behind it is a text field. D27 makes
|
||||
* the public demo (§15) the tester target rather than naming a private shard, which
|
||||
* means the beta opens when the demo VM does. `notBuilt.mjs` already carries that
|
||||
* absence; this page renders the demo link from `demoUrl` when there is one.
|
||||
*
|
||||
* Neither gate is a reason not to collect addresses now — the list is what makes the first
|
||||
* batch possible on day one — but a page that hid them would be advertising a beta that
|
||||
* cannot start, which is exactly what §1 forbids.
|
||||
*/
|
||||
|
||||
import { legal } from './legal.mjs';
|
||||
|
||||
/**
|
||||
* Google Play's closed-testing rules, as verified in the Play Console documentation on
|
||||
* **2026-08-24**.
|
||||
*
|
||||
* These are the one class of fact on this site that `checkFacts.mjs` cannot police: there
|
||||
* is no API to fetch them from and Play's testing requirements have changed more than once
|
||||
* (§8 says so in as many words). So they carry a date, they live in data rather than prose
|
||||
* like every other fact here, and the date is rendered on the page next to them. A reader
|
||||
* can tell how old the claim is, and so can whoever re-checks it before launch.
|
||||
*/
|
||||
export const playPolicy = {
|
||||
verifiedOn: '2026-08-24',
|
||||
/** Opted-in testers required, continuously, before production access can be requested. */
|
||||
testersRequired: 12,
|
||||
/** Consecutive days those testers must stay opted in. */
|
||||
testerDays: 14,
|
||||
/** Addresses one pasted email list holds. Context for the cap below, not a target. */
|
||||
addressesPerList: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* The signup limits (§8's "abuse resistance without a third party").
|
||||
*
|
||||
* Every one of these is overridable by environment variable, and that is deliberate: the
|
||||
* numbers are guesses about a form nobody has attacked yet, and the alternative to tuning
|
||||
* them from the compose file is rebuilding an image to change an integer.
|
||||
*
|
||||
* `TOTAL_CAP` is the org lead's choice of 500 — far above any plausible demand for a beta
|
||||
* that needs twelve people, and far below one list's 2,000, so a run that beats both the
|
||||
* honeypot and the bucket still cannot fill the box before the form closes and says so.
|
||||
*/
|
||||
export const limits = {
|
||||
/** Rows, across all time, above which the form closes. */
|
||||
totalCap: readInt('BETA_TOTAL_CAP', 500),
|
||||
/** Signups one `ip_hash` may make in a rolling hour. */
|
||||
perHour: readInt('BETA_PER_HOUR', 3),
|
||||
/** Signups one `ip_hash` may make in a rolling day. */
|
||||
perDay: readInt('BETA_PER_DAY', 24),
|
||||
/**
|
||||
* Seconds a human plausibly needs between the page rendering and the form posting.
|
||||
*
|
||||
* Two is §8's number and it is generous in the right direction: a person who has already
|
||||
* decided still has to type an address and tick a box. A script does not.
|
||||
*/
|
||||
minSeconds: readInt('BETA_MIN_SECONDS', 2),
|
||||
/**
|
||||
* Seconds after which a rendered form is stale.
|
||||
*
|
||||
* Not an abuse control — a page left open overnight has a timestamp that says nothing,
|
||||
* and re-rendering the form is a better answer than trusting it. Twelve hours.
|
||||
*/
|
||||
maxSeconds: readInt('BETA_MAX_SECONDS', 12 * 60 * 60),
|
||||
};
|
||||
|
||||
function readInt(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
// Loud, and then carry on with the default. A typo in a compose file should not stop
|
||||
// the site from booting, and it must not silently become an unlimited form either.
|
||||
console.warn(`[beta] ignoring ${name}="${raw}" — expected a positive integer.`);
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A label for the batch a row was written in. Stored in no column — see the header.
|
||||
*
|
||||
* Suffixed rather than re-dated when phase 6 added the age clause on the same day the
|
||||
* original wording was written: two different sentences must not share a label, and the
|
||||
* date is what an operator groups a CSV by.
|
||||
*/
|
||||
export const CONSENT_VERSION = '2026-08-24b';
|
||||
|
||||
/**
|
||||
* The exact sentence beside the checkbox, and the exact sentence written to `consent_text`.
|
||||
*
|
||||
* Written to be true of what the code does, not of what a privacy policy template says:
|
||||
* the address is kept until the beta ends or removal is asked for, it is pasted into Play
|
||||
* because that is the only way Play accepts testers, and nothing is mailed to it because
|
||||
* the site cannot send mail at all (D7).
|
||||
*
|
||||
* Phase 6 added the age (D31), and it goes FIRST because it is the only clause the person
|
||||
* ticking the box is asserting rather than acknowledging — everything after it is a
|
||||
* description of what we do. `legal.minimumAge` is interpolated rather than typed, because
|
||||
* /privacy and /terms state the same number and the Data Safety notes answer a question
|
||||
* about it; four surfaces, one source.
|
||||
*
|
||||
* Editing this string is a real act: `consent_text` stores the wording rather than a
|
||||
* version, so rows written from here on carry the new sentence and older rows keep the one
|
||||
* they were given. That is the property that makes the column worth having.
|
||||
*/
|
||||
export const CONSENT_TEXT =
|
||||
`I am ${legal.minimumAge} or older. I understand my email address will be stored so it ` +
|
||||
'can be added to the Google Play closed test, that it will be shared with Google Play ' +
|
||||
'for that purpose only, that Runic Gateway sends no email of any kind, and that I can ' +
|
||||
'ask for it to be deleted at any time.';
|
||||
|
||||
/**
|
||||
* What a tester needs, rendered as the page's eligibility list.
|
||||
*
|
||||
* The third item is the one that matters and the one a beta page usually omits. It is
|
||||
* phrased as a dependency rather than a warning because it is one: the app is a client for
|
||||
* a deployment, and a client with no server is not a product with a missing feature.
|
||||
*/
|
||||
export const requirements = [
|
||||
{
|
||||
title: `Being ${legal.minimumAge} or older`,
|
||||
body:
|
||||
'The beta is for adults. Nothing verifies it and nothing pretends to — ticking the ' +
|
||||
'box on the form is the whole of it — but it is the condition the list is collected ' +
|
||||
'under, and it is why the form needs no parental consent machinery it could not ' +
|
||||
'honestly operate.',
|
||||
},
|
||||
{
|
||||
title: 'An Android device on 10 or newer',
|
||||
body:
|
||||
// No backticks. These strings render as text, not as Markdown, so a reader sees the
|
||||
// punctuation rather than code formatting — caught by looking at the built page.
|
||||
'API level 29 is the minimum the app is built against. Phones and tablets both; ' +
|
||||
'there is no TV or Wear build and none is planned.',
|
||||
},
|
||||
{
|
||||
title: 'A Google account, and the willingness to stay opted in',
|
||||
body:
|
||||
'Play counts testers who are opted in continuously. Leaving the test and rejoining ' +
|
||||
'resets that count for everybody, which is the one thing a tester can do that ' +
|
||||
'actually costs something.',
|
||||
},
|
||||
{
|
||||
title: 'A Runic Gateway deployment to connect to',
|
||||
body:
|
||||
'The app ships pointed at nothing. Its first screen asks for the address of a site ' +
|
||||
'running this platform and validates it before anything else in the app will run — ' +
|
||||
'so a tester needs either their own deployment or the public demo, which is the ' +
|
||||
'second of the two things this beta is waiting on.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The form's field names, in one place because three files need to agree about them: the
|
||||
* markup that renders the inputs, the handler that reads the body, and the test that posts
|
||||
* one. A honeypot whose name drifts is a honeypot that catches nothing, and nothing about
|
||||
* a passing build would say so.
|
||||
*
|
||||
* `HONEYPOT` is named for something a form plausibly has and a bot will want to fill.
|
||||
* Naming it `honeypot` would be a note to the bot.
|
||||
*/
|
||||
export const fields = {
|
||||
EMAIL: 'email',
|
||||
CONSENT: 'consent',
|
||||
HONEYPOT: 'website',
|
||||
/** When the form was rendered — signed, see `betaSignup.mjs`. */
|
||||
ISSUED: 'ts',
|
||||
};
|
||||
503
src/data/capabilities.mjs
Normal file
503
src/data/capabilities.mjs
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* capabilities.mjs — the five capability groups of PLAN.md §10, as data.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS DATA AND NOT MARKUP
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The homepage names these groups, `/features/` expands them, and `/modules/` explains the
|
||||
* core/module split they encode. Three pages listing the same capabilities in three
|
||||
* hand-maintained lists is how a site ends up advertising something that was removed, which
|
||||
* §1 forbids. One list, read by all three.
|
||||
*
|
||||
* Phase 4 added the `detail` line rather than writing `/features/` as prose (D20). The two
|
||||
* pages are then one list rendered twice — `/` takes the label, `/features/` takes the
|
||||
* label and the detail — and they cannot disagree about what exists, only about how much
|
||||
* they say. `assertDetailCoverage()` below is what stops the next capability being added to
|
||||
* the homepage without an argument to go with it.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE PART THAT IS A CHECK, NOT A LIST
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* "Game intelligence" is the only group core does not supply — it comes from whichever
|
||||
* module is installed, and today that is `module-uo`. Its items therefore carry the
|
||||
* capability slugs the module actually declares in its `module.json`, and
|
||||
* `assertCapabilityCoverage()` fails the build if the two lists drift apart.
|
||||
*
|
||||
* That closes a real gap. `platform.json` holds `moduleUoCapabilities` and
|
||||
* `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the
|
||||
* day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this
|
||||
* function, updating the JSON was the end of it and the page kept the old list. Now the
|
||||
* page is what goes red next.
|
||||
*
|
||||
* Note that slugs are NOT one-per-item in either direction: `shard` is the source of four
|
||||
* separate user-facing capabilities, and the marketplace draws on `market` and `cliloc`
|
||||
* together (item names arrive as cliloc ids and are resolved against the shard's own
|
||||
* string table). The check is coverage in both directions, not a bijection.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A group is `moduleSupplied` or it is not, and phase 3 shipped the Community group saying
|
||||
* "everything here works on a deployment with no game module installed at all". Writing the
|
||||
* `/features/` detail for Teams is what exposed that as false, and the tree says so plainly
|
||||
* on `main`: `teams.module_id` is `NOT NULL`, there is no create route anywhere under
|
||||
* `/api/v1/admin/teams`, and `teamSync` is gated on `teamProvider.providerModuleId()`.
|
||||
*
|
||||
* The truth is neither of the two states the file had. Core owns the whole Team machinery —
|
||||
* the tables, the roster resolver, the forums, the notification streams, the Discord bridge,
|
||||
* the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team.
|
||||
* They arrive from the installed module, which is exactly the point: core does not own the
|
||||
* word for a Team, so `module-uo` calls them guilds and builds the pages, and a future
|
||||
* module can call them something else on the same primitive.
|
||||
*
|
||||
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
|
||||
* core it is present, correct and permanently empty. `/` renders the requalified group
|
||||
* summary; `/features/` renders the marker and says why (D24).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of
|
||||
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
|
||||
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
|
||||
* per capability that has a stable public route, appended to the mounted `demoUrl` by
|
||||
* `applyBrand.mjs` at boot and hidden by the same `[data-demo-url='']` rule as the
|
||||
* homepage's slot.
|
||||
*
|
||||
* Only some capabilities have one, and that asymmetry is honest rather than unfinished:
|
||||
* character sheets are reachable only by the account they belong to, and a Team forum lives
|
||||
* behind a Team id no static page can know. Paths are read from the real route tables —
|
||||
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
|
||||
* guessed. Note the module's public pages are namespaced under its own id, so a UO route is
|
||||
* `/uo/…`; a deployment running a different module would deep-link somewhere else, which is
|
||||
* why these sit beside the `caps` slugs on the module-supplied items.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The shape of a capability, written out because TypeScript otherwise infers it per group
|
||||
* from whichever fields that group's items happen to use — and then `/features/` cannot
|
||||
* read `demoPath` off an Administration item, because no Administration item has one.
|
||||
* `astro check` catches that, correctly: the union of five literal shapes is not the shape
|
||||
* the page is written against.
|
||||
*
|
||||
* @typedef {object} Capability
|
||||
* @property {string} label What it is called, on every page that lists it.
|
||||
* @property {string} detail The argument for it. `/features/` only; see D20.
|
||||
* @property {string[]} [caps] Module capability slugs, on module-supplied items only.
|
||||
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
|
||||
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
|
||||
*
|
||||
* @typedef {object} CapabilityGroup
|
||||
* @property {string} id
|
||||
* @property {string} title
|
||||
* @property {string} summary
|
||||
* @property {boolean} moduleSupplied
|
||||
* @property {Capability[]} items
|
||||
*/
|
||||
|
||||
/**
|
||||
* Community — core machinery. Everything here ships with the site itself and none of it
|
||||
* knows what game you run; two of the six still need a module to put anything in them,
|
||||
* which is what `needsModule` says.
|
||||
*
|
||||
* @type {CapabilityGroup}
|
||||
*/
|
||||
const community = {
|
||||
id: 'community',
|
||||
/**
|
||||
* Core, not module-supplied. Stated on every group rather than only on the one that is
|
||||
* true, so the shape of a group is uniform — the homepage reads this field on all five,
|
||||
* and an inferred union that carries it on one member is an error waiting for the next
|
||||
* template that touches it.
|
||||
*/
|
||||
moduleSupplied: false,
|
||||
title: 'Community',
|
||||
summary:
|
||||
'The site your players actually use, none of which knows what game you run — though ' +
|
||||
'Teams arrive from the installed module rather than being created here.',
|
||||
items: [
|
||||
{
|
||||
label: 'Teams',
|
||||
needsModule: true,
|
||||
demoPath: '/uo/guilds',
|
||||
detail:
|
||||
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
|
||||
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
|
||||
'and deliberately cannot create a Team: they arrive from the installed module, ' +
|
||||
'which is how a guild inside the game becomes a Team on the site — and why a ' +
|
||||
'different game can call them something else without core learning a new word.',
|
||||
},
|
||||
{
|
||||
label: 'Team forums',
|
||||
needsModule: true,
|
||||
detail:
|
||||
'Announcements, discussion threads and replies, with an edit window, post ' +
|
||||
'moderation, and abuse reports a member can raise without going through staff ' +
|
||||
'first. Forums are an admin switch for the whole deployment, and image uploads ' +
|
||||
'stay off until someone deliberately turns them on.',
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
detail:
|
||||
'Web, push and email, chosen per stream by each person rather than per person by ' +
|
||||
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
|
||||
'it was asked for.',
|
||||
},
|
||||
{
|
||||
label: 'Wiki',
|
||||
demoPath: '/wiki',
|
||||
detail:
|
||||
'For the things that outlive a news post — rules, guides, the lore nobody wants to ' +
|
||||
'retype in chat. Written in the admin panel, published on the public site.',
|
||||
},
|
||||
{
|
||||
label: 'News and newsletter',
|
||||
demoPath: '/site/news',
|
||||
detail:
|
||||
'Four kinds of post — news, five-on-friday, newsletter issues and screenshots — ' +
|
||||
'plus CMS pages and a page builder for everything that is not a post at all.',
|
||||
},
|
||||
{
|
||||
label: 'Player self-service',
|
||||
detail:
|
||||
'An account area every signed-in person gets, whatever their role: their profile, ' +
|
||||
'their linked game accounts, their own characters, their devices and sessions. ' +
|
||||
'Staff are players too, so it is the same area for everyone.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Game intelligence — module-supplied. The `caps` arrays are the contract with
|
||||
* `platform.json`; see `assertCapabilityCoverage` below.
|
||||
*
|
||||
* @type {CapabilityGroup}
|
||||
*/
|
||||
const gameIntelligence = {
|
||||
id: 'game-intelligence',
|
||||
title: 'Game intelligence',
|
||||
moduleSupplied: true,
|
||||
summary:
|
||||
'Supplied by the installed game module, not by the core site. Today that module is ' +
|
||||
'module-uo, and this is what it publishes from a live shard.',
|
||||
items: [
|
||||
{
|
||||
label: 'Live server status',
|
||||
caps: ['shard'],
|
||||
demoPath: '/uo/shard',
|
||||
detail:
|
||||
'Whether the server is up, who is on it, and how long ago the site last heard from ' +
|
||||
'it. When the game is down this page is the thing that says so — the site does not ' +
|
||||
'go down with it.',
|
||||
},
|
||||
{
|
||||
label: 'Economy and activity',
|
||||
caps: ['shard'],
|
||||
demoPath: '/uo/shard/activity',
|
||||
detail:
|
||||
'A live feed of what is happening in the world, and the economy underneath it. ' +
|
||||
'Every event passes an allowlist before it can reach a public page; staff read a ' +
|
||||
'second stream carrying the rest.',
|
||||
},
|
||||
{
|
||||
label: 'Character sheets',
|
||||
caps: ['shard'],
|
||||
detail:
|
||||
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
|
||||
'somebody filled in. Reachable by the account the character is linked to, and by ' +
|
||||
'staff — not by the public.',
|
||||
},
|
||||
{
|
||||
label: 'Points and loyalty boards',
|
||||
caps: ['shard'],
|
||||
demoPath: '/uo/leaderboards',
|
||||
detail:
|
||||
'The leaderboards the game already keeps, published without anyone exporting a ' +
|
||||
'spreadsheet on a Sunday.',
|
||||
},
|
||||
{
|
||||
label: 'Player-vendor marketplace',
|
||||
caps: ['market', 'cliloc'],
|
||||
demoPath: '/uo/market',
|
||||
detail:
|
||||
'Every player vendor on the server and what is on it, searchable without logging ' +
|
||||
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
|
||||
"against the game's own string table, so they read as names rather than numbers.",
|
||||
},
|
||||
{
|
||||
label: 'Houses and IDOC decay',
|
||||
caps: ['houses'],
|
||||
demoPath: '/uo/houses',
|
||||
detail:
|
||||
'Who owns what and where it stands, including which houses are decaying — ' +
|
||||
'published while it is still information rather than after it has become a rumour.',
|
||||
},
|
||||
{
|
||||
label: 'Spawn atlas',
|
||||
caps: ['atlas'],
|
||||
demoPath: '/uo/atlas',
|
||||
detail:
|
||||
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
|
||||
"describes your server rather than someone else's idea of the game. Regions, " +
|
||||
'landmarks and champion altars come with it.',
|
||||
},
|
||||
{
|
||||
label: 'Champion boards',
|
||||
caps: ['champs'],
|
||||
demoPath: '/uo/champs',
|
||||
detail: 'Which altars are running, how far along they are, and what turned up.',
|
||||
},
|
||||
{
|
||||
label: 'Guilds',
|
||||
caps: ['guilds'],
|
||||
demoPath: '/uo/guilds',
|
||||
detail:
|
||||
'Guild rosters and standings, kept in step with the game. This is also what fills ' +
|
||||
'the Teams primitive above: a guild in the world becomes a Team on the site, with ' +
|
||||
"the forum, the notifications and the voice channel that core attaches to one.",
|
||||
},
|
||||
{
|
||||
label: 'City governors',
|
||||
caps: ['governors'],
|
||||
demoPath: '/uo/governors',
|
||||
detail: 'Who holds which city, and what they did with it.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** @type {CapabilityGroup} */
|
||||
const administration = {
|
||||
id: 'administration',
|
||||
moduleSupplied: false,
|
||||
title: 'Administration',
|
||||
summary: 'Running the place, with a record of who did what.',
|
||||
items: [
|
||||
{
|
||||
label: 'Roles and permissions',
|
||||
detail:
|
||||
'Admin, moderator and player. Admin access is re-checked against the database on ' +
|
||||
'every request rather than trusted from whatever the session was issued with, so ' +
|
||||
'demoting someone takes effect on their next click and not at their next login.',
|
||||
},
|
||||
{
|
||||
label: 'Moderation and appeals',
|
||||
detail:
|
||||
'Decisions carry a written reason, and the person on the receiving end has a ' +
|
||||
'documented way to answer rather than a direct message to whoever is awake.',
|
||||
},
|
||||
{
|
||||
label: 'Content reports',
|
||||
detail:
|
||||
'Anything a member writes can be reported by another member, into a staff queue ' +
|
||||
'with the context attached.',
|
||||
},
|
||||
{
|
||||
label: 'Append-only audit log',
|
||||
detail:
|
||||
'Staff actions are recorded, and nothing in the panel can edit or delete the ' +
|
||||
'record afterwards. That is worth having on the day you need to prove what did ' +
|
||||
'not happen.',
|
||||
},
|
||||
{
|
||||
label: 'Bot scoring and IP bans',
|
||||
detail:
|
||||
'Login attempts are scored on behaviour rather than on a puzzle a real person has ' +
|
||||
'to solve, and a bad enough score bans the address by itself. The panel is a read ' +
|
||||
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
|
||||
'threshold at three in the morning.',
|
||||
},
|
||||
{
|
||||
label: 'Module management',
|
||||
detail:
|
||||
'Install, disable, uninstall and purge a module from the panel. Uninstalling keeps ' +
|
||||
'the data and reinstalling picks it up where it was; deleting it is a separate, ' +
|
||||
'opt-in choice.',
|
||||
},
|
||||
{
|
||||
label: 'The game-server connection',
|
||||
detail:
|
||||
"The bridge's address, token and protocol version live in the panel rather than in " +
|
||||
'an environment file, so connecting a server is not a redeploy. The token is ' +
|
||||
'encrypted at rest and write-only in the API — it is never returned to any client, ' +
|
||||
'including yours.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** @type {CapabilityGroup} */
|
||||
const integration = {
|
||||
id: 'integration',
|
||||
moduleSupplied: false,
|
||||
title: 'Integration',
|
||||
summary: 'The seams that let other things reach in — and one game reach out.',
|
||||
items: [
|
||||
{
|
||||
label: 'Modules',
|
||||
detail:
|
||||
'The whole game-specific half of a deployment is an installable module: routes, ' +
|
||||
'screens, tables and nav rows, versioned against a declared core API. Installing ' +
|
||||
'one is a paste in the admin panel or a line in your environment, never a build.',
|
||||
},
|
||||
{
|
||||
label: 'The sidecar bridge',
|
||||
detail:
|
||||
'A small service beside the game server, speaking a versioned wire protocol to the ' +
|
||||
'site and a loopback socket to the game. It is the only part of the bridge anything ' +
|
||||
'can reach over a network, and the game never listens at all.',
|
||||
},
|
||||
{
|
||||
label: 'Discord: slash commands, notifications, voice',
|
||||
detail:
|
||||
'A bot for the guild you already have. Commands answer from your site, ' +
|
||||
'notifications bridge into channels, and a Team can be granted a voice channel ' +
|
||||
'that maintains its own membership.',
|
||||
},
|
||||
{
|
||||
label: 'Mobile and push',
|
||||
detail:
|
||||
'A native Android app against the same documented API the website uses, with push ' +
|
||||
'delivered through your own ntfy server rather than a vendor in the middle.',
|
||||
},
|
||||
{
|
||||
label: 'SSO over OAuth2 / OIDC',
|
||||
detail:
|
||||
'Google, Discord, or any OIDC provider you run. Link-only by policy: an external ' +
|
||||
'identity has to be attached to an account that already exists, and signing in ' +
|
||||
'with one never creates a user.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** @type {CapabilityGroup} */
|
||||
const infrastructure = {
|
||||
id: 'infrastructure',
|
||||
moduleSupplied: false,
|
||||
title: 'Infrastructure',
|
||||
summary: 'How it runs, and who it answers to.',
|
||||
items: [
|
||||
{
|
||||
label: 'Self-hosted, start to finish',
|
||||
detail:
|
||||
'There is no hosted tier and no account with us. Every part of this runs on ' +
|
||||
'hardware you control, which is the only arrangement under which the rest of the ' +
|
||||
'claims on this page mean anything.',
|
||||
},
|
||||
{
|
||||
label: 'Docker, with prebuilt pull-only images',
|
||||
detail:
|
||||
'Compose up, compose down. Images are pulled rather than built, so nothing ' +
|
||||
'compiles on your server and an upgrade is a pull and a restart.',
|
||||
},
|
||||
{
|
||||
label: 'Branding as data, not a rebuild',
|
||||
detail:
|
||||
'Name, colours, logo and contact address are a mounted file. The same image runs ' +
|
||||
'as any community — including this site, which is built the same way.',
|
||||
},
|
||||
{
|
||||
label: 'OpenAPI 3.0 for the whole API',
|
||||
detail:
|
||||
'The spec ships with the server and an installed module merges its own routes into ' +
|
||||
'it, so the API you build against is the API that is actually running.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const capabilityGroups = [
|
||||
community,
|
||||
gameIntelligence,
|
||||
administration,
|
||||
integration,
|
||||
infrastructure,
|
||||
];
|
||||
|
||||
/**
|
||||
* One group by id, or a build failure naming the id that was asked for.
|
||||
*
|
||||
* `capabilityGroups.find(...)` returns `CapabilityGroup | undefined`, so every page that
|
||||
* wants one group has to either handle an impossible undefined or assert past it — and the
|
||||
* assertion is what would eventually ship a blank section after somebody renamed an id.
|
||||
* Failing here instead means a renamed group is caught by the first page that reads it.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {CapabilityGroup}
|
||||
*/
|
||||
export function capabilityGroup(id) {
|
||||
const group = capabilityGroups.find((candidate) => candidate.id === id);
|
||||
if (group) return group;
|
||||
|
||||
throw new Error(
|
||||
`src/data/capabilities.mjs has no group with id "${id}", but a page asked for it.\n` +
|
||||
`Known ids: ${capabilityGroups.map((candidate) => candidate.id).join(', ')}.\n`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when the module's declared capabilities and this page's list disagree.
|
||||
*
|
||||
* Called from the component rather than from a check script on purpose: the failure needs
|
||||
* to reach whoever is editing the page, and an Astro build error names the component. It
|
||||
* also means the rule cannot be skipped by running `astro build` without `npm run verify`.
|
||||
*/
|
||||
export function assertCapabilityCoverage(declared) {
|
||||
const claimed = new Set();
|
||||
for (const item of gameIntelligence.items) {
|
||||
for (const cap of item.caps || []) claimed.add(cap);
|
||||
}
|
||||
|
||||
const known = new Set(declared);
|
||||
|
||||
const unlisted = declared.filter((cap) => !claimed.has(cap));
|
||||
const invented = [...claimed].filter((cap) => !known.has(cap));
|
||||
|
||||
if (!unlisted.length && !invented.length) return;
|
||||
|
||||
const lines = [];
|
||||
if (unlisted.length) {
|
||||
lines.push(
|
||||
`the installed module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no ` +
|
||||
`capability on the homepage claims — the site is under-selling what it can show.`
|
||||
);
|
||||
}
|
||||
if (invented.length) {
|
||||
lines.push(
|
||||
`the homepage claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
|
||||
`longer declares — the site is advertising something that is gone (§1).`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`src/data/capabilities.mjs disagrees with platform.json's moduleUoCapabilities:\n` +
|
||||
lines.map((line) => ` - ${line}`).join('\n') +
|
||||
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a capability has no `detail`.
|
||||
*
|
||||
* The homepage renders labels, so a capability added with nothing else still looks correct
|
||||
* there — and `/features/` would silently render a heading with no argument under it. The
|
||||
* asymmetry between the two renderings is the whole of D20, and this is what keeps the
|
||||
* thinner one from being the only one anybody notices.
|
||||
*
|
||||
* Called from `/features/` for the same reason `assertCapabilityCoverage` is called from
|
||||
* the homepage: the build error should name the page that would have shipped wrong.
|
||||
*/
|
||||
export function assertDetailCoverage() {
|
||||
const missing = [];
|
||||
for (const group of capabilityGroups) {
|
||||
for (const item of group.items) {
|
||||
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!missing.length) return;
|
||||
|
||||
throw new Error(
|
||||
`src/data/capabilities.mjs has ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} with no detail:\n` +
|
||||
missing.map((entry) => ` - ${entry}`).join('\n') +
|
||||
`\n\n/features/ renders the detail line (D20). A capability without one is a heading\n` +
|
||||
`with nothing under it — write the sentence, or take the capability off the list.\n`
|
||||
);
|
||||
}
|
||||
459
src/data/collection.mjs
Normal file
459
src/data/collection.mjs
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* collection.mjs — what is collected, by whom, for how long. PLAN.md §9, built in phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE POLICY IS DATA AND NOT PROSE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 says the Play Data Safety declaration is "filled from section 2, and section 2 is
|
||||
* written knowing that is what it is for". Two documents saying the same thing about the
|
||||
* same code is the drift this repository already has two mechanisms against — the
|
||||
* capability list (D18) and the absences (D22) — and this is the worst instance of the
|
||||
* three, because the two readers are a published legal page and a form at Google that
|
||||
* cannot be corrected without a review round.
|
||||
*
|
||||
* So the inventory is one array. `/privacy` renders it as prose with the reasoning around
|
||||
* it; `scripts/playDataSafety.mjs` renders the `app`-scoped rows as the console's own
|
||||
* questions. Changing what the app stores means changing one row, and both move (D33).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE THREE SCOPES, WHICH ARE THE WHOLE POINT OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 is explicit that conflating them "would be wrong in both directions", and the
|
||||
* direction people miss is the second one:
|
||||
*
|
||||
* `site` This website. We are the data controller. It is one form.
|
||||
* `app` The Android app. We operate NO server it talks to — every byte goes to
|
||||
* a deployment the user typed the address of, run by whoever runs that
|
||||
* community. What is listed here is therefore mostly what the device
|
||||
* HOLDS, not what we receive, because we receive nothing.
|
||||
* `deployment` A self-hosted install of the platform. The operator is the controller,
|
||||
* not us. This scope exists so an operator sees the responsibility they
|
||||
* are taking on, and so a player never mistakes this policy for the one
|
||||
* governing their own community's site.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* EVERY ROW IS A FACT ABOUT CODE THAT EXISTS
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Each entry names the file it was read out of. Not decoration: a privacy policy is the
|
||||
* document most likely to be written from a template and least likely to be re-read
|
||||
* against the software, and a row that cannot name its source is a row somebody guessed.
|
||||
* `checkFacts.mjs` cannot verify these — there is no version number to compare — so the
|
||||
* citation is what a reviewer uses instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Retention
|
||||
* @property {string} summary Short enough to sit in a table cell.
|
||||
* @property {string} [detail] The mechanism, where the summary alone would be a promise.
|
||||
*
|
||||
* @typedef {object} PlayMapping
|
||||
* How this row answers Google Play's Data Safety form. `app` scope only — see
|
||||
* `scripts/playDataSafety.mjs`, which is the only reader.
|
||||
* @property {string} category The console's grouping, e.g. "Personal info".
|
||||
* @property {string} type The console's data type within that grouping.
|
||||
* @property {boolean} collected Does it leave the device to a server WE operate?
|
||||
* @property {boolean} shared Do we pass it to a third party?
|
||||
* @property {string} answer The recommended console answer, in one line.
|
||||
* @property {string} because Why that answer is the truthful one.
|
||||
*
|
||||
* @typedef {object} Collected
|
||||
* @property {string} id
|
||||
* @property {'site'|'app'|'deployment'} scope
|
||||
* @property {string} title
|
||||
* @property {string} body What it is and why it exists.
|
||||
* @property {Retention} retention
|
||||
* @property {string} source The file this was read out of, repo-relative.
|
||||
* @property {PlayMapping} [play]
|
||||
*
|
||||
* @type {Collected[]}
|
||||
*/
|
||||
export const collected = [
|
||||
/* =====================================================================================
|
||||
1. THIS WEBSITE
|
||||
|
||||
One form, and nothing else. There is no session, no cookie and no script — D9 is not
|
||||
a policy statement here, it is a description of the build output: the site sets no
|
||||
cookie of any kind, and `Base.astro` loads no third-party origin, so there is nothing
|
||||
to disclose beyond the row below and the access log the proxy keeps.
|
||||
===================================================================================== */
|
||||
{
|
||||
id: 'beta-email',
|
||||
scope: 'site',
|
||||
title: 'Your email address',
|
||||
body:
|
||||
'The one thing this site asks anybody for. It is stored so it can be pasted into ' +
|
||||
'the Google Play tester list, which is the only way Play accepts testers for a ' +
|
||||
'closed test. It is not mailed to — this site cannot send email at all — it is not ' +
|
||||
'sold, and it is used for nothing else.',
|
||||
retention: {
|
||||
summary: 'Until the beta ends, or until you ask',
|
||||
detail:
|
||||
'A removal erases the address itself rather than flagging the row: what is left ' +
|
||||
'behind is a date and the fact that a removal happened, which is what lets us ' +
|
||||
'answer “did you action my request” without keeping the thing you asked us to ' +
|
||||
'let go of.',
|
||||
},
|
||||
source: 'src/lib/betaStore.mjs',
|
||||
},
|
||||
{
|
||||
id: 'beta-consent',
|
||||
scope: 'site',
|
||||
title: 'The wording you agreed to, and when',
|
||||
body:
|
||||
'The exact sentence beside the checkbox is stored with the row, along with the ' +
|
||||
'date. A record of consent that cannot reproduce the words somebody actually ' +
|
||||
'agreed to is not a record of consent, and the wording can change over time.',
|
||||
retention: { summary: 'For the life of the row' },
|
||||
source: 'src/data/beta.mjs',
|
||||
},
|
||||
{
|
||||
id: 'beta-ip-hash',
|
||||
scope: 'site',
|
||||
title: 'A one-way hash of your IP address — never the address',
|
||||
body:
|
||||
'The form has to survive a script, and the cheapest defence is a limit per source. ' +
|
||||
'What is stored is a salted SHA-256 of the address, with the salt held in the ' +
|
||||
"server’s environment rather than in the database — so a copy of the file, on its " +
|
||||
'own, cannot be turned back into a list of who signed up from where. Rate limiting ' +
|
||||
'works perfectly well against a hash. Identifying somebody does not.',
|
||||
retention: {
|
||||
summary: 'With the row; the rate-limit log is pruned after 48 hours',
|
||||
detail:
|
||||
'A removal blanks the hash along with the address. Separately, the record of ' +
|
||||
'attempts the limiter counts against prunes itself on every write.',
|
||||
},
|
||||
source: 'src/lib/betaStore.mjs',
|
||||
},
|
||||
{
|
||||
id: 'beta-user-agent',
|
||||
scope: 'site',
|
||||
title: 'Your browser’s user-agent string, truncated',
|
||||
body:
|
||||
'The browser identifies itself on every request anyway; this one is kept beside the ' +
|
||||
'signup because it is the only signal that separates a person from a script after ' +
|
||||
'the fact. It is truncated, because the column is a signal rather than a transcript.',
|
||||
retention: { summary: 'With the row; blanked on removal' },
|
||||
source: 'src/lib/betaStore.mjs',
|
||||
},
|
||||
{
|
||||
id: 'site-access-log',
|
||||
scope: 'site',
|
||||
title: 'The web server’s access log',
|
||||
body:
|
||||
'Ordinary reverse-proxy logging, the same as any web server keeps: the IP address ' +
|
||||
'the request came from, the path, the user agent and the time. It is read when ' +
|
||||
'something is broken or being attacked, and it is not aggregated, profiled or ' +
|
||||
'joined to anything else. Nobody analyses this traffic, because there is nothing ' +
|
||||
'here that would benefit from it (D9, D30).',
|
||||
retention: {
|
||||
summary: 'Short-term operational retention, then rotated away',
|
||||
detail:
|
||||
'The log belongs to the reverse proxy on the host rather than to this ' +
|
||||
'application, so it is the proxy’s rotation that governs it. The site is on ' +
|
||||
'DNS-only records: no CDN or edge provider terminates the connection, so this log ' +
|
||||
'is the whole of it.',
|
||||
},
|
||||
source: 'PLAN.md §13 phase 12 — the operator note',
|
||||
},
|
||||
|
||||
/* =====================================================================================
|
||||
2. THE ANDROID APP
|
||||
|
||||
The unusual part, and the part §9 says must be stated precisely: we operate no server
|
||||
the app talks to. `ConnectScreen.kt` gates the entire app on an address the user
|
||||
enters and the app validates; everything below either stays on the phone or goes to
|
||||
that address. There is no telemetry SDK, no crash reporter and no analytics in the
|
||||
build — the manifest asks for INTERNET, network state, notifications and a data-sync
|
||||
foreground service, and nothing else.
|
||||
|
||||
`play` is filled in on every row here, because a row in this scope with no mapping is
|
||||
a question on the console form that somebody will answer from memory (D33).
|
||||
===================================================================================== */
|
||||
{
|
||||
id: 'app-session-tokens',
|
||||
scope: 'app',
|
||||
title: 'Your sign-in tokens',
|
||||
body:
|
||||
'When you sign in to a deployment, the app keeps the access and refresh tokens it ' +
|
||||
'was issued, plus the username, role and account id they belong to. They are held ' +
|
||||
'in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are ' +
|
||||
'sent to exactly one place: the deployment that issued them.',
|
||||
retention: {
|
||||
summary: 'On the device until you sign out',
|
||||
detail: 'Signing out clears them; uninstalling the app removes them with it.',
|
||||
},
|
||||
source: 'core/auth/EncryptedTokenStore.kt',
|
||||
play: {
|
||||
category: 'Personal info',
|
||||
type: 'User IDs',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
because:
|
||||
'The credentials are issued by, and returned to, a server the user nominated. ' +
|
||||
'Nothing reaches an endpoint under our control, because we run none.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-trust-token',
|
||||
scope: 'app',
|
||||
title: 'The trusted-device token, if you asked for one',
|
||||
body:
|
||||
'Ticking “trust this device” during two-factor sign-in stores an opaque token so ' +
|
||||
'the deployment can skip the second factor next time. It lives in its own encrypted ' +
|
||||
'store, deliberately separate from the session, because it has to outlive a sign-out ' +
|
||||
'to be worth anything — and the deployment holds only a hash of it, so the copy on ' +
|
||||
'your phone is the only usable one.',
|
||||
retention: {
|
||||
summary: 'On the device until it expires or you revoke it',
|
||||
detail:
|
||||
'Thirty days, and revocable at any time from the deployment’s Trusted Devices ' +
|
||||
'screen, which is also where it can be revoked if the phone is lost.',
|
||||
},
|
||||
source: 'core/auth/EncryptedTrustTokenStore.kt',
|
||||
play: {
|
||||
category: 'Personal info',
|
||||
type: 'User IDs',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
because:
|
||||
'Same as the session tokens: minted by the user’s deployment, stored on the ' +
|
||||
'device, presented back to that same deployment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-server-address',
|
||||
scope: 'app',
|
||||
title: 'The address of the deployment you chose',
|
||||
body:
|
||||
'The app ships pointed at nothing and asks for an address on first run. That address ' +
|
||||
'is stored in ordinary preferences rather than encrypted storage — it is not a ' +
|
||||
'secret, it is the equivalent of a bookmark — and it is what every other screen in ' +
|
||||
'the app talks to.',
|
||||
retention: { summary: 'On the device until you change it or uninstall' },
|
||||
source: 'core/prefs/ServerPreferences.kt',
|
||||
play: {
|
||||
category: 'App info and performance',
|
||||
type: 'Other app data',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us. Stored on the device only.',
|
||||
because:
|
||||
'It never leaves the phone. It is the destination of requests, not the contents ' +
|
||||
'of one.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-push',
|
||||
scope: 'app',
|
||||
title: 'Push registration, if you turn notifications on',
|
||||
body:
|
||||
'Push is off until you enable it. When you do, the app mints a random, unguessable ' +
|
||||
'topic name on the notification relay the deployment nominates, and registers that ' +
|
||||
'topic’s URL with the deployment so it has somewhere to send a nudge. What ' +
|
||||
'actually travels through the relay is content-free — a stream name and a reference, ' +
|
||||
'never the message — and the app then fetches the real content over its ' +
|
||||
'authenticated connection to the deployment. A leaked topic name therefore reveals ' +
|
||||
'nothing, which is the reason the relay needs no account and holds nothing about you.',
|
||||
retention: {
|
||||
summary: 'Until you turn push off, sign out, or uninstall',
|
||||
detail:
|
||||
'Signing out or disabling push unregisters the device with the deployment and ' +
|
||||
'discards the topic. The relay retains whatever its own operator configures it to; ' +
|
||||
'if the deployment points at a relay it does not run, that relay is a third party ' +
|
||||
'to both of us, and it still only ever sees a tickle.',
|
||||
},
|
||||
source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other in-app messages',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer:
|
||||
'Not collected by us. Declare the relay hop in the console’s free-text ' +
|
||||
'security section if it asks.',
|
||||
because:
|
||||
'The notification passes through a relay chosen by the deployment, and it carries ' +
|
||||
'no content — the app pulls the content itself, authenticated. Neither hop reaches ' +
|
||||
'a server we operate.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-content',
|
||||
scope: 'app',
|
||||
title: 'Everything you read and post in the app',
|
||||
body:
|
||||
'Forum posts, Team activity, character and shard information, notification ' +
|
||||
'preferences: all of it is a live read or write against the deployment. Nothing is ' +
|
||||
'cached for offline use and nothing is duplicated anywhere else — the app with no ' +
|
||||
'signal is an app with no content, which is a limitation and also an accurate ' +
|
||||
'description of where the data lives.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment, under its operator’s policy',
|
||||
},
|
||||
source: 'PLAN.md §9 section 2',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other user-generated content',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
because:
|
||||
'Content is written to the community’s own installation. We have no copy, no ' +
|
||||
'access and no way to obtain one.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-no-analytics',
|
||||
scope: 'app',
|
||||
title: 'No analytics, no crash reporting, no advertising',
|
||||
body:
|
||||
'There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no ' +
|
||||
'advertising identifier, no measurement library. That is checkable rather than ' +
|
||||
'claimed: it is what the dependency list and the manifest say, and a build that ' +
|
||||
'gained one would gain permissions with it.',
|
||||
retention: { summary: 'Nothing to retain' },
|
||||
source: 'app/build.gradle.kts, app/src/main/AndroidManifest.xml',
|
||||
play: {
|
||||
category: 'Device or other IDs',
|
||||
type: 'Device or other IDs',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected.',
|
||||
because:
|
||||
'No advertising ID, no analytics identifier, and no library that would generate ' +
|
||||
'one is linked into the build.',
|
||||
},
|
||||
},
|
||||
|
||||
/* =====================================================================================
|
||||
3. SELF-HOSTED DEPLOYMENTS
|
||||
|
||||
Written for an operator deciding what they are taking on, and for a player who found
|
||||
this page from their community's site and needs to be told, plainly, that it is not
|
||||
the policy governing them.
|
||||
===================================================================================== */
|
||||
{
|
||||
id: 'deploy-accounts',
|
||||
scope: 'deployment',
|
||||
title: 'Account records',
|
||||
body:
|
||||
'A username, a password hash, an optional email address, the role, and — where the ' +
|
||||
'operator has enabled it — a two-factor secret and single-use recovery codes. The ' +
|
||||
'time and IP address of the last sign-in are stored on the account row.',
|
||||
retention: { summary: 'Set by the operator; nothing expires on its own' },
|
||||
source: 'website server/db/schema.sql — users',
|
||||
},
|
||||
{
|
||||
id: 'deploy-sessions',
|
||||
scope: 'deployment',
|
||||
title: 'Sessions, devices and revocations',
|
||||
body:
|
||||
'Web sessions, mobile refresh tokens, trusted devices and the revocation list. A ' +
|
||||
'trusted-device row keeps a hash of the token, a device label, a truncated user ' +
|
||||
'agent and the times it was created and last used.',
|
||||
retention: {
|
||||
summary: 'Trusted devices expire after 30 days; refresh tokens rotate',
|
||||
},
|
||||
source: 'website server/db/schema.sql — trusted_devices, mobile_refresh_tokens',
|
||||
},
|
||||
{
|
||||
id: 'deploy-audit',
|
||||
scope: 'deployment',
|
||||
title: 'An audit log, with IP addresses on it',
|
||||
body:
|
||||
'Administrative and security-relevant actions are logged with the acting account, ' +
|
||||
'what was done, and the IP address it came from. This is the record a moderator ' +
|
||||
'relies on, and it is also the most sensitive thing in the database.',
|
||||
retention: { summary: 'Kept until the operator removes it' },
|
||||
source: 'website server/db/schema.sql — activity_log',
|
||||
},
|
||||
{
|
||||
id: 'deploy-bot-scoring',
|
||||
scope: 'deployment',
|
||||
title: 'Bot scoring and temporary IP bans',
|
||||
body:
|
||||
'Scanner traffic and failed logins raise a score against the source address, and a ' +
|
||||
'high enough score bans it from the site for an hour. Worth stating precisely ' +
|
||||
'because it is better than it sounds: that score lives in memory in the running ' +
|
||||
'process, not in the database, and it decays after half an hour of quiet — a ' +
|
||||
'restart forgets every address it was watching.',
|
||||
retention: {
|
||||
summary: 'In memory only; scores decay, bans last an hour',
|
||||
},
|
||||
source: 'website server/src/middleware/botScore.js',
|
||||
},
|
||||
{
|
||||
id: 'deploy-content',
|
||||
scope: 'deployment',
|
||||
title: 'Everything posted on the site',
|
||||
body:
|
||||
'Forum threads and replies, uploads, wiki revisions, moderation actions, warnings, ' +
|
||||
'reports and appeals — with the account that made each one. Where the operator has ' +
|
||||
'connected Discord, some of that crosses into Discord and is then also subject to ' +
|
||||
'Discord’s own terms.',
|
||||
retention: {
|
||||
summary: 'Operator-configured; some sweeps run on a retention window',
|
||||
detail:
|
||||
'Soft-deleted forum uploads and Team activity have retention windows an admin ' +
|
||||
'sets; most other content is kept until somebody removes it.',
|
||||
},
|
||||
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
||||
},
|
||||
{
|
||||
id: 'deploy-game-data',
|
||||
scope: 'deployment',
|
||||
title: 'Game data from the connected server',
|
||||
body:
|
||||
'Where a game module is installed, information about characters, guilds, houses and ' +
|
||||
'the in-game economy flows from the game server to the site through the bridge. ' +
|
||||
'Which of it is visible to the public is the operator’s decision, made in the ' +
|
||||
'admin panel — the bridge itself forwards, and the site decides.',
|
||||
retention: { summary: 'Operator-configured' },
|
||||
source: 'docs/link/v4.md — the visibility framework',
|
||||
},
|
||||
];
|
||||
|
||||
/** The entries in one scope, in file order. */
|
||||
export function collectedIn(scope) {
|
||||
return collected.filter((entry) => entry.scope === scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `app` rows that carry a Play mapping — the Data Safety generator's input.
|
||||
*
|
||||
* A separate accessor rather than a filter at the call site, so the invariant below has
|
||||
* somewhere to live: every `app` row MUST map, because the form asks about the app as a
|
||||
* whole and a row nobody mapped is a question answered from memory.
|
||||
*/
|
||||
export function playRows() {
|
||||
const rows = collectedIn('app');
|
||||
const unmapped = rows.filter((entry) => !entry.play).map((entry) => entry.id);
|
||||
|
||||
if (unmapped.length) {
|
||||
throw new Error(
|
||||
`src/data/collection.mjs: app-scoped entries with no Play mapping: ${unmapped.join(', ')}.\n` +
|
||||
'\nEvery app row answers a question on the Data Safety form (D33). Add a `play`\n' +
|
||||
'block, or move the entry to another scope if it is not about the app.\n'
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a scope renders nothing.
|
||||
*
|
||||
* The same guard `notBuilt.mjs` carries, for a stronger reason: an empty section on a
|
||||
* privacy policy does not read as an omission, it reads as "we collect nothing here", and
|
||||
* that is a claim nobody made.
|
||||
*/
|
||||
export function assertScopeNonEmpty(scope) {
|
||||
if (collectedIn(scope).length) return;
|
||||
|
||||
throw new Error(
|
||||
`src/data/collection.mjs has no entry in scope "${scope}", but /privacy renders it.\n` +
|
||||
'\nA section with nothing under it reads as a claim that nothing is collected.\n'
|
||||
);
|
||||
}
|
||||
53
src/data/legal.mjs
Normal file
53
src/data/legal.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* legal.mjs — the handful of values the legal pages and the signup form must agree on.
|
||||
* PLAN.md §9, built in phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THESE THREE THINGS ARE HERE AND NOT IN THE PAGES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Each is stated in more than one place and would be wrong in exactly one of them:
|
||||
*
|
||||
* `minimumAge` /terms says it, /privacy repeats it, the consent sentence beside the
|
||||
* signup checkbox commits somebody to it, and the Play Data Safety notes
|
||||
* answer a question about it. Four surfaces, one number (D31).
|
||||
* `lastUpdated` A legal page with no date is a legal page nobody can reason about, and
|
||||
* two pages with different dates invites the reader to work out which one
|
||||
* is stale. They changed together; they say so together.
|
||||
* `licence` Quoted on /terms and in the footer.
|
||||
*
|
||||
* The contact address is deliberately NOT here. It is a `brand.json` field read through
|
||||
* `src/lib/brand.mjs` (D13), so that changing the published address stays a file copy on a
|
||||
* mount rather than an edit to the source — and `checkFacts.mjs` fails the build if one is
|
||||
* typed into any file under `src/`.
|
||||
*/
|
||||
|
||||
export const legal = {
|
||||
/**
|
||||
* The date the legal pages last changed, in the format they render it.
|
||||
*
|
||||
* Bump it in the same commit that changes what either page says. It is not generated
|
||||
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
||||
* and a commit date would move when a stylesheet changed.
|
||||
*/
|
||||
lastUpdated: '2026-08-24',
|
||||
|
||||
/**
|
||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||
*
|
||||
* Eighteen, chosen over thirteen and sixteen: it is above the children's-consent
|
||||
* threshold in every EEA state, so consent works as a basis with no parental-consent
|
||||
* machinery — which this form has no way to obtain and no way to verify. It is the
|
||||
* simplest thing to state truthfully for a beta that needs twelve people.
|
||||
*
|
||||
* A number rather than a sentence because four surfaces render it. What the site can
|
||||
* actually enforce is a statement, not a check, and every one of those surfaces is
|
||||
* written to say so plainly rather than implying verification that does not happen.
|
||||
*/
|
||||
minimumAge: 18,
|
||||
|
||||
/** The licence, quoted on /terms and in the footer. */
|
||||
licence: {
|
||||
id: 'GPL-3.0-or-later',
|
||||
url: 'https://www.gnu.org/licenses/gpl-3.0.html',
|
||||
},
|
||||
};
|
||||
209
src/data/notBuilt.mjs
Normal file
209
src/data/notBuilt.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* notBuilt.mjs — the deliberate absences of PLAN.md §2, as data (D22).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS A LIST AND NOT A PARAGRAPH
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §2 calls its absent-features list "as load-bearing as the rest", and the homepage already
|
||||
* promises a reader they will find it on both `/features/` and `/integrations/`. Two pages
|
||||
* each writing their own version of "what we did not build" is how the inconvenient half
|
||||
* quietly stops being mentioned on one of them — the same failure `capabilities.mjs` exists
|
||||
* to prevent, pointed the other way.
|
||||
*
|
||||
* So: one list, tagged with the pages that show it. `/modules/` reads it too, because the
|
||||
* three absences a module author most needs to know about are all here.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE RULE FOR ADDING ONE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* An entry belongs here when a reasonable reader would assume the thing exists. That is a
|
||||
* higher bar than "we have not built it" — the site is not an inventory of everything
|
||||
* absent from it — and a lower bar than "someone asked for it". Matrix is here because the
|
||||
* original brief for this site listed it as a feature; the installer's missing platforms
|
||||
* are here because every other tool in the world ships a macOS build.
|
||||
*
|
||||
* Each entry says what it is, and then why not. The "why not" is the point: an absence with
|
||||
* a reason reads as a decision, and an absence without one reads as a gap. Where the
|
||||
* reasoning was written down somewhere in the open, the entry links to it on a BRANCH path
|
||||
* — `scripts/checkLinks.mjs` fails a commit permalink, because a permalink is a fact frozen
|
||||
* at a sha while the document keeps moving.
|
||||
*
|
||||
* `resolvedBy` is not decoration. D8 gives the Integration Kit's draft status a defined
|
||||
* removal condition, and stating the exit condition on the others too is what stops this
|
||||
* file becoming a list of permanent apologies.
|
||||
*/
|
||||
|
||||
const GITEA = 'https://gitea.whitlocktech.com/RunicGateway';
|
||||
|
||||
/**
|
||||
* `scope` — which pages render the entry.
|
||||
*
|
||||
* `features` /features/, under the capability groups
|
||||
* `integrations` /integrations/, under the integrations that do exist
|
||||
* `modules` /modules/, where a module author is deciding whether to start
|
||||
*
|
||||
* Typed rather than inferred, for the same reason `capabilities.mjs` is: `link` is present
|
||||
* on four entries out of six, and an inferred union makes `entry.link` unreadable on the
|
||||
* page that renders all of them.
|
||||
*
|
||||
* @typedef {object} Absence
|
||||
* @property {string} id
|
||||
* @property {string[]} scope
|
||||
* @property {string} title
|
||||
* @property {string} body
|
||||
* @property {string} resolvedBy What would make this entry go away. Never optional.
|
||||
* @property {{ href: string, label: string }} [link]
|
||||
*
|
||||
* @type {Absence[]}
|
||||
*/
|
||||
export const notBuilt = [
|
||||
{
|
||||
id: 'matrix',
|
||||
scope: ['integrations'],
|
||||
title: 'Matrix',
|
||||
body:
|
||||
'Researched properly and then declined. Matrix has no channel-with-overwrites, no ' +
|
||||
'role object, no voice channel of its own — voice is an RTC session needing a media ' +
|
||||
'server the homeserver does not ship — and no way to register a slash command. Of ' +
|
||||
'the five things a shared chat interface would have to name, an honest Matrix ' +
|
||||
'implementation could provide two. What came out of that work was a capability ' +
|
||||
'contract rather than an integration.',
|
||||
resolvedBy:
|
||||
'Nothing planned. If the protocol grows the missing four, the contract is already ' +
|
||||
'the shape a second platform would plug into.',
|
||||
link: { href: `${GITEA}/docs/src/branch/main/website/TEAMS.md`, label: 'The research, in full' },
|
||||
},
|
||||
{
|
||||
id: 'multi-module',
|
||||
scope: ['features', 'integrations', 'modules'],
|
||||
title: 'More than one game module at a time',
|
||||
body:
|
||||
'One active module per deployment. The database columns that would scope data to a ' +
|
||||
'module exist and are populated, so the door is not nailed shut, but nothing ' +
|
||||
'exercises them and no interface offers it. A community running two games runs two ' +
|
||||
'deployments.',
|
||||
resolvedBy:
|
||||
'Someone needing it. The schema was shaped to keep it possible, which is a different ' +
|
||||
'thing from planning it.',
|
||||
},
|
||||
{
|
||||
id: 'second-module',
|
||||
scope: ['integrations', 'modules'],
|
||||
title: 'A second game module',
|
||||
body:
|
||||
'There is exactly one, and it is Ultima Online. A paper dry-run for a Rust module ' +
|
||||
'exists and is deliberately unimplemented — it was written to test whether the ' +
|
||||
'module contract generalises, not to ship. Until a second one exists, "any game" is ' +
|
||||
'an argument about a shape rather than a demonstration.',
|
||||
resolvedBy: 'The first module built for a game that is not Ultima Online.',
|
||||
link: { href: `${GITEA}/docs/src/branch/main/modules/rust-dryrun.md`, label: 'The dry-run' },
|
||||
},
|
||||
{
|
||||
id: 'integration-kit-draft',
|
||||
scope: ['integrations', 'modules'],
|
||||
title: 'A finished Integration Kit',
|
||||
body:
|
||||
'The kit that teaches you to put a different game on this platform describes itself ' +
|
||||
'as a draft, and it is right to. It has four chapters, a working template and a CI ' +
|
||||
'job that builds that template against a pinned core — but nobody outside this ' +
|
||||
'project has yet followed it to a working module, which is the only test of a set of ' +
|
||||
'instructions that counts.',
|
||||
resolvedBy:
|
||||
'Someone outside this project building a working module for a new game by following ' +
|
||||
"it alone. That is the kit's own stated condition, not one invented here.",
|
||||
link: { href: `${GITEA}/Integration-kit/src/branch/main/README.md`, label: 'The kit' },
|
||||
},
|
||||
{
|
||||
id: 'installer-platforms',
|
||||
scope: ['features'],
|
||||
title: 'A macOS or Windows-on-ARM installer',
|
||||
body:
|
||||
'Linux and Windows, on x86-64, plus Linux on arm64. The missing builds are missing ' +
|
||||
'on purpose: the installer runs on the machine the game server lives on, because the ' +
|
||||
'game and the bridge have to share a host, and no game server anybody runs is on ' +
|
||||
'either of those platforms.',
|
||||
resolvedBy: 'A game server that runs there.',
|
||||
link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
|
||||
},
|
||||
{
|
||||
id: 'public-demo',
|
||||
// Phase 5 added `app` and `beta`, and that is not tidying. D27 makes the demo the
|
||||
// deployment a beta tester connects to, so on those two pages this stopped being a
|
||||
// thing the site lacks and became the thing the beta is waiting for. An absence that
|
||||
// blocks a call to action has to be on the page carrying that call to action.
|
||||
scope: ['features', 'app', 'beta'],
|
||||
title: 'A public demo you can click through',
|
||||
body:
|
||||
'Planned and out of scope today: a virtual machine running the whole stack including ' +
|
||||
'a game server, with settings locked down and an hourly reset. Until it exists this ' +
|
||||
'site does not link to one, and there is no screenshot here of something that is not ' +
|
||||
'running somewhere.',
|
||||
resolvedBy:
|
||||
'The machine being stood up. The site is already built to gain it by way of one line ' +
|
||||
'in a configuration file, rather than a rebuild.',
|
||||
},
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
THE ANDROID CLIENT (phase 5)
|
||||
|
||||
These are about the app rather than the platform, and they live here rather than in a
|
||||
second list on `/app/` for the reason this file exists at all: two lists of absences
|
||||
drift, and the one that drifts is always the one nobody is looking at. The `scope` tag
|
||||
is what keeps them off the pages they would be noise on.
|
||||
--------------------------------------------------------------------------------------- */
|
||||
{
|
||||
id: 'ios-app',
|
||||
scope: ['app'],
|
||||
title: 'An iOS app',
|
||||
body:
|
||||
'Android only. There is no iOS build, no cross-platform layer waiting to grow one, ' +
|
||||
'and no work in progress — the app is native Kotlin and Compose, so a second ' +
|
||||
'platform would be a second app rather than another build target.',
|
||||
resolvedBy: 'Nothing planned. A deployment is a website first, and that works on any phone.',
|
||||
},
|
||||
{
|
||||
id: 'play-listing',
|
||||
scope: ['app', 'beta'],
|
||||
title: 'A listing on Google Play',
|
||||
body:
|
||||
'The app is not published. A developer account exists; the closed test is the next ' +
|
||||
'step, and production access cannot even be requested until a run of testers has ' +
|
||||
'been opted in continuously — which is what the beta is for, and why the beta is not ' +
|
||||
'a formality.',
|
||||
resolvedBy: 'The closed test running its course, and then a production review.',
|
||||
link: { href: '/beta/', label: 'The closed beta' },
|
||||
},
|
||||
{
|
||||
id: 'app-offline',
|
||||
scope: ['app'],
|
||||
title: 'Reading anything offline',
|
||||
body:
|
||||
'Every screen is a live read against the deployment. Nothing is cached for offline ' +
|
||||
'use, so the app with no signal is an app with no content.',
|
||||
resolvedBy: 'Somebody asking for it. Nobody has.',
|
||||
},
|
||||
];
|
||||
|
||||
/** The entries a given page renders, in file order. */
|
||||
export function notBuiltFor(scope) {
|
||||
return notBuilt.filter((entry) => entry.scope.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a scope renders nothing.
|
||||
*
|
||||
* The homepage tells a reader in as many words that the absences are listed "on features
|
||||
* and integrations". A tag typo, or an entry removed without checking who was showing it,
|
||||
* turns that sentence into a promise the site does not keep — and an empty section is the
|
||||
* one defect that looks deliberate, because a page with nothing under a heading reads as a
|
||||
* page with nothing to admit.
|
||||
*/
|
||||
export function assertScopeNonEmpty(scope) {
|
||||
if (notBuiltFor(scope).length) return;
|
||||
|
||||
throw new Error(
|
||||
`src/data/notBuilt.mjs has no entry tagged "${scope}", but a page is rendering that scope.\n` +
|
||||
`\nThe homepage promises this list appears on /features/ and /integrations/ (§2, D22).\n` +
|
||||
`Tag an entry with "${scope}", or take the section off the page that asks for it.\n`
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,28 @@
|
||||
|
||||
"androidApplicationId": "com.runicgateway.app",
|
||||
|
||||
"$comment_androidApk": [
|
||||
"Phase 5 / `/app/`. The app is not on any store, so the only way to install it is the",
|
||||
"signed APK attached to each Android-app release. `asset` and `checksums` are the two",
|
||||
"assets checkFacts.mjs asserts exist on releases/latest, and `minSdk` is re-read from",
|
||||
"the app's build.gradle.kts — so the download block on /app/ cannot outlive the file it",
|
||||
"points at, and 'Android 10 or newer' cannot outlive the number that makes it true.",
|
||||
"",
|
||||
"`serviceable` is the one value here with no authority to check it against, and it is",
|
||||
"deliberately manual. It answers a question no API can: does the published build",
|
||||
"actually work. The org lead reports v0.5.0's does not, so the download block renders a",
|
||||
"'being replaced' state instead of a link, and flipping this to true is the single edit",
|
||||
"that turns the link back on once a working build is released. A check cannot run an",
|
||||
"APK; a person can, and this is where they record that they did."
|
||||
],
|
||||
"androidApk": {
|
||||
"asset": "runic-gateway-0.5.0.apk",
|
||||
"checksums": "SHA256SUMS",
|
||||
"minSdk": 29,
|
||||
"minAndroid": "10",
|
||||
"serviceable": false
|
||||
},
|
||||
|
||||
"gitea": {
|
||||
"base": "https://gitea.whitlocktech.com",
|
||||
"org": "RunicGateway"
|
||||
|
||||
160
src/data/quickstart.mjs
Normal file
160
src/data/quickstart.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* quickstart.mjs — the self-contained site deployment (D35, PLAN.md §10).
|
||||
*
|
||||
* The org lead chose a quickstart an operator can copy without leaving the page: the
|
||||
* Compose file and the environment file below are complete enough to boot a site, and
|
||||
* `/docs/getting-started/install-the-site/` renders them verbatim.
|
||||
*
|
||||
* That decision creates the artifact §1 spends its whole length warning about — a second
|
||||
* copy of somebody else's file, free to drift. `scripts/checkQuickstart.mjs` is the price
|
||||
* of it: every service, image, port, mount and variable below is re-read from `website`'s
|
||||
* own `docker-compose.yml` and `.env.example` on `main`, over the Gitea API, and any
|
||||
* disagreement fails the build. Same mechanism and same intent as `checkFacts.mjs`.
|
||||
*
|
||||
* WHAT THIS FILE IS NOT. It is not a smaller compose file that the project supports as an
|
||||
* alternative. It is the shipped one with the parts an operator does not need on day one
|
||||
* left out, and the page says so: `bot` and `ntfy` are real services, documented where
|
||||
* they are configured, and the reader is pointed at the full file for them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Services the quickstart ships, and — for the check — what each one must still agree with
|
||||
* upstream about. `omitted` records the services deliberately left out, because a NEW
|
||||
* service appearing upstream should make someone decide, rather than pass silently.
|
||||
*/
|
||||
export const services = ['db', 'app'];
|
||||
export const omittedServices = {
|
||||
ntfy: 'Push notifications for the Android app. Nothing needs it to boot, and it wants a public URL a first install does not have yet.',
|
||||
bot: 'The Discord bot. It is configured from the admin panel once the site is up, so it is introduced on the integrations page rather than here.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The Compose file, exactly as the page prints it.
|
||||
*
|
||||
* Three differences from upstream's, all deliberate and all asserted by the check:
|
||||
* - `bot` and `ntfy` are absent (above).
|
||||
* - `db` does not bind-mount `./server/db/schema.sql`. That mount is a checkout-relative
|
||||
* path, and this quickstart has no checkout; the server ensures its own schema on boot,
|
||||
* which is what actually creates the tables in every deployment.
|
||||
* - the `MODULES` comment block is reduced to one line pointing at the module page.
|
||||
*/
|
||||
export const compose = `services:
|
||||
db:
|
||||
image: mariadb:11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: \${DB_NAME}
|
||||
MARIADB_USER: \${DB_USER}
|
||||
MARIADB_PASSWORD: \${DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- dbdata:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
image: gitea.whitlocktech.com/runicgateway/website-app:\${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_HOST: db
|
||||
UPLOAD_DIR: /app/uploads
|
||||
LOG_DIR: /app/logs
|
||||
MODULES_DIR: /app/modules
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
- ./brand:/app/brand:ro
|
||||
- ./modules:/app/modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
uploads:
|
||||
`;
|
||||
|
||||
/**
|
||||
* The environment file, as the page prints it. `fill` marks the lines an operator must
|
||||
* change before this is a real deployment — the page highlights exactly these.
|
||||
*/
|
||||
export const env = [
|
||||
{ key: 'IMAGE_TAG', value: 'latest' },
|
||||
|
||||
{ key: 'NODE_ENV', value: 'production' },
|
||||
{ key: 'PORT', value: '3000' },
|
||||
{ key: 'INTERNAL_PORT', value: '3001' },
|
||||
|
||||
{ key: 'DB_HOST', value: 'db' },
|
||||
{ key: 'DB_PORT', value: '3306' },
|
||||
{ key: 'DB_NAME', value: 'runic_gateway' },
|
||||
{ key: 'DB_USER', value: 'runic' },
|
||||
{ key: 'DB_PASSWORD', value: 'change-me-db-password', fill: true },
|
||||
{ key: 'DB_ROOT_PASSWORD', value: 'change-me-root-password', fill: true },
|
||||
|
||||
{ key: 'JWT_SECRET', value: 'change-me-to-a-long-random-string', fill: true },
|
||||
{ key: 'SECRET_ENC_KEY', value: 'change-me-to-another-long-random-string', fill: true },
|
||||
{ key: 'COOKIE_SECURE', value: 'auto' },
|
||||
{ key: 'TRUST_PROXY', value: '1' },
|
||||
|
||||
{ key: 'ADMIN_USERNAME', value: 'admin', fill: true },
|
||||
{ key: 'ADMIN_PASSWORD', value: 'change-me-before-first-boot', fill: true },
|
||||
|
||||
{ key: 'BOT_INTERNAL_KEY', value: 'change-me-to-a-third-long-random-string', fill: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* `SECRET_ENC_KEY` is in this quickstart and NOT in upstream's `.env.example`, which is why
|
||||
* it needs a declaration rather than passing quietly.
|
||||
*
|
||||
* Found by booting this exact file against the published image (phase 7): the server calls
|
||||
* `resolveKey()` in `utils/secretBox.js` at require time and throws
|
||||
* `SECRET_ENC_KEY must be set in production`, so the container crash-loops before it ever
|
||||
* listens. It is documented in `server/.env.example` — the file local development copies —
|
||||
* and missing from the root `.env.example` that Compose actually reads.
|
||||
*
|
||||
* The check treats the omission as upstream's bug, not as licence: it fails the moment the
|
||||
* variable appears in `.env.example`, so this note cannot outlive the defect it describes.
|
||||
*/
|
||||
export const notInUpstreamEnvExample = {
|
||||
SECRET_ENC_KEY:
|
||||
"the app refuses to start in production without it (utils/secretBox.js), but website's root .env.example does not list it",
|
||||
};
|
||||
|
||||
/**
|
||||
* Variables upstream's `.env.example` carries that the quickstart leaves out, each with the
|
||||
* reason. The check requires this list plus the keys above to account for EVERY key in
|
||||
* `.env.example`: when website adds a variable, this repo goes red and someone decides
|
||||
* whether a first install needs it. That failure is the feature.
|
||||
*/
|
||||
export const envOmitted = {
|
||||
UPLOAD_DIR: 'set in the Compose file, where the volume that makes it meaningful is',
|
||||
LOG_LEVEL: 'logging defaults are fine until there is something to debug',
|
||||
FILE_LOG_LEVEL: 'as above',
|
||||
LOG_TO_FILE: 'as above',
|
||||
LOG_DIR: 'set in the Compose file, beside its bind mount',
|
||||
LOG_FILE: 'as above',
|
||||
BRAND_NAME: 'branding is its own admin screen and its own page',
|
||||
BRAND_SHORT_NAME: 'as above',
|
||||
BRAND_TAGLINE: 'as above',
|
||||
BRAND_DESCRIPTION: 'as above',
|
||||
BRAND_CONTACT_EMAIL: 'as above',
|
||||
BRAND_URL: 'as above',
|
||||
BRAND_ACCENT_COLOR: 'as above',
|
||||
BRAND_LOGO: 'as above',
|
||||
BRAND_HERO: 'as above',
|
||||
BRAND_FAVICON: 'as above',
|
||||
JWT_EXPIRES_IN: 'the default session length is a decision for later, not for boot',
|
||||
COOKIE_NAME: 'changing it logs everyone out; not a first-install decision',
|
||||
DEBUG_TRUST_PROXY: 'a diagnostic, and a noisy one',
|
||||
TOTP_CHALLENGE_TTL: 'the default is right',
|
||||
CLIENT_ORIGIN: 'only needed when the client is served from a different origin, which a Compose deployment does not do',
|
||||
BOT_INTERNAL_URL: 'points at the bot service, which this quickstart does not run',
|
||||
NTFY_BASE_URL: 'push notifications need the ntfy service, which this quickstart does not run',
|
||||
};
|
||||
183
src/lib/betaSignup.mjs
Normal file
183
src/lib/betaSignup.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* betaSignup.mjs — everything between a POST body and a row. PLAN.md §8, phase 5.
|
||||
*
|
||||
* Separate from `betaStore.mjs` on purpose: the store is about a file on a disk, and this
|
||||
* is about not trusting a request. The split is what lets the tests drive the whole
|
||||
* decision path — honeypot, timing, limits, cap, validation, duplicate — against a scratch
|
||||
* database without a server, which is the only way this logic gets exercised at all.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE ORDER OF THE CHECKS IS PART OF THE DESIGN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Cheap and silent first, expensive and honest last:
|
||||
*
|
||||
* 1. HONEYPOT — a filled hidden field. Answered with the success screen, deliberately.
|
||||
* A bot that is told it failed learns which field to leave alone next time; a bot that
|
||||
* is told it succeeded goes away. Nothing is written.
|
||||
* 2. FORM TOKEN — the timestamp is signed, so a script has to fetch the page before it
|
||||
* can post to it. Without the signature the timing check is theatre: `ts` is a number
|
||||
* in a hidden field and a bot can put yesterday's in it as easily as today's.
|
||||
* 3. TIMING — under `minSeconds` from render is a script; over `maxSeconds` is a stale tab.
|
||||
* 4. RATE LIMIT — per `ip_hash`, counting attempts rather than successes.
|
||||
* 5. CAP — the form closes at `totalCap` and says so.
|
||||
* 6. VALIDATION and CONSENT — the only two failures a real person can plausibly hit, and
|
||||
* the only two that get a specific, useful message.
|
||||
*
|
||||
* Steps 3–5 are checked before the address is even parsed, so a limited caller is never
|
||||
* told anything about an address, and step 6's messages can be specific precisely because
|
||||
* everything that could be probing has already been turned away.
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { CONSENT_TEXT, fields, limits } from '../data/beta.mjs';
|
||||
import { addSignup, attemptCounts, hashIp, isFull, recordAttempt } from './betaStore.mjs';
|
||||
|
||||
/**
|
||||
* The key that signs a rendered form.
|
||||
*
|
||||
* Random per process when unset, and that is the right default rather than a compromise:
|
||||
* the only cost is that forms rendered before a restart are refused (the page re-renders and
|
||||
* the person tries again), and the alternative — a constant baked into the source — would
|
||||
* let anyone holding this repository mint tokens for every deployment of it.
|
||||
*/
|
||||
const FORM_KEY = process.env.BETA_FORM_KEY || randomBytes(32).toString('hex');
|
||||
|
||||
const sign = (value) => createHmac('sha256', FORM_KEY).update(String(value)).digest('hex');
|
||||
|
||||
/** The value of the hidden `ts` field: when the page rendered, and proof that it did. */
|
||||
export function issueFormToken(at = Date.now()) {
|
||||
return `${at}.${sign(at)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a form token and return how long ago it was issued, or `null` if it is not ours.
|
||||
*/
|
||||
export function readFormToken(token) {
|
||||
if (typeof token !== 'string') return null;
|
||||
|
||||
const dot = token.indexOf('.');
|
||||
if (dot < 1) return null;
|
||||
|
||||
const at = Number.parseInt(token.slice(0, dot), 10);
|
||||
if (!Number.isFinite(at)) return null;
|
||||
|
||||
const given = Buffer.from(token.slice(dot + 1), 'utf8');
|
||||
const want = Buffer.from(sign(at), 'utf8');
|
||||
if (given.length !== want.length || !timingSafeEqual(given, want)) return null;
|
||||
|
||||
return { at, ageSeconds: (Date.now() - at) / 1000 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Address validation.
|
||||
*
|
||||
* Deliberately not RFC 5322. That grammar admits quoted strings, comments and address
|
||||
* literals, and a form whose job is to produce a line in a Google Play tester list gains
|
||||
* nothing by accepting `"a b"(c)@[192.0.2.1]`. This is the shape of an address a person
|
||||
* types, with a length bound that stops the column being used as storage.
|
||||
*
|
||||
* Lower-cased on the way in. The store's UNIQUE constraint is already NOCASE, so this is
|
||||
* about what gets *written* — a CSV pasted into Play should not carry a stranger's
|
||||
* capitalisation choices as though they were significant.
|
||||
*/
|
||||
const EMAIL_RE = /^[^\s@,;:<>"'()[\]\\]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
export function normaliseEmail(raw) {
|
||||
const value = String(raw ?? '').trim().toLowerCase();
|
||||
if (!value || value.length > 254) return null;
|
||||
if (!EMAIL_RE.test(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcomes the page renders. One per branch, so the markup never has to interpret a
|
||||
* message string, and so a new branch cannot be added without giving it a name here.
|
||||
*/
|
||||
export const OUTCOME = {
|
||||
ADDED: 'added',
|
||||
DUPLICATE: 'duplicate',
|
||||
/** Honeypot. Renders as success and writes nothing. */
|
||||
DECOY: 'decoy',
|
||||
STALE: 'stale',
|
||||
TOO_FAST: 'too-fast',
|
||||
LIMITED: 'limited',
|
||||
FULL: 'full',
|
||||
INVALID_EMAIL: 'invalid-email',
|
||||
NO_CONSENT: 'no-consent',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
/** Did this outcome put something in front of the person that looks like success? */
|
||||
export const isSuccess = (outcome) =>
|
||||
outcome === OUTCOME.ADDED || outcome === OUTCOME.DUPLICATE || outcome === OUTCOME.DECOY;
|
||||
|
||||
/**
|
||||
* Run a submitted form through every check and, if it survives, write the row.
|
||||
*
|
||||
* `form` is anything with `.get(name)` — a `FormData` from the request, or a `Map` in a
|
||||
* test. Returns `{ outcome, email? }` and never throws: a store that cannot be written is a
|
||||
* message on one page load, not a stack trace in a person's browser.
|
||||
*/
|
||||
export function submit({ form, ip, userAgent }) {
|
||||
const ipHash = hashIp(ip);
|
||||
const get = (name) => {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
|
||||
// 1. The honeypot. No attempt is recorded — a bot must not be able to consume a real
|
||||
// person's rate limit for a shared address by tripping a field that person cannot see.
|
||||
if (get(fields.HONEYPOT).trim() !== '') return { outcome: OUTCOME.DECOY };
|
||||
|
||||
// 2 and 3. The form has to have come from a page we rendered, recently but not too
|
||||
// recently. A missing or forged token is treated as staleness rather than as an
|
||||
// accusation: the honest cause — a restart, a tab open since yesterday — is far more
|
||||
// common than the dishonest one, and the remedy the page offers is the same. Like the
|
||||
// honeypot it costs no attempt, for the same reason: a caller that never obtained a
|
||||
// token must not be able to spend the budget of everyone behind a shared address.
|
||||
const token = readFormToken(get(fields.ISSUED));
|
||||
if (!token || token.ageSeconds > limits.maxSeconds) return { outcome: OUTCOME.STALE };
|
||||
if (token.ageSeconds < limits.minSeconds) {
|
||||
recordAttempt(ipHash, OUTCOME.TOO_FAST);
|
||||
return { outcome: OUTCOME.TOO_FAST };
|
||||
}
|
||||
|
||||
// 4. The rate limit, before anything is parsed.
|
||||
const counts = attemptCounts(ipHash);
|
||||
if (counts.hour >= limits.perHour || counts.day >= limits.perDay) {
|
||||
recordAttempt(ipHash, OUTCOME.LIMITED);
|
||||
return { outcome: OUTCOME.LIMITED };
|
||||
}
|
||||
|
||||
// 5. The cap. Checked here rather than only when rendering the form, because the form
|
||||
// a person is looking at may have been rendered before the last row went in.
|
||||
if (isFull()) {
|
||||
recordAttempt(ipHash, OUTCOME.FULL);
|
||||
return { outcome: OUTCOME.FULL };
|
||||
}
|
||||
|
||||
// 6. The two things a real person gets wrong.
|
||||
const email = normaliseEmail(get(fields.EMAIL));
|
||||
if (!email) {
|
||||
recordAttempt(ipHash, OUTCOME.INVALID_EMAIL);
|
||||
return { outcome: OUTCOME.INVALID_EMAIL };
|
||||
}
|
||||
|
||||
if (!get(fields.CONSENT)) {
|
||||
recordAttempt(ipHash, OUTCOME.NO_CONSENT);
|
||||
return { outcome: OUTCOME.NO_CONSENT, email };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = addSignup({ email, ipHash, userAgent, consentText: CONSENT_TEXT });
|
||||
const outcome = result.duplicate ? OUTCOME.DUPLICATE : OUTCOME.ADDED;
|
||||
recordAttempt(ipHash, outcome);
|
||||
return { outcome, email };
|
||||
} catch (error) {
|
||||
// A full disk, a read-only mount, a corrupt file. The operator gets the detail; the
|
||||
// person gets a page that admits it went wrong rather than one that pretends it did not.
|
||||
console.error('[beta] could not record a signup:', error);
|
||||
return { outcome: OUTCOME.ERROR, email };
|
||||
}
|
||||
}
|
||||
340
src/lib/betaStore.mjs
Normal file
340
src/lib/betaStore.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* betaStore.mjs — the closed-beta signup store. PLAN.md §8, built in phase 5.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* ONE FILE ON A BIND MOUNT, AND NOTHING ELSE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* SQLite at `<data>/beta.sqlite`, where `<data>` is `/app/data` in the container and
|
||||
* `./data` in a working tree — the same shape as the brand mount (§6, §7). The whole store
|
||||
* is one file the operator can copy, back up, or delete, on a disk they already have shell
|
||||
* on. That is the property that lets §8 refuse to build an admin page: there is no
|
||||
* authenticated surface on this site, because every operation on this data is performed by
|
||||
* the one person who can already `cd` to the directory.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IS AND IS NOT STORED
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The address, because pasting it into Play is the point. The consent wording, because a
|
||||
* record of consent that cannot reproduce the words is not one. A user agent, because it
|
||||
* is the only signal that separates a browser from a script after the fact.
|
||||
*
|
||||
* **Never the IP address.** `ip_hash` is a salted SHA-256 and the salt lives in the
|
||||
* environment, so the linkage is destroyed by rotating an env var rather than by a
|
||||
* migration — and a copy of this file, on its own, cannot be turned back into a list of
|
||||
* people's addresses. Rate limiting works fine against a hash; identification does not.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* OPENED LAZILY, AND SURVIVING NOT BEING OPENABLE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The database is opened on first use, not at import. Two reasons, both real: the CLI and
|
||||
* the server import overlapping code and only one of them should create a file in a
|
||||
* developer's working tree, and `/beta` is a prerendered-looking page whose GET must render
|
||||
* even when the mount is missing or read-only. A signup that cannot be written is an error
|
||||
* on one request; a store that throws at import is a site that will not boot.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { limits } from '../data/beta.mjs';
|
||||
|
||||
/** The bind mount. `DATA_DIR` matches `BRAND_DIR`'s convention in `brandAssets.mjs`. */
|
||||
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
|
||||
/** The store itself. Overridable outright so the tests can point at a scratch file. */
|
||||
export const DB_PATH = process.env.BETA_DB || path.join(DATA_DIR, 'beta.sqlite');
|
||||
|
||||
/** Where `scripts/beta.mjs export` writes. §8 names this path. */
|
||||
export const EXPORT_DIR = path.join(DATA_DIR, 'exports');
|
||||
|
||||
/**
|
||||
* The salt for `ip_hash`.
|
||||
*
|
||||
* A missing salt is not an error, because a site that refuses to serve a signup form over a
|
||||
* missing environment variable is worse than one that rate-limits against a salt nobody
|
||||
* wrote down. But an *unset* salt must never be a *constant* — a hard-coded default would
|
||||
* make every deployment's hashes identical and therefore reversible by anyone with the
|
||||
* source, which is this project's threat model in one sentence. So the fallback is random
|
||||
* per process: rate limiting works within a run, and the linkage does not survive a restart.
|
||||
*
|
||||
* Set `BETA_IP_SALT` in the compose file to make the limiter outlive a deploy.
|
||||
*
|
||||
* Resolved on first use rather than at import, and the warning goes with it. The CLI shares
|
||||
* this module and never hashes anything, so an eager constant meant `beta.mjs stats` opened
|
||||
* with a warning about a variable that operation does not use — which is how an operator
|
||||
* learns to read past warnings.
|
||||
*/
|
||||
let ipSalt = null;
|
||||
|
||||
function salt() {
|
||||
if (ipSalt) return ipSalt;
|
||||
|
||||
ipSalt = process.env.BETA_IP_SALT || '';
|
||||
if (!ipSalt) {
|
||||
console.warn(
|
||||
'[beta] BETA_IP_SALT is not set — using a random per-process salt. Rate limits will ' +
|
||||
'reset on restart. Set it in the compose environment to make them persist.'
|
||||
);
|
||||
ipSalt = randomBytes(32).toString('hex');
|
||||
}
|
||||
return ipSalt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema of §8, verbatim, plus the one table §8 describes in prose but does not draw.
|
||||
*
|
||||
* `email … COLLATE NOCASE` on the UNIQUE constraint is what makes a duplicate answerable
|
||||
* idempotently rather than twice: `Foo@example.com` and `foo@example.com` are one person,
|
||||
* and the store — not the caller — is where that has to be true, because the caller is
|
||||
* three different entry points.
|
||||
*
|
||||
* `attempts` is the token bucket §8 asks for, persisted as the events themselves rather
|
||||
* than as a counter. A counter would need a decay schedule and a clock it trusts; a rolling
|
||||
* count over rows needs neither, prunes trivially, and can answer "why was this refused"
|
||||
* long enough after the fact to be useful. It records EVERY attempt that reaches the
|
||||
* limiter, not every success — a script hammering invalid addresses is exactly the traffic
|
||||
* the limit exists for, and counting only what succeeded would exempt it.
|
||||
*/
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS signups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL,
|
||||
ip_hash TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
consent_text TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS signups_status ON signups (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attempts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ip_hash TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS attempts_ip_at ON attempts (ip_hash, at);
|
||||
`;
|
||||
|
||||
/** Row states. `exported` means "pasted into Play", which only the CLI can know. */
|
||||
export const STATUS = { NEW: 'new', EXPORTED: 'exported', REMOVED: 'removed' };
|
||||
|
||||
let db = null;
|
||||
|
||||
/**
|
||||
* Open (and migrate) the store, creating the mount directory if it is missing.
|
||||
*
|
||||
* WAL is on because two processes touch this file: the server, and the operator running the
|
||||
* CLI against a running container. In the default rollback journal a reader blocks a writer,
|
||||
* so `beta.mjs stats` during a signup is a locking error rather than a number. `busy_timeout`
|
||||
* covers the rest — the writes here are single rows, and waiting five seconds is always
|
||||
* better than failing a person's signup.
|
||||
*/
|
||||
export function open() {
|
||||
if (db) return db;
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Release the handle. The tests need it; the server never calls it. */
|
||||
export function close() {
|
||||
if (!db) return;
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
|
||||
/** The salted hash §8 stores in place of an address. */
|
||||
export function hashIp(ip) {
|
||||
return createHash('sha256')
|
||||
.update(`${salt()}:${ip || 'unknown'}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const isoAgo = (ms) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
/**
|
||||
* Record an attempt, and prune the ones too old to inform any limit.
|
||||
*
|
||||
* Pruning on write rather than on a timer keeps the whole store self-maintaining: there is
|
||||
* no scheduler in this container and nothing should have to remember to run. The window is
|
||||
* the longest limit plus a margin, so nothing a limit still needs is ever thrown away.
|
||||
*/
|
||||
export function recordAttempt(ipHash, outcome) {
|
||||
const handle = open();
|
||||
handle.prepare('INSERT INTO attempts (ip_hash, at, outcome) VALUES (?, ?, ?)').run(
|
||||
ipHash,
|
||||
nowIso(),
|
||||
outcome
|
||||
);
|
||||
handle.prepare('DELETE FROM attempts WHERE at < ?').run(isoAgo(48 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many attempts this hash has made in the last hour and the last day.
|
||||
*/
|
||||
export function attemptCounts(ipHash) {
|
||||
const handle = open();
|
||||
const count = (since) =>
|
||||
handle
|
||||
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE ip_hash = ? AND at >= ?')
|
||||
.get(ipHash, since).n;
|
||||
|
||||
return {
|
||||
hour: count(isoAgo(60 * 60 * 1000)),
|
||||
day: count(isoAgo(24 * 60 * 60 * 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows that count against the global cap — everything not withdrawn. */
|
||||
export function liveCount() {
|
||||
return open()
|
||||
.prepare('SELECT COUNT(*) AS n FROM signups WHERE status != ?')
|
||||
.get(STATUS.REMOVED).n;
|
||||
}
|
||||
|
||||
/** Is the form closed because §8's global cap is reached? */
|
||||
export function isFull() {
|
||||
return liveCount() >= limits.totalCap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a signup, or report that it is already there.
|
||||
*
|
||||
* The duplicate case returns `{ duplicate: true }` rather than throwing, and the page says
|
||||
* "you are already on the list" either way — §8's rule, and the reason for it is not
|
||||
* politeness. An error that distinguishes "added" from "already enrolled" turns this form
|
||||
* into an oracle for whether a given address is in the beta, which is a disclosure about a
|
||||
* person made to anyone who can type their address.
|
||||
*
|
||||
* A previously removed address is treated as new, and that follows from `removeSignup`
|
||||
* rather than being a separate decision: removal ERASES the address, so there is nothing
|
||||
* left to recognise. Keeping a hash of it in order to say "you were removed" would mean
|
||||
* retaining a derived identifier for the one person who asked not to be retained. Somebody
|
||||
* who left and signs up again has chosen to sign up again, which is the correct reading.
|
||||
*/
|
||||
export function addSignup({ email, ipHash, userAgent, consentText }) {
|
||||
const handle = open();
|
||||
|
||||
const existing = handle
|
||||
.prepare('SELECT id, status FROM signups WHERE email = ?')
|
||||
.get(email);
|
||||
if (existing) return { duplicate: true, id: existing.id, status: existing.status };
|
||||
|
||||
const info = handle
|
||||
.prepare(
|
||||
`INSERT INTO signups (email, created_at, ip_hash, user_agent, consent_text, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
email,
|
||||
nowIso(),
|
||||
ipHash,
|
||||
// A user agent is a header, and a header is whatever the client felt like sending.
|
||||
// Truncated because the column is a signal, not a transcript.
|
||||
(userAgent || '').slice(0, 400) || null,
|
||||
consentText,
|
||||
STATUS.NEW
|
||||
);
|
||||
|
||||
return { duplicate: false, id: Number(info.lastInsertRowid), status: STATUS.NEW };
|
||||
}
|
||||
|
||||
/** Rows for the CLI's export, oldest first so a CSV reads in signup order. */
|
||||
export function pending({ all = false } = {}) {
|
||||
const handle = open();
|
||||
return all
|
||||
? handle
|
||||
.prepare('SELECT * FROM signups WHERE status != ? ORDER BY id')
|
||||
.all(STATUS.REMOVED)
|
||||
: handle.prepare('SELECT * FROM signups WHERE status = ? ORDER BY id').all(STATUS.NEW);
|
||||
}
|
||||
|
||||
/** Mark rows as pasted into Play. */
|
||||
export function markExported(ids) {
|
||||
if (!ids.length) return 0;
|
||||
|
||||
const handle = open();
|
||||
const stamp = handle.prepare('UPDATE signups SET status = ?, note = ? WHERE id = ?');
|
||||
const note = `exported ${nowIso()}`;
|
||||
|
||||
const run = handle.transaction((list) => {
|
||||
for (const id of list) stamp.run(STATUS.EXPORTED, note, id);
|
||||
return list.length;
|
||||
});
|
||||
|
||||
return run(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Honour a deletion request.
|
||||
*
|
||||
* The address itself is overwritten, not just flagged: §9 promises deletion, and a row that
|
||||
* still holds the address it promised to delete has not delivered on that. What remains is
|
||||
* a tombstone — the id, the date, and the fact that a removal happened — which is what lets
|
||||
* the operator answer "did you action my request" without keeping the thing they asked to
|
||||
* have removed. The placeholder keeps the UNIQUE constraint satisfiable, so the same person
|
||||
* signing up again later is an ordinary new row.
|
||||
*
|
||||
* ONE CONSEQUENCE, AND IT IS THE RIGHT ONE. After this runs, the store cannot tell a
|
||||
* removed address from one it has never seen — asking twice gives the same answer as asking
|
||||
* about a stranger. That is what erasure means. The alternative, keeping a hash so a second
|
||||
* request could say "already removed", would be retaining a derived identifier for the one
|
||||
* person who has explicitly asked not to be retained, in order to improve a message only an
|
||||
* operator reads.
|
||||
*/
|
||||
export function removeSignup(email) {
|
||||
const handle = open();
|
||||
|
||||
const row = handle.prepare('SELECT id, status FROM signups WHERE email = ?').get(email);
|
||||
if (!row) return { removed: false };
|
||||
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE signups
|
||||
SET email = ?, ip_hash = '', user_agent = NULL, status = ?, note = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(`removed-${row.id}@invalid`, STATUS.REMOVED, `removed ${nowIso()}`, row.id);
|
||||
|
||||
return { removed: true, id: row.id };
|
||||
}
|
||||
|
||||
/** §8's `stats`. */
|
||||
export function stats() {
|
||||
const handle = open();
|
||||
|
||||
const byStatus = Object.fromEntries(
|
||||
handle
|
||||
.prepare('SELECT status, COUNT(*) AS n FROM signups GROUP BY status')
|
||||
.all()
|
||||
.map((row) => [row.status, row.n])
|
||||
);
|
||||
|
||||
return {
|
||||
total: handle.prepare('SELECT COUNT(*) AS n FROM signups').get().n,
|
||||
new: byStatus[STATUS.NEW] || 0,
|
||||
exported: byStatus[STATUS.EXPORTED] || 0,
|
||||
removed: byStatus[STATUS.REMOVED] || 0,
|
||||
live: liveCount(),
|
||||
cap: limits.totalCap,
|
||||
attemptsLastDay: handle
|
||||
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE at >= ?')
|
||||
.get(isoAgo(24 * 60 * 60 * 1000)).n,
|
||||
path: DB_PATH,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
|
||||
|
||||
/**
|
||||
@@ -37,3 +40,76 @@ export const brand = Object.freeze({ ...brandDefault });
|
||||
export function brandFields() {
|
||||
return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$')));
|
||||
}
|
||||
|
||||
/* =========================================================================================
|
||||
THE LIVE READ, FOR ON-DEMAND ROUTES ONLY (phase 5)
|
||||
=========================================================================================
|
||||
|
||||
Everything above is build-time, and the boot rewrite is what carries the mount into
|
||||
prerendered HTML. Neither reaches a page that renders per request: `applyBrand.mjs`
|
||||
rewrites files in `dist/client`, and an on-demand route's HTML never existed as a file.
|
||||
A server-rendered page reading `brand` would therefore show the STOCK value forever, no
|
||||
matter what is mounted — §7 quietly untrue, on exactly the page that needs it most.
|
||||
|
||||
So `/beta` reads the mount itself. It is allowed to, because it is already executing:
|
||||
the reason the rest of the site cannot is that it is not running when its HTML is made,
|
||||
and that argument does not apply here.
|
||||
|
||||
It is also strictly better where it applies. The rewrite happens at boot, so changing a
|
||||
mounted value means restarting the container; this is picked up on the next request. An
|
||||
operator who pastes the Play opt-in URL into `brand.json` has a working confirmation
|
||||
screen before they have finished reading this sentence.
|
||||
|
||||
The mtime guard is what keeps that from being a file read per request. `statSync` on a
|
||||
file the OS has cached is cheap enough to do on every render and honest enough to notice
|
||||
an edit immediately, which a TTL would not be. */
|
||||
|
||||
const MOUNTED_BRAND = path.join(
|
||||
process.env.BRAND_DIR || path.join(process.cwd(), 'brand'),
|
||||
'brand.json'
|
||||
);
|
||||
|
||||
let cache = { mtimeMs: -1, value: brand };
|
||||
|
||||
/**
|
||||
* The brand as it is on disk right now: the mounted `brand.json` layered over the stock
|
||||
* one, per key. Use from on-demand routes; prerendered pages must keep using `brand`.
|
||||
*
|
||||
* Never throws. A missing mount is the normal case, and a malformed one is the operator's
|
||||
* typo — both fall back to stock with a log line, for the reason `applyBrand.mjs` gives at
|
||||
* length: a site up with the wrong logo beats a site down with the right one.
|
||||
*/
|
||||
export function liveBrand() {
|
||||
let mtimeMs;
|
||||
try {
|
||||
mtimeMs = statSync(MOUNTED_BRAND).mtimeMs;
|
||||
} catch {
|
||||
// No mounted file. Cache the stock answer against a sentinel so the miss is not
|
||||
// re-statted into a re-parse every request.
|
||||
if (cache.mtimeMs !== -1) cache = { mtimeMs: -1, value: brand };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
if (mtimeMs === cache.mtimeMs) return cache.value;
|
||||
|
||||
let mounted = null;
|
||||
try {
|
||||
mounted = JSON.parse(readFileSync(MOUNTED_BRAND, 'utf8'));
|
||||
} catch (error) {
|
||||
console.error(`[brand] the mounted brand.json is not valid JSON and is being ignored: ${error.message}`);
|
||||
}
|
||||
|
||||
const merged = { ...brand };
|
||||
if (mounted && typeof mounted === 'object') {
|
||||
for (const [key, value] of Object.entries(mounted)) {
|
||||
// Same two rules as the boot rewrite: `$comment` keys are documentation, and a field
|
||||
// the stock file does not declare is a typo rather than a new feature.
|
||||
if (key.startsWith('$')) continue;
|
||||
if (!(key in brand)) continue;
|
||||
if (typeof value === 'string') merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
cache = { mtimeMs, value: Object.freeze(merged) };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
@@ -327,6 +327,13 @@ export function warmCache() {
|
||||
'favicon-32.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
// The homepage hero (phase 3). Its <picture> offers 256/384/512 in both formats and
|
||||
// the browser picks one, so warming all six would be five wasted encodes; these are
|
||||
// the two the common viewport-and-DPR combinations resolve to, plus the WebP the
|
||||
// `src` attribute names for anything without AVIF.
|
||||
'logo-384.avif',
|
||||
'logo-512.avif',
|
||||
'logo-384.webp',
|
||||
];
|
||||
return Promise.allSettled(names.map((name) => resolveBrandFile(name)));
|
||||
}
|
||||
|
||||
350
src/pages/app.astro
Normal file
350
src/pages/app.astro
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import NotBuilt from '../components/NotBuilt.astro';
|
||||
import Screenshots from '../components/app/Screenshots.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
import { appFeatures, requiresDeployment } from '../data/app.mjs';
|
||||
import { playPolicy } from '../data/beta.mjs';
|
||||
|
||||
/**
|
||||
* `/app/` — PLAN.md §10, phase 5.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE PAGE LEADS WITH A LIMITATION, ON PURPOSE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Directly under the lede, before a single feature, this page says the app ships pointed at
|
||||
* nothing and will not open until it is given the address of a deployment. That is an
|
||||
* unusual thing to put above the fold and it is the right thing here, because the
|
||||
* alternative is somebody installing a 13 MB client and discovering it on the first screen.
|
||||
* §1's "understated honesty" is cheapest to keep exactly at the moment it costs a download.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* TWO WAYS TO GET IT, EQUALLY WEIGHTED — AND ONE OF THEM IS CURRENTLY OFF
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The org lead's call: the sideload and the beta get the same visual weight, with the beta
|
||||
* arguing for itself on delivery and updates rather than on being the only door. Both
|
||||
* panels are the same component, side by side, neither styled as the primary.
|
||||
*
|
||||
* The APK panel then has a second state, because the published build does not work. It
|
||||
* renders `platform.androidApk.serviceable ? <the two download links> : <a plain statement
|
||||
* that the build is being replaced>`. That flag is a person's judgement rather than a
|
||||
* fetched fact — `checkFacts.mjs` asserts the assets EXIST but cannot assert they run — so
|
||||
* turning the link back on is one boolean in `platform.json`, in the same commit as
|
||||
* whatever release fixed it.
|
||||
*
|
||||
* Note what this deliberately does not do: it does not remove the panel. A page that simply
|
||||
* omitted sideloading while the build is broken would read, to somebody who was told the
|
||||
* APK exists, as a page hiding it.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE SCREENSHOT SLOT IS EMPTY AND THAT IS THE DECISION (D26)
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §10 promised "the 14 existing screenshots". They exist, and they are the wrong fourteen:
|
||||
* a July trusted-device smoke test against an unseeded development instance, captured
|
||||
* before the theming work landed, showing mostly login and two-factor screens over an
|
||||
* almost empty home page. Shipping them would break D4 (real screenshots, from the review
|
||||
* stack, not placeholders) and would show an app that no longer looks like that.
|
||||
*
|
||||
* So `Screenshots.astro` renders nothing until phase 9 fills it — the phase that already
|
||||
* stands up the review stack and seeds presentable content, and now also captures the app
|
||||
* against it, so the phone shots and the web shots show the same deployment. The component
|
||||
* exists now so the slot has a defined shape and phase 9 is a data change.
|
||||
*/
|
||||
const title = 'The Android app';
|
||||
const description =
|
||||
'A native Android client for a Runic Gateway deployment: the shard, the site and your ' +
|
||||
'account, themed by whichever community you point it at.';
|
||||
|
||||
const apk = platform.androidApk;
|
||||
const release = platform.releases['Android-app'];
|
||||
const releasePage = `${platform.gitea.base}/${platform.gitea.org}/Android-app/releases`;
|
||||
const downloadBase = `${releasePage}/download/${release}`;
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="On your phone" title="The app for a deployment you already use">
|
||||
<p>
|
||||
A native Android client — Kotlin and Compose, not a website in a frame. It shows the
|
||||
live game data a deployment publishes, the news and wiki it hosts, and the parts of
|
||||
your account that make sense on a phone.
|
||||
</p>
|
||||
<p>
|
||||
It is <a href={releasePage} rel="noopener noreferrer">open source like everything else
|
||||
here</a>, and it carries no Google messaging dependency: notifications arrive over a
|
||||
server the operator runs.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<!-- The limitation, before the features. See the note above. -->
|
||||
<section class="page section">
|
||||
<div class="panel prereq">
|
||||
<h2>{requiresDeployment.title}</h2>
|
||||
<p>{requiresDeployment.body}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<h2 class="app-h2">What it does</h2>
|
||||
<p class="prose app-lede">
|
||||
Grouped by what you would open it for. Most of this is conditional on the deployment
|
||||
you connect to — a community that runs no game module has a news and account app, and
|
||||
that is a legitimate way to run this.
|
||||
</p>
|
||||
|
||||
{
|
||||
appFeatures.map((group) => (
|
||||
<section class="app-group">
|
||||
<h3>{group.heading}</h3>
|
||||
{group.blurb && <p class="app-group__blurb">{group.blurb}</p>}
|
||||
<ul class="app-grid">
|
||||
{group.items.map((item) => (
|
||||
<li class="panel app-item">
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.body}</p>
|
||||
{item.gate && (
|
||||
<p class="app-item__gate">
|
||||
<span class="app-item__gate-label">Needs</span>
|
||||
{item.gate}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
|
||||
<Screenshots />
|
||||
|
||||
<section class="page section" id="get-it">
|
||||
<h2 class="app-h2">Two ways to get it</h2>
|
||||
<p class="prose app-lede">
|
||||
Neither is the “real” one. Sideloading works today and always will;
|
||||
the closed test is how it reaches a phone through Play, with updates that install
|
||||
themselves.
|
||||
</p>
|
||||
|
||||
<div class="app-getgrid">
|
||||
<div class="panel app-get">
|
||||
<p class="eyebrow">Direct download</p>
|
||||
<h3>The signed APK</h3>
|
||||
|
||||
{
|
||||
apk.serviceable ? (
|
||||
<>
|
||||
<p>
|
||||
Built and signed by the same CI that cuts every release. Android asks you to
|
||||
allow installing from your browser or file manager the first time; the
|
||||
checksum file is there so you can verify what you downloaded before you do.
|
||||
</p>
|
||||
<p class="app-get__actions">
|
||||
<a class="btn btn--primary" href={`${downloadBase}/${apk.asset}`} rel="noopener noreferrer">
|
||||
Download {release}
|
||||
</a>
|
||||
<a class="btn btn--ghost" href={`${downloadBase}/${apk.checksums}`} rel="noopener noreferrer">
|
||||
Checksums
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<strong>The published build is being replaced.</strong> {release} is on the
|
||||
releases page but does not install and run correctly, so this page does not
|
||||
link it — a download that wastes your time is worse than no download.
|
||||
</p>
|
||||
<p>
|
||||
The next release restores this. Nothing about the app has been withdrawn and
|
||||
the source has not moved; it is one build that went out wrong.
|
||||
</p>
|
||||
<p class="app-get__actions">
|
||||
<a class="btn btn--ghost" href={releasePage} rel="noopener noreferrer">
|
||||
The releases page
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="app-get__foot">
|
||||
Android {apk.minAndroid} or newer · installs as <code>{platform.androidApplicationId}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel app-get">
|
||||
<p class="eyebrow">Google Play</p>
|
||||
<h3>The closed beta</h3>
|
||||
<p>
|
||||
Delivery through Play, and updates that arrive on their own instead of being
|
||||
downloaded again. It is a closed test, so a place on it has to be granted — the
|
||||
list is being collected now.
|
||||
</p>
|
||||
<p>
|
||||
It has not opened yet, and the page says why in full rather than promising a date:
|
||||
Play needs {playPolicy.testersRequired} people opted in for {playPolicy.testerDays}
|
||||
{' '}days before the app can go any further, and a tester needs somewhere to point
|
||||
it.
|
||||
</p>
|
||||
<p class="app-get__actions">
|
||||
<a class="btn btn--primary" href="/beta/">Join the list</a>
|
||||
</p>
|
||||
<p class="app-get__foot">
|
||||
No email is ever sent · the address is used for the tester list and nothing else
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NotBuilt scope="app" title="What the app does not do" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.app-h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.app-lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The prerequisite panel. Given a gold edge rather than a warning colour: it is a fact
|
||||
about the product, not an error state, and D8's house style does not shout. */
|
||||
.prereq {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.prereq h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: clamp(1.25rem, 2.6vw, 1.5rem);
|
||||
}
|
||||
|
||||
.prereq p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-group + .app-group {
|
||||
margin-top: 2.75rem;
|
||||
}
|
||||
|
||||
.app-group h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--head);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.app-group__blurb {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-item h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-item p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* Same foot-of-card treatment as NotBuilt's exit condition, so "what this needs" reads
|
||||
as the same kind of statement in the same place on every card in a row. */
|
||||
.app-item__gate {
|
||||
flex: 0;
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.app-item__gate-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Two columns that stay equal. `1fr 1fr` rather than auto-fit is the whole point of the
|
||||
org lead's "equal billing": auto-fit would let the longer panel take more room and
|
||||
turn a deliberate tie into an accidental winner. */
|
||||
.app-getgrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-getgrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-get {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-get h3 {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.app-get p {
|
||||
margin: 0 0 0.9rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.app-get__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
/* Pushes the buttons to the same line in both panels regardless of prose length —
|
||||
the second half of keeping the billing equal. */
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.app-get__foot {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.app-get__foot code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
</style>
|
||||
172
src/pages/architecture.astro
Normal file
172
src/pages/architecture.astro
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import TwoHosts from '../components/architecture/TwoHosts.astro';
|
||||
import Allowlist from '../components/architecture/Allowlist.astro';
|
||||
import ModuleSeam from '../components/architecture/ModuleSeam.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/architecture/` — PLAN.md §13 phase 4, built to D21.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS PAGE IS FOR, AND WHAT IT DELIBERATELY IS NOT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §10 gives it one audience: "a technical evaluator deciding whether to run it". That is a
|
||||
* narrower job than "explain the system", and the narrowness is what keeps this page from
|
||||
* becoming a worse copy of the Architecture section in the documentation, which phases 7
|
||||
* and 8 write.
|
||||
*
|
||||
* So the page answers four questions an evaluator actually has, in the order they have
|
||||
* them — what am I deploying, what leaves my server, what is core and what is a module,
|
||||
* and what happens when a part of it dies — and it answers them with drawings and reasons.
|
||||
* It carries no endpoint tables, no configuration keys, no schema and no event catalog.
|
||||
* Those exist, they are canonical elsewhere, and a second copy here would be a copy that
|
||||
* goes stale (§1). Every one of them is a link out.
|
||||
*
|
||||
* The three diagrams are §11's motif doing actual work rather than decoration: each one
|
||||
* draws a boundary, and the boundary is the argument in all three cases. The vocabulary
|
||||
* they share lives in `src/styles/diagram.css`.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* LINKS OUT GO TO `/docs/`, NOT TO A GUESSED SLUG
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The same convention phase 3 set for the homepage: phases 7 and 8 own the documentation
|
||||
* slugs, so linking `/docs/architecture/the-bridge/` today would put a URL in this file
|
||||
* that nothing checks and a later phase would have to remember to fix. Links into the
|
||||
* repositories are different — those are real paths that exist now, and `checkLinks.mjs`
|
||||
* holds them to a branch path rather than a commit permalink.
|
||||
*/
|
||||
const title = 'Architecture';
|
||||
const description =
|
||||
'How Runic Gateway is put together: what you deploy, what crosses the network, and where ' +
|
||||
'the game-specific half stops.';
|
||||
|
||||
const docs = `${platform.gitea.base}/${platform.gitea.org}/docs/src/branch/main`;
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="How it is built" title="The parts, and the lines between them">
|
||||
<p>
|
||||
Three boundaries decide almost everything about how this software behaves: the one
|
||||
between your two machines, the one between what the public sees and what staff see, and
|
||||
the one between the platform and the game. Each is drawn below, with the reasoning
|
||||
rather than the reference.
|
||||
</p>
|
||||
<p>
|
||||
Nothing here is a specification. Where a real one exists it is linked — the protocol,
|
||||
the module contract and the operator guide are all documents in the open, and they are
|
||||
the authority when this page and one of them disagree.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<TwoHosts />
|
||||
<Allowlist />
|
||||
<ModuleSeam />
|
||||
|
||||
<section class="page section deeper">
|
||||
<div class="panel deeper__panel">
|
||||
<p class="eyebrow">Going deeper</p>
|
||||
<h2>The documents this page is a summary of</h2>
|
||||
<p class="prose deeper__lede">
|
||||
Everything above is an argument about shapes. These are the things that specify them,
|
||||
and they are what a module author, an integrator or an operator should be reading.
|
||||
</p>
|
||||
|
||||
<ul class="deeper__list">
|
||||
<li>
|
||||
<a href={`${docs}/link/INTEGRATION.md`} rel="noopener noreferrer">
|
||||
The bridge protocol
|
||||
</a>
|
||||
<span
|
||||
>What the game and the sidecar say to each other, and what the sidecar publishes.
|
||||
Protocol {platform.protocol} today, and versioned so a mismatched pair is refused
|
||||
rather than misread.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
|
||||
The module contract
|
||||
</a>
|
||||
<span
|
||||
>The normative interface between core and a module — currently
|
||||
{platform.moduleApi}. This is the document that decides whether your module
|
||||
loads.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`${docs}/installer/INSTALL.md`} rel="noopener noreferrer">
|
||||
The operator guide
|
||||
</a>
|
||||
<span
|
||||
>Setting the game side up end to end, including the failure modes and what each
|
||||
step should look like when it worked.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/docs/">The documentation on this site</a>
|
||||
<span
|
||||
>The same ground as a guided path rather than a specification, starting from an
|
||||
empty server.</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="deeper__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Start the install guide</a>
|
||||
<a class="btn btn--ghost" href="/modules/">How modules work</a>
|
||||
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">
|
||||
Read the source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.deeper__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.deeper h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.deeper__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.deeper__list {
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
}
|
||||
|
||||
.deeper__list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 2px solid var(--gold-deep);
|
||||
}
|
||||
|
||||
.deeper__list span {
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.deeper__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
647
src/pages/beta.astro
Normal file
647
src/pages/beta.astro
Normal file
@@ -0,0 +1,647 @@
|
||||
---
|
||||
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
|
||||
“coming soon”. 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 to have it deleted and it will be erased. The{' '}
|
||||
<a href="/privacy/">privacy page</a> says all of this in full, including how
|
||||
to ask.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside class="beta-count">
|
||||
<p class="beta-count__n">{signed}</p>
|
||||
<p class="beta-count__label">
|
||||
on the list · 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>
|
||||
288
src/pages/community.astro
Normal file
288
src/pages/community.astro
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/community/` — PLAN.md §10 and §14 N3, built in phase 4.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS IN THIS PHASE AT ALL
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §13's phase table never assigned it one. §10 specifies the page and §14 N3 specifies its
|
||||
* contents, and the header nav and footer have both linked it since phase 1 — so it was a
|
||||
* page the site pointed at and no phase built. The org lead folded it into phase 4 on
|
||||
* 2026-08-20 rather than leaving it to be discovered by the link checker (D23). It is a
|
||||
* marketing page with no new machinery, so this is where it fits.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE HONEST SPLIT, WHICH IS THE WHOLE POINT OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §14 N3 is explicit: this page describes a split rather than a single channel, because the
|
||||
* obvious sentence — "found a bug? open an issue" — is currently false. Gitea registration
|
||||
* is disabled on this instance, so the code is publicly readable and nobody outside the org
|
||||
* can file anything against it. Discord is therefore the front door in fact, not just in
|
||||
* preference (D10), and saying so is cheaper for a reader than letting them find the
|
||||
* sign-up page and its refusal.
|
||||
*
|
||||
* N3 also records that this page is written the same way whether or not registration is
|
||||
* later reopened — only one sentence changes. That sentence is marked below, so whoever
|
||||
* changes the Gitea configuration can find it without rereading the page.
|
||||
*
|
||||
* The security address comes from `brand.json` and appears nowhere in this file. D13
|
||||
* publishes a personal address on the understanding that moving to a role address later is
|
||||
* an edit to a mounted file, and `checkFacts.mjs` fails the build if an address is typed
|
||||
* into any source file — including this one, which is the file most likely to want to.
|
||||
*/
|
||||
const title = 'Community';
|
||||
const description =
|
||||
'Where to ask, where the code is, and how to report a security problem.';
|
||||
|
||||
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Getting in touch" title="Three doors, and which one to use">
|
||||
<p>
|
||||
This is a small project run by people with day jobs. There is no support desk and no
|
||||
ticket queue, which is worth knowing before you choose where to put a question — one of
|
||||
these channels answers in minutes and one of them may not answer at all.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section chan">
|
||||
<ul class="chan__grid">
|
||||
<li class="panel chan__card chan__card--primary">
|
||||
<div class="chan__head">
|
||||
<h2>Discord</h2>
|
||||
<span class="chip chip--live">The front door</span>
|
||||
</div>
|
||||
<p class="chan__lede">
|
||||
Questions, bug reports, help getting an install working, and where the Android beta
|
||||
is announced. No account with us to make, nothing to be approved for, and the
|
||||
fastest way to reach somebody who has run this software.
|
||||
</p>
|
||||
<p class="chan__use">
|
||||
<span class="chan__use-label">Use it for</span>
|
||||
Anything you would otherwise open an issue for, and everything you would not.
|
||||
</p>
|
||||
<a class="btn btn--primary" href={brand.discordInvite} rel="noopener noreferrer">
|
||||
Join the Discord
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="panel chan__card">
|
||||
<div class="chan__head">
|
||||
<h2>The code</h2>
|
||||
<span class="chip">Read freely</span>
|
||||
</div>
|
||||
<p class="chan__lede">
|
||||
Every repository is public and readable without signing in to anything — the
|
||||
website, the bridge, the game plugin, the installer, the module, the app and all of
|
||||
the documentation. Clone it, read it, run it.
|
||||
</p>
|
||||
<p class="chan__use">
|
||||
<span class="chan__use-label">One caveat</span>
|
||||
{/*
|
||||
THE SENTENCE §14 N3 SAYS WILL CHANGE. If Gitea registration is reopened —
|
||||
manual confirm, Turnstile, no repository creation by default — this becomes
|
||||
"issues and pull requests are open to anyone with an account", and nothing else
|
||||
on the page moves.
|
||||
*/}
|
||||
Registration on our Gitea is closed at the moment, so filing an issue needs an
|
||||
account we would have to create for you. Ask on Discord and it will reach the same
|
||||
place.
|
||||
</p>
|
||||
<a class="btn btn--ghost" href={gitea} rel="noopener noreferrer">Browse the source</a>
|
||||
</li>
|
||||
|
||||
<li class="panel chan__card">
|
||||
<div class="chan__head">
|
||||
<h2>Security</h2>
|
||||
<span class="chip chip--draft">Private</span>
|
||||
</div>
|
||||
<p class="chan__lede">
|
||||
If you have found something that should not be discussed in a public channel, email
|
||||
it. You will get a human, not a form, and there is no bounty programme to game —
|
||||
just an acknowledgement and a fix.
|
||||
</p>
|
||||
<p class="chan__use">
|
||||
<span class="chan__use-label">Use it for</span>
|
||||
Anything that would let somebody reach a deployment, an account or a game server
|
||||
they should not.
|
||||
</p>
|
||||
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="page section contrib">
|
||||
<div class="panel contrib__panel">
|
||||
<p class="eyebrow">Contributing</p>
|
||||
<h2>What is useful, in order</h2>
|
||||
|
||||
<ol class="contrib__list">
|
||||
<li>
|
||||
<h3>Run it and say what broke</h3>
|
||||
<p>
|
||||
The install path is the priority of this whole project, and the most valuable
|
||||
thing anyone outside it can do is walk it on a machine we have never seen and
|
||||
report where it stopped making sense.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Build a module for another game</h3>
|
||||
<p>
|
||||
There is one module and it is Ultima Online, so the claim that this platform is
|
||||
game-agnostic is currently an argument rather than a demonstration. The
|
||||
<a href="/modules/">Integration Kit</a> exists to be followed by somebody outside
|
||||
this project — and it stays marked draft until it has been.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Fix the documentation</h3>
|
||||
<p>
|
||||
Documentation is versioned alongside the code it describes and a change is not
|
||||
finished until the docs match it. If something you read was wrong, that is a bug
|
||||
of the same kind as any other.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="contrib__note">
|
||||
All of it is free software under the GPL-3.0-or-later, and contributions carry one
|
||||
house rule worth knowing before you start: work done with AI assistance has to say so
|
||||
— a box on the pull request and a trailer on the commit. Undisclosed AI-generated
|
||||
contributions get closed. Every repository's <code>CONTRIBUTING.md</code> has the
|
||||
details.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.chan__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.chan__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.25rem, 3vw, 1.75rem);
|
||||
}
|
||||
|
||||
/* The one channel that actually answers gets the portal edge — the live signal, used
|
||||
here for the same reason it is used on a running shard. */
|
||||
.chan__card--primary {
|
||||
border-color: color-mix(in srgb, var(--portal) 40%, transparent);
|
||||
}
|
||||
|
||||
.chan__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.chan__card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.chan__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Takes the slack so the button sits at the foot of every card in the row. */
|
||||
.chan__use {
|
||||
flex: 1;
|
||||
margin: 1rem 0 1.5rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chan__use-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chan__card .btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.contrib__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.contrib h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.contrib__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: item;
|
||||
}
|
||||
|
||||
.contrib__list li {
|
||||
position: relative;
|
||||
padding-left: 3.25rem;
|
||||
counter-increment: item;
|
||||
}
|
||||
|
||||
.contrib__list li + li {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.contrib__list li::before {
|
||||
content: counter(item);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.contrib__list h3 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.contrib__list p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.contrib__note {
|
||||
margin: 2rem 0 0;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-width: var(--measure);
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
223
src/pages/features.astro
Normal file
223
src/pages/features.astro
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import NotBuilt from '../components/NotBuilt.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
import {
|
||||
capabilityGroups,
|
||||
assertCapabilityCoverage,
|
||||
assertDetailCoverage,
|
||||
} from '../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* `/features/` — PLAN.md §13 phase 4, built to D20.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE SAME LIST THE HOMEPAGE HAS, WITH THE ARGUMENT ATTACHED
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* D18 put all five groups on the homepage named only, and left the per-capability argument
|
||||
* here. This page is therefore not a second list: it is the same `capabilities.mjs` data
|
||||
* rendered with the `detail` line the homepage drops. That is the whole of D20, and it is
|
||||
* what makes "the site advertises something that was removed" a build failure rather than
|
||||
* a thing somebody has to notice.
|
||||
*
|
||||
* Both assertions below run at build time and both name this page in their message.
|
||||
* `assertCapabilityCoverage` is the module contract the homepage also runs — repeated here
|
||||
* deliberately, since either page can be built alone and each should fail on its own.
|
||||
* `assertDetailCoverage` is this page's own: a capability with no detail renders as a
|
||||
* heading with nothing under it, and nothing else in the repo would notice.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THREE THINGS THE MARKUP SAYS THAT THE HOMEPAGE DOES NOT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. WHERE A CAPABILITY COMES FROM. Every group states whether core supplies it or the
|
||||
* installed module does. The homepage carries one chip on one group; here it is a full
|
||||
* sentence on all five, because this is the page a reader arrives at wanting to know
|
||||
* what they get on a deployment with no module at all.
|
||||
*
|
||||
* 2. WHAT NEEDS A MODULE TO FILL IT. Teams and Team forums are core machinery that cannot
|
||||
* originate a Team — see the `needsModule` note in `capabilities.mjs` for what the tree
|
||||
* actually says. That is neither "core" nor "module-supplied", and a page that offered
|
||||
* only those two words would have to lie in one direction or the other (D24).
|
||||
*
|
||||
* 3. WHERE TO SEE IT RUNNING. Capabilities with a stable public route carry a deep link
|
||||
* into the demo, hidden until a `demoUrl` is mounted (D25). The markup contract is
|
||||
* exact and `scripts/checkBrand.mjs` enforces it:
|
||||
*
|
||||
* href="" data-demo-url="" data-demo-path="/uo/market"
|
||||
*
|
||||
* `applyBrand.mjs` recomputes all three attributes at boot. Do not reorder them, do not
|
||||
* insert anything between them, and do not write a path into the `href` — the rewrite
|
||||
* matches bytes, and a stock build hides every one of these links, so a mistake here is
|
||||
* invisible until the day somebody configures a demo.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
assertDetailCoverage();
|
||||
|
||||
const title = 'Features';
|
||||
const description =
|
||||
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="What you get" title="Everything the platform does">
|
||||
<p>
|
||||
Grouped the way the software is actually divided, because that division is the thing
|
||||
most worth understanding before you install it: the core site is game-agnostic and does
|
||||
not know what a shard is, and everything that does arrives as an <a href="/modules/"
|
||||
>installable module</a
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
Today there is one module and it covers Ultima Online, so the second group below is
|
||||
what a UO deployment gets. On a deployment with no module, that group is simply absent
|
||||
and the other four are unchanged.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
{
|
||||
capabilityGroups.map((group) => (
|
||||
<section class="page section group" id={group.id}>
|
||||
<div class="group__head">
|
||||
<h2>{group.title}</h2>
|
||||
<span class:list={['chip', group.moduleSupplied && 'chip--module']}>
|
||||
{group.moduleSupplied ? 'From the installed module' : 'Core'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="prose group__summary">{group.summary}</p>
|
||||
|
||||
<ul class="group__items">
|
||||
{group.items.map((item) => (
|
||||
<li class="panel group__item">
|
||||
<div class="group__item-head">
|
||||
<h3>{item.label}</h3>
|
||||
{item.needsModule && <span class="chip chip--needs">Needs a module</span>}
|
||||
</div>
|
||||
|
||||
<p class="group__detail">{item.detail}</p>
|
||||
|
||||
{item.demoPath && (
|
||||
<a
|
||||
class="demo-link"
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See it running
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
|
||||
<NotBuilt scope="features" title="Things a reader could reasonably expect, that are not here" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.group__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
}
|
||||
|
||||
.group__summary {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.group__items {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
}
|
||||
|
||||
.group__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The chip is taller than the heading's line box, so a card that has one starts its
|
||||
body a few pixels lower than the card beside it. Reserving the chip's height on
|
||||
every head lines the row up whether or not the marker is there. */
|
||||
.group__item-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 1.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.group__item h3 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
.group__detail {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* The module-supplied chip takes the portal colour rather than gold: it is the same
|
||||
distinction the data-path diagram draws in cyan on the homepage — the parts that
|
||||
know about a game — and using one colour for one idea across the site is cheaper
|
||||
for a reader than two decorative ones. */
|
||||
.chip--module {
|
||||
border-color: color-mix(in srgb, var(--portal) 45%, transparent);
|
||||
color: var(--portal);
|
||||
}
|
||||
|
||||
/* Not a warning. It says which of the two halves supplies the thing, on the two
|
||||
capabilities where the answer is "both" — core builds it, a module fills it. */
|
||||
.chip--needs {
|
||||
border-color: color-mix(in srgb, var(--portal) 30%, transparent);
|
||||
color: var(--dim);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Set as a link rather than a `.btn`: there is one of these per capability and a row
|
||||
of buttons inside a card grid would read as the primary action of the page, which
|
||||
it is not — the primary action is reading the list. `.demo-cta` in global.css stays
|
||||
the button treatment, for the homepage's single slot.
|
||||
|
||||
It is the flex item itself rather than a paragraph wrapping one, so that
|
||||
`global.css`'s `[data-demo-url=''] { display: none }` takes the margin away with
|
||||
it. A wrapper would survive its hidden child and leave a 1rem gap at the foot of
|
||||
every card in a stock build — and hiding the wrapper with `:has()` would have put a
|
||||
second `data-demo-url` in the file, which `checkBrand.mjs` reads as a demo slot
|
||||
written outside its contract. The check is right to: it cannot tell a selector from
|
||||
an attribute, and it should not have to guess. */
|
||||
.demo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--portal);
|
||||
font-size: 0.88rem;
|
||||
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
|
||||
}
|
||||
|
||||
.demo-link:hover {
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
.demo-link::after {
|
||||
content: '\2197'; /* north-east arrow: this leaves the site */
|
||||
}
|
||||
</style>
|
||||
@@ -1,79 +1,34 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
import DataPath from '../components/home/DataPath.astro';
|
||||
import SelfHosted from '../components/home/SelfHosted.astro';
|
||||
import Capabilities from '../components/home/Capabilities.astro';
|
||||
import GetStarted from '../components/home/GetStarted.astro';
|
||||
|
||||
/**
|
||||
* Phase 1 is the foundation, not the homepage — phase 3 builds the real one (hero, the
|
||||
* data-path diagram as inline SVG, the grouped capability sections, the reserved demo
|
||||
* slot). This page exists so the shell is provably assembled: layout, header, footer,
|
||||
* tokens, both typefaces, and a fact read from platform.json rather than typed.
|
||||
* The homepage — PLAN.md §13 phase 3.
|
||||
*
|
||||
* Everything it claims is from §2's verified state. Nothing here is marketing copy yet.
|
||||
* Ordered as an argument rather than as a brochure: what it is (hero), how the hard part
|
||||
* works (the data path), why you would want it on your own hardware, what you actually get,
|
||||
* and how to start. The data path comes second on purpose — it is the claim in the tagline,
|
||||
* and a visitor who does not believe it has no reason to read the feature list.
|
||||
*
|
||||
* The page itself holds no copy and no facts. Each section reads versions from
|
||||
* `platform.json` and brand text from `brand.mjs`, so nothing on this route can go stale
|
||||
* without a check going red first (§12).
|
||||
*
|
||||
* `bareTitle` because the hero's own <h1> is the tagline: the default suffix would render
|
||||
* "Runic Gateway — Put your … — Runic Gateway".
|
||||
*/
|
||||
---
|
||||
|
||||
<Base
|
||||
title={`${brand.siteName} — ${brand.tagline}`}
|
||||
description={brand.tagline}
|
||||
bareTitle
|
||||
>
|
||||
<section class="page hero">
|
||||
<p class="eyebrow">Foundation</p>
|
||||
<h1>{brand.siteName}</h1>
|
||||
<p class="hero__tagline prose">{brand.tagline}</p>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
<span class="chip chip--live">Verified {platform.verifiedOn}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<div class="panel prose">
|
||||
<h2>This is the phase 1 scaffold</h2>
|
||||
<p>
|
||||
The layout shell, the token file, the self-hosted typefaces, the documentation
|
||||
theme and the two build-time checks are in place. The homepage itself is phase 3;
|
||||
the marketing pages are phase 4; the documentation — the installation path, which
|
||||
is the priority of the whole project — is phase 7.
|
||||
</p>
|
||||
<p>
|
||||
Every version above was read from <code>src/data/platform.json</code>, and{' '}
|
||||
<code>scripts/checkFacts.mjs</code> re-reads each one from its authority on every
|
||||
build. No version number is written in prose anywhere on this site.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/docs/">Read the documentation</a> ·{' '}
|
||||
<a href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||
<Hero />
|
||||
<DataPath />
|
||||
<SelfHosted />
|
||||
<Capabilities />
|
||||
<GetStarted />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: clamp(3rem, 9vw, 6rem) 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.hero__tagline {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
301
src/pages/integrations.astro
Normal file
301
src/pages/integrations.astro
Normal file
@@ -0,0 +1,301 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import NotBuilt from '../components/NotBuilt.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
* `/integrations/` — PLAN.md §13 phase 4.
|
||||
*
|
||||
* §10 gives it Discord, mobile and push, SSO, "with an explicit 'not built' list". The
|
||||
* explicit list is the reason this page is worth writing carefully: an integrations page is
|
||||
* the one a reader scans for the name of the thing they already use, and the honest answer
|
||||
* for several of those names is no. §2 calls the absent-features list as load-bearing as the
|
||||
* rest, and `NotBuilt` at the foot of this page is where that lands.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE TRADE-OFFS ARE ON THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Each integration carries a `caveat` — the thing you would find out in week two. Discord
|
||||
* voice channels make Team membership visible on a member's Discord profile, because they
|
||||
* are granted by role; the mobile app has no server of ours to point at; SSO will not create
|
||||
* an account. None of those is a defect and all three change whether someone wants the
|
||||
* feature, so leaving them for the documentation would be the dishonest kind of brevity.
|
||||
* That is D8's "understated honesty" doing something other than adjusting adjectives.
|
||||
*
|
||||
* No version numbers are typed here. The app version and the platform's own numbers come
|
||||
* from `platform.json` (§12), which `checkFacts.mjs` re-reads from each repository's
|
||||
* authority on every build.
|
||||
*/
|
||||
const title = 'Integrations';
|
||||
const description =
|
||||
'What Runic Gateway connects to — Discord, mobile push, single sign-on — and what it ' +
|
||||
'deliberately does not.';
|
||||
|
||||
const integrations = [
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
summary:
|
||||
'A bot for the server your community is already sitting in, doing three separate jobs.',
|
||||
points: [
|
||||
{
|
||||
title: 'Slash commands',
|
||||
body:
|
||||
'Commands registered with your guild that answer from your site — so the thing ' +
|
||||
'somebody wants to look up is available where the conversation is happening, ' +
|
||||
'rather than one tab away.',
|
||||
},
|
||||
{
|
||||
title: 'Notifications into channels',
|
||||
body:
|
||||
'News and Team activity bridged into the channels you choose, with a per-Team ' +
|
||||
'override so one group can route its own notifications somewhere else. Delivery ' +
|
||||
'is best-effort and one-shot: a Discord outage never backs anything up on your ' +
|
||||
'site.',
|
||||
},
|
||||
{
|
||||
title: 'A voice channel per Team',
|
||||
body:
|
||||
'A Team can be granted its own voice channel, with membership maintained by the ' +
|
||||
'bot rather than by whoever is online. The bot creates the category, and the ' +
|
||||
'panel reports how many roles your guild has left before Discord’s own limit.',
|
||||
},
|
||||
],
|
||||
caveat:
|
||||
'Voice access is granted with a Discord role, and roles are visible on a member’s ' +
|
||||
'profile — so a Team with a voice channel is a Team anyone in your guild can see the ' +
|
||||
'membership of. That was a deliberate trade for a limit that counts per guild rather ' +
|
||||
'than per channel, and it is the right one for most communities, but it is not private.',
|
||||
},
|
||||
{
|
||||
id: 'mobile',
|
||||
name: 'Mobile and push',
|
||||
summary:
|
||||
'A native Android app against the same documented API the website uses, with push ' +
|
||||
'through a server you run.',
|
||||
points: [
|
||||
{
|
||||
title: 'The same API, not a second one',
|
||||
body:
|
||||
'The app is a client of the API your deployment already publishes, authenticated ' +
|
||||
'with short-lived tokens and rotated, revocable refresh tokens. There is no ' +
|
||||
'mobile-only backend to keep in step.',
|
||||
},
|
||||
{
|
||||
title: 'Push through your own ntfy',
|
||||
body:
|
||||
'Notifications are delivered by a self-hosted ntfy server rather than a vendor in ' +
|
||||
'the middle. Each person chooses which streams reach them; push arrives by ' +
|
||||
'default and can be switched off entirely.',
|
||||
},
|
||||
{
|
||||
title: 'Trusted devices and two-factor',
|
||||
body:
|
||||
'The app shares the site’s account model, including time-based two-factor ' +
|
||||
'codes, recovery codes, and devices you can mark as trusted and revoke later.',
|
||||
},
|
||||
],
|
||||
caveat:
|
||||
'The app points at no server of ours: the person installing it types the address of ' +
|
||||
'the deployment they belong to. That is what makes one app work for every community ' +
|
||||
'running this software, and it means the app is useless until somebody gives them a ' +
|
||||
'URL — which is a thing worth putting in your welcome message.',
|
||||
},
|
||||
{
|
||||
id: 'sso',
|
||||
name: 'Single sign-on',
|
||||
summary:
|
||||
'OAuth2 and OIDC, against Google, Discord, or any provider you already run.',
|
||||
points: [
|
||||
{
|
||||
title: 'Any OIDC provider',
|
||||
body:
|
||||
'Google and Discord are configured by name; anything else that speaks OIDC is ' +
|
||||
'configured generically. Client secrets are encrypted at rest and never returned ' +
|
||||
'to any client.',
|
||||
},
|
||||
{
|
||||
title: 'It signs people in, not up',
|
||||
body:
|
||||
'An external identity has to be linked to an account that already exists on your ' +
|
||||
'site. Signing in with a provider never creates a user — which means the way ' +
|
||||
'someone joins your community stays a decision you make, not one Google makes.',
|
||||
},
|
||||
{
|
||||
title: 'It respects the rest of the login rules',
|
||||
body:
|
||||
'Two-factor, trusted devices and bans all still apply. An identity provider ' +
|
||||
'proves who someone is; it does not decide whether they may come in.',
|
||||
},
|
||||
],
|
||||
caveat:
|
||||
'Link-only is a policy, not a limitation to be worked around. If you were expecting ' +
|
||||
'to open registration by turning on Google sign-in, this will not do that, and it is ' +
|
||||
'not configurable.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="What it connects to" title="The things it talks to, and the things it does not">
|
||||
<p>
|
||||
Three integrations exist and are in use. Each one below says what it does, and then the
|
||||
thing you would otherwise discover in week two — because an integrations page that only
|
||||
lists the good half is how somebody ends up rebuilding their community around an
|
||||
assumption.
|
||||
</p>
|
||||
<p>
|
||||
Everything here is configured on your own deployment, against services you already run
|
||||
or already have an account with. Nothing routes through us; there is no us to route
|
||||
through.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
{
|
||||
integrations.map((integration) => (
|
||||
<section class="page section integ" id={integration.id}>
|
||||
<h2>{integration.name}</h2>
|
||||
<p class="prose integ__summary">{integration.summary}</p>
|
||||
|
||||
<ul class="integ__grid">
|
||||
{integration.points.map((point) => (
|
||||
<li class="panel">
|
||||
<h3>{point.title}</h3>
|
||||
<p>{point.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="panel integ__caveat">
|
||||
<p class="integ__caveat-label">Worth knowing first</p>
|
||||
<p>{integration.caveat}</p>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
|
||||
<section class="page section integ" id="email">
|
||||
<h2>Email, deliberately quiet</h2>
|
||||
<p class="prose integ__summary">
|
||||
Your deployment can send email — Team notifications and newsletters, through an account
|
||||
you connect — and it only ever sends to someone who asked for it. Email is the one
|
||||
channel that is opt-in rather than opt-out, because an unwanted push notification is an
|
||||
annoyance and an unwanted email is a complaint to somebody’s provider.
|
||||
</p>
|
||||
<p class="prose integ__note">
|
||||
This website is a separate matter: <em>runicgateway.com</em> sends no email at all, has
|
||||
no mailbox behind it and no account to make. The address in the footer is a human being.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<NotBuilt scope="integrations" title="Integrations that do not exist" />
|
||||
|
||||
<section class="page section integ" id="build">
|
||||
<div class="panel integ__build">
|
||||
<p class="eyebrow">If you need another one</p>
|
||||
<h2>The API is the integration point</h2>
|
||||
<p class="prose">
|
||||
The whole backend is described by an OpenAPI 3.0 specification that ships with the
|
||||
server, and an installed module merges its own routes into it — so whatever you build
|
||||
against is documented by the thing that is actually running, at
|
||||
{' '}Module API {platform.moduleApi}. The bridge to a game server is a documented wire
|
||||
protocol on the same principle, currently protocol {platform.protocol}.
|
||||
</p>
|
||||
<div class="integ__actions">
|
||||
<a class="btn btn--primary" href="/modules/">How modules work</a>
|
||||
<a class="btn btn--ghost" href="/architecture/">The architecture</a>
|
||||
<a class="btn btn--ghost" href="/docs/">The documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.integ h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.integ__summary {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.integ__note {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.integ__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.integ__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.integ__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* The caveat is a panel like the others rather than a warning box. It is information
|
||||
of the same kind and the same weight — the difference is that it is the half a
|
||||
reader is not expecting, which is a reason to give it its own line, not a reason
|
||||
to make it look like an error message. */
|
||||
.integ__caveat {
|
||||
margin-top: 1rem;
|
||||
border-left: 3px solid var(--gold-deep);
|
||||
}
|
||||
|
||||
.integ__caveat p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.integ__caveat-label {
|
||||
color: var(--gold);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.integ__caveat .integ__caveat-label {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.integ__build {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.integ__build h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.integ__build .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.integ__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
404
src/pages/modules.astro
Normal file
404
src/pages/modules.astro
Normal file
@@ -0,0 +1,404 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import NotBuilt from '../components/NotBuilt.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
import { capabilityGroup } from '../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* `/modules/` — PLAN.md §13 phase 4.
|
||||
*
|
||||
* §10 gives this page four jobs: what a module is, `module-uo` as the worked example,
|
||||
* writing your own, and the Integration Kit with its draft badge (D8). They are in that
|
||||
* order because they are increasing commitment — a reader deciding whether to install one,
|
||||
* a reader wondering what they get, a reader considering building one.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE WORKED EXAMPLE READS ITS OWN CAPABILITIES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The `module-uo` section lists what the module publishes, and it takes that list from
|
||||
* `capabilities.mjs` rather than retyping it — the same list the homepage names and
|
||||
* `/features/` expands, which is already checked against the module's own manifest through
|
||||
* `platform.json` (§12). A third hand-maintained copy on this page is exactly the failure
|
||||
* that machinery exists to prevent, and this is the page where it would be least visible.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE DRAFT CHIP IS A DECISION, NOT A DISCLAIMER
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* D8 marks the Integration Kit draft until a second module is successfully built against
|
||||
* it by somebody outside this project, and requires that status to carry its removal
|
||||
* condition. Both are here: the chip, and the sentence that says what takes it down. The
|
||||
* same absence appears in `notBuilt.mjs`, so a reader who scrolls past the chip meets it
|
||||
* again in the list of things that do not exist.
|
||||
*/
|
||||
const title = 'Modules';
|
||||
const description =
|
||||
'What a module is, what the Ultima Online module publishes, and what it takes to write ' +
|
||||
'one for another game.';
|
||||
|
||||
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
|
||||
const docs = `${gitea}/docs/src/branch/main`;
|
||||
|
||||
const gameIntelligence = capabilityGroup('game-intelligence');
|
||||
|
||||
/** The three ways a module reaches a running deployment. None of them is a build. */
|
||||
const installPaths = [
|
||||
{
|
||||
name: 'From the admin panel',
|
||||
body:
|
||||
'Paste the URL of a release manifest into Admin → Modules and press restart when it ' +
|
||||
'asks. The site downloads the artifact, verifies the checksum the manifest declares, ' +
|
||||
'inspects the whole archive before writing a single file, and unpacks it.',
|
||||
fits: 'The click path, for a host you have no shell on.',
|
||||
},
|
||||
{
|
||||
name: 'From your environment',
|
||||
body:
|
||||
'Name the module and its version in one environment variable and the container ' +
|
||||
'resolves it at every start. Already at that version means no network call at all, so ' +
|
||||
'a restart with the internet down comes up unchanged.',
|
||||
fits: 'A compose-managed host, where the running set should be a line you version-control.',
|
||||
},
|
||||
{
|
||||
name: 'By hand',
|
||||
body:
|
||||
'Unpack the tarball into the modules directory and restart. The bundle is already ' +
|
||||
'assembled — the client half is prebuilt and its one runtime dependency ships inside.',
|
||||
fits: 'Development, and any host where the other two do not fit.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="The extension model" title="One platform, whichever game you run">
|
||||
<p>
|
||||
A module is the entire game-specific half of a deployment, packaged: its routes, its
|
||||
screens, its database tables, its navigation rows and its slice of the API
|
||||
documentation. The core site holds accounts, Teams, the wiki, posts, moderation and the
|
||||
admin panel, and knows nothing about any game at all.
|
||||
</p>
|
||||
<p>
|
||||
That division is not an aspiration bolted on afterwards. The Ultima Online support was
|
||||
extracted out of the site into a module, and every URL it had before the move it still
|
||||
has — which is the only version of this claim worth making.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section mod" id="what">
|
||||
<p class="eyebrow">What you get</p>
|
||||
<h2>What installing one actually does</h2>
|
||||
|
||||
<ul class="mod__grid">
|
||||
<li class="panel">
|
||||
<h3>It brings its own everything</h3>
|
||||
<p>
|
||||
Server routes, React screens, tables, nav rows and an OpenAPI fragment the site
|
||||
merges into its own spec. Nothing about it is a patch to the core site, so
|
||||
upgrading either half does not involve reconciling the other.
|
||||
</p>
|
||||
</li>
|
||||
<li class="panel">
|
||||
<h3>You never build it</h3>
|
||||
<p>
|
||||
The client half ships prebuilt and the artifact is verified against a published
|
||||
checksum before anything is written to disk. Production runs an image you pulled;
|
||||
an operator who has to compile something has been handed a maintenance job.
|
||||
</p>
|
||||
</li>
|
||||
<li class="panel">
|
||||
<h3>It cannot take the site down</h3>
|
||||
<p>
|
||||
A module whose declared interface version does not match is marked failed and the
|
||||
site starts without it — loudly, rather than half-loading. Disabling one closes its
|
||||
connections and stops its routes answering.
|
||||
</p>
|
||||
</li>
|
||||
<li class="panel">
|
||||
<h3>Your data outlives it</h3>
|
||||
<p>
|
||||
Uninstalling removes the module and keeps its tables, so reinstalling picks up
|
||||
exactly where it was. Destroying the data is a separate, opt-in choice made in its
|
||||
own dialog, and it says what it is about to do.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="page section mod" id="installing">
|
||||
<p class="eyebrow">Installing</p>
|
||||
<h2>Three ways in, and none of them is a build</h2>
|
||||
<p class="prose mod__lede">
|
||||
Which one you use is a question about your host, not about the module. All three end
|
||||
the same way: a restart, and the module's screens appear in the navigation.
|
||||
</p>
|
||||
|
||||
<ol class="mod__paths">
|
||||
{
|
||||
installPaths.map((path) => (
|
||||
<li class="panel">
|
||||
<h3>{path.name}</h3>
|
||||
<p>{path.body}</p>
|
||||
<p class="mod__fits">{path.fits}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="page section mod" id="module-uo">
|
||||
<div class="mod__head">
|
||||
<p class="eyebrow">The worked example</p>
|
||||
<h2>module-uo</h2>
|
||||
<span class="chip chip--version">{platform.releases['Module-uo']}</span>
|
||||
</div>
|
||||
|
||||
<p class="prose mod__lede">
|
||||
The Ultima Online module, and the reference every module that follows is measured
|
||||
against. It is what turns a general-purpose community site into something that knows
|
||||
what a shard is — and it is the proof that the seam described on
|
||||
<a href="/architecture/">the architecture page</a> is real, because the code on the far
|
||||
side of it was moved there rather than designed there.
|
||||
</p>
|
||||
|
||||
<div class="mod__example">
|
||||
<div class="panel mod__caps">
|
||||
<h3>What it publishes</h3>
|
||||
<ul>
|
||||
{gameIntelligence.items.map((item) => <li>{item.label}</li>)}
|
||||
</ul>
|
||||
<p class="mod__caps-note">
|
||||
The same list <a href="/features/">features</a> expands, read from one file that is
|
||||
checked against the module's own manifest on every build.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mod__facts">
|
||||
<section>
|
||||
<h3>It connects to a real server</h3>
|
||||
<p>
|
||||
The module talks to the sidecar beside your game server, not to the game. You
|
||||
deploy that side with the installer and paste four values into the admin panel;
|
||||
nothing here requires the game to exist, and with no server configured the site
|
||||
renders normally and shows it offline.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>It owns its own tables</h3>
|
||||
<p>
|
||||
Its schema is applied by the site on every boot and its data is its own. The
|
||||
module declares which versions of the core interface it speaks — the site runs
|
||||
{' '}{platform.moduleApi} — and refuses to load against one it does not.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>It is a separate release</h3>
|
||||
<p>
|
||||
Versioned, tagged and published on its own cadence, independently of the site.
|
||||
Upgrading one does not mean upgrading the other, as long as the declared interface
|
||||
range still holds.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section mod" id="writing">
|
||||
<div class="mod__head">
|
||||
<p class="eyebrow">Writing your own</p>
|
||||
<h2>The Integration Kit</h2>
|
||||
<span class="chip chip--draft">Draft</span>
|
||||
</div>
|
||||
|
||||
<p class="prose mod__lede">
|
||||
A four-chapter book on putting a different game on this platform — the module, the
|
||||
sidecar beside your game server, the plugin inside it — plus a template module that
|
||||
continuous integration builds against a pinned version of the core site, so the
|
||||
instructions cannot quietly stop working.
|
||||
</p>
|
||||
|
||||
<div class="panel mod__draft">
|
||||
<h3>Why it says draft</h3>
|
||||
<p>
|
||||
Because nobody outside this project has yet followed it to a working module, and that
|
||||
is the only test of a set of instructions that counts. The badge comes off when
|
||||
somebody does — that is the stated condition, not a mood, and it is written down so a
|
||||
future reader knows when to take it down.
|
||||
</p>
|
||||
<p>
|
||||
Everything it teaches is real and in use. What is untested is whether it is
|
||||
<em>sufficient</em>: whether someone with no access to this project's context can get
|
||||
from an empty repository to a running module using it alone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mod__links">
|
||||
<a class="btn btn--primary" href={`${gitea}/Integration-kit`} rel="noopener noreferrer">
|
||||
Read the Integration Kit
|
||||
</a>
|
||||
<a class="btn btn--ghost" href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
|
||||
The module contract
|
||||
</a>
|
||||
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
|
||||
module-uo in depth
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NotBuilt scope="modules" title="What the module system does not do" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.mod h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.mod__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mod__head .eyebrow {
|
||||
flex-basis: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mod__head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mod__lede {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mod__grid,
|
||||
.mod__paths {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.mod__grid h3,
|
||||
.mod__paths h3,
|
||||
.mod__caps h3,
|
||||
.mod__facts h3,
|
||||
.mod__draft h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.mod__grid p,
|
||||
.mod__paths p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.mod__paths li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Which host each path suits, set apart from what it does — a reader is choosing
|
||||
between three, so the distinguishing line should not be buried in the paragraph. */
|
||||
.mod__fits {
|
||||
margin-top: auto;
|
||||
padding-top: 0.85rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mod__example {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mod__caps ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.mod__caps li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mod__caps li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* The same drawn marker the homepage's capability lists use, so a reader who has
|
||||
seen this list once recognises it as the same list. */
|
||||
.mod__caps li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--portal);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mod__caps-note {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mod__facts section + section {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.mod__facts p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mod__draft {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.mod__draft p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.mod__draft p + p {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.mod__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.mod__example {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
395
src/pages/privacy.astro
Normal file
395
src/pages/privacy.astro
Normal file
@@ -0,0 +1,395 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import { assertScopeNonEmpty, collectedIn } from '../data/collection.mjs';
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/privacy` — PLAN.md §9, built in phase 6. The URL given to Google Play.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THREE SCOPES, NEVER MERGED
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9's structure is the substance of the page rather than its layout. Runic Gateway is
|
||||
* self-hosted software, so "we" means three different parties depending on which sentence
|
||||
* you are reading, and a policy that blurred them would be wrong in both directions at
|
||||
* once: it would claim responsibility for data we cannot see, and it would let a player
|
||||
* believe this page governs the community site they actually use.
|
||||
*
|
||||
* So the page is three separately-scoped sections with the boundary stated in each, and
|
||||
* the rows come from `src/data/collection.mjs` — the same array `scripts/playDataSafety.mjs`
|
||||
* answers the console form from (D33). A published policy and a Play declaration that
|
||||
* disagree is the failure this repository already builds machinery against elsewhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* NO ADDRESS IN THIS FILE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The contact route is `brand.contactEmail`, read from the mounted `brand.json`. D13
|
||||
* publishes a personal address on the promise that replacing it with `privacy@` later
|
||||
* costs one file copy, and `checkFacts.mjs` fails the build if an address is typed into any
|
||||
* source file. This is the page most likely to want to — a privacy policy is where an
|
||||
* address belongs — which is exactly why the rule has to hold here.
|
||||
*/
|
||||
const title = 'Privacy';
|
||||
const description =
|
||||
'What this site collects, what the Android app holds on your device, and what a ' +
|
||||
'self-hosted deployment is responsible for.';
|
||||
|
||||
/* A section with nothing under it reads as a claim rather than an omission. */
|
||||
for (const scope of ['site', 'app', 'deployment']) assertScopeNonEmpty(scope);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'this-site',
|
||||
number: 1,
|
||||
heading: 'This website',
|
||||
controller: 'We are responsible for this section.',
|
||||
lede:
|
||||
'There are no cookies, no analytics, no tracking scripts and no third-party ' +
|
||||
'requests of any kind — not as a policy we promise to keep, but as a description of ' +
|
||||
'what the pages load. The only thing this site ever asks you for is an email ' +
|
||||
'address for the Android beta, and only if you choose to give one.',
|
||||
rows: collectedIn('site'),
|
||||
},
|
||||
{
|
||||
id: 'the-app',
|
||||
number: 2,
|
||||
heading: 'The Android app',
|
||||
controller: 'We operate no server the app talks to.',
|
||||
lede:
|
||||
'This is the part that makes the app unusual, and it is worth reading rather than ' +
|
||||
'skimming. The app ships pointed at nothing: on first run it asks for the address ' +
|
||||
'of a Runic Gateway site and nothing else in the app works until one is entered and ' +
|
||||
'validated. That site is run by whoever runs that community. Everything you do in ' +
|
||||
'the app happens between your phone and their server, and there is no account with ' +
|
||||
'us, no service of ours in the middle, and no copy of anything on our side — because ' +
|
||||
'we do not operate one.',
|
||||
rows: collectedIn('app'),
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
number: 3,
|
||||
heading: 'Self-hosted deployments',
|
||||
controller: 'The operator of that deployment is responsible, not us.',
|
||||
lede:
|
||||
'Runic Gateway is software people install on their own machines. If you play on a ' +
|
||||
'community that runs it, your account lives on their server, under their control ' +
|
||||
'and their policy — this page is not it. What follows is an inventory of what the ' +
|
||||
'software collects, so that an operator can see plainly what they are taking on, ' +
|
||||
'and a player can see what to ask their operator about.',
|
||||
rows: collectedIn('deployment'),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Privacy" title="Who holds what, and for how long">
|
||||
<p>
|
||||
Written from what the code does rather than from a template — every entry below was
|
||||
read out of the file that implements it, and the file is named. It is deliberately
|
||||
specific in the places a policy is usually vague, because the vague places are the
|
||||
ones that matter.
|
||||
</p>
|
||||
<p>
|
||||
Three sections, because there are three different answers to “who has this”. Read the
|
||||
one that applies to you; the boundaries between them are real.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section legal-meta">
|
||||
<p class="legal-meta__line">
|
||||
<span class="chip chip--version">Last updated {legal.lastUpdated}</span>
|
||||
<span class="legal-meta__age">You must be {legal.minimumAge} or older to sign up for the beta.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<nav class="page section legal-toc" aria-label="Sections">
|
||||
<ol>
|
||||
{
|
||||
sections.map((section) => (
|
||||
<li>
|
||||
<a href={`#${section.id}`}>
|
||||
<span class="legal-toc__n">{section.number}</span>
|
||||
<span>
|
||||
<strong>{section.heading}</strong>
|
||||
<span class="legal-toc__who">{section.controller}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{
|
||||
sections.map((section) => (
|
||||
<section class="page section legal-sec" id={section.id}>
|
||||
<div class="legal-sec__head">
|
||||
<p class="eyebrow">Section {section.number}</p>
|
||||
<h2>{section.heading}</h2>
|
||||
<p class="legal-sec__who">{section.controller}</p>
|
||||
<p class="prose legal-sec__lede">{section.lede}</p>
|
||||
</div>
|
||||
|
||||
<ul class="legal-rows">
|
||||
{section.rows.map((row) => (
|
||||
<li class="panel legal-row">
|
||||
<h3>{row.title}</h3>
|
||||
<p class="legal-row__body">{row.body}</p>
|
||||
<p class="legal-row__keep">
|
||||
<span class="legal-row__keep-label">How long</span>
|
||||
{row.retention.summary}
|
||||
{row.retention.detail && (
|
||||
<span class="legal-row__keep-detail">{row.retention.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
|
||||
<section class="page section legal-sec" id="your-choices">
|
||||
<div class="legal-sec__head">
|
||||
<p class="eyebrow">Section 4</p>
|
||||
<h2>Removing your address, and asking questions</h2>
|
||||
<p class="legal-sec__who">This applies to section 1 only — the beta list.</p>
|
||||
<p class="prose legal-sec__lede">
|
||||
We hold one piece of information about you and it is the address you typed into the
|
||||
beta form. Ask for it to be removed and it will be erased rather than marked: what
|
||||
stays behind is a date and the fact that a removal happened, so we can confirm we
|
||||
did it without keeping the thing you asked us to let go of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel legal-panel">
|
||||
<ul class="legal-ways">
|
||||
<li>
|
||||
<h3>By email</h3>
|
||||
<p>
|
||||
Say which address to remove. There is no form to fill in and no account to prove
|
||||
— knowing the address is all that is needed, because it is all that is stored.
|
||||
</p>
|
||||
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>On Discord</h3>
|
||||
<p>
|
||||
The same request works in the <a href="/community/">community Discord</a>, which
|
||||
is generally the faster of the two.
|
||||
</p>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">
|
||||
Join the Discord
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="legal-panel__note">
|
||||
One consequence of erasing rather than flagging, stated because it is the honest
|
||||
reading and not a caveat we would rather you missed: afterwards the list cannot tell
|
||||
your address from one it has never seen. Asking twice gets the same answer as asking
|
||||
about a stranger, and signing up again later is an ordinary new signup.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section legal-sec" id="changes">
|
||||
<div class="legal-sec__head">
|
||||
<p class="eyebrow">Section 5</p>
|
||||
<h2>Changes to this page</h2>
|
||||
<p class="prose legal-sec__lede">
|
||||
If what the software collects changes, this page changes with it in the same
|
||||
release — the entries above are generated from a single inventory in the source, so
|
||||
a change to what is stored and a change to what this page says are the same edit.
|
||||
The date at the top is the last time that happened. This site sends no email at all,
|
||||
so there is no notification to send you when it does; the page itself is the record.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* ---- The header strip -------------------------------------------------- */
|
||||
|
||||
.legal-meta {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.legal-meta__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.legal-meta__age {
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---- Contents ---------------------------------------------------------- */
|
||||
|
||||
.legal-toc ol {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.legal-toc a {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
height: 100%;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-flat);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.legal-toc a:hover {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.legal-toc__n {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.legal-toc__who {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- A section --------------------------------------------------------- */
|
||||
|
||||
.legal-sec__head {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.legal-sec h2 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
/* The boundary sentence. Gold, because on this page it is the load-bearing line of
|
||||
each section rather than a subtitle — a reader who takes only one sentence from a
|
||||
section should take this one. */
|
||||
.legal-sec__who {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--gold);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-sec__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legal-rows {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
}
|
||||
|
||||
.legal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.legal-row h3 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
/* Deliberately NOT `flex: 1`, which is what the cards elsewhere on the site use to
|
||||
line their buttons up. These bodies differ in length by a factor of four — the hash
|
||||
entry earns its paragraph, the consent entry needs two sentences — and pushing the
|
||||
retention line to the bottom of the tallest card in the row opened a void in the
|
||||
middle of the short ones that read as missing content rather than as alignment.
|
||||
Caught by looking at the built page, which is the only thing that catches it. */
|
||||
.legal-row__body {
|
||||
margin: 0 0 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-row__keep {
|
||||
margin: 0;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.legal-row__keep-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.legal-row__keep-detail {
|
||||
display: block;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* ---- The two panels at the foot ---------------------------------------- */
|
||||
|
||||
.legal-panel {
|
||||
padding: clamp(1.25rem, 4vw, 2.25rem);
|
||||
}
|
||||
|
||||
.legal-ways {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.legal-ways h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.legal-ways p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-panel__note {
|
||||
margin: 1.75rem 0 0;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-width: var(--measure);
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
259
src/pages/terms.astro
Normal file
259
src/pages/terms.astro
Normal file
@@ -0,0 +1,259 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/terms` — PLAN.md §9, built in phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS PAGE IS ALLOWED TO GOVERN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9: "short and honest… it does not attempt to govern anyone's self-hosted deployment,
|
||||
* because it cannot." That sentence is the whole design. Three things are in scope — this
|
||||
* website, the beta list, and the APK we publish — and the software itself is governed by
|
||||
* its licence rather than by anything written here. A terms page that quietly claimed
|
||||
* authority over every installation of a GPL program would be both unenforceable and
|
||||
* contrary to the licence it ships under, and it is the single most common thing a
|
||||
* generated template gets wrong.
|
||||
*
|
||||
* No governing-law clause, by the org lead's decision on 2026-08-24 (D32). Nothing of
|
||||
* value is contracted for here: the site sells nothing, the software is free under a
|
||||
* licence that carries its own terms, and the beta is a list of addresses somebody asked
|
||||
* to be on. A jurisdiction clause on a page like this is decoration, and this site does not
|
||||
* write decoration into a legal page.
|
||||
*
|
||||
* The contact address is `brand.contactEmail` and appears nowhere in this file (D13).
|
||||
*/
|
||||
const title = 'Terms';
|
||||
const description =
|
||||
'What this site is, what the beta is, and what the licence governs — short, and only ' +
|
||||
'about the things we actually run.';
|
||||
|
||||
const clauses = [
|
||||
{
|
||||
id: 'software',
|
||||
heading: 'The software is free, and its licence governs it',
|
||||
body: [
|
||||
'Everything this site describes — the website, the bridge, the game plugin, the ' +
|
||||
'installer, the module and the Android app — is free software released under the ' +
|
||||
`${legal.licence.id}. That licence is what governs your use of it: what you may do ` +
|
||||
'with it, what you must do if you distribute it, and the fact that it comes with ' +
|
||||
'no warranty.',
|
||||
'Nothing on this page adds to it, subtracts from it, or applies alongside it. If ' +
|
||||
'this page and the licence ever appear to disagree about the software, the licence ' +
|
||||
'is right.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
heading: 'We do not govern anyone’s deployment, and cannot',
|
||||
body: [
|
||||
'If you run this software, the site you run is yours. We have no access to it, no ' +
|
||||
'control over it and no relationship with the people using it — you set its rules ' +
|
||||
'and you carry its responsibilities, including for the personal data it holds.',
|
||||
'If you play on a community that runs it, your agreement is with that community, ' +
|
||||
'not with us. These terms are not the terms of the site you are actually using, ' +
|
||||
'and this is not the place to appeal a ban.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'site',
|
||||
heading: 'This website is informational, and offered as it is',
|
||||
body: [
|
||||
'The pages here describe software and how to run it. We try hard to keep them ' +
|
||||
'accurate — versions and protocol numbers on this site are re-read from the ' +
|
||||
'repositories on every build, precisely so they cannot quietly go stale — but the ' +
|
||||
'site is provided without warranty of any kind, and a decision to run this software ' +
|
||||
'in production is yours.',
|
||||
'Do not attack it, scrape it into the ground, or use it to attack anything else. ' +
|
||||
'That is the whole of the acceptable-use policy for a site with one form on it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'beta',
|
||||
heading: 'The beta is a beta',
|
||||
body: [
|
||||
`Signing up asks for one thing: an email address, given by somebody ${legal.minimumAge} ` +
|
||||
'or older. We take it as given that you meet that — there is no verification, and ' +
|
||||
'saying so plainly is better than implying a check nobody performs.',
|
||||
'The list is used to add testers to a Google Play closed test and for nothing else. ' +
|
||||
'A place on it is not a promise: the test may be delayed, changed, restricted or ' +
|
||||
'abandoned, the app may break in ways a released app would not, and being on the ' +
|
||||
'list does not guarantee an invitation. Ask to be removed at any time and the ' +
|
||||
'address is erased.',
|
||||
'Do not sign somebody else up, and do not put a script on the form. The limits are ' +
|
||||
'modest and the list is small enough that abuse costs a real person their place.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'apk',
|
||||
heading: 'The app you download here is the app we built',
|
||||
body: [
|
||||
'Until the app is on Google Play, the download on this site links straight at a ' +
|
||||
'signed release we publish, with a checksum file beside it. Check it if you like — ' +
|
||||
'that is what it is for.',
|
||||
'It is a pre-release build, it is not distributed by a store, and it comes with the ' +
|
||||
'same absence of warranty as the rest. An APK from anywhere other than our own ' +
|
||||
'releases is not ours, whatever it is called.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
heading: 'If this page changes',
|
||||
body: [
|
||||
'The date at the top is the last time it did. This site sends no email of any kind, ' +
|
||||
'so there is no notice to send — the page is the record, and it changes in the ' +
|
||||
'same release as whatever prompted it.',
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Terms" title="Short, and only about what we run">
|
||||
<p>
|
||||
Three things belong to us: this website, the list of people who asked to test the
|
||||
Android app, and the app builds we publish. Those are what this page covers.
|
||||
</p>
|
||||
<p>
|
||||
The software itself is covered by its licence, and a community’s own site is covered
|
||||
by that community. Saying so is not a disclaimer — it is the accurate description of
|
||||
a program people run on their own machines.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section legal-meta">
|
||||
<p class="legal-meta__line">
|
||||
<span class="chip chip--version">Last updated {legal.lastUpdated}</span>
|
||||
<a class="legal-meta__link" href="/privacy/">What we collect is on the privacy page</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<ol class="terms-list">
|
||||
{
|
||||
clauses.map((clause, index) => (
|
||||
<li class="panel terms-clause" id={clause.id}>
|
||||
<p class="terms-clause__n">{String(index + 1).padStart(2, '0')}</p>
|
||||
<div class="terms-clause__body">
|
||||
<h2>{clause.heading}</h2>
|
||||
{clause.body.map((paragraph) => (
|
||||
<p>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<div class="panel terms-foot">
|
||||
<p class="eyebrow">Questions</p>
|
||||
<h2>There is a person at the other end</h2>
|
||||
<p>
|
||||
Anything about this page, the beta list, or a security problem you would rather not
|
||||
discuss in public goes to the same address — or ask in the{' '}
|
||||
<a href="/community/">Discord</a>, which is faster for everything except the last one.
|
||||
</p>
|
||||
<div class="terms-foot__actions">
|
||||
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
|
||||
<a class="btn btn--ghost" href={legal.licence.url} rel="noopener noreferrer">
|
||||
Read the {legal.licence.id}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* Shared with /privacy in spirit but not in stylesheet — Astro scopes component styles,
|
||||
and two legal pages are not enough repetition to justify a global. The header strip is
|
||||
the one piece both render identically. */
|
||||
|
||||
.legal-meta {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.legal-meta__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.legal-meta__link {
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.terms-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.terms-clause {
|
||||
display: flex;
|
||||
gap: clamp(1rem, 3vw, 2rem);
|
||||
padding: clamp(1.25rem, 3.5vw, 2rem);
|
||||
}
|
||||
|
||||
/* The numeral is the page's only ornament, and it earns its place: these clauses are
|
||||
referred to by number in conversation, and a list with no visible numbers cannot be. */
|
||||
.terms-clause__n {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
color: var(--gold-deep);
|
||||
font-family: var(--display);
|
||||
font-size: clamp(1.5rem, 4vw, 2.1rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.terms-clause__body {
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
.terms-clause h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: clamp(1.15rem, 2.5vw, 1.35rem);
|
||||
}
|
||||
|
||||
.terms-clause p {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.terms-clause p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.terms-foot {
|
||||
padding: clamp(1.25rem, 4vw, 2.25rem);
|
||||
}
|
||||
|
||||
.terms-foot h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: clamp(1.4rem, 3vw, 1.8rem);
|
||||
}
|
||||
|
||||
.terms-foot p {
|
||||
max-width: var(--measure);
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.terms-foot__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
194
src/styles/diagram.css
Normal file
194
src/styles/diagram.css
Normal file
@@ -0,0 +1,194 @@
|
||||
/* ============================================================================
|
||||
The diagram vocabulary
|
||||
============================================================================
|
||||
§11 makes hand-drawn SVG the site's motif, "used where it explains something".
|
||||
Phase 3 drew the first one on the homepage; phase 4 drew three more on
|
||||
`/architecture/`, at which point the same fifteen rules existed in four files.
|
||||
|
||||
Two things live here and nothing else does:
|
||||
|
||||
1. The SVG vocabulary — what a node, a spine, an arrow and the boundary look
|
||||
like. Shared by name, so a diagram is markup and the drawing is one
|
||||
decision. `DataPath.astro` reads these too; it keeps its own layout,
|
||||
because its right-hand column is a numbered walk rather than notes.
|
||||
|
||||
2. The `.diagram` layout — figure beside prose on a wide screen, figure
|
||||
above prose on a narrow one.
|
||||
|
||||
Every colour is a class rather than a presentation attribute, and that is not
|
||||
a style preference: `var()` is only substituted in style declarations, so
|
||||
`fill="var(--line)"` on an element parses and draws nothing at all. It is also
|
||||
what keeps `checkTokens.mjs` green, since no literal ever reaches the markup.
|
||||
|
||||
The two rules every diagram here follows, learned in phase 3:
|
||||
|
||||
- An inline SVG cannot reflow. A tall, ~380px-wide viewBox with only node
|
||||
titles inside it is legible on a phone AND useful at 1440px; a wide
|
||||
horizontal diagram is neither.
|
||||
- The picture is `aria-hidden` because the prose beside it says the same
|
||||
thing better. The consequence is a rule: a diagram must never carry a fact
|
||||
the prose does not.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
/* ---- Layout ------------------------------------------------------------- */
|
||||
|
||||
.diagram__head h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.diagram__head .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diagram__body {
|
||||
display: grid;
|
||||
gap: clamp(1.75rem, 4vw, 3rem);
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.diagram__figure {
|
||||
position: sticky;
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.diagram__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diagram__notes section + section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.diagram__notes h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.diagram__notes p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.diagram__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Sticky is a wide-screen affordance only. Once the figure sits above the
|
||||
prose rather than beside it, pinning it would cover the thing it explains. */
|
||||
.diagram__figure {
|
||||
position: static;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- The drawing -------------------------------------------------------- */
|
||||
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
/* An outer grouping: a machine, a process boundary, a side of a contract. Sits
|
||||
under the nodes it contains, so it reads as the thing they are inside. */
|
||||
.host {
|
||||
fill: var(--panel-flat);
|
||||
stroke: var(--line-soft);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.host-title {
|
||||
fill: var(--head);
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.host-sub {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.node {
|
||||
fill: var(--panel-b);
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* The one node that is the reader's own site. Gold edge, because gold is
|
||||
emphasis everywhere else on the site too. */
|
||||
.node--self {
|
||||
fill: var(--panel-a);
|
||||
stroke: var(--gold-deep);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
fill: var(--head);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-sub {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.spine {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Cyan is the live signal everywhere on this site — the same colour the portal
|
||||
in the emblem is, and the same one the homepage draws the event feed in. A
|
||||
spine in this colour means data actually moving, not a relationship. */
|
||||
.spine--live {
|
||||
stroke: var(--portal);
|
||||
filter: drop-shadow(0 0 6px var(--portal-deep));
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--gold-deep);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.arrow--live {
|
||||
fill: var(--portal);
|
||||
}
|
||||
|
||||
.boundary {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 5;
|
||||
}
|
||||
|
||||
.boundary-label {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* The emblem's concentric rings, used as a ground behind the one place a
|
||||
diagram's argument actually happens. */
|
||||
.rings {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 1;
|
||||
opacity: 0.16;
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
@import '@fontsource-variable/cinzel';
|
||||
@import '@fontsource-variable/inter';
|
||||
|
||||
/* The SVG diagram vocabulary and the figure-beside-prose layout, shared by the
|
||||
homepage's data path and `/architecture/`'s three. Its own file because it is
|
||||
a self-contained language rather than part of the shell — see its header for
|
||||
the two rules every diagram on this site follows. */
|
||||
@import './diagram.css';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -204,11 +210,33 @@ svg {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* The nav collapses to the docs link alone until phase 3 gives it a real
|
||||
disclosure control; a hamburger with nothing behind it is worse than none. */
|
||||
/* Phase 1 left the mobile nav to phase 3, expecting a disclosure control. It got
|
||||
a wrap instead, and deliberately: with four links there is nothing to disclose.
|
||||
The lockup keeps the first row, the links take the second, and the whole thing
|
||||
stays four keyboard stops with no state, no script and no duplicate markup —
|
||||
all three of which a hamburger would have cost.
|
||||
|
||||
Phase 3 found the bug this fixes by rendering the homepage in a 390px frame:
|
||||
the four links plus the lockup measured 433px against a 390px viewport, so
|
||||
every phone got a horizontally scrolling page. Shrinking the type further was
|
||||
the tempting fix and would only have moved the failure to the next narrow
|
||||
screen. */
|
||||
@media (max-width: 720px) {
|
||||
.site-header__inner {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
row-gap: 0.15rem;
|
||||
padding-block: 0.55rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
/* Pull the first link's own padding back to the gutter so the row of links
|
||||
lines up with the lockup above it rather than sitting indented. */
|
||||
margin-left: -0.45rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
@@ -333,6 +361,73 @@ svg {
|
||||
color: var(--mode-live);
|
||||
}
|
||||
|
||||
/* ---- Sections ------------------------------------------------------------
|
||||
The vertical rhythm every marketing page is built from. Here rather than in
|
||||
a component because phases 4 to 6 add pages that must sit on the same grid,
|
||||
and a per-page `padding-block` is how that stops being true. */
|
||||
|
||||
.section {
|
||||
padding-block: clamp(2.5rem, 6vw, 4.5rem);
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* ---- Buttons -------------------------------------------------------------
|
||||
Three variants, all the same box: solid for the one action a page wants,
|
||||
outlined for the alternatives, and the demo's own below. Anchors, not
|
||||
buttons — every one of them navigates, and the site runs no client JS. */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.62rem 1.15rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
font-size: 0.96rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--gold-deep);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The primary action reads as gold-on-dark rather than a filled gold slab: at
|
||||
7.91:1 the token is a text colour, and `--gold-deep` is explicitly annotated
|
||||
"rules, borders, UI edges. NEVER text." — so it carries the edge and the
|
||||
wash, and the gold carries the label. */
|
||||
.btn--primary {
|
||||
border-color: var(--gold);
|
||||
background: color-mix(in srgb, var(--gold-deep) 18%, transparent);
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: color-mix(in srgb, var(--gold-deep) 30%, transparent);
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---- The demo slot -------------------------------------------------------
|
||||
PLAN.md §15 / D12. A public demo instance is planned and out of scope, but
|
||||
the site is built so that gaining one is a line in the mounted brand.json
|
||||
@@ -348,6 +443,38 @@ svg {
|
||||
Written here, before phase 3 writes that markup, because the rule and the
|
||||
rewrite have to agree and they live in different files. */
|
||||
|
||||
/* `!important`, and it is earning its keep rather than papering over something.
|
||||
|
||||
This selector is specificity 0,1,0. So is a class — including the scoped class an
|
||||
Astro component puts on the very same element — and a component's styles are emitted
|
||||
AFTER this file, so any component that gives one of these elements a `display` wins on
|
||||
source order alone. Phase 4 did exactly that: `/features/`'s `.demo-link` set
|
||||
`display: inline-flex` for its arrow, and twelve links to a demo that does not exist
|
||||
appeared on the page, each one pointing at `href=""` — which a browser resolves to the
|
||||
page it is already on.
|
||||
|
||||
Nothing caught it. checkBrand.mjs verifies the ATTRIBUTES, and they were perfect; the
|
||||
defect was three files away in the cascade. It was found by looking at the rendered
|
||||
page, which is not a mechanism.
|
||||
|
||||
So the rule is stated as one: while there is no demo, these elements do not render, and
|
||||
no component style may overrule that by accident. A component that genuinely needs to
|
||||
lay one of these out sets every property except `display`. */
|
||||
[data-demo-url=''] {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button
|
||||
like its neighbours and takes the portal colour — the live signal, for the
|
||||
one link on the site that leads to something actually running. */
|
||||
.demo-cta {
|
||||
border-color: var(--portal);
|
||||
background: color-mix(in srgb, var(--portal-deep) 22%, transparent);
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
.demo-cta:hover {
|
||||
border-color: var(--portal-bright);
|
||||
background: color-mix(in srgb, var(--portal-deep) 34%, transparent);
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
319
test/beta.test.mjs
Normal file
319
test/beta.test.mjs
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* The signup path, tested where it makes decisions. PLAN.md §8, phase 5.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS REPOSITORY HAS A TEST SUITE NOW, HAVING NOT NEEDED ONE FOR FOUR PHASES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Phases 1–4 are pages, and pages are checked by the five scripts in §12: a broken link, a
|
||||
* stale version, a colour literal, a drifted brand contract. Every one of those defects is
|
||||
* visible in the built output, which is exactly why a check that reads the built output
|
||||
* catches them.
|
||||
*
|
||||
* Phase 5 is the first thing here that is neither a page nor visible in one. Whether a
|
||||
* honeypot is checked before the rate limit, whether a duplicate is answered idempotently,
|
||||
* whether the cap counts removed rows — none of that shows up in `dist`, none of it is
|
||||
* exercised by loading the page, and all of it is the kind of logic that is wrong quietly.
|
||||
* The honeypot in particular has the worst failure mode available: it can stop working
|
||||
* entirely and nothing anywhere gets slower, redder or noisier.
|
||||
*
|
||||
* So: `node --test`, the same runner the `website` server uses, against a scratch database.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE ENVIRONMENT HAS TO BE SET BEFORE THE IMPORTS
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `betaStore.mjs` resolves `DB_PATH` and the salt at module scope, and `beta.mjs` reads the
|
||||
* limits the same way. A dynamic import after `process.env` is set is therefore not a
|
||||
* stylistic choice — a static import would bind a developer's real `data/beta.sqlite` and
|
||||
* this file would quietly test, and pollute, the actual list.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { after, before, beforeEach, describe, it } from 'node:test';
|
||||
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-beta-'));
|
||||
|
||||
process.env.BETA_DB = path.join(scratch, 'beta.sqlite');
|
||||
process.env.BETA_IP_SALT = 'test-salt';
|
||||
process.env.BETA_FORM_KEY = 'test-form-key';
|
||||
process.env.BETA_TOTAL_CAP = '5';
|
||||
process.env.BETA_PER_HOUR = '3';
|
||||
process.env.BETA_PER_DAY = '4';
|
||||
process.env.BETA_MIN_SECONDS = '2';
|
||||
|
||||
let store;
|
||||
let signup;
|
||||
let data;
|
||||
|
||||
before(async () => {
|
||||
store = await import('../src/lib/betaStore.mjs');
|
||||
signup = await import('../src/lib/betaSignup.mjs');
|
||||
data = await import('../src/data/beta.mjs');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
store.close();
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const db = store.open();
|
||||
db.exec('DELETE FROM signups; DELETE FROM attempts;');
|
||||
});
|
||||
|
||||
/** A form as the handler sees it, aged past the minimum by default. */
|
||||
function form(overrides = {}, ageSeconds = 5) {
|
||||
const { fields } = data;
|
||||
const map = new Map([
|
||||
[fields.EMAIL, 'person@example.com'],
|
||||
[fields.CONSENT, 'yes'],
|
||||
[fields.HONEYPOT, ''],
|
||||
[fields.ISSUED, signup.issueFormToken(Date.now() - ageSeconds * 1000)],
|
||||
]);
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) map.set(key, value);
|
||||
return map;
|
||||
}
|
||||
|
||||
const post = (overrides, ageSeconds, ip = '198.51.100.7') =>
|
||||
signup.submit({ form: form(overrides, ageSeconds), ip, userAgent: 'test' });
|
||||
|
||||
describe('address validation', () => {
|
||||
it('accepts the shapes a person types, including a plus-alias', () => {
|
||||
for (const value of ['a@b.co', 'first.last+play@example.co.uk', 'UPPER@Example.COM']) {
|
||||
assert.ok(signup.normaliseEmail(value), `${value} should be accepted`);
|
||||
}
|
||||
});
|
||||
|
||||
it('lower-cases, so a CSV does not carry capitalisation as though it mattered', () => {
|
||||
assert.equal(signup.normaliseEmail(' Person@Example.COM '), 'person@example.com');
|
||||
});
|
||||
|
||||
it('rejects what would break a pasted tester list', () => {
|
||||
for (const value of [
|
||||
'',
|
||||
'no-at-sign',
|
||||
'two@@example.com',
|
||||
'trailing@example',
|
||||
'a b@example.com',
|
||||
'comma,injection@example.com',
|
||||
`${'x'.repeat(250)}@example.com`,
|
||||
]) {
|
||||
assert.equal(signup.normaliseEmail(value), null, `${value} should be rejected`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the form token', () => {
|
||||
it('round-trips an age', () => {
|
||||
const read = signup.readFormToken(signup.issueFormToken(Date.now() - 30_000));
|
||||
assert.ok(read);
|
||||
assert.ok(read.ageSeconds >= 29 && read.ageSeconds < 32);
|
||||
});
|
||||
|
||||
it('refuses a token it did not sign — a bot cannot mint its own timestamp', () => {
|
||||
assert.equal(signup.readFormToken(`${Date.now()}.deadbeef`), null);
|
||||
assert.equal(signup.readFormToken(String(Date.now())), null);
|
||||
assert.equal(signup.readFormToken(''), null);
|
||||
assert.equal(signup.readFormToken(undefined), null);
|
||||
});
|
||||
|
||||
it('refuses a valid signature over a tampered timestamp', () => {
|
||||
const token = signup.issueFormToken(Date.now() - 5000);
|
||||
const [, mac] = token.split('.');
|
||||
assert.equal(signup.readFormToken(`${Date.now() - 900_000}.${mac}`), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a good submission', () => {
|
||||
it('is added, and stores the consent wording rather than a version', () => {
|
||||
const result = post();
|
||||
assert.equal(result.outcome, signup.OUTCOME.ADDED);
|
||||
|
||||
const row = store.open().prepare('SELECT * FROM signups').get();
|
||||
assert.equal(row.email, 'person@example.com');
|
||||
assert.equal(row.status, store.STATUS.NEW);
|
||||
assert.equal(row.consent_text, data.CONSENT_TEXT);
|
||||
});
|
||||
|
||||
it('never stores the IP address itself', () => {
|
||||
post({}, 5, '203.0.113.9');
|
||||
const row = store.open().prepare('SELECT ip_hash FROM signups').get();
|
||||
|
||||
assert.ok(!row.ip_hash.includes('203.0.113.9'));
|
||||
assert.equal(row.ip_hash.length, 64);
|
||||
assert.equal(row.ip_hash, store.hashIp('203.0.113.9'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicates are answered idempotently', () => {
|
||||
it('is the same outcome shape whether or not the address was already there', () => {
|
||||
assert.equal(post().outcome, signup.OUTCOME.ADDED);
|
||||
assert.equal(post().outcome, signup.OUTCOME.DUPLICATE);
|
||||
|
||||
// Both render the identical screen — §8's rule, so the form cannot be used to ask
|
||||
// whether a given address is enrolled. This asserts the property the page relies on.
|
||||
assert.ok(signup.isSuccess(signup.OUTCOME.ADDED));
|
||||
assert.ok(signup.isSuccess(signup.OUTCOME.DUPLICATE));
|
||||
|
||||
assert.equal(store.liveCount(), 1);
|
||||
});
|
||||
|
||||
it('treats a differently-cased address as the same person', () => {
|
||||
post();
|
||||
assert.equal(post({ [data.fields.EMAIL]: 'PERSON@EXAMPLE.COM' }).outcome, signup.OUTCOME.DUPLICATE);
|
||||
assert.equal(store.liveCount(), 1);
|
||||
});
|
||||
|
||||
it('treats a re-signup after removal as new, because removal erased the address', () => {
|
||||
post();
|
||||
store.removeSignup('person@example.com');
|
||||
|
||||
// Not a duplicate: there is nothing left in the store to match against, which is the
|
||||
// point of erasing rather than flagging. See removeSignup's note.
|
||||
assert.equal(post().outcome, signup.OUTCOME.ADDED);
|
||||
|
||||
const rows = store.open().prepare('SELECT status FROM signups ORDER BY id').all();
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.status),
|
||||
[store.STATUS.REMOVED, store.STATUS.NEW]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the honeypot', () => {
|
||||
it('writes nothing and reports success', () => {
|
||||
const result = post({ [data.fields.HONEYPOT]: 'http://spam.example' });
|
||||
|
||||
assert.equal(result.outcome, signup.OUTCOME.DECOY);
|
||||
assert.ok(signup.isSuccess(result.outcome), 'a bot must be told it succeeded');
|
||||
assert.equal(store.liveCount(), 0);
|
||||
});
|
||||
|
||||
it('costs no rate-limit budget, so it cannot be used to lock out a shared address', () => {
|
||||
for (let i = 0; i < 10; i += 1) post({ [data.fields.HONEYPOT]: 'x' });
|
||||
assert.equal(post().outcome, signup.OUTCOME.ADDED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timing', () => {
|
||||
it('refuses a submission faster than a person could make', () => {
|
||||
assert.equal(post({}, 0).outcome, signup.OUTCOME.TOO_FAST);
|
||||
assert.equal(store.liveCount(), 0);
|
||||
});
|
||||
|
||||
it('refuses a form rendered too long ago', () => {
|
||||
const stale = signup.issueFormToken(Date.now() - (data.limits.maxSeconds + 60) * 1000);
|
||||
assert.equal(post({ [data.fields.ISSUED]: stale }).outcome, signup.OUTCOME.STALE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rate limit', () => {
|
||||
it('counts attempts rather than successes, so bad addresses are not free', () => {
|
||||
const bad = { [data.fields.EMAIL]: 'nope' };
|
||||
|
||||
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
|
||||
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
|
||||
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
|
||||
assert.equal(post().outcome, signup.OUTCOME.LIMITED, 'the hourly budget is spent');
|
||||
});
|
||||
|
||||
it('is per connection, not global', () => {
|
||||
for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.1');
|
||||
|
||||
assert.equal(post({}, 5, '198.51.100.1').outcome, signup.OUTCOME.LIMITED);
|
||||
assert.equal(post({}, 5, '198.51.100.2').outcome, signup.OUTCOME.ADDED);
|
||||
});
|
||||
|
||||
it('tells a limited caller nothing about any address', () => {
|
||||
post();
|
||||
for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.3');
|
||||
|
||||
// The address IS on the list; a limited caller must still be told only that they are
|
||||
// limited. Ordering the limit ahead of the lookup is what makes that true.
|
||||
const result = post({}, 5, '198.51.100.3');
|
||||
assert.equal(result.outcome, signup.OUTCOME.LIMITED);
|
||||
assert.equal(result.email, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the global cap', () => {
|
||||
const fill = (n) => {
|
||||
for (let i = 0; i < n; i += 1) post({ [data.fields.EMAIL]: `p${i}@example.com` }, 5, `10.0.0.${i}`);
|
||||
};
|
||||
|
||||
it('closes the form at the cap', () => {
|
||||
fill(5);
|
||||
assert.equal(store.liveCount(), 5);
|
||||
assert.ok(store.isFull());
|
||||
assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.1').outcome, signup.OUTCOME.FULL);
|
||||
});
|
||||
|
||||
it('does not count a removed row against it', () => {
|
||||
fill(5);
|
||||
store.removeSignup('p0@example.com');
|
||||
|
||||
assert.equal(store.liveCount(), 4);
|
||||
assert.ok(!store.isFull());
|
||||
assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.2').outcome, signup.OUTCOME.ADDED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consent', () => {
|
||||
it('is required, and an unticked box records nothing', () => {
|
||||
assert.equal(post({ [data.fields.CONSENT]: '' }).outcome, signup.OUTCOME.NO_CONSENT);
|
||||
assert.equal(store.liveCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removal', () => {
|
||||
it('overwrites the address rather than flagging the row', () => {
|
||||
post();
|
||||
const result = store.removeSignup('person@example.com');
|
||||
assert.ok(result.removed);
|
||||
|
||||
const row = store.open().prepare('SELECT * FROM signups WHERE id = ?').get(result.id);
|
||||
assert.equal(row.status, store.STATUS.REMOVED);
|
||||
assert.equal(row.ip_hash, '');
|
||||
assert.ok(!row.email.includes('person@example.com'), 'the address must be gone, not marked');
|
||||
assert.match(row.note, /^removed /);
|
||||
});
|
||||
|
||||
it('is indistinguishable from never having been there, once done', () => {
|
||||
post();
|
||||
store.removeSignup('person@example.com');
|
||||
|
||||
// Asking again gives exactly the answer a stranger's address gives. That is not a gap
|
||||
// in the implementation — it is what "the address is gone" has to mean, and the test
|
||||
// exists to stop somebody "fixing" it by keeping a hash of the removed address.
|
||||
assert.deepEqual(store.removeSignup('person@example.com'), { removed: false });
|
||||
assert.deepEqual(store.removeSignup('nobody@example.com'), { removed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the export', () => {
|
||||
it('takes new rows and marks them, so a second export does not repeat them', () => {
|
||||
post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.0.1');
|
||||
post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.0.2');
|
||||
|
||||
const first = store.pending();
|
||||
assert.equal(first.length, 2);
|
||||
|
||||
store.markExported(first.map((row) => row.id));
|
||||
assert.equal(store.pending().length, 0);
|
||||
assert.equal(store.pending({ all: true }).length, 2);
|
||||
});
|
||||
|
||||
it('leaves removed rows out of --all as well as out of the default', () => {
|
||||
post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.1.1');
|
||||
post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.1.2');
|
||||
store.removeSignup('a@example.com');
|
||||
|
||||
assert.deepEqual(
|
||||
store.pending({ all: true }).map((row) => row.email),
|
||||
['b@example.com']
|
||||
);
|
||||
});
|
||||
});
|
||||
153
test/legal.test.mjs
Normal file
153
test/legal.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The legal pages' data, tested where a mistake would be invisible. PLAN.md §9, phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IS ACTUALLY AT RISK HERE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A privacy policy is prose, and prose is not testable. What IS testable is the small set
|
||||
* of structural promises the page and the Play declaration both rest on, every one of
|
||||
* which fails silently:
|
||||
*
|
||||
* - a row with no retention line renders a card with an empty "How long" — which reads
|
||||
* as "we keep this forever" or "we keep nothing", depending on the reader;
|
||||
* - an `app`-scoped row with no Play mapping means a question on the console form gets
|
||||
* answered from memory, which is the exact failure D33 exists to prevent;
|
||||
* - a row that claims we collect or share something contradicts the premise the whole
|
||||
* page rests on, and would be a real disclosure defect rather than a typo;
|
||||
* - the consent sentence and /terms stating different minimum ages, which is the kind of
|
||||
* inconsistency a reviewer finds and a developer never does.
|
||||
*
|
||||
* None of that shows up in a build, a type check or a link check: the page renders
|
||||
* beautifully with an empty cell and a wrong number in it.
|
||||
*
|
||||
* The generated Play document is checked by `scripts/playDataSafety.mjs --check` rather
|
||||
* than here — a generator's output is a build artefact, and comparing it in two places
|
||||
* means fixing it in two places.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assertScopeNonEmpty,
|
||||
collected,
|
||||
collectedIn,
|
||||
playRows,
|
||||
} from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
import { CONSENT_TEXT, CONSENT_VERSION, requirements } from '../src/data/beta.mjs';
|
||||
|
||||
const SCOPES = ['site', 'app', 'deployment'];
|
||||
|
||||
describe('the collection inventory', () => {
|
||||
it('has a row in every scope /privacy renders', () => {
|
||||
for (const scope of SCOPES) {
|
||||
assert.doesNotThrow(() => assertScopeNonEmpty(scope), `scope "${scope}" is empty`);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only the three scopes the page knows how to render', () => {
|
||||
for (const row of collected) {
|
||||
assert.ok(SCOPES.includes(row.scope), `${row.id} has unknown scope "${row.scope}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every row a unique id', () => {
|
||||
const ids = collected.map((row) => row.id);
|
||||
assert.equal(new Set(ids).size, ids.length, 'duplicate id in collection.mjs');
|
||||
});
|
||||
|
||||
it('gives every row a retention summary and a source', () => {
|
||||
for (const row of collected) {
|
||||
assert.ok(row.retention?.summary?.trim(), `${row.id} has no retention summary`);
|
||||
assert.ok(row.source?.trim(), `${row.id} does not name the file it was read from`);
|
||||
assert.ok(row.title?.trim() && row.body?.trim(), `${row.id} is missing prose`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Play Data Safety mapping', () => {
|
||||
it('maps every app-scoped row', () => {
|
||||
assert.doesNotThrow(() => playRows());
|
||||
assert.equal(playRows().length, collectedIn('app').length);
|
||||
});
|
||||
|
||||
it('answers every mapped question completely', () => {
|
||||
for (const row of playRows()) {
|
||||
const { category, type, answer, because, collected: isCollected, shared } = row.play;
|
||||
assert.ok(category?.trim() && type?.trim(), `${row.id} has no console category/type`);
|
||||
assert.ok(answer?.trim() && because?.trim(), `${row.id} has no answer or reasoning`);
|
||||
assert.equal(typeof isCollected, 'boolean', `${row.id}.play.collected is not a boolean`);
|
||||
assert.equal(typeof shared, 'boolean', `${row.id}.play.shared is not a boolean`);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* The one assertion here that is about the product rather than the shape of the data.
|
||||
*
|
||||
* "We operate no server the app talks to" is the premise of /privacy section 2, of the
|
||||
* generated declaration, and of the argument for why the app needs no account with us.
|
||||
* If that ever stops being true — a telemetry endpoint, a crash reporter, a hosted
|
||||
* directory of deployments — the honest change is a `collected: true` row AND a rewrite
|
||||
* of the page's second section. This test makes the first impossible without noticing
|
||||
* the second, by failing with the reason rather than the diff.
|
||||
*/
|
||||
it('holds the premise the whole section rests on', () => {
|
||||
for (const row of playRows()) {
|
||||
assert.equal(
|
||||
row.play.collected,
|
||||
false,
|
||||
`${row.id} says we collect it. If that is now true, /privacy section 2's premise — ` +
|
||||
'that we operate no server the app talks to — has changed, and the page has to ' +
|
||||
'change with it rather than gaining a row that contradicts its own lede.'
|
||||
);
|
||||
assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the minimum age', () => {
|
||||
it('is stated in the consent sentence the row records', () => {
|
||||
assert.match(
|
||||
CONSENT_TEXT,
|
||||
new RegExp(`\\b${legal.minimumAge}\\b`),
|
||||
'the consent text does not state the minimum age'
|
||||
);
|
||||
});
|
||||
|
||||
it('is stated in the eligibility list', () => {
|
||||
const ages = requirements.filter((entry) =>
|
||||
new RegExp(`\\b${legal.minimumAge}\\b`).test(entry.title)
|
||||
);
|
||||
assert.equal(ages.length, 1, 'the beta requirements should name the age exactly once');
|
||||
});
|
||||
|
||||
/*
|
||||
* Changing the wording without changing the label would leave two different sentences
|
||||
* sharing one version in the exported CSV, which is the only thing that label is for.
|
||||
*/
|
||||
it('was accompanied by a consent version bump', () => {
|
||||
assert.notEqual(
|
||||
CONSENT_VERSION,
|
||||
'2026-08-24',
|
||||
'the age clause changed CONSENT_TEXT; CONSENT_VERSION must not still be the label ' +
|
||||
'the pre-age wording was written under'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the published contact route', () => {
|
||||
/*
|
||||
* D13 in test form. `checkFacts.mjs` enforces this over the whole of `src/` and
|
||||
* `scripts/`, and it needs a network token to run — so it is skipped by anybody working
|
||||
* offline, on the two files most likely to want to type an address into.
|
||||
*/
|
||||
it('is not baked into the legal data', () => {
|
||||
const text = JSON.stringify({ collected, legal });
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
||||
'an email address is hard-coded in the legal data — read brand.contactEmail instead'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user