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
.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:
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.
[group('4. utility')]
[positional-arguments]
+16
View File
@@ -38,6 +38,22 @@ npm create workers-monorepo@latest
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:**
```bash
+5 -6
View File
@@ -1,16 +1,15 @@
# accounts
Accounts Worker served at `accounts.rec.djdevin.net`. A Hono app ported from the
C# `AccountsController`. EF Core (`AppDbContext`) queries are stubbed for now —
no real bindings yet.
Accounts Worker served on the `accounts` subdomain. A Hono app for accounts.
Database queries are stubbed for now — no real bindings yet.
## Behavior
- **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.
- **DB-backed reads** return synthesized default accounts. The C# already fills
every column with a fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.),
so the stubs return those defaults rather than 404ing on a missing row.
- **DB-backed reads** return synthesized default accounts. Every column gets a
fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), so the stubs return
those defaults rather than 404ing on a missing row.
- **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the
request and ack without persisting. `create` mints a random account id and
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)`,
]
/** Client-facing account shape (PascalCase, matches the C# `Account`). */
/** Client-facing account shape (PascalCase, as the client expects). */
export interface Account {
AccountId: number
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
* 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'
/**
* Ported from the C# `AccountsController`. Account reads/writes are backed by the
* shared `accounts` table in D1 (schema owned by the `auth` worker). Accounts not
* in the table fall back to a synthesized default (the C# fills every column with
* a fallback anyway). Profile mutations still accept-and-ack (marked `TODO`).
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
* owned by the `auth` worker). Accounts not in the table fall back to a
* synthesized default (every column has a fallback anyway). Profile mutations
* still accept-and-ack (marked `TODO`).
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/
/**
* 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.
*/
async function authedId(c: Context<App>): Promise<number | null> {
@@ -61,7 +61,7 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
// Root health check (the C# source returned a placeholder string here).
// Root health check.
.get('/', (c) => c.json({ service: 'accounts', status: 'ok' }))
// ---- Self account --------------------------------------------------------
@@ -70,11 +70,10 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
// The C# `SelfAccount` marks `JuniorState` (an enum) and `ParentAccountId`
// with `[JsonIgnore(WhenWritingNull)]`, so they're OMITTED when null —
// `JuniorState` (an enum) and `ParentAccountId` are OMITTED when null —
// emitting `"juniorState":null` makes the client's enum parser throw
// ("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({
...account,
Email: null,
@@ -87,7 +86,7 @@ const app = new Hono<App>()
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.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 =
c.req
.queries('id')
@@ -95,7 +94,7 @@ const app = new Hono<App>()
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
// 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]))
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`).
* 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 {}
}
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
// into the test D1 (mirrors apps/auth/migrations/0001_accounts.sql).
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "accounts.rec.djdevin.net",
"pattern": "accounts.rec.example.com",
"custom_domain": true
}
],
+4 -4
View File
@@ -1,14 +1,14 @@
# api
Game API Worker served at `api.rec.djdevin.net`. A Hono app ported from the C#
`APIController`. EF Core (`AppDbContext`) queries and on-disk JSON files are
stubbed for now — no real bindings yet.
Game API Worker served on the `api` subdomain. A Hono app serving the game's
API surface. Database-backed queries and on-disk JSON files are stubbed for now
— no real bindings yet.
## Behavior
- **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.
- **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-settings.ts``GET /api/settings/v2`
- **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'
/**
* Ported from the C# `APIController`. Endpoints that the C# backs with EF Core
* (`AppDbContext`) or on-disk JSON files are stubbed here — no bindings yet.
* The Game API surface. Endpoints that would be backed by a database or on-disk
* JSON files are stubbed here — no bindings yet.
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*
* 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
* 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.
*/
async function authedId(c: Context<App>): Promise<number | null> {
@@ -57,7 +57,7 @@ function unauthorized(c: Context<App>) {
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[]> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids
@@ -83,9 +83,9 @@ function queryIds(c: Context<App>): number[] {
/**
* Photon access-token response (`/roomserver/photon_access_token`). The 2023
* 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
* reference (`PhotonAccessToken` is empty — the client uses its baked-in Photon
* credentials). Our synthesized instances always use roomInstanceId 1.
* into; a 404 here leaves the player stuck on a black screen. `PhotonAccessToken`
* is empty — the client uses its baked-in Photon credentials. Our synthesized
* instances always use roomInstanceId 1.
*/
function photonAccessToken() {
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) {
return {
AccountId: id,
@@ -198,8 +198,8 @@ const app = new Hono<App>({ strict: false })
return c.json({ PlayerId: id, Level: 1, XP: 0 })
})
.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
// behavior; its DB-less fallback reads a static JSON file instead).
// Synthesize a default reputation per requested id (the intended behavior;
// the DB-less fallback reads a static JSON file instead).
.post('/api/playerReputation/v2/bulk', async (c) => {
const ids = await parseFormIds(c)
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
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) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// The 2023 client calls this as a GET with repeated `id` query params (the
// FemRec reference). Return a default progression per requested id.
// The 2023 client calls this as a GET with repeated `id` query params.
// Return a default progression per requested id.
.get('/api/players/v2/progression/bulk', (c) =>
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)
const update = await c.req.json<Record<string, unknown>>().catch(() => null)
if (update === null) return c.body(null, 400)
// TODO: persist; echo the accepted avatar back like the C# does. Fall back to
// the valid default avatar fields when the client omits them.
// TODO: persist; echo the accepted avatar back. Fall back to the valid
// default avatar fields when the client omits them.
return c.json({
OwnerAccountId: id,
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 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 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)
})
// Custom avatar item gates. None of these are in CannedNet — they're real Rec
// Room client endpoints the C# never implemented. Each returns a bare JSON
// boolean; we enable them. Flip to `false` to disable the corresponding flow.
// Custom avatar item gates — real Rec Room client endpoints with no backing
// implementation yet. Each returns a bare JSON boolean; we enable them. Flip
// to `false` to disable the corresponding flow.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isCreationEnabled', (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.
.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.
.get('/api/checklist/v1/current', async (c) => {
const id = await authedId(c)
@@ -367,7 +367,7 @@ const app = new Hono<App>({ strict: false })
.get('/api/settings/v2', async (c) => {
const id = await authedId(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))
})
.post('/api/settings/v2/set', async (c) => {
@@ -426,7 +426,7 @@ const app = new Hono<App>({ strict: false })
return c.json([]) // TODO: query PlayerBios
})
.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([])
})
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Default avatar items, ported verbatim from the inline list in the C#
* `GET /api/avatar/v4/items`. Stored as `[AvatarItemDesc, FriendlyName, Rarity?]`
* Default avatar items for `GET /api/avatar/v4/items`.
* Stored as `[AvatarItemDesc, FriendlyName, Rarity?]`
* tuples — every entry shares `AvatarItemType: 0`, `PlatformMask: -1`, `Tooltip: ""`,
* and `Rarity` defaults to `0`.
*/
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Default player settings, ported from the inline defaults in the C#
* `GET /api/settings/v2`. The C# seeds these when a player has no stored settings.
* Default player settings for `GET /api/settings/v2`, seeded when a player has
* no stored settings.
*/
export interface PlayerSetting {
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`).
* 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 {}
}
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
// 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)
})
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`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
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 () => {
+1 -1
View File
@@ -369,5 +369,5 @@
"MicSpamSamplePercentageForForceMuteToEnd": 0.2,
"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"],
"routes": [
{
"pattern": "api.rec.djdevin.net",
"pattern": "api.rec.example.com",
"custom_domain": true
}
],
+4 -4
View File
@@ -1,8 +1,8 @@
# auth
Auth Worker served at `auth.rec.djdevin.net`. A Hono app ported from the C#
`AuthController`. Binding-dependent behavior (EF Core `AppDbContext` queries) is
stubbed for now — no real KV/D1/DO bindings yet.
Auth Worker served on the `auth` subdomain. A Hono app handling authentication.
Binding-dependent behavior (database queries) is stubbed for now — no real
KV/D1/DO bindings yet.
## Routes
@@ -21,4 +21,4 @@ stubbed for now — no real KV/D1/DO bindings yet.
filesystem) — replace `EAC_CHALLENGE` with the real challenge text.
- `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB
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)`,
]
/** Client-facing account shape (PascalCase, matches the C# `Account`). */
/** Client-facing account shape (PascalCase, as the client expects). */
export interface Account {
AccountId: number
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
* 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 =
'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> = {
[-1]: 'All',
0: 'Steam',
@@ -131,8 +131,8 @@ const app = new Hono<App>()
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post('/connect/token', async (c) => {
// The C# reads `grant_type`, `account_id`, `platform_id` and `platform` from
// the form body.
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
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) => {
const { id } = c.req.param()
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
* 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'
/** 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
function base64url(input: ArrayBuffer | string): string {
@@ -18,7 +18,7 @@ function base64url(input: ArrayBuffer | string): string {
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 = [
'profile',
'rn',
@@ -36,7 +36,7 @@ const TOKEN_SCOPES = [
'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']
export async function generateToken(
@@ -47,7 +47,6 @@ export async function generateToken(
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
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
// authorize itself; a token with only `sub` is rejected before login finishes.
const payload = {
+2 -2
View File
@@ -12,7 +12,7 @@ declare module 'cloudflare:test' {
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.
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`)
expect(res.status).toBe(200)
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=="')
})
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "auth.rec.djdevin.net",
"pattern": "auth.rec.example.com",
"custom_domain": true
}
],
+11 -13
View File
@@ -10,9 +10,9 @@ import type { Context } from 'hono'
import type { App, Env } from './context'
/**
* Ported from the C# `CDNController`. The class `[Route("cdn")]` prefix maps to
* this worker's subdomain, so method routes are served bare. File-backed routes
* (`sigs`, `upload`) have no storage binding yet and are stubbed.
* CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
* are served bare. File-backed routes (`sigs`, `upload`) have no storage binding
* 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
* (matching the C#'s `Results.File(..., "application/octet-stream")`, which also
* honors Range requests). The C# 404s when the file is missing; so do we.
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
* honoring Range requests. 404s when the file is missing.
* Supports conditional GET and byte-range requests (206) — large-file
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
* 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' }))
// Loading-screen tips. The C# serves JSON/loadingscreentipdata.json; bundled
// here as static JSON.
// Loading-screen tips, bundled here as static JSON.
.get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData))
// Signature blobs by name (C#: Sigs/ directory). Streamed from R2 under the
// `sigs/` key prefix; 404 when missing.
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
// 404 when missing.
.get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`))
// Room build data by name (C#: Data/DataBlobs/). The client fetches this for
// a SubRoom's DataBlob to load the room. Streamed from R2 under `room/`.
// Room build data by name. The client fetches this for a SubRoom's DataBlob to
// load the room. Streamed from R2 under `room/`.
.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
// persisting it. TODO: write to an R2 bucket like the `img` worker.
.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 & {
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>` (mirrors the C#'s Sigs/ and
// Data/DataBlobs/ directories).
// room build data under `room/<name>`.
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`).
* 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 {}
}
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.
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -8,7 +8,7 @@
],
"routes": [
{
"pattern": "cdn.rec.djdevin.net",
"pattern": "cdn.rec.example.com",
"custom_domain": true
}
],
+2 -3
View File
@@ -1,10 +1,9 @@
# chat
Chat worker served at `chat.rec.djdevin.net`.
Chat worker served on the `chat` subdomain.
- `GET /` — service status `{ "service": "chat", "status": "ok" }`.
- `GET /thread` — chat threads. No DB binding yet, so returns `[]` (matching the
C# `ChatController.Get`).
- `GET /thread` — chat threads. No DB binding yet, so returns `[]`.
## Development
+1 -1
View File
@@ -21,7 +21,7 @@ const app = new Hono<App>()
.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([]))
export default app
+1 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import '../../chat.app'
const ORIGIN = 'https://chat.rec.djdevin.net'
const ORIGIN = 'https://example.com'
describe('chat endpoints', () => {
it('GET / reports service status', async () => {
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "chat.rec.djdevin.net",
"pattern": "chat.rec.example.com",
"custom_domain": true
}
],
+3 -4
View File
@@ -1,12 +1,11 @@
# clubs
Clubs Worker served at `clubs.rec.djdevin.net`. A Hono app ported from the C#
`ClubsController`.
Clubs Worker served on the `clubs` subdomain. A Hono app for clubs.
## Behavior
- `GET /club/home/me` — returns 404. The C# source returned `Results.NotFound()`
unconditionally; there's nothing to hydrate yet.
- `GET /club/home/me` — returns 404 unconditionally; there's nothing to hydrate
yet.
## TODO before production
+10 -10
View File
@@ -9,8 +9,8 @@ import type { Context } from 'hono'
import type { App } from './context'
/**
* Ported from the C# `ClubsController`. The only endpoint is `[Authorize]` and
* then returns `Results.NotFound()` unconditionally — no DB binding involved.
* The only endpoint is auth-gated and returns 404 unconditionally — no DB
* binding involved.
*/
/**
@@ -43,22 +43,22 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
// [Authorize] → 401 without a valid token. The C# returns NotFound here, but
// the client treats that 404 as an error, so we return an empty object stub.
// Auth-gated → 401 without a valid token. A bare 404 here makes the client
// treat it as an error, so we return an empty object stub.
.get('/club/home/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({})
})
// Not present in CannedNet — a real Rec Room client endpoint the C# never
// implemented. The client calls it on the clubs host at /subscription/mine/member
// (no /club prefix) and sends no auth header, so it isn't gated. Returns an
// empty array = no club subscription memberships (the client chokes on null).
// A real Rec Room client endpoint with no backing implementation yet. The
// client calls it on the clubs host at /subscription/mine/member (no /club
// prefix) and sends no auth header, so it isn't gated. Returns an empty
// array = no club subscription memberships (the client chokes on null).
.get('/subscription/mine/member', (c) => c.json([]))
// Details for a given subscription. Also not in CannedNet; the client
// deserializes this into an object, so it must return `{}` (not `[]`).
// Details for a given subscription. The client deserializes this into an
// object, so it must return `{}` (not `[]`).
.get('/subscription/details/:subscription', (c) => c.json({}))
// 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`).
* 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'
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.
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "clubs.rec.djdevin.net",
"pattern": "clubs.rec.example.com",
"custom_domain": true
}
],
+4 -4
View File
@@ -6,8 +6,8 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
import type { App } from './context'
/**
* Ported from the C# `CommerceController`. The class `[Route("commerce")]` prefix
* maps to this worker's subdomain, so method routes are served bare.
* Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
* method routes are served bare.
*/
const app = new Hono<App>()
.use(
@@ -25,8 +25,8 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'commerce', status: 'ok' }))
// Whether the player has ever spent money. The C# returns NotFound(), but the
// client treats that 404 as an error, so we return `false` (no purchases).
// Whether the player has ever spent money. A 404 here makes the client treat
// it as an error, so we return `false` (no purchases).
.get('/purchase/v1/hasspentmoney', (c) => c.json(false))
export default app
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import '../../commerce.app'
const ORIGIN = 'https://commerce.rec.djdevin.net'
const ORIGIN = 'https://example.com'
describe('commerce endpoints', () => {
it('GET / reports service status', async () => {
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "commerce.rec.djdevin.net",
"pattern": "commerce.rec.example.com",
"custom_domain": true
}
],
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import '../../datacollection.app'
const ORIGIN = 'https://datacollection.rec.djdevin.net'
const ORIGIN = 'https://example.com'
describe('datacollection endpoints', () => {
it('GET / reports service status', async () => {
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "datacollection.rec.djdevin.net",
"pattern": "datacollection.rec.example.com",
"custom_domain": true
}
],
+10 -10
View File
@@ -1,6 +1,6 @@
# 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
`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
from the bundled `static/default-avatar-items.json` catalog.
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. The C#
reads the same source file as `defaultunlocked`, so it returns the identical
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads
the same source file as `defaultunlocked`, so it returns the identical
catalog.
- `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
empty and this returns just the catalog.
- `GET /api/avatar/v2``[Authorize]`. The player's avatar. No DB binding yet,
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
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
route here shows up as "Failed to download unlocked avatar items".
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (matching
the C#, which serves a static JSON file verbatim); returns the bundled
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a
static JSON file verbatim); returns the bundled
`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/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
the bundled `static/storefronts-v3-giftdropstore-3.json`.
- `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/roomkeys/v1/mine` — the player's room keys; `[]`.
- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup;
`{ 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 `[]`.
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.
## 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.
.get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems))
// Default base avatar items. The C# reads the same JSON/defaultAvatarItems.json
// file as defaultunlocked, so it returns the identical catalog.
// Default base avatar items. Reads the same source file as defaultunlocked,
// so it returns the identical catalog.
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json(defaultAvatarItems))
// The player's avatar items — owned items concatenated with the default
@@ -72,18 +72,18 @@ const app = new Hono<App>()
return c.json(defaultAvatarItems)
})
// The player's owned custom avatar items. No auth in the C#, which returns
// `{ items: [] }`. The client downloads these when custom-item creation is
// The player's owned custom avatar items. No auth; returns `{ items: [] }`.
// The client downloads these when custom-item creation is
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
.get('/econ/customAvatarItems/v1/owned', (c) => c.json({ items: [] }))
// The player's objectives progress. The C# serves a static JSON file
// (JSON/tempmyprogress.json) verbatim with no auth — same default for everyone
// until there's a DB binding to track per-player progress.
// The player's objectives progress. Serves a static JSON file verbatim with
// no auth — same default for everyone until there's a DB binding to track
// per-player progress.
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
// 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) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -117,18 +117,18 @@ const app = new Hono<App>()
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([]))
// Not in CannedNet — room consumables/currencies for a given room. Stubbed
// as empty lists so the client doesn't 404.
// Room consumables/currencies for a given room. Stubbed as empty lists so the
// client doesn't 404.
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (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/getAllBalances', (c) => c.json([]))
// Persist player settings. [Authorize]; the C# replaces the player's settings
// and returns Ok(). No DB binding yet, so accept-and-ack.
// Persist player settings. [Authorize]; would replace the player's settings.
// No DB binding yet, so accept-and-ack.
.post('/api/settings/v2/set', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -152,23 +152,23 @@ const app = new Hono<App>()
return c.json([])
})
// Gift-drop storefront. The C# falls back to JSON/storefront3.json when no
// storefront row exists; that's the bundled static catalog here.
// Gift-drop storefront. Falls back to the bundled static catalog when no
// storefront row exists.
.get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3))
// Current weekly challenge. Served from the bundled static JSON (the C#'s
// JSON/weeklychallenge.json) until per-rotation challenge data is wired up.
// Current weekly challenge. Served from the bundled static JSON until
// per-rotation challenge data is wired up.
.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([]))
// The player's room keys. The C# returns "[]".
// The player's room keys. Returns "[]".
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
// Room keys for a given room (client calls this on the econ host). [] with no DB.
.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) =>
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`).
* 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'
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.
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "econ.rec.djdevin.net",
"pattern": "econ.rec.example.com",
"custom_domain": true
}
],
+3 -3
View File
@@ -1,6 +1,6 @@
# 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
key:
@@ -12,8 +12,8 @@ key:
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
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
to verify image integrity. Signing buffers the whole object.
header. The client uses this to verify image integrity. Signing buffers the
whole object.
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
}
/** 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> {
const key = await getSigningKey(env)
if (!key) return null
@@ -60,8 +60,8 @@ const app = new Hono<App>()
// objects. Supports conditional requests via If-None-Match.
//
// 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#
// ImageController). Signing requires the full body, so the object is buffered.
// the signature returned in a `Content-Signature` header. Signing requires the
// full body, so the object is buffered.
.get('/:key{.+}', async (c) => {
const key = c.req.param('key')
if (key.includes('..')) return c.body(null, 400)
+1 -1
View File
@@ -9,7 +9,7 @@ declare module 'cloudflare:test' {
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.
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"],
"routes": [
{
"pattern": "img.rec.djdevin.net",
"pattern": "img.rec.example.com",
"custom_domain": true
}
],
+4 -5
View File
@@ -1,8 +1,7 @@
# match
Matchmaking Worker served at `match.rec.djdevin.net`. A Hono app ported from the
C# `MatchmakingController`. EF Core (`AppDbContext`) queries are stubbed for now
— no real bindings yet.
Matchmaking Worker served on the `match` subdomain. A Hono app for matchmaking.
Database queries are stubbed for now — no real bindings yet.
## 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
401 when it's missing/invalid.
- **`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
`photonRoomId`.
- **`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`
is always null and `isOnline` false until there's a DB binding.
- **`/player/login`, `/player/statusvisibility`, `/roominstance/:id/reportjoinresult`**
return empty 200s, as in the source.
return empty 200s.
## 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`).
* 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'
/**
* Ported from the C# `MatchmakingController`. Endpoints the C# backs with EF Core
* (`AppDbContext`) are stubbed here there's no DB binding yet, so room/player
* lookups fall back to the same defaults the C# uses when nothing is found.
* The matchmaking surface. Database-backed endpoints are stubbed here there's
* no DB binding yet, so room/player lookups fall back to default values when
* nothing is found.
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/
/**
* Default `/player` payload. The C# serves this from `JSON/getplayer.json`
* whenever the `id` is missing/invalid or the account isn't found; Workers have
* no filesystem so it's inlined here.
* Default `/player` payload, served whenever the `id` is missing/invalid or the
* account isn't found. Inlined here (Workers have no filesystem).
*/
const DEFAULT_GET_PLAYER = [
{
@@ -48,7 +47,7 @@ interface HeartbeatRequest {
/**
* 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.
*/
async function authedId(c: Context<App>): Promise<number | null> {
@@ -89,10 +88,9 @@ interface Presence {
const PRESENCE_TTL = 900
/**
* Game build version reported in presence. Like the reference servers (FemRec
* `ServerConfig.GameVersion`, 2025 `HeartbeatDB`), this is a server-side
* constant the client doesn't supply it, and an empty value breaks the
* client's presence/version handling. Matches our target 2023 client build.
* Game build version reported in presence. This is a server-side constant the
* client doesn't supply it, and an empty value breaks the client's
* presence/version handling. Matches our target 2023 client build.
*/
const GAME_VERSION = '20230302'
@@ -256,8 +254,8 @@ const app = new Hono<App>()
.post('/player/logout', (c) => c.body(null, 200))
.get('/player', async (c) => {
// Returns each requested player's presence. The C# reads the `id` query
// param(s); with none it serves the static getplayer.json default.
// Returns each requested player's presence. Reads the `id` query param(s);
// with none it serves the static getplayer.json default.
const ids = c.req
.queries('id')
?.flatMap((v) => v.split(','))
@@ -363,8 +361,7 @@ const app = new Hono<App>()
// isn't swallowed by the auth-gated matchmake handler.
.post('/matchmake/none', async (c) => {
const id = await authedId(c)
// FemRec (our 2023-client target) returns the player's *current* heartbeat
// here rather than forcing the dorm (the 2025 server's behavior we'd copied).
// Return the player's *current* heartbeat here rather than forcing the dorm.
// 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.
// 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 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 =
room.toLowerCase() === 'dorm'
? dormRoomInstance()
+1 -1
View File
@@ -10,7 +10,7 @@ declare module 'cloudflare:test' {
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.
// 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"],
"routes": [
{
"pattern": "match.rec.djdevin.net",
"pattern": "match.rec.example.com",
"custom_domain": true
}
],
+6 -9
View File
@@ -1,13 +1,11 @@
# notify
Notifications Worker served at `notify.rec.djdevin.net`. Ported from the C#
`NotifyController` / `NotificationsHub` / `NotificationService`, which host a
SignalR hub at `/hub/v1`.
Notifications Worker served on the `notify` subdomain. Hosts a SignalR hub at
`/hub/v1`.
The hub is implemented as a **Durable Object** (`NotificationsHub`) speaking the
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
all connections).
instance holds the shared hub state (one process across all connections).
## Endpoints
@@ -27,8 +25,7 @@ all connections).
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
`{}␞` (`␞` = record separator `0x1e`).
3. Server immediately sends the `OnConnect` invocation (mirrors the C#
`OnConnectedAsync`).
3. Server immediately sends the `OnConnect` invocation on connect.
4. Client→server invocations:
- `SubscribeToPlayers({ playerIds })` — replaces this connection's
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:
- `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
subscribes.
A connection's rows are removed on `webSocketClose` (the C# `OnDisconnected`).
A connection's rows are removed on `webSocketClose`.
## TODO before production
+6 -7
View File
@@ -3,9 +3,8 @@ import { DurableObject } from 'cloudflare:workers'
import type { Env } from './context'
/**
* Durable Object hosting the SignalR notifications hub, ported from the C#
* `NotificationsHub` + `NotificationService`. A single global instance plays the
* role of the C# static dictionaries (one process, shared across connections).
* Durable Object hosting the SignalR notifications hub. A single global instance
* holds the shared hub state (one process, shared across connections).
*
* 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`.
@@ -15,7 +14,7 @@ import type { Env } from './context'
*
* Connection/subscription state lives in SQLite so it survives hibernation:
* - `subscriptions(connectionId, playerId)` both the connectionplayers and
* (queried the other way) the playerconnections maps from the C#.
* (queried the other way) the playerconnections maps.
* - `pending(id, playerId, payload)` the per-player queue delivered once a
* player is subscribed.
*/
@@ -136,7 +135,7 @@ export class NotificationsHub extends DurableObject<Env> {
state.handshakeDone = true
ws.serializeAttachment(state)
// C# OnConnectedAsync sends "OnConnect" to the caller after connecting.
// Send "OnConnect" to the caller after connecting.
ws.send(this.invocation('OnConnect', []))
}
@@ -275,8 +274,8 @@ export class NotificationsHub extends DurableObject<Env> {
// ---- Helpers -------------------------------------------------------------
/**
* Build the `Notification` argument: a JSON string `{ Id, Msg }`, matching the
* C# `SendToConnection` (null values are dropped from `Msg`).
* Build the `Notification` argument: a JSON string `{ Id, Msg }`
* (null values are dropped from `Msg`).
*/
private buildNotificationPayload(
notificationType: number,
+5 -6
View File
@@ -8,15 +8,14 @@ import { NotificationsHub } from './notifications-hub'
import type { App } from './context'
/**
* Ported from the C# `NotifyController`, which maps a SignalR hub at `/hub/v1`
* (see `NotificationsHub` / `NotificationService`). The hub itself WebSocket
* transport, the SignalR JSON Hub Protocol, and the shared connection state
* Maps a SignalR hub at `/hub/v1`. The hub itself WebSocket transport, the
* SignalR JSON Hub Protocol, and the shared connection state
* lives in the `NotificationsHub` Durable Object; this worker handles the
* SignalR negotiate handshake, forwards the WebSocket upgrade to the DO, and
* 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 app = new Hono<App>()
@@ -57,8 +56,8 @@ const app = new Hono<App>()
})
// ---- Internal service-to-service send/broadcast --------------------------
// Lets other workers push notifications, the way the C# controllers called
// the shared NotificationService. TODO: protect these before production.
// Lets other workers push notifications through the shared hub.
// TODO: protect these before production.
.post('/internal/notify', async (c) => {
const body = await c.req
.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'
const ORIGIN = 'https://notify.rec.djdevin.net'
const ORIGIN = 'https://example.com'
const RS = '\u001e'
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}`)
await waitFor((r) => r.type === 1 && r.target === 'OnConnect')
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "notify.rec.djdevin.net",
"pattern": "notify.rec.example.com",
"custom_domain": true
}
],
+9 -7
View File
@@ -1,20 +1,22 @@
# 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
discover every service host (Accounts, API, Auth, Econ, Matchmaking,
Notifications, …).
The document is served from `static/endpoints.json` (a snapshot downloaded from
the live host). **It will be generated dynamically eventually** — e.g. per
environment / from the deployed routes — at which point the static file goes
away.
The document is served from `static/endpoints.json`, whose hosts are generated
from the repo-root `env.json` by `runx sync` — every entry is derived from the
configured base `domain`.
## Updating the snapshot
## Updating endpoints
Change `domain` in `env.json` (or edit the host map in `static/endpoints.json`),
then regenerate:
```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.)
+3 -2
View File
@@ -8,9 +8,10 @@ import endpoints from '../static/endpoints.json'
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
* 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>()
.use(
+4 -5
View File
@@ -2,17 +2,16 @@ import { exports } from 'cloudflare:workers'
import { describe, expect, test } from 'vitest'
import '../../ns.app'
import endpoints from '../../../static/endpoints.json'
const ORIGIN = 'https://ns.rec.djdevin.net'
const ORIGIN = 'https://example.com'
describe('ns endpoints', () => {
test('GET / returns the endpoints document', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, string>
expect(body.API).toBe('https://api.rec.djdevin.net')
expect(body.Notifications).toBe('https://notify.rec.djdevin.net')
expect(body.Econ).toBe('https://econ.rec.djdevin.net')
const body = await res.json()
expect(body).toEqual(endpoints)
})
test('unknown path returns 404', async () => {
+36 -36
View File
@@ -1,38 +1,38 @@
{
"Accounts": "https://accounts.rec.djdevin.net",
"AI": "https://ai.rec.djdevin.net",
"API": "https://api.rec.djdevin.net",
"Auth": "https://auth.rec.djdevin.net",
"BugReporting": "https://bugreporting.rec.djdevin.net",
"Cards": "https://cards.rec.djdevin.net",
"CDN": "https://cdn.rec.djdevin.net",
"Chat": "https://chat.rec.djdevin.net",
"Clubs": "https://clubs.rec.djdevin.net",
"CMS": "https://cms.rec.djdevin.net",
"Commerce": "https://commerce.rec.djdevin.net",
"Data": "https://data.rec.djdevin.net",
"DataCollection": "https://datacollection.rec.djdevin.net",
"Discovery": "https://discovery.rec.djdevin.net",
"Econ": "https://econ.rec.djdevin.net",
"GameLogs": "https://gamelogs.rec.djdevin.net",
"Geo": "https://geo.rec.djdevin.net",
"Images": "https://img.rec.djdevin.net",
"Leaderboard": "https://leaderboard.rec.djdevin.net",
"Link": "https://link.rec.djdevin.net",
"Lists": "https://lists.rec.djdevin.net",
"Matchmaking": "https://match.rec.djdevin.net",
"Moderation": "https://api.rec.djdevin.net",
"Notifications": "https://notify.rec.djdevin.net",
"PlatformNotifications": "https://platformnotifications.rec.djdevin.net",
"PlayerSettings": "https://playersettings.rec.djdevin.net",
"RoomComments": "https://roomcomments.rec.djdevin.net",
"RoomieIntegrations": "https://roomieintegrations.rec.djdevin.net",
"Rooms": "https://rooms.rec.djdevin.net",
"Storage": "https://storage.rec.djdevin.net",
"Strings": "https://strings.rec.djdevin.net",
"StringsCDN": "https://strings-cdn.rec.djdevin.net",
"Studio": "https://studio.rec.djdevin.net",
"Thorn": "https://thorn.rec.djdevin.net",
"Videos": "https://videos.rec.djdevin.net",
"WWW": "https://www.rec.djdevin.net"
"Accounts": "https://accounts.rec.example.com",
"AI": "https://ai.rec.example.com",
"API": "https://api.rec.example.com",
"Auth": "https://auth.rec.example.com",
"BugReporting": "https://bugreporting.rec.example.com",
"Cards": "https://cards.rec.example.com",
"CDN": "https://cdn.rec.example.com",
"Chat": "https://chat.rec.example.com",
"Clubs": "https://clubs.rec.example.com",
"CMS": "https://cms.rec.example.com",
"Commerce": "https://commerce.rec.example.com",
"Data": "https://data.rec.example.com",
"DataCollection": "https://datacollection.rec.example.com",
"Discovery": "https://discovery.rec.example.com",
"Econ": "https://econ.rec.example.com",
"GameLogs": "https://gamelogs.rec.example.com",
"Geo": "https://geo.rec.example.com",
"Images": "https://img.rec.example.com",
"Leaderboard": "https://leaderboard.rec.example.com",
"Link": "https://link.rec.example.com",
"Lists": "https://lists.rec.example.com",
"Matchmaking": "https://match.rec.example.com",
"Moderation": "https://api.rec.example.com",
"Notifications": "https://notify.rec.example.com",
"PlatformNotifications": "https://platformnotifications.rec.example.com",
"PlayerSettings": "https://playersettings.rec.example.com",
"RoomComments": "https://roomcomments.rec.example.com",
"RoomieIntegrations": "https://roomieintegrations.rec.example.com",
"Rooms": "https://rooms.rec.example.com",
"Storage": "https://storage.rec.example.com",
"Strings": "https://strings.rec.example.com",
"StringsCDN": "https://strings-cdn.rec.example.com",
"Studio": "https://studio.rec.example.com",
"Thorn": "https://thorn.rec.example.com",
"Videos": "https://videos.rec.example.com",
"WWW": "https://www.rec.example.com"
}
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "ns.rec.djdevin.net",
"pattern": "ns.rec.example.com",
"custom_domain": true
}
],
+3 -3
View File
@@ -1,17 +1,17 @@
# 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 /playersettings``[Authorize]`. The player's settings as
`{ 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
`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`.
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`)
> 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`).
* 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'
/**
* 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`
* 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
* form-urlencoded `key`/`value`, or a JSON body (single object or array).
* Entries with an empty key are dropped.
* Pull `{ key, value }` pairs out of a PUT body: a form-urlencoded `key`/`value`,
* or a JSON body (single object or array). Entries with an empty key are dropped.
*/
async function parseSettings(c: Context<App>): Promise<Array<{ key: string; value: string }>> {
const contentType = c.req.header('content-type') ?? ''
@@ -84,7 +83,7 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'playersettings', status: 'ok' }))
// 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) => {
const id = await authedId(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.
// 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.
.put('/playersettings', async (c) => {
const id = await authedId(c)
@@ -9,7 +9,7 @@ declare module 'cloudflare:test' {
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.
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "playersettings.rec.djdevin.net",
"pattern": "playersettings.rec.example.com",
"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`).
* 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.
* 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
* `/roomserver` prefix, so both forms are registered.
*/
@@ -100,8 +100,8 @@ const app = new Hono<App>()
.get('/', (c) => c.json({ service: 'rooms', status: 'ok' }))
// Room lookup by `id` (first match wins) or `name`. The C# 400s when neither
// is supplied and returns `{}` when nothing matches.
// Room lookup by `id` (first match wins) or `name`. 400s when neither is
// supplied and returns `{}` when nothing matches.
.get('/rooms', async (c) => {
const idParam = c.req.query('id')
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
// 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) => {
const interaction = await toggleCheer(
c.env.DB,
@@ -180,8 +180,8 @@ const app = new Hono<App>()
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
// Single room by id. 404 when the room isn't in D1 (matches the C#). Ignores
// the include/unityAsset* query params, same as the C#.
// Single room by id. 404 when the room isn't in D1. Ignores the
// include/unityAsset* query params.
.get('/rooms/:roomId{[0-9]+}', async (c) => {
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return room ? c.json(room) : c.notFound()
+1 -1
View File
@@ -12,7 +12,7 @@ declare module 'cloudflare:test' {
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.
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
+1 -1
View File
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
"pattern": "rooms.rec.djdevin.net",
"pattern": "rooms.rec.example.com",
"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 { fixCmd } from '../cmd/fix.cmd'
import { shfmtCmd } from '../cmd/shfmt.cmd'
import { syncCmd } from '../cmd/sync.cmd'
import { updateCmd } from '../cmd/update.cmd'
program
@@ -25,6 +26,7 @@ program
.addCommand(ciCmd)
.addCommand(updateCmd)
.addCommand(shfmtCmd)
.addCommand(syncCmd)
// Don't hang for unresolved promises
.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}`)
})