diff --git a/packages/browser/src/shared/kms.ts b/packages/browser/src/shared/kms.ts index 040a1e5..03fe135 100644 --- a/packages/browser/src/shared/kms.ts +++ b/packages/browser/src/shared/kms.ts @@ -1,47 +1,49 @@ /** * KMS (Key Management / Secrets) client — shared across all browser targets. * - * Lightweight fetch-based client for Hanzo KMS (kms.hanzo.ai). - * Uses IAM bearer tokens for auth — no separate KMS credentials needed. + * Lightweight fetch-based client for Hanzo KMS. Uses IAM bearer tokens for + * auth — no separate KMS credentials needed. * - * API path. Hanzo convention is `/v1//` — but the KMS - * upstream publishes its v3 API under the root - * `${KMS_PATH_PREFIX}/secrets/raw`. The kms.hanzo.ai gateway currently passes that - * through unchanged (verified: `kms.hanzo.ai/api/v3/secrets/raw` → 401 JSON, - * `/v1/kms/...` → SPA HTML). We centralise the path in `KMS_PATH_PREFIX` - * so the day the gateway adds the rewrite we update one constant. + * Every path is `/v1/kms/...`, which is the Hanzo `/v1//` + * convention and, more importantly, what the server actually serves: * - * GET {KMS_PATH_PREFIX}/secrets/raw → list - * GET {KMS_PATH_PREFIX}/secrets/raw/:name → get - * POST {KMS_PATH_PREFIX}/secrets/raw/:name → create - * PATCH {KMS_PATH_PREFIX}/secrets/raw/:name → update - * DELETE {KMS_PATH_PREFIX}/secrets/raw/:name → delete + * GET /v1/kms/orgs/{org}/secrets?path=&env= → list names + * GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env= → read one value + * POST /v1/kms/orgs/{org}/secrets → create or replace + * DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env= → delete + * + * This file used to speak Infisical's shape under an `/api/v3` prefix, with a + * header comment reporting that `/v1/kms/...` had been *verified* to return SPA + * HTML while `/api/v3/secrets/raw` returned JSON — so `/api/v3` was pinned as + * the working path and a rewrite was awaited. The observation was real and the + * conclusion was backwards: the KMS binary embedded a console SPA under a root + * catch-all, so it answered EVERY unmatched path with 200 text/html. The HTML + * was the 404. luxfi/kms 1.12.8 removed the catch-all, and the same probe now + * reads the other way round — `/v1/kms/...` answers JSON, `/api/v3/...` is a + * JSON 404. There is no gateway rewrite to wait for. + * + * Create and update are ONE call because the server has one upsert, not two + * endpoints. Listing arrived in luxfi/kms 1.12.9; before that the HTTP surface + * had no list at all, which is what made the Infisical prefix look necessary. */ import { KMS_API_URL } from './config.js'; -/** Prefix for every KMS request. See file header. */ -const KMS_PATH_PREFIX = '/api/v3'; - // ── Types ─────────────────────────────────────────────────────────────── -export interface KmsSecret { - secretKey: string; - secretValue?: string; - version?: number; - type?: string; - environment?: string; - secretPath?: string; +/** Where a secret lives. `path` groups secrets; `name` identifies one. */ +export interface KmsRef { + /** Org scope — also the scope of the bearer token that reads it. */ + org: string; + /** Environment slug (`dev` / `test` / `main`). Defaults to `default`. */ + env?: string; + /** Grouping path, e.g. `gateway`. Omit to address the org root. */ + path?: string; } -export interface KmsListParams { - workspaceId: string; - environment: string; - secretPath?: string; -} - -export interface KmsSecretParams extends KmsListParams { - secretName: string; +/** A ref plus the name of a single secret. */ +export interface KmsSecretRef extends KmsRef { + name: string; } // ── Helpers ───────────────────────────────────────────────────────────── @@ -57,121 +59,94 @@ function kmsUrl(path: string, baseUrl?: string): string { return `${baseUrl || KMS_API_URL}${path}`; } -function buildQuery(params: Record): string { - const entries = Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][]; - if (!entries.length) return ''; - return '?' + new URLSearchParams(entries).toString(); +/** The collection URL for an org, with `path`/`env` as query. */ +function collectionUrl(ref: KmsRef, baseUrl?: string): string { + const q = new URLSearchParams(); + if (ref.path) q.set('path', ref.path); + q.set('env', ref.env || 'default'); + return kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets?${q}`, baseUrl); +} + +/** + * The URL of one secret. The server splits the trailing `{rest...}` at its LAST + * slash into (path, name), so each segment is escaped on its own — escaping the + * joined string would encode the separators and the server would read one long + * name. + */ +function secretUrl(ref: KmsSecretRef, baseUrl?: string): string { + const segs = [...(ref.path ? ref.path.split('/') : []), ref.name] + .filter((s) => s.length > 0) + .map(encodeURIComponent) + .join('/'); + const q = new URLSearchParams({ env: ref.env || 'default' }); + return kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets/${segs}?${q}`, baseUrl); +} + +async function fail(op: string, resp: Response): Promise { + throw new Error(`KMS ${op} failed: ${resp.status} ${await resp.text()}`); } // ── API Functions ─────────────────────────────────────────────────────── -/** List secrets in a workspace/environment */ +/** List the names of the secrets under a path. */ export async function kmsListSecrets( token: string, - params: KmsListParams, + ref: KmsRef, baseUrl?: string, -): Promise { - const query = buildQuery({ - workspaceId: params.workspaceId, - environment: params.environment, - secretPath: params.secretPath, - }); - const resp = await fetch(kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw${query}`, baseUrl), { - headers: kmsHeaders(token), - }); - if (!resp.ok) throw new Error(`KMS list failed: ${resp.status} ${await resp.text()}`); +): Promise { + const resp = await fetch(collectionUrl(ref, baseUrl), { headers: kmsHeaders(token) }); + if (!resp.ok) return fail('list', resp); const data = await resp.json(); - return data.secrets || data; + return data.names ?? []; } -/** Get a single secret by name */ +/** Read one secret's value. */ export async function kmsGetSecret( token: string, - params: KmsSecretParams, + ref: KmsSecretRef, baseUrl?: string, -): Promise { - const query = buildQuery({ - workspaceId: params.workspaceId, - environment: params.environment, - secretPath: params.secretPath, - }); - const resp = await fetch( - kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl), - { headers: kmsHeaders(token) }, - ); - if (!resp.ok) throw new Error(`KMS get failed: ${resp.status} ${await resp.text()}`); +): Promise { + const resp = await fetch(secretUrl(ref, baseUrl), { headers: kmsHeaders(token) }); + if (!resp.ok) return fail('get', resp); const data = await resp.json(); - return data.secret || data; + return data.secret?.value ?? ''; } -/** Create a new secret */ -export async function kmsCreateSecret( +/** + * Create a secret, or replace it if the name already exists. One call, because + * the server has one upsert — there is no separate create and update. + */ +export async function kmsPutSecret( token: string, - params: KmsSecretParams, + ref: KmsSecretRef, value: string, baseUrl?: string, -): Promise { +): Promise { const resp = await fetch( - kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl), + kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets`, baseUrl), { method: 'POST', headers: kmsHeaders(token), body: JSON.stringify({ - workspaceId: params.workspaceId, - environment: params.environment, - secretPath: params.secretPath, - secretValue: value, - type: 'shared', + path: ref.path ?? '', + name: ref.name, + env: ref.env || 'default', + value, }), }, ); - if (!resp.ok) throw new Error(`KMS create failed: ${resp.status} ${await resp.text()}`); - const data = await resp.json(); - return data.secret || data; + if (!resp.ok) return fail('put', resp); } -/** Update an existing secret */ -export async function kmsUpdateSecret( - token: string, - params: KmsSecretParams, - value: string, - baseUrl?: string, -): Promise { - const resp = await fetch( - kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl), - { - method: 'PATCH', - headers: kmsHeaders(token), - body: JSON.stringify({ - workspaceId: params.workspaceId, - environment: params.environment, - secretPath: params.secretPath, - secretValue: value, - }), - }, - ); - if (!resp.ok) throw new Error(`KMS update failed: ${resp.status} ${await resp.text()}`); - const data = await resp.json(); - return data.secret || data; -} - -/** Delete a secret */ +/** Delete a secret. */ export async function kmsDeleteSecret( token: string, - params: KmsSecretParams, + ref: KmsSecretRef, baseUrl?: string, ): Promise { - const resp = await fetch( - kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl), - { - method: 'DELETE', - headers: kmsHeaders(token), - body: JSON.stringify({ - workspaceId: params.workspaceId, - environment: params.environment, - secretPath: params.secretPath, - }), - }, - ); - if (!resp.ok) throw new Error(`KMS delete failed: ${resp.status} ${await resp.text()}`); + const resp = await fetch(secretUrl(ref, baseUrl), { + method: 'DELETE', + headers: kmsHeaders(token), + }); + if (!resp.ok) return fail('delete', resp); } diff --git a/packages/browser/tests/shared-kms.test.ts b/packages/browser/tests/shared-kms.test.ts index 6ad4bc8..339e0b8 100644 --- a/packages/browser/tests/shared-kms.test.ts +++ b/packages/browser/tests/shared-kms.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { kmsListSecrets, kmsGetSecret, - kmsCreateSecret, - kmsUpdateSecret, + kmsPutSecret, kmsDeleteSecret, } from '../src/shared/kms'; @@ -24,49 +23,95 @@ function mockResponse(data: any, ok = true, status = 200) { } const TOKEN = 'test-bearer-token'; -const PARAMS = { - workspaceId: 'ws-123', - environment: 'production', - secretPath: '/app', -}; +const REF = { org: 'hanzo', env: 'production', path: 'app' }; +const SECRET = { ...REF, name: 'API_KEY' }; + +function calledUrl(): string { + return mockFetch.mock.calls[0][0] as string; +} + +function calledInit(): any { + return mockFetch.mock.calls[0][1]; +} beforeEach(() => { mockFetch.mockReset(); }); +// --------------------------------------------------------------------------- +// Path shape — the regression that actually happened +// --------------------------------------------------------------------------- + +describe('KMS paths', () => { + it('never emits an /api/ prefix', async () => { + // This client used to speak /api/v3/secrets/raw, because the KMS binary + // embedded a console SPA under a root catch-all and answered every + // unmatched path with 200 text/html — so the wrong path looked alive and + // the right one looked broken. Nothing may drift back to it. + mockFetch.mockResolvedValue(mockResponse({ names: [] })); + await kmsListSecrets(TOKEN, REF); + mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'v' } })); + await kmsGetSecret(TOKEN, SECRET); + mockFetch.mockResolvedValue(mockResponse({})); + await kmsPutSecret(TOKEN, SECRET, 'v'); + mockFetch.mockResolvedValue(mockResponse({})); + await kmsDeleteSecret(TOKEN, SECRET); + + expect(mockFetch.mock.calls).toHaveLength(4); + for (const [url] of mockFetch.mock.calls) { + expect(url).not.toContain('/api/'); + expect(url).toContain('/v1/kms/'); + } + }); + + it('escapes path segments individually so separators survive', async () => { + // The server splits {rest...} at its LAST slash into (path, name). Escaping + // the joined string would encode the separators into one long name. + mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'v' } })); + await kmsGetSecret(TOKEN, { org: 'hanzo', env: 'main', path: 'a/b', name: 'C D' }); + expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets/a/b/C%20D'); + }); +}); + // --------------------------------------------------------------------------- // kmsListSecrets // --------------------------------------------------------------------------- describe('kmsListSecrets', () => { - it('sends GET with correct URL and query params', async () => { - mockFetch.mockResolvedValue(mockResponse({ secrets: [{ secretKey: 'API_KEY' }] })); - - const result = await kmsListSecrets(TOKEN, PARAMS); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toContain('kms.hanzo.ai/api/v3/secrets/raw?'); - expect(url).toContain('workspaceId=ws-123'); - expect(url).toContain('environment=production'); - expect(url).toContain('secretPath=%2Fapp'); - expect(opts.headers.Authorization).toBe(`Bearer ${TOKEN}`); - expect(result).toEqual([{ secretKey: 'API_KEY' }]); + it('returns the names array', async () => { + mockFetch.mockResolvedValue(mockResponse({ names: ['API_KEY', 'DB_URL'] })); + await expect(kmsListSecrets(TOKEN, REF)).resolves.toEqual(['API_KEY', 'DB_URL']); }); - it('uses custom baseUrl when provided', async () => { - mockFetch.mockResolvedValue(mockResponse({ secrets: [] })); - - await kmsListSecrets(TOKEN, PARAMS, 'https://custom-kms.example.com'); - - const [url] = mockFetch.mock.calls[0]; - expect(url).toContain('custom-kms.example.com/api/v3/secrets/raw'); + it('addresses the collection with path and env as query, and sends the bearer', async () => { + mockFetch.mockResolvedValue(mockResponse({ names: [] })); + await kmsListSecrets(TOKEN, REF); + expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets?'); + expect(calledUrl()).toContain('path=app'); + expect(calledUrl()).toContain('env=production'); + expect(calledInit().headers.Authorization).toBe(`Bearer ${TOKEN}`); }); - it('throws on non-ok response', async () => { + it('defaults env when the ref omits it', async () => { + mockFetch.mockResolvedValue(mockResponse({ names: [] })); + await kmsListSecrets(TOKEN, { org: 'hanzo' }); + expect(calledUrl()).toContain('env=default'); + }); + + it('yields an empty array when the server omits names', async () => { + mockFetch.mockResolvedValue(mockResponse({})); + await expect(kmsListSecrets(TOKEN, REF)).resolves.toEqual([]); + }); + + it('uses a custom baseUrl when provided', async () => { + mockFetch.mockResolvedValue(mockResponse({ names: [] })); + await kmsListSecrets(TOKEN, REF, 'https://custom-kms.example.com'); + expect(calledUrl()).toContain('custom-kms.example.com/v1/kms/orgs/hanzo/secrets'); + }); + + it('throws with the status on failure', async () => { mockFetch.mockResolvedValue(mockResponse('Not found', false, 404)); - - await expect(kmsListSecrets(TOKEN, PARAMS)).rejects.toThrow('KMS list failed: 404'); + await expect(kmsListSecrets(TOKEN, REF)).rejects.toThrow('KMS list failed: 404'); }); }); @@ -75,78 +120,47 @@ describe('kmsListSecrets', () => { // --------------------------------------------------------------------------- describe('kmsGetSecret', () => { - it('sends GET with encoded secret name', async () => { - mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'DB_URL', secretValue: 'postgres://...' } })); - - const result = await kmsGetSecret(TOKEN, { ...PARAMS, secretName: 'DB_URL' }); - - const [url] = mockFetch.mock.calls[0]; - expect(url).toContain('/api/v3/secrets/raw/DB_URL'); - expect(result.secretKey).toBe('DB_URL'); + it('unwraps secret.value', async () => { + mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'postgres://...' } })); + await expect(kmsGetSecret(TOKEN, SECRET)).resolves.toBe('postgres://...'); }); - it('URL-encodes special characters in secret name', async () => { - mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'app/key' } })); - - await kmsGetSecret(TOKEN, { ...PARAMS, secretName: 'app/key' }); - - const [url] = mockFetch.mock.calls[0]; - expect(url).toContain('app%2Fkey'); + it('addresses the secret and carries env', async () => { + mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'v' } })); + await kmsGetSecret(TOKEN, SECRET); + expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets/app/API_KEY'); + expect(calledUrl()).toContain('env=production'); }); - it('throws on non-ok response', async () => { + it('throws with the status on failure', async () => { + mockFetch.mockResolvedValue(mockResponse('Not found', false, 404)); + await expect(kmsGetSecret(TOKEN, SECRET)).rejects.toThrow('KMS get failed: 404'); + }); +}); + +// --------------------------------------------------------------------------- +// kmsPutSecret — create and replace are one call, because the server has one +// --------------------------------------------------------------------------- + +describe('kmsPutSecret', () => { + it('POSTs to the collection with the flat body the server takes', async () => { + mockFetch.mockResolvedValue(mockResponse({})); + await kmsPutSecret(TOKEN, SECRET, 'sk-live-123'); + + expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets'); + expect(calledUrl()).not.toContain('API_KEY'); // name is in the body, not the URL + expect(calledInit().method).toBe('POST'); + expect(JSON.parse(calledInit().body)).toEqual({ + path: 'app', + name: 'API_KEY', + env: 'production', + value: 'sk-live-123', + }); + }); + + it('throws with the status on failure', async () => { mockFetch.mockResolvedValue(mockResponse('Forbidden', false, 403)); - - await expect(kmsGetSecret(TOKEN, { ...PARAMS, secretName: 'X' })).rejects.toThrow('KMS get failed: 403'); - }); -}); - -// --------------------------------------------------------------------------- -// kmsCreateSecret -// --------------------------------------------------------------------------- - -describe('kmsCreateSecret', () => { - it('sends POST with correct body', async () => { - mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'NEW_KEY' } })); - - await kmsCreateSecret(TOKEN, { ...PARAMS, secretName: 'NEW_KEY' }, 'secret-value'); - - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toContain('/api/v3/secrets/raw/NEW_KEY'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.secretValue).toBe('secret-value'); - expect(body.workspaceId).toBe('ws-123'); - expect(body.environment).toBe('production'); - expect(body.type).toBe('shared'); - }); - - it('includes Authorization header', async () => { - mockFetch.mockResolvedValue(mockResponse({ secret: {} })); - - await kmsCreateSecret(TOKEN, { ...PARAMS, secretName: 'X' }, 'val'); - - const [, opts] = mockFetch.mock.calls[0]; - expect(opts.headers.Authorization).toBe(`Bearer ${TOKEN}`); - expect(opts.headers['Content-Type']).toBe('application/json'); - }); -}); - -// --------------------------------------------------------------------------- -// kmsUpdateSecret -// --------------------------------------------------------------------------- - -describe('kmsUpdateSecret', () => { - it('sends PATCH with correct body', async () => { - mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'KEY' } })); - - await kmsUpdateSecret(TOKEN, { ...PARAMS, secretName: 'KEY' }, 'new-value'); - - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toContain('/api/v3/secrets/raw/KEY'); - expect(opts.method).toBe('PATCH'); - const body = JSON.parse(opts.body); - expect(body.secretValue).toBe('new-value'); + await expect(kmsPutSecret(TOKEN, SECRET, 'v')).rejects.toThrow('KMS put failed: 403'); }); }); @@ -155,21 +169,16 @@ describe('kmsUpdateSecret', () => { // --------------------------------------------------------------------------- describe('kmsDeleteSecret', () => { - it('sends DELETE with correct body', async () => { + it('DELETEs the secret URL', async () => { mockFetch.mockResolvedValue(mockResponse({})); - - await kmsDeleteSecret(TOKEN, { ...PARAMS, secretName: 'OLD_KEY' }); - - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toContain('/api/v3/secrets/raw/OLD_KEY'); - expect(opts.method).toBe('DELETE'); - const body = JSON.parse(opts.body); - expect(body.workspaceId).toBe('ws-123'); + await kmsDeleteSecret(TOKEN, SECRET); + expect(calledInit().method).toBe('DELETE'); + expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets/app/API_KEY'); + expect(calledUrl()).toContain('env=production'); }); - it('throws on non-ok response', async () => { - mockFetch.mockResolvedValue(mockResponse('Server Error', false, 500)); - - await expect(kmsDeleteSecret(TOKEN, { ...PARAMS, secretName: 'X' })).rejects.toThrow('KMS delete failed: 500'); + it('throws with the status on failure', async () => { + mockFetch.mockResolvedValue(mockResponse('Forbidden', false, 403)); + await expect(kmsDeleteSecret(TOKEN, SECRET)).rejects.toThrow('KMS delete failed: 403'); }); });