diff --git a/worker/__tests__/heavy-writes.integration.test.ts b/worker/__tests__/heavy-writes.integration.test.ts new file mode 100644 index 0000000..3579bb0 --- /dev/null +++ b/worker/__tests__/heavy-writes.integration.test.ts @@ -0,0 +1,593 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 3c heavy write routes: +// POST /api/assets/:id/freeze +// POST /api/assets/:id/mint +// POST /api/assets/:assetId/calculate-trust-score +// POST /api/evidence/:evidenceId/analyze +// POST /api/legal/generate-document +// POST /api/evidence-ledger/submit +// POST /api/evidence-ledger/:chittyId/verify +// POST /api/seed-demo +// +// Tests hit real Neon (ephemeral branch) — NO MOCKS, NO FAKE DATA. External +// service calls go to real hosts; where success requires a wired endpoint +// we exercise the real error path (502 mapping for unreachable host, 503 +// for missing OPENAI_API_KEY) which is genuine error-path coverage, not a +// mock. +// +// Per chittycanon://gov/governance#core-types — Person (P) caller, Thing +// (T) asset/evidence, Event (E) timeline + ai_analysis_results, Authority +// (A) and Location (L) enumerated in env.ts though not exercised here. +// +// OpenAI test strategy: handlers are exercised with OPENAI_API_KEY unset +// (Worker secret not bound in test env) — assertion is the 503 +// service_unavailable mapping. Real OpenAI invocation is deferred to +// staging smoke-tests (cost + key handling) — documented in PR body. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Hono } from "hono"; +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { registerAssetRoutes } from "../src/routes/assets"; +import { registerEvidenceRoutes } from "../src/routes/evidence"; +import { registerLegalCaseRoutes } from "../src/routes/legal-cases"; +import { registerEvidenceLedgerRoutes } from "../src/routes/evidence-ledger"; +import { registerSeedRoutes } from "../src/routes/seed"; +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 5G/5H per phase 3c spec. +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5G-1-X"; +const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5H-1-X"; +const NONEXISTENT_LEDGER_ID = "ZZ-Z-ZZZ-ZZZZ-T-ZZ-Z-Z"; + +interface BuildOpts { + claims?: ChittyAuthClaims | null; + env?: Partial; +} + +function buildTestApp(opts: BuildOpts = {}) { + const claimsOverride = opts.claims === undefined ? ownerClaims() : opts.claims; + 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", + CHITTYMINT_URL: "https://mint.chitty.cc", + CHITTYLEDGER_URL: "https://ledger.chitty.cc", + CHITTYASSETS_DB: { + connectionString: TEST_DB_URL, + } as unknown as Hyperdrive, + ...opts.env, + } 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); + + registerAssetRoutes(apiApp, authMw); + registerEvidenceRoutes(apiApp, authMw); + registerLegalCaseRoutes(apiApp, authMw); + registerEvidenceLedgerRoutes(apiApp, authMw); + registerSeedRoutes(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: "heavy.owner@chitty.cc", + }; +} + +function intruderClaims(): ChittyAuthClaims { + return { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + email: "heavy.intruder@chitty.cc", + }; +} + +let sql: ReturnType; +let db: ReturnType; +const createdAssetIds = new Set(); +const createdEvidenceIds = new Set(); + +beforeAll(async () => { + sql = postgres(TEST_DB_URL!, { ssl: "require", max: 1 }); + db = drizzle(sql, { schema }); + for (const id of [OWNER_CHITTY_ID, INTRUDER_CHITTY_ID]) { + await db + .insert(schema.users) + .values({ + id, + chittyId: id, + email: id === OWNER_CHITTY_ID ? "heavy.owner@chitty.cc" : "heavy.intruder@chitty.cc", + firstName: "Heavy", + lastName: id === OWNER_CHITTY_ID ? "Owner" : "Intruder", + }) + .onConflictDoNothing(); + } +}); + +afterAll(async () => { + for (const id of createdAssetIds) { + await sql`DELETE FROM ai_analysis_results WHERE evidence_id IN (SELECT id FROM evidence 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}`; + } + 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, + overrides: Partial = {}, +) { + const [asset] = await db + .insert(schema.assets) + .values({ + userId: OWNER_CHITTY_ID, + name, + assetType: "electronics", + ...overrides, + }) + .returning(); + createdAssetIds.add(asset.id); + return asset; +} + +async function seedOwnerEvidence(assetId: string, name: string) { + const [row] = await db + .insert(schema.evidence) + .values({ + assetId, + userId: OWNER_CHITTY_ID, + name, + evidenceType: "receipt", + }) + .returning(); + createdEvidenceIds.add(row.id); + return row; +} + +// ========================================================================= +// POST /api/assets/:id/freeze +// ========================================================================= +describe("POST /api/assets/:id/freeze", () => { + it("400 — bad UUID", async () => { + const app = buildTestApp(); + const res = await app.request("/api/assets/not-uuid/freeze", { + method: "POST", + }); + expect(res.status).toBe(400); + }); + + it("404 — intruder cannot freeze owner's asset", async () => { + const asset = await seedOwnerAsset("Freeze intrusion probe"); + const app = buildTestApp({ claims: intruderClaims() }); + const res = await app.request(`/api/assets/${asset.id}/freeze`, { + method: "POST", + }); + expect(res.status).toBe(404); + const rows = await sql`SELECT chitty_chain_status FROM assets WHERE id = ${asset.id}`; + expect(rows[0].chitty_chain_status).toBe("draft"); + }); + + it("401 — no auth", async () => { + const app = buildTestApp({ claims: null }); + const res = await app.request( + "/api/assets/00000000-0000-0000-0000-000000000000/freeze", + { method: "POST" }, + ); + expect(res.status).toBe(401); + }); + + it("502 — chittymint unreachable returns mint_unavailable", async () => { + const asset = await seedOwnerAsset("Freeze unreachable mint probe"); + const app = buildTestApp({ + env: { CHITTYMINT_URL: "https://mint-does-not-exist.chitty-invalid-tld" }, + }); + const res = await app.request(`/api/assets/${asset.id}/freeze`, { + method: "POST", + }); + expect(res.status).toBe(502); + const body = (await res.json()) as any; + expect(body.error).toBe("mint_unavailable"); + // State unchanged on upstream failure. + const rows = await sql`SELECT chitty_chain_status FROM assets WHERE id = ${asset.id}`; + expect(rows[0].chitty_chain_status).toBe("draft"); + }, 10000); +}); + +// ========================================================================= +// POST /api/assets/:id/mint +// ========================================================================= +describe("POST /api/assets/:id/mint", () => { + it("400 — bad UUID", async () => { + const app = buildTestApp(); + const res = await app.request("/api/assets/not-uuid/mint", { + method: "POST", + }); + expect(res.status).toBe(400); + }); + + it("400 — invalid state when asset not frozen", async () => { + const asset = await seedOwnerAsset("Mint pre-freeze gate test"); + const app = buildTestApp(); + const res = await app.request(`/api/assets/${asset.id}/mint`, { + method: "POST", + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.error).toBe("invalid_state"); + expect(body.current_status).toBe("draft"); + }); + + it("404 — intruder cannot mint owner's asset", async () => { + const asset = await seedOwnerAsset("Mint intrusion probe", { + chittyChainStatus: "frozen", + }); + const app = buildTestApp({ claims: intruderClaims() }); + const res = await app.request(`/api/assets/${asset.id}/mint`, { + method: "POST", + }); + expect(res.status).toBe(404); + }); + + it("502 — mint upstream unreachable from frozen asset", async () => { + const asset = await seedOwnerAsset("Mint unreachable upstream", { + chittyChainStatus: "frozen", + ipfsHash: "QmFakeButRealisticHash", + }); + const app = buildTestApp({ + env: { CHITTYMINT_URL: "https://mint-does-not-exist.chitty-invalid-tld" }, + }); + const res = await app.request(`/api/assets/${asset.id}/mint`, { + method: "POST", + }); + expect(res.status).toBe(502); + const body = (await res.json()) as any; + expect(body.error).toBe("mint_unavailable"); + const rows = await sql`SELECT chitty_chain_status FROM assets WHERE id = ${asset.id}`; + expect(rows[0].chitty_chain_status).toBe("frozen"); // unchanged + }, 10000); +}); + +// ========================================================================= +// POST /api/assets/:assetId/calculate-trust-score +// ========================================================================= +describe("POST /api/assets/:assetId/calculate-trust-score", () => { + it("400 — bad UUID", async () => { + const app = buildTestApp(); + const res = await app.request("/api/assets/not-uuid/calculate-trust-score", { + method: "POST", + }); + expect(res.status).toBe(400); + }); + + it("404 — asset not owned", async () => { + const asset = await seedOwnerAsset("Trust-score intrusion probe"); + const app = buildTestApp({ claims: intruderClaims() }); + const res = await app.request( + `/api/assets/${asset.id}/calculate-trust-score`, + { method: "POST" }, + ); + expect(res.status).toBe(404); + }); + + it("503 — OPENAI_API_KEY not configured returns service_unavailable", async () => { + const asset = await seedOwnerAsset("Trust-score no-key probe"); + const app = buildTestApp({ env: { OPENAI_API_KEY: undefined } }); + const res = await app.request( + `/api/assets/${asset.id}/calculate-trust-score`, + { method: "POST" }, + ); + expect(res.status).toBe(503); + const body = (await res.json()) as any; + expect(body.error).toBe("service_unavailable"); + // No mutation on unavailable upstream. + const rows = await sql`SELECT trust_score FROM assets WHERE id = ${asset.id}`; + expect(String(rows[0].trust_score)).toBe("0.0"); + }); +}); + +// ========================================================================= +// POST /api/evidence/:evidenceId/analyze +// ========================================================================= +describe("POST /api/evidence/:evidenceId/analyze", () => { + it("400 — bad UUID", async () => { + const app = buildTestApp(); + const res = await app.request("/api/evidence/not-uuid/analyze", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ base64Image: "x", analysisType: "receipt" }), + }); + expect(res.status).toBe(400); + }); + + it("400 — invalid analysis type", async () => { + const asset = await seedOwnerAsset("Analyze type-gate host"); + const ev = await seedOwnerEvidence(asset.id, "Bad-type analyze probe"); + const app = buildTestApp(); + const res = await app.request(`/api/evidence/${ev.id}/analyze`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ base64Image: "x", analysisType: "nonsense" }), + }); + expect(res.status).toBe(400); + }); + + it("400 — missing fields", async () => { + const asset = await seedOwnerAsset("Analyze missing-fields host"); + const ev = await seedOwnerEvidence(asset.id, "Missing-field probe"); + const app = buildTestApp(); + const res = await app.request(`/api/evidence/${ev.id}/analyze`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("404 — evidence not owned by caller", async () => { + const asset = await seedOwnerAsset("Analyze intrusion host"); + const ev = await seedOwnerEvidence(asset.id, "Intrusion analyze target"); + const app = buildTestApp({ claims: intruderClaims() }); + const res = await app.request(`/api/evidence/${ev.id}/analyze`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ base64Image: "x", analysisType: "receipt" }), + }); + expect(res.status).toBe(404); + }); + + it("503 — OPENAI_API_KEY not configured", async () => { + const asset = await seedOwnerAsset("Analyze no-key host"); + const ev = await seedOwnerEvidence(asset.id, "No-key analyze target"); + const app = buildTestApp({ env: { OPENAI_API_KEY: undefined } }); + const res = await app.request(`/api/evidence/${ev.id}/analyze`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ base64Image: "x", analysisType: "receipt" }), + }); + expect(res.status).toBe(503); + const body = (await res.json()) as any; + expect(body.error).toBe("service_unavailable"); + // No ai_analysis_results row inserted on upstream-unavailable. + const rows = await sql`SELECT id FROM ai_analysis_results WHERE evidence_id = ${ev.id}`; + expect(rows.length).toBe(0); + }); +}); + +// ========================================================================= +// POST /api/legal/generate-document +// ========================================================================= +describe("POST /api/legal/generate-document", () => { + it("400 — missing required fields", async () => { + const app = buildTestApp(); + const res = await app.request("/api/legal/generate-document", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jurisdiction: "NY" }), + }); + expect(res.status).toBe(400); + }); + + it("400 — bad asset UUID", async () => { + const app = buildTestApp(); + const res = await app.request("/api/legal/generate-document", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ templateType: "bill_of_sale", assetId: "not-uuid" }), + }); + expect(res.status).toBe(400); + }); + + it("404 — asset not owned", async () => { + const asset = await seedOwnerAsset("Legal-doc intrusion host"); + const app = buildTestApp({ claims: intruderClaims() }); + const res = await app.request("/api/legal/generate-document", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + templateType: "bill_of_sale", + assetId: asset.id, + }), + }); + expect(res.status).toBe(404); + }); + + it("503 — OPENAI_API_KEY not configured", async () => { + const asset = await seedOwnerAsset("Legal-doc no-key host"); + const app = buildTestApp({ env: { OPENAI_API_KEY: undefined } }); + const res = await app.request("/api/legal/generate-document", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + templateType: "bill_of_sale", + assetId: asset.id, + jurisdiction: "New York State", + }), + }); + expect(res.status).toBe(503); + }); +}); + +// ========================================================================= +// POST /api/evidence-ledger/submit + /verify +// ========================================================================= +describe("POST /api/evidence-ledger/submit", () => { + it("400 — missing evidenceType / data", async () => { + const app = buildTestApp(); + const res = await app.request("/api/evidence-ledger/submit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("400 — invalid JSON body", async () => { + const app = buildTestApp(); + const res = await app.request("/api/evidence-ledger/submit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }); + expect(res.status).toBe(400); + }); + + it("502 — ledger unreachable returns ledger_unavailable", async () => { + const app = buildTestApp({ + env: { + CHITTYLEDGER_URL: "https://ledger-does-not-exist.chitty-invalid-tld", + }, + }); + const res = await app.request("/api/evidence-ledger/submit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + evidenceType: "receipt", + data: { merchant: "Test" }, + }), + }); + expect(res.status).toBe(502); + const body = (await res.json()) as any; + expect(body.error).toBe("ledger_unavailable"); + }, 10000); + + it("2xx or 502 — live ledger real call (no mocks)", async () => { + // Real HTTP — may succeed if ledger accepts submissions, or fail with + // 502 if the endpoint shape differs. Both are valid real-error coverage. + const app = buildTestApp(); + const res = await app.request("/api/evidence-ledger/submit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + evidenceType: "receipt", + data: { merchant: "Smoke test merchant" }, + }), + }); + expect([200, 201, 502]).toContain(res.status); + }, 15000); +}); + +describe("POST /api/evidence-ledger/:chittyId/verify", () => { + it("400 — invalid chitty_id format", async () => { + const app = buildTestApp(); + const res = await app.request("/api/evidence-ledger/not-canonical/verify", { + method: "POST", + }); + expect(res.status).toBe(400); + }); + + it("404 or 502 — real call against live ledger for nonexistent canonical ID", async () => { + const app = buildTestApp(); + const res = await app.request( + `/api/evidence-ledger/${NONEXISTENT_LEDGER_ID}/verify`, + { method: "POST" }, + ); + expect([404, 502]).toContain(res.status); + const body = (await res.json()) as any; + expect(["not_found", "ledger_unavailable"]).toContain(body.error); + }, 15000); + + it("502 — points-at unreachable host", async () => { + const app = buildTestApp({ + env: { + CHITTYLEDGER_URL: "https://ledger-does-not-exist.chitty-invalid-tld", + }, + }); + const res = await app.request( + `/api/evidence-ledger/${NONEXISTENT_LEDGER_ID}/verify`, + { method: "POST" }, + ); + expect(res.status).toBe(502); + }, 10000); +}); + +// ========================================================================= +// POST /api/seed-demo (dev-only) +// ========================================================================= +describe("POST /api/seed-demo", () => { + it("403 — disabled in production env", async () => { + const app = buildTestApp({ env: { ENVIRONMENT: "production" } }); + const res = await app.request("/api/seed-demo", { method: "POST" }); + expect(res.status).toBe(403); + }); + + it("401 — no auth", async () => { + const app = buildTestApp({ claims: null }); + const res = await app.request("/api/seed-demo", { method: "POST" }); + expect(res.status).toBe(401); + }); + + it("201 — development env seeds 3 demo assets owned by caller", async () => { + const app = buildTestApp(); // ENVIRONMENT defaults to development + const res = await app.request("/api/seed-demo", { method: "POST" }); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + expect(body.assetCount).toBe(3); + + // Verify persisted state — real Neon. + const rows = await sql` + SELECT id, name, user_id FROM assets WHERE user_id = ${OWNER_CHITTY_ID} + AND name IN ('MacBook Pro 16-inch M3 Max', '2023 Tesla Model Y Long Range', 'Home Office Property') + `; + expect(rows.length).toBe(3); + for (const r of rows) { + expect(r.user_id).toBe(OWNER_CHITTY_ID); + createdAssetIds.add(r.id as string); + } + // Timeline events emitted per asset. + const events = await sql` + SELECT event_type FROM timeline_events + WHERE user_id = ${OWNER_CHITTY_ID} AND event_type = 'acquisition' + `; + expect(events.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/worker/src/clients/chittyledger.ts b/worker/src/clients/chittyledger.ts index a2b7ff6..cd84f26 100644 --- a/worker/src/clients/chittyledger.ts +++ b/worker/src/clients/chittyledger.ts @@ -6,6 +6,8 @@ // restrict by type; this client is type-agnostic and forwards opaquely. // // Phase 2c: backs GET /api/evidence-ledger/:chittyId. Real HTTP only — no mocks. +// Phase 3c: extends with submitEvidence() + verifyEvidence() for the two write +// surfaces (POST /api/evidence-ledger/submit, POST /api/evidence-ledger/:chittyId/verify). import type { Env } from "../env"; @@ -74,3 +76,94 @@ export async function getEvidence( clearTimeout(timer); } } + +// ---------------------------------------------------------------------- +// Phase 3c write helpers — submit + verify. Both are real HTTP, 3s timeout. +// Per chittycanon://gov/governance#core-types the submitted evidence is a +// Thing (T); the submitter is a Person (P); the resulting ledger record is +// an Event (E). Authority (A) and Location (L) are not exercised here. All +// five P/L/T/E/A remain enumerated in env.ts. +// ---------------------------------------------------------------------- + +export interface LedgerSubmitRequest { + evidenceType: string; + data: unknown; + metadata?: Record; + submitterId: string; +} + +export interface LedgerSubmitResponse { + chittyId?: string; + status?: string; + trustScore?: number; + retentionUntil?: string; + chainResult?: unknown; + [k: string]: unknown; +} + +export interface LedgerVerifyResponse { + verified?: boolean; + trustScore?: number; + [k: string]: unknown; +} + +async function postJson( + url: string, + body: unknown, + timeoutMs: number, +): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs); + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + if (!res.ok) { + throw new LedgerClientError( + `ChittyLedger returned ${res.status}`, + res.status, + url, + ); + } + return (await res.json()) as T; + } 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); + } +} + +/** + * Submit evidence to ChittyLedger. Real network call, 3s timeout. + * Mirrors server/chittyCore.ts:evidenceLedger.submitEvidence — wire shape + * forwarded opaquely; the ledger owns its schema. + */ +export async function submitEvidence( + env: Env, + body: LedgerSubmitRequest, + opts: { timeoutMs?: number } = {}, +): Promise { + const url = `${baseUrl(env)}/api/v1/evidence`; + return postJson(url, body, opts.timeoutMs ?? 3000); +} + +/** + * Trigger ledger-side verification for a previously submitted chittyId. + * Mirrors server/chittyCore.ts:evidenceLedger.verifyEvidence. + */ +export async function verifyEvidence( + env: Env, + chittyId: string, + opts: { timeoutMs?: number } = {}, +): Promise { + const url = `${baseUrl(env)}/api/v1/evidence/${encodeURIComponent(chittyId)}/verify`; + return postJson(url, {}, opts.timeoutMs ?? 3000); +} diff --git a/worker/src/clients/chittymint.ts b/worker/src/clients/chittymint.ts new file mode 100644 index 0000000..082b69d --- /dev/null +++ b/worker/src/clients/chittymint.ts @@ -0,0 +1,127 @@ +// @canon: chittycanon://core/services/chittyassets +// ChittyMint HTTP client — freeze + mint operations on the ChittyChain. +// Workers-compatible (uses global fetch). 3s per-call timeout, no retries. +// +// Phase 3c: backs /api/assets/:id/freeze and /api/assets/:id/mint. +// +// Per chittycanon://gov/governance#core-types — the chittyId being frozen/ +// minted references a Thing (T) asset. The caller is a Person (P). Mint +// produces an Event (E) recorded on the timeline. All five P/L/T/E/A +// enumerated in env.ts. +// +// KNOWN GAP (documented in PR body): Express called the legacy +// chittyCloudMcp at api.chittycloud.com with paths /v1/chain/freeze and +// /v1/chain/mint. The Worker `CHITTYMINT_URL` defaults to mint.chitty.cc; +// the path shape there has not been verified live. Real integration tests +// exercise the 502 mapping (host-unreachable / non-2xx) — real-success +// coverage requires the mint endpoint to be wired or a Cloudflare service +// binding to be added in Phase 4. + +import type { Env } from "../env"; + +const DEFAULT_MINT_URL = "https://mint.chitty.cc"; + +export class MintClientError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly upstream: string, + ) { + super(message); + this.name = "MintClientError"; + } +} + +function baseUrl(env: Env): string { + return env.CHITTYMINT_URL ?? DEFAULT_MINT_URL; +} + +async function postJson( + url: string, + body: unknown, + timeoutMs: number, +): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs); + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + if (!res.ok) { + throw new MintClientError( + `ChittyMint returned ${res.status}`, + res.status, + url, + ); + } + return (await res.json()) as T; + } catch (err) { + if (err instanceof MintClientError) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new MintClientError(`mint fetch failed: ${msg}`, 502, url); + } finally { + clearTimeout(timer); + } +} + +export interface FreezeResponse { + ipfsHash?: string; + freezeTimestamp?: string; + [k: string]: unknown; +} + +export interface MintResponse { + tokenId?: string; + transactionHash?: string; + [k: string]: unknown; +} + +/** + * Freeze a Thing-typed asset on the ChittyChain for the 7-day immutability + * window. Mirrors server/chittyCloudMcp.ts:freezeAsset. + */ +export async function freezeAsset( + env: Env, + chittyId: string, + assetData: unknown, + opts: { timeoutMs?: number } = {}, +): Promise { + const url = `${baseUrl(env)}/v1/chain/freeze`; + return postJson( + url, + { + chittyId, + assetData, + freezeDuration: "7d", + metadata: { + source: "ChittyAssets", + timestamp: new Date().toISOString(), + }, + }, + opts.timeoutMs ?? 3000, + ); +} + +/** + * Mint an evidence token for a frozen asset. Mirrors + * server/chittyCloudMcp.ts:mintAssetToken. + */ +export async function mintAssetToken( + env: Env, + chittyId: string, + evidenceHash: string, + opts: { timeoutMs?: number } = {}, +): Promise { + const url = `${baseUrl(env)}/v1/chain/mint`; + return postJson( + url, + { chittyId, evidenceHash, mintingFee: "0.1" }, + opts.timeoutMs ?? 3000, + ); +} diff --git a/worker/src/clients/openai.ts b/worker/src/clients/openai.ts new file mode 100644 index 0000000..4c52106 --- /dev/null +++ b/worker/src/clients/openai.ts @@ -0,0 +1,277 @@ +// @canon: chittycanon://core/services/chittyassets +// OpenAI HTTP client — Workers-compatible (uses global fetch, NO node SDK). +// +// Phase 3c: backs /api/evidence/:evidenceId/analyze, /api/legal/generate-document, +// /api/assets/:assetId/calculate-trust-score. +// +// Per chittycanon://gov/governance#core-types — OpenAI is a Tool (T) consumed +// by a Person (P) caller to produce Event (E) records (ai_analysis_results). +// Authority (A) and Location (L) are not exercised by this client. All five +// P/L/T/E/A remain enumerated in env.ts. +// +// 3s per-call timeout via AbortController. No retries — caller's concern. +// API key is a Worker secret; if unset, callers must surface 503. + +import type { Env } from "../env"; + +const OPENAI_URL = "https://api.openai.com/v1/chat/completions"; +const MODEL = "gpt-4o"; + +export class OpenAIClientError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "OpenAIClientError"; + } +} + +export class OpenAIConfigError extends Error { + constructor() { + super("OPENAI_API_KEY not configured"); + this.name = "OpenAIConfigError"; + } +} + +type ChatMessage = + | { role: "system" | "user" | "assistant"; content: string } + | { + role: "user"; + content: Array< + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + >; + }; + +interface ChatRequest { + messages: ChatMessage[]; + responseFormat?: "json_object" | "text"; + maxTokens?: number; + timeoutMs?: number; +} + +interface ChatResponse { + content: string; +} + +export async function chatCompletion( + env: Env, + req: ChatRequest, +): Promise { + if (!env.OPENAI_API_KEY) throw new OpenAIConfigError(); + const ctrl = new AbortController(); + const timer = setTimeout( + () => ctrl.abort(new Error("timeout")), + req.timeoutMs ?? 3000, + ); + try { + const body: Record = { + model: MODEL, + messages: req.messages, + max_tokens: req.maxTokens ?? 1000, + }; + if (req.responseFormat === "json_object") { + body.response_format = { type: "json_object" }; + } + const res = await fetch(OPENAI_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${env.OPENAI_API_KEY}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + if (!res.ok) { + throw new OpenAIClientError( + `OpenAI returned ${res.status}`, + res.status, + ); + } + const json = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = json.choices?.[0]?.message?.content ?? ""; + return { content }; + } catch (err) { + if (err instanceof OpenAIClientError || err instanceof OpenAIConfigError) { + throw err; + } + const msg = err instanceof Error ? err.message : String(err); + throw new OpenAIClientError(`openai fetch failed: ${msg}`, 502); + } finally { + clearTimeout(timer); + } +} + +// ----------------------------------------------------------------------- +// High-level helpers — mirror server/aiAnalysis.ts prompts verbatim so the +// migration is semantically identical to Express. +// ----------------------------------------------------------------------- + +export async function analyzeReceipt(env: Env, base64Image: string) { + const { content } = await chatCompletion(env, { + responseFormat: "json_object", + maxTokens: 1000, + messages: [ + { + role: "system", + content: `You are an expert receipt analyzer. Analyze the receipt image and extract structured data. + Respond with JSON in this exact format: { + "merchant": string, + "amount": number, + "currency": string, + "date": string (ISO format), + "items": [{"description": string, "quantity": number, "price": number}], + "taxAmount": number, + "confidence": number (0-1), + "category": string + }`, + }, + { + role: "user", + content: [ + { + type: "text", + text: "Analyze this receipt and extract all the key information including merchant, total amount, date, individual items, and tax amount.", + }, + { + type: "image_url", + image_url: { url: `data:image/jpeg;base64,${base64Image}` }, + }, + ], + }, + ], + }); + return JSON.parse(content || "{}"); +} + +export async function analyzeDocument(env: Env, base64Image: string) { + const { content } = await chatCompletion(env, { + responseFormat: "json_object", + maxTokens: 1500, + messages: [ + { + role: "system", + content: `You are an expert document analyzer. Analyze the document image and extract structured data. + Respond with JSON in this exact format: { + "documentType": string, + "keyFields": object, + "confidence": number (0-1), + "summary": string, + "extractedText": string + }`, + }, + { + role: "user", + content: [ + { + type: "text", + text: "Analyze this document and extract the document type, key fields, and provide a summary of the content.", + }, + { + type: "image_url", + image_url: { url: `data:image/jpeg;base64,${base64Image}` }, + }, + ], + }, + ], + }); + return JSON.parse(content || "{}"); +} + +export async function analyzeAssetPhoto( + env: Env, + base64Image: string, + assetType: string, +) { + const { content } = await chatCompletion(env, { + responseFormat: "json_object", + maxTokens: 1000, + messages: [ + { + role: "system", + content: `You are an expert asset appraiser. Analyze the asset photo and provide valuation insights. + Respond with JSON in this exact format: { + "estimatedValue": number, + "currency": string, + "confidence": number (0-1), + "factors": [string], + "marketComparisons": [{"source": string, "price": number, "similarity": number}] + }`, + }, + { + role: "user", + content: [ + { + type: "text", + text: `Analyze this ${assetType} photo and provide an estimated value based on visible condition, brand, model, and other factors. Include factors that influenced your valuation.`, + }, + { + type: "image_url", + image_url: { url: `data:image/jpeg;base64,${base64Image}` }, + }, + ], + }, + ], + }); + return JSON.parse(content || "{}"); +} + +export async function generateLegalDocument( + env: Env, + templateType: string, + assetData: unknown, + jurisdiction: string, +): Promise { + const { content } = await chatCompletion(env, { + maxTokens: 2000, + timeoutMs: 8000, // long-form generation needs more headroom + messages: [ + { + role: "system", + content: `You are an expert legal document generator. Generate a professional ${templateType} document for ${jurisdiction} jurisdiction. Use proper legal formatting and language.`, + }, + { + role: "user", + content: `Generate a ${templateType} document with the following asset information: ${JSON.stringify(assetData)}. Include all necessary legal clauses and make it court-ready.`, + }, + ], + }); + return content; +} + +/** + * Calculate trust score via OpenAI. Mirrors Express server/aiAnalysis.ts + * verbatim — returns 0..100 float. NOTE: the assets.trust_score column is + * numeric(3,1) so callers must cap at 99.9 before INSERT/UPDATE. + */ +export async function calculateTrustScore( + env: Env, + asset: unknown, + evidenceItems: unknown[], +): Promise<{ trustScore: number; factors: string[] }> { + const { content } = await chatCompletion(env, { + responseFormat: "json_object", + maxTokens: 500, + messages: [ + { + role: "system", + content: `You are a trust scoring expert. Analyze the asset and evidence data to calculate a trust score from 0.0 to 100.0. + Respond with JSON: {"trustScore": number, "factors": [string]}`, + }, + { + role: "user", + content: `Calculate trust score for this asset: ${JSON.stringify(asset)} with evidence: ${JSON.stringify(evidenceItems)}. Consider verification status, documentation completeness, blockchain verification, and source credibility.`, + }, + ], + }); + const parsed = JSON.parse(content || '{"trustScore":0,"factors":[]}'); + const raw = Number(parsed.trustScore ?? 0); + return { + trustScore: Math.max(0, Math.min(100, Number.isFinite(raw) ? raw : 0)), + factors: Array.isArray(parsed.factors) ? parsed.factors : [], + }; +} diff --git a/worker/src/env.ts b/worker/src/env.ts index f3886ca..3793322 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -26,6 +26,11 @@ export interface Env { CHITTYRESOLUTION_URL?: string; CHITTYFILE_URL?: string; + // Phase 3c — OpenAI for AI analysis & document generation. Worker SECRET + // (set via `wrangler secret put OPENAI_API_KEY`); never a `vars` entry. + // Handlers return 503 service_unavailable if unset rather than crashing. + OPENAI_API_KEY?: string; + // Phase 2+ bindings (active): CHITTYASSETS_DB: Hyperdrive; // EVIDENCE: R2Bucket; diff --git a/worker/src/index.ts b/worker/src/index.ts index d1c1f36..81b74be 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -14,6 +14,15 @@ // (POST /api/assets/:assetId/warranties, // POST /api/assets/:assetId/insurance, // POST /api/legal-cases). +// Phase 3c: heavy writes with external service calls +// (POST /api/assets/:id/freeze, +// POST /api/assets/:id/mint, +// POST /api/assets/:assetId/calculate-trust-score, +// POST /api/evidence/:evidenceId/analyze, +// POST /api/legal/generate-document, +// POST /api/evidence-ledger/submit, +// POST /api/evidence-ledger/:chittyId/verify, +// POST /api/seed-demo [dev-only]). import { Hono } from "hono"; import { cors } from "hono/cors"; @@ -28,6 +37,7 @@ import { toolRoutes } from "./routes/tools"; import { evidenceLedgerRoutes } from "./routes/evidence-ledger"; import { ecosystemRoutes } from "./routes/ecosystem"; import { evidenceRoutes } from "./routes/evidence"; +import { seedRoutes } from "./routes/seed"; type Variables = { claims: ChittyAuthClaims }; @@ -74,7 +84,7 @@ app.get("/api/v1/status", (c) => canonical_uri: "chittycanon://core/services/chittyassets", version: "1.0.0", environment: c.env.ENVIRONMENT, - migration_status: "PHASE_3B_DOMAIN_WRITES", + migration_status: "PHASE_3C_HEAVY_WRITES", migrated_routes: [ "GET /api/assets", "GET /api/assets/stats", @@ -95,6 +105,14 @@ app.get("/api/v1/status", (c) => "POST /api/assets/:assetId/warranties", "POST /api/assets/:assetId/insurance", "POST /api/legal-cases", + "POST /api/assets/:id/freeze", + "POST /api/assets/:id/mint", + "POST /api/assets/:assetId/calculate-trust-score", + "POST /api/evidence/:evidenceId/analyze", + "POST /api/legal/generate-document", + "POST /api/evidence-ledger/submit", + "POST /api/evidence-ledger/:chittyId/verify", + "POST /api/seed-demo", ], entity_types_handled: [...ENTITY_TYPES], dependencies: { @@ -126,6 +144,7 @@ app.route("/api", toolRoutes); app.route("/api", evidenceLedgerRoutes); app.route("/api", ecosystemRoutes); app.route("/api", evidenceRoutes); +app.route("/api", seedRoutes); // Unmigrated routes return 501 unconditionally — no auth oracle. app.all("/api/*", (c) => diff --git a/worker/src/routes/assets.ts b/worker/src/routes/assets.ts index ddd796c..c0da612 100644 --- a/worker/src/routes/assets.ts +++ b/worker/src/routes/assets.ts @@ -31,6 +31,16 @@ import { import { requireChittyAuth } from "../auth"; import { getDb } from "../db"; import type { Env, ChittyAuthClaims } from "../env"; +import { + freezeAsset, + mintAssetToken, + MintClientError, +} from "../clients/chittymint"; +import { + calculateTrustScore, + OpenAIClientError, + OpenAIConfigError, +} from "../clients/openai"; // ----------------------------------------------------------------- // Input validation schemas — server-controlled fields are stripped. @@ -420,6 +430,241 @@ export function registerAssetRoutes( } return c.body(null, 204); }); + + // ----------------------------------------------------------------- + // POST /api/assets/:id/freeze — Phase 3c + // Mirrors Express server/routes.ts:125. Calls ChittyMint freeze endpoint, + // updates asset row to {chittyChainStatus:'frozen', ipfsHash, freezeTimestamp}, + // and emits a timeline_events row in the same transaction. + // ----------------------------------------------------------------- + app.post("/assets/:id/freeze", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { id } = c.req.param(); + + if (!isValidId(id)) { + return c.json( + { error: "invalid_id", message: "Asset ID must be a valid UUID" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const [asset] = await db + .select() + .from(assets) + .where(and(eq(assets.id, id), eq(assets.userId, userId))); + if (!asset) return c.json({ message: "Asset not found" }, 404); + + // External freeze — failures are surfaced as 502. + let freezeResult; + try { + freezeResult = await freezeAsset( + c.env, + asset.chittyId ?? asset.id, + asset, + ); + } catch (err) { + if (err instanceof MintClientError) { + return c.json( + { + error: "mint_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(assets) + .set({ + chittyChainStatus: "frozen", + ipfsHash: freezeResult.ipfsHash ?? null, + freezeTimestamp: freezeResult.freezeTimestamp + ? new Date(freezeResult.freezeTimestamp) + : new Date(), + updatedAt: new Date(), + }) + .where(and(eq(assets.id, id), eq(assets.userId, userId))) + .returning(); + if (!row) return null; + await tx.insert(timelineEvents).values({ + assetId: row.id, + userId, + eventType: "other", + title: "Asset frozen on ChittyChain", + description: "7-day immutability period started", + eventDate: new Date(), + }); + return row; + }); + + if (!updated) return c.json({ message: "Asset not found" }, 404); + return c.json(updated); + }); + + // ----------------------------------------------------------------- + // POST /api/assets/:id/mint — Phase 3c + // Mirrors Express server/routes.ts:166. Gate: chittyChainStatus === 'frozen' + // (matches Express; the "7 days elapsed" check is aspirational and NOT in + // Express today). Calls ChittyMint mint endpoint, updates asset row. + // + // NOTE: This endpoint mints an evidence TOKEN, not the ChittyID itself. + // Phase 3a's chitty_id=NULL deferral is NOT resolved here — see PR body. + // ----------------------------------------------------------------- + app.post("/assets/:id/mint", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { id } = c.req.param(); + + if (!isValidId(id)) { + return c.json( + { error: "invalid_id", message: "Asset ID must be a valid UUID" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const [asset] = await db + .select() + .from(assets) + .where(and(eq(assets.id, id), eq(assets.userId, userId))); + if (!asset) return c.json({ message: "Asset not found" }, 404); + + if (asset.chittyChainStatus !== "frozen") { + return c.json( + { + error: "invalid_state", + message: "Asset must be frozen before minting", + current_status: asset.chittyChainStatus, + }, + 400, + ); + } + + let mintResult; + try { + mintResult = await mintAssetToken( + c.env, + asset.chittyId ?? asset.id, + asset.ipfsHash ?? "placeholder", + ); + } catch (err) { + if (err instanceof MintClientError) { + return c.json( + { + error: "mint_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(assets) + .set({ + chittyChainStatus: "minted", + blockchainHash: mintResult.transactionHash ?? null, + mintingFee: "0.1", + updatedAt: new Date(), + }) + .where(and(eq(assets.id, id), eq(assets.userId, userId))) + .returning(); + if (!row) return null; + await tx.insert(timelineEvents).values({ + assetId: row.id, + userId, + eventType: "other", + title: "Evidence token minted", + description: "Asset ownership token created on ChittyChain", + eventDate: new Date(), + }); + return row; + }); + + if (!updated) return c.json({ message: "Asset not found" }, 404); + return c.json(updated); + }); + + // ----------------------------------------------------------------- + // POST /api/assets/:assetId/calculate-trust-score — Phase 3c + // Mirrors Express server/routes.ts:596. Express delegates to + // aiAnalysisService.calculateTrustScore which calls OpenAI (NOT + // ChittyTrust — divergence from the migration spec, preserved to match + // Express semantics verbatim). + // + // assets.trust_score column is numeric(3,1) — max representable is 99.9. + // The OpenAI response is clamped to [0, 99.9] before UPDATE. + // ----------------------------------------------------------------- + app.post("/assets/:assetId/calculate-trust-score", 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 [asset] = await db + .select() + .from(assets) + .where(and(eq(assets.id, assetId), eq(assets.userId, userId))); + if (!asset) return c.json({ message: "Asset not found" }, 404); + + const evidenceRows = await db + .select() + .from(evidence) + .where( + and(eq(evidence.assetId, assetId), eq(evidence.userId, userId)), + ); + + let result; + try { + result = await calculateTrustScore(c.env, asset, evidenceRows); + } catch (err) { + if (err instanceof OpenAIConfigError) { + return c.json( + { + error: "service_unavailable", + message: "OPENAI_API_KEY not configured", + }, + 503, + ); + } + if (err instanceof OpenAIClientError) { + return c.json( + { + error: "openai_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + + // numeric(3,1) max is 99.9; clamp to keep INSERT/UPDATE valid. + const clamped = Math.min(99.9, Math.max(0, result.trustScore)); + await db + .update(assets) + .set({ trustScore: clamped.toFixed(1), updatedAt: new Date() }) + .where(and(eq(assets.id, assetId), eq(assets.userId, userId))); + + return c.json({ trustScore: clamped, factors: result.factors }); + }); } // Production sub-app with real auth middleware. diff --git a/worker/src/routes/evidence-ledger.ts b/worker/src/routes/evidence-ledger.ts index 9ad8180..895f050 100644 --- a/worker/src/routes/evidence-ledger.ts +++ b/worker/src/routes/evidence-ledger.ts @@ -12,7 +12,12 @@ 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"; +import { + getEvidence, + submitEvidence, + verifyEvidence, + LedgerClientError, +} from "../clients/chittyledger"; type Variables = { claims: ChittyAuthClaims }; type AppType = { Bindings: Env; Variables: Variables }; @@ -53,6 +58,105 @@ export function registerEvidenceLedgerRoutes( throw err; } }); + + // ----------------------------------------------------------------- + // POST /api/evidence-ledger/submit — Phase 3c + // Mirrors Express server/routes.ts:51. Forwards body opaquely to the + // ledger; submitterId is server-injected from claims.chitty_id. + // ----------------------------------------------------------------- + app.post("/evidence-ledger/submit", authMiddleware, async (c) => { + const claims = c.get("claims"); + let body: any; + try { + body = await c.req.json(); + } catch { + return c.json( + { error: "invalid_json", message: "Request body must be valid JSON" }, + 400, + ); + } + const { evidenceType, data, metadata } = body ?? {}; + if (!evidenceType || typeof data === "undefined") { + return c.json( + { + error: "invalid_input", + message: "evidenceType and data are required", + }, + 400, + ); + } + try { + const result = await submitEvidence(c.env, { + evidenceType, + data, + metadata: { ...(metadata ?? {}), submissionSource: "ChittyAssets" }, + submitterId: claims.chitty_id, + }); + return c.json({ + success: true, + chittyId: result.chittyId, + status: result.status, + trustScore: result.trustScore, + retentionUntil: result.retentionUntil, + chainResult: result.chainResult, + }); + } catch (err) { + if (err instanceof LedgerClientError) { + return c.json( + { + error: "ledger_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + }); + + // ----------------------------------------------------------------- + // POST /api/evidence-ledger/:chittyId/verify — Phase 3c + // Mirrors Express server/routes.ts:92. + // ----------------------------------------------------------------- + app.post("/evidence-ledger/:chittyId/verify", 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 verification = await verifyEvidence(c.env, chittyId); + return c.json(verification); + } catch (err) { + if (err instanceof LedgerClientError) { + 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 = (() => { diff --git a/worker/src/routes/evidence.ts b/worker/src/routes/evidence.ts index 0057b62..3bc3abe 100644 --- a/worker/src/routes/evidence.ts +++ b/worker/src/routes/evidence.ts @@ -24,12 +24,20 @@ import { z } from "zod"; import { assets, evidence, + aiAnalysisResults, timelineEvents, insertEvidenceSchema, } from "@shared/schema"; import { requireChittyAuth } from "../auth"; import { getDb } from "../db"; import type { Env, ChittyAuthClaims } from "../env"; +import { + analyzeReceipt, + analyzeDocument, + analyzeAssetPhoto, + OpenAIClientError, + OpenAIConfigError, +} from "../clients/openai"; type Variables = { claims: ChittyAuthClaims }; type AppType = { Bindings: Env; Variables: Variables }; @@ -136,6 +144,144 @@ export function registerEvidenceRoutes( } return c.json(result.evidence, 201); }); + + // ----------------------------------------------------------------- + // POST /api/evidence/:evidenceId/analyze — Phase 3c + // Mirrors Express server/routes.ts:379. Routes to OpenAI gpt-4o based on + // analysisType ('receipt' | 'document' | 'asset_valuation'). Stores result + // in ai_analysis_results (Event E) and updates evidence row with the + // analysis JSON + verificationStatus. + // ----------------------------------------------------------------- + app.post("/evidence/:evidenceId/analyze", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { evidenceId } = c.req.param(); + + if (!isValidId(evidenceId)) { + return c.json( + { error: "invalid_id", message: "Evidence ID must be a valid UUID" }, + 400, + ); + } + + let body: any; + try { + body = await c.req.json(); + } catch { + return c.json( + { error: "invalid_json", message: "Request body must be valid JSON" }, + 400, + ); + } + + const { base64Image, analysisType } = body ?? {}; + if (!base64Image || !analysisType) { + return c.json( + { + error: "invalid_input", + message: "base64Image and analysisType are required", + }, + 400, + ); + } + if ( + analysisType !== "receipt" && + analysisType !== "document" && + analysisType !== "asset_valuation" + ) { + return c.json( + { error: "invalid_input", message: "Invalid analysis type" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + + // Ownership check — evidence must belong to caller. + const [evidenceItem] = await db + .select() + .from(evidence) + .where(and(eq(evidence.id, evidenceId), eq(evidence.userId, userId))); + if (!evidenceItem) { + return c.json({ message: "Evidence not found" }, 404); + } + + const startTime = Date.now(); + let results: any; + let confidence = 0; + try { + if (analysisType === "receipt") { + results = await analyzeReceipt(c.env, base64Image); + } else if (analysisType === "document") { + results = await analyzeDocument(c.env, base64Image); + } else { + const [asset] = await db + .select() + .from(assets) + .where( + and( + eq(assets.id, evidenceItem.assetId), + eq(assets.userId, userId), + ), + ); + results = await analyzeAssetPhoto( + c.env, + base64Image, + asset?.assetType ?? "unknown", + ); + } + confidence = typeof results?.confidence === "number" ? results.confidence : 0; + } catch (err) { + if (err instanceof OpenAIConfigError) { + return c.json( + { + error: "service_unavailable", + message: "OPENAI_API_KEY not configured", + }, + 503, + ); + } + if (err instanceof OpenAIClientError) { + return c.json( + { + error: "openai_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + const processingTime = Date.now() - startTime; + // ai_analysis_results.confidence is numeric(3,2) — max 9.99, range 0..1. + const confidenceStr = Math.max(0, Math.min(1, confidence)).toFixed(2); + + const inserted = await db.transaction(async (tx) => { + const [row] = await tx + .insert(aiAnalysisResults) + .values({ + evidenceId, + analysisType, + confidence: confidenceStr, + results, + processingTime, + modelUsed: "gpt-4o", + }) + .returning(); + await tx + .update(evidence) + .set({ + aiAnalysis: results, + verificationStatus: confidence > 0.8 ? "verified" : "pending", + updatedAt: new Date(), + }) + .where(and(eq(evidence.id, evidenceId), eq(evidence.userId, userId))); + return row; + }); + + return c.json(inserted); + }); } // Production sub-app with real auth middleware. diff --git a/worker/src/routes/legal-cases.ts b/worker/src/routes/legal-cases.ts index fdbe490..00594ac 100644 --- a/worker/src/routes/legal-cases.ts +++ b/worker/src/routes/legal-cases.ts @@ -16,15 +16,30 @@ import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; -import { eq, desc } from "drizzle-orm"; +import { eq, and, desc } from "drizzle-orm"; import { z } from "zod"; import { legalCases, + assets, + evidence, + timelineEvents, insertLegalCaseSchema, } from "@shared/schema"; import { requireChittyAuth } from "../auth"; import { getDb } from "../db"; import type { Env, ChittyAuthClaims } from "../env"; +import { + generateLegalDocument, + OpenAIClientError, + OpenAIConfigError, +} from "../clients/openai"; + +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); +} type Variables = { claims: ChittyAuthClaims }; type AppType = { Bindings: Env; Variables: Variables }; @@ -113,6 +128,120 @@ export function registerLegalCaseRoutes( return c.json(inserted, 201); }); + + // ----------------------------------------------------------------- + // POST /api/legal/generate-document — Phase 3c + // Mirrors Express server/routes.ts:557. Builds a documentData payload + // from the asset + top-5 evidence + recent-10 timeline events, then asks + // OpenAI gpt-4o to generate a court-ready document for the given + // jurisdiction (default 'New York State'). + // ----------------------------------------------------------------- + app.post("/legal/generate-document", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + + let body: any; + try { + body = await c.req.json(); + } catch { + return c.json( + { error: "invalid_json", message: "Request body must be valid JSON" }, + 400, + ); + } + const { + templateType, + assetId, + jurisdiction, + includeNotarization, + includeBlockchain, + } = body ?? {}; + if (!templateType || !assetId) { + return c.json( + { + error: "invalid_input", + message: "templateType and assetId are required", + }, + 400, + ); + } + 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 [asset] = await db + .select() + .from(assets) + .where(and(eq(assets.id, assetId), eq(assets.userId, userId))); + if (!asset) return c.json({ message: "Asset not found" }, 404); + + const evidenceRows = await db + .select() + .from(evidence) + .where(and(eq(evidence.assetId, assetId), eq(evidence.userId, userId))) + .orderBy(desc(evidence.createdAt)) + .limit(5); + const timelineRows = await db + .select() + .from(timelineEvents) + .where( + and( + eq(timelineEvents.assetId, assetId), + eq(timelineEvents.userId, userId), + ), + ) + .orderBy(desc(timelineEvents.eventDate)) + .limit(10); + + const documentData = { + asset, + evidence: evidenceRows, + timeline: timelineRows, + includeNotarization: Boolean(includeNotarization), + includeBlockchain: Boolean(includeBlockchain), + }; + + let document: string; + try { + document = await generateLegalDocument( + c.env, + String(templateType), + documentData, + String(jurisdiction ?? "New York State"), + ); + } catch (err) { + if (err instanceof OpenAIConfigError) { + return c.json( + { + error: "service_unavailable", + message: "OPENAI_API_KEY not configured", + }, + 503, + ); + } + if (err instanceof OpenAIClientError) { + return c.json( + { + error: "openai_unavailable", + message: err.message, + upstream_status: err.status, + }, + 502, + ); + } + throw err; + } + + return c.json({ + document, + templateType, + jurisdiction: jurisdiction ?? "New York State", + }); + }); } export const legalCaseRoutes = (() => { diff --git a/worker/src/routes/seed.ts b/worker/src/routes/seed.ts new file mode 100644 index 0000000..70ded4a --- /dev/null +++ b/worker/src/routes/seed.ts @@ -0,0 +1,157 @@ +// @canon: chittycanon://core/services/chittyassets +// Dev-only seed route — Phase 3c. +// +// Route ported: +// POST /api/seed-demo server/routes.ts:39 +// +// Gate: returns 403 unless c.env.ENVIRONMENT === "development". Production +// deployment must reject this endpoint unconditionally — operational +// hygiene, not security-critical (still ownership-scoped to caller). +// +// Per chittycanon://gov/governance#core-types — seeded rows are Thing (T) +// assets owned by the calling Person (P). No Authority (A), Location (L), +// or Event (E) records are minted here beyond the implicit acquisition +// timeline event per asset. All five P/L/T/E/A enumerated in env.ts. + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { assets, timelineEvents } 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 }; + +// Realistic ChittyOS-shaped demo rows. No "Lorem ipsum" / "Foo Bar" placeholders. +// chittyId left null — assets table treats chitty_id as nullable; minting +// happens via POST /api/assets/:id/mint per Phase 3a's documented deferral. +function buildDemoAssets(userId: string) { + return [ + { + userId, + name: "MacBook Pro 16-inch M3 Max", + description: + "High-performance laptop for professional work with M3 Max chip", + assetType: "electronics" as const, + status: "active" as const, + purchasePrice: "3499.00", + currentValue: "2800.00", + purchaseDate: new Date("2024-01-15"), + location: "Home Office", + serialNumber: "MBP2024001", + model: "MacBook Pro 16-inch", + manufacturer: "Apple", + condition: "excellent", + trustScore: "92.5", + verificationStatus: "verified" as const, + chittyChainStatus: "minted" as const, + tags: ["work", "computer", "apple", "high-value"], + metadata: { + warranty: "AppleCare+ until 2027", + specifications: { + processor: "M3 Max", + memory: "32GB", + storage: "1TB SSD", + }, + }, + }, + { + userId, + name: "2023 Tesla Model Y Long Range", + description: "Electric SUV with Full Self-Driving capability", + assetType: "vehicle" as const, + status: "active" as const, + purchasePrice: "68990.00", + currentValue: "58500.00", + purchaseDate: new Date("2023-06-20"), + location: "Garage", + serialNumber: "5YJYGDEE3NF123456", + model: "Model Y", + manufacturer: "Tesla", + condition: "excellent", + trustScore: "96.8", + verificationStatus: "verified" as const, + chittyChainStatus: "settled" as const, + tags: ["vehicle", "electric", "tesla", "high-value"], + metadata: { + vin: "5YJYGDEE3NF123456", + features: ["Full Self-Driving", "Premium Interior", "Tow Package"], + color: "Pearl White Multi-Coat", + }, + }, + { + userId, + name: "Home Office Property", + description: "Commercial property used as home office and studio", + assetType: "real_estate" as const, + status: "active" as const, + purchasePrice: "650000.00", + currentValue: "825000.00", + purchaseDate: new Date("2021-03-10"), + location: "123 Innovation Drive, Tech City", + serialNumber: "PROP-2021-001", + model: "Commercial Office Space", + manufacturer: "Custom Build", + condition: "excellent", + trustScore: "89.3", + verificationStatus: "pending" as const, + chittyChainStatus: "draft" as const, + tags: ["real-estate", "commercial", "office", "investment"], + metadata: { + sqft: "2500", + zoning: "Commercial", + taxId: "123-456-789", + }, + }, + ]; +} + +export function registerSeedRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + app.post("/seed-demo", authMiddleware, async (c) => { + if (c.env.ENVIRONMENT !== "development") { + return c.json( + { + error: "forbidden", + message: "Seed endpoint disabled outside development", + }, + 403, + ); + } + const claims = c.get("claims"); + const userId = claims.chitty_id; + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const demoAssets = buildDemoAssets(userId); + + const inserted = await db.transaction(async (tx) => { + const rows = await tx.insert(assets).values(demoAssets).returning(); + // Emit one acquisition timeline event per asset, matching Express + // semantics for asset creates. + for (const r of rows) { + await tx.insert(timelineEvents).values({ + assetId: r.id, + userId, + eventType: "acquisition", + title: `Asset "${r.name}" added to portfolio`, + description: "Seeded via /api/seed-demo (development only)", + eventDate: new Date(), + }); + } + return rows; + }); + + return c.json( + { message: `Created ${inserted.length} demo assets`, assetCount: inserted.length }, + 201, + ); + }); +} + +export const seedRoutes = (() => { + const r = new Hono(); + registerSeedRoutes(r, requireChittyAuth); + return r; +})();