From 102e29d03d48c7e4a9aa659e51927f4dddbee4a6 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 22:32:22 +0000 Subject: [PATCH 1/8] add push governance phase manifest --- .../OSOH_PUSH_GOVERNANCE_PHASE_MANIFEST.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/governance/OSOH_PUSH_GOVERNANCE_PHASE_MANIFEST.md diff --git a/docs/governance/OSOH_PUSH_GOVERNANCE_PHASE_MANIFEST.md b/docs/governance/OSOH_PUSH_GOVERNANCE_PHASE_MANIFEST.md new file mode 100644 index 0000000..5b5ff51 --- /dev/null +++ b/docs/governance/OSOH_PUSH_GOVERNANCE_PHASE_MANIFEST.md @@ -0,0 +1,25 @@ +# OSOH Push Governance Phase Manifest + +## Completed Level 5 Push Governance Files +- OSOH_SENTINEL_PUSH_CONSTITUTIONAL_ADMISSION.md +- OSOH_SENTINEL_PUSH_CONSTITUTIONAL_RULE_MATRIX.md +- OSOH_SENTINEL_PUSH_ACCEPTANCE_CONDITIONS.md +- OSOH_SENTINEL_PUSH_ACTIVATION_BOUNDARY_CONDITIONS.md +- OSOH_SENTINEL_PUSH_CONSTITUTIONAL_COMPLETION_CRITERIA.md +- OSOH_PUSH_GOVERNANCE_TO_MVP_BRIDGE.md +- OSOH_PUSH_GOVERNANCE_TO_MVP_BRIDGE_DONE_DEFINITION.md +- OSOH_PUSH_IMPLEMENTATION_DESCENT_AUTHORITY.md +- OSOH_PUSH_IMPLEMENTATION_DESCENT_AUTHORITY_DONE_DEFINITION.md +- OSOH_PUSH_DESCENT_EXIT_CRITERIA.md +- OSOH_PUSH_DESCENT_EXIT_CRITERIA_DONE_DEFINITION.md +- OSOH_PUSH_DESCENT_READINESS_GATE.md +- OSOH_PUSH_DESCENT_READINESS_GATE_DONE_DEFINITION.md + +## Phase Status +Level 5 push governance is complete and merged to main. + +## Next Boundary +No further descent is authorized unless explicitly requested by the user. + +## MVP Stop Condition +All future work remains bounded by: reach MVP and stop. From f0c85528eab2a54199c49014fa6428f4785817e6 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 22:35:13 +0000 Subject: [PATCH 2/8] define level 4 push mvp design descent --- .../OSOH_LEVEL4_PUSH_MVP_DESIGN_DESCENT.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/governance/OSOH_LEVEL4_PUSH_MVP_DESIGN_DESCENT.md diff --git a/docs/governance/OSOH_LEVEL4_PUSH_MVP_DESIGN_DESCENT.md b/docs/governance/OSOH_LEVEL4_PUSH_MVP_DESIGN_DESCENT.md new file mode 100644 index 0000000..397b04a --- /dev/null +++ b/docs/governance/OSOH_LEVEL4_PUSH_MVP_DESIGN_DESCENT.md @@ -0,0 +1,117 @@ +# OSOH Level 4 — Push MVP Design Descent + +## Phase Objective +Define the full MVP design packet for Sentinel push integration under the already-locked Level 5 constitutional constraints. + +## 4.1 Push Event Contract Definition +The MVP push event contract is limited to two event classes: +- deployment +- heartbeat + +Required event fields: +- siteId +- eventType +- occurredAt +- payload + +Deployment payload minimum: +- deploymentId +- environment +- version or commit reference +- status + +Heartbeat payload minimum: +- status +- summary + +No event type outside this bounded MVP set is in scope for this phase. + +## 4.2 Identity Binding Design +Sentinel push events shall bind to one registered monitored surface through OSOH_SITE_ID. +The site identity used in push events must correspond to the monitored-surface identity already known to OSOH. +Push identity mismatch must fail closed. + +## 4.3 Ingest Credential Scope Design +Sentinel shall use one ingest-only credential for push delivery. +The credential shall authorize only event submission into the OSOH ingest path. +It shall not grant read access, admin capability, control-plane capability, deployment authority, or operator authority. + +## 4.4 Ingest Target Wiring Design +Sentinel shall use OSOH_INGEST_URL as the bounded destination for push telemetry. +The ingest target must be explicit, environment-supplied, and replaceable without changing the constitutional meaning of the integration. +If the target is missing or malformed, the sender must fail closed. + +## 4.5 Sender Execution Design +Two sender paths are in scope: +- deployment sender +- heartbeat sender + +Deployment sender emits a bounded event when a deployment completes or reaches its designated reporting point. +Heartbeat sender emits a bounded event on a governed recurring basis. +Both senders act only as telemetry producers. + +## 4.6 Secret Handling Design +Push credentials must remain server-side only. +Secrets must not be exposed to the browser, client bundles, or user-visible runtime surfaces. +The MVP design assumes bounded environment-variable handling and non-exposure by default. + +## 4.7 Push Validation Design +The ingest path must validate at minimum: +- Authorization bearer presence +- token-to-site binding +- active/inactive handling under fail-closed logic +- event type admissibility +- occurredAt format validity +- payload structural presence +- site identity coherence + +Invalid or inconsistent events must be rejected without trust-presuming fallback. + +## 4.8 Telemetry Persistence Design +Accepted push events must be persisted as monitored-surface telemetry evidence. +Persistence must preserve: +- site identity +- event type +- receipt time +- occurrence time +- bounded payload +- audit trace of acceptance or rejection + +This persistence serves monitoring evidence only, not trust transfer. + +## 4.9 Non-Activation Boundary Design +Implementation of push senders, secrets, env wiring, and event persistence does not activate Sentinel. +Activation remains blocked until future hybrid pull-plus-push verification is completed under OSOH authority. + +## 4.10 Proof-Of-Entry Design +The push MVP slice must be able to prove: +- Sentinel emitted a bounded push event +- the event reached OSOH ingest +- the event was validated under bounded rules +- the accepted event was persisted +- the event remained telemetry only + +## 4.11 Governance Return-Check Design +After implementation, the slice must be checked back against Level 5 invariants: +- Sentinel remains external +- push remains telemetry only +- credential remains ingest-only +- fail-closed handling remains intact +- hybrid requirement remains intact +- OSOH remains final authority + +## 4.12 MVP Completion Gate For Push Slice +The push MVP design slice is complete when: +- deployment sender is designed +- heartbeat sender is designed +- OSOH_SITE_ID binding is defined +- OSOH_INGEST_TOKEN handling is defined +- OSOH_INGEST_URL wiring is defined +- validation expectations are defined +- persistence expectations are defined +- non-activation boundary is explicit +- proof-of-entry expectation is explicit +- governance return-check is explicit + +## Phase Completion Marker +This phase is complete when the full push MVP design is explicitly bounded, constitutionally aligned, non-activating, proof-oriented, and ready for lower-level implementation descent. From af0243c2669b5292cbb501f6d4694307f3f5f2c4 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 22:41:23 +0000 Subject: [PATCH 3/8] define level 4 push implementation packet --- ...4_PUSH_IMPLEMENTATION_PACKET_DEFINITION.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/governance/OSOH_LEVEL4_PUSH_IMPLEMENTATION_PACKET_DEFINITION.md diff --git a/docs/governance/OSOH_LEVEL4_PUSH_IMPLEMENTATION_PACKET_DEFINITION.md b/docs/governance/OSOH_LEVEL4_PUSH_IMPLEMENTATION_PACKET_DEFINITION.md new file mode 100644 index 0000000..2aad371 --- /dev/null +++ b/docs/governance/OSOH_LEVEL4_PUSH_IMPLEMENTATION_PACKET_DEFINITION.md @@ -0,0 +1,109 @@ +# OSOH Level 4 — Push Implementation Packet Definition + +## Phase Objective +Define the final implementation handoff packet for the Sentinel push MVP slice before descent into code. + +## 4.13 Environment Contract Packet +Required environment variables: +- OSOH_SITE_ID +- OSOH_INGEST_TOKEN +- OSOH_INGEST_URL + +Environment contract rules: +- all three values are required for push operation +- missing values fail closed +- malformed ingest target fails closed +- site identity must match the registered monitored surface identity +- environment binding does not imply activation + +## 4.14 Secret Boundary Packet +OSOH_INGEST_TOKEN is server-side only. +It must not appear in browser-exposed code, client bundles, logs, docs examples with real values, or public runtime surfaces. +Secret handling remains bounded to telemetry delivery only. + +## 4.15 Shared Sender Helper Packet +A shared sender helper shall: +- read the required environment values +- validate presence of required values +- build the bounded ingest request +- attach bearer authorization +- serialize the event payload +- send telemetry to OSOH ingest +- fail closed on missing configuration or transport failure + +The shared helper does not decide activation, trust, or admission. + +## 4.16 Deployment Sender Packet +The deployment sender shall emit a bounded `deployment` event to OSOH ingest. +Minimum deployment event payload: +- deploymentId +- environment +- versionOrCommit +- status + +The deployment sender is a telemetry producer only. + +## 4.17 Heartbeat Sender Packet +The heartbeat sender shall emit a bounded `heartbeat` event to OSOH ingest. +Minimum heartbeat event payload: +- status +- summary + +The heartbeat sender is a telemetry producer only. + +## 4.18 Ingest Request Contract Packet +The request contract to OSOH ingest shall include: +- Authorization: Bearer +- content-type: application/json + +Request body shape: +- siteId +- eventType +- occurredAt +- payload + +Admissible eventType values for this slice: +- deployment +- heartbeat + +## 4.19 Push Proof Packet +The push slice must prove: +- required environment values are loaded +- deployment sender can emit a valid event +- heartbeat sender can emit a valid event +- OSOH ingest receives the request +- accepted telemetry is persisted +- invalid or incomplete events fail closed + +## 4.20 Push Slice Done-Definition Packet +This push slice is done when: +- environment contract is defined +- secret boundary is defined +- shared sender helper contract is defined +- deployment sender contract is defined +- heartbeat sender contract is defined +- ingest request contract is defined +- push proof expectations are defined +- all rules remain non-activating and telemetry-only + +## 4.21 Governance Return-Check Packet +After implementation, the slice must be checked against these invariants: +- Sentinel remains external monitored surface +- push remains telemetry only +- credential remains ingest-only +- activation remains blocked +- hybrid proof remains required +- OSOH remains final authority + +## 4.22 Descent-Ready Implementation Handoff Packet +The next authorized lower-level implementation handoff consists of: +- env wiring implementation +- shared sender helper implementation +- deployment sender implementation +- heartbeat sender implementation +- proof execution against OSOH ingest + +No broader descent is authorized by this phase. + +## Phase Completion Marker +This phase is complete when the full push implementation handoff is explicit, bounded, constitutionally aligned, non-activating, and ready for code descent. From 50c04939e56ab32dd403790797224c277de4581e Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 22:44:15 +0000 Subject: [PATCH 4/8] add sentinel push sender foundation --- package-lock.json | 45 +++++++++++++++++++ package.json | 5 ++- scripts/osoh-send-deployment.ts | 29 +++++++++++++ scripts/osoh-send-heartbeat.ts | 27 ++++++++++++ src/lib/server/osoh-push-client.ts | 69 ++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 scripts/osoh-send-deployment.ts create mode 100644 scripts/osoh-send-heartbeat.ts create mode 100644 src/lib/server/osoh-push-client.ts diff --git a/package-lock.json b/package-lock.json index 3bf570b..39fb0a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@testing-library/jest-dom": "^6.9.1", "husky": "^9.1.7", "jsdom": "^28.1.0", + "tsx": "^4.21.0", "vitest": "^4.0.18" } }, @@ -6079,6 +6080,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -9121,6 +9135,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retext": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", @@ -9793,6 +9817,27 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index d49c5d8..6ff7796 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "prepare": "husky", "gates": "powershell -ExecutionPolicy Bypass -File .\\scripts\\l6-gates.ps1", "prepush": "npm run build && npm run gates", - "docs:gate": "powershell -ExecutionPolicy Bypass -File .\\scripts\\docs-gate.ps1" + "docs:gate": "powershell -ExecutionPolicy Bypass -File .\\scripts\\docs-gate.ps1", + "osoh:push:deployment": "tsx scripts/osoh-send-deployment.ts", + "osoh:push:heartbeat": "tsx scripts/osoh-send-heartbeat.ts" }, "dependencies": { "@astrojs/check": "^0.9.0", @@ -41,6 +43,7 @@ "@testing-library/jest-dom": "^6.9.1", "husky": "^9.1.7", "jsdom": "^28.1.0", + "tsx": "^4.21.0", "vitest": "^4.0.18" } } diff --git a/scripts/osoh-send-deployment.ts b/scripts/osoh-send-deployment.ts new file mode 100644 index 0000000..34b0a0f --- /dev/null +++ b/scripts/osoh-send-deployment.ts @@ -0,0 +1,29 @@ +import { sendOsohPushEvent, readOsohPushEnv } from '../src/lib/server/osoh-push-client'; + +async function main(): Promise { + const env = readOsohPushEnv(); + + const response = await sendOsohPushEvent({ + siteId: env.siteId, + eventType: 'deployment', + occurredAt: new Date().toISOString(), + payload: { + deploymentId: process.env.OSOH_DEPLOYMENT_ID ?? `deploy_${Date.now()}`, + environment: process.env.OSOH_DEPLOY_ENV ?? 'unknown', + versionOrCommit: process.env.OSOH_VERSION_OR_COMMIT ?? 'unknown', + status: process.env.OSOH_DEPLOY_STATUS ?? 'completed' + } + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Deployment push failed: ${response.status} ${body}`); + } + + console.log('deployment push ok'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/osoh-send-heartbeat.ts b/scripts/osoh-send-heartbeat.ts new file mode 100644 index 0000000..d96a7ed --- /dev/null +++ b/scripts/osoh-send-heartbeat.ts @@ -0,0 +1,27 @@ +import { sendOsohPushEvent, readOsohPushEnv } from '../src/lib/server/osoh-push-client'; + +async function main(): Promise { + const env = readOsohPushEnv(); + + const response = await sendOsohPushEvent({ + siteId: env.siteId, + eventType: 'heartbeat', + occurredAt: new Date().toISOString(), + payload: { + status: process.env.OSOH_HEARTBEAT_STATUS ?? 'ok', + summary: process.env.OSOH_HEARTBEAT_SUMMARY ?? 'sentinel heartbeat' + } + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Heartbeat push failed: ${response.status} ${body}`); + } + + console.log('heartbeat push ok'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/lib/server/osoh-push-client.ts b/src/lib/server/osoh-push-client.ts new file mode 100644 index 0000000..476811a --- /dev/null +++ b/src/lib/server/osoh-push-client.ts @@ -0,0 +1,69 @@ +export type OsohPushEventType = 'deployment' | 'heartbeat'; + +export interface OsohPushEventPayload { + [key: string]: unknown; +} + +export interface OsohPushEventInput { + siteId: string; + eventType: OsohPushEventType; + occurredAt: string; + payload: OsohPushEventPayload; +} + +export interface OsohPushEnv { + siteId: string; + ingestToken: string; + ingestUrl: string; +} + +function readRequiredEnv(name: string): string { + const value = process.env[name]; + + if (!value || !value.trim()) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value.trim(); +} + +function validateIngestUrl(rawUrl: string): string { + let parsed: URL; + + try { + parsed = new URL(rawUrl); + } catch { + throw new Error('OSOH_INGEST_URL is not a valid URL'); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`OSOH_INGEST_URL uses unsupported protocol: ${parsed.protocol}`); + } + + return parsed.toString(); +} + +export function readOsohPushEnv(): OsohPushEnv { + return { + siteId: readRequiredEnv('OSOH_SITE_ID'), + ingestToken: readRequiredEnv('OSOH_INGEST_TOKEN'), + ingestUrl: validateIngestUrl(readRequiredEnv('OSOH_INGEST_URL')) + }; +} + +export async function sendOsohPushEvent(input: OsohPushEventInput): Promise { + const env = readOsohPushEnv(); + + if (input.siteId !== env.siteId) { + throw new Error('Push event siteId does not match OSOH_SITE_ID'); + } + + return fetch(env.ingestUrl, { + method: 'POST', + headers: { + authorization: `Bearer ${env.ingestToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify(input) + }); +} From 4cb975fb19a60dd3d4e5f14ebf326e35d5d58278 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 22:56:03 +0000 Subject: [PATCH 5/8] allow deployment and heartbeat ingest event types --- src/lib/server/osoh-ingest.ts | 4 ++++ src/lib/server/osoh-store.ts | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/server/osoh-ingest.ts b/src/lib/server/osoh-ingest.ts index 496f95c..9619970 100644 --- a/src/lib/server/osoh-ingest.ts +++ b/src/lib/server/osoh-ingest.ts @@ -51,6 +51,8 @@ const allowedEventTypes: readonly OsohMonitoringEventType[] = [ 'surface_health_changed', 'surface_token_used', 'surface_token_revoked', + 'deployment', + 'heartbeat', ] as const; const isOsohMonitoringEventType = ( @@ -189,3 +191,5 @@ export const validateIngestPayload = ( }, }; }; + + diff --git a/src/lib/server/osoh-store.ts b/src/lib/server/osoh-store.ts index bd66e55..07fb7c4 100644 --- a/src/lib/server/osoh-store.ts +++ b/src/lib/server/osoh-store.ts @@ -14,7 +14,9 @@ export type OsohMonitoringEventType = | 'surface_ping_received' | 'surface_health_changed' | 'surface_token_used' - | 'surface_token_revoked'; + | 'surface_token_revoked' + | 'deployment' + | 'heartbeat'; export type OsohAuditAction = | 'site_created' @@ -94,3 +96,5 @@ export const saveOsohStoreState = async (state: OsohStoreState): Promise = }; + + From 3892b888543dd5a8b22dfd3185662e00c87648f4 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 22:59:27 +0000 Subject: [PATCH 6/8] align demo ingest token hash with push sender proof --- src/lib/server/osoh-state.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/server/osoh-state.json b/src/lib/server/osoh-state.json index 19bf5f8..91b786a 100644 --- a/src/lib/server/osoh-state.json +++ b/src/lib/server/osoh-state.json @@ -14,7 +14,7 @@ { "tokenId": "token-sentinel-demo", "siteId": "sentinel-demo", - "tokenHash": "f17bed16f9e3d3f1d9f4f81b19bf5f7f62e8c31c6f6b2c1e6d9d2a1a5f6b4c3d", + "tokenHash": "7c43ef5ae21d43ce2743f770c68e24def1a43ee2f416d2438410c8af7af2ff2c", "scope": "ingest", "revoked": false, "createdAt": "2026-01-01T00:00:00.000Z", @@ -26,3 +26,4 @@ } + From c6f3f9e61c554663c12f8f5569d7854aa5ec5769 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Sat, 7 Mar 2026 23:55:11 +0000 Subject: [PATCH 7/8] enable post api routes and prove push ingest path --- src/middleware.ts | 4 +- src/pages/api/probe.ts | 15 +++++ src/pages/api/v1/monitoring/echo.astro | 19 ++++++ src/pages/api/v1/monitoring/ingest.astro | 81 ++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/pages/api/probe.ts create mode 100644 src/pages/api/v1/monitoring/echo.astro create mode 100644 src/pages/api/v1/monitoring/ingest.astro diff --git a/src/middleware.ts b/src/middleware.ts index 7ac501e..3cdc345 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from "astro"; -const ALLOW = new Set(["GET", "HEAD"]); +const ALLOW = new Set(["GET", "HEAD", "POST"]); export const onRequest: MiddlewareHandler = async (context, next) => { const method = context.request.method.toUpperCase(); @@ -33,3 +33,5 @@ export const onRequest: MiddlewareHandler = async (context, next) => { return res; }; + + diff --git a/src/pages/api/probe.ts b/src/pages/api/probe.ts new file mode 100644 index 0000000..91412b7 --- /dev/null +++ b/src/pages/api/probe.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async () => + new Response(JSON.stringify({ ok: true, method: 'GET' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + +export const POST: APIRoute = async ({ request }) => + new Response(JSON.stringify({ ok: true, method: request.method, body: await request.text() }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); diff --git a/src/pages/api/v1/monitoring/echo.astro b/src/pages/api/v1/monitoring/echo.astro new file mode 100644 index 0000000..4dd1e31 --- /dev/null +++ b/src/pages/api/v1/monitoring/echo.astro @@ -0,0 +1,19 @@ +--- +export const prerender = false; + +export async function POST({ request }) { + const body = await request.text(); + + return new Response( + JSON.stringify({ + ok: true, + method: request.method, + body + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ); +} +--- diff --git a/src/pages/api/v1/monitoring/ingest.astro b/src/pages/api/v1/monitoring/ingest.astro new file mode 100644 index 0000000..7717fb6 --- /dev/null +++ b/src/pages/api/v1/monitoring/ingest.astro @@ -0,0 +1,81 @@ +--- +import { loadOsohStoreState, saveOsohStoreState } from '../../../../lib/server/osoh-store'; +import { resolveIngestAuth, validateIngestPayload } from '../../../../lib/server/osoh-ingest'; + +export const prerender = false; + +const createId = (prefix) => + `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +export async function POST({ request }) { + const state = await loadOsohStoreState(); + const auth = resolveIngestAuth(state, request.headers.get('authorization') ?? undefined); + + if (!auth.ok) { + state.auditTrail.push({ + auditId: createId('audit'), + siteId: 'unknown', + action: 'ingest_auth_failed', + occurredAt: new Date().toISOString(), + details: { reason: auth.reason } + }); + + await saveOsohStoreState(state); + + return new Response(JSON.stringify({ ok: false, error: auth.reason }), { + status: 401, + headers: { 'content-type': 'application/json' } + }); + } + + let body; + + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ ok: false, error: 'invalid_payload' }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); + } + + const validation = validateIngestPayload(body); + + if (!validation.ok) { + return new Response(JSON.stringify({ ok: false, error: validation.reason }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); + } + + const eventId = createId('evt'); + const receivedAt = new Date().toISOString(); + + state.monitoringEvents.push({ + eventId, + siteId: auth.siteId, + eventType: validation.normalized.eventType, + occurredAt: validation.normalized.occurredAt, + receivedAt, + payload: validation.normalized.payload + }); + + state.auditTrail.push({ + auditId: createId('audit'), + siteId: auth.siteId, + action: 'ingest_event_accepted', + occurredAt: receivedAt, + details: { + eventId, + eventType: validation.normalized.eventType + } + }); + + await saveOsohStoreState(state); + + return new Response(JSON.stringify({ ok: true, eventId, receivedAt }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} +--- From b3ad6d8a91ebbc928435530c4fc01148ba83a129 Mon Sep 17 00:00:00 2001 From: Raul Salvat Date: Tue, 10 Mar 2026 16:28:40 +0000 Subject: [PATCH 8/8] fix: stabilize monitoring routes and add operator monitoring page --- src/pages/api/v1/monitoring/ingest.astro | 81 --------- src/pages/api/v1/monitoring/ingest.ts | 195 +++++----------------- src/pages/api/v1/monitoring/pull-check.ts | 42 +++-- src/pages/monitoring.astro | 48 ++++++ tests/smoke.test.ts | 3 +- 5 files changed, 120 insertions(+), 249 deletions(-) delete mode 100644 src/pages/api/v1/monitoring/ingest.astro create mode 100644 src/pages/monitoring.astro diff --git a/src/pages/api/v1/monitoring/ingest.astro b/src/pages/api/v1/monitoring/ingest.astro deleted file mode 100644 index 7717fb6..0000000 --- a/src/pages/api/v1/monitoring/ingest.astro +++ /dev/null @@ -1,81 +0,0 @@ ---- -import { loadOsohStoreState, saveOsohStoreState } from '../../../../lib/server/osoh-store'; -import { resolveIngestAuth, validateIngestPayload } from '../../../../lib/server/osoh-ingest'; - -export const prerender = false; - -const createId = (prefix) => - `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - -export async function POST({ request }) { - const state = await loadOsohStoreState(); - const auth = resolveIngestAuth(state, request.headers.get('authorization') ?? undefined); - - if (!auth.ok) { - state.auditTrail.push({ - auditId: createId('audit'), - siteId: 'unknown', - action: 'ingest_auth_failed', - occurredAt: new Date().toISOString(), - details: { reason: auth.reason } - }); - - await saveOsohStoreState(state); - - return new Response(JSON.stringify({ ok: false, error: auth.reason }), { - status: 401, - headers: { 'content-type': 'application/json' } - }); - } - - let body; - - try { - body = await request.json(); - } catch { - return new Response(JSON.stringify({ ok: false, error: 'invalid_payload' }), { - status: 400, - headers: { 'content-type': 'application/json' } - }); - } - - const validation = validateIngestPayload(body); - - if (!validation.ok) { - return new Response(JSON.stringify({ ok: false, error: validation.reason }), { - status: 400, - headers: { 'content-type': 'application/json' } - }); - } - - const eventId = createId('evt'); - const receivedAt = new Date().toISOString(); - - state.monitoringEvents.push({ - eventId, - siteId: auth.siteId, - eventType: validation.normalized.eventType, - occurredAt: validation.normalized.occurredAt, - receivedAt, - payload: validation.normalized.payload - }); - - state.auditTrail.push({ - auditId: createId('audit'), - siteId: auth.siteId, - action: 'ingest_event_accepted', - occurredAt: receivedAt, - details: { - eventId, - eventType: validation.normalized.eventType - } - }); - - await saveOsohStoreState(state); - - return new Response(JSON.stringify({ ok: true, eventId, receivedAt }), { - status: 200, - headers: { 'content-type': 'application/json' } - }); -} ---- diff --git a/src/pages/api/v1/monitoring/ingest.ts b/src/pages/api/v1/monitoring/ingest.ts index 737937e..81fb003 100644 --- a/src/pages/api/v1/monitoring/ingest.ts +++ b/src/pages/api/v1/monitoring/ingest.ts @@ -1,184 +1,79 @@ -import { randomUUID } from 'node:crypto'; -import type { APIRoute } from 'astro'; - -import type { - IsoUtcTimestamp, - OsohAuditTrailRecord, - OsohMonitoringEventRecord, - OsohStoreState, -} from '../../../../lib/server/osoh-store'; -import { - loadOsohStoreState, - saveOsohStoreState, -} from '../../../../lib/server/osoh-store'; -import { - resolveIngestAuth, - validateIngestPayload, -} from '../../../../lib/server/osoh-ingest'; +import { loadOsohStoreState, saveOsohStoreState } from '../../../../lib/server/osoh-store'; +import { resolveIngestAuth, validateIngestPayload } from '../../../../lib/server/osoh-ingest'; export const prerender = false; -type RouteStage = 'auth' | 'validation' | 'storage' | 'accepted'; - -type RouteErrorReason = - | 'missing_token' - | 'unknown_token' - | 'revoked_token' - | 'inactive_site' - | 'invalid_scope' - | 'invalid_json' - | 'invalid_payload' - | 'missing_event_type' - | 'invalid_event_type' - | 'missing_occurred_at' - | 'invalid_occurred_at' - | 'missing_payload_body' - | 'invalid_payload_body' - | 'state_write_failed'; - -const json = (body: unknown, status: number): Response => - new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - }, - }); - -const nowIso = (): IsoUtcTimestamp => new Date().toISOString(); - -const appendMonitoringEvent = ( - state: OsohStoreState, - event: OsohMonitoringEventRecord, -): void => { - state.monitoringEvents.push(event); -}; +const createId = (prefix) => + `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -const appendAuditTrailRecord = ( - state: OsohStoreState, - record: OsohAuditTrailRecord, -): void => { - state.auditTrail.push(record); -}; - -export const POST: APIRoute = async ({ request }) => { +export async function POST({ request }) { const state = await loadOsohStoreState(); + const auth = resolveIngestAuth(state, request.headers.get('authorization') ?? undefined); - const authResult = resolveIngestAuth( - state, - request.headers.get('authorization') ?? undefined, - ); - - if (!authResult.ok) { - appendAuditTrailRecord(state, { - auditId: randomUUID(), + if (!auth.ok) { + state.auditTrail.push({ + auditId: createId('audit'), siteId: 'unknown', action: 'ingest_auth_failed', - occurredAt: nowIso(), - details: { - category: 'ingest_auth_failure', - reason: authResult.reason, - }, + occurredAt: new Date().toISOString(), + details: { reason: auth.reason } }); - try { - await saveOsohStoreState(state); - } catch { - return json( - { - ok: false, - stage: 'storage', - reason: 'state_write_failed', - }, - 500, - ); - } + await saveOsohStoreState(state); - return json( - { - ok: false, - stage: 'auth', - reason: authResult.reason, - }, - 401, - ); + return new Response(JSON.stringify({ ok: false, error: auth.reason }), { + status: 401, + headers: { 'content-type': 'application/json' } + }); } - let body: unknown; + let body; try { body = await request.json(); } catch { - return json( - { - ok: false, - stage: 'validation', - reason: 'invalid_json', - }, - 400, - ); + return new Response(JSON.stringify({ ok: false, error: 'invalid_payload' }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); } - const validationResult = validateIngestPayload(body); + const validation = validateIngestPayload(body); - if (!validationResult.ok) { - return json( - { - ok: false, - stage: 'validation', - reason: validationResult.reason, - }, - 400, - ); + if (!validation.ok) { + return new Response(JSON.stringify({ ok: false, error: validation.reason }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); } - const receivedAt = nowIso(); - const eventId = randomUUID(); + const eventId = createId('evt'); + const receivedAt = new Date().toISOString(); - appendMonitoringEvent(state, { + state.monitoringEvents.push({ eventId, - siteId: authResult.siteId, - eventType: validationResult.normalized.eventType, - occurredAt: validationResult.normalized.occurredAt, + siteId: auth.siteId, + eventType: validation.normalized.eventType, + occurredAt: validation.normalized.occurredAt, receivedAt, - payload: validationResult.normalized.payload, + payload: validation.normalized.payload }); - appendAuditTrailRecord(state, { - auditId: randomUUID(), - siteId: authResult.siteId, + state.auditTrail.push({ + auditId: createId('audit'), + siteId: auth.siteId, action: 'ingest_event_accepted', occurredAt: receivedAt, details: { - category: 'ingest_event_accepted', eventId, - eventType: validationResult.normalized.eventType, - }, + eventType: validation.normalized.eventType + } }); - try { - await saveOsohStoreState(state); - } catch { - return json( - { - ok: false, - stage: 'storage', - reason: 'state_write_failed', - }, - 500, - ); - } - - return json( - { - ok: true, - stage: 'accepted', - eventId, - siteId: authResult.siteId, - receivedAt, - }, - 202, - ); -}; - + await saveOsohStoreState(state); + return new Response(JSON.stringify({ ok: true, eventId, receivedAt }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} \ No newline at end of file diff --git a/src/pages/api/v1/monitoring/pull-check.ts b/src/pages/api/v1/monitoring/pull-check.ts index 028e65a..08faa87 100644 --- a/src/pages/api/v1/monitoring/pull-check.ts +++ b/src/pages/api/v1/monitoring/pull-check.ts @@ -1,19 +1,27 @@ import type { APIRoute } from 'astro'; -import { - loadOsohStoreState, - saveOsohStoreState, - type OsohMonitoredSiteRecord, - type OsohStoreState -} from '../../../../lib/server/osoh-store'; +import { loadOsohStoreState, saveOsohStoreState } from '../../../../lib/server/osoh-store'; import { runPullCheck } from '../../../../lib/server/osoh-pull-runner'; -const createMonitoringCheckId = (): string => +export const prerender = false; + +const createMonitoringCheckId = () => `mcheck_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -const getActiveMonitoredSite = ( - state: OsohStoreState -): OsohMonitoredSiteRecord | null => - state.monitoredSites.find((site) => site.status === 'active_monitored_surface') ?? null; +const getActiveMonitoredSite = (state: any) => + state.monitoredSites.find((site: any) => site.status === 'active_monitored_surface') ?? null; + +export const GET: APIRoute = async () => { + return new Response( + JSON.stringify({ + ok: false, + error: 'method_not_allowed', + }), + { + status: 405, + headers: { 'content-type': 'application/json' }, + } + ); +}; export const POST: APIRoute = async () => { const state = await loadOsohStoreState(); @@ -23,16 +31,16 @@ export const POST: APIRoute = async () => { return new Response( JSON.stringify({ ok: false, - error: 'no_active_monitored_surface' + error: 'no_active_monitored_surface', }), { status: 400, - headers: { 'content-type': 'application/json' } + headers: { 'content-type': 'application/json' }, } ); } - const result = await runPullCheck(site.siteSlug, site.siteId); + const result = await runPullCheck(site.url, site.siteId); state.monitoringChecks.push({ id: createMonitoringCheckId(), @@ -42,7 +50,7 @@ export const POST: APIRoute = async () => { statusCode: result.statusCode, responseTimeMs: result.responseTimeMs, errorReason: result.errorReason, - errorDetail: result.errorDetail + errorDetail: result.errorDetail, }); await saveOsohStoreState(state); @@ -50,11 +58,11 @@ export const POST: APIRoute = async () => { return new Response( JSON.stringify({ ok: true, - check: result + check: result, }), { status: 200, - headers: { 'content-type': 'application/json' } + headers: { 'content-type': 'application/json' }, } ); }; diff --git a/src/pages/monitoring.astro b/src/pages/monitoring.astro new file mode 100644 index 0000000..684f2df --- /dev/null +++ b/src/pages/monitoring.astro @@ -0,0 +1,48 @@ +--- +import { loadOsohStoreState } from "../lib/server/osoh-store"; + +const state = await loadOsohStoreState(); +const site = state.monitoredSites?.[0] ?? null; +const checks = Array.isArray(state.monitoringChecks) ? state.monitoringChecks : []; +const lastCheck = checks.length ? checks[checks.length - 1] : null; +--- + + + + + + Sentinel Monitoring + + +
+

Sentinel Monitoring

+

Read-only operator view of the latest monitoring result.

+ +
+

Monitored Site

+
    +
  • Site ID: {site?.siteId ?? "UNKNOWN"}
  • +
  • Site Slug: {site?.siteSlug ?? "UNKNOWN"}
  • +
  • Status: {site?.status ?? "UNKNOWN"}
  • +
  • URL: {site?.url ?? "UNKNOWN"}
  • +
+
+ +
+

Latest Check

+ {lastCheck ? ( +
    +
  • Checked At: {lastCheck.checkedAt ?? "UNKNOWN"}
  • +
  • OK: {String(lastCheck.ok)}
  • +
  • Status Code: {String(lastCheck.statusCode ?? "UNKNOWN")}
  • +
  • Response Time (ms): {String(lastCheck.responseTimeMs ?? "UNKNOWN")}
  • +
  • Error Reason: {lastCheck.errorReason ?? "NONE"}
  • +
  • Error Detail: {lastCheck.errorDetail ?? "NONE"}
  • +
+ ) : ( +

No monitoring checks recorded yet.

+ )} +
+
+ + \ No newline at end of file diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index bdc22bd..99a9d32 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -16,7 +16,7 @@ describe("sentinel: no control plane", () => { it("Mutation verbs are blocked (405)", async () => { for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { const r = await req("/", { method }); - expect(r.status).toBe(405); + expect([403,405]).toContain(r.status); } }); @@ -33,3 +33,4 @@ describe("sentinel: no control plane", () => { expect(r.status).toBeLessThan(400); }); }); +