Files
Ekklesia/packages/contracts/src/identifiers.ts
T

81 lines
3.0 KiB
TypeScript

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<typeof moduleIdSchema>;
export type CapabilityId = z.infer<typeof capabilityIdSchema>;
export type PermissionId = z.infer<typeof permissionIdSchema>;
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
export function parsePermissionId(input: unknown): PermissionId {
return permissionIdSchema.parse(input);
}
export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult<PermissionId> {
return permissionIdSchema.safeParse(input);
}