From 3ebff472040d353e353125d49fde62d82a339b82 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 3 May 2026 22:58:17 -0700 Subject: [PATCH] Add cloud id helpers: prefixed local ids, scope id constructors, handle slugifier --- apps/cloud/src/services/ids.test.ts | 85 ++++++++++++++++++++++++++++ apps/cloud/src/services/ids.ts | 88 +++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 apps/cloud/src/services/ids.test.ts create mode 100644 apps/cloud/src/services/ids.ts diff --git a/apps/cloud/src/services/ids.test.ts b/apps/cloud/src/services/ids.test.ts new file mode 100644 index 00000000..3e4f203d --- /dev/null +++ b/apps/cloud/src/services/ids.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + newId, + orgScopeId, + slugifyHandle, + userOrgScopeId, + userWorkspaceScopeId, + withHandleSuffix, + workspaceScopeId, +} from "./ids"; + +describe("newId", () => { + it("emits prefix + base58 body", () => { + const id = newId("workspace"); + expect(id.startsWith("workspace_")).toBe(true); + const body = id.slice("workspace_".length); + expect(body).toMatch(/^[1-9A-HJ-NP-Za-km-z]{22}$/); + }); + + it("collides with negligible probability across 1k draws", () => { + const seen = new Set(); + for (let i = 0; i < 1000; i++) seen.add(newId("workspace")); + expect(seen.size).toBe(1000); + }); +}); + +describe("scope id constructors", () => { + it("orgScopeId formats org_", () => { + expect(orgScopeId("org_abc").toString()).toBe("org_org_abc"); + expect(orgScopeId("01H").toString()).toBe("org_01H"); + }); + + it("workspaceScopeId formats workspace_", () => { + expect(workspaceScopeId("ws_abc").toString()).toBe("workspace_ws_abc"); + }); + + it("userOrgScopeId formats user_org__", () => { + expect(userOrgScopeId("u1", "o1").toString()).toBe("user_org_u1_o1"); + }); + + it("userWorkspaceScopeId formats user_workspace__", () => { + expect(userWorkspaceScopeId("u1", "w1").toString()).toBe( + "user_workspace_u1_w1", + ); + }); +}); + +describe("slugifyHandle", () => { + it("lowercases and hyphenates", () => { + expect(slugifyHandle("Acme Corp")).toBe("acme-corp"); + }); + + it("collapses runs and trims edges", () => { + expect(slugifyHandle(" --Acme!! Corp__ ")).toBe("acme-corp"); + }); + + it("strips diacritics", () => { + expect(slugifyHandle("Café Münchën")).toBe("cafe-munchen"); + }); + + it("falls back to 'org' for empty results", () => { + expect(slugifyHandle("!!!")).toBe("org"); + expect(slugifyHandle("")).toBe("org"); + }); + + it("caps length at 48", () => { + const long = "a".repeat(120); + expect(slugifyHandle(long).length).toBe(48); + }); +}); + +describe("withHandleSuffix", () => { + it("appends -n", () => { + expect(withHandleSuffix("acme", 2)).toBe("acme-2"); + expect(withHandleSuffix("acme", 17)).toBe("acme-17"); + }); + + it("keeps total length <= 48 by truncating the base", () => { + const base = "a".repeat(48); + const out = withHandleSuffix(base, 9); + expect(out.length).toBe(48); + expect(out.endsWith("-9")).toBe(true); + }); +}); diff --git a/apps/cloud/src/services/ids.ts b/apps/cloud/src/services/ids.ts new file mode 100644 index 00000000..95edb3f4 --- /dev/null +++ b/apps/cloud/src/services/ids.ts @@ -0,0 +1,88 @@ +// --------------------------------------------------------------------------- +// Cloud id helpers +// --------------------------------------------------------------------------- +// +// Two flavors: +// +// - `newId(prefix)` — random, prefixed local id (Unkey-style). Used for +// entities the cloud owns (workspaces, future local orgs, …). WorkOS +// ids stay as identity anchors; we don't re-prefix them. +// +// - `orgScopeId / workspaceScopeId / userOrgScopeId / userWorkspaceScopeId` +// — deterministic scope id constructors. Scope rows are addressed by +// these strings; the prefixes make a row's owner trivially inspectable +// and prevent accidental collisions between user scopes and org scopes. +// +// Plus `slugifyHandle / withHandleSuffix` for generating org handles and +// workspace slugs from human-entered names. + +import { ScopeId } from "@executor-js/sdk"; + +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +const randomBase58 = (length: number): string => { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + let out = ""; + for (let i = 0; i < length; i++) { + out += BASE58_ALPHABET[bytes[i]! % 58]; + } + return out; +}; + +/** + * Random prefixed id, ~128 bits of entropy. Output shape: `${prefix}_<22 base58>`. + */ +export const newId = (prefix: string): string => `${prefix}_${randomBase58(22)}`; + +// --------------------------------------------------------------------------- +// Deterministic scope id constructors +// --------------------------------------------------------------------------- + +export const orgScopeId = (orgId: string): ScopeId => + ScopeId.make(`org_${orgId}`); + +export const workspaceScopeId = (workspaceId: string): ScopeId => + ScopeId.make(`workspace_${workspaceId}`); + +export const userOrgScopeId = (userId: string, orgId: string): ScopeId => + ScopeId.make(`user_org_${userId}_${orgId}`); + +export const userWorkspaceScopeId = ( + userId: string, + workspaceId: string, +): ScopeId => ScopeId.make(`user_workspace_${userId}_${workspaceId}`); + +// --------------------------------------------------------------------------- +// Handle / slug helpers +// --------------------------------------------------------------------------- + +const HANDLE_MAX = 48; + +/** + * Reduce a free-form name to a handle/slug. Lowercase, ASCII-ish, hyphenated. + * Caller is responsible for collision handling — see `withHandleSuffix`. + */ +export const slugifyHandle = (name: string): string => { + const cleaned = name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") + .slice(0, HANDLE_MAX); + return cleaned.length > 0 ? cleaned : "org"; +}; + +/** + * Append a numeric suffix to a handle, keeping the result within HANDLE_MAX. + * `withHandleSuffix("acme", 2)` → `"acme-2"`. + */ +export const withHandleSuffix = (handle: string, n: number): string => { + const suffix = `-${n}`; + const room = HANDLE_MAX - suffix.length; + const base = handle.slice(0, Math.max(1, room)); + return `${base}${suffix}`; +};