browser/kms: talk to /v1/kms — the SPA had been lying about the path

This client spoke Infisical under an /api/v3 prefix, and its header comment
explained why: "verified: kms.hanzo.ai/api/v3/secrets/raw -> 401 JSON,
/v1/kms/... -> SPA HTML", so /api/v3 was pinned as the live path and a gateway
rewrite was awaited.

The observation was real and the conclusion was backwards. The KMS binary
embedded a console SPA under a root catch-all and answered EVERY unmatched path
with 200 text/html — the HTML was the 404. So the path that worked looked broken
and the path that did not looked fine. luxfi/kms 1.12.8 removed the catch-all;
the same probe now reads the other way round (/v1/kms/... -> JSON,
/api/v3/... -> JSON 404). There is no rewrite to wait for.

  GET    /v1/kms/orgs/{org}/secrets?path=&env=          -> {"names": []}
  GET    /v1/kms/orgs/{org}/secrets/{path}/{name}?env=  -> {"secret":{"value"}}
  POST   /v1/kms/orgs/{org}/secrets                     {path,name,env,value}
  DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env=

Create and update collapse into one kmsPutSecret, because the server has one
upsert rather than two endpoints. Listing is a real call now — the HTTP surface
had no list until luxfi/kms 1.12.9, which is a large part of why the Infisical
prefix looked necessary in the first place.

Params move from Infisical vocabulary (workspaceId/environment/secretPath/
secretName) to the server-s own: KmsRef {org, env, path} and KmsSecretRef adding
name. Only the tests consumed this module, so nothing downstream breaks.

Path segments are escaped one at a time: the server splits {rest...} at its LAST
slash into (path, name), and escaping the joined string would encode the
separators into a single long name. A test pins that, and another asserts no
request may contain /api/ again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zeekay
2026-07-26 01:07:35 -07:00
co-authored by Claude Opus 5
parent 69310a0445
commit 7461549cd6
2 changed files with 209 additions and 225 deletions
+90 -115
View File
@@ -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/<service>/<endpoint>` — 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/<service>/<endpoint>`
* 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, string | undefined>): 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<never> {
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<KmsSecret[]> {
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<string[]> {
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<KmsSecret> {
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<string> {
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<KmsSecret> {
): Promise<void> {
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<KmsSecret> {
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<void> {
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);
}
+119 -110
View File
@@ -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');
});
});