Files
iios/packages/iios-service/src/support/support.controller.ts
T
maaz519 f2d590b04d feat(iios): wire the three-proof gate into the request path (§1b)
Applies the context-attestation verifier at the request boundary via a guard, so a
stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe
to roll out.

- ContextAttestationGuard: if an X-Context-Attestation header is present, verify it
  (its app_id must match the actor token's app_id) → 403 on any failure; if absent,
  allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is
  read per-request and defaults OFF, so existing callers keep working until AppShell/
  be-crm start forwarding attestations.
- AttestationModule (@Global): provides the verifier + Prisma stores + guard, and
  dev-seeds the crm-support-widget client so locally minted attestations verify.
- Guard applied to ThreadsController + SupportController.
- Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed.

Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote
be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:49:40 +05:30

103 lines
3.8 KiB
TypeScript

import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { IiosTicketState } from '@prisma/client';
import { SupportService } from './support.service';
import { AssignmentService } from './assignment.service';
import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import type { MessagePrincipal } from '../identity/actor.resolver';
import {
AssignTicketDto,
AvailabilityDto,
CallbackDto,
CreateQueueDto,
CreateTicketDto,
EscalateDto,
PatchTicketDto,
} from './support.dto';
@Controller('v1/support')
@UseGuards(ContextAttestationGuard)
export class SupportController {
constructor(
private readonly support: SupportService,
private readonly assignment: AssignmentService,
private readonly session: SessionVerifier,
) {}
@Post('tickets')
async createTicket(
@Body() body: CreateTicketDto,
@Headers('idempotency-key') idempotencyKey?: string,
@Headers('authorization') auth?: string,
) {
return this.support.createTicket(this.principal(auth), body, idempotencyKey);
}
@Post('escalate')
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
}
@Get('tickets')
async listTickets(@Query('scope') scope?: string, @Headers('authorization') auth?: string) {
return this.support.listTickets(this.principal(auth), { scope: scope === 'assigned' ? 'assigned' : 'mine' });
}
@Patch('tickets/:id')
async patchTicket(@Param('id') id: string, @Body() body: PatchTicketDto, @Headers('authorization') auth?: string) {
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason);
}
/** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */
@Post('tickets/:id/assignee')
async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) {
return this.support.assignTo(id, this.principal(auth), body.userId);
}
@Post('callbacks')
async callback(
@Body() body: CallbackDto,
@Headers('idempotency-key') idempotencyKey?: string,
@Headers('authorization') auth?: string,
) {
return this.support.requestCallback(
this.principal(auth),
{
preferChannel: body.preferChannel,
preferredTime: body.preferredTime ? new Date(body.preferredTime) : undefined,
notes: body.notes,
ticketId: body.ticketId,
},
idempotencyKey,
);
}
// ─── admin / agent ────────────────────────────────────────────────
@Post('queues')
async createQueue(@Body() body: CreateQueueDto, @Headers('authorization') auth?: string) {
return this.assignment.createQueue(this.principal(auth), body.name);
}
@Post('queues/:id/members')
async joinQueue(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.assignment.addMember(id, this.principal(auth));
}
@Post('agents/me/join')
async joinDefault(@Headers('authorization') auth?: string) {
return this.assignment.joinDefault(this.principal(auth));
}
@Patch('members/me/availability')
async availability(@Body() body: AvailabilityDto, @Headers('authorization') auth?: string) {
return this.assignment.setAvailability(this.principal(auth), body.state);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}