@rigkit/provider-freestyle 0.2.8 → 0.2.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/provider-freestyle",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,16 +17,16 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^4",
20
- "@rigkit/sdk": "0.2.8",
21
- "@rigkit/engine": "0.2.8",
22
- "@rigkit/provider-cmux": "0.2.8"
20
+ "@rigkit/sdk": "0.2.10",
21
+ "@rigkit/engine": "0.2.10",
22
+ "@rigkit/provider-cmux": "0.2.10"
23
23
  },
24
24
  "peerDependencies": {
25
- "freestyle": "^0.1.51"
25
+ "freestyle": "^0.1.52"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/bun": "latest",
29
- "freestyle": "^0.1.51",
29
+ "freestyle": "^0.1.52",
30
30
  "typescript": "latest"
31
31
  },
32
32
  "publishConfig": {
@@ -8,7 +8,7 @@ import type {
8
8
  WorkflowEvent,
9
9
  } from "@rigkit/engine";
10
10
  import { FREESTYLE_PROVIDER_ID, freestyle, freestyleProviderPlugin } from "./index.ts";
11
- import { createFreestyleProxyFetch } from "./host-auth.ts";
11
+ import { createFreestyleProxyFetch, createFreestyleSdkFetch } from "./host-auth.ts";
12
12
  import type { FreestyleRuntime } from "./provider.ts";
13
13
  import { RIGKIT_PROVIDER_FREESTYLE_VERSION } from "./version.ts";
14
14
 
@@ -293,6 +293,102 @@ describe("Freestyle provider host auth", () => {
293
293
  });
294
294
 
295
295
  describe("Freestyle provider proxy fetch", () => {
296
+ test("logs a replayable API-key fetch with the Freestyle API key redacted", async () => {
297
+ const sdkFetch = createFreestyleSdkFetch(testFetch(async () =>
298
+ Response.json({
299
+ code: "INTERNAL_ERROR",
300
+ message: "Internal server error",
301
+ }, { status: 500, statusText: "Internal Server Error" })
302
+ ));
303
+
304
+ const messages = await captureConsoleError(async () => {
305
+ const response = await sdkFetch("https://api.freestyle.sh/v1/vms", {
306
+ method: "POST",
307
+ headers: {
308
+ Authorization: "Bearer real-api-key",
309
+ "Content-Type": "application/json",
310
+ },
311
+ body: JSON.stringify({
312
+ image: "ubuntu-24.04",
313
+ apiKey: "body-api-key",
314
+ }),
315
+ });
316
+ expect(response.status).toBe(500);
317
+ await response.text();
318
+ });
319
+
320
+ expect(messages).toHaveLength(1);
321
+ expect(messages[0]).toContain('await fetch("https://api.freestyle.sh/v1/vms", {');
322
+ expect(messages[0]).toContain('"Authorization": "Bearer <redacted FREESTYLE_API_KEY>"');
323
+ expect(messages[0]).toContain('"image": "ubuntu-24.04"');
324
+ expect(messages[0]).toContain('"apiKey": "[redacted]"');
325
+ expect(messages[0]).toContain('Response: 500 Internal Server Error');
326
+ expect(messages[0]).not.toContain("real-api-key");
327
+ expect(messages[0]).not.toContain("body-api-key");
328
+ });
329
+
330
+ test("logs the original replayable request when a background request fails through the proxy", async () => {
331
+ const proxyFetch = createFreestyleProxyFetch({
332
+ dashboardUrl: "https://dash.freestyle.sh",
333
+ accessToken: "stack-access-token",
334
+ teamId: "team_123",
335
+ fetch: testFetch(async (resource, init) => {
336
+ const url = resourceUrl(resource);
337
+ expect(url.href).toBe("https://dash.freestyle.sh/api/proxy/request");
338
+ const body = JSON.parse(String(init?.body));
339
+ if (body.data.path === "v1/vms") {
340
+ return Response.json({
341
+ requestId: "ri_test_123",
342
+ status: "pending",
343
+ });
344
+ }
345
+ if (body.data.path === "auth/v1/background-requests/ri_test_123") {
346
+ return Response.json({
347
+ code: "INTERNAL_ERROR",
348
+ message: "Internal server error",
349
+ accessToken: "should-redact",
350
+ }, { status: 500, statusText: "Internal Server Error" });
351
+ }
352
+ return Response.json({ error: "unexpected request", body }, { status: 500 });
353
+ }),
354
+ });
355
+
356
+ const first = await proxyFetch("https://api.freestyle.sh/v1/vms", {
357
+ method: "POST",
358
+ headers: {
359
+ Authorization: "Bearer rigkit-browser-auth",
360
+ "Content-Type": "application/json",
361
+ },
362
+ body: JSON.stringify({
363
+ image: "ubuntu-24.04",
364
+ }),
365
+ });
366
+ expect(first.status).toBe(202);
367
+
368
+ const messages = await captureConsoleError(async () => {
369
+ const failed = await proxyFetch("https://api.freestyle.sh/auth/v1/background-requests/ri_test_123", {
370
+ method: "GET",
371
+ headers: {
372
+ Authorization: "Bearer rigkit-browser-auth",
373
+ },
374
+ });
375
+ expect(failed.status).toBe(500);
376
+ await failed.text();
377
+ });
378
+
379
+ expect(messages).toHaveLength(1);
380
+ expect(messages[0]).toContain("Freestyle background request ri_test_123 failed. Original API request:");
381
+ expect(messages[0]).toContain('await fetch("https://api.freestyle.sh/v1/vms", {');
382
+ expect(messages[0]).toContain('method: "POST"');
383
+ expect(messages[0]).toContain('"Authorization": "Bearer <redacted FREESTYLE_API_KEY>"');
384
+ expect(messages[0]).toContain('"image": "ubuntu-24.04"');
385
+ expect(messages[0]).toContain('Response: 500 Internal Server Error');
386
+ expect(messages[0]).toContain('"accessToken":"[redacted]"');
387
+ expect(messages[0]).not.toContain("stack-access-token");
388
+ expect(messages[0]).not.toContain("rigkit-browser-auth");
389
+ expect(messages[0]).not.toContain("should-redact");
390
+ });
391
+
296
392
  test("preserves Freestyle background request semantics through the browser-auth proxy", async () => {
297
393
  const proxyFetch = createFreestyleProxyFetch({
298
394
  dashboardUrl: "https://dash.freestyle.sh",
@@ -356,13 +452,16 @@ describe("Freestyle provider proxy fetch", () => {
356
452
  ),
357
453
  });
358
454
 
359
- const response = await proxyFetch("https://api.freestyle.sh/v1/vms", {
360
- method: "POST",
361
- body: "{}",
455
+ let response: Response | undefined;
456
+ await captureConsoleError(async () => {
457
+ response = await proxyFetch("https://api.freestyle.sh/v1/vms", {
458
+ method: "POST",
459
+ body: "{}",
460
+ });
362
461
  });
363
462
 
364
- expect(response.status).toBe(500);
365
- await expect(response.json()).resolves.toEqual({
463
+ expect(response?.status).toBe(500);
464
+ await expect(response?.json()).resolves.toEqual({
366
465
  code: "INTERNAL_ERROR",
367
466
  message: "VM setup failed",
368
467
  details: {
@@ -389,10 +488,13 @@ describe("Freestyle provider proxy fetch", () => {
389
488
  ),
390
489
  });
391
490
 
392
- const response = await proxyFetch("https://api.freestyle.sh/v1/vms");
491
+ let response: Response | undefined;
492
+ await captureConsoleError(async () => {
493
+ response = await proxyFetch("https://api.freestyle.sh/v1/vms");
494
+ });
393
495
 
394
- expect(response.status).toBe(500);
395
- await expect(response.json()).resolves.toEqual({
496
+ expect(response?.status).toBe(500);
497
+ await expect(response?.json()).resolves.toEqual({
396
498
  code: "INTERNAL_ERROR",
397
499
  message: "Internal server error",
398
500
  requestId: "req_123",
@@ -475,6 +577,20 @@ function resourceUrl(resource: Parameters<typeof fetch>[0]): URL {
475
577
  return new URL(resource.url);
476
578
  }
477
579
 
580
+ async function captureConsoleError(action: () => Promise<void>): Promise<string[]> {
581
+ const previous = console.error;
582
+ const messages: string[] = [];
583
+ console.error = (...args: unknown[]) => {
584
+ messages.push(args.map((arg) => String(arg)).join(" "));
585
+ };
586
+ try {
587
+ await action();
588
+ } finally {
589
+ console.error = previous;
590
+ }
591
+ return messages;
592
+ }
593
+
478
594
  function setEnv(name: string, value: string | undefined): void {
479
595
  if (value === undefined) {
480
596
  delete process.env[name];
package/src/host-auth.ts CHANGED
@@ -8,6 +8,7 @@ import { RIGKIT_PROVIDER_FREESTYLE_VERSION } from "./version.ts";
8
8
 
9
9
  const DEFAULT_STACK_API_URL = "https://api.stack-auth.com";
10
10
  const DEFAULT_STACK_APP_URL = "https://dash.freestyle.sh";
11
+ const DEFAULT_FREESTYLE_API_URL = "https://api.freestyle.sh";
11
12
  const DEFAULT_STACK_PROJECT_ID = "0edf478c-f123-46fb-818f-34c0024a9f35";
12
13
  const DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY = "pck_h2aft7g9pqjzrkdnzs199h1may5wjtdtdxeex7m2wzp1r";
13
14
  const DEFAULT_CLI_AUTH_TIMEOUT_MILLIS = 10 * 60 * 1000;
@@ -112,10 +113,15 @@ export function createFreestyleProxyFetch(input: {
112
113
  }): typeof fetch {
113
114
  const fetchFn = input.fetch ?? globalThis.fetch;
114
115
  const dashboardUrl = trimTrailingSlash(input.dashboardUrl);
116
+ const backgroundRequests = new Map<string, string>();
115
117
 
116
118
  const proxyFetch = async (resource: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
117
119
  const url = resourceUrl(resource);
118
120
  const path = `${url.pathname}${url.search}`.replace(/^\/+/, "");
121
+ const freestyleRequestInit: RequestInit = {
122
+ ...init,
123
+ headers: withRigkitHeaders(init?.headers),
124
+ };
119
125
  const proxyResponse = await fetchFn(`${dashboardUrl}/api/proxy/request`, {
120
126
  method: "POST",
121
127
  headers: withRigkitHeaders({
@@ -126,8 +132,8 @@ export function createFreestyleProxyFetch(input: {
126
132
  accessToken: input.accessToken,
127
133
  teamId: input.teamId,
128
134
  path,
129
- method: init?.method ?? "GET",
130
- headers: Object.fromEntries(withRigkitHeaders(init?.headers).entries()),
135
+ method: resolveRequestMethod(resource, init),
136
+ headers: Object.fromEntries(new Headers(freestyleRequestInit.headers).entries()),
131
137
  body: init?.body ? String(init.body) : undefined,
132
138
  },
133
139
  }),
@@ -135,6 +141,13 @@ export function createFreestyleProxyFetch(input: {
135
141
 
136
142
  if (!proxyResponse.ok) {
137
143
  const errorText = await proxyResponse.text();
144
+ await logFreestyleApiRequestFailure({
145
+ backgroundRequests,
146
+ resource,
147
+ init: freestyleRequestInit,
148
+ response: proxyResponse,
149
+ responseText: errorText,
150
+ });
138
151
  const normalized = normalizeProxyError(errorText, proxyResponse.status);
139
152
  return new Response(normalized.body, {
140
153
  status: proxyResponse.status,
@@ -146,6 +159,9 @@ export function createFreestyleProxyFetch(input: {
146
159
  const data = await proxyResponse.json();
147
160
  if (isBackgroundRequestPending(data)) {
148
161
  const requestId = backgroundRequestId(data);
162
+ if (requestId) {
163
+ backgroundRequests.set(requestId, formatReplayableFetchRequest(resource, freestyleRequestInit));
164
+ }
149
165
  return Response.json(data, {
150
166
  status: 202,
151
167
  headers: {
@@ -162,11 +178,24 @@ export function createFreestyleProxyFetch(input: {
162
178
  }
163
179
 
164
180
  export function createFreestyleSdkFetch(fetchFn: typeof fetch = globalThis.fetch): typeof fetch {
165
- const rigkitFetch = (async (resource, init) =>
166
- await fetchFn(resource, {
181
+ const backgroundRequests = new Map<string, string>();
182
+ const rigkitFetch = (async (resource, init) => {
183
+ const requestInit: RequestInit = {
167
184
  ...init,
168
185
  headers: withRigkitHeaders(init?.headers),
169
- })) as typeof fetch;
186
+ };
187
+ const response = await fetchFn(resource, requestInit);
188
+ await rememberBackgroundRequest(backgroundRequests, resource, requestInit, response);
189
+ if (!response.ok) {
190
+ await logFreestyleApiRequestFailure({
191
+ backgroundRequests,
192
+ resource,
193
+ init: requestInit,
194
+ response,
195
+ });
196
+ }
197
+ return response;
198
+ }) as typeof fetch;
170
199
  return Object.assign(rigkitFetch, {
171
200
  preconnect: fetchFn.preconnect?.bind(fetchFn) ?? (() => {}),
172
201
  }) as typeof fetch;
@@ -269,6 +298,162 @@ function withRigkitHeaders(headers: HeadersInit | undefined): Headers {
269
298
  return next;
270
299
  }
271
300
 
301
+ async function rememberBackgroundRequest(
302
+ backgroundRequests: Map<string, string>,
303
+ resource: Parameters<typeof fetch>[0],
304
+ init: RequestInit,
305
+ response: Response,
306
+ ): Promise<void> {
307
+ if (response.status !== 202) return;
308
+ const requestId = await responseBackgroundRequestId(response);
309
+ if (!requestId) return;
310
+ backgroundRequests.set(requestId, formatReplayableFetchRequest(resource, init));
311
+ }
312
+
313
+ async function logFreestyleApiRequestFailure(input: {
314
+ backgroundRequests: Map<string, string>;
315
+ resource: Parameters<typeof fetch>[0];
316
+ init: RequestInit;
317
+ response: Response;
318
+ responseText?: string;
319
+ }): Promise<void> {
320
+ if (isFreestyleBackgroundLogRequest(input.resource)) return;
321
+
322
+ const requestId = backgroundRequestIdFromResource(input.resource);
323
+ const replayRequest = requestId ? input.backgroundRequests.get(requestId) : undefined;
324
+ const responseSummary = await formatResponseSummary(input.response, input.responseText);
325
+ const heading = requestId
326
+ ? `Freestyle background request ${requestId} failed. Original API request:`
327
+ : "Freestyle API request failed. Replay request:";
328
+ const request = replayRequest ?? formatReplayableFetchRequest(input.resource, input.init);
329
+ console.error(`${heading}\n${request}\n${responseSummary}`);
330
+ }
331
+
332
+ async function responseBackgroundRequestId(response: Response): Promise<string | undefined> {
333
+ const header = response.headers.get("x-freestyle-background-request-id");
334
+ if (header) return header;
335
+ const data = await response.clone().json().catch(() => undefined);
336
+ return backgroundRequestId(data);
337
+ }
338
+
339
+ function backgroundRequestIdFromResource(resource: Parameters<typeof fetch>[0]): string | undefined {
340
+ const path = resourceUrl(resource).pathname;
341
+ const match = path.match(/\/auth\/v1\/background-requests\/([^/]+)$/);
342
+ return match?.[1] ? decodeURIComponent(match[1]) : undefined;
343
+ }
344
+
345
+ function isFreestyleBackgroundLogRequest(resource: Parameters<typeof fetch>[0]): boolean {
346
+ return resourceUrl(resource).pathname === "/observability/v1/logs";
347
+ }
348
+
349
+ async function formatResponseSummary(response: Response, responseText?: string): Promise<string> {
350
+ const text = responseText ?? await response.clone().text().catch(() => "");
351
+ const status = [response.status, response.statusText].filter(Boolean).join(" ");
352
+ const redactedBody = formatRedactedResponseBody(text);
353
+ return [
354
+ `Response: ${status}`,
355
+ ...(redactedBody ? [`Response body: ${redactedBody}`] : []),
356
+ ].join("\n");
357
+ }
358
+
359
+ function formatRedactedResponseBody(text: string): string | undefined {
360
+ if (!text) return undefined;
361
+ try {
362
+ return JSON.stringify(redactSensitiveFields(JSON.parse(text)));
363
+ } catch {
364
+ return text;
365
+ }
366
+ }
367
+
368
+ function formatReplayableFetchRequest(resource: Parameters<typeof fetch>[0], init: RequestInit): string {
369
+ const lines = [
370
+ `await fetch(${JSON.stringify(resourceUrl(resource).href)}, {`,
371
+ ` method: ${JSON.stringify(resolveRequestMethod(resource, init))},`,
372
+ ];
373
+ const headers = replayableHeaders(resource, init);
374
+ if (Object.keys(headers).length > 0) {
375
+ lines.push(` headers: ${indentContinuation(JSON.stringify(headers, null, 2), 2)},`);
376
+ }
377
+ const body = replayableBody(init.body);
378
+ if (body) {
379
+ lines.push(` body: ${indentContinuation(body, 2)},`);
380
+ }
381
+ lines.push("});");
382
+ return lines.join("\n");
383
+ }
384
+
385
+ function replayableHeaders(resource: Parameters<typeof fetch>[0], init: RequestInit): Record<string, string> {
386
+ const headers = resource instanceof Request ? new Headers(resource.headers) : new Headers();
387
+ new Headers(init.headers).forEach((value, key) => {
388
+ headers.set(key, value);
389
+ });
390
+ const result: Record<string, string> = {};
391
+ headers.forEach((value, key) => {
392
+ result[displayHeaderName(key)] = redactHeaderValue(key, value);
393
+ });
394
+ return result;
395
+ }
396
+
397
+ function resolveRequestMethod(resource: Parameters<typeof fetch>[0], init?: RequestInit): string {
398
+ return init?.method ?? (resource instanceof Request ? resource.method : "GET");
399
+ }
400
+
401
+ function replayableBody(body: BodyInit | null | undefined): string | undefined {
402
+ if (body === undefined || body === null) return undefined;
403
+ if (typeof body === "string") {
404
+ try {
405
+ const parsed = JSON.parse(body) as unknown;
406
+ return `JSON.stringify(${JSON.stringify(redactSensitiveFields(parsed), null, 2)})`;
407
+ } catch {
408
+ return JSON.stringify(body);
409
+ }
410
+ }
411
+ if (body instanceof URLSearchParams) {
412
+ return `new URLSearchParams(${JSON.stringify(body.toString())})`;
413
+ }
414
+ return JSON.stringify(String(body));
415
+ }
416
+
417
+ function indentContinuation(value: string, spaces: number): string {
418
+ const lines = value.split("\n");
419
+ const indent = " ".repeat(spaces);
420
+ return [lines[0], ...lines.slice(1).map((line) => `${indent}${line}`)].join("\n");
421
+ }
422
+
423
+ function displayHeaderName(name: string): string {
424
+ const normalized = name.toLowerCase();
425
+ return {
426
+ authorization: "Authorization",
427
+ "content-type": "Content-Type",
428
+ "user-agent": "User-Agent",
429
+ "x-freestyle-identity-access-token": "X-Freestyle-Identity-Access-Token",
430
+ [RIGKIT_HEADER]: RIGKIT_HEADER,
431
+ [RIGKIT_VERSION_HEADER]: RIGKIT_VERSION_HEADER,
432
+ }[normalized] ?? name;
433
+ }
434
+
435
+ function redactHeaderValue(name: string, value: string): string {
436
+ if (name.toLowerCase() === "authorization" && /^Bearer\s+/i.test(value)) {
437
+ return "Bearer <redacted FREESTYLE_API_KEY>";
438
+ }
439
+ return isSensitiveFieldName(name) ? "[redacted]" : value;
440
+ }
441
+
442
+ function redactSensitiveFields(value: unknown): unknown {
443
+ if (Array.isArray(value)) return value.map(redactSensitiveFields);
444
+ if (!value || typeof value !== "object") return value;
445
+ const next: Record<string, unknown> = {};
446
+ for (const [key, field] of Object.entries(value)) {
447
+ next[key] = isSensitiveFieldName(key) ? "[redacted]" : redactSensitiveFields(field);
448
+ }
449
+ return next;
450
+ }
451
+
452
+ function isSensitiveFieldName(name: string): boolean {
453
+ return /authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret|credential|cookie/i
454
+ .test(name);
455
+ }
456
+
272
457
  async function resolveStackAccessToken(input: {
273
458
  config: StackAuthConfig;
274
459
  storage: ProviderStorage;
@@ -512,7 +697,7 @@ function saveStackAuthState(storage: ProviderStorage, key: string, state: StackA
512
697
  }
513
698
 
514
699
  function resourceUrl(resource: Parameters<typeof fetch>[0]): URL {
515
- if (typeof resource === "string") return new URL(resource);
700
+ if (typeof resource === "string") return new URL(resource, DEFAULT_FREESTYLE_API_URL);
516
701
  if (resource instanceof URL) return resource;
517
702
  return new URL(resource.url);
518
703
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const RIGKIT_PROVIDER_FREESTYLE_VERSION = "0.2.8";
1
+ export const RIGKIT_PROVIDER_FREESTYLE_VERSION = "0.2.10";