cdba0f0179
POST /v1/dev/retention/sweep runs the sweep on demand; /metrics gains a global retention section (snapshot counts by status). smoke-retention.mjs (0-day windows) proves an aged interaction is redacted in place and surfaces as a REDACTED snapshot on /metrics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { BadRequestException, Body, Controller, ForbiddenException, Headers, Param, Post } from '@nestjs/common';
|
|
import jwt from 'jsonwebtoken';
|
|
import { IIOS_EVENTS } from '@insignia/iios-contracts';
|
|
import { hmacSign } from '@insignia/iios-adapter-sdk';
|
|
import { InboundService } from '../adapters/inbound.service';
|
|
import { AdapterRegistry } from '../adapters/adapter.registry';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
|
import { SessionVerifier } from '../platform/session.verifier';
|
|
import { RetentionService } from '../retention/retention.service';
|
|
|
|
/**
|
|
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
|
|
*/
|
|
@Controller('v1/dev')
|
|
export class DevController {
|
|
constructor(
|
|
private readonly inbound: InboundService,
|
|
private readonly registry: AdapterRegistry,
|
|
private readonly prisma: PrismaService,
|
|
private readonly actors: ActorResolver,
|
|
private readonly session: SessionVerifier,
|
|
private readonly retention: RetentionService,
|
|
) {}
|
|
|
|
@Post('token')
|
|
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
|
|
this.assertDev();
|
|
let secrets: Record<string, string>;
|
|
try {
|
|
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
|
} catch {
|
|
secrets = {};
|
|
}
|
|
const secret = secrets[body.appId];
|
|
if (!secret) throw new BadRequestException(`unknown app: ${body.appId}`);
|
|
const token = jwt.sign(
|
|
{ sub: body.userId, name: body.name ?? body.userId, appId: body.appId, orgId: body.orgId ?? `org_${body.appId}` },
|
|
secret,
|
|
{ algorithm: 'HS256', expiresIn: '2h' },
|
|
);
|
|
return { token };
|
|
}
|
|
|
|
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */
|
|
@Post('webhook/:channelType')
|
|
async injectWebhook(@Param('channelType') channelType: string, @Body() body?: { from?: string; text?: string }) {
|
|
this.assertDev();
|
|
const reg = this.registry.get(channelType);
|
|
if (!reg) throw new BadRequestException(`unknown channel type: ${channelType}`);
|
|
const payload = {
|
|
eventId: `dev-${randomUUID()}`,
|
|
from: body?.from ?? 'sim@customer',
|
|
displayName: 'Sim Customer',
|
|
threadRef: `sim-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Simulated inbound',
|
|
text: body?.text ?? 'Hello from a simulated provider',
|
|
};
|
|
const raw = JSON.stringify(payload);
|
|
return this.inbound.receive(channelType, raw, {
|
|
'x-iios-signature': hmacSign(raw, reg.config.secret),
|
|
'content-type': 'application/json',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inject a poison chaos event into the outbox. The relay publishes it, the
|
|
* ChaosConsumer throws on first delivery → it is dead-lettered under the caller's
|
|
* scope, then `POST /v1/dlq/:id/replay` resolves it. Drives the DLQ smoke.
|
|
*/
|
|
@Post('chaos/poison')
|
|
async poison(@Headers('authorization') auth?: string): Promise<{ eventId: string; scopeId: string }> {
|
|
this.assertDev();
|
|
const principal = this.principal(auth);
|
|
const scope = await this.actors.resolveScope(principal); // find-or-create so GET /v1/dlq matches
|
|
const id = `evt_chaos_${randomUUID()}`;
|
|
const cloudEvent = {
|
|
specversion: '1.0',
|
|
id,
|
|
type: IIOS_EVENTS.devChaos,
|
|
source: `iios/dev/${principal.appId}`,
|
|
subject: `chaos/${id}`,
|
|
time: new Date().toISOString(),
|
|
datacontenttype: 'application/json',
|
|
insignia: { scopeSnapshotId: scope.id, idempotencyKey: id, dataClass: 'internal' },
|
|
data: { poison: true },
|
|
};
|
|
await this.prisma.iiosOutboxEvent.create({
|
|
data: {
|
|
aggregateType: 'dev-chaos',
|
|
aggregateId: id,
|
|
eventType: IIOS_EVENTS.devChaos,
|
|
cloudEvent: cloudEvent as unknown as object,
|
|
partitionKey: `${principal.orgId}:${principal.appId}:chaos`,
|
|
},
|
|
});
|
|
return { eventId: id, scopeId: scope.id };
|
|
}
|
|
|
|
/** Run the retention sweep on demand (drives the retention smoke). */
|
|
@Post('retention/sweep')
|
|
async retentionSweep() {
|
|
this.assertDev();
|
|
return this.retention.sweep();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private assertDev(): void {
|
|
if (process.env.IIOS_DEV_TOKENS !== '1') throw new ForbiddenException('dev endpoints disabled');
|
|
}
|
|
}
|