Router.Africa — Backend Scope
Backlog item #4, kept short on purpose: a service map, the request flows, and where each piece of data lives. It restates decisions from decisions-log.md, it does not replace it; where the two ever disagree, the decisions log wins.
Terms: an org owns members, roles and billing details, and has one or more workspaces. Each workspace has its own currency, credit balance, holds, settlements and keys, and is the unit the request path charges. The decisions log says “account” loosely: for balance, hold and spend attribution it means the workspace; for anything set at the customer level it may mean the org. The content-logging opt-out is per workspace (decided); the hold toggle and rate limits are per org (decided).
1. Services
Section titled “1. Services”| Service | Origin | Owns |
|---|---|---|
apps/api (Worker, Hono) |
api.router.africa |
External OpenAI-compatible API. API-key auth, model gating, idempotency claim, hold, Bifrost call, settle, post-response work. No CORS. Nothing else. |
apps/dashboard-api (Worker, Hono) |
app.router.africa, admin.router.africa |
Better Auth; dashboard routes (orgs, workspaces, keys, billing, usage, logs); admin routes (own route group, own middleware, staff flag); key lifecycle and Bifrost sync; top-ups; the queue() consumer; scheduled jobs (Cron Triggers). |
apps/niobi-proxy (Node, K3s) |
private (Tunnel) | Relay to Niobi payments from a static IP. All Niobi signing and credentials, both directions. No business logic. |
| Bifrost (K3s) | private | Model gateway. Enforces rate limits (per virtual key, from the org’s setting), the admin-set spend cap (USD), and model allowlists via 1:1 virtual keys. Not the ledger. |
apps/dashboard, apps/admin (Vite) |
app., admin. |
Frontends. Admin ships as its own build, never in the customer bundle. |
Shared packages: packages/types, packages/db, packages/config.
2. Data map
Section titled “2. Data map”| Data | Lives in | Notes |
|---|---|---|
| Balances, holds, settlements, idempotency keys, top-ups, transfers | PlanetScale ledger cluster (via Hyperdrive) | Source of truth for money. |
Orgs, workspaces, members, API keys (hash only), allowed_models, spend caps, key sync status; rate-limit config and hold toggle on the org |
Ledger cluster | Rate limits are pushed to every key’s virtual key in the org; the admin cap is pushed to Bifrost. |
| Published rates, markup rules, workspace discounts, FX rates, top-up fees, model catalog (“on” flags) | Ledger cluster | Admin-configured. Rates and FX are never per workspace; a workspace’s price difference is a discount. |
| Request log, audit events (including reads of stored content), rollup tables | Same cluster, separate schema, own Hyperdrive config | Append-only, created_at in every key. See future-optimizations.md. |
| Prompt and response content | R2, one object per request | Pointer on the request-log row. Only where the content-logging flag is on (opt-out). |
| Virtual keys, budgets, rate limits, model allowlists | Bifrost | Configured from Postgres; never authoritative for money. |
3. Request path (apps/api)
Section titled “3. Request path (apps/api)”- Authenticate: hash the presented key, look it up (active, not expired, resolves to a workspace).
- Gate: requested model must be in the key’s
allowed_modelsand on (staff-enabled, rate published). - One transaction: claim idempotency (
hash(workspace + exact body),ON CONFLICT DO NOTHING), then hold via the atomic conditionalUPDATE. The rate and the workspace’s discount in effect are locked here (at request start if the org has hold off). The user-set spend cap is checked here, in credits.- Conflict on an
in_progressoriginal returns409; on acompletedoriginal, return the stored response and skip hold/settle.
- Conflict on an
- Call Bifrost synchronously with the mapped virtual key, sending our request id as
x-request-idso Bifrost’s log id is our request id (the usage reconciliation relies on it). Bifrost applies its rate limit, admin cap and allowlist. The Worker’s timeout is longer than Bifrost’s worst-case fallback time. Streaming (SSE) passes through. - Settle: charge Bifrost-reported input and output tokens × the locked rate × (1 − the locked discount), close the hold and release the unused part in the same transaction, floor the balance at zero (absorbed shortfall tracked). On any final failure or Bifrost rejection (including
429), release the hold; no Worker-level retry. - After the response, off the hot path: publish the usage event to Cloudflare Queues (metadata only, including Bifrost’s
usage.costas COGS); if the content-logging flag is on for the key’s workspace, write the content object to R2 (a failure here never fails the request); store the response for idempotency.
Client disconnect mid-stream: settle on Bifrost’s reported partial usage; the Worker’s own count is only a sanity check. Buffered content is written and marked partial.
4. Other flows
Section titled “4. Other flows”Key create (apps/dashboard-api): insert row as pending (hash only) → create Bifrost virtual key with rate limits and allowed_models → mark active → only then show the raw key once. Any failure rolls back both sides and shows no key. Update: same order, revert on failure. Revoke: mark revoked in Postgres first, then delete in Bifrost with retry.
Top-up: dashboard-api computes credits + fee → calls niobi-proxy → Niobi. On success, credit only the credit amount and record the fee as revenue, in one ledger transaction. Signature verification of inbound Niobi traffic happens in niobi-proxy. A top-up with no callback is resolved by the top-up status check (section 5), which uses the same guarded credit path so it can’t be applied twice.
Queue consumer (queue() in dashboard-api, own module): writes request-log rows through the store interface and updates rollups. Idempotent: duplicate deliveries are ignored (ON CONFLICT DO NOTHING, rollups incremented only when the insert happened). Privileged audit events are not queued: apps/dashboard-api writes them directly in the same request.
Stored-content read (dashboard-api): admins only (staff Admin, customer Owner or Admin; not Support, not Members). Writes the audit event first, then fetches the R2 object via the row’s pointer. No audit event, no content.
Admin: rates, markup rules, FX, model catalog, keys, per-org rate limits and hold toggle, top-up fees, ledger adjustments (reason required), workspace discounts, unbilled usage (charge or release, reason required), scheduled jobs (status, run history, schedule, enable or disable, run now), audit, and the gateway-sync discrepancy view (below).
5. Scheduled jobs (dispatcher on apps/dashboard-api, decided)
Section titled “5. Scheduled jobs (dispatcher on apps/dashboard-api, decided)”One Cron Trigger fires every minute; its scheduled() handler runs whichever jobs in core.scheduled_jobs are enabled and due, one lease per job so runs never overlap, and records each run in logs.job_runs. Schedules, enabled flags and config are edited from the admin Scheduled jobs screen, not in Wrangler. Proposed defaults, all editable: usage reconciliation hourly, top-up status check every 5 minutes, ledger invariant check daily, daily rates daily, model catalog sync daily, key reconciliation and low-balance alerts hourly (times to be set at build).
| Job | Does |
|---|---|
| Daily rates | Pull Bifrost cost (highest across providers) per model and token type, convert with the day’s FX (Frankfurter), apply markup rules, round up, publish. Only for models that are on. |
| Model catalog sync | Read the configured models from Bifrost for the admin catalog screen. New models stay off until staff enable them. |
| Key reconciliation | Compare Postgres keys and Bifrost virtual keys; write mismatches to the discrepancy view (stuck pending, missing on either side, revoked but live, config drift). |
| Low-balance alerts | Per workspace, in credits. |
| Top-up status check | Takes pending top-ups that have had no callback for a couple of minutes, asks niobi-proxy for the transaction status by Niobi reference, and on a terminal result credits or fails the top-up through the same guarded path as the callback. Non-terminal: records the check and tries again next run. |
| Usage reconciliation | Hourly. Pages Bifrost’s GET /api/logs from the last watermark to now minus a 15-minute grace and checks each log id against settlements.request_id. No settlement: not_charged. Different token counts: token_mismatch. Both go to the admin Unbilled usage screen. Advances the watermark only after the window is fully read. |
| Ledger invariant check | Per workspace: balance = SUM(ledger_entries.amount) and held = SUM(open holds). Alerts on any difference (a stuck hold shows up here). Also deletes expired idempotency rows. |
6. Failure behaviour
Section titled “6. Failure behaviour”| Failure | Behaviour |
|---|---|
| Postgres unreachable | apps/api rejects the request; Bifrost is never called without a hold. |
Bifrost timeout, error or 429 |
Release the hold, return the error. |
| Worker dies between hold and settle | The hold stays open and held stays raised. The invariant check flags the stuck hold; the usage reconciliation finds the uncharged call; an admin charges it or releases it without charge (reason required, audit-logged). No expiry job. |
| Niobi callback never arrives | The top-up stays pending until the top-up status check gets a terminal answer. Pending past a threshold is flagged in the admin Transactions view. |
| Queue publish fails | Log and alert. Money is already durable in the ledger settle; only the request-log row is at risk. |
| R2 write fails | Log and move on. Never fails the request. |
| Bifrost key create fails | Roll back, show the customer no key. |
| Postgres and Bifrost drift | Surfaces in the admin discrepancy view; admin re-syncs or cleans up. |
7. To confirm before the schema
Section titled “7. To confirm before the schema”- Content-logging opt-out level: decided, per workspace. No org lookup on the write path.
- Hold toggle and rate limits: decided, per org. Rate limits are copied onto each key’s Bifrost virtual key (Bifrost limits are per virtual key only), so an org’s limit is per key in effect.
- User-set spend cap counters: decided in the schema. A separate
key_spend_windowstable, one row per key per period, so there is no reset job (db-schema.md). - Whether queue publish failures retry, or are only alerted on. Ledger correctness doesn’t depend on it.
- Developer API docs (
../product/api-docs-scope.md): the public docs are hand-written and curated, atrouter.africa/docs. The error catalogue (status, error code, body shape) and rate-limit response headers should be defined as a contract with the build, since the docs depend on them.