www stuff

This commit is contained in:
Devin Zuczek
2026-07-27 18:46:37 -04:00
parent d5e3d3946e
commit 8c773da137
8 changed files with 468 additions and 52 deletions
+4 -7
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RecFlare — an open source implementation of the 2023 RecNet servers</title>
<title>RecFlare — play like it&apos;s 2023</title>
<meta
name="description"
content="RecFlare is an open source implementation of the 2023 RecNet servers, designed for the cloud and running on Cloudflare Workers."
content="RecFlare is a free, open source fan rebuild of the 2023 RecNet servers. Download for PC, join the Discord, and play like it's 2023."
/>
<meta name="theme-color" content="#14100c" />
<!-- Inline so the mark costs no request: the orange spark from the RecFlare logo. -->
@@ -14,14 +14,11 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2314100c'/%3E%3Cpath d='M16 5c1.8 4.2 4.3 6 6.4 8.1a8.9 8.9 0 1 1-12.8 0C11.7 11 14.2 9.2 16 5z' fill='%23fe7101'/%3E%3C/svg%3E"
/>
<!--
Archivo sets the nameplate; IBM Plex Sans/Mono is the machine's own voice
(body copy and the mono metadata on the photo feed).
-->
<!-- Archivo sets the nameplate; IBM Plex Sans carries everything else. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
+13 -21
View File
@@ -1,19 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
import type { ReactNode } from 'react'
/** The community Discord — the join instructions and the build both live there. */
const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
/** Where the stage's "Download for PC" button goes: the client's release listing. */
const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
/** The public source repo, linked from the homepage and footer. */
const SOURCE_REPO = 'https://github.com/djdevin/recflare'
/** The repo's licence, behind the footer's "MIT licensed". */
const LICENSE_URL = `${SOURCE_REPO}/blob/main/LICENSE`
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
interface SelfAccount {
accountId: number
@@ -132,9 +122,12 @@ function SiteFooter() {
<a href={LICENSE_URL} target="_blank" rel="noreferrer">
MIT licensed
</a>{' '}
· a fan project, not affiliated with Rec Room Inc.
made by fans, not affiliated with Rec Room Inc.
</span>
<nav>
{/* A real navigation, not a client-side route: /privacy is rendered by the
Worker (see src/privacy.ts) so it reads without JavaScript. */}
<a href="/privacy">Privacy</a>
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord
</a>
@@ -301,12 +294,11 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
return (
<section className="about">
<div>
<h2 className="about-title">
An open source implementation of the 2023 RecNet servers, designed for the cloud
</h2>
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
<p className="about-lede">
A free, independent fan project, aiming to be <strong>feature-complete</strong> and
infinitely scalable. No gatekeeping, no basement server. Designed for Cloudflare Workers.
A free fan project, made by players who missed it. Aiming to be{' '}
<strong>feature-complete</strong> and infinitely scalable no gatekeeping, no basement
server.
</p>
</div>
<div className="about-side">
@@ -319,10 +311,10 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
<p className={`status ${state}`}>
<span className="dot" />
{state === 'online'
? 'Server online'
? 'Servers are up'
: state === 'down'
? 'Server unreachable'
: 'Checking the server'}
? "Can't reach the servers"
: 'Checking'}
</p>
{/* Only when it's actually up: when it isn't, people want the status, not the joke. */}
{state === 'online' && <p className="status-quip">The cloud never goes down, right?</p>}
+16 -22
View File
@@ -1,14 +1,15 @@
/*
* RecFlare — a live-service readout.
* RecFlare — a poster for a game that's up right now.
*
* The page's job is to show that this server is actually up and that real people are
* on it, then get you into the Discord. So the surface is warm-dark (the room the
* The page's job is to show that real people are playing on this server, then get you
* into the game or the Discord. So the surface is warm-dark (the room the
* screenshots are lit against), and the logo orange (#FE7101) is spent only on actions
* and the brand mark — never as decoration, or it stops reading as "click this". Green
* means one thing throughout, healthy: the server is up, or the change saved.
*
* Type: Archivo for the nameplate, IBM Plex Sans for prose, IBM Plex Mono for anything
* the server itself would say (handles, room names, counts, endpoint paths).
* Type: Archivo for the nameplate, IBM Plex Sans for everything else. There is
* deliberately no monospace anywhere — this is a game, and mono type made the whole
* page read like a status dashboard.
*/
:root {
color-scheme: dark light;
@@ -29,7 +30,6 @@
--display: Archivo, system-ui, sans-serif;
--body: 'IBM Plex Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--radius: 10px;
}
@@ -109,9 +109,8 @@ body {
gap: 9px;
font-family: var(--display);
font-weight: 800;
font-size: 1.05rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 1.2rem;
letter-spacing: -0.015em;
color: var(--text);
text-decoration: none;
}
@@ -132,9 +131,8 @@ body {
.nav-links a,
.linkish {
font-family: var(--mono);
font-size: 0.8rem;
letter-spacing: 0.02em;
font-family: var(--body);
font-size: 0.95rem;
color: var(--muted);
text-decoration: none;
}
@@ -345,16 +343,15 @@ body {
opacity: 0.8;
}
/* Status of the actual server, not decoration — see About. */
/* Status of the actual server, not decoration — see About. Set in plain sentence
case: uppercase mono made a friendly "we're up" read like a monitoring alert. */
.status {
display: inline-flex;
align-items: center;
gap: 9px;
margin: 0;
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.95rem;
font-weight: 500;
color: var(--muted);
}
@@ -443,8 +440,7 @@ body {
margin: 0 auto;
padding: 24px 20px 40px;
border-top: 1px solid var(--line);
font-family: var(--mono);
font-size: 0.75rem;
font-size: 0.875rem;
color: var(--muted);
}
@@ -543,10 +539,8 @@ h2 {
margin-bottom: 0;
}
/* Identity strip on the account page: the account's own record, set in mono. */
.identity .handle {
font-family: var(--mono);
font-size: 0.85rem;
font-size: 0.9rem;
color: var(--muted);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Outbound links shared by the React client and the Worker-rendered pages.
*
* The privacy policy (`privacy.ts`) is rendered server-side while the rest of the site
* is a React SPA, so both need the same Discord/GitHub URLs. They live here so a moved
* invite or a renamed repo is one edit, not two that can silently drift — the policy
* naming a dead contact channel is exactly what VRC.Privacy.4 fails on.
*/
/** The community Discord — the join instructions and the build both live there. */
export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
/** Where the stage's "Download for PC" button goes: the client's release listing. */
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
/** The public source repo, linked from the homepage and footer. */
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
/** The repo's licence, behind the footer's "MIT licensed". */
export const LICENSE_URL = `${SOURCE_REPO}/blob/main/LICENSE`
/** Where a data-deletion or privacy request can be opened without a Discord account. */
export const ISSUES_URL = `${SOURCE_REPO}/issues/new`
/**
* Mailbox for privacy and data-deletion requests, or '' when there isn't one.
*
* Empty by default on purpose: an address printed here that nobody reads is worse than
* no address at all, and Meta re-validates the policy after approval. While it's empty
* the policy routes deletion requests through Discord and GitHub issues, both of which
* are free and open to anyone in any region — which is what VRC.Privacy.4 asks for. Set
* it to a real, monitored mailbox and the policy adds it as the preferred contact.
*/
export const PRIVACY_EMAIL: string = 'privacy@recflare.net'
+359
View File
@@ -0,0 +1,359 @@
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
/**
* The privacy policy, served on www at `/privacy`.
*
* Rendered by the Worker rather than by the React SPA on purpose. The Meta Horizon
* Store's VRC.Privacy.1 check fetches this URL periodically and looks for a live page
* whose text contains "Privacy Policy"; a client-rendered route would answer that fetch
* with an empty `<div id="root">` and could be flagged non-compliant even though a
* browser renders it fine. Server-rendering also means the policy survives a JS error
* or a blocked script, which is the one page on this site that has to.
*
* `/privacy` must therefore stay listed in `run_worker_first` in wrangler.jsonc —
* without it a top-level navigation is served index.html and never reaches this module.
*
* The four VRCs this is written against (developers.meta.com/horizon/resources/):
* Privacy.1 — the URL is live, public, HTTPS, and owned by the app's team.
* Privacy.2 — states what data is processed, collected and stored.
* Privacy.3 — states what that data is used for.
* Privacy.4 — states how any user, in any region, can request deletion, for free.
* Keep the "What we collect" section honest against the schemas it describes
* (packages/domain/src/accounts-db.ts and the per-worker migrations) — that section is
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
* something new.
*
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker
* still stubs — see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts.
* When that lands, check the text still matches what the integration actually requests
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and
* the same disclosure has to agree with the Data Use Checkup filed for the app.
*/
/** Last substantive revision, shown in the header. Bump when the text changes. */
const EFFECTIVE_DATE = '26 July 2026'
/** The palette and type of the main site, inlined — this page loads no stylesheet. */
const STYLES = `
:root {
color-scheme: dark light;
--bg: #14100c;
--surface: #1e1813;
--line: #33291f;
--text: #f5ede1;
--muted: #a8927c;
--accent: #fe7101;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f5f2;
--surface: #ffffff;
--line: #e2ddd6;
--text: #201a14;
--muted: #736656;
--accent: #e05f00;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: 1rem;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
.nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
max-width: 760px;
margin: 0 auto;
padding: 20px;
border-bottom: 1px solid var(--line);
}
.brand {
font-family: Archivo, system-ui, sans-serif;
font-weight: 800;
font-size: 1.2rem;
letter-spacing: -0.015em;
color: var(--text);
text-decoration: none;
}
.nav a.back { color: var(--muted); text-decoration: none; font-size: 0.95rem; }
.nav a.back:hover { color: var(--text); }
main { max-width: 760px; margin: 0 auto; padding: 40px 20px 8px; }
h1 {
font-family: Archivo, system-ui, sans-serif;
font-weight: 800;
font-size: clamp(1.9rem, 4vw, 2.6rem);
line-height: 1.1;
letter-spacing: -0.03em;
margin: 0 0 8px;
}
h2 {
font-family: Archivo, system-ui, sans-serif;
font-weight: 700;
font-size: 1.3rem;
letter-spacing: -0.02em;
margin: 40px 0 10px;
}
h3 { font-size: 1rem; font-weight: 600; margin: 24px 0 6px; }
p, li { max-width: 68ch; }
.updated { color: var(--muted); font-size: 0.9rem; margin: 0 0 8px; }
.lede { font-size: 1.075rem; }
ul { padding-left: 22px; }
li { margin-bottom: 8px; }
li > strong { font-weight: 600; }
.callout {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 10px;
padding: 18px 22px;
margin: 20px 0;
}
.callout p:first-child { margin-top: 0; }
.callout p:last-child { margin-bottom: 0; }
footer {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 48px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.875rem;
}
footer a { color: var(--muted); }
`
/**
* How to reach a human about a privacy or deletion request. Rendered in both the
* deletion section and the footer, so the mailbox (when there is one — see
* PRIVACY_EMAIL) can't be listed in one place and forgotten in the other.
*/
function contactList(): string {
const email = PRIVACY_EMAIL
? `<li><strong>Email</strong> — <a href="mailto:${PRIVACY_EMAIL}">${PRIVACY_EMAIL}</a>.</li>`
: ''
return `<ul>
${email}
<li><strong>Discord</strong> — ask a moderator in <a href="${DISCORD_INVITE}" target="_blank" rel="noreferrer">our Discord server</a>.</li>
<li><strong>GitHub</strong> — <a href="${ISSUES_URL}" target="_blank" rel="noreferrer">open an issue</a> on the project repo. Don't post personal details in a public issue; your username is enough for us to find you.</li>
</ul>`
}
/** The `/privacy` HTML page. Static text — nothing here is interpolated from a request. */
export function privacyPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Privacy Policy — RecFlare</title>
<meta name="description" content="What RecFlare collects, why, and how to have your data deleted." />
<meta name="theme-color" content="#14100c" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<style>${STYLES}</style>
</head>
<body>
<header class="nav">
<a class="brand" href="/">RecFlare</a>
<a class="back" href="/">Back to the site</a>
</header>
<main>
<h1>Privacy Policy</h1>
<p class="updated">Last updated ${EFFECTIVE_DATE}</p>
<p class="lede">
RecFlare is a free, open source, fan-run game server. It is not a business, it sells
nothing, and it has no interest in your data beyond making the game work. This page
explains exactly what we store, why we store it, and how to make us delete it.
</p>
<div class="callout">
<p>
<strong>The short version.</strong> We store your account, the things you make in
game (photos, rooms, messages, inventory), and the technical details needed to log
you in and keep the server from being abused. We don't sell anything, we run no
advertising, and we've integrated no third-party analytics or tracking. Ask us and
we'll delete your account and its data, free, wherever you live.
</p>
</div>
<h2>Who runs this</h2>
<p>
RecFlare is maintained by a volunteer community, and its source code is public at
<a href="${SOURCE_REPO}" target="_blank" rel="noreferrer">github.com/djdevin/recflare</a>.
This policy covers the RecFlare game servers and this website. It is a fan project, not
affiliated with, endorsed by, or connected to Rec Room Inc.
</p>
<p>
Because the server code is open source, anyone can read exactly how the data described
below is handled, and anyone can run their own separate copy of RecFlare. This policy
applies only to the servers we operate. If you play on someone else's instance, their
operator is responsible for your data, not us.
</p>
<h2>What we collect</h2>
<p>
Almost all of it is data you create by playing. We do not buy data about you from
anyone, and we do not combine what's here with data from other services.
</p>
<h3>Your account</h3>
<ul>
<li>Your username, display name, profile picture, pronoun and identity settings, bio, and the date the account was created.</li>
<li>An email address and phone number <em>only if you choose to add them</em>. Neither is required to play, and neither is used for marketing.</li>
</ul>
<h3>How you sign in</h3>
<p>
You can sign in with an account from the platform you play on, or with a password.
Whichever you use, we store the minimum needed to recognise you next time.
</p>
<ul>
<li><strong>Steam.</strong> Your SteamID64, so the account can be matched to the right player. Steam signs the login ticket the game sends us, and we check that signature on our own servers — nothing about you is sent to Steam to do it.</li>
<li><strong>Meta.</strong> The user ID Meta issues for you <em>for this app</em>, and the display name attached to it. This is an app-scoped ID: it identifies you within RecFlare and is not your Meta account identity anywhere else. To confirm a sign-in is genuine, and that the account is entitled to the app, we send the token your headset gives us to Meta for verification — so Meta learns that a sign-in to RecFlare happened. We don't receive your Meta email address, friends list or profile beyond the ID and display name, and we don't ask Meta for them.</li>
<li><strong>A password on this website.</strong> Stored only as a salted PBKDF2 hash. We never store the password itself and cannot read it.</li>
</ul>
<p>
Linking a platform account is how you log in — we don't use it to look you up on that
platform, post anything there, or match you to advertising.
</p>
<h3>Device and technical data</h3>
<ul>
<li>The time of your most recent sign-in.</li>
<li>A device identifier the game client generates for each installation, and the kind of device it is — a PC or a headset, for example.</li>
<li>The IP address the account was created from, and the IP address of your most recent sign-in.</li>
<li>Session tokens. Refresh tokens are stored only as a one-way hash and are single-use.</li>
<li>Ordinary server request logs, held by our hosting provider, which include IP addresses, timestamps and the requests made.</li>
</ul>
<h3>What you make and do in game</h3>
<ul>
<li>Photos you take in game, and their details: who took them, the room they were taken in, and any players tagged.</li>
<li>Rooms and subrooms you create, inventions you build, and clubs you own or join.</li>
<li>Chat messages you send and the conversations they belong to, so they can be delivered and read later.</li>
<li>Your relationships with other players — friends, invites and blocks — and your interactions with rooms, such as favourites and cheers.</li>
<li>Your in-game economy: token balance, inventory, outfits and gifts received.</li>
<li>Your presence — which room instance you are currently in — so friends can find you and join. Presence records expire automatically on their own.</li>
<li>Your player settings and preferences.</li>
</ul>
<h2>Why we use it</h2>
<ul>
<li><strong>To run the game.</strong> Nearly everything above exists so the world can be reassembled the next time you log in — your avatar, your rooms, your inventory, your photos, your conversations.</li>
<li><strong>To sign you in.</strong> Your platform identity, password hash and session tokens are what prove an account is yours and stop anyone else using it.</li>
<li><strong>To let players find each other.</strong> Presence, friend lists and public feeds — including the photo slideshow on this website's front page, which shows public in-game photos along with the username of the player who took each one.</li>
<li><strong>To keep the server usable.</strong> IP addresses, device identifiers and logs are used to investigate abuse, ban evasion and bugs, and to limit how many accounts can be created from one place. This is the only reason we keep them.</li>
<li><strong>To contact you, if you asked us to.</strong> An email address you add is used for account recovery and account notices, nothing else.</li>
</ul>
<p>
We do not use your data for advertising or profiling, we do not sell or rent it, and we
do not share it for anyone else's marketing. There are no advertising SDKs, analytics
SDKs or tracking pixels in the game client or on this website.
</p>
<h2>Who else sees it</h2>
<ul>
<li><strong>Other players.</strong> Some of what you create is public by design: your username, display name, profile picture, bio, the rooms you publish, photos you make public, and messages you send to the people you send them to.</li>
<li><strong>Our hosting provider.</strong> The servers, databases, file storage and logs run on Cloudflare, which processes this data on our behalf in order to host the service.</li>
<li><strong>Meta, when you sign in with a Meta account.</strong> We send Meta the sign-in token from your headset so it can be verified, which tells Meta that a RecFlare sign-in took place. That exchange is governed by Meta's own privacy policy. Signing in with Steam involves no such call.</li>
<li><strong>Nobody else</strong> — except where we're required by law to disclose something, or where it's necessary to investigate a serious safety issue or abuse of the service.</li>
</ul>
<p>
Our community Discord server and our GitHub repository are run by Discord and GitHub
under their own privacy policies. Anything you post there is covered by their terms,
not this one.
</p>
<h2>Cookies</h2>
<p>
This website sets one cookie, <code>rf_token</code>, which holds your sign-in session.
It is strictly necessary to stay signed in, it is not readable by page scripts, and it
is cleared when you sign out. We set no advertising or analytics cookies. The game
client itself uses no cookies.
</p>
<h2>How long we keep it</h2>
<p>
Account and game data is kept for as long as your account exists, so your progress is
there when you come back. Presence records expire within minutes. Session tokens expire
on their own schedule. Server logs are retained for a short period and then age out
automatically. When you ask us to delete your account, we delete it as described below.
</p>
<h2>Deleting your data</h2>
<p>
<strong>You can ask us to delete your account and the data we hold about you at any
time, from anywhere in the world.</strong> There is no charge for this, and you don't
need to give a reason. Contact us by any of these routes:
</p>
${contactList()}
<p>
Tell us your RecFlare username, and be ready to prove the account is yours — normally
by signing in to it, or by sending the request from the email address on the account.
We ask because otherwise anyone could delete anyone else's account. We'll confirm when
it's done, and we aim to complete every request within 30 days.
</p>
<p>Deleting your account removes:</p>
<ul>
<li>Your account record — username, display name, profile picture, bio, email, phone number and password hash.</li>
<li>The link between the account and your Steam or Meta identity, and the stored IP addresses and device identifier.</li>
<li>Your session and refresh tokens, ending any active sign-in.</li>
<li>Your photos, rooms, inventions, inventory, settings and presence.</li>
</ul>
<p>
Two honest limits. Messages you sent live in shared conversations, so copies already
delivered to other players may remain in their message history, no longer attached to
an account. And routine server logs and backups age out on their own timers rather than
being edited, so a record of a request may persist for a short period after deletion.
Beyond those, if there's ever a reason we can't complete a deletion request, we'll tell
you what it is.
</p>
<p>
You can also change or correct most of your details yourself, either in game or on the
<a href="/account">account page</a> of this website, and you can ask us for a copy of
the data we hold about you using the same contact routes above.
</p>
<h2>Security</h2>
<p>
All traffic between the game client, this website and our servers is encrypted in
transit. Passwords are stored only as salted hashes and refresh tokens only as one-way
hashes, so a copy of our database would not reveal either. Access to the production
data is limited to the maintainers who operate the service. No system is perfectly
secure, and we won't pretend otherwise — but this is a hobby server, so please don't
reuse a password here that you use anywhere important.
</p>
<h2>Children</h2>
<p>
RecFlare is not directed at children under 13, and we don't knowingly collect data from
them. If you believe a child under 13 has created an account, contact us using any of
the routes above and we will delete the account and its data.
</p>
<h2>Changes to this policy</h2>
<p>
If this policy changes we'll update the date at the top of this page, and the change
will be visible in the project's public commit history. Significant changes will be
announced in our Discord server.
</p>
<h2>Contact</h2>
<p>Questions about this policy, or about any data we hold:</p>
${contactList()}
</main>
<footer>
<a href="/">RecFlare</a> — a fan project, not affiliated with Rec Room Inc.
</footer>
</body>
</html>`
}
+29
View File
@@ -2,6 +2,7 @@ import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
it('rejects unauthenticated account reads', async () => {
const res = await SELF.fetch('https://example.com/api/me')
@@ -69,3 +70,31 @@ it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
const res = await SELF.fetch('https://example.com/docs/openapi/evil.json')
expect(res.status).toBe(404)
})
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.14 checks are run
// against, and a reviewer only sees the rendered page — so the four things they look
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
// assertion: these strings are the requirement, not incidental copy.
it('serves the privacy policy as real server-rendered HTML', async () => {
const res = await SELF.fetch('https://example.com/privacy')
// VRC.Privacy.1 — live, public, no sign-in, and text without JavaScript.
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/html')
const html = await res.text()
expect(html).toContain('Privacy Policy')
// VRC.Privacy.2 — what is collected, VRC.Privacy.3 — what it is used for.
expect(html).toContain('What we collect')
expect(html).toContain('Why we use it')
// VRC.Privacy.4 — deletion is explained, free, and open to every region.
expect(html).toContain('Deleting your data')
expect(html).toMatch(/delete your account[^.]*at any\s+time, from anywhere in the world/)
expect(html).toContain('There is no charge for this')
// A deletion route a reader can actually follow. Discord and GitHub are always
// listed; the mailbox only when one is configured (see PRIVACY_EMAIL).
expect(html).toContain(DISCORD_INVITE)
expect(html).toContain(ISSUES_URL)
if (PRIVACY_EMAIL) expect(html).toContain(`mailto:${PRIVACY_EMAIL}`)
})
+7
View File
@@ -6,6 +6,7 @@ import { withOnError } from '@repo/hono-helpers'
import { NotificationType } from '../../notify/src/notification-types'
import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
import type { Context } from 'hono'
@@ -282,6 +283,12 @@ const app = new Hono<App>()
return c.json({ success: true, sent: result.sent ?? 0 })
})
// ---- Privacy policy -----------------------------------------------------
// Server-rendered rather than a SPA route so the page has real text without
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
// still live, and an empty SPA shell can read as a broken link (see privacy.ts).
.get('/privacy', (c) => c.html(privacyPage()))
// ---- Aggregated API docs ------------------------------------------------
// `/docs` serves the self-hosted Scalar UI; `/docs/openapi/:service.json` proxies
// each worker's spec same-origin (see docs.ts). The Scalar bundle itself
+6 -2
View File
@@ -17,12 +17,16 @@
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
// routes (all under `/api/*` and `/docs*`). `/docs/scalar.standalone.js` is
// routes (`/api/*`, `/docs*` and `/privacy`). `/docs/scalar.standalone.js` is
// deliberately excluded so it's served directly as the static asset it is.
//
// `/privacy` is here for the same reason `/docs` is, and it matters more: the Meta
// Horizon Store re-fetches the privacy policy URL to confirm it's live, and dropping
// it from this list would serve that fetch the SPA shell instead of the policy.
"assets": {
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*"]
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
},
"upload_source_maps": true,
"observability": {