import { createHash, randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { eq, users as usersTable, type Db } from '@mosaicstack/db'; import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; import { DB } from '../database/database.module.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; export type CommandRole = 'admin' | 'member' | 'viewer'; export interface CommandApproval { approvalId: string; actionDigest: string; actorId: string; command: string; expiresAt: string; } /** Exact immutable binding for a privileged runtime termination. */ export interface RuntimeTerminationApprovalAction { providerId: string; sessionId: string; actorId: string; tenantId: string; channelId: string; correlationId: string; /** Provisioned roster identity; isolates approvals between interaction agents. */ agentName: string; } export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction { approvalId: string; actionDigest: string; expiresAt: string; } export interface CommandAuthorizationResult { allowed: boolean; reason?: string; } @Injectable() export class CommandAuthorizationService { constructor( @Inject(DB) private readonly db: Db, @Inject(COMMANDS_REDIS) private readonly redis: { get(key: string): Promise; set(key: string, value: string, ...args: string[]): Promise; del(key: string): Promise; }, ) {} async authorize( command: CommandDef, payload: SlashCommandPayload, actorId: string, approvalId?: string, ): Promise { const role = await this.resolveRole(actorId); if (!role || !this.hasScope(role, command.scope)) { return { allowed: false, reason: 'not authorized for this command scope' }; } if (command.scope !== 'admin') return { allowed: true }; if (!approvalId) return { allowed: false, reason: 'durable approval is required' }; const actionDigest = this.actionDigest(command.name, payload); const approved = await this.consumeApproval(approvalId, actorId, actionDigest); return approved ? { allowed: true } : { allowed: false, reason: 'approval is invalid, expired, replayed, or does not match this action', }; } async createApproval( command: CommandDef, payload: SlashCommandPayload, actorId: string, ): Promise { const role = await this.resolveRole(actorId); if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null; const approvalId = randomUUID(); const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); const approval: CommandApproval = { approvalId, actionDigest: this.actionDigest(command.name, payload), actorId, command: command.name, expiresAt, }; await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300'); return approval; } /** * Uses the same `interaction:command-approval:*` store and one-time deletion rule as * command approvals. This deliberately avoids a parallel approval database. */ async createRuntimeTerminationApproval( action: RuntimeTerminationApprovalAction, ): Promise { if (!this.hasRuntimeTerminationAction(action)) return null; const role = await this.resolveRole(action.actorId); if (role !== 'admin') return null; const approval: RuntimeTerminationApproval = { approvalId: randomUUID(), actionDigest: this.runtimeActionDigest(action), ...action, expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), }; await this.redis.set( this.runtimeKey(action.agentName, approval.approvalId), JSON.stringify(approval), 'EX', '300', ); return approval; } async consumeRuntimeTerminationApproval( approvalId: string, action: RuntimeTerminationApprovalAction, ): Promise { const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId)); if (!encoded) return false; let approval: unknown; try { approval = JSON.parse(encoded); } catch { return false; } if ( !this.isRuntimeTerminationApproval(approval) || approval.actionDigest !== this.runtimeActionDigest(action) || approval.actorId !== action.actorId || approval.tenantId !== action.tenantId || !this.isUnexpired(approval.expiresAt) ) { return false; } if ((await this.resolveRole(approval.actorId)) !== 'admin') return false; return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1; } private async resolveRole(actorId: string): Promise { const [user] = await this.db .select({ role: usersTable.role }) .from(usersTable) .where(eq(usersTable.id, actorId)) .limit(1); const role = user?.role; return role === 'admin' || role === 'member' || role === 'viewer' ? role : null; } private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean { if (role === 'admin') return true; return role === 'member' && (scope === 'core' || scope === 'agent'); } private async consumeApproval( approvalId: string, actorId: string, actionDigest: string, ): Promise { const key = this.key(approvalId); const encoded = await this.redis.get(key); if (!encoded) return false; let parsed: unknown; try { parsed = JSON.parse(encoded); } catch { return false; } if ( !this.isCommandApproval(parsed) || parsed.actorId !== actorId || parsed.actionDigest !== actionDigest || !this.isUnexpired(parsed.expiresAt) ) return false; return (await this.redis.del(key)) === 1; } private actionDigest(command: string, payload: SlashCommandPayload): string { return createHash('sha256') .update( JSON.stringify({ command, args: payload.args?.trim() ?? '', conversationId: payload.conversationId, }), ) .digest('hex'); } private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean { return [ action.providerId, action.sessionId, action.actorId, action.tenantId, action.channelId, action.correlationId, action.agentName, ].every((value: string): boolean => value.trim().length > 0); } private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string { return createHash('sha256') .update( JSON.stringify({ providerId: action.providerId, sessionId: action.sessionId, actorId: action.actorId, tenantId: action.tenantId, channelId: action.channelId, correlationId: action.correlationId, agentName: action.agentName, }), ) .digest('hex'); } private isUnexpired(expiresAt: unknown): expiresAt is string { if (typeof expiresAt !== 'string') return false; const expiresAtMs = Date.parse(expiresAt); return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now(); } private isCommandApproval(value: unknown): value is CommandApproval { return ( typeof value === 'object' && value !== null && 'approvalId' in value && 'actionDigest' in value && 'actorId' in value && 'expiresAt' in value && 'command' in value ); } private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval { return ( typeof value === 'object' && value !== null && 'approvalId' in value && 'actionDigest' in value && 'actorId' in value && 'tenantId' in value && 'providerId' in value && 'sessionId' in value && 'channelId' in value && 'correlationId' in value && 'agentName' in value && 'expiresAt' in value ); } private key(approvalId: string): string { return `interaction:command-approval:${approvalId}`; } private runtimeKey(agentName: string, approvalId: string): string { return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`; } }