diff --git a/apps/web/prisma/migrations/20260821163000_index_the_manifest_lookup/migration.sql b/apps/web/prisma/migrations/20260821163000_index_the_manifest_lookup/migration.sql new file mode 100644 index 00000000..29198ae4 --- /dev/null +++ b/apps/web/prisma/migrations/20260821163000_index_the_manifest_lookup/migration.sql @@ -0,0 +1,19 @@ +-- Index the read that decides capability availability. +-- +-- `capability-registry/manifest.ts` answers "what does this tenant's manifest +-- declare?" with the newest `Tenant.Reconciled` audit row for the institution. +-- Until this slice that ran once per page render, from the app layout. It now +-- also runs on every route bound to a capability and on every fifteen-second +-- poll of /api/reports/pulse, so its cost sits on ordinary traffic. +-- +-- The existing ("institutionId", "occurredAt") index cannot serve it: the +-- planner walks that institution's audit history backwards, discarding every +-- row whose action is not Tenant.Reconciled. For a tenant with NO reconcile row +-- — which is every tenant today, the pilot included — that is a walk of the +-- whole history to return nothing, and an append-only log only grows. +-- +-- Putting "action" between the two makes it a single index seek. The cost is +-- one more index to maintain on an insert-only table, which is the right side +-- of the trade for a read that is now on the request path. +CREATE INDEX "AuditEvent_institutionId_action_occurredAt_idx" + ON "AuditEvent"("institutionId", "action", "occurredAt"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index d179fc1e..f91873c7 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -1050,6 +1050,14 @@ model AuditEvent { @@index([institutionId, occurredAt]) @@index([actorId, occurredAt]) @@index([resourceType, resourceId]) + // The capability resolver's input. `capability-registry/manifest.ts` reads the + // newest `Tenant.Reconciled` row for a tenant, and since the availability gate + // moved onto the routes and the polled endpoint that read runs on ordinary + // page traffic rather than once in a while. On [institutionId, occurredAt] + // alone, a tenant with NO reconcile row — which is every tenant today — costs + // a backwards walk of its entire audit history to return nothing, and that + // history only grows. + @@index([institutionId, action, occurredAt]) } // ─── Institution role transfers ─────────────────────────────────────────────── diff --git a/apps/web/src/app/(app)/admin/metering/page.tsx b/apps/web/src/app/(app)/admin/metering/page.tsx index e771a3a7..f7a4b7ff 100644 --- a/apps/web/src/app/(app)/admin/metering/page.tsx +++ b/apps/web/src/app/(app)/admin/metering/page.tsx @@ -6,9 +6,7 @@ import { hasCapability } from "@/lib/admin/capabilities" import { withTenantScope } from "@/lib/tenant-scope" import { currentTerm } from "@/lib/tenant/term" import { institutionTimeZone } from "@/lib/institution-time" -import { CAPABILITY_REGISTRY, PILOT_SCOPE } from "@/lib/capability-registry/registry" -import { declaredModules } from "@/lib/capability-registry/manifest" -import { resolveCapability } from "@/lib/capability-registry/resolve" +import { offeredTo } from "@/lib/capability-registry/gate" import { academicYearPeriod, meteredQuantityToDate, reconcileSeatMeter } from "@/lib/billing/seat-meter" import { Card, CardHeader } from "@/components/ui/Card" import { Badge } from "@/components/ui/Badge" @@ -26,11 +24,19 @@ export const dynamic = "force-dynamic" * * `billing.viewMeter` asks WHO MAY SEE IT and is Director-only: the quantity is * a term of a contract, and the seat that signs the contract is the Director's. - * `resolveCapability("payments.seat-metering")` asks WHETHER THIS DEPLOYMENT - * OFFERS IT AT ALL, which is a per-tenant question with no user in it. Audit - * §8.2 requires the second — availability must never be a consequence of the - * code existing — and `capability-registry/types.ts` is explicit that merging - * the two would be a security bug rather than a tidy-up. + * `offeredTo("payments.seat-metering")` asks WHETHER THIS DEPLOYMENT OFFERS IT + * AT ALL, which is a per-tenant question with no user in it. Audit §8.2 + * requires the second — availability must never be a consequence of the code + * existing — and `capability-registry/types.ts` is explicit that merging the + * two would be a security bug rather than a tidy-up. + * + * The second gate went through `resolveCapability` directly, assembling the + * scope, the registry and the tenant's declared modules at this call site. That + * predates `capability-registry/gate.ts`, which exists so that every acting + * surface asks the same question the same way — and a hand-assembled copy of + * the resolver call is how the four arguments drift apart, one surface at a + * time. `offeredTo` is that exact call; this now makes it rather than repeating + * it, and `enforcement.test.ts` holds the binding in `routes.ts` to it. * * Both refuse with `notFound()` rather than a message, matching every other * admin surface: a page that says "you may not see this" has told you it @@ -61,12 +67,7 @@ export default async function AdminMeteringPage() { const { userId, ctx, institutionId } = await requireAdminContext() if (!hasCapability(ctx, "billing.viewMeter", institutionId)) notFound() - const offered = resolveCapability( - "payments.seat-metering", - PILOT_SCOPE, - CAPABILITY_REGISTRY, - await declaredModules(institutionId), - ) + const offered = await offeredTo("payments.seat-metering", institutionId) if (!offered.available) notFound() return withTenantScope(userId, async () => { diff --git a/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx index af4babb9..b2832f2f 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx @@ -9,6 +9,8 @@ import { toSeatFacts, } from "@/lib/seats" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { canViewOrg, getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -73,6 +75,25 @@ export default async function HandoffPage({ const ctx = await getUserContext(session.user.id) if (!canViewOrg(ctx, org)) notFound() + // Bound to `collaboration.institutional-memory` in routes.ts — the handoff + // packet is the preserved knowledge for a seat, so it is the same capability + // the memory route carries, and a tenant that does not have one does not + // have the other. + const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) + if (!memory.available) + return ( +
+
+

{org.name}

+
+ {/* The refusal keeps the club's tab strip. Members, Finance, + Documents and Impact are unaffected by this capability, and a page + that withholds one thing must not also strand the person on it. */} + + +
+ ) + const now = new Date() const [deliverables, pendingApprovals, budgetLines] = await Promise.all([ db.deliverable.findMany({ diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts new file mode 100644 index 00000000..d4729bc9 --- /dev/null +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts @@ -0,0 +1,120 @@ +import { CapabilityUnavailableError } from "@/lib/capability-registry/gate" +import { createMemoryCard } from "./actions" + +/** + * The mutating surface refuses, and the refusal is reachable. + * + * ── Why this test exists at all ───────────────────────────────────────────── + * + * /orgs/[slug]/memory has been bound to `collaboration.institutional-memory` + * since the second slice, and until this one the binding was read by CI and by + * nothing at runtime. The side nav resolved the capability and dropped the + * link; the page still rendered its "Add to memory" form, and this action still + * wrote. A gate that only removes a link cannot refuse a form POST, and a form + * POST is what writes to the database. + * + * So the assertion that matters is not that an error is thrown — it is that + * `$transaction` is never reached. A refusal that fires after the write is not + * a refusal. + * + * ── The two questions, kept apart ─────────────────────────────────────────── + * + * `canContribute` is mocked TRUE throughout. Every refusal below is therefore + * the deployment's answer, not the person's: someone who unambiguously may + * write is still refused because this tenant is not offered the capability. + * That is the property the registry exists for — promotion is not procurement + * — and it would be invisible in a test where the role could also explain it. + */ + +const mockState: { modules: string[] | null; wrote: boolean } = { + modules: ["organizations@1.0.0"], + wrote: false, +} + +jest.mock("next/cache", () => ({ revalidatePath: () => undefined })) + +jest.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "user-1" } }) })) + +jest.mock("@/lib/tenant-scope", () => ({ + withTenantScope: (_userId: string, fn: () => Promise) => fn(), +})) + +jest.mock("@/lib/rbac", () => ({ + getUserContext: async () => ({ institutionRoles: [], orgRoles: [] }), + // The permission question, answered YES for every case below. + canContribute: () => true, +})) + +jest.mock("@/lib/db", () => ({ + db: { + organization: { + findUnique: async () => ({ id: "org-1", institutionId: "inst-1", slug: "chess" }), + }, + role: { findFirst: async () => null }, + auditEvent: { + findFirst: async () => + mockState.modules === null ? null : { metadata: { modules: mockState.modules } }, + create: () => ({}), + }, + memoryRecord: { create: () => ({}) }, + $transaction: async () => { + mockState.wrote = true + return [] + }, + }, +})) + +function card(): FormData { + const form = new FormData() + form.set("type", "LESSON") + form.set("title", "Book the hall before the caterer") + form.set("body", "The hall goes first every year and the caterer never does.") + return form +} + +beforeEach(() => { + mockState.modules = ["organizations@1.0.0"] + mockState.wrote = false +}) + +describe("createMemoryCard resolves availability before it writes", () => { + it("writes when the tenant's manifest declares the module", async () => { + // The control's control: without this, every assertion below could pass + // because the action never works. + await createMemoryCard("chess", card()) + expect(mockState.wrote).toBe(true) + }) + + it("refuses, and writes nothing, when the manifest does not declare it", async () => { + mockState.modules = ["reporting@1.0.0"] + + await expect(createMemoryCard("chess", card())).rejects.toBeInstanceOf( + CapabilityUnavailableError, + ) + expect(mockState.wrote).toBe(false) + }) + + it("names the module the tenant is missing, not just 'unavailable'", async () => { + mockState.modules = [] + + let refusal: CapabilityUnavailableError | null = null + try { + await createMemoryCard("chess", card()) + } catch (e) { + refusal = e as CapabilityUnavailableError + } + if (!refusal) throw new Error("the action wrote; the capability gate never fired") + + expect(refusal.decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") + expect(refusal.decision.refusals[0].detail).toContain("organizations") + }) + + it("still writes for a tenant nobody has reconciled", async () => { + // null is not []. A hand-built pilot has no manifest, and refusing it here + // would take a working product dark on a document that does not exist. + mockState.modules = null + + await createMemoryCard("chess", card()) + expect(mockState.wrote).toBe(true) + }) +}) diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts index 46469d74..702c41ec 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache" import { auth } from "@/lib/auth" +import { requireOffered } from "@/lib/capability-registry/gate" import { db } from "@/lib/db" import { canContribute, getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -19,6 +20,19 @@ export async function createMemoryCard(slug: string, formData: FormData) { const ctx = await getUserContext(userId) if (!canContribute(ctx, org)) throw new Error("You need an active role to add memory") + // A second, different question, asked as a separate expression on purpose. + // `canContribute` says this PERSON may write; this says this DEPLOYMENT + // offers institutional memory to this club's institution at all. Merging + // them would let a president's seat unlock a capability the cell was never + // certified to offer — promotion is not procurement. + // + // The page refuses first and renders the reason, so nobody meets this throw + // by clicking. What reaches it is an invocation with no page behind it: a + // tab open since before the manifest changed, a replayed POST, a form + // action called directly. That is exactly the case a nav-only gate could + // not refuse, and the reason a mutating surface resolves for itself. + await requireOffered("collaboration.institutional-memory", org.institutionId) + const roleIdRaw = String(formData.get("roleId") ?? "") const parsed = knowledgeCardSchema.safeParse({ title: String(formData.get("title") ?? "").trim(), diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx index 0d1e8333..afe1957e 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx @@ -10,6 +10,8 @@ import { Timer, } from "@/components/ui/icons" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { canContribute, canViewOrg, getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -55,6 +57,29 @@ export default async function MemoryPage({ const ctx = await getUserContext(session.user.id) if (!canViewOrg(ctx, org)) notFound() + + // What this person may do, and what this deployment offers, asked + // separately. `canViewOrg` above is the first; this is the second, and it + // is the one the side nav has been answering alone — a club officer who + // typed this URL reached the page and its "Add to memory" form whatever the + // tenant's manifest said. `createMemoryCard` refuses too, so the form + // cannot be replayed past this; the page refuses first so the person gets a + // reason instead of an error card. + const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) + if (!memory.available) + return ( +
+
+

{org.name}

+
+ {/* The refusal keeps the club's tab strip. Members, Finance, + Documents and Impact are unaffected by this capability, and a page + that withholds one thing must not also strand the person on it. */} + + +
+ ) + const canAdd = canContribute(ctx, org) const allCards = await db.memoryRecord.findMany({ diff --git a/apps/web/src/app/(app)/reports/finance/page.tsx b/apps/web/src/app/(app)/reports/finance/page.tsx index e1cccbe2..31736b2f 100644 --- a/apps/web/src/app/(app)/reports/finance/page.tsx +++ b/apps/web/src/app/(app)/reports/finance/page.tsx @@ -1,6 +1,8 @@ import { notFound, redirect } from "next/navigation" import Link from "next/link" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -30,6 +32,13 @@ export default async function PortfolioFinancePage() { const institutionId = ctx.institutionRoles[0]?.institutionId if (!institutionId) notFound() // OSE only + // Bound to the same capability as /reports, and gated the same way. A + // tenant without reporting must not reach the consolidation view through + // the link on /reports either — one binding, enforced at every route that + // carries it. + const reporting = await offeredTo("collaboration.reporting", institutionId) + if (!reporting.available) return + const orgs = await db.organization.findMany({ where: { institutionId, status: "ACTIVE" }, select: { diff --git a/apps/web/src/app/(app)/reports/page.test.tsx b/apps/web/src/app/(app)/reports/page.test.tsx index 9a3ad91b..28203944 100644 --- a/apps/web/src/app/(app)/reports/page.test.tsx +++ b/apps/web/src/app/(app)/reports/page.test.tsx @@ -43,6 +43,17 @@ jest.mock("@/lib/seats-data", () => ({ ], })) +/** + * What this tenant's DeploymentManifest declares, per test. + * + * The page resolves `collaboration.reporting` before it renders anything, and + * the resolver's input is the newest `Tenant.Reconciled` audit row — so the + * manifest is set by answering that read. `mock`-prefixed because `jest.mock` + * is hoisted above every other statement in the file and may only close over + * names that say so. + */ +const mockManifest: { modules: string[] | null } = { modules: ["reporting@1.0.0"] } + jest.mock("@/lib/db", () => { const decidedAt = new Date(Date.now() - 2 * 86_400_000) return { @@ -74,6 +85,10 @@ jest.mock("@/lib/db", () => { findMany: async () => [{ type: "LESSON", createdAt: new Date(Date.now() - 86_400_000) }], }, auditEvent: { + // The manifest read. `null` is a tenant nobody has reconciled, which is + // deliberately not the same answer as a manifest declaring nothing. + findFirst: async () => + mockManifest.modules === null ? null : { metadata: { modules: mockManifest.modules } }, // 10pm on July 31st in Rochester, which is already August at a server // running in UTC — the row exists to make the zone visible. findMany: async () => [ @@ -95,6 +110,10 @@ async function render(searchParams: { range?: string } = {}): Promise { return renderToStaticMarkup(await ReportsPage({ searchParams: Promise.resolve(searchParams) })) } +beforeEach(() => { + mockManifest.modules = ["reporting@1.0.0"] +}) + describe("reports page", () => { it("renders the median once, and the tile and the card subtitle are byte-identical", async () => { const html = await render() @@ -156,3 +175,50 @@ describe("range filter", () => { expect(html).toContain('href="/reports?range=all"') }) }) + +/** + * The route enforces its own binding. + * + * `capability-registry/routes.ts` has bound /reports to + * `collaboration.reporting` since the second slice, and for two slices the only + * thing that read the binding was the side nav: a tenant whose manifest dropped + * the reporting module lost the LINK and kept the page, in full, to anyone who + * typed the URL or had it bookmarked. This is that hole, held shut — and the + * assertion runs the whole page, so it fails if the gate is moved below the + * queries as easily as if it is deleted. + */ +describe("availability is enforced at the route, not only in the nav", () => { + it("renders the refusal instead of the metrics when the manifest omits reporting", async () => { + mockManifest.modules = ["organizations@1.0.0"] + + const html = await render() + + expect(html).toContain("is not available here") + expect(html).toContain("MODULE_NOT_DECLARED") + // The figures are the point: not a hidden link, not a greyed tile — the + // institution's numbers do not leave the server. + expect(html).not.toContain("Median time to decision") + expect(html).not.toContain("Refreshes every 15 seconds") + }) + + it("names the capability and its owner, so the refusal is actionable", async () => { + mockManifest.modules = [] + + const html = await render() + + expect(html).toContain("Institution-wide reporting and analytics") + expect(html).toContain("owned by platform") + }) + + it("still renders for a tenant nobody has reconciled", async () => { + // null is not []. Filtering against a manifest that does not exist would + // take the hand-built pilot dark, which is the failure the third slice's + // `unpublished` state exists to prevent. + mockManifest.modules = null + + const html = await render() + + expect(html).toContain("Median time to decision") + expect(html).not.toContain("is not available here") + }) +}) diff --git a/apps/web/src/app/(app)/reports/page.tsx b/apps/web/src/app/(app)/reports/page.tsx index aad1b8fa..55c335b8 100644 --- a/apps/web/src/app/(app)/reports/page.tsx +++ b/apps/web/src/app/(app)/reports/page.tsx @@ -4,6 +4,8 @@ import { capSeatNames, summariseSeats, summariseSeatsByName } from "@/lib/seats" import { institutionSeatsWhere, loadSeatFacts } from "@/lib/seats-data" import { buildReportsAnalytics, parseReportsRange, REPORTS_RANGES } from "@/lib/reports-analytics" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { getUserContext } from "@/lib/rbac" import { institutionTimeZone } from "@/lib/institution-time" @@ -41,6 +43,14 @@ export default async function ReportsPage({ const institutionId = ctx.institutionRoles[0]?.institutionId if (!institutionId) notFound() // OSE only + // The binding in `capability-registry/routes.ts` says this route depends on + // `collaboration.reporting`. Until this call it said so only to CI: the side + // nav resolved the capability and dropped the link, and the route itself + // rendered every figure to anyone who typed the URL. Enforcing it in the + // navigation alone is a cosmetic gate. + const reporting = await offeredTo("collaboration.reporting", institutionId) + if (!reporting.available) return + // Every calendar boundary on this page is the institution's, not the // server's. Production runs in UTC, so a term or a month decided by the // server's clock files an evening event under the next day — see the diff --git a/apps/web/src/app/api/reports/pulse/route.test.ts b/apps/web/src/app/api/reports/pulse/route.test.ts new file mode 100644 index 00000000..03a27937 --- /dev/null +++ b/apps/web/src/app/api/reports/pulse/route.test.ts @@ -0,0 +1,106 @@ +import { GET } from "./route" + +/** + * The polled endpoint answers the availability question for itself. + * + * ── What was reachable before ─────────────────────────────────────────────── + * + * This is the endpoint behind the strip on /reports, polled every fifteen + * seconds. Availability was enforced in the side nav, so a tenant whose + * manifest dropped the reporting module lost the nav LINK — and any tab already + * open kept polling this handler, which kept answering with the institution's + * live approval, event, seat and conflict counts. Nothing about hiding a link + * closes a socket. + * + * ── 501, and why the status is asserted ───────────────────────────────────── + * + * The handler already answers 403 to a signed-in person with no OSE role. That + * is the answer to WHO is asking. A withheld capability is a different claim — + * this deployment does not offer the functionality to this tenant at all — and + * if both answered 403 the one place a client can tell them apart would stop + * showing the difference. So the status is part of the contract, not an + * incidental. + */ + +const mockState: { modules: string[] | null; institutionRoles: { institutionId: string }[] } = { + modules: ["reporting@1.0.0"], + institutionRoles: [{ institutionId: "inst-1" }], +} + +jest.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "user-1" } }) })) + +jest.mock("@/lib/tenant-scope", () => ({ + withTenantScope: (_userId: string, fn: () => Promise) => fn(), +})) + +jest.mock("@/lib/rbac", () => ({ + getUserContext: async () => ({ institutionRoles: mockState.institutionRoles }), +})) + +jest.mock("@/lib/seats-data", () => ({ + institutionSeatsWhere: () => ({}), + loadSeatFacts: async () => [ + { + organizationId: "org-1", + name: "President", + assignments: [{ status: "ACTIVE", key: "pres@example.edu" }], + holdings: [], + }, + ], +})) + +jest.mock("@/lib/db", () => ({ + db: { + approvalRequest: { count: async () => 2 }, + event: { count: async () => 7 }, + conflictRecord: { count: async () => 1 }, + auditEvent: { + findFirst: async () => + mockState.modules === null ? null : { metadata: { modules: mockState.modules } }, + }, + }, +})) + +beforeEach(() => { + mockState.modules = ["reporting@1.0.0"] + mockState.institutionRoles = [{ institutionId: "inst-1" }] +}) + +describe("GET /api/reports/pulse", () => { + it("answers with the counts when the tenant is offered reporting", async () => { + const response = await GET() + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ publishedEvents: 7, hardConflicts: 1 }) + }) + + it("refuses with 501 when the manifest does not declare the reporting module", async () => { + mockState.modules = ["organizations@1.0.0"] + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(501) + expect(body.error).toBe("capability_unavailable") + expect(body.refusals[0].code).toBe("MODULE_NOT_DECLARED") + // No numbers leave the server on a refusal — the counts are the thing being + // withheld, so a refusal that still carried them would be decoration. + expect(body.publishedEvents).toBeUndefined() + }) + + it("keeps 403 for the person question, so the two refusals stay distinguishable", async () => { + mockState.institutionRoles = [] + + const response = await GET() + + expect(response.status).toBe(403) + }) + + it("answers for a tenant nobody has reconciled", async () => { + // null is not []. The pilot has no published manifest, and this endpoint + // must keep working for it. + mockState.modules = null + + expect((await GET()).status).toBe(200) + }) +}) diff --git a/apps/web/src/app/api/reports/pulse/route.ts b/apps/web/src/app/api/reports/pulse/route.ts index c0bfb1b4..baa390be 100644 --- a/apps/web/src/app/api/reports/pulse/route.ts +++ b/apps/web/src/app/api/reports/pulse/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server" +import { capabilityRefusal, offeredTo } from "@/lib/capability-registry/gate" import { auth } from "@/lib/auth" import { getUserContext } from "@/lib/rbac" import { loadInstitutionPulse } from "@/lib/reports-data" @@ -33,6 +34,18 @@ export async function GET() { return NextResponse.json({ error: "forbidden" }, { status: 403 }) } + // Two questions, and the 403 above answers only the first. Holding an OSE + // role says this PERSON may read institution metrics; it says nothing about + // whether this DEPLOYMENT offers reporting to their tenant at all. The nav + // entry has resolved that since the first slice — but an endpoint is not + // behind the nav, and a tenant whose manifest drops the reporting module + // kept getting live counts here from a polling tab nobody had reloaded. + // + // The gate runs BEFORE the loader, not after: a refusal that had already + // read the counts would have spent the query it exists to withhold. + const reporting = await offeredTo("collaboration.reporting", institutionId) + if (!reporting.available) return capabilityRefusal(reporting) + const pulse = await loadInstitutionPulse(institutionId) return NextResponse.json( diff --git a/apps/web/src/components/CapabilityUnavailable.tsx b/apps/web/src/components/CapabilityUnavailable.tsx new file mode 100644 index 00000000..589445d8 --- /dev/null +++ b/apps/web/src/components/CapabilityUnavailable.tsx @@ -0,0 +1,83 @@ +import { AlertCircle } from "@/components/ui/icons" +import { Card } from "@/components/ui/Card" +import type { Decision, RefusalCode } from "@/lib/capability-registry/types" + +/** + * What a route renders when this deployment does not offer it. + * + * ── Why a panel and not notFound() ────────────────────────────────────────── + * + * `UNAVAILABLE` is a value in this vocabulary, not an absence — the whole + * registry exists because "we decided not to offer it" and "somebody forgot" + * had been answering identically. A 404 would collapse them again at the last + * step: the person in front of the screen would get "no such page" for a + * deliberate, owned, recorded decision, and would go looking for a broken link. + * + * So the refusal says which thing is unavailable, why in one sentence, and who + * owns it. The last part is not decoration: `owner` is what turns "it's + * broken" into a support ticket that reaches the team that can answer it. + * + * The sentence comes from the refusal CODE rather than the resolver's `detail`. + * Detail is written for whoever is fixing the deployment — it names manifest + * modules and scope axes — and putting that in front of a club treasurer would + * be a different failure of the same kind. The code is on screen too, in small + * text, so a screenshot in a ticket is still actionable. + */ +export function CapabilityUnavailable({ decision }: { decision: Decision }) { + const label = decision.name ?? decision.capability + + return ( +
+ + {/* Outline-only, matching ComingSoon: a hairline ring, never a tinted + plate — this is an ordinary answer, not an alarm. */} +
+ +
+

{label} is not available here

+

{explain(decision)}

+

+ {decision.refusals[0]?.code ?? "UNAVAILABLE"} + {decision.owner ? ` · owned by ${decision.owner}` : ""} +

+
+
+ ) +} + +/** + * One sentence per refusal code, written for the person reading the screen. + * + * The switch is exhaustive by construction: `never` below stops compiling the + * day a sixth `RefusalCode` is added, so a new way to be refused cannot ship + * with a blank panel or a fallback that says nothing. + */ +function explain(decision: Decision): string { + const code: RefusalCode | undefined = decision.refusals[0]?.code + switch (code) { + case "MODULE_NOT_DECLARED": + return "This institution's Tenure deployment does not include it. Nothing is wrong with your account, and nothing here has been lost." + case "STATUS_NOT_LIVE": + return decision.runtimeStatus === "PLANNED" || decision.runtimeStatus === "ARCHITECTED" + ? "It is planned but not built yet, so it is not offered to anyone." + : "It is not at a stage where it can be offered yet." + case "SCOPE_NOT_DECLARED": + return "It is not offered to this institution." + case "CONDITION_NOT_MET": + return "It is built, but it has not cleared every check required before it can be offered." + case "NOT_REGISTERED": + // Reachable only from a surface gating on an id nobody registered, which + // `routes.test.ts` and `surfaces.test.ts` both refuse. If it renders, the + // binding is the bug — so the sentence sends someone to look at it rather + // than telling a user something false about their institution. + return "This part of Tenure is not registered as something this deployment offers. Please report it." + case undefined: + // An unavailable decision always carries at least one refusal; the + // resolver has no path that returns none. Stated rather than assumed. + return "It is not available here." + default: { + const unhandled: never = code + return unhandled + } + } +} diff --git a/apps/web/src/lib/capability-registry/enforcement.test.ts b/apps/web/src/lib/capability-registry/enforcement.test.ts new file mode 100644 index 00000000..53a56dea --- /dev/null +++ b/apps/web/src/lib/capability-registry/enforcement.test.ts @@ -0,0 +1,150 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" +import { API_CAPABILITY } from "./surfaces" +import { ROUTE_CAPABILITY } from "./routes" + +/** + * A binding must be ENFORCED by the surface, not merely declared about it. + * + * ── The defect this closes ────────────────────────────────────────────────── + * + * `routes.ts` has bound four routes to capabilities since the second slice, and + * for two slices nothing read those bindings at runtime. CI checked them, the + * side nav resolved the capability and dropped the link, and the routes + * themselves rendered every figure to anyone who typed the URL — their server + * action still wrote and their polled endpoint still answered. Availability was + * enforced in the NAVIGATION and nowhere else, which is a cosmetic gate: a + * kept-open tab, a bookmark or a replayed POST walks straight past it. + * + * A binding nothing enforces is worse than no binding, because it reads as + * done. So this asserts the join: every route in `ROUTE_CAPABILITY` and every + * handler in `API_CAPABILITY` calls the gate, with the id it is bound to, from + * the gate module rather than from a same-named local. + * + * ── Server actions are DERIVED, not listed ────────────────────────────────── + * + * An `actions.ts` beside a bound route inherits that route's binding, so there + * is no second list to keep in step. Today that rule reaches exactly one file; + * its value is the day someone adds an action next to a route somebody else + * bound, which is precisely when nobody is thinking about capabilities. + * + * ── Why a source scan ─────────────────────────────────────────────────────── + * + * The same reason as `middleware-covers-every-app-route` and the sign-in + * refusal suite: rendering these pages needs a session, a database and a tenant + * scope, and would then prove one path through one of them. The property is + * "this file consults the gate", and the file is where that is legible. + */ + +const APP = path.resolve(__dirname, "../../app") + +/** `/orgs/[slug]/memory` → `src/app/(app)/orgs/[slug]/memory`. */ +function appDirFor(route: string): string { + return path.join(APP, "(app)", route === "/" ? "" : route.slice(1)) +} + +/** `/api/reports/pulse` → `src/app/api/reports/pulse/route.ts`. */ +function apiFileFor(route: string): string { + return path.join(APP, route.slice(1), "route.ts") +} + +const IMPORTS_GATE = /from "@\/lib\/capability-registry\/gate"/ + +/** `fn(""` — the call, with the id it is bound to. */ +function gateCall(fn: "offeredTo" | "requireOffered" | "offeredTo|requireOffered", capabilityId: string): RegExp { + return new RegExp(`(?:${fn})\\(\\s*"${capabilityId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`) +} + +/** + * Does this source resolve `capabilityId` through the gate? + * + * The import is checked as well as the call, so a local function that happened + * to be named `offeredTo` could not satisfy this — the point is that the one + * resolver decided, not that a similarly-named thing was called. + */ +function callsGateWith(source: string, capabilityId: string): boolean { + return IMPORTS_GATE.test(source) && gateCall("offeredTo|requireOffered", capabilityId).test(source) +} + +/** + * Each `export async function` body in a `"use server"` module. + * + * Split rather than parsed: every action in this codebase is a top-level + * `export async function`, and a body runs to the next one. A parser would be + * more correct and would also be the only thing here anyone had to maintain. + */ +function actionBodies(source: string): { name: string; body: string }[] { + const parts = source.split(/^export async function /m).slice(1) + return parts.map((part) => ({ name: part.slice(0, part.indexOf("(")), body: part })) +} + +describe("every declared binding is enforced at the surface it binds", () => { + const pageRoutes = Object.entries(ROUTE_CAPABILITY) + const apiRoutes = Object.entries(API_CAPABILITY) + + it("finds bindings to check, so this suite is not vacuously passing", () => { + expect(pageRoutes.length).toBeGreaterThan(0) + expect(apiRoutes.length).toBeGreaterThan(0) + }) + + it("every bound page resolves its own capability", () => { + const unenforced = pageRoutes + .filter(([route, capabilityId]) => { + const page = path.join(appDirFor(route), "page.tsx") + // A missing page is `routes.test.ts`'s assertion, not this one. + if (!existsSync(page)) return false + return !callsGateWith(readFileSync(page, "utf8"), capabilityId) + }) + .map(([route, id]) => `${route} is bound to ${id} and never resolves it`) + expect(unenforced).toEqual([]) + }) + + it("every server action beside a bound page resolves that page's capability", () => { + // Derived from the page binding: an action next to a bound route is part of + // that route's surface, and a mutation that skips the gate is the version + // of this defect that writes to the database. + const unenforced = pageRoutes.flatMap(([route, capabilityId]) => { + const actions = path.join(appDirFor(route), "actions.ts") + if (!existsSync(actions)) return [] + const source = readFileSync(actions, "utf8") + const required = gateCall("requireOffered", capabilityId) + return actionBodies(source) + .filter(({ body }) => !IMPORTS_GATE.test(source) || !required.test(body)) + .map(({ name }) => `${route} → ${name}() writes without resolving ${capabilityId}`) + }) + expect(unenforced).toEqual([]) + }) + + it("the derived rule reaches a real file, so it is not passing on an empty set", () => { + const withActions = pageRoutes.filter(([route]) => + existsSync(path.join(appDirFor(route), "actions.ts")), + ) + expect(withActions.length).toBeGreaterThan(0) + }) + + it("every bound API handler resolves its own capability", () => { + const unenforced = apiRoutes + .filter(([route, capabilityId]) => { + const handler = apiFileFor(route) + if (!existsSync(handler)) return false + return !callsGateWith(readFileSync(handler, "utf8"), capabilityId) + }) + .map(([route, id]) => `${route} is bound to ${id} and never resolves it`) + expect(unenforced).toEqual([]) + }) + + it("an action refuses by throwing, so the refusal cannot be ignored by the next line", () => { + // `offeredTo` returns a decision a page renders; `requireOffered` throws. + // A mutating surface must use the throwing form — a returned flag is one + // `if` away from being skipped, and the line after it writes. + const wrongForm = Object.entries(ROUTE_CAPABILITY).flatMap(([route]) => { + const actions = path.join(appDirFor(route), "actions.ts") + if (!existsSync(actions)) return [] + const source = readFileSync(actions, "utf8") + return /\bofferedTo\(/.test(source) + ? [`${route} → actions.ts uses offeredTo; an action must use requireOffered`] + : [] + }) + expect(wrongForm).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/capability-registry/gate.test.ts b/apps/web/src/lib/capability-registry/gate.test.ts new file mode 100644 index 00000000..37bf5082 --- /dev/null +++ b/apps/web/src/lib/capability-registry/gate.test.ts @@ -0,0 +1,145 @@ +import { CapabilityUnavailableError, capabilityRefusal, offeredTo, requireOffered } from "./gate" +import { declaredModules } from "./manifest" +import type { Decision } from "./types" + +/** + * The gate a server surface calls, and the refusal it produces. + * + * The manifest read is mocked because the property under test is what the gate + * DOES with a tenant's declared modules, not that Prisma can find an audit row + * — `manifest.ts`'s own read is exercised end to end against a real database by + * the third slice's proof. Mocking it here also keeps the three states the + * resolver distinguishes (null, [], and a list) legible in one file, which is + * the distinction the whole design turns on. + */ + +jest.mock("./manifest", () => ({ declaredModules: jest.fn() })) + +const modulesFor = declaredModules as jest.MockedFunction + +/** A capability the real registry offers the pilot, and its module. */ +const REPORTING = "collaboration.reporting" + +beforeEach(() => { + modulesFor.mockReset() +}) + +describe("offeredTo", () => { + it("reads THIS tenant's manifest and answers from the real registry", async () => { + modulesFor.mockResolvedValue(["reporting@1.0.0", "organizations@1.0.0"]) + + const decision = await offeredTo(REPORTING, "inst_1") + + expect(modulesFor).toHaveBeenCalledWith("inst_1") + expect(decision.available).toBe(true) + expect(decision.manifestState).toBe("published") + }) + + it("refuses when the tenant's manifest does not declare the module", async () => { + // The refusal a nav-only gate could not deliver: this tenant is not offered + // reporting, and every surface bound to it must say so, not just the link. + modulesFor.mockResolvedValue(["organizations@1.0.0"]) + + const decision = await offeredTo(REPORTING, "inst_1") + + expect(decision.available).toBe(false) + expect(decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") + expect(decision.mode).toBe("UNAVAILABLE") + }) + + it("does not read a manifest for a tenant it was not given, and says it did not", async () => { + // A signed-in account with no institution has no manifest. Reading one + // anyway would need an institution to guess at; filtering as though it had + // read one would take the product dark on a document that does not exist. + const decision = await offeredTo(REPORTING, null) + + expect(modulesFor).not.toHaveBeenCalled() + expect(decision.manifestState).toBe("unpublished") + expect(decision.available).toBe(true) + }) + + it("carries the registry row's name, so a refusal can be rendered without a hand-typed label", async () => { + modulesFor.mockResolvedValue([]) + + const decision = await offeredTo(REPORTING, "inst_1") + + expect(decision.name).toBe("Institution-wide reporting and analytics") + expect(decision.owner).toBe("platform") + }) + + it("has no name for a capability nobody registered", async () => { + const decision = await offeredTo("never.registered", null) + + expect(decision.name).toBeNull() + expect(decision.refusals[0].code).toBe("NOT_REGISTERED") + }) +}) + +describe("requireOffered — the form a mutating surface uses", () => { + it("returns the decision when the capability is offered", async () => { + modulesFor.mockResolvedValue(["reporting@1.0.0"]) + await expect(requireOffered(REPORTING, "inst_1")).resolves.toMatchObject({ available: true }) + }) + + it("throws rather than returning a flag the next line can ignore", async () => { + modulesFor.mockResolvedValue([]) + + await expect(requireOffered(REPORTING, "inst_1")).rejects.toBeInstanceOf( + CapabilityUnavailableError, + ) + }) + + it("carries the decision on the error, so the reason survives the throw", async () => { + modulesFor.mockResolvedValue([]) + + let error: CapabilityUnavailableError | null = null + try { + await requireOffered(REPORTING, "inst_1") + } catch (e) { + error = e as CapabilityUnavailableError + } + if (!error) throw new Error("requireOffered resolved; the refusal never happened") + + expect(error.decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") + // The message names the capability and the code — a log line that says only + // "unavailable" sends nobody anywhere. + expect(error.message).toContain(REPORTING) + expect(error.message).toContain("MODULE_NOT_DECLARED") + }) +}) + +describe("capabilityRefusal — what an API route answers", () => { + const refused = (): Decision => ({ + capability: REPORTING, + name: "Institution-wide reporting and analytics", + manifestState: "published", + available: false, + mode: "UNAVAILABLE", + architectureScore: "S", + runtimeStatus: "TENANT_PILOT", + owner: "platform", + refusals: [{ code: "MODULE_NOT_DECLARED", detail: "not in this tenant's manifest" }], + }) + + it("answers 501, not 403 — the two questions must stay distinguishable on the wire", async () => { + // 403 is the answer to WHO is asking, and the pulse route already uses it + // for exactly that. If a withheld capability answered 403 too, the one + // place a client can see the difference would stop showing it. + const response = capabilityRefusal(refused()) + expect(response.status).toBe(501) + }) + + it("states the refusal code and the owner, so a screenshot is a support ticket", async () => { + const body = await capabilityRefusal(refused()).json() + expect(body).toMatchObject({ + error: "capability_unavailable", + capability: REPORTING, + owner: "platform", + refusals: [{ code: "MODULE_NOT_DECLARED" }], + }) + }) + + it("is never cached — an availability answer that outlives a reconcile is wrong", () => { + expect(capabilityRefusal(refused()).headers.get("cache-control")).toBe("no-store") + }) +}) diff --git a/apps/web/src/lib/capability-registry/gate.ts b/apps/web/src/lib/capability-registry/gate.ts new file mode 100644 index 00000000..a4155291 --- /dev/null +++ b/apps/web/src/lib/capability-registry/gate.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server" +import { declaredModules } from "./manifest" +import { CAPABILITY_REGISTRY, PILOT_SCOPE } from "./registry" +import { resolveCapability } from "./resolve" +import type { Decision } from "./types" + +/** + * How a SERVER SURFACE asks the resolver. One function, one answer shape. + * + * ── The hole this closes ──────────────────────────────────────────────────── + * + * `resolveCapability` has been the single decision point since the first slice, + * and `routes.ts` has bound four routes to capabilities since the second. But + * nothing joined them: the binding was read by CI and by nothing at runtime. The + * only consumer was the side nav, so availability was enforced in the + * NAVIGATION and nowhere else — hide the entry, and the route it pointed at + * still rendered, its server action still wrote, and its API route still + * answered. A gate that only removes the link is a cosmetic gate; anyone who + * typed the URL, kept a tab open, or replayed a form POST walked straight past + * it. + * + * So the surfaces that act now resolve for themselves. A page renders the + * refusal, an action refuses to write, a route handler answers 501 — all from + * this one call, so "unavailable" cannot acquire a second vocabulary at the + * third call site. + * + * ── Still no identity, on purpose ─────────────────────────────────────────── + * + * This takes an `institutionId`, never a session or a user. WHO MAY ACT is + * `lib/admin/capabilities.ts` and stays there; a test asserts nothing in this + * directory imports a role, a session or `lib/rbac`. Both gates run on the + * mutating surfaces below, in that order and as separate expressions, because a + * promotion must not unlock a capability the cell was never certified to offer. + */ + +/** + * Does this deployment offer `capabilityId` to this tenant? + * + * `institutionId` may be null: a signed-in account with no institution has no + * manifest to read, which is `manifestState: "unpublished"` — not filtered, and + * recorded as not filtered. That is the same answer the pilot's own tenant gets + * today, and it is deliberately not the same as an empty manifest. Filtering + * against a document that does not exist would take the product dark. + */ +export async function offeredTo(capabilityId: string, institutionId: string | null): Promise { + const modules = institutionId ? await declaredModules(institutionId) : null + return resolveCapability(capabilityId, PILOT_SCOPE, CAPABILITY_REGISTRY, modules) +} + +/** + * A refusal carrying the decision that produced it. + * + * The decision travels with the error because the refusal codes are the whole + * point — `MODULE_NOT_DECLARED` sends a reader to the tenant's manifest and + * `STATUS_NOT_LIVE` sends them to the owning team, and an error whose message + * said only "unavailable" would send them to neither. + */ +export class CapabilityUnavailableError extends Error { + readonly decision: Decision + + constructor(decision: Decision) { + super( + `This deployment does not offer "${decision.capability}". ` + + decision.refusals.map((r) => `${r.code}: ${r.detail}`).join(" "), + ) + this.name = "CapabilityUnavailableError" + this.decision = decision + } +} + +/** + * The gate for a surface that ACTS — a server action, or anything whose refusal + * must stop work rather than render. + * + * Throws rather than returning a flag because a returned flag can be ignored by + * the next line, and the next line writes to the database. The thrown form is + * also why the pages below gate too: a person should meet the refusal as page + * state, with a reason, rather than as an error card. Reaching this throw means + * the action was invoked without the page that gates it — a stale tab, a + * replayed POST, a capability withdrawn mid-session — which is precisely the + * case a nav-only gate could not refuse. + */ +export async function requireOffered( + capabilityId: string, + institutionId: string | null, +): Promise { + const decision = await offeredTo(capabilityId, institutionId) + if (!decision.available) throw new CapabilityUnavailableError(decision) + return decision +} + +/** + * The refusal an API route answers with. + * + * 501, and the alternatives were all worse: + * + * 403 is the answer to WHO is asking, which this deliberately does not know. + * Reusing it would merge the two questions the registry exists to keep + * apart, in the one place a client can see the difference. + * 404 reads as "no such URL", so a withheld capability would be + * indistinguishable from a typo, and the reason could not be stated. + * 503 promises the caller that retrying later will work. A capability this + * tenant's manifest does not declare is not a temporary condition. + * + * RFC 9110 §15.6.2 is "the server does not support the functionality required + * to fulfil the request", which is exactly the claim being made. + * + * The body carries the refusal codes because the caller here is a colleague + * with a support ticket, not the public internet: `MODULE_NOT_DECLARED` plus an + * owner is a sentence a person can act on, and "unavailable" is not. + */ +export function capabilityRefusal(decision: Decision): NextResponse { + return NextResponse.json( + { + error: "capability_unavailable", + capability: decision.capability, + refusals: decision.refusals.map((r) => ({ code: r.code, detail: r.detail })), + owner: decision.owner, + runtimeStatus: decision.runtimeStatus, + manifestState: decision.manifestState, + }, + { status: 501, headers: { "cache-control": "no-store" } }, + ) +} diff --git a/apps/web/src/lib/capability-registry/resolve.ts b/apps/web/src/lib/capability-registry/resolve.ts index 7ab06cea..1d4a1958 100644 --- a/apps/web/src/lib/capability-registry/resolve.ts +++ b/apps/web/src/lib/capability-registry/resolve.ts @@ -52,6 +52,7 @@ export function resolveCapability( if (!definition) { return { capability: id, + name: null, manifestState, available: false, mode: "UNAVAILABLE", @@ -71,6 +72,7 @@ export function resolveCapability( const base = { capability: id, + name: definition.name, manifestState, architectureScore: definition.architectureScore, runtimeStatus: definition.runtimeStatus, diff --git a/apps/web/src/lib/capability-registry/surfaces.test.ts b/apps/web/src/lib/capability-registry/surfaces.test.ts new file mode 100644 index 00000000..26ace4ce --- /dev/null +++ b/apps/web/src/lib/capability-registry/surfaces.test.ts @@ -0,0 +1,82 @@ +import { readdirSync, statSync } from "node:fs" +import path from "node:path" +import { CAPABILITY_REGISTRY } from "./registry" +import { API_CAPABILITY, API_PENDING_BINDING } from "./surfaces" + +/** + * The ratchet, extended to the surfaces that answer without a page. + * + * `routes.test.ts` accounts for every page under `(app)`. Twenty-three route + * handlers under `src/app/api` were in no list at all — including the endpoint + * the reports strip polls every fifteen seconds and the two that write + * documents. A page is at least conspicuous by its absence from a nav; an + * endpoint is visible to nobody, which is how they stayed unexamined through + * three slices of an item whose title says "every surface". + * + * Same three refusals as the route ratchet, for the same reasons: a handler in + * neither list fails, a binding to an unregistered capability fails (it reads + * as done and resolves NOT_REGISTERED at runtime), and a deferral with no + * reason fails. Whether a bound handler actually CALLS the gate is + * `enforcement.test.ts` — that assertion is separate because it is a separate + * claim, and because for two slices the route bindings satisfied this one + * while being enforced nowhere. + */ + +const API_DIR = path.resolve(__dirname, "../../app/api") + +/** Every route handler, as the URL path it serves. */ +function handlers(dir: string, prefix = "/api"): string[] { + const found: string[] = [] + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry) + if (statSync(full).isDirectory()) { + found.push(...handlers(full, `${prefix}/${entry}`)) + } else if (entry === "route.ts") { + found.push(prefix) + } + } + return found +} + +describe("every API route declares its capability, or declares that it has not", () => { + const discovered = handlers(API_DIR).sort() + + it("finds the handlers, so this suite is not vacuously passing", () => { + expect(discovered.length).toBeGreaterThan(15) + }) + + it("no handler is unaccounted for", () => { + const unaccounted = discovered.filter( + (r) => !(r in API_CAPABILITY) && !(r in API_PENDING_BINDING), + ) + expect(unaccounted).toEqual([]) + }) + + it("no handler claims both a binding and a deferral", () => { + const both = Object.keys(API_CAPABILITY).filter((r) => r in API_PENDING_BINDING) + expect(both).toEqual([]) + }) + + it("every binding names a capability that actually exists", () => { + const known = new Set(CAPABILITY_REGISTRY.map((c) => c.id)) + const dangling = Object.entries(API_CAPABILITY) + .filter(([, id]) => !known.has(id)) + .map(([route, id]) => `${route} → ${id}`) + expect(dangling).toEqual([]) + }) + + it("every deferral gives a reason", () => { + const silent = Object.entries(API_PENDING_BINDING) + .filter(([, reason]) => !reason.trim()) + .map(([route]) => route) + expect(silent).toEqual([]) + }) + + it("no stale entries — every listed handler still exists", () => { + const live = new Set(discovered) + const stale = [...Object.keys(API_CAPABILITY), ...Object.keys(API_PENDING_BINDING)].filter( + (r) => !live.has(r), + ) + expect(stale).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/capability-registry/surfaces.ts b/apps/web/src/lib/capability-registry/surfaces.ts new file mode 100644 index 00000000..fb771844 --- /dev/null +++ b/apps/web/src/lib/capability-registry/surfaces.ts @@ -0,0 +1,77 @@ +/** + * Which capability each API route depends on. + * + * ── Why routes.ts was not enough ──────────────────────────────────────────── + * + * `routes.ts` accounts for every page under `(app)`. An API route is not under + * `(app)` and never appeared there, so twenty-three handlers — including every + * one the client polls, and the two that write documents — were outside the + * only list that could have noticed them. A page is at least visible in the nav + * it is missing from; a route handler is visible to nobody. + * + * The ratchet is the same shape as the route one, for the same reason: a + * handler must appear in exactly one list below, so a NEW endpoint cannot ship + * without its author either binding it or writing down that they deferred. + * Wildcards and counts are both refused — a reader closing this gap needs the + * names, and a reviewer needs to see one leave the list in the diff. + * + * ── A binding here is enforced, not declared ──────────────────────────────── + * + * `surfaces.test.ts` asserts that a bound handler's source actually calls the + * gate. That assertion exists because of what this slice found: the four page + * bindings in `routes.ts` had been read by CI and by nothing at runtime for two + * slices. Availability was enforced in the navigation, so hiding the link was + * the entire gate, and typing the URL walked past it. A binding nothing + * enforces is worse than no binding — it reads as done. + */ + +/** API routes whose availability is decided by a registered capability. */ +export const API_CAPABILITY: Readonly> = { + "/api/reports/pulse": "collaboration.reporting", +} + +/** + * API routes not yet bound, with the reason. Every entry here is a real gap. + * + * Three kinds of reason, and they are genuinely three rather than twenty-two: + * the capability does not exist yet, the handler is infrastructure rather than + * a tenant-facing feature, or binding it would be circular. + */ +export const API_PENDING_BINDING: Readonly> = { + // The capability these belong to is not registered. Binding them would mean + // inventing registry rows to satisfy a checker, which is the fiction the + // registry exists to prevent — a capability is declared because it is + // offered, not because a handler needed something to point at. + "/api/ai/chat": "assistant capability not yet registered", + "/api/ai/diagnostic": "assistant capability not yet registered", + "/api/ai/draft": "assistant capability not yet registered", + "/api/search": "search capability not yet registered", + "/api/calendar/event/[id]": "events/calendar capability not yet registered", + "/api/calendar/ics/[token]": "events/calendar capability not yet registered", + "/api/calendar/reschedule": "events/calendar capability not yet registered", + "/api/documents/[id]/content": "documents capability not yet registered", + "/api/documents/[id]/save": "documents capability not yet registered", + "/api/attachment/[id]": "messaging capability not yet registered", + "/api/attachment/[id]/content": "messaging capability not yet registered", + "/api/templates/budget": "finance capability not yet registered", + "/api/integrations/slack/install": "integrations capability not yet registered", + "/api/integrations/slack/callback": "integrations capability not yet registered", + "/api/admin/directory": "admin plane — role-gated; tenant-level withholding undecided", + + // Infrastructure: no tenant is asking, or the answer cannot depend on one. + "/api/auth/[...nextauth]": + "sign-in transport — a capability gate here would make a tenant unable to authenticate its way to being told why", + "/api/health": "load-balancer health check — has no tenant and no session", + "/api/jobs/reminders": "scheduled job — runs for every institution at once, with no tenant asking", + "/api/notifications": "per-user shell, like /notifications — not per-tenant-capability", + "/api/org-image/[orgId]": "image bytes for the club spine", + "/api/profile-image/[userId]": "image bytes, per-user", + + // Circular, and the one entry here that is a decision rather than a gap. + // This handler is where a signed DeploymentManifest ARRIVES. The resolver's + // module filter reads that manifest, so gating this on a capability would + // mean a tenant needs a manifest before it can be given one — an unreconciled + // cell could never receive its first. It is authenticated by a signature + // instead, which is the right authority for a control-plane push. + "/api/platform/reconcile": "control plane — publishes the manifest the resolver reads; gating it would deadlock the first reconcile", +} diff --git a/apps/web/src/lib/capability-registry/types.ts b/apps/web/src/lib/capability-registry/types.ts index 714d7686..c4aca323 100644 --- a/apps/web/src/lib/capability-registry/types.ts +++ b/apps/web/src/lib/capability-registry/types.ts @@ -185,6 +185,15 @@ export type ManifestState = "published" | "unpublished" export interface Decision { readonly capability: string + /** + * The registry row's human name, or null when nothing is registered. + * + * Carried on the decision so a refusal can be RENDERED without the surface + * hand-typing a label beside the id it gates on — two names for one + * capability drift, and the one on screen is the one nobody updates. + * Reported for display only; never an input to `available`. + */ + readonly name: string | null /** Whether module filtering was applied, and why not if it was not. */ readonly manifestState: ManifestState readonly available: boolean diff --git a/docs/PROGRAM-BACKLOG.md b/docs/PROGRAM-BACKLOG.md index 516d1f19..5c99d40a 100644 --- a/docs/PROGRAM-BACKLOG.md +++ b/docs/PROGRAM-BACKLOG.md @@ -153,10 +153,46 @@ Two structural facts that change how items are scheduled: `null` and `[]` are deliberately different answers — `[]` is a manifest that grants nothing, `null` is a tenant nobody has reconciled. Recording that distinction in every decision is what makes "Simon OSE is hand-built, not distro-deployed" a measurable fact rather than an assumption. -- **STILL OPEN in this item**: server actions, API routes and AI answers do not resolve through the registry; - 31 of 35 routes are deferred rather than bound; the registry is code rather than tenant data; and - `declaredModules` reads the manifest out of an append-only AUDIT row, which is where the data actually is - but not where configuration belongs — a purge policy could one day delete a tenant's entitlements. +- **~~FOURTH SLICE — DONE~~**: the surfaces that ACT resolve for themselves, through one gate + (`capability-registry/gate.ts`): `offeredTo` for a surface that renders the refusal, `requireOffered` for one + that must not proceed, `capabilityRefusal` for a route handler. Wired to the mutating surface + (`createMemoryCard`), the polled API route (`/api/reports/pulse`, answering **501** — not 403, which is the + answer to *who* is asking) and all five bound pages. A second ratchet accounts for every one of the 23 API + routes: 1 bound, 22 deferred with reasons. + The fifth bound page, `/admin/metering`, arrived from `main` during this merge: bound there to + `payments.seat-metering`, and resolving it by writing out `resolveCapability(id, PILOT_SCOPE, + CAPABILITY_REGISTRY, await declaredModules(...))` at the call site — the gate's four arguments, copied, + because the page predates the gate. It now calls `offeredTo`. The merge was clean and git reported nothing; + `enforcement.test.ts` is what made it visible, and reverting that one file fails it. + **A correction to the line below, which this slice found to be wrong**: as it stood — "31 of 35 routes are + deferred rather than bound" — it implied the other 4 resolved through the registry. They did not. `ROUTE_CAPABILITY` was read by + CI and by nothing at runtime — the side nav resolved the capability and dropped the *link*, and the route + behind it rendered in full to anyone who typed the URL, its server action still wrote, and its endpoint still + answered a polling tab. Availability was enforced in the navigation and nowhere else. So the real count was + **0 of 35 enforced** as the tree stood then, and a binding nothing enforces is worse than none because it + reads as done — + `enforcement.test.ts` now asserts the join, for pages, for API handlers, and for any `actions.ts` beside a + bound route (derived from the page binding, so there is no second list to keep in step). + Controls, each run red then restored: manifest without the module → /reports renders the refusal and no + figures leave the server; same → `createMemoryCard` throws and `$transaction` is never reached; same → + `/api/reports/pulse` answers 501 with no counts in the body; gate deleted from a page, from the action, and + from the handler → three separate assertions fail; a page bound to a *different* registered capability → the + enforcement assertion still fails, so it checks the id and not just the presence of a call; a new API route + with no entry, a stale entry, and a binding to an unregistered capability → the ratchet fails. + **One cost this exposed and paid off**: putting the gate on the routes moved `declaredModules` onto ordinary + request traffic, and `AuditEvent`'s indexes could not serve it — measured on 50,000 rows for one institution + with no reconcile row (every tenant's state today), the read was a 667-buffer sequential scan at 2.495 ms. + `20260821163000_index_the_manifest_lookup` makes it a 3-buffer seek at 0.013 ms, which also removes a cost + the app layout has been paying on every authenticated page render since the third slice. +- **STILL OPEN in this item**: AI answers do not resolve through the registry (no assistant/search capability + is registered, and inventing one so the assistant has something to resolve against is the fiction this + registry exists to prevent); 33 of 38 page routes and 22 of 23 API routes are deferred rather than bound + (measured against the merged tree, not carried over — `main` added three page routes while this branch was + open, one of them bound); + `OrgTabs` is the next static array to route through the resolver, exactly as `SideNav` was; the registry is + code rather than tenant data; and `declaredModules` reads the manifest out of an append-only AUDIT row, + which is where the data actually is but not where configuration belongs — a purge policy could one day + delete a tenant's entitlements. ### ✅ [governance] Make BLOCKED_ARCHITECTURE an enumerable register with a CI check - **Done**: Tenure@9552515 — `apps/web/src/lib/governance/{blocked-architecture,register}.ts` and a 13-assertion