diff --git a/.env.example b/.env.example index c92a3b3..a2bcfc4 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,7 @@ NEXT_PUBLIC_STELLAR_NETWORK=testnet NEXT_PUBLIC_HORIZON_TESTNET_URL=https://horizon-testnet.stellar.org NEXT_PUBLIC_HORIZON_MAINNET_URL=https://horizon.stellar.org NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Optional service status, see docs/PUBLIC_STATUS.md. Unset means normal. +# UTIX_SERVICE_MODE=maintenance +# UTIX_SERVICE_MESSAGE=Scheduled maintenance; some tools may be briefly unavailable. +# UTIX_SERVICE_UNTIL=2026-03-10T13:00:00.000Z diff --git a/SECURITY.md b/SECURITY.md index f60afe8..6ac6898 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,3 +26,25 @@ npm run build - Public key validation and public Horizon lookups are in scope. - Friendbot usage is testnet-only and has no real asset value. - The app does not store wallet keys or submit signed transactions. +- `GET /api/status` and the site banner publish only an allow-listed public notice; operator messages containing secrets, tokens, credential URLs, internal addresses or stack frames are refused (see [docs/PUBLIC_STATUS.md](./docs/PUBLIC_STATUS.md)). + +## Read-only threat boundaries + +Every tool is an independently contributed slice under `features/`, so the +read-only promise is verified per slice rather than assumed: + +- Each slice's `README.md` has a `## Threat boundary` section listing its + **data sources**, its **outbound requests**, and the **proof that it cannot + sign or submit** a transaction. See rule 15 in + [docs/FEATURE_CONTRACT.md](./docs/FEATURE_CONTRACT.md) for the format and + the reviewer checklist, and + [features/trustline-checker/README.md](./features/trustline-checker/README.md) + for a worked example. +- `npm run verify:features` (run in CI) fails a slice whose section is missing + or unfinished, and fails any slice whose runtime code calls a signing or + submission API. Slices written before the rule are tracked in + `scripts/threat-boundary-migration.json` until they are documented. + +When reviewing a security-relevant change, read the slice's threat boundary +against its `lib/` and `msw/handlers.ts`: every request the code makes should +be listed, and every listed request should be a public, read-only endpoint. diff --git a/app/api/status/route.ts b/app/api/status/route.ts new file mode 100644 index 0000000..6efa6bc --- /dev/null +++ b/app/api/status/route.ts @@ -0,0 +1,10 @@ +import { buildStatusResponse } from "@/core/status/service-config"; + +// Read the environment and the clock per request, never at build time. +export const dynamic = "force-dynamic"; + +/** Public, read-only status: the service notice plus the schema-backed feed. */ +export function GET(): Response { + const response = buildStatusResponse(process.env, Date.now()); + return Response.json(response.body, { status: response.status, headers: response.headers }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 1f55035..6facfb4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,11 @@ import type { Metadata } from "next"; import "./globals.css"; import { AppShell } from "@/core/layout/AppShell"; +// The service-status banner and the paused-tool gate are resolved at render +// time. Re-rendering at most once a minute lets a scheduled `since` or an +// expired `until` (core/status/service-config.ts) take effect without a deploy. +export const revalidate = 60; + export const metadata: Metadata = { metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"), title: "RevyHubX", diff --git a/app/tools/[slug]/page.tsx b/app/tools/[slug]/page.tsx index 6b70ae0..415b9de 100644 --- a/app/tools/[slug]/page.tsx +++ b/app/tools/[slug]/page.tsx @@ -3,6 +3,8 @@ import { notFound } from "next/navigation"; import { findFeature, loadFeaturePanel } from "@/core/registry/registry"; import { featureSlugs, findManifest } from "@/core/registry/manifests"; import { FeatureShell } from "@/core/ui/FeatureShell"; +import { currentServiceNotice } from "@/core/status/service-config"; +import { PausedToolNotice } from "@/core/status/ServiceStatusBanner"; interface RouteParams { params: Promise<{ slug: string }>; @@ -41,6 +43,17 @@ export default async function ToolPage({ params }: RouteParams) { if (!feature) notFound(); const { manifest } = feature; + + // A pause is a kill switch: the panel is never mounted, so no tool can make + // a request until the operator lifts it. + if (currentServiceNotice().mode === "paused") { + return ( + + + + ); + } + const loaded = await loadFeaturePanel(feature); if (!loaded.ok) { diff --git a/core/__tests__/threatBoundaryDocs.test.ts b/core/__tests__/threatBoundaryDocs.test.ts new file mode 100644 index 0000000..384bb87 --- /dev/null +++ b/core/__tests__/threatBoundaryDocs.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { + THREAT_BOUNDARY_FIELDS, + evaluateThreatBoundaryDoc, + findSigningCalls, + loadMigrationLedger, + verifySlice +} from "../../scripts/verify-features.mjs"; + +const root = process.cwd(); +const featuresDir = path.join(root, "features"); +const ledgerPath = path.join(root, "scripts/threat-boundary-migration.json"); + +const slugs = readdirSync(featuresDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith(".") && !dirent.name.startsWith("_")) + .map((dirent) => dirent.name) + .sort(); + +const documented = [ + "# Tool", + "", + "## Threat boundary", + "", + "- **Data sources:** the form field.", + "- **Outbound requests:** None — runs entirely in the browser.", + "- **Cannot sign or submit:** the schema rejects `S…` secrets.", + "", + "## Files" +].join("\n"); + +describe("threat-boundary documentation check", () => { + it("passes a README that answers every field", () => { + expect(evaluateThreatBoundaryDoc("tool", documented)).toEqual({ + slug: "tool", + ok: true, + hasSection: true, + missing: [] + }); + }); + + it("fails a README without the section, naming every field", () => { + const result = evaluateThreatBoundaryDoc("tool", "# Tool\n\n## Safety\n\nRead-only."); + expect(result.ok).toBe(false); + expect(result.hasSection).toBe(false); + expect(result.missing).toEqual([...THREAT_BOUNDARY_FIELDS]); + }); + + it("treats a missing README the same as a missing section", () => { + expect(evaluateThreatBoundaryDoc("tool", undefined).missing).toEqual([...THREAT_BOUNDARY_FIELDS]); + }); + + it("does not accept the scaffold's TODO placeholders as answers", () => { + const scaffolded = documented.replace("the form field.", "TODO: every input the tool reads."); + expect(evaluateThreatBoundaryDoc("tool", scaffolded).missing).toEqual(["Data sources"]); + }); + + it("does not accept an empty answer", () => { + const empty = documented.replace("**Outbound requests:** None — runs entirely in the browser.", "**Outbound requests:**"); + expect(evaluateThreatBoundaryDoc("tool", empty).missing).toEqual(["Outbound requests"]); + }); + + it("only reads fields inside the section, not elsewhere in the README", () => { + const outside = [ + "# Tool", + "- **Data sources:** the form field.", + "## Threat boundary", + "- **Outbound requests:** None.", + "- **Cannot sign or submit:** no secrets accepted." + ].join("\n"); + expect(evaluateThreatBoundaryDoc("tool", outside).missing).toEqual(["Data sources"]); + }); +}); + +describe("read-only signing scan", () => { + it.each([ + ["const pair = Keypair.fromSecret(input);", "secret_keypair"], + ["const pair = Keypair.random();", "secret_keypair"], + ["tx.sign(pair);", "sign_call"], + ["await freighter.signTransaction(xdr);", "wallet_sign"], + ["await server.submitTransaction(tx);", "horizon_submit"], + ['rpc("sendTransaction", { transaction })', "rpc_send"] + ])("flags %s", (source, id) => { + expect(findSigningCalls({ "lib/tool.ts": source })).toEqual([{ file: "lib/tool.ts", id }]); + }); + + it("ignores fixtures, tests, e2e specs and request mocks, which build signed samples", () => { + const sources = { + "fixtures/tool.fixture.ts": "tx.sign(source);", + "__tests__/tool.test.ts": "Keypair.random();", + "e2e/tool.spec.ts": "tx.sign(source);", + "msw/handlers.ts": '"sendTransaction"' + }; + expect(findSigningCalls(sources)).toEqual([]); + }); + + it("ignores comments and unrelated calls such as Math.sign and verify", () => { + const sources = { + "lib/tool.ts": [ + "// This tool never calls submitTransaction() or tx.sign().", + "/* sendTransaction is deliberately absent */", + "const direction = Math.sign(delta);", + "const valid = keypair.verify(data, signature);", + 'const horizon = "https://horizon.stellar.org";' + ].join("\n") + }; + expect(findSigningCalls(sources)).toEqual([]); + }); +}); + +describe("threat-boundary migration ledger", () => { + const pending = loadMigrationLedger(ledgerPath); + + it("only lists slices that exist", () => { + expect([...pending].filter((slug) => !slugs.includes(slug))).toEqual([]); + }); + + it("does not list a slice that already documents its boundary", () => { + // The ledger must shrink: once a README is complete its slug comes out. + const stale = [...pending].filter((slug) => { + const readme = path.join(featuresDir, slug, "README.md"); + let source: string | undefined; + try { + source = readFileSync(readme, "utf8"); + } catch { + source = undefined; + } + return evaluateThreatBoundaryDoc(slug, source).ok; + }); + expect(stale).toEqual([]); + }); + + it("documents the representative example, trustline-checker, outside the ledger", async () => { + expect(pending.has("trustline-checker")).toBe(false); + const result = await verifySlice("trustline-checker"); + expect(result.threatBoundaryPending).toBe(false); + expect(result.threatBoundaryMissing).toEqual([]); + expect(result.failures).toEqual([]); + }); + + it("fails a slice that is neither documented nor on the ledger", async () => { + const result = await verifySlice("trustline-limit-planner", { threatBoundaryPending: new Set() }); + expect(result.failures.some((failure) => failure.includes("Threat boundary"))).toBe(true); + }); + + it("finds no signing or submission call in any slice's runtime code", async () => { + const offenders: string[] = []; + for (const slug of slugs) { + const result = await verifySlice(slug); + offenders.push(...result.failures.filter((failure) => failure.includes("read-only boundary")).map((f) => `${slug}: ${f}`)); + } + expect(offenders).toEqual([]); + }); +}); diff --git a/core/audit/audit.ts b/core/audit/audit.ts index aa53d40..bbc7548 100644 --- a/core/audit/audit.ts +++ b/core/audit/audit.ts @@ -99,7 +99,11 @@ export const SENSITIVE_ACTIONS = [ /** A freeze window was bypassed for an emergency action. */ "freeze.bypassed", /** An emergency bypass of a freeze window was refused. */ - "freeze.bypass_denied" + "freeze.bypass_denied", + /** The public service mode (degraded, paused, maintenance) was changed or a change was refused. */ + "service_mode.changed", + /** A service mode reached its `until` and returned to normal on its own. */ + "service_mode.expired" ] as const; export type AuditAction = (typeof SENSITIVE_ACTIONS)[number]; diff --git a/core/layout/AppShell.tsx b/core/layout/AppShell.tsx index 013f101..93f4746 100644 --- a/core/layout/AppShell.tsx +++ b/core/layout/AppShell.tsx @@ -2,8 +2,12 @@ import { AppHeader } from "@/core/layout/AppHeader"; import { Sidebar } from "@/core/layout/Sidebar"; import { NotificationProvider } from "@/core/notifications/NotificationProvider"; import { NetworkProvider } from "@/core/network/NetworkProvider"; +import { currentServiceNotice } from "@/core/status/service-config"; +import { ServiceStatusBanner } from "@/core/status/ServiceStatusBanner"; export function AppShell({ children }: { children: React.ReactNode }) { + const notice = currentServiceNotice(); + return ( @@ -15,6 +19,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { + diff --git a/core/registry/manifests.generated.ts b/core/registry/manifests.generated.ts index c18fa35..88d4b8d 100644 --- a/core/registry/manifests.generated.ts +++ b/core/registry/manifests.generated.ts @@ -18,6 +18,7 @@ import { manifest as batchAddressValidator } from "@/features/batch-address-vali import { manifest as claimableBalances } from "@/features/claimable-balances/manifest"; import { manifest as claimablePredicateBuilder } from "@/features/claimable-predicate-builder/manifest"; import { manifest as contractEvents } from "@/features/contract-events/manifest"; +import { manifest as duplicateTransactionScanner } from "@/features/duplicate-transaction-scanner/manifest"; import { manifest as effectsTimeline } from "@/features/effects-timeline/manifest"; import { manifest as explorerLinks } from "@/features/explorer-links/manifest"; import { manifest as federationResolver } from "@/features/federation-resolver/manifest"; @@ -32,8 +33,10 @@ import { manifest as liquidityPoolCalculator } from "@/features/liquidity-pool-c import { manifest as liquidityPoolInspector } from "@/features/liquidity-pool-inspector/manifest"; import { manifest as manageDataCodec } from "@/features/manage-data-codec/manifest"; import { manifest as memoInspector } from "@/features/memo-inspector/manifest"; +import { manifest as multisigAnalyzer } from "@/features/multisig-analyzer/manifest"; import { manifest as muxedAccountCodec } from "@/features/muxed-account-codec/manifest"; import { manifest as networkComparison } from "@/features/network-comparison/manifest"; +import { manifest as offerInspector } from "@/features/offer-inspector/manifest"; import { manifest as operationBrowser } from "@/features/operation-browser/manifest"; import { manifest as operationSourceMap } from "@/features/operation-source-map/manifest"; import { manifest as orderbookViewer } from "@/features/orderbook-viewer/manifest"; @@ -57,6 +60,7 @@ import { manifest as sorobanDecoder } from "@/features/soroban-decoder/manifest" import { manifest as sorobanFeeEstimator } from "@/features/soroban-fee-estimator/manifest"; import { manifest as sorobanSpecViewer } from "@/features/soroban-spec-viewer/manifest"; import { manifest as sponsoredReserves } from "@/features/sponsored-reserves/manifest"; +import { manifest as sponsorshipPlanner } from "@/features/sponsorship-planner/manifest"; import { manifest as strkeyInspector } from "@/features/strkey-inspector/manifest"; import { manifest as testnetFaucet } from "@/features/testnet-faucet/manifest"; import { manifest as tomlValidator } from "@/features/toml-validator/manifest"; @@ -88,6 +92,7 @@ export const generatedManifests: FeatureManifest[] = [ claimableBalances, claimablePredicateBuilder, contractEvents, + duplicateTransactionScanner, effectsTimeline, explorerLinks, federationResolver, @@ -102,8 +107,10 @@ export const generatedManifests: FeatureManifest[] = [ liquidityPoolInspector, manageDataCodec, memoInspector, + multisigAnalyzer, muxedAccountCodec, networkComparison, + offerInspector, operationBrowser, operationSourceMap, orderbookViewer, @@ -127,6 +134,7 @@ export const generatedManifests: FeatureManifest[] = [ sorobanFeeEstimator, sorobanSpecViewer, sponsoredReserves, + sponsorshipPlanner, strkeyInspector, testnetFaucet, tomlValidator, diff --git a/core/registry/panels.generated.ts b/core/registry/panels.generated.ts index accca7a..4c9ca33 100644 --- a/core/registry/panels.generated.ts +++ b/core/registry/panels.generated.ts @@ -21,6 +21,7 @@ export const generatedPanels: Record = { "claimable-balances": () => import("@/features/claimable-balances/panel").then((mod) => mod.default as ComponentType), "claimable-predicate-builder": () => import("@/features/claimable-predicate-builder/panel").then((mod) => mod.default as ComponentType), "contract-events": () => import("@/features/contract-events/panel").then((mod) => mod.default as ComponentType), + "duplicate-transaction-scanner": () => import("@/features/duplicate-transaction-scanner/panel").then((mod) => mod.default as ComponentType), "effects-timeline": () => import("@/features/effects-timeline/panel").then((mod) => mod.default as ComponentType), "explorer-links": () => import("@/features/explorer-links/panel").then((mod) => mod.default as ComponentType), "federation-resolver": () => import("@/features/federation-resolver/panel").then((mod) => mod.default as ComponentType), @@ -35,8 +36,10 @@ export const generatedPanels: Record = { "liquidity-pool-inspector": () => import("@/features/liquidity-pool-inspector/panel").then((mod) => mod.default as ComponentType), "manage-data-codec": () => import("@/features/manage-data-codec/panel").then((mod) => mod.default as ComponentType), "memo-inspector": () => import("@/features/memo-inspector/panel").then((mod) => mod.default as ComponentType), + "multisig-analyzer": () => import("@/features/multisig-analyzer/panel").then((mod) => mod.default as ComponentType), "muxed-account-codec": () => import("@/features/muxed-account-codec/panel").then((mod) => mod.default as ComponentType), "network-comparison": () => import("@/features/network-comparison/panel").then((mod) => mod.default as ComponentType), + "offer-inspector": () => import("@/features/offer-inspector/panel").then((mod) => mod.default as ComponentType), "operation-browser": () => import("@/features/operation-browser/panel").then((mod) => mod.default as ComponentType), "operation-source-map": () => import("@/features/operation-source-map/panel").then((mod) => mod.default as ComponentType), "orderbook-viewer": () => import("@/features/orderbook-viewer/panel").then((mod) => mod.default as ComponentType), @@ -60,6 +63,7 @@ export const generatedPanels: Record = { "soroban-fee-estimator": () => import("@/features/soroban-fee-estimator/panel").then((mod) => mod.default as ComponentType), "soroban-spec-viewer": () => import("@/features/soroban-spec-viewer/panel").then((mod) => mod.default as ComponentType), "sponsored-reserves": () => import("@/features/sponsored-reserves/panel").then((mod) => mod.default as ComponentType), + "sponsorship-planner": () => import("@/features/sponsorship-planner/panel").then((mod) => mod.default as ComponentType), "strkey-inspector": () => import("@/features/strkey-inspector/panel").then((mod) => mod.default as ComponentType), "testnet-faucet": () => import("@/features/testnet-faucet/panel").then((mod) => mod.default as ComponentType), "toml-validator": () => import("@/features/toml-validator/panel").then((mod) => mod.default as ComponentType), diff --git a/core/registry/registry.generated.ts b/core/registry/registry.generated.ts index e1e8d65..378214e 100644 --- a/core/registry/registry.generated.ts +++ b/core/registry/registry.generated.ts @@ -19,6 +19,7 @@ import { manifest as batchAddressValidator } from "@/features/batch-address-vali import { manifest as claimableBalances } from "@/features/claimable-balances/manifest"; import { manifest as claimablePredicateBuilder } from "@/features/claimable-predicate-builder/manifest"; import { manifest as contractEvents } from "@/features/contract-events/manifest"; +import { manifest as duplicateTransactionScanner } from "@/features/duplicate-transaction-scanner/manifest"; import { manifest as effectsTimeline } from "@/features/effects-timeline/manifest"; import { manifest as explorerLinks } from "@/features/explorer-links/manifest"; import { manifest as federationResolver } from "@/features/federation-resolver/manifest"; @@ -33,8 +34,10 @@ import { manifest as liquidityPoolCalculator } from "@/features/liquidity-pool-c import { manifest as liquidityPoolInspector } from "@/features/liquidity-pool-inspector/manifest"; import { manifest as manageDataCodec } from "@/features/manage-data-codec/manifest"; import { manifest as memoInspector } from "@/features/memo-inspector/manifest"; +import { manifest as multisigAnalyzer } from "@/features/multisig-analyzer/manifest"; import { manifest as muxedAccountCodec } from "@/features/muxed-account-codec/manifest"; import { manifest as networkComparison } from "@/features/network-comparison/manifest"; +import { manifest as offerInspector } from "@/features/offer-inspector/manifest"; import { manifest as operationBrowser } from "@/features/operation-browser/manifest"; import { manifest as operationSourceMap } from "@/features/operation-source-map/manifest"; import { manifest as orderbookViewer } from "@/features/orderbook-viewer/manifest"; @@ -58,6 +61,7 @@ import { manifest as sorobanDecoder } from "@/features/soroban-decoder/manifest" import { manifest as sorobanFeeEstimator } from "@/features/soroban-fee-estimator/manifest"; import { manifest as sorobanSpecViewer } from "@/features/soroban-spec-viewer/manifest"; import { manifest as sponsoredReserves } from "@/features/sponsored-reserves/manifest"; +import { manifest as sponsorshipPlanner } from "@/features/sponsorship-planner/manifest"; import { manifest as strkeyInspector } from "@/features/strkey-inspector/manifest"; import { manifest as testnetFaucet } from "@/features/testnet-faucet/manifest"; import { manifest as tomlValidator } from "@/features/toml-validator/manifest"; @@ -89,6 +93,7 @@ export const generatedFeatures: FeatureEntry[] = [ { manifest: claimableBalances, load: async () => { const mod = await import("@/features/claimable-balances/panel"); return mod.default as ComponentType; } }, { manifest: claimablePredicateBuilder, load: async () => { const mod = await import("@/features/claimable-predicate-builder/panel"); return mod.default as ComponentType; } }, { manifest: contractEvents, load: async () => { const mod = await import("@/features/contract-events/panel"); return mod.default as ComponentType; } }, + { manifest: duplicateTransactionScanner, load: async () => { const mod = await import("@/features/duplicate-transaction-scanner/panel"); return mod.default as ComponentType; } }, { manifest: effectsTimeline, load: async () => { const mod = await import("@/features/effects-timeline/panel"); return mod.default as ComponentType; } }, { manifest: explorerLinks, load: async () => { const mod = await import("@/features/explorer-links/panel"); return mod.default as ComponentType; } }, { manifest: federationResolver, load: async () => { const mod = await import("@/features/federation-resolver/panel"); return mod.default as ComponentType; } }, @@ -103,8 +108,10 @@ export const generatedFeatures: FeatureEntry[] = [ { manifest: liquidityPoolInspector, load: async () => { const mod = await import("@/features/liquidity-pool-inspector/panel"); return mod.default as ComponentType; } }, { manifest: manageDataCodec, load: async () => { const mod = await import("@/features/manage-data-codec/panel"); return mod.default as ComponentType; } }, { manifest: memoInspector, load: async () => { const mod = await import("@/features/memo-inspector/panel"); return mod.default as ComponentType; } }, + { manifest: multisigAnalyzer, load: async () => { const mod = await import("@/features/multisig-analyzer/panel"); return mod.default as ComponentType; } }, { manifest: muxedAccountCodec, load: async () => { const mod = await import("@/features/muxed-account-codec/panel"); return mod.default as ComponentType; } }, { manifest: networkComparison, load: async () => { const mod = await import("@/features/network-comparison/panel"); return mod.default as ComponentType; } }, + { manifest: offerInspector, load: async () => { const mod = await import("@/features/offer-inspector/panel"); return mod.default as ComponentType; } }, { manifest: operationBrowser, load: async () => { const mod = await import("@/features/operation-browser/panel"); return mod.default as ComponentType; } }, { manifest: operationSourceMap, load: async () => { const mod = await import("@/features/operation-source-map/panel"); return mod.default as ComponentType; } }, { manifest: orderbookViewer, load: async () => { const mod = await import("@/features/orderbook-viewer/panel"); return mod.default as ComponentType; } }, @@ -128,6 +135,7 @@ export const generatedFeatures: FeatureEntry[] = [ { manifest: sorobanFeeEstimator, load: async () => { const mod = await import("@/features/soroban-fee-estimator/panel"); return mod.default as ComponentType; } }, { manifest: sorobanSpecViewer, load: async () => { const mod = await import("@/features/soroban-spec-viewer/panel"); return mod.default as ComponentType; } }, { manifest: sponsoredReserves, load: async () => { const mod = await import("@/features/sponsored-reserves/panel"); return mod.default as ComponentType; } }, + { manifest: sponsorshipPlanner, load: async () => { const mod = await import("@/features/sponsorship-planner/panel"); return mod.default as ComponentType; } }, { manifest: strkeyInspector, load: async () => { const mod = await import("@/features/strkey-inspector/panel"); return mod.default as ComponentType; } }, { manifest: testnetFaucet, load: async () => { const mod = await import("@/features/testnet-faucet/panel"); return mod.default as ComponentType; } }, { manifest: tomlValidator, load: async () => { const mod = await import("@/features/toml-validator/panel"); return mod.default as ComponentType; } }, diff --git a/core/status/ServiceStatusBanner.tsx b/core/status/ServiceStatusBanner.tsx new file mode 100644 index 0000000..a0138a4 --- /dev/null +++ b/core/status/ServiceStatusBanner.tsx @@ -0,0 +1,49 @@ +import { formatDateTime } from "@/core/format/date"; +import { StatusMessage, type StatusType } from "@/core/ui/StatusMessage"; +import { copy } from "./copy"; +import type { PublicServiceNotice, ServiceMode } from "./service-mode"; + +const TONE: Record, StatusType> = { + degraded: "warning", + maintenance: "info", + paused: "warning" +}; + +/** + * Site-wide notice for a degraded, paused or maintenance mode. Renders nothing + * in `normal`. It only ever receives a `PublicServiceNotice`, so there is no + * internal field it could display by mistake. + */ +export function ServiceStatusBanner({ notice }: { notice: PublicServiceNotice }) { + if (notice.mode === "normal") return null; + const mode = notice.mode; + + return ( + + + {notice.message ?? copy.banner.fallback[mode]} + {notice.until ? ( + + {copy.banner.until} {formatDateTime(notice.until)} + + ) : null} + + + {copy.banner.statusLink} + + + > + } + /> + + ); +} + +/** Replaces a tool's panel while the service is paused, so no request is made. */ +export function PausedToolNotice() { + return ; +} diff --git a/core/status/__tests__/ServiceStatusBanner.test.tsx b/core/status/__tests__/ServiceStatusBanner.test.tsx new file mode 100644 index 0000000..f2e37f7 --- /dev/null +++ b/core/status/__tests__/ServiceStatusBanner.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { copy } from "../copy"; +import { PausedToolNotice, ServiceStatusBanner } from "../ServiceStatusBanner"; +import { toPublicServiceNotice, type PublicServiceNotice, type ServiceModeState } from "../service-mode"; + +const NOW = Date.parse("2026-03-10T12:00:00.000Z"); +const UNTIL = "2026-03-10T13:00:00.000Z"; + +function notice(overrides: Partial = {}): PublicServiceNotice { + return toPublicServiceNotice( + { + mode: "maintenance", + message: "Upgrading the indexer.", + since: "2026-03-10T11:00:00.000Z", + until: UNTIL, + reason: "change:chg-7", + changedBy: "maintainer-ada-42", + ...overrides + }, + NOW + ); +} + +describe("ServiceStatusBanner", () => { + it("renders nothing in normal mode", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing once the window has passed", () => { + const expired = toPublicServiceNotice({ mode: "paused", message: null, since: null, until: UNTIL }, Date.parse(UNTIL)); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it.each(["degraded", "maintenance", "paused"] as const)("announces %s with its title and the operator's message", (mode) => { + render(); + const region = screen.getByRole("region", { name: copy.banner.label }); + expect(region).toHaveAttribute("data-service-mode", mode); + expect(screen.getByRole("status")).toHaveTextContent(copy.banner.title[mode]); + expect(screen.getByText("Upgrading the indexer.")).toBeInTheDocument(); + }); + + it("falls back to generic copy when there is no safe message", () => { + render(); + expect(screen.getByText(copy.banner.fallback.paused)).toBeInTheDocument(); + }); + + it("shows the expected end as a machine-readable time and links the status API", () => { + render(); + expect(screen.getByText(copy.banner.until).querySelector("time")).toHaveAttribute("datetime", UNTIL); + expect(screen.getByRole("link", { name: copy.banner.statusLink })).toHaveAttribute("href", "/api/status"); + }); + + it("never renders an internal field", () => { + const { container } = render(); + expect(container.textContent).not.toContain("chg-7"); + expect(container.textContent).not.toContain("maintainer-ada-42"); + }); + + it("has no axe violations in the degraded and paused states", async () => { + const degraded = render(); + await expectNoAxeViolations(degraded.container); + degraded.unmount(); + + const paused = render(); + await expectNoAxeViolations(paused.container); + }); +}); + +describe("PausedToolNotice", () => { + it("explains that no request was made", async () => { + const { container } = render(); + expect(screen.getByRole("status")).toHaveTextContent(copy.paused.title); + expect(screen.getByText(copy.paused.description)).toBeInTheDocument(); + await expectNoAxeViolations(container); + }); +}); diff --git a/core/status/__tests__/service-config.test.ts b/core/status/__tests__/service-config.test.ts new file mode 100644 index 0000000..be5abf3 --- /dev/null +++ b/core/status/__tests__/service-config.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + createCaptureSink, + resetTelemetrySink, + setTelemetrySink, + type TelemetryCaptureSink +} from "@/core/telemetry/telemetry"; +import { validatePublicStatusFeed } from "../feed"; +import { + buildStatusResponse, + currentServiceNotice, + serviceModeFromEnv, + serviceNoticeFromEnv, + type StatusResponseBody +} from "../service-config"; +import { validatePublicServiceNotice } from "../service-mode"; +import { GET } from "@/app/api/status/route"; + +const NOW = Date.parse("2026-03-10T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; +const iso = (ms: number): string => new Date(ms).toISOString(); +const secret = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).secret(); + +describe("serviceModeFromEnv", () => { + it("is normal when nothing is configured", () => { + expect(serviceModeFromEnv({})).toEqual({ state: expect.objectContaining({ mode: "normal" }), issues: [] }); + expect(serviceModeFromEnv({ UTIX_SERVICE_MODE: " Normal " }).state.mode).toBe("normal"); + }); + + it("reads a complete configuration", () => { + const { state, issues } = serviceModeFromEnv({ + UTIX_SERVICE_MODE: "MAINTENANCE", + UTIX_SERVICE_MESSAGE: "Upgrading the indexer.", + UTIX_SERVICE_SINCE: iso(NOW), + UTIX_SERVICE_UNTIL: iso(NOW + HOUR) + }); + expect(issues).toEqual([]); + expect(state).toMatchObject({ mode: "maintenance", message: "Upgrading the indexer.", since: iso(NOW), until: iso(NOW + HOUR) }); + }); + + it("fails visible on an unknown mode instead of silently reporting normal", () => { + expect(serviceModeFromEnv({ UTIX_SERVICE_MODE: "pasued" })).toMatchObject({ + state: { mode: "degraded" }, + issues: ["invalid_service_mode"] + }); + }); + + it("drops an unsafe message but keeps the mode", () => { + const { state, issues } = serviceModeFromEnv({ UTIX_SERVICE_MODE: "paused", UTIX_SERVICE_MESSAGE: `key ${secret}` }); + expect(state).toMatchObject({ mode: "paused", message: null }); + expect(issues).toEqual(["unsafe_service_message"]); + }); + + it("ignores an unparseable or inverted window rather than never applying the mode", () => { + expect(serviceModeFromEnv({ UTIX_SERVICE_MODE: "paused", UTIX_SERVICE_UNTIL: "friday" })).toMatchObject({ + state: { mode: "paused", until: null }, + issues: ["invalid_service_window"] + }); + expect( + serviceModeFromEnv({ UTIX_SERVICE_MODE: "paused", UTIX_SERVICE_SINCE: iso(NOW + HOUR), UTIX_SERVICE_UNTIL: iso(NOW) }) + ).toMatchObject({ state: { since: iso(NOW + HOUR), until: null }, issues: ["invalid_service_window"] }); + }); + + it("resolves the UI notice with the same clock rules as the API", () => { + const env = { UTIX_SERVICE_MODE: "paused", UTIX_SERVICE_UNTIL: iso(NOW + HOUR) }; + expect(serviceNoticeFromEnv(env, NOW).mode).toBe("paused"); + expect(serviceNoticeFromEnv(env, NOW + HOUR).mode).toBe("normal"); + }); +}); + +describe("buildStatusResponse", () => { + let sink: TelemetryCaptureSink; + + beforeEach(() => { + sink = createCaptureSink(); + setTelemetrySink(sink); + }); + + afterEach(() => resetTelemetrySink()); + + it("publishes an operational, uncacheable status by default", () => { + const response = buildStatusResponse({}, NOW); + expect(response.status).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + const body = response.body as StatusResponseBody; + expect(body.notice.mode).toBe("normal"); + expect(body.feed.status).toBe("operational"); + expect(body.feed.components).toEqual([{ id: "tools", name: "Stellar tools", status: "operational" }]); + }); + + it.each([ + ["degraded", "degraded"], + ["maintenance", "maintenance"], + ["paused", "major_outage"] + ])("keeps the notice and the feed consistent for %s", (mode, overall) => { + const body = buildStatusResponse({ UTIX_SERVICE_MODE: mode, UTIX_SERVICE_MESSAGE: "Working on it." }, NOW) + .body as StatusResponseBody; + expect(body.notice).toMatchObject({ mode, message: "Working on it." }); + expect(body.feed.status).toBe(overall); + expect(validatePublicServiceNotice(body.notice).ok).toBe(true); + expect(validatePublicStatusFeed(body.feed).ok).toBe(true); + }); + + it("never publishes a rejected value, and reports the problem as a code", () => { + const response = buildStatusResponse({ UTIX_SERVICE_MODE: "paused", UTIX_SERVICE_MESSAGE: `token=${secret}` }, NOW); + expect(JSON.stringify(response.body)).not.toContain(secret); + expect(JSON.stringify(sink.events)).not.toContain(secret); + expect(sink.find((e) => e.op === "status.service_mode_config")).toMatchObject({ + result: "failure", + errorCode: "unsafe_service_message" + }); + }); + + it("returns to normal once the configured window has passed", () => { + const env = { UTIX_SERVICE_MODE: "maintenance", UTIX_SERVICE_UNTIL: iso(NOW + HOUR) }; + expect((buildStatusResponse(env, NOW + HOUR).body as StatusResponseBody).feed.status).toBe("operational"); + }); + + it("answers 503 without detail when the feed would not validate", () => { + const response = buildStatusResponse({}, NOW, { + components: [{ id: "", name: "", status: "operational", visibility: "public" }], + incidents: [], + maintenance: [] + }); + expect(response).toMatchObject({ status: 503, body: { error: "status_unavailable" } }); + expect(sink.find((e) => e.op === "status.publish")?.errorCode).toBe("invalid_status_feed"); + }); +}); + +describe("GET /api/status", () => { + const original = { ...process.env }; + + afterEach(() => { + for (const key of Object.keys(process.env)) if (key.startsWith("UTIX_SERVICE_")) delete process.env[key]; + Object.assign(process.env, original); + }); + + it("serves the notice and feed from the environment at request time", async () => { + process.env.UTIX_SERVICE_MODE = "degraded"; + process.env.UTIX_SERVICE_MESSAGE = "Horizon is slow."; + + const response = GET(); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = (await response.json()) as StatusResponseBody; + expect(body.notice).toMatchObject({ mode: "degraded", message: "Horizon is slow." }); + expect(body.feed.status).toBe("degraded"); + + delete process.env.UTIX_SERVICE_MODE; + expect(((await GET().json()) as StatusResponseBody).notice.mode).toBe("normal"); + }); + + it("gives server components the same notice the API serves", () => { + process.env.UTIX_SERVICE_MODE = "paused"; + expect(currentServiceNotice().mode).toBe("paused"); + }); +}); diff --git a/core/status/__tests__/service-mode.test.ts b/core/status/__tests__/service-mode.test.ts new file mode 100644 index 0000000..b8eba28 --- /dev/null +++ b/core/status/__tests__/service-mode.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { createIsolatedAuditTrail, type AuditActor } from "@/core/audit/audit"; +import { + createCaptureSink, + resetTelemetrySink, + setTelemetrySink, + type TelemetryCaptureSink +} from "@/core/telemetry/telemetry"; +import { createWorkerFramework } from "@/core/workers/queue"; +import { buildPublicStatusFeed, worseComponentStatus } from "../feed"; +import { + applyServiceMode, + checkServiceMessage, + createServiceModeStore, + effectiveServiceMode, + NORMAL_SERVICE, + parseServiceModeChange, + PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION, + registerServiceModeJobs, + SERVICE_MESSAGE_MAX_LENGTH, + SERVICE_MODE_EXPIRY_OP, + toPublicServiceNotice, + validatePublicServiceNotice, + type ServiceModeState +} from "../service-mode"; +import type { TrustedStatusState } from "../types"; + +const NOW = Date.parse("2026-03-10T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; +const iso = (ms: number): string => new Date(ms).toISOString(); + +const maintainer: AuditActor = { kind: "maintainer", id: "ada" }; +const user: AuditActor = { kind: "user", id: "mallory" }; + +const secret = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).secret(); + +const paused: ServiceModeState = { + mode: "paused", + message: "Tools are paused while we investigate a Horizon issue.", + since: iso(NOW - HOUR), + until: iso(NOW + HOUR), + reason: "incident:inc-42", + changedBy: "ada" +}; + +const trusted: TrustedStatusState = { + components: [ + { id: "tools", name: "Stellar tools", status: "operational", visibility: "public" }, + { id: "horizon", name: "Horizon", status: "partial_outage", visibility: "public" }, + { + id: "worker-queue", + name: "Internal worker queue", + status: "operational", + visibility: "internal", + diagnostics: { host: "10.0.0.5" } + } + ], + incidents: [], + maintenance: [] +}; + +function trailBackedStore(initial?: ServiceModeState, trail = createIsolatedAuditTrail()) { + let now = NOW; + const store = createServiceModeStore({ + initial, + now: () => now, + record: (input) => { + const result = trail.record(input); + return result.ok ? result.value : undefined; + } + }); + return { store, trail, advance: (ms: number) => (now += ms) }; +} + +describe("checkServiceMessage", () => { + it("accepts and normalises plain public text", () => { + expect(checkServiceMessage(" Horizon is slow.\n Retrying helps. ")).toEqual({ + ok: true, + value: "Horizon is slow. Retrying helps." + }); + }); + + it.each([ + ["", "service_message_required"], + [" ", "service_message_required"], + [undefined, "service_message_required"], + [42, "service_message_required"], + ["x".repeat(SERVICE_MESSAGE_MAX_LENGTH + 1), "service_message_too_long"] + ])("rejects %j with %s", (value, code) => { + expect(checkServiceMessage(value)).toMatchObject({ ok: false, code }); + }); + + it.each([ + [`Rotate ${secret} now`], + ["Use Bearer abc.def.ghi to retry"], + ["See https://ops:hunter2@status.internal/"], + ["api_key=12345 was revoked"], + ["password: swordfish"], + ["Upstream 10.0.0.5 is down"], + ["Upstream 192.168.1.20 is down"], + ["Upstream 172.20.1.4 is down"], + ["Proxy on localhost failed"], + ["Error at run (worker.ts:42:7)"] + ])("refuses a message that leaks internals: %s", (value) => { + expect(checkServiceMessage(value)).toMatchObject({ ok: false, code: "unsafe_service_message" }); + }); + + it("does not mistake public addresses and public hosts for secrets", () => { + const address = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)).publicKey(); + expect(checkServiceMessage(`Lookups for ${address} on horizon.stellar.org are slow.`).ok).toBe(true); + }); +}); + +describe("effectiveServiceMode", () => { + it("is the stored mode inside its window", () => { + expect(effectiveServiceMode(paused, NOW)).toBe(paused); + }); + + it("is normal before a scheduled start and from the end onwards", () => { + expect(effectiveServiceMode({ ...paused, since: iso(NOW + 1) }, NOW)).toEqual(NORMAL_SERVICE); + expect(effectiveServiceMode(paused, Date.parse(paused.until!))).toEqual(NORMAL_SERVICE); + }); + + it("keeps an open-ended mode indefinitely", () => { + expect(effectiveServiceMode({ ...paused, until: null }, NOW + 1000 * HOUR).mode).toBe("paused"); + }); +}); + +describe("toPublicServiceNotice", () => { + it("publishes only the public fields", () => { + const notice = toPublicServiceNotice(paused, NOW); + expect(notice).toEqual({ + schemaVersion: PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION, + mode: "paused", + message: paused.message, + since: paused.since, + until: paused.until + }); + const json = JSON.stringify(notice); + expect(json).not.toContain("incident:inc-42"); + expect(json).not.toContain("ada"); + expect(validatePublicServiceNotice(notice).ok).toBe(true); + }); + + it("publishes a bare normal notice once the window has passed", () => { + expect(toPublicServiceNotice(paused, NOW + 2 * HOUR)).toEqual({ + schemaVersion: PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION, + mode: "normal", + message: null, + since: null, + until: null + }); + }); +}); + +describe("validatePublicServiceNotice", () => { + const notice = toPublicServiceNotice(paused, NOW); + + it.each([ + [null, "$"], + [{ ...notice, reason: "incident:inc-42" }, "$"], + [{ ...notice, schemaVersion: "2.0.0" }, "schemaVersion"], + [{ ...notice, mode: "offline" }, "mode"], + [{ ...notice, message: `leak ${secret}` }, "message"], + [{ ...notice, until: "tomorrow" }, "until"] + ])("rejects an invalid notice at %j", (value, path) => { + expect(validatePublicServiceNotice(value)).toEqual({ ok: false, code: "invalid_service_notice", detail: path }); + }); +}); + +describe("applyServiceMode", () => { + it.each([ + ["degraded", "degraded"], + ["maintenance", "under_maintenance"], + ["paused", "major_outage"] + ] as const)("raises public components for %s", (mode, status) => { + const applied = applyServiceMode(trusted, { ...paused, mode }, NOW); + expect(applied.components.find((c) => c.id === "tools")?.status).toBe(status); + }); + + it("never lowers a component that is already worse", () => { + const applied = applyServiceMode(trusted, { ...paused, mode: "degraded" }, NOW); + expect(applied.components.find((c) => c.id === "horizon")?.status).toBe("partial_outage"); + expect(worseComponentStatus("major_outage", "under_maintenance")).toBe("major_outage"); + }); + + it("leaves internal components and the normal mode alone", () => { + const applied = applyServiceMode(trusted, paused, NOW); + expect(applied.components.find((c) => c.id === "worker-queue")?.status).toBe("operational"); + expect(applyServiceMode(trusted, NORMAL_SERVICE, NOW)).toBe(trusted); + }); + + it("moves the overall feed status, and lets it recover after `until`", () => { + expect(buildPublicStatusFeed(applyServiceMode(trusted, paused, NOW), NOW).status).toBe("major_outage"); + const later = NOW + 2 * HOUR; + expect(buildPublicStatusFeed(applyServiceMode(trusted, paused, later), later).status).toBe("partial_outage"); + }); +}); + +describe("parseServiceModeChange", () => { + const request = { mode: "maintenance", message: "Upgrading the indexer.", reason: "change:chg-7" }; + + it("defaults since to now and accepts an open-ended mode", () => { + expect(parseServiceModeChange(request, NOW)).toEqual({ + ok: true, + value: { mode: "maintenance", message: "Upgrading the indexer.", since: iso(NOW), until: null, reason: "change:chg-7" } + }); + }); + + it("drops the message when returning to normal", () => { + expect(parseServiceModeChange({ mode: "normal", message: "ignored", reason: "resolved" }, NOW)).toEqual({ + ok: true, + value: { ...NORMAL_SERVICE, reason: "resolved" } + }); + }); + + it.each([ + [{ ...request, mode: "offline" }, "invalid_service_mode"], + [{ ...request, reason: "Because I said so" }, "invalid_service_reason"], + [{ ...request, message: undefined }, "service_message_required"], + [{ ...request, message: `use ${secret}` }, "unsafe_service_message"], + [{ ...request, until: "soon" }, "invalid_service_window"], + [{ ...request, until: iso(NOW - 1) }, "invalid_service_window"], + [{ ...request, since: iso(NOW + 2 * HOUR), until: iso(NOW + HOUR) }, "invalid_service_window"] + ])("rejects %j with %s", (value, code) => { + expect(parseServiceModeChange(value, NOW)).toMatchObject({ ok: false, code }); + }); +}); + +describe("createServiceModeStore", () => { + let sink: TelemetryCaptureSink; + + beforeEach(() => { + sink = createCaptureSink(); + setTelemetrySink(sink); + }); + + afterEach(() => resetTelemetrySink()); + + it("applies a maintainer's change after auditing it", () => { + const { store, trail } = trailBackedStore(); + const result = store.change(maintainer, { + mode: "degraded", + message: "Horizon is slow.", + until: iso(NOW + HOUR), + reason: "incident:inc-42" + }); + + expect(result.ok).toBe(true); + expect(store.current().mode).toBe("degraded"); + expect(store.stored().changedBy).toBe("ada"); + + const [event] = trail.all(); + expect(event).toMatchObject({ + action: "service_mode.changed", + outcome: "allowed", + actorId: "ada", + reason: "incident:inc-42", + target: { kind: "service_mode", id: "app" }, + before: { mode: "normal", until: null }, + after: { mode: "degraded", until: iso(NOW + HOUR) } + }); + // The public message is not copied into the trail. + expect(JSON.stringify(event)).not.toContain("Horizon is slow"); + expect(sink.find((e) => e.op === "status.service_mode_change")).toMatchObject({ + result: "success", + payload: { from: "normal", to: "degraded" } + }); + }); + + it("refuses a plain user and records the attempt", () => { + const { store, trail } = trailBackedStore(); + expect(store.change(user, { mode: "paused", message: "Paused.", reason: "prank" })).toEqual({ + ok: false, + code: "service_mode_forbidden" + }); + expect(store.current().mode).toBe("normal"); + expect(trail.all()[0]).toMatchObject({ outcome: "denied", errorCode: "service_mode_forbidden" }); + expect(sink.latest()).toMatchObject({ result: "failure", errorCode: "service_mode_forbidden" }); + }); + + it("records an invalid request as denied without changing the mode", () => { + const { store, trail } = trailBackedStore(paused); + expect(store.change(maintainer, { mode: "degraded", message: `oops ${secret}`, reason: "incident:inc-42" })).toEqual({ + ok: false, + code: "unsafe_service_message" + }); + expect(store.stored()).toBe(paused); + expect(JSON.stringify(trail.all())).not.toContain(secret); + expect(JSON.stringify(sink.events)).not.toContain(secret); + }); + + it("does not change the mode when the audit trail rejects the event", () => { + const store = createServiceModeStore({ now: () => NOW, record: () => undefined }); + expect(store.change(maintainer, { mode: "paused", message: "Paused.", reason: "incident:inc-1" })).toEqual({ + ok: false, + code: "service_mode_audit_rejected" + }); + expect(store.current().mode).toBe("normal"); + }); + + it("lets the system actor set and clear the mode", () => { + const { store } = trailBackedStore(); + expect(store.change({ kind: "system", id: "rollout-guard" }, { mode: "degraded", message: "Rolling back.", reason: "rollback" }).ok).toBe(true); + expect(store.change(maintainer, { mode: "normal", reason: "resolved" }).ok).toBe(true); + expect(store.notice().mode).toBe("normal"); + }); + + it("expires a lapsed mode once, recording the automatic recovery", () => { + const { store, trail, advance } = trailBackedStore(paused); + expect(store.expire()).toBe(false); + + advance(HOUR); + expect(store.expire()).toBe(true); + expect(store.expire()).toBe(false); + expect(store.stored().mode).toBe("normal"); + expect(trail.all().map((event) => [event.action, event.actorId, event.reason])).toEqual([ + ["service_mode.expired", SERVICE_MODE_EXPIRY_OP, "service_mode:until_reached"] + ]); + expect(sink.find((e) => e.op === "status.service_mode_expired")?.actorType).toBe("system"); + }); +}); + +describe("status.expire_service_mode background job", () => { + it("returns a lapsed mode to normal when the worker runs it", () => { + const { store, trail, advance } = trailBackedStore(paused); + const framework = registerServiceModeJobs(createWorkerFramework(), store); + + advance(2 * HOUR); + framework.enqueue({ operation: SERVICE_MODE_EXPIRY_OP }); + const summary = framework.drainDueJobs(); + + expect(summary.succeeded).toBe(1); + expect(store.stored().mode).toBe("normal"); + expect(trail.all()).toHaveLength(1); + }); + + it("is a harmless no-op when run early", () => { + const { store, trail } = trailBackedStore(paused); + const framework = registerServiceModeJobs(createWorkerFramework(), store); + + framework.enqueue({ operation: SERVICE_MODE_EXPIRY_OP }); + expect(framework.drainDueJobs().succeeded).toBe(1); + expect(store.stored()).toBe(paused); + expect(trail.all()).toHaveLength(0); + }); +}); diff --git a/core/status/copy.ts b/core/status/copy.ts new file mode 100644 index 0000000..12292dd --- /dev/null +++ b/core/status/copy.ts @@ -0,0 +1,26 @@ +import type { ServiceMode } from "./service-mode"; + +type ActiveMode = Exclude; + +export const copy = { + banner: { + label: "Service status", + title: { + degraded: "Some tools are slower or less reliable than usual", + maintenance: "Scheduled maintenance in progress", + paused: "Tools are temporarily paused" + } satisfies Record, + // Shown when the operator gave no message, or one that failed the safety check. + fallback: { + degraded: "Results may take longer or fail intermittently. Retrying usually helps.", + maintenance: "Some tools may be unavailable for a short time.", + paused: "No tool will make a request until the pause is lifted. Please check back later." + } satisfies Record, + until: "Expected to end by", + statusLink: "Machine-readable status" + }, + paused: { + title: "This tool is paused", + description: "All tools are temporarily paused while the maintainers investigate. No request has been made." + } +} as const; diff --git a/core/status/feed.ts b/core/status/feed.ts index dfc37c7..a368b12 100644 --- a/core/status/feed.ts +++ b/core/status/feed.ts @@ -77,6 +77,15 @@ function worst(statuses: readonly OverallStatus[]): OverallStatus { return OVERALL_STATUSES[rank]; } +/** + * The worse of two component statuses, by the same ranking the overall status + * uses. Raising a component to `floor` never hides an outage it already has. + */ +export function worseComponentStatus(current: ComponentStatus, floor: ComponentStatus): ComponentStatus { + const rank = (status: ComponentStatus) => OVERALL_STATUSES.indexOf(COMPONENT_RANK[status]); + return rank(floor) > rank(current) ? floor : current; +} + /** The state of a maintenance window at `nowMs`, from the clock alone. */ export function maintenanceStateAt(window: Pick, nowMs: number): MaintenanceState { if (nowMs < Date.parse(window.startsAt)) return "scheduled"; diff --git a/core/status/index.ts b/core/status/index.ts index 2ded46c..dfda860 100644 --- a/core/status/index.ts +++ b/core/status/index.ts @@ -1,2 +1,4 @@ export * from "./types"; export * from "./feed"; +export * from "./service-mode"; +export * from "./service-config"; diff --git a/core/status/service-config.ts b/core/status/service-config.ts new file mode 100644 index 0000000..ff73b71 --- /dev/null +++ b/core/status/service-config.ts @@ -0,0 +1,161 @@ +/** + * Deployment configuration for the service mode, and the public status + * response built from it (#221). + * + * Operators set the mode with environment variables, so a pause or a + * maintenance notice needs no code change and no database: + * + * | Variable | Value | + * | --------------------- | --------------------------------------------- | + * | `UTIX_SERVICE_MODE` | `normal` (default), `degraded`, `paused`, `maintenance` | + * | `UTIX_SERVICE_MESSAGE` | Public text, at most 280 characters | + * | `UTIX_SERVICE_SINCE` | Optional ISO-8601 start; a future start schedules the mode | + * | `UTIX_SERVICE_UNTIL` | Optional ISO-8601 end; the mode lapses to `normal` after it | + * + * A bad value never throws and never publishes the bad value. An unknown mode + * fails *visible* — it is published as `degraded` rather than silently as + * `normal`, because the operator was clearly trying to say something was + * wrong. An unsafe message is dropped and the UI falls back to the generic copy + * for the mode. Every such problem is emitted as telemetry by the response + * builder. + */ + +import { emitTelemetry } from "@/core/telemetry/telemetry"; +import { publishPublicStatusFeed } from "./feed"; +import { + applyServiceMode, + checkServiceMessage, + isServiceMode, + NORMAL_SERVICE, + toPublicServiceNotice, + validatePublicServiceNotice, + type PublicServiceNotice, + type ServiceModeErrorCode, + type ServiceModeState +} from "./service-mode"; +import type { PublicStatusFeed, TrustedComponent, TrustedStatusState } from "./types"; + +export const SERVICE_MODE_ENV = { + mode: "UTIX_SERVICE_MODE", + message: "UTIX_SERVICE_MESSAGE", + since: "UTIX_SERVICE_SINCE", + until: "UTIX_SERVICE_UNTIL" +} as const; + +export type ServiceEnv = Readonly>; + +export interface ServiceModeConfig { + readonly state: ServiceModeState; + /** Problems found in the configuration, as codes. Never the values. */ + readonly issues: readonly ServiceModeErrorCode[]; +} + +function isIso(value: string): boolean { + const parsed = Date.parse(value); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === value; +} + +/** Reads the service mode from the environment. Never throws. */ +export function serviceModeFromEnv(env: ServiceEnv): ServiceModeConfig { + const issues: ServiceModeErrorCode[] = []; + const raw = env[SERVICE_MODE_ENV.mode]?.trim().toLowerCase() ?? ""; + if (raw === "" || raw === "normal") return { state: NORMAL_SERVICE, issues }; + + let mode = raw; + if (!isServiceMode(mode)) { + issues.push("invalid_service_mode"); + mode = "degraded"; + } + + const rawMessage = env[SERVICE_MODE_ENV.message]; + let message: string | null = null; + if (rawMessage !== undefined && rawMessage.trim() !== "") { + const checked = checkServiceMessage(rawMessage); + if (checked.ok) message = checked.value; + else issues.push(checked.code); + } + + const window = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + if (!trimmed) return null; + if (isIso(trimmed)) return trimmed; + issues.push("invalid_service_window"); + return null; + }; + const since = window(env[SERVICE_MODE_ENV.since]); + let until = window(env[SERVICE_MODE_ENV.until]); + if (since && until && Date.parse(until) <= Date.parse(since)) { + // An inverted window would make the mode never apply; keep the mode open + // ended instead, so the operator's intent is still published. + issues.push("invalid_service_window"); + until = null; + } + + return { state: { mode: mode as ServiceModeState["mode"], message, since, until, reason: "config:env" }, issues }; +} + +/** + * The single published component when no richer trusted state is supplied. + * The app is one static deployment, so "the tools" is what a user can observe. + */ +export const DEFAULT_STATUS_STATE: TrustedStatusState = { + components: [{ id: "tools", name: "Stellar tools", status: "operational", visibility: "public" } satisfies TrustedComponent], + incidents: [], + maintenance: [] +}; + +export interface StatusResponseBody { + readonly notice: PublicServiceNotice; + readonly feed: PublicStatusFeed; +} + +export interface StatusResponse { + readonly status: 200 | 503; + readonly headers: Readonly>; + readonly body: StatusResponseBody | { readonly error: "status_unavailable" }; +} + +const HEADERS = { + "content-type": "application/json; charset=utf-8", + // Status must be current: a cached "operational" during an outage is wrong. + "cache-control": "no-store" +} as const; + +/** + * Builds the `GET /api/status` response. Pure apart from telemetry: the route + * passes `process.env` and the clock. + */ +export function buildStatusResponse( + env: ServiceEnv, + now: number | Date, + trusted: TrustedStatusState = DEFAULT_STATUS_STATE +): StatusResponse { + const config = serviceModeFromEnv(env); + for (const issue of config.issues) { + emitTelemetry({ op: "status.service_mode_config", actorType: "system", result: "failure", errorCode: issue }); + } + + const notice = validatePublicServiceNotice(toPublicServiceNotice(config.state, now)); + const feed = publishPublicStatusFeed(applyServiceMode(trusted, config.state, now), now); + + if (!notice.ok || !feed.ok) { + const code = !notice.ok ? notice.code : !feed.ok ? feed.code : undefined; + emitTelemetry({ op: "status.publish", actorType: "system", result: "failure", errorCode: code }); + return { status: 503, headers: HEADERS, body: { error: "status_unavailable" } }; + } + + return { status: 200, headers: HEADERS, body: { notice: notice.value, feed: feed.value } }; +} + +/** The notice the UI renders, resolved the same way the API resolves it. */ +export function serviceNoticeFromEnv(env: ServiceEnv, now: number | Date): PublicServiceNotice { + return toPublicServiceNotice(serviceModeFromEnv(env).state, now); +} + +/** + * The notice in force right now, for server components. Reading the clock and + * the environment here keeps both out of the render body. + */ +export function currentServiceNotice(): PublicServiceNotice { + return serviceNoticeFromEnv(process.env, Date.now()); +} diff --git a/core/status/service-mode.ts b/core/status/service-mode.ts new file mode 100644 index 0000000..4d7d770 --- /dev/null +++ b/core/status/service-mode.ts @@ -0,0 +1,351 @@ +/** + * Safe public service mode: degraded, paused or maintenance (#221). + * + * The public status feed (#184) derives health from components, incidents and + * maintenance windows. This module adds the operator's switch on top of it: one + * app-wide mode with a short public message, set by a maintainer, audited, and + * published through the same "trusted in, public out" split the feed uses. + * + * - `ServiceModeState` is trusted. It may carry an internal `reason` and the + * id of whoever set it. `toPublicServiceNotice` copies only the public + * fields, so neither can reach a user. + * - The public message is operator-written text, so it is checked before it is + * accepted: a Stellar secret, bearer token, credential URL, `key=value` + * secret, private network address or stack frame is refused outright rather + * than scrubbed, because a half-redacted message is still a leak. + * - A mode can carry `since` and `until`. Outside that window the effective + * mode is `normal`, from the clock alone, so a forgotten maintenance mode + * recovers by itself. `createServiceModeStore().expire()` records that + * recovery in the audit trail; the `status.expire_service_mode` worker job + * runs it in the background. + * - Every change is audited before it is applied. If the trail rejects the + * event, the mode does not change. + */ + +import { recordAudit, type AuditActor, type AuditEvent, type AuditInput } from "@/core/audit/audit"; +import { err, ok, type Result } from "@/core/result/result"; +import { emitTelemetry, type ActorType } from "@/core/telemetry/telemetry"; +import type { WorkerFramework } from "@/core/workers/queue"; +import { worseComponentStatus } from "./feed"; +import type { ComponentStatus, TrustedStatusState } from "./types"; + +export const SERVICE_MODES = ["normal", "degraded", "paused", "maintenance"] as const; +export type ServiceMode = (typeof SERVICE_MODES)[number]; + +export const PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION = "1.0.0"; + +/** Background job that records a mode's automatic recovery. */ +export const SERVICE_MODE_EXPIRY_OP = "status.expire_service_mode"; + +/** Long enough for a sentence and an ETA, short enough for a banner. */ +export const SERVICE_MESSAGE_MAX_LENGTH = 280; + +export type ServiceMessageErrorCode = "service_message_required" | "service_message_too_long" | "unsafe_service_message"; + +export type ServiceModeErrorCode = + | ServiceMessageErrorCode + | "invalid_service_mode" + | "invalid_service_window" + | "invalid_service_reason" + | "service_mode_forbidden" + | "service_mode_audit_rejected"; + +export interface ServiceModeState { + readonly mode: ServiceMode; + /** Public. Shown to every user as written. */ + readonly message: string | null; + /** ISO-8601 instants bounding the mode; `null` means unbounded. */ + readonly since: string | null; + readonly until: string | null; + /** Internal-only: why the mode was set, e.g. `incident:inc-42`. Never published. */ + readonly reason?: string; + /** Internal-only: who set it. Never published. */ + readonly changedBy?: string; +} + +export interface PublicServiceNotice { + readonly schemaVersion: typeof PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION; + readonly mode: ServiceMode; + readonly message: string | null; + readonly since: string | null; + readonly until: string | null; +} + +export const NORMAL_SERVICE: ServiceModeState = { mode: "normal", message: null, since: null, until: null }; + +/** How each mode raises the published components in the status feed. */ +export const SERVICE_MODE_COMPONENT_STATUS: Record, ComponentStatus> = { + degraded: "degraded", + maintenance: "under_maintenance", + // Paused means the tools are deliberately unavailable, which is what a user + // experiences as an outage. + paused: "major_outage" +}; + +export function isServiceMode(value: unknown): value is ServiceMode { + return typeof value === "string" && (SERVICE_MODES as readonly string[]).includes(value); +} + +const UNSAFE_MESSAGE_PATTERNS: readonly RegExp[] = [ + // Stellar secret seed or muxed secret. + /[SM][A-Z2-7]{55}/, + /\bBearer\s+\S+/i, + // Credentials inside a URL: scheme://user:pass@host. + /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i, + // key=value or key: value for anything that names a secret. + /\b(?:secret|token|password|passphrase|api[_-]?key|private[_-]?key|seed)\b\s*[:=]/i, + // Private, loopback and link-local addresses: an internal host is diagnostics. + /\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, + /\b192\.168\.\d{1,3}\.\d{1,3}\b/, + /\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/, + /\b169\.254\.\d{1,3}\.\d{1,3}\b/, + /\blocalhost\b/i, + // A stack frame such as "at run (worker.ts:42" or "worker.ts:42:7". + /\b[\w./-]+\.(?:ts|tsx|js|mjs|cjs):\d+/ +]; + +/** Validates operator-written public text. Refuses rather than scrubs. */ +export function checkServiceMessage(value: unknown): Result { + const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; + if (!text) return err("service_message_required"); + if (text.length > SERVICE_MESSAGE_MAX_LENGTH) return err("service_message_too_long"); + if (UNSAFE_MESSAGE_PATTERNS.some((pattern) => pattern.test(text))) return err("unsafe_service_message"); + return ok(text); +} + +function isIso(value: unknown): value is string { + if (typeof value !== "string") return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === value; +} + +/** The mode in force at `nowMs`: `normal` outside the `since`–`until` window. */ +export function effectiveServiceMode(state: ServiceModeState, nowMs: number): ServiceModeState { + if (state.mode === "normal") return state; + if (state.since && nowMs < Date.parse(state.since)) return NORMAL_SERVICE; + if (state.until && nowMs >= Date.parse(state.until)) return NORMAL_SERVICE; + return state; +} + +/** The published notice for `now`. Copies only the public fields. */ +export function toPublicServiceNotice(state: ServiceModeState, now: number | Date): PublicServiceNotice { + const effective = effectiveServiceMode(state, typeof now === "number" ? now : now.getTime()); + return { + schemaVersion: PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION, + mode: effective.mode, + message: effective.mode === "normal" ? null : effective.message, + since: effective.mode === "normal" ? null : effective.since, + until: effective.mode === "normal" ? null : effective.until + }; +} + +/** + * The notice schema: exactly these keys, known values, ISO instants, and a + * message that passes the same safety check an operator's input does. + */ +export function validatePublicServiceNotice(value: unknown): Result { + const invalid = (path: string) => err("invalid_service_notice" as const, path); + if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid("$"); + const fields = value as Record; + const keys = ["schemaVersion", "mode", "message", "since", "until"]; + if (Object.keys(fields).length !== keys.length || !keys.every((key) => key in fields)) return invalid("$"); + if (fields.schemaVersion !== PUBLIC_SERVICE_NOTICE_SCHEMA_VERSION) return invalid("schemaVersion"); + if (!isServiceMode(fields.mode)) return invalid("mode"); + if (fields.message !== null && !checkServiceMessage(fields.message).ok) return invalid("message"); + if (fields.since !== null && !isIso(fields.since)) return invalid("since"); + if (fields.until !== null && !isIso(fields.until)) return invalid("until"); + return ok(value as PublicServiceNotice); +} + +/** + * Projects the effective mode onto trusted status state, so the feed's + * per-component and overall status agree with the notice. Components are only + * raised, never lowered, and internal components are left alone. + */ +export function applyServiceMode( + state: TrustedStatusState, + serviceMode: ServiceModeState, + now: number | Date +): TrustedStatusState { + const effective = effectiveServiceMode(serviceMode, typeof now === "number" ? now : now.getTime()); + if (effective.mode === "normal") return state; + const floor = SERVICE_MODE_COMPONENT_STATUS[effective.mode]; + return { + ...state, + components: state.components.map((component) => + component.visibility === "public" + ? { ...component, status: worseComponentStatus(component.status, floor) } + : component + ) + }; +} + +export interface ServiceModeChangeRequest { + readonly mode: unknown; + readonly message?: unknown; + /** ISO-8601; defaults to now. A future `since` schedules the mode. */ + readonly since?: unknown; + /** ISO-8601; must be after `since`. Omit for an open-ended mode. */ + readonly until?: unknown; + /** Internal reason code, e.g. `incident:inc-42`. Audited, never published. */ + readonly reason: string; +} + +const REASON_PATTERN = /^[a-z0-9_.:-]{1,64}$/; + +/** + * Validates a change request into the next state. Pure: authorization, audit + * and telemetry happen in the store. + */ +export function parseServiceModeChange( + request: ServiceModeChangeRequest, + nowMs: number +): Result> { + if (!isServiceMode(request.mode)) return err("invalid_service_mode"); + if (typeof request.reason !== "string" || !REASON_PATTERN.test(request.reason)) return err("invalid_service_reason"); + if (request.mode === "normal") return ok({ ...NORMAL_SERVICE, reason: request.reason }); + + const message = checkServiceMessage(request.message); + if (!message.ok) return message; + + if (request.since !== undefined && !isIso(request.since)) return err("invalid_service_window"); + if (request.until !== undefined && !isIso(request.until)) return err("invalid_service_window"); + const since = (request.since as string | undefined) ?? new Date(nowMs).toISOString(); + const until = (request.until as string | undefined) ?? null; + if (until && (Date.parse(until) <= Date.parse(since) || Date.parse(until) <= nowMs)) { + return err("invalid_service_window"); + } + + return ok({ mode: request.mode, message: message.value, since, until, reason: request.reason }); +} + +export interface ServiceModeStore { + /** The stored state, which may be outside its window. */ + readonly stored: () => ServiceModeState; + /** The state in force at `now`. */ + readonly current: (now?: number) => ServiceModeState; + readonly notice: (now?: number) => PublicServiceNotice; + /** Maintainers and the system may change the mode; users may not. */ + readonly change: (actor: AuditActor, request: ServiceModeChangeRequest) => Result; + /** + * Returns the store to `normal` if the stored mode's `until` has passed, and + * records that recovery. `true` when it did. + */ + readonly expire: (now?: number) => boolean; +} + +export interface ServiceModeStoreOptions { + readonly initial?: ServiceModeState; + readonly now?: () => number; + /** + * Test seam: substitutes the audit recorder. When it returns `undefined` the + * store behaves as if the audit trail rejected the event and does not apply + * the change. + */ + readonly record?: (input: AuditInput) => AuditEvent | undefined; +} + +function telemetryActor(actor: AuditActor): ActorType { + return actor.kind === "system" ? "system" : "user"; +} + +export function createServiceModeStore(options: ServiceModeStoreOptions = {}): ServiceModeStore { + const record = options.record ?? recordAudit; + const clock = options.now ?? Date.now; + let state: ServiceModeState = options.initial ?? NORMAL_SERVICE; + + interface ServiceModeAudit { + readonly action: "service_mode.changed" | "service_mode.expired"; + readonly actor: AuditActor; + readonly next: { readonly mode: unknown; readonly until?: unknown }; + readonly outcome: "allowed" | "denied"; + readonly reason: string | undefined; + readonly errorCode?: ServiceModeErrorCode; + } + + function audit({ action, actor, next, outcome, reason, errorCode }: ServiceModeAudit): AuditEvent | undefined { + return record({ + action, + actor, + scope: "maintainer", + target: { kind: "service_mode", id: "app" }, + reason: reason && REASON_PATTERN.test(reason) ? reason : undefined, + outcome, + before: { mode: state.mode, until: state.until }, + after: { mode: typeof next.mode === "string" ? next.mode : null, until: typeof next.until === "string" ? next.until : null }, + errorCode + }); + } + + function report(op: string, actor: AuditActor, from: ServiceMode, to: unknown, errorCode?: ServiceModeErrorCode): void { + emitTelemetry({ + op, + actorType: telemetryActor(actor), + result: errorCode ? "failure" : "success", + errorCode, + payload: { from, to: isServiceMode(to) ? to : "invalid" } + }); + } + + const store: ServiceModeStore = { + stored: () => state, + current: (now = clock()) => effectiveServiceMode(state, now), + notice: (now = clock()) => toPublicServiceNotice(state, now), + + change(actor, request) { + const deny = (code: ServiceModeErrorCode) => { + audit({ action: "service_mode.changed", actor, next: request, outcome: "denied", reason: request.reason, errorCode: code }); + report("status.service_mode_change", actor, state.mode, request.mode, code); + return err(code); + }; + + if (actor.kind === "user") return deny("service_mode_forbidden"); + + const parsed = parseServiceModeChange(request, clock()); + if (!parsed.ok) return deny(parsed.code); + + const next: ServiceModeState = { + ...parsed.value, + changedBy: actor.kind === "maintainer" ? actor.id : (actor.id ?? "system") + }; + + // Audit-first: nothing changes unless the event is on the trail. + if (!audit({ action: "service_mode.changed", actor, next, outcome: "allowed", reason: request.reason })) { + report("status.service_mode_change", actor, state.mode, next.mode, "service_mode_audit_rejected"); + return err("service_mode_audit_rejected"); + } + + report("status.service_mode_change", actor, state.mode, next.mode); + state = next; + return ok(state); + }, + + expire(now = clock()) { + if (state.mode === "normal" || !state.until || now < Date.parse(state.until)) return false; + const actor: AuditActor = { kind: "system", id: SERVICE_MODE_EXPIRY_OP }; + const reason = "service_mode:until_reached"; + if (!audit({ action: "service_mode.expired", actor, next: NORMAL_SERVICE, outcome: "allowed", reason })) return false; + report("status.service_mode_expired", actor, state.mode, "normal"); + state = { ...NORMAL_SERVICE, reason, changedBy: SERVICE_MODE_EXPIRY_OP }; + return true; + } + }; + + return store; +} + +/** + * Registers the expiry job on a worker framework. Schedule it at (or just + * after) a mode's `until`; running it early or twice is a harmless no-op. + */ +export function registerServiceModeJobs(framework: WorkerFramework, store: ServiceModeStore): WorkerFramework { + framework.register( + SERVICE_MODE_EXPIRY_OP, + (_params, context) => { + context.signal.throwIfAborted(); + store.expire(); + }, + { maxAttempts: 3 } + ); + return framework; +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3871bdf..c09d182 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ stable **core** and any number of independent **feature slices**. ``` core/ shared kernel — changes rarely, reviewed carefully features/ one directory per tool — added freely, never collides -app/ three files: layout, dashboard, and one dynamic tool route +app/ layout, dashboard, one dynamic tool route, and GET /api/status scripts/ registry generation, scaffolding, contract verification ``` @@ -24,6 +24,7 @@ scripts/ registry generation, scaffolding, contract verification | `core/audit` | Append-only audit trail for sensitive user and maintainer actions | | `core/export` | Privacy-safe, scoped, schema-versioned data exports | | `core/contract` | API contract schemas and drift detection | +| `core/status` | Public status feed and the operator service mode (degraded, paused, maintenance) behind `/api/status` and the site banner | | `core/network` | Network selection, URLs, passphrases, `NetworkProvider` | | `core/horizon` | Memoised Horizon client and the shared error taxonomy | | `core/rpc` | Minimal Soroban JSON-RPC caller | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c7c24c5..1899359 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -21,7 +21,21 @@ NEXT_PUBLIC_HORIZON_MAINNET_URL=https://horizon.stellar.org NEXT_PUBLIC_APP_URL=https://your-vercel-domain.vercel.app ``` -Use `NEXT_PUBLIC_STELLAR_NETWORK=testnet` for public demos. The in-app network switch can still query mainnet Horizon-backed tools. +Use `NEXT_PUBLIC_STELLAR_NETWORK=testnet` for public demos. + +### Service status (optional) + +```env +UTIX_SERVICE_MODE=normal # degraded | paused | maintenance +UTIX_SERVICE_MESSAGE= # public text, at most 280 characters +UTIX_SERVICE_SINCE= # ISO-8601, optional +UTIX_SERVICE_UNTIL= # ISO-8601, optional; the mode lifts itself after it +``` + +These show a site-wide banner, pause every tool (`paused`), and drive +`GET /api/status`. Leave them unset in normal operation. See the runbook in +[PUBLIC_STATUS.md](./PUBLIC_STATUS.md#runbook-entering-leaving-and-rolling-back) +before using them in production. The in-app network switch can still query mainnet Horizon-backed tools. ## Pre-Deploy Checks diff --git a/docs/FEATURE_CONTRACT.md b/docs/FEATURE_CONTRACT.md index d516416..3065097 100644 --- a/docs/FEATURE_CONTRACT.md +++ b/docs/FEATURE_CONTRACT.md @@ -77,7 +77,7 @@ features// │ └── handlers.ts request mocks (required if it makes requests) ├── e2e/ │ └── .spec.ts end-to-end specification -└── README.md what it does, how, and why +└── README.md what it does, how, why, and its threat boundary ``` **Minimum 20 files.** A complete slice lands at 23-25 without padding. @@ -253,6 +253,64 @@ consumers, schema constant, and old-schema regression test in one pull request; then run `npm run registry`. Treat each increment as a small registry-format changelog in the pull request description. +### 15. Every slice documents its read-only threat boundary + +Read-only is the project's central promise, and it has to be checkable in every +independently contributed slice rather than taken on trust. Each slice's +`README.md` ends with a `## Threat boundary` section that answers three +questions, each as a bold label followed by the answer on the same line: + +```md +## Threat boundary + +- **Data sources:** every input the tool reads — form fields, pasted XDR or + URIs, files, Horizon or RPC responses, browser storage. +- **Outbound requests:** the method, host and path of each request, or + "None — runs entirely in the browser", and what user data the URL carries. +- **Cannot sign or submit:** the proof — which input rejects a secret key and + the test that shows it, and that the slice calls no signing or submission + API. +``` + +[`features/trustline-checker/README.md`](../features/trustline-checker/README.md) +is the reference example. + +#### Reviewer checklist + +When reviewing a slice, check the section against the code, not just its +presence: + +- [ ] **Data sources** matches what `schema.ts` and `lib/` actually read. +- [ ] **Outbound requests** lists every `fetch`, `horizonServer(...)` and RPC + call in `lib/`, each against a public, read-only endpoint, and + `msw/handlers.ts` mocks exactly those requests. +- [ ] No request URL or body carries anything a user would consider private. +- [ ] **Cannot sign or submit** names the validation that rejects `S…` secret + keys (or explains why the tool takes no address at all) and the test that + proves it. +- [ ] The slice adds no wallet integration that requests a signature. + +#### What `verify:features` enforces + +- **Documentation presence.** A README without the section, or with an empty or + `TODO` answer for any field, fails. `npm run new:feature` scaffolds the + section with `TODO` placeholders, so a new slice fails until it is filled in. + Slices that predate this rule are listed in + `scripts/threat-boundary-migration.json` and produce a warning instead. Like + the rule 11 ledger, **it must only shrink, and new slices are never added to + it.** +- **No signing code.** Runtime sources (everything except `__tests__/`, + `fixtures/`, `e2e/` and `msw/`) must not call `Keypair.fromSecret`, + `Keypair.random`, `Keypair.fromRawEd25519Seed`, `.sign(…)`, + `signTransaction`/`signAuthEntry`/`signMessage`/`signBlob`, + `submitTransaction`, or the RPC `sendTransaction` method. This rule has no + ledger: it applies to every slice today. Fixtures may still sign sample + envelopes from fixed seeds. + +Both checks are tested in `core/__tests__/threatBoundaryDocs.test.ts`. The scan +is a tripwire, not a proof of absence: a reviewer still reads the section +against the code. + ## What "done" means ```bash @@ -268,6 +326,7 @@ All four must pass, plus: - [ ] `README.md` explains the non-obvious decision in the slice, not just what the tool does - [ ] No secret key is ever accepted or echoed +- [ ] `README.md` has a complete `## Threat boundary` section (rule 15) - [ ] Nothing outside `features//` was modified That last line is the important one. If your pull request touches a shared @@ -285,8 +344,9 @@ in [adr/](./adr/README.md) — in particular [0003](./adr/0003-result-values-and-error-codes.md) (error codes), [0004](./adr/0004-slice-local-fixtures-and-network-boundary-mocks.md) (fixtures and MSW), [0005](./adr/0005-runtime-contract-migration-ledger.md) -(runtime states) and [0006](./adr/0006-shared-amount-and-date-formatting.md) -(amounts and dates). +(runtime states), [0006](./adr/0006-shared-amount-and-date-formatting.md) +(amounts and dates) and [0007](./adr/0007-documented-read-only-threat-boundaries.md) +(threat boundaries). A pull request that changes this document — a rule, the layout, or what `verify:features` enforces — must add a new ADR or supersede an existing one in diff --git a/docs/PUBLIC_STATUS.md b/docs/PUBLIC_STATUS.md index acb7d18..6d36077 100644 --- a/docs/PUBLIC_STATUS.md +++ b/docs/PUBLIC_STATUS.md @@ -72,6 +72,129 @@ internal; the indicator's message and details only ever land in `diagnostics`. ## Serving the feed -The module returns plain JSON-serializable data. Serving it over HTTP, and -loading incidents and maintenance windows from wherever maintainers record them, -are left to the route or job that calls `publishPublicStatusFeed`. +The module returns plain JSON-serializable data. `GET /api/status` +(`app/api/status/route.ts`) serves it together with the service-mode notice +described below. Loading incidents and maintenance windows from wherever +maintainers record them is left to the caller: pass them as the `trusted` +argument of `buildStatusResponse`. + +## Service mode: degraded, paused, maintenance (#221) + +The feed above reports what is *observed*. The service mode is what an +operator *declares*: one app-wide mode with a short public message, set +without a code change. It lives in `core/status/service-mode.ts` and +`core/status/service-config.ts`. + +| Mode | Meaning for users | Published components | Overall feed status (at least) | +| ------------- | --------------------------------------------------- | -------------------- | ------------------------------ | +| `normal` | Nothing to report. No banner. | unchanged | unchanged | +| `degraded` | Tools work but may be slow or fail intermittently. | `degraded` | `degraded` | +| `maintenance` | Planned work; some tools may be briefly unavailable. | `under_maintenance` | `maintenance` | +| `paused` | Kill switch: tool panels are not mounted, so no tool makes a request. | `major_outage` | `major_outage` | + +Components are only ever *raised* to the mode's level, never lowered, so a +declared `degraded` never hides an observed outage. Internal components are +untouched. + +### Configuring it + +| Variable | Value | +| ---------------------- | ----- | +| `UTIX_SERVICE_MODE` | `normal` (default when unset), `degraded`, `paused`, `maintenance` (case-insensitive) | +| `UTIX_SERVICE_MESSAGE` | Optional public text, at most 280 characters | +| `UTIX_SERVICE_SINCE` | Optional ISO-8601 instant (`2026-03-10T12:00:00.000Z`); a future value schedules the mode | +| `UTIX_SERVICE_UNTIL` | Optional ISO-8601 instant; at and after it the mode is `normal` again | + +These are server-side variables, deliberately not `NEXT_PUBLIC_*`: they are +read when a page or the API renders, never inlined into client JavaScript. + +Programmatic changes (an admin tool, the rollout guard, a script) go through +`createServiceModeStore().change(actor, request)`. Only `maintainer` and +`system` actors may change the mode; a `user` is refused with +`service_mode_forbidden`. Every attempt, allowed or denied, is written to the +audit trail as `service_mode.changed` **before** the mode changes, with the +internal `reason` code (for example `incident:inc-42`) and the before/after +mode. The public message is not copied into the trail. If the trail rejects +the event, the mode does not change (`service_mode_audit_rejected`). + +### Where it shows up + +- **UI.** `AppShell` renders `ServiceStatusBanner` under the header in every + non-normal mode: the operator's message, or generic copy if there is none, the + expected end time, and a link to `/api/status`. While `paused`, the tool route + renders `PausedToolNotice` instead of the tool's panel. +- **API.** `GET /api/status` returns `{ notice, feed }`: the notice below and + the schema-`1.0.0` feed with the mode applied. It is rendered per request and + sent with `Cache-Control: no-store`. If either part fails its schema, the + route answers `503 { "error": "status_unavailable" }` and emits telemetry + instead of publishing it. +- **Background.** `registerServiceModeJobs(framework, store)` registers the + `status.expire_service_mode` worker job. When it runs after `until`, it + returns a store-held mode to `normal` and records `service_mode.expired` from + the system actor. Running it early or twice does nothing. + +```jsonc +// notice, schema version 1.0.0 (validatePublicServiceNotice) +{ "schemaVersion": "1.0.0", "mode": "maintenance", + "message": "Upgrading the indexer.", "since": "…", "until": "… | null" } +``` + +The feed's schema is unchanged. Existing consumers of the `1.0.0` feed keep +working and simply see component statuses that reflect the declared mode. + +### Keeping secrets out + +The notice is built only from its five public fields. The internal `reason` +and `changedBy` never leave the store. The message is operator-written text, +so `checkServiceMessage` **refuses** it, rather than scrubbing it, if it +contains any of these: + +- a Stellar secret seed; +- a bearer token; +- credentials in a URL; +- a `secret=`, `token:` or `api_key=` style pair; +- a private, loopback or link-local address, or `localhost`; +- a stack frame such as `worker.ts:42`. + +A half-redacted message is still a leak, and a refusal tells the operator +exactly what to fix. + +### Failure modes and edge cases + +| Situation | Behaviour | Signal | +| --- | --- | --- | +| `UTIX_SERVICE_MODE` is not a known value (e.g. a typo) | Published as `degraded` with generic copy, so the problem stays visible to users instead of being reported as `normal` | telemetry `status.service_mode_config`, `invalid_service_mode` | +| Message missing | Generic copy for the mode | none, this is allowed | +| Message unsafe or longer than 280 characters | Message dropped; mode kept; generic copy | `unsafe_service_message` / `service_message_too_long` | +| `SINCE`/`UNTIL` not ISO-8601 | That bound is ignored and the mode is open-ended | `invalid_service_window` | +| `UNTIL` not after `SINCE` | `UNTIL` ignored; mode is open-ended | `invalid_service_window` | +| `UNTIL` has passed | `normal` everywhere, from the clock alone | none | +| Feed or notice fails its own schema | `503 status_unavailable`, nothing partial is published | telemetry `status.publish` | +| A user actor tries to change the mode | Refused and audited as denied | `service_mode_forbidden` | +| Audit trail unavailable | Change refused, mode unchanged | `service_mode_audit_rejected` | + +**Freshness.** The API is evaluated per request. Pages are statically rendered +and revalidated at most every 60 seconds (`revalidate` in `app/layout.tsx`). +So a `SINCE` or `UNTIL` boundary reaches the banner and the paused gate within +about a minute, while the API reflects it immediately. Changing a variable +itself needs a redeploy or restart on most hosts, Vercel included, which +rebuilds the pages. + +### Runbook: entering, leaving and rolling back + +1. **Enter a mode.** Set `UTIX_SERVICE_MODE`, a `UTIX_SERVICE_MESSAGE` written + for the public, and preferably `UTIX_SERVICE_UNTIL`, so a forgotten mode + lifts itself. Redeploy. +2. **Verify.** `curl -s https:///api/status | jq .notice` should show + the mode and message. If `message` is `null` when you set one, it was + refused: check telemetry for `status.service_mode_config`, then reword it. + The banner should appear on the next page load. +3. **Leave or roll back.** Set `UTIX_SERVICE_MODE=normal`, or unset it, and + redeploy. `/api/status` should report `"mode": "normal"` and the banner + disappears. Unsetting the variables is always a safe rollback: the default + is `normal`, and no data or schema migration is involved. +4. **Emergency.** If a deploy is not possible, an already-set `UNTIL` still ends + the mode on time. Plan pauses with an `UNTIL` for this reason. +5. **Roll back the code.** Reverting this change removes the banner, the + paused gate and `/api/status`. The `1.0.0` feed and its consumers are + unaffected. diff --git a/docs/adr/0007-documented-read-only-threat-boundaries.md b/docs/adr/0007-documented-read-only-threat-boundaries.md new file mode 100644 index 0000000..dc9af5c --- /dev/null +++ b/docs/adr/0007-documented-read-only-threat-boundaries.md @@ -0,0 +1,42 @@ +# 0007. Document each slice's read-only threat boundary and scan for signing code + +- Status: Proposed +- Date: 2026-10-21 +- Sources: issue #157 ("Document read-only threat boundaries per feature"); [SECURITY.md](../../SECURITY.md); [FEATURE_CONTRACT.md](../FEATURE_CONTRACT.md) rules 10 and 15; [0005](./0005-runtime-contract-migration-ledger.md) + +## Context + +The project never signs or submits a transaction, but that promise was recorded +only in SECURITY.md and in each README's free-form "Safety" paragraph. A +reviewer checking a new slice had to reconstruct, from the code, what data it +reads, which hosts it calls and why it cannot sign. Nothing failed when a slice +left that out, and nothing failed when runtime code called a signing API. +Seventy slices already existed when the rule was introduced, so requiring the +documentation everywhere at once would have blocked unrelated work. + +## Decision + +Every slice README carries a `## Threat boundary` section with three labelled +answers: **Data sources**, **Outbound requests** and **Cannot sign or submit**. +`verify:features` fails a slice whose section is missing, empty or still `TODO`. +Slices that predate the rule are listed in +`scripts/threat-boundary-migration.json` and warn instead, following the +shrinking-ledger approach of [0005](./0005-runtime-contract-migration-ledger.md). +`trustline-checker` is documented as the reference example. + +`verify:features` also scans each slice's runtime sources for signing and +submission calls and fails on any match. That scan has no ledger, because no +existing slice makes such a call. + +## Consequences + +- New slices must fill in the section in their first pull request; the + scaffold's `TODO` placeholders fail until they do. +- Documenting one existing slice and removing it from the ledger is a + self-contained change. The ledger must never grow. +- The signing scan is a pattern match. It catches the obvious APIs but cannot + prove a negative, so review against the documented boundary is still + required. A legitimate future need for one of the matched names in runtime + code means changing this decision, not working around the scan. +- Fixtures, tests, e2e specs and request mocks are outside the scan, so + deterministic signed sample envelopes stay possible. diff --git a/docs/adr/README.md b/docs/adr/README.md index 19ccb2c..90f92a4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,4 @@ old one's status to `Superseded by NNNN`, and update both rows. | [0004](./0004-slice-local-fixtures-and-network-boundary-mocks.md) | Fixtures live in the slice; requests are mocked with MSW | Accepted | | [0005](./0005-runtime-contract-migration-ledger.md) | Runtime contract states are enforced with a shrinking ledger | Accepted | | [0006](./0006-shared-amount-and-date-formatting.md) | Amounts and dates go through `core/format` | Accepted | +| [0007](./0007-documented-read-only-threat-boundaries.md) | Each slice documents its read-only threat boundary; signing code is scanned for | Proposed | diff --git a/features/trustline-checker/README.md b/features/trustline-checker/README.md index 32752c8..1b4be17 100644 --- a/features/trustline-checker/README.md +++ b/features/trustline-checker/README.md @@ -28,3 +28,18 @@ issuing account never holds a trustline to its own asset. Trustlines are public ledger data. This tool is read-only and never creates, modifies or removes a trustline. + +## Threat boundary + +- **Data sources:** three form fields (account id, asset code, issuer id) and + the public account record Horizon returns for the selected network. Nothing + is read from or written to browser storage. +- **Outbound requests:** one `GET {horizon}/accounts/{accountId}` per check, + through `horizonServer(network).loadAccount` in `lib/trustlineChecker.ts`, + to `horizon-testnet.stellar.org` or `horizon.stellar.org`. The URL carries + only the public account id; the asset code and issuer are matched locally. +- **Cannot sign or submit:** `parseTrustlineInput` accepts only `G…` public + keys, so a pasted `S…` secret is rejected as `invalid_account` before any + request (`__tests__/schema.test.ts`). The slice never calls a signing or + submission API, which `npm run verify:features` enforces by scanning its + runtime sources, and `loadAccount` is a read-only Horizon endpoint. diff --git a/features/trustline-checker/__tests__/schema.test.ts b/features/trustline-checker/__tests__/schema.test.ts index 66afda0..4863ec8 100644 --- a/features/trustline-checker/__tests__/schema.test.ts +++ b/features/trustline-checker/__tests__/schema.test.ts @@ -1,3 +1,4 @@ +import { Keypair } from "@stellar/stellar-sdk"; import { describe, expect, it } from "vitest"; import { FIELD_OF_CODE, parseTrustlineInput } from "@/features/trustline-checker/schema"; import { @@ -29,6 +30,13 @@ describe("parseTrustlineInput", () => { expect(parseTrustlineInput(input)).toEqual({ ok: false, code }); }); + // The README's threat boundary relies on this: a secret never reaches a request. + it("rejects a pasted secret key in either address field before any request", () => { + const secret = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)).secret(); + expect(parseTrustlineInput({ ...valid, accountId: secret })).toEqual({ ok: false, code: "invalid_account" }); + expect(parseTrustlineInput({ ...valid, issuerId: secret })).toEqual({ ok: false, code: "invalid_issuer" }); + }); + it("rejects an asset code longer than 12 characters", () => { expect(parseTrustlineInput({ ...valid, assetCode: "A".repeat(13) })).toEqual({ ok: false, diff --git a/scripts/new-feature.mjs b/scripts/new-feature.mjs index e41bb79..9dd00b4 100755 --- a/scripts/new-feature.mjs +++ b/scripts/new-feature.mjs @@ -463,6 +463,14 @@ shape of the result. ## Safety TODO: state explicitly that this tool never asks for a secret key. + +## Threat boundary + + + +- **Data sources:** TODO: every input the tool reads — form fields, URLs, files, Horizon or RPC responses, browser storage. +- **Outbound requests:** TODO: method, host and path of each request (or "None — runs entirely in the browser"), and what user data the URL carries. +- **Cannot sign or submit:** TODO: why this tool cannot sign or submit — which input rejects secret keys, which test proves it, and that no signing API is called. ` }; diff --git a/scripts/threat-boundary-migration.json b/scripts/threat-boundary-migration.json new file mode 100644 index 0000000..f87f777 --- /dev/null +++ b/scripts/threat-boundary-migration.json @@ -0,0 +1,80 @@ +{ + "$comment": "Migration ledger for the verify:features threat-boundary documentation check (see docs/FEATURE_CONTRACT.md rule 15 and docs/adr/0007). Each slug here has a README without a complete \"## Threat boundary\" section; verify:features reports it as a warning instead of a failure. This list must only ever shrink. Do NOT add a new slice to it — new slices must document their boundary. The signing scan is never exempt.", + "fields": [ + "Data sources", + "Outbound requests", + "Cannot sign or submit" + ], + "pending": [ + "account-data-entries", + "account-merge-preflight", + "account-signers", + "account-snapshot-diff", + "activity-timeline", + "address-validator", + "amount-converter", + "anchor-discovery", + "asset-flags-inspector", + "asset-metadata", + "asset-statistics", + "balance-viewer", + "batch-address-validator", + "claimable-balances", + "claimable-predicate-builder", + "contract-events", + "duplicate-transaction-scanner", + "effects-timeline", + "explorer-links", + "federation-resolver", + "fee-bump-inspector", + "fee-stats", + "freighter-connect", + "hash-calculator", + "horizon-health", + "ledger-lookup", + "ledger-range-planner", + "liquidity-pool-calculator", + "liquidity-pool-inspector", + "manage-data-codec", + "memo-inspector", + "multisig-analyzer", + "muxed-account-codec", + "network-comparison", + "offer-inspector", + "operation-browser", + "operation-source-map", + "orderbook-viewer", + "path-payment-finder", + "path-payment-inspector", + "payment-csv-preflight", + "payment-history", + "payment-qr", + "payment-uri-parser", + "preconditions-explainer", + "price-fraction-lab", + "reserve-calculator", + "result-code-explainer", + "sep10-inspector", + "sep7-signature-verifier", + "sequence-inspector", + "signature-verifier", + "simulation-explainer", + "soroban-auth-inspector", + "soroban-decoder", + "soroban-fee-estimator", + "soroban-spec-viewer", + "sponsored-reserves", + "sponsorship-planner", + "strkey-inspector", + "testnet-faucet", + "toml-validator", + "trade-aggregation-viewer", + "trade-history", + "transaction-decoder", + "transaction-envelope-diff", + "transaction-lookup", + "transaction-size-analyzer", + "trustline-limit-planner", + "xdr-inspector" + ] +} diff --git a/scripts/verify-audit.mjs b/scripts/verify-audit.mjs index 619dc3b..d4876b6 100644 --- a/scripts/verify-audit.mjs +++ b/scripts/verify-audit.mjs @@ -55,7 +55,9 @@ const boundaries = [ { file: "core/authorizations/store.ts", emitter: "authorization.granted" }, { file: "core/authorizations/store.ts", emitter: "authorization.renewed" }, { file: "core/authorizations/store.ts", emitter: "authorization.revoked" }, - { file: "core/authorizations/store.ts", emitter: "authorization.cleaned_up" } + { file: "core/authorizations/store.ts", emitter: "authorization.cleaned_up" }, + { file: "core/status/service-mode.ts", emitter: "service_mode.changed" }, + { file: "core/status/service-mode.ts", emitter: "service_mode.expired" } ]; const sources = new Map(); diff --git a/scripts/verify-features.d.mts b/scripts/verify-features.d.mts index f263071..8f7acef 100644 --- a/scripts/verify-features.d.mts +++ b/scripts/verify-features.d.mts @@ -19,6 +19,20 @@ export interface VerifiedSlice { contractPending: boolean; contractMissing: string[]; contractUsesHarness: boolean; + threatBoundaryPending: boolean; + threatBoundaryMissing: string[]; +} + +export interface ThreatBoundaryResult { + slug: string; + ok: boolean; + hasSection: boolean; + missing: string[]; +} + +export interface SigningCall { + file: string; + id: string; } export interface VerifySliceOptions { @@ -26,10 +40,15 @@ export interface VerifySliceOptions { featuresDir?: string; /** Slices exempt from the runtime contract rule (migration ledger). */ contractPending?: Set; + /** Slices exempt from the threat-boundary documentation rule (migration ledger). */ + threatBoundaryPending?: Set; } export const MINIMUM_FILES: number; export const CONTRACT_STATES: readonly string[]; +export const THREAT_BOUNDARY_HEADING: string; +export const THREAT_BOUNDARY_FIELDS: readonly string[]; +export const SIGNING_PATTERNS: readonly { id: string; pattern: RegExp }[]; export function loadMigrationLedger(ledgerPath?: string): Set; export function collectTestSources(sliceDir: string): Promise>; @@ -42,3 +61,5 @@ export function checkContractCoverage( sliceDir: string ): Promise; export function verifySlice(slug: string, options?: VerifySliceOptions): Promise; +export function evaluateThreatBoundaryDoc(slug: string, readme: string | undefined): ThreatBoundaryResult; +export function findSigningCalls(sources: Record): SigningCall[]; diff --git a/scripts/verify-features.mjs b/scripts/verify-features.mjs index 4d5aa1e..d7f1b6d 100755 --- a/scripts/verify-features.mjs +++ b/scripts/verify-features.mjs @@ -76,6 +76,30 @@ const CONTRACT_STATE_MATCHERS = { }; const MIGRATION_LEDGER = path.join(root, "scripts", "feature-contract-migration.json"); +const THREAT_BOUNDARY_LEDGER = path.join(root, "scripts", "threat-boundary-migration.json"); + +/** + * The read-only threat boundary every slice documents in its README (see + * docs/FEATURE_CONTRACT.md rule 15). Each field is a bold label followed, on + * the same line, by a real answer — a `TODO` placeholder does not count. + */ +export const THREAT_BOUNDARY_HEADING = "## Threat boundary"; +export const THREAT_BOUNDARY_FIELDS = ["Data sources", "Outbound requests", "Cannot sign or submit"]; + +/** + * Calls that would let a slice sign or submit a transaction. Runtime sources + * must never contain them; fixtures and tests may, because they build signed + * sample envelopes from fixed seeds. + */ +export const SIGNING_PATTERNS = [ + { id: "secret_keypair", pattern: /\bKeypair\.(?:fromSecret|fromRawEd25519Seed|random)\s*\(/ }, + { id: "sign_call", pattern: /(? line.trim() === THREAT_BOUNDARY_HEADING); + if (start === -1) return { slug, ok: false, hasSection: false, missing: [...THREAT_BOUNDARY_FIELDS] }; + + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => /^#{1,2}\s/.test(line)); + const section = (end === -1 ? rest : rest.slice(0, end)).join("\n"); + + const missing = THREAT_BOUNDARY_FIELDS.filter((field) => { + const label = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const answer = section.match(new RegExp(`^\\s*(?:[-*]\\s+)?\\*\\*${label}:\\*\\*[ \\t]*(.*)$`, "m"))?.[1]; + return !answer || answer.trim().length === 0 || /\bTODO\b/.test(answer); + }); + + return { slug, ok: missing.length === 0, hasSection: true, missing }; +} + +/** Removes comments so prose such as "never calls sendTransaction" is not a match. */ +function stripComments(source) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +} + +/** + * Pure signing scan: given a slice's runtime sources (path → text), list every + * call that could sign or submit a transaction. + */ +export function findSigningCalls(sources) { + const hits = []; + for (const [file, source] of Object.entries(sources)) { + if (NON_RUNTIME_DIRS.some((dir) => file.startsWith(dir))) continue; + const code = stripComments(source); + for (const { id, pattern } of SIGNING_PATTERNS) { + if (pattern.test(code)) hits.push({ file, id }); + } + } + return hits; +} + +async function collectRuntimeSources(sliceDir, files) { + const sources = {}; + for (const file of files) { + if (!/\.(ts|tsx)$/.test(file) || NON_RUNTIME_DIRS.some((dir) => file.startsWith(dir))) continue; + sources[file] = await readFile(path.join(sliceDir, file), "utf8"); + } + return sources; +} + async function isNetworkBacked(slug, sliceDir) { const manifestPath = path.join(sliceDir, "manifest.ts"); if (!existsSync(manifestPath)) return false; @@ -261,13 +337,32 @@ export async function verifySlice(slug, options = {}) { if (!contract.ok && !pending) failures.push(contractFailure(contract)); + // Signing is never grandfathered: a read-only tool has no reason to sign. + for (const hit of findSigningCalls(await collectRuntimeSources(sliceDir, files))) { + failures.push(`${hit.file} — read-only boundary: must not sign or submit transactions (${hit.id})`); + } + + const readmePath = path.join(sliceDir, "README.md"); + const threat = evaluateThreatBoundaryDoc(slug, existsSync(readmePath) ? await readFile(readmePath, "utf8") : ""); + const threatPending = (options.threatBoundaryPending ?? loadMigrationLedger(THREAT_BOUNDARY_LEDGER)).has(slug); + const threatBoundaryPending = !threat.ok && threatPending; + + if (!threat.ok && !threatPending) { + failures.push( + `README.md — "${THREAT_BOUNDARY_HEADING}" section missing ${threat.missing.join(", ")} ` + + "(see docs/FEATURE_CONTRACT.md rule 15)" + ); + } + return { slug, fileCount: files.length, failures, contractPending, contractMissing: contract.missing, - contractUsesHarness: contract.usesHarness + contractUsesHarness: contract.usesHarness, + threatBoundaryPending, + threatBoundaryMissing: threat.missing }; } @@ -311,11 +406,20 @@ async function main() { `see scripts/feature-contract-migration.json` ); } + if (result.threatBoundaryPending && requested.length > 0) { + console.log( + ` warning: threat boundary not documented (${result.threatBoundaryMissing.join(", ")}) — ` + + `pending migration, see scripts/threat-boundary-migration.json` + ); + } } + const threatPending = results.filter((result) => result.threatBoundaryPending); + console.log( `\n${results.length} slice(s) checked, ${failed.length} failing, ` + `${pending.length} pending contract migration, ` + + `${threatPending.length} pending threat-boundary docs, ` + `${results.reduce((sum, r) => sum + r.fileCount, 0)} files total` );
{notice.message ?? copy.banner.fallback[mode]}
+ {copy.banner.until} {formatDateTime(notice.until)} +
+ + {copy.banner.statusLink} + +