mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add game config configuration
This commit is contained in:
@@ -23,3 +23,36 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# Kept out of the committed wrangler.jsonc (which uses a "local" placeholder) and
|
# Kept out of the committed wrangler.jsonc (which uses a "local" placeholder) and
|
||||||
# spliced in at deploy time. Required to deploy any worker.
|
# spliced in at deploy time. Required to deploy any worker.
|
||||||
# RECFLARE_SECRETS_STORE=00000000-0000-0000-0000-000000000000
|
# 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
@@ -5,8 +5,8 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
|
|||||||
## Why Cloudflare?
|
## Why Cloudflare?
|
||||||
|
|
||||||
- **Makes it easy to mirror RecNet**
|
- **Makes it easy to mirror RecNet**
|
||||||
Rec Room's backend is (was) a set of independent microservices, not one big monolith.
|
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
|
Modeling each service as its own isolated Worker keeps RecFlare's structure close to
|
||||||
the real thing — services scale,
|
the real thing — services scale,
|
||||||
fail, and deploy independently — instead of collapsing everything into a single
|
fail, and deploy independently — instead of collapsing everything into a single
|
||||||
giant server.
|
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
|
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,
|
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.
|
well, we'll cross that bridge when we get to it.
|
||||||
- D1 (a SQLite-compatible distributed database)
|
- D1 (a SQLite-compatible distributed database)
|
||||||
- R2 (service like S3 for mass file hosting)
|
- R2 (service like S3 for mass file hosting)
|
||||||
- KV (service to distributed offer key/value stores)
|
- KV (service to distributed offer key/value stores)
|
||||||
- Durable Objects (for a notifications hub)
|
- Durable Objects (for a notifications hub)
|
||||||
|
|
||||||
## Do I have to use Cloudflare?
|
## 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.
|
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
|
## Repository Structure
|
||||||
|
|
||||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
} from '@repo/domain'
|
} 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 { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import { hashPassword, verifyPassword } from './password'
|
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
|
* 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
|
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
|
||||||
* count by hopping networks.
|
* 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 DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
|
||||||
const MAX_ACCOUNTS_PER_IP = 3
|
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
|
||||||
|
|
||||||
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||||
const ORIENTATION_ROOM_ID = 13
|
const ORIENTATION_ROOM_ID = 13
|
||||||
@@ -301,12 +307,19 @@ const app = new Hono<App>()
|
|||||||
let accountId: string
|
let accountId: string
|
||||||
if (grantType === 'create_account') {
|
if (grantType === 'create_account') {
|
||||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
// 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
|
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||||
// platform id / no client IP) — an unattributable signup can't be counted against
|
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||||
// anyone, and lumping them together would lock out real players.
|
// 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 (
|
if (
|
||||||
|
maxPerPlatformId > 0 &&
|
||||||
verifiedSteamId !== null &&
|
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 })
|
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||||
return c.json(
|
return c.json(
|
||||||
@@ -318,8 +331,9 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
maxPerIp > 0 &&
|
||||||
clientIp !== '' &&
|
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 })
|
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
|
||||||
return c.json(
|
return c.json(
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
|
|||||||
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
|
// 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).
|
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||||
JWT_SECRET: SecretsStoreSecret
|
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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -419,10 +419,36 @@ describe('auth worker routes', () => {
|
|||||||
expect(capped.json.error_description).toMatch(/network/)
|
expect(capped.json.error_description).toMatch(/network/)
|
||||||
|
|
||||||
// A different IP is unaffected, and the capped IP can still LOG IN to what it has.
|
// 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)
|
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 () => {
|
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
|
// Account 42's owner may be over the signup cap; that must never lock them out of
|
||||||
// an account they already have.
|
// an account they already have.
|
||||||
|
|||||||
@@ -36,6 +36,11 @@
|
|||||||
"head_sampling_rate": 1 // 100%
|
"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": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
|
|||||||
+43
-17
@@ -68,16 +68,26 @@ const SPENDABLE: readonly number[] = [
|
|||||||
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
|
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.
|
* (see `ensureStartingBalances`). Currencies absent here start at 0.
|
||||||
*
|
*
|
||||||
* This is the whole signup grant — change the number here and it applies to every
|
* This is the whole signup grant. It is NOT re-granted: a player who spends down to 0
|
||||||
* player who hasn't been granted yet. It is NOT re-granted: a player who spends down
|
* keeps a 0 row, and the grant is skipped because the row exists. That also means
|
||||||
* to 0 keeps a 0 row, and the grant is skipped because the row exists.
|
* 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 }> = [
|
export function startingBalances(
|
||||||
{ currencyType: CurrencyType.RecCenterTokens, amount: 10_000 },
|
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
|
* `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
|
* Called on read rather than at account creation so accounts that predate this table
|
||||||
* (every existing player) get their grant too.
|
* (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(
|
const stmt = db.prepare(
|
||||||
'INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3)'
|
'INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3)'
|
||||||
)
|
)
|
||||||
await db.batch(
|
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). */
|
/** Every balance an account holds (after its starting grant is applied). */
|
||||||
export async function getBalances(db: D1Database, accountId: number): Promise<Balance[]> {
|
export async function getBalances(
|
||||||
await ensureStartingBalances(db, accountId)
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
startingTokens: number
|
||||||
|
): Promise<Balance[]> {
|
||||||
|
await ensureStartingBalances(db, accountId, startingTokens)
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT currency_type, amount FROM balance WHERE account_id = ?1 ORDER BY currency_type'
|
'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(
|
export async function getBalance(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
currencyType: number
|
currencyType: number,
|
||||||
|
startingTokens: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
await ensureStartingBalances(db, accountId)
|
await ensureStartingBalances(db, accountId, startingTokens)
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = ?2')
|
.prepare('SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = ?2')
|
||||||
.bind(accountId, currencyType)
|
.bind(accountId, currencyType)
|
||||||
@@ -155,7 +179,8 @@ export async function creditCurrency(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
currencyType: number,
|
currencyType: number,
|
||||||
amount: number
|
amount: number,
|
||||||
|
startingTokens: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!Number.isInteger(amount) || amount <= 0) {
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
throw new Error(`creditCurrency: amount must be a positive integer, got ${amount}`)
|
throw new Error(`creditCurrency: amount must be a positive integer, got ${amount}`)
|
||||||
@@ -167,7 +192,7 @@ export async function creditCurrency(
|
|||||||
)
|
)
|
||||||
.bind(accountId, currencyType, amount)
|
.bind(accountId, currencyType, amount)
|
||||||
.run()
|
.run()
|
||||||
return getBalance(db, accountId, currencyType)
|
return getBalance(db, accountId, currencyType, startingTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,12 +207,13 @@ export async function spendCurrency(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
currencyType: number,
|
currencyType: number,
|
||||||
amount: number
|
amount: number,
|
||||||
|
startingTokens: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!Number.isInteger(amount) || amount <= 0) {
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
throw new Error(`spendCurrency: amount must be a positive integer, got ${amount}`)
|
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
|
const { meta } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE balance SET amount = amount - ?3
|
`UPDATE balance SET amount = amount - ?3
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export type Env = SharedHonoEnv & {
|
|||||||
DB: D1Database
|
DB: D1Database
|
||||||
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
|
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
|
||||||
ASSETS: Fetcher
|
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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
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 { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
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 myProgress from '../static/my-progress.json'
|
||||||
import weeklyChallenge from '../static/weekly-challenge.json'
|
import weeklyChallenge from '../static/weekly-challenge.json'
|
||||||
import { getAvatar, setAvatar } from './avatar-db'
|
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 { getOutfits, setOutfit } from './outfit-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -220,7 +220,14 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
||||||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
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 }])
|
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SCHEMA_DDL } from '../../avatar-db'
|
|||||||
import {
|
import {
|
||||||
BALANCE_SCHEMA_DDL,
|
BALANCE_SCHEMA_DDL,
|
||||||
CurrencyType,
|
CurrencyType,
|
||||||
|
DEFAULT_STARTING_TOKENS,
|
||||||
getBalance,
|
getBalance,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from '../../balance-db'
|
} from '../../balance-db'
|
||||||
@@ -423,7 +424,9 @@ describe('econ endpoints', () => {
|
|||||||
|
|
||||||
test('GET /api/storefronts/v4/balance/2 reflects what the player has spent', async () => {
|
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).
|
// 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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
headers: await bearer('7'),
|
headers: await bearer('7'),
|
||||||
})
|
})
|
||||||
@@ -431,15 +434,47 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('a spend the player cannot afford changes nothing', async () => {
|
test('a spend the player cannot afford changes nothing', async () => {
|
||||||
const before = await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)
|
const before = await getBalance(
|
||||||
expect(await spendCurrency(env.DB, 8, CurrencyType.RecCenterTokens, before + 1)).toBe(false)
|
env.DB,
|
||||||
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)).toBe(before)
|
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 () => {
|
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
|
// 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.
|
// 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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
headers: await bearer('9'),
|
headers: await bearer('9'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
"head_sampling_rate": 1 // 100%
|
"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": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
export type { HonoApp, SharedHonoEnv, SharedHonoVariables, SharedAppContext } from './types'
|
export type { HonoApp, SharedHonoEnv, SharedHonoVariables, SharedAppContext } from './types'
|
||||||
|
export * from './helpers/env'
|
||||||
export { logger } from './helpers/logger'
|
export { logger } from './helpers/logger'
|
||||||
export { getRequestLogData, type LogDataRequest } from './helpers/request'
|
export { getRequestLogData, type LogDataRequest } from './helpers/request'
|
||||||
export * from './helpers/errors'
|
export * from './helpers/errors'
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
. "$(git rev-parse --show-toplevel)/packages/tools/src/sh/env.sh"
|
||||||
|
|
||||||
# Extract name and version from package.json using jq
|
# Extract name and version from package.json using jq
|
||||||
NAME=$(jq -r '.name' package.json)
|
NAME=$(jq -r '.name' package.json)
|
||||||
VERSION=$(get-version)
|
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
|
# 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
|
# 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
|
# — committed wrangler.jsonc has no routes, and the base domain is passed as the
|
||||||
# DOMAIN var at runtime.
|
# DOMAIN var at runtime. Per-app subdomain overrides come from RECFLARE_SUBDOMAINS
|
||||||
#
|
# (a JSON object, e.g. {"playersettings":"settings"}).
|
||||||
# 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
|
|
||||||
|
|
||||||
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
|
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
|
||||||
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
|
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -129,6 +123,12 @@ if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV"
|
|||||||
fi
|
fi
|
||||||
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
|
# Vite-built configs set no_bundle (vite already bundled and minified), which is
|
||||||
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
||||||
MINIFY="--minify"
|
MINIFY="--minify"
|
||||||
@@ -136,11 +136,14 @@ MINIFY="--minify"
|
|||||||
|
|
||||||
# Deploy with wrangler using the extracted values as binding variables
|
# Deploy with wrangler using the extracted values as binding variables
|
||||||
echo "Deploying worker $NAME version $VERSION to $HOST"
|
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 \
|
wrangler deploy \
|
||||||
--config "$CONFIG" \
|
--config "$CONFIG" \
|
||||||
--var NAME:"$NAME" \
|
--var NAME:"$NAME" \
|
||||||
--var SENTRY_RELEASE:"$VERSION" \
|
--var SENTRY_RELEASE:"$VERSION" \
|
||||||
--var DOMAIN:"$DOMAIN" \
|
--var DOMAIN:"$DOMAIN" \
|
||||||
|
$EXTRA_VARS \
|
||||||
--domain "$HOST" \
|
--domain "$HOST" \
|
||||||
$MINIFY \
|
$MINIFY \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
. "$(git rev-parse --show-toplevel)/packages/tools/src/sh/env.sh"
|
||||||
|
|
||||||
NAME=$(jq -r '.name' package.json)
|
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
|
# 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
|
# 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
|
# port). The offset is the worker's alphabetical position among its siblings, so
|
||||||
@@ -16,8 +27,10 @@ OFFSET=$(
|
|||||||
PORT=$((8787 + OFFSET - 1))
|
PORT=$((8787 + OFFSET - 1))
|
||||||
INSPECTOR_PORT=$((9229 + OFFSET - 1))
|
INSPECTOR_PORT=$((9229 + OFFSET - 1))
|
||||||
|
|
||||||
|
# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split on.
|
||||||
exec wrangler dev \
|
exec wrangler dev \
|
||||||
--var NAME:"$NAME" \
|
--var NAME:"$NAME" \
|
||||||
|
$EXTRA_VARS \
|
||||||
--port "$PORT" \
|
--port "$PORT" \
|
||||||
--inspector-port "$INSPECTOR_PORT" \
|
--inspector-port "$INSPECTOR_PORT" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
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
|
# 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
|
# `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`.
|
# 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" "$@"
|
exec wrangler d1 migrations apply "$DB_NAME" "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load RECFLARE_D1 from the gitignored root .env if not already set (mirrors
|
# RECFLARE_D1 comes from the gitignored root .env, or from CI secrets, which win over it.
|
||||||
# run-wrangler-deploy's handling of RECFLARE_DOMAIN). An already-set value wins.
|
recflare_load_env
|
||||||
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
|
|
||||||
|
|
||||||
DB_ID=${RECFLARE_D1:-}
|
DB_ID=${RECFLARE_D1:-}
|
||||||
if [ -z "$DB_ID" ]; then
|
if [ -z "$DB_ID" ]; then
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user