The API

September 7, 2026 · View on GitHub

Composition

api.router preserves public registration order while concern routers contribute ordered route blocks. bootstrap.create_app() assembles the combined route table into FastAPI roles. api.app remains the deployed, backward-compatible all role. Everything the CLI + skill do is one HTTP call over this. The factory lifespan runs read-only verify_db() and creates the shared keepalive httpx.AsyncClient at app.state.http (and audit.drain()s on shutdown). It also starts the Google Ads conversion uploader (adsconv.worker) as a background task, but only when adsconv.enabled() - see ads-conversions. Content-driven provider companion backfills run in the explicit python -m treg upgrade release phase, outside every app role's lifespan. The default python -m treg entrypoint also provisions the guarded local single-user identity before Uvicorn starts.

Instagram authorization strategy

POST /oauth/start keeps the same request shape. TREG_OAUTH_REVIEW_PENDING is the single operational switch for incomplete reviews; provider-registry metadata maps its keys to fallback capabilities, conditional warnings, and method defaults. With both Instagram keys pending, provider=instagram uses the approved Facebook Page core flow. capability=manage selects direct Instagram Login, and capability=page-messages explicitly widens the Page flow for reviewers and app roles. When keys are removed, plain Connect first expands to Page messages and then returns to direct Instagram Login. The response returns capability-specific connect_guidance, so clients need no provider-specific review logic. Connection rows include authorization method and setup health. Catalog calls accept X-Treg-Authorization-Method to select one of an endpoint's declared methods; the header is an internal routing control and is stripped before the faithful upstream relay.

Catalog calls can fail before relay with HTTP 428. Its detail object has a stable error code, provider, endpoint id, required method, capability and scopes, explanation, CLI command, and dashboard action. This distinguishes a missing grant, missing resource, missing scope, and expired grant. See instagram-oauth.

WAF escape hatch - X-Treg-Body-Encoding

Some hosting edges (Cloudflare, including Render's) 403 any request whose body matches an injection signature - a skill recipe or a proxied call that legitimately carries SQL/HTML. The pure-ASGI _BodyDecodeMiddleware (registered via app.add_middleware) lets a client smuggle such a body past the edge: send it base64/gzip-encoded and set X-Treg-Body-Encoding: base64 (or gzip, or base64+gzip). The middleware calls _decode_request_body() to restore the real bytes, fixes content-length, and hands the decoded body to routing - so both the Pydantic JSON endpoints (e.g. POST /skills) and the /call proxy (which relays request.body() upstream) see plaintext. A malformed encoded body is a clean 400. No header ⇒ untouched. The CLI's _RegistryClient uses this automatically on a WAF 403 (see cli), as does the local proxy for an intercepted call. Body replay does not imply connection closure: after delivering the decoded request, _BodyDecodeMiddleware delegates later receive() calls to the original ASGI channel and forwards only a real http.disconnect. If the client disconnects before the encoded body is complete, the middleware skips decoding and replays each consumed partial-body message followed by that real disconnect.

503 provider_capacity_unavailable - treg's own account is out

A metered (tier-4) call whose provider treg's own account cannot serve right now (a confirmed balance/quota signal, or the capacity sweep) is refused before any hold with a typed 503: {"detail": {"error": "provider_capacity_unavailable", "provider", "endpoint_id", "resets_at" | null, "alternatives": [...], "message"}}, X-Treg-Error: 1, no X-Treg-Cost-Micro, refused_by="capacity" on the audit row. The caller's own key for the provider is never affected (tiers 1/2 win first), and treg does not call an alternative on the caller's behalf - it names them. A lock set by the call path lets one call a minute through as a probe and lifts on its 2xx, so the message says a retry in a minute may succeed; a lock from the sweep lasts until resets_at. Not the pool-saturation 503 (treg_saturated), which is a different exit. See architecture/proxy-model.md § Platform capacity.

X-Treg-Served-Via - this answer came through an overflow relay

GET/PATCH /orgs/{id}/settings carries platform_overflow (default true); false opts the team out - such calls get the 503 provider_capacity_unavailable below instead of a relay.

overflow:<aggregator> on a metered call that treg served through a treg-owned aggregator account because its own account for the provider was out. Same request, same vendor body shape (routes are verified for that), the caller paid the aggregator's real price (X-Treg-Cost-Micro), and X-Treg-Call-Id is the parent call's. Absent on every direct call. Off by default (TREG_OVERFLOW_MODE). See architecture/proxy-model.md § Overflow.

X-Treg-Smoothed - the call waited for treg's own rate limit

On platform calls, including owned free polling: wait=<ms> when the call was spaced behind other callers on the same platform key, retry=1 when a burst-429 with a short retry-after was re-sent once (body-less GET/HEAD only). Informational; the status and body are the provider's. See architecture/proxy-model.md § Burst smoothing.

X-Treg-Error - whose refusal is this?

bootstrap_handlers._mark_treg_own_errors tags treg's own refusals on /call/ and /catalog/call/ paths with X-Treg-Error: 1, then answers exactly as before - the status and body are untouched, and a client that ignores the header sees what it always saw. Without it a caller cannot tell treg's 404 ("no tool registered for that host") from the vendor's own 404: both are a status and some JSON. The local proxy needs that distinction to explain a failure without ever rewriting a real vendor response. application.call failures carry a mechanism kind and separately mapped blame; the compatibility header remains the literal 1.

Resolution refusals are actionable: a named miss that resembles one of the caller's usable own tools returns a structured detail with hint and did_you_mean, including after a real catalog endpoint falls through and finds no usable marketplace credential. A genuine URL-passthrough tie returns 409 with the names of the colliding usable tools and the explicit /call/<name>/<path> escape hatch.

Auth

require_member() reads the X-Treg-Token header, hashes it (crypto.hash_token), looks up the Membership by token_hash, and returns a Caller (membership, user, org + org_id/email/role); 401 on missing/invalid. Every scoped endpoint depends on it except POST /users + POST /invites/accept (open, self-registering) and GET /oauth/callback (browser-hit, protected by state). Each successful identity dependency commits its read-only transaction before the handler runs, so an application use case can open its own session without waiting behind the request's pool slot. The dependency-cached session remains usable because every session maker sets expire_on_commit=False. require_superadmin is the one gate on a different pool - it takes get_admin_session, and so must every /admin/* handler under it (FastAPI caches dependencies by identity; see super-admin). Authz = org scoping + a role gate: _can_manage lets admin/owner manage any org resource, a member only what they created; _require_admin_of gates the org-admin endpoints. See multi-tenancy.

Cookie mechanics stay at the HTTP boundary because they interpret request cookies; the shared _same_origin CSRF check is available to every cookie-authenticated mutation.

Email OTP and invite sign-in use cases own their sessions and every commit, including OTP rate, attempt, consumption, user provisioning, and invite-token consumption. They return framework-neutral results or semantic errors; the HTTP boundary maps them to responses and sets browser cookies. Every identity door reuses the same first-proof provisioning command.

The CLI pairing state machine prunes before creating a pending entry, pops a completed result exactly once, and preserves session lookup, attempt decrement, pending pop, and result publication order. Its team-selection sessions are read-only. The three short-lived dictionaries are process-local and shared by every pairing door.

Social login builds each authorization request, exchanges the provider code, validates the proven email, provisions the user, and commits before publishing a CLI result. Provider callback state is validated before resolving the shared HTTP client. /auth/logout remains an HTTP-only cookie action.

Endpoints

  • Users / orgs: register_user (POST /users, open, legacy - used by the test fixture) creates the user + an org + owner membership and returns a token once; the dashboard/CLI login doors do NOT go through it (they create the user only, no auto org). Both this door and create_org read the first-party treg_ad cookie (_ad_attribution_from) and, when conversion tracking is enabled, stamp Org.ad_gclid/ad_click_id_type/ad_landing/ad_click_at on the new org when present - see ads-conversions. create_org (POST /orgs, require_identity so a zero-org user can make their first team) + list_orgs (GET /orgs, each org carries a tool_count - one grouped query - so the dashboard can land on the org with tools; its active flag follows require_member's precedence - per-org membership token, else X-Treg-Org, else a team-pinned identity token's own org claim - so treg login --token <pinned key> lands on the baked-in team instead of the caller's first membership); invites via create_invite (POST /orgs/{id}/invites, admin+) → one-time code (emailed via email.send_invite, best-effort, along with a separate inbox-only email_token sign-in link - the token is never in the JSON response; see the invite sign-in link below), accept_invite (POST /invites/accept, open) → registers/joins + mints a token. Code-free invites: an invite is addressed to an email, so my_invites (GET /invites/mine, require_identity) lists every pending invite for the caller's proven email and accept_my_invite (POST /invites/{id}/accept, require_identity) accepts one with no code (403 if the invite's email ≠ yours). list_members / remove_member (GET/DELETE /orgs/{id}/members[/{user}], admin+); set_member_role (PATCH …/members/{user}, owner-only), leave_org (POST /orgs/{id}/leave), delete_org (DELETE /orgs/{id}?confirm=<slug>, owner-only AND the slug is REQUIRED - see hardening - via cascade_delete_org (in domain/governance/teams.py), which now also sweeps each org's RunRecord rows); list_invites / revoke_invite (GET/DELETE /orgs/{id}/invites[/{id}], admin+). Full behavior: multi-tenancy.

  • Agents (machine identities): create_agent (POST /orgs/{id}/agents, admin+) mints/rotates a member token for a machine caller - its own daily_call_cap, tool_access and audit trail, with no new table; list_agents (GET, never returns a token) and revoke_agent (DELETE …/{user_id}, which also sweeps the deny rules and idempotent replay cache owned by that agent before deleting its membership; the schema cascades that cache as a backstop). An agent token is refused by require_identity and can never be an owner. Re-POSTing the same name ROTATES, and a field the caller omits is left as it is - a rotate changes the token, never the limits. AgentIn also takes project_access (slugs or ids), so an agent can be project-scoped at mint time; created_by stamps the minting admin. GET /orgs/{id}/agents/observed (admin+) lists the agents detected in member traffic - one row per (member, runtime) from CallRecord.client/RunRecord.client over 30 days, excluding plain-terminal (''/cli) and machine-identity traffic; attribution, never a gate. See multi-tenancy.

  • Projects (a sub-scope inside the org): create_project / list_projects / delete_project (/orgs/{id}/projects, admin+ to mutate) - deleting frees its tools to org-wide and removes the id from every member's project_access, leaving an emptied list as [] (NULL would mean every project). POST /tools and PATCH /tools/{id} take project (slug or id; null = org-wide), and set_member_access / create_invite take project_access. See multi-tenancy.

  • Deny rules (org policy): create_deny_rule (POST /orgs/{id}/deny, admin+) blocks a host / path_prefix / method for the whole org, one member (user_id), and/or one project (project_id - fires only on calls through that project's tools); an all-empty rule is refused (it would freeze the org) and a full URL is reduced to its host. Plus list_deny_rules (GET) and delete_deny_rule (DELETE …/{rule_id}, 404 across orgs). Enforced on the proxy and both run tiers - see proxy-model. GET /orgs/{id}/policy/cli-deny (admin+, read-only) reports each CLI tool's effective argv deny patterns with their source (skill treg.json vs catalog) so the Policy screen shows every deny layer in one place.

  • Usage metering + caps (usage-metering v1, docs/USAGE-METERING-PLAN.md): org_usage (GET /orgs/{id}/usage?days=, admin+) rolls up CallRecord + RunRecord since the window start into by-user (with a call/local_run/server_run split), by-tool, by-day, and totals - pure GROUP BY, no request/response bodies (they aren't stored). set_member_cap (PATCH /orgs/{id}/members/{user}/cap, admin+) sets Membership.daily_call_cap (-1 = unlimited, rejects < -1). my_usage (GET /usage/me, any member) returns the caller's own used_today + cap. list_members also returns each member's daily_call_cap + used_today. Enforcement: governance/usage.enforce_daily_cap runs in /call/'s authorization gate and, through routers/call._enforce_daily_cap, at the top of run_tool_server and grant_local_run (so no path dodges the cap). It takes the slot with ONE conditional UPDATE of Membership.calls_today / calls_today_day (take_daily_slot, revision 0024): the WHERE is the check and the SET is the count, so the cap is exact under concurrency, a refused event is not counted, and the first event of a new UTC day starts from 1. -1 (default) skips it entirely (zero overhead); the sandbox is exempt; a database error fails open (a courtesy limit, not a money gate). used_today in the roster and /usage/me still comes from the journal (count_today = today's CallRecord + RunRecord), and set_member_cap copies that journal count onto the counter when a member goes from unlimited to capped, so a cap set mid-day does not start from zero. Until 2026-09-06 the gate itself ran that journal count - 2.8 s per call for a member with 110k rows that day.

  • Super-admin (cross-tenant, require_superadmin): /admin/stats|orgs|orgs/{id}|users|tools|calls| errors|health (reads - errors is failed calls across every credential tier with captured, admin-only request/response evidence, supports a tier filter, and runs the 14-day retention pass; see super-admin)

    • /admin/users/{id}/superadmin|suspend, DELETE /admin/users/{id}, /admin/orgs/{id}/suspend, DELETE /admin/orgs/{id} (Phase-2). See super-admin.
  • Secrets: create_secret / list_secrets / update_secret (re-encrypts on value change) / delete_secret (409 if a tool binding references it). Values never returned (_secret_view).

  • Tools: create_tool (bindings via body.bindings, or _flat_binding(body) sugar, or []; validated by _validate_bindings; host derived by _host_of; optional examples; optional cli local-run profile validated by _validate_cli_profile), list_tools, update_tool (re-derives host on base_url change; cli set/clear here - this is how the local-run toggle flips cli.enabled), delete_tool. View via _tool_view (now includes cli). delete_secret refuses a secret referenced by a tool binding or a cli.inject entry.

    • Owner-only binding. _validate_bindings (HTTP bindings) and _validate_cli_secrets (local-run cli.inject entries) require the caller to own every secret they bind/inject, via _require_secret_ownership; only an admin/owner may wire up a shared-key tool with a teammate's secret. This stops a member laundering another member's key into a tool they control and then extracting it (through the proxy's base_url or a /grant). update_tool grandfathers the secrets already on the tool (it passes their ids as a grandfather set) - only a newly-added binding/inject is ownership-checked, so re-saving a tool an admin wired with a shared key doesn't lock its owner out on edit. The skill/folder importer runs the same checks.
    • No SSRF at registration. _require_public_base_url (reusing health.safe_webhook_url) rejects a base_url pointing at loopback / private / link-local / cloud-metadata hosts - including numeric IP encodings (decimal/hex/octal/short forms) - on create_tool, update_tool, and each imported skill tool, so a member can't turn treg call into a request to an internal address. The proxy also re-resolves the host at call time (health.host_is_public) to defeat DNS rebinding - see proxy-model.
  • Local runs (treg run --local, see local-run): grant_local_run (POST /tools/{name}/grant) is the one audited, owner-opt-in exception to "values are never returned" - member+ only (a viewer may call but not extract a value). It matches the catalog profile (providers.match_skill), server-side deny-checks the argv, renders the credential (oauth → leaf only), and writes a synchronous GRANT/DENY audit row (argv redacted of key-shaped tokens by _redact_argv). Runner-proof gate: returning a secret the caller does not own (a shared-key tool they may run but not read) requires the header X-Treg-Run-Proof to equal TREG_RUN_PROOF - the value held only by the isolated treg-run runner, which the member's own uid can't read. A caller who owns the injected secret (or is an admin) skips the gate. On refusal a DENY audit row is written and the grant is 403'd, so a direct member call can never read a teammate's key value. The grant response also carries redact_output (true exactly when the caller doesn't own the key, i.e. the runner-proof case) - the client then scrubs the injected value from the CLI's output (see local-run). report_local_run (POST /tools/{name}/run-report) takes the client's verdict enum (never raw output); credential_invalid marks the injected secret(s) invalid via the health fields, skipping param kind.

  • Server runs (treg run --server): POST /run accepts {tool, args, timeout_s} and executes the org-scoped tool's Tool.cli profile through runner.run_tool. Secrets enter a scrubbed child environment, argv is an array, and the process gets its own temporary home. A profile is server-runnable without local-run opt-in, but tool/project ACL, deny rules, member usage caps, global/per-user concurrency limits and the executable allowlist still apply. runner.resolve_exec_bin is shared by validation and execution; allowed commands come from the catalog or TREG_RUN_ALLOWED_BINS. The sandbox cannot run commands.

    The response is {stdout, stderr, exit_code, timed_out}; a nonzero child exit is a normal 200, startup/configuration errors are 4xx, and exhausted run slots return 429. RunRecord.bundle_name stores the tool name for compatibility. GET /runs merges server records with local grants, tagged where=server|local and s|l-prefixed ids. Run settings are managed through Tool.cli, not bundle runtime metadata. See local-run.

    • Resource-limit sandbox (runner.py, the DoS half of the server-run sandbox): every server-run child is spawned with POSIX rlimits via a preexec_fn (_spawn_preexec/_rlimit_preexec) - a CPU-seconds cap, a max-file-size cap, and core dumps disabled (a core would spill the injected secret to disk). Env-gated (TREG_RUN_RLIMITS on by default; TREG_RUN_CPU_SECONDS, TREG_RUN_FSIZE_MB), a no-op where resource is unavailable. Deliberately no address-space or process-count cap - a virtual-memory cap crashes Go CLIs (gh/stripe/doctl) and RLIMIT_NPROC is per-uid, shared with the server. Full filesystem/network isolation needs a container deploy and is a planned follow-up.
  • Meta: meta (GET /meta, open) → {public_url, github, google, app_version, treg_version, posthog_key/posthog_host, intercom_app_id} for the dashboard. The last three are the opt-in third-party keys (analytics, support chat): empty on a deployment that didn't set them, so self-hosted pages load neither PostHog nor the Intercom Messenger. intercom_app_id is paired server-side with intercom_secret, which never leaves the server: _intercom_user_hash (HMAC-SHA256 of the email, keyed by the secret) is added to GET /auth/me as intercom_user_hash - Intercom identity verification, so a third party who knows an email can't impersonate that user in chat.

  • Provider catalog: providers_catalog (GET /providers.json, open) → {version, providers} - the catalog treg upload uses to detect env keys → tools; served so the CLI can refresh centrally. See env-import.

  • Catalog discovery and inspection: open routes use catalog_store; full matching, pricing, ranking and grouping rules live in catalog.

    RouteContract
    GET /catalog/platformsNon-empty platforms with capability/endpoint counts and providers, ordered by endpoint count
    GET /catalog/platforms/{slug}Capabilities, extended endpoints, dashboard domain rows and provider metadata; unknown slug is 404
    GET /catalog/search?q=&limit=Ranked endpoint views, count/total and hints; default 25, maximum 100
    GET /catalog/endpoints/{id}Endpoint, provider, capability siblings, call template, inline example and next-step hints
    GET /catalog/examples/{id}Captured JSON, resolved through the catalog before constructing a file path
    POST /tool-requestsOpen, rate-limited demand report with capped fields and optional caller attribution

    Domain grouping is server-side (domain_rows), so CLI and dashboard share ordering and comparison semantics. call_template uses the verified test request, then documented examples.

    Search uses token matching, aliases and BM25 weighting, followed by reliability/core/price reranking within the score band. Routed parents accompany matching children; capped groups carry children_hidden. Empty results include near matches and unmet tokens, plus a tool-request hint. Reliability is optional cached evidence: fresh for five minutes, stale while refreshing up to thirty minutes; cold or failed reads still answer 200 with no request DB checkout.

    Unknown endpoint ids return {error, hint, did_you_mean[]}, using provider-local segment matching. Retired/broken entries remain inspectable with status_note and superseded_by, disappear from discovery, and return 410 on call/access checks. platform_blocked entries remain discoverable for BYOK but cannot use treg's key.

    Zero-result searches emit identity-free SearchMiss rows through the lossy audit queue. Sources distinguish HTTP, team MCP and the Claude connector. Tool requests may attach a token or same-origin session identity; cross-origin cookie submissions remain anonymous. scripts/usage_report.py reports this unserved demand.

  • Identity doors: GitHub, Google and email OTP share first-proof user provisioning. They create a user without an automatic org; new users name their first team through onboarding or the CLI login picker. Suspended users are refused at every door, and so is any address on a domain listed in TREG_BLOCKED_EMAIL_DOMAINS (the whole blocklist — nothing is blocked when it is unset; subdomains included): the OTP start and verify, both social callbacks, the emailed invite link, plus POST /users, POST /orgs and POST /invites/accept, all with the same 403 this address cannot be used to sign in the machine-identity guard uses. See multi-tenancy.

    • GitHub/Google: GET /auth/{provider}[/callback], optional ?cli=<id>; callbacks validate state before resolving the shared HTTP client, require a proven email, and set the session before returning CLI users to the team picker.
    • Email: POST /auth/email/start and /verify. Six-digit codes, attempt counts and per-email/ per-IP start limits live in DB-backed Ephemeral state. expose_dev_code permits response/ log disclosure only on guarded local SQLite; other deployments email the code. Verification issues an identity token and session cookie.
    • Invite sign-in: the admin-visible invite code is join-only. An independent inbox-only email_token authenticates through /auth/invite-signin, consumed once. Invalid/expired links return /?invite_expired=1. See auth-secrets.

    CLI pairing starts at POST /auth/cli/start, which mints a login id and four-character code. The code appears in the terminal, never the URL. GET /login?cli=<id> validates the id and offers configured identity doors, then a team picker (including first-team creation). GET /auth/cli/orgs lists the session user's teams; POST /auth/cli/approve requires the session, same-origin CSRF check, matching code and membership in the chosen team. Wrong attempts are bounded. GET /auth/cli/poll consumes the completed identity token and selected org exactly once. Pairing dictionaries remain process-local.

    GET /auth/me accepts token or session authentication and returns identity/onboarding state. GET /auth/cli-token uses application.auth.issue_cli_token for optional team pinning. Typed session credentials require expiry and aud=session; copied identity keys use aud=identity without expiry. The two audiences are not interchangeable; MCP's internal exchange token is the deliberate 120-second identity exception.

    Legacy untyped keys with an org claim or without expiry are identity-only. An org-less untyped token with expiry remains compatible only until expiry. token_version revokes permanent keys; missing tv means zero. POST /auth/revoke-tokens bumps the version and returns a replacement cookie/token for the caller. /auth/logout is a same-origin cookie action. Onboarding routes are POST /onboard/demo|skip|reset; see onboarding.

    The shared dependencies resolve a membership token, identity bearer plus X-Treg-Org, or session cookie plus X-Treg-Org. Identity and session validation live in domain.identity.session; authorization belongs to domain.identity.access.

  • Static (dashboard + tutorials): dashboard (GET /, FileResponse + Cache-Control: no-cache), tutorial_js (GET /tutorial.js - shared window.TREG_TUTORIAL + hl()), tutorial_page (GET /tutorial - standalone CLI tutorial). The dashboard tour is a StaticFiles(html=True) mount at /dashboard-tour/ (serves web/tour/ - tour.js, the standalone index.html, and the WebP img/). Vendored front-end libraries are an _ImmutableStatic mount at /vendor/ (serves web/vendor/ - today just Vue, version-pinned in the filename, hence Cache-Control: immutable): the dashboard must not depend on a CDN a visitor's network may not reach, see dashboard. favicon (GET /favicon.svg + /favicon.ico). llms_txt (GET /llms.txt) serves web/llms.txt as text/plain with {BASE} templated from public_url - the llms.txt agent-onboarding file (call protocol + discovery + auth + CLI + skills + doc links). See dashboard. install_sh (GET /install.sh, {BASE}-templated) serves the CLI installer (web/install.sh). sitetrack_js (GET /sitetrack.js, no-cache) serves web/sitetrack.js with {POSTHOG_KEY} / {POSTHOG_HOST} templated from settings: the always-on first-party treg_utm first-touch cookie (utm_* + referring host, read by _utm_attribution_from / _stamp_utm in BOTH signup doors, /users and /orgs) plus the PostHog bootstrap with pageviews ON. Loaded by every public page - landing, use-case pages, resources, tutorial, and the SPA - so analytics sees the visitor's first hop rather than the post-OAuth /app landing. Without a key the analytics half is inert (empty string). adtrack_js (GET /adtrack.js, no-cache) serves the first-party ad-click capture script loaded by index.html's <script src="/adtrack.js">; it returns an empty script when conversion tracking is disabled, so unconfigured deployments do not collect advertising cookies. See ads-conversions. well_known_skills_index (GET /.well-known/skills/index.json) + well_known_skill_md (GET /.well-known/skills/treg/SKILL.md) advertise treg's own skill under the agentskills.io convention, making this host a skill source with no registry in between (Hermes reads it directly). The index's description comes from the skill's frontmatter at request time via _skill_frontmatter() - never a second copy - and the SKILL.md route is the same _serve_md as /skill.md, so {BASE} templates to the serving host and a self-hosted registry advertises itself. See skill.md for the other three distribution doors. terms_page (GET /terms) + privacy_page (GET /privacy) serve the hosted registry's legal pages (_legal_page, no-cache) with legal_css (GET /legal.css) as the shared skin - /privacy is also the URL given to OAuth providers at app-verification time, so don't rename it. resources_page (GET /resources) is the hub for the outcome pages and the only thing linking to them: the landing footer and each page's own footer carry one resources link rather than five that grow with the cluster, and without the hub every /use-cases/* page is an orphan no crawler reaches. resources.html is generated alongside them, so a new page appears on the hub automatically. use_case_page (GET /use-cases/{slug}) serves the per-vertical outcome landing pages - the destinations for search ads and the organic /use-cases/ cluster - off the _USE_CASES slug map, with usecase_css (GET /usecase.css) as their shared skin, the same split as the legal pages. Two deliberate differences from /: an unknown slug is a 404 rather than a fall-through to the SPA (a typo in a live ad should be visible, not silently swallowed), and a signed-in visitor is not redirected to /app - bouncing a returning user off the page an ad paid to reach would make the campaign data unreadable. The HTML in web/usecase-*.html is generated from marketing/landing/*.md by that directory's build.py + build_html.py; never hand-edit it, and note the generator refuses to emit anything past the ad-kit heading so bid and negative keywords cannot reach a public page. Provider brand marks are mounted at /logos (StaticFiles over web/logos/, resolved by convention logos/<service>.svg). dashboard_marketplace (GET /app/marketplace/{service}) serves the plain SPA (a connect page is only meaningful to a signed-in member, so no OG meta). _serve_md backs quickstart_md (GET /quickstart.md) + tutorial_md (GET /tutorial.md) - {BASE}-templated markdown served as inline text/plain (so "open in new tab" shows it, not a download); the docs pages' Copy markdown dropdowns (copy / open-in-tab) fetch these. vendor_listing_md (GET /vendor-listing, alias /vendor-listing.md) serves the same way: the instructions a VENDOR's own coding agent follows to raise a listing PR on the repo (the dashboard's "List as vendor" modal hands vendors a prompt naming this URL; the repo-side counterpart is docs/VENDORS.md). Browser-facing auth pages (GitHub callback, OAuth-connect result) render via _auth_page (brand card).

  • Landing sandbox + hosted skills: demo_sandbox_mint (POST /demo/sandbox, open, per-IP rate-limited) mints an anonymous throwaway team (its response now carries live = whether the seeded stripe tool is a real wire); demo_sandbox_skill (GET /demo/sandbox/skill) exports what the visitor built. skill_samples (GET /skills/samples, open) + skill_install (GET /skills/{name}/install.sh?token=) host sample skills. call_tool short-circuits sandbox orgs to sandbox.synthesize (real injection, no network). Caps via domain.governance.sandbox.enforce_sandbox_cap. Full behavior: landing-sandbox.

    • The one live wire (real Stripe demo). When demo_stripe_key is set, a sandbox call to the exact seeded stripe tool (fingerprint-matched by demo_sandbox.is_live_tool, GET/POST only) is relayed for real to Stripe's test API via _relay_live_demo - a deliberately narrower relay: the auth header is built from the env key (never from a sandbox secret, which doesn't hold it), the body is form-encoded, and metadata[visitor] is overridden server-side. Metered per client IP (_enforce_public_demo_ip_cap) since the wire is one shared credential. demo_sandbox_live (GET /demo/sandbox/live) reports live + the visitor's feed name for an existing sandbox. _require_not_live_demo_tool/_require_not_live_demo_secret freeze the seeded stripe tool and its STRIPE_KEY against edit/delete so a visitor can't break their own live pane. The public payments feed: stripe_webhook (POST /stripe/webhook, 404 when demo_stripe_webhook_secret unset, verifies the signature via pubfeed.verify_signature, pushes a charge.succeeded into pubfeed.push_charge) and landing_stripe_feed (GET /landing/stripe-feed, unauthenticated SSE via pubfeed.stream, server-chosen fields only).
  • Public demo token (publishable, call-only credential): create_public_token (POST /orgs/{id}/public-token, owner-only) flips the org to public_demo and mints a viewer-role token bound to a dedicated can't-log-in identity (pub-<slug>@public-demo.treg.local) - safe to print on a web page. Re-POSTing rotates (instant revocation of the old one); delete_public_token (DELETE …) revokes and lifts the lockdown. Lockdown is centralized in the auth deps: when org.public_demo and the role is below admin, require_member allows only /call/* + GET/HEAD/OPTIONS (every mutation is frozen no matter what routes are added later), and require_identity refuses the token entirely (it must never act as a user - mint identity tokens, create orgs, accept invites). Its /call traffic is metered per client IP (_enforce_public_demo_ip_cap, PUBLIC_DEMO_HIT_NS, ~10 calls/min/IP) since one token stands in for thousands of strangers. The limiter and its constants share the domain.governance.publicdemo owner; the API commits its ratestore write before translating a semantic exhaustion result to 429.

  • Skills / bundles: register_skill (POST /skills) composes a Bundle + its secrets + tools atomically, resolving each binding's secret local-name to the created secret id; the shared core is _register_skill_bundle (also used by the folder importer). list_bundles, get_bundle, delete_bundle (cascades; it 409s if a bundle secret is referenced by a tool outside the bundle - now guarding both an outside HTTP binding and an outside cli.inject entry, matching delete_secret, so a local-run tool can't be left with a dangling secret_id), and update_bundle (PATCH /bundles/{id}, creator/admin only) edits the bundle recipe; execution settings belong to Tool.cli. _bundle_view. Folder importer (dashboard mirror of treg upload skills): analyze_skill_folder (POST /skills/analyze) writes uploaded files to a temp dir and runs the CLI's own skills.scan_skills/_classify to classify each (recipe-only / contract / generated) without registering; import_skill_folder (POST /skills/import) scans + build_payloads + registers the selected ones (_materialize_skill_files sandboxes the upload). list_orgs now carries tool_count.

  • Activity and call inspection:

    • GET /calls?days=&before_id=&limit= returns org-scoped audit rows, with limit 1-500. SQL excludes kind=async_poll before pagination. Failure evidence is neither selected nor returned. Counts and fees in this feed are analytics, not an invoice.
    • GET /calls/{call_ref} returns the audit record, ledger entries and async-task view. Hidden poll records remain inspectable here; admin diagnostics retain failure evidence.
    • Both routes derive async status, final charge and result from async_task_app.views_for. _async_charged returns null while pending and the settled amount afterward, including zero for a refund. The original submission is the Activity row for the task. An authorized terminal poll updates that task immediately when settlement evidence is complete; cron remains the fallback. Polling itself is free and does not add Activity entries.
    • GET /calls/{id}/result joins the archive hashes to the request shape and stored response. has_result identifies archived rows; unavailable content returns stored: false and a reason (own-key/tool, failure, recording off, expiry or hash-only storage). Failure-evidence columns remain excluded. See archive.
  • OAuth connect + the provider marketplace: oauth_start (POST /oauth/start) creates a PendingOAuth and returns consent_url + state + redirect_uri + registry-owned connect_guidance; oauth_callback (GET /oauth/callback, open) exchanges the code and creates/updates the oauth secret; oauth_status polls. Two modes (OAuthStartIn): BYO (supply client_id/client_secret/auth_uri/ token_uri/scopes) or REGISTRY (supply provider + optional capability) where treg fills everything from its own approved OAuth app - the marketplace. oauth_providers_list (GET /oauth/providers) lists the providers treg holds an app for, each flagged configured (false when this deployment hasn't set that provider's client credentials) and metered (true when the provider's upstream bills treg's app per use AND this deployment charges for it - then billed_rates carries the default prices, so the UI can show them before consent; see auth-secrets on platform_billed). In registry mode oauth_start reads the provider from oauth_providers.get, resolves scopes via scopes_for(capability), and stashes every per-provider auth quirk on the PendingOAuth (PKCE code_verifier, auth_params, token_endpoint_auth_method, client_id_param, scope_separator, long_lived_exchange) so the callback exchanges the code exactly the way the consent URL was built. connection_id (BYO or registry) targets ONE existing connection to reconnect/widen it - scoped to the caller's org and matched to the provider, recorded as replaces_secret_id - instead of adding another account. Callback does the real work: it either replaces the named connection (replaces_secret_id) or adds a new one named by _free_connection_name (the first account for a provider keeps the bare service name - google-search-console - later ones get -2/-3), normalizes granted_scopes to space-joined, sets expires_at (oauth.expiry_of), then _autoprovision_provider_tool binds the fresh credential to the provider's API as a callable tool (idempotent by (org, name); a token-kind provider gets an env header binding, an oauth one gets a Bearer {access_token} binding; a provider needing treg's own second credential - Google Ads' developer token - also gets a platform binding, see proxy-model) and _record_connected_identity best-effort asks the provider who connected. _upsert_provider_extra_tools is shared by this connect path and the startup backfill, so companions use the same (org_id, name) upsert and binding shape in both cases. See auth-secrets. The tool's examples come from _provider_tool_examples: the registry's hand-written ones first, then the endpoint catalog's verified core endpoints for that provider (catalog_store.tool_examples{method, path, note} where the note carries the summary, required params and capability), de-duplicated by (method, path) and capped at CATALOG_STAMP_CAP (12). Unverified endpoints are never stamped - an example is a promise the call works, and the verified date is the only evidence of that. Search Console's hand-written example additionally documents that direct own-tool path substitution takes a site_url encoded exactly once; catalog calls accept either raw values or existing %HH escapes and prevent the latter from being encoded a second time.

  • Connections (the marketplace's dashboard surface): list_connections (GET /connections) returns every OAuth/registry credential in the org - metadata only, no token material - with health, expiry, and (for a known provider) capabilities/missing_capabilities + extra-credential notes. The filter is kind=="oauth" OR provider!="" so a bring-your-own-token provider (a plain env string, e.g. Slack) still lists. connection_resources (GET /connections/{id}/resources) live-fetches what a connection can act on (GSC sites, GA properties, Ads accounts), enriching id-only rows with the upstream's human name concurrently (_enrich_resource_labels) and recording the successful upstream call as proof of health. For providers declaring discover_extra_path (the Meta pair) it also walks the Business graph and merges Business-owned rows after the primary ones - deduped by id, id-less rows dropped, and a failing walk swallowed rather than surfaced as a 502; set_connection_resource (POST …/resource) pins the chosen resource_ref

    • resource_name. connect_with_token (POST /connections/token) connects any pasted-secret provider - a bring-your-own bot token (Slack) or an API key (Apollo, Hunter, TikHub, Semrush, …) - verifying the credential against the provider's probe before storing (a header- OR query-param probe, an off-host probe_url, tolerating a CSV/text body or a 200-with-false-token_verify_field reply), then auto-provisioning its tool with a header or query binding. Two probe subtleties the code handles because upstreams don't cooperate: a probe_path may bake in a required ?query (PDL, Akta, JustOneAPI, SpyFu), and httpx replaces a URL's own query when params= is passed - so the path's query is parsed out and merged into params (the credential wins a key collision) rather than silently dropped; and the body is parsed as JSON regardless of content-type, because ScrapeCreators serves real JSON under text/plain and gating on application/json left token_verify_field unread (a genuinely non-JSON balance still throws → empty payload → the ERROR-text branch, unchanged). A base64-encode provider (token_encode, DataForSEO/Moz) accepts EITHER a raw login:password OR the dashboard's ready-made base64 blob - a blob is detected (strict-decodes to printable text with a :) and kept as-is instead of being double-encoded. See auth-secrets. set_extra_credential (POST /connections/{id}/extra-credential) stores the second credential a provider needs when treg does NOT hold it centrally (Tomba's X-Tomba-Secret) and finishes the tool with BOTH bindings - the primary half built by _provider_bindings, so it follows the provider's own auth shape (pasted key or OAuth) rather than assuming a bearer token. revoke_connection (DELETE /connections/{id}) deletes the credential and cleans up: it removes the tool treg auto-provisioned for the provider and drops the dead binding from any user-built tool, leaving that tool's other bindings intact. All require_can_register (member+). Helpers: _owned_connection, _dig (dotted-path walk).
  • Health: run_health (POST /health/run) → health.run_all; get_health (GET /health) now returns health._view(s) plus a needs_reconnect flag (health.needs_reconnect) so a credential treg can't renew announces itself before it dies.

  • The proxy: routers.call.call_tool (* /call/{rest:path}) captures a framework-neutral CallInputapplication.call.service.execute_callapplication.call.resolve → (on a dotted 404, catalog lookup + retirement gate + credential ladder) → application.call.authorize (tool/project ACL, deny, per-user cap, then public-demo rate cap) → load secrets (+ ensure_fresh) → db.commit() - the DB phase ends here; a call in flight holds no pooled connectionrelay()audit.record_call. A pool that has no slot within 5 s answers 503 {"treg_saturated": true} + Retry-After: 2 (_pool_saturated, the handler for sqlalchemy.exc.TimeoutError) rather than a 30 s wait and an anonymous 500. The adapter also calls analytics.capture_fault(component="db_pool"): saturation is a handled, typed response for the caller but remains an infrastructure fault for PostHog alerting. After identity resolution it also emits one tool_called event with outcome=gateway_failed and failure_kind=db_pool. During identity resolution it emits call_intake_failed instead, without team or target attribution and with a call or catalog_call surface label. The same compensation applies to /catalog/call/, including release of an acquired idempotency label so a retry does not get a false 409. An unexpected exception raised while call_tool awaits execute_call emits tool_called with the same outcome and failure_kind=unexpected_exception. A platform binding carries no secret_id (its value comes from settings at relay time), so secret-loading now skips secret_id is None. Detail in proxy-model. call_catalog_endpoint (* /catalog/call/{rest:path}, hidden from public OpenAPI) is the narrower internal entrance used by catalog-only MCP surfaces: it requires an exact catalog id and skips _resolve_call, so an exact same-named team tool cannot shadow the catalog endpoint. From the credential ladder onward it delegates to call_tool, retaining provider/user credentials, ACLs, deny rules, caps, metering, audit, idempotency and faithful relay.

    A resolved catalog endpoint with an async descriptor adds one treg-owned response header: X-Treg-Async, containing compact JSON for the already-known effective descriptor. The router attaches it after catalog resolution and before Starlette begins streaming; no provider response bytes are read, parsed, or rewritten.

Schemas

Pydantic input models: UserIn, OrgIn / InviteIn / AcceptIn, EmailStartIn / EmailVerifyIn, SecretIn / SecretUpdate, ToolIn (flat single-binding sugar + optional bindings + health_check + cli) / ToolUpdate (incl. cli), SkillIn (SkillSecretIn + SkillToolIn, whose cli inject entries reference secrets by local_name), GrantIn (argv) / RunReportIn (audit_id + exit_code + verdict), OAuthStartIn (now BYO-or-registry: provider / capability / connection_id plus the BYO client_id/secret/URIs/ scopes), and the connection models ResourceRefIn, TokenConnectIn, ExtraCredentialIn. Output helpers _secret_view / _tool_view / _bundle_view never leak secret values - _tool_view returns health_check + examples + cli (it once omitted health_check, so a tool's probe was stored but never surfaced by GET /tools / /bundles/{id}).

Cross-cutting hardening

  • Legacy-host redirect: _LegacyHostRedirectMiddleware 301s GET/HEAD marketing pages (_REDIRECT_PATHS) from the legacy hosts (config.LEGACY_PUBLIC_HOSTS) to the canonical public_url host (treg.to)
    • but only for anonymous visitors: a treg_session cookie is host-scoped, so a signed-in browser (e.g. the invite flow landing on /?invite_org=…) is served in place. The auth entries /auth/github, /auth/google and GET /oauth/authorize (_REDIRECT_ALWAYS) redirect unconditionally and with a 302 (one-shot OAuth params must not be cached as permanent) - each parks a host-scoped cookie (CSRF state / treg_oauth_return) that the flow's continuation on public_url must be able to read. Everything else is served in place on BOTH hosts, forever: installed CLIs/skills hold tokens pointed at the legacy host, HTTP clients strip Authorization on a cross-host redirect, /vendor-listing is fetched by agents, and curl {BASE}/install.sh | sh runs without -L. The legacy names also stay in MCP's transport allow-lists (mcp._allowed_hosts/_allowed_origins) and in the OAuth token-audience set (mcp_oauth.mcp_resource_audiences() - pre-move grants keep their old audience for life, and refresh reissues it). Never remove the legacy domain from Render.
  • Security headers: pure-ASGI _SecurityHeadersMiddleware adds X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, and HSTS to every response (setdefault, so the /call proxy's stricter CSP/nosniff wins).
  • No 500 on bad ids/URLs: an OverflowError handler turns an oversized all-digit id into a 404 (SQLite's 64-bit INTEGER); _host_of/_resolve_call guard urlsplit ValueError (malformed base_url/passthrough) into a 422/400.
  • CSRF/redirect: auth_logout rejects a cross-Origin request (forced-logout CSRF); oauth_start pins redirect_uri to treg's own /oauth/callback (consent-phishing guard).
  • Destructive routes name their target: DELETE /orgs/{id} requires ?confirm=<slug> and 422s without it. It is irreversible and sits one path segment above every other org route, so any client that normalizes .. turns DELETE /orgs/{id}/<anything>/.. into it - treg org unpin .. really did delete a team in testing, because httpx rewrites the path before the request is sent. Server- side validation cannot see that, so the defence is the confirmation, plus keeping user-supplied values out of path segments (the pin capability moved to a query parameter for the same reason). The CLI and the dashboard already made a human type the slug; now the API insists too.
  • A machine identity can learn its own org: GET /auth/me returns org_id/org/role when the caller authenticated with a token. require_identity refuses machine identities on /orgs by design (create_org hangs off it, so an agent could otherwise mint an org it owns) - but its token IS one membership, and without this every /orgs/{id}/… command died with "no active org" for exactly the callers those commands serve.
  • Config default: returning the OTP in the response (dev_code) is now gated by a dedicated expose_dev_code - true only on a local sqlite box, never on a real (Postgres) deploy - so a misconfigured email_dev_mode can't leak the code and enable an unauth takeover in prod.

Full endpoint list + the running server's OpenAPI: README.md and /docs. CLI-level usage: USAGE.md.

/docs is ours - a server-rendered reference built from app.openapi(), not Swagger. FastAPI's console moved to /docs/api (ReDoc is off), and /openapi.json is unchanged. The /catalog, /catalog/<slug> and /robots.txt + /sitemap.xml surfaces live alongside it; see seo, which also explains why HEAD is widened onto every GET route after registration and why that widening must be kept out of the schema.

OAuth + MCP routes

treg is an OAuth authorization server for its own MCP endpoint. Detail in architecture/mcp-oauth.md; this is the surface.

GET  /.well-known/oauth-protected-resource      what guards /mcp/ (served at BOTH v1 lookup paths)
GET  /.well-known/oauth-protected-resource/mcp/v2
                                                distinct metadata for /mcp/v2/
GET  /.well-known/oauth-authorization-server    endpoints, S256, DCR + CIMD support
POST /oauth/register                            dynamic client registration (RFC 7591)
GET  /oauth/authorize                           the consent screen (JSON with Accept: application/json)
POST /oauth/authorize                           the human's decision - approval is never a GET
POST /oauth/token                               authorization_code and refresh_token grants
POST /oauth/revoke                              RFC 7009; ends the whole refresh family, always 200
GET  /oauth/grants                              live (non-retired, non-expired) grant families
POST /oauth/grants/{family}/team                move family authority to another member team
POST /mcp/                                      the MCP transport itself
POST /mcp/v2/                                   catalog-only Claude directory transport

GET  /connect-demo                              a page that pretends to be an MCP client
GET  /connect-demo/callback                     its OAuth callback
GET  /connectors/claude                         setup, scope, pricing, data flow and removal docs

The V2 metadata and transport routes are available only when TREG_CLAUDE_CONNECTOR_ENABLED=true. The metadata route returns 404 when V2 is disabled. New V2 OAuth grants and catalog-only calls are also refused.

Cost and asynchronous-task headers

X-Treg-Cost-Micro reports the metered call's charge. On an async submission it reports the reservation, repeated by idempotent replay; the CLI labels it "generation reservation". Final charge and task status come from /calls or /calls/{ref}.

An owned free platform poll explicitly returns zero. Own-key/tool calls omit the header because their upstream charge is outside treg's billing. X-Treg-Async carries the effective catalog descriptor, without parsing or rewriting the provider's response.

Idempotency-Key on /call/

A caller-supplied label that makes a retry free. Sent on /call/, honoured only when present, so a caller who omits it sees byte-identical behaviour to before the feature existed.

Idempotency-Key: <caller's label>        → replay if we already answered this label
X-Treg-Idempotent-Replay: true           → on the response, when it came from store
X-Treg-Cost-Micro: <original charge>     → what the FIRST call cost, not a new charge

Refusals: 422 when a key is reused for a different request (a caller bug, and answering it would hand them a response to a question they did not ask), 409 while the first call with that key is still in flight.

Over MCP the same thing is the optional idempotency_key argument to the call tool, and a replayed result carries replayed: true.

Reasoning, storage rules and the concurrency guard: architecture/money.md.

Caller tags, budgets and per-tag usage

For a builder embedding treg in their own product and billing their own users. Design and rationale: money.

Tag a call. Set the header from your backend - never from a model, which will eventually omit it:

X-Treg-Meta: customer=cust_8123, workspace=ws_9, feature=email-finder

Up to 5 pairs; keys [a-z0-9_]{1,32}, values ≤128 chars, whole header ≤512 bytes. Any violation is a 422 before anything is relayed (so a malformed bag costs nothing and does not burn an Idempotency-Key). Values containing @ are refused: tags land in an append-only ledger.

Every relayed response on either call surface carries X-Treg-Call-Id, and so does every refusal treg raises before the relay, plus the saturation 503 (_stamp_call_exit mints it for the exits that never reach call_tool's own bookkeeping). The same id is written to the audit row, making it the join key for your own records. An unexpected fault raised by execute_call is answered by Starlette as a bare 500. The response has no id because Starlette owns it, but treg records the row and one matching tool_called event before re-raising. Failures before execute_call, plus body-stream failures after the handler returns its StreamingResponse, are not covered by that compensation path.

A direct metered /call/ honours X-Treg-Route-Max-Cost: <usd> as a hard per-call ceiling (service._enforce_caller_max_cost, 2026-09-05): when the reserve with margin would exceed it the call is refused 402 error: route_max_cost (max_cost_micro, estimated_cost_micro, no charge, audited refused_by=balance like every 402) — the same header and body shape the routed path uses, so one agent-side handler covers both. Unlike /do/ there is NO default on a direct call: a caller who named the endpoint and page size is uncapped unless they send the header. A non-numeric value is a 400. Asked for by a customer whose runner approved $0.23 and was billed $0.56 (2026-09-04).

Metered responses also carry X-Treg-Cost-Micro; a reserved call that fails before a provider answer carries an explicit 0. That 0 is what the call ends up costing, but the balance can lag it: if returning the hold itself fails, the money comes back when the hold is reaped rather than at once (see money).

RouteDoes
GET /calls?days=&before_id=&limit=this team's calls, windowed and pageable. Analytics - not an invoice source
GET /calls/{call_ref}one call by its X-Treg-Call-Id, plus the ledger entries for it and its async_task view when it was a metered generation
GET /calls/{id}/resultwhat one call asked and what came back - the archive's copy; metered platform 2xx only, stored: false + note otherwise
GET /orgs/{id}/usage/by-tag?key=&days=per-value spend for one tag key. Money from the ledger; admin+
GET/PUT/DELETE /orgs/{id}/budgets[/{dim}/{val}]per-tag limits and blocking; admin+
GET/PATCH /orgs/{id}/settingsthe team's daily spend cap, budget dimensions and primary dimension

PUT /orgs/{id}/budgets/{dim}/{val} is an upsert that leaves unsent fields alone - a PUT that only sets status does not wipe the caps. Body: daily_cap_micro, monthly_cap_micro, calls_per_day, status (active|blocked), note.

Refusals a builder may show their own user. These deliberately carry nothing about your team - no balance, slug, platform cap or top-up link:

  • 403 {error: "tag_blocked", dim, val, message}
  • 429 {error: "tag_spend_cap_reached", dim, val, spent_micro, cap_micro, period, estimated_cost_micro, message}
  • 429 {error: "tag_call_cap_reached", dim, val, used_today, calls_per_day, message}

Caps are advisory: concurrent calls can overshoot slightly. Your balance is the hard limit.

usage/by-tag reconciles. attributed_micro + unattributed_micro == total_micro, for every key. Untagged traffic shows up as unattributed_micro rather than being dropped.

Isolation. treg org agent-new <name> --pin customer=cust_A mints a token pinned to one tag value; the pin beats the header and a mismatch is a 403. Rule of thumb: tag for counting, token for control.

Referrals

GET /referrals · POST /referrals/code · GET /admin/referrals. Policy lives in money; three API-shaped decisions live here.

require_identity, not require_member. A referral belongs to a PERSON, not to one of their teams (User.referral_code). The reward does land in an org, but which org is our decision - the oldest one they own - so nothing on these routes is scoped by X-Treg-Org.

GET /referrals returns the referred person's full email, which makes it the one route here where a scoping mistake leaks another user's data rather than merely miscounting. It is scoped in the query itself (referrer_user_id == caller.id), never filtered afterwards, and pinned by a test. privacy.html discloses the visibility.

/?ref=CODE is the one query string the landing route serves. GET / deliberately treats any query string as the SPA's and falls through to the dashboard - which for a referral link would send a stranger who has never heard of treg to an empty app shell instead of the pitch. So a lone ref counts as parameterless (anything alongside it still belongs to the SPA), and the code is parked in treg_ref - httponly, lax, 30 days, and revalidated on read exactly like _take_oauth_return, because a cookie is attacker-supplied and this value reaches a query.

Redemption happens at first team creation, in both org-creating doors (POST /orgs and the legacy POST /users), immediately after _grant_signup_promo and with the same swallow-and-log posture: a referral is a marketing nicety and a signup is not. It must never be why someone cannot make a team. A failed grant recovers by rolling the session back, which expires every object it tracks - so both doors read their response fields before granting, and the redemption revives an expired user/org with db.refresh before touching them. The recovery path costs the team its credit, never the signup response or the referral attribution.