Encryption at rest
All vendor credentials use pgp_sym_encrypt with a server-side key (ARCHIVE_ENC_KEY env var). Postgres-level pgcrypto means even a stolen DB snapshot is useless without the key.
How Mnueron isolates each org's data, encrypts vendor credentials at rest, and stays auditable for enterprise customers. Pair this with the architecture overview — this page is for security reviewers.
All vendor credentials use pgp_sym_encrypt with a server-side key (ARCHIVE_ENC_KEY env var). Postgres-level pgcrypto means even a stolen DB snapshot is useless without the key.
Every connection — browser-to-app, app-to-Supabase, app-to-LLM-provider — uses TLS 1.2+. We don't carry secrets over plaintext channels.
Postgres RLS scopes every tenant table by org_id. The app sets app.current_org_id GUC at request start. A buggy query that forgets WHERE org_id = … still can't leak rows.
Decrypted values never enter console.log, structured logs, or error messages. The API only sees last4 (the masked preview) and audit timestamps.
App-layer authorization, RLS, encryption, and admin-only routes for cross-org reads all work together. Removing any single layer doesn't expose data.
ARCHIVE_ENC_KEY can be rotated by writing a re-encrypt migration. Org members can rotate their own API keys from /account-settings/keys at any time — last_verified_at + verify_status help track validity.
Every customer is one row in the orgs table. Everything that belongs to that customer carries org_idas a foreign key and is scoped by RLS at the database level. There is no shared table where multiple orgs' data sits unfiltered.
meetings · meeting_attendees · meeting_sources · meeting_linksdecisions · action_itemsmemories · namespaces · memory_visibilityprojects · project_assignment_rulesorg_secrets · org_archive_config · archive_runsrecall_events · memory_recallsentities · relations · consolidationsMnueron does not have a separate org_memberships table. Membership is implicit through api_tokens: each row carries both user_id and org_id. A user belongs to an org if and only if at least one of their api_tokens points to that org. This keeps the schema small and makes deleting access (revoking a token) an atomic operation.
-- Every tenant-scoped table follows this template.
-- Mnueron's app code sets app.current_org_id at the start of every
-- request via withTenantScope() before any DB statement runs.
CREATE POLICY <table>_tenant_isolation ON <table>
FOR ALL
USING (org_id::text = current_setting('app.current_org_id', true))
WITH CHECK (org_id::text = current_setting('app.current_org_id', true));withTenantScope(ctx, fn) in src/lib/db.ts is the ONLY public-facing way to talk to the DB. It calls SELECT set_config('app.current_org_id', $1, false) before fn runs and resets after.
Anything that grants third-party access is encrypted. The DB stores only the encrypted blob plus a masked preview of the last 4 characters. The encryption key (ARCHIVE_ENC_KEY) lives only in the server's environment variables — it never enters the DB, never reaches a browser, never appears in commits.
| Column | Contains | Encryption |
|---|---|---|
| org_secrets.value_encrypted | OpenAI / Anthropic / Granola / Fathom / Zoom / Teams keys | pgp_sym_encrypt(value, ARCHIVE_ENC_KEY) |
| org_archive_config.secret_encrypted | S3 / R2 / B2 secret access keys | pgp_sym_encrypt(value, ARCHIVE_ENC_KEY) |
| api_tokens.token_hash | User session tokens | scrypt-derived hash; never reversible |
| users.password_hash | User passwords | bcryptjs |
The flow below is enforced in code in src/lib/secrets/db.ts and the /api/account/secrets/* routes:
// On WRITE:
// user pastes value → /api/account/secrets PUT
// → cleaned + last4 computed
// → INSERT ... VALUES (pgp_sym_encrypt($value, $encKey))
// → DB stores only the encrypted bytea + last4 ("…wxyz")
// → response returns { kind, last4 }; never the plaintext.
// On READ (pipeline / cron / extractors only):
// await resolveSecretAdmin(orgId, "openai_key")
// → SELECT pgp_sym_decrypt(value_encrypted, $encKey) WHERE org_id = $1
// → returns plaintext string in process memory ONLY for the duration
// of the LLM call. Never logged, never persisted, never returned
// through any API response.
// On VIEW (dashboard listing):
// /api/account/secrets GET
// → projection EXCLUDES value_encrypted entirely
// → returns kind + last4 + audit fields onlyThree explicit rules that the codebase enforces:
listOrgSecrets() projects only kind, last4, audit fields— the SQL doesn't select the ciphertext at all.resolveSecretAdmin() runs in admin context (bypasses RLS), takes an explicit org_idparameter, and never returns another org's secret because WHERE org_id = $1 is hard-coded into the query.console.log, no error message, no structured event carries the plaintext. The only place it exists is in the local variable used to make the HTTP call to the upstream provider.Every secret-bearing table has the same audit shape so SOC2-style reviews are straightforward:
created_by + created_at — who introduced itupdated_at — last write (rotation, edit)last_used_at — last decrypt by the pipeline / cronlast_verified_at + verify_status — most recent test resultMutating routes record actor + timestamp on every change. Audit-only tables that complement this:
archive_runs — one row per archive worker invocation (manual or cron), including ok flag and error_message.billing_events — every Stripe webhook landed, idempotent by stripe_event_id.recall_events — every memory recall captured for the savings analytics. Includes org, client, model, query length.account_deletion_audit — records account deletion requests for compliance follow-up.memories rows)For orgs that want even tighter control: the archive feature offloads aged meeting bodies to a bucket your org owns as self-describing .md files. After the offload, our DB keeps only a stub row (id + title + url) and you can delete the bucket files yourself any time.
For the most security-sensitive orgs, the mnueron CLI supports local-only mode: meeting memory lives in SQLite on the user's laptop and never leaves the machine. No cloud sync, no third-party APIs unless the user adds their own LLM key. Configure via ~/.mnueron/config.json and choose "Local only" at /account-settings/storage.
Every schema change ships as a numbered SQL file in supabase/migrations/. Notable ones for the security story:
016_org_plan_tier.sql — orgs + plan tiers (free/basic/pro/team/enterprise).017_stripe_billing.sql — billing + audit event tables.024_storage_quota.sql — per-org storage quota enforcement.027_account_deletion_audit.sql — compliance audit for account deletion.048_memory_visibility.sql — per-memory visibility (private / shared-link).050_meetings.sql — Meet pipeline schema with RLS on every table.052_storage_modes_and_archive.sql — first pgcrypto-encrypted column (archive secret).053_meeting_sources_auth_jsonb.sql — convert vendor auth from bytea to jsonb idempotently.054_projects.sql — projects layer + classifier rules.055_meeting_synopsis.sql — adds AI synopsis columns.056_org_secrets.sql — unified encrypted store for all per-org API keys.057_secret_access_log.sql — append-only audit trail of every secret decryption.058_org_secrets_envelope.sql — envelope encryption columns (encryption_mode, encrypted_dek, iv, kms_key_id). Enables AWS KMS path alongside legacy pgcrypto.Generate a new 32-byte base64 key, deploy it as a SECOND env var (e.g. ARCHIVE_ENC_KEY_NEXT), run a one-off migration that re-encrypts every org_secrets.value_encrypted and org_archive_config.secret_encrypted with the new key, promote the new env var to be the primary, then drop the old one. Plan for read downtime ≤ 1 minute on the swap or use blue/green.
Update CRON_SECRET in Vercel, redeploy, then update local .env values on every machine running npm run sync:granola or archive:run. There's no DB migration needed — the value is only ever compared in-memory against the request header.
Delete from api_tokens where user_id = ? ororg_id = ?. Because membership is implicit through tokens, this immediately removes that user from the org's data. RLS prevents any prior session cookies from resolving.
Mnueron supports two encryption modes for stored credentials. Operators choose at deploy time by setting two env vars:
MNUERON_KMS_ENABLED=trueAWS_KMS_KEY_ID=<key ARN>When both are set, every NEW secret saved through the UI is encrypted under envelope_v1:
GenerateDataKey.org_secrets.encrypted_dek.Decrypt, gets back the plaintext DEK, AES-decrypts the secret locally, wipes the DEK. One KMS call per secret read.What this unlocks: FIPS 140-2 Level 3 validated crypto (KMS is FIPS-certified), per-row blast radius (compromise of one DEK exposes one secret, not all), and CloudTrail audit of every KMS call by IAM principal. Pairs with Mnueron's own secret_access_log for in-app activity.
Per-row migration from the legacy pgcrypto path runs one org at a time:
ORG_SLUG=acme-insurance npm run migrate:org-to-kmsExisting rows stay in encryption_mode = 'pgcrypto' until migrated; the dispatch logic in resolveSecretAdmin reads either mode transparently. Mid-rollout is safe.
BYOK (customer-managed keys): the schema includes a kms_key_id column per row so a future per-org BYOK UI can let enterprise customers point each org at their own KMS key ARN. Their KMS key policy grants Mnueron's IAM principal kms:GenerateDataKey + kms:Decrypt; we never see the underlying master.
Mnueron's current security bar is appropriate for solo developers, startups, mid-market SaaS, and most public-company departments. It's honest to tell you what we don't yet offer for stricter use cases so you don't fail a vendor security review halfway through procurement:
| Gap | What strict reviewers want | On our roadmap? |
|---|---|---|
| HSM / KMS | Master key in FIPS 140-2 Level 2+ HSM (AWS KMS, GCP KMS, Vault) — never in an env var | ✓ Available (AWS KMS envelope encryption — operator enables per deploy) |
| Customer-managed keys (BYOK) | Provide your own KMS key, retain control of decryption | Yes — depends on KMS first |
| FIPS 140-2 validated crypto | Federal contracts and some healthcare require this | ✓ Available via AWS KMS (Level 3 certified) |
| Per-row DEKs (envelope encryption) | Compromise of one DEK exposes one secret, not all | ✓ Available when envelope_v1 enabled |
| FedRAMP / SOC2 Type 2 | Audited controls, attestation report | Not pursued yet — happy to discuss |
| Dedicated tenancy | Isolated DB cluster, isolated network, no shared compute | Available on Enterprise contract |
| Customer type | Likely outcome today |
|---|---|
| Solo developer / startup / SMB | Pass |
| Mid-market SaaS (50–500 employees) | Pass with this doc |
| Public company InfoSec review | Conditional — likely wants secret access logs (we have) + documented rotation (we have) |
| Healthcare with BAA / HIPAA strict | Conditional — need BAA + KMS |
| Bank / financial services | Needs KMS + dedicated tenancy |
| Federal / FedRAMP | Major rebuild required |
If you're evaluating Mnueron and need controls beyond what's documented above, reach out — most of these gaps are paid-tier features we can light up per-contract rather than globally.