import { createHash } from 'node:crypto'; import { normalizeApprovedActionDispatchRecord, normalizeApprovedActionFence, type ApprovedActionDispatchRecord, } from '../approved-action/approvedAction'; import { createPluginPackageLock, normalizePluginPackageInstallActionInput, pluginPackageInstallActionDigest, pluginPackageInstallPlanDigest, type PluginPackageInstallActionInput, type PluginPackageLock, } from './installation/pluginPackageInstall'; import { normalizeProjectPolicySubject, } from '../security/project-policy/projectPolicy'; import type { SecurityPolicyFence, SecuritySubject, } from '../security/security'; import type { SecurityAuditRecord } from '../security/audit/securityAudit'; export const PLUGIN_PACKAGE_INSTALL_PROPOSAL_SCHEMA = 'qinglong/plugin-package-install-proposal@v1' as const; export const PLUGIN_PACKAGE_INSTALL_ACTION_TYPE = 'plugin_package.install' as const; export const PLUGIN_PACKAGE_INSTALL_PERMISSION = 'package.manage' as const; export interface PluginPackageInstallProposal { readonly schema: typeof PLUGIN_PACKAGE_INSTALL_PROPOSAL_SCHEMA; readonly actionRef: string; readonly projectId: string; readonly actionType: typeof PLUGIN_PACKAGE_INSTALL_ACTION_TYPE; readonly permission: typeof PLUGIN_PACKAGE_INSTALL_PERMISSION; readonly actionInput: Readonly; readonly actionDigest: string; readonly previewDigest: string; readonly proposedBy: Readonly; readonly proposalFence: Readonly; readonly createdAtMs: number; readonly proposalDigest: string; } export interface CreatePluginPackageInstallProposalInput { readonly actionRef: string; readonly actionInput: PluginPackageInstallActionInput; readonly proposedBy: SecuritySubject; readonly proposalFence: SecurityPolicyFence; readonly createdAtMs: number; } export interface PluginPackageInstallProposalRepository { findProposalByActionRef( actionRef: string, ): Promise | null>; createProposal( command: CreatePluginPackageInstallProposalCommand, ): Promise>; } export interface CreatePluginPackageInstallProposalCommand { readonly proposal: PluginPackageInstallProposal; readonly audit: SecurityAuditRecord; } export interface CreatePluginPackageInstallProposalResult { readonly status: 'created' | 'existing'; readonly proposal: Readonly; } export class InvalidPluginPackageInstallProposalError extends TypeError { readonly code = 'PLUGIN_PACKAGE_INSTALL_PROPOSAL_INVALID'; constructor(message: string) { super(`Plugin Package install proposal is invalid: ${message}`); this.name = 'InvalidPluginPackageInstallProposalError'; } } export class PluginPackageInstallProposalBindingConflictError extends Error { readonly code = 'PLUGIN_PACKAGE_INSTALL_PROPOSAL_BINDING_CONFLICT'; constructor() { super('Plugin Package install proposal does not match its dispatch'); this.name = 'PluginPackageInstallProposalBindingConflictError'; } } export class PluginPackageInstallProposalConflictError extends Error { readonly code = 'PLUGIN_PACKAGE_INSTALL_PROPOSAL_CONFLICT'; constructor() { super('Plugin Package install proposal conflicts with durable authority'); this.name = 'PluginPackageInstallProposalConflictError'; } } export class PluginPackageInstallProposalUnavailableError extends Error { readonly code = 'PLUGIN_PACKAGE_INSTALL_PROPOSAL_UNAVAILABLE'; constructor(options?: ErrorOptions) { super('Plugin Package install proposal authority is unavailable', options); this.name = 'PluginPackageInstallProposalUnavailableError'; } } const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; const DIGEST_PATTERN = /^[0-9a-f]{64}$/; function dataRecord(value: unknown, label: string): Record { if ( !value || typeof value !== 'object' || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ) { throw new InvalidPluginPackageInstallProposalError( `${label} must be an object`, ); } return value as Record; } function exactKeys( value: object, expected: readonly string[], label: string, ): void { const actual = Object.keys(value).sort(); const canonical = [...expected].sort(); if ( actual.length !== canonical.length || actual.some((key, index) => key !== canonical[index]) ) { throw new InvalidPluginPackageInstallProposalError( `${label} shape is invalid`, ); } } function actionRef(value: unknown): string { if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { throw new InvalidPluginPackageInstallProposalError( 'action reference is invalid', ); } return value; } function digest(value: unknown, label: string): string { if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { throw new InvalidPluginPackageInstallProposalError(`${label} is invalid`); } return value; } function timestamp(value: unknown, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0) { throw new InvalidPluginPackageInstallProposalError(`${label} is invalid`); } return value as number; } function contractDigest(domain: string, value: unknown): string { return createHash('sha256') .update(domain) .update('\0') .update(JSON.stringify(value)) .digest('hex'); } function withoutProposalDigest( value: Omit, ): Omit { return Object.freeze(value); } function withProposalDigest( value: Omit, ): Readonly { const normalized = withoutProposalDigest(value); return Object.freeze({ ...normalized, proposalDigest: contractDigest( 'qinglong/plugin-package-install-proposal-digest@v1', normalized, ), }); } export function createPluginPackageInstallProposal( inputValue: CreatePluginPackageInstallProposalInput, ): Readonly { const input = dataRecord(inputValue, 'proposal input'); exactKeys( input, [ 'actionRef', 'actionInput', 'proposedBy', 'proposalFence', 'createdAtMs', ], 'proposal input', ); const actionInput = normalizePluginPackageInstallActionInput( inputValue.actionInput, ); const proposedBy = normalizeProjectPolicySubject(inputValue.proposedBy); return withProposalDigest({ schema: PLUGIN_PACKAGE_INSTALL_PROPOSAL_SCHEMA, actionRef: actionRef(inputValue.actionRef), projectId: actionInput.projectId, actionType: PLUGIN_PACKAGE_INSTALL_ACTION_TYPE, permission: PLUGIN_PACKAGE_INSTALL_PERMISSION, actionInput, actionDigest: pluginPackageInstallActionDigest(actionInput), previewDigest: pluginPackageInstallPlanDigest(actionInput.plan), proposedBy, proposalFence: normalizeApprovedActionFence(inputValue.proposalFence), createdAtMs: timestamp(inputValue.createdAtMs, 'creation time'), }); } export function normalizePluginPackageInstallProposal( value: PluginPackageInstallProposal, ): Readonly { const proposal = dataRecord(value, 'proposal'); exactKeys( proposal, [ 'schema', 'actionRef', 'projectId', 'actionType', 'permission', 'actionInput', 'actionDigest', 'previewDigest', 'proposedBy', 'proposalFence', 'createdAtMs', 'proposalDigest', ], 'proposal', ); if ( value.schema !== PLUGIN_PACKAGE_INSTALL_PROPOSAL_SCHEMA || value.actionType !== PLUGIN_PACKAGE_INSTALL_ACTION_TYPE || value.permission !== PLUGIN_PACKAGE_INSTALL_PERMISSION ) { throw new InvalidPluginPackageInstallProposalError( 'schema or action authority is invalid', ); } const normalized = createPluginPackageInstallProposal({ actionRef: actionRef(value.actionRef), actionInput: value.actionInput, proposedBy: value.proposedBy, proposalFence: value.proposalFence, createdAtMs: timestamp(value.createdAtMs, 'creation time'), }); const proposalDigest = digest(value.proposalDigest, 'proposal digest'); if ( value.projectId !== normalized.projectId || value.actionDigest !== normalized.actionDigest || value.previewDigest !== normalized.previewDigest || proposalDigest !== normalized.proposalDigest ) { throw new InvalidPluginPackageInstallProposalError( 'proposal digest or derived binding is invalid', ); } return normalized; } export function resolvePluginPackageInstallProposal( proposalValue: PluginPackageInstallProposal, dispatchValue: ApprovedActionDispatchRecord, createdAtMsValue: number, ): Readonly { const proposal = normalizePluginPackageInstallProposal(proposalValue); const dispatch = normalizeApprovedActionDispatchRecord(dispatchValue); const createdAtMs = timestamp(createdAtMsValue, 'lock creation time'); if ( dispatch.projectId !== proposal.projectId || dispatch.action.actionRef !== proposal.actionRef || dispatch.action.actionType !== proposal.actionType || dispatch.action.permission !== proposal.permission || dispatch.action.actionDigest !== proposal.actionDigest || dispatch.action.previewDigest !== proposal.previewDigest || dispatch.requestedBy.type !== proposal.proposedBy.type || dispatch.requestedBy.id !== proposal.proposedBy.id || dispatch.createdAtMs < proposal.createdAtMs || createdAtMs < dispatch.createdAtMs || createdAtMs >= dispatch.expiresAtMs ) { throw new PluginPackageInstallProposalBindingConflictError(); } return createPluginPackageLock({ ...proposal.actionInput, approval: { requestId: dispatch.approvalRequestId, requestVersion: dispatch.approvalRequestVersion, dispatchId: dispatch.id, actionDigest: dispatch.action.actionDigest, previewDigest: dispatch.action.previewDigest, approvedBy: dispatch.approvedBy, approvedAtMs: dispatch.approvedAtMs, expiresAtMs: dispatch.expiresAtMs, fence: dispatch.approvalFence, }, createdAtMs, }); }