Compare commits
22 Commits
9b11c5cb99
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f38dec2ac4 | |||
| 080f9827b8 | |||
| 7ac19a791c | |||
| b8306dcdaf | |||
| 08a4973bde | |||
| 8b8896f54b | |||
| e5ec81b172 | |||
| e0b2885cd7 | |||
| c1b38081f5 | |||
| dff4232491 | |||
| caab3476eb | |||
| 70b31302b7 | |||
| 094c606238 | |||
| 338cc6f3de | |||
| d6d5a70ed4 | |||
| 04e8c14d1f | |||
| bda4662a89 | |||
| fcc1a06361 | |||
| 769d213c80 | |||
| 8701126d64 | |||
| e7bbc250a5 | |||
| d59c993430 |
@@ -157,6 +157,21 @@ jobs:
|
||||
docker push "${IMAGE}:latest"
|
||||
docker push "${IMAGE}:${TAG}"
|
||||
|
||||
- name: Remove this run's image and cap the build cache
|
||||
# The build runs on the HOST daemon, so whatever it leaves stays on the
|
||||
# runner host for good: every run used to leave two tagged images, and
|
||||
# each newer `latest` turned the previous one into a permanent `sha-` image
|
||||
# until the host ran out of disk. Both tags are in the registry by now, so
|
||||
# the local copies are dead weight. BuildKit keeps its layer cache apart
|
||||
# from the images, so the next build stays fast; the cache is pruned to a
|
||||
# cap instead of growing forever (`--reserved-space` is the same flag's
|
||||
# name on newer Docker). Runs even after a failure, and never fails the job.
|
||||
if: always()
|
||||
run: |
|
||||
docker image rm "${IMAGE}:latest" "${IMAGE}:${TAG}" 2>/dev/null || true
|
||||
docker builder prune -f --keep-storage 5GB \
|
||||
|| docker builder prune -f --reserved-space 5GB || true
|
||||
|
||||
- name: Log out
|
||||
if: always()
|
||||
run: docker logout "${REGISTRY}" || true
|
||||
@@ -207,3 +222,11 @@ jobs:
|
||||
echo "::error::The site did not become healthy within 150s."
|
||||
docker compose logs --tail 100 site
|
||||
exit 1
|
||||
|
||||
- name: Remove the image this deploy replaced
|
||||
# The pull moves `latest` to the new image and leaves the old one untagged
|
||||
# on the host. Untagged images are never used again (a rollback pulls a
|
||||
# `sha-` tag from the registry), so drop them. Dangling images only: nothing
|
||||
# tagged and nothing a container is using is touched.
|
||||
if: always()
|
||||
run: docker image prune -f || true
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '22.23.4'
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
|
||||
10
DEPLOY.md
10
DEPLOY.md
@@ -244,7 +244,7 @@ recolours the site and leaves every logo alone. `PLAN.md` §7 is the full accoun
|
||||
```
|
||||
brand/
|
||||
brand.json Site name, tagline, contact address, Discord invite, Gitea org,
|
||||
demo URL, Play opt-in URL
|
||||
demo URL and the games it runs, Play opt-in URL
|
||||
logo.png ONE raster. Every size the site asks for — header at three pixel
|
||||
ratios, install icons, apple-touch, favicons, a real .ico — is
|
||||
derived from it on demand
|
||||
@@ -264,6 +264,14 @@ mounted site name has to reach forty-nine pages that were rendered before the fi
|
||||
day the Play closed test opens, pasting the URL into `brand/brand.json` puts a working link on the
|
||||
confirmation screen on the very next request.
|
||||
|
||||
**The public demo** is two fields. `demoUrl` shows the homepage's "See it running" button and the
|
||||
core features' links into it; `demoGames` lists the game modules it runs (`["rust"]` for
|
||||
`demo.runicgateway.com`), and only those games' links show. Both need the restart. `/beta` also
|
||||
reads `demoUrl` live: it fetches the demo's published sign-ins from
|
||||
`<demoUrl>/api/v1/public/settings` and lists them, so the container must be able to reach the
|
||||
demo's public address. If it cannot, `/beta` links to the demo's sign-in page instead and logs
|
||||
`[beta] could not read the demo's sign-ins`.
|
||||
|
||||
To see which source answered a given asset:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Stage 1 — build
|
||||
# ---------------------------------------------------------------------------------------
|
||||
FROM node:22-bookworm-slim AS build
|
||||
FROM node:22.23.4-bookworm-slim AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN mkdir -p /split \
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Stage 2 — runtime
|
||||
# ---------------------------------------------------------------------------------------
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
FROM node:22.23.4-bookworm-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
107
PLAN.md
107
PLAN.md
@@ -232,12 +232,12 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
|
||||
| **D10** | **Support routes to Discord and Gitea issues.** Invite: `https://discord.gg/t2Jav8yT4g`. | §10. Discord is the front door; Gitea takes issues from anyone with an account, which today is nobody outside the org — §14, N3. |
|
||||
| **D11** | **Keep the existing emblem.** `runic-emblem.png` is the mark on both the website and the Android launcher icon; the site adopts it rather than drawing a new one. | §11. Revision 1's "draw a new geometric mark" recommendation is withdrawn. |
|
||||
| **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. |
|
||||
| **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. **Built 2026-10-27** as `demo.runicgateway.com`, on Rust with a two-hour reset; the site reaches it through D82 and D83 (§15). |
|
||||
| **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 **seventy-nine**:
|
||||
somewhere other than the thing it decided. The count of record is **eighty-four**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
@@ -261,6 +261,10 @@ somewhere other than the thing it decided. The count of record is **seventy-nine
|
||||
| D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification |
|
||||
| D72–D74 | §16, "How phase 16 built it" | The RunicNPC subgroup is eight pages with a shared profile-field reference; the API check compares signatures and every data shape's field names; the site's API page carries the current API only, with no decision numbers and no version history |
|
||||
| D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages |
|
||||
| D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data |
|
||||
| D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists |
|
||||
| D84 | §9, "How phase 6 built the legal pages" | The Data Safety answers say "collected, not shared" for everything the app sends, amending D33 |
|
||||
| D85–D86 | §9, "`/delete-account/`" | The deletion page is linked from the footer, `/privacy` and `/app/`; someone who cannot sign in is sent to their community's operator, never to us |
|
||||
|
||||
---
|
||||
|
||||
@@ -952,6 +956,8 @@ deliberately so — no fetch can tell whether an APK runs. `checkFacts.mjs` asse
|
||||
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.
|
||||
*(2026-10-31: the link is back on. Android `v0.7.0`'s APK was installed on an Android 16 emulator,
|
||||
connected to the public demo and read its Rust servers, so `serviceable` is `true`.)*
|
||||
|
||||
**Three mechanisms this phase added that the plan did not anticipate.**
|
||||
|
||||
@@ -1066,6 +1072,51 @@ not shared"**, failing with the reason rather than a diff: "we operate no server
|
||||
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.
|
||||
|
||||
**D84 — The Data Safety answers say "collected" for everything the app sends** (org lead,
|
||||
2026-10-29, Android `PLAN.md` M8; amends D33). Play counts data that leaves the device as
|
||||
collected, whoever runs the server it lands on, so "not collected because we run no server" was the
|
||||
wrong answer to the form's question even though it is the right sentence for `/privacy`. An audit of
|
||||
the app's `edge` (every Retrofit body, query and header in `data/api/`) found more leaving the phone
|
||||
than section 2 listed: the sign-in itself, the device's make and model on a trusted-device request,
|
||||
the User-Agent, game-account links and in-app game-account creation, the Contact form's name, email
|
||||
and message, staff posts and moderation, notification read state and preferences, and search text.
|
||||
Each is now a row, declared **collected, not shared, encrypted in transit, required**; the rows
|
||||
that stay on the phone say so and carry no console type. `PlayMapping` takes a list of `types`
|
||||
(checked against `PLAY_DATA_TYPES`, the console's own vocabulary, which caught the original file
|
||||
filing "Other user-generated content" under Messages), `required` and `purposes`; the generator
|
||||
folds rows into one answer per data type, lists every type that is not collected, and answers the
|
||||
form's security questions. D33's test now asserts the part of the premise that is still true:
|
||||
**nothing is shared**. `/privacy` section 2 keeps saying none of it reaches us, and `lastUpdated`
|
||||
moved with it. Report, block and hide (Android M8 step 3) adds its rows when it lands.
|
||||
|
||||
### `/delete-account/` (2026-10-30)
|
||||
|
||||
Play needs one account-deletion address from whoever publishes the app. The platform gained
|
||||
self-service deletion on the website and in the app (`docs/website/ACCOUNT_DELETION.md`, AD1), and
|
||||
this page is the address Play is given (AD2, §4.5 there). It cannot delete anything: we hold no
|
||||
account, so it explains where the button is in the app and on the website, what goes and what stays
|
||||
and for how long, and who to ask when you cannot sign in.
|
||||
|
||||
**The facts are one row.** What a deletion takes is the `deploy-account-deletion` row in
|
||||
`src/data/collection.mjs`, whose `deletion` field holds the website's own words for what goes and
|
||||
what stays (`client/src/lib/accountDeletion.js`, which the app's strings repeat) plus how long each
|
||||
kept thing is kept, which Play asks and the screens do not say. `/privacy` section 3 renders the row
|
||||
as prose, the page renders its lists, and `PLAY_DATA_SAFETY.md` prints them under the console's
|
||||
deletion questions, so the three cannot disagree. `deletionRow()` throws when the row is missing,
|
||||
and `test/legal.test.mjs` fails a kept entry with no retention. The app rows that deletion touches
|
||||
(the session, the trust token, push and the inbox snapshot) say so, and `lastUpdated` moved.
|
||||
|
||||
**D85 — The page is linked from the footer, `/privacy` and `/app/`** (org lead, 2026-10-30). The
|
||||
footer's legal bar reads Privacy · Terms · Delete account, because a player looking for it looks
|
||||
where the privacy link is; `/privacy` sections 2 and 3 and the bottom of `/app/`'s "Two ways to get
|
||||
it" link to it as well.
|
||||
|
||||
**D86 — Someone who cannot sign in is sent to their community's operator, never to us** (org lead,
|
||||
2026-10-30). The page offers "Forgot your password?" first, which works when the account has an
|
||||
email address and the site can send mail, and then the operator, who can delete any account from the
|
||||
admin panel. It says plainly that we cannot do it: we hold no account and have no access to any
|
||||
community's site. Offering our contact address to help find an operator was considered and declined.
|
||||
|
||||
**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
|
||||
@@ -1091,6 +1142,7 @@ Organised by what a reader is trying to do. A reader should never need to know t
|
||||
| `/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 |
|
||||
| `/delete-account/` | §9: how to delete an account you use in the app, and what it keeps. The address Play is given (D85, D86) |
|
||||
|
||||
**Feature grouping**, using project terminology:
|
||||
|
||||
@@ -1807,9 +1859,56 @@ whether or not N3 is actioned; only one sentence changes.
|
||||
|
||||
---
|
||||
|
||||
## 15. Planned, not in scope: the demo instance
|
||||
## 15. The demo instance
|
||||
|
||||
D12. Recorded now so the IA reserves room for it and nothing has to be restructured later.
|
||||
D12. Recorded on 2026-08-19 so the IA reserved room for it and nothing had to be restructured
|
||||
later. **It exists now:** `https://demo.runicgateway.com`, built in `website` and live since
|
||||
2026-10-27. Its design of record is `docs/website/DEMO_MODE.md` (DM1-DM29), not this section, and
|
||||
it came out different from the plan below in three ways that matter to this site:
|
||||
|
||||
- **It runs Rust, not Ultima Online.** The site is the normal `website` image with the Rust module,
|
||||
linked to the org's Oxide test server. So the `/uo/…` deep links D25 wrote have nothing to open
|
||||
there.
|
||||
- **It resets every two hours, not hourly, and not by reverting a VM.** A scheduled workflow
|
||||
restores the game server from a baseline, recreates the site's database, and seeds it again
|
||||
(`DEMO_MODE.md` §7). The banner gives the next reset in UTC.
|
||||
- **Its sign-ins are generated on its host.** The passwords exist only in the demo's own `.env`
|
||||
and in what the demo publishes: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
|
||||
`demo.logins`, which is what its sign-in page lists.
|
||||
|
||||
### How the site reaches it (2026-10-28)
|
||||
|
||||
`DEMO_MODE.md` §10 step 6 says "runicgateway.com `/beta` publishes the logins". The org lead took
|
||||
two decisions to do that:
|
||||
|
||||
**D82 — `/beta` lists the demo's sign-ins as the demo publishes them.** With `demoUrl` mounted,
|
||||
`/beta` (already rendered per request) reads the demo's `GET /api/v1/public/settings` on the
|
||||
server, caches the answer for a minute, and lists each `demo.logins` row with the next wipe
|
||||
time. A copy pasted into `brand.json` was the alternative, and it is one regenerated `.env` away
|
||||
from publishing passwords that do not work. The read is this server's, not the visitor's, so D9
|
||||
and the CSP are unchanged. It never fails the page: the demo is down for a few minutes at every
|
||||
wipe, and then (or after 1.5 seconds without an answer) `/beta` links to the demo's own sign-in
|
||||
page instead, and asks again 15 seconds later. Every field is shape- and length-checked before
|
||||
it is rendered. Gate two ("somewhere to point it") reads as in place, with the demo's host name
|
||||
to type into the app's first screen. `lib/demoLogins.mjs` holds it; `test/demo.test.mjs` tests it.
|
||||
|
||||
**D83 — a demo deep link names its game, and shows only for the games the demo runs.** A link
|
||||
into a game module's pages carries `data-demo-game` after `data-demo-path`. A new mounted list,
|
||||
`demoGames` (stock `[]`), names the modules the demo runs, and `applyBrand.mjs` fills a game's
|
||||
links only for a game listed there. Core's links (`/wiki`, `/site/events`, `/site/news`) carry no
|
||||
game and follow `demoUrl` alone. The Rust features gain links into the demo's server page
|
||||
(`/rust`, and `/rust/servers/demo` with its `?tab=` for the feed, leaderboard, online, map,
|
||||
wipes and clans). `demo` is the server's id, fixed by the demo's seed fixture. UO's links stay
|
||||
in the markup and stay hidden, so a UO demo later is one more entry in the list. `checkBrand.mjs`
|
||||
holds `demoGames` to the games in `games/index.mjs` and checks that `DEEP_LINK` reads the game.
|
||||
|
||||
The `public-demo` entry in `notBuilt.mjs` is gone, because its `resolvedBy` was met.
|
||||
|
||||
**What the operator mounts:** `"demoUrl": "https://demo.runicgateway.com"` and
|
||||
`"demoGames": ["rust"]` in the site's `brand.json`, then a restart. `/beta` follows `demoUrl` on
|
||||
the next request; the prerendered pages follow both at the restart.
|
||||
|
||||
### The plan as written (2026-08-19)
|
||||
|
||||
**Shape:** a VM on the org lead's Proxmox host running the full stack — website, `module-uo`, the
|
||||
uo-link sidecar and a real ServUO shard — so an evaluator clicks one link and sees the actual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--
|
||||
GENERATED FILE — do not edit.
|
||||
|
||||
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs
|
||||
Source: src/data/collection.mjs (scope "app", and the deletion row) + src/data/legal.mjs
|
||||
Generator: scripts/playDataSafety.mjs
|
||||
|
||||
Edit the data file and run `npm run play:datasafety`. CI runs the same
|
||||
@@ -19,49 +19,100 @@ The Play Console asks, for every category of data, whether the app **collects**
|
||||
|
||||
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.
|
||||
Play still counts what the app sends to that server as **collected**: the data leaves the device, and the form does not ask whose server it lands on. So everything the app sends is declared collected, not shared, and required, which is the conservative answer and the accurate one (org lead, 2026-10-29). What stays true, and what `/privacy` says, is that none of it reaches us.
|
||||
|
||||
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 collection and security
|
||||
|
||||
## Data types
|
||||
- **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.
|
||||
- **Is all of the user data collected by the app encrypted in transit?** Yes. The release build’s network security config sets `cleartextTrafficPermitted="false"` for every connection, and the app refuses an `http` deployment address outside debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, `core/net/ServerUrl.kt`.
|
||||
- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, under its operator’s policy, and we cannot promise otherwise on its behalf.
|
||||
- **Free text on security practices**, where the console offers it: 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.
|
||||
|
||||
| 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. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Stored on the device only. |
|
||||
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||
## Data types the app collects
|
||||
|
||||
| Category | Data type | Collected | Shared | Required | Purposes | From |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Personal info | Name | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact |
|
||||
| Personal info | Email address | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact |
|
||||
| Personal info | User IDs | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Account management | Your sign-in, and the tokens it issues; Linking a game account |
|
||||
| Messages | Other in-app messages | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact; What you post and send from the app |
|
||||
| App activity | App interactions | Yes | No | Yes | App functionality; Personalization | Which notifications you have read, and which you want |
|
||||
| App activity | In-app search history | Yes | No | Yes | App functionality | What you search for |
|
||||
| App activity | Other user-generated content | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Personalization | What you post and send from the app; What you report, block or hide |
|
||||
| App activity | Other actions | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Personalization | What you report, block or hide |
|
||||
| App info and performance | Diagnostics | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance | The app’s version and your Android version |
|
||||
| Device or other IDs | Device or other IDs | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Account management | The trusted-device token and the device’s name, if you asked for one; Push registration, if you turn notifications on |
|
||||
|
||||
## Data types the app does not collect
|
||||
|
||||
The form lists every type, so every one is answered. Vocabulary read from Play Console Help on 2026-10-29; check it against the console’s current list.
|
||||
|
||||
- **Location:** none collected.
|
||||
- **Personal info:** Address, Phone number, Race and ethnicity, Political or religious beliefs, Sexual orientation, Other info: not collected.
|
||||
- **Financial info:** none collected.
|
||||
- **Health and fitness:** none collected.
|
||||
- **Messages:** Emails, SMS or MMS: not collected.
|
||||
- **Photos and videos:** none collected.
|
||||
- **Audio files:** none collected.
|
||||
- **Files and docs:** none collected.
|
||||
- **Calendar:** none collected.
|
||||
- **Contacts:** none collected.
|
||||
- **App activity:** Installed apps: not collected.
|
||||
- **Web browsing:** none collected.
|
||||
- **App info and performance:** Crash logs, Other app performance data: not collected.
|
||||
|
||||
## Held on the device only
|
||||
|
||||
Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.
|
||||
|
||||
- **The address of the deployment you chose.** It never leaves the phone. It is the destination of requests, not the contents of one.
|
||||
- **A snapshot of your notifications, so the inbox opens without a signal.** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere.
|
||||
- **No analytics, no crash reporting, no advertising.** No crash reporter, no advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
|
||||
|
||||
## Each answer, and why it is the truthful one
|
||||
|
||||
### Your sign-in tokens
|
||||
### Your sign-in, and the tokens it issues
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
**Personal info → User IDs.** Collected, not shared, required.
|
||||
|
||||
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.
|
||||
Purposes: App functionality; Account management; Fraud prevention, security, and compliance.
|
||||
|
||||
- **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`
|
||||
Signing in sends your username and password to the deployment, or with single sign-on a one-time code from the provider, plus a two-factor code when the account has one. The deployment answers with an access and a refresh token and the username, role and account id they belong to. The tokens 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, with every request. Changing your username or password, setting up two-factor sign-in and generating recovery codes from the app go to the same place. So does deleting the account, which sends your username, your password and, if the account has one, a two-factor code.
|
||||
|
||||
### The trusted-device token, if you asked for one
|
||||
- **Why that answer:** The username, password and tokens leave the phone for the deployment the user chose, and Play counts that as collection whoever runs the server. None of it reaches us. Security is a purpose because the deployment checks two-factor codes and trusted devices with it.
|
||||
- **Retention:** On the device until you sign out or delete the account
|
||||
- **In detail:** Signing out clears them, and so does deleting the account; uninstalling the app removes them with it.
|
||||
- **Read from:** `data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt`
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
### The trusted-device token and the device’s name, if you asked for one
|
||||
|
||||
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.
|
||||
**Device or other IDs → Device or other IDs.** Collected, not shared, required.
|
||||
|
||||
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment.
|
||||
Purposes: Account management; Fraud prevention, security, and compliance.
|
||||
|
||||
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. The phone’s make and model, such as “Samsung SM-S918B”, goes with the request as the device’s label, so you can tell it apart in the deployment’s list of trusted devices. It is a model name, not a hardware identifier.
|
||||
|
||||
- **Why that answer:** The token identifies this device to the deployment, and the label is sent with it. Both are presented to the deployment that minted the token and to nothing else.
|
||||
- **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`
|
||||
- **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. Deleting the account from the app drops it from the phone too, since there is no next sign-in for it to skip.
|
||||
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt`
|
||||
|
||||
### The app’s version and your Android version
|
||||
|
||||
**App info and performance → Diagnostics.** Collected, not shared, required.
|
||||
|
||||
Purposes: App functionality; Fraud prevention, security, and compliance.
|
||||
|
||||
Every request carries a User-Agent such as “RunicGatewayApp/0.6.0 (Android 15)”. It is there because a deployment turns away traffic that looks like a scanner, and it names nothing about you or the phone beyond those two version numbers.
|
||||
|
||||
- **Why that answer:** It is technical information about the app and the device, and it is sent with every request. The deployment uses it to tell the app from a scanner.
|
||||
- **Retention:** Under the deployment operator’s policy
|
||||
- **In detail:** A deployment’s access log may keep it, as any web server keeps the user agent of a browser.
|
||||
- **Read from:** `di/NetworkModule.kt, core/net/UserAgentInterceptor.kt`
|
||||
|
||||
### The address of the deployment you chose
|
||||
|
||||
**App info and performance → Other app data.** Not collected by us. Stored on the device only.
|
||||
**No console type.** Not collected. 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.
|
||||
|
||||
@@ -71,43 +122,110 @@ The app ships pointed at nothing and asks for an address on first run. That addr
|
||||
|
||||
### 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.
|
||||
**Device or other IDs → Device or other IDs.** Collected, not shared, required. Mention the relay in the free-text security section if the console asks.
|
||||
|
||||
Purposes: App functionality.
|
||||
|
||||
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`
|
||||
- **Why that answer:** The topic URL is an address for this device, and the app sends it to the deployment. The relay is the one the deployment nominates to deliver its own notifications, so it carries them on the deployment’s behalf rather than receiving a share. It only ever sees a topic name and a tickle; the app pulls the content itself, authenticated. No hop reaches a server we operate.
|
||||
- **Retention:** Until you turn push off, sign out, delete the account, or uninstall
|
||||
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic; deleting the account drops the registration on the deployment with everything else. 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, data/api/NotificationsApi.kt`
|
||||
|
||||
### Everything you read and post in the app
|
||||
### Linking a game account
|
||||
|
||||
**Messages → Other user-generated content.** Not collected by us.
|
||||
**Personal info → User IDs.** Collected, not shared, required.
|
||||
|
||||
Forum posts, Team activity, what the game module shows you, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
|
||||
Purposes: App functionality; Account management.
|
||||
|
||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
||||
Linking your site account to a game sends the one-time code the game gave you. Where a game server offers it, the app can also create a new game account, which sends the account name and password you chose. Unlinking sends the id being unlinked, such as a Steam id. All of it goes to the deployment, which keeps the link against your site account.
|
||||
|
||||
- **Why that answer:** Game account names, Steam ids and link codes identify a player, and the app sends them to the deployment.
|
||||
- **Retention:** Held by the deployment until you unlink, under its operator’s policy
|
||||
- **Read from:** `data/api/PlayerShardApi.kt, data/api/PlayerRustApi.kt, data/api/MeApi.kt`
|
||||
|
||||
### Your name, email address and message, if you use Contact
|
||||
|
||||
**Personal info → Name; Personal info → Email address; Messages → Other in-app messages.** Collected, not shared, required.
|
||||
|
||||
Purposes: App functionality.
|
||||
|
||||
The Contact screen sends the name, email address and message you type to the deployment, which passes them on to its operator by email so they can reply. Nothing is sent until you press send.
|
||||
|
||||
- **Why that answer:** The form’s three fields leave the phone for the deployment, whose operator receives them.
|
||||
- **Retention:** Held by the deployment’s operator, under their policy
|
||||
- **In detail:** The message is delivered to the operator’s mailbox, and kept as long as they keep it.
|
||||
- **Read from:** `data/api/PublicApi.kt, data/api/dto/ContactDto.kt`
|
||||
|
||||
### What you post and send from the app
|
||||
|
||||
**Messages → Other in-app messages; App activity → Other user-generated content.** Collected, not shared, required.
|
||||
|
||||
Purposes: App functionality.
|
||||
|
||||
Most of the app is reading. What it sends is what you write, and most of that is for staff: news posts, wiki categories, a broadcast to the game, the answer to a player’s help page, and the account and reason on a kick or a ban. All of it is a live write to the deployment. Apart from the notification snapshot described below, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
|
||||
|
||||
- **Why that answer:** Posts, broadcasts and replies are 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`
|
||||
- **Read from:** `data/api/AdminApi.kt`
|
||||
|
||||
### What you report, block or hide
|
||||
|
||||
**App activity → Other user-generated content; App activity → Other actions.** Collected, not shared, required.
|
||||
|
||||
Purposes: App functionality; Fraud prevention, security, and compliance; Personalization.
|
||||
|
||||
Long-pressing a chat line offers Report, Hide for me and Block player. A report sends which line it is, the reason you picked and, if you wrote one, a note of up to 500 characters, so the deployment’s staff can review it. Hide for me and Block player send which line it is; the deployment works out who wrote it and keeps the hide or the block on your account, so it applies on the website and on every device. Your list of blocks, and unblocking someone, come from and go to the same place. For staff, Hide for everyone and Show to everyone again send the line too. Nothing is sent until you choose one.
|
||||
|
||||
- **Why that answer:** The report’s note is text the user writes, and reporting, blocking and hiding are actions the app sends to the deployment, which keeps them. Reports exist so staff can act on abuse; blocks and hides change what this one reader is shown.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **In detail:** A block or a hide lasts until you remove it or delete the account, which deletes them. A report stays as a moderation record, with a copy of what the line said, until the operator removes it.
|
||||
- **Read from:** `data/api/SafetyApi.kt, data/api/dto/SafetyDto.kt, data/api/RustApi.kt`
|
||||
|
||||
### Which notifications you have read, and which you want
|
||||
|
||||
**App activity → App interactions.** Collected, not shared, required.
|
||||
|
||||
Purposes: App functionality; Personalization.
|
||||
|
||||
Opening a notification marks it read on the deployment, so the badge agrees on every device you use. Your choice of notification streams, and of which arrive by push or email, is saved there too.
|
||||
|
||||
- **Why that answer:** Read state and preferences are actions in the app that are sent to, and kept by, the deployment.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **Read from:** `data/api/NotificationsApi.kt`
|
||||
|
||||
### What you search for
|
||||
|
||||
**App activity → In-app search history.** Collected, not shared, required.
|
||||
|
||||
Purposes: App functionality.
|
||||
|
||||
Searching the wiki, a game’s market or its creature atlas sends what you typed to the deployment, because the deployment does the searching. The app keeps no history of searches.
|
||||
|
||||
- **Why that answer:** Search text leaves the phone in the request to the deployment.
|
||||
- **Retention:** Under the deployment operator’s policy
|
||||
- **In detail:** The query is part of the request’s address, so a deployment’s access log may record it. That is why it is declared rather than called ephemeral.
|
||||
- **Read from:** `data/api/PublicApi.kt`
|
||||
|
||||
### A snapshot of your notifications, so the inbox opens without a signal
|
||||
|
||||
**Messages → Other in-app messages.** Not collected by us. Stored on the device only.
|
||||
**No console type.** Not collected. Stored on the device only.
|
||||
|
||||
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages.
|
||||
|
||||
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere, and no server we operate is on either end of it.
|
||||
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones
|
||||
- **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
|
||||
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere.
|
||||
- **Retention:** Until you sign out or delete the account, or the thirty are pushed out by newer ones
|
||||
- **In detail:** Signing out, or deleting the account, deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
|
||||
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
|
||||
|
||||
### No analytics, no crash reporting, no advertising
|
||||
|
||||
**Device or other IDs → Device or other IDs.** Not collected.
|
||||
**App info and performance → Crash logs.** Not collected. The advertising ID declaration is "no".
|
||||
|
||||
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.
|
||||
- **Why that answer:** No crash reporter, 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`
|
||||
|
||||
@@ -115,8 +233,31 @@ There is no third-party SDK in the app at all — no Firebase, no Crashlytics, n
|
||||
|
||||
- **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).
|
||||
- **Accounts the app can start:** the sign-in screen links to the deployment’s own sign-up page, which opens in the browser, and where a game server offers it the app creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.
|
||||
- **Can users delete their account? Yes,** in the app (*My account* → *Delete my account…*) and on the deployment’s website (*Account* → *Delete my account*). It takes the password, and a two-factor code where the account has one, and the account is deleted at once. The account belongs to the deployment the user chose, so it is deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, `data/api/MeApi.kt`.
|
||||
- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and us, gives the steps in the app and on the website, says what is deleted and what is kept and for how long, and tells someone who cannot sign in to ask their community’s operator, who can delete any account from the admin panel (D86).
|
||||
- **Deleting some data without deleting the account:** the deployment holds it, so that is a request to its operator. The one list we hold, the beta signup, is erased on request; `/privacy` section 4 says how to ask.
|
||||
|
||||
## What an account deletion deletes and keeps
|
||||
|
||||
The console asks what is deleted and what is kept. These are the same words `/delete-account/` renders, from the same row of `src/data/collection.mjs`.
|
||||
|
||||
**Deleted at once:**
|
||||
|
||||
- your sign-in, password, email address, two-factor settings and recovery codes
|
||||
- every signed-in device, trusted device and push registration
|
||||
- your linked sign-ins, such as Google, Discord or Steam
|
||||
- your game-account links, and anything this site granted in the game through them
|
||||
- your notification settings and inbox
|
||||
|
||||
**Kept:**
|
||||
|
||||
- What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”. *Until a moderator or the operator removes it, as with any post.*
|
||||
- Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses. *Until the operator removes them.*
|
||||
- Event results stay with the score and the in-game name, with no site account behind them. *Until the operator removes them.*
|
||||
- A game account stays on the game server. Only its link to this site goes. *For as long as the game server keeps it, which is up to whoever runs that server.*
|
||||
- The record of notifications sent to you keeps a one-way hash of your email address, never the address, and no longer names your account. An address that bounced or was reported as spam stays on the suppression list, so it is never mailed again. *The record is swept after a period the operator sets, 180 days by default. The suppression list does not expire.*
|
||||
- If you were staff, your username can never be used again, by anyone.
|
||||
|
||||
## What the website collects, for the same reviewer
|
||||
|
||||
@@ -128,4 +269,4 @@ Not part of the Data Safety form — that form is about the app — but a review
|
||||
- **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-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
Last generated from data dated 2026-10-30. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
|
||||
@@ -32,7 +32,7 @@ npm run build # → dist/ (prerendered pages + the Node server entry)
|
||||
npm start # serve the built site
|
||||
```
|
||||
|
||||
Node 22 LTS or newer. Nothing else — no database, no game server, no container runtime.
|
||||
Node 22.23.4+ (or 24.22.1+); older builds carry the Node2Shell Brotli bug, CVE-2026-111497. Nothing else — no database, no game server, no container runtime.
|
||||
|
||||
## Running it in production
|
||||
|
||||
|
||||
@@ -23,13 +23,22 @@
|
||||
"giteaOrg": "https://gitea.whitlocktech.com/RunicGateway",
|
||||
|
||||
"$comment_demo": [
|
||||
"§15 / D12. A public demo instance is planned and out of scope today. The homepage's",
|
||||
"'See it running' slot and /features/'s per-capability affordance render only when this",
|
||||
"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."
|
||||
"§15 / D12. The public demo's address, e.g. https://demo.runicgateway.com. The",
|
||||
"homepage's 'See it running' slot and /features/'s per-capability affordance render only",
|
||||
"when this 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. /beta also reads the demo's published",
|
||||
"sign-ins from it, on the next request (D82)."
|
||||
],
|
||||
"demoUrl": "",
|
||||
|
||||
"$comment_demoGames": [
|
||||
"D83. The game modules the demo at demoUrl runs, by module id (\"rust\", \"uo\"). A",
|
||||
"'See it running' link into a game's own pages shows only for a game listed here, so",
|
||||
"a Rust demo never links to a /uo/ page it does not have. Core's links (the wiki, the",
|
||||
"event calendar, news) follow demoUrl alone. Read at the next restart, like demoUrl."
|
||||
],
|
||||
"demoGames": [],
|
||||
|
||||
"$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.",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"check:csp": "node scripts/checkCsp.mjs",
|
||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||
"beta": "node scripts/beta.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs test/demo.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"screens:capture": "node scripts/captureScreens.mjs",
|
||||
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
|
||||
|
||||
@@ -193,17 +193,38 @@ if (demoFrom !== demoTo) {
|
||||
* 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 DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"(?: data-demo-game="([^"]*)")?/g;
|
||||
|
||||
const deepLinkTo = (demoPath) => {
|
||||
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
|
||||
/**
|
||||
* Which games the demo runs (D83). A deep link into a game module's pages carries that
|
||||
* game's id in `data-demo-game`, and it is filled only when the mounted `demoGames` names
|
||||
* it: the demo is a Rust deployment, and a `/uo/market` link into it is a 404. Core's own
|
||||
* links (`/wiki`, `/site/events`) carry no game and follow `demoUrl` alone, because every
|
||||
* deployment has them.
|
||||
*
|
||||
* An absent or malformed list means no game's links, not every game's. A demo whose games
|
||||
* nobody named is one whose game links cannot be known to work.
|
||||
*/
|
||||
const gamesOf = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []);
|
||||
const gamesFrom = gamesOf(previous.demoGames);
|
||||
const gamesTo = gamesOf(resolved.demoGames);
|
||||
const gamesChanged = gamesFrom.join(',') !== gamesTo.join(',');
|
||||
|
||||
if ('demoGames' in mounted && !Array.isArray(mounted.demoGames)) {
|
||||
console.warn('[brand] the mounted "demoGames" is not a list of game ids — no game links will show.');
|
||||
}
|
||||
|
||||
const deepLinkTo = (demoPath, game) => {
|
||||
const root = !game || gamesTo.includes(game) ? demoTo : '';
|
||||
const href = root ? `${root.replace(/\/+$/, '')}${demoPath}` : '';
|
||||
return (
|
||||
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
|
||||
`data-demo-path="${demoPath}"`
|
||||
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(root)}" ` +
|
||||
`data-demo-path="${demoPath}"` +
|
||||
(game === undefined ? '' : ` data-demo-game="${game}"`)
|
||||
);
|
||||
};
|
||||
|
||||
if (!replacements.length) {
|
||||
if (!replacements.length && !gamesChanged) {
|
||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -303,8 +324,8 @@ for (const file of walk(CLIENT)) {
|
||||
// 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);
|
||||
after = after.replace(DEEP_LINK, (whole, demoPath, game) => {
|
||||
const rebuilt = deepLinkTo(demoPath, game);
|
||||
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
|
||||
return rebuilt;
|
||||
});
|
||||
@@ -330,6 +351,9 @@ if (counts.get('demoDeep')) {
|
||||
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
|
||||
);
|
||||
}
|
||||
if (gamesChanged) {
|
||||
console.log(` ${'demoGames'.padEnd(14)} ${gamesTo.length ? gamesTo.join(', ') : 'no game links'}`);
|
||||
}
|
||||
|
||||
if (inlineCollisions.length) {
|
||||
console.error(
|
||||
|
||||
@@ -32,6 +32,7 @@ import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { classify } from '../src/lib/brandAssets.mjs';
|
||||
import { games } from '../src/data/games/index.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const DEFAULTS = path.join(ROOT, 'brand-default');
|
||||
@@ -157,6 +158,20 @@ if (brand) {
|
||||
}
|
||||
}
|
||||
|
||||
// D83: the one list-valued field. Each entry is a game module's id, checked against the
|
||||
// games this site describes, so a typo is a failed build rather than a demo whose game
|
||||
// links silently never appear.
|
||||
const gameIds = games.map((game) => game.key);
|
||||
if (!Array.isArray(brand.demoGames)) {
|
||||
fail('brand.json is missing the list field "demoGames" (an empty list is the stock value).');
|
||||
} else {
|
||||
for (const id of brand.demoGames) {
|
||||
if (!gameIds.includes(id)) {
|
||||
fail(`brand.json's "demoGames" names ${JSON.stringify(id)}, which is not one of ${gameIds.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
|
||||
* restating the list. A field added there and forgotten here would be unchecked; a field
|
||||
@@ -348,6 +363,23 @@ if (!deepPattern) {
|
||||
);
|
||||
}
|
||||
|
||||
// A game's link (D83) must also be read WITH its game. A pattern that matched the first
|
||||
// three attributes and dropped the fourth would treat a /uo/ link as a core one and fill
|
||||
// it on every demo, which is the 404 the attribute exists to prevent.
|
||||
const gameSample = `${sample} data-demo-game="rust"`;
|
||||
let gameRead = null;
|
||||
try {
|
||||
gameRead = new RegExp(deepPattern[1]).exec(gameSample)?.[2] ?? null;
|
||||
} catch {
|
||||
// Reported above.
|
||||
}
|
||||
if (gameRead !== 'rust') {
|
||||
fail(
|
||||
`applyBrand.mjs's DEEP_LINK does not read the game out of \`${gameSample}\`.\n` +
|
||||
' Game links would follow demoUrl alone and point into modules the demo may not run.'
|
||||
);
|
||||
}
|
||||
|
||||
const deepStrays = [];
|
||||
let deepLinks = 0;
|
||||
|
||||
|
||||
@@ -38,7 +38,14 @@ 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 {
|
||||
collectedIn,
|
||||
deletionRow,
|
||||
PLAY_DATA_TYPES,
|
||||
PLAY_PURPOSES,
|
||||
PLAY_VOCABULARY_READ,
|
||||
playRows,
|
||||
} from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -52,6 +59,33 @@ const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, '
|
||||
|
||||
const yesNo = (value) => (value ? 'Yes' : 'No');
|
||||
|
||||
/**
|
||||
* The form asks per data type, not per row, and two rows can answer the same type (the
|
||||
* trusted-device token and the push topic are both "Device or other IDs"). So the
|
||||
* collected rows are folded into one entry per type, in the console's order: required if
|
||||
* any row requires it, the union of the purposes, and the rows it came from.
|
||||
*/
|
||||
function declaredTypes(rows) {
|
||||
const entries = [];
|
||||
for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) {
|
||||
for (const type of types) {
|
||||
const from = rows.filter(
|
||||
(row) => row.play.collected && row.play.types.some((t) => t.category === category && t.type === type)
|
||||
);
|
||||
if (!from.length) continue;
|
||||
const purposes = new Set(from.flatMap((row) => row.play.purposes));
|
||||
entries.push({
|
||||
category,
|
||||
type,
|
||||
required: from.some((row) => row.play.required),
|
||||
purposes: PLAY_PURPOSES.filter((p) => purposes.has(p)),
|
||||
rows: from,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = playRows();
|
||||
const site = collectedIn('site');
|
||||
@@ -61,7 +95,9 @@ function render() {
|
||||
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(
|
||||
` Source: src/data/collection.mjs (scope "app", and the deletion row) + 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');
|
||||
@@ -101,40 +137,87 @@ function render() {
|
||||
);
|
||||
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.'
|
||||
'Play still counts what the app sends to that server as **collected**: the data leaves ' +
|
||||
'the device, and the form does not ask whose server it lands on. So everything the ' +
|
||||
'app sends is declared collected, not shared, and required, which is the conservative ' +
|
||||
'answer and the accurate one (org lead, 2026-10-29). What stays true, and what ' +
|
||||
'`/privacy` says, is that none of it reaches us.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## Data types');
|
||||
lines.push('## Data collection and security');
|
||||
lines.push('');
|
||||
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
for (const row of rows) {
|
||||
lines.push('- **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.');
|
||||
lines.push(
|
||||
'- **Is all of the user data collected by the app encrypted in transit?** Yes. The ' +
|
||||
'release build’s network security config sets `cleartextTrafficPermitted="false"` ' +
|
||||
'for every connection, and the app refuses an `http` deployment address outside ' +
|
||||
'debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, ' +
|
||||
'`core/net/ServerUrl.kt`.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, ' +
|
||||
'under its operator’s policy, and we cannot promise otherwise on its behalf.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Free text on security practices**, where the console offers it: 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('');
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
const declared = declaredTypes(rows);
|
||||
|
||||
lines.push('## Data types the app collects');
|
||||
lines.push('');
|
||||
lines.push('| Category | Data type | Collected | Shared | Required | Purposes | From |');
|
||||
lines.push('|---|---|---|---|---|---|---|');
|
||||
for (const entry of declared) {
|
||||
lines.push(
|
||||
`| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` +
|
||||
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |`
|
||||
`| ${cell(entry.category)} | ${cell(entry.type)} | Yes | No | ${yesNo(entry.required)} ` +
|
||||
`| ${cell(entry.purposes.join('; '))} | ${cell(entry.rows.map((r) => r.title).join('; '))} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Data types the app does not collect');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'The form lists every type, so every one is answered. Vocabulary read from Play ' +
|
||||
`Console Help on ${PLAY_VOCABULARY_READ}; check it against the console’s current list.`
|
||||
);
|
||||
lines.push('');
|
||||
for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) {
|
||||
const rest = types.filter((type) => !declared.some((e) => e.category === category && e.type === type));
|
||||
if (!rest.length) continue;
|
||||
const all = rest.length === types.length;
|
||||
lines.push(`- **${category}:** ${all ? 'none collected' : `${rest.join(', ')}: not collected`}.`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const onDevice = rows.filter((row) => !row.play.collected);
|
||||
lines.push('## Held on the device only');
|
||||
lines.push('');
|
||||
lines.push('Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.');
|
||||
lines.push('');
|
||||
for (const row of onDevice) lines.push(`- **${cell(row.title)}.** ${cell(row.play.because)}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Each answer, and why it is the truthful one');
|
||||
lines.push('');
|
||||
for (const row of rows) {
|
||||
const types = row.play.types.map((t) => `${t.category} → ${t.type}`).join('; ');
|
||||
lines.push(`### ${row.title}`);
|
||||
lines.push('');
|
||||
lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`);
|
||||
lines.push(`**${types || 'No console type'}.** ${cell(row.play.answer)}`);
|
||||
lines.push('');
|
||||
if (row.play.collected) {
|
||||
lines.push(`Purposes: ${row.play.purposes.join('; ')}.`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(cell(row.body));
|
||||
lines.push('');
|
||||
lines.push(`- **Why that answer:** ${cell(row.play.because)}`);
|
||||
@@ -156,14 +239,49 @@ function render() {
|
||||
'(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.'
|
||||
'- **Accounts the app can start:** the sign-in screen links to the deployment’s own ' +
|
||||
'sign-up page, which opens in the browser, and where a game server offers it the app ' +
|
||||
'creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, ' +
|
||||
'`data/api/PlayerShardApi.kt`.'
|
||||
);
|
||||
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).'
|
||||
'- **Can users delete their account? Yes,** in the app (*My account* → *Delete my ' +
|
||||
'account…*) and on the deployment’s website (*Account* → *Delete my account*). It ' +
|
||||
'takes the password, and a two-factor code where the account has one, and the account ' +
|
||||
'is deleted at once. The account belongs to the deployment the user chose, so it is ' +
|
||||
'deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, ' +
|
||||
'`data/api/MeApi.kt`.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and ' +
|
||||
'us, gives the steps in the app and on the website, says what is deleted and what is ' +
|
||||
'kept and for how long, and tells someone who cannot sign in to ask their community’s ' +
|
||||
'operator, who can delete any account from the admin panel (D86).'
|
||||
);
|
||||
lines.push(
|
||||
'- **Deleting some data without deleting the account:** the deployment holds it, so ' +
|
||||
'that is a request to its operator. The one list we hold, the beta signup, is erased ' +
|
||||
'on request; `/privacy` section 4 says how to ask.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
const deletion = deletionRow().deletion;
|
||||
lines.push('## What an account deletion deletes and keeps');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'The console asks what is deleted and what is kept. These are the same words ' +
|
||||
'`/delete-account/` renders, from the same row of `src/data/collection.mjs`.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('**Deleted at once:**');
|
||||
lines.push('');
|
||||
for (const item of deletion.goes) lines.push(`- ${cell(item)}`);
|
||||
lines.push('');
|
||||
lines.push('**Kept:**');
|
||||
lines.push('');
|
||||
for (const entry of deletion.stays) lines.push(`- ${cell(entry.what)} *${cell(entry.keep)}*`);
|
||||
lines.push(`- ${cell(deletion.staff)}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## What the website collects, for the same reviewer');
|
||||
|
||||
@@ -13,6 +13,8 @@ import platform from '../data/platform.json';
|
||||
* 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.
|
||||
* `/delete-account/` joined them for the same reason (D85): it is the address Google Play
|
||||
* is given, and a player looking for it looks where the privacy link is.
|
||||
*/
|
||||
// `/beta` renders per request, so the footer it gets must read the mount rather than the
|
||||
// value baked at build time. See `renderBrand` in src/lib/brand.mjs (phase 11).
|
||||
@@ -59,6 +61,7 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
<span class="site-footer__links">
|
||||
<a href="/privacy/">Privacy</a>
|
||||
<a href="/terms/">Terms</a>
|
||||
<a href="/delete-account/">Delete account</a>
|
||||
</span>
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
|
||||
@@ -12,14 +12,19 @@ import type { Capability } from '../data/capabilities.mjs';
|
||||
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
|
||||
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
|
||||
* see the note in `/features/` before touching it. A stock build hides every one.
|
||||
*
|
||||
* `game` is the module's id, written as `data-demo-game` straight after the path: these
|
||||
* links are into that module's pages, so they show only on a demo whose mounted
|
||||
* `demoGames` names it (D83).
|
||||
*/
|
||||
interface Props {
|
||||
title: string;
|
||||
lede?: string;
|
||||
game: string;
|
||||
items: Capability[];
|
||||
}
|
||||
|
||||
const { title, lede, items } = Astro.props;
|
||||
const { title, lede, game, items } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="page section feats">
|
||||
@@ -35,7 +40,7 @@ const { title, lede, items } = Astro.props;
|
||||
{item.demoPath && (
|
||||
<a
|
||||
class="demo-link"
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath}
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath} data-demo-game={game}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See it running
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Moderation
|
||||
description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction.
|
||||
description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction — and what members can do for themselves.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
@@ -11,7 +11,7 @@ not overlap.
|
||||
| Screen | Is about | Comes from |
|
||||
|---|---|---|
|
||||
| **Moderation** | Your **Discord** server — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
|
||||
| **Reports** | **Team forum content** members have reported | the site |
|
||||
| **Reports** | **Content** members have reported — Team forum threads and posts, and a game's chat lines where its module offers them | the site |
|
||||
| **Appeals** | Sanctions people are asking you to reverse | the site |
|
||||
|
||||
## Moderation (Discord)
|
||||
@@ -29,13 +29,20 @@ browsing the window.
|
||||
|
||||
## Reports
|
||||
|
||||
Reports raised by members about Team forum content. Two design decisions show through in
|
||||
how this screen behaves:
|
||||
Reports raised by members about Team forum threads and posts and, where a game module offers
|
||||
it, the game's chat lines — the Rust module does, from the `⋯` menu on a chat line on the
|
||||
site and a long press in the [app](/app/). Each report keeps a copy of what the item said when it was
|
||||
reported, so an author editing it afterwards does not change what staff read. Three 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.
|
||||
- **A report changes nothing on its own.** Filing one must never be a way to hide
|
||||
something, so the reported item stays where it is until staff act.
|
||||
- **Acting is a separate, deliberate step.** *Hide for everyone* on a report hides the item
|
||||
from everybody except staff, who still see it marked *Hidden by staff* and can show it
|
||||
again, and it records the report as actioned. Removing content outright, or sanctioning the
|
||||
account, is still done in the forum or 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.
|
||||
@@ -46,6 +53,19 @@ that never empties stops being read, and the reporter's next report is the one t
|
||||
matters.
|
||||
</Aside>
|
||||
|
||||
## What members do for themselves
|
||||
|
||||
Two tools need no staff at all, and nothing about them reaches the queue:
|
||||
|
||||
- **Hide for me** takes one item out of that member's own view, on every device.
|
||||
- **Block** takes everything a person writes — chat lines and forum content — out of the
|
||||
blocker's view, on the site and in the app. The blocked person is not told. Staff cannot be
|
||||
blocked, and a block whose target later becomes staff stops applying and says so. A game
|
||||
player who has not linked a site account is blocked by their game identity, which turns
|
||||
into a block on their account if they link one later.
|
||||
|
||||
A member undoes either from **Account → Blocked**.
|
||||
|
||||
## Appeals
|
||||
|
||||
An appeal is a request to reverse a sanction, filtered by *Open*, *Pending*, *Under
|
||||
|
||||
@@ -42,7 +42,7 @@ with six tabs.
|
||||
|
||||
| Tab | What it shows |
|
||||
|---|---|
|
||||
| **Feed** | Kills, chat, comings and goings, the server's own story, RunicNPC bosses, and Rust's world events — cargo, helicopters, airdrops — as they happen |
|
||||
| **Feed** | Kills, chat, comings and goings, the server's own story, RunicNPC bosses, and Rust's world events — cargo, helicopters, airdrops — as they happen. A signed-in member can report a chat line, hide it for themself or block the player from its `⋯` menu, and staff can hide a line for everyone ([Moderation](/docs/administration/moderation/)) |
|
||||
| **Leaderboard** | Per wipe: kills, deaths, NPC kills, structures, time played, and the chat titles players have earned |
|
||||
| **Online** | Who is on right now |
|
||||
| **Map** | The [live map](/docs/rust/the-live-map/): the server's own map picture, with layers |
|
||||
|
||||
@@ -128,6 +128,22 @@ export const appFeatures = [
|
||||
'Visible to the account they belong to and to nobody else.',
|
||||
gate: 'A game module',
|
||||
},
|
||||
{
|
||||
title: 'Report, block and hide',
|
||||
body:
|
||||
'Long-press a chat line to report it to the site’s staff, hide it for yourself, or ' +
|
||||
'block the player. A block is kept on your account, so it applies on the website and ' +
|
||||
'on every device too; staff cannot be blocked, and Account → Blocked undoes any of ' +
|
||||
'it. Staff get Hide for everyone on the same menu.',
|
||||
gate: 'A game module whose chat lines can be reported (Rust)',
|
||||
},
|
||||
{
|
||||
title: 'Deleting your account',
|
||||
body:
|
||||
'From My account, with your password and, where the account has one, a two-factor ' +
|
||||
'code. The account is deleted on the deployment at once — see what that removes and ' +
|
||||
'what it keeps on the delete-account page.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
* 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.
|
||||
* the public demo (§15) the tester target rather than naming a private shard. The demo
|
||||
* went up on 2026-10-27; with `demoUrl` mounted, `/beta` marks this gate in place and
|
||||
* lists the demo's sign-ins as the demo publishes them (D82, `lib/demoLogins.mjs`).
|
||||
*
|
||||
* 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
|
||||
@@ -180,8 +180,7 @@ export const requirements = [
|
||||
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.',
|
||||
'so a tester needs either their own deployment or the public demo.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `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
|
||||
* §15/D12 kept 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
|
||||
@@ -63,8 +63,10 @@
|
||||
* 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. A module's public pages are namespaced under its own id, so a route into the
|
||||
* Ultima Online module is `/uo/…`: the demo §15 plans runs that game, which is why the
|
||||
* Teams item points there and the module-supplied items in `games/` carry their own.
|
||||
* Ultima Online module is `/uo/…`. A link into a module's pages names that game
|
||||
* (`demoGame` here, the `game` prop of `GameFeatures` for the lists in `games/`), and it
|
||||
* shows only when the mounted `demoGames` lists it (D83): the demo that exists runs Rust,
|
||||
* so the Teams item's `/uo/guilds` stays hidden there and Rust's clans link stands in.
|
||||
*/
|
||||
|
||||
import { games } from './games/index.mjs';
|
||||
@@ -82,6 +84,7 @@ import { games } from './games/index.mjs';
|
||||
* @property {string[]} [caps] Module capability slugs, on a game's own items (`games/`) 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).
|
||||
* @property {string} [demoGame] The game module whose pages `demoPath` is in, if any (D83).
|
||||
*
|
||||
* @typedef {object} CapabilityGroup
|
||||
* @property {string} id
|
||||
@@ -116,6 +119,7 @@ const community = {
|
||||
label: 'Teams',
|
||||
needsModule: true,
|
||||
demoPath: '/uo/guilds',
|
||||
demoGame: 'uo',
|
||||
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 ' +
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
* `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.
|
||||
* community. What is listed here is what the device holds and what it
|
||||
* sends to that deployment. None of it reaches us, because we receive
|
||||
* nothing; Play still counts what is sent as collected (see `PlayMapping`).
|
||||
* `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
|
||||
@@ -46,13 +47,23 @@
|
||||
* @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} PlayType
|
||||
* @property {string} category The console's grouping, a key of `PLAY_DATA_TYPES`.
|
||||
* @property {string} type The console's data type within that grouping.
|
||||
*
|
||||
* @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 {PlayType[]} types The console data types this row answers.
|
||||
* @property {boolean} collected Does it leave the device? Play counts data the app sends
|
||||
* off the phone as collected, whoever runs the server it
|
||||
* goes to. Ours receives none of it, because we run none;
|
||||
* the deployment the user chose receives it (org lead,
|
||||
* 2026-10-29: the conservative answers).
|
||||
* @property {boolean} shared Is it passed to a third party? Never, for this app.
|
||||
* @property {boolean} [required] Collected rows only. Declared required throughout (same
|
||||
* decision), rather than arguing feature by feature.
|
||||
* @property {string[]} [purposes] Collected rows only, from `PLAY_PURPOSES`.
|
||||
* @property {string} answer The recommended console answer, in one line.
|
||||
* @property {string} because Why that answer is the truthful one.
|
||||
*
|
||||
@@ -64,9 +75,74 @@
|
||||
* @property {Retention} retention
|
||||
* @property {string} source The file this was read out of, repo-relative.
|
||||
* @property {PlayMapping} [play]
|
||||
* @property {Deletion} [deletion] The one row `/delete-account/` renders. See that row.
|
||||
*
|
||||
* @type {Collected[]}
|
||||
* @typedef {object} Kept
|
||||
* @property {string} what What stays, and who sees what of it.
|
||||
* @property {string} keep For how long. Play asks for the retention of what stays.
|
||||
*
|
||||
* @typedef {object} Deletion
|
||||
* @property {string[]} goes What goes with the account, in the deployment's own words.
|
||||
* @property {Kept[]} stays
|
||||
* @property {string} staff Said of a staff account only (AD9).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Data Safety form's own vocabulary: every category and the data types under it, and
|
||||
* the purposes a collected type can be given. Read from Play Console Help ("Provide
|
||||
* information for Google Play's Data safety section") on `PLAY_VOCABULARY_READ`.
|
||||
*
|
||||
* Here so a typo or a misfiled type fails a test instead of reaching the console. The
|
||||
* first version of this file filed "Other user-generated content" under Messages; it
|
||||
* lives under App activity. The full list is also what lets the generator say, for every
|
||||
* type the app does NOT collect, that it does not — the form asks about all of them.
|
||||
*/
|
||||
export const PLAY_VOCABULARY_READ = '2026-10-29';
|
||||
|
||||
export const PLAY_DATA_TYPES = {
|
||||
Location: ['Approximate location', 'Precise location'],
|
||||
'Personal info': [
|
||||
'Name',
|
||||
'Email address',
|
||||
'User IDs',
|
||||
'Address',
|
||||
'Phone number',
|
||||
'Race and ethnicity',
|
||||
'Political or religious beliefs',
|
||||
'Sexual orientation',
|
||||
'Other info',
|
||||
],
|
||||
'Financial info': ['User payment info', 'Purchase history', 'Credit score', 'Other financial info'],
|
||||
'Health and fitness': ['Health info', 'Fitness info'],
|
||||
Messages: ['Emails', 'SMS or MMS', 'Other in-app messages'],
|
||||
'Photos and videos': ['Photos', 'Videos'],
|
||||
'Audio files': ['Voice or sound recordings', 'Music files', 'Other audio files'],
|
||||
'Files and docs': ['Files and docs'],
|
||||
Calendar: ['Calendar events'],
|
||||
Contacts: ['Contacts'],
|
||||
'App activity': [
|
||||
'App interactions',
|
||||
'In-app search history',
|
||||
'Installed apps',
|
||||
'Other user-generated content',
|
||||
'Other actions',
|
||||
],
|
||||
'Web browsing': ['Web browsing history'],
|
||||
'App info and performance': ['Crash logs', 'Diagnostics', 'Other app performance data'],
|
||||
'Device or other IDs': ['Device or other IDs'],
|
||||
};
|
||||
|
||||
export const PLAY_PURPOSES = [
|
||||
'App functionality',
|
||||
'Analytics',
|
||||
'Developer communications',
|
||||
'Advertising or marketing',
|
||||
'Fraud prevention, security, and compliance',
|
||||
'Personalization',
|
||||
'Account management',
|
||||
];
|
||||
|
||||
/** @type {Collected[]} */
|
||||
export const collected = [
|
||||
/* =====================================================================================
|
||||
1. THIS WEBSITE
|
||||
@@ -163,8 +239,14 @@ export const collected = [
|
||||
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.
|
||||
build — the manifest asks for INTERNET, network state, notifications, a special-use
|
||||
foreground service for push and the boot broadcast that restarts it, and nothing else.
|
||||
|
||||
What goes to that address is still COLLECTED in Play's sense, because it leaves the
|
||||
phone, and it is declared that way (org lead, 2026-10-29). Read out of the Retrofit
|
||||
interfaces in `data/api/` on the Android app's `edge` (19c2f77): every request body,
|
||||
query and header the app sends is in one of the rows below. Rows that stay on the
|
||||
phone say so, and carry no console type.
|
||||
|
||||
`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).
|
||||
@@ -172,54 +254,103 @@ export const collected = [
|
||||
{
|
||||
id: 'app-session-tokens',
|
||||
scope: 'app',
|
||||
title: 'Your sign-in tokens',
|
||||
title: 'Your sign-in, and the tokens it issues',
|
||||
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.',
|
||||
'Signing in sends your username and password to the deployment, or with single ' +
|
||||
'sign-on a one-time code from the provider, plus a two-factor code when the account ' +
|
||||
'has one. The deployment answers with an access and a refresh token and the ' +
|
||||
'username, role and account id they belong to. The tokens 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, with every request. Changing ' +
|
||||
'your username or password, setting up two-factor sign-in and generating recovery ' +
|
||||
'codes from the app go to the same place. So does deleting the account, which sends ' +
|
||||
'your username, your password and, if the account has one, a two-factor code.',
|
||||
retention: {
|
||||
summary: 'On the device until you sign out',
|
||||
detail: 'Signing out clears them; uninstalling the app removes them with it.',
|
||||
summary: 'On the device until you sign out or delete the account',
|
||||
detail:
|
||||
'Signing out clears them, and so does deleting the account; uninstalling the app ' +
|
||||
'removes them with it.',
|
||||
},
|
||||
source: 'core/auth/EncryptedTokenStore.kt',
|
||||
source:
|
||||
'data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt',
|
||||
play: {
|
||||
category: 'Personal info',
|
||||
type: 'User IDs',
|
||||
collected: false,
|
||||
types: [{ category: 'Personal info', type: 'User IDs' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
required: true,
|
||||
purposes: [
|
||||
'App functionality',
|
||||
'Account management',
|
||||
'Fraud prevention, security, and compliance',
|
||||
],
|
||||
answer: 'Collected, not shared, required.',
|
||||
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.',
|
||||
'The username, password and tokens leave the phone for the deployment the user ' +
|
||||
'chose, and Play counts that as collection whoever runs the server. None of it ' +
|
||||
'reaches us. Security is a purpose because the deployment checks two-factor codes ' +
|
||||
'and trusted devices with it.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-trust-token',
|
||||
scope: 'app',
|
||||
title: 'The trusted-device token, if you asked for one',
|
||||
title: 'The trusted-device token and the device’s name, 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.',
|
||||
'your phone is the only usable one. The phone’s make and model, such as “Samsung ' +
|
||||
'SM-S918B”, goes with the request as the device’s label, so you can tell it apart ' +
|
||||
'in the deployment’s list of trusted devices. It is a model name, not a hardware ' +
|
||||
'identifier.',
|
||||
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.',
|
||||
'screen, which is also where it can be revoked if the phone is lost. Deleting the ' +
|
||||
'account from the app drops it from the phone too, since there is no next sign-in ' +
|
||||
'for it to skip.',
|
||||
},
|
||||
source: 'core/auth/EncryptedTrustTokenStore.kt',
|
||||
source: 'core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt',
|
||||
play: {
|
||||
category: 'Personal info',
|
||||
type: 'User IDs',
|
||||
collected: false,
|
||||
types: [{ category: 'Device or other IDs', type: 'Device or other IDs' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
required: true,
|
||||
purposes: ['Account management', 'Fraud prevention, security, and compliance'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'Same as the session tokens: minted by the user’s deployment, stored on the ' +
|
||||
'device, presented back to that same deployment.',
|
||||
'The token identifies this device to the deployment, and the label is sent with ' +
|
||||
'it. Both are presented to the deployment that minted the token and to nothing ' +
|
||||
'else.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-user-agent',
|
||||
scope: 'app',
|
||||
title: 'The app’s version and your Android version',
|
||||
body:
|
||||
'Every request carries a User-Agent such as “RunicGatewayApp/0.6.0 (Android 15)”. ' +
|
||||
'It is there because a deployment turns away traffic that looks like a scanner, ' +
|
||||
'and it names nothing about you or the phone beyond those two version numbers.',
|
||||
retention: {
|
||||
summary: 'Under the deployment operator’s policy',
|
||||
detail:
|
||||
'A deployment’s access log may keep it, as any web server keeps the user agent of ' +
|
||||
'a browser.',
|
||||
},
|
||||
source: 'di/NetworkModule.kt, core/net/UserAgentInterceptor.kt',
|
||||
play: {
|
||||
types: [{ category: 'App info and performance', type: 'Diagnostics' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality', 'Fraud prevention, security, and compliance'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'It is technical information about the app and the device, and it is sent with ' +
|
||||
'every request. The deployment uses it to tell the app from a scanner.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -234,11 +365,10 @@ export const collected = [
|
||||
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',
|
||||
types: [],
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us. Stored on the device only.',
|
||||
answer: 'Not collected. Stored on the device only.',
|
||||
because:
|
||||
'It never leaves the phone. It is the destination of requests, not the contents ' +
|
||||
'of one.',
|
||||
@@ -257,52 +387,210 @@ export const collected = [
|
||||
'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',
|
||||
summary: 'Until you turn push off, sign out, delete the account, 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; ' +
|
||||
'discards the topic; deleting the account drops the registration on the deployment ' +
|
||||
'with everything else. 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',
|
||||
source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt, data/api/NotificationsApi.kt',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other in-app messages',
|
||||
collected: false,
|
||||
types: [{ category: 'Device or other IDs', type: 'Device or other IDs' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality'],
|
||||
answer:
|
||||
'Not collected by us. Declare the relay hop in the console’s free-text ' +
|
||||
'security section if it asks.',
|
||||
'Collected, not shared, required. Mention the relay in the free-text security ' +
|
||||
'section if the console 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.',
|
||||
'The topic URL is an address for this device, and the app sends it to the ' +
|
||||
'deployment. The relay is the one the deployment nominates to deliver its own ' +
|
||||
'notifications, so it carries them on the deployment’s behalf rather than ' +
|
||||
'receiving a share. It only ever sees a topic name and a tickle; the app pulls the ' +
|
||||
'content itself, authenticated. No hop reaches a server we operate.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-game-accounts',
|
||||
scope: 'app',
|
||||
title: 'Linking a game account',
|
||||
body:
|
||||
'Linking your site account to a game sends the one-time code the game gave you. ' +
|
||||
'Where a game server offers it, the app can also create a new game account, which sends ' +
|
||||
'the account name and password you chose. Unlinking sends the id being unlinked, ' +
|
||||
'such as a Steam id. All of it goes to the deployment, which keeps the link against ' +
|
||||
'your site account.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment until you unlink, under its operator’s policy',
|
||||
},
|
||||
source: 'data/api/PlayerShardApi.kt, data/api/PlayerRustApi.kt, data/api/MeApi.kt',
|
||||
play: {
|
||||
types: [{ category: 'Personal info', type: 'User IDs' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality', 'Account management'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'Game account names, Steam ids and link codes identify a player, and the app ' +
|
||||
'sends them to the deployment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-contact',
|
||||
scope: 'app',
|
||||
title: 'Your name, email address and message, if you use Contact',
|
||||
body:
|
||||
'The Contact screen sends the name, email address and message you type to the ' +
|
||||
'deployment, which passes them on to its operator by email so they can reply. ' +
|
||||
'Nothing is sent until you press send.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment’s operator, under their policy',
|
||||
detail: 'The message is delivered to the operator’s mailbox, and kept as long as they keep it.',
|
||||
},
|
||||
source: 'data/api/PublicApi.kt, data/api/dto/ContactDto.kt',
|
||||
play: {
|
||||
types: [
|
||||
{ category: 'Personal info', type: 'Name' },
|
||||
{ category: 'Personal info', type: 'Email address' },
|
||||
{ category: 'Messages', type: 'Other in-app messages' },
|
||||
],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'The form’s three fields leave the phone for the deployment, whose operator ' +
|
||||
'receives them.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-content',
|
||||
scope: 'app',
|
||||
title: 'Everything you read and post in the app',
|
||||
title: 'What you post and send from the app',
|
||||
body:
|
||||
'Forum posts, Team activity, what the game module shows you, notification ' +
|
||||
'preferences: all of it is a live read or write against the deployment. Apart from ' +
|
||||
'the notification snapshot described in the next entry, nothing is cached for ' +
|
||||
'offline use and nothing is duplicated anywhere else — the app with no signal is ' +
|
||||
'an app with almost no content, which is a limitation and also an accurate ' +
|
||||
'description of where the data lives.',
|
||||
'Most of the app is reading. What it sends is what you write, and most of that is ' +
|
||||
'for staff: news posts, wiki categories, a broadcast to the game, the answer to a ' +
|
||||
'player’s help page, and the account and reason on a kick or a ban. All of it is a ' +
|
||||
'live write to the deployment. Apart from the notification snapshot described ' +
|
||||
'below, nothing is cached for offline use and nothing is duplicated anywhere else ' +
|
||||
'— the app with no signal is an app with almost 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',
|
||||
source: 'data/api/AdminApi.kt',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other user-generated content',
|
||||
collected: false,
|
||||
types: [
|
||||
{ category: 'Messages', type: 'Other in-app messages' },
|
||||
{ category: 'App activity', type: 'Other user-generated content' },
|
||||
],
|
||||
collected: true,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
required: true,
|
||||
purposes: ['App functionality'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'Content is written to the community’s own installation. We have no copy, no ' +
|
||||
'access and no way to obtain one.',
|
||||
'Posts, broadcasts and replies are written to the community’s own installation. ' +
|
||||
'We have no copy, no access and no way to obtain one.',
|
||||
},
|
||||
},
|
||||
/*
|
||||
* Report, block and hide (USER_SAFETY.md §4.3), on Android-app#64 rather than the edge
|
||||
* commit the section header names. Read out of `data/api/SafetyApi.kt` and
|
||||
* `data/api/dto/SafetyDto.kt`: a report sends the item's type and id, a reason and an
|
||||
* optional note; a block and a hide send the item's type and id; unblocking sends the
|
||||
* block's id. No request names the person being blocked — the deployment works out who
|
||||
* wrote the line — so none of it is a User ID.
|
||||
*/
|
||||
{
|
||||
id: 'app-safety',
|
||||
scope: 'app',
|
||||
title: 'What you report, block or hide',
|
||||
body:
|
||||
'Long-pressing a chat line offers Report, Hide for me and Block player. A report ' +
|
||||
'sends which line it is, the reason you picked and, if you wrote one, a note of up to ' +
|
||||
'500 characters, so the deployment’s staff can review it. Hide for me and Block ' +
|
||||
'player send which line it is; the deployment works out who wrote it and keeps the ' +
|
||||
'hide or the block on your account, so it applies on the website and on every device. ' +
|
||||
'Your list of blocks, and unblocking someone, come from and go to the same place. For ' +
|
||||
'staff, Hide for everyone and Show to everyone again send the line too. Nothing is ' +
|
||||
'sent until you choose one.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment, under its operator’s policy',
|
||||
detail:
|
||||
'A block or a hide lasts until you remove it or delete the account, which deletes ' +
|
||||
'them. A report stays as a moderation record, with a copy of what the line said, ' +
|
||||
'until the operator removes it.',
|
||||
},
|
||||
source: 'data/api/SafetyApi.kt, data/api/dto/SafetyDto.kt, data/api/RustApi.kt',
|
||||
play: {
|
||||
types: [
|
||||
{ category: 'App activity', type: 'Other user-generated content' },
|
||||
{ category: 'App activity', type: 'Other actions' },
|
||||
],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality', 'Fraud prevention, security, and compliance', 'Personalization'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'The report’s note is text the user writes, and reporting, blocking and hiding are ' +
|
||||
'actions the app sends to the deployment, which keeps them. Reports exist so staff ' +
|
||||
'can act on abuse; blocks and hides change what this one reader is shown.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-interactions',
|
||||
scope: 'app',
|
||||
title: 'Which notifications you have read, and which you want',
|
||||
body:
|
||||
'Opening a notification marks it read on the deployment, so the badge agrees on ' +
|
||||
'every device you use. Your choice of notification streams, and of which arrive by ' +
|
||||
'push or email, is saved there too.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment, under its operator’s policy',
|
||||
},
|
||||
source: 'data/api/NotificationsApi.kt',
|
||||
play: {
|
||||
types: [{ category: 'App activity', type: 'App interactions' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality', 'Personalization'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because:
|
||||
'Read state and preferences are actions in the app that are sent to, and kept ' +
|
||||
'by, the deployment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-search',
|
||||
scope: 'app',
|
||||
title: 'What you search for',
|
||||
body:
|
||||
'Searching the wiki, a game’s market or its creature atlas sends what you typed ' +
|
||||
'to the deployment, because the deployment does the searching. The app keeps no ' +
|
||||
'history of searches.',
|
||||
retention: {
|
||||
summary: 'Under the deployment operator’s policy',
|
||||
detail:
|
||||
'The query is part of the request’s address, so a deployment’s access log may ' +
|
||||
'record it. That is why it is declared rather than called ephemeral.',
|
||||
},
|
||||
source: 'data/api/PublicApi.kt',
|
||||
play: {
|
||||
types: [{ category: 'App activity', type: 'In-app search history' }],
|
||||
collected: true,
|
||||
shared: false,
|
||||
required: true,
|
||||
purposes: ['App functionality'],
|
||||
answer: 'Collected, not shared, required.',
|
||||
because: 'Search text leaves the phone in the request to the deployment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -318,9 +606,9 @@ export const collected = [
|
||||
'second person signing in on the same phone is never shown the first one’s ' +
|
||||
'messages.',
|
||||
retention: {
|
||||
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
|
||||
summary: 'Until you sign out or delete the account, or the thirty are pushed out by newer ones',
|
||||
detail:
|
||||
'Signing out deletes the snapshot outright. It lives in the app’s ordinary ' +
|
||||
'Signing out, or deleting the account, deletes the snapshot outright. It lives in the app’s ordinary ' +
|
||||
'preference store rather than the encrypted one — sign-in tokens are the thing ' +
|
||||
'that store is for — which is worth stating plainly: on a device where someone ' +
|
||||
'has root, these are readable, and they are notification bodies rather than ' +
|
||||
@@ -328,15 +616,13 @@ export const collected = [
|
||||
},
|
||||
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other in-app messages',
|
||||
types: [],
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us. Stored on the device only.',
|
||||
answer: 'Not collected. Stored on the device only.',
|
||||
because:
|
||||
'The snapshot is written on the phone from data the deployment had already ' +
|
||||
'delivered. It is not uploaded anywhere, and no server we operate is on either ' +
|
||||
'end of it.',
|
||||
'delivered. It is not uploaded anywhere.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -351,14 +637,13 @@ export const collected = [
|
||||
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',
|
||||
types: [{ category: 'App info and performance', type: 'Crash logs' }],
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected.',
|
||||
answer: 'Not collected. The advertising ID declaration is "no".',
|
||||
because:
|
||||
'No advertising ID, no analytics identifier, and no library that would generate ' +
|
||||
'one is linked into the build.',
|
||||
'No crash reporter, no advertising ID, no analytics identifier, and no library ' +
|
||||
'that would generate one is linked into the build.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -383,6 +668,84 @@ export const collected = [
|
||||
retention: { summary: 'Set by the operator; nothing expires on its own' },
|
||||
source: 'website server/db/schema.sql — users, user_identities',
|
||||
},
|
||||
/*
|
||||
* The facts `/delete-account/` is built from (ACCOUNT_DELETION.md §4.5), which is the page
|
||||
* Google Play is given. A row here, rather than a list on that page, so `/privacy` and the
|
||||
* deletion page cannot disagree about what a deletion takes.
|
||||
*
|
||||
* `goes` and the first four `stays` are the website's own words, from
|
||||
* `client/src/lib/accountDeletion.js` (WHAT_GOES, WHAT_STAYS), which the app's strings
|
||||
* repeat: a member who reads this page and then the confirm step must recognise the list.
|
||||
* Play also asks how long what stays is kept, which the screens do not say, so each
|
||||
* `stays` entry carries a `keep`. The last entry, the delivery log, is on BACKEND_DESIGN.md
|
||||
* §6.6's list but not on the screens; it is here because Play asks about everything kept.
|
||||
*/
|
||||
{
|
||||
id: 'deploy-account-deletion',
|
||||
scope: 'deployment',
|
||||
title: 'Deleting an account',
|
||||
body:
|
||||
'You can delete your own account, on the site from the Account page, or in the app ' +
|
||||
'from My account. It takes your password, and a two-factor code if the account has ' +
|
||||
'one, and the account goes at once: there is no waiting period and nothing to ' +
|
||||
'cancel. Your sign-in, email address, devices, linked sign-ins, game-account links ' +
|
||||
'and inbox go with it. What you wrote where others read it stays, and other members ' +
|
||||
'see it as written by “[deleted player]”; staff still see who it was, for ' +
|
||||
'moderation. An operator can delete any account from the admin panel in the same way.',
|
||||
retention: {
|
||||
summary: 'Deleted at once; what stays, and for how long, is on the account-deletion page',
|
||||
detail:
|
||||
'Where the site can send email and the account had an address, one last message ' +
|
||||
'goes to it, saying the account was deleted.',
|
||||
},
|
||||
source:
|
||||
'website server/src/auth/accountDeletion.js, client/src/lib/accountDeletion.js; ' +
|
||||
'docs/website/ACCOUNT_DELETION.md',
|
||||
deletion: {
|
||||
goes: [
|
||||
'your sign-in, password, email address, two-factor settings and recovery codes',
|
||||
'every signed-in device, trusted device and push registration',
|
||||
'your linked sign-ins, such as Google, Discord or Steam',
|
||||
'your game-account links, and anything this site granted in the game through them',
|
||||
'your notification settings and inbox',
|
||||
],
|
||||
stays: [
|
||||
{
|
||||
what:
|
||||
'What you wrote where others read it, such as forum posts, stays. Other members ' +
|
||||
'see it as written by “[deleted player]”.',
|
||||
keep: 'Until a moderator or the operator removes it, as with any post.',
|
||||
},
|
||||
{
|
||||
what:
|
||||
'Reports you filed and appeals you made stay as moderation records. Staff can ' +
|
||||
'still see who it was, and the activity log keeps its IP addresses.',
|
||||
keep: 'Until the operator removes them.',
|
||||
},
|
||||
{
|
||||
what:
|
||||
'Event results stay with the score and the in-game name, with no site account ' +
|
||||
'behind them.',
|
||||
keep: 'Until the operator removes them.',
|
||||
},
|
||||
{
|
||||
what: 'A game account stays on the game server. Only its link to this site goes.',
|
||||
keep: 'For as long as the game server keeps it, which is up to whoever runs that server.',
|
||||
},
|
||||
{
|
||||
what:
|
||||
'The record of notifications sent to you keeps a one-way hash of your email ' +
|
||||
'address, never the address, and no longer names your account. An address that ' +
|
||||
'bounced or was reported as spam stays on the suppression list, so it is never ' +
|
||||
'mailed again.',
|
||||
keep:
|
||||
'The record is swept after a period the operator sets, 180 days by default. The ' +
|
||||
'suppression list does not expire.',
|
||||
},
|
||||
],
|
||||
staff: 'If you were staff, your username can never be used again, by anyone.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deploy-sessions',
|
||||
scope: 'deployment',
|
||||
@@ -428,7 +791,10 @@ export const collected = [
|
||||
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 ' +
|
||||
'reports and appeals — with the account that made each one. A report keeps a copy ' +
|
||||
'of what the reported post or chat line said, so staff read what was reported even ' +
|
||||
'after it is edited or pruned. Each member’s blocks and the items they hid for ' +
|
||||
'themselves are kept against their account, and deleted with it. Where the operator has ' +
|
||||
'connected Discord, some of that crosses into Discord and is then also subject to ' +
|
||||
'Discord’s own terms.',
|
||||
retention: {
|
||||
@@ -437,7 +803,9 @@ export const collected = [
|
||||
'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',
|
||||
source:
|
||||
'website server/db/schema.sql — team_forum_*, mod_actions, content_reports, ' +
|
||||
'user_blocks, user_hidden_items',
|
||||
},
|
||||
{
|
||||
id: 'deploy-engagement',
|
||||
@@ -533,6 +901,24 @@ export function collectedIn(scope) {
|
||||
return collected.filter((entry) => entry.scope === scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row `/delete-account/` renders. It throws rather than returning nothing, because
|
||||
* that page is the address Google Play is given, and with no row it would render as a
|
||||
* deletion page that names nothing deleted.
|
||||
*
|
||||
* @returns {Collected & { deletion: Deletion }}
|
||||
*/
|
||||
export function deletionRow() {
|
||||
const rows = collected.filter((entry) => entry.deletion);
|
||||
if (rows.length !== 1) {
|
||||
throw new Error(
|
||||
`src/data/collection.mjs: expected exactly one row with \`deletion\`, found ${rows.length}.\n` +
|
||||
'\n/delete-account/ renders that row (ACCOUNT_DELETION.md §4.5).\n'
|
||||
);
|
||||
}
|
||||
return /** @type {Collected & { deletion: Deletion }} */ (rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `app` rows that carry a Play mapping — the Data Safety generator's input.
|
||||
*
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* The staff items carry no `caps`: a capability is what a client feature-detects on, and
|
||||
* the admin screens are not something any client detects.
|
||||
*
|
||||
* No `demoPath` anywhere: the demo instance planned in §15 runs Ultima Online.
|
||||
* `demoPath` is a public route in Module-Rust's own `client/src/entry.jsx` (`/rust`, and
|
||||
* `/rust/servers/:id` with its `?tab=` search parameter, which `ServerDetail.jsx` reads).
|
||||
* The public demo runs Rust (§15), and `demo` is its server's id, fixed by
|
||||
* `website/deploy/demo/seed.json` (`modules.rust.servers`) and recreated at every wipe.
|
||||
* The links show only while the mounted `demoGames` lists `rust` (D83). The staff items
|
||||
* carry none: every admin page needs a sign-in, and D25's links are to public routes.
|
||||
*/
|
||||
|
||||
/** @typedef {import('../capabilities.mjs').Capability} Capability */
|
||||
@@ -26,6 +31,7 @@ export const features = [
|
||||
{
|
||||
label: 'Every server, live',
|
||||
caps: ['rust', 'servers'],
|
||||
demoPath: '/rust',
|
||||
detail:
|
||||
'Every Rust server the community runs, as it last reported itself: up or down, who is ' +
|
||||
'on, which map, and when the next wipe is. One sidecar serves one server, so a ' +
|
||||
@@ -35,6 +41,7 @@ export const features = [
|
||||
{
|
||||
label: 'The kill feed',
|
||||
caps: ['killfeed'],
|
||||
demoPath: '/rust/servers/demo',
|
||||
detail:
|
||||
'Kills, chat, comings and goings, RunicNPC bosses, and the world itself — cargo, ' +
|
||||
'helicopters, airdrops — as they happen. Every kind passes a default-deny allowlist ' +
|
||||
@@ -44,6 +51,7 @@ export const features = [
|
||||
{
|
||||
label: 'Leaderboards, per wipe',
|
||||
caps: ['leaderboard'],
|
||||
demoPath: '/rust/servers/demo?tab=leaderboard',
|
||||
detail:
|
||||
'Kills, deaths, NPC kills, structures and time played, for one wipe or all-time — with ' +
|
||||
'the chat titles players have earned. The record survives the wipe: the game forgets, ' +
|
||||
@@ -52,6 +60,7 @@ export const features = [
|
||||
{
|
||||
label: 'Who is online',
|
||||
caps: ['presence'],
|
||||
demoPath: '/rust/servers/demo?tab=online',
|
||||
detail:
|
||||
'Who is on right now, and who is asleep. Nothing names a player to the public by ' +
|
||||
'default: how far a name reaches — staff, signed-in players, everyone — is the ' +
|
||||
@@ -60,6 +69,7 @@ export const features = [
|
||||
{
|
||||
label: 'Wipes',
|
||||
caps: ['wipes'],
|
||||
demoPath: '/rust/servers/demo?tab=wipes',
|
||||
detail:
|
||||
'Every wipe the site has seen, and the next one once the server has a wipe schedule — ' +
|
||||
'the monthly forced wipe or a rule of your own.',
|
||||
@@ -67,6 +77,7 @@ export const features = [
|
||||
{
|
||||
label: 'The live map',
|
||||
caps: ['map'],
|
||||
demoPath: '/rust/servers/demo?tab=map',
|
||||
detail:
|
||||
'The server’s own map picture with layers on it: monuments and world events, the ' +
|
||||
'zones and NPCs your events placed, players, bases as positions only, and a player’s ' +
|
||||
@@ -82,6 +93,7 @@ export const features = [
|
||||
},
|
||||
{
|
||||
label: 'Clans are Teams',
|
||||
demoPath: '/rust/servers/demo?tab=clans',
|
||||
detail:
|
||||
'Each clan on a server is a Team on the site: a page, a feed for its members and ' +
|
||||
'core’s Team notifications. Who may see a clan’s roster is set alongside who may see ' +
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* ids and are resolved against the shard's own string table). Coverage, not a bijection.
|
||||
*
|
||||
* `demoPath` is a stable public route on the module's own pages, deep-linked into a demo
|
||||
* when one is mounted (D25). Paths are read from `module-uo`'s `client/src/entry.jsx`.
|
||||
* when one is mounted (D25) and the mounted `demoGames` lists `uo` (D83) — which the public
|
||||
* demo, a Rust deployment, does not. Paths are read from `module-uo`'s `client/src/entry.jsx`.
|
||||
*/
|
||||
|
||||
/** @typedef {import('../capabilities.mjs').Capability} Capability */
|
||||
|
||||
@@ -29,7 +29,7 @@ export const legal = {
|
||||
* 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-09-01',
|
||||
lastUpdated: '2026-10-30',
|
||||
|
||||
/**
|
||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||
|
||||
@@ -117,23 +117,9 @@ export const notBuilt = [
|
||||
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.',
|
||||
},
|
||||
// `public-demo` stood here until 2026-10-28. Its `resolvedBy` was met when
|
||||
// demo.runicgateway.com went up (docs/website/DEMO_MODE.md), and the site reaches it through
|
||||
// the mounted `demoUrl`, as the entry promised (D82, D83).
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
THE ANDROID CLIENT (phase 5)
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
"wrong protocol number in the first place."
|
||||
],
|
||||
|
||||
"verifiedOn": "2026-10-23",
|
||||
"verifiedOn": "2026-10-30",
|
||||
|
||||
"$comment_moduleApi": [
|
||||
"The one version every deployment shares, whichever game it runs. D66: it is the only",
|
||||
"version the homepage hero and the footer show — a protocol and a bundle belong to a game,",
|
||||
"so they appear on that game's own pages."
|
||||
],
|
||||
"moduleApi": "1.17.0",
|
||||
"moduleApi": "1.21.0",
|
||||
|
||||
"$comment_releases": [
|
||||
"Every repository whose release the site quotes, flat, so checkFacts.mjs reads them all",
|
||||
@@ -27,12 +27,12 @@
|
||||
],
|
||||
"releases": {
|
||||
"installer": "v0.8.1",
|
||||
"Android-app": "v0.6.0",
|
||||
"Android-app": "v0.7.0",
|
||||
"link": "v2.3.0",
|
||||
"Module-uo": "v1.3.0",
|
||||
"Module-uo": "v1.4.1",
|
||||
"Rust-Link": "v0.3.0",
|
||||
"Rust-Plugins": "v0.3.0",
|
||||
"Module-Rust": "v0.5.1",
|
||||
"Rust-Plugins": "v0.3.1",
|
||||
"Module-Rust": "v0.7.0",
|
||||
"runicnpc-rust": "v1.0.0"
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"protocol": 8,
|
||||
"module": {
|
||||
"repo": "Module-uo",
|
||||
"coreApi": "^1.10.0",
|
||||
"coreApi": "^1.20.0",
|
||||
"capabilities": ["shard", "atlas", "market", "governors", "guilds", "houses", "champs", "cliloc"]
|
||||
},
|
||||
"sidecarRepo": "link",
|
||||
@@ -79,15 +79,15 @@
|
||||
"protocol": 14,
|
||||
"module": {
|
||||
"repo": "Module-Rust",
|
||||
"coreApi": "^1.17.0",
|
||||
"coreApi": "^1.21.0",
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
|
||||
},
|
||||
"sidecarRepo": "Rust-Link",
|
||||
"pluginRepo": "Rust-Plugins",
|
||||
"bundle": {
|
||||
"tag": "2026.10.22",
|
||||
"tag": "2026.10.30",
|
||||
"sidecar": "v0.3.0",
|
||||
"plugin": "v0.3.0",
|
||||
"plugin": "v0.3.1",
|
||||
"oxideMin": "2.0.7585",
|
||||
"carbonMin": "2.0.259",
|
||||
"requiresPlugins": ["Kits", "ZoneManager"],
|
||||
@@ -120,17 +120,18 @@
|
||||
"",
|
||||
"`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."
|
||||
"actually work. v0.5.0's did not, and while false the download block renders a 'being",
|
||||
"replaced' state instead of a link. A check cannot run an APK; a person can, and this",
|
||||
"is where they record that they did. v0.7.0's was installed on an Android 16 emulator",
|
||||
"on 2026-10-31, connected to the public demo and read its Rust servers: true.",
|
||||
"When `asset` moves to a new release, set this back to false until someone has run it."
|
||||
],
|
||||
"androidApk": {
|
||||
"asset": "runic-gateway-0.6.0.apk",
|
||||
"asset": "runic-gateway-0.7.0.apk",
|
||||
"checksums": "SHA256SUMS",
|
||||
"minSdk": 29,
|
||||
"minAndroid": "10",
|
||||
"serviceable": false
|
||||
"serviceable": true
|
||||
},
|
||||
|
||||
"gitea": {
|
||||
|
||||
124
src/lib/demoLogins.mjs
Normal file
124
src/lib/demoLogins.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* demoLogins.mjs — the public demo's sign-ins, read from the demo itself (D82).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE PASSWORDS ARE NOT IN THIS REPOSITORY
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The demo's accounts are recreated at every wipe from passwords generated on the demo's own
|
||||
* host, so the only copy that is right by definition is the one the demo serves. It already
|
||||
* publishes them: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
|
||||
* `demo: { resetsAt, logins: [{ username, password, role }] }`, which is what its own
|
||||
* sign-in page lists (`docs/website/DEMO_MODE.md` §4 and §6). `/beta` reads the same answer.
|
||||
* A copy pasted into a mounted file would be one regenerated `.env` away from a page
|
||||
* publishing passwords that no longer work.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS DOES NOT CHANGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The fetch is made by this server, never by the browser, so D9 and §6's CSP stand: the
|
||||
* visitor's page loads nothing from another origin. Only `/beta` calls it, and `/beta`
|
||||
* already renders per request.
|
||||
*
|
||||
* It never throws and never holds a page up for long. The demo is down for a few minutes at
|
||||
* every wipe, and a sign-up page must not fail with it: a failed or slow read answers `null`,
|
||||
* the page points at the demo's own sign-in page instead, and the failure is cached briefly
|
||||
* so a down demo is not asked again on every request.
|
||||
*
|
||||
* Everything read is checked before it is used. The demo is ours, but it is a public admin
|
||||
* playground, and a string from it goes into this page's markup. Astro escapes it, and the
|
||||
* length and shape limits below keep a strange answer from becoming a strange page.
|
||||
*/
|
||||
|
||||
const OK_TTL_MS = 60_000;
|
||||
const FAIL_TTL_MS = 15_000;
|
||||
const TIMEOUT_MS = 1_500;
|
||||
|
||||
const MAX_LOGINS = 8;
|
||||
const MAX_FIELD = 64;
|
||||
|
||||
let cache = { key: '', until: 0, value: null };
|
||||
let pending = null;
|
||||
|
||||
const isShortString = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_FIELD;
|
||||
|
||||
/**
|
||||
* The demo's answer reduced to what `/beta` shows, or `null` when it is not the shape
|
||||
* `DEMO_MODE.md` §6 describes. Exported for the tests.
|
||||
*
|
||||
* @param {unknown} body The parsed JSON of `GET /api/v1/public/settings`.
|
||||
* @returns {{ logins: { username: string, password: string, role: string }[], resetsAt: string | null } | null}
|
||||
*/
|
||||
export function readDemo(body) {
|
||||
const demo = body && typeof body === 'object' ? body.demo : null;
|
||||
if (!demo || typeof demo !== 'object' || !Array.isArray(demo.logins)) return null;
|
||||
|
||||
const logins = demo.logins
|
||||
.filter((l) => l && isShortString(l.username) && isShortString(l.password) && isShortString(l.role))
|
||||
.slice(0, MAX_LOGINS)
|
||||
.map(({ username, password, role }) => ({ username, password, role }));
|
||||
if (!logins.length) return null;
|
||||
|
||||
const at = typeof demo.resetsAt === 'string' ? new Date(demo.resetsAt) : null;
|
||||
const resetsAt = at && !Number.isNaN(at.getTime()) ? at.toISOString() : null;
|
||||
|
||||
return { logins, resetsAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* "22:00 UTC" — in UTC and not converted, matching the demo's own banner (§6).
|
||||
* @param {string | null} iso
|
||||
*/
|
||||
export function resetTime(iso) {
|
||||
return iso ? `${iso.slice(11, 16)} UTC` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-ins the demo at `demoUrl` publishes now, cached for a minute.
|
||||
*
|
||||
* @param {string} demoUrl The mounted `brand.json` `demoUrl`.
|
||||
* @param {{ fetchImpl?: typeof fetch, now?: () => number }} [options] For the tests.
|
||||
* @returns {Promise<ReturnType<typeof readDemo>>}
|
||||
*/
|
||||
export async function demoLogins(demoUrl, { fetchImpl = fetch, now = Date.now } = {}) {
|
||||
let base;
|
||||
try {
|
||||
base = new URL(demoUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (base.protocol !== 'https:' && base.protocol !== 'http:') return null;
|
||||
|
||||
const key = base.origin + base.pathname.replace(/\/+$/, '');
|
||||
if (cache.key === key && now() < cache.until) return cache.value;
|
||||
if (pending?.key === key) return pending.promise;
|
||||
|
||||
const promise = (async () => {
|
||||
let value = null;
|
||||
try {
|
||||
const response = await fetchImpl(`${key}/api/v1/public/settings`, {
|
||||
headers: { accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (response.ok) value = readDemo(await response.json());
|
||||
if (!value) console.warn(`[beta] the demo at ${key} published no sign-ins (HTTP ${response.status}).`);
|
||||
} catch (error) {
|
||||
console.warn(`[beta] could not read the demo's sign-ins from ${key}: ${error.message}`);
|
||||
}
|
||||
cache = { key, until: now() + (value ? OK_TTL_MS : FAIL_TTL_MS), value };
|
||||
return value;
|
||||
})();
|
||||
|
||||
pending = { key, promise };
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (pending?.promise === promise) pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the cache. For the tests. */
|
||||
export function resetDemoLoginsCache() {
|
||||
cache = { key: '', until: 0, value: null };
|
||||
pending = null;
|
||||
}
|
||||
@@ -196,6 +196,12 @@ const downloadBase = `${releasePage}/download/${release}`;
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D85: the deletion page Play is given, beside the two ways in. -->
|
||||
<p class="app-delete">
|
||||
Done with it? <a href="/delete-account/">How to delete the account you use in the
|
||||
app</a>, and what deleting it keeps.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<NotBuilt scope="app" title="What the app does not do" />
|
||||
@@ -335,6 +341,12 @@ const downloadBase = `${releasePage}/download/${release}`;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.app-delete {
|
||||
margin: 1.25rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.app-get__foot {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
|
||||
@@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
import NotBuilt from '../components/NotBuilt.astro';
|
||||
|
||||
import { liveBrand } from '../lib/brand.mjs';
|
||||
import { demoLogins, resetTime } from '../lib/demoLogins.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';
|
||||
@@ -35,9 +36,9 @@ import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/
|
||||
* 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.
|
||||
* plainly that it is a list rather than a queue that is moving. Gate two closed when the
|
||||
* public demo went up (2026-10-27): with `demoUrl` mounted it says so, and the page lists
|
||||
* the demo's sign-ins as the demo itself publishes them (D82).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT
|
||||
@@ -53,6 +54,22 @@ export const prerender = false;
|
||||
|
||||
const brand = liveBrand();
|
||||
|
||||
/**
|
||||
* The public demo, which closes gate two (D27): the deployment a tester points the app at.
|
||||
* Its sign-ins are read from the demo on this request (D82, `demoLogins.mjs`), so they are
|
||||
* whatever the demo's own sign-in page lists. `null` with a `demoUrl` set means the demo did
|
||||
* not answer, and the page sends people to that sign-in page instead.
|
||||
*/
|
||||
let demoHost = '';
|
||||
try {
|
||||
demoHost = brand.demoUrl ? new URL(brand.demoUrl).host : '';
|
||||
} catch {
|
||||
console.warn(`[beta] the mounted demoUrl is not a URL: ${JSON.stringify(brand.demoUrl)}`);
|
||||
}
|
||||
const demoBase = demoHost ? brand.demoUrl.replace(/\/+$/, '') : '';
|
||||
const demo = demoBase ? await demoLogins(demoBase) : null;
|
||||
const demoReset = resetTime(demo?.resetsAt ?? null);
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -193,7 +210,7 @@ const formToken = issueFormToken();
|
||||
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
|
||||
It has not opened yet, and what it is waiting on is 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>
|
||||
@@ -249,8 +266,11 @@ const formToken = issueFormToken();
|
||||
<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.
|
||||
{demoBase
|
||||
? 'Two things, neither of which is a date, and the second is in place. Both are ' +
|
||||
'visible from outside, so there is no reason to be vague about them.'
|
||||
: '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">
|
||||
@@ -271,22 +291,85 @@ const formToken = issueFormToken();
|
||||
</li>
|
||||
|
||||
<li class="panel beta-gate">
|
||||
<p class="eyebrow">Gate two</p>
|
||||
<p class="eyebrow">{demoBase ? 'Gate two · in place' : '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>
|
||||
{
|
||||
demoBase ? (
|
||||
<p>
|
||||
The app is a client and ships pointed at nothing — its first screen asks for the
|
||||
address of a site running this platform. The public demo is that site: a real
|
||||
deployment with a real game server behind it, open to anyone with the sign-ins{' '}
|
||||
<a href="#demo">below</a>.
|
||||
</p>
|
||||
) : (
|
||||
<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
|
||||
without a public one to point at, 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.
|
||||
If you already run a Runic Gateway deployment, you can point the app at that
|
||||
instead — say so in Discord.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{
|
||||
demoBase && (
|
||||
<section class="page section" id="demo">
|
||||
<h2 class="beta-h2">The demo</h2>
|
||||
<p class="prose beta-lede">
|
||||
Enter <strong>{demoHost}</strong> on the app's first screen, or{' '}
|
||||
<a href={demoBase} rel="noopener noreferrer">open it in a browser</a>. Every account
|
||||
below is shared with everyone else trying it, and the site is wiped back to the same
|
||||
starting point every few hours, so nothing you change there lasts.
|
||||
</p>
|
||||
|
||||
{demo ? (
|
||||
<div class="panel beta-demo">
|
||||
<table class="beta-demo__logins">
|
||||
<caption>Sign-ins, as the demo publishes them right now</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Account</th>
|
||||
<th scope="col">Password</th>
|
||||
<th scope="col">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{demo.logins.map((login) => (
|
||||
<tr>
|
||||
<td><code>{login.username}</code></td>
|
||||
<td><code>{login.password}</code></td>
|
||||
<td>{login.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{demoReset && (
|
||||
<p class="beta-demo__foot">
|
||||
Next wipe at {demoReset}. Anything you change before then goes with it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div class="panel beta-notice beta-notice--warn">
|
||||
<h3>The demo did not answer just now.</h3>
|
||||
<p>
|
||||
It is offline for a few minutes at every wipe. Its sign-ins are listed on{' '}
|
||||
<a href={`${demoBase}/account/login`} rel="noopener noreferrer">its own sign-in page</a>
|
||||
{' '}once it is back.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<section class="page section">
|
||||
<h2 class="beta-h2">What a tester needs</h2>
|
||||
<ul class="beta-reqs">
|
||||
@@ -524,6 +607,46 @@ const formToken = issueFormToken();
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* The demo's sign-ins (D82). A plain table: three columns a person copies from, so the
|
||||
passwords are code and nothing about them is decorated. */
|
||||
.beta-demo {
|
||||
margin-top: 1.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.beta-demo__logins {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.beta-demo__logins caption {
|
||||
margin-bottom: 0.65rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.beta-demo__logins th,
|
||||
.beta-demo__logins td {
|
||||
padding: 0.55rem 0.75rem 0.55rem 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.beta-demo__logins th {
|
||||
color: var(--head);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.beta-demo__logins td {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.beta-demo__foot {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
||||
327
src/pages/delete-account.astro
Normal file
327
src/pages/delete-account.astro
Normal file
@@ -0,0 +1,327 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import { deletionRow } from '../data/collection.mjs';
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/delete-account/` — the page Google Play is given for account deletion
|
||||
* (docs/website/ACCOUNT_DELETION.md §4.5, AD2).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* IT EXPLAINS; IT CANNOT PERFORM
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Play needs one deletion address from whoever publishes the app, and that is us. But we
|
||||
* hold no account: every community runs its own site, and the account lives there. So this
|
||||
* page says where the button is on the website and in the app, what goes and what stays,
|
||||
* and who to ask when you cannot sign in, which is that community's operator and never us
|
||||
* (D86). We have no access to any community's site, so offering to help would promise
|
||||
* something we cannot do.
|
||||
*
|
||||
* What goes and what stays is not written here. It is the `deletion` row in
|
||||
* `src/data/collection.mjs`, the same row `/privacy` section 3 renders, so the two pages
|
||||
* cannot disagree about what a deletion takes.
|
||||
*/
|
||||
const title = 'Delete your account';
|
||||
const description =
|
||||
`How to delete an account you use in the ${brand.siteName} Android app or on a ` +
|
||||
`${brand.siteName} community site, what is deleted, and what is kept.`;
|
||||
|
||||
const row = deletionRow();
|
||||
const { goes, stays, staff } = row.deletion;
|
||||
|
||||
const surfaces = [
|
||||
{
|
||||
id: 'on-the-website',
|
||||
eyebrow: 'On the website',
|
||||
heading: 'Your community’s site',
|
||||
steps: [
|
||||
'Sign in to the site.',
|
||||
'Open “Account”. Staff open “Account” in the admin panel instead.',
|
||||
'Scroll to the bottom, to “Delete my account”, and press “Delete my account…”.',
|
||||
'Type your username, then your password, and a two-factor code if the account has two-factor sign-in on.',
|
||||
'Press “Delete my account”. You are signed out everywhere, and the site says the account has been deleted.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'in-the-app',
|
||||
eyebrow: 'In the app',
|
||||
heading: `The ${brand.siteName} app`,
|
||||
steps: [
|
||||
'Sign in, if you are not already.',
|
||||
'Open the menu and choose “My account”.',
|
||||
'Scroll to the bottom and tap “Delete my account…”.',
|
||||
'Type your username, then your password, and a two-factor code if the account has two-factor sign-in on.',
|
||||
'Tap “Delete my account”. The app forgets the account and returns to sign-in, saying it has been deleted.',
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Account deletion" title="Deleting your account">
|
||||
<p>
|
||||
For the {brand.siteName} Android app, and for the community sites it connects to. We
|
||||
are {brand.siteName}, the project that makes both.
|
||||
</p>
|
||||
<p>
|
||||
You can delete your account yourself, on the website or in the app. It is deleted at
|
||||
once: there is no waiting period and nothing to cancel.
|
||||
</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/#deployments">The same list is on the privacy page</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<div class="panel del-who">
|
||||
<h2>Your account is with your community, not with us</h2>
|
||||
<p>
|
||||
The app ships pointed at nothing. On first run it asks for the address of a
|
||||
{' '}{brand.siteName} site, and that site is run by whoever runs that community. Your
|
||||
account lives there, under their control. We hold no account for you and have no
|
||||
copy of one, so the deletion happens on that site, and you are the one who does it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section" id="how">
|
||||
<h2 class="del-h2">How to delete it</h2>
|
||||
<p class="prose del-lede">
|
||||
The same steps on both, and either one deletes the account everywhere: deleting it in
|
||||
the app also deletes it on the website, and the other way round.
|
||||
</p>
|
||||
|
||||
<div class="del-grid">
|
||||
{
|
||||
surfaces.map((surface) => (
|
||||
<div class="panel del-surface" id={surface.id}>
|
||||
<p class="eyebrow">{surface.eyebrow}</p>
|
||||
<h3>{surface.heading}</h3>
|
||||
<ol class="del-steps">
|
||||
{surface.steps.map((step) => (
|
||||
<li>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<ul class="del-notes">
|
||||
<li>
|
||||
<strong>A password is required.</strong> If you only ever signed in with Google,
|
||||
Discord or Steam and never set a password, set one in the Password section of the
|
||||
same page first.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The only admin cannot delete themselves.</strong> Make someone else an admin
|
||||
first, so the site is never left with nobody to run it.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="page section" id="what-goes">
|
||||
<div class="del-cols">
|
||||
<div class="panel del-list">
|
||||
<p class="eyebrow">Deleted</p>
|
||||
<h2>What goes with the account</h2>
|
||||
<ul>
|
||||
{goes.map((item) => <li>{item[0].toUpperCase() + item.slice(1)}.</li>)}
|
||||
</ul>
|
||||
<p class="del-list__foot">{row.retention.detail}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel del-list">
|
||||
<p class="eyebrow">Kept</p>
|
||||
<h2>What stays, and for how long</h2>
|
||||
<ul class="del-kept">
|
||||
{
|
||||
stays.map((entry) => (
|
||||
<li>
|
||||
{entry.what}
|
||||
<span class="del-kept__keep">
|
||||
<span class="del-kept__label">How long</span>
|
||||
{entry.keep}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<p class="del-list__foot">{staff}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section" id="cannot-sign-in">
|
||||
<div class="panel del-who">
|
||||
<h2>If you cannot sign in</h2>
|
||||
<ol class="del-steps">
|
||||
<li>
|
||||
Use “Forgot your password?” on the sign-in page, on the website or in the app. It
|
||||
works if your account has an email address and the site can send email.
|
||||
</li>
|
||||
<li>
|
||||
Otherwise, ask the community that runs the site. Its operator can delete any
|
||||
account from the admin panel, and the result is the same as deleting it yourself.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="del-who__note">
|
||||
We cannot do it for you. We hold no account, and we have no access to any
|
||||
community’s site.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* The header strip, as /privacy and /terms render it. Astro scopes component styles, so
|
||||
each legal page carries its own copy (see the note in terms.astro). */
|
||||
/* Unlike /privacy, a panel follows the strip directly, so it keeps some room below. */
|
||||
.legal-meta {
|
||||
padding-top: 0;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.del-h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.del-lede {
|
||||
margin: 0 0 1.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The two panels that frame the page: who holds the account, and who to ask. Gold edge,
|
||||
as /app's prerequisite panel, because each is a fact about the product rather than a
|
||||
warning. */
|
||||
.del-who {
|
||||
padding: clamp(1.25rem, 4vw, 2.25rem);
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.del-who h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: clamp(1.3rem, 3vw, 1.7rem);
|
||||
}
|
||||
|
||||
.del-who p,
|
||||
.del-who li {
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.del-who p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The one sentence a reader who skims must not miss, so it is brighter than the rest. */
|
||||
.del-who .del-who__note {
|
||||
margin-top: 1rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.del-grid,
|
||||
.del-cols {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
}
|
||||
|
||||
.del-surface,
|
||||
.del-list {
|
||||
padding: clamp(1.1rem, 3vw, 1.75rem);
|
||||
}
|
||||
|
||||
.del-surface h3 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.del-list h2 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: clamp(1.2rem, 2.6vw, 1.45rem);
|
||||
}
|
||||
|
||||
.del-steps {
|
||||
margin: 0;
|
||||
padding-left: 1.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.del-steps li + li {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.del-notes {
|
||||
margin: 1.25rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.del-notes li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.del-list ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.del-list li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.del-kept li + li {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.del-kept__keep {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.del-kept__label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.del-list__foot {
|
||||
margin: 1.1rem 0 0;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
@@ -133,7 +133,7 @@ const description =
|
||||
{item.demoPath && (
|
||||
<a
|
||||
class="demo-link"
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath}
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath} data-demo-game={item.demoGame}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See it running
|
||||
|
||||
@@ -92,6 +92,7 @@ const description =
|
||||
</section>
|
||||
|
||||
<GameFeatures
|
||||
game="rust"
|
||||
title="What players see"
|
||||
lede="Every public surface is the operator's to widen: nothing names a player to the public by default."
|
||||
items={features}
|
||||
@@ -107,6 +108,7 @@ const description =
|
||||
</section>
|
||||
|
||||
<GameFeatures
|
||||
game="rust"
|
||||
title="What staff run from the site"
|
||||
lede="Seven admin screens, and a Rust section on each user's page."
|
||||
items={staffFeatures}
|
||||
|
||||
@@ -87,6 +87,7 @@ const description =
|
||||
</section>
|
||||
|
||||
<GameFeatures
|
||||
game="uo"
|
||||
title="What it adds"
|
||||
lede="Everything here is published from the live shard, and every public surface passes the visibility settings first — each one is the operator's to switch on."
|
||||
items={features}
|
||||
|
||||
@@ -66,6 +66,8 @@ const sections = [
|
||||
'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.',
|
||||
/* The deletion page Play is given (D85). The app is where most people will look. */
|
||||
link: { href: '/delete-account/', text: 'How to delete the account you use in the app' },
|
||||
rows: collectedIn('app'),
|
||||
},
|
||||
{
|
||||
@@ -79,6 +81,7 @@ const sections = [
|
||||
'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.',
|
||||
link: { href: '/delete-account/', text: 'What deleting an account takes, and what it keeps' },
|
||||
rows: collectedIn('deployment'),
|
||||
},
|
||||
];
|
||||
@@ -131,6 +134,11 @@ const sections = [
|
||||
<h2>{section.heading}</h2>
|
||||
<p class="legal-sec__who">{section.controller}</p>
|
||||
<p class="prose legal-sec__lede">{section.lede}</p>
|
||||
{section.link && (
|
||||
<p class="legal-sec__link">
|
||||
<a href={section.link.href}>{section.link.text}</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul class="legal-rows">
|
||||
@@ -304,6 +312,11 @@ const sections = [
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legal-sec__link {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-rows {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
208
test/demo.test.mjs
Normal file
208
test/demo.test.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* The public demo's two hooks into this site (D82, D83).
|
||||
*
|
||||
* `demoLogins.mjs` decides what `/beta` prints from an answer it does not control, so its
|
||||
* shape checks and its failure path are tested here rather than trusted. `applyBrand.mjs`
|
||||
* decides which "See it running" links point anywhere, and the failure it guards against
|
||||
* (a Rust demo linked to `/uo/` pages) is invisible in a build that has no demo mounted, so
|
||||
* it is run here against a scratch build that does.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, beforeEach, describe, it } from 'node:test';
|
||||
|
||||
import { demoLogins, readDemo, resetDemoLoginsCache, resetTime } from '../src/lib/demoLogins.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const LOGINS = [
|
||||
{ username: 'demo-admin', password: 'pw-a', role: 'admin' },
|
||||
{ username: 'demo-player', password: 'pw-p', role: 'player' },
|
||||
];
|
||||
|
||||
const answer = (body, status = 200) => async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('readDemo', () => {
|
||||
it('keeps the logins and normalises the reset time', () => {
|
||||
const read = readDemo({ demo: { resetsAt: '2026-10-28T22:00:00.000Z', logins: LOGINS } });
|
||||
assert.deepEqual(read, { logins: LOGINS, resetsAt: '2026-10-28T22:00:00.000Z' });
|
||||
assert.equal(resetTime(read.resetsAt), '22:00 UTC');
|
||||
});
|
||||
|
||||
it('answers null for a site that is not in demo mode', () => {
|
||||
assert.equal(readDemo({ site_mode: 'live' }), null);
|
||||
assert.equal(readDemo(null), null);
|
||||
assert.equal(readDemo({ demo: { logins: [] } }), null);
|
||||
});
|
||||
|
||||
it('drops malformed rows, extra fields and an unreadable time', () => {
|
||||
const read = readDemo({
|
||||
demo: {
|
||||
resetsAt: 'soon',
|
||||
logins: [
|
||||
{ username: 'demo-admin', password: 'pw-a', role: 'admin', token: 'x' },
|
||||
{ username: 'x'.repeat(65), password: 'pw', role: 'admin' },
|
||||
{ username: 'demo-player', password: 42, role: 'player' },
|
||||
'demo-moderator',
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(read, { logins: [LOGINS[0]], resetsAt: null });
|
||||
assert.equal(resetTime(read.resetsAt), null);
|
||||
});
|
||||
|
||||
it('shows at most eight', () => {
|
||||
const many = Array.from({ length: 12 }, (_, i) => ({ username: `u${i}`, password: 'p', role: 'player' }));
|
||||
assert.equal(readDemo({ demo: { logins: many } }).logins.length, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('demoLogins', () => {
|
||||
beforeEach(() => resetDemoLoginsCache());
|
||||
|
||||
it('reads the public settings route under the demo URL, without its trailing slash', async () => {
|
||||
let asked = null;
|
||||
const fetchImpl = async (url) => {
|
||||
asked = url;
|
||||
return answer({ demo: { logins: LOGINS } })();
|
||||
};
|
||||
const read = await demoLogins('https://demo.example.test/', { fetchImpl });
|
||||
assert.equal(asked, 'https://demo.example.test/api/v1/public/settings');
|
||||
assert.deepEqual(read.logins, LOGINS);
|
||||
});
|
||||
|
||||
it('caches an answer for a minute', async () => {
|
||||
let calls = 0;
|
||||
let clock = 1_000;
|
||||
const fetchImpl = async () => {
|
||||
calls++;
|
||||
return answer({ demo: { logins: LOGINS } })();
|
||||
};
|
||||
const options = { fetchImpl, now: () => clock };
|
||||
await demoLogins('https://demo.example.test', options);
|
||||
clock += 59_000;
|
||||
await demoLogins('https://demo.example.test', options);
|
||||
assert.equal(calls, 1);
|
||||
clock += 2_000;
|
||||
await demoLogins('https://demo.example.test', options);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it('answers null when the demo is down, and asks again sooner', async () => {
|
||||
let calls = 0;
|
||||
let clock = 1_000;
|
||||
const fetchImpl = async () => {
|
||||
calls++;
|
||||
throw new Error('connect ECONNREFUSED');
|
||||
};
|
||||
const options = { fetchImpl, now: () => clock };
|
||||
assert.equal(await demoLogins('https://demo.example.test', options), null);
|
||||
clock += 10_000;
|
||||
assert.equal(await demoLogins('https://demo.example.test', options), null);
|
||||
assert.equal(calls, 1);
|
||||
clock += 6_000;
|
||||
await demoLogins('https://demo.example.test', options);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it('answers null for an error status and for a non-web URL', async () => {
|
||||
assert.equal(await demoLogins('https://demo.example.test', { fetchImpl: answer({}, 502) }), null);
|
||||
resetDemoLoginsCache();
|
||||
let called = false;
|
||||
const fetchImpl = async () => {
|
||||
called = true;
|
||||
return answer({ demo: { logins: LOGINS } })();
|
||||
};
|
||||
assert.equal(await demoLogins('file:///etc/passwd', { fetchImpl }), null);
|
||||
assert.equal(await demoLogins('not a url', { fetchImpl }), null);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
it('shares one request between renders that arrive together', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = async () => {
|
||||
calls++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return answer({ demo: { logins: LOGINS } })();
|
||||
};
|
||||
const [a, b] = await Promise.all([
|
||||
demoLogins('https://demo.example.test', { fetchImpl }),
|
||||
demoLogins('https://demo.example.test', { fetchImpl }),
|
||||
]);
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyBrand: which demo links point anywhere (D83)', () => {
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-brand-'));
|
||||
const dist = path.join(scratch, 'dist');
|
||||
const mount = path.join(scratch, 'brand');
|
||||
const page = path.join(dist, 'client', 'index.html');
|
||||
|
||||
// The four shapes a build writes: the homepage slot, a core deep link, and a link into
|
||||
// each game's pages. Same attribute order as the templates; checkBrand.mjs holds them to it.
|
||||
const STOCK =
|
||||
'<a class="btn demo-cta" href="" data-demo-url="">See it running</a>\n' +
|
||||
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/wiki" rel="noopener">w</a>\n' +
|
||||
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/rust/servers/demo?tab=map" data-demo-game="rust" rel="noopener">r</a>\n' +
|
||||
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market" data-demo-game="uo" rel="noopener">u</a>\n';
|
||||
|
||||
fs.mkdirSync(path.dirname(page), { recursive: true });
|
||||
fs.mkdirSync(mount, { recursive: true });
|
||||
fs.writeFileSync(page, STOCK);
|
||||
|
||||
const apply = (brand) => {
|
||||
fs.writeFileSync(path.join(mount, 'brand.json'), JSON.stringify(brand));
|
||||
execFileSync(process.execPath, [path.join(ROOT, 'scripts/applyBrand.mjs')], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAND_DIST: dist,
|
||||
BRAND_DIR: mount,
|
||||
BRAND_DEFAULT_DIR: path.join(ROOT, 'brand-default'),
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return fs.readFileSync(page, 'utf8');
|
||||
};
|
||||
|
||||
const hrefOf = (html, marker) => html.split('\n').find((line) => line.includes(marker)).match(/href="([^"]*)"/)[1];
|
||||
|
||||
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
it('fills the slot, core links and the listed game only', () => {
|
||||
const html = apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] });
|
||||
assert.equal(hrefOf(html, 'demo-cta'), 'https://demo.example.test');
|
||||
assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki');
|
||||
assert.equal(hrefOf(html, 'tab=map'), 'https://demo.example.test/rust/servers/demo?tab=map');
|
||||
assert.equal(hrefOf(html, '/uo/market'), '');
|
||||
assert.match(html, /data-demo-path="\/uo\/market" data-demo-game="uo"/);
|
||||
});
|
||||
|
||||
it('hides a game link again when only demoGames changes', () => {
|
||||
const html = apply({ demoUrl: 'https://demo.example.test', demoGames: [] });
|
||||
assert.equal(hrefOf(html, 'tab=map'), '');
|
||||
assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki');
|
||||
});
|
||||
|
||||
it('treats a malformed demoGames as no games', () => {
|
||||
apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] });
|
||||
const html = apply({ demoUrl: 'https://demo.example.test', demoGames: 'rust' });
|
||||
assert.equal(hrefOf(html, 'tab=map'), '');
|
||||
});
|
||||
|
||||
it('returns to the stock markup when the demo is unmounted', () => {
|
||||
apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust', 'uo'] });
|
||||
const html = apply({});
|
||||
assert.equal(html, STOCK);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,9 @@
|
||||
* 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;
|
||||
* - a row that claims the app shares something contradicts the premise the whole page
|
||||
* rests on, and would be a real disclosure defect rather than a typo;
|
||||
* - a data type that is not the console's own, which the form then never asks about;
|
||||
* - the consent sentence and /terms stating different minimum ages, which is the kind of
|
||||
* inconsistency a reviewer finds and a developer never does.
|
||||
*
|
||||
@@ -32,6 +33,9 @@ import {
|
||||
assertScopeNonEmpty,
|
||||
collected,
|
||||
collectedIn,
|
||||
deletionRow,
|
||||
PLAY_DATA_TYPES,
|
||||
PLAY_PURPOSES,
|
||||
playRows,
|
||||
} from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
@@ -74,38 +78,102 @@ describe('the Play Data Safety mapping', () => {
|
||||
|
||||
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`);
|
||||
const { types, answer, because, collected: isCollected, shared } = row.play;
|
||||
assert.ok(Array.isArray(types), `${row.id}.play.types is not a list`);
|
||||
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 console's own words, or nothing. A type filed under the wrong category reads fine
|
||||
* in the generated table and is simply not on the form, so whoever fills it in either
|
||||
* skips it or guesses where it went.
|
||||
*/
|
||||
it('uses only the console’s categories, types and purposes', () => {
|
||||
for (const row of playRows()) {
|
||||
for (const { category, type } of row.play.types) {
|
||||
assert.ok(PLAY_DATA_TYPES[category], `${row.id}: "${category}" is not a console category`);
|
||||
assert.ok(
|
||||
PLAY_DATA_TYPES[category].includes(type),
|
||||
`${row.id}: "${type}" is not a data type under "${category}"`
|
||||
);
|
||||
}
|
||||
for (const purpose of row.play.purposes ?? []) {
|
||||
assert.ok(PLAY_PURPOSES.includes(purpose), `${row.id}: "${purpose}" is not a console purpose`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* The form asks three more questions of every collected type, and a collected row with
|
||||
* no type is data leaving the phone that the form never hears about.
|
||||
*/
|
||||
it('answers the follow-up questions for everything collected', () => {
|
||||
for (const row of playRows().filter((r) => r.play.collected)) {
|
||||
assert.ok(row.play.types.length, `${row.id} is collected but names no console type`);
|
||||
assert.equal(typeof row.play.required, 'boolean', `${row.id}.play.required is not a boolean`);
|
||||
assert.ok(row.play.purposes?.length, `${row.id} is collected but gives no purpose`);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* What the app sends is collected in Play's sense, and declared so: it leaves the phone
|
||||
* for the deployment the user chose. What stays true is the premise of /privacy section 2,
|
||||
* that none of it reaches us and none of it is handed on. A row that says otherwise — a
|
||||
* telemetry endpoint, a crash reporter, a hosted directory of deployments — means that
|
||||
* premise has changed, and the page has to change with it rather than gaining a row that
|
||||
* contradicts its own lede.
|
||||
*/
|
||||
it('holds the premise the whole section rests on', () => {
|
||||
for (const row of playRows()) {
|
||||
assert.equal(
|
||||
row.play.collected,
|
||||
row.play.shared,
|
||||
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.'
|
||||
`${row.id} says the app shares it. If that is now true, /privacy section 2's ` +
|
||||
'premise — that the app talks only to the deployment the user chose — has changed.'
|
||||
);
|
||||
assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* `/delete-account/` is the address Google Play is given (ACCOUNT_DELETION.md §4.5), and
|
||||
* Play asks two things of it that render fine when missing: what is deleted, and how long
|
||||
* what stays is kept. A `stays` entry with no `keep` is a card with no answer to the
|
||||
* second question.
|
||||
*/
|
||||
describe('the account-deletion row', () => {
|
||||
it('exists exactly once, in the deployment scope', () => {
|
||||
const row = deletionRow();
|
||||
assert.equal(row.scope, 'deployment', 'the deletion row is about a deployment, not about us');
|
||||
});
|
||||
|
||||
it('says what goes and what stays, with how long for each', () => {
|
||||
const { goes, stays, staff } = deletionRow().deletion;
|
||||
assert.ok(goes.length, 'the deletion row names nothing deleted');
|
||||
assert.ok(stays.length, 'the deletion row names nothing kept');
|
||||
for (const entry of stays) {
|
||||
assert.ok(entry.what?.trim(), 'a kept entry says nothing about what is kept');
|
||||
assert.ok(entry.keep?.trim(), `"${entry.what}" does not say how long it is kept`);
|
||||
}
|
||||
assert.ok(staff?.trim(), 'the staff note (AD9) is missing');
|
||||
});
|
||||
|
||||
/*
|
||||
* AD5 in test form: members see "[deleted player]", the wording the website and the app
|
||||
* render. A page that named a different placeholder would describe a product that does
|
||||
* not exist.
|
||||
*/
|
||||
it('names the placeholder members see', () => {
|
||||
const text = deletionRow().deletion.stays.map((entry) => entry.what).join(' ');
|
||||
assert.match(text, /“\[deleted player\]”/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the minimum age', () => {
|
||||
it('is stated in the consent sentence the row records', () => {
|
||||
assert.match(
|
||||
|
||||
Reference in New Issue
Block a user