feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2.
This commit is contained in:
@@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints {
|
||||
readonly jobs?: string;
|
||||
}
|
||||
|
||||
type DeepReadonly<T> = T extends (...arguments_: never[]) => unknown
|
||||
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||
// which structurally satisfy `T extends object` — without this check first, the mapped-type
|
||||
// branch below would incorrectly expand a branded string into `String.prototype`'s member shape
|
||||
// and lose the brand entirely, rather than leaving an already-immutable primitive untouched.
|
||||
type DeepReadonly<T> = T extends string | number | boolean | bigint | symbol | null | undefined
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
: T extends (...arguments_: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
|
||||
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./discovery.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
createPermissionRegistry,
|
||||
type PermissionDeclaration,
|
||||
type PermissionRegistry,
|
||||
} from "../authorization/index.js";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
const CORE_PERMISSION_NAMESPACE_PREFIX = "core.";
|
||||
|
||||
export type ModulePermissionCompositionErrorCode = "MODULE_PERMISSION_RESERVED_NAMESPACE";
|
||||
|
||||
/** Thrown only at composition time, never per-request. Carries the offending module and
|
||||
* permission id deliberately — like `PermissionRegistryError`, this is a startup-time
|
||||
* configuration error, not a runtime response, and neither value is sensitive. Never carries a
|
||||
* module directory, manifest path, caught error, or stack detail. */
|
||||
export class ModulePermissionCompositionError extends Error {
|
||||
readonly code: ModulePermissionCompositionErrorCode;
|
||||
readonly moduleId: string;
|
||||
readonly permissionId: string;
|
||||
|
||||
constructor(moduleId: string, permissionId: string) {
|
||||
super(
|
||||
`Module "${moduleId}" cannot declare permission "${permissionId}": the "core." namespace is reserved for Core's own permissions.`,
|
||||
);
|
||||
this.name = "ModulePermissionCompositionError";
|
||||
this.code = "MODULE_PERMISSION_RESERVED_NAMESPACE";
|
||||
this.moduleId = moduleId;
|
||||
this.permissionId = permissionId;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's module-integration-layer composition helper: builds the one platform permission
|
||||
* registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every already
|
||||
* discovered module's manifest-declared permissions. Deliberately lives in `core/src/modules`,
|
||||
* not `core/src/authorization` — the dependency runs one way only, this layer depends on
|
||||
* authorization's `createPermissionRegistry`, and authorization never imports module discovery
|
||||
* or runtime. This is pure, synchronous, in-memory composition over already-discovered,
|
||||
* already-validated data: no filesystem, environment, database, network, or module-code
|
||||
* execution happens here, and the caller (not this function) is responsible for having already
|
||||
* produced a complete, successfully discovered module list (for example via `discoverModules`).
|
||||
*
|
||||
* Declaration order is deterministic and meaningful for duplicate detection: Core's own
|
||||
* declarations first, then each module in the exact order it appears in `modules`, and within a
|
||||
* module its declarations in manifest-array order. Duplicate exact permission ids — including a
|
||||
* module colliding with a Core-owned id or with another module's — fail via the same
|
||||
* `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate; this
|
||||
* function does not reimplement or duplicate that detection.
|
||||
*
|
||||
* `core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||||
* `core.`-prefixed permission id — regardless of that module's own id, and regardless of whether
|
||||
* it happens to exactly collide with an existing declaration — fails composition with
|
||||
* `ModulePermissionCompositionError` rather than silently registering or silently colliding.
|
||||
*/
|
||||
export function composePlatformPermissionRegistry(
|
||||
modules: readonly DiscoveredModule[],
|
||||
): PermissionRegistry {
|
||||
const declarations: PermissionDeclaration[] = [...CORE_PERMISSION_DECLARATIONS];
|
||||
|
||||
for (const module of modules) {
|
||||
for (const declaration of module.manifest.permissions) {
|
||||
if (declaration.id.startsWith(CORE_PERMISSION_NAMESPACE_PREFIX)) {
|
||||
throw new ModulePermissionCompositionError(module.manifest.id, declaration.id);
|
||||
}
|
||||
declarations.push(declaration);
|
||||
}
|
||||
}
|
||||
|
||||
return createPermissionRegistry(declarations);
|
||||
}
|
||||
Reference in New Issue
Block a user