import { z } from "zod"; const localIdentifierPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const namespacedIdentifierPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; export const moduleIdSchema = z .string() .min(1) .max(64) .regex(localIdentifierPattern, "Must be a lowercase kebab-case module identifier."); export const capabilityIdSchema = z .string() .min(3) .max(160) .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier."); /** * Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId`: a plain * `string` is not assignable to `PermissionId` without passing through * `parsePermissionId`/`safeParsePermissionId`. The brand adds no runtime tag or wrapper — a value * still serializes as a plain string. This is the one permission-identifier type shared by module * manifest declarations (`moduleManifestSchema`'s `permissions` array) and Core's authorization * evaluator, so a permission a module declares and a permission Core evaluates are provably the * same identifier space. Namespacing (the required `.`-segments) is purely a naming convention for * readability and collision-avoidance — it never implies hierarchy or inheritance during * evaluation, and wildcard segments are not part of this format at all. */ export const permissionIdSchema = z .string() .min(3) .max(160) .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.") .brand<"PermissionId">(); export const logicalBucketIdSchema = z .string() .min(1) .max(48) .regex(localIdentifierPattern, "Must be a lowercase kebab-case logical bucket identifier."); export const packageRelativePathSchema = z.string().superRefine((value, context) => { if (!value.startsWith("./")) { context.addIssue({ code: "custom", message: "Must begin with ./ and remain inside the module package.", }); return; } if (value.includes("\\") || value.includes("\0") || value.includes("?") || value.includes("#")) { context.addIssue({ code: "custom", message: "Must be a portable package-relative path without URLs, queries, or fragments.", }); } const segments = value.slice(2).split("/"); if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { context.addIssue({ code: "custom", message: "Must not contain empty, current-directory, or parent-directory segments.", }); } }); export type ModuleId = z.infer; export type CapabilityId = z.infer; export type PermissionId = z.infer; export type LogicalBucketId = z.infer; export type PackageRelativePath = z.infer; export function parsePermissionId(input: unknown): PermissionId { return permissionIdSchema.parse(input); } export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult { return permissionIdSchema.safeParse(input); }