diff --git a/PLAN.md b/PLAN.md index fde1ff8..6586752 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1622,6 +1622,17 @@ a mechanism rather than diligence: replaceable by a file copy, and that promise survives exactly as long as nobody types the address into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the mechanism, diligence is not. + + **All three network checks read a file through Gitea's `contents` endpoint, never `raw`** — + `checkFacts.mjs`, `checkQuickstart.mjs`, `checkReference.mjs`. Phase 12b found the reason. + `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea keeps + a copy for six hours: on cutover day this check read `website`'s `version.js` from a fortnight + earlier and failed the site for saying Module API 1.9.0 when `main` said 1.6.0 — except that + `main` said 1.9.0, and nothing anyone could edit here would have made it pass. `contents` + answers `private, must-revalidate` and is not cached, at the cost of a base64 decode. Same + argument as `checkLinks.mjs` fetching nothing: a check that goes red on someone else's + infrastructure is a check people learn to ignore, and one that goes red on a stale copy is + worse — it is indistinguishable from the failure it exists to report. - **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a `RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23), and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from diff --git a/PLAY_DATA_SAFETY.md b/PLAY_DATA_SAFETY.md index ca50a70..89b0767 100644 --- a/PLAY_DATA_SAFETY.md +++ b/PLAY_DATA_SAFETY.md @@ -32,6 +32,7 @@ Where the console offers free text about security practices, two things are wort | 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. | ## Each answer, and why it is the truthful one @@ -83,12 +84,23 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl **Messages → Other user-generated content.** Not collected by us. -Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives. +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. - **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one. - **Retention:** Held by the deployment, under its operator’s policy - **Read from:** `PLAN.md §9 section 2` +### 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. + +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. +- **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. @@ -116,4 +128,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-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores. +Last generated from data dated 2026-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores. diff --git a/scripts/checkFacts.mjs b/scripts/checkFacts.mjs index 8bf8fd2..f6e3a33 100644 --- a/scripts/checkFacts.mjs +++ b/scripts/checkFacts.mjs @@ -60,8 +60,28 @@ async function api(pathname) { return res; } -const raw = async (repo, filePath, ref) => - (await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text(); +/** + * A file's bytes, read through the `contents` endpoint rather than `raw`. + * + * `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea + * serves a copy for six hours and this check can read a blob most of a working day old. + * That is not theoretical: on the day of the engagement cutover it reported website's + * MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site + * whose number was right. A check that goes red on stale data is a check people learn to + * ignore, which is the one failure mode this file exists to avoid. + * + * `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is + * always the ref's current blob. The cost is a JSON parse and a base64 decode. + */ +async function raw(repo, filePath, ref) { + const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`); + if (meta.encoding !== 'base64' || typeof meta.content !== 'string') { + throw new Error( + `${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).` + ); + } + return Buffer.from(meta.content, 'base64').toString('utf8'); +} const json = async (pathname) => (await api(pathname)).json(); diff --git a/scripts/checkQuickstart.mjs b/scripts/checkQuickstart.mjs index d824222..7b4da4b 100644 --- a/scripts/checkQuickstart.mjs +++ b/scripts/checkQuickstart.mjs @@ -55,12 +55,18 @@ const checked = []; const ok = (what) => checked.push(what); const fail = (what, detail) => failures.push({ what, detail }); -/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */ +/** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */ async function raw(repo, filePath, ref) { - const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; + const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); - return res.text(); + const meta = await res.json(); + if (meta.encoding !== 'base64' || typeof meta.content !== 'string') { + throw new Error( + `${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).` + ); + } + return Buffer.from(meta.content, 'base64').toString('utf8'); } /** diff --git a/scripts/checkReference.mjs b/scripts/checkReference.mjs index e297ce0..93bd51b 100644 --- a/scripts/checkReference.mjs +++ b/scripts/checkReference.mjs @@ -53,12 +53,18 @@ const checked = []; const ok = (what) => checked.push(what); const fail = (what, detail) => failures.push({ what, detail }); -/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */ +/** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */ async function raw(repo, filePath, ref = 'main') { - const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; + const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); - return res.text(); + const meta = await res.json(); + if (meta.encoding !== 'base64' || typeof meta.content !== 'string') { + throw new Error( + `${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).` + ); + } + return Buffer.from(meta.content, 'base64').toString('utf8'); } /** diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs index 08a06cc..e91818e 100644 --- a/src/config/sidebar.mjs +++ b/src/config/sidebar.mjs @@ -38,6 +38,8 @@ export const docsSidebar = [ { label: 'Teams', slug: 'docs/administration/teams' }, { label: 'Moderation', slug: 'docs/administration/moderation' }, { label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' }, + { label: 'Engagement rules', slug: 'docs/administration/engagement-rules' }, + { label: 'Message templates', slug: 'docs/administration/message-templates' }, { label: 'Managing modules', slug: 'docs/administration/managing-modules' }, { label: 'The shard connection', slug: 'docs/administration/the-shard-connection' }, { label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' }, @@ -113,6 +115,8 @@ export const plannedSidebar = { 'Teams', 'Moderation', 'Notifications and email', + 'Engagement rules', + 'Message templates', 'Managing modules', 'The shard connection', 'Maintenance and upgrades', diff --git a/src/content/docs/docs/administration/configuration.mdx b/src/content/docs/docs/administration/configuration.mdx index a101bda..f5bbc2d 100644 --- a/src/content/docs/docs/administration/configuration.mdx +++ b/src/content/docs/docs/administration/configuration.mdx @@ -59,9 +59,10 @@ per-Team settings: ## Email Configured on the same screen and covered in -[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over -OAuth2, it reuses the Google authentication client, and it must be set up on the -[Authentication](/docs/administration/authentication/) page first. +[Notifications and email](/docs/administration/notifications-and-email/): pick a mail +transport, enter its host, port and credentials, and send a test. It depends on nothing +else on the site — a relay is the recommended posture, a mailbox provider over SMTP the +simplest, and your own MTA needs no credentials at all.