Files
runicgateway.com/src/data/collection.mjs
wtclaude c8a293b8f6 docs(admin): the engagement rules screen, and the privacy inventory an engagement mailer changes
Engagement Phase 12a. The site had pages for where a message goes (Notifications and
email) and what it says (Message templates), and nothing at all for what makes one get
sent -- the four Engagement screens the workstream built.

New page: Engagement rules. Rules, Audiences, the trigger catalog and the send log on
one page, sitting between the two it joins up. Templates already has its own page and
Suppressions is in Troubleshooting, so neither is repeated here.

Two things it exists to state plainly:

  * Every rule ships disabled, including the ones a module brings. "Installed" is not
    "on", and an upgrade whose Team mail went quiet is the same fact.
  * The ceiling is a TREE, not a ladder. The tempting reading -- a staff-only event
    could obviously also go to one person -- is wrong, and the example is the argument:
    "one person" for cheat detection is the player it was detected on.

Troubleshooting gains the symptom that page answers ("nothing is sent for one
particular event"): the rule is off, the rule is dormant, its own cooldown held it, or
the audience is empty.

Privacy: two rows the engagement work makes necessary, and one sentence it made false.

  * app-content claimed "Nothing is cached for offline use". Phase 8 shipped a DataStore
    snapshot of the inbox, so it was untrue -- and that row feeds the generated Play Data
    Safety answers, which is a store-review matter rather than a doc nit. The snapshot now
    has its own row and its own Play mapping (Messages / Other in-app messages; not
    collected by us, stored on the device), and app-content's claim is narrowed to
    everything else.
  * deploy-engagement, for the deployment scope: an address is now used for more than
    getting into an account, there is a delivery log holding a one-way hash of it, and
    there is a suppression list. Its retention line says what is true rather than what a
    reader assumes -- none of these tables has a retention sweep.

PLAY_DATA_SAFETY.md regenerated from the inventory; legal.lastUpdated moved with the page
it dates.

Verified: the whole `verify` chain green -- checkSidebar (plannedSidebar moved with the
live tree), checkFacts 19/19, checkQuickstart 59, checkReference 22, checkLinks 2605,
checkA11y, checkCsp, playDataSafety --check, 42 + 7 tests. Read in a browser as well, in
the served build.

AI-assisted: written with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 08:11:10 -05:00

523 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* collection.mjs — what is collected, by whom, for how long. PLAN.md §9, built in phase 6.
*
* ---------------------------------------------------------------------------------------
* WHY THE POLICY IS DATA AND NOT PROSE
* ---------------------------------------------------------------------------------------
* §9 says the Play Data Safety declaration is "filled from section 2, and section 2 is
* written knowing that is what it is for". Two documents saying the same thing about the
* same code is the drift this repository already has two mechanisms against — the
* capability list (D18) and the absences (D22) — and this is the worst instance of the
* three, because the two readers are a published legal page and a form at Google that
* cannot be corrected without a review round.
*
* So the inventory is one array. `/privacy` renders it as prose with the reasoning around
* it; `scripts/playDataSafety.mjs` renders the `app`-scoped rows as the console's own
* questions. Changing what the app stores means changing one row, and both move (D33).
*
* ---------------------------------------------------------------------------------------
* THE THREE SCOPES, WHICH ARE THE WHOLE POINT OF THE PAGE
* ---------------------------------------------------------------------------------------
* §9 is explicit that conflating them "would be wrong in both directions", and the
* direction people miss is the second one:
*
* `site` This website. We are the data controller. It is one form.
* `app` The Android app. We operate NO server it talks to — every byte goes to
* a deployment the user typed the address of, run by whoever runs that
* community. What is listed here is therefore mostly what the device
* HOLDS, not what we receive, because we receive nothing.
* `deployment` A self-hosted install of the platform. The operator is the controller,
* not us. This scope exists so an operator sees the responsibility they
* are taking on, and so a player never mistakes this policy for the one
* governing their own community's site.
*
* ---------------------------------------------------------------------------------------
* EVERY ROW IS A FACT ABOUT CODE THAT EXISTS
* ---------------------------------------------------------------------------------------
* Each entry names the file it was read out of. Not decoration: a privacy policy is the
* document most likely to be written from a template and least likely to be re-read
* against the software, and a row that cannot name its source is a row somebody guessed.
* `checkFacts.mjs` cannot verify these — there is no version number to compare — so the
* citation is what a reviewer uses instead.
*/
/**
* @typedef {object} Retention
* @property {string} summary Short enough to sit in a table cell.
* @property {string} [detail] The mechanism, where the summary alone would be a promise.
*
* @typedef {object} PlayMapping
* How this row answers Google Play's Data Safety form. `app` scope only — see
* `scripts/playDataSafety.mjs`, which is the only reader.
* @property {string} category The console's grouping, e.g. "Personal info".
* @property {string} type The console's data type within that grouping.
* @property {boolean} collected Does it leave the device to a server WE operate?
* @property {boolean} shared Do we pass it to a third party?
* @property {string} answer The recommended console answer, in one line.
* @property {string} because Why that answer is the truthful one.
*
* @typedef {object} Collected
* @property {string} id
* @property {'site'|'app'|'deployment'} scope
* @property {string} title
* @property {string} body What it is and why it exists.
* @property {Retention} retention
* @property {string} source The file this was read out of, repo-relative.
* @property {PlayMapping} [play]
*
* @type {Collected[]}
*/
export const collected = [
/* =====================================================================================
1. THIS WEBSITE
One form, and nothing else. There is no session, no cookie and no script — D9 is not
a policy statement here, it is a description of the build output: the site sets no
cookie of any kind, and `Base.astro` loads no third-party origin, so there is nothing
to disclose beyond the row below and the access log the proxy keeps.
===================================================================================== */
{
id: 'beta-email',
scope: 'site',
title: 'Your email address',
body:
'The one thing this site asks anybody for. It is stored so it can be pasted into ' +
'the Google Play tester list, which is the only way Play accepts testers for a ' +
'closed test. It is not mailed to — this site cannot send email at all — it is not ' +
'sold, and it is used for nothing else.',
retention: {
summary: 'Until the beta ends, or until you ask',
detail:
'A removal erases the address itself rather than flagging the row: what is left ' +
'behind is a date and the fact that a removal happened, which is what lets us ' +
'answer “did you action my request” without keeping the thing you asked us to ' +
'let go of.',
},
source: 'src/lib/betaStore.mjs',
},
{
id: 'beta-consent',
scope: 'site',
title: 'The wording you agreed to, and when',
body:
'The exact sentence beside the checkbox is stored with the row, along with the ' +
'date. A record of consent that cannot reproduce the words somebody actually ' +
'agreed to is not a record of consent, and the wording can change over time.',
retention: { summary: 'For the life of the row' },
source: 'src/data/beta.mjs',
},
{
id: 'beta-ip-hash',
scope: 'site',
title: 'A one-way hash of your IP address — never the address',
body:
'The form has to survive a script, and the cheapest defence is a limit per source. ' +
'What is stored is a salted SHA-256 of the address, with the salt held in the ' +
"servers environment rather than in the database — so a copy of the file, on its " +
'own, cannot be turned back into a list of who signed up from where. Rate limiting ' +
'works perfectly well against a hash. Identifying somebody does not.',
retention: {
summary: 'With the row; the rate-limit log is pruned after 48 hours',
detail:
'A removal blanks the hash along with the address. Separately, the record of ' +
'attempts the limiter counts against prunes itself on every write.',
},
source: 'src/lib/betaStore.mjs',
},
{
id: 'beta-user-agent',
scope: 'site',
title: 'Your browsers user-agent string, truncated',
body:
'The browser identifies itself on every request anyway; this one is kept beside the ' +
'signup because it is the only signal that separates a person from a script after ' +
'the fact. It is truncated, because the column is a signal rather than a transcript.',
retention: { summary: 'With the row; blanked on removal' },
source: 'src/lib/betaStore.mjs',
},
{
id: 'site-access-log',
scope: 'site',
title: 'The web servers access log',
body:
'Ordinary reverse-proxy logging, the same as any web server keeps: the IP address ' +
'the request came from, the path, the user agent and the time. It is read when ' +
'something is broken or being attacked, and it is not aggregated, profiled or ' +
'joined to anything else. Nobody analyses this traffic, because there is nothing ' +
'here that would benefit from it (D9, D30).',
retention: {
summary: 'Short-term operational retention, then rotated away',
detail:
'The log belongs to the reverse proxy on the host rather than to this ' +
'application, so it is the proxys rotation that governs it. The site is on ' +
'DNS-only records: no CDN or edge provider terminates the connection, so this log ' +
'is the whole of it.',
},
source: 'PLAN.md §13 phase 12 — the operator note',
},
/* =====================================================================================
2. THE ANDROID APP
The unusual part, and the part §9 says must be stated precisely: we operate no server
the app talks to. `ConnectScreen.kt` gates the entire app on an address the user
enters and the app validates; everything below either stays on the phone or goes to
that address. There is no telemetry SDK, no crash reporter and no analytics in the
build — the manifest asks for INTERNET, network state, notifications and a data-sync
foreground service, and nothing else.
`play` is filled in on every row here, because a row in this scope with no mapping is
a question on the console form that somebody will answer from memory (D33).
===================================================================================== */
{
id: 'app-session-tokens',
scope: 'app',
title: 'Your sign-in tokens',
body:
'When you sign in to a deployment, the app keeps the access and refresh tokens it ' +
'was issued, plus the username, role and account id they belong to. They are held ' +
'in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are ' +
'sent to exactly one place: the deployment that issued them.',
retention: {
summary: 'On the device until you sign out',
detail: 'Signing out clears them; uninstalling the app removes them with it.',
},
source: 'core/auth/EncryptedTokenStore.kt',
play: {
category: 'Personal info',
type: 'User IDs',
collected: false,
shared: false,
answer: 'Not collected by us.',
because:
'The credentials are issued by, and returned to, a server the user nominated. ' +
'Nothing reaches an endpoint under our control, because we run none.',
},
},
{
id: 'app-trust-token',
scope: 'app',
title: 'The trusted-device token, if you asked for one',
body:
'Ticking “trust this device” during two-factor sign-in stores an opaque token so ' +
'the deployment can skip the second factor next time. It lives in its own encrypted ' +
'store, deliberately separate from the session, because it has to outlive a sign-out ' +
'to be worth anything — and the deployment holds only a hash of it, so the copy on ' +
'your phone is the only usable one.',
retention: {
summary: 'On the device until it expires or you revoke it',
detail:
'Thirty days, and revocable at any time from the deployments Trusted Devices ' +
'screen, which is also where it can be revoked if the phone is lost.',
},
source: 'core/auth/EncryptedTrustTokenStore.kt',
play: {
category: 'Personal info',
type: 'User IDs',
collected: false,
shared: false,
answer: 'Not collected by us.',
because:
'Same as the session tokens: minted by the users deployment, stored on the ' +
'device, presented back to that same deployment.',
},
},
{
id: 'app-server-address',
scope: 'app',
title: 'The address of the deployment you chose',
body:
'The app ships pointed at nothing and asks for an address on first run. That address ' +
'is stored in ordinary preferences rather than encrypted storage — it is not a ' +
'secret, it is the equivalent of a bookmark — and it is what every other screen in ' +
'the app talks to.',
retention: { summary: 'On the device until you change it or uninstall' },
source: 'core/prefs/ServerPreferences.kt',
play: {
category: 'App info and performance',
type: 'Other app data',
collected: false,
shared: false,
answer: 'Not collected by us. Stored on the device only.',
because:
'It never leaves the phone. It is the destination of requests, not the contents ' +
'of one.',
},
},
{
id: 'app-push',
scope: 'app',
title: 'Push registration, if you turn notifications on',
body:
'Push is off until you enable it. When you do, the app mints a random, unguessable ' +
'topic name on the notification relay the deployment nominates, and registers that ' +
'topics URL with the deployment so it has somewhere to send a nudge. What ' +
'actually travels through the relay is content-free — a stream name and a reference, ' +
'never the message — and the app then fetches the real content over its ' +
'authenticated connection to the deployment. A leaked topic name therefore reveals ' +
'nothing, which is the reason the relay needs no account and holds nothing about you.',
retention: {
summary: 'Until you turn push off, sign out, or uninstall',
detail:
'Signing out or disabling push unregisters the device with the deployment and ' +
'discards the topic. The relay retains whatever its own operator configures it to; ' +
'if the deployment points at a relay it does not run, that relay is a third party ' +
'to both of us, and it still only ever sees a tickle.',
},
source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt',
play: {
category: 'Messages',
type: 'Other in-app messages',
collected: false,
shared: false,
answer:
'Not collected by us. Declare the relay hop in the consoles free-text ' +
'security section if it asks.',
because:
'The notification passes through a relay chosen by the deployment, and it carries ' +
'no content — the app pulls the content itself, authenticated. Neither hop reaches ' +
'a server we operate.',
},
},
{
id: 'app-content',
scope: 'app',
title: 'Everything you read and post in the app',
body:
'Forum posts, Team activity, character and shard information, notification ' +
'preferences: all of it is a live read or write against the deployment. 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.',
retention: {
summary: 'Held by the deployment, under its operators policy',
},
source: 'PLAN.md §9 section 2',
play: {
category: 'Messages',
type: 'Other user-generated content',
collected: false,
shared: false,
answer: 'Not collected by us.',
because:
'Content is written to the communitys own installation. We have no copy, no ' +
'access and no way to obtain one.',
},
},
{
id: 'app-inbox-cache',
scope: 'app',
title: 'A snapshot of your notifications, so the inbox opens without a signal',
body:
'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 ones ' +
'messages.',
retention: {
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
detail:
'Signing out deletes the snapshot outright. It lives in the apps 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.',
},
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
play: {
category: 'Messages',
type: 'Other in-app messages',
collected: false,
shared: false,
answer: 'Not collected by us. 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.',
},
},
{
id: 'app-no-analytics',
scope: 'app',
title: 'No analytics, no crash reporting, no advertising',
body:
'There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no ' +
'advertising identifier, no measurement library. That is checkable rather than ' +
'claimed: it is what the dependency list and the manifest say, and a build that ' +
'gained one would gain permissions with it.',
retention: { summary: 'Nothing to retain' },
source: 'app/build.gradle.kts, app/src/main/AndroidManifest.xml',
play: {
category: 'Device or other IDs',
type: 'Device or other IDs',
collected: false,
shared: false,
answer: 'Not collected.',
because:
'No advertising ID, no analytics identifier, and no library that would generate ' +
'one is linked into the build.',
},
},
/* =====================================================================================
3. SELF-HOSTED DEPLOYMENTS
Written for an operator deciding what they are taking on, and for a player who found
this page from their community's site and needs to be told, plainly, that it is not
the policy governing them.
===================================================================================== */
{
id: 'deploy-accounts',
scope: 'deployment',
title: 'Account records',
body:
'A username, a password hash, an optional email address, the role, and — where the ' +
'operator has enabled it — a two-factor secret and single-use recovery codes. The ' +
'time and IP address of the last sign-in are stored on the account row.',
retention: { summary: 'Set by the operator; nothing expires on its own' },
source: 'website server/db/schema.sql — users',
},
{
id: 'deploy-sessions',
scope: 'deployment',
title: 'Sessions, devices and revocations',
body:
'Web sessions, mobile refresh tokens, trusted devices and the revocation list. A ' +
'trusted-device row keeps a hash of the token, a device label, a truncated user ' +
'agent and the times it was created and last used.',
retention: {
summary: 'Trusted devices expire after 30 days; refresh tokens rotate',
},
source: 'website server/db/schema.sql — trusted_devices, mobile_refresh_tokens',
},
{
id: 'deploy-audit',
scope: 'deployment',
title: 'An audit log, with IP addresses on it',
body:
'Administrative and security-relevant actions are logged with the acting account, ' +
'what was done, and the IP address it came from. This is the record a moderator ' +
'relies on, and it is also the most sensitive thing in the database.',
retention: { summary: 'Kept until the operator removes it' },
source: 'website server/db/schema.sql — activity_log',
},
{
id: 'deploy-bot-scoring',
scope: 'deployment',
title: 'Bot scoring and temporary IP bans',
body:
'Scanner traffic and failed logins raise a score against the source address, and a ' +
'high enough score bans it from the site for an hour. Worth stating precisely ' +
'because it is better than it sounds: that score lives in memory in the running ' +
'process, not in the database, and it decays after half an hour of quiet — a ' +
'restart forgets every address it was watching.',
retention: {
summary: 'In memory only; scores decay, bans last an hour',
},
source: 'website server/src/middleware/botScore.js',
},
{
id: 'deploy-content',
scope: 'deployment',
title: 'Everything posted on the site',
body:
'Forum threads and replies, uploads, wiki revisions, moderation actions, warnings, ' +
'reports and appeals — with the account that made each one. Where the operator has ' +
'connected Discord, some of that crosses into Discord and is then also subject to ' +
'Discords own terms.',
retention: {
summary: 'Operator-configured; some sweeps run on a retention window',
detail:
'Soft-deleted forum uploads and Team activity have retention windows an admin ' +
'sets; most other content is kept until somebody removes it.',
},
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
},
{
id: 'deploy-engagement',
scope: 'deployment',
title: 'Notifications, and the record of what was sent',
body:
'An operator can have the site notify people about things that happen on it — on ' +
'the site, by email, by push — so an address is now used for more than getting ' +
'into an account. Each member chooses this per notification and per channel, and ' +
'email and push are both off until they ask for them. Alongside that the site ' +
'keeps a delivery log: what fired, which account, which channel, whether it ' +
'arrived, and a one-way hash of the address rather than the address. Addresses ' +
'that bounce or are reported as spam go on a suppression list, which stores the ' +
'same hash plus a masked form (`d***@example.com`, never the local part) so an ' +
'operator can see what was suppressed without the list becoming a second address ' +
'book.',
retention: {
summary: 'Kept until the operator removes them; nothing here expires on its own',
detail:
'Stated plainly because it is the answer people assume the other way round: ' +
'the delivery log, the suppression list and the per-person rate limits have no ' +
'retention sweep, so they are as long as the site is old. Deleting an account ' +
'detaches its rows from it rather than deleting them — a delivery history stops ' +
'naming a person, and a suppressed address stays suppressed.',
},
source:
'website server/db/schema.sql — engagement_sends, engagement_suppressions, ' +
'notification_channel_prefs',
},
{
id: 'deploy-game-data',
scope: 'deployment',
title: 'Game data from the connected server',
body:
'Where a game module is installed, information about characters, guilds, houses and ' +
'the in-game economy flows from the game server to the site through the bridge. ' +
'Which of it is visible to the public is the operators decision, made in the ' +
'admin panel — the bridge itself forwards, and the site decides.',
retention: { summary: 'Operator-configured' },
source: 'docs/link/v4.md — the visibility framework',
},
];
/** The entries in one scope, in file order. */
export function collectedIn(scope) {
return collected.filter((entry) => entry.scope === scope);
}
/**
* The `app` rows that carry a Play mapping — the Data Safety generator's input.
*
* A separate accessor rather than a filter at the call site, so the invariant below has
* somewhere to live: every `app` row MUST map, because the form asks about the app as a
* whole and a row nobody mapped is a question answered from memory.
*/
export function playRows() {
const rows = collectedIn('app');
const unmapped = rows.filter((entry) => !entry.play).map((entry) => entry.id);
if (unmapped.length) {
throw new Error(
`src/data/collection.mjs: app-scoped entries with no Play mapping: ${unmapped.join(', ')}.\n` +
'\nEvery app row answers a question on the Data Safety form (D33). Add a `play`\n' +
'block, or move the entry to another scope if it is not about the app.\n'
);
}
return rows;
}
/**
* Fails the build when a scope renders nothing.
*
* The same guard `notBuilt.mjs` carries, for a stronger reason: an empty section on a
* privacy policy does not read as an omission, it reads as "we collect nothing here", and
* that is a claim nobody made.
*/
export function assertScopeNonEmpty(scope) {
if (collectedIn(scope).length) return;
throw new Error(
`src/data/collection.mjs has no entry in scope "${scope}", but /privacy renders it.\n` +
'\nA section with nothing under it reads as a claim that nothing is collected.\n'
);
}