@tinybirdco/sdk 0.0.36 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -1
- package/dist/api/api.d.ts +66 -1
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js +114 -0
- package/dist/api/api.js.map +1 -1
- package/dist/api/api.test.js +192 -0
- package/dist/api/api.test.js.map +1 -1
- package/dist/api/tokens.d.ts +79 -0
- package/dist/api/tokens.d.ts.map +1 -0
- package/dist/api/tokens.js +80 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/api/tokens.test.d.ts +2 -0
- package/dist/api/tokens.test.d.ts.map +1 -0
- package/dist/api/tokens.test.js +209 -0
- package/dist/api/tokens.test.js.map +1 -0
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +2 -3
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/client/base.d.ts +29 -1
- package/dist/client/base.d.ts.map +1 -1
- package/dist/client/base.js +44 -0
- package/dist/client/base.js.map +1 -1
- package/dist/client/base.test.js +25 -0
- package/dist/client/base.test.js.map +1 -1
- package/dist/client/tokens.d.ts +42 -0
- package/dist/client/tokens.d.ts.map +1 -0
- package/dist/client/tokens.js +67 -0
- package/dist/client/tokens.js.map +1 -0
- package/dist/client/tokens.test.d.ts +2 -0
- package/dist/client/tokens.test.d.ts.map +1 -0
- package/dist/client/tokens.test.js +79 -0
- package/dist/client/tokens.test.js.map +1 -0
- package/dist/client/types.d.ts +49 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/schema/project.d.ts +40 -12
- package/dist/schema/project.d.ts.map +1 -1
- package/dist/schema/project.js +67 -13
- package/dist/schema/project.js.map +1 -1
- package/dist/schema/project.test.js +63 -10
- package/dist/schema/project.test.js.map +1 -1
- package/package.json +1 -1
- package/src/api/api.test.ts +265 -0
- package/src/api/api.ts +196 -0
- package/src/api/tokens.test.ts +253 -0
- package/src/api/tokens.ts +169 -0
- package/src/cli/commands/init.ts +5 -3
- package/src/client/base.test.ts +32 -0
- package/src/client/base.ts +60 -0
- package/src/client/tokens.test.ts +103 -0
- package/src/client/tokens.ts +69 -0
- package/src/client/types.ts +54 -0
- package/src/index.ts +20 -0
- package/src/schema/project.test.ts +75 -10
- package/src/schema/project.ts +134 -27
package/src/api/api.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createTinybirdFetcher, type TinybirdFetch } from "./fetcher.js";
|
|
2
2
|
import type {
|
|
3
|
+
AppendOptions,
|
|
4
|
+
AppendResult,
|
|
3
5
|
IngestOptions,
|
|
4
6
|
IngestResult,
|
|
5
7
|
QueryOptions,
|
|
@@ -41,6 +43,59 @@ export interface TinybirdApiIngestOptions extends IngestOptions {
|
|
|
41
43
|
token?: string;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
export interface TinybirdApiAppendOptions extends Omit<AppendOptions, 'url' | 'file'> {
|
|
47
|
+
/** Optional token override for this request */
|
|
48
|
+
token?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Scope definition for token creation APIs
|
|
53
|
+
*/
|
|
54
|
+
export interface TinybirdApiTokenScope {
|
|
55
|
+
type: string;
|
|
56
|
+
resource?: string;
|
|
57
|
+
fixed_params?: Record<string, string | number | boolean>;
|
|
58
|
+
filter?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Request body for creating Tinybird tokens.
|
|
63
|
+
* Supports JWT-style scopes and static-token scope strings.
|
|
64
|
+
*/
|
|
65
|
+
export interface TinybirdApiCreateTokenRequest {
|
|
66
|
+
/** Token name/identifier */
|
|
67
|
+
name: string;
|
|
68
|
+
/** JWT-style scopes */
|
|
69
|
+
scopes?: TinybirdApiTokenScope[];
|
|
70
|
+
/** Static-token scope strings */
|
|
71
|
+
scope?: string | string[];
|
|
72
|
+
/** Optional rate-limiting config */
|
|
73
|
+
limits?: {
|
|
74
|
+
rps?: number;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Options for token creation requests
|
|
80
|
+
*/
|
|
81
|
+
export interface TinybirdApiCreateTokenOptions {
|
|
82
|
+
/** Optional expiration time for JWT tokens */
|
|
83
|
+
expirationTime?: number;
|
|
84
|
+
/** Optional token override for this request */
|
|
85
|
+
token?: string;
|
|
86
|
+
/** Request timeout in milliseconds */
|
|
87
|
+
timeout?: number;
|
|
88
|
+
/** AbortController signal for cancellation */
|
|
89
|
+
signal?: AbortSignal;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Result of token creation
|
|
94
|
+
*/
|
|
95
|
+
export interface TinybirdApiCreateTokenResult {
|
|
96
|
+
token: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
44
99
|
/**
|
|
45
100
|
* Error thrown by TinybirdApi when a response is not OK
|
|
46
101
|
*/
|
|
@@ -242,6 +297,147 @@ export class TinybirdApi {
|
|
|
242
297
|
return (await response.json()) as QueryResult<T>;
|
|
243
298
|
}
|
|
244
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Append data to a datasource from a URL or local file
|
|
302
|
+
*/
|
|
303
|
+
async appendDatasource(
|
|
304
|
+
datasourceName: string,
|
|
305
|
+
options: AppendOptions,
|
|
306
|
+
apiOptions: TinybirdApiAppendOptions = {}
|
|
307
|
+
): Promise<AppendResult> {
|
|
308
|
+
const { url: sourceUrl, file: filePath } = options;
|
|
309
|
+
|
|
310
|
+
if (!sourceUrl && !filePath) {
|
|
311
|
+
throw new Error("Either 'url' or 'file' must be provided in options");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (sourceUrl && filePath) {
|
|
315
|
+
throw new Error("Only one of 'url' or 'file' can be provided, not both");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const url = new URL("/v0/datasources", `${this.baseUrl}/`);
|
|
319
|
+
url.searchParams.set("name", datasourceName);
|
|
320
|
+
url.searchParams.set("mode", "append");
|
|
321
|
+
|
|
322
|
+
// Auto-detect format from file/url extension
|
|
323
|
+
const format = this.detectFormat(sourceUrl ?? filePath!);
|
|
324
|
+
if (format) {
|
|
325
|
+
url.searchParams.set("format", format);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Add CSV dialect options if applicable
|
|
329
|
+
if (options.csvDialect) {
|
|
330
|
+
if (options.csvDialect.delimiter) {
|
|
331
|
+
url.searchParams.set("dialect_delimiter", options.csvDialect.delimiter);
|
|
332
|
+
}
|
|
333
|
+
if (options.csvDialect.newLine) {
|
|
334
|
+
url.searchParams.set("dialect_new_line", options.csvDialect.newLine);
|
|
335
|
+
}
|
|
336
|
+
if (options.csvDialect.escapeChar) {
|
|
337
|
+
url.searchParams.set("dialect_escapechar", options.csvDialect.escapeChar);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let response: Response;
|
|
342
|
+
|
|
343
|
+
if (sourceUrl) {
|
|
344
|
+
// Remote URL: send as form-urlencoded with url parameter
|
|
345
|
+
response = await this.request(url.toString(), {
|
|
346
|
+
method: "POST",
|
|
347
|
+
token: apiOptions.token,
|
|
348
|
+
headers: {
|
|
349
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
350
|
+
},
|
|
351
|
+
body: `url=${encodeURIComponent(sourceUrl)}`,
|
|
352
|
+
signal: this.createAbortSignal(options.timeout ?? apiOptions.timeout, options.signal ?? apiOptions.signal),
|
|
353
|
+
});
|
|
354
|
+
} else {
|
|
355
|
+
// Local file: send as multipart form data
|
|
356
|
+
const formData = await this.createFileFormData(filePath!);
|
|
357
|
+
response = await this.request(url.toString(), {
|
|
358
|
+
method: "POST",
|
|
359
|
+
token: apiOptions.token,
|
|
360
|
+
body: formData,
|
|
361
|
+
signal: this.createAbortSignal(options.timeout ?? apiOptions.timeout, options.signal ?? apiOptions.signal),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!response.ok) {
|
|
366
|
+
await this.handleErrorResponse(response);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return (await response.json()) as AppendResult;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create a token using Tinybird Token API.
|
|
374
|
+
* Supports both static and JWT token payloads.
|
|
375
|
+
*/
|
|
376
|
+
async createToken(
|
|
377
|
+
body: TinybirdApiCreateTokenRequest,
|
|
378
|
+
options: TinybirdApiCreateTokenOptions = {}
|
|
379
|
+
): Promise<TinybirdApiCreateTokenResult> {
|
|
380
|
+
const url = new URL("/v0/tokens/", `${this.baseUrl}/`);
|
|
381
|
+
|
|
382
|
+
if (options.expirationTime !== undefined) {
|
|
383
|
+
url.searchParams.set("expiration_time", String(options.expirationTime));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const response = await this.request(url.toString(), {
|
|
387
|
+
method: "POST",
|
|
388
|
+
token: options.token,
|
|
389
|
+
headers: {
|
|
390
|
+
"Content-Type": "application/json",
|
|
391
|
+
},
|
|
392
|
+
body: JSON.stringify(body),
|
|
393
|
+
signal: this.createAbortSignal(options.timeout, options.signal),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
await this.handleErrorResponse(response);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return (await response.json()) as TinybirdApiCreateTokenResult;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Detect format from file path or URL extension
|
|
405
|
+
*/
|
|
406
|
+
private detectFormat(source: string): "csv" | "ndjson" | "parquet" | undefined {
|
|
407
|
+
// Remove query string if present
|
|
408
|
+
const pathOnly = source.split("?")[0];
|
|
409
|
+
const extension = pathOnly.split(".").pop()?.toLowerCase();
|
|
410
|
+
|
|
411
|
+
switch (extension) {
|
|
412
|
+
case "csv":
|
|
413
|
+
return "csv";
|
|
414
|
+
case "ndjson":
|
|
415
|
+
case "jsonl":
|
|
416
|
+
return "ndjson";
|
|
417
|
+
case "parquet":
|
|
418
|
+
return "parquet";
|
|
419
|
+
default:
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create FormData for file upload
|
|
426
|
+
*/
|
|
427
|
+
private async createFileFormData(filePath: string): Promise<FormData> {
|
|
428
|
+
// Dynamic import for Node.js fs module (browser-safe)
|
|
429
|
+
const fs = await import("node:fs");
|
|
430
|
+
const path = await import("node:path");
|
|
431
|
+
|
|
432
|
+
const fileContent = await fs.promises.readFile(filePath);
|
|
433
|
+
const fileName = path.basename(filePath);
|
|
434
|
+
|
|
435
|
+
const formData = new FormData();
|
|
436
|
+
formData.append("csv", new Blob([fileContent]), fileName);
|
|
437
|
+
|
|
438
|
+
return formData;
|
|
439
|
+
}
|
|
440
|
+
|
|
245
441
|
private createAbortSignal(
|
|
246
442
|
timeout?: number,
|
|
247
443
|
existingSignal?: AbortSignal
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createJWT,
|
|
4
|
+
TokenApiError,
|
|
5
|
+
type TokenApiConfig,
|
|
6
|
+
} from "./tokens.js";
|
|
7
|
+
|
|
8
|
+
// Mock fetch globally
|
|
9
|
+
const mockFetch = vi.fn();
|
|
10
|
+
global.fetch = mockFetch;
|
|
11
|
+
|
|
12
|
+
function expectFromParam(url: string) {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
expect(parsed.searchParams.get("from")).toBe("ts-sdk");
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("Token API client", () => {
|
|
19
|
+
const config: TokenApiConfig = {
|
|
20
|
+
baseUrl: "https://api.tinybird.co",
|
|
21
|
+
token: "p.admin-token",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockFetch.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("createJWT", () => {
|
|
29
|
+
it("creates a JWT token with scopes", async () => {
|
|
30
|
+
const mockResponse = { token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test" };
|
|
31
|
+
|
|
32
|
+
mockFetch.mockResolvedValueOnce({
|
|
33
|
+
ok: true,
|
|
34
|
+
json: () => Promise.resolve(mockResponse),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const result = await createJWT(config, {
|
|
38
|
+
name: "user_token",
|
|
39
|
+
expiresAt: 1700000000,
|
|
40
|
+
scopes: [
|
|
41
|
+
{
|
|
42
|
+
type: "PIPES:READ",
|
|
43
|
+
resource: "analytics_pipe",
|
|
44
|
+
fixed_params: { user_id: 123 },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.token).toBe(mockResponse.token);
|
|
50
|
+
|
|
51
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
52
|
+
const parsed = expectFromParam(url);
|
|
53
|
+
expect(parsed.pathname).toBe("/v0/tokens/");
|
|
54
|
+
expect(parsed.searchParams.get("expiration_time")).toBe("1700000000");
|
|
55
|
+
expect(init.method).toBe("POST");
|
|
56
|
+
const headers = new Headers(init.headers);
|
|
57
|
+
expect(headers.get("Authorization")).toBe("Bearer p.admin-token");
|
|
58
|
+
expect(headers.get("Content-Type")).toBe("application/json");
|
|
59
|
+
|
|
60
|
+
const body = JSON.parse(init.body);
|
|
61
|
+
expect(body.name).toBe("user_token");
|
|
62
|
+
expect(body.scopes).toHaveLength(1);
|
|
63
|
+
expect(body.scopes[0].type).toBe("PIPES:READ");
|
|
64
|
+
expect(body.scopes[0].resource).toBe("analytics_pipe");
|
|
65
|
+
expect(body.scopes[0].fixed_params).toEqual({ user_id: 123 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("converts Date to Unix timestamp", async () => {
|
|
69
|
+
mockFetch.mockResolvedValueOnce({
|
|
70
|
+
ok: true,
|
|
71
|
+
json: () => Promise.resolve({ token: "jwt-token" }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const expirationDate = new Date("2024-01-01T00:00:00Z");
|
|
75
|
+
await createJWT(config, {
|
|
76
|
+
name: "test",
|
|
77
|
+
expiresAt: expirationDate,
|
|
78
|
+
scopes: [{ type: "PIPES:READ", resource: "pipe" }],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const [url] = mockFetch.mock.calls[0];
|
|
82
|
+
const parsed = new URL(url);
|
|
83
|
+
expect(parsed.searchParams.get("expiration_time")).toBe("1704067200");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("converts ISO string to Unix timestamp", async () => {
|
|
87
|
+
mockFetch.mockResolvedValueOnce({
|
|
88
|
+
ok: true,
|
|
89
|
+
json: () => Promise.resolve({ token: "jwt-token" }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await createJWT(config, {
|
|
93
|
+
name: "test",
|
|
94
|
+
expiresAt: "2024-01-01T00:00:00Z",
|
|
95
|
+
scopes: [{ type: "PIPES:READ", resource: "pipe" }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [url] = mockFetch.mock.calls[0];
|
|
99
|
+
const parsed = new URL(url);
|
|
100
|
+
expect(parsed.searchParams.get("expiration_time")).toBe("1704067200");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("includes limits when provided", async () => {
|
|
104
|
+
mockFetch.mockResolvedValueOnce({
|
|
105
|
+
ok: true,
|
|
106
|
+
json: () => Promise.resolve({ token: "jwt-token" }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await createJWT(config, {
|
|
110
|
+
name: "rate_limited_token",
|
|
111
|
+
expiresAt: 1700000000,
|
|
112
|
+
scopes: [{ type: "PIPES:READ", resource: "pipe" }],
|
|
113
|
+
limits: { rps: 100 },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
117
|
+
const body = JSON.parse(init.body);
|
|
118
|
+
expect(body.limits).toEqual({ rps: 100 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses custom fetch implementation when provided", async () => {
|
|
122
|
+
const customFetch = vi.fn().mockResolvedValueOnce({
|
|
123
|
+
ok: true,
|
|
124
|
+
json: () => Promise.resolve({ token: "jwt-token" }),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await createJWT(
|
|
128
|
+
{
|
|
129
|
+
...config,
|
|
130
|
+
fetch: customFetch as typeof fetch,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "custom_fetch_token",
|
|
134
|
+
expiresAt: 1700000000,
|
|
135
|
+
scopes: [{ type: "PIPES:READ", resource: "pipe" }],
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(customFetch).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("supports datasource scope with filter", async () => {
|
|
144
|
+
mockFetch.mockResolvedValueOnce({
|
|
145
|
+
ok: true,
|
|
146
|
+
json: () => Promise.resolve({ token: "jwt-token" }),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await createJWT(config, {
|
|
150
|
+
name: "filtered_token",
|
|
151
|
+
expiresAt: 1700000000,
|
|
152
|
+
scopes: [
|
|
153
|
+
{
|
|
154
|
+
type: "DATASOURCES:READ",
|
|
155
|
+
resource: "events",
|
|
156
|
+
filter: "org_id = 'acme'",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
162
|
+
const body = JSON.parse(init.body);
|
|
163
|
+
expect(body.scopes[0].filter).toBe("org_id = 'acme'");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("throws TokenApiError on 403 with helpful message", async () => {
|
|
167
|
+
mockFetch.mockResolvedValueOnce({
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 403,
|
|
170
|
+
statusText: "Forbidden",
|
|
171
|
+
text: () => Promise.resolve("Insufficient permissions"),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await expect(
|
|
175
|
+
createJWT(config, {
|
|
176
|
+
name: "test",
|
|
177
|
+
expiresAt: 1700000000,
|
|
178
|
+
scopes: [],
|
|
179
|
+
})
|
|
180
|
+
).rejects.toThrow(TokenApiError);
|
|
181
|
+
|
|
182
|
+
mockFetch.mockResolvedValueOnce({
|
|
183
|
+
ok: false,
|
|
184
|
+
status: 403,
|
|
185
|
+
statusText: "Forbidden",
|
|
186
|
+
text: () => Promise.resolve("Insufficient permissions"),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await createJWT(config, {
|
|
191
|
+
name: "test",
|
|
192
|
+
expiresAt: 1700000000,
|
|
193
|
+
scopes: [],
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
expect((error as TokenApiError).status).toBe(403);
|
|
197
|
+
expect((error as TokenApiError).message).toContain("TOKENS or ADMIN scope");
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("throws TokenApiError on 400 with validation error", async () => {
|
|
202
|
+
mockFetch.mockResolvedValueOnce({
|
|
203
|
+
ok: false,
|
|
204
|
+
status: 400,
|
|
205
|
+
statusText: "Bad Request",
|
|
206
|
+
text: () => Promise.resolve('{"error": "Invalid scope type"}'),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await expect(
|
|
210
|
+
createJWT(config, {
|
|
211
|
+
name: "test",
|
|
212
|
+
expiresAt: 1700000000,
|
|
213
|
+
scopes: [],
|
|
214
|
+
})
|
|
215
|
+
).rejects.toThrow(TokenApiError);
|
|
216
|
+
|
|
217
|
+
mockFetch.mockResolvedValueOnce({
|
|
218
|
+
ok: false,
|
|
219
|
+
status: 400,
|
|
220
|
+
statusText: "Bad Request",
|
|
221
|
+
text: () => Promise.resolve('{"error": "Invalid scope type"}'),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await createJWT(config, {
|
|
226
|
+
name: "test",
|
|
227
|
+
expiresAt: 1700000000,
|
|
228
|
+
scopes: [],
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
expect((error as TokenApiError).status).toBe(400);
|
|
232
|
+
expect((error as TokenApiError).message).toContain("Invalid scope type");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("does not include limits if not provided", async () => {
|
|
237
|
+
mockFetch.mockResolvedValueOnce({
|
|
238
|
+
ok: true,
|
|
239
|
+
json: () => Promise.resolve({ token: "jwt-token" }),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await createJWT(config, {
|
|
243
|
+
name: "simple_token",
|
|
244
|
+
expiresAt: 1700000000,
|
|
245
|
+
scopes: [{ type: "PIPES:READ", resource: "pipe" }],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
249
|
+
const body = JSON.parse(init.body);
|
|
250
|
+
expect(body.limits).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tinybird Token API client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createTinybirdApi,
|
|
7
|
+
TinybirdApiError,
|
|
8
|
+
} from "./api.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* API configuration for token operations.
|
|
12
|
+
* Requires a token with TOKENS or ADMIN scope.
|
|
13
|
+
*/
|
|
14
|
+
export interface TokenApiConfig {
|
|
15
|
+
/** Tinybird API base URL */
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
/** Workspace token with TOKENS or ADMIN scope */
|
|
18
|
+
token: string;
|
|
19
|
+
/** Custom fetch implementation (optional, defaults to global fetch) */
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
/** Default timeout in milliseconds (optional) */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Error thrown by token API operations
|
|
27
|
+
*/
|
|
28
|
+
export class TokenApiError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
public readonly status: number,
|
|
32
|
+
public readonly body?: unknown
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "TokenApiError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scope type for JWT tokens
|
|
41
|
+
*/
|
|
42
|
+
export type JWTScopeType =
|
|
43
|
+
| "PIPES:READ"
|
|
44
|
+
| "DATASOURCES:READ"
|
|
45
|
+
| "DATASOURCES:APPEND";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A scope definition for JWT tokens
|
|
49
|
+
*/
|
|
50
|
+
export interface JWTScope {
|
|
51
|
+
/** The type of access being granted */
|
|
52
|
+
type: JWTScopeType;
|
|
53
|
+
/** The resource name (pipe or datasource) */
|
|
54
|
+
resource: string;
|
|
55
|
+
/** Fixed parameters embedded in the JWT (for pipes) */
|
|
56
|
+
fixed_params?: Record<string, string | number | boolean>;
|
|
57
|
+
/** SQL filter expression (for datasources) */
|
|
58
|
+
filter?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rate limiting configuration for JWT tokens
|
|
63
|
+
*/
|
|
64
|
+
export interface JWTLimits {
|
|
65
|
+
/** Requests per second limit */
|
|
66
|
+
rps?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Options for creating a JWT token
|
|
71
|
+
*/
|
|
72
|
+
export interface CreateJWTOptions {
|
|
73
|
+
/** Token name/identifier */
|
|
74
|
+
name: string;
|
|
75
|
+
/** Expiration time as Date, Unix timestamp (number), or ISO string */
|
|
76
|
+
expiresAt: Date | number | string;
|
|
77
|
+
/** Array of scopes defining access permissions */
|
|
78
|
+
scopes: JWTScope[];
|
|
79
|
+
/** Optional rate limiting configuration */
|
|
80
|
+
limits?: JWTLimits;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Result of creating a JWT token
|
|
85
|
+
*/
|
|
86
|
+
export interface CreateJWTResult {
|
|
87
|
+
/** The generated JWT token string */
|
|
88
|
+
token: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Request body for creating a JWT token
|
|
93
|
+
*/
|
|
94
|
+
interface CreateJWTRequestBody {
|
|
95
|
+
name: string;
|
|
96
|
+
scopes: JWTScope[];
|
|
97
|
+
limits?: JWTLimits;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert expiration input to Unix timestamp
|
|
102
|
+
*/
|
|
103
|
+
function toUnixTimestamp(expiresAt: Date | number | string): number {
|
|
104
|
+
if (typeof expiresAt === "number") {
|
|
105
|
+
return expiresAt;
|
|
106
|
+
}
|
|
107
|
+
if (expiresAt instanceof Date) {
|
|
108
|
+
return Math.floor(expiresAt.getTime() / 1000);
|
|
109
|
+
}
|
|
110
|
+
return Math.floor(new Date(expiresAt).getTime() / 1000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create a JWT token
|
|
115
|
+
* POST /v0/tokens/?expiration_time={unix_timestamp}
|
|
116
|
+
*
|
|
117
|
+
* @param config - API configuration (requires TOKENS or ADMIN scope)
|
|
118
|
+
* @param options - JWT creation options
|
|
119
|
+
* @returns The created JWT token
|
|
120
|
+
*/
|
|
121
|
+
export async function createJWT(
|
|
122
|
+
config: TokenApiConfig,
|
|
123
|
+
options: CreateJWTOptions
|
|
124
|
+
): Promise<CreateJWTResult> {
|
|
125
|
+
const expirationTime = toUnixTimestamp(options.expiresAt);
|
|
126
|
+
|
|
127
|
+
const body: CreateJWTRequestBody = {
|
|
128
|
+
name: options.name,
|
|
129
|
+
scopes: options.scopes,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (options.limits) {
|
|
133
|
+
body.limits = options.limits;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const api = createTinybirdApi({
|
|
137
|
+
baseUrl: config.baseUrl,
|
|
138
|
+
token: config.token,
|
|
139
|
+
fetch: config.fetch,
|
|
140
|
+
timeout: config.timeout,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const result = await api.createToken(body, {
|
|
145
|
+
expirationTime,
|
|
146
|
+
});
|
|
147
|
+
return { token: result.token };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (!(error instanceof TinybirdApiError)) {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const responseBody = error.responseBody ?? error.message;
|
|
154
|
+
let message: string;
|
|
155
|
+
|
|
156
|
+
if (error.statusCode === 403) {
|
|
157
|
+
message =
|
|
158
|
+
`Permission denied creating JWT token. ` +
|
|
159
|
+
`Make sure the token has TOKENS or ADMIN scope. ` +
|
|
160
|
+
`API response: ${responseBody}`;
|
|
161
|
+
} else if (error.statusCode === 400) {
|
|
162
|
+
message = `Invalid JWT token request: ${responseBody}`;
|
|
163
|
+
} else {
|
|
164
|
+
message = `Failed to create JWT token: ${error.statusCode}. API response: ${responseBody}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new TokenApiError(message, error.statusCode, responseBody);
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -22,7 +22,6 @@ import { getGitRoot } from "../git.js";
|
|
|
22
22
|
import { fetchAllResources } from "../../api/resources.js";
|
|
23
23
|
import { generateCombinedFile } from "../../codegen/index.js";
|
|
24
24
|
import { execSync } from "child_process";
|
|
25
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
26
25
|
import {
|
|
27
26
|
detectPackageManager,
|
|
28
27
|
getPackageManagerAddCmd,
|
|
@@ -313,6 +312,8 @@ export interface InitOptions {
|
|
|
313
312
|
includeCdWorkflow?: boolean;
|
|
314
313
|
/** Git provider for workflow templates */
|
|
315
314
|
workflowProvider?: "github" | "gitlab";
|
|
315
|
+
/** Skip auto-installing @tinybirdco/sdk dependency */
|
|
316
|
+
skipDependencyInstall?: boolean;
|
|
316
317
|
}
|
|
317
318
|
|
|
318
319
|
/**
|
|
@@ -415,6 +416,8 @@ export async function runInit(options: InitOptions = {}): Promise<InitResult> {
|
|
|
415
416
|
const cwd = options.cwd ?? process.cwd();
|
|
416
417
|
const force = options.force ?? false;
|
|
417
418
|
const skipLogin = options.skipLogin ?? false;
|
|
419
|
+
const skipDependencyInstall =
|
|
420
|
+
options.skipDependencyInstall ?? Boolean(process.env.VITEST);
|
|
418
421
|
|
|
419
422
|
const created: string[] = [];
|
|
420
423
|
const skipped: string[] = [];
|
|
@@ -739,14 +742,13 @@ export async function runInit(options: InitOptions = {}): Promise<InitResult> {
|
|
|
739
742
|
}
|
|
740
743
|
|
|
741
744
|
// Install @tinybirdco/sdk if not already installed
|
|
742
|
-
if (!hasTinybirdSdkDependency(cwd)) {
|
|
745
|
+
if (!skipDependencyInstall && !hasTinybirdSdkDependency(cwd)) {
|
|
743
746
|
const s = p.spinner();
|
|
744
747
|
s.start("Installing dependencies");
|
|
745
748
|
const packageManager = detectPackageManager(cwd);
|
|
746
749
|
const addCmd = getPackageManagerAddCmd(packageManager);
|
|
747
750
|
try {
|
|
748
751
|
execSync(`${addCmd} @tinybirdco/sdk`, { cwd, stdio: "pipe" });
|
|
749
|
-
await sleep(1000);
|
|
750
752
|
s.stop("Installed dependencies");
|
|
751
753
|
created.push("@tinybirdco/sdk");
|
|
752
754
|
} catch (error) {
|