diff --git a/src/api/secrets.test.ts b/src/api/secrets.test.ts new file mode 100644 index 0000000..9be5a04 --- /dev/null +++ b/src/api/secrets.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createSecret, + listSecrets, + removeSecret, + SecretApiError, + setSecret, + updateSecret, + type SecretApiConfig, +} from "./secrets.js"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +function expectFromParam(url: string) { + const parsed = new URL(url); + expect(parsed.searchParams.get("from")).toBe("ts-sdk"); + return parsed; +} + +function jsonResponse(body: unknown, init?: { ok?: boolean; status?: number }) { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +function textResponse(body: string, init?: { ok?: boolean; status?: number }) { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + text: () => Promise.resolve(body), + }; +} + +describe("Secret API client", () => { + const config: SecretApiConfig = { + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + }; + + beforeEach(() => { + mockFetch.mockReset(); + }); + + describe("listSecrets", () => { + it("lists secrets without exposing values", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + variables: [ + { name: "API_KEY", created_at: "2024-01-01", updated_at: "2024-01-02" }, + { name: "DB_PASSWORD", created_at: "2024-01-03", updated_at: "2024-01-03" }, + ], + }) + ); + + const result = await listSecrets(config); + + expect(result).toEqual([ + { name: "API_KEY", created_at: "2024-01-01", updated_at: "2024-01-02" }, + { name: "DB_PASSWORD", created_at: "2024-01-03", updated_at: "2024-01-03" }, + ]); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/variables"); + expect(init.method).toBe("GET"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBe("Bearer p.admin-token"); + }); + + it("returns an empty array when the API returns no variables field", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({})); + + const result = await listSecrets(config); + expect(result).toEqual([]); + }); + + it("throws SecretApiError on failure", async () => { + mockFetch.mockResolvedValueOnce( + textResponse('{"error": "Forbidden"}', { ok: false, status: 403 }) + ); + + await expect(listSecrets(config)).rejects.toThrow(SecretApiError); + + mockFetch.mockResolvedValueOnce( + textResponse('{"error": "Forbidden"}', { ok: false, status: 403 }) + ); + + try { + await listSecrets(config); + expect.unreachable(); + } catch (error) { + expect((error as SecretApiError).status).toBe(403); + expect((error as SecretApiError).message).toBe("Forbidden"); + } + }); + }); + + describe("createSecret", () => { + it("creates a secret with name and value as form body", async () => { + mockFetch.mockResolvedValueOnce(textResponse("")); + + await createSecret(config, "API_KEY", "sk_live_12345"); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/variables"); + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded"); + + const body = new URLSearchParams(init.body as string); + expect(body.get("name")).toBe("API_KEY"); + expect(body.get("value")).toBe("sk_live_12345"); + }); + + it("rejects an empty secret name", async () => { + await expect(createSecret(config, "", "value")).rejects.toThrow( + "Secret name must be a non-empty string." + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("redacts the secret value from thrown error messages", async () => { + mockFetch.mockResolvedValueOnce( + textResponse('{"error": "Invalid value sk_live_12345 for secret"}', { + ok: false, + status: 400, + }) + ); + + try { + await createSecret(config, "API_KEY", "sk_live_12345"); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SecretApiError); + const apiError = error as SecretApiError; + expect(apiError.message).not.toContain("sk_live_12345"); + expect(apiError.message).toContain("[REDACTED]"); + expect(apiError.body).not.toContain("sk_live_12345"); + } + }); + }); + + describe("updateSecret", () => { + it("updates a secret with only the value as form body", async () => { + mockFetch.mockResolvedValueOnce(textResponse("")); + + await updateSecret(config, "API_KEY", "sk_live_updated"); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/variables/API_KEY"); + expect(init.method).toBe("PUT"); + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded"); + + const body = new URLSearchParams(init.body as string); + expect(body.get("value")).toBe("sk_live_updated"); + expect(body.get("name")).toBeNull(); + }); + }); + + describe("setSecret", () => { + it("creates the secret when it does not already exist", async () => { + // existence check (GET) -> 404 + mockFetch.mockResolvedValueOnce(textResponse("Not found", { ok: false, status: 404 })); + // create (POST) + mockFetch.mockResolvedValueOnce(textResponse("")); + + const result = await setSecret(config, "NEW_SECRET", "value1"); + + expect(result).toEqual({ name: "NEW_SECRET", created: true }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const [existsUrl, existsInit] = mockFetch.mock.calls[0]; + expect(new URL(existsUrl).pathname).toBe("/v0/variables/NEW_SECRET"); + expect(existsInit.method).toBe("GET"); + + const [createUrl, createInit] = mockFetch.mock.calls[1]; + expect(new URL(createUrl).pathname).toBe("/v0/variables"); + expect(createInit.method).toBe("POST"); + }); + + it("updates the secret when it already exists", async () => { + // existence check (GET) -> 200 + mockFetch.mockResolvedValueOnce(textResponse('{"name":"EXISTING"}', { ok: true })); + // update (PUT) + mockFetch.mockResolvedValueOnce(textResponse("")); + + const result = await setSecret(config, "EXISTING", "value2"); + + expect(result).toEqual({ name: "EXISTING", created: false }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const [, updateInit] = mockFetch.mock.calls[1]; + expect(updateInit.method).toBe("PUT"); + }); + + it("treats an existence-check failure (e.g. network error) as not existing, like the CLI", async () => { + mockFetch.mockRejectedValueOnce(new Error("network error")); + mockFetch.mockResolvedValueOnce(textResponse("")); + + const result = await setSecret(config, "FLAKY_CHECK", "value3"); + + expect(result).toEqual({ name: "FLAKY_CHECK", created: true }); + const [, createInit] = mockFetch.mock.calls[1]; + expect(createInit.method).toBe("POST"); + }); + + it("rejects an empty secret name before making any request", async () => { + await expect(setSecret(config, "", "value")).rejects.toThrow( + "Secret name must be a non-empty string." + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe("removeSecret", () => { + it("deletes a secret", async () => { + mockFetch.mockResolvedValueOnce(textResponse("")); + + const result = await removeSecret(config, "API_KEY"); + + expect(result).toEqual({ name: "API_KEY" }); + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/variables/API_KEY"); + expect(init.method).toBe("DELETE"); + }); + + it("throws SecretApiError on failure", async () => { + mockFetch.mockResolvedValueOnce( + textResponse('{"error": "Secret not found"}', { ok: false, status: 404 }) + ); + + await expect(removeSecret(config, "MISSING")).rejects.toThrow(SecretApiError); + }); + + it("rejects an empty secret name", async () => { + await expect(removeSecret(config, "")).rejects.toThrow( + "Secret name must be a non-empty string." + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/api/secrets.ts b/src/api/secrets.ts new file mode 100644 index 0000000..aed69c5 --- /dev/null +++ b/src/api/secrets.ts @@ -0,0 +1,302 @@ +/** + * Tinybird Secrets (variables) API client + * + * Wraps the `/v0/variables` endpoints used by `tb secret ls/set/rm` to manage + * the actual value of a secret server-side. This is distinct from the + * `secret()` helper in `src/schema/secret.ts`, which only emits a + * `{{ tb_secret(...) }}` template reference for use inside datafiles - it + * never talks to the API. + */ + +import { createTinybirdApi } from "./api.js"; + +/** + * API configuration for secret operations. + * Requires a token with ADMIN scope (secrets are workspace-level). + */ +export interface SecretApiConfig { + /** Tinybird API base URL */ + baseUrl: string; + /** Workspace token with ADMIN scope */ + token: string; + /** Custom fetch implementation (optional, defaults to global fetch) */ + fetch?: typeof fetch; + /** Default timeout in milliseconds (optional) */ + timeout?: number; +} + +/** + * Error thrown by secret API operations. + * + * `message` and `body` are sanitized so a secret `value` passed to + * `setSecret`/`createSecret`/`updateSecret` is never echoed back, even if the + * underlying API error response happened to include it. + */ +export class SecretApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly body?: string + ) { + super(message); + this.name = "SecretApiError"; + } +} + +/** + * Secret metadata as returned by `GET /v0/variables` and used by `tb secret ls`. + * + * Note: this is metadata only. The Tinybird API does not return the secret's + * plaintext value in list responses, and this client never reads/returns the + * value from `GET /v0/variables/{name}` either (that endpoint is only used + * internally to check whether a secret already exists). + */ +export interface SecretInfo { + /** Secret name */ + name: string; + /** ISO creation timestamp, if returned by the API */ + created_at?: string; + /** ISO last-updated timestamp, if returned by the API */ + updated_at?: string; +} + +/** + * Result of creating or updating a secret + */ +export interface SetSecretResult { + /** Secret name */ + name: string; + /** `true` if the secret was newly created, `false` if an existing secret was updated */ + created: boolean; +} + +/** + * Result of removing a secret + */ +export interface RemoveSecretResult { + /** Secret name */ + name: string; +} + +function assertValidName(name: string): void { + if (!name || name.trim().length === 0) { + throw new Error("Secret name must be a non-empty string."); + } +} + +/** + * Redact a sensitive value from arbitrary response text before it ever + * reaches an error message, in case the API ever echoes a submitted value + * back in an error response. + */ +function redact(text: string, sensitiveValue?: string): string { + if (!sensitiveValue) { + return text; + } + return text.split(sensitiveValue).join("[REDACTED]"); +} + +async function throwForFailedResponse( + response: Response, + sensitiveValue?: string +): Promise { + const rawBody = await response.text().catch(() => ""); + const sanitizedBody = redact(rawBody, sensitiveValue); + + let parsed: { error?: string } | undefined; + try { + parsed = sanitizedBody ? (JSON.parse(sanitizedBody) as { error?: string }) : undefined; + } catch { + // ignore parse error and keep the sanitized raw body + } + + const message = + parsed?.error ?? + (sanitizedBody + ? `Request failed with status ${response.status}: ${sanitizedBody}` + : `Request failed with status ${response.status}`); + + throw new SecretApiError(message, response.status, sanitizedBody || undefined); +} + +function getApi(config: SecretApiConfig) { + return createTinybirdApi({ + baseUrl: config.baseUrl, + token: config.token, + fetch: config.fetch, + timeout: config.timeout, + }); +} + +/** + * List secrets + * GET /v0/variables + * + * Returns metadata only (name + timestamps), matching `tb secret ls` which + * never prints secret values. + * + * @param config - API configuration (requires ADMIN scope) + * @returns The list of secrets defined in the workspace + */ +export async function listSecrets(config: SecretApiConfig): Promise { + const api = getApi(config); + + const response = await api.request("/v0/variables", { method: "GET" }); + + if (!response.ok) { + await throwForFailedResponse(response); + } + + const data = (await response.json()) as { variables?: SecretInfo[] }; + return data.variables ?? []; +} + +/** + * Check whether a secret already exists. + * GET /v0/variables/{name} + * + * Mirrors the Tinybird CLI's existing-secret check (see + * `tinybird/tb/modules/secret_common.py` / `secret.py` `secret_set`), which + * treats any failure to fetch the secret (404, permission error, etc.) as + * "it doesn't exist yet" and falls back to creating it. + * + * This never parses or returns the response body, since it is unconfirmed + * whether this endpoint includes the plaintext value for an existing secret. + */ +async function secretExists( + api: ReturnType, + name: string +): Promise { + try { + const response = await api.request(`/v0/variables/${encodeURIComponent(name)}`, { + method: "GET", + }); + await response.text().catch(() => undefined); + return response.ok; + } catch { + return false; + } +} + +/** + * Create a new secret + * POST /v0/variables + * + * @param config - API configuration (requires ADMIN scope) + * @param name - Secret name + * @param value - Secret value + */ +export async function createSecret( + config: SecretApiConfig, + name: string, + value: string +): Promise { + assertValidName(name); + const api = getApi(config); + + const body = new URLSearchParams(); + body.set("name", name); + body.set("value", value); + + const response = await api.request("/v0/variables", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!response.ok) { + await throwForFailedResponse(response, value); + } + + // Never parse/return the success body - it is unconfirmed whether it + // echoes the plaintext value, and callers never need it. + await response.text().catch(() => undefined); +} + +/** + * Update an existing secret's value + * PUT /v0/variables/{name} + * + * @param config - API configuration (requires ADMIN scope) + * @param name - Secret name + * @param value - New secret value + */ +export async function updateSecret( + config: SecretApiConfig, + name: string, + value: string +): Promise { + assertValidName(name); + const api = getApi(config); + + const body = new URLSearchParams(); + body.set("value", value); + + const response = await api.request(`/v0/variables/${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!response.ok) { + await throwForFailedResponse(response, value); + } + + await response.text().catch(() => undefined); +} + +/** + * Create or update a secret, transparently. + * + * Checks whether the secret already exists (`GET /v0/variables/{name}`) and + * creates it (`POST /v0/variables`) or updates it (`PUT /v0/variables/{name}`) + * accordingly - mirroring `tb secret set` / `load_secrets` in the Tinybird CLI. + * + * @param config - API configuration (requires ADMIN scope) + * @param name - Secret name + * @param value - Secret value + * @returns Whether the secret was created or updated + */ +export async function setSecret( + config: SecretApiConfig, + name: string, + value: string +): Promise { + assertValidName(name); + const api = getApi(config); + const exists = await secretExists(api, name); + + if (exists) { + await updateSecret(config, name, value); + return { name, created: false }; + } + + await createSecret(config, name, value); + return { name, created: true }; +} + +/** + * Delete a secret + * DELETE /v0/variables/{name} + * + * @param config - API configuration (requires ADMIN scope) + * @param name - Secret name + */ +export async function removeSecret( + config: SecretApiConfig, + name: string +): Promise { + assertValidName(name); + const api = getApi(config); + + const response = await api.request(`/v0/variables/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + + if (!response.ok) { + await throwForFailedResponse(response); + } + + await response.text().catch(() => undefined); + return { name }; +} diff --git a/src/client/base.ts b/src/client/base.ts index 637c0c8..c40a9de 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -21,6 +21,7 @@ import { TinybirdError } from "./types.js"; import { TinybirdApi, TinybirdApiError } from "../api/api.js"; import type { CreateBranchOptions } from "../api/branches.js"; import { TokensNamespace } from "./tokens.js"; +import { SecretsNamespace } from "./secrets.js"; /** * Resolved token info from dev mode @@ -74,6 +75,9 @@ export class TinybirdClient { /** Token operations (JWT creation, etc.) */ public readonly tokens: TokensNamespace; + /** Secret operations (list/set/remove secret values, via `/v0/variables`) */ + public readonly secrets: SecretsNamespace; + constructor(config: ClientConfig) { // Validate required config if (!config.baseUrl) { @@ -122,6 +126,14 @@ export class TinybirdClient { this.config.fetch, this.config.timeout ); + + // Initialize secrets namespace + this.secrets = new SecretsNamespace( + () => this.getToken(), + this.config.baseUrl, + this.config.fetch, + this.config.timeout + ); } /** diff --git a/src/client/secrets.test.ts b/src/client/secrets.test.ts new file mode 100644 index 0000000..db6ce2b --- /dev/null +++ b/src/client/secrets.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TinybirdClient } from "./base.js"; +import { TinybirdError } from "./types.js"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +function textResponse(body: string, init?: { ok?: boolean; status?: number }) { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + text: () => Promise.resolve(body), + json: () => Promise.resolve(body ? JSON.parse(body) : {}), + }; +} + +describe("SecretsNamespace", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("uses client custom fetch for secrets.list", async () => { + const customFetch = vi.fn().mockResolvedValueOnce( + textResponse( + JSON.stringify({ variables: [{ name: "API_KEY" }] }) + ) + ); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as unknown as typeof fetch, + }); + + const result = await client.secrets.list(); + + expect(result).toEqual([{ name: "API_KEY" }]); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/variables"); + expect(parsed.searchParams.get("from")).toBe("ts-sdk"); + expect(init.method).toBe("GET"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBe("Bearer p.admin-token"); + }); + + it("creates a new secret via secrets.set when it does not exist", async () => { + const customFetch = vi.fn(); + // existence check -> 404 + customFetch.mockResolvedValueOnce(textResponse("Not found", { ok: false, status: 404 })); + // create + customFetch.mockResolvedValueOnce(textResponse("")); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as unknown as typeof fetch, + }); + + const result = await client.secrets.set("NEW_SECRET", "super-secret-value"); + + expect(result).toEqual({ name: "NEW_SECRET", created: true }); + expect(customFetch).toHaveBeenCalledTimes(2); + + const [, createInit] = customFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + const body = new URLSearchParams(createInit.body as string); + expect(body.get("name")).toBe("NEW_SECRET"); + expect(body.get("value")).toBe("super-secret-value"); + }); + + it("updates an existing secret via secrets.set", async () => { + const customFetch = vi.fn(); + // existence check -> 200 (exists) + customFetch.mockResolvedValueOnce(textResponse('{"name":"EXISTING"}')); + // update + customFetch.mockResolvedValueOnce(textResponse("")); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as unknown as typeof fetch, + }); + + const result = await client.secrets.set("EXISTING", "rotated-value"); + + expect(result).toEqual({ name: "EXISTING", created: false }); + + const [updateUrl, updateInit] = customFetch.mock.calls[1] as [string, RequestInit]; + expect(new URL(updateUrl).pathname).toBe("/v0/variables/EXISTING"); + expect(updateInit.method).toBe("PUT"); + const body = new URLSearchParams(updateInit.body as string); + expect(body.get("value")).toBe("rotated-value"); + }); + + it("removes a secret via secrets.remove", async () => { + const customFetch = vi.fn().mockResolvedValueOnce(textResponse("")); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as unknown as typeof fetch, + }); + + const result = await client.secrets.remove("API_KEY"); + + expect(result).toEqual({ name: "API_KEY" }); + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + expect(new URL(url).pathname).toBe("/v0/variables/API_KEY"); + expect(init.method).toBe("DELETE"); + }); + + it("wraps secret API failures in TinybirdError", async () => { + const customFetch = vi + .fn() + .mockResolvedValueOnce(textResponse('{"error": "Forbidden"}', { ok: false, status: 403 })); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as unknown as typeof fetch, + }); + + await expect(client.secrets.list()).rejects.toThrow(TinybirdError); + + customFetch.mockResolvedValueOnce( + textResponse('{"error": "Forbidden"}', { ok: false, status: 403 }) + ); + + try { + await client.secrets.list(); + expect.unreachable(); + } catch (error) { + expect((error as TinybirdError).statusCode).toBe(403); + expect((error as TinybirdError).message).toBe("Forbidden"); + } + }); + + it("never leaks the secret value in a thrown error message", async () => { + const customFetch = vi.fn(); + // existence check -> 404 (not found, so goes to create) + customFetch.mockResolvedValueOnce(textResponse("Not found", { ok: false, status: 404 })); + // create fails and echoes the value back + customFetch.mockResolvedValueOnce( + textResponse('{"error": "value top-secret-value is invalid"}', { + ok: false, + status: 400, + }) + ); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as unknown as typeof fetch, + }); + + try { + await client.secrets.set("BAD_SECRET", "top-secret-value"); + expect.unreachable(); + } catch (error) { + expect((error as TinybirdError).message).not.toContain("top-secret-value"); + } + }); +}); diff --git a/src/client/secrets.ts b/src/client/secrets.ts new file mode 100644 index 0000000..9aac8a6 --- /dev/null +++ b/src/client/secrets.ts @@ -0,0 +1,132 @@ +/** + * Secret operations namespace for TinybirdClient + */ + +import type { RemoveSecretResult, SecretInfo, SetSecretResult } from "../api/secrets.js"; +import { + listSecrets as apiListSecrets, + removeSecret as apiRemoveSecret, + SecretApiError, + setSecret as apiSetSecret, +} from "../api/secrets.js"; +import { TinybirdError } from "./types.js"; + +/** + * Secret operations namespace for TinybirdClient + * + * Manages the server-side value of a secret via the `/v0/variables` API + * (`tb secret ls/set/rm`). This is a separate, complementary concern to the + * `secret()` helper exported from the package root, which only produces a + * `{{ tb_secret(...) }}` template reference for datafiles and never touches + * the API. + */ +export class SecretsNamespace { + constructor( + private readonly getToken: () => Promise, + private readonly baseUrl: string, + private readonly fetchFn?: typeof globalThis.fetch, + private readonly timeout?: number + ) {} + + /** + * List secrets defined in the workspace + * + * Returns metadata only (name and timestamps) - secret values are never + * included in the list response. + * + * @returns The list of secrets + * + * @example + * ```ts + * const secrets = await client.secrets.list(); + * console.log(secrets.map((s) => s.name)); // ["API_KEY", "DB_PASSWORD"] + * ``` + */ + async list(): Promise { + const token = await this.getToken(); + + try { + return await apiListSecrets({ + baseUrl: this.baseUrl, + token, + fetch: this.fetchFn, + timeout: this.timeout, + }); + } catch (error) { + this.rethrow(error); + } + } + + /** + * Create or update a secret, transparently + * + * Checks whether a secret with this name already exists and creates or + * updates it accordingly, matching `tb secret set`. + * + * @param name - Secret name + * @param value - Secret value + * @returns Whether the secret was created or updated + * + * @example + * ```ts + * await client.secrets.set("API_KEY", "sk_live_..."); + * ``` + */ + async set(name: string, value: string): Promise { + const token = await this.getToken(); + + try { + return await apiSetSecret( + { + baseUrl: this.baseUrl, + token, + fetch: this.fetchFn, + timeout: this.timeout, + }, + name, + value + ); + } catch (error) { + this.rethrow(error); + } + } + + /** + * Delete a secret + * + * @param name - Secret name + * @returns The name of the removed secret + * + * @example + * ```ts + * await client.secrets.remove("API_KEY"); + * ``` + */ + async remove(name: string): Promise { + const token = await this.getToken(); + + try { + return await apiRemoveSecret( + { + baseUrl: this.baseUrl, + token, + fetch: this.fetchFn, + timeout: this.timeout, + }, + name + ); + } catch (error) { + this.rethrow(error); + } + } + + private rethrow(error: unknown): never { + if (error instanceof SecretApiError) { + throw new TinybirdError(error.message, error.status, { + error: error.message, + status: error.status, + }); + } + throw error; + } +} diff --git a/src/index.ts b/src/index.ts index b8bab18..343223b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -315,6 +315,22 @@ export type { CreateJWTResult, } from "./api/tokens.js"; +// ============ Secret API ============ +export { + listSecrets, + createSecret, + updateSecret, + setSecret, + removeSecret, + SecretApiError, +} from "./api/secrets.js"; +export type { + SecretApiConfig, + SecretInfo, + SetSecretResult, + RemoveSecretResult, +} from "./api/secrets.js"; + // ============ Config Types ============ // Import from config-types.ts to avoid bundling esbuild in client code export type { TinybirdConfig, DevMode, BranchDataMode } from "./cli/config-types.js"; diff --git a/src/schema/project.ts b/src/schema/project.ts index 494e64a..99ec801 100644 --- a/src/schema/project.ts +++ b/src/schema/project.ts @@ -23,6 +23,7 @@ import type { } from "../client/types.js"; import type { InferRow, InferParams, InferOutputRow } from "../infer/index.js"; import type { TokensNamespace } from "../client/tokens.js"; +import type { SecretsNamespace } from "../client/secrets.js"; // Symbol for brand typing - use Symbol.for() for global registry // This ensures the same symbol is used across module instances @@ -96,6 +97,8 @@ type DatasourceAccessors = { interface ProjectClientBase { /** Token operations (JWT creation, etc.) */ readonly tokens: TokensNamespace; + /** Secret operations (list/set/remove secret values) */ + readonly secrets: SecretsNamespace; /** Datasource operations (ingest/append/replace/delete/truncate) */ readonly datasources: DatasourcesNamespace; /** Execute raw SQL queries */ @@ -246,7 +249,7 @@ export function isProjectDefinition(value: unknown): value is ProjectDefinition ); } -const RESERVED_CLIENT_NAMES = new Set(["tokens", "datasources", "sql", "client"]); +const RESERVED_CLIENT_NAMES = new Set(["tokens", "secrets", "datasources", "sql", "client"]); /** * Constructor interface for Tinybird class @@ -429,6 +432,16 @@ export const Tinybird: TinybirdConstructor = class Tinybird< return this.#client.tokens; } + /** Secret operations (list/set/remove secret values) */ + get secrets(): SecretsNamespace { + if (!this.#client) { + throw new Error( + "Client not initialized. Call a query or ingest method first, or access client asynchronously." + ); + } + return this.#client.secrets; + } + /** Datasource operations (ingest/append/replace/delete/truncate) */ get datasources(): DatasourcesNamespace { if (!this.#client) {