mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
beta openapi docs
This commit is contained in:
+94
-23
@@ -1,26 +1,105 @@
|
||||
# auth
|
||||
|
||||
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.
|
||||
Auth Worker served on the `auth` subdomain (`auth.recflare.net`) — a Hono app that
|
||||
authenticates players and issues the JWTs every other worker verifies.
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------ | ---------------------------------- |
|
||||
| GET | `/eac/challenge` | EAC challenge, served as text |
|
||||
| GET | `/cachedlogin/forplatformid/:platform/:id` | Cached logins (stubbed → `[]`) |
|
||||
| POST | `/connect/token` | OAuth token endpoint, issues a JWT |
|
||||
| GET | `/role/developer/:id` | Developer role lookup (TODO) |
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------ | ------------------------------------------------------ |
|
||||
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
|
||||
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
|
||||
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
|
||||
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
|
||||
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
|
||||
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
|
||||
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
|
||||
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
## API documentation
|
||||
|
||||
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
|
||||
alongside each handler, with the schemas in `src/openapi.ts`.
|
||||
|
||||
**The spec is descriptive, not enforced.** Nothing validates requests against it. That
|
||||
is deliberate: this worker serves a protocol reverse-engineered from the Rec Room
|
||||
client, and the handlers are intentionally lenient — every field is read as
|
||||
`typeof body.x === 'string' ? body.x : ''`, and missing or malformed input generally
|
||||
falls through to a graceful path rather than a 400. Which parts of that tolerance the
|
||||
client actually depends on isn't fully known, so enforcing a schema would risk
|
||||
rejecting requests that work today. Read a "required" field as _the client always
|
||||
sends it_, not _the server rejects it if absent_.
|
||||
|
||||
A test asserts that every route the worker serves appears in the spec, so adding a
|
||||
route without documenting it fails rather than silently shipping an incomplete spec.
|
||||
|
||||
## Grants
|
||||
|
||||
`POST /connect/token` selects behavior from `grant_type`:
|
||||
|
||||
- **`create_account`** — mints an account with an auto-assigned random username and
|
||||
places the player in the Orientation room (RoomId 13), which the client enters
|
||||
without matchmaking. A posted `password` becomes the login credential.
|
||||
- **`cached_login`** — logs into an already-linked account using platform ownership as
|
||||
the credential; no password. The posted `account_id` must be linked to exactly the
|
||||
identity the Steam ticket proves.
|
||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
||||
30-day TTL; platform and platform id come from what was stored at issue time.
|
||||
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||
the account by `username` or numeric `account_id` and requires the matching password
|
||||
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
||||
all, which is what closes id/username-only takeover.
|
||||
|
||||
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
|
||||
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||
|
||||
### Steam is the only verifiable platform
|
||||
|
||||
`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the
|
||||
ticket and checks Steam's signature against Steam's system public key. No publisher
|
||||
Web API key, no network call. Steam (platform `0`) is therefore the only platform
|
||||
whose identity can be proven, so any grant that authenticates _by platform identity_
|
||||
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
|
||||
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
|
||||
ever written to an account's `platformId`.
|
||||
|
||||
## Signup caps
|
||||
|
||||
`create_account` is capped on two independent arms, per verified platform id and per
|
||||
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
|
||||
is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||
networks. Both default to 3.
|
||||
|
||||
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
|
||||
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
|
||||
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
||||
small private server, or when a shared network is being locked out.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| -------------------- | ------------- | ------------------------------------------------------ |
|
||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||
|
||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
||||
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||
database. Run them with `pnpm -F auth migrate`.
|
||||
|
||||
## Signing key
|
||||
|
||||
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved
|
||||
at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
|
||||
**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify
|
||||
in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root
|
||||
`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"`
|
||||
`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
|
||||
Tokens are signed HS256 with the `JWT_SECRET` binding (see `@repo/jwt`), resolved at
|
||||
request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
|
||||
**Cloudflare Secrets Store** that every worker binds, so `auth`-signed tokens verify in
|
||||
`rooms`, `api`, `match`, etc. The store id is kept out of source in the root `.env` as
|
||||
`RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` `store_id`
|
||||
placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
|
||||
|
||||
If the secret resolves empty, the worker refuses to issue a token at all rather than
|
||||
sign one with an empty key — every worker validates against that same key, so an
|
||||
empty-key token would be forgeable by anyone.
|
||||
|
||||
One-time setup (needs Cloudflare auth):
|
||||
|
||||
@@ -39,11 +118,3 @@ wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> -
|
||||
```
|
||||
|
||||
Rotating the store value invalidates all existing tokens (clients re-authenticate).
|
||||
|
||||
## Notes / TODO
|
||||
|
||||
- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no
|
||||
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 (`// TODO: implement`).
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+472
-283
@@ -1,4 +1,5 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
@@ -20,6 +21,18 @@ import {
|
||||
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
form,
|
||||
json,
|
||||
OAuthError,
|
||||
PlatformIdsRequest,
|
||||
roleLookup,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
|
||||
@@ -211,323 +224,462 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
|
||||
.get('/eac/challenge', (c) => c.text(`"AA=="`))
|
||||
.get(
|
||||
'/eac/challenge',
|
||||
describeRoute({
|
||||
tags: ['EAC'],
|
||||
summary: 'Easy Anti-Cheat challenge',
|
||||
description:
|
||||
'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The challenge, JSON-quoted, as text/plain',
|
||||
content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
(c) => c.text(`"AA=="`)
|
||||
)
|
||||
|
||||
// Cached logins for a platform id — the accounts linked to this platform-native
|
||||
// id, so the client can offer them on the login screen (and post one back as a
|
||||
// cached_login grant). No linked account → [], and the client falls back to a
|
||||
// fresh login / create_account.
|
||||
.get('/cachedlogin/forplatformid/:platform/:id', async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||
return c.json(
|
||||
accounts
|
||||
.filter((a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id))
|
||||
.map(toCachedLogin)
|
||||
)
|
||||
})
|
||||
.get(
|
||||
'/cachedlogin/forplatformid/:platform/:id',
|
||||
describeRoute({
|
||||
tags: ['Cached login'],
|
||||
summary: 'Accounts linked to a platform id',
|
||||
description:
|
||||
'Accounts the client may offer on its login screen for this platform identity. ' +
|
||||
'Filtered to those a `cached_login` grant would actually accept, so an entry here ' +
|
||||
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls ' +
|
||||
'back to a fresh login or create_account.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'PlatformType integer. A non-numeric value disables the link filter.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Platform-native id — a SteamID64 for Steam.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(CachedLogin.array(), 'Matching accounts; `[]` if none') },
|
||||
}),
|
||||
async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||
return c.json(
|
||||
accounts
|
||||
.filter(
|
||||
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
|
||||
)
|
||||
.map(toCachedLogin)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk cached-login lookup by platform id (friends resolution). The client POSTs
|
||||
// repeated `id=` params on the auth host; resolve each to its linked accounts.
|
||||
.post('/cachedlogin/forplatformids', async (c) => {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const raw = body.id
|
||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||
for (const pid of ids) {
|
||||
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
||||
.post(
|
||||
'/cachedlogin/forplatformids',
|
||||
describeRoute({
|
||||
tags: ['Cached login'],
|
||||
summary: 'Bulk cached-login lookup (friends resolution)',
|
||||
description:
|
||||
'Resolves many platform ids at once. Results are flattened across all ids, so the ' +
|
||||
'response cannot be mapped back to a specific input id — the client uses each ' +
|
||||
"entry's own `platformId`. Unlike the single-id route, results are NOT filtered to " +
|
||||
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const raw = body.id
|
||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||
for (const pid of ids) {
|
||||
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
||||
}
|
||||
return c.json(out)
|
||||
}
|
||||
return c.json(out)
|
||||
})
|
||||
)
|
||||
|
||||
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
|
||||
.post('/connect/token', async (c) => {
|
||||
// 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 : ''
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
|
||||
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
||||
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
|
||||
.post(
|
||||
'/connect/token',
|
||||
describeRoute({
|
||||
tags: ['Token'],
|
||||
summary: 'OAuth token endpoint — issues a JWT',
|
||||
description: [
|
||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||
'',
|
||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||
'(players do not pick one initially) and places it in the Orientation room. A posted',
|
||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
||||
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
||||
'`platform`, that platform must be Steam and `platform_auth` must verify.',
|
||||
'',
|
||||
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
|
||||
'`account_id` must be linked to exactly the identity that ticket proves. An account',
|
||||
'with no stored platform identity cannot be cached-logged-into.',
|
||||
'',
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
'',
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
'which is what closes id/username-only takeover.',
|
||||
'',
|
||||
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
|
||||
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
|
||||
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
|
||||
'`platform_id` and is the only value ever written to an account. Password and refresh',
|
||||
'grants carry their own credential and are not gated this way.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
'Union of all grants; see the description for per-grant requirements'
|
||||
),
|
||||
responses: {
|
||||
200: json(TokenResponse, 'Access token, refresh token and granted scopes'),
|
||||
400: json(
|
||||
OAuthError,
|
||||
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an ' +
|
||||
'invalid/expired refresh token, a missing account identifier, or a signup cap reached'
|
||||
),
|
||||
500: json(
|
||||
OAuthError,
|
||||
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
|
||||
),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
// 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 : ''
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
|
||||
const platformInt =
|
||||
typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
||||
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
|
||||
|
||||
// The device this login came from. The client posts both on every grant; they're
|
||||
// unverified (client-picked) so they're recorded on the account, never trusted as
|
||||
// a credential. Stored on account creation AND refreshed on each successful login,
|
||||
// so the account's device tracks the player across devices — the raw material for
|
||||
// linking accounts that share a device later.
|
||||
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
|
||||
const deviceClassInt =
|
||||
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
|
||||
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
|
||||
// The device this login came from. The client posts both on every grant; they're
|
||||
// unverified (client-picked) so they're recorded on the account, never trusted as
|
||||
// a credential. Stored on account creation AND refreshed on each successful login,
|
||||
// so the account's device tracks the player across devices — the raw material for
|
||||
// linking accounts that share a device later.
|
||||
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
|
||||
const deviceClassInt =
|
||||
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
|
||||
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
|
||||
|
||||
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
|
||||
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
|
||||
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
|
||||
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
|
||||
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself,
|
||||
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||
// ticket. So those logins must be Steam:
|
||||
// - cached_login authenticates purely by platform identity → always Steam-only.
|
||||
// - create_account that asserts a platform is rejected unless it's Steam, since
|
||||
// we won't bind an identity we can't prove. (create_account with NO platform
|
||||
// is the password-account path — allowed, but it binds no platformId.)
|
||||
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
|
||||
// the ONLY value ever written to an account's `platformId`. Credential (password)
|
||||
// and refresh_token grants carry their own credential and aren't gated here.
|
||||
let verifiedSteamId: string | null = null
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||
if (platformInt !== 0) {
|
||||
// A platform-authenticated login proves who you are with the platform itself,
|
||||
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||
// ticket. So those logins must be Steam:
|
||||
// - cached_login authenticates purely by platform identity → always Steam-only.
|
||||
// - create_account that asserts a platform is rejected unless it's Steam, since
|
||||
// we won't bind an identity we can't prove. (create_account with NO platform
|
||||
// is the password-account path — allowed, but it binds no platformId.)
|
||||
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
|
||||
// the ONLY value ever written to an account's `platformId`. Credential (password)
|
||||
// and refresh_token grants carry their own credential and aren't gated here.
|
||||
let verifiedSteamId: string | null = null
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||
if (platformInt !== 0) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'invalid or missing platform_auth ticket',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedSteamId = verified.steamId
|
||||
platformId = verified.steamId
|
||||
}
|
||||
|
||||
// Resolve the account this token is for:
|
||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
// A `password` may be posted to establish the account's login credential.
|
||||
// - refresh_token: redeem a stored (single-use) refresh token for its account +
|
||||
// platform, so an expiring session renews without re-login.
|
||||
// - otherwise: a credential login. The request identifies the account by
|
||||
// `username` (RecRoom's password grant posts the username, not the id) or a
|
||||
// numeric `account_id`, and MUST post the account's correct `password`. An
|
||||
// account with no password set can't be logged into (no credential to verify)
|
||||
// — closing the id/username-only takeover. New accounts establish a password
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
||||
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||
// signup can't be counted against anyone, and lumping them together would lock out
|
||||
// real players. The disabled check comes first so a disabled arm costs no D1 read.
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
)
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedSteamId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'account limit reached for this platform account',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'too many accounts created from this network',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
||||
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
||||
// so only this Steam user can log back into the account. A password/anonymous
|
||||
// create_account (no platform) binds no platformId.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||
platformId: verifiedSteamId ?? undefined,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
deviceId: deviceId || undefined,
|
||||
deviceClass: deviceId ? deviceClass : undefined,
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
accountId = String(account.accountId)
|
||||
// Establish the login password when one is posted (raw password never stored).
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password !== '') {
|
||||
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
||||
}
|
||||
// Place the new player in Orientation (they don't explicitly matchmake into it).
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
|
||||
} else if (grantType === 'refresh_token') {
|
||||
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
||||
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
||||
if (!refreshed) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(refreshed.accountId)
|
||||
platform = refreshed.platform
|
||||
platformId = refreshed.platformId
|
||||
} else if (grantType === 'cached_login') {
|
||||
// Platform-authenticated login into an already-linked account. The client posts
|
||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
||||
// account is linked to exactly this platform identity — this is the check that
|
||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
||||
// ownership is the credential; no password needed). An account with no stored
|
||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
||||
//
|
||||
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
||||
// above), never the client-supplied field. See steam-ticket.ts.
|
||||
//
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'no linked account for this platform identity',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(account.accountId)
|
||||
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
|
||||
} else {
|
||||
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
|
||||
// password grant sends, a `username` (case-insensitive; trailing whitespace
|
||||
// is trimmed off the posted value).
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
let resolvedId: number | null = null
|
||||
if (/^\d+$/.test(postedId)) {
|
||||
resolvedId = Number(postedId)
|
||||
} else if (postedUsername !== '') {
|
||||
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
|
||||
}
|
||||
if (resolvedId === null) {
|
||||
return c.json(
|
||||
{ error: 'invalid_request', error_description: 'account_id or username is required' },
|
||||
400
|
||||
)
|
||||
}
|
||||
// The account's password MUST be presented and match. An account with no
|
||||
// stored hash has no credential to authenticate against, so login is refused
|
||||
// — this closes the id/username-only takeover.
|
||||
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (!storedHash || !(await verifyPassword(password, storedHash))) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
// binding) would still yield a well-formed token — but one signed with an empty
|
||||
// key, which every worker validates against, so anyone could forge it. Refuse to
|
||||
// issue a token at all rather than complete the login with a forgeable credential.
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing to issue token: JWT_SECRET is empty')
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam can be verified',
|
||||
},
|
||||
400
|
||||
{ error: 'server_error', error_description: 'token signing is not configured' },
|
||||
500
|
||||
)
|
||||
}
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' },
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedSteamId = verified.steamId
|
||||
platformId = verified.steamId
|
||||
}
|
||||
|
||||
// Resolve the account this token is for:
|
||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
// A `password` may be posted to establish the account's login credential.
|
||||
// - refresh_token: redeem a stored (single-use) refresh token for its account +
|
||||
// platform, so an expiring session renews without re-login.
|
||||
// - otherwise: a credential login. The request identifies the account by
|
||||
// `username` (RecRoom's password grant posts the username, not the id) or a
|
||||
// numeric `account_id`, and MUST post the account's correct `password`. An
|
||||
// account with no password set can't be logged into (no credential to verify)
|
||||
// — closing the id/username-only takeover. New accounts establish a password
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
||||
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||
// signup can't be counted against anyone, and lumping them together would lock out
|
||||
// real players. The disabled check comes first so a disabled arm costs no D1 read.
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
||||
// authorizes developer/moderator powers from the token itself (not just the
|
||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
||||
// every login and every refresh_token grant.
|
||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||
const accessToken = await generateToken(
|
||||
accountId,
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount)
|
||||
)
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedSteamId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'account limit reached for this platform account',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'too many accounts created from this network',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, {
|
||||
accountId: Number(accountId),
|
||||
platform,
|
||||
platformId,
|
||||
})
|
||||
|
||||
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
||||
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
||||
// so only this Steam user can log back into the account. A password/anonymous
|
||||
// create_account (no platform) binds no platformId.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||
platformId: verifiedSteamId ?? undefined,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
deviceId: deviceId || undefined,
|
||||
deviceClass: deviceId ? deviceClass : undefined,
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
// @kludge Why is this necessary? Who knows.
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
})
|
||||
accountId = String(account.accountId)
|
||||
// Establish the login password when one is posted (raw password never stored).
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password !== '') {
|
||||
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
||||
}
|
||||
// Place the new player in Orientation (they don't explicitly matchmake into it).
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
|
||||
} else if (grantType === 'refresh_token') {
|
||||
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
||||
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
||||
if (!refreshed) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(refreshed.accountId)
|
||||
platform = refreshed.platform
|
||||
platformId = refreshed.platformId
|
||||
} else if (grantType === 'cached_login') {
|
||||
// Platform-authenticated login into an already-linked account. The client posts
|
||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
||||
// account is linked to exactly this platform identity — this is the check that
|
||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
||||
// ownership is the credential; no password needed). An account with no stored
|
||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
||||
//
|
||||
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
||||
// above), never the client-supplied field. See steam-ticket.ts.
|
||||
//
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'no linked account for this platform identity',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(account.accountId)
|
||||
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
|
||||
} else {
|
||||
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
|
||||
// password grant sends, a `username` (case-insensitive; trailing whitespace
|
||||
// is trimmed off the posted value).
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
let resolvedId: number | null = null
|
||||
if (/^\d+$/.test(postedId)) {
|
||||
resolvedId = Number(postedId)
|
||||
} else if (postedUsername !== '') {
|
||||
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
|
||||
}
|
||||
if (resolvedId === null) {
|
||||
return c.json(
|
||||
{ error: 'invalid_request', error_description: 'account_id or username is required' },
|
||||
400
|
||||
)
|
||||
}
|
||||
// The account's password MUST be presented and match. An account with no
|
||||
// stored hash has no credential to authenticate against, so login is refused
|
||||
// — this closes the id/username-only takeover.
|
||||
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (!storedHash || !(await verifyPassword(password, storedHash))) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
// binding) would still yield a well-formed token — but one signed with an empty
|
||||
// key, which every worker validates against, so anyone could forge it. Refuse to
|
||||
// issue a token at all rather than complete the login with a forgeable credential.
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing to issue token: JWT_SECRET is empty')
|
||||
return c.json(
|
||||
{ error: 'server_error', error_description: 'token signing is not configured' },
|
||||
500
|
||||
)
|
||||
}
|
||||
|
||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
||||
// authorizes developer/moderator powers from the token itself (not just the
|
||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
||||
// every login and every refresh_token grant.
|
||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||
const accessToken = await generateToken(
|
||||
accountId,
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount)
|
||||
)
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, {
|
||||
accountId: Number(accountId),
|
||||
platform,
|
||||
platformId,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
// @kludge Why is this necessary? Who knows.
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
|
||||
// row (the raw password is never persisted). When the account already has a
|
||||
// password, `oldPassword` must match; the first time it's set, `oldPassword` is
|
||||
// empty (as the client sends).
|
||||
.post('/account/me/changepassword', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/account/me/changepassword',
|
||||
describeRoute({
|
||||
tags: ['Account'],
|
||||
summary: "Change the caller's password",
|
||||
description:
|
||||
'Stores a PBKDF2 hash on the account row; the raw password is never persisted. ' +
|
||||
'When the account already has a password, `oldPassword` must match. The first time ' +
|
||||
'a password is set, `oldPassword` is empty — which is what the client sends.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'),
|
||||
responses: {
|
||||
200: json(ChangePasswordResponse, 'Password changed'),
|
||||
400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'),
|
||||
401: { description: 'Missing or invalid bearer token (empty body)' },
|
||||
404: { description: 'The account no longer exists (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
|
||||
if (newPassword === '') {
|
||||
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
|
||||
if (newPassword === '') {
|
||||
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
|
||||
}
|
||||
|
||||
const currentHash = await getPasswordHash(c.env.DB, id)
|
||||
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
|
||||
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
|
||||
}
|
||||
|
||||
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
|
||||
if (!ok) return c.body(null, 404)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
const currentHash = await getPasswordHash(c.env.DB, id)
|
||||
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
|
||||
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
|
||||
}
|
||||
|
||||
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
|
||||
if (!ok) return c.body(null, 404)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// Developer role lookup. Returns a bare JSON boolean (the client reads the body as
|
||||
// a bool), and 404s for an unknown player — mirroring the reference API. The role
|
||||
// is off by default and only an operator grants it (via `runx admin grant-developer`,
|
||||
// which sets the account's isDeveloper flag); it also rides in the token's `role`
|
||||
// claim (see accountRoles).
|
||||
.get('/role/developer/:id', async (c) => {
|
||||
.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
@@ -539,7 +691,7 @@ const app = new Hono<App>()
|
||||
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
|
||||
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
|
||||
// the token's `role` claim.
|
||||
.get('/role/moderator/:id', async (c) => {
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('moderator role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
@@ -548,4 +700,41 @@ const app = new Hono<App>()
|
||||
return c.json(account.isModerator === true)
|
||||
})
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare auth',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Authentication and token issuance for recflare, a private-server reimplementation',
|
||||
'of the Rec Room backend.',
|
||||
'',
|
||||
'The shapes here are **reverse-engineered from the game client**, which is the only',
|
||||
'real consumer. They record observed behaviour rather than a designed contract, and',
|
||||
'the handlers are deliberately lenient: missing or malformed fields generally fall',
|
||||
'through to a graceful path instead of erroring. Nothing in this spec is enforced at',
|
||||
'runtime, so treat a field marked required as "the client always sends it", not "the',
|
||||
'server rejects it if absent".',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the auth worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
|
||||
*
|
||||
* That is deliberate, not an oversight. This worker serves a reverse-engineered
|
||||
* protocol: the Rec Room client is the only real consumer, and the handlers are
|
||||
* intentionally lenient — every field is read as
|
||||
* `typeof body.x === 'string' ? body.x : ''` and missing/malformed input falls
|
||||
* through to a graceful path rather than a 400. Which parts of that tolerance the
|
||||
* client actually depends on is not fully known, so enforcing a schema would risk
|
||||
* rejecting requests that work today, for a client that is hard to debug against.
|
||||
*
|
||||
* So: these schemas record what the client is *observed* to send and what we send
|
||||
* back. If you want to enforce one, do it per-route and land a test with it.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a zod schema as an `application/x-www-form-urlencoded` request body.
|
||||
*
|
||||
* Unlike `responses`, `describeRoute`'s `requestBody` takes a plain OpenAPI schema
|
||||
* and won't accept a `resolver()`, so convert here. zod's `$schema` key is dropped
|
||||
* (not meaningful in an OpenAPI schema position), as is `additionalProperties: false`
|
||||
* — these handlers read the fields they know and ignore the rest, so claiming a
|
||||
* closed object would misreport the server as stricter than it is.
|
||||
*/
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject (it carries
|
||||
// `~standard` and every draft keyword), so the two never match structurally
|
||||
// even though the emitted value is valid OpenAPI 3.1. Cast at the boundary.
|
||||
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PlatformType, by value. The `platform` form field is posted as the integer; the
|
||||
* token's `platform` claim carries the name. Only Steam (0) can actually be
|
||||
* verified — see the platform-auth notes on `POST /connect/token`.
|
||||
*/
|
||||
export const PlatformType = z
|
||||
.union([z.literal(-1), z.int().min(0).max(8)])
|
||||
.describe(
|
||||
'-1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico'
|
||||
)
|
||||
|
||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
||||
export const CachedLogin = z
|
||||
.object({
|
||||
platform: PlatformType,
|
||||
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
||||
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
||||
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||
requirePassword: z
|
||||
.literal(false)
|
||||
.describe('Always false — platform ownership is the credential for a cached login'),
|
||||
})
|
||||
.meta({ id: 'CachedLogin' })
|
||||
|
||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||
export const OAuthError = z
|
||||
.object({
|
||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||
error_description: z.string(),
|
||||
})
|
||||
.meta({ id: 'OAuthError' })
|
||||
|
||||
/** Successful `POST /connect/token` body. */
|
||||
export const TokenResponse = z
|
||||
.object({
|
||||
access_token: z.string().describe('Signed JWT; `sub` is the account id'),
|
||||
expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'),
|
||||
token_type: z.literal('Bearer'),
|
||||
refresh_token: z
|
||||
.string()
|
||||
.describe('Single-use; redeem via grant_type=refresh_token, which rotates it'),
|
||||
scope: z.string().describe('Space-separated granted scopes'),
|
||||
key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'),
|
||||
})
|
||||
.meta({ id: 'TokenResponse' })
|
||||
|
||||
/**
|
||||
* `POST /connect/token` form body — the union of every grant's fields, since
|
||||
* OpenAPI cannot express "these fields iff grant_type=X" without splitting the
|
||||
* endpoint. Per-grant requirements are spelled out in the route description.
|
||||
*/
|
||||
export const TokenRequest = z
|
||||
.object({
|
||||
grant_type: z
|
||||
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
|
||||
.describe('Anything unrecognised (including absent) is treated as a password grant'),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Password grant alternative to account_id; case-insensitive, trimmed'),
|
||||
password: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Required on a password grant. On create_account, sets the initial password'),
|
||||
platform: z.string().optional().describe('PlatformType as an integer string'),
|
||||
platform_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Unverified; ignored in favour of the Steam-verified id where a ticket is required'
|
||||
),
|
||||
platform_auth: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Client-chosen, unverified. Recorded on the account, never trusted'),
|
||||
device_class: z.string().optional().describe('Integer string; defaults to 0'),
|
||||
})
|
||||
.meta({ id: 'TokenRequest' })
|
||||
|
||||
/** `POST /account/me/changepassword` form body. */
|
||||
export const ChangePasswordRequest = z
|
||||
.object({
|
||||
newPassword: z.string().describe('Required; empty is rejected'),
|
||||
oldPassword: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Must match when the account already has a password; empty when first setting it'),
|
||||
})
|
||||
.meta({ id: 'ChangePasswordRequest' })
|
||||
|
||||
/** `POST /account/me/changepassword` response body. */
|
||||
export const ChangePasswordResponse = z
|
||||
.object({ success: z.boolean(), error: z.string().optional() })
|
||||
.meta({ id: 'ChangePasswordResponse' })
|
||||
|
||||
/**
|
||||
* Spec for the `/role/:role/:id` lookups, which are identical apart from the role.
|
||||
* Both return a BARE JSON boolean rather than an object — the client reads the whole
|
||||
* body as a bool — and 404 an unknown player, mirroring the reference API.
|
||||
*/
|
||||
export function roleLookup(role: 'developer' | 'moderator') {
|
||||
return {
|
||||
tags: ['Roles'],
|
||||
summary: `Whether a player has the ${role} role`,
|
||||
description:
|
||||
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.`,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path' as const,
|
||||
required: true,
|
||||
description: 'Account id. A non-numeric value is treated as unknown (404).',
|
||||
schema: { type: 'string' as const },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(z.boolean(), `\`true\` if the player has the ${role} role`),
|
||||
404: { description: 'No such player (empty body)' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk cached-login lookup form body: repeated `id=` fields. */
|
||||
export const PlatformIdsRequest = z
|
||||
.object({ id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields') })
|
||||
.meta({ id: 'PlatformIdsRequest' })
|
||||
@@ -624,4 +624,41 @@ describe('auth worker routes', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /cachedlogin/forplatformid/{platform}/{id}',
|
||||
'GET /eac/challenge',
|
||||
'GET /role/developer/{id}',
|
||||
'GET /role/moderator/{id}',
|
||||
'POST /account/me/changepassword',
|
||||
'POST /cachedlogin/forplatformids',
|
||||
'POST /connect/token',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+132
@@ -136,12 +136,27 @@ importers:
|
||||
'@repo/jwt':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jwt
|
||||
'@standard-community/standard-json':
|
||||
specifier: ^0.3.5
|
||||
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
|
||||
'@standard-community/standard-openapi':
|
||||
specifier: ^0.2.9
|
||||
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
|
||||
hono:
|
||||
specifier: 4.12.27
|
||||
version: 4.12.27
|
||||
hono-openapi:
|
||||
specifier: 1.3.1
|
||||
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
|
||||
openapi-types:
|
||||
specifier: ^12.1.3
|
||||
version: 12.1.3
|
||||
workers-tagged-logger:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@cloudflare/vitest-pool-workers':
|
||||
specifier: 0.16.20
|
||||
@@ -1951,6 +1966,67 @@ packages:
|
||||
'@speed-highlight/core@1.2.7':
|
||||
resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==}
|
||||
|
||||
'@standard-community/standard-json@0.3.5':
|
||||
resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==}
|
||||
peerDependencies:
|
||||
'@standard-schema/spec': ^1.0.0
|
||||
'@types/json-schema': ^7.0.15
|
||||
'@valibot/to-json-schema': ^1.3.0
|
||||
arktype: ^2.1.20
|
||||
effect: ^3.16.8
|
||||
quansync: ^0.2.11
|
||||
sury: ^10.0.0
|
||||
typebox: ^1.0.17
|
||||
valibot: ^1.1.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
zod-to-json-schema: ^3.24.5
|
||||
peerDependenciesMeta:
|
||||
'@valibot/to-json-schema':
|
||||
optional: true
|
||||
arktype:
|
||||
optional: true
|
||||
effect:
|
||||
optional: true
|
||||
sury:
|
||||
optional: true
|
||||
typebox:
|
||||
optional: true
|
||||
valibot:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
zod-to-json-schema:
|
||||
optional: true
|
||||
|
||||
'@standard-community/standard-openapi@0.2.9':
|
||||
resolution: {integrity: sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==}
|
||||
peerDependencies:
|
||||
'@standard-community/standard-json': ^0.3.5
|
||||
'@standard-schema/spec': ^1.0.0
|
||||
arktype: ^2.1.20
|
||||
effect: ^3.17.14
|
||||
openapi-types: ^12.1.3
|
||||
sury: ^10.0.0
|
||||
typebox: ^1.0.0
|
||||
valibot: ^1.1.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
zod-openapi: ^4
|
||||
peerDependenciesMeta:
|
||||
arktype:
|
||||
optional: true
|
||||
effect:
|
||||
optional: true
|
||||
sury:
|
||||
optional: true
|
||||
typebox:
|
||||
optional: true
|
||||
valibot:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
zod-openapi:
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -2021,6 +2097,9 @@ packages:
|
||||
'@types/fs-extra@11.0.4':
|
||||
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
||||
|
||||
@@ -2266,6 +2345,21 @@ packages:
|
||||
git-hooks-list@4.2.1:
|
||||
resolution: {integrity: sha512-WNvqJjOxxs/8ZP9+DWdwWJ7cDsd60NHf39XnD82pDVrKO5q7xfPqpkK6hwEAmBa/ZSEE4IOoR75EzbbIuwGlMw==}
|
||||
|
||||
hono-openapi@1.3.1:
|
||||
resolution: {integrity: sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw==}
|
||||
peerDependencies:
|
||||
'@hono/standard-validator': ^0.2.0
|
||||
'@standard-community/standard-json': ^0.3.5
|
||||
'@standard-community/standard-openapi': ^0.2.9
|
||||
'@types/json-schema': ^7.0.15
|
||||
hono: ^4.11.2
|
||||
openapi-types: ^12.1.3
|
||||
peerDependenciesMeta:
|
||||
'@hono/standard-validator':
|
||||
optional: true
|
||||
hono:
|
||||
optional: true
|
||||
|
||||
hono@4.12.27:
|
||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
@@ -2409,6 +2503,9 @@ packages:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
|
||||
hasBin: true
|
||||
@@ -2466,6 +2563,9 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
react-dom@19.2.7:
|
||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||
peerDependencies:
|
||||
@@ -3610,6 +3710,22 @@ snapshots:
|
||||
|
||||
'@speed-highlight/core@1.2.7': {}
|
||||
|
||||
'@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/json-schema': 7.0.15
|
||||
quansync: 0.2.11
|
||||
optionalDependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
'@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
|
||||
'@standard-schema/spec': 1.1.0
|
||||
openapi-types: 12.1.3
|
||||
optionalDependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@taplo/core@0.2.0': {}
|
||||
@@ -3682,6 +3798,8 @@ snapshots:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 26.0.1
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 26.0.1
|
||||
@@ -3933,6 +4051,16 @@ snapshots:
|
||||
|
||||
git-hooks-list@4.2.1: {}
|
||||
|
||||
hono-openapi@1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3):
|
||||
dependencies:
|
||||
'@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
|
||||
'@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
|
||||
'@types/json-schema': 7.0.15
|
||||
openapi-types: 12.1.3
|
||||
optionalDependencies:
|
||||
'@hono/standard-validator': 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27)
|
||||
hono: 4.12.27
|
||||
|
||||
hono@4.12.27: {}
|
||||
|
||||
http-codex@0.6.7: {}
|
||||
@@ -4046,6 +4174,8 @@ snapshots:
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.23.0
|
||||
@@ -4107,6 +4237,8 @@ snapshots:
|
||||
|
||||
prettier@3.9.4: {}
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
react-dom@19.2.7(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
Reference in New Issue
Block a user