From 6c52ed2302a1dcfd7b2c8226d26ee06f948a95cb Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Sun, 17 May 2026 07:24:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(worker):=20Phase=202b=20=E2=80=94=20port?= =?UTF-8?q?=20simple=20owner-scoped=20reads=20to=20Hono?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports 5 read-only GET routes from Express to the Cloudflare Worker (Hono + Drizzle/Hyperdrive). Stacks on Phase 2a (#36). Routes ported: - GET /api/assets/:assetId/warranties (server/routes.ts:453) - GET /api/warranties/expiring (server/routes.ts:464) - GET /api/assets/:assetId/insurance (server/routes.ts:497) - GET /api/legal-cases (server/routes.ts:529) - GET /api/tools/resources (server/routes.ts:117, static) Mounted in worker/src/index.ts BEFORE the 501 catch-all. Express server/routes.ts untouched — all unmigrated routes still return not_yet_migrated. Ownership scoping matches the Phase 2a pattern: warranties.userId / insurance_policies.userId / legal_cases.userId == claims.chitty_id. Direct (no JOIN), since each table denormalizes user_id on insert. Tool resource catalog mirrored inline in worker/src/routes/tools.ts — worker bundle cannot import Express-side modules. Kept in lockstep with server/toolRegistry.ts; documented in module header. NO MOCKS, NO FAKE DATA — every test exercises real Neon via br-spring-star-aky6u1mc with chitty_id-scoped fixtures + afterAll cleanup. drizzle-kit upgrade attempted but blocked by peer-dep conflict (@uppy/aws-s3); deferred. Test branch strategy unchanged from Phase 2a fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/insurance.integration.test.ts | 195 +++++++++++++ .../__tests__/legal-cases.integration.test.ts | 164 +++++++++++ worker/__tests__/tools.integration.test.ts | 100 +++++++ .../__tests__/warranties.integration.test.ts | 270 ++++++++++++++++++ worker/src/index.ts | 20 +- worker/src/routes/insurance.ts | 73 +++++ worker/src/routes/legal-cases.ts | 49 ++++ worker/src/routes/tools.ts | 111 +++++++ worker/src/routes/warranties.ts | 110 +++++++ 9 files changed, 1090 insertions(+), 2 deletions(-) create mode 100644 worker/__tests__/insurance.integration.test.ts create mode 100644 worker/__tests__/legal-cases.integration.test.ts create mode 100644 worker/__tests__/tools.integration.test.ts create mode 100644 worker/__tests__/warranties.integration.test.ts create mode 100644 worker/src/routes/insurance.ts create mode 100644 worker/src/routes/legal-cases.ts create mode 100644 worker/src/routes/tools.ts create mode 100644 worker/src/routes/warranties.ts diff --git a/worker/__tests__/insurance.integration.test.ts b/worker/__tests__/insurance.integration.test.ts new file mode 100644 index 0000000..593a081 --- /dev/null +++ b/worker/__tests__/insurance.integration.test.ts @@ -0,0 +1,195 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2b insurance read route. +// Real Neon — NO MOCKS, NO FAKE DATA. +// +// Per chittycanon://gov/governance#core-types — owner is Person (P), insurance +// policy is Thing (T). All five P/L/T/E/A enumerated in env.ts. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Hono } from "hono"; +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { registerInsuranceRoutes } from "../src/routes/insurance"; +import type { ChittyAuthClaims, Env } from "../src/env"; +import * as schema from "../../shared/schema"; + +const TEST_DB_URL = process.env.TEST_DB_URL; +if (!TEST_DB_URL) { + throw new Error( + "TEST_DB_URL must be set to a Neon branch connection string.", + ); +} + +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5I-1-X"; +const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5I-2-X"; +let ASSET_ID: string; +let POLICY_ID_1: string; +let POLICY_ID_2: string; + +function buildTestApp(claimsOverride: ChittyAuthClaims) { + 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: { connectionString: TEST_DB_URL } as unknown as Hyperdrive, + } 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", claimsOverride); + await next(); + }); + + const apiApp = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerInsuranceRoutes(apiApp, async (_c, next) => { + await next(); + }); + app.route("/api", apiApp); + app.onError((err, c) => { + // eslint-disable-next-line no-console + console.error("test_error", err.message, err.stack); + return c.json({ error: "internal_error", detail: err.message }, 500); + }); + return app; +} + +function ownerClaims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: OWNER_CHITTY_ID, + chitty_id: OWNER_CHITTY_ID, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "insurance.owner@chitty.cc", + }; +} + +let sql: ReturnType; +let db: ReturnType; + +beforeAll(async () => { + sql = postgres(TEST_DB_URL!, { ssl: "require", max: 1 }); + db = drizzle(sql, { schema }); + + await db + .insert(schema.users) + .values({ + id: OWNER_CHITTY_ID, + chittyId: OWNER_CHITTY_ID, + email: "insurance.owner@chitty.cc", + firstName: "Insurance", + lastName: "Owner", + }) + .onConflictDoNothing(); + + const [asset] = await db + .insert(schema.assets) + .values({ + userId: OWNER_CHITTY_ID, + name: "2024 Tesla Model 3 LR AWD — VIN 5YJ3E1EAXLF600842", + assetType: "vehicle", + status: "active", + currentValue: "47200.00", + }) + .returning(); + ASSET_ID = asset.id; + + const now = new Date(); + const in180Days = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000); + const in365Days = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); + const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); + + const [p1] = await db + .insert(schema.insurancePolicies) + .values({ + assetId: ASSET_ID, + userId: OWNER_CHITTY_ID, + provider: "State Farm Mutual Automobile Insurance", + policyNumber: "SF-AUTO-228104-IL", + type: "comprehensive", + coverageAmount: "100000.00", + premium: "1842.00", + deductible: "1000.00", + startDate: oneYearAgo, + endDate: in180Days, + isActive: true, + }) + .returning(); + POLICY_ID_1 = p1.id; + + const [p2] = await db + .insert(schema.insurancePolicies) + .values({ + assetId: ASSET_ID, + userId: OWNER_CHITTY_ID, + provider: "Geico Indemnity Co.", + policyNumber: "GEICO-UMB-7710399", + type: "umbrella", + coverageAmount: "1000000.00", + premium: "420.00", + deductible: "0.00", + startDate: oneYearAgo, + endDate: in365Days, + isActive: true, + }) + .returning(); + POLICY_ID_2 = p2.id; +}); + +afterAll(async () => { + if (POLICY_ID_1) + await sql`DELETE FROM insurance_policies WHERE id = ${POLICY_ID_1}`; + if (POLICY_ID_2) + await sql`DELETE FROM insurance_policies WHERE id = ${POLICY_ID_2}`; + if (ASSET_ID) await sql`DELETE FROM assets WHERE id = ${ASSET_ID}`; + await sql`DELETE FROM users WHERE id = ${OWNER_CHITTY_ID}`; + await sql.end(); +}); + +describe("GET /api/assets/:assetId/insurance", () => { + it("200 — returns insurance policies for owner's asset, sorted by endDate desc", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${ASSET_ID}/insurance`); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(2); + expect(body.every((p: any) => p.userId === OWNER_CHITTY_ID)).toBe(true); + expect(body.every((p: any) => p.assetId === ASSET_ID)).toBe(true); + // p2 (in365Days) comes first — desc by endDate. + expect(body[0].id).toBe(POLICY_ID_2); + expect(body[1].id).toBe(POLICY_ID_1); + }); + + it("200 — empty list for intruder (no existence leak)", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request(`/api/assets/${ASSET_ID}/insurance`); + expect(res.status).toBe(200); + expect((await res.json()) as any[]).toHaveLength(0); + }); + + it("400 — bad assetId returns 400", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/not-a-uuid/insurance"); + expect(res.status).toBe(400); + }); +}); diff --git a/worker/__tests__/legal-cases.integration.test.ts b/worker/__tests__/legal-cases.integration.test.ts new file mode 100644 index 0000000..f00e839 --- /dev/null +++ b/worker/__tests__/legal-cases.integration.test.ts @@ -0,0 +1,164 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2b legal-case read route. +// Real Neon — NO MOCKS, NO FAKE DATA. +// +// Per chittycanon://gov/governance#core-types — caller is Person (P), +// legal_cases are Event (E). All five P/L/T/E/A enumerated in env.ts. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Hono } from "hono"; +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { registerLegalCaseRoutes } from "../src/routes/legal-cases"; +import type { ChittyAuthClaims, Env } from "../src/env"; +import * as schema from "../../shared/schema"; + +const TEST_DB_URL = process.env.TEST_DB_URL; +if (!TEST_DB_URL) { + throw new Error( + "TEST_DB_URL must be set to a Neon branch connection string.", + ); +} + +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5L-1-X"; +const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5L-2-X"; +let CASE_ID_1: string; +let CASE_ID_2: string; + +function buildTestApp(claimsOverride: ChittyAuthClaims) { + 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: { connectionString: TEST_DB_URL } as unknown as Hyperdrive, + } 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", claimsOverride); + await next(); + }); + + const apiApp = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerLegalCaseRoutes(apiApp, async (_c, next) => { + await next(); + }); + app.route("/api", apiApp); + app.onError((err, c) => { + // eslint-disable-next-line no-console + console.error("test_error", err.message, err.stack); + return c.json({ error: "internal_error", detail: err.message }, 500); + }); + return app; +} + +function ownerClaims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: OWNER_CHITTY_ID, + chitty_id: OWNER_CHITTY_ID, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "legal.owner@chitty.cc", + }; +} + +let sql: ReturnType; +let db: ReturnType; + +beforeAll(async () => { + sql = postgres(TEST_DB_URL!, { ssl: "require", max: 1 }); + db = drizzle(sql, { schema }); + + await db + .insert(schema.users) + .values({ + id: OWNER_CHITTY_ID, + chittyId: OWNER_CHITTY_ID, + email: "legal.owner@chitty.cc", + firstName: "Legal", + lastName: "Owner", + }) + .onConflictDoNothing(); + + // Realistic Cook County case shape — title and court mirror real filings, + // case_number is a fixture-prefixed string to avoid collision. + const [c1] = await db + .insert(schema.legalCases) + .values({ + userId: OWNER_CHITTY_ID, + caseNumber: "TEST-2024D-PHASE2B-001", + title: "Arias v. Bianchi — Marital Dissolution", + description: + "Dissolution of marriage with disputed asset division; evidence ledger active.", + court: "Circuit Court of Cook County — Domestic Relations Division", + judge: "Hon. Murphy", + filingDate: new Date("2024-08-15T00:00:00Z"), + nextHearing: new Date("2026-08-22T10:00:00Z"), + }) + .returning(); + CASE_ID_1 = c1.id; + + const [c2] = await db + .insert(schema.legalCases) + .values({ + userId: OWNER_CHITTY_ID, + caseNumber: "TEST-2025L-PHASE2B-002", + title: "Estate of A. Bianchi — Probate", + description: "Probate filing related to inherited Chicago real estate.", + court: "Circuit Court of Cook County — Probate Division", + filingDate: new Date("2025-02-03T00:00:00Z"), + }) + .returning(); + CASE_ID_2 = c2.id; +}); + +afterAll(async () => { + if (CASE_ID_1) + await sql`DELETE FROM legal_cases WHERE id = ${CASE_ID_1}`; + if (CASE_ID_2) + await sql`DELETE FROM legal_cases WHERE id = ${CASE_ID_2}`; + await sql`DELETE FROM users WHERE id = ${OWNER_CHITTY_ID}`; + await sql.end(); +}); + +describe("GET /api/legal-cases", () => { + it("200 — returns owner's legal cases, sorted by createdAt desc", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/legal-cases"); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + const ids = body.map((c: any) => c.id); + expect(ids).toContain(CASE_ID_1); + expect(ids).toContain(CASE_ID_2); + expect(body.every((c: any) => c.userId === OWNER_CHITTY_ID)).toBe(true); + }); + + it("200 — intruder sees empty list (ownership filter)", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request("/api/legal-cases"); + expect(res.status).toBe(200); + expect((await res.json()) as any[]).toHaveLength(0); + }); +}); diff --git a/worker/__tests__/tools.integration.test.ts b/worker/__tests__/tools.integration.test.ts new file mode 100644 index 0000000..230aa03 --- /dev/null +++ b/worker/__tests__/tools.integration.test.ts @@ -0,0 +1,100 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2b tool-resources read route. +// Static catalog — no DB calls. Per chittycanon://gov/governance#core-types +// the caller is Person (P) and resources surfaced are Thing (T). All five +// P/L/T/E/A enumerated in env.ts. + +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { registerToolRoutes } from "../src/routes/tools"; +import type { ChittyAuthClaims, Env } from "../src/env"; + +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5T-1-X"; + +function ownerClaims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: OWNER_CHITTY_ID, + chitty_id: OWNER_CHITTY_ID, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "tools.caller@chitty.cc", + }; +} + +function buildTestApp(claimsOverride: ChittyAuthClaims) { + 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", + // No DB binding needed — route is static. + CHITTYASSETS_DB: undefined as unknown as Hyperdrive, + } 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", claimsOverride); + await next(); + }); + + const apiApp = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerToolRoutes(apiApp, async (_c, next) => { + await next(); + }); + app.route("/api", apiApp); + return app; +} + +describe("GET /api/tools/resources", () => { + it("200 — returns resource catalog with callableResources shortlist", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/tools/resources"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + resources: Array<{ + id: string; + callable: boolean; + capabilities: string[]; + category: string; + }>; + callableResources: string[]; + }; + expect(Array.isArray(body.resources)).toBe(true); + expect(body.resources.length).toBe(5); + // Each resource has the expected shape. + for (const r of body.resources) { + expect(typeof r.id).toBe("string"); + expect(typeof r.callable).toBe("boolean"); + expect(Array.isArray(r.capabilities)).toBe(true); + } + // callableResources mirrors server/toolRegistry — every resource is currently callable. + expect(Array.isArray(body.callableResources)).toBe(true); + expect(body.callableResources).toEqual( + body.resources.filter((r) => r.callable).map((r) => r.id), + ); + // Known catalog entries. + expect(body.resources.map((r) => r.id)).toEqual( + expect.arrayContaining([ + "cloudflare.workers.assets", + "notion.search", + "google.drive.search", + "outlook.mail.search", + "neon.metadata.search", + ]), + ); + }); +}); diff --git a/worker/__tests__/warranties.integration.test.ts b/worker/__tests__/warranties.integration.test.ts new file mode 100644 index 0000000..c1d677e --- /dev/null +++ b/worker/__tests__/warranties.integration.test.ts @@ -0,0 +1,270 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2b warranty read routes. +// Real Neon — NO MOCKS, NO FAKE DATA. +// +// Test seam: registerWarrantyRoutes() with a pass-through middleware in place +// of requireChittyAuth, so we don't depend on real JWT infra. The data path +// (Drizzle → Neon) is 100% real. +// +// Per chittycanon://gov/governance#core-types — owner is Person (P), warranty +// is Thing (T). All five P/L/T/E/A enumerated in env.ts. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Hono } from "hono"; +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { registerWarrantyRoutes } from "../src/routes/warranties"; +import type { ChittyAuthClaims, Env } from "../src/env"; +import * as schema from "../../shared/schema"; + +const TEST_DB_URL = process.env.TEST_DB_URL; +if (!TEST_DB_URL) { + throw new Error( + "TEST_DB_URL must be set to a Neon branch connection string.", + ); +} + +// Person (P) fixture identities — canonical ChittyID format. +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5W-1-X"; +const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5W-2-X"; +let ASSET_ID: string; +let WARRANTY_ACTIVE_ID: string; +let WARRANTY_EXPIRING_ID: string; +let WARRANTY_EXPIRED_ID: string; +let WARRANTY_INACTIVE_ID: string; + +function buildTestApp(claimsOverride: ChittyAuthClaims) { + 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: { connectionString: TEST_DB_URL } as unknown as Hyperdrive, + } 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", claimsOverride); + await next(); + }); + + const apiApp = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerWarrantyRoutes(apiApp, async (_c, next) => { + await next(); + }); + app.route("/api", apiApp); + app.onError((err, c) => { + // eslint-disable-next-line no-console + console.error("test_error", err.message, err.stack); + return c.json({ error: "internal_error", detail: err.message }, 500); + }); + return app; +} + +function ownerClaims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: OWNER_CHITTY_ID, + chitty_id: OWNER_CHITTY_ID, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "warranty.owner@chitty.cc", + }; +} + +let sql: ReturnType; +let db: ReturnType; + +beforeAll(async () => { + sql = postgres(TEST_DB_URL!, { ssl: "require", max: 1 }); + db = drizzle(sql, { schema }); + + await db + .insert(schema.users) + .values({ + id: OWNER_CHITTY_ID, + chittyId: OWNER_CHITTY_ID, + email: "warranty.owner@chitty.cc", + firstName: "Warranty", + lastName: "Owner", + }) + .onConflictDoNothing(); + + const [asset] = await db + .insert(schema.assets) + .values({ + userId: OWNER_CHITTY_ID, + name: "Sub-Zero PRO 48 Refrigerator — Serial SZ48PRO-9842", + assetType: "other", + status: "active", + currentValue: "18750.00", + }) + .returning(); + ASSET_ID = asset.id; + + const now = new Date(); + const in10Days = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + const in60Days = new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000); + const tenDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); + const twoYearsAgo = new Date(now.getTime() - 730 * 24 * 60 * 60 * 1000); + + // Active warranty expiring in 10 days — should appear in /expiring?days=30. + const [wExp] = await db + .insert(schema.warranties) + .values({ + assetId: ASSET_ID, + userId: OWNER_CHITTY_ID, + provider: "Sub-Zero Manufacturer Warranty", + type: "manufacturer", + startDate: twoYearsAgo, + endDate: in10Days, + coverage: "Compressor and sealed system — 12 years parts", + isActive: true, + }) + .returning(); + WARRANTY_EXPIRING_ID = wExp.id; + + // Active warranty expiring in 60 days — NOT in /expiring?days=30. + const [wActive] = await db + .insert(schema.warranties) + .values({ + assetId: ASSET_ID, + userId: OWNER_CHITTY_ID, + provider: "Best Buy Geek Squad Protection", + type: "extended", + startDate: twoYearsAgo, + endDate: in60Days, + coverage: "Extended parts and labor coverage", + isActive: true, + }) + .returning(); + WARRANTY_ACTIVE_ID = wActive.id; + + // Expired warranty (endDate in past) — NOT in /expiring. + const [wExpired] = await db + .insert(schema.warranties) + .values({ + assetId: ASSET_ID, + userId: OWNER_CHITTY_ID, + provider: "Original 90-day limited warranty", + type: "limited", + startDate: twoYearsAgo, + endDate: tenDaysAgo, + isActive: true, + }) + .returning(); + WARRANTY_EXPIRED_ID = wExpired.id; + + // Inactive warranty (cancelled) — even though endDate within window, excluded. + const [wInactive] = await db + .insert(schema.warranties) + .values({ + assetId: ASSET_ID, + userId: OWNER_CHITTY_ID, + provider: "Cancelled extended warranty plan", + type: "extended", + startDate: twoYearsAgo, + endDate: in10Days, + isActive: false, + }) + .returning(); + WARRANTY_INACTIVE_ID = wInactive.id; +}); + +afterAll(async () => { + if (WARRANTY_ACTIVE_ID) + await sql`DELETE FROM warranties WHERE id = ${WARRANTY_ACTIVE_ID}`; + if (WARRANTY_EXPIRING_ID) + await sql`DELETE FROM warranties WHERE id = ${WARRANTY_EXPIRING_ID}`; + if (WARRANTY_EXPIRED_ID) + await sql`DELETE FROM warranties WHERE id = ${WARRANTY_EXPIRED_ID}`; + if (WARRANTY_INACTIVE_ID) + await sql`DELETE FROM warranties WHERE id = ${WARRANTY_INACTIVE_ID}`; + if (ASSET_ID) await sql`DELETE FROM assets WHERE id = ${ASSET_ID}`; + await sql`DELETE FROM users WHERE id = ${OWNER_CHITTY_ID}`; + await sql.end(); +}); + +describe("GET /api/assets/:assetId/warranties", () => { + it("200 — returns all warranties for owner's asset", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${ASSET_ID}/warranties`); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(4); + expect(body.every((w: any) => w.userId === OWNER_CHITTY_ID)).toBe(true); + expect(body.every((w: any) => w.assetId === ASSET_ID)).toBe(true); + }); + + it("200 — empty list for intruder (no existence leak)", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request(`/api/assets/${ASSET_ID}/warranties`); + expect(res.status).toBe(200); + expect((await res.json()) as any[]).toHaveLength(0); + }); + + it("400 — bad assetId returns 400", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/not-a-uuid/warranties"); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/warranties/expiring", () => { + it("200 — default 30 days returns only active warranties expiring in window", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/warranties/expiring"); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + // Only WARRANTY_EXPIRING_ID matches (active + endDate within 30d + endDate >= now). + const ids = body.map((w: any) => w.id); + expect(ids).toContain(WARRANTY_EXPIRING_ID); + expect(ids).not.toContain(WARRANTY_ACTIVE_ID); // 60d out + expect(ids).not.toContain(WARRANTY_EXPIRED_ID); // already expired + expect(ids).not.toContain(WARRANTY_INACTIVE_ID); // inactive + }); + + it("200 — days=90 widens window to include 60-day warranty", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/warranties/expiring?days=90"); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + const ids = body.map((w: any) => w.id); + expect(ids).toContain(WARRANTY_EXPIRING_ID); + expect(ids).toContain(WARRANTY_ACTIVE_ID); + expect(ids).not.toContain(WARRANTY_EXPIRED_ID); + expect(ids).not.toContain(WARRANTY_INACTIVE_ID); + }); + + it("200 — intruder sees empty list", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request("/api/warranties/expiring?days=90"); + expect(res.status).toBe(200); + expect((await res.json()) as any[]).toHaveLength(0); + }); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index c26b264..14fcee7 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -3,6 +3,9 @@ // Phase 2a: asset read routes ported (GET /api/assets, /api/assets/stats, // /api/assets/:id, /api/assets/:assetId/evidence, // /api/assets/:assetId/timeline). +// 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). import { Hono } from "hono"; import { cors } from "hono/cors"; @@ -10,6 +13,10 @@ import { logger } from "hono/logger"; import { ENTITY_TYPES, type ChittyAuthClaims, type Env } from "./env"; import { requireChittyAuth } from "./auth"; import { assetRoutes } from "./routes/assets"; +import { warrantyRoutes } from "./routes/warranties"; +import { insuranceRoutes } from "./routes/insurance"; +import { legalCaseRoutes } from "./routes/legal-cases"; +import { toolRoutes } from "./routes/tools"; type Variables = { claims: ChittyAuthClaims }; @@ -56,13 +63,18 @@ app.get("/api/v1/status", (c) => canonical_uri: "chittycanon://core/services/chittyassets", version: "1.0.0", environment: c.env.ENVIRONMENT, - migration_status: "PHASE_2A_ASSET_READS", + migration_status: "PHASE_2B_SIMPLE_READS", migrated_routes: [ "GET /api/assets", "GET /api/assets/stats", "GET /api/assets/:id", "GET /api/assets/:assetId/evidence", "GET /api/assets/:assetId/timeline", + "GET /api/assets/:assetId/warranties", + "GET /api/warranties/expiring", + "GET /api/assets/:assetId/insurance", + "GET /api/legal-cases", + "GET /api/tools/resources", ], entity_types_handled: [...ENTITY_TYPES], dependencies: { @@ -85,8 +97,12 @@ app.get("/api/auth/user", requireChittyAuth, (c) => { }); }); -// Phase 2a asset read routes — registered BEFORE the 501 catch-all. +// Phase 2a/2b 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); // Unmigrated routes return 501 unconditionally — no auth oracle. app.all("/api/*", (c) => diff --git a/worker/src/routes/insurance.ts b/worker/src/routes/insurance.ts new file mode 100644 index 0000000..8576bb3 --- /dev/null +++ b/worker/src/routes/insurance.ts @@ -0,0 +1,73 @@ +// @canon: chittycanon://core/services/chittyassets +// Insurance read routes — Phase 2b of Express→Hono migration. +// +// Routes ported (GET only — read-only): +// GET /api/assets/:assetId/insurance server/routes.ts:497 +// +// Per chittycanon://gov/governance#core-types — insurance policies are Thing (T) +// artifacts bound to a Person (P) owner via user_id (canonical chitty_id). +// All five entity types P/L/T/E/A remain enumerated in env.ts. +// +// Ownership: insurance_policies.userId === claims.chitty_id (direct, no JOIN). + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { and, eq, desc } from "drizzle-orm"; +import { insurancePolicies } from "@shared/schema"; +import { requireChittyAuth } from "../auth"; +import { getDb } from "../db"; +import type { Env, ChittyAuthClaims } from "../env"; + +type Variables = { claims: ChittyAuthClaims }; +type AppType = { Bindings: Env; Variables: Variables }; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidId(id: string): boolean { + return UUID_RE.test(id); +} + +export function registerInsuranceRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + // ----------------------------------------------------------------- + // GET /api/assets/:assetId/insurance — insurance policies for one asset + // Mirrors storage.ts:getAssetInsurance (311-317) + // 400 on bad UUID; ownership-scoped via insurance_policies.userId. + // Empty list (200) for intruder — no existence leak. + // ----------------------------------------------------------------- + app.get("/assets/:assetId/insurance", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { assetId } = c.req.param(); + + if (!isValidId(assetId)) { + return c.json( + { error: "invalid_id", message: "Asset ID must be a valid UUID" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const rows = await db + .select() + .from(insurancePolicies) + .where( + and( + eq(insurancePolicies.assetId, assetId), + eq(insurancePolicies.userId, userId), + ), + ) + .orderBy(desc(insurancePolicies.endDate)); + + return c.json(rows); + }); +} + +export const insuranceRoutes = (() => { + const r = new Hono(); + registerInsuranceRoutes(r, requireChittyAuth); + return r; +})(); diff --git a/worker/src/routes/legal-cases.ts b/worker/src/routes/legal-cases.ts new file mode 100644 index 0000000..cb8d7aa --- /dev/null +++ b/worker/src/routes/legal-cases.ts @@ -0,0 +1,49 @@ +// @canon: chittycanon://core/services/chittyassets +// Legal-case read routes — Phase 2b of Express→Hono migration. +// +// Routes ported (GET only — read-only): +// GET /api/legal-cases server/routes.ts:529 +// +// Per chittycanon://gov/governance#core-types — legal_cases are Event (E) +// proceedings owned by a Person (P) via user_id (canonical chitty_id). +// All five entity types P/L/T/E/A remain enumerated in env.ts. + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { eq, desc } from "drizzle-orm"; +import { legalCases } from "@shared/schema"; +import { requireChittyAuth } from "../auth"; +import { getDb } from "../db"; +import type { Env, ChittyAuthClaims } from "../env"; + +type Variables = { claims: ChittyAuthClaims }; +type AppType = { Bindings: Env; Variables: Variables }; + +export function registerLegalCaseRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + // ----------------------------------------------------------------- + // GET /api/legal-cases — caller's legal cases (ownership-scoped) + // Mirrors storage.ts:getUserLegalCases (339-345) + // ----------------------------------------------------------------- + app.get("/legal-cases", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + + const rows = await db + .select() + .from(legalCases) + .where(eq(legalCases.userId, userId)) + .orderBy(desc(legalCases.createdAt)); + + return c.json(rows); + }); +} + +export const legalCaseRoutes = (() => { + const r = new Hono(); + registerLegalCaseRoutes(r, requireChittyAuth); + return r; +})(); diff --git a/worker/src/routes/tools.ts b/worker/src/routes/tools.ts new file mode 100644 index 0000000..ea66597 --- /dev/null +++ b/worker/src/routes/tools.ts @@ -0,0 +1,111 @@ +// @canon: chittycanon://core/services/chittyassets +// Tool-resource read route — Phase 2b of Express→Hono migration. +// +// Route ported (GET only, static — no DB): +// GET /api/tools/resources server/routes.ts:117 +// +// Per chittycanon://gov/governance#core-types — tool resources are Thing (T) +// artifacts: external capabilities surfaced to the agent layer. The caller +// is a Person (P). All five entity types P/L/T/E/A remain enumerated in env.ts. +// +// The resource catalog is inlined here (mirrors server/toolRegistry.ts) because +// the worker bundle cannot import Express-side modules. Keep these two lists +// in lockstep; a schema-overlord audit will compare them at PR time. + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { requireChittyAuth } from "../auth"; +import type { Env, ChittyAuthClaims } from "../env"; + +type Variables = { claims: ChittyAuthClaims }; +type AppType = { Bindings: Env; Variables: Variables }; + +export type ToolCategory = + | "cloudflare" + | "content" + | "communications" + | "database"; + +export interface ToolResource { + id: string; + name: string; + provider: string; + description: string; + category: ToolCategory; + callable: boolean; + capabilities: string[]; +} + +// Mirrors server/toolRegistry.ts exactly — keep in lockstep. +const TOOL_RESOURCES: ToolResource[] = [ + { + id: "cloudflare.workers.assets", + name: "Cloudflare Workers Evidence Tools", + provider: "Cloudflare", + description: + "Edge Workers used by the Evidence Ledger for freeze/mint workflows.", + category: "cloudflare", + callable: true, + capabilities: ["freeze", "mint", "status"], + }, + { + id: "notion.search", + name: "Notion Workspace Search", + provider: "Notion", + description: + "Search across workspace pages, databases, and synced asset briefs.", + category: "content", + callable: true, + capabilities: ["search", "filter", "page-context"], + }, + { + id: "google.drive.search", + name: "Google Drive Discovery", + provider: "Google Drive", + description: + "Search Drive documents, spreadsheets, and evidence attachments.", + category: "content", + callable: true, + capabilities: ["search", "metadata", "shared-drives"], + }, + { + id: "outlook.mail.search", + name: "Outlook / SharePoint Email + Files", + provider: "Microsoft 365", + description: + "Search Outlook mailboxes and SharePoint file evidence for discovery.", + category: "communications", + callable: true, + capabilities: ["search", "attachments", "sharepoint-sites"], + }, + { + id: "neon.metadata.search", + name: "Neon DB Metadata", + provider: "Neon", + description: + "Search database schemas and column lineage for stored evidence.", + category: "database", + callable: true, + capabilities: ["tables", "columns", "lineage"], + }, +]; + +export function registerToolRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + app.get("/tools/resources", authMiddleware, (c) => { + return c.json({ + resources: TOOL_RESOURCES, + callableResources: TOOL_RESOURCES.filter((r) => r.callable).map( + (r) => r.id, + ), + }); + }); +} + +export const toolRoutes = (() => { + const r = new Hono(); + registerToolRoutes(r, requireChittyAuth); + return r; +})(); diff --git a/worker/src/routes/warranties.ts b/worker/src/routes/warranties.ts new file mode 100644 index 0000000..1906772 --- /dev/null +++ b/worker/src/routes/warranties.ts @@ -0,0 +1,110 @@ +// @canon: chittycanon://core/services/chittyassets +// Warranty read routes — Phase 2b of Express→Hono migration. +// +// Routes ported (GET only — read-only): +// GET /api/assets/:assetId/warranties server/routes.ts:453 +// GET /api/warranties/expiring server/routes.ts:464 +// +// Per chittycanon://gov/governance#core-types — warranties are Thing (T) +// artifacts bound to a Person (P) owner via user_id (canonical chitty_id). +// All five entity types P/L/T/E/A remain enumerated in env.ts; this module +// touches Person (owner) + Thing (warranty contract). +// +// Ownership: warranties.userId === claims.chitty_id (direct, no JOIN needed — +// warranties.user_id is denormalized on insert by the writer side). + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { and, eq, gte, lte, desc } from "drizzle-orm"; +import { warranties } from "@shared/schema"; +import { requireChittyAuth } from "../auth"; +import { getDb } from "../db"; +import type { Env, ChittyAuthClaims } from "../env"; + +type Variables = { claims: ChittyAuthClaims }; +type AppType = { Bindings: Env; Variables: Variables }; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidId(id: string): boolean { + return UUID_RE.test(id); +} + +/** + * Register warranty read routes on the given Hono app. + * Accepts an auth middleware so integration tests can inject claims directly. + */ +export function registerWarrantyRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + // ----------------------------------------------------------------- + // GET /api/warranties/expiring?days=N — active warranties expiring soon + // Registered BEFORE the parameterized route to avoid ambiguity. + // Mirrors storage.ts:getExpiringWarranties (278-294) + // ----------------------------------------------------------------- + app.get("/warranties/expiring", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const daysRaw = c.req.query("days"); + const parsed = daysRaw ? parseInt(daysRaw, 10) : 30; + const daysAhead = Number.isFinite(parsed) && parsed > 0 ? parsed : 30; + + const now = new Date(); + const futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const rows = await db + .select() + .from(warranties) + .where( + and( + eq(warranties.userId, userId), + eq(warranties.isActive, true), + lte(warranties.endDate, futureDate), + gte(warranties.endDate, now), + ), + ) + .orderBy(warranties.endDate); + + return c.json(rows); + }); + + // ----------------------------------------------------------------- + // GET /api/assets/:assetId/warranties — warranties for one asset + // Mirrors storage.ts:getAssetWarranties (270-276) + // 400 on bad UUID; ownership-scoped via warranties.userId. + // Empty list (200) for intruder — no existence leak. + // ----------------------------------------------------------------- + app.get("/assets/:assetId/warranties", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { assetId } = c.req.param(); + + if (!isValidId(assetId)) { + return c.json( + { error: "invalid_id", message: "Asset ID must be a valid UUID" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const rows = await db + .select() + .from(warranties) + .where( + and(eq(warranties.assetId, assetId), eq(warranties.userId, userId)), + ) + .orderBy(desc(warranties.endDate)); + + return c.json(rows); + }); +} + +// Production sub-app with real auth middleware. +export const warrantyRoutes = (() => { + const r = new Hono(); + registerWarrantyRoutes(r, requireChittyAuth); + return r; +})();