App Secrets & Vault

The Vault-backed one-source-of-truth secret store and the getAppSecret read boundary — every runtime secret encrypted at rest, resolved server-side, never read back on any surface.

App secrets — the Vault-backed, dashboard-controllable secret store

App-managed credentials (Stripe sk_…/whsec_…, future PATs/tokens) live encrypted at rest in native Supabase Vault (supabase_vault 0.3.1 — AEAD via libsodium; the root key lives outside SQL in Supabase's backend and survives backups/replication encrypted), reachable only through audited SECURITY DEFINER RPCs. This replaces parking a secret in the cleartext settings_*.data.env store (where the "••••" mask is UI-only) and replaces CLI supabase secrets set for anything the dashboard needs to rotate.

Deno.env (supabase secrets set) stays the home for TRUE bootstrap secrets onlySUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY — the chicken-and-egg values needed to construct the client itself. Everything else lives in Vault.

Architecture

                    dashboard Secrets form / MCP create_app_secret
                                     │ (value, write-only)

        public.upsert_app_secret()  ── SECURITY DEFINER, is_admin() gate
              │                              │
              ▼                              ▼
   vault.create_secret()           public.app_secrets (metadata mirror)
   (encrypted ciphertext)          name · vault_secret_id · secret_class
   → vault.secrets                 · hint · note · *_by · *_at      ← NO value column

                                          ▼  AFTER trigger
                                  realtime broadcast on `app_secrets:admin`
                                  (metadata columns ONLY)
 
        Edge runtime (service-role)
              │ getAppSecret(client, "STRIPE_SB_SECRET_KEY")

   public.resolve_app_secret()  ── THE decrypt boundary (service_role | admin only)


   vault.decrypted_secrets  → cleartext value (per-cold-start cache; never logged)

Components

ObjectKindNotes
public.app_secretstableMetadata mirror. NO value column. RLS has_capability('global','edit'); direct DML revoked from anon+authenticated (writes go through the RPCs).
public.upsert_app_secret(name, value, hint, note)DEFINER RPCCreate or rotate. is_admin() gate, search_path=''. Value passed THROUGH to vault.create_secret/update_secret, never read/returned/logged. Returns metadata only.
public.delete_app_secret(name)DEFINER RPCDrops the ciphertext (vault.secrets) + the metadata row. is_admin() gate.
public.list_app_secrets()DEFINER RPCMetadata only (name/class/hint/note/timestamps). NEVER reads vault.decrypted_secrets. is_admin() gate.
public.resolve_app_secret(name)DEFINER RPCThe one decrypt boundary. Returns the cleartext value to the service-role Edge client OR a human admin only; everyone else → insufficient_privilege. Bumps last_accessed_at.
app_secrets:adminRealtime topicBroadcasts metadata-mirror changes; read RLS gated to has_capability('global','view'). Metadata columns ONLY (the mirror has no value), so a value can never ride the firehose.
_shared/vaultClient.ts getAppSecret(client, name)Edge helperResolves by name via resolve_app_secret; per-cold-start in-memory cache; never logs the value.
_shared/envSecure.ts getBootstrapEnv(name)Edge helperReads ONLY the bootstrap allowlist (SUPABASE_*) from Deno.env; rejects any other name (steers app secrets to getAppSecret).

Why values never leak

  • No read-back tool on any surface. list_app_secrets is metadata-only; there is deliberately no get_app_secret. The only path to a cleartext value is the service-role Edge resolver, never an MCP/dashboard call.
  • The metadata mirror has no value column, so a PostgREST reach + even a service-role leak on public.app_secrets yields only names + hints, never ciphertext-with-key.
  • The vault schema is not exposed by PostgREST. Anon/authenticated have NO grant on vault.secrets / vault.decrypted_secrets.
  • The Realtime broadcast carries the metadata mirror row — which has no value — so the app_secrets:admin firehose can't ship a value (the ws: whole-row footgun is avoided by broadcasting from a value-free table).
  • Blast radius vs the old cleartext env: previously a single global:view grant on a custom role exposed every cleartext env value. Now secrets are AEAD-encrypted at rest / in backups / in replication; the only plaintext exposure is a service-role-key compromise (already game-over) or the audited resolver.

Five-surface parity

  1. Dashboard — a "Secrets (encrypted)" tab on the Environment page; the value field is write-only (no reveal toggle), name immutable after create, Delete confirm-gated. (Built by the dashboard specialist — see the queue unit #3.)
  2. MCPlist_app_secrets (global:view), create_app_secret / update_app_secret / delete_app_secret (global:edit + adminOnly + confirm; the confirm prompt shows NAME + hint, never the value).
  3. REST — the 4 RPC endpoints (/rest/v1/rpc/{upsert,delete,list}_app_secret); the resolve RPC is service-role/admin only. (REST manifest entry — dashboard specialist.)
  4. WS — the private app_secrets:admin channel (metadata only, global:view). (WS channels doc — dashboard specialist.)
  5. BusyBrolist_app_secrets is exposed (read-only); the 3 mutators are denylisted (REGISTRY_DENYLIST 4th class) — BusyBro is list-only for secrets, since the value always comes from the user, never the brain.

Adding / rotating a secret

  1. Dashboard → Environment → Secrets (encrypted)Add Secret → type the name (e.g. STRIPE_SB_SECRET_KEY) and paste the value into the write-only field → save. The value is encrypted server-side via upsert_app_secretvault.create_secret and is never seen by the assistant or echoed back.
  2. Rotate by editing the secret and entering a new value (re-encrypts via vault.update_secret). Propagation: Edge readers cache per cold start, so a rotation takes effect as cold starts cycle — redeploy the consuming function for instant.
  3. After confirming the Vault copy resolves, remove any legacy cleartext copy of the same key from the Environment Variables tab.

Consuming a secret in an Edge Function

ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { getBootstrapEnv } from "../_shared/envSecure.ts";
import { getAppSecret } from "../_shared/vaultClient.ts";
 
const client = createClient(
  getBootstrapEnv("SUPABASE_URL"),
  getBootstrapEnv("SUPABASE_SERVICE_ROLE_KEY"),
);
 
const sk = await getAppSecret(client, "STRIPE_SB_SECRET_KEY");
const whsec = await getAppSecret(client, "STRIPE_WEBHOOK_SECRET");
// const stripe = new Stripe(sk); … verify the webhook with whsec

Both are pure NAME references in code — the assistant never sees a value. No supabase secrets set STRIPE_* step exists; those names live in Vault, typed by the user into the dashboard Secrets form.

VPS secret matrix

The Vault store is not just for Edge Functions — it is the home for the secrets the VPS services (dashboard, proxy-server) read at runtime too. The standing rule: on the VPS the only secrets that may live in systemd env / on-disk are (a) the bootstrap pair + (b) genuinely machine-local, non-rotatable material. Every OTHER runtime secret is a Vault secret resolved via getAppSecret(serviceClient, NAME) (Vault-first → env/disk fallback → fail-closed). The proxy-server reuses its getControlSupabase() service-role client; the dashboard server uses its own server-only service-role client (web/dashboard/app/lib/supabase/service.ts, guarded by import "server-only" so it can never reach the browser bundle). The Node readers (web/dashboard/app/lib/appSecret.ts + web/proxy-server/src/appSecret.ts) are thin mirrors of the Edge _shared/vaultClient.ts getAppSecret — two copies, like blockRules.ts / types.ts, since there is no monorepo workspace.

Secret / config on the VPSWhere it livesIn Vault?Why
SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEYsystemd drop-ins (busymate-{dashboard,dashboard-v2,proxy}.service.d/env.conf)No — bootstrapChicken-and-egg: you cannot resolve a Vault secret without first having a service-role client. Mirrors the Edge BOOTSTRAP_ALLOWLIST in _shared/envSecure.ts. The dashboard's service.ts reads these (NEVER NEXT_PUBLIC_*).
MYPRIVATEPROXY_FETCH_URLdashboard: Vault (Secrets tab) → env fallbackYes (migrating)The External-Proxy pool fetch URL embeds an API key. /api/proxies resolves it Vault-first via getAppSecret, falls back to the legacy process.env copy, else fail-closes 503. Once Vault-verified, the systemd-env line is removed.
PROXY_CERT_BUNDLE_AUTHproxy-server: Vault → env fallbackYes (migrating)The cert-bundle endpoint auth. Resolved via the proxy-server's ported getAppSecret over getControlSupabase(). (proxy-server specialist's half of the Wave-2 migration.)
BUSYMATE_CA_CERT_PEM_B64dashboard systemd drop-inNo — PUBLIC configThis is the base64 of the PUBLISHED CA cert that /ca.pem + /ca.mobileconfig serve. It is not a secret — it's the public cert iOS Safari fetches during onboarding. Keeping it in env (vs Vault, or fetching it live from the proxy) means the critical CA-install onboarding endpoint does not depend on Vault/proxy uptime — a deliberate availability choice.
CA private key ca.key/home/ubuntu/.busymate-proxy/ca.key (on-disk)No — machine-localThe proxy mints per-host leaves from it at boot, before any DB/Vault call is possible. Non-rotatable, boot-critical machine-local material.
certbot / Let's Encrypt privkeys/etc/letsencrypt/.../privkey.pemNo — machine-localnginx TLS material owned by certbot; read at boot.
profile-signing key sign-key.pemon-diskNo — machine-localCode/profile-signing material, machine-local.
NEXT_PUBLIC_* (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY).env.production (baked at build)No — publicPublic build-time config baked into the client bundle by design; never secrets.
busymate-status.service (PORT / NODE_ENV)systemd unitn/a — no secretsThe standalone status board (:3940) carries only PORT + NODE_ENV (like busymate-marketing). No secret to migrate.

The dead DASHBOARD_USER / DASHBOARD_PASSWORD env vars (the old HTTP Basic-auth gate, now Supabase Auth in web/dashboard/proxy.ts — there is no middleware.ts) had zero live consumer and were deleted from both dashboard units as dead config (not migrated). The per-secret state tracker is notes/secrets-migration.md.

Consuming a secret on the VPS (dashboard / proxy-server)

ts
// dashboard (Node) — a Route Handler / Server Action:
import { getServiceSupabase } from "@/app/lib/supabase/service"; // server-only
import { getAppSecret } from "@/app/lib/appSecret";
 
let url: string | null = null;
try {
  url = await getAppSecret(getServiceSupabase(), "MYPRIVATEPROXY_FETCH_URL"); // Vault
} catch {
  url = process.env.MYPRIVATEPROXY_FETCH_URL ?? null; // legacy env fallback
}
if (!url) return new Response("secret unset", { status: 503 }); // fail-closed
// proxy-server (Node) is identical but reuses getControlSupabase() + src/appSecret.ts.

The resolved value is a live credential — never log it, never put it in an error body.

Source

Migration supabase/migrations/20260617020000_app_secrets_vault.sql; Edge helpers supabase/functions/_shared/vaultClient.ts + envSecure.ts; the VPS Node readers web/dashboard/app/lib/{appSecret.ts,supabase/service.ts} + web/proxy-server/src/appSecret.ts; MCP tools in supabase/functions/_shared/mcpRegistry.ts; BusyBro list-only gate in supabase/functions/_shared/busybroDispatch.ts. Migration tracker: notes/secrets-migration.md.