@skyhighmedia/byok-vault 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SkyHighMedia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @skyhighmedia/byok-vault
2
+
3
+ [![CI](https://github.com/Genesisapps11/byok-vault/actions/workflows/ci.yml/badge.svg)](https://github.com/Genesisapps11/byok-vault/actions/workflows/ci.yml)
4
+
5
+ **The BYOK vault module SkyHighMedia apps use to keep your provider API key safe — the actual code that does it.**
6
+
7
+ These apps are BYOK: you bring your own provider API key (e.g. your voice-AI key for
8
+ [SpeakOS](https://speakos.ai)). That key is powerful, so here is *exactly* how it is stored
9
+ and used. This is the real vault module from the products — open so anyone can verify the
10
+ claims below.
11
+
12
+ **Used by:** [SpeakOS](https://speakos.ai) _(and other SkyHighMedia apps)_.
13
+
14
+ ---
15
+
16
+ ## In plain English
17
+
18
+ - Your key is **locked in a vault** (encrypted). What sits in the database is scrambled
19
+ text — the unlock key is held by the database provider, **outside** the data, so a stolen
20
+ database dump is useless on its own.
21
+ - It is **only ever unlocked on the server, for the moment it is needed**, and never
22
+ shown to anyone — not other customers, not even to you through the app.
23
+ - It is **never logged by the vault**, which also ships a redaction helper to scrub
24
+ key-shaped tokens from the app's other logging paths.
25
+ - **You hold the off switch.** It's *your* account — revoke the key anytime from your own
26
+ provider dashboard and the app instantly has nothing.
27
+
28
+ ## The honest part (read this)
29
+
30
+ Open-sourcing this code proves our **design and intent**. It does **not** cryptographically
31
+ prove our servers run exactly this code — no amount of publishing can prove that. Concretely,
32
+ the real boundary is **custody of the server-side `service_role` key plus the SQL grants**:
33
+ whoever holds that key can decrypt a stored key server-side, so the guarantee can't rest on any
34
+ single line of app code. That's why the real, structural guarantee isn't our promise: it's
35
+ **BYOK**. You own the account, you see your own billing, and you can kill the key in one click.
36
+ The code below shows we built the careful version; BYOK means you never have to take our word for it.
37
+
38
+ ---
39
+
40
+ ## How it works (technical)
41
+
42
+ 1. **Encrypted at rest — Supabase Vault.** Keys are stored as Vault secrets (`sql/vault.sql`).
43
+ The ciphertext lives in `vault.secrets`; the encryption key is managed by Supabase outside
44
+ the database. A `pg_dump` yields ciphertext only.
45
+ 2. **Plaintext is `service_role`-only.** Two `SECURITY DEFINER` functions (`store_org_key`,
46
+ `get_org_key`) are the *only* way to reach a key. `EXECUTE` is **revoked from
47
+ `anon`/`authenticated`** and granted to `service_role` alone — so a logged-in customer
48
+ literally cannot call them, and the `vault` schema is never granted to tenant roles.
49
+ 3. **Row-Level Security.** A customer can read only their own metadata row — provider +
50
+ **last 4 characters**, never the key, never another tenant's.
51
+ 4. **No-log redaction** (`src/redact.ts`) scrubs key-shaped tokens from values the app
52
+ passes through it; the vault's own errors carry codes, never key material.
53
+ 5. **One chokepoint.** All *app-side* key handling lives in `src/keys.ts`. The boundary that
54
+ actually *enforces* this is the `service_role`-only SQL functions (#2) plus custody of the
55
+ service-role key — the TS file is the convention; the SQL grants are the enforcement.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pnpm add @skyhighmedia/byok-vault
61
+ # or: npm install @skyhighmedia/byok-vault
62
+ ```
63
+
64
+ ```ts
65
+ import { storeProviderKey, getProviderKey } from '@skyhighmedia/byok-vault'
66
+ import { redactSecrets } from '@skyhighmedia/byok-vault/redact'
67
+ ```
68
+
69
+ > **ESM-only** (Node 18+). Import via ESM; there is no CommonJS `require()` build.
70
+
71
+ The chokepoint reads `SUPABASE_URL` (or `NEXT_PUBLIC_SUPABASE_URL`) and
72
+ `SUPABASE_SERVICE_ROLE_KEY` from the environment. **Server-only:** the service-role key
73
+ bypasses RLS — never bundle it to a browser. In Next.js, re-export from a module that begins
74
+ with `import 'server-only'`.
75
+
76
+ ## What's in here
77
+
78
+ | Path | What |
79
+ |---|---|
80
+ | `src/keys.ts` | the store / decrypt-at-call chokepoint |
81
+ | `src/redact.ts` | the no-log redaction layer |
82
+ | `sql/vault.sql` | the vault schema: `org_keys`, the `service_role`-only functions, grants, RLS |
83
+ | `sql/context.sql` | minimal tenancy fixture `vault.sql` builds on (test-only; apps bring their own) |
84
+ | `test/keys.test.ts` | proves: round-trip, ciphertext-only storage, tenant role denied |
85
+
86
+ ## Adopting it in your own schema
87
+
88
+ `sql/vault.sql` ships with the SpeakOS tenancy as the default. It is parameterised by two
89
+ **HOST SCHEMA CONTRACT** points (documented at the top of the file): the tenant table the key
90
+ rows reference, and the metadata-read RLS predicate. Substitute your own (e.g. `tenants` + a
91
+ JWT-claim predicate) in your app's migration. **Keep the function names and param names
92
+ (`store_org_key` / `get_org_key`, `p_org_id`) unchanged** — the shared `src/keys.ts` calls
93
+ them by name, so keeping them is what lets every app use this package's chokepoint as-is.
94
+
95
+ ## Verify it yourself
96
+
97
+ ```bash
98
+ # 1. create a Supabase project, then apply the schema:
99
+ psql "$DATABASE_URL" -f sql/context.sql -f sql/vault.sql
100
+ # 2. set SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, DATABASE_URL in .env
101
+ pnpm install && pnpm test
102
+ ```
103
+
104
+ The tests are the proof — and they run in CI on every push (see the badge above).
105
+
106
+ ## License
107
+
108
+ MIT — see [LICENSE](./LICENSE). Part of the SkyHighMedia ecosystem · [speakos.ai](https://speakos.ai)
package/dist/keys.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function storeProviderKey(orgId: string, key: string, provider?: string): Promise<void>;
2
+ export declare function getProviderKey(orgId: string, provider?: string): Promise<string | null>;
package/dist/keys.js ADDED
@@ -0,0 +1,39 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // THE single chokepoint for provider-key plaintext.
4
+ // Keys are encrypted in Supabase Vault and only ever read here, server-side,
5
+ // via service_role-only SQL functions. Errors NEVER include key material.
6
+ //
7
+ // SERVER-ONLY: SUPABASE_SERVICE_ROLE_KEY bypasses RLS — never ship it to a
8
+ // browser. This module is framework-agnostic (no hard `import 'server-only'`);
9
+ // re-add that guard in your app's wrapper if your framework supports it.
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ const DEFAULT_PROVIDER = 'vapi';
12
+ function adminClient() {
13
+ // SUPABASE_URL is the canonical name; NEXT_PUBLIC_SUPABASE_URL is accepted as a
14
+ // fallback so Next.js apps don't need to duplicate the (non-secret) URL.
15
+ const url = process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
16
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
17
+ if (!url || !serviceRoleKey) {
18
+ throw new Error('vault: missing SUPABASE_URL and/or SUPABASE_SERVICE_ROLE_KEY');
19
+ }
20
+ return createClient(url, serviceRoleKey, { auth: { persistSession: false } });
21
+ }
22
+ export async function storeProviderKey(orgId, key, provider = DEFAULT_PROVIDER) {
23
+ const { error } = await adminClient().rpc('store_org_key', {
24
+ p_org_id: orgId,
25
+ p_provider: provider,
26
+ p_key: key,
27
+ });
28
+ if (error)
29
+ throw new Error(`vault: store failed (${error.code ?? 'unknown'})`);
30
+ }
31
+ export async function getProviderKey(orgId, provider = DEFAULT_PROVIDER) {
32
+ const { data, error } = await adminClient().rpc('get_org_key', {
33
+ p_org_id: orgId,
34
+ p_provider: provider,
35
+ });
36
+ if (error)
37
+ throw new Error(`vault: read failed (${error.code ?? 'unknown'})`);
38
+ return data ?? null;
39
+ }
@@ -0,0 +1 @@
1
+ export declare function redactSecrets(input: unknown): string;
package/dist/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ // No-log redaction layer. Scrub secret-looking tokens from any value before it
2
+ // is logged. Defense-in-depth: the vault module already avoids logging keys;
3
+ // this protects every other logging path (errors, request dumps, etc.).
4
+ const PATTERNS = [
5
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWTs
6
+ /\b(?:vk|sk|sb|pk|rk|key)[_-][A-Za-z0-9_-]{8,}/gi, // provider key prefixes
7
+ /\b[A-Fa-f0-9]{32,}\b/g, // long hex blobs
8
+ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, // UUIDs
9
+ ];
10
+ export function redactSecrets(input) {
11
+ let s;
12
+ if (typeof input === 'string') {
13
+ s = input;
14
+ }
15
+ else {
16
+ try {
17
+ s = JSON.stringify(input);
18
+ }
19
+ catch {
20
+ s = String(input);
21
+ }
22
+ }
23
+ for (const re of PATTERNS)
24
+ s = s.replace(re, '«redacted»');
25
+ return s;
26
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@skyhighmedia/byok-vault",
3
+ "version": "0.1.0",
4
+ "description": "BYOK provider-key vault — encrypted at rest in Supabase Vault, decrypted only server-side at point of use. The actual module SkyHighMedia apps run.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "packageManager": "pnpm@11.1.1",
8
+ "main": "./dist/keys.js",
9
+ "types": "./dist/keys.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/keys.d.ts",
13
+ "import": "./dist/keys.js"
14
+ },
15
+ "./redact": {
16
+ "types": "./dist/redact.d.ts",
17
+ "import": "./dist/redact.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "sql",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest run",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/Genesisapps11/byok-vault.git"
39
+ },
40
+ "keywords": [
41
+ "byok",
42
+ "supabase",
43
+ "vault",
44
+ "secrets",
45
+ "encryption",
46
+ "api-keys"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "peerDependencies": {
52
+ "@supabase/supabase-js": ">=2"
53
+ },
54
+ "devDependencies": {
55
+ "@supabase/supabase-js": "^2.108.0",
56
+ "@types/node": "^22.10.0",
57
+ "@types/pg": "^8.15.0",
58
+ "dotenv": "^17.0.0",
59
+ "pg": "^8.21.0",
60
+ "typescript": "^5.7.0",
61
+ "vitest": "^4.1.8"
62
+ }
63
+ }
@@ -0,0 +1,34 @@
1
+ -- TEST FIXTURE ONLY — host apps NEVER apply this file. It is the minimal tenancy
2
+ -- context that the DEFAULT sql/vault.sql builds on (the `orgs` table + the
3
+ -- `current_user_org_ids()` predicate), so vault.sql applies and test/keys.test.ts
4
+ -- runs standalone against a fresh Supabase project. Each real app brings its own
5
+ -- tenancy (see the HOST SCHEMA CONTRACT header in sql/vault.sql).
6
+
7
+ create type org_type as enum ('direct', 'agency', 'client');
8
+ create type org_role as enum ('owner', 'admin', 'member', 'viewer', 'guardian');
9
+
10
+ create table orgs (
11
+ id uuid primary key default gen_random_uuid(),
12
+ type org_type not null,
13
+ name text not null,
14
+ parent_org_id uuid references orgs(id) on delete restrict,
15
+ created_at timestamptz not null default now()
16
+ );
17
+
18
+ create table org_memberships (
19
+ id uuid primary key default gen_random_uuid(),
20
+ org_id uuid not null references orgs(id) on delete cascade,
21
+ user_id uuid not null,
22
+ role org_role not null default 'member',
23
+ unique (org_id, user_id)
24
+ );
25
+
26
+ -- Orgs the current user may access (own orgs + child orgs they own/admin).
27
+ create or replace function current_user_org_ids()
28
+ returns setof uuid language sql stable security definer set search_path = public as $$
29
+ select m.org_id from org_memberships m where m.user_id = auth.uid()
30
+ union
31
+ select o.id from orgs o
32
+ join org_memberships m on m.org_id = o.parent_org_id
33
+ where m.user_id = auth.uid() and m.role in ('owner', 'admin')
34
+ $$;
package/sql/vault.sql ADDED
@@ -0,0 +1,93 @@
1
+ -- BYOK key vault. Provider keys are stored in Supabase Vault — ciphertext in
2
+ -- vault.secrets, the encryption key held by Supabase outside the DB, so a DB
3
+ -- dump alone is useless. Plaintext is reachable ONLY via the two security-
4
+ -- definer functions below, which are granted to service_role alone.
5
+ --
6
+ -- ┌─ HOST SCHEMA CONTRACT ──────────────────────────────────────────────────┐
7
+ -- │ This template is shown with the default (SpeakOS) tenancy. Each host app │
8
+ -- │ applies its own migration substituting TWO contract points: │
9
+ -- │ │
10
+ -- │ 1. TENANT TABLE the key rows reference. │
11
+ -- │ default: org_id uuid references orgs(id) │
12
+ -- │ e.g.: tenant_id uuid references tenants(id) │
13
+ -- │ │
14
+ -- │ 2. METADATA-READ PREDICATE for the SELECT RLS policy — the tenant rows │
15
+ -- │ the current user may see (provider + last4 only; never the key). │
16
+ -- │ default: org_id in (select current_user_org_ids()) │
17
+ -- │ e.g.: tenant_id = (select (auth.jwt() -> 'app_metadata' │
18
+ -- │ ->> 'tenant_id')::uuid) │
19
+ -- │ │
20
+ -- │ FIXED RPC CONTRACT (do NOT rename): the shared TS chokepoint │
21
+ -- │ (src/keys.ts) calls these functions BY NAME with these param names: │
22
+ -- │ store_org_key(p_org_id uuid, p_provider text, p_key text) │
23
+ -- │ get_org_key(p_org_id uuid, p_provider text) │
24
+ -- │ Even if your tenant column is named differently, keep the function and │
25
+ -- │ param names and pass your tenant id as p_org_id — that is what keeps the │
26
+ -- │ published package's keys.ts usable unchanged across apps. │
27
+ -- └──────────────────────────────────────────────────────────────────────────┘
28
+
29
+ create table org_keys (
30
+ id uuid primary key default gen_random_uuid(),
31
+ org_id uuid not null references orgs(id) on delete cascade, -- contract point 1
32
+ provider text not null default 'vapi',
33
+ vault_secret_id uuid not null, -- points at vault.secrets; NOT the plaintext
34
+ key_last4 text not null,
35
+ created_at timestamptz not null default now(),
36
+ updated_at timestamptz not null default now(),
37
+ unique (org_id, provider)
38
+ );
39
+
40
+ alter table org_keys enable row level security;
41
+ -- Supabase's default public-table ACL grants anon/authenticated full DML. Revoke it
42
+ -- EXPLICITLY so tenant roles cannot write key metadata (e.g. repoint vault_secret_id
43
+ -- at another tenant's secret, or spoof key_last4). RLS already blocks writes (no
44
+ -- permissive write policy exists), but this makes the lockdown explicit rather than
45
+ -- "one accidental policy away".
46
+ revoke all on org_keys from anon, authenticated;
47
+ grant select on org_keys to authenticated; -- metadata only (provider + last4); no plaintext lives here
48
+ create policy org_keys_select on org_keys for select
49
+ using (org_id in (select current_user_org_ids())); -- contract point 2
50
+ -- writes go ONLY through store_org_key (security definer, service_role): no write policy + revoked DML
51
+
52
+ -- Store or replace a provider key: upserts the Vault secret + the metadata row.
53
+ create or replace function store_org_key(p_org_id uuid, p_provider text, p_key text)
54
+ returns void language plpgsql security definer set search_path = public, vault as $$
55
+ declare v_existing uuid; v_id uuid;
56
+ begin
57
+ if p_key is null or p_key = '' then
58
+ raise exception 'vault: empty key';
59
+ end if;
60
+ -- Serialize concurrent writes for the same (org, provider) so two racing first-time
61
+ -- creates can't each call vault.create_secret and leave one secret orphaned. The
62
+ -- lock is transaction-scoped (released on commit/rollback).
63
+ perform pg_advisory_xact_lock(hashtextextended(p_org_id::text || ':' || p_provider, 0));
64
+ select vault_secret_id into v_existing from org_keys where org_id = p_org_id and provider = p_provider;
65
+ if v_existing is not null then
66
+ perform vault.update_secret(v_existing, p_key);
67
+ v_id := v_existing;
68
+ else
69
+ v_id := vault.create_secret(p_key, 'provider_key:' || p_org_id || ':' || p_provider, 'BYOK provider key');
70
+ end if;
71
+ insert into org_keys (org_id, provider, vault_secret_id, key_last4)
72
+ values (p_org_id, p_provider, v_id, right(p_key, 4))
73
+ on conflict (org_id, provider)
74
+ do update set vault_secret_id = excluded.vault_secret_id, key_last4 = excluded.key_last4, updated_at = now();
75
+ end $$;
76
+
77
+ -- Fetch the decrypted provider key (service_role only).
78
+ create or replace function get_org_key(p_org_id uuid, p_provider text)
79
+ returns text language plpgsql security definer set search_path = public, vault as $$
80
+ declare v_id uuid; v_secret text;
81
+ begin
82
+ select vault_secret_id into v_id from org_keys where org_id = p_org_id and provider = p_provider;
83
+ if v_id is null then return null; end if;
84
+ select decrypted_secret into v_secret from vault.decrypted_secrets where id = v_id;
85
+ return v_secret;
86
+ end $$;
87
+
88
+ revoke all on function store_org_key(uuid, text, text) from public, anon, authenticated;
89
+ revoke all on function get_org_key(uuid, text) from public, anon, authenticated;
90
+ grant execute on function store_org_key(uuid, text, text) to service_role;
91
+ grant execute on function get_org_key(uuid, text) to service_role;
92
+
93
+ notify pgrst, 'reload schema';