From 87a543df69afb4eb21927aa9a9e5473fae9d5846 Mon Sep 17 00:00:00 2001 From: Source Open Date: Mon, 6 Apr 2026 17:38:20 -0700 Subject: [PATCH] fix(den): validate TypeIDs and refine OpenAPI docs (#1376) * fix(den): validate TypeIDs in API schemas Use the shared TypeID utility in den-api request and response schemas so invalid IDs are rejected consistently and Swagger documents the expected prefixes. Keep the API docs aligned with the live validation rules used by the app. * docs(den): refine OpenAPI route visibility Hide internal and sensitive Den routes from Swagger so the published docs stay focused on public workflows. Clarify route tags and permission descriptions so ownership and workspace-admin requirements are easier to understand. --------- Co-authored-by: src-opn --- ee/apps/den-api/README.md | 7 + ee/apps/den-api/src/app.ts | 41 ++-- ee/apps/den-api/src/openapi.ts | 12 ++ ee/apps/den-api/src/routes/admin/index.ts | 4 +- .../src/routes/auth/desktop-handoff.ts | 6 +- ee/apps/den-api/src/routes/auth/index.ts | 1 + ee/apps/den-api/src/routes/me/index.ts | 8 +- ee/apps/den-api/src/routes/org/api-keys.ts | 18 +- ee/apps/den-api/src/routes/org/core.ts | 15 +- ee/apps/den-api/src/routes/org/invitations.ts | 14 +- .../den-api/src/routes/org/llm-providers.ts | 40 ++-- ee/apps/den-api/src/routes/org/members.ts | 10 +- ee/apps/den-api/src/routes/org/roles.ts | 14 +- ee/apps/den-api/src/routes/org/shared.ts | 25 ++- ee/apps/den-api/src/routes/org/skills.ts | 74 +++---- ee/apps/den-api/src/routes/org/teams.ts | 26 +-- ee/apps/den-api/src/routes/org/templates.ts | 24 +-- ee/apps/den-api/src/routes/workers/billing.ts | 6 +- ee/apps/den-api/src/routes/workers/core.ts | 12 +- ee/apps/den-api/src/routes/workers/shared.ts | 3 +- ee/packages/utils/package.json | 6 +- ee/packages/utils/src/typeid.ts | 180 +++++++++++++++--- pnpm-lock.yaml | 23 +++ 23 files changed, 386 insertions(+), 183 deletions(-) diff --git a/ee/apps/den-api/README.md b/ee/apps/den-api/README.md index 0469126f..0412a7c6 100644 --- a/ee/apps/den-api/README.md +++ b/ee/apps/den-api/README.md @@ -34,6 +34,13 @@ pnpm --filter @openwork-ee/den-api dev:local Each major folder also has its own `README.md` so future agents can inspect one area in isolation. +## TypeID validation + +- Shared Den TypeID validation lives in `ee/packages/utils/src/typeid.ts`. +- Use `typeId.schema("...")` or the compatibility helpers like `normalizeDenTypeId("...", value)` when an endpoint accepts or returns a Den TypeID. +- `ee/apps/den-api/src/openapi.ts` exposes `denTypeIdSchema(...)` so path params, request bodies, and response fields all share the same validation rules and Swagger examples. +- Swagger now documents Den IDs with their required prefix and fixed 26-character TypeID suffix, so invalid IDs fail request validation before route logic runs. + ## Migration approach 1. Keep `den-api` (formerly `den-controller`) as the source of truth for Den control-plane behavior. diff --git a/ee/apps/den-api/src/app.ts b/ee/apps/den-api/src/app.ts index 361487ca..2adbb980 100644 --- a/ee/apps/den-api/src/app.ts +++ b/ee/apps/den-api/src/app.ts @@ -68,6 +68,7 @@ app.get( "/", describeRoute({ tags: ["System"], + hide: true, summary: "Redirect API root", description: "Redirects the API root to the OpenWork marketing site instead of serving API content.", responses: { @@ -110,7 +111,7 @@ registerWorkerRoutes(app) app.get( "/openapi.json", describeRoute({ - tags: ["Documentation"], + tags: ["System"], summary: "Get OpenAPI document", description: "Returns the machine-readable OpenAPI 3.1 document for the Den API so humans and tools can inspect the API surface.", responses: { @@ -123,29 +124,35 @@ app.get( info: { title: "Den API", version: "dev", - description: "OpenAPI spec for the Den control plane API.", + description: [ + "OpenAPI spec for the Den control plane API.", + "", + "Authentication:", + "- Use `Authorization: Bearer ` for user-authenticated routes that require a Den session.", + "- Use `x-api-key: ` for API-key-authenticated routes that accept organization API keys.", + "- Public routes like health and documentation do not require authentication.", + "", + "Swagger tip: use the security schemes in the Authorize dialog to set either `bearerAuth` or `denApiKey` before trying protected endpoints.", + ].join("\n"), }, servers: [ { url: "https://api.openworklabs.com" }, ], tags: [ { name: "System", description: "Service health and operational routes." }, - { name: "Documentation", description: "OpenAPI document and Swagger UI routes." }, - { name: "Organizations", description: "Organization-scoped Den API routes." }, - { name: "Organization Invitations", description: "Organization invitation creation, preview, acceptance, and cancellation routes." }, - { name: "Organization API Keys", description: "Organization API key management routes." }, - { name: "Organization Members", description: "Organization member management routes." }, - { name: "Organization Roles", description: "Organization custom role management routes." }, - { name: "Organization Teams", description: "Organization team management routes." }, - { name: "Organization Templates", description: "Organization shared template routes." }, - { name: "Organization LLM Providers", description: "Organization LLM provider catalog, configuration, and access routes." }, - { name: "Organization Skills", description: "Organization skill authoring and sharing routes." }, - { name: "Organization Skill Hubs", description: "Organization skill hub management and access routes." }, + { name: "Organizations", description: "Top-level organization creation and context routes." }, + { name: "Invitations", description: "Invitation preview, acceptance, creation, and cancellation routes." }, + { name: "API Keys", description: "Organization API key management routes." }, + { name: "Members", description: "Organization member management routes." }, + { name: "Roles", description: "Organization custom role management routes." }, + { name: "Teams", description: "Organization team management routes." }, + { name: "Templates", description: "Organization shared template routes." }, + { name: "LLM Providers", description: "Organization LLM provider catalog, configuration, and access routes." }, + { name: "Skills", description: "Organization skill authoring and sharing routes." }, + { name: "Skill Hubs", description: "Organization skill hub management and access routes." }, { name: "Workers", description: "Worker lifecycle, billing, and runtime routes." }, - { name: "Worker Billing", description: "Worker subscription and billing status routes." }, { name: "Worker Runtime", description: "Worker runtime inspection and upgrade routes." }, { name: "Worker Activity", description: "Worker heartbeat and activity reporting routes." }, - { name: "Authentication", description: "Authentication and desktop sign-in handoff routes." }, { name: "Admin", description: "Administrative reporting routes." }, { name: "Users", description: "Current user and membership routes." }, ], @@ -155,11 +162,13 @@ app.get( type: "http", scheme: "bearer", bearerFormat: "session-token", + description: "Session token passed as `Authorization: Bearer ` for user-authenticated Den routes.", }, denApiKey: { type: "apiKey", in: "header", name: "x-api-key", + description: "Organization API key passed as the `x-api-key` header for API-key-authenticated Den routes.", }, }, }, @@ -178,7 +187,7 @@ app.get( app.get( "/docs", describeRoute({ - tags: ["Documentation"], + tags: ["System"], summary: "Serve Swagger UI", description: "Serves Swagger UI so developers can browse and try the Den API from a browser.", responses: { diff --git a/ee/apps/den-api/src/openapi.ts b/ee/apps/den-api/src/openapi.ts index b5330d55..62743788 100644 --- a/ee/apps/den-api/src/openapi.ts +++ b/ee/apps/den-api/src/openapi.ts @@ -1,6 +1,9 @@ +import { type DenTypeIdName, typeId } from "@openwork-ee/utils/typeid" import { resolver } from "hono-openapi" import { z } from "zod" +const TYPE_ID_EXAMPLE_SUFFIX = "01h2xcejqtf2nbrexx3vqjhp41" + function toPascalCase(value: string) { return value .replace(/[^a-zA-Z0-9]+/g, " ") @@ -34,6 +37,15 @@ export function buildOperationId(method: string, path: string) { .replace(/^[A-Z]/, (char) => char.toLowerCase()) } +export function denTypeIdSchema(typeName: TName) { + const prefix = typeId.prefix[typeName] + return typeId.schema(typeName).describe(`Den TypeID with '${prefix}_' prefix.`).meta({ + description: `Den TypeID with '${prefix}_' prefix and a ${typeId.suffixLength}-character base32 suffix.`, + examples: [`${prefix}_${TYPE_ID_EXAMPLE_SUFFIX}`], + format: "typeid", + }) +} + const validationIssueSchema = z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])).optional(), diff --git a/ee/apps/den-api/src/routes/admin/index.ts b/ee/apps/den-api/src/routes/admin/index.ts index f58ceb02..186686a2 100644 --- a/ee/apps/den-api/src/routes/admin/index.ts +++ b/ee/apps/den-api/src/routes/admin/index.ts @@ -6,7 +6,7 @@ import { z } from "zod" import { getCloudWorkerAdminBillingStatus } from "../../billing/polar.js" import { db } from "../../db.js" import { queryValidator, requireAdminMiddleware } from "../../middleware/index.js" -import { invalidRequestSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js" +import { denTypeIdSchema, invalidRequestSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" type UserId = typeof AuthUserTable.$inferSelect.id @@ -17,7 +17,7 @@ const overviewQuerySchema = z.object({ const adminOverviewResponseSchema = z.object({ viewer: z.object({ - id: z.string(), + id: denTypeIdSchema("user"), email: z.string(), name: z.string().nullable(), }), diff --git a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts index 1907b838..ad838dd5 100644 --- a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts +++ b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts @@ -7,7 +7,7 @@ import { describeRoute } from "hono-openapi" import { z } from "zod" import { jsonValidator, requireUserMiddleware } from "../../middleware/index.js" import { db } from "../../db.js" -import { invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js" +import { denTypeIdSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" const createGrantSchema = z.object({ @@ -28,7 +28,7 @@ const desktopHandoffGrantResponseSchema = z.object({ const desktopHandoffExchangeResponseSchema = z.object({ token: z.string(), user: z.object({ - id: z.string(), + id: denTypeIdSchema("user"), email: z.string().email(), name: z.string().nullable(), }), @@ -115,6 +115,7 @@ export function registerDesktopAuthRoutes(app: Hono) { app.post( "/v1/orgs/:orgId/invitations", describeRoute({ - tags: ["Organizations", "Organization Invitations"], + tags: ["Invitations"], summary: "Create organization invitation", description: "Creates or refreshes a pending organization invitation for an email address and sends the invite email.", responses: { @@ -40,7 +40,7 @@ export function registerOrgInvitationRoutes { if (value.source === "models_dev") { if (!value.providerId) { @@ -482,7 +482,7 @@ export function registerOrgLlmProviderRoutes { const payload = c.get("organizationContext") @@ -1015,7 +1015,7 @@ export function registerOrgLlmProviderRoutes(app: Hono) { app.post( "/v1/orgs/:orgId/members/:memberId/role", describeRoute({ - tags: ["Organizations", "Organization Members"], + tags: ["Members"], summary: "Update member role", description: "Changes the role assigned to a specific organization member.", responses: { 200: jsonResponse("Member role updated successfully.", successSchema), 400: jsonResponse("The member role update request was invalid.", invalidRequestSchema), 401: jsonResponse("The caller must be signed in to update member roles.", unauthorizedSchema), - 403: jsonResponse("Only organization owners can update member roles.", forbiddenSchema), + 403: jsonResponse("Only workspace owners can update member roles.", forbiddenSchema), 404: jsonResponse("The member or organization could not be found.", notFoundSchema), }, }), @@ -83,14 +83,14 @@ export function registerOrgMemberRoutes(app: Hono) { app.post( "/v1/orgs/:orgId/roles", describeRoute({ - tags: ["Organizations", "Organization Roles"], + tags: ["Roles"], summary: "Create organization role", description: "Creates a custom organization role with a named permission map.", responses: { 201: jsonResponse("Organization role created successfully.", successSchema), 400: jsonResponse("The role creation request was invalid.", invalidRequestSchema), 401: jsonResponse("The caller must be signed in to create organization roles.", unauthorizedSchema), - 403: jsonResponse("Only organization owners can create organization roles.", forbiddenSchema), + 403: jsonResponse("Only workspace owners can create custom roles.", forbiddenSchema), 404: jsonResponse("The organization could not be found.", notFoundSchema), }, }), @@ -83,14 +83,14 @@ export function registerOrgRoleRoutes export const orgIdParamSchema = z.object({ - orgId: z.string().trim().min(1).max(255), + orgId: denTypeIdSchema("organization"), }) -export function idParamSchema(key: K) { +export function idParamSchema(key: K, typeName?: DenTypeIdName) { + if (!typeName) { + return z.object({ + [key]: z.string().trim().min(1).max(255), + } as unknown as Record) + } + return z.object({ - [key]: z.string().trim().min(1).max(255), - } as Record) + [key]: denTypeIdSchema(typeName), + } as unknown as Record>) } export function splitRoles(value: string) { @@ -72,7 +79,7 @@ export function ensureOwner(c: { get: (key: "organizationContext") => OrgRouteVa ok: false as const, response: { error: "forbidden", - message: "Only organization owners can manage members and roles.", + message: "Only workspace owners can manage members and roles.", }, } } @@ -99,7 +106,7 @@ export function ensureInviteManager(c: { get: (key: "organizationContext") => Or ok: false as const, response: { error: "forbidden", - message: "Only organization owners and admins can invite members.", + message: "Only workspace owners and admins can invite members.", }, } } @@ -123,7 +130,7 @@ export function ensureTeamManager(c: { get: (key: "organizationContext") => OrgR ok: false as const, response: { error: "forbidden", - message: "Only organization owners and admins can manage teams.", + message: "Only workspace owners and admins can manage teams.", }, } } @@ -147,7 +154,7 @@ export function ensureApiKeyManager(c: { get: (key: "organizationContext") => Or ok: false as const, response: { error: "forbidden", - message: "Only organization owners and admins can manage API keys.", + message: "Only workspace owners and admins can manage API keys.", }, } } diff --git a/ee/apps/den-api/src/routes/org/skills.ts b/ee/apps/den-api/src/routes/org/skills.ts index 2b28a7f7..380e932c 100644 --- a/ee/apps/den-api/src/routes/org/skills.ts +++ b/ee/apps/den-api/src/routes/org/skills.ts @@ -22,7 +22,7 @@ import { resolveOrganizationContextMiddleware, } from "../../middleware/index.js" import type { MemberTeamsContext } from "../../middleware/member-teams.js" -import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js" +import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js" import type { OrgRouteVariables } from "./shared.js" import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js" @@ -80,12 +80,12 @@ const updateSkillHubSchema = z.object({ }) const addSkillToHubSchema = z.object({ - skillId: z.string().trim().min(1), + skillId: denTypeIdSchema("skill"), }) const addSkillHubAccessSchema = z.object({ - orgMembershipId: z.string().trim().min(1).optional(), - teamId: z.string().trim().min(1).optional(), + orgMembershipId: denTypeIdSchema("member").optional(), + teamId: denTypeIdSchema("team").optional(), }).superRefine((value, ctx) => { const count = Number(Boolean(value.orgMembershipId)) + Number(Boolean(value.teamId)) if (count !== 1) { @@ -105,10 +105,10 @@ type MemberId = typeof MemberTable.$inferSelect.id type SkillRow = typeof SkillTable.$inferSelect type SkillHubRow = typeof SkillHubTable.$inferSelect -const orgSkillHubParamsSchema = orgIdParamSchema.extend(idParamSchema("skillHubId").shape) -const orgSkillParamsSchema = orgIdParamSchema.extend(idParamSchema("skillId").shape) -const orgSkillHubSkillParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("skillId").shape) -const orgSkillHubAccessParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("accessId").shape) +const orgSkillHubParamsSchema = orgIdParamSchema.extend(idParamSchema("skillHubId", "skillHub").shape) +const orgSkillParamsSchema = orgIdParamSchema.extend(idParamSchema("skillId", "skill").shape) +const orgSkillHubSkillParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("skillId", "skill").shape) +const orgSkillHubAccessParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("accessId", "skillHubMember").shape) const skillResponseSchema = z.object({ skill: z.object({}).passthrough(), @@ -265,7 +265,7 @@ export function registerOrgSkillRoutes { @@ -414,14 +414,14 @@ export function registerOrgSkillRoutes { @@ -822,14 +822,14 @@ export function registerOrgSkillRoutes { if (value.name === undefined && value.memberIds === undefined) { ctx.addIssue({ @@ -45,16 +45,16 @@ const updateTeamSchema = z.object({ type TeamId = typeof TeamTable.$inferSelect.id type MemberId = typeof MemberTable.$inferSelect.id -const orgTeamParamsSchema = orgIdParamSchema.extend(idParamSchema("teamId").shape) +const orgTeamParamsSchema = orgIdParamSchema.extend(idParamSchema("teamId", "team").shape) const teamResponseSchema = z.object({ team: z.object({ - id: z.string(), - organizationId: z.string(), + id: denTypeIdSchema("team"), + organizationId: denTypeIdSchema("organization"), name: z.string(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), - memberIds: z.array(z.string()), + memberIds: z.array(denTypeIdSchema("member")), }), }).meta({ ref: "TeamResponse" }) @@ -87,14 +87,14 @@ export function registerOrgTeamRoutes(app: Hono) { app.post( "/v1/orgs/:orgId/templates", describeRoute({ - tags: ["Organizations", "Organization Templates"], + tags: ["Templates"], summary: "Create shared template", description: "Stores a reusable shared template snapshot inside an organization.", responses: { @@ -103,7 +103,7 @@ export function registerOrgTemplateRoutes diff --git a/ee/packages/utils/package.json b/ee/packages/utils/package.json index ac7a8843..9ff94c15 100644 --- a/ee/packages/utils/package.json +++ b/ee/packages/utils/package.json @@ -21,9 +21,13 @@ "build": "tsup" }, "dependencies": { - "typeid-js": "^1.2.0" + "luxon": "^3.6.1", + "typeid-js": "^1.2.0", + "uuid": "^11.1.0", + "zod": "^4.3.6" }, "devDependencies": { + "@types/luxon": "^3.6.2", "@types/node": "^20.11.30", "tsup": "^8.5.0", "typescript": "^5.5.4" diff --git a/ee/packages/utils/src/typeid.ts b/ee/packages/utils/src/typeid.ts index e9c99a70..4e5a91c4 100644 --- a/ee/packages/utils/src/typeid.ts +++ b/ee/packages/utils/src/typeid.ts @@ -1,6 +1,13 @@ -import { fromString, getType, typeid } from "typeid-js" +import { DateTime } from "luxon" +import { TypeID, typeid } from "typeid-js" +import { v7 as uuidv7 } from "uuid" +import { z } from "zod" -export const denTypeIdPrefixes = { +export const TYPE_ID_SUFFIX_LENGTH = 26 + +const BASE32_REGEX = /^[0-9a-hjkmnp-tv-z]+$/ + +export const idTypesMapNameToPrefix = { request: "req", user: "usr", session: "ses", @@ -33,40 +40,165 @@ export const denTypeIdPrefixes = { auditEvent: "aev", } as const -export type DenTypeIdName = keyof typeof denTypeIdPrefixes -export type DenTypeIdPrefix = (typeof denTypeIdPrefixes)[TName] -export type DenTypeId = `${DenTypeIdPrefix}_${string}` +export const denTypeIdPrefixes = idTypesMapNameToPrefix + +type IdTypesMapNameToPrefix = typeof idTypesMapNameToPrefix +type IdTypesMapPrefixToName = { + [K in keyof IdTypesMapNameToPrefix as IdTypesMapNameToPrefix[K]]: K +} + +const idTypesMapPrefixToName = Object.fromEntries( + Object.entries(idTypesMapNameToPrefix).map(([name, prefix]) => [prefix, name]), +) as IdTypesMapPrefixToName + +export type IdTypePrefixNames = keyof typeof idTypesMapNameToPrefix +export type DenTypeIdName = IdTypePrefixNames +export type TypeId = `${IdTypesMapNameToPrefix[T]}_${string}` +export type DenTypeId = TypeId + +type TypeIdSchema = z.ZodType, string> + +const schemaCache = new Map>() + +const buildTypeIdSchema = (prefix: T): TypeIdSchema => { + const expectedPrefix = idTypesMapNameToPrefix[prefix] + const expectedLength = TYPE_ID_SUFFIX_LENGTH + expectedPrefix.length + 1 + + return z + .string() + .length(expectedLength, { + message: `TypeID must be ${expectedLength} characters (${expectedPrefix}_<26 char suffix>)`, + }) + .startsWith(`${expectedPrefix}_`, { + message: `TypeID must start with '${expectedPrefix}_'`, + }) + .refine( + (input) => { + const suffix = input.slice(expectedPrefix.length + 1) + return BASE32_REGEX.test(suffix) + }, + { message: "TypeID suffix contains invalid base32 characters" }, + ) + .refine( + (input) => { + try { + TypeID.fromString(input) + return true + } catch { + return false + } + }, + { message: "TypeID is structurally invalid" }, + ) + .transform((input) => TypeID.fromString(input).toString() as TypeId) +} + +const typeIdZodSchema = (prefix: T): TypeIdSchema => { + let schema = schemaCache.get(prefix) + if (!schema) { + schema = buildTypeIdSchema(prefix) + schemaCache.set(prefix, schema) + } + return schema as TypeIdSchema +} + +const typeIdGenerator = ( + prefix: T, +) => typeid(idTypesMapNameToPrefix[prefix]).toString() as TypeId + +const validateTypeId = ( + prefix: T, + data: unknown, +): data is TypeId => typeIdZodSchema(prefix).safeParse(data).success + +const inferTypeId = ( + input: `${T}_${string}`, +): IdTypesMapPrefixToName[T] => { + const parsed = TypeID.fromString(input) + const prefix = parsed.getType() as T + const typeName = idTypesMapPrefixToName[prefix] + + if (typeName === undefined) { + throw new Error( + `Unknown TypeID prefix '${prefix}'. Registered prefixes: ${Object.keys(idTypesMapPrefixToName).join(", ")}`, + ) + } + + return typeName +} + +const typeIdFromString = ( + typeName: T, + input: string, +): TypeId => { + const parsed = TypeID.fromString(input) + const expectedPrefix = idTypesMapNameToPrefix[typeName] + const actualPrefix = parsed.getType() + + if (actualPrefix !== expectedPrefix) { + throw new Error( + `TypeID prefix mismatch: expected '${expectedPrefix}' but got '${actualPrefix}'`, + ) + } + + return parsed.toString() as TypeId +} + +const typeIdWithTimestamp = ( + typeName: T, + timestamp?: Date | number, +): TypeId => { + let msecs: number + + if (timestamp === undefined) { + msecs = DateTime.now().toMillis() + } else if (timestamp instanceof Date) { + msecs = timestamp.getTime() + } else { + msecs = timestamp + } + + if (!Number.isFinite(msecs)) { + throw new Error(`Invalid timestamp: expected finite number, got ${msecs}`) + } + if (msecs < 0) { + throw new Error(`Invalid timestamp: expected non-negative number, got ${msecs}`) + } + + const uuid = uuidv7({ msecs }) + const prefix = idTypesMapNameToPrefix[typeName] + return TypeID.fromUUID(prefix, uuid).toString() as TypeId +} + +const getColumnLength = (typeName: T) => + idTypesMapNameToPrefix[typeName].length + 1 + TYPE_ID_SUFFIX_LENGTH + +export const typeId = { + schema: typeIdZodSchema, + generator: typeIdGenerator, + generatorWithTimestamp: typeIdWithTimestamp, + validator: validateTypeId, + infer: inferTypeId, + fromString: typeIdFromString, + suffixLength: TYPE_ID_SUFFIX_LENGTH, + prefix: idTypesMapNameToPrefix, + columnLength: getColumnLength, +} export function createDenTypeId(name: TName): DenTypeId { - return typeid(denTypeIdPrefixes[name]).toString() as DenTypeId + return typeId.generator(name) } export function normalizeDenTypeId( name: TName, value: string, ): DenTypeId { - const parsed = fromString(value) - const expectedPrefix = denTypeIdPrefixes[name] - - if (getType(parsed) !== expectedPrefix) { - throw new Error(`invalid_den_typeid_prefix:${name}:${getType(parsed)}`) - } - - return parsed as DenTypeId + return typeId.fromString(name, value) } export function isDenTypeId( name: TName, value: unknown, ): value is DenTypeId { - if (typeof value !== "string") { - return false - } - - try { - normalizeDenTypeId(name, value) - return true - } catch { - return false - } + return typeId.validator(name, value) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf7bb58e..51d511b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -649,10 +649,22 @@ importers: ee/packages/utils: dependencies: + luxon: + specifier: ^3.6.1 + version: 3.7.2 typeid-js: specifier: ^1.2.0 version: 1.2.0 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: + '@types/luxon': + specifier: ^3.6.2 + version: 3.7.1 '@types/node': specifier: ^20.11.30 version: 20.12.12 @@ -3418,6 +3430,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -4777,6 +4792,10 @@ packages: peerDependencies: solid-js: ^1.4.7 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -9167,6 +9186,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.7.1': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -10500,6 +10521,8 @@ snapshots: dependencies: solid-js: 1.9.9 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5