import { z } from "zod"; import { permissionIdSchema } from "./identifiers.js"; import type { EkklesiaUserId } from "./identity.js"; import { parseEkklesiaUserId } from "./identity.js"; /** * Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and * `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through * `parseGrantId`/`safeParseGrantId`. Callers must still treat the *value* as opaque — nothing * outside Core's store may construct one, derive meaning from it, or depend on its internals. * * The accepted shape is deliberately narrow: exactly the canonical lowercase hyphenated text form * PostgreSQL emits for the UUID-backed `id` column (`8-4-4-4-12` lowercase hexadecimal). A looser * "any short string" rule meant a malformed path parameter passed validation and only failed later, * inside PostgreSQL's own UUID comparison — turning a request-validation problem into an * indistinguishable infrastructure failure. There is deliberately **no** `.trim()`: whitespace * padding is a malformed identifier, not something to silently repair, and normalizing it here * would let two spellings of the same id reach the store. Uppercase is likewise rejected rather * than folded, so exactly one spelling of any grant is ever valid. */ export const grantIdSchema = z .string() .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u) .brand<"GrantId">(); export type GrantId = z.infer; export function parseGrantId(input: unknown): GrantId { return grantIdSchema.parse(input); } export function safeParseGrantId(input: unknown): z.ZodSafeParseResult { return grantIdSchema.safeParse(input); } /** * A generic, Core-opaque resource scope narrowing a grant or an authorization check to one * resource (for example a specific calendar event or media item). Core never interprets the * value beyond exact string equality — the owning module defines what a scope string means, if * anything. Not branded: unlike a permission or a grant, a scope carries no identity of its own * within Core; it is simply an opaque comparison key. */ export const authorizationScopeSchema = z.string().trim().min(1).max(256); export type AuthorizationScope = z.infer; export function parseAuthorizationScope(input: unknown): AuthorizationScope { return authorizationScopeSchema.parse(input); } export function safeParseAuthorizationScope( input: unknown, ): z.ZodSafeParseResult { return authorizationScopeSchema.safeParse(input); } /** * Every permission declaration has exactly one baseline. `public` allows any principal, anonymous * or authenticated. `authenticated` requires a valid authenticated principal and nothing more. * `restricted` requires an active matching grant or platform-owner authority — baseline alone * never satisfies it. */ export const permissionBaselineSchema = z.enum(["public", "authenticated", "restricted"]); export type PermissionBaseline = z.infer; /** * The one authoritative shape of a *registered* permission declaration — what both Core's * authorization registry (`core/src/authorization/permissions.ts`) and a module manifest's * `permissions` array parse into, so a permission a module declares and a permission Core's * registry enforces are provably the same structure, never two independently maintained * interfaces that could quietly drift apart. `.strict()` rejects any property beyond these three — * there is no room for a module or a future caller to smuggle extra, unvalidated metadata into a * declaration Core treats as trusted. * * This schema says nothing about *registration*: parsing a value into a `PermissionDeclaration` * only proves it is shaped correctly, not that any registry has accepted it. Registration remains * exclusively `PermissionRegistry`'s responsibility (`core/src/authorization/permissions.ts`) — * built by composing exactly the declarations a trusted caller supplies, Core's own plus every * discovered module's. */ export const permissionDeclarationSchema = z .object({ id: permissionIdSchema, baseline: permissionBaselineSchema, /** Operator/audit-facing text only. Authorization never reads, parses, or otherwise * interprets this value — changing it can never change what a permission allows. */ description: z.string().trim().min(1).max(240), }) .strict(); export type PermissionDeclarationInput = z.input; export type PermissionDeclaration = z.output; export function parsePermissionDeclaration(input: unknown): PermissionDeclaration { return permissionDeclarationSchema.parse(input); } export function safeParsePermissionDeclaration( input: unknown, ): z.ZodSafeParseResult { return permissionDeclarationSchema.safeParse(input); } /** * Distinguishes a grant an operator/system created directly (`explicit`) from one a module * created as a side effect of its own business operation, attributed to some source the module * understands (`relationship`) — for example adding a person to a volunteer roster. Core never * interprets which relationship produced the grant; it only records the distinction and an * opaque source reference for audit purposes. */ export const grantSourceKindSchema = z.enum(["explicit", "relationship"]); export type GrantSourceKind = z.infer; /** A Core-opaque reference to whatever produced a `relationship` grant (or, optionally, extra * context for an `explicit` one) — for example a module-defined identifier. Never interpreted by * Core, never a foreign key into another module's tables. */ export const grantSourceRefSchema = z.string().trim().min(1).max(256); export type GrantSourceRef = z.infer; const authorizationActorTypeSchema = z.enum(["user", "system"]); export type AuthorizationActorType = z.infer; export interface AuthorizationUserActor { readonly type: "user"; readonly userId: EkklesiaUserId; } export interface AuthorizationSystemActor { readonly type: "system"; } /** * Who performed a security-relevant authorization mutation (a grant or platform-owner change). * Mirrors `EkklesiaPrincipal`'s discriminated-union style. `system` covers Core-internal or * automated actions with no specific human actor (for example a future bootstrap command run by * an operator with no prior Ekklesia identity); it never means "unauthenticated" or "anonymous * caller" — every mutating call site chooses this deliberately. */ export type AuthorizationActor = AuthorizationUserActor | AuthorizationSystemActor; export const SYSTEM_ACTOR: AuthorizationSystemActor = Object.freeze({ type: "system" }); export function createUserActor(userId: unknown): AuthorizationUserActor { return Object.freeze({ type: "user", userId: parseEkklesiaUserId(userId) }); } export function isUserActor(actor: AuthorizationActor): actor is AuthorizationUserActor { return actor.type === "user"; } /** * The only public result of an authorization check. Deliberately minimal and frozen: it never * carries a reason, a matched grant, a permission echo, or any other detail a caller could use to * infer another user's grants or Core's internal evaluation path. `AUTHORIZATION_ALLOWED` and * `AUTHORIZATION_DENIED` are the only two values ever produced. */ export interface AuthorizationDecision { readonly allowed: boolean; } export const AUTHORIZATION_ALLOWED: AuthorizationDecision = Object.freeze({ allowed: true }); export const AUTHORIZATION_DENIED: AuthorizationDecision = Object.freeze({ allowed: false }); export function isAuthorizationAllowed(decision: AuthorizationDecision): boolean { return decision.allowed; }