From cdba0f017956d9ce05c8a28f9562fdfdd3259c8c Mon Sep 17 00:00:00 2001 From: maaz519 Date: Fri, 3 Jul 2026 12:57:51 +0530 Subject: [PATCH] feat(iios): retention sweep dev endpoint + /metrics + smoke (P9) 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) --- .../iios-service/scripts/smoke-retention.mjs | 51 +++++++++++++++++++ .../iios-service/src/dev/dev.controller.ts | 9 ++++ .../src/observability/metrics.controller.ts | 3 ++ 3 files changed, 63 insertions(+) create mode 100644 packages/iios-service/scripts/smoke-retention.mjs diff --git a/packages/iios-service/scripts/smoke-retention.mjs b/packages/iios-service/scripts/smoke-retention.mjs new file mode 100644 index 0000000..e4175d0 --- /dev/null +++ b/packages/iios-service/scripts/smoke-retention.mjs @@ -0,0 +1,51 @@ +// P9 slice-9 retention smoke: an aged interaction's snapshot passes its delete window +// and the sweep redacts the content in place (never a hard delete), visible as a +// REDACTED snapshot on /metrics. Requires the service running with IIOS_DEV_TOKENS=1 +// and IIOS_RETENTION_DELETE_DAYS=0 IIOS_RETENTION_ARCHIVE_DAYS=0 (new content due at once; +// scheduled interval left OFF so only the explicit dev sweep runs). +import 'dotenv/config'; + +const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200'; +const APP_ID = 'portal-demo'; +const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function devToken(userId, orgId) { + const r = await fetch(`${SERVICE}/v1/dev/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }), + }); + if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`); + return (await r.json()).token; +} +async function api(token, path, method = 'GET', body) { + const r = await fetch(`${SERVICE}${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`); + return r.json(); +} +const redactedCount = (m) => m.retention?.byStatus?.REDACTED ?? 0; + +const token = await devToken('ret-a', 'org_ret_A'); + +const base = redactedCount(await api(token, '/metrics')); +console.log(`baseline REDACTED snapshots = ${base}`); + +// Inject content and give the relay a moment to normalize it into an interaction. +await api(token, '/v1/dev/webhook/WEBHOOK', 'POST', { text: 'retention smoke content' }); +await sleep(2000); + +// Sweep: 0-day windows make every interaction's snapshot immediately due → redacted. +const swept = await api(token, '/v1/dev/retention/sweep', 'POST'); +assert(swept.redacted >= 1, `sweep redacted the aged interaction(s) (redacted=${swept.redacted})`); + +const after = await api(token, '/metrics'); +assert(redactedCount(after) > base, `/metrics shows more REDACTED snapshots (${base} → ${redactedCount(after)})`); +assert((after.retention?.total ?? 0) >= 1, 'retention.total reflects captured snapshots'); + +console.log('\nP9 retention smoke: PASS'); +process.exit(0); diff --git a/packages/iios-service/src/dev/dev.controller.ts b/packages/iios-service/src/dev/dev.controller.ts index a1ebd25..db58ec5 100644 --- a/packages/iios-service/src/dev/dev.controller.ts +++ b/packages/iios-service/src/dev/dev.controller.ts @@ -8,6 +8,7 @@ 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. @@ -20,6 +21,7 @@ export class DevController { private readonly prisma: PrismaService, private readonly actors: ActorResolver, private readonly session: SessionVerifier, + private readonly retention: RetentionService, ) {} @Post('token') @@ -96,6 +98,13 @@ export class DevController { 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'); diff --git a/packages/iios-service/src/observability/metrics.controller.ts b/packages/iios-service/src/observability/metrics.controller.ts index b657904..d72ac57 100644 --- a/packages/iios-service/src/observability/metrics.controller.ts +++ b/packages/iios-service/src/observability/metrics.controller.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { SessionVerifier } from '../platform/session.verifier'; import { ProjectionCursorService } from '../projection/projection-cursor.service'; +import { RetentionService } from '../retention/retention.service'; /** * Tenant-scoped metrics (P9 observability, JSON). Returns the CALLER's tenant @@ -17,6 +18,7 @@ export class MetricsController { private readonly actors: ActorResolver, private readonly session: SessionVerifier, private readonly cursors: ProjectionCursorService, + private readonly retention: RetentionService, ) {} @Get() @@ -59,6 +61,7 @@ export class MetricsController { tenant, relay: { outboxPending, oldestPendingMs }, projections, + retention: await this.retention.summary(), }; }