From 5e341e39da58647b2a1a5d857862ea4248fafa3d Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Sun, 17 May 2026 07:30:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(worker):=20Phase=202c=20=E2=80=94=20port?= =?UTF-8?q?=20external=20HTTP=20reads=20(evidence-ledger=20+=20ecosystem)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on #37 (Phase 2b) -> #36 (Phase 2a) -> #34 -> #33. Adds Workers-native HTTP clients and Hono routes for the two external-read endpoints from the Express server: GET /api/evidence-ledger/:chittyId server/routes.ts:80 GET /api/ecosystem/status server/routes.ts:105 Clients (worker/src/clients/): - chittyledger.ts — getEvidence(env, chittyId), 3s timeout via AbortController, LedgerClientError surfaces upstream HTTP status (404 mapped distinctly). - ecosystem.ts — getEcosystemStatus(env) fans out to 5 ChittyChain services (ChittyID, ChittyAssets, ChittyTrust, ChittyResolution, ChittyFile) in parallel. Per-call timeout 3s, aggregate deadline 5s. Never throws — degraded services are reported per-row with error context. Routes (worker/src/routes/): - evidence-ledger.ts — validates CHITTY_ID_PATTERN before calling upstream; maps 404 -> 404 and other upstream failures -> 502 (ledger_unavailable). - ecosystem.ts — returns 200 with aggregated body even when partial-failure, matching Express semantics (degraded != HTTP error). Env additions (env.ts): - CHITTYID_URL, CHITTYASSETS_URL, CHITTYTRUST_URL, CHITTYRESOLUTION_URL, CHITTYFILE_URL (all optional; defaults at https://{svc}.chitty.cc). Canonical compliance: - @canon: chittycanon://core/services/chittyassets header on every new file. - All five P/L/T/E/A entity types enumerated in env.ts (unchanged). - chittyId is type-agnostic per ledger schema; ecosystem services act as Authority (A) bearers — documented inline. No-mocks compliance: - Tests use real fetch against live ledger.chitty.cc and *.chitty.cc/health. - 404/502 paths exercised against real upstream + a non-routable host for the unreachable-host case (real DNS failure, not a mock). - No vi.mock / no nock / no fake fetch. Validation evidence: - npm run check: 0 new errors in worker/ (pre-existing server/ errors only). - Worker tests: 5/5 new tests pass; 6 prior no-DB tests still pass. (4 DB-backed test files require TEST_DB_URL, same as in #36/#37.) - npx wrangler deploy --dry-run --env production: success, 585 KiB upload. - Live HTTP sample (curl, 2026-05-17): id.chitty.cc/health -> 200 (53ms) assets.chitty.cc/health -> 522 trust.chitty.cc/health -> 200 (52ms) resolution.chitty.cc/health -> DNS NXDOMAIN file.chitty.cc/health -> DNS NXDOMAIN Route handles this exact degraded state: returns 200 with summary { total: 5, reachable: 2, unreachable: 3 } and per-service error rows. Unilateral decisions: - 5 ecosystem services per spec (ChittyID/ChittyAssets/ChittyTrust/ ChittyResolution/ChittyFile). Defaults at https://{svc}.chitty.cc; envs added so they can be overridden later without code change. - Degraded ecosystem -> HTTP 200 (matches Express). HTTP 502 reserved for ledger upstream failure where the caller asked a specific question. - Invalid chittyId guard returns 400 before any upstream call — saves the 3s timeout when the path param is obviously malformed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/ecosystem.integration.test.ts | 132 +++++++++++++++ .../evidence-ledger.integration.test.ts | 97 +++++++++++ worker/src/clients/chittyledger.ts | 76 +++++++++ worker/src/clients/ecosystem.ts | 153 ++++++++++++++++++ worker/src/env.ts | 7 + worker/src/index.ts | 12 +- worker/src/routes/ecosystem.ts | 39 +++++ worker/src/routes/evidence-ledger.ts | 62 +++++++ 8 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 worker/__tests__/ecosystem.integration.test.ts create mode 100644 worker/__tests__/evidence-ledger.integration.test.ts create mode 100644 worker/src/clients/chittyledger.ts create mode 100644 worker/src/clients/ecosystem.ts create mode 100644 worker/src/routes/ecosystem.ts create mode 100644 worker/src/routes/evidence-ledger.ts diff --git a/worker/__tests__/ecosystem.integration.test.ts b/worker/__tests__/ecosystem.integration.test.ts new file mode 100644 index 0000000..b9de94d --- /dev/null +++ b/worker/__tests__/ecosystem.integration.test.ts @@ -0,0 +1,132 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2c ecosystem-status fan-out. +// REAL HTTP — no mocks. Hits the live *.chitty.cc/health endpoints in parallel. +// +// Per chittycanon://gov/governance#core-types — ecosystem services are +// Authority (A) bearers. Caller is Person (P). All five P/L/T/E/A enumerated +// in env.ts. + +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { registerEcosystemRoutes } from "../src/routes/ecosystem"; +import type { ChittyAuthClaims, Env } from "../src/env"; + +const CALLER = "01-A-CHT-ASST-P-5T-1-X"; + +function claims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: CALLER, + chitty_id: CALLER, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "phase2c@chitty.cc", + }; +} + +function buildApp(envOverrides: Partial = {}) { + const stubEnv = { + ENVIRONMENT: "development" as const, + CHITTYAUTH_ISSUER: "https://auth.chitty.cc", + CHITTYAUTH_JWKS_URL: "https://auth.chitty.cc/.well-known/jwks.json", + CHITTYAUTH_AUDIENCE: "chittyassets-api", + CHITTYASSETS_DB: undefined as unknown as Hyperdrive, + ...envOverrides, + } as Env; + + const app = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + app.use("*", async (c, next) => { + Object.defineProperty(c, "env", { get: () => stubEnv, configurable: true }); + c.set("claims", claims()); + await next(); + }); + const api = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerEcosystemRoutes(api, async (_c, next) => { + await next(); + }); + app.route("/api", api); + return app; +} + +const EXPECTED_SERVICES = [ + "chittyid", + "chittyassets", + "chittytrust", + "chittyresolution", + "chittyfile", +]; + +interface ServiceRow { + service: string; + url: string; + reachable: boolean; + http_status: number | null; + latency_ms: number | null; + health: Record | null; + error: string | null; +} +interface EcoBody { + ok: boolean; + checked_at: string; + services: ServiceRow[]; + summary: { total: number; reachable: number; unreachable: number }; +} + +describe("GET /api/ecosystem/status — Phase 2c (live HTTP fan-out)", () => { + it("200 — fans out to 5 services, returns aggregated status", async () => { + const app = buildApp(); + const res = await app.request("/api/ecosystem/status"); + expect(res.status).toBe(200); + const body = (await res.json()) as EcoBody; + + expect(Array.isArray(body.services)).toBe(true); + expect(body.services.length).toBe(5); + expect(body.services.map((s) => s.service).sort()).toEqual( + [...EXPECTED_SERVICES].sort(), + ); + // Summary is internally consistent. + expect(body.summary.total).toBe(5); + expect(body.summary.reachable + body.summary.unreachable).toBe(5); + expect(body.summary.reachable).toBe( + body.services.filter((s) => s.reachable).length, + ); + // ok flag mirrors all-reachable. + expect(body.ok).toBe(body.summary.reachable === 5); + // checked_at is a valid ISO timestamp. + expect(() => new Date(body.checked_at).toISOString()).not.toThrow(); + // Each row has the expected shape. + for (const s of body.services) { + expect(typeof s.url).toBe("string"); + expect(s.url).toMatch(/\/health$/); + expect(typeof s.reachable).toBe("boolean"); + } + }, 15000); + + it("200 — unreachable hosts produce per-service error context (degraded ok=false)", async () => { + const app = buildApp({ + CHITTYID_URL: "https://invalid-id-host.chitty-invalid-tld", + CHITTYASSETS_URL: "https://invalid-assets-host.chitty-invalid-tld", + CHITTYTRUST_URL: "https://invalid-trust-host.chitty-invalid-tld", + CHITTYRESOLUTION_URL: "https://invalid-resolution-host.chitty-invalid-tld", + CHITTYFILE_URL: "https://invalid-file-host.chitty-invalid-tld", + }); + const res = await app.request("/api/ecosystem/status"); + expect(res.status).toBe(200); + const body = (await res.json()) as EcoBody; + expect(body.ok).toBe(false); + expect(body.summary.unreachable).toBe(5); + for (const s of body.services) { + expect(s.reachable).toBe(false); + expect(s.error).not.toBeNull(); + } + }, 15000); +}); diff --git a/worker/__tests__/evidence-ledger.integration.test.ts b/worker/__tests__/evidence-ledger.integration.test.ts new file mode 100644 index 0000000..314756a --- /dev/null +++ b/worker/__tests__/evidence-ledger.integration.test.ts @@ -0,0 +1,97 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2c evidence-ledger external read. +// REAL HTTP — no mocks. Tests verify: invalid ID guard (no upstream call), +// upstream-error mapping (real call to a sentinel chitty_id, expect 404 or 502 +// passthrough), shape of error envelope. +// +// Per chittycanon://gov/governance#core-types — caller is Person (P); the +// chittyId being looked up may reference any of P/L/T/E/A. All five enumerated +// in env.ts. + +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { registerEvidenceLedgerRoutes } from "../src/routes/evidence-ledger"; +import type { ChittyAuthClaims, Env } from "../src/env"; + +const CALLER = "01-A-CHT-ASST-P-5T-1-X"; +// Canonical-shape chittyId that should not exist on the live ledger — used to +// exercise the real 404 / upstream-error path without mocking. +const NONEXISTENT = "ZZ-Z-ZZZ-ZZZZ-T-ZZ-Z-Z"; + +function claims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: CALLER, + chitty_id: CALLER, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "phase2c@chitty.cc", + }; +} + +function buildApp(envOverrides: Partial = {}) { + const stubEnv = { + ENVIRONMENT: "development" as const, + CHITTYAUTH_ISSUER: "https://auth.chitty.cc", + CHITTYAUTH_JWKS_URL: "https://auth.chitty.cc/.well-known/jwks.json", + CHITTYAUTH_AUDIENCE: "chittyassets-api", + CHITTYLEDGER_URL: "https://ledger.chitty.cc", + CHITTYASSETS_DB: undefined as unknown as Hyperdrive, + ...envOverrides, + } as Env; + + const app = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + app.use("*", async (c, next) => { + Object.defineProperty(c, "env", { get: () => stubEnv, configurable: true }); + c.set("claims", claims()); + await next(); + }); + const api = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerEvidenceLedgerRoutes(api, async (_c, next) => { + await next(); + }); + app.route("/api", api); + return app; +} + +describe("GET /api/evidence-ledger/:chittyId — Phase 2c", () => { + it("400 — rejects malformed chittyId without calling upstream", async () => { + const app = buildApp(); + const res = await app.request("/api/evidence-ledger/not-a-valid-id"); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("invalid_chitty_id"); + }); + + it("404 or 502 — real call against live ledger for nonexistent canonical ID", async () => { + // Real network call to https://ledger.chitty.cc — no mocks. + // We accept either: 404 (ledger responded with not-found, which we map to 404), + // 502 (ledger unreachable / 5xx / network — also valid real behavior). + const app = buildApp(); + const res = await app.request(`/api/evidence-ledger/${NONEXISTENT}`); + expect([404, 502]).toContain(res.status); + const body = (await res.json()) as { error: string }; + expect(typeof body.error).toBe("string"); + expect(["not_found", "ledger_unavailable"]).toContain(body.error); + }, 15000); + + it("502 — points-at unreachable host returns ledger_unavailable", async () => { + // Real call to a non-routable URL — exercises timeout / fetch-error path. + const app = buildApp({ + CHITTYLEDGER_URL: "https://ledger-does-not-exist.chitty-invalid-tld", + }); + const res = await app.request(`/api/evidence-ledger/${NONEXISTENT}`); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("ledger_unavailable"); + }, 10000); +}); diff --git a/worker/src/clients/chittyledger.ts b/worker/src/clients/chittyledger.ts new file mode 100644 index 0000000..a2b7ff6 --- /dev/null +++ b/worker/src/clients/chittyledger.ts @@ -0,0 +1,76 @@ +// @canon: chittycanon://core/services/chittyassets +// ChittyLedger HTTP client — Workers-compatible (uses global fetch). +// +// Per chittycanon://gov/governance#core-types the chittyId being looked up can +// reference any of the five entity types P/L/T/E/A — the ledger does not +// restrict by type; this client is type-agnostic and forwards opaquely. +// +// Phase 2c: backs GET /api/evidence-ledger/:chittyId. Real HTTP only — no mocks. + +import type { Env } from "../env"; + +const DEFAULT_LEDGER_URL = "https://ledger.chitty.cc"; + +export interface LedgerEvidenceResponse { + // Opaque pass-through — ledger owns the schema. We surface JSON as-is so the + // schema-overlord can audit ledger schema separately. + [key: string]: unknown; +} + +export class LedgerClientError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly upstream: string, + ) { + super(message); + this.name = "LedgerClientError"; + } +} + +function baseUrl(env: Env): string { + return env.CHITTYLEDGER_URL ?? DEFAULT_LEDGER_URL; +} + +/** + * Fetch evidence by ChittyID from ChittyLedger. + * Real network call. 3s per-request timeout via AbortController. + * Throws LedgerClientError on non-2xx / timeout / network failure. + */ +export async function getEvidence( + env: Env, + chittyId: string, + opts: { timeoutMs?: number; signal?: AbortSignal } = {}, +): Promise { + const url = `${baseUrl(env)}/api/v1/evidence/${encodeURIComponent(chittyId)}`; + const timeoutMs = opts.timeoutMs ?? 3000; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs); + // Chain caller-supplied signal if present. + if (opts.signal) { + if (opts.signal.aborted) ctrl.abort(opts.signal.reason); + else opts.signal.addEventListener("abort", () => ctrl.abort(opts.signal!.reason)); + } + try { + const res = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: ctrl.signal, + }); + if (!res.ok) { + throw new LedgerClientError( + `ChittyLedger returned ${res.status}`, + res.status, + url, + ); + } + const body = (await res.json()) as LedgerEvidenceResponse; + return body; + } catch (err) { + if (err instanceof LedgerClientError) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new LedgerClientError(`ledger fetch failed: ${msg}`, 502, url); + } finally { + clearTimeout(timer); + } +} diff --git a/worker/src/clients/ecosystem.ts b/worker/src/clients/ecosystem.ts new file mode 100644 index 0000000..06adbb7 --- /dev/null +++ b/worker/src/clients/ecosystem.ts @@ -0,0 +1,153 @@ +// @canon: chittycanon://core/services/chittyassets +// ChittyChain ecosystem fan-out client — Workers-compatible (global fetch). +// +// Phase 2c: backs GET /api/ecosystem/status. Fans out in parallel to the 5 +// ChittyChain services (ChittyID, ChittyAssets, ChittyTrust, ChittyResolution, +// ChittyFile), each with a 3s timeout, aggregate 5s deadline. +// +// Per chittycanon://gov/governance#core-types the services themselves are +// Authority (A) bearers (they issue/verify credentials, decisions, attestations +// over Things, Events, Persons, and Locations). The full P/L/T/E/A enumeration +// lives in env.ts. Status here describes Authority availability — it is NOT a +// claim about any P/L/T/E entity. + +import type { Env } from "../env"; + +export type ServiceKey = + | "chittyid" + | "chittyassets" + | "chittytrust" + | "chittyresolution" + | "chittyfile"; + +export interface ServiceStatus { + service: ServiceKey; + url: string; + reachable: boolean; + http_status: number | null; + latency_ms: number | null; + health: Record | null; + error: string | null; +} + +export interface EcosystemStatus { + ok: boolean; + checked_at: string; + services: ServiceStatus[]; + summary: { + total: number; + reachable: number; + unreachable: number; + }; +} + +const DEFAULTS: Record = { + chittyid: "https://id.chitty.cc", + chittyassets: "https://assets.chitty.cc", + chittytrust: "https://trust.chitty.cc", + chittyresolution: "https://resolution.chitty.cc", + chittyfile: "https://file.chitty.cc", +}; + +function resolveUrls(env: Env): Record { + return { + chittyid: env.CHITTYID_URL ?? DEFAULTS.chittyid, + chittyassets: env.CHITTYASSETS_URL ?? DEFAULTS.chittyassets, + chittytrust: env.CHITTYTRUST_URL ?? DEFAULTS.chittytrust, + chittyresolution: env.CHITTYRESOLUTION_URL ?? DEFAULTS.chittyresolution, + chittyfile: env.CHITTYFILE_URL ?? DEFAULTS.chittyfile, + }; +} + +async function checkOne( + service: ServiceKey, + base: string, + parentSignal: AbortSignal, + perCallTimeoutMs: number, +): Promise { + const url = `${base.replace(/\/$/, "")}/health`; + const ctrl = new AbortController(); + const timer = setTimeout( + () => ctrl.abort(new Error("per-call timeout")), + perCallTimeoutMs, + ); + if (parentSignal.aborted) ctrl.abort(parentSignal.reason); + else parentSignal.addEventListener("abort", () => ctrl.abort(parentSignal.reason)); + + const start = Date.now(); + try { + const res = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: ctrl.signal, + }); + const latency = Date.now() - start; + let health: Record | null = null; + try { + health = (await res.json()) as Record; + } catch { + health = null; + } + return { + service, + url, + reachable: res.ok, + http_status: res.status, + latency_ms: latency, + health, + error: res.ok ? null : `non-2xx: ${res.status}`, + }; + } catch (err) { + const latency = Date.now() - start; + const msg = err instanceof Error ? err.message : String(err); + return { + service, + url, + reachable: false, + http_status: null, + latency_ms: latency, + health: null, + error: msg, + }; + } finally { + clearTimeout(timer); + } +} + +/** + * Fan out parallel health checks to the 5 ChittyChain ecosystem services. + * Per-call timeout: 3s. Aggregate deadline: 5s. Never throws — degraded + * services are reported individually with error context. + */ +export async function getEcosystemStatus( + env: Env, + opts: { perCallTimeoutMs?: number; aggregateTimeoutMs?: number } = {}, +): Promise { + const perCall = opts.perCallTimeoutMs ?? 3000; + const aggregate = opts.aggregateTimeoutMs ?? 5000; + const urls = resolveUrls(env); + const parent = new AbortController(); + const aggTimer = setTimeout( + () => parent.abort(new Error("aggregate timeout")), + aggregate, + ); + try { + const keys = Object.keys(urls) as ServiceKey[]; + const results = await Promise.all( + keys.map((k) => checkOne(k, urls[k], parent.signal, perCall)), + ); + const reachable = results.filter((r) => r.reachable).length; + return { + ok: reachable === results.length, + checked_at: new Date().toISOString(), + services: results, + summary: { + total: results.length, + reachable, + unreachable: results.length - reachable, + }, + }; + } finally { + clearTimeout(aggTimer); + } +} diff --git a/worker/src/env.ts b/worker/src/env.ts index fd7da89..f3886ca 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -18,6 +18,13 @@ export interface Env { CHITTYMINT_URL?: string; CHITTYCONNECT_URL?: string; CHITTYLEDGER_URL?: string; + // Phase 2c — ChittyChain ecosystem fan-out (5 services). Defaults applied at + // call-site if unset. Each is a base URL with a /health endpoint. + CHITTYID_URL?: string; + CHITTYASSETS_URL?: string; + CHITTYTRUST_URL?: string; + CHITTYRESOLUTION_URL?: string; + CHITTYFILE_URL?: string; // Phase 2+ bindings (active): CHITTYASSETS_DB: Hyperdrive; diff --git a/worker/src/index.ts b/worker/src/index.ts index 14fcee7..7c1c10d 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -6,6 +6,8 @@ // Phase 2b: simple owner-scoped reads ported // (GET /api/assets/:assetId/warranties, /api/warranties/expiring, // /api/assets/:assetId/insurance, /api/legal-cases, /api/tools/resources). +// Phase 2c: external HTTP reads ported +// (GET /api/evidence-ledger/:chittyId, GET /api/ecosystem/status). import { Hono } from "hono"; import { cors } from "hono/cors"; @@ -17,6 +19,8 @@ import { warrantyRoutes } from "./routes/warranties"; import { insuranceRoutes } from "./routes/insurance"; import { legalCaseRoutes } from "./routes/legal-cases"; import { toolRoutes } from "./routes/tools"; +import { evidenceLedgerRoutes } from "./routes/evidence-ledger"; +import { ecosystemRoutes } from "./routes/ecosystem"; type Variables = { claims: ChittyAuthClaims }; @@ -63,7 +67,7 @@ app.get("/api/v1/status", (c) => canonical_uri: "chittycanon://core/services/chittyassets", version: "1.0.0", environment: c.env.ENVIRONMENT, - migration_status: "PHASE_2B_SIMPLE_READS", + migration_status: "PHASE_2C_EXTERNAL_READS", migrated_routes: [ "GET /api/assets", "GET /api/assets/stats", @@ -75,6 +79,8 @@ app.get("/api/v1/status", (c) => "GET /api/assets/:assetId/insurance", "GET /api/legal-cases", "GET /api/tools/resources", + "GET /api/evidence-ledger/:chittyId", + "GET /api/ecosystem/status", ], entity_types_handled: [...ENTITY_TYPES], dependencies: { @@ -97,12 +103,14 @@ app.get("/api/auth/user", requireChittyAuth, (c) => { }); }); -// Phase 2a/2b read routes — registered BEFORE the 501 catch-all. +// Phase 2a/2b/2c read routes — registered BEFORE the 501 catch-all. app.route("/api", assetRoutes); app.route("/api", warrantyRoutes); app.route("/api", insuranceRoutes); app.route("/api", legalCaseRoutes); app.route("/api", toolRoutes); +app.route("/api", evidenceLedgerRoutes); +app.route("/api", ecosystemRoutes); // Unmigrated routes return 501 unconditionally — no auth oracle. app.all("/api/*", (c) => diff --git a/worker/src/routes/ecosystem.ts b/worker/src/routes/ecosystem.ts new file mode 100644 index 0000000..a07171e --- /dev/null +++ b/worker/src/routes/ecosystem.ts @@ -0,0 +1,39 @@ +// @canon: chittycanon://core/services/chittyassets +// Ecosystem-status read route — Phase 2c of Express→Hono migration. +// +// Route ported (GET only — external HTTP fan-out): +// GET /api/ecosystem/status server/routes.ts:105 +// +// Per chittycanon://gov/governance#core-types — ecosystem services are +// Authority (A) bearers (they issue/verify credentials, decisions, attestations +// across Persons (P), Locations (L), Things (T), and Events (E)). This endpoint +// reports Authority availability; it makes no claim about any P/L/T/E entity. +// All five entity types P/L/T/E/A remain enumerated in env.ts. + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { requireChittyAuth } from "../auth"; +import type { Env, ChittyAuthClaims } from "../env"; +import { getEcosystemStatus } from "../clients/ecosystem"; + +type Variables = { claims: ChittyAuthClaims }; +type AppType = { Bindings: Env; Variables: Variables }; + +export function registerEcosystemRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + app.get("/ecosystem/status", authMiddleware, async (c) => { + const status = await getEcosystemStatus(c.env); + // Degraded ecosystem still returns 200 — the response body reflects which + // services are reachable. This matches the original Express semantics where + // partial failures were surfaced in the payload, not as HTTP errors. + return c.json(status); + }); +} + +export const ecosystemRoutes = (() => { + const r = new Hono(); + registerEcosystemRoutes(r, requireChittyAuth); + return r; +})(); diff --git a/worker/src/routes/evidence-ledger.ts b/worker/src/routes/evidence-ledger.ts new file mode 100644 index 0000000..9ad8180 --- /dev/null +++ b/worker/src/routes/evidence-ledger.ts @@ -0,0 +1,62 @@ +// @canon: chittycanon://core/services/chittyassets +// Evidence-ledger read route — Phase 2c of Express→Hono migration. +// +// Route ported (GET only — external HTTP read): +// GET /api/evidence-ledger/:chittyId server/routes.ts:80 +// +// Per chittycanon://gov/governance#core-types — the chittyId path param can +// reference any of P/L/T/E/A; ChittyLedger is type-agnostic. The caller is a +// Person (P). All five entity types P/L/T/E/A remain enumerated in env.ts. + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { requireChittyAuth } from "../auth"; +import { CHITTY_ID_PATTERN, type Env, type ChittyAuthClaims } from "../env"; +import { getEvidence, LedgerClientError } from "../clients/chittyledger"; + +type Variables = { claims: ChittyAuthClaims }; +type AppType = { Bindings: Env; Variables: Variables }; + +export function registerEvidenceLedgerRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + app.get("/evidence-ledger/:chittyId", authMiddleware, async (c) => { + const chittyId = c.req.param("chittyId"); + if (!CHITTY_ID_PATTERN.test(chittyId)) { + return c.json( + { error: "invalid_chitty_id", message: "chittyId must match canonical format" }, + 400, + ); + } + try { + const evidence = await getEvidence(c.env, chittyId); + return c.json(evidence); + } catch (err) { + if (err instanceof LedgerClientError) { + // Surface 404 distinctly; everything else becomes 502 bad-gateway. + if (err.status === 404) { + return c.json( + { error: "not_found", message: "Evidence not in ledger", chitty_id: chittyId }, + 404, + ); + } + return c.json( + { + error: "ledger_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + }); +} + +export const evidenceLedgerRoutes = (() => { + const r = new Hono(); + registerEvidenceLedgerRoutes(r, requireChittyAuth); + return r; +})();