diff --git a/.gitignore b/.gitignore
index ddec99d..15c4121 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,6 @@ yarn-error.log*
# Agents
.claude/settings.local.json
+
+# Configuration
+env.json
diff --git a/.idea/php.xml b/.idea/php.xml
deleted file mode 100644
index f324872..0000000
--- a/.idea/php.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Justfile b/Justfile
index e0137b5..85e747b 100644
--- a/Justfile
+++ b/Justfile
@@ -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]
diff --git a/README.md b/README.md
index 5d5bc0a..ec6e306 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/apps/accounts/README.md b/apps/accounts/README.md
index ca0da5c..8abd145 100644
--- a/apps/accounts/README.md
+++ b/apps/accounts/README.md
@@ -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 }`.
diff --git a/apps/accounts/src/accounts-db.ts b/apps/accounts/src/accounts-db.ts
index 1262ae6..ebc0a38 100644
--- a/apps/accounts/src/accounts-db.ts
+++ b/apps/accounts/src/accounts-db.ts
@@ -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.
*/
diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts
index 9023354..d6e695a 100644
--- a/apps/accounts/src/accounts.app.ts
+++ b/apps/accounts/src/accounts.app.ts
@@ -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): Promise {
@@ -61,7 +61,7 @@ const app = new Hono()
.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()
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()
// ---- 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()
.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)))
})
diff --git a/apps/accounts/src/jwt.ts b/apps/accounts/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/accounts/src/jwt.ts
+++ b/apps/accounts/src/jwt.ts
@@ -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.
diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts
index 6083434..92c3259 100644
--- a/apps/accounts/src/test/integration/api.test.ts
+++ b/apps/accounts/src/test/integration/api.test.ts
@@ -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).
diff --git a/apps/accounts/wrangler.jsonc b/apps/accounts/wrangler.jsonc
index 51ca3f8..bbd8da4 100644
--- a/apps/accounts/wrangler.jsonc
+++ b/apps/accounts/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "accounts.rec.djdevin.net",
+ "pattern": "accounts.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/api/README.md b/apps/api/README.md
index db05f05..b6efd8b 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -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.
diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts
index e34fe4c..bb72e30 100644
--- a/apps/api/src/api.app.ts
+++ b/apps/api/src/api.app.ts
@@ -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): Promise {
@@ -57,7 +57,7 @@ function unauthorized(c: Context) {
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): Promise {
const body = await c.req.parseBody().catch(() => ({}) as Record)
const ids = body.Ids
@@ -83,9 +83,9 @@ function queryIds(c: Context): 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({ 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({ 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({ strict: false })
if (id === null) return unauthorized(c)
const update = await c.req.json>().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({ 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({ 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({ 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({ 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([])
})
diff --git a/apps/api/src/default-avatar-items.ts b/apps/api/src/default-avatar-items.ts
index 49dbd95..6b0a71e 100644
--- a/apps/api/src/default-avatar-items.ts
+++ b/apps/api/src/default-avatar-items.ts
@@ -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`.
*/
diff --git a/apps/api/src/default-settings.ts b/apps/api/src/default-settings.ts
index 93d7e96..a5f6fe6 100644
--- a/apps/api/src/default-settings.ts
+++ b/apps/api/src/default-settings.ts
@@ -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
diff --git a/apps/api/src/jwt.ts b/apps/api/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/api/src/jwt.ts
+++ b/apps/api/src/jwt.ts
@@ -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.
diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts
index eba8e80..f013499 100644
--- a/apps/api/src/test/integration/api.test.ts
+++ b/apps/api/src/test/integration/api.test.ts
@@ -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 () => {
diff --git a/apps/api/static/api-config-v2.json b/apps/api/static/api-config-v2.json
index 533738e..f2ba2c6 100644
--- a/apps/api/static/api-config-v2.json
+++ b/apps/api/static/api-config-v2.json
@@ -369,5 +369,5 @@
"MicSpamSamplePercentageForForceMuteToEnd": 0.2,
"MicSpamWarningStateVolumeMultiplier": 0.25
},
- "ShareBaseUrl": "https://www.rec.djdevin.net/{0}"
+ "ShareBaseUrl": "https://www.rec.example.com/{0}"
}
diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc
index f749572..0b8351b 100644
--- a/apps/api/wrangler.jsonc
+++ b/apps/api/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "api.rec.djdevin.net",
+ "pattern": "api.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/auth/README.md b/apps/auth/README.md
index f5cbb5b..0421ceb 100644
--- a/apps/auth/README.md
+++ b/apps/auth/README.md
@@ -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`).
diff --git a/apps/auth/src/accounts-db.ts b/apps/auth/src/accounts-db.ts
index 9cf71ec..b2c961c 100644
--- a/apps/auth/src/accounts-db.ts
+++ b/apps/auth/src/accounts-db.ts
@@ -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.
*/
diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts
index d145363..cc8032d 100644
--- a/apps/auth/src/auth.app.ts
+++ b/apps/auth/src/auth.app.ts
@@ -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 = {
[-1]: 'All',
0: 'Steam',
@@ -131,8 +131,8 @@ const app = new Hono()
// 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)
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()
})
})
- // 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 })
diff --git a/apps/auth/src/jwt.ts b/apps/auth/src/jwt.ts
index 35cbdb7..1824533 100644
--- a/apps/auth/src/jwt.ts
+++ b/apps/auth/src/jwt.ts
@@ -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 {
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 = {
diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts
index 1cf1087..3ba7c4a 100644
--- a/apps/auth/src/test/integration/api.test.ts
+++ b/apps/auth/src/test/integration/api.test.ts
@@ -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=="')
})
diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc
index b541a14..b151c2b 100644
--- a/apps/auth/wrangler.jsonc
+++ b/apps/auth/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "auth.rec.djdevin.net",
+ "pattern": "auth.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts
index 3a88602..ff259e8 100644
--- a/apps/cdn/src/cdn.app.ts
+++ b/apps/cdn/src/cdn.app.ts
@@ -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()
.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) => {
diff --git a/apps/cdn/src/context.ts b/apps/cdn/src/context.ts
index f31caf3..4d59f34 100644
--- a/apps/cdn/src/context.ts
+++ b/apps/cdn/src/context.ts
@@ -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/` and
- // room build data under `room/` (mirrors the C#'s Sigs/ and
- // Data/DataBlobs/ directories).
+ // room build data under `room/`.
CDN_ASSETS: R2Bucket
}
diff --git a/apps/cdn/src/jwt.ts b/apps/cdn/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/cdn/src/jwt.ts
+++ b/apps/cdn/src/jwt.ts
@@ -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.
diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts
index bbce378..e8c2930 100644
--- a/apps/cdn/src/test/integration/api.test.ts
+++ b/apps/cdn/src/test/integration/api.test.ts
@@ -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'
diff --git a/apps/cdn/wrangler.jsonc b/apps/cdn/wrangler.jsonc
index ca0919e..f3b52c5 100644
--- a/apps/cdn/wrangler.jsonc
+++ b/apps/cdn/wrangler.jsonc
@@ -8,7 +8,7 @@
],
"routes": [
{
- "pattern": "cdn.rec.djdevin.net",
+ "pattern": "cdn.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/chat/README.md b/apps/chat/README.md
index fc4de5a..9ae1838 100644
--- a/apps/chat/README.md
+++ b/apps/chat/README.md
@@ -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
diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts
index 11f3b4f..af1ca37 100644
--- a/apps/chat/src/chat.app.ts
+++ b/apps/chat/src/chat.app.ts
@@ -21,7 +21,7 @@ const app = new Hono()
.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
diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts
index eaedb80..16ccd71 100644
--- a/apps/chat/src/test/integration/api.test.ts
+++ b/apps/chat/src/test/integration/api.test.ts
@@ -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 () => {
diff --git a/apps/chat/wrangler.jsonc b/apps/chat/wrangler.jsonc
index 2c7e60f..7ca3c0f 100644
--- a/apps/chat/wrangler.jsonc
+++ b/apps/chat/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "chat.rec.djdevin.net",
+ "pattern": "chat.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/clubs/README.md b/apps/clubs/README.md
index ff43f73..98d7675 100644
--- a/apps/clubs/README.md
+++ b/apps/clubs/README.md
@@ -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
diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts
index a6c52c5..a18c249 100644
--- a/apps/clubs/src/clubs.app.ts
+++ b/apps/clubs/src/clubs.app.ts
@@ -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()
.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-
diff --git a/apps/clubs/src/jwt.ts b/apps/clubs/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/clubs/src/jwt.ts
+++ b/apps/clubs/src/jwt.ts
@@ -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.
diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts
index 1ea46c6..3e627fe 100644
--- a/apps/clubs/src/test/integration/api.test.ts
+++ b/apps/clubs/src/test/integration/api.test.ts
@@ -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'
diff --git a/apps/clubs/wrangler.jsonc b/apps/clubs/wrangler.jsonc
index 0dd1692..dadcad0 100644
--- a/apps/clubs/wrangler.jsonc
+++ b/apps/clubs/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "clubs.rec.djdevin.net",
+ "pattern": "clubs.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/commerce/src/commerce.app.ts b/apps/commerce/src/commerce.app.ts
index fbe9244..5515095 100644
--- a/apps/commerce/src/commerce.app.ts
+++ b/apps/commerce/src/commerce.app.ts
@@ -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()
.use(
@@ -25,8 +25,8 @@ const app = new Hono()
.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
diff --git a/apps/commerce/src/test/integration/api.test.ts b/apps/commerce/src/test/integration/api.test.ts
index 07c8345..2be65ef 100644
--- a/apps/commerce/src/test/integration/api.test.ts
+++ b/apps/commerce/src/test/integration/api.test.ts
@@ -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 () => {
diff --git a/apps/commerce/wrangler.jsonc b/apps/commerce/wrangler.jsonc
index 3ce788a..a45a568 100644
--- a/apps/commerce/wrangler.jsonc
+++ b/apps/commerce/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "commerce.rec.djdevin.net",
+ "pattern": "commerce.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/datacollection/src/test/integration/api.test.ts b/apps/datacollection/src/test/integration/api.test.ts
index d45cacd..fa94041 100644
--- a/apps/datacollection/src/test/integration/api.test.ts
+++ b/apps/datacollection/src/test/integration/api.test.ts
@@ -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 () => {
diff --git a/apps/datacollection/wrangler.jsonc b/apps/datacollection/wrangler.jsonc
index 9494410..8184b2f 100644
--- a/apps/datacollection/wrangler.jsonc
+++ b/apps/datacollection/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "datacollection.rec.djdevin.net",
+ "pattern": "datacollection.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/econ/README.md b/apps/econ/README.md
index 7b7c74e..afeed55 100644
--- a/apps/econ/README.md
+++ b/apps/econ/README.md
@@ -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
diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts
index 43fb568..62c50ab 100644
--- a/apps/econ/src/econ.app.ts
+++ b/apps/econ/src/econ.app.ts
@@ -59,8 +59,8 @@ const app = new Hono()
// 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()
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()
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()
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 })
)
diff --git a/apps/econ/src/jwt.ts b/apps/econ/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/econ/src/jwt.ts
+++ b/apps/econ/src/jwt.ts
@@ -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.
diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts
index 9436e91..0a11d89 100644
--- a/apps/econ/src/test/integration/api.test.ts
+++ b/apps/econ/src/test/integration/api.test.ts
@@ -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'
diff --git a/apps/econ/wrangler.jsonc b/apps/econ/wrangler.jsonc
index 6b918ef..3022c7a 100644
--- a/apps/econ/wrangler.jsonc
+++ b/apps/econ/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "econ.rec.djdevin.net",
+ "pattern": "econ.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/img/README.md b/apps/img/README.md
index ab4256f..ce9bc61 100644
--- a/apps/img/README.md
+++ b/apps/img/README.md
@@ -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 /?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=`
- 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`).
diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts
index 3375332..8f600a3 100644
--- a/apps/img/src/img.app.ts
+++ b/apps/img/src/img.app.ts
@@ -29,7 +29,7 @@ function getSigningKey(env: Env): Promise {
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 {
const key = await getSigningKey(env)
if (!key) return null
@@ -60,8 +60,8 @@ const app = new Hono()
// 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)
diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts
index 435f548..affae3e 100644
--- a/apps/img/src/test/integration/api.test.ts
+++ b/apps/img/src/test/integration/api.test.ts
@@ -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])
diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc
index 12b4fb2..308b828 100644
--- a/apps/img/wrangler.jsonc
+++ b/apps/img/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "img.rec.djdevin.net",
+ "pattern": "img.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/match/README.md b/apps/match/README.md
index 99a38e9..c27ee29 100644
--- a/apps/match/README.md
+++ b/apps/match/README.md
@@ -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
diff --git a/apps/match/src/jwt.ts b/apps/match/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/match/src/jwt.ts
+++ b/apps/match/src/jwt.ts
@@ -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.
diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts
index 7bd9e51..8f9a0f7 100644
--- a/apps/match/src/match.app.ts
+++ b/apps/match/src/match.app.ts
@@ -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): Promise {
@@ -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()
.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()
// 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()
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()
diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts
index 7cb49d0..11b010d 100644
--- a/apps/match/src/test/integration/api.test.ts
+++ b/apps/match/src/test/integration/api.test.ts
@@ -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).
diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc
index e206681..f3d454d 100644
--- a/apps/match/wrangler.jsonc
+++ b/apps/match/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "match.rec.djdevin.net",
+ "pattern": "match.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/notify/README.md b/apps/notify/README.md
index afc4fa4..6f10e87 100644
--- a/apps/notify/README.md
+++ b/apps/notify/README.md
@@ -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=`.
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
diff --git a/apps/notify/src/notifications-hub.ts b/apps/notify/src/notifications-hub.ts
index 26beb9c..531b11f 100644
--- a/apps/notify/src/notifications-hub.ts
+++ b/apps/notify/src/notifications-hub.ts
@@ -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 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
* player is subscribed.
*/
@@ -136,7 +135,7 @@ export class NotificationsHub extends DurableObject {
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 {
// ---- 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,
diff --git a/apps/notify/src/notify.app.ts b/apps/notify/src/notify.app.ts
index 8ed4ddc..9a1e3bb 100644
--- a/apps/notify/src/notify.app.ts
+++ b/apps/notify/src/notify.app.ts
@@ -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()
@@ -57,8 +56,8 @@ const app = new Hono()
})
// ---- 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 }>()
diff --git a/apps/notify/src/test/integration/api.test.ts b/apps/notify/src/test/integration/api.test.ts
index 6f90eca..14ba66f 100644
--- a/apps/notify/src/test/integration/api.test.ts
+++ b/apps/notify/src/test/integration/api.test.ts
@@ -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')
diff --git a/apps/notify/wrangler.jsonc b/apps/notify/wrangler.jsonc
index fc5677c..ba9b3b0 100644
--- a/apps/notify/wrangler.jsonc
+++ b/apps/notify/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "notify.rec.djdevin.net",
+ "pattern": "notify.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/ns/README.md b/apps/ns/README.md
index 4805fef..b4a6855 100644
--- a/apps/ns/README.md
+++ b/apps/ns/README.md
@@ -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.)
diff --git a/apps/ns/src/ns.app.ts b/apps/ns/src/ns.app.ts
index 622f72b..42c43ad 100644
--- a/apps/ns/src/ns.app.ts
+++ b/apps/ns/src/ns.app.ts
@@ -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()
.use(
diff --git a/apps/ns/src/test/integration/api.test.ts b/apps/ns/src/test/integration/api.test.ts
index 1e2406d..fdd0584 100644
--- a/apps/ns/src/test/integration/api.test.ts
+++ b/apps/ns/src/test/integration/api.test.ts
@@ -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
- 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 () => {
diff --git a/apps/ns/static/endpoints.json b/apps/ns/static/endpoints.json
index d955111..da68746 100644
--- a/apps/ns/static/endpoints.json
+++ b/apps/ns/static/endpoints.json
@@ -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"
}
diff --git a/apps/ns/wrangler.jsonc b/apps/ns/wrangler.jsonc
index f0b0d7c..2c8e612 100644
--- a/apps/ns/wrangler.jsonc
+++ b/apps/ns/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "ns.rec.djdevin.net",
+ "pattern": "ns.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/playersettings/README.md b/apps/playersettings/README.md
index 6d4a015..e363ac4 100644
--- a/apps/playersettings/README.md
+++ b/apps/playersettings/README.md
@@ -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:`).
-> 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.
diff --git a/apps/playersettings/src/jwt.ts b/apps/playersettings/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/playersettings/src/jwt.ts
+++ b/apps/playersettings/src/jwt.ts
@@ -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.
diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts
index 3f0134e..676c22a 100644
--- a/apps/playersettings/src/playersettings.app.ts
+++ b/apps/playersettings/src/playersettings.app.ts
@@ -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) {
}
/**
- * 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): Promise> {
const contentType = c.req.header('content-type') ?? ''
@@ -84,7 +83,7 @@ const app = new Hono()
.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()
})
// 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)
diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts
index b9d36e2..880a641 100644
--- a/apps/playersettings/src/test/integration/api.test.ts
+++ b/apps/playersettings/src/test/integration/api.test.ts
@@ -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'
diff --git a/apps/playersettings/wrangler.jsonc b/apps/playersettings/wrangler.jsonc
index 7916f43..51cf3ea 100644
--- a/apps/playersettings/wrangler.jsonc
+++ b/apps/playersettings/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "playersettings.rec.djdevin.net",
+ "pattern": "playersettings.rec.example.com",
"custom_domain": true
}
],
diff --git a/apps/rooms/src/jwt.ts b/apps/rooms/src/jwt.ts
index d3c5d2d..b105025 100644
--- a/apps/rooms/src/jwt.ts
+++ b/apps/rooms/src/jwt.ts
@@ -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.
diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts
index 2ff7017..5ea3399 100644
--- a/apps/rooms/src/rooms.app.ts
+++ b/apps/rooms/src/rooms.app.ts
@@ -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()
.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()
})
// 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()
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()
diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts
index 6a50b4a..6164efb 100644
--- a/apps/rooms/src/test/integration/api.test.ts
+++ b/apps/rooms/src/test/integration/api.test.ts
@@ -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'
diff --git a/apps/rooms/wrangler.jsonc b/apps/rooms/wrangler.jsonc
index a0728e1..af9390d 100644
--- a/apps/rooms/wrangler.jsonc
+++ b/apps/rooms/wrangler.jsonc
@@ -6,7 +6,7 @@
"compatibility_flags": ["nodejs_compat"],
"routes": [
{
- "pattern": "rooms.rec.djdevin.net",
+ "pattern": "rooms.rec.example.com",
"custom_domain": true
}
],
diff --git a/env.example.json b/env.example.json
new file mode 100644
index 0000000..a67d27b
--- /dev/null
+++ b/env.example.json
@@ -0,0 +1,6 @@
+{
+ "domain": "rec.example.com",
+ "subdomains": {
+ "playersettings": "settings"
+ }
+}
diff --git a/packages/tools/src/bin/runx.cmd.ts b/packages/tools/src/bin/runx.cmd.ts
index c4def41..c2230c7 100644
--- a/packages/tools/src/bin/runx.cmd.ts
+++ b/packages/tools/src/bin/runx.cmd.ts
@@ -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))
diff --git a/packages/tools/src/cmd/sync.cmd.ts b/packages/tools/src/cmd/sync.cmd.ts
new file mode 100644
index 0000000..6c284f7
--- /dev/null
+++ b/packages/tools/src/cmd/sync.cmd.ts
@@ -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: `.`, 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}`)
+ })