f2d590b04d
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>
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
BadRequestException,
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Headers,
|
|
HttpCode,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { ThreadsService } from './threads.service';
|
|
import { MessageService } from '../messaging/message.service';
|
|
import { SessionVerifier } from '../platform/session.verifier';
|
|
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
|
import { SendMessageDto } from './send-message.dto';
|
|
|
|
@Controller('v1/threads')
|
|
@UseGuards(ContextAttestationGuard)
|
|
export class ThreadsController {
|
|
constructor(
|
|
private readonly threads: ThreadsService,
|
|
private readonly messages: MessageService,
|
|
private readonly session: SessionVerifier,
|
|
) {}
|
|
|
|
/**
|
|
* Generic "my threads" — every thread the caller participates in (last message + unread).
|
|
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
|
|
*/
|
|
@Get()
|
|
async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
|
|
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
|
|
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
|
|
}
|
|
|
|
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
|
|
@Get('my-annotations')
|
|
async myAnnotations(@Headers('authorization') auth?: string, @Query('type') type = 'save') {
|
|
return this.messages.listMyAnnotated(this.principal(auth), type);
|
|
}
|
|
|
|
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
|
@Post()
|
|
@HttpCode(201)
|
|
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
|
|
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
|
|
}
|
|
|
|
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
|
@Post(':id/participants')
|
|
@HttpCode(201)
|
|
async addParticipant(@Param('id') id: string, @Body() body: { userId: string; role?: string }, @Headers('authorization') auth?: string) {
|
|
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
|
}
|
|
|
|
@Get(':id/messages')
|
|
async listMessages(@Param('id') id: string) {
|
|
return this.threads.getMessages(id);
|
|
}
|
|
|
|
/** Mute / unmute notifications from this thread for the caller. */
|
|
@Post(':id/mute')
|
|
@HttpCode(200)
|
|
async mute(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
|
return this.messages.muteThread(id, this.principal(auth), true);
|
|
}
|
|
|
|
@Post(':id/unmute')
|
|
@HttpCode(200)
|
|
async unmute(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
|
return this.messages.muteThread(id, this.principal(auth), false);
|
|
}
|
|
|
|
/** REST/polling fallback for native send (same write as the socket path). */
|
|
@Post(':id/messages')
|
|
@HttpCode(201)
|
|
async send(
|
|
@Param('id') id: string,
|
|
@Body() body: SendMessageDto,
|
|
@Headers('authorization') authorization?: string,
|
|
@Headers('idempotency-key') idempotencyKey?: string,
|
|
) {
|
|
return this.messages.send(
|
|
id,
|
|
this.principal(authorization),
|
|
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
|
|
idempotencyKey ?? randomUUID(),
|
|
undefined,
|
|
body.parentInteractionId,
|
|
);
|
|
}
|
|
|
|
private principal(authorization?: string) {
|
|
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
|
return this.session.verify(token);
|
|
}
|
|
}
|