/** * Federation grants service — CRUD + status transitions (FED-M2-06). * * Business logic only. CSR/cert work is handled by M2-07. * * Status lifecycle: * pending → active (activateGrant, called by M2-07 enrollment controller after cert signed) * active → revoked (revokeGrant) * active → expired (expireGrant, called by M6 scheduler) */ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { type Db, and, eq, federationGrants } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; import { parseFederationScope } from './scope-schema.js'; import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js'; export type Grant = typeof federationGrants.$inferSelect; @Injectable() export class GrantsService { constructor(@Inject(DB) private readonly db: Db) {} /** * Create a new grant in `pending` state. * Validates the scope against the federation scope JSON schema before inserting. */ async createGrant(dto: CreateGrantDto): Promise { // Throws FederationScopeError (a plain Error subclass) on invalid scope. parseFederationScope(dto.scope); const [grant] = await this.db .insert(federationGrants) .values({ peerId: dto.peerId, subjectUserId: dto.subjectUserId, scope: dto.scope, status: 'pending', expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null, }) .returning(); return grant!; } /** * Fetch a single grant by ID. Throws NotFoundException if not found. */ async getGrant(id: string): Promise { const [grant] = await this.db .select() .from(federationGrants) .where(eq(federationGrants.id, id)) .limit(1); if (!grant) { throw new NotFoundException(`Grant ${id} not found`); } return grant; } /** * List grants with optional filters for peerId, subjectUserId, and status. */ async listGrants(filters: ListGrantsDto): Promise { const conditions = []; if (filters.peerId != null) { conditions.push(eq(federationGrants.peerId, filters.peerId)); } if (filters.subjectUserId != null) { conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId)); } if (filters.status != null) { conditions.push(eq(federationGrants.status, filters.status)); } if (conditions.length === 0) { return this.db.select().from(federationGrants); } return this.db .select() .from(federationGrants) .where(and(...conditions)); } /** * Transition a grant from `pending` → `active`. * Called by M2-07 enrollment controller after cert is signed. * Throws ConflictException if the grant is not in `pending` state. */ async activateGrant(id: string): Promise { const grant = await this.getGrant(id); if (grant.status !== 'pending') { throw new ConflictException( `Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`, ); } const [updated] = await this.db .update(federationGrants) .set({ status: 'active' }) .where(eq(federationGrants.id, id)) .returning(); return updated!; } /** * Transition a grant from `active` → `revoked`. * Sets revokedAt and optionally revokedReason. * Throws ConflictException if the grant is not in `active` state. */ async revokeGrant(id: string, reason?: string): Promise { const grant = await this.getGrant(id); if (grant.status !== 'active') { throw new ConflictException( `Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`, ); } const [updated] = await this.db .update(federationGrants) .set({ status: 'revoked', revokedAt: new Date(), revokedReason: reason ?? null, }) .where(eq(federationGrants.id, id)) .returning(); return updated!; } /** * Transition a grant from `active` → `expired`. * Intended for use by the M6 scheduler. * Throws ConflictException if the grant is not in `active` state. */ async expireGrant(id: string): Promise { const grant = await this.getGrant(id); if (grant.status !== 'active') { throw new ConflictException( `Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`, ); } const [updated] = await this.db .update(federationGrants) .set({ status: 'expired' }) .where(eq(federationGrants.id, id)) .returning(); return updated!; } }