diff --git a/package-lock.json b/package-lock.json index 7374224..50ee78a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "next-themes": "^0.4.6", "openai": "^5.12.0", "openid-client": "^6.6.3", + "postgres": "^3.4.9", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -11484,6 +11485,19 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/postgres-array": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", diff --git a/package.json b/package.json index f5a55c1..03b0e4a 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "next-themes": "^0.4.6", "openai": "^5.12.0", "openid-client": "^6.6.3", + "postgres": "^3.4.9", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/tsconfig.json b/tsconfig.json index a0203ee..893b79c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["client/src/**/*", "shared/**/*", "server/**/*"], + "include": ["client/src/**/*", "shared/**/*", "server/**/*", "worker/src/**/*"], "exclude": ["node_modules", "build", "dist", "**/*.test.ts"], "compilerOptions": { "incremental": true, @@ -14,7 +14,7 @@ "allowImportingTsExtensions": true, "moduleResolution": "bundler", "baseUrl": ".", - "types": ["node", "vite/client"], + "types": ["node", "vite/client", "@cloudflare/workers-types"], "paths": { "@/*": ["./client/src/*"], "@shared/*": ["./shared/*"] diff --git a/worker/__tests__/asset-reads.integration.test.ts b/worker/__tests__/asset-reads.integration.test.ts new file mode 100644 index 0000000..56aa2ed --- /dev/null +++ b/worker/__tests__/asset-reads.integration.test.ts @@ -0,0 +1,304 @@ +// @canon: chittycanon://core/services/chittyassets +// Integration tests for Phase 2a asset read routes. +// Tests call real Neon (ephemeral branch) — NO MOCKS, NO FAKE DATA. +// +// Test seam: uses registerAssetRoutes() with a claims-injecting pass-through +// middleware instead of requireChittyAuth, so we don't need a real JWT +// infrastructure. The data path (Drizzle → Neon) is 100% real. +// +// Per chittycanon://gov/governance#core-types — owner is a Person (P) entity. +// Authority (A), Location (L), Thing (T), Event (E) types are not exercised +// in this asset-read test but the type system enumerates all five. + +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 type { ChittyAuthClaims, Env } from "../src/env"; +import * as schema from "../../shared/schema"; + +// --------------------------------------------------------------------------- +// Test configuration — connection string from env var. +// --------------------------------------------------------------------------- +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.", + ); +} + +// ChittyOS-shaped fixture identities. Person (P) entities, canonical format. +const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5A-1-X"; +const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5B-1-X"; +let ASSET_ID_1: string; +let ASSET_ID_2: string; +let EVIDENCE_ID: string; + +function buildTestApp(claimsOverride: ChittyAuthClaims) { + const stubEnv = { + ENVIRONMENT: "development" as const, + CHITTYAUTH_ISSUER: "https://auth.chitty.cc", + CHITTYAUTH_JWKS_URL: "https://auth.chitty.cc/.well-known/jwks.json", + CHITTYAUTH_AUDIENCE: "chittyassets-api", + CHITTYASSETS_DB: { connectionString: TEST_DB_URL } as unknown as Hyperdrive, + } as Env; + + const app = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + + app.use("*", async (c, next) => { + Object.defineProperty(c, "env", { + get: () => stubEnv, + configurable: true, + }); + c.set("claims", claimsOverride); + await next(); + }); + + const apiApp = new Hono<{ + Bindings: Env; + Variables: { claims: ChittyAuthClaims }; + }>(); + registerAssetRoutes(apiApp, async (_c, next) => { + await next(); + }); + app.route("/api", apiApp); + + app.onError((err, c) => { + // eslint-disable-next-line no-console + console.error("test_error", err.message, err.stack); + return c.json({ error: "internal_error", detail: err.message }, 500); + }); + + return app; +} + +function ownerClaims(): ChittyAuthClaims { + const now = Math.floor(Date.now() / 1000); + return { + iss: "https://auth.chitty.cc", + sub: OWNER_CHITTY_ID, + chitty_id: OWNER_CHITTY_ID, + entity_type: "P", + trust_level: 3, + exp: now + 3600, + iat: now, + email: "owner.test@chitty.cc", + }; +} + +let sql: ReturnType; +let db: ReturnType; + +beforeAll(async () => { + sql = postgres(TEST_DB_URL!, { ssl: "require", max: 1 }); + db = drizzle(sql, { schema }); + + await db + .insert(schema.users) + .values({ + id: OWNER_CHITTY_ID, + chittyId: OWNER_CHITTY_ID, + email: "owner.test@chitty.cc", + firstName: "Test", + lastName: "Owner", + }) + .onConflictDoNothing(); + + const [a1] = await db + .insert(schema.assets) + .values({ + userId: OWNER_CHITTY_ID, + name: "Artisan Pocket Watch — 1921 Patek Philippe", + assetType: "jewelry", + status: "active", + currentValue: "42500.00", + trustScore: "4.2", + verificationStatus: "verified", + chittyChainStatus: "minted", + }) + .returning(); + ASSET_ID_1 = a1.id; + + const [a2] = await db + .insert(schema.assets) + .values({ + userId: OWNER_CHITTY_ID, + name: "MacBook Pro M3 Max — Serial CK3T4P8XQ1", + assetType: "electronics", + status: "active", + currentValue: "3200.00", + trustScore: "3.8", + verificationStatus: "pending", + chittyChainStatus: "draft", + }) + .returning(); + ASSET_ID_2 = a2.id; + + const [ev] = await db + .insert(schema.evidence) + .values({ + assetId: ASSET_ID_1, + userId: OWNER_CHITTY_ID, + name: "Patek Philippe Certificate of Authenticity", + evidenceType: "contract", + verificationStatus: "verified", + }) + .returning(); + EVIDENCE_ID = ev.id; + + await db.insert(schema.timelineEvents).values({ + assetId: ASSET_ID_1, + userId: OWNER_CHITTY_ID, + eventType: "acquisition", + title: "Watch acquired at Christie's auction — Lot 284", + description: "Acquired via Christie's Geneva auction. Provenance verified.", + eventDate: new Date("2024-11-15T10:30:00Z"), + }); +}); + +afterAll(async () => { + if (ASSET_ID_1) { + await sql`DELETE FROM timeline_events WHERE asset_id = ${ASSET_ID_1}`; + await sql`DELETE FROM evidence WHERE asset_id = ${ASSET_ID_1}`; + } + if (ASSET_ID_2) { + await sql`DELETE FROM assets WHERE id = ${ASSET_ID_2}`; + } + if (ASSET_ID_1) { + await sql`DELETE FROM assets WHERE id = ${ASSET_ID_1}`; + } + await sql`DELETE FROM users WHERE id = ${OWNER_CHITTY_ID}`; + await sql.end(); +}); + +describe("GET /api/assets", () => { + it("200 — returns owner's assets", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets"); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + expect(body.every((a: any) => a.userId === OWNER_CHITTY_ID)).toBe(true); + }); + + it("200 — filter by assetType=jewelry returns only jewelry", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets?type=jewelry"); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body.every((a: any) => a.assetType === "jewelry")).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + }); + + it("200 — intruder sees empty list (ownership filter)", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request("/api/assets"); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body.length).toBe(0); + }); +}); + +describe("GET /api/assets/stats", () => { + it("200 — returns correct aggregate shape", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/stats"); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(typeof body.totalAssets).toBe("number"); + expect(body.totalAssets).toBeGreaterThanOrEqual(2); + expect(typeof body.totalValue).toBe("number"); + expect(typeof body.verifiedAssets).toBe("number"); + expect(typeof body.averageTrustScore).toBe("number"); + expect(body.assetsByType).toHaveProperty("jewelry"); + expect(body.assetsByType).toHaveProperty("electronics"); + }); +}); + +describe("GET /api/assets/:id", () => { + it("200 — returns asset for owner", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${ASSET_ID_1}`); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.id).toBe(ASSET_ID_1); + expect(body.userId).toBe(OWNER_CHITTY_ID); + }); + + it("404 — intruder cannot see owner's asset", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request(`/api/assets/${ASSET_ID_1}`); + expect(res.status).toBe(404); + }); + + it("400 — bad UUID format returns 400", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/not-a-uuid"); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/assets/:assetId/evidence", () => { + it("200 — returns evidence list for owner's asset", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${ASSET_ID_1}/evidence`); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body[0].id).toBe(EVIDENCE_ID); + }); + + it("200 — empty array for intruder query (no existence leak)", async () => { + const intruder: ChittyAuthClaims = { + ...ownerClaims(), + sub: INTRUDER_CHITTY_ID, + chitty_id: INTRUDER_CHITTY_ID, + }; + const app = buildTestApp(intruder); + const res = await app.request(`/api/assets/${ASSET_ID_1}/evidence`); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body.length).toBe(0); + }); + + it("400 — bad assetId returns 400", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/not-a-uuid/evidence"); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/assets/:assetId/timeline", () => { + it("200 — returns timeline events for owner's asset", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request(`/api/assets/${ASSET_ID_1}/timeline`); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body[0].assetId).toBe(ASSET_ID_1); + expect(body[0].eventType).toBe("acquisition"); + }); + + it("400 — bad assetId returns 400", async () => { + const app = buildTestApp(ownerClaims()); + const res = await app.request("/api/assets/bad-id/timeline"); + expect(res.status).toBe(400); + }); +}); diff --git a/worker/src/db.ts b/worker/src/db.ts new file mode 100644 index 0000000..05b506d --- /dev/null +++ b/worker/src/db.ts @@ -0,0 +1,29 @@ +// @canon: chittycanon://core/services/chittyassets +// Drizzle ORM factory for Cloudflare Workers + Hyperdrive. +// +// Per-request instantiation — do NOT cache at module scope. Hyperdrive pools +// connections externally; each Worker invocation gets a proxied connection string. +// +// Adapter: drizzle-orm/postgres-js (postgres.js) — Cloudflare-recommended for Hyperdrive. + +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import * as schema from "@shared/schema"; + +export type ChittyAssetsDb = ReturnType; + +/** + * Create a Drizzle client for a single Worker invocation. + * Pass `env.CHITTYASSETS_DB.connectionString` in production, + * or a direct Neon connection string in integration tests. + */ +export function getDb(connectionString: string) { + const sql = postgres(connectionString, { + // max=1: Hyperdrive pools externally; one connection per isolate invocation. + max: 1, + idle_timeout: 20, + ssl: "require", + connection: { application_name: "chittyassets-worker" }, + }); + return drizzle(sql, { schema }); +} diff --git a/worker/src/env.ts b/worker/src/env.ts index 9ab9b13..fd7da89 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -19,8 +19,8 @@ export interface Env { CHITTYCONNECT_URL?: string; CHITTYLEDGER_URL?: string; - // Phase 2+ bindings: - // CHITTYASSETS_DB: Hyperdrive; + // Phase 2+ bindings (active): + CHITTYASSETS_DB: Hyperdrive; // EVIDENCE: R2Bucket; // PROCESSED: R2Bucket; // CHITTYCONNECT: Fetcher; diff --git a/worker/src/index.ts b/worker/src/index.ts index 0738100..c26b264 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,12 +1,15 @@ // @canon: chittycanon://core/services/chittyassets // chittyassets Worker entrypoint (Hono). Tier 4 Domain service. -// Phase 1 of Express→Hono migration: skeleton + auth + health. +// Phase 2a: asset read routes ported (GET /api/assets, /api/assets/stats, +// /api/assets/:id, /api/assets/:assetId/evidence, +// /api/assets/:assetId/timeline). import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { ENTITY_TYPES, type ChittyAuthClaims, type Env } from "./env"; import { requireChittyAuth } from "./auth"; +import { assetRoutes } from "./routes/assets"; type Variables = { claims: ChittyAuthClaims }; @@ -53,7 +56,14 @@ app.get("/api/v1/status", (c) => canonical_uri: "chittycanon://core/services/chittyassets", version: "1.0.0", environment: c.env.ENVIRONMENT, - migration_status: "MIGRATING_EXPRESS_TO_HONO", + migration_status: "PHASE_2A_ASSET_READS", + migrated_routes: [ + "GET /api/assets", + "GET /api/assets/stats", + "GET /api/assets/:id", + "GET /api/assets/:assetId/evidence", + "GET /api/assets/:assetId/timeline", + ], entity_types_handled: [...ENTITY_TYPES], dependencies: { chittyauth: c.env.CHITTYAUTH_ISSUER, @@ -75,6 +85,9 @@ app.get("/api/auth/user", requireChittyAuth, (c) => { }); }); +// Phase 2a asset read routes — registered BEFORE the 501 catch-all. +app.route("/api", assetRoutes); + // Unmigrated routes return 501 unconditionally — no auth oracle. app.all("/api/*", (c) => c.json( diff --git a/worker/src/routes/assets.ts b/worker/src/routes/assets.ts new file mode 100644 index 0000000..dbeb701 --- /dev/null +++ b/worker/src/routes/assets.ts @@ -0,0 +1,232 @@ +// @canon: chittycanon://core/services/chittyassets +// Asset read routes — Phase 2a of Express→Hono migration. +// +// Ownership key decision (documented for PR review): +// Express (Replit Auth era): assets.userId === "chitty_" + req.auth.userId +// Hono (ChittyAuth era): assets.userId === claims.chitty_id (canonical VV-G-LLL-SSSS-P-YM-C-X) +// This is an intentional semantic alignment. ChittyAuth tokens carry chitty_id directly; +// the "chitty_" prefix was a Replit Auth workaround. Seed data and prod data must use +// the canonical chitty_id as assets.user_id. +// +// Per chittycanon://gov/governance#core-types — entities are one of P/L/T/E/A. +// Users in this service are Person (P) entities. +// +// Routes ported (GET only — read-only Phase 2a): +// GET /api/assets server/routes.ts:215 +// GET /api/assets/stats server/routes.ts:234 +// GET /api/assets/:id server/routes.ts:245 +// GET /api/assets/:assetId/evidence server/routes.ts:335 +// GET /api/assets/:assetId/timeline server/routes.ts:441 + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { eq, and, desc, gte, lte, sql } from "drizzle-orm"; +import { assets, evidence, 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 }; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidId(id: string): boolean { + return UUID_RE.test(id); +} + +/** + * Register all 5 asset read routes on the given Hono app. + * Accepts an auth middleware so integration tests can inject claims directly. + */ +export function registerAssetRoutes( + app: Hono, + authMiddleware: MiddlewareHandler, +) { + // ----------------------------------------------------------------- + // GET /api/assets — list user's assets with optional filters + // Mirrors storage.ts:getUserAssets (130-170) + // ----------------------------------------------------------------- + app.get("/assets", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + + const assetType = c.req.query("type"); + const status = c.req.query("status"); + const searchTerm = c.req.query("search"); + const minValueRaw = c.req.query("minValue"); + const maxValueRaw = c.req.query("maxValue"); + const minValue = minValueRaw ? parseFloat(minValueRaw) : undefined; + const maxValue = maxValueRaw ? parseFloat(maxValueRaw) : undefined; + + const conditions = [eq(assets.userId, userId)]; + + if (assetType) { + conditions.push(eq(assets.assetType, assetType as any)); + } + if (status) { + conditions.push(eq(assets.status, status as any)); + } + if (searchTerm) { + conditions.push( + sql`(${assets.name} ILIKE ${`%${searchTerm}%`} OR ${assets.description} ILIKE ${`%${searchTerm}%`})`, + ); + } + if (minValue !== undefined && !isNaN(minValue)) { + conditions.push(gte(assets.currentValue, minValue.toString())); + } + if (maxValue !== undefined && !isNaN(maxValue)) { + conditions.push(lte(assets.currentValue, maxValue.toString())); + } + + const rows = await db + .select() + .from(assets) + .where(and(...conditions)) + .orderBy(desc(assets.createdAt)); + + return c.json(rows); + }); + + // ----------------------------------------------------------------- + // GET /api/assets/stats — aggregated counts/values + // Mirrors storage.ts:getAssetStats (185-213) + // Registered BEFORE /:id so Hono router does not match "stats" as :id. + // ----------------------------------------------------------------- + app.get("/assets/stats", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + + const userAssets = await db + .select() + .from(assets) + .where(eq(assets.userId, userId)); + + const totalAssets = userAssets.length; + const totalValue = userAssets.reduce( + (sum, a) => sum + (a.currentValue ? parseFloat(a.currentValue) : 0), + 0, + ); + const verifiedAssets = userAssets.filter( + (a) => a.verificationStatus === "verified", + ).length; + const averageTrustScore = + userAssets.reduce( + (sum, a) => sum + (a.trustScore ? parseFloat(a.trustScore) : 0), + 0, + ) / (totalAssets || 1); + + const assetsByType: Record = {}; + const assetsByStatus: Record = {}; + for (const a of userAssets) { + assetsByType[a.assetType] = (assetsByType[a.assetType] ?? 0) + 1; + assetsByStatus[a.status ?? "unknown"] = + (assetsByStatus[a.status ?? "unknown"] ?? 0) + 1; + } + + return c.json({ + totalAssets, + totalValue, + verifiedAssets, + averageTrustScore, + assetsByType, + assetsByStatus, + }); + }); + + // ----------------------------------------------------------------- + // GET /api/assets/:id — single asset, ownership-checked + // 400 on bad UUID; 404 on not-found or ownership mismatch (no existence leak). + // Mirrors storage.ts:getAsset (122-128) + // ----------------------------------------------------------------- + app.get("/assets/:id", 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); + } + return c.json(asset); + }); + + // ----------------------------------------------------------------- + // GET /api/assets/:assetId/evidence — evidence list for asset + // Mirrors storage.ts:getAssetEvidence (229-235) + // ----------------------------------------------------------------- + app.get("/assets/:assetId/evidence", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { assetId } = c.req.param(); + + if (!isValidId(assetId)) { + return c.json( + { error: "invalid_id", message: "Asset ID must be a valid UUID" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const rows = await db + .select() + .from(evidence) + .where(and(eq(evidence.assetId, assetId), eq(evidence.userId, userId))) + .orderBy(desc(evidence.createdAt)); + + return c.json(rows); + }); + + // ----------------------------------------------------------------- + // GET /api/assets/:assetId/timeline — timeline events for asset + // Mirrors storage.ts:getAssetTimeline (256-262) + // ----------------------------------------------------------------- + app.get("/assets/:assetId/timeline", authMiddleware, async (c) => { + const claims = c.get("claims"); + const userId = claims.chitty_id; + const { assetId } = c.req.param(); + + if (!isValidId(assetId)) { + return c.json( + { error: "invalid_id", message: "Asset ID must be a valid UUID" }, + 400, + ); + } + + const db = getDb(c.env.CHITTYASSETS_DB.connectionString); + const rows = await db + .select() + .from(timelineEvents) + .where( + and( + eq(timelineEvents.assetId, assetId), + eq(timelineEvents.userId, userId), + ), + ) + .orderBy(desc(timelineEvents.eventDate)); + + return c.json(rows); + }); +} + +// Production sub-app with real auth middleware. +export const assetRoutes = (() => { + const r = new Hono(); + registerAssetRoutes(r, requireChittyAuth); + return r; +})();