81 lines
3.0 KiB
TypeScript
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);
|
|
}
|