@rigkit/provider-freestyle 0.2.2 → 0.2.4

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.
@@ -0,0 +1,591 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Freestyle } from "freestyle";
3
+ import type { LocalWorkspaceRuntime, ProviderStorage } from "@rigkit/engine";
4
+ import type { JsonValue } from "@rigkit/sdk";
5
+ import { freestyleIdentityId, freestyleToken, freestyleTokenId } from "./auth.ts";
6
+ import { createFreestyleStore } from "./store.ts";
7
+
8
+ const DEFAULT_STACK_API_URL = "https://api.stack-auth.com";
9
+ const DEFAULT_STACK_APP_URL = "https://dash.freestyle.sh";
10
+ const DEFAULT_STACK_PROJECT_ID = "0edf478c-f123-46fb-818f-34c0024a9f35";
11
+ const DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY = "pck_h2aft7g9pqjzrkdnzs199h1may5wjtdtdxeex7m2wzp1r";
12
+ const DEFAULT_CLI_AUTH_TIMEOUT_MILLIS = 10 * 60 * 1000;
13
+ const DEFAULT_POLL_INTERVAL_MILLIS = 2000;
14
+
15
+ export type FreestyleProviderAuthConfig = {
16
+ apiKey?: string;
17
+ profile?: string;
18
+ teamId?: string;
19
+ apiUrl?: string;
20
+ dashboardUrl?: string;
21
+ stackApiUrl?: string;
22
+ stackAppUrl?: string;
23
+ stackProjectId?: string;
24
+ stackPublishableClientKey?: string;
25
+ };
26
+
27
+ export type FreestyleAuthenticatedClient = {
28
+ client: Freestyle;
29
+ identityId: ReturnType<typeof freestyleIdentityId>;
30
+ tokenId: ReturnType<typeof freestyleTokenId>;
31
+ token: ReturnType<typeof freestyleToken>;
32
+ };
33
+
34
+ type CreateFreestyleAuthenticatedClientInput = {
35
+ auth?: FreestyleProviderAuthConfig;
36
+ hostStorage: ProviderStorage;
37
+ local: LocalWorkspaceRuntime;
38
+ fetch?: typeof fetch;
39
+ timeoutMs?: number;
40
+ pollIntervalMs?: number;
41
+ };
42
+
43
+ type ResolvedClientAuth = {
44
+ client: Freestyle;
45
+ identityKey: string;
46
+ };
47
+
48
+ type StackAuthConfig = {
49
+ stackApiUrl: string;
50
+ appUrl: string;
51
+ dashboardUrl: string;
52
+ projectId: string;
53
+ publishableClientKey: string;
54
+ profile: string;
55
+ };
56
+
57
+ type StackAuthState = {
58
+ refreshToken: string;
59
+ updatedAt: number;
60
+ defaultTeamId?: string;
61
+ accessToken?: string;
62
+ accessTokenUpdatedAt?: number;
63
+ };
64
+
65
+ type StackTokenRefresh = {
66
+ accessToken: string;
67
+ refreshToken?: string;
68
+ };
69
+
70
+ type FreestyleTeam = {
71
+ id: string;
72
+ displayName?: string;
73
+ sandboxAccountId?: string | null;
74
+ };
75
+
76
+ export async function createFreestyleAuthenticatedClient(
77
+ input: CreateFreestyleAuthenticatedClientInput,
78
+ ): Promise<FreestyleAuthenticatedClient> {
79
+ const auth = await resolveClientAuth(input);
80
+ const store = createFreestyleStore(input.hostStorage);
81
+ const savedIdentity = store.getIdentity(auth.identityKey);
82
+ if (savedIdentity) {
83
+ return {
84
+ client: auth.client,
85
+ identityId: savedIdentity.identityId,
86
+ tokenId: savedIdentity.tokenId,
87
+ token: savedIdentity.token,
88
+ };
89
+ }
90
+
91
+ const { identity, identityId } = await auth.client.identities.create();
92
+ const { token, tokenId } = await identity.tokens.create();
93
+ const createdIdentity = store.saveIdentity({
94
+ key: auth.identityKey,
95
+ identityId: freestyleIdentityId(identityId),
96
+ tokenId: freestyleTokenId(tokenId),
97
+ token: freestyleToken(token),
98
+ });
99
+
100
+ return {
101
+ client: auth.client,
102
+ identityId: createdIdentity.identityId,
103
+ tokenId: createdIdentity.tokenId,
104
+ token: createdIdentity.token,
105
+ };
106
+ }
107
+
108
+ export function createFreestyleProxyFetch(input: {
109
+ dashboardUrl: string;
110
+ accessToken: string;
111
+ teamId: string;
112
+ fetch?: typeof fetch;
113
+ }): typeof fetch {
114
+ const fetchFn = input.fetch ?? globalThis.fetch;
115
+ const dashboardUrl = trimTrailingSlash(input.dashboardUrl);
116
+
117
+ const proxyFetch = async (resource: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
118
+ const url = resourceUrl(resource);
119
+ const path = `${url.pathname}${url.search}`.replace(/^\/+/, "");
120
+ const proxyResponse = await fetchFn(`${dashboardUrl}/api/proxy/request`, {
121
+ method: "POST",
122
+ headers: {
123
+ "Content-Type": "application/json",
124
+ },
125
+ body: JSON.stringify({
126
+ data: {
127
+ accessToken: input.accessToken,
128
+ teamId: input.teamId,
129
+ path,
130
+ method: init?.method ?? "GET",
131
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {},
132
+ body: init?.body ? String(init.body) : undefined,
133
+ },
134
+ }),
135
+ });
136
+
137
+ if (!proxyResponse.ok) {
138
+ const errorText = await proxyResponse.text();
139
+ const normalized = normalizeProxyError(errorText, proxyResponse.status);
140
+ return new Response(normalized.body, {
141
+ status: proxyResponse.status,
142
+ statusText: proxyResponse.statusText,
143
+ headers: { "Content-Type": normalized.contentType },
144
+ });
145
+ }
146
+
147
+ const data = await proxyResponse.json();
148
+ if (isBackgroundRequestPending(data)) {
149
+ const requestId = backgroundRequestId(data);
150
+ return Response.json(data, {
151
+ status: 202,
152
+ headers: {
153
+ ...(requestId ? { "x-freestyle-background-request-id": requestId } : {}),
154
+ },
155
+ });
156
+ }
157
+ return Response.json(data);
158
+ };
159
+
160
+ return Object.assign(proxyFetch, {
161
+ preconnect: fetchFn.preconnect?.bind(fetchFn) ?? (() => {}),
162
+ }) as typeof fetch;
163
+ }
164
+
165
+ async function resolveClientAuth(input: CreateFreestyleAuthenticatedClientInput): Promise<ResolvedClientAuth> {
166
+ const apiKey = nonEmpty(input.auth?.apiKey);
167
+ const apiUrl = nonEmpty(input.auth?.apiUrl) ?? nonEmpty(process.env.FREESTYLE_API_URL);
168
+ const fetchFn = input.fetch ?? globalThis.fetch;
169
+
170
+ if (apiKey) {
171
+ return {
172
+ client: new Freestyle({
173
+ apiKey,
174
+ ...(apiUrl ? { baseUrl: apiUrl } : {}),
175
+ fetch: fetchFn,
176
+ }),
177
+ identityKey: `api-key:${fingerprint({ apiUrl: apiUrl ?? "default", apiKey })}`,
178
+ };
179
+ }
180
+
181
+ const stack = resolveStackAuthConfig(input.auth);
182
+ const stackStateKey = stackAuthStateKey(stack);
183
+ const stored = readStackAuthState(input.hostStorage.get(stackStateKey)?.value);
184
+ const refreshed = await resolveStackAccessToken({
185
+ config: stack,
186
+ storage: input.hostStorage,
187
+ storageKey: stackStateKey,
188
+ stored,
189
+ local: input.local,
190
+ fetch: fetchFn,
191
+ timeoutMs: input.timeoutMs ?? DEFAULT_CLI_AUTH_TIMEOUT_MILLIS,
192
+ pollIntervalMs: input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MILLIS,
193
+ });
194
+ const teamId = await resolveTeamId({
195
+ configuredTeamId: input.auth?.teamId,
196
+ stored: readStackAuthState(input.hostStorage.get(stackStateKey)?.value) ?? stored,
197
+ accessToken: refreshed.accessToken,
198
+ config: stack,
199
+ storage: input.hostStorage,
200
+ storageKey: stackStateKey,
201
+ fetch: fetchFn,
202
+ });
203
+ const client = new Freestyle({
204
+ apiKey: "rigkit-browser-auth",
205
+ ...(apiUrl ? { baseUrl: apiUrl } : {}),
206
+ fetch: createFreestyleProxyFetch({
207
+ dashboardUrl: stack.dashboardUrl,
208
+ accessToken: refreshed.accessToken,
209
+ teamId,
210
+ fetch: fetchFn,
211
+ }),
212
+ });
213
+
214
+ return {
215
+ client,
216
+ identityKey: `browser:${fingerprint({
217
+ apiUrl: apiUrl ?? "default",
218
+ dashboardUrl: stack.dashboardUrl,
219
+ profile: stack.profile,
220
+ teamId,
221
+ })}`,
222
+ };
223
+ }
224
+
225
+ function resolveStackAuthConfig(auth: FreestyleProviderAuthConfig | undefined): StackAuthConfig {
226
+ const dashboardUrl = trimTrailingSlash(
227
+ nonEmpty(auth?.dashboardUrl) ??
228
+ nonEmpty(process.env.FREESTYLE_DASHBOARD_URL) ??
229
+ DEFAULT_STACK_APP_URL,
230
+ );
231
+ return {
232
+ stackApiUrl: trimTrailingSlash(
233
+ nonEmpty(auth?.stackApiUrl) ??
234
+ nonEmpty(process.env.FREESTYLE_STACK_API_URL) ??
235
+ DEFAULT_STACK_API_URL,
236
+ ),
237
+ appUrl: trimTrailingSlash(
238
+ nonEmpty(auth?.stackAppUrl) ??
239
+ nonEmpty(process.env.FREESTYLE_STACK_APP_URL) ??
240
+ dashboardUrl,
241
+ ),
242
+ dashboardUrl,
243
+ projectId:
244
+ nonEmpty(auth?.stackProjectId) ??
245
+ nonEmpty(process.env.FREESTYLE_STACK_PROJECT_ID) ??
246
+ nonEmpty(process.env.NEXT_PUBLIC_STACK_PROJECT_ID) ??
247
+ nonEmpty(process.env.VITE_STACK_PROJECT_ID) ??
248
+ DEFAULT_STACK_PROJECT_ID,
249
+ publishableClientKey:
250
+ nonEmpty(auth?.stackPublishableClientKey) ??
251
+ nonEmpty(process.env.FREESTYLE_STACK_PUBLISHABLE_CLIENT_KEY) ??
252
+ nonEmpty(process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY) ??
253
+ nonEmpty(process.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY) ??
254
+ DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY,
255
+ profile: nonEmpty(auth?.profile) ?? "default",
256
+ };
257
+ }
258
+
259
+ async function resolveStackAccessToken(input: {
260
+ config: StackAuthConfig;
261
+ storage: ProviderStorage;
262
+ storageKey: string;
263
+ stored: StackAuthState | undefined;
264
+ local: LocalWorkspaceRuntime;
265
+ fetch: typeof fetch;
266
+ timeoutMs: number;
267
+ pollIntervalMs: number;
268
+ }): Promise<StackTokenRefresh> {
269
+ let refreshToken = input.stored?.refreshToken;
270
+ if (!refreshToken) {
271
+ refreshToken = await startCliLogin(input);
272
+ saveStackAuthState(input.storage, input.storageKey, {
273
+ refreshToken,
274
+ defaultTeamId: input.stored?.defaultTeamId,
275
+ updatedAt: Date.now(),
276
+ });
277
+ }
278
+
279
+ let refreshed = await refreshStackAccessToken(input.config, refreshToken, input.fetch);
280
+ if (!refreshed) {
281
+ input.storage.delete(input.storageKey);
282
+ refreshToken = await startCliLogin(input);
283
+ saveStackAuthState(input.storage, input.storageKey, {
284
+ refreshToken,
285
+ defaultTeamId: input.stored?.defaultTeamId,
286
+ updatedAt: Date.now(),
287
+ });
288
+ refreshed = await refreshStackAccessToken(input.config, refreshToken, input.fetch);
289
+ }
290
+
291
+ if (!refreshed) {
292
+ throw new Error("Failed to authenticate with Freestyle.");
293
+ }
294
+
295
+ const nextRefreshToken = refreshed.refreshToken ?? refreshToken;
296
+ saveStackAuthState(input.storage, input.storageKey, {
297
+ refreshToken: nextRefreshToken,
298
+ defaultTeamId: input.stored?.defaultTeamId,
299
+ accessToken: refreshed.accessToken,
300
+ accessTokenUpdatedAt: Date.now(),
301
+ updatedAt: Date.now(),
302
+ });
303
+ return refreshed;
304
+ }
305
+
306
+ async function startCliLogin(input: {
307
+ config: StackAuthConfig;
308
+ local: LocalWorkspaceRuntime;
309
+ fetch: typeof fetch;
310
+ timeoutMs: number;
311
+ pollIntervalMs: number;
312
+ }): Promise<string> {
313
+ const initResponse = await input.fetch(`${input.config.stackApiUrl}/api/v1/auth/cli`, {
314
+ method: "POST",
315
+ headers: stackClientHeaders(input.config),
316
+ body: JSON.stringify({
317
+ expires_in_millis: input.timeoutMs,
318
+ }),
319
+ });
320
+ if (!initResponse.ok) {
321
+ const errorText = await initResponse.text();
322
+ throw new Error(
323
+ `Failed to start Freestyle authentication (${initResponse.status}). ${errorText || "Check Stack Auth configuration."}`,
324
+ );
325
+ }
326
+
327
+ const initData = await initResponse.json() as Record<string, unknown>;
328
+ const pollingCode = stringField(initData, "polling_code");
329
+ const loginCode = stringField(initData, "login_code");
330
+ const loginUrl = `${input.config.appUrl}/handler/cli-auth-confirm?login_code=${encodeURIComponent(loginCode)}`;
331
+
332
+ console.log(`Freestyle authentication required. Opening ${loginUrl}`);
333
+ try {
334
+ await input.local.open(loginUrl);
335
+ } catch {
336
+ console.log(`Open this URL to authenticate with Freestyle:\n${loginUrl}`);
337
+ }
338
+
339
+ const deadline = Date.now() + input.timeoutMs;
340
+ while (Date.now() < deadline) {
341
+ const pollResponse = await input.fetch(`${input.config.stackApiUrl}/api/v1/auth/cli/poll`, {
342
+ method: "POST",
343
+ headers: stackClientHeaders(input.config),
344
+ body: JSON.stringify({
345
+ polling_code: pollingCode,
346
+ }),
347
+ });
348
+ if (![200, 201].includes(pollResponse.status)) {
349
+ throw new Error(`Failed while polling Freestyle authentication (${pollResponse.status}).`);
350
+ }
351
+
352
+ const pollData = await pollResponse.json() as Record<string, unknown>;
353
+ const status = typeof pollData.status === "string" ? pollData.status : "pending";
354
+ if (status === "completed" || status === "success") {
355
+ return stringField(pollData, "refresh_token");
356
+ }
357
+ if (status !== "pending" && status !== "waiting") {
358
+ throw new Error(
359
+ typeof pollData.error === "string"
360
+ ? pollData.error
361
+ : `Freestyle authentication ${status}. Please retry.`,
362
+ );
363
+ }
364
+
365
+ await sleep(input.pollIntervalMs);
366
+ }
367
+
368
+ throw new Error("Timed out waiting for Freestyle authentication.");
369
+ }
370
+
371
+ async function refreshStackAccessToken(
372
+ config: StackAuthConfig,
373
+ refreshToken: string,
374
+ fetchFn: typeof fetch,
375
+ ): Promise<StackTokenRefresh | null> {
376
+ const response = await fetchFn(`${config.stackApiUrl}/api/v1/auth/sessions/current/refresh`, {
377
+ method: "POST",
378
+ headers: {
379
+ ...stackClientHeaders(config),
380
+ "x-stack-refresh-token": refreshToken,
381
+ },
382
+ body: "{}",
383
+ });
384
+ if (!response.ok) return null;
385
+ const data = await response.json() as Record<string, unknown>;
386
+ const accessToken = typeof data.access_token === "string" ? data.access_token : undefined;
387
+ if (!accessToken) return null;
388
+ return {
389
+ accessToken,
390
+ refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined,
391
+ };
392
+ }
393
+
394
+ async function resolveTeamId(input: {
395
+ configuredTeamId: string | undefined;
396
+ stored: StackAuthState | undefined;
397
+ accessToken: string;
398
+ config: StackAuthConfig;
399
+ storage: ProviderStorage;
400
+ storageKey: string;
401
+ fetch: typeof fetch;
402
+ }): Promise<string> {
403
+ const teamId =
404
+ nonEmpty(input.configuredTeamId) ??
405
+ nonEmpty(process.env.FREESTYLE_TEAM_ID) ??
406
+ nonEmpty(input.stored?.defaultTeamId);
407
+ if (teamId) {
408
+ saveStackAuthState(input.storage, input.storageKey, {
409
+ ...input.stored,
410
+ refreshToken: input.stored?.refreshToken ?? "",
411
+ defaultTeamId: teamId,
412
+ updatedAt: Date.now(),
413
+ });
414
+ return teamId;
415
+ }
416
+
417
+ const teams = await listTeams(input.config, input.accessToken, input.fetch);
418
+ if (teams.length === 1) {
419
+ const onlyTeam = teams[0]!;
420
+ saveStackAuthState(input.storage, input.storageKey, {
421
+ ...input.stored,
422
+ refreshToken: input.stored?.refreshToken ?? "",
423
+ defaultTeamId: onlyTeam.id,
424
+ updatedAt: Date.now(),
425
+ });
426
+ return onlyTeam.id;
427
+ }
428
+
429
+ if (teams.length === 0) {
430
+ throw new Error("Freestyle authentication succeeded, but no teams were available for this account.");
431
+ }
432
+
433
+ const choices = teams.map((team) => `${team.displayName ?? team.id} (${team.id})`).join(", ");
434
+ throw new Error(
435
+ `Freestyle authentication found multiple teams. Set freestyle.provider({ auth: { teamId } }) or FREESTYLE_TEAM_ID. Teams: ${choices}`,
436
+ );
437
+ }
438
+
439
+ async function listTeams(config: StackAuthConfig, accessToken: string, fetchFn: typeof fetch): Promise<FreestyleTeam[]> {
440
+ const response = await fetchFn(`${config.dashboardUrl}/api/cli/teams`, {
441
+ method: "POST",
442
+ headers: {
443
+ "Content-Type": "application/json",
444
+ },
445
+ body: JSON.stringify({
446
+ data: { accessToken },
447
+ }),
448
+ });
449
+ if (!response.ok) {
450
+ throw new Error(`Failed to list Freestyle teams (${response.status}). ${await response.text()}`);
451
+ }
452
+ const data = await response.json() as unknown;
453
+ if (!Array.isArray(data)) {
454
+ throw new Error("Freestyle team list response was invalid.");
455
+ }
456
+ return data.filter(isFreestyleTeam);
457
+ }
458
+
459
+ function stackClientHeaders(config: StackAuthConfig): Record<string, string> {
460
+ return {
461
+ "Content-Type": "application/json",
462
+ "x-stack-project-id": config.projectId,
463
+ "x-stack-access-type": "client",
464
+ "x-stack-publishable-client-key": config.publishableClientKey,
465
+ };
466
+ }
467
+
468
+ function stackAuthStateKey(config: StackAuthConfig): string {
469
+ return `stack-auth:${fingerprint({
470
+ stackApiUrl: config.stackApiUrl,
471
+ appUrl: config.appUrl,
472
+ projectId: config.projectId,
473
+ profile: config.profile,
474
+ })}`;
475
+ }
476
+
477
+ function readStackAuthState(value: JsonValue | undefined): StackAuthState | undefined {
478
+ if (!isRecord(value)) return undefined;
479
+ const refreshToken = typeof value.refreshToken === "string" ? value.refreshToken : undefined;
480
+ if (!refreshToken) return undefined;
481
+ return {
482
+ refreshToken,
483
+ updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : Date.now(),
484
+ defaultTeamId: typeof value.defaultTeamId === "string" ? value.defaultTeamId : undefined,
485
+ accessToken: typeof value.accessToken === "string" ? value.accessToken : undefined,
486
+ accessTokenUpdatedAt: typeof value.accessTokenUpdatedAt === "number" ? value.accessTokenUpdatedAt : undefined,
487
+ };
488
+ }
489
+
490
+ function saveStackAuthState(storage: ProviderStorage, key: string, state: StackAuthState): void {
491
+ if (!state.refreshToken) return;
492
+ storage.set(key, {
493
+ refreshToken: state.refreshToken,
494
+ updatedAt: state.updatedAt,
495
+ ...(state.defaultTeamId ? { defaultTeamId: state.defaultTeamId } : {}),
496
+ ...(state.accessToken ? { accessToken: state.accessToken } : {}),
497
+ ...(state.accessTokenUpdatedAt ? { accessTokenUpdatedAt: state.accessTokenUpdatedAt } : {}),
498
+ });
499
+ }
500
+
501
+ function resourceUrl(resource: Parameters<typeof fetch>[0]): URL {
502
+ if (typeof resource === "string") return new URL(resource);
503
+ if (resource instanceof URL) return resource;
504
+ return new URL(resource.url);
505
+ }
506
+
507
+ function normalizeProxyError(errorText: string, status: number): { body: string; contentType: string } {
508
+ const fallbackCode = status === 400
509
+ ? "BAD_REQUEST"
510
+ : status === 401
511
+ ? "UNAUTHORIZED_ERROR"
512
+ : status === 403
513
+ ? "FORBIDDEN"
514
+ : "INTERNAL_ERROR";
515
+ try {
516
+ const parsed = JSON.parse(errorText) as Record<string, unknown>;
517
+ if (typeof parsed.code === "string" && typeof parsed.message === "string") {
518
+ return { body: JSON.stringify(parsed), contentType: "application/json" };
519
+ }
520
+ const message = [parsed.error, parsed.message, parsed.reason].find((value) =>
521
+ typeof value === "string" && value.length > 0
522
+ );
523
+ if (typeof message === "string") {
524
+ return {
525
+ body: JSON.stringify({ code: fallbackCode, message }),
526
+ contentType: "application/json",
527
+ };
528
+ }
529
+ } catch {
530
+ // Keep the non-JSON text below.
531
+ }
532
+ return {
533
+ body: JSON.stringify({ code: fallbackCode, message: errorText || "Request failed" }),
534
+ contentType: "application/json",
535
+ };
536
+ }
537
+
538
+ function isBackgroundRequestPending(value: unknown): boolean {
539
+ return Boolean(
540
+ isRecord(value) &&
541
+ (value.status === "pending" || value.status === "running") &&
542
+ (typeof value.requestId === "string" || typeof value.request_id === "string")
543
+ );
544
+ }
545
+
546
+ function backgroundRequestId(value: unknown): string | undefined {
547
+ if (!isRecord(value)) return undefined;
548
+ return typeof value.requestId === "string"
549
+ ? value.requestId
550
+ : typeof value.request_id === "string"
551
+ ? value.request_id
552
+ : undefined;
553
+ }
554
+
555
+ function stringField(record: Record<string, unknown>, key: string): string {
556
+ const value = record[key];
557
+ if (typeof value !== "string" || value.trim() === "") {
558
+ throw new Error(`Freestyle authentication response did not include ${key}.`);
559
+ }
560
+ return value;
561
+ }
562
+
563
+ function isFreestyleTeam(value: unknown): value is FreestyleTeam {
564
+ return Boolean(
565
+ value &&
566
+ typeof value === "object" &&
567
+ !Array.isArray(value) &&
568
+ typeof (value as { id?: unknown }).id === "string"
569
+ );
570
+ }
571
+
572
+ function isRecord(value: unknown): value is Record<string, JsonValue> {
573
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
574
+ }
575
+
576
+ function nonEmpty(value: string | undefined): string | undefined {
577
+ const trimmed = value?.trim();
578
+ return trimmed ? trimmed : undefined;
579
+ }
580
+
581
+ function trimTrailingSlash(value: string): string {
582
+ return value.replace(/\/+$/, "");
583
+ }
584
+
585
+ function fingerprint(value: unknown): string {
586
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24);
587
+ }
588
+
589
+ function sleep(ms: number): Promise<void> {
590
+ return new Promise((resolve) => setTimeout(resolve, ms));
591
+ }