fix(legal): declare what the app sends as collected, not shared (D84) #44
20
PLAN.md
20
PLAN.md
@@ -237,7 +237,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
|
||||
**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 **eighty-three**:
|
||||
somewhere other than the thing it decided. The count of record is **eighty-four**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
@@ -263,6 +263,7 @@ somewhere other than the thing it decided. The count of record is **eighty-three
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1068,6 +1069,23 @@ 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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -19,49 +19,99 @@ 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 | What you post and send from the app |
|
||||
| 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, Other actions: 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.
|
||||
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.
|
||||
|
||||
- **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
|
||||
- **In detail:** Signing out clears them; uninstalling the app removes them with it.
|
||||
- **Read from:** `core/auth/EncryptedTokenStore.kt`
|
||||
- **Read from:** `data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt`
|
||||
|
||||
### The trusted-device token, if you asked for one
|
||||
### The trusted-device token and the device’s name, if you asked for one
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
**Device or other IDs → Device or other IDs.** Collected, not shared, required.
|
||||
|
||||
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.
|
||||
Purposes: Account management; Fraud prevention, security, and compliance.
|
||||
|
||||
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment.
|
||||
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`
|
||||
- **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 +121,97 @@ 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.
|
||||
- **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, 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`
|
||||
- **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`
|
||||
|
||||
### 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.
|
||||
- **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 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.
|
||||
- **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`
|
||||
|
||||
@@ -116,6 +220,7 @@ 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.
|
||||
- **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. The platform has no self-service account deletion today; a deployment’s admin deletes accounts from its admin panel. Read from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.
|
||||
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13).
|
||||
|
||||
## What the website collects, for the same reviewer
|
||||
@@ -128,4 +233,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-29. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
|
||||
@@ -38,7 +38,13 @@ 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,
|
||||
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 +58,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');
|
||||
@@ -101,40 +134,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)}`);
|
||||
@@ -160,6 +240,13 @@ function render() {
|
||||
'the deployment the user chose, and is deleted there. The only list we hold is the ' +
|
||||
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.'
|
||||
);
|
||||
lines.push(
|
||||
'- **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. The platform has no self-service account ' +
|
||||
'deletion today; a deployment’s admin deletes accounts from its admin panel. 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).'
|
||||
|
||||
@@ -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,64 @@
|
||||
* @property {Retention} retention
|
||||
* @property {string} source The file this was read out of, repo-relative.
|
||||
* @property {PlayMapping} [play]
|
||||
*
|
||||
* @type {Collected[]}
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 +229,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 +244,98 @@ 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.',
|
||||
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',
|
||||
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.',
|
||||
},
|
||||
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 +350,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.',
|
||||
@@ -264,45 +379,157 @@ export const collected = [
|
||||
'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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -328,15 +555,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 +576,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.',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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-29',
|
||||
|
||||
/**
|
||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
assertScopeNonEmpty,
|
||||
collected,
|
||||
collectedIn,
|
||||
PLAY_DATA_TYPES,
|
||||
PLAY_PURPOSES,
|
||||
playRows,
|
||||
} from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
@@ -74,34 +77,64 @@ 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.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user