diff --git a/src/api/api.ts b/src/api/api.ts index 53b9230..7369355 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -134,6 +134,35 @@ export interface TinybirdApiCreateTokenResult { token: string; } +/** + * Raw token representation returned by the Tinybird Token API + * (GET /v0/tokens, GET /v0/tokens/{id}, POST .../refresh, PUT /v0/tokens/{id}). + */ +export interface TinybirdApiTokenInfo { + /** Token ID */ + id?: string; + /** Token name */ + name: string; + /** Human-readable description */ + description?: string; + /** Raw token secret value (only present when the API exposes it) */ + token?: string; + /** Scopes granted to the token */ + scopes?: TinybirdApiTokenScope[]; +} + +/** + * Options shared by the single-token read/write operations below + */ +export interface TinybirdApiTokenRequestOptions { + /** Optional token override for this request */ + token?: string; + /** Request timeout in milliseconds */ + timeout?: number; + /** AbortController signal for cancellation */ + signal?: AbortSignal; +} + /** * Error thrown by TinybirdApi when a response is not OK */ @@ -536,6 +565,129 @@ export class TinybirdApi { return (await response.json()) as TinybirdApiCreateTokenResult; } + /** + * List all tokens in the workspace. + * GET /v0/tokens + */ + async listTokens( + options: TinybirdApiTokenRequestOptions = {} + ): Promise { + const url = new URL("/v0/tokens", `${this.baseUrl}/`); + + const response = await this.request(url.toString(), { + method: "GET", + token: options.token, + signal: this.createAbortSignal(options.timeout, options.signal), + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + const data = (await response.json()) as { tokens?: TinybirdApiTokenInfo[] }; + return data.tokens ?? []; + } + + /** + * Get a single token's details, including its scopes. + * GET /v0/tokens/{name_or_id} + */ + async getToken( + nameOrId: string, + options: TinybirdApiTokenRequestOptions = {} + ): Promise { + const url = new URL(`/v0/tokens/${encodeURIComponent(nameOrId)}`, `${this.baseUrl}/`); + + const response = await this.request(url.toString(), { + method: "GET", + token: options.token, + signal: this.createAbortSignal(options.timeout, options.signal), + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + return (await response.json()) as TinybirdApiTokenInfo; + } + + /** + * Delete (revoke) a token. + * DELETE /v0/tokens/{name_or_id} + */ + async deleteToken( + nameOrId: string, + options: TinybirdApiTokenRequestOptions = {} + ): Promise { + const url = new URL(`/v0/tokens/${encodeURIComponent(nameOrId)}`, `${this.baseUrl}/`); + + const response = await this.request(url.toString(), { + method: "DELETE", + token: options.token, + signal: this.createAbortSignal(options.timeout, options.signal), + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + await this.discardResponseBody(response); + } + + /** + * Refresh (rotate) a token's value. + * POST /v0/tokens/{name_or_id}/refresh + */ + async refreshToken( + nameOrId: string, + options: TinybirdApiTokenRequestOptions = {} + ): Promise { + const url = new URL( + `/v0/tokens/${encodeURIComponent(nameOrId)}/refresh`, + `${this.baseUrl}/` + ); + + const response = await this.request(url.toString(), { + method: "POST", + token: options.token, + signal: this.createAbortSignal(options.timeout, options.signal), + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + return this.parseOptionalJson(response); + } + + /** + * Update a token's scopes. + * PUT /v0/tokens/{name_or_id} + */ + async updateTokenScopes( + nameOrId: string, + scopes: TinybirdApiTokenScope[], + options: TinybirdApiTokenRequestOptions = {} + ): Promise { + const url = new URL(`/v0/tokens/${encodeURIComponent(nameOrId)}`, `${this.baseUrl}/`); + + const response = await this.request(url.toString(), { + method: "PUT", + token: options.token, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ scopes }), + signal: this.createAbortSignal(options.timeout, options.signal), + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + return this.parseOptionalJson(response); + } + /** * Detect format from file path or URL extension */ diff --git a/src/api/tokens.test.ts b/src/api/tokens.test.ts index 0da32d2..3420176 100644 --- a/src/api/tokens.test.ts +++ b/src/api/tokens.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createJWT, + deleteToken, + getToken, + getTokenValue, + listTokens, + refreshToken, TokenApiError, + updateTokenScopes, type TokenApiConfig, } from "./tokens.js"; @@ -250,4 +256,256 @@ describe("Token API client", () => { expect(body.limits).toBeUndefined(); }); }); + + describe("listTokens", () => { + it("lists tokens in the workspace", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + tokens: [ + { id: "t1", name: "admin_token", token: "p.admin" }, + { id: "t2", name: "read_token", scopes: [{ type: "PIPES:READ" }] }, + ], + }), + }); + + const tokens = await listTokens(config); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ + id: "t1", + name: "admin_token", + description: undefined, + scopes: undefined, + value: "p.admin", + }); + expect(tokens[1].scopes).toEqual([ + { type: "PIPES:READ", resource: undefined, filter: undefined }, + ]); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/tokens"); + 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 tokens field", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const tokens = await listTokens(config); + expect(tokens).toEqual([]); + }); + + it("throws TokenApiError on 403", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: "Forbidden", + text: () => Promise.resolve("Insufficient permissions"), + }); + + await expect(listTokens(config)).rejects.toThrow(TokenApiError); + }); + }); + + describe("getToken", () => { + it("gets a token's details including scopes", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "t1", + name: "user_token", + token: "p.user-token-value", + scopes: [{ type: "DATASOURCES:READ", resource: "events", filter: "org_id = 1" }], + }), + }); + + const result = await getToken(config, "user_token"); + + expect(result.name).toBe("user_token"); + expect(result.value).toBe("p.user-token-value"); + expect(result.scopes).toEqual([ + { type: "DATASOURCES:READ", resource: "events", filter: "org_id = 1" }, + ]); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token"); + expect(init.method).toBe("GET"); + }); + + it("URL-encodes the name/id", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ name: "weird token" }), + }); + + await getToken(config, "weird token"); + + const [url] = mockFetch.mock.calls[0]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/tokens/weird%20token"); + }); + + it("throws TokenApiError on 404 with helpful message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + text: () => Promise.resolve("Token not found"), + }); + + await expect(getToken(config, "missing_token")).rejects.toThrow(TokenApiError); + }); + }); + + describe("getTokenValue", () => { + it("returns the raw token value", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ name: "user_token", token: "p.user-token-value" }), + }); + + const value = await getTokenValue(config, "user_token"); + expect(value).toBe("p.user-token-value"); + }); + + it("throws TokenApiError when the token has no retrievable value", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ name: "admin_token" }), + }); + + await expect(getTokenValue(config, "admin_token")).rejects.toThrow(TokenApiError); + }); + }); + + describe("deleteToken", () => { + it("deletes a token", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await deleteToken(config, "user_token"); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token"); + expect(init.method).toBe("DELETE"); + }); + + it("throws TokenApiError on 403 with helpful message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: "Forbidden", + text: () => Promise.resolve("Insufficient permissions"), + }); + + await expect(deleteToken(config, "user_token")).rejects.toThrow(TokenApiError); + }); + }); + + describe("refreshToken", () => { + it("refreshes a token and returns its new value", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ name: "user_token", token: "p.new-value" }) + ), + }); + + const result = await refreshToken(config, "user_token"); + expect(result.value).toBe("p.new-value"); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token/refresh"); + expect(init.method).toBe("POST"); + }); + + it("handles an empty response body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + const result = await refreshToken(config, "user_token"); + expect(result).toEqual({ + id: undefined, + name: undefined, + description: undefined, + scopes: undefined, + value: undefined, + }); + }); + + it("throws TokenApiError on 404", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + text: () => Promise.resolve("Token not found"), + }); + + await expect(refreshToken(config, "missing_token")).rejects.toThrow( + TokenApiError + ); + }); + }); + + describe("updateTokenScopes", () => { + it("updates a token's scopes", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + name: "user_token", + scopes: [{ type: "DATASOURCES:READ", resource: "events" }], + }) + ), + }); + + const result = await updateTokenScopes(config, "user_token", [ + { type: "DATASOURCES:READ", resource: "events" }, + ]); + + expect(result.scopes).toEqual([ + { type: "DATASOURCES:READ", resource: "events", filter: undefined }, + ]); + + const [url, init] = mockFetch.mock.calls[0]; + const parsed = expectFromParam(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token"); + expect(init.method).toBe("PUT"); + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + const body = JSON.parse(init.body); + expect(body.scopes).toEqual([{ type: "DATASOURCES:READ", resource: "events" }]); + }); + + it("throws TokenApiError on 400 with validation error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + text: () => Promise.resolve('{"error": "Invalid scope type"}'), + }); + + await expect( + updateTokenScopes(config, "user_token", [{ type: "INVALID" }]) + ).rejects.toThrow(TokenApiError); + }); + }); }); diff --git a/src/api/tokens.ts b/src/api/tokens.ts index e63fad6..59dc7b6 100644 --- a/src/api/tokens.ts +++ b/src/api/tokens.ts @@ -5,6 +5,7 @@ import { createTinybirdApi, TinybirdApiError, + type TinybirdApiTokenInfo, } from "./api.js"; /** @@ -167,3 +168,275 @@ export async function createJWT( throw new TokenApiError(message, error.statusCode, responseBody); } } + +/** + * A scope granted to a token, as returned by the Token API. + */ +export interface TokenScope { + /** The type of access granted (e.g. "PIPES:READ", "DATASOURCES:APPEND", "ADMIN") */ + type: string; + /** The resource name the scope applies to (pipe or datasource), if any */ + resource?: string; + /** SQL filter expression applied to the scope (for datasource scopes), if any */ + filter?: string; +} + +/** + * Token information as returned by list/get/refresh/update operations. + */ +export interface TokenInfo { + /** Token ID */ + id?: string; + /** Token name */ + name: string; + /** Human-readable description */ + description?: string; + /** Scopes granted to the token (included by the "get" operation) */ + scopes?: TokenScope[]; + /** + * The raw token secret value, when exposed by the API for this token. + * This is the value a human would otherwise copy from `tb token copy`. + */ + value?: string; +} + +/** + * Convert a raw API token payload into the public `TokenInfo` shape. + */ +function toTokenInfo(raw: TinybirdApiTokenInfo): TokenInfo { + return { + id: raw.id, + name: raw.name, + description: raw.description, + scopes: raw.scopes?.map((scope) => ({ + type: scope.type, + resource: scope.resource, + filter: scope.filter, + })), + value: raw.token, + }; +} + +/** + * Build a `TokenApiError` from a caught error, with context-aware messages + * mirroring the style used by `createJWT`. Rethrows non-API errors as-is. + */ +function toTokenApiError(error: unknown, action: string): never { + if (!(error instanceof TinybirdApiError)) { + throw error; + } + + const responseBody = error.responseBody ?? error.message; + let message: string; + + if (error.statusCode === 403) { + message = + `Permission denied ${action}. ` + + `Make sure the token has TOKENS or ADMIN scope. ` + + `API response: ${responseBody}`; + } else if (error.statusCode === 404) { + message = `Not found while ${action}. API response: ${responseBody}`; + } else if (error.statusCode === 400) { + message = `Invalid request while ${action}: ${responseBody}`; + } else { + message = `Failed while ${action}: ${error.statusCode}. API response: ${responseBody}`; + } + + throw new TokenApiError(message, error.statusCode, responseBody); +} + +/** + * List all tokens in the workspace. + * GET /v0/tokens + * + * @param config - API configuration (requires TOKENS or ADMIN scope) + * @returns Array of tokens, including their IDs and (when exposed) raw values + * + * @example + * ```ts + * const tokens = await listTokens(config); + * console.log(tokens.map((t) => t.name)); + * ``` + */ +export async function listTokens(config: TokenApiConfig): Promise { + const api = createTinybirdApi({ + baseUrl: config.baseUrl, + token: config.token, + fetch: config.fetch, + timeout: config.timeout, + }); + + try { + const tokens = await api.listTokens(); + return tokens.map(toTokenInfo); + } catch (error) { + toTokenApiError(error, "listing tokens"); + } +} + +/** + * Get a single token's details, including its scopes. + * GET /v0/tokens/{name_or_id} + * + * @param config - API configuration (requires TOKENS or ADMIN scope) + * @param nameOrId - Token name or ID + * @returns The token's details, including its scopes + * + * @example + * ```ts + * const token = await getToken(config, "user_123_token"); + * console.log(token.scopes); + * ``` + */ +export async function getToken( + config: TokenApiConfig, + nameOrId: string +): Promise { + const api = createTinybirdApi({ + baseUrl: config.baseUrl, + token: config.token, + fetch: config.fetch, + timeout: config.timeout, + }); + + try { + const token = await api.getToken(nameOrId); + return toTokenInfo(token); + } catch (error) { + toTokenApiError(error, `getting token '${nameOrId}'`); + } +} + +/** + * Get a token's raw secret value. + * + * This is the SDK equivalent of `tb token copy`: the Forward CLI copies the + * value to the OS clipboard, which doesn't make sense in an SDK context, so + * this instead just returns the raw string for the caller to use as they see + * fit (e.g. hand it to another service, store it, etc). + * + * @param config - API configuration (requires TOKENS or ADMIN scope) + * @param nameOrId - Token name or ID + * @returns The raw token secret value + * + * @example + * ```ts + * const value = await getTokenValue(config, "user_123_token"); + * console.log(value); // "p...." + * ``` + */ +export async function getTokenValue( + config: TokenApiConfig, + nameOrId: string +): Promise { + const token = await getToken(config, nameOrId); + + if (!token.value) { + throw new TokenApiError( + `Token '${nameOrId}' does not expose a retrievable raw value`, + 404 + ); + } + + return token.value; +} + +/** + * Delete (revoke) a token. + * DELETE /v0/tokens/{name_or_id} + * + * @param config - API configuration (requires TOKENS or ADMIN scope) + * @param nameOrId - Token name or ID + * + * @example + * ```ts + * await deleteToken(config, "user_123_token"); + * ``` + */ +export async function deleteToken( + config: TokenApiConfig, + nameOrId: string +): Promise { + const api = createTinybirdApi({ + baseUrl: config.baseUrl, + token: config.token, + fetch: config.fetch, + timeout: config.timeout, + }); + + try { + await api.deleteToken(nameOrId); + } catch (error) { + toTokenApiError(error, `deleting token '${nameOrId}'`); + } +} + +/** + * Refresh (rotate) a token's value. The token keeps its name/scopes but gets + * a new secret value; the previous value stops working immediately. + * POST /v0/tokens/{name_or_id}/refresh + * + * @param config - API configuration (requires TOKENS or ADMIN scope) + * @param nameOrId - Token name or ID + * @returns The refreshed token's details + * + * @example + * ```ts + * const refreshed = await refreshToken(config, "user_123_token"); + * console.log(refreshed.value); // new raw value, if exposed by the API + * ``` + */ +export async function refreshToken( + config: TokenApiConfig, + nameOrId: string +): Promise { + const api = createTinybirdApi({ + baseUrl: config.baseUrl, + token: config.token, + fetch: config.fetch, + timeout: config.timeout, + }); + + try { + const token = await api.refreshToken(nameOrId); + return toTokenInfo(token); + } catch (error) { + toTokenApiError(error, `refreshing token '${nameOrId}'`); + } +} + +/** + * Update a token's scopes. + * PUT /v0/tokens/{name_or_id} + * + * @param config - API configuration (requires TOKENS or ADMIN scope) + * @param nameOrId - Token name or ID + * @param scopes - The new set of scopes to apply to the token + * @returns The updated token's details + * + * @example + * ```ts + * const updated = await updateTokenScopes(config, "user_123_token", [ + * { type: "DATASOURCES:READ", resource: "events" }, + * ]); + * ``` + */ +export async function updateTokenScopes( + config: TokenApiConfig, + nameOrId: string, + scopes: TokenScope[] +): Promise { + const api = createTinybirdApi({ + baseUrl: config.baseUrl, + token: config.token, + fetch: config.fetch, + timeout: config.timeout, + }); + + try { + const token = await api.updateTokenScopes(nameOrId, scopes); + return toTokenInfo(token); + } catch (error) { + toTokenApiError(error, `updating scopes for token '${nameOrId}'`); + } +} diff --git a/src/client/tokens.test.ts b/src/client/tokens.test.ts index ec02950..e5b4d62 100644 --- a/src/client/tokens.test.ts +++ b/src/client/tokens.test.ts @@ -1,5 +1,6 @@ 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; @@ -100,4 +101,207 @@ describe("TokensNamespace", () => { limits: { rps: 10 }, }); }); + + describe("list", () => { + it("lists tokens using the client's custom fetch", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + tokens: [{ id: "t1", name: "admin_token", token: "p.admin" }], + }), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + const tokens = await client.tokens.list(); + + expect(tokens).toEqual([ + { + id: "t1", + name: "admin_token", + description: undefined, + scopes: undefined, + value: "p.admin", + }, + ]); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/tokens"); + expect(init.method).toBe("GET"); + }); + + it("throws TinybirdError on API failure", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: "Forbidden", + text: () => Promise.resolve("Insufficient permissions"), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + await expect(client.tokens.list()).rejects.toThrow(TinybirdError); + }); + }); + + describe("get", () => { + it("gets a token's details", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "t1", + name: "user_token", + token: "p.value", + scopes: [{ type: "PIPES:READ", resource: "analytics_pipe" }], + }), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + const result = await client.tokens.get("user_token"); + + expect(result.value).toBe("p.value"); + expect(result.scopes).toEqual([ + { type: "PIPES:READ", resource: "analytics_pipe", filter: undefined }, + ]); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token"); + expect(init.method).toBe("GET"); + }); + }); + + describe("getValue", () => { + it("returns the raw token value", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ name: "user_token", token: "p.raw-value" }), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + const value = await client.tokens.getValue("user_token"); + expect(value).toBe("p.raw-value"); + }); + + it("throws TinybirdError when the token has no retrievable value", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ name: "admin_token" }), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + await expect(client.tokens.getValue("admin_token")).rejects.toThrow( + TinybirdError + ); + }); + }); + + describe("revoke", () => { + it("deletes a token", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + await client.tokens.revoke("user_token"); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token"); + expect(init.method).toBe("DELETE"); + }); + }); + + describe("refresh", () => { + it("refreshes a token", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve(JSON.stringify({ name: "user_token", token: "p.new-value" })), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + const result = await client.tokens.refresh("user_token"); + expect(result.value).toBe("p.new-value"); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token/refresh"); + expect(init.method).toBe("POST"); + }); + }); + + describe("updateScopes", () => { + it("updates a token's scopes", async () => { + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + name: "user_token", + scopes: [{ type: "DATASOURCES:READ", resource: "events" }], + }) + ), + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "p.admin-token", + fetch: customFetch as typeof fetch, + }); + + const result = await client.tokens.updateScopes("user_token", [ + { type: "DATASOURCES:READ", resource: "events" }, + ]); + + expect(result.scopes).toEqual([ + { type: "DATASOURCES:READ", resource: "events", filter: undefined }, + ]); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/tokens/user_token"); + expect(init.method).toBe("PUT"); + const body = JSON.parse(String(init.body)) as { scopes: unknown[] }; + expect(body.scopes).toEqual([{ type: "DATASOURCES:READ", resource: "events" }]); + }); + }); }); diff --git a/src/client/tokens.ts b/src/client/tokens.ts index 99600ab..6ae3398 100644 --- a/src/client/tokens.ts +++ b/src/client/tokens.ts @@ -2,8 +2,22 @@ * Token operations namespace for TinybirdClient */ -import type { CreateJWTOptions, CreateJWTResult } from "../api/tokens.js"; -import { createJWT as apiCreateJWT, TokenApiError } from "../api/tokens.js"; +import type { + CreateJWTOptions, + CreateJWTResult, + TokenInfo, + TokenScope, +} from "../api/tokens.js"; +import { + createJWT as apiCreateJWT, + deleteToken as apiDeleteToken, + getToken as apiGetToken, + getTokenValue as apiGetTokenValue, + listTokens as apiListTokens, + refreshToken as apiRefreshToken, + TokenApiError, + updateTokenScopes as apiUpdateTokenScopes, +} from "../api/tokens.js"; import { TinybirdError } from "./types.js"; /** @@ -66,4 +80,163 @@ export class TokensNamespace { throw error; } } + + /** + * List all tokens in the workspace + * + * Requires a token with TOKENS or ADMIN scope. + * + * @returns Array of tokens, including their IDs and (when exposed) raw values + * + * @example + * ```ts + * const tokens = await client.tokens.list(); + * console.log(tokens.map((t) => t.name)); + * ``` + */ + async list(): Promise { + const token = await this.getToken(); + + try { + return await apiListTokens(this.toApiConfig(token)); + } catch (error) { + throw this.toTinybirdError(error); + } + } + + /** + * Get a single token's details, including its scopes + * + * @param nameOrId - Token name or ID + * @returns The token's details, including its scopes + * + * @example + * ```ts + * const token = await client.tokens.get("user_123_token"); + * console.log(token.scopes); + * ``` + */ + async get(nameOrId: string): Promise { + const token = await this.getToken(); + + try { + return await apiGetToken(this.toApiConfig(token), nameOrId); + } catch (error) { + throw this.toTinybirdError(error); + } + } + + /** + * Get a token's raw secret value + * + * This is the SDK equivalent of `tb token copy`: the Forward CLI copies the + * value to the OS clipboard, which doesn't make sense in an SDK context, so + * this instead returns the raw string for the caller to use as needed. + * + * @param nameOrId - Token name or ID + * @returns The raw token secret value + * + * @example + * ```ts + * const value = await client.tokens.getValue("user_123_token"); + * console.log(value); // "p...." + * ``` + */ + async getValue(nameOrId: string): Promise { + const token = await this.getToken(); + + try { + return await apiGetTokenValue(this.toApiConfig(token), nameOrId); + } catch (error) { + throw this.toTinybirdError(error); + } + } + + /** + * Revoke (delete) a token + * + * @param nameOrId - Token name or ID + * + * @example + * ```ts + * await client.tokens.revoke("user_123_token"); + * ``` + */ + async revoke(nameOrId: string): Promise { + const token = await this.getToken(); + + try { + await apiDeleteToken(this.toApiConfig(token), nameOrId); + } catch (error) { + throw this.toTinybirdError(error); + } + } + + /** + * Refresh (rotate) a token's value + * + * The token keeps its name/scopes but gets a new secret value; the + * previous value stops working immediately. + * + * @param nameOrId - Token name or ID + * @returns The refreshed token's details + * + * @example + * ```ts + * const refreshed = await client.tokens.refresh("user_123_token"); + * console.log(refreshed.value); // new raw value, if exposed by the API + * ``` + */ + async refresh(nameOrId: string): Promise { + const token = await this.getToken(); + + try { + return await apiRefreshToken(this.toApiConfig(token), nameOrId); + } catch (error) { + throw this.toTinybirdError(error); + } + } + + /** + * Update a token's scopes + * + * @param nameOrId - Token name or ID + * @param scopes - The new set of scopes to apply to the token + * @returns The updated token's details + * + * @example + * ```ts + * const updated = await client.tokens.updateScopes("user_123_token", [ + * { type: "DATASOURCES:READ", resource: "events" }, + * ]); + * ``` + */ + async updateScopes(nameOrId: string, scopes: TokenScope[]): Promise { + const token = await this.getToken(); + + try { + return await apiUpdateTokenScopes(this.toApiConfig(token), nameOrId, scopes); + } catch (error) { + throw this.toTinybirdError(error); + } + } + + private toApiConfig(token: string) { + return { + baseUrl: this.baseUrl, + token, + fetch: this.fetchFn, + timeout: this.timeout, + }; + } + + private toTinybirdError(error: unknown): unknown { + if (error instanceof TokenApiError) { + return new TinybirdError(error.message, error.status, { + error: error.message, + status: error.status, + }); + } + return error; + } } diff --git a/src/index.ts b/src/index.ts index b8bab18..11b188c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -285,6 +285,8 @@ export type { TinybirdApiCreateTokenRequest, TinybirdApiCreateTokenOptions, TinybirdApiCreateTokenResult, + TinybirdApiTokenInfo, + TinybirdApiTokenRequestOptions, } from "./api/api.js"; // ============ Preview Environment ============ @@ -305,7 +307,16 @@ export { export type { RegionInfo } from "./api/dashboard.js"; // ============ Token API ============ -export { createJWT, TokenApiError } from "./api/tokens.js"; +export { + createJWT, + deleteToken, + getToken, + getTokenValue, + listTokens, + refreshToken, + TokenApiError, + updateTokenScopes, +} from "./api/tokens.js"; export type { TokenApiConfig, JWTScope, @@ -313,6 +324,8 @@ export type { JWTLimits, CreateJWTOptions, CreateJWTResult, + TokenInfo, + TokenScope, } from "./api/tokens.js"; // ============ Config Types ============