feat(service): P5.4 outbound sandbox sink + rate limit + inspector read endpoints

OutboundService: idempotent commands, per-(channelType,target) rate-limit bucket,
sandbox send (no network) + delivery attempts. POST /v1/adapters/:type/send +
GET inbound/outbound (session-auth). 55 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:46:56 +05:30
parent 29b64c3c4e
commit 4f661f4c81
5 changed files with 152 additions and 4 deletions
@@ -0,0 +1,73 @@
import { randomUUID } from 'node:crypto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AdapterRegistry } from './adapter.registry';
/**
* Outbound to a provider — in P5 the adapter's `send` is a SANDBOX sink (no real
* network). Per-(channelType,target) rate limiting; idempotent per command key;
* every attempt recorded. Real delivery is gated to P6+.
*/
@Injectable()
export class OutboundService {
/** Public so tests can lower them; default from env. */
limit = Number(process.env.IIOS_OUTBOUND_LIMIT ?? 5);
windowMs = Number(process.env.IIOS_OUTBOUND_WINDOW_MS ?? 60_000);
constructor(
private readonly prisma: PrismaService,
private readonly registry: AdapterRegistry,
) {}
async send(
channelType: string,
target: string,
payload: Record<string, unknown>,
idempotencyKey: string = randomUUID(),
) {
const reg = this.registry.get(channelType);
if (!reg) throw new NotFoundException(`unknown channel type: ${channelType}`);
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
if (existing) return existing;
if (!(await this.allow(channelType, target))) {
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
});
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd;
}
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
});
const result = await reg.adapter.send({ channelType, target, payload }); // sandbox — no network
const [updated] = await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'SENT', providerRef: result.providerRef } }),
this.prisma.iiosDeliveryAttempt.create({
data: { commandId: cmd.id, attemptNo: 1, status: 'SENT', providerRef: result.providerRef, latencyMs: result.latencyMs },
}),
]);
return updated;
}
/** Token/window bucket keyed by (channelType, target). Resets when the window elapses. */
private async allow(channelType: string, target: string): Promise<boolean> {
const now = new Date();
const where = { channelType_bucketKey: { channelType, bucketKey: target } };
const bucket = await this.prisma.iiosRateLimitBucket.findUnique({ where });
if (!bucket || bucket.resetAt <= now) {
await this.prisma.iiosRateLimitBucket.upsert({
where,
create: { channelType, bucketKey: target, windowStart: now, count: 1, resetAt: new Date(now.getTime() + this.windowMs) },
update: { windowStart: now, count: 1, resetAt: new Date(now.getTime() + this.windowMs) },
});
return true;
}
if (bucket.count >= this.limit) return false;
await this.prisma.iiosRateLimitBucket.update({ where, data: { count: { increment: 1 } } });
return true;
}
}