public_url/email_from defaults, render.yaml, CLI fallbacks, packaging (npm/plugin/pyproject), web pages (+canonical tag), docs and context fragments all move to https://treg.to. The legacy host keeps serving the FULL API forever — installed CLIs, skill.md files and .mcp.json configs in the wild hold Bearer tokens pointed at it, and HTTP clients strip Authorization on cross-host redirects. Only browser-facing marketing pages 301 to treg.to (new middleware + tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
43 KiB
title, status, sources, related
| title | status | sources | related | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| The API — the only brain (FastAPI) | shipped |
|
|
The API
FastAPI app in src/treg/api.py. Everything the CLI + skill do is one HTTP call over this. lifespan
runs init_db() and creates the shared keepalive httpx.AsyncClient at app.state.http (and
audit.drain()s on shutdown).
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.
X-Treg-Error — whose refusal is this?
_mark_treg_own_errors (an @app.exception_handler(StarletteHTTPException)) tags treg's own
refusals on /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.
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).
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.
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).create_org(POST /orgs,require_identityso a zero-org user can make their first team) +list_orgs(GET /orgs, each org carries atool_count— one grouped query — so the dashboard can land on the org with tools); invites viacreate_invite(POST /orgs/{id}/invites, admin+) → one-time code (emailed viaemail.send_invite, best-effort, along with a separate inbox-onlyemail_tokensign-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, somy_invites(GET /invites/mine,require_identity) lists every pending invite for the caller's proven email andaccept_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, which now also sweeps each org'sRunRecordrows);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 owndaily_call_cap,tool_accessand audit trail, with no new table;list_agents(GET, never returns a token) andrevoke_agent(DELETE …/{user_id}, which also sweeps the deny rules aimed at that agent). An agent token is refused byrequire_identityand 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.AgentInalso takesproject_access(slugs or ids), so an agent can be project-scoped at mint time;created_bystamps the minting admin.GET /orgs/{id}/agents/observed(admin+) lists the agents detected in member traffic — one row per (member, runtime) fromCallRecord.client/RunRecord.clientover 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'sproject_access, leaving an emptied list as[](NULL would mean every project).POST /toolsandPATCH /tools/{id}takeproject(slug or id; null = org-wide), andset_member_access/create_invitetakeproject_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. Pluslist_deny_rules(GET) anddelete_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 (skilltreg.jsonvs 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 upCallRecord+RunRecordsince the window start into by-user (with acall/local_run/server_runsplit), by-tool, by-day, and totals — pureGROUP BY, no request/response bodies (they aren't stored).set_member_cap(PATCH /orgs/{id}/members/{user}/cap, admin+) setsMembership.daily_call_cap(-1= unlimited, rejects< -1).my_usage(GET /usage/me, any member) returns the caller's ownused_today+cap.list_membersalso returns each member'sdaily_call_cap+used_today. Enforcement:_enforce_daily_capruns at the top ofcall_tool,run_tool_server, andgrant_local_run(so no path dodges the cap);count_today= today'sCallRecord+RunRecordfor the user.-1(default) skips the count entirely (zero overhead); the sandbox is exempt. Soft by design — it counts the best-effortCallRecord, so under load it fails open, never closed. - Super-admin (cross-tenant,
require_superadmin):/admin/stats|orgs|orgs/{id}|users|tools|calls| health(reads) +/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 viabody.bindings, or_flat_binding(body)sugar, or[]; validated by_validate_bindings;hostderived by_host_of; optionalexamples; optionalclilocal-run profile validated by_validate_cli_profile),list_tools,update_tool(re-derives host on base_url change;cliset/clear here — this is how the local-run toggle flipscli.enabled),delete_tool. View via_tool_view(now includescli).delete_secretrefuses a secret referenced by a tool binding or acli.injectentry.- Owner-only binding.
_validate_bindings(HTTP bindings) and_validate_cli_secrets(local-runcli.injectentries) 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'sbase_urlor a/grant).update_toolgrandfathers the secrets already on the tool (it passes their ids as agrandfatherset) — 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(reusinghealth.safe_webhook_url) rejects abase_urlpointing at loopback / private / link-local / cloud-metadata hosts — including numeric IP encodings (decimal/hex/octal/short forms) — oncreate_tool,update_tool, and each imported skill tool, so a member can't turntreg callinto 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.
- Owner-only binding.
- 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 synchronousGRANT/DENYaudit 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 headerX-Treg-Run-Proofto equalTREG_RUN_PROOF— the value held only by the isolatedtreg-runrunner, 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 aDENYaudit 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 carriesredact_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_invalidmarks the injected secret(s) invalid via the health fields, skippingparamkind. - Server runs (
treg run --server, Tier 0):POST /runruns a runnable bundle's CLI on the server viarunner.run_bundle(secrets injected into a scrubbed child env, per-run temp$HOME, argv array — no shell), returns{stdout, stderr, exit_code, timed_out}and writes aRunRecord.GET /runs(list_runs) is now a unified execution log: it merges serverRunRecords with local-runGRANTCallRecords, each taggedwhere(server|local), ids prefixeds/l, newest first (a local success has a nullexit_code, since only failures report back). Bundle run-metadata (runtime/package/entrypoint/runnable) is set viaPATCH /bundles/{id}(CLIskill runtime). Command allow-list: the bundle's exec command (entrypoint/package/name) must be a catalog-known CLI or an admin-listed one inTREG_RUN_ALLOWED_BINS(_allowed_server_bins); namingbash/pythonto run arbitrary code as the server user is 422'd (--localis the path for anything else). Run-metadata command names are also shape- checked by_validate_run_meta(plain command name — no path separators, spaces, or shell characters). The sandbox is excluded, and/runis member+ (executing argv server-side is a register-tier capability).- Resource-limit sandbox (
runner.py, the DoS half of the server-run sandbox): every server-run child is spawned with POSIX rlimits via apreexec_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_RLIMITSon by default;TREG_RUN_CPU_SECONDS,TREG_RUN_FSIZE_MB), a no-op whereresourceis unavailable. Deliberately no address-space or process-count cap — a virtual-memory cap crashes Go CLIs (gh/stripe/doctl) andRLIMIT_NPROCis per-uid, shared with the server. Full filesystem/network isolation needs a container deploy and is a planned follow-up.
- Resource-limit sandbox (
- Meta:
meta(GET /meta, open) →{public_url, github}for the dashboard. - Provider catalog:
providers_catalog(GET /providers.json, open) →{version, providers}— the catalogtreg uploaduses to detect env keys → tools; served so the CLI can refresh centrally. See env-import. - Endpoint catalog (open,
include_in_schema=False, read viacatalog_store): the operations layer — what a connected provider can DO.catalog_platforms(GET /catalog/platforms) →{platforms: [{slug, label, capabilities, endpoints, verified, providers[]}], generated_from: "catalog"}, endpoint count desc, platforms nobody implements omitted.catalog_platform(GET /catalog/platforms/{slug}, 404 unknown) →{platform:{slug,label}, capabilities:[{id, description, endpoints[]}] (sorted by id), extended:[…], domains:[…], providers:{…}}where an endpoint is{id, provider, provider_display, summary, method, path, scope, tier, domain, call_template, cost, verified, docs_url, has_example, input}— grouping by capability is what makes two providers' take on one job comparable; capability-less endpoints fall toextended. Two axes, both served:capabilities/extendedis the shapetreg catalogrenders;domainsis the LEDGER the dashboard renders —[{domain, rows:[{kind:"merged"|"single", capability?, description, domain, endpoints[]}]}], sections orderedother-last then busiest-first, merged rows (a job ≥2 providers do) before single rows within a section, a single row described by its ENDPOINT's summary rather than the capability's. Ordering and merging happen server-side (catalog_store.domain_rows) so every client shows the same page. Every endpoint carries thedomainthat files it (catalog_store._domain: explicitdomain:→ the capability id's middle segment → a path keyword → the path's grouping segment →other) and a paste-readycall_template.providersmaps service →{service, display_name, limits?, pricing_url?, docs?}, once per provider, for an expanded row.catalog_example(GET /catalog/examples/{endpoint_id}) streams the captured response JSON — the id is resolved through the loaded catalog before any path is built, so caller input never reaches the filesystem (404 otherwise). Consumed by the dashboard andtreg catalog; see catalog. - Catalog discover → inspect (open, same section): the two routes that complete the loop whose third
step is
treg call.catalog_search(GET /catalog/search?q=&limit=, default 25, capped 100) →{query, count, total, results[], hints[]}; a result is the endpoint view plus{capability, capability_description, platform, platform_label, score}. Ranking is plain token containment (catalog_store.search, no deps, no embeddings): every query token must match somewhere (AND, so a second word narrows), and score sums each token's best field weight — capability id/description + platform label/slug (3) > summary (2) > id/path/provider (1). Ties break core-before-extended, then verified-before-not, then id, so the order is total and reproducible.catalog_endpoint(GET /catalog/endpoints/{endpoint_id}, 404 unknown) answers everything in ONE round-trip:{endpoint, provider:{service, display_name, limits?, pricing_url?, docs?}, siblings[], call_template, example_response, hints[]}—siblingsare the other providers implementing the same capability (so a price/verification comparison needs no second call),example_responseis inlined rather than left behind/catalog/examples, andcall_templateis a paste-readytreg call …line built from the endpoint'stest_request(the request the verifier actually ran) falling back to documented examples.hintson both routes carries the next command, since finding an endpoint is never the goal. - Auth — three identity doors (all resolve to a user via the shared
_find_or_create_user, so first-proof = registration — the user only, no auto personal org; a brand-new user lands with zero teams and names their first via the mandatory welcome /treg org create): GitHub —auth_github(GET /auth/github,?cli=<id>for the CLI handshake),auth_github_callback(browser → signed cookie, CLI → stashes an identity token),auth_cli_poll(GET /auth/cli/poll?login_id=<id>→ the CLI collects its identity token once; carries no code — nothing to brute-force). The handshake starts server-side —auth_cli_start(POST /auth/cli/start, unauthenticated) mints BOTH thelogin_idand a short pairing code (_PAIR_ALPHABET, 4 chars) held in_cli_pending;treg loginshows the code only in the terminal (never in the URL). The universal sign-in page —login_page(GET /login?cli=<id>, the pagetreg loginopens; nocli→ redirect to/; the id is whitelist-validated by_LOGIN_ID_RE, which is also the XSS guard since it's echoed into the page's JS): with a live session it shows a team picker (the JSloadOrgsfetchesauth_cli_orgs—GET /auth/cli/orgs, session-authed,_orgs_briefreturns the user's teams sorted team-first, personal-last, most-tools-first; one team → a single "Continue as" button, many → a labelled list; zero teams → an inline "name your team" input (createTeam→POST /orgs→ approve with the new slug) so a brand-new CLI login never completes team-less), else every configured door (GitHub/Google buttons link to the?cli=flows; the email form drivesauth_email_start/verifythen loads the picker — always present, so login works with no OAuth app configured).auth_cli_approve(POST /auth/cli/approve, session-cookie-authed) completes the handshake by stashing the identity token under the givenlogin_id, plus theorgthe user picked (validated to be one of their memberships) asactive_orgin the poll result — so the CLI lands on the RIGHT team instead of guessing. It requires the pairing code to be typed into the page (#paircode) and validates it against_cli_pendingserver-side (_norm_pair_code, case-insensitive;CLI_APPROVE_MAX_TRIESwrong tries then the pending login is discarded) — so a mistyped code fails immediately in the browser, and a phished/login?cli=<attacker-id>link (whose code the victim doesn't have, or that was neverstarted) can never complete. Deliberately a POST guarded by_same_origin(Origin must be the configuredpublic_urlor the request's own host — public_url alone broke localhost). The GitHub/Google callbacks share_finish_oauth_login, which sets the session cookie then bounces a CLI handshake back to/login?cli=<id>so all four doors go through the same picker.auth_logoutuses the same_same_originguard. Google —auth_google/auth_google_callback(GET /auth/google[/callback]): the same session + CLI-handshake plumbing as GitHub (token fromgoogle_token_url, email fromgoogle_userinfo_url), gated ongoogle_client_idand surfaced via/meta'sgoogleflag. The callback now requiresemail_verifiedon the Google profile (like the GitHub door) — identity is keyed by email, so an unverified Google address equal to a victim's registered email would otherwise resolve to the victim (account takeover). Email one-time code —auth_email_start(POST /auth/email/start, mints a 6-digit code stored in the DB —ratestoreover theEphemeraltable, namespaceotp; thedev_codeis put in the response + logged only whenget_settings().expose_dev_code— true on a local sqlite box, never on a real Postgres deploy — otherwise the code is emailed via Resend —email.send_otp, best-effort) andauth_email_verify(POST /auth/email/verify→ mints an identity token and sets the session cookie, so the CLI and dashboard share one endpoint). A wrong code burns one ofMAX_OTP_ATTEMPTSbefore the code dies (brute-force cap)./startis rate-limited per-email AND per-IP (ratestore.rate_checksliding window in namespaceotp_start,OTP_START_MAX_PER_EMAIL/_PER_IP) so it can't email-bomb an inbox or reset the attempt counter at will. All this — the code, its attempt counter, and the throttle windows — is DB-backed (backlog #3), so a restart can't reset the caps and they stay correct across instances (rows are swept byexpires_at+ratestore.sweep; the landing/demo/sandboxthrottle shares the same table, namespacesandbox_hit). The one remaining in-process piece is the short-lived CLI-login handshake (_cli_pending, self-heals on retry). A suspended account is refused at every door. Invite sign-in link — an invite carries TWO split secrets (models.Invite): the admin-visiblecode(returned fromcreate_invitefor out-of-band relay — join-only, NEVER an auth factor, since the admin provably holds it) and an inbox-onlyemail_token(stored asemail_token_hash, embedded ONLY in the email's link — possession proves inbox access, the same bar as the emailed OTP).auth_invite_signin(GET /auth/invite-signin?t=<email_token>, the email button): the GET renders a confirm page only (mail scanners prefetch GETs; a one-time credential must survive that) — the page's button POSTs the token back, andauth_invite_signin_confirm(POST /auth/invite-signin, urlencoded form parsed by hand to avoid the python-multipart dep) re-validates,_find_or_create_users, refuses the suspended, consumes the token (email_token_hash=None, one-time) and mints the session cookie → 303/?invite_org=<org_id>(the dashboard opens its multi-select accept modal on that org). The invite itself stayspending— acceptance happens in the app so a multi-team invitee can accept several at once. The legacy?code=path stays: it never mints a session — validate and 303 to/?invite=<email>(a prefilled normal login; the invitee proves the email at a real door and the invite auto-appears via/invites/mine, now ordered newest-first +created_at). Invalid/expired either way →/?invite_expired=1.auth_me(GET /auth/me) answers for a token (X-Treg-Token) as well as a session cookie, so the dashboard's token door can learn its own email.auth_cli_token(GET /auth/cli-token,require_identity) mints a fresh identity token for the caller (session OR token); the dashboard embeds it in copy-paste snippets + a "copy token" button (pair withX-Treg-Orgto pick the org). Signed session cookies + identity tokens carry atv(token_version) claim bound to the user row (sess.make/read_claims, checked in_user_from_session/_user_from_identity_token);auth_revoke_tokens(POST /auth/revoke-tokens,require_identity) bumpsUser.token_version, invalidating every token that user holds at once — the kill switch for a leaked token that (unlike suspension) keeps the account and (unlike rotatingTREG_SESSION_SECRET) affects only that user; it re-issues a fresh cookie + token so the caller stays signed in. A token with notv(minted before this shipped) reads astv=0, so a plain deploy revokes nobody. Plusauth_me(returnsonboarded),auth_logout, and onboarding —POST /onboard/demo|skip|reset(require_identity) seed/dismiss/remove a first-run demo team (see onboarding). Triple resolution:require_identity/require_member/require_superadminaccept a per-org token, a signed identity token (bearer, fromtreg login) - Static (dashboard + tutorials):
dashboard(GET /,FileResponse+Cache-Control: no-cache),tutorial_js(GET /tutorial.js— sharedwindow.TREG_TUTORIAL+hl()),tutorial_page(GET /tutorial— standalone CLI tutorial). The dashboard tour is aStaticFiles(html=True)mount at/dashboard-tour/(servesweb/tour/—tour.js, the standaloneindex.html, and the WebPimg/).favicon(GET /favicon.svg+/favicon.ico).llms_txt(GET /llms.txt) servesweb/llms.txtastext/plainwith{BASE}templated frompublic_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).terms_page(GET /terms) +privacy_page(GET /privacy) serve the hosted registry's legal pages (_legal_page, no-cache) withlegal_css(GET /legal.css) as the shared skin —/privacyis also the URL given to OAuth providers at app-verification time, so don't rename it. Provider brand marks are mounted at/logos(StaticFilesoverweb/logos/, resolved by conventionlogos/<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_mdbacksquickstart_md(GET /quickstart.md) +tutorial_md(GET /tutorial.md) —{BASE}-templated markdown served as inlinetext/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 isdocs/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 carrieslive= 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_toolshort-circuits sandbox orgs tosandbox.synthesize(real injection, no network). Caps via_enforce_sandbox_cap. Full behavior: landing-sandbox.- The one live wire (real Stripe demo). When
demo_stripe_keyis set, a sandbox call to the exact seededstripetool (fingerprint-matched bydemo_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, andmetadata[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) reportslive+ the visitor's feed name for an existing sandbox._require_not_live_demo_tool/_require_not_live_demo_secretfreeze the seededstripetool and itsSTRIPE_KEYagainst edit/delete so a visitor can't break their own live pane. The public payments feed:stripe_webhook(POST /stripe/webhook, 404 whendemo_stripe_webhook_secretunset, verifies the signature viapubfeed.verify_signature, pushes acharge.succeededintopubfeed.push_charge) andlanding_stripe_feed(GET /landing/stripe-feed, unauthenticated SSE viapubfeed.stream, server-chosen fields only).
- The one live wire (real Stripe demo). When
- Public demo token (publishable, call-only credential):
create_public_token(POST /orgs/{id}/public-token, owner-only) flips the org topublic_demoand 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: whenorg.public_demoand the role is below admin,require_memberallows only/call/*+ GET/HEAD/OPTIONS (every mutation is frozen no matter what routes are added later), andrequire_identityrefuses the token entirely (it must never act as a user — mint identity tokens, create orgs, accept invites). Its/calltraffic 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. - Skills / bundles:
register_skill(POST /skills) composes aBundle+ its secrets + tools atomically, resolving each binding'ssecretlocal-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 outsidecli.injectentry, matchingdelete_secret, so a local-run tool can't be left with a dangling secret_id), andupdate_bundle(PATCH /bundles/{id}, creator/admin only) edits a recipe's SKILL.md text and the run-metadata (runtime/package/entrypoint/runnable)._bundle_view. Folder importer (dashboard mirror oftreg upload skills):analyze_skill_folder(POST /skills/analyze) writes uploaded files to a temp dir and runs the CLI's ownskills.scan_skills/_classifyto classify each (recipe-only / contract / generated) without registering;import_skill_folder(POST /skills/import) scans +build_payloads + registers the selected ones (_materialize_skill_filessandboxes the upload).list_orgsnow carriestool_count. - Audit:
list_calls(GET /calls, limit clamped 1–500; each row carries itskind—call/local_run— for the Activity + Usage views, andrefused_by— non-null = treg refused pre-relay; see the data-model fragment — sotreg auditcan tell "the provider failed" from "we said no"). - OAuth connect + the provider marketplace:
oauth_start(POST /oauth/start) creates aPendingOAuthand returnsconsent_url+state+redirect_uri;oauth_callback(GET /oauth/callback, open) exchanges the code and creates/updates the oauth secret;oauth_statuspolls. Two modes (OAuthStartIn): BYO (supplyclient_id/client_secret/auth_uri/token_uri/scopes) or REGISTRY (supplyprovider+ optionalcapability) 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 flaggedconfigured(false when this deployment hasn't set that provider's client credentials). In registry modeoauth_startreads the provider fromoauth_providers.get, resolves scopes viascopes_for(capability), and stashes every per-provider auth quirk on thePendingOAuth(PKCEcode_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 asreplaces_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), normalizesgranted_scopesto space-joined, setsexpires_at(oauth.expiry_of), then_autoprovision_provider_toolbinds the fresh credential to the provider's API as a callable tool (idempotent by (org, name); a token-kind provider gets anenvheader binding, an oauth one gets aBearer {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_identitybest-effort asks the provider who connected. See auth-secrets. The tool'sexamplescome 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 atCATALOG_STAMP_CAP(12). Unverified endpoints are never stamped — an example is a promise the call works, and theverifieddate is the only evidence of that. - 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 iskind=="oauth" OR provider!=""so a bring-your-own-token provider (a plainenvstring, 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;set_connection_resource(POST …/resource) pins the chosenresource_refresource_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-hostprobe_url, tolerating a CSV/text body or a 200-with-false-token_verify_fieldreply), then auto-provisioning its tool with a header or query binding. 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 (rare) and finishes the tool with BOTH bindings.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. Allrequire_can_register(member+). Helpers:_owned_connection,_dig(dotted-path walk).
- Health:
run_health(POST /health/run) →health.run_all;get_health(GET /health) now returnshealth._view(s)plus aneeds_reconnectflag (health.needs_reconnect) so a credential treg can't renew announces itself before it dies. - The proxy:
call_tool(* /call/{rest:path}) →_resolve_call→_enforce_daily_cap(the per-user daily cap; 429 when over) → (public-demo token →_enforce_public_demo_ip_cap) → load secrets (+ensure_fresh) →relay()→audit.record_call. A platform binding carries nosecret_id(its value comes from settings at relay time), so secret-loading now skipssecret_id is None. Detail in proxy-model.
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 (bug-hunt)
- Legacy-host redirect:
_legacy_host_redirect301s GET/HEAD marketing pages (_REDIRECT_PATHS) fromtreg.superdesign.dev(_LEGACY_HOSTS) to the canonicalpublic_urlhost (treg.to). Everything else is served in place on BOTH hosts, forever: installed CLIs/skills hold tokens pointed at the legacy host, HTTP clients stripAuthorizationon a cross-host redirect, andcurl {BASE}/install.sh | shruns without-L. Never remove the legacy domain from Render. - Security headers: a
@app.middlewareaddsX-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, and HSTS to every response (setdefault, so the/callproxy's stricter CSP/nosniff wins). - No 500 on bad ids/URLs: an
OverflowErrorhandler turns an oversized all-digit id into a404(SQLite's 64-bit INTEGER);_host_of/_resolve_callguardurlsplitValueError(malformedbase_url/passthrough) into a422/400. - CSRF/redirect:
auth_logoutrejects a cross-Originrequest (forced-logout CSRF);oauth_startpinsredirect_urito 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..turnsDELETE /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/mereturnsorg_id/org/rolewhen the caller authenticated with a token.require_identityrefuses machine identities on/orgsby design (create_orghangs 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 dedicatedexpose_dev_code— true only on a local sqlite box, never on a real (Postgres) deploy — so a misconfiguredemail_dev_modecan'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.
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 lookup paths)
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
POST /mcp/ the MCP transport itself
GET /connect-demo a page that pretends to be an MCP client
GET /connect-demo/callback its OAuth callback
/call/ gained one thing for this: a metered response now carries X-Treg-Cost-Micro, so a caller
can report what it spent instead of diffing the balance. Absent on an unmetered call — a team's own
key is not ours to bill, and 0 would read as "free".
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.