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; } 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; } 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; const parsed: unknown = JSON.parse(encoded); if ( !this.isApproval(parsed) || parsed.actorId !== actorId || parsed.actionDigest !== actionDigest || Date.parse(parsed.expiresAt) <= Date.now() ) 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 isApproval(value: unknown): value is CommandApproval { return ( typeof value === 'object' && value !== null && 'approvalId' in value && 'actionDigest' in value && 'actorId' in value && 'expiresAt' in value ); } private key(approvalId: string): string { return `tess:command-approval:${approvalId}`; } }