mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
testing generic deploy
This commit is contained in:
@@ -47,3 +47,6 @@ yarn-error.log*
|
||||
|
||||
# Agents
|
||||
.claude/settings.local.json
|
||||
|
||||
# Configuration
|
||||
env.json
|
||||
|
||||
Generated
-19
@@ -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>
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }`.
|
||||
|
||||
@@ -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,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,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).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "accounts.rec.djdevin.net",
|
||||
"pattern": "accounts.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+4
-4
@@ -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
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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://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 () => {
|
||||
|
||||
@@ -369,5 +369,5 @@
|
||||
"MicSpamSamplePercentageForForceMuteToEnd": 0.2,
|
||||
"MicSpamWarningStateVolumeMultiplier": 0.25
|
||||
},
|
||||
"ShareBaseUrl": "https://www.rec.djdevin.net/{0}"
|
||||
"ShareBaseUrl": "https://www.rec.example.com/{0}"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "api.rec.djdevin.net",
|
||||
"pattern": "api.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+4
-4
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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=="')
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "auth.rec.djdevin.net",
|
||||
"pattern": "auth.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+11
-13
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 @@ 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'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "cdn.rec.djdevin.net",
|
||||
"pattern": "cdn.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "chat.rec.djdevin.net",
|
||||
"pattern": "chat.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "clubs.rec.djdevin.net",
|
||||
"pattern": "clubs.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "datacollection.rec.djdevin.net",
|
||||
"pattern": "datacollection.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+10
-10
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "econ.rec.djdevin.net",
|
||||
"pattern": "econ.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+3
-3
@@ -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`).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "img.rec.djdevin.net",
|
||||
"pattern": "img.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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,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
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "match.rec.djdevin.net",
|
||||
"pattern": "match.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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> }>()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "notify.rec.djdevin.net",
|
||||
"pattern": "notify.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
+9
-7
@@ -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.)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "ns.rec.djdevin.net",
|
||||
"pattern": "ns.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "playersettings.rec.djdevin.net",
|
||||
"pattern": "playersettings.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "rooms.rec.djdevin.net",
|
||||
"pattern": "rooms.rec.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "rec.example.com",
|
||||
"subdomains": {
|
||||
"playersettings": "settings"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
Reference in New Issue
Block a user