UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

370 lines (352 loc) 12.2 kB
import { $inject, Alepha, AlephaError, z } from "alepha"; import { $secure } from "alepha/security"; import { $action, okSchema } from "alepha/server"; import { ParameterAudits } from "../audits/ParameterAudits.ts"; import { activateParameterBodySchema } from "../schemas/activateParameterBodySchema.ts"; import { createParameterVersionBodySchema } from "../schemas/createParameterVersionBodySchema.ts"; import { parameterCurrentResponseSchema } from "../schemas/parameterCurrentResponseSchema.ts"; import { parameterHistoryResponseSchema } from "../schemas/parameterHistoryResponseSchema.ts"; import { parameterNameParamSchema } from "../schemas/parameterNameParamSchema.ts"; import { parameterNamesResponseSchema } from "../schemas/parameterNamesResponseSchema.ts"; import { parameterResponseSchema } from "../schemas/parameterResponseSchema.ts"; import { parameterTreeNodeSchema } from "../schemas/parameterTreeNodeSchema.ts"; import { parameterVersionParamSchema } from "../schemas/parameterVersionParamSchema.ts"; import { parameterVersionResponseSchema } from "../schemas/parameterVersionResponseSchema.ts"; import { rollbackParameterBodySchema } from "../schemas/rollbackParameterBodySchema.ts"; import { ParameterProvider } from "../services/ParameterProvider.ts"; /** * REST API controller for versioned parameter management. * * Provides endpoints for: * - Listing all parameters (tree view support) * - Getting parameter history (all versions with calculated status) * - Getting current/next parameter values * - Creating new parameter versions (immediate or scheduled) * - Rolling back to previous versions * - Activating scheduled versions immediately */ export class AdminParameterController { protected readonly url = "/parameters"; protected readonly group = "admin:parameters"; protected readonly provider = $inject(ParameterProvider); protected readonly alepha = $inject(Alepha); /** * Get tree structure of all parameter names. * Useful for admin UI navigation. */ getParameterTree = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get tree structure of all parameter names for navigation.", path: "/parameters/tree", method: "GET", schema: { response: z.array(parameterTreeNodeSchema), }, handler: async () => { return this.provider.getParameterTree(); }, }); /** * List all unique parameter names. */ listParameterNames = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "List all unique parameter names.", path: "/parameters", method: "GET", schema: { response: parameterNamesResponseSchema, }, handler: async () => { const names = await this.provider.getParameterNames(); return { names }; }, }); /** * Get version history for a specific parameter. * Returns all versions with calculated status. */ getHistory = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get all versions of a specific parameter.", path: "/parameters/:name/history", method: "GET", schema: { params: parameterNameParamSchema, query: z.object({ limit: z.integer().min(1).max(100).optional(), offset: z.integer().min(0).optional(), }), response: parameterHistoryResponseSchema, }, handler: async ({ params, query }) => { const rawVersions = await this.provider.getHistory(params.name, { limit: query.limit, offset: query.offset, }); const versions = this.provider.calculateStatuses(rawVersions); return { versions }; }, }); /** * Get current and next values for a parameter. * Includes defaultValue and currentValue from the registered primitive * even if no versions exist in the database yet. */ getCurrent = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get current and next scheduled values for a parameter.", path: "/parameters/:name", method: "GET", schema: { params: parameterNameParamSchema, response: parameterCurrentResponseSchema, }, handler: async ({ params }) => { const result = await this.provider.getCurrentWithDefault(params.name); return { current: result.current ?? undefined, next: result.next ?? undefined, defaultValue: result.defaultValue ?? undefined, currentValue: result.currentValue ?? undefined, schema: result.schema ?? undefined, }; }, }); /** * Get a specific version of a parameter. */ getVersion = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:read"] })], description: "Get a specific version of a parameter.", path: "/parameters/:name/versions/:version", method: "GET", schema: { params: parameterVersionParamSchema, response: parameterVersionResponseSchema, }, handler: async ({ params }) => { const version = await this.provider.getVersion( params.name, params.version, ); if (!version) { return { parameter: undefined }; } const [withStatus] = this.provider.calculateStatuses([version]); return { parameter: withStatus }; }, }); /** * Create a new parameter version. */ createVersion = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:create"] })], description: "Create a new version of a parameter (immediate or scheduled).", path: "/parameters/:name", method: "POST", schema: { params: parameterNameParamSchema, body: createParameterVersionBodySchema, response: parameterResponseSchema, }, handler: async ({ params, body, user }) => { const result = await this.provider.save( params.name, body.content, body.schemaHash, { activationDate: body.activationDate ? new Date(body.activationDate) : undefined, changeDescription: body.changeDescription, tags: body.tags, creatorId: user?.id, creatorName: this.creatorNameOf(user), }, ); await this.audit("create", params.name, { version: result.version, scheduled: result.status !== "current", }); return result; }, }); /** * Rollback to a previous version. */ rollback = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:rollback"] })], description: "Rollback a parameter to a previous version (creates new version with old content).", path: "/parameters/:name/rollback", method: "POST", schema: { params: parameterNameParamSchema, body: rollbackParameterBodySchema, response: parameterResponseSchema, }, handler: async ({ params, body, user }) => { const result = await this.provider.rollback( params.name, body.targetVersion, { changeDescription: body.changeDescription, creatorId: user?.id, creatorName: this.creatorNameOf(user), }, ); await this.audit("rollback", params.name, { version: result.version, targetVersion: body.targetVersion, }); return result; }, }); /** * Activate a scheduled version immediately. * Creates a new version with the same content but immediate activation. */ activateNow = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:activate"] })], description: "Activate a future/next parameter version immediately.", path: "/parameters/:name/activate", method: "POST", schema: { params: parameterNameParamSchema, body: activateParameterBodySchema, response: parameterResponseSchema, }, handler: async ({ params, body, user }) => { const allVersions = await this.provider.getHistory(params.name); const withStatuses = this.provider.calculateStatuses(allVersions); const target = withStatuses.find((v) => v.version === body.version); if (!target) { throw new AlephaError( `Version ${body.version} not found for parameter ${params.name}`, ); } if (target.status === "current") { return target; } if (target.status === "expired") { throw new AlephaError( "Cannot activate an expired version. Use rollback instead.", ); } // Create new version with same content but immediate activation const result = await this.provider.save( params.name, target.content, target.schemaHash, { changeDescription: `Early activation of version ${body.version}`, creatorId: user?.id, creatorName: this.creatorNameOf(user), }, ); await this.audit("activate", params.name, { version: result.version, activatedFrom: body.version, }); return result; }, }); /** * Delete all versions of a parameter. */ deleteParameter = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:delete"] })], description: "Delete all versions of a parameter.", path: "/parameters/:name", method: "DELETE", schema: { params: parameterNameParamSchema, response: okSchema, }, handler: async ({ params }) => { await this.provider.delete(params.name); await this.audit("delete", params.name); return { ok: true }; }, }); /** * Delete many parameters (all versions of each) in one request. */ deleteParameters = $action({ group: this.group, use: [$secure({ permissions: ["admin:parameter:delete"] })], description: "Delete all versions of many parameters by name.", path: "/parameters/delete", method: "POST", schema: { body: z.object({ names: z.array(z.string().min(1)).min(1).max(1000), }), response: z.object({ deleted: z.array(z.string()), }), }, handler: async ({ body }) => { const deleted = await this.provider.deleteMany(body.names); for (const name of deleted) { await this.audit("delete", name); } return { deleted }; }, }); /** * Derive a human-readable creator name from the authenticated session token. * * The name is snapshotted onto the version at write time rather than joined * from the users table on read: this keeps the parameters module free of any * dependency on the users module (no import, no circular-import risk). The * trade-off is that the stored name does not follow later user renames. * * Precedence: `username` → `email`. The token's `name` is intentionally * skipped: the security layer fills it with a placeholder ("Anonymous User") * when the issuer supplies no name, which would otherwise be snapshotted. */ protected creatorNameOf(user?: { username?: string; email?: string; }): string | undefined { return user?.username ?? user?.email; } /** * Record a parameter mutation via the `ParameterAudits` holder — best-effort. * * `ParameterAudits` is resolved lazily from the container; when it is not * registered, auditing is silently skipped. Actor (userId / email / realm), * IP, and request id are auto-filled by `AuditService` from the request * context. Failures never break the underlying operation. */ protected async audit( action: "create" | "rollback" | "activate" | "delete", name: string, metadata?: Record<string, unknown>, ): Promise<void> { if (!this.alepha.has(ParameterAudits)) { return; } try { await this.alepha.inject(ParameterAudits).parameter.log(action, { resourceType: "parameter", resourceId: name, metadata, }); } catch { // Auditing is best-effort; never fail the operation because of it. } } }