diff --git a/apphosting.yaml b/apphosting.yaml index d0cab77..27c13e1 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -104,6 +104,17 @@ env: availability: - RUNTIME + # Shared ingest secret for the cross-tenant Foundry portfolio rollup (Atlas + # Situational Awareness, Phase 51 PRT). Atlas tenants POST their PII-free + # rollup to /api/platform/ingest/portfolio-rollup with this as x-ingest-secret. + # ONE shared value across every Atlas tenant project AND here. Provision FIRST + # (a missing secret fails the App Hosting rollout). Set with: + # gcloud secrets create PORTFOLIO_ROLLUP_INGEST_SECRET --project triarch-dev-website --data-file=- < value + - variable: PORTFOLIO_ROLLUP_INGEST_SECRET + secret: PORTFOLIO_ROLLUP_INGEST_SECRET + availability: + - RUNTIME + # CL-6 server-side adoption enforcement mode (Phase 27). # Values: 'off' | 'warn' | 'enforce' # off - gate check skipped entirely (rollback escape hatch) diff --git a/package-lock.json b/package-lock.json index bdc45ae..c27bbc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "triarch-dev", - "version": "2.27.0", + "version": "2.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "triarch-dev", - "version": "2.27.0", + "version": "2.28.0", "dependencies": { "@anthropic-ai/sdk": "^0.98.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index b82da94..d7132b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "triarch-dev", - "version": "2.27.0", + "version": "2.28.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/platform/ingest/portfolio-rollup/route.ts b/src/app/api/platform/ingest/portfolio-rollup/route.ts new file mode 100644 index 0000000..dcb882e --- /dev/null +++ b/src/app/api/platform/ingest/portfolio-rollup/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { timingSafeEqual } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { tenantPortfolioRollup } from '@/db/schema'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// POST /api/platform/ingest/portfolio-rollup +// +// Central RECEIVER for the cross-tenant Foundry Situational-Awareness portfolio +// rollup. Each Atlas tenant pushes its OWN PII-free rollup here daily; this route +// validates, then REPLACES the rows for that tenant. The per-tenant push cron lives +// in the atlas repo (/api/internal/portfolio-rollup-report, Phase 51 PRT). +// +// AUTH — mirrors /ingest/llm-usage: the frozen wire contract mandates a single shared +// header `x-ingest-secret`, validated against PORTFOLIO_ROLLUP_INGEST_SECRET with a +// CONSTANT-TIME comparison; FAILS CLOSED (503 if unconfigured, 401 on mismatch). The +// secret and full payloads are NEVER logged. +// +// PII: the payload is PII-free by construction (per-category counts + max severity + +// an at-risk-$ SUM). This route stores ONLY those aggregates — never an entity or value. + +const CATEGORIES = ['health', 'ingest', 'intelGaps', 'focus'] as const; +type Category = (typeof CATEGORIES)[number]; +const SEVERITIES = ['critical', 'warn', 'info', 'unknown'] as const; + +interface NormalizedCategory { + category: Category; + count: number; + maxSeverity: string | null; + severityCounts: { critical: number; warn: number; info: number; unknown: number }; + totalAtRiskMicros: number | null; +} + +interface NormalizedBody { + tenantSlug: string; + generatedAt: Date; + categories: NormalizedCategory[]; +} + +function asInt(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return 0; + return Math.trunc(n); +} + +function normalizeSeverityCounts(raw: unknown): NormalizedCategory['severityCounts'] { + const r = (raw && typeof raw === 'object' ? raw : {}) as Record; + return { critical: asInt(r.critical), warn: asInt(r.warn), info: asInt(r.info), unknown: asInt(r.unknown) }; +} + +/** + * Validate + normalize the wire body. Returns { ok: false, error } on any structural + * problem (caller -> 400), or { ok: true, body }. Unknown categories are skipped. + */ +export function normalizeRollupBody( + raw: unknown, +): { ok: true; body: NormalizedBody } | { ok: false; error: string } { + if (!raw || typeof raw !== 'object') return { ok: false, error: 'body must be a JSON object' }; + const b = raw as Record; + + const tenantSlug = (typeof b.tenantSlug === 'string' ? b.tenantSlug : '').trim(); + if (!tenantSlug) return { ok: false, error: 'tenantSlug is required' }; + + if (typeof b.generatedAt !== 'string') return { ok: false, error: 'generatedAt is required (ISO string)' }; + const generatedAt = new Date(b.generatedAt); + if (Number.isNaN(generatedAt.getTime())) return { ok: false, error: 'generatedAt is not a valid date' }; + + const rollup = (b.rollup && typeof b.rollup === 'object' ? b.rollup : null) as Record | null; + if (!rollup || !Array.isArray(rollup.categories)) { + return { ok: false, error: 'rollup.categories must be an array' }; + } + + const categories: NormalizedCategory[] = []; + for (const c of rollup.categories) { + if (!c || typeof c !== 'object') continue; + const cat = c as Record; + const category = cat.category; + if (typeof category !== 'string' || !(CATEGORIES as readonly string[]).includes(category)) continue; // skip unknown + const maxSeverityRaw = cat.maxSeverity; + const maxSeverity = + typeof maxSeverityRaw === 'string' && (SEVERITIES as readonly string[]).includes(maxSeverityRaw) + ? maxSeverityRaw + : null; + const usd = cat.totalAtRiskUsd; + const totalAtRiskMicros = usd == null || !Number.isFinite(Number(usd)) ? null : Math.round(Number(usd) * 1e6); + categories.push({ + category: category as Category, + count: asInt(cat.count), + maxSeverity, + severityCounts: normalizeSeverityCounts(cat.severityCounts), + totalAtRiskMicros, + }); + } + + return { ok: true, body: { tenantSlug, generatedAt, categories } }; +} + +function secretMatches(provided: string | null, expected: string): boolean { + if (!provided) return false; + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export async function POST(req: NextRequest) { + const expected = process.env.PORTFOLIO_ROLLUP_INGEST_SECRET; + if (!expected) { + return NextResponse.json({ error: 'ingest not configured' }, { status: 503 }); + } + + const provided = req.headers.get('x-ingest-secret'); + if (!secretMatches(provided, expected)) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + + const parsed = normalizeRollupBody(raw); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + const { tenantSlug, generatedAt, categories } = parsed.body; + + const now = new Date(); + let upserted = 0; + + // Replace the tenant's rows atomically: a re-push may carry fewer categories, so + // DELETE the tenant's rows then INSERT the fresh set inside one transaction. + try { + await db.transaction(async (tx) => { + await tx.delete(tenantPortfolioRollup).where(eq(tenantPortfolioRollup.tenantSlug, tenantSlug)); + for (const c of categories) { + await tx + .insert(tenantPortfolioRollup) + .values({ + tenantSlug, + category: c.category, + count: c.count, + maxSeverity: c.maxSeverity, + severityCounts: c.severityCounts, + totalAtRiskMicros: c.totalAtRiskMicros, + generatedAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [tenantPortfolioRollup.tenantSlug, tenantPortfolioRollup.category], + set: { + count: c.count, + maxSeverity: c.maxSeverity, + severityCounts: c.severityCounts, + totalAtRiskMicros: c.totalAtRiskMicros, + generatedAt, + updatedAt: now, + }, + }); + upserted += 1; + } + }); + } catch (err) { + console.error('[ingest/portfolio-rollup] upsert failed', { + tenantSlug, + message: err instanceof Error ? err.message : String(err), + }); + return NextResponse.json({ error: 'ingest failed' }, { status: 500 }); + } + + return NextResponse.json({ ok: true, upserted }, { status: 200 }); +} diff --git a/src/db/migrations/0025_tenant_portfolio_rollup.sql b/src/db/migrations/0025_tenant_portfolio_rollup.sql new file mode 100644 index 0000000..e7cf93f --- /dev/null +++ b/src/db/migrations/0025_tenant_portfolio_rollup.sql @@ -0,0 +1,39 @@ +-- Cross-tenant Foundry Situational-Awareness portfolio rollup — receiver table. +-- +-- Each Atlas tenant pushes its OWN PII-free portfolio rollup here daily; the platform +-- is the RECEIVER and aggregates across tenants. The per-tenant push cron lives in the +-- atlas repo (/api/internal/portfolio-rollup-report). Frozen wire contract: +-- +-- POST /api/platform/ingest/portfolio-rollup (header x-ingest-secret) +-- { tenantSlug, generatedAt, +-- rollup: { categories: [ { category, count, maxSeverity, +-- severityCounts: { critical, warn, info, unknown }, totalAtRiskUsd } ] } } +-- +-- PII-FREE by construction: per-category counts + max severity + an at-risk-$ SUM only — +-- never an entity id, contact, or per-record value. total_at_risk_micros stores the $ SUM +-- as integer micros (mirrors tenant_llm_usage.cost_micros); null when no signal carried a value. +-- +-- Idempotency: a re-push for a tenant fully REPLACES that tenant's rows — the ingest route +-- DELETEs the tenant's rows then INSERTs the fresh per-category set inside one transaction; +-- the unique index below also backstops duplicate (tenant_slug, category) via onConflictDoUpdate. +-- +-- CRDB constraint: CREATE TABLE and CREATE INDEX are kept as separate statements. + +CREATE TABLE IF NOT EXISTS tenant_portfolio_rollup ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + tenant_slug TEXT NOT NULL, + category TEXT NOT NULL, + count INT8 NOT NULL DEFAULT 0, + max_severity TEXT, + severity_counts JSONB NOT NULL, + total_at_risk_micros INT8, + generated_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT tenant_portfolio_rollup_category_check + CHECK (category IN ('health', 'ingest', 'intelGaps', 'focus')), + CONSTRAINT tenant_portfolio_rollup_max_severity_check + CHECK (max_severity IS NULL OR max_severity IN ('critical', 'warn', 'info', 'unknown')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS tenant_portfolio_rollup_unique + ON tenant_portfolio_rollup (tenant_slug, category); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 6aafeb8..5a3f356 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1781999999000, "tag": "0024_tenant_llm_usage", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1782000100000, + "tag": "0025_tenant_portfolio_rollup", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index a568df9..6b111a0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -206,3 +206,31 @@ export const tenantLlmUsage = pgTable('tenant_llm_usage', { export type TenantLlmUsage = typeof tenantLlmUsage.$inferSelect; export type NewTenantLlmUsage = typeof tenantLlmUsage.$inferInsert; + +// Cross-tenant Foundry Situational-Awareness portfolio rollup — receiver table. +// Each Atlas tenant pushes its OWN PII-free portfolio rollup here daily (atlas +// repo: /api/internal/portfolio-rollup-report cron). The platform is the RECEIVER +// and aggregates across tenants. Frozen wire contract (header x-ingest-secret): +// POST /api/platform/ingest/portfolio-rollup +// { tenantSlug, generatedAt, rollup: { categories: [ { category, count, +// maxSeverity, severityCounts: {critical,warn,info,unknown}, totalAtRiskUsd } ] } } +// The payload is PII-FREE by construction (per-category counts + max severity + +// an at-risk-$ SUM — never an entity, contact, or per-record value). Idempotent: +// a re-push REPLACES that tenant's rows. total_at_risk_micros stores the $ SUM as +// integer micros (mirrors tenant_llm_usage cost_micros) — null when absent. +export const tenantPortfolioRollup = pgTable('tenant_portfolio_rollup', { + id: uuid('id').defaultRandom().primaryKey(), + tenantSlug: text('tenant_slug').notNull(), + category: text('category').notNull(), // 'health' | 'ingest' | 'intelGaps' | 'focus' — CHECK in migration + count: bigint('count', { mode: 'number' }).notNull().default(0), + maxSeverity: text('max_severity'), // 'critical' | 'warn' | 'info' | 'unknown' | null (count===0) + severityCounts: jsonb('severity_counts').notNull(), // { critical, warn, info, unknown } + totalAtRiskMicros: bigint('total_at_risk_micros', { mode: 'number' }), // $ SUM × 1e6; null when absent + generatedAt: timestamp('generated_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + unique('tenant_portfolio_rollup_unique').on(table.tenantSlug, table.category), +]); + +export type TenantPortfolioRollup = typeof tenantPortfolioRollup.$inferSelect; +export type NewTenantPortfolioRollup = typeof tenantPortfolioRollup.$inferInsert; diff --git a/src/lib/version.ts b/src/lib/version.ts index a23609a..02b224e 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1 +1 @@ -export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? 'v2.27.0'; +export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? 'v2.28.0';