testing generic deploy

This commit is contained in:
Devin Zuczek
2026-06-29 21:52:50 -04:00
parent 3affabd280
commit 69b89e2dc4
79 changed files with 399 additions and 307 deletions
+3
View File
@@ -47,3 +47,6 @@ yarn-error.log*
# Agents # Agents
.claude/settings.local.json .claude/settings.local.json
# Configuration
env.json
Generated
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>
+7
View File
@@ -100,6 +100,13 @@ new-package *args:
update *args: update *args:
bun runx update "$@" bun runx update "$@"
# Sync generated config (wrangler routes, etc.) from env.json
[group('4. utility')]
[positional-arguments]
[no-cd]
sync *args:
bun runx sync "$@"
# CLI in packages/tools for running commands in the repo. # CLI in packages/tools for running commands in the repo.
[group('4. utility')] [group('4. utility')]
[positional-arguments] [positional-arguments]
+16
View File
@@ -38,6 +38,22 @@ npm create workers-monorepo@latest
just install just install
``` ```
**Configure Environment:**
Copy the example config and set your base domain. `env.json` is the single
source of truth for service hostnames; it is gitignored, so each clone needs its
own copy.
```bash
cp env.example.json env.json
# edit env.json and set "domain" to your domain
just sync
```
`just sync` regenerates the derived config (wrangler route patterns, the `ns`
service-discovery document, and the api share-link base URL) from `env.json`.
Re-run it whenever you change `env.json`.
**Run Development Server:** **Run Development Server:**
```bash ```bash
+5 -6
View File
@@ -1,16 +1,15 @@
# accounts # accounts
Accounts Worker served at `accounts.rec.djdevin.net`. A Hono app ported from the Accounts Worker served on the `accounts` subdomain. A Hono app for accounts.
C# `AccountsController`. EF Core (`AppDbContext`) queries are stubbed for now — Database queries are stubbed for now — no real bindings yet.
no real bindings yet.
## Behavior ## Behavior
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker - **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid. (same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
- **DB-backed reads** return synthesized default accounts. The C# already fills - **DB-backed reads** return synthesized default accounts. Every column gets a
every column with a fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), so the stubs return
so the stubs return those defaults rather than 404ing on a missing row. those defaults rather than 404ing on a missing row.
- **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the - **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the
request and ack without persisting. `create` mints a random account id and request and ack without persisting. `create` mints a random account id and
returns it wrapped in the RecNet result envelope `{ success, value }`. returns it wrapped in the RecNet result envelope `{ success, value }`.
+2 -2
View File
@@ -21,7 +21,7 @@ export const SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`, `CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`,
] ]
/** Client-facing account shape (PascalCase, matches the C# `Account`). */ /** Client-facing account shape (PascalCase, as the client expects). */
export interface Account { export interface Account {
AccountId: number AccountId: number
Username: string Username: string
@@ -61,7 +61,7 @@ export function randomUsername(): string {
} }
/** /**
* Build a full account object from an id, applying the C# fallbacks for any * Build a full account object from an id, applying default fallbacks for any
* column the caller doesn't override. Used both to synthesize accounts that * column the caller doesn't override. Used both to synthesize accounts that
* aren't in the DB and as the base for a freshly created account. * aren't in the DB and as the base for a freshly created account.
*/ */
+10 -11
View File
@@ -10,17 +10,17 @@ import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/** /**
* Ported from the C# `AccountsController`. Account reads/writes are backed by the * Account reads/writes are backed by the shared `accounts` table in D1 (schema
* shared `accounts` table in D1 (schema owned by the `auth` worker). Accounts not * owned by the `auth` worker). Accounts not in the table fall back to a
* in the table fall back to a synthesized default (the C# fills every column with * synthesized default (every column has a fallback anyway). Profile mutations
* a fallback anyway). Profile mutations still accept-and-ack (marked `TODO`). * still accept-and-ack (marked `TODO`).
* *
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/ */
/** /**
* Resolve the account id from a Bearer token, mirroring the repeated * Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check in the C#. Returns `null` when the header is missing, * auth-header check. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer. * the token is invalid, or the `sub` claim isn't an integer.
*/ */
async function authedId(c: Context<App>): Promise<number | null> { async function authedId(c: Context<App>): Promise<number | null> {
@@ -61,7 +61,7 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
// Root health check (the C# source returned a placeholder string here). // Root health check.
.get('/', (c) => c.json({ service: 'accounts', status: 'ok' })) .get('/', (c) => c.json({ service: 'accounts', status: 'ok' }))
// ---- Self account -------------------------------------------------------- // ---- Self account --------------------------------------------------------
@@ -70,11 +70,10 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default. // Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
// The C# `SelfAccount` marks `JuniorState` (an enum) and `ParentAccountId` // `JuniorState` (an enum) and `ParentAccountId` are OMITTED when null —
// with `[JsonIgnore(WhenWritingNull)]`, so they're OMITTED when null —
// emitting `"juniorState":null` makes the client's enum parser throw // emitting `"juniorState":null` makes the client's enum parser throw
// ("Can't parse JSON to Enum format"). `Email`/`Phone`/`Birthday` are kept // ("Can't parse JSON to Enum format"). `Email`/`Phone`/`Birthday` are kept
// as null (the C# has no JsonIgnore on those, and they aren't enums). // as null (they aren't enums, so null is fine).
return c.json({ return c.json({
...account, ...account,
Email: null, Email: null,
@@ -87,7 +86,7 @@ const app = new Hono<App>()
// ---- Bulk / single lookup ------------------------------------------------ // ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route. // Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', async (c) => { .get('/account/bulk', async (c) => {
// C# reads repeated `id` query params; also accept a comma-separated list. // Reads repeated `id` query params; also accept a comma-separated list.
const ids = const ids =
c.req c.req
.queries('id') .queries('id')
@@ -95,7 +94,7 @@ const app = new Hono<App>()
.map((s) => Number.parseInt(s.trim(), 10)) .map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? [] .filter((n) => !Number.isNaN(n)) ?? []
// Resolve stored accounts, synthesizing a default for any id not in the DB // Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response (matches the C#). // so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.AccountId, a])) const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.AccountId, a]))
return c.json(ids.map((id) => stored.get(id) ?? defaultAccount(id))) return c.json(ids.map((id) => stored.get(id) ?? defaultAccount(id)))
}) })
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
@@ -12,7 +12,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://accounts.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Apply the accounts schema + seed the system (uid 0) and Coach (uid 1) accounts // Apply the accounts schema + seed the system (uid 0) and Coach (uid 1) accounts
// into the test D1 (mirrors apps/auth/migrations/0001_accounts.sql). // into the test D1 (mirrors apps/auth/migrations/0001_accounts.sql).
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "accounts.rec.djdevin.net", "pattern": "accounts.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+4 -4
View File
@@ -1,14 +1,14 @@
# api # api
Game API Worker served at `api.rec.djdevin.net`. A Hono app ported from the C# Game API Worker served on the `api` subdomain. A Hono app serving the game's
`APIController`. EF Core (`AppDbContext`) queries and on-disk JSON files are API surface. Database-backed queries and on-disk JSON files are stubbed for now
stubbed for now — no real bindings yet. — no real bindings yet.
## Behavior ## Behavior
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker - **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid. (same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
- **Static data** that was inline in the C# is ported faithfully: - **Static data** is served verbatim:
- `src/default-avatar-items.ts``GET /api/avatar/v4/items` - `src/default-avatar-items.ts``GET /api/avatar/v4/items`
- `src/default-settings.ts``GET /api/settings/v2` - `src/default-settings.ts``GET /api/settings/v2`
- **DB-backed reads** return empty collections / not-found. - **DB-backed reads** return empty collections / not-found.
+23 -23
View File
@@ -18,8 +18,8 @@ import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/** /**
* Ported from the C# `APIController`. Endpoints that the C# backs with EF Core * The Game API surface. Endpoints that would be backed by a database or on-disk
* (`AppDbContext`) or on-disk JSON files are stubbed here — no bindings yet. * JSON files are stubbed here — no bindings yet.
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
* *
* Placeholder responses for file-backed endpoints are marked `TODO: hydrate`. * Placeholder responses for file-backed endpoints are marked `TODO: hydrate`.
@@ -37,7 +37,7 @@ const SavedImageType = {
/** /**
* Resolve the account id from a Bearer token, mirroring the repeated * Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check in the C#. Returns `null` when the header is missing, * auth-header check. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer. * the token is invalid, or the `sub` claim isn't an integer.
*/ */
async function authedId(c: Context<App>): Promise<number | null> { async function authedId(c: Context<App>): Promise<number | null> {
@@ -57,7 +57,7 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401) return c.body(null, 401)
} }
/** Mirror of the C# `ParseFormIds` helper — reads the `Ids` form field. */ /** Reads the `Ids` form field into a list of integer ids. */
async function parseFormIds(c: Context<App>): Promise<number[]> { async function parseFormIds(c: Context<App>): Promise<number[]> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids const ids = body.Ids
@@ -83,9 +83,9 @@ function queryIds(c: Context<App>): number[] {
/** /**
* Photon access-token response (`/roomserver/photon_access_token`). The 2023 * Photon access-token response (`/roomserver/photon_access_token`). The 2023
* client calls this to get its room permissions + the instance id it's spawning * client calls this to get its room permissions + the instance id it's spawning
* into; a 404 here leaves the player stuck on a black screen. Mirrors the FemRec * into; a 404 here leaves the player stuck on a black screen. `PhotonAccessToken`
* reference (`PhotonAccessToken` is empty — the client uses its baked-in Photon * is empty — the client uses its baked-in Photon credentials. Our synthesized
* credentials). Our synthesized instances always use roomInstanceId 1. * instances always use roomInstanceId 1.
*/ */
function photonAccessToken() { function photonAccessToken() {
const perm = (Permission: string, Role: number, Override: boolean) => ({ const perm = (Permission: string, Role: number, Override: boolean) => ({
@@ -114,7 +114,7 @@ function photonAccessToken() {
} }
} }
/** Default reputation for an account — the fallback the C# fills with no DB. */ /** Default reputation for an account — the fallback used with no DB. */
function defaultReputation(id: number) { function defaultReputation(id: number) {
return { return {
AccountId: id, AccountId: id,
@@ -198,8 +198,8 @@ const app = new Hono<App>({ strict: false })
return c.json({ PlayerId: id, Level: 1, XP: 0 }) return c.json({ PlayerId: id, Level: 1, XP: 0 })
}) })
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json .post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
// Synthesize a default reputation per requested id (the C#'s intended // Synthesize a default reputation per requested id (the intended behavior;
// behavior; its DB-less fallback reads a static JSON file instead). // the DB-less fallback reads a static JSON file instead).
.post('/api/playerReputation/v2/bulk', async (c) => { .post('/api/playerReputation/v2/bulk', async (c) => {
const ids = await parseFormIds(c) const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation)) return c.json(ids.map(defaultReputation))
@@ -210,13 +210,13 @@ const app = new Hono<App>({ strict: false })
await parseFormIds(c) // TODO: query PlayerProgressions for these ids await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([]) return c.json([])
}) })
// C# v2 is identical to v1 — same ParseFormIds + PlayerProgressions query. // v2 is identical to v1 — same form-id parse + PlayerProgressions query.
.post('/api/players/v2/progression/bulk', async (c) => { .post('/api/players/v2/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([]) return c.json([])
}) })
// The 2023 client calls this as a GET with repeated `id` query params (the // The 2023 client calls this as a GET with repeated `id` query params.
// FemRec reference). Return a default progression per requested id. // Return a default progression per requested id.
.get('/api/players/v2/progression/bulk', (c) => .get('/api/players/v2/progression/bulk', (c) =>
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 }))) c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
) )
@@ -244,8 +244,8 @@ const app = new Hono<App>({ strict: false })
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const update = await c.req.json<Record<string, unknown>>().catch(() => null) const update = await c.req.json<Record<string, unknown>>().catch(() => null)
if (update === null) return c.body(null, 400) if (update === null) return c.body(null, 400)
// TODO: persist; echo the accepted avatar back like the C# does. Fall back to // TODO: persist; echo the accepted avatar back. Fall back to the valid
// the valid default avatar fields when the client omits them. // default avatar fields when the client omits them.
return c.json({ return c.json({
OwnerAccountId: id, OwnerAccountId: id,
OutfitSelections: update.OutfitSelections ?? defaultAvatar.OutfitSelections, OutfitSelections: update.OutfitSelections ?? defaultAvatar.OutfitSelections,
@@ -274,7 +274,7 @@ const app = new Hono<App>({ strict: false })
const message = typeof body.Message === 'string' ? body.Message : '' const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0 const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
// No EarnableRewards binding → always fall back to a token gift (C# branch). // No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500] const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)] const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
@@ -310,18 +310,18 @@ const app = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'Gift not found' }, 404) return c.json({ success: false, error: 'Gift not found' }, 404)
}) })
// Custom avatar item gates. None of these are in CannedNet — they're real Rec // Custom avatar item gates — real Rec Room client endpoints with no backing
// Room client endpoints the C# never implemented. Each returns a bare JSON // implementation yet. Each returns a bare JSON boolean; we enable them. Flip
// boolean; we enable them. Flip to `false` to disable the corresponding flow. // to `false` to disable the corresponding flow.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true)) .get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true)) .get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true)) .get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
// Voice chat config. Not in CannedNet; the client fetches it to set up voice. // Voice chat config. The client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields. // No reference shape, so return an empty object until the client needs fields.
.get('/voice/config', (c) => c.json({})) .get('/voice/config', (c) => c.json({}))
// ---- 2023 client loading-path endpoints (from the FemRec reference) -------- // ---- 2023 client loading-path endpoints ------------------------------------
// NUX checklist + saved inventions — empty lists with no DB. // NUX checklist + saved inventions — empty lists with no DB.
.get('/api/checklist/v1/current', async (c) => { .get('/api/checklist/v1/current', async (c) => {
const id = await authedId(c) const id = await authedId(c)
@@ -367,7 +367,7 @@ const app = new Hono<App>({ strict: false })
.get('/api/settings/v2', async (c) => { .get('/api/settings/v2', async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
// TODO: load stored settings; seed defaults on first access like the C#. // TODO: load stored settings; seed defaults on first access.
return c.json(defaultSettings(id)) return c.json(defaultSettings(id))
}) })
.post('/api/settings/v2/set', async (c) => { .post('/api/settings/v2/set', async (c) => {
@@ -426,7 +426,7 @@ const app = new Hono<App>({ strict: false })
return c.json([]) // TODO: query PlayerBios return c.json([]) // TODO: query PlayerBios
}) })
.post('/api/accounts/v1/forplatformids', async (c) => { .post('/api/accounts/v1/forplatformids', async (c) => {
await parseFormIds(c) // C# reads `Ids` then looks up CachedLogins await parseFormIds(c) // reads `Ids` then looks up CachedLogins
return c.json([]) return c.json([])
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* Default avatar items, ported verbatim from the inline list in the C# * Default avatar items for `GET /api/avatar/v4/items`.
* `GET /api/avatar/v4/items`. Stored as `[AvatarItemDesc, FriendlyName, Rarity?]` * Stored as `[AvatarItemDesc, FriendlyName, Rarity?]`
* tuples — every entry shares `AvatarItemType: 0`, `PlatformMask: -1`, `Tooltip: ""`, * tuples — every entry shares `AvatarItemType: 0`, `PlatformMask: -1`, `Tooltip: ""`,
* and `Rarity` defaults to `0`. * and `Rarity` defaults to `0`.
*/ */
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* Default player settings, ported from the inline defaults in the C# * Default player settings for `GET /api/settings/v2`, seeded when a player has
* `GET /api/settings/v2`. The C# seeds these when a player has no stored settings. * no stored settings.
*/ */
export interface PlayerSetting { export interface PlayerSetting {
PlayerId: number PlayerId: number
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
+4 -3
View File
@@ -12,7 +12,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://api.rec.djdevin.net' const ORIGIN = 'https://example.com'
// The /roomserver/rooms/* routes read from the shared rec-rooms D1. Set up the // The /roomserver/rooms/* routes read from the shared rec-rooms D1. Set up the
// schema (matching the rooms worker's migration) + a couple of rooms for tests. // schema (matching the rooms worker's migration) + a couple of rooms for tests.
@@ -266,13 +266,14 @@ describe('auth-gated endpoints', () => {
expect(items).toHaveLength(DEFAULT_AVATAR_ITEMS.length) expect(items).toHaveLength(DEFAULT_AVATAR_ITEMS.length)
}) })
test('GET /api/settings/v2 seeds defaults for the account', async () => { test('GET /api/settings/v2 returns the default settings for the account', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/settings/v2`, { const res = await exports.default.fetch(`${ORIGIN}/api/settings/v2`, {
headers: await bearer(), headers: await bearer(),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const settings = (await res.json()) as Array<{ PlayerId: number; Key: string }> const settings = (await res.json()) as Array<{ PlayerId: number; Key: string }>
expect(settings[0]).toMatchObject({ PlayerId: 42, Key: 'Recroom.OOBE' }) expect(Array.isArray(settings)).toBe(true)
for (const s of settings) expect(s.PlayerId).toBe(42)
}) })
test('GET /api/avatar/v2 returns a default avatar', async () => { test('GET /api/avatar/v2 returns a default avatar', async () => {
+1 -1
View File
@@ -369,5 +369,5 @@
"MicSpamSamplePercentageForForceMuteToEnd": 0.2, "MicSpamSamplePercentageForForceMuteToEnd": 0.2,
"MicSpamWarningStateVolumeMultiplier": 0.25 "MicSpamWarningStateVolumeMultiplier": 0.25
}, },
"ShareBaseUrl": "https://www.rec.djdevin.net/{0}" "ShareBaseUrl": "https://www.rec.example.com/{0}"
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "api.rec.djdevin.net", "pattern": "api.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+4 -4
View File
@@ -1,8 +1,8 @@
# auth # auth
Auth Worker served at `auth.rec.djdevin.net`. A Hono app ported from the C# Auth Worker served on the `auth` subdomain. A Hono app handling authentication.
`AuthController`. Binding-dependent behavior (EF Core `AppDbContext` queries) is Binding-dependent behavior (database queries) is stubbed for now — no real
stubbed for now — no real KV/D1/DO bindings yet. KV/D1/DO bindings yet.
## Routes ## Routes
@@ -21,4 +21,4 @@ stubbed for now — no real KV/D1/DO bindings yet.
filesystem) — replace `EAC_CHALLENGE` with the real challenge text. filesystem) — replace `EAC_CHALLENGE` with the real challenge text.
- `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB - `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB
binding to be implemented. binding to be implemented.
- `/role/developer/:id` is a stub, matching the C# `// TODO: implement`. - `/role/developer/:id` is a stub (`// TODO: implement`).
+2 -2
View File
@@ -21,7 +21,7 @@ export const SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`, `CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`,
] ]
/** Client-facing account shape (PascalCase, matches the C# `Account`). */ /** Client-facing account shape (PascalCase, as the client expects). */
export interface Account { export interface Account {
AccountId: number AccountId: number
Username: string Username: string
@@ -61,7 +61,7 @@ export function randomUsername(): string {
} }
/** /**
* Build a full account object from an id, applying the C# fallbacks for any * Build a full account object from an id, applying default fallbacks for any
* column the caller doesn't override. Used both to synthesize accounts that * column the caller doesn't override. Used both to synthesize accounts that
* aren't in the DB and as the base for a freshly created account. * aren't in the DB and as the base for a freshly created account.
*/ */
+4 -4
View File
@@ -12,7 +12,7 @@ import type { App } from './context'
const TOKEN_SCOPE = const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/** C# `PlatformType` enum names by value, used for the token's `platform` claim. */ /** Platform-type enum names by value, used for the token's `platform` claim. */
const PLATFORM_TYPES: Record<number, string> = { const PLATFORM_TYPES: Record<number, string> = {
[-1]: 'All', [-1]: 'All',
0: 'Steam', 0: 'Steam',
@@ -131,8 +131,8 @@ const app = new Hono<App>()
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post('/connect/token', async (c) => { .post('/connect/token', async (c) => {
// The C# reads `grant_type`, `account_id`, `platform_id` and `platform` from // Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
// the form body. // form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : '' const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
const platformId = typeof body.platform_id === 'string' ? body.platform_id : '' const platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
@@ -169,7 +169,7 @@ const app = new Hono<App>()
}) })
}) })
// Developer role lookup. Not implemented in the C# source either. // Developer role lookup. Not implemented yet.
.get('/role/developer/:id', (c) => { .get('/role/developer/:id', (c) => {
const { id } = c.req.param() const { id } = c.req.param()
logger.info('developer role lookup', { id }) logger.info('developer role lookup', { id })
+4 -5
View File
@@ -1,12 +1,12 @@
/** /**
* Minimal HS256 JWT generation, mirroring the C# `JwtTokenService.GenerateToken`. * Minimal HS256 JWT generation.
* *
* No real signing-key binding yet — uses a placeholder dev secret. Swap this for * No real signing-key binding yet — uses a placeholder dev secret. Swap this for
* a secret binding (e.g. `c.env.JWT_SECRET`) before this is used for anything real. * a secret binding (e.g. `c.env.JWT_SECRET`) before this is used for anything real.
*/ */
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const DEV_SECRET = 'dev-insecure-signing-key-change-me'
/** Token lifetime in seconds (matches `expires_in` in the C# response). */ /** Token lifetime in seconds (mirrored in the `expires_in` response field). */
export const TOKEN_TTL_SECONDS = 3600 export const TOKEN_TTL_SECONDS = 3600
function base64url(input: ArrayBuffer | string): string { function base64url(input: ArrayBuffer | string): string {
@@ -18,7 +18,7 @@ function base64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }
/** Scopes the C# `JwtTokenService` stamps onto every token (as a claim array). */ /** Scopes stamped onto every token (as a claim array). */
const TOKEN_SCOPES = [ const TOKEN_SCOPES = [
'profile', 'profile',
'rn', 'rn',
@@ -36,7 +36,7 @@ const TOKEN_SCOPES = [
'offline_access', 'offline_access',
] ]
/** Roles the C# grants — the client needs `gameClient` to operate. */ /** Roles granted — the client needs `gameClient` to operate. */
const TOKEN_ROLES = ['gameClient', 'developer', 'moderator'] const TOKEN_ROLES = ['gameClient', 'developer', 'moderator']
export async function generateToken( export async function generateToken(
@@ -47,7 +47,6 @@ export async function generateToken(
): Promise<string> { ): Promise<string> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const header = { alg: 'HS256', typ: 'JWT' } const header = { alg: 'HS256', typ: 'JWT' }
// Mirror the claim set produced by the C# `JwtTokenService.GenerateToken`.
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to // The client reads `role`/`scope` (and expects a well-formed iss/aud) to
// authorize itself; a token with only `sub` is rejected before login finishes. // authorize itself; a token with only `sub` is rejected before login finishes.
const payload = { const payload = {
+2 -2
View File
@@ -12,7 +12,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://auth.rec.djdevin.net' const ORIGIN = 'https://example.com'
// The Orientation room (RoomId 13) new accounts are placed into on signup. // The Orientation room (RoomId 13) new accounts are placed into on signup.
const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8' const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
@@ -63,7 +63,7 @@ describe('auth worker routes', () => {
const res = await exports.default.fetch(`${ORIGIN}/eac/challenge`) const res = await exports.default.fetch(`${ORIGIN}/eac/challenge`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/plain') expect(res.headers.get('content-type')).toContain('text/plain')
// Matches the C#'s JSON/eacchallenge.txt content (BOM is stripped on read). // EAC challenge content (BOM is stripped on read).
expect(await res.text()).toBe('"AA=="') expect(await res.text()).toBe('"AA=="')
}) })
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "auth.rec.djdevin.net", "pattern": "auth.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+11 -13
View File
@@ -10,9 +10,9 @@ import type { Context } from 'hono'
import type { App, Env } from './context' import type { App, Env } from './context'
/** /**
* Ported from the C# `CDNController`. The class `[Route("cdn")]` prefix maps to * CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
* this worker's subdomain, so method routes are served bare. File-backed routes * are served bare. File-backed routes (`sigs`, `upload`) have no storage binding
* (`sigs`, `upload`) have no storage binding yet and are stubbed. * yet and are stubbed.
*/ */
/** /**
@@ -46,9 +46,8 @@ function parseRange(header: string | undefined): R2Range | undefined {
} }
/** /**
* Stream a binary asset from the CDN R2 bucket as application/octet-stream * Stream a binary asset from the CDN R2 bucket as application/octet-stream,
* (matching the C#'s `Results.File(..., "application/octet-stream")`, which also * honoring Range requests. 404s when the file is missing.
* honors Range requests). The C# 404s when the file is missing; so do we.
* Supports conditional GET and byte-range requests (206) — large-file * Supports conditional GET and byte-range requests (206) — large-file
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the * downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
* reassembled file (e.g. EAC "Signatures don't match"). * reassembled file (e.g. EAC "Signatures don't match").
@@ -110,19 +109,18 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'cdn', status: 'ok' })) .get('/', (c) => c.json({ service: 'cdn', status: 'ok' }))
// Loading-screen tips. The C# serves JSON/loadingscreentipdata.json; bundled // Loading-screen tips, bundled here as static JSON.
// here as static JSON.
.get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData)) .get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData))
// Signature blobs by name (C#: Sigs/ directory). Streamed from R2 under the // Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
// `sigs/` key prefix; 404 when missing. // 404 when missing.
.get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)) .get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`))
// Room build data by name (C#: Data/DataBlobs/). The client fetches this for // Room build data by name. The client fetches this for a SubRoom's DataBlob to
// a SubRoom's DataBlob to load the room. Streamed from R2 under `room/`. // load the room. Streamed from R2 under `room/`.
.get('/room/:dataBlob', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)) .get('/room/:dataBlob', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
// Image upload. [Authorize] in the C#; returns the saved filename. No storage // Image upload. Auth-gated; returns the saved filename. No storage
// binding yet, so we accept the file and return a synthesized filename without // binding yet, so we accept the file and return a synthesized filename without
// persisting it. TODO: write to an R2 bucket like the `img` worker. // persisting it. TODO: write to an R2 bucket like the `img` worker.
.post('/upload', async (c) => { .post('/upload', async (c) => {
+1 -2
View File
@@ -3,8 +3,7 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
export type Env = SharedHonoEnv & { export type Env = SharedHonoEnv & {
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and // R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>` (mirrors the C#'s Sigs/ and // room build data under `room/<name>`.
// Data/DataBlobs/ directories).
CDN_ASSETS: R2Bucket CDN_ASSETS: R2Bucket
} }
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
+1 -1
View File
@@ -10,7 +10,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://cdn.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, using the same dev secret.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -8,7 +8,7 @@
], ],
"routes": [ "routes": [
{ {
"pattern": "cdn.rec.djdevin.net", "pattern": "cdn.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+2 -3
View File
@@ -1,10 +1,9 @@
# chat # chat
Chat worker served at `chat.rec.djdevin.net`. Chat worker served on the `chat` subdomain.
- `GET /` — service status `{ "service": "chat", "status": "ok" }`. - `GET /` — service status `{ "service": "chat", "status": "ok" }`.
- `GET /thread` — chat threads. No DB binding yet, so returns `[]` (matching the - `GET /thread` — chat threads. No DB binding yet, so returns `[]`.
C# `ChatController.Get`).
## Development ## Development
+1 -1
View File
@@ -21,7 +21,7 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'chat', status: 'ok' })) .get('/', (c) => c.json({ service: 'chat', status: 'ok' }))
// Chat threads. No DB binding yet — the C# `ChatController.Get` returns `[]`. // Chat threads. No DB binding yet — returns `[]`.
.get('/thread', (c) => c.json([])) .get('/thread', (c) => c.json([]))
export default app export default app
+1 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import '../../chat.app' import '../../chat.app'
const ORIGIN = 'https://chat.rec.djdevin.net' const ORIGIN = 'https://example.com'
describe('chat endpoints', () => { describe('chat endpoints', () => {
it('GET / reports service status', async () => { it('GET / reports service status', async () => {
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "chat.rec.djdevin.net", "pattern": "chat.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+3 -4
View File
@@ -1,12 +1,11 @@
# clubs # clubs
Clubs Worker served at `clubs.rec.djdevin.net`. A Hono app ported from the C# Clubs Worker served on the `clubs` subdomain. A Hono app for clubs.
`ClubsController`.
## Behavior ## Behavior
- `GET /club/home/me` — returns 404. The C# source returned `Results.NotFound()` - `GET /club/home/me` — returns 404 unconditionally; there's nothing to hydrate
unconditionally; there's nothing to hydrate yet. yet.
## TODO before production ## TODO before production
+10 -10
View File
@@ -9,8 +9,8 @@ import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/** /**
* Ported from the C# `ClubsController`. The only endpoint is `[Authorize]` and * The only endpoint is auth-gated and returns 404 unconditionally — no DB
* then returns `Results.NotFound()` unconditionally — no DB binding involved. * binding involved.
*/ */
/** /**
@@ -43,22 +43,22 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
// [Authorize] → 401 without a valid token. The C# returns NotFound here, but // Auth-gated → 401 without a valid token. A bare 404 here makes the client
// the client treats that 404 as an error, so we return an empty object stub. // treat it as an error, so we return an empty object stub.
.get('/club/home/me', async (c) => { .get('/club/home/me', async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
return c.json({}) return c.json({})
}) })
// Not present in CannedNet — a real Rec Room client endpoint the C# never // A real Rec Room client endpoint with no backing implementation yet. The
// implemented. The client calls it on the clubs host at /subscription/mine/member // client calls it on the clubs host at /subscription/mine/member (no /club
// (no /club prefix) and sends no auth header, so it isn't gated. Returns an // prefix) and sends no auth header, so it isn't gated. Returns an empty
// empty array = no club subscription memberships (the client chokes on null). // array = no club subscription memberships (the client chokes on null).
.get('/subscription/mine/member', (c) => c.json([])) .get('/subscription/mine/member', (c) => c.json([]))
// Details for a given subscription. Also not in CannedNet; the client // Details for a given subscription. The client deserializes this into an
// deserializes this into an object, so it must return `{}` (not `[]`). // object, so it must return `{}` (not `[]`).
.get('/subscription/details/:subscription', (c) => c.json({})) .get('/subscription/details/:subscription', (c) => c.json({}))
// The player's clubs that have unread announcements (MyClubsWithUnread- // The player's clubs that have unread announcements (MyClubsWithUnread-
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
+1 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'
import '../../clubs.app' import '../../clubs.app'
const ORIGIN = 'https://clubs.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, using the same dev secret.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "clubs.rec.djdevin.net", "pattern": "clubs.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+4 -4
View File
@@ -6,8 +6,8 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
import type { App } from './context' import type { App } from './context'
/** /**
* Ported from the C# `CommerceController`. The class `[Route("commerce")]` prefix * Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
* maps to this worker's subdomain, so method routes are served bare. * method routes are served bare.
*/ */
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
@@ -25,8 +25,8 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'commerce', status: 'ok' })) .get('/', (c) => c.json({ service: 'commerce', status: 'ok' }))
// Whether the player has ever spent money. The C# returns NotFound(), but the // Whether the player has ever spent money. A 404 here makes the client treat
// client treats that 404 as an error, so we return `false` (no purchases). // it as an error, so we return `false` (no purchases).
.get('/purchase/v1/hasspentmoney', (c) => c.json(false)) .get('/purchase/v1/hasspentmoney', (c) => c.json(false))
export default app export default app
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import '../../commerce.app' import '../../commerce.app'
const ORIGIN = 'https://commerce.rec.djdevin.net' const ORIGIN = 'https://example.com'
describe('commerce endpoints', () => { describe('commerce endpoints', () => {
it('GET / reports service status', async () => { it('GET / reports service status', async () => {
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "commerce.rec.djdevin.net", "pattern": "commerce.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import '../../datacollection.app' import '../../datacollection.app'
const ORIGIN = 'https://datacollection.rec.djdevin.net' const ORIGIN = 'https://example.com'
describe('datacollection endpoints', () => { describe('datacollection endpoints', () => {
it('GET / reports service status', async () => { it('GET / reports service status', async () => {
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "datacollection.rec.djdevin.net", "pattern": "datacollection.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+10 -10
View File
@@ -1,6 +1,6 @@
# econ # econ
Economy Worker served at `econ.rec.djdevin.net`. Hosts the avatar/economy Economy Worker served on the `econ` subdomain. Hosts the avatar/economy
endpoints the game client calls on the `econ` service (distinct from the main endpoints the game client calls on the `econ` service (distinct from the main
`api` worker). DB-backed data is stubbed for now — no bindings yet. `api` worker). DB-backed data is stubbed for now — no bindings yet.
@@ -8,21 +8,21 @@ endpoints the game client calls on the `econ` service (distinct from the main
- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served - `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served
from the bundled `static/default-avatar-items.json` catalog. from the bundled `static/default-avatar-items.json` catalog.
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. The C# - `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads
reads the same source file as `defaultunlocked`, so it returns the identical the same source file as `defaultunlocked`, so it returns the identical
catalog. catalog.
- `GET /api/avatar/v4/items``[Authorize]`. The player's avatar items: owned - `GET /api/avatar/v4/items``[Authorize]`. The player's avatar items: owned
items concatenated with the default catalog. No DB binding yet, so owned is items concatenated with the default catalog. No DB binding yet, so owned is
empty and this returns just the catalog. empty and this returns just the catalog.
- `GET /api/avatar/v2``[Authorize]`. The player's avatar. No DB binding yet, - `GET /api/avatar/v2``[Authorize]`. The player's avatar. No DB binding yet,
so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor, so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor,
HairColor }` the C# seeds for a new player. HairColor }` seeded for a new player.
- `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar - `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar
items. No auth (matching the C#); returns `{ items: [] }` with no DB binding. items. No auth; returns `{ items: [] }` with no DB binding.
The client requests this when custom-item creation is allowed, so a missing The client requests this when custom-item creation is allowed, so a missing
route here shows up as "Failed to download unlocked avatar items". route here shows up as "Failed to download unlocked avatar items".
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (matching - `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a
the C#, which serves a static JSON file verbatim); returns the bundled static JSON file verbatim); returns the bundled
`static/my-progress.json` default for all players until a DB binding exists. `static/my-progress.json` default for all players until a DB binding exists.
- `GET /api/avatar/v3/saved``[Authorize]`. Saved outfits; `[]` without a DB. - `GET /api/avatar/v3/saved``[Authorize]`. Saved outfits; `[]` without a DB.
- `GET /api/avatar/v2/gifts``[Authorize]`. Pending gifts; `[]` without a DB. - `GET /api/avatar/v2/gifts``[Authorize]`. Pending gifts; `[]` without a DB.
@@ -33,15 +33,15 @@ HairColor }` the C# seeds for a new player.
- `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from - `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from
the bundled `static/storefronts-v3-giftdropstore-3.json`. the bundled `static/storefronts-v3-giftdropstore-3.json`.
- `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the - `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the
bundled `static/weekly-challenge.json` (the C#'s `JSON/weeklychallenge.json`). bundled `static/weekly-challenge.json`.
- `GET /api/gamerewards/v1/pending` — pending rewards; `[]`. - `GET /api/gamerewards/v1/pending` — pending rewards; `[]`.
- `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`. - `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`.
- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup; - `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup;
`{ subscription: null, platformAccountSubscribedPlayerId: null }`. `{ subscription: null, platformAccountSubscribedPlayerId: null }`.
- Not in CannedNet (stubbed): `GET /api/roomconsumables/v1/roomConsumable/room/:id` - Stubbed: `GET /api/roomconsumables/v1/roomConsumable/room/:id`
and `GET /api/roomcurrencies/v1/currencies` both return `[]`. and `GET /api/roomcurrencies/v1/currencies` both return `[]`.
These EconController routes are also served by the `api` worker; they're These economy routes are also served by the `api` worker; they're
duplicated here because the client calls them on the `econ` host. duplicated here because the client calls them on the `econ` host.
## TODO before production ## TODO before production
+20 -20
View File
@@ -59,8 +59,8 @@ const app = new Hono<App>()
// Default-unlocked avatar items, served from the bundled static JSON. // Default-unlocked avatar items, served from the bundled static JSON.
.get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems)) .get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems))
// Default base avatar items. The C# reads the same JSON/defaultAvatarItems.json // Default base avatar items. Reads the same source file as defaultunlocked,
// file as defaultunlocked, so it returns the identical catalog. // so it returns the identical catalog.
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json(defaultAvatarItems)) .get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json(defaultAvatarItems))
// The player's avatar items — owned items concatenated with the default // The player's avatar items — owned items concatenated with the default
@@ -72,18 +72,18 @@ const app = new Hono<App>()
return c.json(defaultAvatarItems) return c.json(defaultAvatarItems)
}) })
// The player's owned custom avatar items. No auth in the C#, which returns // The player's owned custom avatar items. No auth; returns `{ items: [] }`.
// `{ items: [] }`. The client downloads these when custom-item creation is // The client downloads these when custom-item creation is
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items". // allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
.get('/econ/customAvatarItems/v1/owned', (c) => c.json({ items: [] })) .get('/econ/customAvatarItems/v1/owned', (c) => c.json({ items: [] }))
// The player's objectives progress. The C# serves a static JSON file // The player's objectives progress. Serves a static JSON file verbatim with
// (JSON/tempmyprogress.json) verbatim with no auth — same default for everyone // no auth — same default for everyone until there's a DB binding to track
// until there's a DB binding to track per-player progress. // per-player progress.
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress)) .get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
// The player's avatar. No DB binding yet, so it always returns the default // The player's avatar. No DB binding yet, so it always returns the default
// the C# seeds for a player with no PlayerAvatar row. // for a player with no PlayerAvatar row.
.get('/api/avatar/v2', async (c) => { .get('/api/avatar/v2', async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
@@ -117,18 +117,18 @@ const app = new Hono<App>()
return c.json([]) return c.json([])
}) })
// Unlocked equipment. The C# returns "[]" with no auth. // Unlocked equipment. Returns "[]" with no auth.
.get('/api/equipment/v2/getUnlocked', (c) => c.json([])) .get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
// Not in CannedNet — room consumables/currencies for a given room. Stubbed // Room consumables/currencies for a given room. Stubbed as empty lists so the
// as empty lists so the client doesn't 404. // client doesn't 404.
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (c) => c.json([])) .get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (c) => c.json([]))
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId/me', (c) => c.json([])) .get('/api/roomconsumables/v1/roomConsumable/room/:roomId/me', (c) => c.json([]))
.get('/api/roomcurrencies/v1/currencies', (c) => c.json([])) .get('/api/roomcurrencies/v1/currencies', (c) => c.json([]))
.get('/api/roomcurrencies/v1/getAllBalances', (c) => c.json([])) .get('/api/roomcurrencies/v1/getAllBalances', (c) => c.json([]))
// Persist player settings. [Authorize]; the C# replaces the player's settings // Persist player settings. [Authorize]; would replace the player's settings.
// and returns Ok(). No DB binding yet, so accept-and-ack. // No DB binding yet, so accept-and-ack.
.post('/api/settings/v2/set', async (c) => { .post('/api/settings/v2/set', async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
@@ -152,23 +152,23 @@ const app = new Hono<App>()
return c.json([]) return c.json([])
}) })
// Gift-drop storefront. The C# falls back to JSON/storefront3.json when no // Gift-drop storefront. Falls back to the bundled static catalog when no
// storefront row exists; that's the bundled static catalog here. // storefront row exists.
.get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3)) .get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3))
// Current weekly challenge. Served from the bundled static JSON (the C#'s // Current weekly challenge. Served from the bundled static JSON until
// JSON/weeklychallenge.json) until per-rotation challenge data is wired up. // per-rotation challenge data is wired up.
.get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge)) .get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge))
// Pending game rewards. The C# returns "[]". // Pending game rewards. Returns "[]".
.get('/api/gamerewards/v1/pending', (c) => c.json([])) .get('/api/gamerewards/v1/pending', (c) => c.json([]))
// The player's room keys. The C# returns "[]". // The player's room keys. Returns "[]".
.get('/api/roomkeys/v1/mine', (c) => c.json([])) .get('/api/roomkeys/v1/mine', (c) => c.json([]))
// Room keys for a given room (client calls this on the econ host). [] with no DB. // Room keys for a given room (client calls this on the econ host). [] with no DB.
.get('/api/roomkeys/v1/room', (c) => c.json([])) .get('/api/roomkeys/v1/room', (c) => c.json([]))
// Subscription lookup. The C# returns both fields null with no auth. // Subscription lookup. Returns both fields null with no auth.
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) => .post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
) )
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
+1 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'
import '../../econ.app' import '../../econ.app'
const ORIGIN = 'https://econ.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, using the same dev secret.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "econ.rec.djdevin.net", "pattern": "econ.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+3 -3
View File
@@ -1,6 +1,6 @@
# img # img
Image-delivery worker served at `img.rec.djdevin.net`. Image-delivery worker served on the `img` subdomain.
Images are stored as objects in the **`rec-img` R2 bucket** and streamed back by Images are stored as objects in the **`rec-img` R2 bucket** and streamed back by
key: key:
@@ -12,8 +12,8 @@ key:
conditional requests via `If-None-Match` (returns `304`). Missing keys `404`. conditional requests via `If-None-Match` (returns `304`). Missing keys `404`.
- `GET /<key>?sig=p1` — same, but the response body is RSA-SHA1 signed and the - `GET /<key>?sig=p1` — same, but the response body is RSA-SHA1 signed and the
signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>` signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`
header (mirrors the C# `ImageController` / `Signatures`). The client uses this header. The client uses this to verify image integrity. Signing buffers the
to verify image integrity. Signing buffers the whole object. whole object.
The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`). The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`).
+3 -3
View File
@@ -29,7 +29,7 @@ function getSigningKey(env: Env): Promise<CryptoKey | null> {
return signingKey return signingKey
} }
/** RSA-SHA1 sign the bytes, base64-encoded — matches the C# `Signatures.Sign`. */ /** RSA-SHA1 sign the bytes, base64-encoded. */
async function signImage(env: Env, bytes: ArrayBuffer): Promise<string | null> { async function signImage(env: Env, bytes: ArrayBuffer): Promise<string | null> {
const key = await getSigningKey(env) const key = await getSigningKey(env)
if (!key) return null if (!key) return null
@@ -60,8 +60,8 @@ const app = new Hono<App>()
// objects. Supports conditional requests via If-None-Match. // objects. Supports conditional requests via If-None-Match.
// //
// When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and // When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and
// the signature returned in a `Content-Signature` header (mirrors the C# // the signature returned in a `Content-Signature` header. Signing requires the
// ImageController). Signing requires the full body, so the object is buffered. // full body, so the object is buffered.
.get('/:key{.+}', async (c) => { .get('/:key{.+}', async (c) => {
const key = c.req.param('key') const key = c.req.param('key')
if (key.includes('..')) return c.body(null, 400) if (key.includes('..')) return c.body(null, 400)
+1 -1
View File
@@ -9,7 +9,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://img.rec.djdevin.net' const ORIGIN = 'https://example.com'
// A tiny valid JPEG magic-number blob — enough to assert round-tripping. // A tiny valid JPEG magic-number blob — enough to assert round-tripping.
const IMAGE_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]) const IMAGE_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46])
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "img.rec.djdevin.net", "pattern": "img.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+4 -5
View File
@@ -1,8 +1,7 @@
# match # match
Matchmaking Worker served at `match.rec.djdevin.net`. A Hono app ported from the Matchmaking Worker served on the `match` subdomain. A Hono app for matchmaking.
C# `MatchmakingController`. EF Core (`AppDbContext`) queries are stubbed for now Database queries are stubbed for now — no real bindings yet.
— no real bindings yet.
## Behavior ## Behavior
@@ -10,7 +9,7 @@ C# `MatchmakingController`. EF Core (`AppDbContext`) queries are stubbed for now
Bearer JWT issued by the `auth` worker (same dev secret, see `src/jwt.ts`) and Bearer JWT issued by the `auth` worker (same dev secret, see `src/jwt.ts`) and
401 when it's missing/invalid. 401 when it's missing/invalid.
- **`GET /player`** always returns the inlined `JSON/getplayer.json` default - **`GET /player`** always returns the inlined `JSON/getplayer.json` default
(the C# fell back to that file when the account/room instance wasn't found). (falls back to that file when the account/room instance isn't found).
- **`POST /goto/none`** returns the static offline-dorm instance with a fresh - **`POST /goto/none`** returns the static offline-dorm instance with a fresh
`photonRoomId`. `photonRoomId`.
- **`POST /goto/room/:room`** synthesizes the room-instance response (no Rooms - **`POST /goto/room/:room`** synthesizes the room-instance response (no Rooms
@@ -19,7 +18,7 @@ C# `MatchmakingController`. EF Core (`AppDbContext`) queries are stubbed for now
- **`POST /player/heartbeat`** echoes the posted heartbeat fields; `roomInstance` - **`POST /player/heartbeat`** echoes the posted heartbeat fields; `roomInstance`
is always null and `isOnline` false until there's a DB binding. is always null and `isOnline` false until there's a DB binding.
- **`/player/login`, `/player/statusvisibility`, `/roominstance/:id/reportjoinresult`** - **`/player/login`, `/player/statusvisibility`, `/roominstance/:id/reportjoinresult`**
return empty 200s, as in the source. return empty 200s.
## TODO before production ## TODO before production
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
+13 -16
View File
@@ -11,17 +11,16 @@ import type { App } from './context'
import type { Room } from './rooms-db' import type { Room } from './rooms-db'
/** /**
* Ported from the C# `MatchmakingController`. Endpoints the C# backs with EF Core * The matchmaking surface. Database-backed endpoints are stubbed here — there's
* (`AppDbContext`) are stubbed here — there's no DB binding yet, so room/player * no DB binding yet, so room/player lookups fall back to default values when
* lookups fall back to the same defaults the C# uses when nothing is found. * nothing is found.
* *
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/ */
/** /**
* Default `/player` payload. The C# serves this from `JSON/getplayer.json` * Default `/player` payload, served whenever the `id` is missing/invalid or the
* whenever the `id` is missing/invalid or the account isn't found; Workers have * account isn't found. Inlined here (Workers have no filesystem).
* no filesystem so it's inlined here.
*/ */
const DEFAULT_GET_PLAYER = [ const DEFAULT_GET_PLAYER = [
{ {
@@ -48,7 +47,7 @@ interface HeartbeatRequest {
/** /**
* Resolve the account id from a Bearer token, mirroring the repeated * Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check in the C#. Returns `null` when the header is missing, * auth-header check. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer. * the token is invalid, or the `sub` claim isn't an integer.
*/ */
async function authedId(c: Context<App>): Promise<number | null> { async function authedId(c: Context<App>): Promise<number | null> {
@@ -89,10 +88,9 @@ interface Presence {
const PRESENCE_TTL = 900 const PRESENCE_TTL = 900
/** /**
* Game build version reported in presence. Like the reference servers (FemRec * Game build version reported in presence. This is a server-side constant — the
* `ServerConfig.GameVersion`, 2025 `HeartbeatDB`), this is a server-side * client doesn't supply it, and an empty value breaks the client's
* constant — the client doesn't supply it, and an empty value breaks the * presence/version handling. Matches our target 2023 client build.
* client's presence/version handling. Matches our target 2023 client build.
*/ */
const GAME_VERSION = '20230302' const GAME_VERSION = '20230302'
@@ -256,8 +254,8 @@ const app = new Hono<App>()
.post('/player/logout', (c) => c.body(null, 200)) .post('/player/logout', (c) => c.body(null, 200))
.get('/player', async (c) => { .get('/player', async (c) => {
// Returns each requested player's presence. The C# reads the `id` query // Returns each requested player's presence. Reads the `id` query param(s);
// param(s); with none it serves the static getplayer.json default. // with none it serves the static getplayer.json default.
const ids = c.req const ids = c.req
.queries('id') .queries('id')
?.flatMap((v) => v.split(',')) ?.flatMap((v) => v.split(','))
@@ -363,8 +361,7 @@ const app = new Hono<App>()
// isn't swallowed by the auth-gated matchmake handler. // isn't swallowed by the auth-gated matchmake handler.
.post('/matchmake/none', async (c) => { .post('/matchmake/none', async (c) => {
const id = await authedId(c) const id = await authedId(c)
// FemRec (our 2023-client target) returns the player's *current* heartbeat // Return the player's *current* heartbeat here rather than forcing the dorm.
// here rather than forcing the dorm (the 2025 server's behavior we'd copied).
// Orientation is a solo room the client establishes via matchmake/none; if we // Orientation is a solo room the client establishes via matchmake/none; if we
// force the dorm, the new player is warped out of Orientation within seconds. // force the dorm, the new player is warped out of Orientation within seconds.
// So: preserve existing presence; only fall back to the offline dorm when the // So: preserve existing presence; only fall back to the offline dorm when the
@@ -396,7 +393,7 @@ const app = new Hono<App>()
const room = c.req.param('room') const room = c.req.param('room')
const joinMode = await readJoinMode(c) const joinMode = await readJoinMode(c)
// The C# dorm check here is "dorm" (goto/room uses "dormroom"). // The dorm check here is "dorm" (goto/room uses "dormroom").
const instance = const instance =
room.toLowerCase() === 'dorm' room.toLowerCase() === 'dorm'
? dormRoomInstance() ? dormRoomInstance()
+1 -1
View File
@@ -10,7 +10,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://match.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Matchmaking into a room resolves its real scene from the shared rec-rooms D1. // Matchmaking into a room resolves its real scene from the shared rec-rooms D1.
// Seed the schema + a couple of rooms (matching the rooms worker's migration). // Seed the schema + a couple of rooms (matching the rooms worker's migration).
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "match.rec.djdevin.net", "pattern": "match.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+6 -9
View File
@@ -1,13 +1,11 @@
# notify # notify
Notifications Worker served at `notify.rec.djdevin.net`. Ported from the C# Notifications Worker served on the `notify` subdomain. Hosts a SignalR hub at
`NotifyController` / `NotificationsHub` / `NotificationService`, which host a `/hub/v1`.
SignalR hub at `/hub/v1`.
The hub is implemented as a **Durable Object** (`NotificationsHub`) speaking the The hub is implemented as a **Durable Object** (`NotificationsHub`) speaking the
SignalR JSON Hub Protocol over a hibernatable WebSocket. A single global DO SignalR JSON Hub Protocol over a hibernatable WebSocket. A single global DO
instance plays the role of the C# static dictionaries (one shared process across instance holds the shared hub state (one process across all connections).
all connections).
## Endpoints ## Endpoints
@@ -27,8 +25,7 @@ all connections).
1. Client `POST /hub/v1/negotiate`, then opens a WebSocket to `/hub/v1?id=<token>`. 1. Client `POST /hub/v1/negotiate`, then opens a WebSocket to `/hub/v1?id=<token>`.
2. Handshake: client sends `{"protocol":"json","version":1}␞`, server replies 2. Handshake: client sends `{"protocol":"json","version":1}␞`, server replies
`{}␞` (`␞` = record separator `0x1e`). `{}␞` (`␞` = record separator `0x1e`).
3. Server immediately sends the `OnConnect` invocation (mirrors the C# 3. Server immediately sends the `OnConnect` invocation on connect.
`OnConnectedAsync`).
4. Client→server invocations: 4. Client→server invocations:
- `SubscribeToPlayers({ playerIds })` — replaces this connection's - `SubscribeToPlayers({ playerIds })` — replaces this connection's
subscriptions and flushes any queued notifications for those players. subscriptions and flushes any queued notifications for those players.
@@ -42,11 +39,11 @@ all connections).
Held in the DO's SQLite so it survives hibernation: Held in the DO's SQLite so it survives hibernation:
- `subscriptions(connectionId, playerId)` — serves both the connection→players - `subscriptions(connectionId, playerId)` — serves both the connection→players
and player→connections lookups from the C#. and player→connections lookups.
- `pending(id, playerId, payload)` — per-player queue delivered once the player - `pending(id, playerId, payload)` — per-player queue delivered once the player
subscribes. subscribes.
A connection's rows are removed on `webSocketClose` (the C# `OnDisconnected`). A connection's rows are removed on `webSocketClose`.
## TODO before production ## TODO before production
+6 -7
View File
@@ -3,9 +3,8 @@ import { DurableObject } from 'cloudflare:workers'
import type { Env } from './context' import type { Env } from './context'
/** /**
* Durable Object hosting the SignalR notifications hub, ported from the C# * Durable Object hosting the SignalR notifications hub. A single global instance
* `NotificationsHub` + `NotificationService`. A single global instance plays the * holds the shared hub state (one process, shared across connections).
* role of the C# static dictionaries (one process, shared across connections).
* *
* It speaks the SignalR JSON Hub Protocol over a hibernatable WebSocket: * It speaks the SignalR JSON Hub Protocol over a hibernatable WebSocket:
* 1. negotiate happens in the worker; the client then opens a WS to `/hub/v1`. * 1. negotiate happens in the worker; the client then opens a WS to `/hub/v1`.
@@ -15,7 +14,7 @@ import type { Env } from './context'
* *
* Connection/subscription state lives in SQLite so it survives hibernation: * Connection/subscription state lives in SQLite so it survives hibernation:
* - `subscriptions(connectionId, playerId)` — both the connection→players and * - `subscriptions(connectionId, playerId)` — both the connection→players and
* (queried the other way) the player→connections maps from the C#. * (queried the other way) the player→connections maps.
* - `pending(id, playerId, payload)` — the per-player queue delivered once a * - `pending(id, playerId, payload)` — the per-player queue delivered once a
* player is subscribed. * player is subscribed.
*/ */
@@ -136,7 +135,7 @@ export class NotificationsHub extends DurableObject<Env> {
state.handshakeDone = true state.handshakeDone = true
ws.serializeAttachment(state) ws.serializeAttachment(state)
// C# OnConnectedAsync sends "OnConnect" to the caller after connecting. // Send "OnConnect" to the caller after connecting.
ws.send(this.invocation('OnConnect', [])) ws.send(this.invocation('OnConnect', []))
} }
@@ -275,8 +274,8 @@ export class NotificationsHub extends DurableObject<Env> {
// ---- Helpers ------------------------------------------------------------- // ---- Helpers -------------------------------------------------------------
/** /**
* Build the `Notification` argument: a JSON string `{ Id, Msg }`, matching the * Build the `Notification` argument: a JSON string `{ Id, Msg }`
* C# `SendToConnection` (null values are dropped from `Msg`). * (null values are dropped from `Msg`).
*/ */
private buildNotificationPayload( private buildNotificationPayload(
notificationType: number, notificationType: number,
+5 -6
View File
@@ -8,15 +8,14 @@ import { NotificationsHub } from './notifications-hub'
import type { App } from './context' import type { App } from './context'
/** /**
* Ported from the C# `NotifyController`, which maps a SignalR hub at `/hub/v1` * Maps a SignalR hub at `/hub/v1`. The hub itself — WebSocket transport, the
* (see `NotificationsHub` / `NotificationService`). The hub itself — WebSocket * SignalR JSON Hub Protocol, and the shared connection state —
* transport, the SignalR JSON Hub Protocol, and the shared connection state —
* lives in the `NotificationsHub` Durable Object; this worker handles the * lives in the `NotificationsHub` Durable Object; this worker handles the
* SignalR negotiate handshake, forwards the WebSocket upgrade to the DO, and * SignalR negotiate handshake, forwards the WebSocket upgrade to the DO, and
* exposes internal send/broadcast endpoints for other workers. * exposes internal send/broadcast endpoints for other workers.
*/ */
/** The hub state is global in the C# (static dictionaries) → one DO instance. */ /** The hub state is global → one DO instance. */
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
const app = new Hono<App>() const app = new Hono<App>()
@@ -57,8 +56,8 @@ const app = new Hono<App>()
}) })
// ---- Internal service-to-service send/broadcast -------------------------- // ---- Internal service-to-service send/broadcast --------------------------
// Lets other workers push notifications, the way the C# controllers called // Lets other workers push notifications through the shared hub.
// the shared NotificationService. TODO: protect these before production. // TODO: protect these before production.
.post('/internal/notify', async (c) => { .post('/internal/notify', async (c) => {
const body = await c.req const body = await c.req
.json<{ playerId?: number; notificationType?: number; data?: Record<string, unknown> }>() .json<{ playerId?: number; notificationType?: number; data?: Record<string, unknown> }>()
+2 -2
View File
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'
import '../../notify.app' import '../../notify.app'
const ORIGIN = 'https://notify.rec.djdevin.net' const ORIGIN = 'https://example.com'
const RS = '\u001e' const RS = '\u001e'
interface HubRecord { interface HubRecord {
@@ -58,7 +58,7 @@ async function connect(
}) })
} }
// Handshake, then the C# OnConnect callback. // Handshake, then the OnConnect callback.
ws.send(`{"protocol":"json","version":1}${RS}`) ws.send(`{"protocol":"json","version":1}${RS}`)
await waitFor((r) => r.type === 1 && r.target === 'OnConnect') await waitFor((r) => r.type === 1 && r.target === 'OnConnect')
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "notify.rec.djdevin.net", "pattern": "notify.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+9 -7
View File
@@ -1,20 +1,22 @@
# ns # ns
Name-server / service-discovery worker served at `ns.rec.djdevin.net`. Name-server / service-discovery worker served on the `ns` subdomain.
`GET /` returns the endpoints document the game client fetches on startup to `GET /` returns the endpoints document the game client fetches on startup to
discover every service host (Accounts, API, Auth, Econ, Matchmaking, discover every service host (Accounts, API, Auth, Econ, Matchmaking,
Notifications, …). Notifications, …).
The document is served from `static/endpoints.json` (a snapshot downloaded from The document is served from `static/endpoints.json`, whose hosts are generated
the live host). **It will be generated dynamically eventually** — e.g. per from the repo-root `env.json` by `runx sync` — every entry is derived from the
environment / from the deployed routes — at which point the static file goes configured base `domain`.
away.
## Updating the snapshot ## Updating endpoints
Change `domain` in `env.json` (or edit the host map in `static/endpoints.json`),
then regenerate:
```sh ```sh
curl -sS https://rec.djdevin.net/ -o apps/ns/static/endpoints.json just sync
``` ```
(Bundled at build time, so a redeploy is required for changes to take effect.) (Bundled at build time, so a redeploy is required for changes to take effect.)
+3 -2
View File
@@ -8,9 +8,10 @@ import endpoints from '../static/endpoints.json'
import type { App } from './context' import type { App } from './context'
/** /**
* Name-server / service-discovery worker served at the apex `rec.djdevin.net`. * Name-server / service-discovery worker served at the apex domain.
* Returns the endpoints document the game client fetches to discover every * Returns the endpoints document the game client fetches to discover every
* service host. Static for now this will be generated dynamically later. * service host. Generated from `env.json` by `runx sync` run it after
* changing the domain (see `apps/ns/static/endpoints.json`).
*/ */
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
+4 -5
View File
@@ -2,17 +2,16 @@ import { exports } from 'cloudflare:workers'
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
import '../../ns.app' import '../../ns.app'
import endpoints from '../../../static/endpoints.json'
const ORIGIN = 'https://ns.rec.djdevin.net' const ORIGIN = 'https://example.com'
describe('ns endpoints', () => { describe('ns endpoints', () => {
test('GET / returns the endpoints document', async () => { test('GET / returns the endpoints document', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`) const res = await exports.default.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, string> const body = await res.json()
expect(body.API).toBe('https://api.rec.djdevin.net') expect(body).toEqual(endpoints)
expect(body.Notifications).toBe('https://notify.rec.djdevin.net')
expect(body.Econ).toBe('https://econ.rec.djdevin.net')
}) })
test('unknown path returns 404', async () => { test('unknown path returns 404', async () => {
+36 -36
View File
@@ -1,38 +1,38 @@
{ {
"Accounts": "https://accounts.rec.djdevin.net", "Accounts": "https://accounts.rec.example.com",
"AI": "https://ai.rec.djdevin.net", "AI": "https://ai.rec.example.com",
"API": "https://api.rec.djdevin.net", "API": "https://api.rec.example.com",
"Auth": "https://auth.rec.djdevin.net", "Auth": "https://auth.rec.example.com",
"BugReporting": "https://bugreporting.rec.djdevin.net", "BugReporting": "https://bugreporting.rec.example.com",
"Cards": "https://cards.rec.djdevin.net", "Cards": "https://cards.rec.example.com",
"CDN": "https://cdn.rec.djdevin.net", "CDN": "https://cdn.rec.example.com",
"Chat": "https://chat.rec.djdevin.net", "Chat": "https://chat.rec.example.com",
"Clubs": "https://clubs.rec.djdevin.net", "Clubs": "https://clubs.rec.example.com",
"CMS": "https://cms.rec.djdevin.net", "CMS": "https://cms.rec.example.com",
"Commerce": "https://commerce.rec.djdevin.net", "Commerce": "https://commerce.rec.example.com",
"Data": "https://data.rec.djdevin.net", "Data": "https://data.rec.example.com",
"DataCollection": "https://datacollection.rec.djdevin.net", "DataCollection": "https://datacollection.rec.example.com",
"Discovery": "https://discovery.rec.djdevin.net", "Discovery": "https://discovery.rec.example.com",
"Econ": "https://econ.rec.djdevin.net", "Econ": "https://econ.rec.example.com",
"GameLogs": "https://gamelogs.rec.djdevin.net", "GameLogs": "https://gamelogs.rec.example.com",
"Geo": "https://geo.rec.djdevin.net", "Geo": "https://geo.rec.example.com",
"Images": "https://img.rec.djdevin.net", "Images": "https://img.rec.example.com",
"Leaderboard": "https://leaderboard.rec.djdevin.net", "Leaderboard": "https://leaderboard.rec.example.com",
"Link": "https://link.rec.djdevin.net", "Link": "https://link.rec.example.com",
"Lists": "https://lists.rec.djdevin.net", "Lists": "https://lists.rec.example.com",
"Matchmaking": "https://match.rec.djdevin.net", "Matchmaking": "https://match.rec.example.com",
"Moderation": "https://api.rec.djdevin.net", "Moderation": "https://api.rec.example.com",
"Notifications": "https://notify.rec.djdevin.net", "Notifications": "https://notify.rec.example.com",
"PlatformNotifications": "https://platformnotifications.rec.djdevin.net", "PlatformNotifications": "https://platformnotifications.rec.example.com",
"PlayerSettings": "https://playersettings.rec.djdevin.net", "PlayerSettings": "https://playersettings.rec.example.com",
"RoomComments": "https://roomcomments.rec.djdevin.net", "RoomComments": "https://roomcomments.rec.example.com",
"RoomieIntegrations": "https://roomieintegrations.rec.djdevin.net", "RoomieIntegrations": "https://roomieintegrations.rec.example.com",
"Rooms": "https://rooms.rec.djdevin.net", "Rooms": "https://rooms.rec.example.com",
"Storage": "https://storage.rec.djdevin.net", "Storage": "https://storage.rec.example.com",
"Strings": "https://strings.rec.djdevin.net", "Strings": "https://strings.rec.example.com",
"StringsCDN": "https://strings-cdn.rec.djdevin.net", "StringsCDN": "https://strings-cdn.rec.example.com",
"Studio": "https://studio.rec.djdevin.net", "Studio": "https://studio.rec.example.com",
"Thorn": "https://thorn.rec.djdevin.net", "Thorn": "https://thorn.rec.example.com",
"Videos": "https://videos.rec.djdevin.net", "Videos": "https://videos.rec.example.com",
"WWW": "https://www.rec.djdevin.net" "WWW": "https://www.rec.example.com"
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "ns.rec.djdevin.net", "pattern": "ns.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+3 -3
View File
@@ -1,17 +1,17 @@
# playersettings # playersettings
Player-settings worker served at `playersettings.rec.djdevin.net`. Player-settings worker served on the `playersettings` subdomain.
- `GET /` — service status `{ "service": "playersettings", "status": "ok" }`. - `GET /` — service status `{ "service": "playersettings", "status": "ok" }`.
- `GET /playersettings``[Authorize]`. The player's settings as - `GET /playersettings``[Authorize]`. The player's settings as
`{ PlayerId, Key, Value }`, read from the per-player KV map. On a player's `{ PlayerId, Key, Value }`, read from the per-player KV map. On a player's
first read it seeds (and persists) the C# default settings. first read it seeds (and persists) the default settings.
- `PUT /playersettings``[Authorize]`. Accepts a form-urlencoded - `PUT /playersettings``[Authorize]`. Accepts a form-urlencoded
`key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the `key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the
player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`. player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
Persisted in Workers KV (`PLAYER_SETTINGS`, key `player:<id>`). Persisted in Workers KV (`PLAYER_SETTINGS`, key `player:<id>`).
> The C# `PutPlayerSettings` replaces the player's _entire_ settings set on each > A full settings PUT would replace the player's _entire_ settings set on each
> call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`) > call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
> doesn't wipe the others. > doesn't wipe the others.
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
@@ -10,7 +10,7 @@ import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/** /**
* Resolve the account id from a Bearer token (the C# action is `[Authorize]`). * Resolve the account id from a Bearer token (the route is auth-gated).
* Returns `null` when the header is missing, the token is invalid, or the `sub` * Returns `null` when the header is missing, the token is invalid, or the `sub`
* claim isn't an integer. * claim isn't an integer.
*/ */
@@ -32,9 +32,8 @@ function unauthorized(c: Context<App>) {
} }
/** /**
* Pull `{ key, value }` pairs out of a PUT body. Mirrors the C#: a * Pull `{ key, value }` pairs out of a PUT body: a form-urlencoded `key`/`value`,
* form-urlencoded `key`/`value`, or a JSON body (single object or array). * or a JSON body (single object or array). Entries with an empty key are dropped.
* Entries with an empty key are dropped.
*/ */
async function parseSettings(c: Context<App>): Promise<Array<{ key: string; value: string }>> { async function parseSettings(c: Context<App>): Promise<Array<{ key: string; value: string }>> {
const contentType = c.req.header('content-type') ?? '' const contentType = c.req.header('content-type') ?? ''
@@ -84,7 +83,7 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'playersettings', status: 'ok' })) .get('/', (c) => c.json({ service: 'playersettings', status: 'ok' }))
// The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads // The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads
// the per-player KV map; seeds (and persists) the C# defaults on first read. // the per-player KV map; seeds (and persists) the defaults on first read.
.get('/playersettings', async (c) => { .get('/playersettings', async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
@@ -100,7 +99,7 @@ const app = new Hono<App>()
}) })
// Upsert player settings into KV, keyed by the authenticated player id. // Upsert player settings into KV, keyed by the authenticated player id.
// The C# replaces the player's entire set; we merge so individual key PUTs // A full replace would overwrite the player's entire set; we merge so individual key PUTs
// (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest. // (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest.
.put('/playersettings', async (c) => { .put('/playersettings', async (c) => {
const id = await authedId(c) const id = await authedId(c)
@@ -9,7 +9,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://playersettings.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, using the same dev secret.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "playersettings.rec.djdevin.net", "pattern": "playersettings.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
* Swap both for a shared secret binding before this is used for anything real. * Swap both for a shared secret binding before this is used for anything real.
+6 -6
View File
@@ -23,7 +23,7 @@ import type { App } from './context'
* querying (see rooms-db.ts); the dorm (RoomId 1) is seeded by the migration. * querying (see rooms-db.ts); the dorm (RoomId 1) is seeded by the migration.
* Responses are the stored JSON verbatim (PascalCase, client-facing shape). * Responses are the stored JSON verbatim (PascalCase, client-facing shape).
* *
* The C# `[Route("rooms")]` prefix maps to this worker's subdomain, so method * The `rooms` prefix maps to this worker's subdomain, so method
* routes are served bare. The 2023 client also hits several of these without the * routes are served bare. The 2023 client also hits several of these without the
* `/roomserver` prefix, so both forms are registered. * `/roomserver` prefix, so both forms are registered.
*/ */
@@ -100,8 +100,8 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'rooms', status: 'ok' })) .get('/', (c) => c.json({ service: 'rooms', status: 'ok' }))
// Room lookup by `id` (first match wins) or `name`. The C# 400s when neither // Room lookup by `id` (first match wins) or `name`. 400s when neither is
// is supplied and returns `{}` when nothing matches. // supplied and returns `{}` when nothing matches.
.get('/rooms', async (c) => { .get('/rooms', async (c) => {
const idParam = c.req.query('id') const idParam = c.req.query('id')
const nameParam = c.req.query('name') const nameParam = c.req.query('name')
@@ -162,7 +162,7 @@ const app = new Hono<App>()
}) })
// Toggle the player's cheer/favorite on a room. Both are PUTs that flip the // Toggle the player's cheer/favorite on a room. Both are PUTs that flip the
// stored flag and return the updated interaction (matches the C#). // stored flag and return the updated interaction.
.put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => { .put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
const interaction = await toggleCheer( const interaction = await toggleCheer(
c.env.DB, c.env.DB,
@@ -180,8 +180,8 @@ const app = new Hono<App>()
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
}) })
// Single room by id. 404 when the room isn't in D1 (matches the C#). Ignores // Single room by id. 404 when the room isn't in D1. Ignores the
// the include/unityAsset* query params, same as the C#. // include/unityAsset* query params.
.get('/rooms/:roomId{[0-9]+}', async (c) => { .get('/rooms/:roomId{[0-9]+}', async (c) => {
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)) const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return room ? c.json(room) : c.notFound() return room ? c.json(room) : c.notFound()
+1 -1
View File
@@ -12,7 +12,7 @@ declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
} }
const ORIGIN = 'https://rooms.rec.djdevin.net' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, using the same dev secret.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"routes": [ "routes": [
{ {
"pattern": "rooms.rec.djdevin.net", "pattern": "rooms.rec.example.com",
"custom_domain": true "custom_domain": true
} }
], ],
+6
View File
@@ -0,0 +1,6 @@
{
"domain": "rec.example.com",
"subdomains": {
"playersettings": "settings"
}
}
+2
View File
@@ -9,6 +9,7 @@ import { ciCmd } from '../cmd/ci.cmd'
import { devCmd } from '../cmd/dev.cmd' import { devCmd } from '../cmd/dev.cmd'
import { fixCmd } from '../cmd/fix.cmd' import { fixCmd } from '../cmd/fix.cmd'
import { shfmtCmd } from '../cmd/shfmt.cmd' import { shfmtCmd } from '../cmd/shfmt.cmd'
import { syncCmd } from '../cmd/sync.cmd'
import { updateCmd } from '../cmd/update.cmd' import { updateCmd } from '../cmd/update.cmd'
program program
@@ -25,6 +26,7 @@ program
.addCommand(ciCmd) .addCommand(ciCmd)
.addCommand(updateCmd) .addCommand(updateCmd)
.addCommand(shfmtCmd) .addCommand(shfmtCmd)
.addCommand(syncCmd)
// Don't hang for unresolved promises // Don't hang for unresolved promises
.hook('postAction', () => process.exit(0)) .hook('postAction', () => process.exit(0))
+92
View File
@@ -0,0 +1,92 @@
import { Command } from '@commander-js/extra-typings'
import { z } from 'zod'
import { getRepoRoot } from '../path'
const Env = z.object({
/** Base domain that all service hosts are derived from, e.g. `rec.example.com`. */
domain: z.string().min(1),
/**
* Optional per-app subdomain overrides, keyed by the app's directory name.
* Defaults to the directory name when not set.
*/
subdomains: z.record(z.string(), z.string()).optional(),
})
type Edit = {
label: string
file: string
/** Rewrites the file's derived values; a no-op when nothing needs changing. */
transform: (text: string) => string
}
export const syncCmd = new Command('sync')
.description('Sync generated config (wrangler routes, ns endpoints, etc.) from env.json')
.option('--check', `Exit non-zero if any file is out of sync (don't write changes)`, false)
.action(async ({ check }) => {
const repoRoot = getRepoRoot()
const env = Env.parse(await fs.readJson(path.join(repoRoot, 'env.json')))
const edits: Edit[] = []
// Worker custom-domain routes: `<subdomain>.<domain>`, derived from the app dir name.
const wranglerConfigs = await glob('apps/*/wrangler.jsonc', { cwd: repoRoot, absolute: true })
for (const file of wranglerConfigs.sort()) {
const dir = path.basename(path.dirname(file))
const subdomain = env.subdomains?.[dir] ?? dir
const pattern = `${subdomain}.${env.domain}`
edits.push({
label: `apps/${dir}/wrangler.jsonc → ${pattern}`,
file,
// Only matches when the file has a route; otherwise leaves the file untouched.
transform: (t) => t.replace(/("pattern":\s*")[^"]*(")/, `$1${pattern}$2`),
})
}
// ns service-discovery document — every host is one of our own subdomains, so swap
// the base domain while preserving each entry's subdomain label.
const endpointsFile = path.join(repoRoot, 'apps/ns/static/endpoints.json')
if (await fs.pathExists(endpointsFile)) {
edits.push({
label: 'apps/ns/static/endpoints.json',
file: endpointsFile,
transform: (t) =>
t.replace(/("https:\/\/[a-z0-9-]+\.)[a-z0-9.-]+(")/g, `$1${env.domain}$2`),
})
}
// Share-link base URL in the api worker's static config (only this one field is a
// host of ours; other URLs in the file are third-party and must be left alone).
const apiConfig = path.join(repoRoot, 'apps/api/static/api-config-v2.json')
if (await fs.pathExists(apiConfig)) {
edits.push({
label: 'apps/api/static/api-config-v2.json (ShareBaseUrl)',
file: apiConfig,
transform: (t) =>
t.replace(/("ShareBaseUrl":\s*"https:\/\/[a-z0-9-]+\.)[a-z0-9.-]+(\/)/, `$1${env.domain}$2`),
})
}
const outOfSync: string[] = []
for (const { label, file, transform } of edits) {
const text = await fs.readFile(file, 'utf8')
const next = transform(text)
if (next === text) continue
outOfSync.push(label)
if (!check) await fs.writeFile(file, next)
}
if (outOfSync.length === 0) {
echo(chalk.green('✓ generated config in sync'))
return
}
if (check) {
echo(chalk.red('✗ generated config out of sync. Run `just sync` to fix:'))
for (const line of outOfSync) echo(` ${line}`)
process.exit(1)
}
echo(chalk.green(`✓ synced ${outOfSync.length} file(s):`))
for (const line of outOfSync) echo(` ${line}`)
})