From 27f35a1dd273101d90b32de85bb139e688c99408 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Sun, 17 May 2026 07:50:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(worker):=20Phase=203b=20=E2=80=94=20port?= =?UTF-8?q?=20domain=20writes=20(warranties/insurance/legal-cases=20create?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the three remaining domain write routes from Express to Hono: POST /api/assets/:assetId/warranties (server/routes.ts:476) POST /api/assets/:assetId/insurance (server/routes.ts:508) POST /api/legal-cases (server/routes.ts:540) Pattern mirrors Phase 3a evidence attach: - Zod input schemas omit server-owned fields (userId / assetId-from-URL / chittyId) so clients cannot spoof them. - For warranties + insurance, parent-asset ownership is verified inside the same transaction as the INSERT (SELECT id FROM assets WHERE id=? AND user_id=?), returning 404 on mismatch — no existence leak. - For legal_cases, user_id is server-injected from claims; no parent asset. - Express version emits no timeline_events side effects for these creates; parity preserved (pure INSERTs). - Timestamp columns (start_date / end_date / filing_date / next_hearing) are coerced via z.coerce.date() since JSON carries ISO strings while drizzle-zod emits z.date(). Documented divergence from raw insertSchema. Stacks on #39 → #38 → #37 → #36 → #34 → #33. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../domain-writes.integration.test.ts | 477 ++++++++++++++++++ worker/src/index.ts | 9 +- worker/src/routes/insurance.ts | 101 +++- worker/src/routes/legal-cases.ts | 81 ++- worker/src/routes/warranties.ts | 111 +++- 5 files changed, 761 insertions(+), 18 deletions(-) create mode 100644 worker/__tests__/domain-writes.integration.test.ts diff --git a/worker/__tests__/domain-writes.integration.test.ts b/worker/__tests__/domain-writes.integration.test.ts new file mode 100644 index 0000000..d8d0046 --- /dev/null +++ b/worker/__tests__/domain-writes.integration.test.ts @@ -0,0 +1,477 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 3b domain write routes: +// POST /api/assets/:assetId/warranties +// POST /api/assets/:assetId/insurance +// POST /api/legal-cases +// +// Tests call real Neon (ephemeral branch) — NO MOCKS, NO FAKE DATA. +// +// Per chittycanon://gov/governance#core-types — Person (P) owner, Thing (T) +// warranty + insurance policy artifacts, Event (E) legal case. Authority (A) +// and Location (L) are not exercised by these writes but the type enum at +// worker/src/env.ts covers all five P/L/T/E/A. + +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 { registerInsuranceRoutes } from "../src/routes/insurance"; +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 an ephemeral Neon branch connection string.", + ); +} + +// Suffixes 5E/5F to avoid collision with existing 5A..5L fixtures. +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5E-1-X"; +const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5F-1-X"; + +function buildTestApp(claimsOverride: ChittyAuthClaims | null) { + 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, + }); + if (claimsOverride) c.set("claims", claimsOverride); + await next(); + }); + + const apiApp = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + + const authMw = claimsOverride + ? async (_c: any, next: any) => { + await next(); + } + : async (c: any) => c.json({ error: "unauthorized" }, 401); + + registerWarrantyRoutes(apiApp, authMw); + registerInsuranceRoutes(apiApp, authMw); + registerLegalCaseRoutes(apiApp, authMw); + 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: "domain.owner@chitty.cc", + }; +} + +function intruderClaims(): ChittyAuthClaims { + return { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + email: "domain.intruder@chitty.cc", + }; +} + +let sql: ReturnType; +let db: ReturnType; +const createdAssetIds = new Set(); +const createdLegalCaseIds = new Set(); + +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: "domain.owner@chitty.cc", + firstName: "Domain", + lastName: "Owner", + }) + .onConflictDoNothing(); + await db + .insert(schema.users) + .values({ + id: INTRUDER_CHITTY_ID, + chittyId: INTRUDER_CHITTY_ID, + email: "domain.intruder@chitty.cc", + firstName: "Domain", + lastName: "Intruder", + }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + for (const id of createdAssetIds) { + await sql`DELETE FROM warranties WHERE asset_id = ${id}`; + await sql`DELETE FROM insurance_policies WHERE asset_id = ${id}`; + await sql`DELETE FROM timeline_events WHERE asset_id = ${id}`; + await sql`DELETE FROM evidence WHERE asset_id = ${id}`; + await sql`DELETE FROM assets WHERE id = ${id}`; + } + for (const id of createdLegalCaseIds) { + await sql`DELETE FROM legal_cases WHERE id = ${id}`; + } + await sql`DELETE FROM users WHERE id = ${OWNER_CHITTY_ID}`; + await sql`DELETE FROM users WHERE id = ${INTRUDER_CHITTY_ID}`; + await sql.end(); +}); + +async function seedOwnerAsset(name: string) { + const [asset] = await db + .insert(schema.assets) + .values({ + userId: OWNER_CHITTY_ID, + name, + assetType: "electronics", + }) + .returning(); + createdAssetIds.add(asset.id); + return asset; +} + +describe("POST /api/assets/:assetId/warranties", () => { + it("201 — owner creates warranty on own asset", async () => { + const asset = await seedOwnerAsset("Warranty host — MacBook Pro 16 M3 Max"); + const app = buildTestApp(ownerClaims()); + const start = new Date("2025-01-15T00:00:00Z"); + const end = new Date("2028-01-15T00:00:00Z"); + const res = await app.request(`/api/assets/${asset.id}/warranties`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "AppleCare+", + type: "extended", + startDate: start.toISOString(), + endDate: end.toISOString(), + coverage: "Accidental damage, battery service, 24/7 priority support", + cost: "399.00", + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + expect(body.id).toBeTruthy(); + expect(body.assetId).toBe(asset.id); + expect(body.userId).toBe(OWNER_CHITTY_ID); + expect(body.provider).toBe("AppleCare+"); + expect(body.isActive).toBe(true); + + const rows = await sql` + SELECT asset_id, user_id, provider FROM warranties WHERE id = ${body.id} + `; + expect(rows.length).toBe(1); + expect(rows[0].asset_id).toBe(asset.id); + expect(rows[0].user_id).toBe(OWNER_CHITTY_ID); + }); + + it("404 — intruder cannot attach warranty to owner's asset", async () => { + const asset = await seedOwnerAsset("Warranty intrusion probe"); + const app = buildTestApp(intruderClaims()); + const res = await app.request(`/api/assets/${asset.id}/warranties`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "ForgedCare", + type: "extended", + startDate: new Date().toISOString(), + endDate: new Date(Date.now() + 86400000).toISOString(), + }), + }); + expect(res.status).toBe(404); + const rows = await sql`SELECT id FROM warranties WHERE asset_id = ${asset.id}`; + expect(rows.length).toBe(0); + }); + + it("400 — bad asset UUID", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/not-uuid/warranties", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "X", + type: "extended", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + }), + }); + expect(res.status).toBe(400); + }); + + it("400 — missing required fields", async () => { + const asset = await seedOwnerAsset("Warranty validation host"); + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${asset.id}/warranties`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ coverage: "only coverage, no provider/type/dates" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.error).toBe("invalid_input"); + }); + + it("400 — invalid JSON body", async () => { + const asset = await seedOwnerAsset("Warranty json host"); + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${asset.id}/warranties`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }); + expect(res.status).toBe(400); + }); + + it("401 — no auth", async () => { + const app = buildTestApp(null); + const res = await app.request( + "/api/assets/00000000-0000-0000-0000-000000000000/warranties", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "X", + type: "extended", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + }), + }, + ); + expect(res.status).toBe(401); + }); + + it("201 — server-owned fields (userId/assetId/chittyId) are stripped", async () => { + const asset = await seedOwnerAsset("Warranty strip-test host"); + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${asset.id}/warranties`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "Squaretrade", + type: "extended", + startDate: new Date().toISOString(), + endDate: new Date(Date.now() + 365 * 86400000).toISOString(), + // Override attempts — schema omit drops these. + userId: INTRUDER_CHITTY_ID, + assetId: "00000000-0000-0000-0000-000000000000", + chittyId: "01-X-XXX-XXXX-T-99-9-Z", + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + expect(body.userId).toBe(OWNER_CHITTY_ID); + expect(body.assetId).toBe(asset.id); + expect(body.chittyId).not.toBe("01-X-XXX-XXXX-T-99-9-Z"); + }); +}); + +describe("POST /api/assets/:assetId/insurance", () => { + it("201 — owner creates policy on own asset", async () => { + const asset = await seedOwnerAsset("Insurance host — 2024 Range Rover Sport"); + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${asset.id}/insurance`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "Chubb Personal Insurance", + policyNumber: "CHUBB-AUTO-2025-447821", + type: "auto", + coverageAmount: "150000.00", + premium: "4200.00", + deductible: "1000.00", + startDate: new Date("2025-01-01T00:00:00Z").toISOString(), + endDate: new Date("2026-01-01T00:00:00Z").toISOString(), + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + expect(body.id).toBeTruthy(); + expect(body.assetId).toBe(asset.id); + expect(body.userId).toBe(OWNER_CHITTY_ID); + expect(body.policyNumber).toBe("CHUBB-AUTO-2025-447821"); + + const rows = await sql` + SELECT user_id, policy_number FROM insurance_policies WHERE id = ${body.id} + `; + expect(rows.length).toBe(1); + expect(rows[0].user_id).toBe(OWNER_CHITTY_ID); + }); + + it("404 — intruder cannot attach policy to owner's asset", async () => { + const asset = await seedOwnerAsset("Insurance intrusion probe"); + const app = buildTestApp(intruderClaims()); + const res = await app.request(`/api/assets/${asset.id}/insurance`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "ForgedInsurance Co", + policyNumber: "FAKE-1", + type: "auto", + startDate: new Date().toISOString(), + endDate: new Date(Date.now() + 86400000).toISOString(), + }), + }); + expect(res.status).toBe(404); + const rows = await sql` + SELECT id FROM insurance_policies WHERE asset_id = ${asset.id} + `; + expect(rows.length).toBe(0); + }); + + it("400 — bad asset UUID", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/bad-uuid/insurance", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "x", + policyNumber: "x", + type: "auto", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + }), + }); + expect(res.status).toBe(400); + }); + + it("400 — missing required fields", async () => { + const asset = await seedOwnerAsset("Insurance validation host"); + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${asset.id}/insurance`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ premium: "100.00" }), + }); + expect(res.status).toBe(400); + }); + + it("401 — no auth", async () => { + const app = buildTestApp(null); + const res = await app.request( + "/api/assets/00000000-0000-0000-0000-000000000000/insurance", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "x", + policyNumber: "x", + type: "auto", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + }), + }, + ); + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/legal-cases", () => { + it("201 — owner creates legal case scoped to their chitty_id", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/legal-cases", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + caseNumber: "2025-CV-77821", + title: "Property valuation dispute — Q1 2025 appraisal", + description: "Disputed appraisal of insured collectibles portfolio", + status: "active", + court: "New York County Supreme Court", + filingDate: new Date("2025-02-14T00:00:00Z").toISOString(), + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + expect(body.id).toBeTruthy(); + expect(body.userId).toBe(OWNER_CHITTY_ID); + expect(body.title).toContain("Property valuation dispute"); + createdLegalCaseIds.add(body.id); + + const rows = await sql` + SELECT user_id, case_number FROM legal_cases WHERE id = ${body.id} + `; + expect(rows.length).toBe(1); + expect(rows[0].user_id).toBe(OWNER_CHITTY_ID); + expect(rows[0].case_number).toBe("2025-CV-77821"); + }); + + it("201 — client-supplied userId/chittyId are stripped", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/legal-cases", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Server-owned-field strip verification", + // Override attempts. + userId: INTRUDER_CHITTY_ID, + chittyId: "01-X-XXX-XXXX-E-99-9-Z", + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + createdLegalCaseIds.add(body.id); + expect(body.userId).toBe(OWNER_CHITTY_ID); + expect(body.chittyId).not.toBe("01-X-XXX-XXXX-E-99-9-Z"); + }); + + it("400 — missing required title", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/legal-cases", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ description: "no title field" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.error).toBe("invalid_input"); + }); + + it("400 — invalid JSON body", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/legal-cases", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }); + expect(res.status).toBe(400); + }); + + it("401 — no auth", async () => { + const app = buildTestApp(null); + const res = await app.request("/api/legal-cases", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "x" }), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index 0e0f772..d1c1f36 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -10,6 +10,10 @@ // (GET /api/evidence-ledger/:chittyId, GET /api/ecosystem/status). // Phase 3a: asset write routes ported (POST/PUT/DELETE /api/assets[/:id]) // and evidence attach (POST /api/assets/:assetId/evidence). +// Phase 3b: domain write routes ported +// (POST /api/assets/:assetId/warranties, +// POST /api/assets/:assetId/insurance, +// POST /api/legal-cases). import { Hono } from "hono"; import { cors } from "hono/cors"; @@ -70,7 +74,7 @@ app.get("/api/v1/status", (c) => canonical_uri: "chittycanon://core/services/chittyassets", version: "1.0.0", environment: c.env.ENVIRONMENT, - migration_status: "PHASE_3A_ASSET_WRITES", + migration_status: "PHASE_3B_DOMAIN_WRITES", migrated_routes: [ "GET /api/assets", "GET /api/assets/stats", @@ -88,6 +92,9 @@ app.get("/api/v1/status", (c) => "PUT /api/assets/:id", "DELETE /api/assets/:id", "POST /api/assets/:assetId/evidence", + "POST /api/assets/:assetId/warranties", + "POST /api/assets/:assetId/insurance", + "POST /api/legal-cases", ], entity_types_handled: [...ENTITY_TYPES], dependencies: { diff --git a/worker/src/routes/insurance.ts b/worker/src/routes/insurance.ts index 8576bb3..76ae823 100644 --- a/worker/src/routes/insurance.ts +++ b/worker/src/routes/insurance.ts @@ -1,19 +1,28 @@ // @canon: chittycanon://core/services/chittyassets -// Insurance read routes — Phase 2b of Express→Hono migration. +// Insurance routes — Phase 2b reads + Phase 3b writes of Express→Hono migration. // -// Routes ported (GET only — read-only): -// GET /api/assets/:assetId/insurance server/routes.ts:497 +// Routes: +// GET /api/assets/:assetId/insurance server/routes.ts:497 (Phase 2b) +// POST /api/assets/:assetId/insurance server/routes.ts:508 (Phase 3b) // // 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). +// Ownership: insurance_policies.userId === claims.chitty_id. For writes, parent +// asset ownership is verified inside the same transaction (SELECT-then-INSERT) +// before the policy is inserted — mirrors the Phase 3a evidence pattern. +// Express version emits no timeline_events for policy creates; preserved here. import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import { and, eq, desc } from "drizzle-orm"; -import { insurancePolicies } from "@shared/schema"; +import { z } from "zod"; +import { + assets, + insurancePolicies, + insertInsurancePolicySchema, +} from "@shared/schema"; import { requireChittyAuth } from "../auth"; import { getDb } from "../db"; import type { Env, ChittyAuthClaims } from "../env"; @@ -28,6 +37,33 @@ function isValidId(id: string): boolean { return UUID_RE.test(id); } +// Server-controlled: client cannot set userId, assetId (from URL), or chittyId. +const INSURANCE_SERVER_OWNED = { + userId: true, + assetId: true, + chittyId: true, +} as const; + +// Drizzle-zod emits z.date() for timestamp columns; JSON carries ISO strings. +const createInsuranceInputSchema = insertInsurancePolicySchema + .omit(INSURANCE_SERVER_OWNED) + .extend({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }); + +function formatZodError(err: z.ZodError) { + return { + error: "invalid_input", + message: "Request body failed validation", + errors: err.errors.map((e) => ({ + path: e.path.join("."), + message: e.message, + code: e.code, + })), + }; +} + export function registerInsuranceRoutes( app: Hono, authMiddleware: MiddlewareHandler, @@ -64,6 +100,61 @@ export function registerInsuranceRoutes( return c.json(rows); }); + + // ----------------------------------------------------------------- + // POST /api/assets/:assetId/insurance — attach policy to an asset. + // Mirrors Express server/routes.ts:508. + // + // 400 on bad UUID or invalid body. 404 if asset not owned. 201 on success. + // ----------------------------------------------------------------- + app.post("/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, + ); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json( + { error: "invalid_json", message: "Request body must be valid JSON" }, + 400, + ); + } + + const parsed = createInsuranceInputSchema.safeParse(body); + if (!parsed.success) { + return c.json(formatZodError(parsed.error), 400); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const result = await db.transaction(async (tx) => { + const [owned] = await tx + .select({ id: assets.id }) + .from(assets) + .where(and(eq(assets.id, assetId), eq(assets.userId, userId))); + if (!owned) return { notFound: true as const }; + + const [inserted] = await tx + .insert(insurancePolicies) + .values({ ...parsed.data, assetId, userId }) + .returning(); + + return { notFound: false as const, policy: inserted }; + }); + + if (result.notFound) { + return c.json({ message: "Asset not found" }, 404); + } + return c.json(result.policy, 201); + }); } export const insuranceRoutes = (() => { diff --git a/worker/src/routes/legal-cases.ts b/worker/src/routes/legal-cases.ts index cb8d7aa..fdbe490 100644 --- a/worker/src/routes/legal-cases.ts +++ b/worker/src/routes/legal-cases.ts @@ -1,17 +1,27 @@ // @canon: chittycanon://core/services/chittyassets -// Legal-case read routes — Phase 2b of Express→Hono migration. +// Legal-case routes — Phase 2b reads + Phase 3b writes of Express→Hono migration. // -// Routes ported (GET only — read-only): -// GET /api/legal-cases server/routes.ts:529 +// Routes: +// GET /api/legal-cases server/routes.ts:529 (Phase 2b) +// POST /api/legal-cases server/routes.ts:540 (Phase 3b) // // 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. +// +// Ownership: legal_cases.userId === claims.chitty_id. No parent asset to +// verify — scoping is direct on user_id (the row is created with the caller's +// chitty_id). Express version emits no timeline_events for case creates; +// preserved here. import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import { eq, desc } from "drizzle-orm"; -import { legalCases } from "@shared/schema"; +import { z } from "zod"; +import { + legalCases, + insertLegalCaseSchema, +} from "@shared/schema"; import { requireChittyAuth } from "../auth"; import { getDb } from "../db"; import type { Env, ChittyAuthClaims } from "../env"; @@ -19,6 +29,33 @@ import type { Env, ChittyAuthClaims } from "../env"; type Variables = { claims: ChittyAuthClaims }; type AppType = { Bindings: Env; Variables: Variables }; +// Server-controlled: client cannot set userId or chittyId. +const LEGAL_CASE_SERVER_OWNED = { + userId: true, + chittyId: true, +} as const; + +// filingDate / nextHearing are timestamps; drizzle-zod emits z.date(). JSON +// carries ISO strings, so coerce. Both are optional on the underlying table. +const createLegalCaseInputSchema = insertLegalCaseSchema + .omit(LEGAL_CASE_SERVER_OWNED) + .extend({ + filingDate: z.coerce.date().optional().nullable(), + nextHearing: z.coerce.date().optional().nullable(), + }); + +function formatZodError(err: z.ZodError) { + return { + error: "invalid_input", + message: "Request body failed validation", + errors: err.errors.map((e) => ({ + path: e.path.join("."), + message: e.message, + code: e.code, + })), + }; +} + export function registerLegalCaseRoutes( app: Hono, authMiddleware: MiddlewareHandler, @@ -40,6 +77,42 @@ export function registerLegalCaseRoutes( return c.json(rows); }); + + // ----------------------------------------------------------------- + // POST /api/legal-cases — create a new legal case for the caller. + // Mirrors Express server/routes.ts:540. + // + // 400 on invalid body. 201 on success. No parent asset to verify — + // user_id is server-injected from claims, so cross-user creation is + // structurally impossible. + // ----------------------------------------------------------------- + app.post("/legal-cases", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json( + { error: "invalid_json", message: "Request body must be valid JSON" }, + 400, + ); + } + + const parsed = createLegalCaseInputSchema.safeParse(body); + if (!parsed.success) { + return c.json(formatZodError(parsed.error), 400); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const [inserted] = await db + .insert(legalCases) + .values({ ...parsed.data, userId }) + .returning(); + + return c.json(inserted, 201); + }); } export const legalCaseRoutes = (() => { diff --git a/worker/src/routes/warranties.ts b/worker/src/routes/warranties.ts index 1906772..c0a3e21 100644 --- a/worker/src/routes/warranties.ts +++ b/worker/src/routes/warranties.ts @@ -1,22 +1,31 @@ // @canon: chittycanon://core/services/chittyassets -// Warranty read routes — Phase 2b of Express→Hono migration. +// Warranty routes — Phase 2b reads + Phase 3b writes 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 +// Routes: +// GET /api/assets/:assetId/warranties server/routes.ts:453 (Phase 2b) +// GET /api/warranties/expiring server/routes.ts:464 (Phase 2b) +// POST /api/assets/:assetId/warranties server/routes.ts:476 (Phase 3b) // // 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). +// Ownership: warranties.userId === claims.chitty_id. For writes, parent asset +// ownership is verified inside the same transaction (SELECT-then-INSERT) +// before the warranty row is inserted — mirrors the Phase 3a evidence pattern. +// Express version emits no timeline_events for warranty creates; we preserve +// that behavior here (no side effects beyond the INSERT). import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import { and, eq, gte, lte, desc } from "drizzle-orm"; -import { warranties } from "@shared/schema"; +import { z } from "zod"; +import { + assets, + warranties, + insertWarrantySchema, +} from "@shared/schema"; import { requireChittyAuth } from "../auth"; import { getDb } from "../db"; import type { Env, ChittyAuthClaims } from "../env"; @@ -31,8 +40,37 @@ function isValidId(id: string): boolean { return UUID_RE.test(id); } +// Server-controlled: client cannot set userId, assetId (from URL), or chittyId. +const WARRANTY_SERVER_OWNED = { + userId: true, + assetId: true, + chittyId: true, +} as const; + +// Drizzle-zod emits z.date() for timestamp columns; JSON bodies carry ISO +// strings, so we extend with coercive date fields. Express's body-parser path +// is more forgiving; we make it explicit here. +const createWarrantyInputSchema = insertWarrantySchema + .omit(WARRANTY_SERVER_OWNED) + .extend({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }); + +function formatZodError(err: z.ZodError) { + return { + error: "invalid_input", + message: "Request body failed validation", + errors: err.errors.map((e) => ({ + path: e.path.join("."), + message: e.message, + code: e.code, + })), + }; +} + /** - * Register warranty read routes on the given Hono app. + * Register warranty routes on the given Hono app. * Accepts an auth middleware so integration tests can inject claims directly. */ export function registerWarrantyRoutes( @@ -100,6 +138,63 @@ export function registerWarrantyRoutes( return c.json(rows); }); + + // ----------------------------------------------------------------- + // POST /api/assets/:assetId/warranties — attach warranty to an asset. + // Mirrors Express server/routes.ts:476. + // + // 400 on bad UUID or invalid body. 404 if asset not owned. 201 on success. + // Ownership check inside transaction prevents toctou races and existence + // leaks (404 looks identical to "asset doesn't exist"). + // ----------------------------------------------------------------- + app.post("/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, + ); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json( + { error: "invalid_json", message: "Request body must be valid JSON" }, + 400, + ); + } + + const parsed = createWarrantyInputSchema.safeParse(body); + if (!parsed.success) { + return c.json(formatZodError(parsed.error), 400); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const result = await db.transaction(async (tx) => { + const [owned] = await tx + .select({ id: assets.id }) + .from(assets) + .where(and(eq(assets.id, assetId), eq(assets.userId, userId))); + if (!owned) return { notFound: true as const }; + + const [inserted] = await tx + .insert(warranties) + .values({ ...parsed.data, assetId, userId }) + .returning(); + + return { notFound: false as const, warranty: inserted }; + }); + + if (result.notFound) { + return c.json({ message: "Asset not found" }, 404); + } + return c.json(result.warranty, 201); + }); } // Production sub-app with real auth middleware.