add game config configuration

This commit is contained in:
Devin Zuczek
2026-07-14 18:01:10 -04:00
parent df2365fa8f
commit 3e8f66b620
17 changed files with 376 additions and 65 deletions
+33
View File
@@ -23,3 +23,36 @@ RECFLARE_DOMAIN=rec.example.com
# Kept out of the committed wrangler.jsonc (which uses a "local" placeholder) and
# spliced in at deploy time. Required to deploy any worker.
# RECFLARE_SECRETS_STORE=00000000-0000-0000-0000-000000000000
# --- Server tuning (all optional; the shown value is the built-in default) ---
# Everything below is passed to the workers as a variable, named without the RECFLARE_
# prefix: RECFLARE_STARTING_TOKENS becomes STARTING_TOKENS. Every worker gets every knob —
# the ones that don't read a knob just ignore it — so nothing here has to be routed to a
# particular service, and two services reading the same knob agree on it for free. (The five
# settings above are the exception: they configure the deploy itself, not the workers.)
#
# The same values are used by `just deploy` and by `just dev`, so a knob is set in exactly
# one place. Change one and re-deploy the worker that reads it (e.g. `just deploy -F auth`)
# for it to take effect. Leave a line commented out and the worker uses its built-in
# default — and deleting a line you'd set really does restore that default on the next
# deploy.
#
# Don't set these in the Cloudflare dashboard — a deploy replaces a worker's variables
# wholesale, so a dashboard-set value is wiped by your next `just deploy`. This file is the
# durable place. (Actual secrets don't go here either: they live in the Cloudflare Secrets
# Store, like the shared JWT signing key above.)
# How many accounts one signup source may create (`auth`). Enforced on signup only,
# never on login: an existing account always stays reachable. Set either to 0 to turn
# that cap off entirely.
# ...PER_PLATFORM_ID counts accounts per Steam-verified identity — unspoofable.
# ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus
# network shares one address. This is the one to raise (or zero out) if real players
# report being locked out.
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# RecCenterTokens a new player is granted, the first time their balance is read (`econ`).
# 0 means players start broke. Applies only to players who haven't been granted yet —
# raising it later does NOT top up existing players.
# RECFLARE_STARTING_TOKENS=10000
+48 -6
View File
@@ -5,8 +5,8 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
## Why Cloudflare?
- **Makes it easy to mirror RecNet**
Rec Room's backend is (was) a set of independent microservices, not one big monolith.
Modeling each service as its own isolated Worker keeps RecFlare's structure close to
Rec Room's backend is (was) a set of independent microservices, not one big monolith.
Modeling each service as its own isolated Worker keeps RecFlare's structure close to
the real thing — services scale,
fail, and deploy independently — instead of collapsing everything into a single
giant server.
@@ -21,10 +21,10 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
stateless (effectively read-only). They are also scalable by default so we don't need to worry
about adding more disk space or upgrading services. If we start outgrowing the limits of these,
well, we'll cross that bridge when we get to it.
- D1 (a SQLite-compatible distributed database)
- R2 (service like S3 for mass file hosting)
- KV (service to distributed offer key/value stores)
- Durable Objects (for a notifications hub)
- D1 (a SQLite-compatible distributed database)
- R2 (service like S3 for mass file hosting)
- KV (service to distributed offer key/value stores)
- Durable Objects (for a notifications hub)
## Do I have to use Cloudflare?
@@ -157,6 +157,48 @@ just deploy # Deploy code
Optionally if you know there was only a change to a single service, you can use `just [migrate|deploy] -F econ` for example to only deploy the `econ` microservice.
## Tuning your server
A few gameplay/policy values are knobs rather than hardcoded constants, and they live in
the same `.env` you already created. `.env.example` carries each one commented out, set to
its built-in default: copy the lines you want to change into your `.env`, uncomment them,
edit the value, then re-deploy the worker that reads them.
| `.env` variable | Read by | Default | What it does |
| --------------------------------------- | ------- | ------- | -------------------------------------------------------------- |
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
Then deploy just the worker that reads it:
```bash
just deploy -F auth
```
A line you leave out of `.env` keeps its default, so only copy over what you actually want
to change — and deleting a line you'd set restores the default on the next deploy. The same
`.env` feeds `just dev`, so a knob is configured once and behaves the same locally as it
does deployed.
Adding a knob of your own takes no changes to the deploy tooling. Every `RECFLARE_*` in
`.env` is handed to the workers as a variable under its unprefixed name — `RECFLARE_STARTING_TOKENS`
arrives as `STARTING_TOKENS` — so a new one only needs declaring in that worker's
`src/context.ts` and reading in its code. Every worker receives every knob and ignores the
ones it doesn't read, which is also how two services can share a value. (The domain and the
resource ids above are the exception: those configure the deploy itself and are never
passed to a worker.)
Both account caps are enforced on signup only, never on login — an existing account always
stays reachable no matter how many its owner has accumulated. The per-IP cap is the coarse
one: households, NAT and shared campus/mobile networks put many legitimate players behind a
single address, so raise it (or set it to `0`) if real players report being locked out.
> **Don't set these as Worker variables in the Cloudflare dashboard.** A deploy replaces a
> worker's variables wholesale, so a dashboard-set value is wiped by your next
> `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they
> go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above.
## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
+22 -8
View File
@@ -15,7 +15,7 @@ import {
setPasswordHash,
setPresence,
} from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { hashPassword, verifyPassword } from './password'
@@ -57,9 +57,15 @@ const PLATFORM_TYPES: Record<number, string> = {
* legitimate players behind one address, so this WILL be the arm that produces false
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
* count by hopping networks.
*
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
* setting one to 0 disables that arm entirely — which a small private server that trusts
* everyone it invites will want, and which the IP arm in particular is worth reaching for
* if a shared network is being locked out.
*/
const MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
@@ -301,12 +307,19 @@ const app = new Hono<App>()
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 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.
// 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)) >= MAX_ACCOUNTS_PER_PLATFORM_ID
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
return c.json(
@@ -318,8 +331,9 @@ const app = new Hono<App>()
)
}
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= MAX_ACCOUNTS_PER_IP
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
return c.json(
+7
View File
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
// number while the same var set from the dashboard or `--var` arrives as a string —
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
}
/** Variables can be extended */
+27 -1
View File
@@ -419,10 +419,36 @@ describe('auth worker routes', () => {
expect(capped.json.error_description).toMatch(/network/)
// A different IP is unaffected, and the capped IP can still LOG IN to what it has.
const other = await postToken('grant_type=create_account&platform_id=steam-cap4', '198.51.100.23')
const other = await postToken(
'grant_type=create_account&platform_id=steam-cap4',
'198.51.100.23'
)
expect(other.status).toBe(200)
})
test('the signup caps come from vars, and 0 disables an arm', async () => {
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
// override is restored in `finally` rather than leaking a cap of 0 into the tests above.
const original = env.MAX_ACCOUNTS_PER_IP
try {
env.MAX_ACCOUNTS_PER_IP = 1
const ip = '198.51.100.30'
const first = await postToken('grant_type=create_account&platform_id=steam-var0', ip)
expect(first.status).toBe(200)
const capped = await postToken('grant_type=create_account&platform_id=steam-var1', ip)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/network/)
// 0 disables the arm entirely: the IP that was just capped can sign up again.
env.MAX_ACCOUNTS_PER_IP = 0
const uncapped = await postToken('grant_type=create_account&platform_id=steam-var2', ip)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
// Account 42's owner may be over the signup cap; that must never lock them out of
// an account they already have.
+5
View File
@@ -36,6 +36,11 @@
"head_sampling_rate": 1 // 100%
}
},
// The signup caps (MAX_ACCOUNTS_PER_PLATFORM_ID, MAX_ACCOUNTS_PER_IP) are deliberately
// NOT set here. They're injected at deploy time from the gitignored .env
// (RECFLARE_MAX_ACCOUNTS_*, see .env.example), so tuning them never means editing a
// versioned file. Unset — the default — falls back to the DEFAULT_MAX_ACCOUNTS_*
// constants in src/auth.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
+43 -17
View File
@@ -68,16 +68,26 @@ const SPENDABLE: readonly number[] = [
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
/**
* What a player starts with, granted lazily the first time their balances are read
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
* players start broke.
*/
export const DEFAULT_STARTING_TOKENS = 10_000
/**
* What a player starts with, granted lazily the first time their balances are touched
* (see `ensureStartingBalances`). Currencies absent here start at 0.
*
* This is the whole signup grant — change the number here and it applies to every
* player who hasn't been granted yet. It is NOT re-granted: a player who spends down
* to 0 keeps a 0 row, and the grant is skipped because the row exists.
* This is the whole signup grant. It is NOT re-granted: a player who spends down to 0
* keeps a 0 row, and the grant is skipped because the row exists. That also means
* raising `STARTING_TOKENS` later only affects players who haven't been granted yet —
* existing players keep the amount they were granted under the old setting.
*/
export const STARTING_BALANCES: ReadonlyArray<{ currencyType: number; amount: number }> = [
{ currencyType: CurrencyType.RecCenterTokens, amount: 10_000 },
]
export function startingBalances(
startingTokens: number
): ReadonlyArray<{ currencyType: number; amount: number }> {
return [{ currencyType: CurrencyType.RecCenterTokens, amount: startingTokens }]
}
/**
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
@@ -108,19 +118,32 @@ export interface Balance {
*
* Called on read rather than at account creation so accounts that predate this table
* (every existing player) get their grant too.
*
* `startingTokens` is passed in rather than read from a module constant because it's
* operator configuration (`STARTING_TOKENS`), and every path that can trigger the grant
* has to agree on it — a caller that skipped it would quietly grant the built-in default
* to whichever player happened to touch that path first.
*/
export async function ensureStartingBalances(db: D1Database, accountId: number): Promise<void> {
export async function ensureStartingBalances(
db: D1Database,
accountId: number,
startingTokens: number
): Promise<void> {
const stmt = db.prepare(
'INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3)'
)
await db.batch(
STARTING_BALANCES.map((b) => stmt.bind(accountId, b.currencyType, b.amount))
startingBalances(startingTokens).map((b) => stmt.bind(accountId, b.currencyType, b.amount))
)
}
/** Every balance an account holds (after its starting grant is applied). */
export async function getBalances(db: D1Database, accountId: number): Promise<Balance[]> {
await ensureStartingBalances(db, accountId)
export async function getBalances(
db: D1Database,
accountId: number,
startingTokens: number
): Promise<Balance[]> {
await ensureStartingBalances(db, accountId, startingTokens)
const { results } = await db
.prepare(
'SELECT currency_type, amount FROM balance WHERE account_id = ?1 ORDER BY currency_type'
@@ -134,9 +157,10 @@ export async function getBalances(db: D1Database, accountId: number): Promise<Ba
export async function getBalance(
db: D1Database,
accountId: number,
currencyType: number
currencyType: number,
startingTokens: number
): Promise<number> {
await ensureStartingBalances(db, accountId)
await ensureStartingBalances(db, accountId, startingTokens)
const row = await db
.prepare('SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = ?2')
.bind(accountId, currencyType)
@@ -155,7 +179,8 @@ export async function creditCurrency(
db: D1Database,
accountId: number,
currencyType: number,
amount: number
amount: number,
startingTokens: number
): Promise<number> {
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error(`creditCurrency: amount must be a positive integer, got ${amount}`)
@@ -167,7 +192,7 @@ export async function creditCurrency(
)
.bind(accountId, currencyType, amount)
.run()
return getBalance(db, accountId, currencyType)
return getBalance(db, accountId, currencyType, startingTokens)
}
/**
@@ -182,12 +207,13 @@ export async function spendCurrency(
db: D1Database,
accountId: number,
currencyType: number,
amount: number
amount: number,
startingTokens: number
): Promise<boolean> {
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error(`spendCurrency: amount must be a positive integer, got ${amount}`)
}
await ensureStartingBalances(db, accountId)
await ensureStartingBalances(db, accountId, startingTokens)
const { meta } = await db
.prepare(
`UPDATE balance SET amount = amount - ?3
+9
View File
@@ -10,6 +10,15 @@ export type Env = SharedHonoEnv & {
DB: D1Database
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
ASSETS: Fetcher
/**
* The RecCenterTokens a new player is granted (see balance-db.ts). Optional — unset
* falls back to DEFAULT_STARTING_TOKENS, and 0 means players start broke.
*
* Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
* number while the same var set from the dashboard or `--var` arrives as a string —
* read it through `intVar`, never as a bare number.
*/
STARTING_TOKENS?: string | number
}
/** Variables can be extended */
+10 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import defaultAvatarItems from '../static/default-avatar-items.json'
@@ -9,7 +9,7 @@ import defaultAvatar from '../static/default-avatar.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import { ALL_PLATFORMS, getBalance, isSpendable } from './balance-db'
import { ALL_PLATFORMS, DEFAULT_STARTING_TOKENS, getBalance, isSpendable } from './balance-db'
import { getOutfits, setOutfit } from './outfit-db'
import type { Context } from 'hono'
@@ -220,7 +220,14 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
if (Number.isNaN(currencyType)) return c.body(null, 400)
const amount = isSpendable(currencyType) ? await getBalance(c.env.DB, id, currencyType) : 0
const amount = isSpendable(currencyType)
? await getBalance(
c.env.DB,
id,
currencyType,
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
)
: 0
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
})
+40 -5
View File
@@ -8,6 +8,7 @@ import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
CurrencyType,
DEFAULT_STARTING_TOKENS,
getBalance,
spendCurrency,
} from '../../balance-db'
@@ -423,7 +424,9 @@ describe('econ endpoints', () => {
test('GET /api/storefronts/v4/balance/2 reflects what the player has spent', async () => {
// Spend from account 7 (a fresh account: the read below grants it first).
expect(await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500)).toBe(true)
expect(
await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('7'),
})
@@ -431,15 +434,47 @@ describe('econ endpoints', () => {
})
test('a spend the player cannot afford changes nothing', async () => {
const before = await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)
expect(await spendCurrency(env.DB, 8, CurrencyType.RecCenterTokens, before + 1)).toBe(false)
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)).toBe(before)
const before = await getBalance(
env.DB,
8,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect(
await spendCurrency(
env.DB,
8,
CurrencyType.RecCenterTokens,
before + 1,
DEFAULT_STARTING_TOKENS
)
).toBe(false)
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)).toBe(
before
)
})
test('the starting grant comes from the STARTING_TOKENS var', async () => {
// The grant an operator actually runs is the var; DEFAULT_STARTING_TOKENS is only the
// fallback. `env` is shared by every test in this file, so restore it in `finally`.
const original = env.STARTING_TOKENS
try {
env.STARTING_TOKENS = 250
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('11'),
})
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 250 }])
} finally {
env.STARTING_TOKENS = original
}
})
test('the starting grant is not re-granted after spending down to zero', async () => {
// The grant is INSERT OR IGNORE against the row, not a top-up: a player who spends
// everything stays at 0 rather than being refilled by their next balance read.
expect(await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000)).toBe(true)
expect(
await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('9'),
})
+4
View File
@@ -44,6 +44,10 @@
"head_sampling_rate": 1 // 100%
}
},
// The signup token grant (STARTING_TOKENS) is deliberately NOT set here. It's injected
// at deploy time from the gitignored .env (RECFLARE_STARTING_TOKENS, see .env.example),
// so tuning it never means editing a versioned file. Unset — the default — falls back
// to DEFAULT_STARTING_TOKENS in src/balance-db.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
+19
View File
@@ -0,0 +1,19 @@
/**
* Read an integer worker var, falling back to `fallback` when it is unset or unusable.
*
* A var arrives as a number when it's declared in wrangler.jsonc `vars`, but as a string
* when it's set anywhere else (the dashboard, `wrangler deploy --var`, `.dev.vars`), so
* both have to be accepted — the same var is a different type depending on where the
* operator set it.
*
* Anything that isn't a finite integer (an empty string, a typo, `3.5`) is treated as
* unset rather than coerced: `Number.parseInt` would read `"3abc"` as 3 and `"3.9"` as 3,
* which turns a typo into a silently wrong limit. Falling back to the documented default
* is the safe failure here.
*/
export function intVar(value: unknown, fallback: number): number {
if (typeof value === 'number') return Number.isInteger(value) ? value : fallback
if (typeof value !== 'string' || value.trim() === '') return fallback
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
+1
View File
@@ -1,4 +1,5 @@
export type { HonoApp, SharedHonoEnv, SharedHonoVariables, SharedAppContext } from './types'
export * from './helpers/env'
export { logger } from './helpers/logger'
export { getRequestLogData, type LogDataRequest } from './helpers/request'
export * from './helpers/errors'
+18 -15
View File
@@ -1,28 +1,22 @@
#!/bin/sh
set -eu
. "$(git rev-parse --show-toplevel)/packages/tools/src/sh/env.sh"
# Extract name and version from package.json using jq
NAME=$(jq -r '.name' package.json)
VERSION=$(get-version)
# Everything an operator supplies — domain, resource ids, tuning knobs — comes from the
# single gitignored .env at the repo root (in CI, from exported secrets, which win over the
# file). See .env.example.
recflare_load_env
# Resolve the base domain (and this worker's subdomain), then deploy onto the
# custom domain via `--domain`. This keeps the real domain out of versioned files
# — committed wrangler.jsonc has no routes, and the base domain is passed as the
# DOMAIN var at runtime.
#
# The domain comes from the RECFLARE_DOMAIN env var. For local dev that's set in a
# gitignored .env at the repo root; in CI it's an exported secret. An already-set
# RECFLARE_DOMAIN wins and skips the file. Per-app subdomain overrides come from
# RECFLARE_SUBDOMAINS (a JSON object, e.g. {"playersettings":"settings"}).
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
ENV_FILE="$(git rev-parse --show-toplevel)/.env"
if [ -f "$ENV_FILE" ]; then
set -a
. "$ENV_FILE"
set +a
fi
fi
# DOMAIN var at runtime. Per-app subdomain overrides come from RECFLARE_SUBDOMAINS
# (a JSON object, e.g. {"playersettings":"settings"}).
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
exit 1
@@ -129,6 +123,12 @@ if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV"
fi
fi
# The operator's tuning knobs, injected the same way as the resource ids above: each lives
# in the .env as RECFLARE_<VAR> and rides along as a `--var`. See recflare_vars in
# packages/tools/src/sh/env.sh. run-wrangler-dev passes the same flags, so one .env tunes
# both a deploy and a local dev server.
EXTRA_VARS=$(recflare_vars)
# Vite-built configs set no_bundle (vite already bundled and minified), which is
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
MINIFY="--minify"
@@ -136,11 +136,14 @@ MINIFY="--minify"
# Deploy with wrangler using the extracted values as binding variables
echo "Deploying worker $NAME version $VERSION to $HOST"
# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split, and every
# value in it is an integer, so there's nothing to split on inside a value.
wrangler deploy \
--config "$CONFIG" \
--var NAME:"$NAME" \
--var SENTRY_RELEASE:"$VERSION" \
--var DOMAIN:"$DOMAIN" \
$EXTRA_VARS \
--domain "$HOST" \
$MINIFY \
"$@"
+13
View File
@@ -1,8 +1,19 @@
#!/bin/sh
set -eu
. "$(git rev-parse --show-toplevel)/packages/tools/src/sh/env.sh"
NAME=$(jq -r '.name' package.json)
# Tuning knobs come from the same gitignored root .env a deploy reads (RECFLARE_<VAR>, see
# .env.example), so a knob is configured in exactly one place whether you're running locally
# or shipping. Unset knobs fall back to the worker's own default constants, same as a deploy.
#
# Passed as `--var` rather than left to wrangler's own .env loading: wrangler only reads a
# .env sitting next to the worker's wrangler.jsonc, which would mean a second file per app.
recflare_load_env
EXTRA_VARS=$(recflare_vars)
# Give each worker a stable, unique dev port so `turbo dev` can run them all in
# parallel without colliding on wrangler's default 8787 (and its 9229 inspector
# port). The offset is the worker's alphabetical position among its siblings, so
@@ -16,8 +27,10 @@ OFFSET=$(
PORT=$((8787 + OFFSET - 1))
INSPECTOR_PORT=$((9229 + OFFSET - 1))
# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split on.
exec wrangler dev \
--var NAME:"$NAME" \
$EXTRA_VARS \
--port "$PORT" \
--inspector-port "$INSPECTOR_PORT" \
"$@"
+4 -10
View File
@@ -1,6 +1,8 @@
#!/bin/sh
set -eu
. "$(git rev-parse --show-toplevel)/packages/tools/src/sh/env.sh"
# Apply this worker's D1 migrations. Run from a worker directory (e.g. via
# `bun turbo -F rooms migrate`). Defaults to --remote; pass --local to target the
# dev SQLite db. Extra args pass through to `wrangler d1 migrations apply`.
@@ -41,16 +43,8 @@ if [ "$LOCAL" -eq 1 ]; then
exec wrangler d1 migrations apply "$DB_NAME" "$@"
fi
# Load RECFLARE_D1 from the gitignored root .env if not already set (mirrors
# run-wrangler-deploy's handling of RECFLARE_DOMAIN). An already-set value wins.
if [ -z "${RECFLARE_D1:-}" ]; then
ENV_FILE="$(git rev-parse --show-toplevel)/.env"
if [ -f "$ENV_FILE" ]; then
set -a
. "$ENV_FILE"
set +a
fi
fi
# RECFLARE_D1 comes from the gitignored root .env, or from CI secrets, which win over it.
recflare_load_env
DB_ID=${RECFLARE_D1:-}
if [ -z "$DB_ID" ]; then
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# Shared by the run-wrangler-* scripts. Source it, don't run it:
#
# . "$(git rev-parse --show-toplevel)/packages/tools/src/sh/env.sh"
#
# It lives outside bin/ on purpose — package.json sets directories.bin to bin/, so anything
# in there becomes a runnable command in node_modules/.bin.
#
# RecFlare keeps a single gitignored .env at the repo root (see .env.example) holding
# everything an operator has to supply: their domain, the ids of the storage resources they
# created, and any tuning knobs they want to change. That one file feeds both `just deploy`
# and `just dev`, so a value is never configured twice.
# Names the deploy scripts consume themselves — the domain and the ids of the operator's
# Cloudflare resources. Everything else in .env is worker config; see recflare_vars.
RECFLARE_RESERVED="DOMAIN SUBDOMAINS D1 KV SECRETS_STORE ENV_LOADED"
# Load the root .env, letting anything already in the environment win. The file is a local
# convenience; CI exports the same names as secrets and must not be clobbered by a stray
# .env in a checkout. Safe to call more than once.
recflare_load_env() {
[ -z "${RECFLARE_ENV_LOADED:-}" ] || return 0
RECFLARE_ENV_LOADED=1
_env_file="$(git rev-parse --show-toplevel)/.env"
[ -f "$_env_file" ] || return 0
# `export -p` re-emits the already-exported values as quoted assignments, so we can put
# them back after the file has had its say.
_preset=$(export -p | grep -E '(^|[[:space:]])RECFLARE_[A-Za-z0-9_]+=' || true)
set -a
. "$_env_file"
set +a
eval "$_preset"
unset _env_file _preset
}
# Echo the `--var` flags carrying the operator's tuning knobs, e.g.
# " --var MAX_ACCOUNTS_PER_IP:10 --var STARTING_TOKENS:250".
#
# This is a convention, not a list kept here: every RECFLARE_<VAR> in the environment that
# isn't one of the RECFLARE_RESERVED deploy inputs above is handed to the worker as
# `--var <VAR>:<value>`. So RECFLARE_MAX_ACCOUNTS_PER_IP=10 gives every worker
# MAX_ACCOUNTS_PER_IP=10 — the workers that don't read it simply ignore it, and two workers
# that read the same knob agree on it for free. Adding a knob means declaring it in the
# worker's context.ts, reading it there, and documenting it in .env.example; these scripts
# never need to change and stay free of any knowledge of specific app names.
#
# Every knob is optional: unset means no `--var` at all, so the worker falls back to the
# default constant in its own source, and deleting a line from .env really does restore that
# default on the next deploy. (Vars are replaced wholesale by a deploy — which is exactly
# why a value set in the Cloudflare dashboard doesn't survive one.)
#
# Values must not contain whitespace: the result is a flag list the caller word-splits.
# Knobs are numbers and short enums, and real secrets belong in the Secrets Store (which is
# bound in wrangler.jsonc, not passed through here), so this hasn't been worth the ceremony
# of an array. Vars also arrive in the Worker as strings (`--var X:3` is "3", not 3), which
# is why the workers parse them through `intVar` rather than reading them as numbers.
recflare_vars() {
# The sed only ever yields [A-Z0-9_] names, so the eval below can't expand anything else.
for _name in $(env | sed -n 's/^RECFLARE_\([A-Z0-9_][A-Z0-9_]*\)=.*/\1/p'); do
case " $RECFLARE_RESERVED " in
*" $_name "*) continue ;;
esac
eval "_value=\${RECFLARE_${_name}}"
[ -n "$_value" ] || continue
printf ' --var %s:%s' "$_name" "$_value"
done
unset _name _value
}