Changelog
Release notes fromCHANGELOG.mdwith the newest changes first.
Unreleased
-
Fixed: Postgres bootstrap checks the legacy OAuth index in its active schema, so a same-named index in another schema cannot make startup fail.
-
Tests: Postgres identity, OIDC, and store-parity fixtures now use isolated schemas. Parallel runs no longer replace or delete another test’s identity key during enrollment or KEK rotation.
-
Site: redesigned the homepage around a working approval example, clearer setup paths, mobile navigation, and the shared light/dark palette. Refined the ticket-bot SVGs and rebuilt favicons, app icons, and social cards; the social generator now imports brand tokens and preserves SVG masks.
-
Fixed: credential drawers match approvals by item ID, keeping same-name staging and production credentials separate. Failed loads offer Retry, late responses cannot overwrite a different drawer, and a pending credential lookup cannot interrupt navigation.
-
Console: first-use guidance, a clear-filters action, and persistent error notices. Site copy controls prevent overlapping requests and provide manual-copy guidance when clipboard access is blocked. Browser CI now covers the marketing site and docs search as well as the console.
-
Fixed: Always-allow / Connect Also allow this agent for a user-OAuth account stands the source credential the agent names (
SPOTIFY_SECRETonapi.spotify.com), not only the hidden token mint (POST accounts.spotify.com/api/tokenonSPOTIFY_REFRESH). A user-pathhttp_requestreads<ITEM>_REFRESHfor the send the way it already reads a sibling client secret; it does not create an Inbox grant whoserequested_scopeis the token endpoint (BOTP-14). A directPOSTof the refresh item to the token host still needs its own approval. Same for GitHub, Google, Slack, and Stripe Connect. -
Site: remaining docs name the laptop hosted kernel (
npm run hosted:dev) and portable self-host. Homepage, FAQ, troubleshooting, Guided setup, Start, Install, Self-hosting, Copy-paste prompts, and the connect pages no longer describe Fly plus Neon as the only run path, or:8888as the hosted connect URI. -
Fixed: unauthenticated
POST /mcpinitialize,ping, andtools/listare 401 with the sameWWW-Authenticateresource_metadataandscope="mcp"asGET /mcpandtools/call. Grok Bot’s AuthenticateMcpServer builds the Authorize browser URL from that first JSON-RPC 401; a 200 handshake leftneedsAuthwithno_auth_link(Authorize spun, then Retry, no browser) after PR #15 put the challenge on GET only (BOTP-13). A preconfiguredavm_Bearer still skips the card. Cursor desktop OAuth and DCR (includinggrokbot://andhttps://www.cursor.com/agents/mcp/oauth/callback) are unchanged. -
Added:
npm run hosted:devboots the hosted kernel on loopback with sqlite-hosted (or--postgres/VAULT_HOSTED_DEV_DATABASE_URL). Secrets land in gitignored.botpasses-hosted/secrets.json(mode0600). OTP codes print in the terminal, not in structured logs. Planedevbinds127.0.0.1whenVAULT_BIND_HOSTis unset and is refused whenFLY_APP_NAMEis set. Run one process per sqlite file; a second writer can get SQLITE_BUSY. Self-host accepts any Postgres 16 URL and a customhttpsorigin; first-party staging/production still pin botpasses.com. Hosted connect on loopback is{origin}/connect/callback; the CLI vault callback stayshttp://127.0.0.1:8888/callback. -
Fixed: link previews and other cross-origin fetches can load the public site icons and social cards. A foreign
Originon GET/HEAD/OPTIONS of marketing and discovery URLs (/og.png,/logo.png,/favicon.ico, and the rest of the public site) is 200 and reflectsAccess-Control-Allow-Origin./api,/console,/collect, and auth pages still 403 that Origin with no allow header. The Host allowlist is unchanged. -
Fixed: docs code blocks use the brand tokens (navy, muted, teal, and the other tested pair colours). A GitHub Shiki theme used to paint its own hex on the light elevated background, so the self-hosting copy-paste prompt was unreadable. Light and dark pages now follow the same contrast contract as the rest of the site.
-
Site: copy-paste bootstrap prompts on the homepage and
/docs/promptsso an agent can set up hosted MCP, a local laptop vault, or a self-hosted plane. Start, Guided setup, Install, Self-hosting, and the connect pages link them. You type secrets on Botpasses, never in chat. -
Fixed: MCP OAuth dynamic client registration accepts RFC 8252 loopback
http://localhost:<port>/…as well as127.0.0.1and[::1]. Cursor (and other hosts) registerhttp://localhost:8787/callbackin the same DCR set ascursor://…andhttps://www.cursor.com/agents/mcp/oauth/callback; rejecting localhost used to fail the whole registration withredirect_uri must be https, loopback http…. Theavm_Bearer path is unchanged.javascript:,data:,file:, andhttpto a non-loopback host are still refused. -
Fixed: an unauthenticated
GET /mcp(SSE listen) is 401 withWWW-Authenticateresource_metadata, same asPOST /mcptools/call. Streamable HTTP hosts open that stream afterinitialize; without the challenge they showed Authorize → Retry and never opened the browser (BOTP-13). DCR accepts thegrokbot:desktop scheme and logsdcr_redirect_rejectedwith scheme and reason only. Grok Bot still needed a 401 oninitializeitself (see the handshake fix above). -
Changed: user connect uses one redirect URI for every provider:
https://botpasses.com/connect/callbackon production,https://staging.botpasses.com/connect/callbackon staging, and{origin}/connect/callbackon the laptop hosted kernel. The CLI vault callback stayshttp://127.0.0.1:8888/callback. The Store credential and Collect forms show and copy that URI next to Kind when Kind is Client ID and secret; the Connect dialog shows it again before opening the vendor./integrations/:provider/callbackstays as an alias for apps that already registered it. -
Security: operator inboxes (users, invites, OTP lookup) and live OAuth adapter bearers (refresh, auth code, device code, access token, session, interaction) are stored as a keyed HMAC plus an AES-GCM wrap under the identity DEK. Grant ids stay plaintext so revoke still joins. Magic-link tokens are stored as SHA-256, not the URL token. A Neon dump without the platform KEK no longer yields inboxes or those tokens. Item names, last-4, hosts, and Grant ids remain. Rollback runs
scripts/email-restore-plaintext.tsandscripts/oidc-restore-plaintext.tsbefore reverting the image. -
Site:
/securitynames bound item AAD (orgId|itemId|allowed_hosts_json|inject), email-at-rest, and OAuth-token-at-rest. Last-4 is not stripped from connector bodies. The raw origin cap is 1 MiB, then redact, then 256 KiB. A dump without the KEK hides values, inboxes, and bearer tokens, not names. Bootstrap MFA is skipped only withVAULT_BOOTSTRAP_ALLOW_PLANE=1. -
Infra: CI smoke installs Playwright Chromium from Playwright’s CDN, not Google’s Chrome apt repo, so a Packages.gz hash mismatch cannot fail the job.
-
Site and console: official brand. Navy
#14213D, teal#00C2A8, Inter wordmarkbotpasses(no.com). Navy ticket at/mark.svg; white ticket at/mark-on-dark.svg(dark surfaces and social cards). Favicon is the simplified eyes, perforation, and barcode on a navy tile. The localvault serveconsole uses the same tokens. Social cards and home-screen icons rebuild from the official rasters (node site/scripts/og.mjs,node site/scripts/write-icons.mjs). -
Site: the homepage, social cards, and webmanifest drop the dangling “It never gets the key.” The second sentence is now the same claim as the FAQ: the model does not get the key.
-
Site: social cards and icons.
/og.pngis a 1200x630 card that names Stripe, Slack, and GitHub./og-square.pngcovers LinkedIn, Slack, and iMessage square slots./logo.pngis the JSON-LD Organization logo (no longer the landscape card). Android Chrome 192/512 icons and/site.webmanifestship next to the existing favicon and Apple touch icon. Rebuild withnode site/scripts/og.mjsandnode site/scripts/write-icons.mjs. -
Repository: tracked files must not contain a local home path, a Linear workspace URL, or a Neon project-slug shape. Operator identifiers stay in gitignored
.env.ops. The check scans for those shapes so the real values are not stored in the test. -
Site: FAQ, homepage FAQ, and the grant-vault definition now say the model does not get the key.
-
Docs: root
SECURITY.mdtells researchers to email[email protected](same process as the site disclosure page). Ops and plan docs no longer name a Neon project slug, a Linear workspace URL, or a local home path. Those identifiers live in gitignored.env.ops(see.env.ops.example). User-facing copy now says the model does not get the key. -
Site: the homepage hero rotates the named API (Stripe, Slack, GitHub, and dozens more) so the promise is not Stripe-only. The highlight sizes to the current name so the period stays next to the word. The first paint and no-JS heading stay “Stripe”. Motion stops when the system asks for reduced motion, the tab is hidden, or the word is hovered.
-
Changed: the Agents list groups standing approvals by credential. One Clear control ends the pair (every grant for that agent and credential), instead of repeating the same row for each grant. Hosts and last used stay on the grouped row.
-
Site: SEO and AIO/GEO. Every public page ships Organization and WebSite JSON-LD,
llms.txt/llms-full.txtalternate links, andindex, follow. The homepage defines Botpasses in the first sentence and adds FAQPage, HowTo, SoftwareApplication, and a grant-vault DefinedTerm. Docs pages add TechArticle (FAQ page also FAQPage).llms.txtis generated from the docs collection;llms-full.txtis every docs page in one file. Productionrobots.txtpoints AI crawlers at both and listsSitemap: https://botpasses.com/sitemap.xml. Conventional discovery URLs that crawlers and browsers request are real files, not JSON 404s:/sitemap.xml(the public urlset),/favicon.ico,/apple-touch-icon.png, and/security.txt(same bytes as/.well-known/security.txt). Entries carrylastmod. -
Fixed: API hostnames must be DNS names (
api.example.com). A host with spaces, shell characters, a path, or a port is refused atassertAllowedHostname, so localsetup,find_items, andhttp_requestcannot putvault set --host api.example.com;…in the result. -
Docs: homepage, OG card, console, and docs copy now lead with a person and a concrete noun. Same promise: your agent can call an API and does not get the key.
-
Added: MCP
setupwalks Collect, optional user Connect, and Always-allow for Spotify, Stripe (secret key), GitHub, Google, and Slack. A miss on a recipe host now prefills Collect (SPOTIFY_SECRET, Client ID + secret, both Spotify hosts). Collect can write a standing grant when Always allow this agent to use this credential is checked (default off). After you connect MCP, ask “Set up Spotify so you can call the API for me.” -
Fixed:
http_requesttoapi.spotify.com/v1/playlists/{id}/tracks(GET,POST,PUT,DELETE) is sent as/v1/playlists/{id}/items(Spotify February 2026 playlist content rename), so agents on the legacy path no longer get an opaque origin 403. ADELETEJSON body that still usestracksis sent withitems. Other/trackspaths are unchanged. The result setspath_rewritten,requested_path, andrewritten_path;next.for_modelnames the rewrite. Grant scope still uses the path the agent sent. -
Fixed:
request_grantno longer returns a standing grant asactivewhen the stated host, method, or path is outsidegrant_scope. A token-scopeditem_standing(for examplePOST accounts.spotify.com /api/token) does not satisfy a playlist call; the tool creates or reuses a pending grant for the requested scope so the operator can approve it.http_requeststill refuses the out-of-scope call withscope_denied. Grant lookup before consume picks an active grant that covers the call when more than one is open. -
Docs: the OAuth client-secret how-to and Guided setup name the shared redirect URI (
https://botpasses.com/connect/callbackon production) instead of a per-provider/integrations/<provider>/callback. -
Added: Inbox Always approve for this agent (opt-in
item_standingfor one credential and one agent, on hosts already on the item). Default Approve stays one call. A matching laterhttp_requestproceeds without a new Inbox card and writes auditauto_approved(item name, client, host; never values). Clear the standing approval from the agent row, Approvals, or the credential drawer. Approve with limits can check the same box. -
Fixed: console. Closing the credential drawer or the connect dialog returns to the list only while the route still names that item, so a close event delivered after a newer route (another item, a deep link) no longer sends the page back to the list.
-
Changed: an agent that calls a provider path which answers only for a signed-in user (Spotify
GET /v1/me) with the app client secret and no<ITEM>_REFRESHis refused before dialing. The result is{ status: "user_connect_required", provider, item_name, refresh_item_name, connect_url, need_id, hint }withnextsteering; nothing is sent, so a one-call approval comes back instead of being spent on a 401. The request lands in the Inbox as ”needs a account for - ” (
need_items.kind = connect, migration 013; repeated calls reuse the row; auditconnect_requested). Its Connect button, and theconnect_urldeep link (#credentials/item/<id>?connect=&agent=&need=), open the connect dialog with the Client ID prefilled and “Also allowto use the connected account” checked, so the callback writes the agent’s standing policy on the refresh item, closes the card ( need_fulfilled), and the agent’s retry succeeds through the user-token path without a second approval. Deny on the card marks the need denied (POST /api/need-items/:id/deny, auditneed_denied); the collect page refuses a connect need.dry_runreportsreason: "user_connect_required". The local plane returns the same status withoutconnect_urland a hint namingvault set <ITEM>_REFRESH --inject refresh. - ” (
-
Infra: the GitHub default branch is
dev, sodeploy-stagingcan run afterciondev. Provider-test fixtures no longer use Slack-shaped token strings that gitleaks treated as leaks. -
Fixed: the hosted Postgres pool no longer sends
statement_timeoutoroptionsas startup parameters. Neon-pooler(PgBouncer) refuses those, so the process never listened and Fly’s/readycheck failed. Client-side connect/idle timeouts andapplication_namestay. Fly HTTP check grace is 60 s. -
Infra:
deploy-stagingstarts a Machine flyctl left stopped and waits untilhttps://botpasses-staging.fly.dev/readyis 200, so a rolling-update leftover cannot go green while the origin is down. -
Fixed: a leftover
VAULT_BOOTSTRAP_TOKENon staging withoutVAULT_BOOTSTRAP_ALLOW_PLANE=1no longer exits 78. Auth still ignores the token; boot logsbootstrap_token_ignored. The previous refuse-to-boot guard crash-looped the Fly Machine. -
Security: the browser-side CSRF cookie readers (console and collect bundles, the auth pages, the invite page) read
__Host-bp_csrffirst and fall back to the plainbp_csrfonly when the host-prefixed cookie is absent. A plain cookie a subdomain plants can no longer shadow the real token and break every mutation on the HTTPS origin. -
Security: the hosted entry point (
npm run hosted,startHosted) refuses to start withoutVAULT_MODE=hosted(exit 78 with a message naming it). Every hosted boot guard is keyed on that mode, so an unset mode used to run the process with no plane check, an empty session secret, test auth accepted, and a bootstrap token needing no flag.VAULT_OIDC_PREVIOUS_JWKwith the wrong shape is now the same exit 78 at boot instead of exit 1 after the KEK was unwrapped and Postgres opened. -
Security: proxy headers are trusted per header.
Fly-Client-IPcounts only on Fly (FLY_APP_NAME); behind a proxy the deployer runs (VAULT_TRUST_PROXY=1) only the lastX-Forwarded-Forhop counts, so a client can no longer choose its own address for every rate limiter, or claim a Cloudflare address to unlockCF-Connecting-IP, by sending a Fly header. The request id follows the same rule:Fly-Request-Idis reused on Fly, a client-chosenx-request-idis never reused, and a UUID is minted everywhere else. -
Security:
POST /mcponvault serveis one session per client.initializeanswers with anMcp-Session-Idheader (400 without it on later frames, 404 when unknown or idle for 8 hours), so a second client’sinitializeno longer renames the agent that another client’s grants are keyed on.vault mcp --remotecarries the header. Stdiovault mcpis unchanged: one session per process. -
Fixed: local
POST /api/itemsand/api/secretstakeusername(HTTP Basic user, OAuth client id, or AWS access key id;nullclears it), and the local console’s store form has a username field, which HTTP Basic requires. -
Docs: the self-hosting page shows hosted mode started with
VAULT_BIND_HOSTandPORT(the--hostand--portflags are local only), listsVAULT_BIND_HOST,PORT, andVAULT_SITE_ROOT, and no longer listsVAULT_TRUST_PROXYtwice;.env.examplelistsVAULT_KEK_NEW. -
Fixed: an allowlisted API answering 204, 205, or 304 (a
DELETEthat returns no content) or a status outside 200 to 599 through the pinned TLS path threw inside the HTTP parser callback, which ended the hosted process and never settled the call. The connector no longer builds a WHATWGResponsethere: a bodiless answer comes back with its status and headers and an emptybody; a status no client may carry is a 502bad_status. Both count as an origin answer, so a one-call approval stays spent. -
Security: the
body_too_largeresult (an origin body over 1 MiB) returned its origin headers unredacted; aLinkheader echoing aquery:<param>key reached the model. Its headers are now redacted like every other answer’s.http_requestrefuses double-encoded separators and dots (%252F,%252E, and deeper), a percent-encoded NUL (%00,%2500), and control characters inpath, which an origin could decode again. -
Fixed: a refresh-token exchange for a stored
<ITEM>_REFRESHsent onlyrefresh_tokenandclient_id, which every provider except a PKCE public client refuses withinvalid_client. The exchange now authenticates the app the way the connect exchange did: the client secret item goes out as HTTP Basic (Spotify, Slack) or asclient_idandclient_secretform fields (GitHub, Google, Stripe Connect), the refresh token rides in the form, both items must allow the token host, and neither value reaches the result or the audit. The same holds when an agent posts<ITEM>_REFRESHto the token endpoint itself throughhttp_request: it needs only its approval on the refresh item, Botpasses reads the sibling client secret (<ITEM>_SECRET, then<ITEM>, same environment), builds the exchange per the provider’s token auth, returns the redacted body withrefreshed: trueandtoken_last4, caches the user token, persists a rotated refresh token (refresh_rotated), and auditsinjectfor both items. Without a sibling, a PKCE provider gets the public-client exchange; any other provider is refused before dialing withinject_deniedandmissing_iteminstead of a vendorinvalid_client. -
Fixed: a token endpoint that answered 2xx with an unusable body (not JSON, no
access_token) was auditedinject_failedalthough the credential had been sent; the row isinject.inject_failednow means only that the origin was unreachable before the TLS handshake. -
Fixed:
http_requestwithdry_runreported an expired or spent standing policy asstanding; it now applies the same liveness test a real call does and reads the stale row past without deleting it. -
Fixed: the console’s plain Approve on a trusted (
avt_) client’s grant was refused withscope_unenforceablewhenever the agent had stated a call, because the request’s method, host, and path were inherited. A plain approve for a trusted client is written unrestricted; an explicitmethods,path_prefixes, orhostsis still refused. -
Changed: the
next.for_modelsteering for abody_too_largeanswer tells the model the response was over 1 MiB and to narrow the call (pagination, a smaller page, a fields filter) instead of “retry once”, which spent another one-call approval on the same oversized answer. -
Fixed:
token_last4for a minted token under four characters showed the whole token; it reads****like every otherlast4. -
Repository:
sha256Hexandlast4in the provider engines are theids.tsones; the dead PK-conflict catch in personal-org provisioning (ids are random now) is gone;docs/ops/migrations.mdnotes the010to012numbering gap. -
Repository: the local plane’s grant scope type is
LocalGrantScope(onceorsession); it no longer shares the nameGrantScopewith the hosted scope object. -
Docs: the threat model covers trust decided at boot, proxy trust, single-use sign-in state, the canonical request path, and prompt-grant semantics; ADR 0008 records the hardening decisions and their consequences. The security page links it and names ADR 0006 correctly.
-
Repository:
npm run botpassesis gone; it ran the same command asnpm run vault(thebotpassesbin name stays fornpx). OnenowIso(ids.ts; the local vault imports it there), onesha256Hex, onetokensEqual, onesendHtml, and oneVAULT_DEPLOY_PLANEparser (deployPlaneRaw, used by the KMS provider, the bootstrap-token check, and thekek-wrapandkek-rotatecommands too) replace their copies; the unusedOperatorIdentity.clientIp, theclientIpauth-route option,FindItemsStatus, andloadout.lock.jsonare removed..env.examplelistsFLY_ALLOC_IDand no longer repeatsVAULT_OIDC_PREVIOUS_JWKandVAULT_TRUST_PROXY. -
Fixed:
vault initon a fresh machine createdmaster.keybefore its directory existed and failed with ENOENT; the home directory is created first (mode 0700). -
Fixed: the stdio MCP server survives a malformed frame. A line that is not a JSON-RPC request object (
null, a number, a string, an array) is answered with-32600andid: null, a frame without amethodis-32600rather than-32601, and an unexpected error while handling one frame is answered with-32603instead of ending the process with exit 1. -
Fixed:
vault mcp --user-jwt=TOKEN(the equals form) was not recognised and silently served the local vault.--user-jwtin either form selects hosted mode; the token is the flag’s value orVAULT_USER_JWT, and a missing token is an error.--remotewith--user-jwt, an unknown option, or a stray argument is refused instead of serving. -
Fixed: Google and Slack user connects sent no scopes and both vendors refused the authorize request. Google now asks for
openid emailplus the read-only Gmail and Sheets scopes and the per-file Drive scope, withaccess_type=offlineandprompt=consentso a refresh token is issued on every connect; Slack asks for a user token withuser_scope(comma-joinedusers:read,channels:read,chat:write) and the exchange reads the user token fromauthed_user. A provider whose endpoint requires scopes refuses to build an authorize URL without any (400scopes_required). With the refresh fix above, a Google or Slack connect now works end to end: the stored<ITEM>_REFRESHis exchanged with the app’s client secret on later calls, on a user path and at the token endpoint directly. -
Fixed: both SQLite stores set
busy_timeoutto 5 s, so a second writer (vault servenext tovault set) waits for the lock instead of failing at once with “database is locked”. -
Fixed: console. A
#credentials/item/<id>deep link opens the drawer after the list has loaded (an unknown id says so and returns to the list); Enter or Space on a focused credential row opens the drawer and it stays open; each panel loads once per navigation and once at boot (hashchange only, popstate fired a duplicate); the backup-code Copy and Download buttons work on every click and a regenerate no longer stacks stale handlers; the confirm dialog focuses Cancel, not the destructive button; a failed deny keeps the confirm open with the server’s message; a load error on Credentials hides the “no credentials match” line; the activity page survives an inbox refresh with the same filter; 59.6 minutes reads “1 hour ago”, not “60 mins ago”. -
Fixed:
vault grantrefuses--oncewith--sessionand--ttlwithout--session(both were silently ignored);vault <command> --helpprints usage for every command instead of “Unknown option”, andvault mcp --helpno longer starts serving;vault serve --portmust be a whole number from 1 to 65535. -
Fixed:
scripts/hosted-backup.tsprints one line to stderr and exits 1 on a usage error or a badBACKUP_KEYinstead of a stack trace. -
Fixed: the migrator identifies an applied migration by its three-digit number. A migration file renamed after it ran is refused with both names instead of being applied again.
-
Docs: the site install and connect pages, the CLI reference, both HTTP references, and the README describe the local server as it is (five tools including
http_request;GET/POST /api/itemsnext to/api/secrets;--ttlbelongs to--session). -
Security: a personal org gets a random id and records who created it (
orgs.created_by, migration 010). An owner who was removed from the org they created no longer regains it by signing in; they land in a fresh org. An org a user created that has no members at all (a provisioning that stopped before the owner insert) is reclaimed instead of duplicated. -
Security: item envelopes written before AAD binding (bound to the org id alone) are re-encrypted under
org_id|item_id|allowed_hosts_json|injectonce at boot (aad_rebindlog withrebound,verified,unreadable,batches,stopped,ms;items.aad_version, migration 010). The inject path no longer falls back to the legacy binding.updateItemwrites a re-encrypted envelope and the columns it is bound to in one statement. The rebind runs after the server listens, so/healthand/readyanswer inside the Fly check’s grace period on a large table, and reads rows in pages of 200 (aad_rebind_progressper page; memory bounded by one page). The signal handlers are installed before it: aSIGTERMduring the pass ends it at the next page boundary and the drain waits for the page in flight before closing the pool. A rebind that throws logsaad_rebind_failedand the process keeps serving (rows still at version 0 fail closed on inject; the next boot resumes).docs/ops/migrations.mddescribes the first boot after 010. -
Security:
rotateItemenforces the 64 KiB value cap, and rotate, update, and delete by item id refuse production items on the staging deploy (404) like list and inject already did. -
Security: a grant for a trusted (
avt_) client refuses method, path, and host limits at approve (400scope_unenforceable), explicit or inherited from the request, because/runtime/resolvehas no call to check them against;max_callsandttl_secondsstay. Wrong approval-code attempts survive a code rotation (an agent re-requesting no longer resets the five tries) andPOST /api/grants/approve-by-codeis limited to 20 attempts per org per 15 minutes (429). -
Security:
GET /approve?token=sends a session that has not passed the authenticator step to/verify-totp(or/enroll-totp) like a signed-out visitor goes to/sign-in, instead of a JSON 403. -
Changed: KEK rotation runs while serving. The hosted process accepts
VAULT_KEK_PREVIOUS_WRAPPED(or rawVAULT_KEK_PREVIOUS, refused underVAULT_KEK_REQUIRE_KMS=1) next to the current KEK; an org DEK or the identity DEK still wrapped under the previous KEK opens and is re-wrapped in place (dek_rewrappedaudit row and log line,kek_previous_loadedat boot). Boot refuses a previous key equal to the current one, both forms at once, or a bad length.vault kek-rotatestays for the offline pass and acceptsVAULT_KEK_NEW. Runbook rewritten: set previous, deploy, rotate, remove previous. -
Changed: revoking a grant drops the
folder_standingpolicy only when that grant was activated by it; revoking a prompt, session, or item grant leaves a folder-wide approval in place. Approvingitem_standingorfolder_standingreplaces a stale policy row for the pair instead of failing on the unique index. Grants are looked up per (client, item) pair through thegrants_client_item_statusindex rather than by scanning the org, and the hourly sweep deletes revoked, consumed, and expired grants settled more than 30 days ago (sweep_expiredlogsgrants; audit rows stay). -
Changed: the offsite backup envelope starts with
BPBKand a version byte, authenticated with the body;hosted-backup.ts decryptrefuses a file without the magic or with an unknown version. Dumps written before this change do not carry the header and must be decrypted with the previous release. -
Repository:
clients_oauth_client_idandorg_members_org(migration 003) are inschema.tstoo, and the Postgres parity test now checks indexes in both directions. Docs say what the item AAD is (README, threat model). -
Security: sign-in counters are atomic in the store. An email-code attempt is claimed with one conditional
UPDATEbefore the hash check (claimOtpAttempt), so concurrent guesses cannot share a slot;POST /api/auth/otp/verifyis limited to 50 verifies per address and 25 wrong codes per email in 15 minutes (a verify for an address with no live code, or a correct code, does not spend the per-email budget, so bogus verifies posted for someone else’s address cannot lock that person out), and a verify for an address with no live code costs the same scrypt work as a wrong code. Authenticator attempts are charged the same way (claimTotpAttempt), the replay guard and backup-code consumption are conditional updates (consumeTotpStep,markBackupUsedreturns whether it consumed), so the same code or backup code passes exactly once even when submitted twice at once. Confirming an enrollment consumes the pending secret with one conditional update (consumePendingTotp), so two concurrent confirms of the same code replace the secret and mint backup codes exactly once. -
Security: the CSRF token is signed for the session cookie it travels with; a token from another session is 403. On HTTPS only the
__Host-cookies are read, only on a request the proxy marksx-forwarded-proto: https; a plain-named cookie is no longer a fallback (cookie tossing cannot fix a session). A cookie value that is not valid percent-encoding is ignored instead of a 500. -
Security: operator sessions are listed and revocable by the org they act in (
active_org_idwhen still a member, else the first membership), not by every org the user belongs to.listMembershipsForUserorders byjoined_at NULLS FIRST, org_idon both stores, so “first membership” is the same on SQLite and Postgres. -
Security:
VAULT_BOOTSTRAP_TOKENis honoured on staging and production only withVAULT_BOOTSTRAP_ALLOW_PLANE=1(break-glass window; unset both afterwards). A leftover token without the flag is ignored (bootstrap_token_ignored) and does not fail boot. When the token is live it logsbootstrap_token_enabledat boot and every use logsauth_bootstrap_usedwith a token hash, address, method, and path. Proxy headers (Fly-Client-IP,X-Forwarded-For) are used for the caller’s address only behind Fly or withVAULT_TRUST_PROXY=1; otherwise the socket peer counts. -
Security:
POST /api/orgsrequires a ready session (authenticator step passed), a printable name of 1 to 80 characters, and stays under the new per-userorgsplan limit (10;VAULT_PLAN_LIMITS_JSONkeyorgs, 402plan_limit). Invites are limited to 10 per inviting account and 30 per address per hour (429). -
Fixed:
POST /api/auth/otp/sendemails first and stores the challenge only after delivery, so a mailer outage no longer leaves a dead code that blocks sign-in for ten minutes. A resend while a code is still valid sends a new code and expires the old one (it counts against the 5 per 15 minutes budget); the Resend button works. -
Fixed: session cookies carry the 7-day absolute lifetime instead of the 12-hour idle window, so the server-side idle extension is reachable: activity keeps a session alive for up to 7 days, an idle one still ends after 12 hours.
-
Fixed:
auth_otp_sent,auth_otp_locked,auth_totp_verified,auth_session_revoked,auth_signed_out, andauth_bootstrap_usedlog lines are emitted.auth_session_revokedis written after the session is deleted, so a refused attempt (an operator on another member’s session, 403) is not logged as a revocation. The console follows a 403mfa_requiredtoverify_urlorenroll_urlinstead of showing an error. -
Fixed: OAuth consent completes in a real browser. The consent page’s script posted the decision and then read the redirect’s
Location, which browsers hide from scripts, so Allow never left the page.POST /consentnow answers a caller that sendsAccept: application/jsonwith200 { location }and the page navigates there itself; a caller that sends the CSRF header without thatAcceptkeeps the 303. A form submit without the header (scripts off) is 403: the consent form carries no CSRF field andPOST /consentneeds the double-submit token like every other cookie-session mutation. A Chromium smoke test registers a client, opens/oauth/authorize, clicks Allow, lands on the redirect URI with a code, and exchanges it. -
Fixed: re-enrolling an authenticator from the console shows a QR code.
/enroll-totpsent a ready operator straight back to the console, and the page would have started a second secret anyway. The page now renders while a re-enroll secret is in flight and reads it back through the newGET /api/auth/totp/pending(the sameotpauth_urlandqr_svgthattotp/startreturned, live for ten minutes), callingstartonly when nothing is pending. A reload during first enrollment keeps its secret the same way. The console’s code dialog also accepts a 10-character backup code now (it cut input at 8 characters, so a backup code could never pass), and says so. A Chromium smoke test re-enrolls from the console with a backup code and confirms the new secret. -
Changed: console, auth, and collect assets are referenced at content-hashed paths (
/assets/console.<sha8>.js) and servedimmutable; the plain names still resolve but areno-cache, so a deploy can no longer be pinned to a stale bundle by a year-long cache. -
Security: JSON routes require
Content-Type: application/json(415 otherwise; a bodiless POST still reads as{}), so a cross-site HTML form can never reach a JSON handler whatever the CSRF check says.POST /approveandPOST /consent, which back HTML forms, also accept a form body. -
Security: the marketing site is served only on an allowed
Host, like every other page; a foreign or loopbackHoston a public plane is refused. Health, readiness, discovery, and assets still answer on any Host. -
Repository: the unused
src/hosted/collect-js.tsis gone (the collect page has been served from the client bundle);/consentand/deviceare only mounted with the OAuth provider instead of falling back to an unbacked form. -
Security: hosted boot refuses
VAULT_AUTH_MODE=testoutright (the process never installs header principals) and requiresVAULT_DEPLOY_PLANEto bestagingorproduction; an unset plane used to mean production and skipped the plane guards.VAULT_APPROVAL_HMACmust be 64 hex characters, the kernel refuses a key under 32 bytes, and approval links are bound to the org they were minted for.createHostedServerwithout anauthResolvernow authenticates nobody instead of trusting test headers. -
Security:
vault serveprints two loopback bearers instead of one. The operator bearer opens/apiand the console; the model bearer opensPOST /mcp; each is refused on the other surface, so an MCP client can no longer approve its own grants.vault mcp --remoteforwards stdio to a runningvault servewith the model bearer. The local server answers only to a loopbackHost, caps JSON bodies at 128 KiB, sends a nonce CSP on the console, and no longer echoes driver or parser errors.vault set --valueis gone: pipe the value or type it at the no-echo prompt.master.keyis refused when group or others can read it. -
Security: behind Cloudflare the rate-limit address comes from
CF-Connecting-IPonly when the address Fly saw is insideVAULT_TRUSTED_PROXY_CIDRS(default: Cloudflare’s published ranges; empty disables the header). Deploy and backup secrets move to GitHub environments (staging,production,backup) andflyctl deploynames its app with-a;deploy-prodalso refuses a SHA with no successfuldeploy-stagingrun. -
Fixed: one request id per request. The inbound
x-request-id(or the minted one) is now the same value in the access log, therequest_errorline, the 500 body, and the Sentry event; error messages are redacted before they are logged. A malformed percent-escape in/collect/:idor a site path is a 400 or 404 instead of a 500 plus a Sentry event;/readycaches its database ping for 5 s. Errors after headers are sent are logged under the request id and the stream is cut; a site file stream error no longer ends the response as if complete. -
Changed: the hosted server sets
headersTimeout15 s,requestTimeout30 s, andkeepAliveTimeout65 s (SSE streams exempt); one principal may hold at most four openGET /mcpstreams (429 past that). Signal handlers are installed before the boot-time sweep;vault servein hosted mode installs the process guards; a plane withoutSENTRY_DSNlogssentry_dsn_missing. Docs:.env.examplelistsVAULT_TRUST_PROXY,VAULT_TRUSTED_PROXY_CIDRS,VAULT_BOOTSTRAP_ALLOW_PLANE,VAULT_OIDC_PREVIOUS_JWK;docs/ops/migrations.mdcovers statement timeouts. -
Security: a revoked OAuth agent stays revoked. Only a fresh consent (authorization code or device code) reactivates the
(org_id, oauth_client_id)row; a refresh token held by another member of the same org for the same client id is refused withinvalid_grant, and revoking the agent destroys every consent the org’s members gave for that client id, not only the first consenter’s; the account-scoped sweep for the consenting account then removes only rows with no grant behind them, so that person’s grants for the same client id in other orgs (and their refresh tokens) survive. Consent binds the org the operator session acts in into the OAuth grant, the access JWT carries it asorg_id, and/mcpchecks the account is still a member of that org instead of picking the account’s first membership (verification no longer provisions an org). Authorization codes, device codes, and refresh tokens are consumed with one conditional write, so two concurrent exchanges of the same code cannot both succeed. -
Security:
POST /oauth/revokedenylists JWT access tokens (verified against this server’s keys and bound to the calling client), and revoking a refresh token, or replaying a rotated one, marks every access token issued under the same grant in the ledger (access_events.grant_id, migration 012). Client ID Metadata Document fetches are limited per requesting IP (10 per hour) as well as per target host (30 per hour), capped at 4 in flight, and time out after 3 s.POST /deviceuser-code attempts are limited to 10 per 15 minutes per IP and per OP session. JWT verification allows 30 s of clock skew, no more. DPoP is off (nothing enforced it). -
Changed: the OAuth server accepts only what discovery advertises:
token_endpoint_auth_methods_supportedis["none"](dynamic and CIMD clients are public clients),response_types_supportedis["code"], andresponse_modes_supportedlists the modes the engine really serves (query,fragment,form_post); the engine and the document are built from one set of constants.POST /api/clients/:id/rotateon a revoked agent is 409.VAULT_OIDC_PREVIOUS_JWKpublishes the key being rotated out in JWKS and keeps verifying tokens it signed; new tokens are always signed withVAULT_OIDC_PRIVATE_JWK. Runbook:docs/ops/oidc-key-rotation.md. -
Removed: the AgentPass Authority stub (
/agentpass/*,VAULT_AGENTPASS, theagentpasslist onGET /api/inbox). It reported pending passes as allowed, never used its signing key, and validated without authentication; the tableagentpass_passesstays (schema is expand-only) and is unused. -
Fixed: the OAuth request handler now settles once oidc-provider has answered (it awaited a Koa
nextthat never fires), so every OAuth request no longer leaves a pending promise behind. -
Security: the connector no longer hangs when an origin closes the connection mid-response (the call fails at once with a 502 that names the cause), reads at most 1 MiB from an origin before cutting the socket and returning
origin_status: 502with{ "error": "body_too_large" }, and asks for uncompressed bodies. Request paths are canonical in every layer: onecanonicalRequestPathvalidateshttp_requestandrequest_grantpaths, is stored asrequested_scope, shown on the inbox card, checked against the approval scope, and sent on the wire; backslashes, percent-encoded/\.inside a segment, and.or..segments (encoded or with a;paramsuffix) are refused, so/v1/read/..%2fadminand..;/no longer pass a/v1/readscope. -
Security: a one-call (
prompt) approval is spent as soon as the credential leaves the process. Any origin status, a connection closed mid-response, or a timeout after the TLS handshake consumes it; it is handed back only when the send never left (host mismatch, blocked address, unusable mode, or a DNS, connect, or TLS failure). Operators who expect retries approve withmax_callsorsession; the inbox card, steering copy, and docs say so. The localoncegrant follows the same rule. -
Security: the local MCP no longer accepts
agent_idortool_idarguments to act as another agent; every tool refuses arguments outside its schema with JSON-RPC-32602. Cookie sessions that have not passed the authenticator step cannot drivePOST /mcpas a model. Another organization’s need is 404 onGET /api/need-items/:id, not 403. Redaction also covers lowercase percent-encoding, form+encoding, HTML entities, and the HTTP Basic pair under theclient_idactually sent to a token endpoint. -
Fixed: the user-token path uses a cached access token without spending the
<ITEM>_REFRESHapproval; when the refresh item has no approval for this agent,http_requestreturns the pending grant for<ITEM>_REFRESHwith a hint instead of silently falling back to an app token; a refresh token the provider rotates replaces the stored value in place (auditrefresh_rotated).dry_runreportsscope_deniedwhen the approval would refuse the call.content_typemust be one of the two supported values and a URL or host with a port other than 443 is refused with a hint.allowed_hostsare stored lowercase and compared case-insensitively.task_descriptionis cut to 500 characters on grants.suggestedNameFromHostyields a valid name for every host (api-v2.example.combecomesAPI_V2_EXAMPLE_COM, a leading digit getsH_). The provider connect callback carries areasoncode (state_expired,provider_denied,exchange_failed,no_refresh_token). -
Local plane:
http_requesthonoursclient_id,timeout_ms, anddry_runwith the hosted payload shapes and returnsorigin_statusandorigin_headers;vault settakes the hosted inject vocabulary and--username(HTTP Basic user, OAuth client id, or AWS access key id; local schema 3 adds the column);list_itemsshows the username. -
Fixed: the production image builds again. The site stage now carries the three repository files the site build reads (
src/brand-visual.ts,CHANGELOG.md,docs/security/threat-model.md);.dockerignorelets those two Markdown files through. CI gained animagejob that runsdocker buildand checks the image exits 78 on an unsafe configuration.
0.5.0 (2026-09-04)
-
Console: the sidebar sign-in links are hidden while signed in; the Connect account action shows only on OAuth client secrets; the credential drawer shows created and updated times (
created_at,updated_aton/api/items); approving or denying re-renders the inbox even while another card’s limits form is open; a pending approval’s button reads Deny. Site: homepage spacing and shorter examples in How it works; docs name the Credentials panel; the docs breadcrumb no longer repeats a section that matches the page. -
Infra: the site builds on Astro 7 (
npm --prefix site auditwas failing on advisories fixed only in 7.1+);@astrojs/markdown-remarkis installed for the docs table rehype plugin. Thesecrets-scanCI job haspull-requests: readso gitleaks can list the PR’s commits.npm auditin CI and the prod deploy runs throughscripts/audit.sh(90 s per attempt, three attempts) because the registry’s advisory endpoints sometimes hang; in CI the audits are their ownauditjob so they cannot consume thetestjob’s time budget. -
Security: a provider connect that finds an existing
<ITEM>_REFRESHresets its hosts and inject mode to the provider defaults, not only its value, so a row someone stored earlier with other settings cannot receive the token; the connect state is bound to the operator who started it; the dev loopback callback is accepted only when Botpasses itself runs on loopback; connects and theagent_client_idstanding policy are audited (provider_connected,grant). Origin response headers are redacted on the token-endpoint paths too, including for the minted token. -
Changed: provider user connect is generic.
POST /api/integrations/:provider/startandGET /integrations/:provider/callbackwork for every registry provider with an authorize URL (Spotify, GitHub, Google, Slack, Stripe Connect); thespotifypaths are the same routes. The console shows “Connectaccount” on credentials whose hosts belong to a provider, and one dialog serves them all. The refresh token is stored as <ITEM>_REFRESH, allowed on the provider’s API and token hosts, and the callback refuses a state minted for another provider. -
Changed: connecting an account no longer gives every model client standing access to the refresh token. Pass
agent_client_idon the start request to grant one named agent; otherwise approve in the inbox as usual. -
Changed: the hosted sweep also deletes unaccepted org invites more than seven days past expiry (
sweep_expiredlogsorgInvites).VAULT_PLAN_LIMITS_JSONis documented in.env.exampleand the README. -
Repository: dead code removed (
hashBearer,lookupTrusted,updateItemMeta,assertNoSecret,dirnameOf,siteIndexExists,openVaultFromEnv, the store’sgetClientByHashedSecret);inject_deniedandscope_deniedare typed errors (isInjectDenied,isScopeDenied).kernel.ts,kernel-grants.ts, andhttp.tsare split into modules under 600 lines (kernel-items,kernel-clients,kernel-orgs,kernel-connect,kernel-grant-scope,kernel-grant-approval,http-connect-routes); the SQLite and Postgres stores share one set of row mappers (src/store/rows.ts). Tests that grepped source or README wording now test behaviour. -
Changed: an operator session calling
POST /mcp(the stdio shim) uses the plane’s default environment instead of one named in the RPC body; a revoked OAuth client row is reactivated by a fresh consent (client_reactivatedaudit) because(org_id, oauth_client_id)is unique; deleting an org removes its members’ OAuth payloads only for that org’s clients (a member’s sessions in another org survive).Fly-Client-IPis trusted only behind Fly or withVAULT_TRUST_PROXY=1. Scope path prefixes match on segment boundaries and paths with.or..segments are refused. Origin response headers are redacted like bodies. Token minting requires the provider token host on the credential’s allowed hosts. -
Team: owners invite by email (7-day link, also copyable from the console), set roles, and remove members; the last owner cannot be demoted or removed. Invited operators accept at
/accept-invitewith the matching email. Sessions carry an active org and the rail shows a switcher when the user belongs to more than one. New Team panel under Account. Migration 009. -
Plan limits: free tier of 25 credentials, 10 agents, 3 members, 5000
http_requestcalls per month (VAULT_PLAN_LIMITS_JSONoverrides); exceeding one is402 plan_limit.GET /api/planand the Account panel show usage. -
Local plane:
vault mcpexposes the hosted tool names and shapes, includinghttp_requestover the shared connector with per-item hosts and inject mode (vault set --host … --inject …); the agent id comes from the MCP client name. The local console no longer usesinnerHTML. -
Added: a provider registry (Spotify, GitHub, Google, Slack, Stripe Connect) drives OAuth token minting and refresh; every vendor hostname and rule left
connector.tsandmcp-http.ts. Refresh sendsrefresh_tokenandclient_idin the form body (RFC 6749 section 6) and the user-token path forwards the request body. -
Added: inject modes
query:<param>,cookie:<name>,hmac:stripe_sig|slack_sig|github_sig, andsigv4; unknown modes are refused at store time (400) and at send time (500inject_unsupported) instead of falling through to Bearer. -
Added:
http_requestreturnsorigin_statusand allowlistedorigin_headers, acceptstimeout_ms(1 to 30 s) anddry_run.statuson origin results is deprecated in favour oforigin_status. Out-of-scope calls steer the model torequest_grantwith the call it needs. MCP instructions no longer name vendors except the providers line. -
Scoped approvals.
request_grantandPOST /api/grants/requesttake thehost,method, andpaththe agent will call; the inbox card reads “wants to GET api.stripe.com/v1/balance” and a one-click Approve is limited to that call. “Approve with limits” sets methods, a path prefix, a call quota, and a duration. Standing approvals can expire;sessionlength is a parameter (ttl_seconds, up to 24 h). Out-of-scope calls are refused withscope_deniedand audited; quotas mark the grantconsumedon the last call.list_itemsincludesallowed_hosts. Grant and policy code moved tokernel-grants.ts. Migration 008 adds nullable scope columns tograntsandpolicies. -
Security:
POST /api/grants/requestignoresoperator_emailfrom model clients; operators may only name a member.GET /approve?token=renders a confirm page and no longer changes state;POST /approveapproves. Cookie sessions callingPOST /mcpneed the CSRF token and a same-originOrigin;GET /mcprequires a principal. Approval emails escape client and item names and go to every verified org member when no address is given (notify_failedis now false when one send succeeds). -
Security:
request_grantreturns the existing open grant for a client and item instead of inserting duplicates; revoking a grant revokes every open row for that pair and deletes its standing policies; the org rate limit is counted inside the kernel sohttp_requestcannot bypass it.sessiongrants pastexpires_atread asexpired. -
Security: origin bodies are redacted for every encoding of the secret (raw, Basic base64, base64url, URL-encoded, JSON-escaped, hex) and nested OAuth token keys, before the 256 KiB cut; body-wide last-4 masking is gone. Hosted item envelopes bind
orgId|itemId|allowedHosts|injectas AAD (migrate-on-read). SSRF blocklist covers multicast, reserved, TEST-NET, NAT64, 6to4, and site-local ranges; the pinned HTTPS path is tested against a local TLS server. -
Breaking for MCP clients: the primary tool is
http_request(destructiveHint: true);http.requeststays as an alias ontools/callfor one release and is no longer advertised. Theenvironmentargument left every tool schema (it was ignored). Steering distinguishes 4xx (change the request) from 5xx (retry once); connection failures say what failed without the raw error. -
Fixed: a wrong 8-digit code no longer burns attempts on every pending approval; unexpected server errors are
500 { error, request_id }with anx-request-idheader instead of echoing the message; malformed JSON is400 Invalid JSON;injectis audited after a successful send (inject_denied/inject_failedotherwise); session revoke needs the 12-character id prefix and owner role for another member;deleteOrgremoves access events, rate hits, and members’ OAuth payloads;HEAD /consoleworks; newPOST /api/clients/:id/environment. -
Repository:
http.tssplit intohttp-item-routes.ts,http-client-routes.ts,http-grant-routes.ts,http-mcp-routes.ts,http-util.ts. ADRs renumbered: grant-vault trust model is 0006 and KMS-wrapped KEK is 0007 (they collided with 0003 and 0004).CLAUDE.mdis 35 lines again. -
Security: the authenticator step is enforced at sign-in. The email code issues a pending session; operator routes stay 403
mfa_required(withverify_url) untilPOST /api/auth/totp/verifyor enrollment setsmfa_at. Existing sessions re-verify once after deploy. Re-enrolling an authenticator requires a current code and invalidates unused backup codes. Ten wrong codes lock the account for 15 minutes. Per-IP limits readFly-Client-IP. Authenticator secrets are wrapped under an identity DEK thatvault kek-rotatere-wraps. Backup codes are shown in their own step with Copy and Download; nothing redirects past them. NewGET /api/auth/meandPOST /api/auth/backup-codes/regenerate. -
Security: OAuth access tokens are scoped to the operator’s org (the JWT
subselects the org, then(org_id, oauth_client_id)), so a dynamic client id shared by every user of a hosted MCP client no longer crosses tenants. Consent shows the app name, redirect hosts, and a first-connection notice; the device page renders the field oidc-provider requires so device login works; only the literalallowdecision grants. Redirect URIs must be https, IP-literal loopback http, or a named desktop scheme.oidc-provideris told it sits behind the TLS proxy; its session lasts one hour, is never remembered, and ends on logout; cookie keys are HKDF-derived. Client ID Metadata Documents are fetched through the SSRF-pinned fetch.oidc_payloadsgained indexeduid,user_code,grant_id,client_id,account_idcolumns; consumed codes keep their TTL. -
Fixed: OAuth-connected agents are created in the plane’s default environment (production on production) instead of always staging, named after the app’s
client_name, and record the consenting account. -
Console: panels are Inbox, Credentials, Agents, and Account (with Sign out). Cancel works in every dialog. Confirm dialogs name the credential or agent and what stops working. Credentials has search, environment and kind filters, sort, and a detail drawer with per-item revoke. Agents has a two-step Connect card, a once-shown token modal, an inline environment selector per agent, and Agents, Approvals, Sessions, and Activity tabs with readable activity lines and paging. Inbox polls every 15 s, shows request times and code expiry, and has Deny. Vendor tips (Grok, OAuth client secrets) sit behind disclosures; the deploy plane is only shown on staging. A light theme follows the system. Client JS is built from typed modules under
src/hosted/client/byscripts/build-client.ts; the committed bundle must be regenerated when they change (a test catches a stale bundle). Playwright smoke test covers sign-up through sign-out at 1280 and 390 px. -
Site: docs are Markdown content collections with a generated sidebar, breadcrumbs, and prev/next. New pages: connect Claude Code and ChatGPT, install, troubleshooting, FAQ, self-hosting, rate limits, security disclosure, and using an OAuth client secret. The homepage explains the flow with the real
http_requestcall and a redacted result, names the trust model, and answers common questions; the security page imports the threat-model table, encryption, connector hardening, and the canary tests. Canonical URLs have no.html(old forms 308), unknown site paths get an HTML 404, hashed assets are immutable,llms.txtandsecurity.txtship, docs search works, and/changelogrenders this file. Site and console share one token source. -
Repository: vendored agent-harness scaffolding (
.cursor/*,.loadout/,processes/,.claude/{agents,commands,hooks,settings.json}, loadout docs) removed;INSTALL.mdnow describes Botpasses. Migrations006_identity_mfa.sqland007_oauth_tenancy_and_payload_columns.sqlmirror the schema changes (007 needs a duplicate check on existing databases; see the file header). -
Schema migrations are real:
scripts/migrate.tsappliesmigrations/NNN_*.sqlunder an advisory lock, recordsschema_migrations, and runs as the Flyrelease_commandoverDATABASE_URL_DIRECT. Boot no longer runs DDL once that table exists (it logsschema_bootstrapwhen it still has to, for dev and test databases). New005_indexes_and_rate_hits.sqlbrings the SQL files to parity withschema.ts; a Postgres test asserts it. Runbook: docs/ops/migrations.md. -
Hosted process sweeps expired rows at boot and hourly (
sweep_expiredlog line with counts): email OTP challenges, operator sessions, approval challenges, oidc payloads pastexpires_at, cancelled or expired needs older than 24 h,rate_hitswindows older than 2 h. -
Postgres pool has connection (5 s), idle (30 s), and statement (15 s) timeouts, TCP keep-alive, and
application_name=botpasses-<plane>. Resend and Sentry calls time out after 10 s. A database that cannot be opened at boot exits 78 with a message instead of an unhandled rejection. SIGTERM drains for 25 s, then cuts open connections (SSE), ends the pool, zeroes the KEK, and exits; a second signal is ignored. Unhandled rejections and uncaught exceptions log one line and exit 1. -
Sentry uses the envelope endpoint with
X-Sentry-Auth,release=botpasses@<version>,environment=<plane>, and a redacted message (machine tokens, bearer values, and pg key details stripped).observe.tsexportslogRequest(method, path, status, ms, request id fromx-request-idor generated) andlogAuthEvent(emails hashed). Alerting onkek_raw_fallbackand backup failures: docs/ops/alerts.md. -
CI: a
smokejob installs Playwright and Chromium and runsnpm run test:smoke, the browser walk through the console (the test skips with a reason where they are missing;BOTPASSES_PLAYWRIGHT_PKGandBOTPASSES_CHROMIUMpoint at other installs).test/store-parity.test.tsruns the same item, grant, client, session, team, need, and delete-org scenarios on SQLite and, undertest:pg, on Postgres. -
CI: eslint (typescript-eslint type-checked with
no-explicit-any,no-floating-promises,switch-exhaustiveness-check), gitleaks,npm auditforsite/, migrations applied twice against Postgres, 80 percent line-coverage gate, and a job that fails ondev/mainpushes while the GitHub default branch is notdev(docs/ops/default-branch.md). All actions pinned to commit SHAs;permissions: contents: readeverywhere; Dependabot also watchesgithub-actionsanddocker. -
deploy-stagingruns onworkflow_runofciinstead of repeating the suite.deploy-prodrequires theproductionenvironment, verifiesstaging_shais a full SHA ondev, and runs audit, lint, migrations, and the suite with Postgres beforeflyctl deploy. -
backup-prodinstalls PGDGpostgresql-client-16(matching the Neon major), pinsaws4fetch, and ends with abackup-verifyjob that downloads the object and decrypts it withBACKUP_KEY. -
Dockerfile pins
node:22.22-bookworm-slimand shipsscripts/;.dockerignoreexcludes**/node_modules,site/dist, docs, tests, and agent harness directories..mcp.jsonand.cursor/mcp.jsonpin@upstash/context7-mcpand@playwright/mcp. -
.env.exampledocuments every variable with a required-per-plane table, includingFLY_APP_NAME,VAULT_BIND_HOST,VAULT_PORT,VAULT_SITE_ROOT, and whatDATABASE_URL_DIRECTis for. -
npm scripts:
lint,test:coverage,test:pg,site:build,migrate,dev, and apretestthat says “run npm run site:build first” whensite/distis missing. Package version is 0.5.0. -
vault set,list, and the other local commands no longer loadpg, the AWS KMS SDK, or the hosted kernel;serve(hosted),kek-wrap,kek-rotate, andmcp --user-jwtimport them on demand. -
Store Kind is API token or Client ID and secret. Username and password is gone from Kind. HTTP Basic stays under Change how it is sent. Client ID and Client Secret labels appear for app secrets. Empty hosts prefill
api.spotify.com, accounts.spotify.com. Submit storesclient_secretso the vault pill says app secret, not token. Each row has Edit (same form, prefilled; blank value keeps the secret). Rotate and Delete stay. -
Grok Bot:
Authorization: Bearer avm_…is sufficient. Handshake methods (initialize,ping,tools/list) no longer 401, so a connect card /needsAuthis not forced when the Bearer is already configured. DCR accepts desktop redirect schemes (cursor://,grok://) as well as https and loopback http. -
Prompt grants reactivate after a failed origin HTTP (401/410/5xx). One inbox approve can retry Spotify token mint. The origin status and body are returned (empty 410 is the origin, not a Botpasses consume error).
-
Spotify client-credentials helper: Client Secret is not injected as Bearer on
accounts.spotify.com.inject=client_credentialsorclient_id+ stored secret mints with HTTP Basic andapplication/x-www-form-urlencoded, caches the app token, then callsapi.spotify.com. Access tokens are[redacted].GET /v1/searchworks;GET /v1/metells the model it needs user OAuth. -
Spotify user connect (Authorization Code + PKCE) stores a refresh token in the vault. Console inbox approve/retry copy, collect/store Client Secret preset, and Grok docs updated. No new 8-digit code after a failed call.
-
MCP OAuth discovery is RFC 8414 complete (
response_types_supported: ["code"], grants, token auth methods). Path-aware well-known andopenid-configurationserve the same documents.WWW-Authenticateuses an absolute PRM URL. Browser MCP hosts (Grok) may call/mcpand/oauthfrom another origin; operator/apistill rejects a foreign Origin. CORS Origin and the PRM host are bound to that request, not a process-global slot. -
Hosted backup fails closed if R2 is unset. Dump encryption lives in
backup-envelope(tested). Restore: docs/ops/restore.md. Staging Neon history is 7 days; the root branch is protected. -
Access is issue plus inventory. The Connect nav is gone (
#connectstill opens Access). Rows show last-4 of the machine bearer (••••abcd), created, first/last access, and fetched names. The full token is shown once at issue or rotate. Audit log on a row opens that client’s activity (actions and names only).http.requestand trusted resolve write aninjectaudit event. -
Sign-in, sign-up, enroll, consent, device, and collect use the same brand fonts and card chrome as
/console. Verify is hidden until a code is sent. Inbox needs say Store the credential. Vault empty offers Store credential. Connect copies the MCP URL and one-time token. Rotate no longer shows the item id. -
Staging console lists and stores only staging items.
GET /api/itemswith no environment returns every env this deploy plane serves (staging only on staging; both on production). Explicitenvironment=productionon staging is still 404. Failed Store, Rotate, or delete/revoke show the API error inside the open dialog. A failed Access snapshot shows an error, not an empty panel. Collect and approve-by-code still flash on the page. Issue Grok uses the same environment list as Store. Secret fields stay filled until the request succeeds. -
Console and collect pick how a credential is sent from Kind (API token → Bearer, username and password → HTTP Basic). Inject is behind “Change how it is sent.” Username stays hidden unless Basic is in use.
-
/enroll-totpshows a local QR code, anotpauth://link, and the grouped secret afterPOST /api/auth/totp/start. The QR is SVG generated on this origin (not a third-party image). -
Hosted mail sends through Resend on the verified public hosts. Staging From is
Botpasses <[email protected]>; production isBotpasses <[email protected]>. Fly uses a sending-access key per plane. OTP mail is a short letter plus the code (not two bare paragraphs). A still-valid unused code is not emailed again. -
Public and internal MCP + HTTP API reference: hosted tools (
http.request, find, grant), operator/api, OAuth, localvault serve. Noget_secret.
0.4.1 (2026-08-31)
Grant-vault hardening: honest trust model, KMS-wrapped KEK, surface locks.
- Hosted is a grant-vault, not zero-knowledge. ADRs 0003-grant-vault and 0004-kms. Threat table: docs/security/threat-model.md.
- AWS KMS unwraps the platform KEK at boot (
VAULT_KEK_WRAPPED). Expand/contract: rawVAULT_KEKstill boots untilVAULT_KEK_REQUIRE_KMS=1.vault kek-wrap/vault kek-rotateon a laptop. Runbook: docs/ops/kek-rotation.md. - Hosted HTML/JSON: CSP nonce, DENY frames, nosniff, HSTS,
no-store. Disallowed CORS Origin is 403 with no ACAO. - Collect GET is a shell. Need details require an operator
GET /api/need-items/:id. - Store-backed org rate limit.
POST /api/clients/:id/rotateinvalidates the oldavm_/avt_hash. - Local envelopes bind AAD to the secret name (migrate-on-open).
vault serverequiresHMAC-SHA256(master, "botpasses-loopback")on/apiandPOST /mcp. - CI:
npm audit --omit=dev --audit-level=high. Dependabot weekly for npm.
0.4.0 (2026-08-31)
Marketing site, first-party operator accounts, same-origin OAuth, and the Access panel. Clerk is removed.
- Hosted
/is the Astro marketing site. Console is/console. Design tokens live at/design. - Operators create an account with email OTP and required TOTP. Sessions are HttpOnly cookies plus signed CSRF.
- This origin is the MCP authorization server (
oidc-provider9.12). PKCE S256 only. Access JWTsaud=${origin}/mcp, 600s. RFC 7009/oauth/revoke. - Access panel lists operators, clients, grants, sessions, and the issuance ledger. Revoke a client, grant, or other session from that screen.
- Env:
VAULT_SESSION_SECRET,VAULT_OIDC_PRIVATE_JWK. NoCLERK_*. - ADRs 0003, 0004, 0005.
0.3.4 (2026-08-31)
MCP tells the model when to use Botpasses, so the user does not have to paste a procedure.
- Hosted
initializeinstructions: if the user wants a third-party API called, runhttp.requestin the same turn. Do notlist_itemsfirst. Example: “get my Spotify profile” →GET https://api.spotify.com/v1/me. Never ask for a secret in chat. http.requestis the primary tool:hostor a full httpspathURL is enough. It finds the credential, requests a grant when inject is denied, and returnsnext.for_modelplusnext.argumentsto retry.- Concurrent prompt consume still hits the origin once. The other call returns a pending grant (not
inject_denied), so the model can ask the operator to approve again. need_itemis not MCPisError(models stop on errors).host_mismatchstill is.- Tool and param descriptions state when to use each tool versus siblings.
- Operator console and README: after the Grok connector is on, ask for the task in plain language.
0.3.3 (2026-08-31)
Public origins are botpasses.com, not botpasses.ai.
- Prod
https://botpasses.com, staginghttps://staging.botpasses.com. - Documented Clerk FAPI
clerk.botpasses.com/clerk.staging.botpasses.comand Resendmail.botpasses.com. - Hosted boot requires
VAULT_PUBLIC_URLto match the deploy plane origin. MCP, CLI, and emails never use a platform default hostname. - Canonical constants:
src/brand.ts. Identity ADR: 0002.
0.3.2 (2026-08-30)
Agents can find named credentials and operators can enter a missing key on Botpasses. The model does not get the key.
- MCP
find_itemsmatches an exactitem_nameand/or exact API hostname. Results arefound,ambiguous(up to 5, withallowed_hosts),host_mismatch, orneed_item. - A miss returns a path-only
collect_url(/collect/{needId}, no query HMAC). Sign in on Botpasses and POST fulfill as the operator. Model and trusted tokens cannot fulfill. - Fulfill stores the item and an active prompt grant for the requesting client, then
http.requestinjects in-process. Local MCP miss tells the operator tovault storeand does not mint a collect URL. - Inbox lists pending needs next to grants. Console store form uses an inject select and host example
api.spotify.com.
0.3.1 (2026-08-30)
Hosted console and Grok Bot can run without Clerk.
- Operator HTML at
/is public; APIs still require auth. PasteVAULT_BOOTSTRAP_TOKEN(32+ chars) to operate. POST /api/clients/modelissues a one-timeavm_…bearer for remote MCP (Grok Bot header auth).avt_…trusted tokens still cannot call MCP. Model tokens still cannot/runtime/resolve.- Process env:
VAULT_BOOTSTRAP_TOKEN. - GitHub repository is
naffis/botpasses(oldnaffis/agent-vaultURL redirects).
0.3.0 (2026-08-30)
Product identity is Botpasses. Hosted origins are https://botpasses.ai and https://staging.botpasses.ai.
- Display name, MCP
serverInfo.name,/healthproduct, and npm package name arebotpasses/ Botpasses. - CLI bins:
botpassesandvault(same entry). Default local home is$HOME/.botpasses. - Fly apps:
botpasses-staging,botpasses-prod. Nightly backup object prefix isbotpasses-. - Hosted email From comes from
VAULT_EMAIL_FROM(required at boot whenRESEND_API_KEYis set). - Process env prefix stays
VAULT_*. AgentPass paths are unchanged.
0.2.0 (2026-08-30)
Hosted multi-user grant vault (Fly + Neon + Cloudflare) alongside the local sqlite CLI.
- Store
secretandloginitems per org environment (staging|production). - Grant policies:
prompt,session,item_standing,folder_standing(standing rows short-circuitrequest_grant). - Operator approve via web inbox, Resend magic link, or hashed 8-digit code. MCP has no
revoke_grant; operatorPOST /api/grants/:id/revokeremains. - Model MCP tools:
list_items,request_grant,list_grants,http.request. Connector attaches the granted credential to the item allowlist, DNS-pins the origin, and redacts values from the result. - Trusted clients call
POST /runtime/resolve. Model tokens cannot. - Local
vault servelistens on 8788./healthno longer includes a key fingerprint. - Hosted process (
VAULT_MODE=hosted) uses Neon Postgres, refusesVAULT_HOME(exit 78), one Fly Machine per app. - Hosted MCP stdio:
vault loginthenvault mcp --user-jwtwithVAULT_PUBLIC_URLand a Clerk session JWT (neverCLERK_SECRET_KEY). - AgentPass Authority HTTP is dark unless
VAULT_AGENTPASS=1.