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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user