@barekey/cli 0.4.0 → 0.5.1

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.
Files changed (60) hide show
  1. package/README.md +53 -12
  2. package/bun.lock +9 -3
  3. package/dist/auth-provider.js +7 -4
  4. package/dist/command-utils.js +6 -6
  5. package/dist/commands/audit.d.ts +2 -0
  6. package/dist/commands/audit.js +47 -0
  7. package/dist/commands/auth.js +31 -9
  8. package/dist/commands/billing.d.ts +2 -0
  9. package/dist/commands/billing.js +59 -0
  10. package/dist/commands/env.js +157 -125
  11. package/dist/commands/init.d.ts +2 -0
  12. package/dist/commands/init.js +32 -0
  13. package/dist/commands/org.d.ts +2 -0
  14. package/dist/commands/org.js +85 -0
  15. package/dist/commands/project.d.ts +2 -0
  16. package/dist/commands/project.js +99 -0
  17. package/dist/commands/stage.d.ts +2 -0
  18. package/dist/commands/stage.js +125 -0
  19. package/dist/commands/target-prompts.d.ts +184 -0
  20. package/dist/commands/target-prompts.js +312 -0
  21. package/dist/commands/typegen.d.ts +2 -2
  22. package/dist/commands/typegen.js +57 -32
  23. package/dist/constants.d.ts +1 -1
  24. package/dist/constants.js +1 -1
  25. package/dist/context/session-id.d.ts +11 -0
  26. package/dist/context/session-id.js +14 -0
  27. package/dist/contracts/index.d.ts +499 -0
  28. package/dist/contracts/index.js +313 -0
  29. package/dist/credentials-store.js +70 -11
  30. package/dist/http.d.ts +34 -0
  31. package/dist/http.js +56 -2
  32. package/dist/index.js +12 -0
  33. package/dist/runtime-config.js +14 -26
  34. package/dist/typegen/core.d.ts +45 -0
  35. package/dist/typegen/core.js +219 -0
  36. package/dist/types.d.ts +5 -3
  37. package/package.json +2 -2
  38. package/src/auth-provider.ts +8 -5
  39. package/src/command-utils.ts +6 -6
  40. package/src/commands/audit.ts +63 -0
  41. package/src/commands/auth.ts +45 -39
  42. package/src/commands/billing.ts +70 -0
  43. package/src/commands/env.ts +211 -218
  44. package/src/commands/init.ts +47 -0
  45. package/src/commands/org.ts +104 -0
  46. package/src/commands/project.ts +130 -0
  47. package/src/commands/stage.ts +167 -0
  48. package/src/commands/target-prompts.ts +357 -0
  49. package/src/commands/typegen.ts +71 -45
  50. package/src/constants.ts +1 -1
  51. package/src/context/session-id.ts +14 -0
  52. package/src/contracts/index.ts +376 -0
  53. package/src/credentials-store.ts +86 -12
  54. package/src/http.ts +78 -2
  55. package/src/index.ts +12 -0
  56. package/src/runtime-config.ts +19 -32
  57. package/src/typegen/core.ts +311 -0
  58. package/src/types.ts +5 -3
  59. package/test/command-utils.test.ts +47 -0
  60. package/test/credentials-store.test.ts +40 -0
@@ -0,0 +1,357 @@
1
+ import { cancel, isCancel, select } from "@clack/prompts";
2
+
3
+ import { createCliAuthProvider } from "../auth-provider.js";
4
+ import { requireLocalSession } from "../command-utils.js";
5
+ import {
6
+ OrganizationsResponseSchema,
7
+ ProjectCreateResponseSchema,
8
+ ProjectDeleteResponseSchema,
9
+ ProjectsListResponseSchema,
10
+ StageCreateResponseSchema,
11
+ StageDeleteResponseSchema,
12
+ StageRenameResponseSchema,
13
+ StagesListResponseSchema,
14
+ } from "../contracts/index.js";
15
+ import { getJson, postJson } from "../http.js";
16
+
17
+ async function resolveManagementAccess() {
18
+ const local = await requireLocalSession();
19
+ const authProvider = createCliAuthProvider();
20
+ const accessToken = await authProvider.getAccessToken();
21
+
22
+ return {
23
+ local,
24
+ accessToken,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Loads organizations for the current CLI user.
30
+ *
31
+ * @returns The accessible organizations for the signed-in user.
32
+ * @remarks Interactive command flows use this to avoid any hidden org-scoped auth fallback.
33
+ * @lastModified 2026-03-19
34
+ * @author GPT-5.4
35
+ */
36
+ export async function listOrganizationsForCurrentUser() {
37
+ const { local, accessToken } = await resolveManagementAccess();
38
+ const response = await getJson({
39
+ baseUrl: local.baseUrl,
40
+ path: "/v1/cli/orgs",
41
+ accessToken,
42
+ schema: OrganizationsResponseSchema,
43
+ });
44
+ return response.organizations;
45
+ }
46
+
47
+ /**
48
+ * Loads projects for one organization.
49
+ *
50
+ * @param orgSlug The organization slug to inspect.
51
+ * @returns The projects for the organization.
52
+ * @remarks Project-scoped CLI flows call the same backend contract as `barekey init`.
53
+ * @lastModified 2026-03-19
54
+ * @author GPT-5.4
55
+ */
56
+ export async function listProjectsForOrganization(orgSlug: string) {
57
+ const { local, accessToken } = await resolveManagementAccess();
58
+ const response = await postJson({
59
+ baseUrl: local.baseUrl,
60
+ path: "/v1/cli/projects/list",
61
+ accessToken,
62
+ payload: {
63
+ orgSlug,
64
+ },
65
+ schema: ProjectsListResponseSchema,
66
+ });
67
+ return response.projects;
68
+ }
69
+
70
+ /**
71
+ * Loads stages for one project.
72
+ *
73
+ * @param orgSlug The organization slug to inspect.
74
+ * @param projectSlug The project slug to inspect.
75
+ * @returns The stages for the selected project.
76
+ * @remarks Stage prompts use this to drive repo initialization and stage management flows.
77
+ * @lastModified 2026-03-19
78
+ * @author GPT-5.4
79
+ */
80
+ export async function listStagesForProject(orgSlug: string, projectSlug: string) {
81
+ const { local, accessToken } = await resolveManagementAccess();
82
+ const response = await postJson({
83
+ baseUrl: local.baseUrl,
84
+ path: "/v1/cli/stages/list",
85
+ accessToken,
86
+ payload: {
87
+ orgSlug,
88
+ projectSlug,
89
+ },
90
+ schema: StagesListResponseSchema,
91
+ });
92
+ return response.stages;
93
+ }
94
+
95
+ /**
96
+ * Creates one project for one organization.
97
+ *
98
+ * @param orgSlug The organization slug that should own the project.
99
+ * @param name The project name to create.
100
+ * @returns The created project summary.
101
+ * @remarks CLI project-create flows use this helper so the command stays a thin prompt wrapper.
102
+ * @lastModified 2026-03-19
103
+ * @author GPT-5.4
104
+ */
105
+ export async function createProjectForOrganization(orgSlug: string, name: string) {
106
+ const { local, accessToken } = await resolveManagementAccess();
107
+ const response = await postJson({
108
+ baseUrl: local.baseUrl,
109
+ path: "/v1/cli/projects/create",
110
+ accessToken,
111
+ payload: {
112
+ orgSlug,
113
+ name,
114
+ },
115
+ schema: ProjectCreateResponseSchema,
116
+ });
117
+ return response.project;
118
+ }
119
+
120
+ /**
121
+ * Deletes one project for one organization.
122
+ *
123
+ * @param orgSlug The organization slug that owns the project.
124
+ * @param projectSlug The project slug to delete.
125
+ * @returns The deleted project payload.
126
+ * @remarks Destructive project flows use this shared helper so command handlers stay focused on prompts and output.
127
+ * @lastModified 2026-03-19
128
+ * @author GPT-5.4
129
+ */
130
+ export async function deleteProjectForOrganization(orgSlug: string, projectSlug: string) {
131
+ const { local, accessToken } = await resolveManagementAccess();
132
+ return await postJson({
133
+ baseUrl: local.baseUrl,
134
+ path: "/v1/cli/projects/delete",
135
+ accessToken,
136
+ payload: {
137
+ orgSlug,
138
+ projectSlug,
139
+ },
140
+ schema: ProjectDeleteResponseSchema,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Creates one stage for one project.
146
+ *
147
+ * @param orgSlug The organization slug that owns the project.
148
+ * @param projectSlug The project slug that should receive the stage.
149
+ * @param name The stage name to create.
150
+ * @returns The created stage summary.
151
+ * @remarks CLI stage-create flows use this helper so the command stays a thin prompt wrapper.
152
+ * @lastModified 2026-03-19
153
+ * @author GPT-5.4
154
+ */
155
+ export async function createStageForProject(orgSlug: string, projectSlug: string, name: string) {
156
+ const { local, accessToken } = await resolveManagementAccess();
157
+ const response = await postJson({
158
+ baseUrl: local.baseUrl,
159
+ path: "/v1/cli/stages/create",
160
+ accessToken,
161
+ payload: {
162
+ orgSlug,
163
+ projectSlug,
164
+ name,
165
+ },
166
+ schema: StageCreateResponseSchema,
167
+ });
168
+ return response.stage;
169
+ }
170
+
171
+ /**
172
+ * Renames one stage for one project.
173
+ *
174
+ * @param orgSlug The organization slug that owns the project.
175
+ * @param projectSlug The project slug that owns the stage.
176
+ * @param stageSlug The stage slug to rename.
177
+ * @param name The next display name for the stage.
178
+ * @returns The updated stage summary.
179
+ * @remarks Stage rename keeps immutable slugs and only patches the display name.
180
+ * @lastModified 2026-03-19
181
+ * @author GPT-5.4
182
+ */
183
+ export async function renameStageForProject(
184
+ orgSlug: string,
185
+ projectSlug: string,
186
+ stageSlug: string,
187
+ name: string,
188
+ ) {
189
+ const { local, accessToken } = await resolveManagementAccess();
190
+ const response = await postJson({
191
+ baseUrl: local.baseUrl,
192
+ path: "/v1/cli/stages/rename",
193
+ accessToken,
194
+ payload: {
195
+ orgSlug,
196
+ projectSlug,
197
+ stageSlug,
198
+ name,
199
+ },
200
+ schema: StageRenameResponseSchema,
201
+ });
202
+ return response.stage;
203
+ }
204
+
205
+ /**
206
+ * Deletes one stage for one project.
207
+ *
208
+ * @param orgSlug The organization slug that owns the project.
209
+ * @param projectSlug The project slug that owns the stage.
210
+ * @param stageSlug The stage slug to delete.
211
+ * @returns The deleted stage payload.
212
+ * @remarks Destructive stage flows use this helper so command handlers stay prompt-focused.
213
+ * @lastModified 2026-03-19
214
+ * @author GPT-5.4
215
+ */
216
+ export async function deleteStageForProject(
217
+ orgSlug: string,
218
+ projectSlug: string,
219
+ stageSlug: string,
220
+ ) {
221
+ const { local, accessToken } = await resolveManagementAccess();
222
+ return await postJson({
223
+ baseUrl: local.baseUrl,
224
+ path: "/v1/cli/stages/delete",
225
+ accessToken,
226
+ payload: {
227
+ orgSlug,
228
+ projectSlug,
229
+ stageSlug,
230
+ },
231
+ schema: StageDeleteResponseSchema,
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Resolves one organization target from an explicit flag or an interactive selection.
237
+ *
238
+ * @param orgSlug The optionally supplied organization slug.
239
+ * @returns The resolved organization slug.
240
+ * @remarks Non-interactive runs must provide the target explicitly or via local repo config higher up the call stack.
241
+ * @lastModified 2026-03-19
242
+ * @author GPT-5.4
243
+ */
244
+ export async function promptForOrganizationSlug(orgSlug: string | undefined): Promise<string> {
245
+ const explicit = orgSlug?.trim();
246
+ if (explicit && explicit.length > 0) {
247
+ return explicit;
248
+ }
249
+ if (!process.stdout.isTTY) {
250
+ throw new Error("Organization slug is required in non-interactive mode.");
251
+ }
252
+
253
+ const organizations = await listOrganizationsForCurrentUser();
254
+ if (organizations.length === 0) {
255
+ throw new Error("No organizations found. Create one first with barekey org create.");
256
+ }
257
+
258
+ const selected = await select({
259
+ message: "Which organization should Barekey use?",
260
+ options: organizations.map((organization) => ({
261
+ value: organization.slug,
262
+ label: organization.name,
263
+ hint: organization.slug,
264
+ })),
265
+ });
266
+ if (isCancel(selected)) {
267
+ cancel("Command canceled.");
268
+ process.exit(0);
269
+ }
270
+ return selected;
271
+ }
272
+
273
+ /**
274
+ * Resolves one project target from an explicit flag or an interactive selection.
275
+ *
276
+ * @param orgSlug The owning organization slug.
277
+ * @param projectSlug The optionally supplied project slug.
278
+ * @returns The resolved project slug.
279
+ * @remarks This intentionally loads live project choices instead of guessing from auth state.
280
+ * @lastModified 2026-03-19
281
+ * @author GPT-5.4
282
+ */
283
+ export async function promptForProjectSlug(
284
+ orgSlug: string,
285
+ projectSlug: string | undefined,
286
+ ): Promise<string> {
287
+ const explicit = projectSlug?.trim();
288
+ if (explicit && explicit.length > 0) {
289
+ return explicit;
290
+ }
291
+ if (!process.stdout.isTTY) {
292
+ throw new Error("Project slug is required in non-interactive mode.");
293
+ }
294
+
295
+ const projects = await listProjectsForOrganization(orgSlug);
296
+ if (projects.length === 0) {
297
+ throw new Error("No projects found. Create one first with barekey project create.");
298
+ }
299
+
300
+ const selected = await select({
301
+ message: "Which project should Barekey use?",
302
+ options: projects.map((project) => ({
303
+ value: project.slug,
304
+ label: project.name,
305
+ hint: project.slug,
306
+ })),
307
+ });
308
+ if (isCancel(selected)) {
309
+ cancel("Command canceled.");
310
+ process.exit(0);
311
+ }
312
+ return selected;
313
+ }
314
+
315
+ /**
316
+ * Resolves one stage target from an explicit flag or an interactive selection.
317
+ *
318
+ * @param orgSlug The owning organization slug.
319
+ * @param projectSlug The owning project slug.
320
+ * @param stageSlug The optionally supplied stage slug.
321
+ * @returns The resolved stage slug.
322
+ * @remarks This is used by `barekey init` so local repo setup can be a guided flow.
323
+ * @lastModified 2026-03-19
324
+ * @author GPT-5.4
325
+ */
326
+ export async function promptForStageSlug(
327
+ orgSlug: string,
328
+ projectSlug: string,
329
+ stageSlug: string | undefined,
330
+ ): Promise<string> {
331
+ const explicit = stageSlug?.trim();
332
+ if (explicit && explicit.length > 0) {
333
+ return explicit;
334
+ }
335
+ if (!process.stdout.isTTY) {
336
+ throw new Error("Stage slug is required in non-interactive mode.");
337
+ }
338
+
339
+ const stages = await listStagesForProject(orgSlug, projectSlug);
340
+ if (stages.length === 0) {
341
+ throw new Error("No stages found. Create one first with barekey stage create.");
342
+ }
343
+
344
+ const selected = await select({
345
+ message: "Which stage should Barekey use?",
346
+ options: stages.map((stage) => ({
347
+ value: stage.slug,
348
+ label: stage.name,
349
+ hint: stage.slug,
350
+ })),
351
+ });
352
+ if (isCancel(selected)) {
353
+ cancel("Command canceled.");
354
+ process.exit(0);
355
+ }
356
+ return selected;
357
+ }
@@ -1,19 +1,24 @@
1
- import { BarekeyClient } from "@barekey/sdk/server";
2
- import type { BarekeyJsonConfig, BarekeyTypegenResult } from "@barekey/sdk/server";
3
1
  import { Command } from "commander";
4
2
  import pc from "picocolors";
5
3
 
4
+ import { createCliAuthProvider } from "../auth-provider.js";
6
5
  import {
7
6
  addTargetOptions,
8
7
  requireLocalSession,
9
8
  resolveTarget,
10
9
  type EnvTargetOptions,
11
10
  } from "../command-utils.js";
11
+ import { TypegenManifestSchema } from "../contracts/index.js";
12
+ import { getJson } from "../http.js";
12
13
  import { loadRuntimeConfig } from "../runtime-config.js";
14
+ import {
15
+ type CliTypegenResult,
16
+ writeInstalledSdkGeneratedTypes,
17
+ } from "../typegen/core.js";
13
18
 
14
19
  const DEFAULT_TYPEGEN_WATCH_INTERVAL_MS = 5_000;
15
20
 
16
- export function formatTypegenResultMessage(result: BarekeyTypegenResult): string {
21
+ export function formatTypegenResultMessage(result: CliTypegenResult): string {
17
22
  const title = result.written
18
23
  ? pc.green(pc.bold("Typegen complete"))
19
24
  : pc.cyan(pc.bold("Typegen already up to date"));
@@ -61,54 +66,69 @@ function sleep(milliseconds: number): Promise<void> {
61
66
  });
62
67
  }
63
68
 
64
- async function resolveTypegenClient(options: EnvTargetOptions): Promise<{
65
- client: BarekeyClient;
69
+ async function resolveTypegenContext(options: EnvTargetOptions): Promise<{
70
+ baseUrl: string | null;
71
+ accessToken: string | null;
66
72
  organization: string;
67
73
  project: string;
68
74
  environment: string;
75
+ runtimeMode: "centralized" | "standalone";
76
+ typegenMode: "semantic" | "minimal";
77
+ localEnvRoot: string | null;
69
78
  }> {
70
79
  const runtime = await loadRuntimeConfig();
71
80
  const local = runtime?.config.config?.mode === "standalone" ? null : await requireLocalSession();
72
81
  const target = await resolveTarget(options, local);
73
- const isStandalone = runtime?.config.config?.mode === "standalone";
74
- const organization = target.orgSlug ?? local?.credentials.orgSlug;
75
- if ((!organization || organization.trim().length === 0) && !isStandalone) {
76
- throw new Error("Organization slug is required.");
77
- }
78
-
79
- const jsonConfig = {
80
- ...(organization && organization.trim().length > 0 ? { organization } : {}),
81
- ...(target.projectSlug.trim().length > 0 ? { project: target.projectSlug } : {}),
82
- ...(target.stageSlug.trim().length > 0 ? { environment: target.stageSlug } : {}),
83
- ...(runtime?.config.config?.typegen === undefined
84
- ? {}
85
- : {
86
- config: {
87
- typegen: runtime.config.config.typegen,
88
- mode: runtime.config.config.mode,
89
- },
90
- }),
91
- } as BarekeyJsonConfig & {
92
- config?: {
93
- typegen?: "semantic" | "minimal";
94
- mode?: "centralized" | "standalone";
95
- };
96
- };
82
+ const authProvider = local === null ? null : createCliAuthProvider();
97
83
 
98
84
  return {
99
- client: new BarekeyClient({
100
- json: jsonConfig as BarekeyJsonConfig,
101
- typegen: false,
102
- }),
103
- organization: organization && organization.trim().length > 0 ? organization : "local",
85
+ baseUrl: local?.baseUrl ?? null,
86
+ accessToken: authProvider === null ? null : await authProvider.getAccessToken(),
87
+ organization: target.orgSlug ?? "local",
104
88
  project: target.projectSlug.trim().length > 0 ? target.projectSlug : "standalone",
105
89
  environment: target.stageSlug.trim().length > 0 ? target.stageSlug : "local",
90
+ runtimeMode: runtime?.config.config?.mode ?? "centralized",
91
+ typegenMode: runtime?.config.config?.typegen ?? "semantic",
92
+ localEnvRoot: runtime?.path ?? null,
93
+ };
94
+ }
95
+
96
+ async function fetchManifest(options: EnvTargetOptions) {
97
+ const context = await resolveTypegenContext(options);
98
+ if (context.baseUrl === null || context.accessToken === null) {
99
+ throw new Error("Typegen currently requires centralized Barekey auth.");
100
+ }
101
+
102
+ const searchParams = new URLSearchParams({
103
+ orgSlug: context.organization,
104
+ projectSlug: context.project,
105
+ stageSlug: context.environment,
106
+ });
107
+
108
+ const manifest = await getJson({
109
+ baseUrl: context.baseUrl,
110
+ path: `/v1/typegen/manifest?${searchParams.toString()}`,
111
+ accessToken: context.accessToken,
112
+ schema: TypegenManifestSchema,
113
+ });
114
+
115
+ return {
116
+ manifest,
117
+ context,
106
118
  };
107
119
  }
108
120
 
109
121
  async function runTypegen(options: EnvTargetOptions): Promise<void> {
110
- const { client } = await resolveTypegenClient(options);
111
- const result = await client.typegen();
122
+ const { manifest, context } = await fetchManifest(options);
123
+ const result = await writeInstalledSdkGeneratedTypes(manifest, {
124
+ baseUrl: context.baseUrl,
125
+ orgSlug: context.organization,
126
+ projectSlug: context.project,
127
+ stageSlug: context.environment,
128
+ typegenMode: context.typegenMode,
129
+ runtimeMode: context.runtimeMode,
130
+ localEnvRoot: context.localEnvRoot,
131
+ });
112
132
 
113
133
  console.log(formatTypegenResultMessage(result));
114
134
  }
@@ -119,13 +139,13 @@ async function runTypegenWatch(
119
139
  },
120
140
  ): Promise<void> {
121
141
  const intervalMs = parseTypegenWatchInterval(options.interval);
122
- const { client, organization, project, environment } = await resolveTypegenClient(options);
142
+ const { context } = await fetchManifest(options);
123
143
 
124
144
  console.log(
125
145
  formatTypegenWatchStartedMessage({
126
- organization,
127
- project,
128
- environment,
146
+ organization: context.organization,
147
+ project: context.project,
148
+ environment: context.environment,
129
149
  intervalMs,
130
150
  }),
131
151
  );
@@ -140,7 +160,16 @@ async function runTypegenWatch(
140
160
  try {
141
161
  let isFirstRun = true;
142
162
  while (!stopped) {
143
- const result = await client.typegen();
163
+ const { manifest, context: nextContext } = await fetchManifest(options);
164
+ const result = await writeInstalledSdkGeneratedTypes(manifest, {
165
+ baseUrl: nextContext.baseUrl,
166
+ orgSlug: nextContext.organization,
167
+ projectSlug: nextContext.project,
168
+ stageSlug: nextContext.environment,
169
+ typegenMode: nextContext.typegenMode,
170
+ runtimeMode: nextContext.runtimeMode,
171
+ localEnvRoot: nextContext.localEnvRoot,
172
+ });
144
173
  if (isFirstRun || result.written) {
145
174
  if (!isFirstRun) {
146
175
  console.log("");
@@ -167,10 +196,7 @@ export function registerTypegenCommand(program: Command): void {
167
196
  .command("typegen")
168
197
  .description("Generate Barekey SDK types in the installed @barekey/sdk module")
169
198
  .option("--watch", "Keep generated types up to date during development", false)
170
- .option(
171
- "--interval <ms>",
172
- "Polling interval for --watch in milliseconds",
173
- ),
199
+ .option("--interval <ms>", "Polling interval for --watch in milliseconds"),
174
200
  ).action(async (options: EnvTargetOptions & { watch?: boolean; interval?: string }) => {
175
201
  if (options.watch) {
176
202
  await runTypegenWatch(options);
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export const CLI_NAME = "barekey";
2
2
  export const CLI_DESCRIPTION = "Barekey CLI";
3
- export const CLI_VERSION = "0.4.0";
3
+ export const CLI_VERSION = "0.5.0";
4
4
  export const DEFAULT_BAREKEY_API_URL = "https://api.barekey.dev";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Builds the stable CLI session id for one base URL and Clerk user.
3
+ *
4
+ * @param baseUrl The Barekey API base URL.
5
+ * @param clerkUserId The authenticated Clerk user id.
6
+ * @returns The normalized CLI session id.
7
+ * @remarks CLI auth is global per user and base URL, not per organization.
8
+ * @lastModified 2026-03-19
9
+ * @author GPT-5.4
10
+ */
11
+ export function buildSessionId(baseUrl: string, clerkUserId: string): string {
12
+ const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
13
+ return `${normalizedBaseUrl}::${clerkUserId}`;
14
+ }