import { ForbiddenException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; /** The authenticated principal derived from the session port (token). */ export interface MessagePrincipal { userId: string; orgId: string; appId: string; tenantId?: string; displayName?: string; /** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped * token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */ audience?: string; } /** * Resolves a principal to kernel rows (scope → source handle → actor) and * manages thread participation. Shared by the message + inbox layers so neither * re-implements identity resolution. Unknown handles stay UNVERIFIED (the MDM * port resolution is applied at ingest; native users get a PORTAL_USER handle). */ @Injectable() export class ActorResolver { constructor(private readonly prisma: PrismaService) {} async resolveScope(principal: MessagePrincipal) { return ( (await this.findScope(principal)) ?? (await this.prisma.iiosScope.create({ data: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId, // Cell-partition hook (P9): assign the tenant scope to a cell. cellId: process.env.IIOS_CELL_ID ?? 'cell-default', }, })) ); } /** Resolve the caller's scope WITHOUT creating it — a caller with no scope owns nothing. */ async findScope(principal: MessagePrincipal) { return this.prisma.iiosScope.findFirst({ where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null }, }); } /** * Tenant fence (P9 / KG-02): throw unless the caller's scope owns the resource. * Every by-id operation calls this before reading or mutating. */ async assertOwns(principal: MessagePrincipal, resourceScopeId: string) { const scope = await this.findScope(principal); if (!scope || scope.id !== resourceScopeId) { throw new ForbiddenException('resource not in your tenant scope'); } return scope; } async resolveActor(scopeId: string, principal: MessagePrincipal) { const handle = await this.prisma.iiosSourceHandle.upsert({ where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } }, create: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId, displayName: principal.displayName }, update: { lastSeenAt: new Date(), displayName: principal.displayName }, }); return ( (await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } })) ?? (await this.prisma.iiosActorRef.create({ data: { kind: 'HUMAN', sourceHandleId: handle.id, displayName: principal.displayName }, })) ); } async ensureParticipant(threadId: string, actorId: string, participantRole = 'MEMBER'): Promise { await this.prisma.iiosThreadParticipant.upsert({ where: { threadId_actorId: { threadId, actorId } }, create: { threadId, actorId, participantRole }, update: {}, }); } }