/** * @pwngh/economy-lab * * Copyright (c) Preston Neal * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ import type { EnvMap } from './env.js'; /** * All tunable policy settings in one object. Policy only — no secrets live here, so the whole * object is safe to log; credentials ride in {@link Secrets}. * * No module reads env vars itself; the startup program builds this once (via * {@link loadConfig}) and passes it in, so a misconfigured deploy fails at startup * rather than deep inside a request. * * @see {@link https://economy-lab-docs.pages.dev/economy/reference/configuration/ Configuration} * for every tunable and its default. */ export interface Config { /** Max clock skew (ms) the HTTP service accepts on a signed webhook's timestamp; a request * dated outside the window is refused as a replay. Default 5 minutes. */ replayWindowMs: number; /** Retryable-failure cap on a payout saga: at this many attempts the worker stops * resubmitting, marks the saga FAILED, and returns the reserved credits to the seller's * earned account. Default 5. */ maxPayoutAttempts: number; /** Delivery-attempt cap per outbox event; the relay dead-letters the event when it is * reached. Default 10. */ maxOutboxAttempts: number; /** Processing-attempt cap per inbound webhook; the inbox sweep dead-letters the row when it * is reached or the failure is not retryable. Default 10. */ maxInboxAttempts: number; /** Failed-renewal cap per subscription; at this many attempts the sweep lapses the * subscription instead of re-billing it. Default 10. */ maxSubscriptionAttempts: number; /** Force-fail deadline (ms) for a SUBMITTED payout — unlike `payoutSla.SUBMITTED`, which only * schedules the next settle check. */ maxPayoutAgeMs: number; /** Platform's cut in basis points (hundredths of a percent); 10000 = 100%, 1530 = 15.3%. */ platformFeeBps: number; /** Payout-rail fee in basis points: the processor's own cut, deducted from the disbursement so * the seller receives the net. Not platform revenue. */ payoutFeeBps: number; /** The single-knob velocity ceiling (CREDIT minor units): both window classes fall back to it * unless their own limit below is set. The default is demo-scale; production must state it. */ velocityLimitMinor: bigint; /** Ceiling for the inflow window (topUp, grantPromo) — card testing fills this one. Unset * means `velocityLimitMinor`. */ velocityInflowLimitMinor?: bigint; /** Ceiling for the outflow window (spend, subscribe, requestPayout) — a drained wallet fills * this one. Unset means `velocityLimitMinor`. */ velocityOutflowLimitMinor?: bigint; /** Length (ms) of the velocity window. Captured at store construction; changing it means a * rebuild over the same store (the config object is frozen for exactly this reason). */ velocityWindowMs: number; /** Smallest subscription price, in CREDIT minor units. A price outside the band is refused * at subscribe time; the band keeps a typo'd price from silently binding a buyer. */ subscriptionPriceMinMinor: bigint; /** Largest subscription price, in CREDIT minor units. */ subscriptionPriceMaxMinor: bigint; /** The purchase catalog: the only top-up amounts accepted, in CREDIT minor units. Stores sell * credits in fixed bundles, so a deployment that mirrors its store sets the same list and a * mispriced grant fails at submit. Unset means any positive amount. */ topUpBundlesMinor?: readonly bigint[]; /** * How long (ms) topped-up funds must wait before they can be spent or paid out, keyed by * funding source ("card", "crypto", "steam", "meta"); unlisted sources use "default". */ maturityHorizonMs: Record; /** * Time budget (ms) per payout-processing step, used to schedule when the worker next examines * a saga. `PENDING` delays the first submit pass after a request, `SUBMITTED` delays the next * check on a submitted payout, and `DEFAULT` covers either when unset. */ payoutSla: Record; /** Smallest payout a user may request, counted only against earned CREDIT — never bought or * promo-granted. */ payoutMinimumEarnedMinor: bigint; /** * Min time (ms) between payout requests. The default is 24h to match the live docs; a * deployment bound by the 14-day legal requirement must set 1_209_600_000. */ payoutMinIntervalMs: number; /** * Scheduled maintenance window (epoch ms): end-user discretionary writes decline as * ECONOMY_PAUSED while `pauseStartMs <= now < pauseEndMs`; either bound null means no window. * Settlement (actor 'system'), operator fixes, and reads are never gated. */ pauseStartMs: number | null; /** The window's exclusive end (epoch ms); see `pauseStartMs` for the gate. */ pauseEndMs: number | null; /** * Max connections in the SQL engine's pool (`DB_POOL_MAX`). Null keeps each driver's default * of 10. Excess concurrent submits queue for a connection; a transaction holds exactly one * connection for its whole life, so the queue always drains. */ dbPoolMax: number | null; /** * Rows each hot platform account is split across. Shard 0 keeps the bare id, so raising the * count later is safe; only ever lower it back to 1. */ platformShards: number; /** * The accrual split (`ACCRUAL_DRAIN=1`): spend and subscribe park seller shares on a * SETTLEMENT_ACCRUAL shard and the worker's drain sweep moves them to `earned` in batches, so * concurrent buyers of one seller stop serializing on that seller's row. Off (the default) * posts exactly today's legs. Meant to run with `platformShards >= 2`. */ accrualDrain: boolean; } /** Every name {@link loadConfig} reads; .env.example is held to this list. Policy only — * the secret names live in {@link SECRET_KEYS}. */ export declare const CONFIG_KEYS: readonly ["NODE_ENV", "REPLAY_WINDOW_MS", "MAX_PAYOUT_ATTEMPTS", "MAX_OUTBOX_ATTEMPTS", "MAX_INBOX_ATTEMPTS", "MAX_SUBSCRIPTION_ATTEMPTS", "MAX_PAYOUT_AGE_MS", "PLATFORM_FEE_BPS", "PAYOUT_FEE_BPS", "VELOCITY_LIMIT_MINOR", "VELOCITY_INFLOW_LIMIT_MINOR", "VELOCITY_OUTFLOW_LIMIT_MINOR", "VELOCITY_WINDOW_MS", "SUBSCRIPTION_PRICE_MIN_MINOR", "SUBSCRIPTION_PRICE_MAX_MINOR", "TOP_UP_BUNDLES_MINOR", "MATURITY_HORIZON_CARD_MS", "MATURITY_HORIZON_CRYPTO_MS", "MATURITY_HORIZON_STEAM_MS", "MATURITY_HORIZON_META_MS", "MATURITY_HORIZON_DEFAULT_MS", "SLA_PENDING_MS", "SLA_SUBMITTED_MS", "SLA_DEFAULT_MS", "PAYOUT_MIN_EARNED_MINOR", "PAYOUT_MIN_INTERVAL_MS", "ECONOMY_PAUSE_START_MS", "ECONOMY_PAUSE_END_MS", "PLATFORM_SHARDS", "ACCRUAL_DRAIN", "DB_POOL_MAX"]; /** Every name `loadSecrets` reads. Kept apart from {@link CONFIG_KEYS} so the log-safe * policy list can never grow a credential. */ export declare const SECRET_KEYS: readonly ["WEBHOOK_SECRET", "SIGNING_SECRET", "SIGNING_SECRETS_PRIOR"]; /** Boolean flags (1/true) that declare a production deployment runs without the named optional * port on purpose; openPorts treats a bare omission as an error (§ absence policy). */ export declare const DECLINE_KEYS: readonly ["DISPATCHER_DECLINED", "PAYEES_DECLINED", "ANCHOR_DECLINED"]; /** * Credentials, split from {@link Config} so the policy object stays log-safe. `signingSecretsPrior` * lists rotated-out signing secrets old checkpoints must still verify against. */ export interface Secrets { readonly webhookSecret: string; readonly signingSecret: string; readonly signingSecretsPrior?: readonly string[]; } /** * Build {@link Secrets} from env vars, any `overrides` winning per field. In production a blank * required secret on the merged bag throws one CONFIG.INVALID fault listing every missing key; * outside production a blank stays blank and the construction layer supplies its dev stand-in. */ export declare function loadSecrets(env: EnvMap, overrides?: Partial): Secrets; /** The required secret env names still blank on a merged bag; `preflight` reports these. */ export declare function missingSecretFields(secrets: Secrets): string[]; /** * Build {@link Config} from env vars, defaulting any value that is unset or invalid. * * If a policy anchor — MATURITY_HORIZON_CARD_MS or VELOCITY_LIMIT_MINOR — is missing in * production, throws a single CONFIG.INVALID fault listing all missing keys at once, so the * program fails at startup rather than one key at a time during requests. */ export declare function loadConfig(env: EnvMap): Config; /** * The production policy-anchor env names satisfied by neither `env` nor `overrides`; empty * outside production. `preflight` reports these; {@link inspectConfig} throws on them. */ export declare function missingPolicyAnchors(env: EnvMap, overrides?: Partial): string[]; /** * The default {@link Config} without an environment: the exact values {@link loadConfig} derives from * an empty env, with any knobs in `overrides` applied on top ({@link mergeConfig} semantics). For * tests and the in-memory quickstart that want a Config in hand without assembling an {@link EnvMap}. * * @example * const config = defaultConfig({ * platformFeeBps: 3000, * maturityHorizonMs: { card: 7 * 24 * 60 * 60_000 }, // the other rails keep their defaults * }); */ export declare function defaultConfig(overrides?: Partial): Config; /** * `overrides` on top of `base`, last-wins per knob — except the record-valued knobs * (`maturityHorizonMs`, `payoutSla`), which merge one level deep: overriding one funding source * or one SLA step keeps the others instead of replacing the whole record. */ export declare function mergeConfig(base: Config, overrides: Partial): Config; /** * The resolved {@link Config} a given env produces, with `overrides` applied on top — the same * derivation openPorts runs, so an override-supplied policy anchor satisfies the production * check. Config carries no secrets, so the result is safe to print whole. */ export declare function inspectConfig(env?: EnvMap, overrides?: Partial): Config; /** * The config slice for a scheduled maintenance window, ready to spread into a PortsInit config: * `{ config: maintenanceWindow(start, end) }`. Bounds are epoch ms; end is exclusive. */ export declare function maintenanceWindow(startMs: number, endMs: number): Pick; /** * Whether the maintenance window is active at `now`. Pure: derives solely from `now` and the two * config bounds, so the gate and the read surface agree. */ export declare function economyPaused(now: number, config: Pick): boolean;