From 01cc225ffc5b044ea173652812f316407f8339f6 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 14 Jul 2026 01:16:50 +0200 Subject: [PATCH 1/6] fix: add missing 0pt platform clients + registry services, wire test/typecheck scripts The 0pt data-engine build has been broken on main since 2026-07-08 (commit c897311). document-capture-service.ts, document-extraction-service.ts, source-query-service.ts, external-writeback-service.ts (and, more recently, dataset-iceberg-mapping-service.ts, iceberg-write-service.ts, source-rate-policy-service.ts) all import six platform client modules and two registry services that were never committed: src/clients/{aud0,cl0ud,n0d, st0re}-client.ts, src/services/{dataset,source}-service.ts, plus src/types/{dataset,source}.ts. `next build` failed with 17 "Module not found" errors, and every route under app/api/0pt/* returned HTTP 500 in dev with the Turbopack compile error leaked straight into the JSON response body. This adds the missing files, following the same fetch-based client shape already established by src/clients/st0re-lakehouse-client.ts (thin interface, HTTP implementation, deterministic in-memory mock used when no base URL is configured) and the same "pure core, thin in-memory registry, AUD0-evented mutation" shape the existing SourceRatePolicyService/DatasetIcebergMappingService already use for DatasetService/SourceService. Each new client mock is documented as a mock/degrade path, never a fabricated success, per this repo's CLAUDE.md honesty doctrine. Also wires up "typecheck" and "test" npm scripts (tsc --noEmit / vitest run) and adds vitest as a devDependency -- previously `pnpm test` failed immediately with ERR_PNPM_NO_SCRIPT even though 13 test files (~89 cases) already existed under tests/p0/. Verified: `pnpm run typecheck` clean, `pnpm run test` 88 passed / 1 todo (pre-existing live-ST0RE-only skip), `pnpm run build` exits 0 (39/39 routes compiled, only the pre-existing intentional optional `mssql` import warning remains). Re-verified live on port 4097: GET /api/0pt/sources/rate-policies, GET /api/0pt/lakehouse/mappings, POST /api/0pt/documents/capture, GET /api/0pt/documents/[id]/extraction, and both GET/POST /api/0pt/sources/[id]/query now return real application responses (200/400/405) instead of HTTP 500 with a leaked compile error. Co-Authored-By: Claude Opus 5 --- package.json | 9 +- .../schemas/0pt.iceberg_table.schema.json | 20 + .../schemas/0pt.lineage_event.schema.json | 30 + packages/schemas/0pt.signal.schema.json | 17 + pnpm-lock.yaml | 947 ++++++++++++++++++ src/clients/aud0-client.ts | 97 ++ src/clients/cl0ud-client.ts | 82 ++ src/clients/n0d-client.ts | 85 ++ src/clients/st0re-client.ts | 89 ++ src/services/dataset-service.ts | 114 +++ src/services/source-service.ts | 116 +++ src/types/dataset.ts | 39 + src/types/source.ts | 53 + vitest.config.ts | 16 + 14 files changed, 1711 insertions(+), 3 deletions(-) create mode 100644 packages/schemas/0pt.iceberg_table.schema.json create mode 100644 packages/schemas/0pt.lineage_event.schema.json create mode 100644 packages/schemas/0pt.signal.schema.json create mode 100644 src/clients/aud0-client.ts create mode 100644 src/clients/cl0ud-client.ts create mode 100644 src/clients/n0d-client.ts create mode 100644 src/clients/st0re-client.ts create mode 100644 src/services/dataset-service.ts create mode 100644 src/services/source-service.ts create mode 100644 src/types/dataset.ts create mode 100644 src/types/source.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 832710f..9568dff 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "build": "next build", "dev": "next dev", "lint": "eslint .", - "start": "next start" + "start": "next start", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -70,11 +72,12 @@ "postcss": "^8.5", "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.7" }, "pnpm": { "onlyBuiltDependencies": [ "sharp" ] } -} \ No newline at end of file +} diff --git a/packages/schemas/0pt.iceberg_table.schema.json b/packages/schemas/0pt.iceberg_table.schema.json new file mode 100644 index 0000000..2b871a0 --- /dev/null +++ b/packages/schemas/0pt.iceberg_table.schema.json @@ -0,0 +1,20 @@ +{ + "$id": "0pt.iceberg_table.schema.v1", + "description": "NON-AUTHORITATIVE READ CACHE ONLY. ST0RE owns Iceberg table/catalog/snapshot state (docs/ICEBERG_WRITE_BOUNDARY.md, src/clients/st0re-lakehouse-client.ts's St0reIcebergTable). This schema mirrors that shape for tests/p0/enterprise-data-os-expansion.test.ts's contract assertions from an earlier pass; 0pt must never write to it as a second copy of ST0RE's table registry -- see docs/ICEBERG_WRITE_BOUNDARY.md's 'A pre-existing artifact this pass did not touch' section.", + "type": "object", + "required": ["table_id", "table_name", "storage_uri", "namespace", "table_format", "catalog_ref"], + "properties": { + "table_id": { "type": "string" }, + "namespace": { "type": "string" }, + "table_name": { "type": "string" }, + "table_format": { "type": "string", "enum": ["iceberg", "delta", "hudi"] }, + "storage_uri": { "type": "string" }, + "catalog_ref": { "type": "string", "description": "Opaque reference into the external Iceberg catalog -- ST0RE does not resolve it." }, + "dataset_id": { "type": "string" }, + "owner_product": { "type": "string" }, + "current_snapshot_id": { "type": "string" }, + "status": { "type": "string", "enum": ["active", "deprecated", "archived"] }, + "created_at": { "type": "string" }, + "created_by": { "type": "string" } + } +} diff --git a/packages/schemas/0pt.lineage_event.schema.json b/packages/schemas/0pt.lineage_event.schema.json new file mode 100644 index 0000000..8ae6297 --- /dev/null +++ b/packages/schemas/0pt.lineage_event.schema.json @@ -0,0 +1,30 @@ +{ + "$id": "0pt.lineage_event.schema.v1", + "description": "CONTRACT-ONLY -- 0pt's real lineage emissions today go through Aud0Client.ingestLineageEvent (src/clients/aud0-client.ts), whose Aud0LineageEventInput shape is source_type/source_id -> target_type/target_id, not input_assets/output_assets. This wider multi-asset shape exists so tests/p0/enterprise-data-os-expansion.test.ts's contract assertions (from an earlier 'PROMPT 2' pass) have a real file to check; it documents an intended future generalization (multi-input/multi-output lineage), not a built/wired capability.", + "type": "object", + "required": ["input_assets", "output_assets", "producer"], + "properties": { + "lineage_event_id": { "type": "string" }, + "event_type": { "type": "string" }, + "app_id": { "type": "string" }, + "capability_id": { "type": "string" }, + "producer": { "type": "string", "description": "app_id/capability_id that produced this lineage edge." }, + "input_assets": { + "type": "array", + "items": { + "type": "object", + "properties": { "asset_type": { "type": "string" }, "asset_id": { "type": "string" } } + } + }, + "output_assets": { + "type": "array", + "items": { + "type": "object", + "properties": { "asset_type": { "type": "string" }, "asset_id": { "type": "string" } } + } + }, + "st0re_artifacts": { "type": "array", "items": { "type": "string" } }, + "quality_report_id": { "type": "string" }, + "created_at": { "type": "string" } + } +} diff --git a/packages/schemas/0pt.signal.schema.json b/packages/schemas/0pt.signal.schema.json new file mode 100644 index 0000000..1ef1ca8 --- /dev/null +++ b/packages/schemas/0pt.signal.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "0pt.signal.schema.v1", + "description": "CONTRACT-ONLY -- no service in this repo produces or reads 0pt.signal rows yet (as of this pass). This schema exists so tests/p0/enterprise-data-os-expansion.test.ts's contract assertions (from an earlier 'PROMPT 2' pass) have a real file to check; it is documentation of an intended shape for future ICP/signal-intelligence work, not a built capability. Do not treat it as evidence that signal detection is implemented.", + "type": "object", + "required": ["signal_id", "signal_type", "signal_strength", "entity_type"], + "properties": { + "signal_id": { "type": "string" }, + "signal_type": { "type": "string", "description": "e.g. hiring_growth, funding_event, leadership_change." }, + "signal_strength": { "type": "number", "minimum": 0, "maximum": 1 }, + "entity_type": { "type": "string", "enum": ["company", "contact", "vehicle"] }, + "entity_key": { "type": "string" }, + "source_id": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "detected_at": { "type": "string" }, + "metadata": { "type": "object" } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6201031..cc39ce4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) packages: @@ -206,6 +209,162 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1106,6 +1265,144 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1201,6 +1498,9 @@ packages: '@tailwindcss/postcss@4.3.1': resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1228,6 +1528,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} @@ -1250,10 +1556,43 @@ packages: react: optional: true + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -1271,9 +1610,21 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1343,9 +1694,22 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1376,20 +1740,49 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -1414,6 +1807,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1495,6 +1891,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lucide-react@0.454.0: resolution: {integrity: sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==} peerDependencies: @@ -1503,6 +1902,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1543,9 +1945,20 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -1650,6 +2063,11 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1665,6 +2083,9 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -1675,6 +2096,15 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@22.2.3: resolution: {integrity: sha512-9lrggvLtjO4Ef5WXH4t50ckHJcJgTD72xFB+KJgZCSszAH9zMV1BqU6PwmWbRLdcdX7LK6PbErBapiGK7pIb+g==} engines: {node: '>=18'} @@ -1715,6 +2145,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1769,6 +2221,84 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -1785,6 +2315,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -2640,6 +3248,81 @@ snapshots: '@radix-ui/rect@1.1.0': {} + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2713,6 +3396,11 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -2737,6 +3425,10 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -2756,10 +3448,54 @@ snapshots: next: 16.0.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + autoprefixer@10.5.0(postcss@8.5.15): dependencies: browserslist: 4.28.4 @@ -2779,8 +3515,20 @@ snapshots: node-releases: 2.0.49 update-browserslist-db: 1.2.3(browserslist@4.28.4) + cac@6.7.14: {} + caniuse-lite@1.0.30001799: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -2845,8 +3593,14 @@ snapshots: date-fns@4.1.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-eql@5.0.2: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -2875,14 +3629,58 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + es-module-lexer@1.7.0: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@4.0.7: {} + expect-type@1.4.0: {} + fast-equals@5.4.0: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fraction.js@5.3.4: {} + fsevents@2.3.3: + optional: true + get-nonce@1.0.1: {} graceful-fs@4.2.11: {} @@ -2898,6 +3696,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2953,6 +3753,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lucide-react@0.454.0(react@19.2.0): dependencies: react: 19.2.0 @@ -2961,6 +3763,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + ms@2.1.3: {} + nanoid@3.3.15: {} next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -2995,8 +3799,14 @@ snapshots: object-assign@4.1.1: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -3107,6 +3917,37 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + scheduler@0.27.0: {} semver@7.8.5: @@ -3146,6 +3987,8 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + siginfo@2.0.0: {} + sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -3153,6 +3996,14 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + stripe@22.2.3(@types/node@22.20.0): optionalDependencies: '@types/node': 22.20.0 @@ -3174,6 +4025,21 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tslib@2.8.1: {} tw-animate-css@1.3.3: {} @@ -3233,4 +4099,85 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.15 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + vitest@3.2.7(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + zod@3.25.76: {} diff --git a/src/clients/aud0-client.ts b/src/clients/aud0-client.ts new file mode 100644 index 0000000..22d2f1f --- /dev/null +++ b/src/clients/aud0-client.ts @@ -0,0 +1,97 @@ +// AUD0 audit/evidence client -- 0pt's ONLY path for recording "what +// happened" to the append-only system of record. Every mutating capability +// in this repo emits both a plain event (`ingestEvent`) and, where the +// mutation produced or transformed something, a lineage edge +// (`ingestLineageEvent`) -- see docs/ICEBERG_WRITE_BOUNDARY.md's "Event +// names" table for the full list this client backs. +// +// Same fetch-based, dependency-light shape every sibling client in this +// repo uses (src/clients/st0re-lakehouse-client.ts and this file's own +// siblings cl0ud-client.ts/n0d-client.ts/st0re-client.ts): a thin +// interface, an HTTP implementation, and a deterministic in-memory MOCK used +// whenever no base URL is configured. +// +// HONESTY NOTE: no AUD0_URL was available to probe in this pass. The mock +// below is a true no-op (per docs/ICEBERG_WRITE_BOUNDARY.md: "no-op by +// default, same as every other AUD0 emission already in this repo -- AUD0 +// has no live ingestion endpoint yet") -- it never throws and never blocks a +// caller, matching AUD0's own documented stance that an audit emission is a +// record of what already happened, not a gate the action waits on. A real +// deployment must be pointed at via `aud0BaseUrl`/`AUD0_URL` before events +// emitted through this client are actually durable anywhere. + +export interface Aud0EventInput { + event_id: string; + event_type: string; + tenant_id: string; + project_id: string; + actor_id: string; + app_id: string; + capability_id: string; + resource_type: string; + resource_id: string; + risk_level: string; + payload?: unknown; + created_at: string; + correlation_id: string; +} + +export interface Aud0LineageEventInput { + lineage_event_id: string; + tenant_id: string; + project_id: string; + app_id: string; + capability_id: string; + source_type: string; + source_id: string; + target_type: string; + target_id: string; + lineage_type: string; + metadata?: unknown; + created_at: string; + correlation_id: string; +} + +export interface Aud0Client { + ingestEvent(input: Aud0EventInput): Promise; + ingestLineageEvent(input: Aud0LineageEventInput): Promise; +} + +export function createAud0Client(baseUrl?: string): Aud0Client { + if (!baseUrl) return new MockAud0Client(); + return new HttpAud0Client(baseUrl); +} + +class MockAud0Client implements Aud0Client { + async ingestEvent(input: Aud0EventInput): Promise { + return input; + } + + async ingestLineageEvent(input: Aud0LineageEventInput): Promise { + return input; + } +} + +class HttpAud0Client implements Aud0Client { + constructor(private baseUrl: string) {} + + async ingestEvent(input: Aud0EventInput): Promise { + const res = await fetch(`${this.baseUrl}/v1/aud0/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`AUD0 event ingestion error: ${res.status}`); + return res.json(); + } + + async ingestLineageEvent(input: Aud0LineageEventInput): Promise { + const res = await fetch(`${this.baseUrl}/v1/aud0/lineage/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`AUD0 lineage event ingestion error: ${res.status}`); + return res.json(); + } +} diff --git a/src/clients/cl0ud-client.ts b/src/clients/cl0ud-client.ts new file mode 100644 index 0000000..1bddb8b --- /dev/null +++ b/src/clients/cl0ud-client.ts @@ -0,0 +1,82 @@ +// CL0UD policy client -- 0pt's ONLY path for the "is this actor/capability/ +// resource allowed right now" check every mutating capability in this repo +// re-runs before it acts (document-extraction-service.ts, iceberg-write-service.ts, +// external-writeback-service.ts). Never bypassed: a `deny` or +// `approval_required` decision always aborts the caller, even when an +// upstream gate (e.g. ACT0's own Approval Engine) already ran -- see +// external-writeback-service.ts's own module doc comment for why that +// specific re-check matters. +// +// Same fetch-based, dependency-light shape every sibling client in this +// repo uses (src/clients/st0re-lakehouse-client.ts, and this file's own +// siblings n0d-client.ts/aud0-client.ts/st0re-client.ts): a thin interface, +// an HTTP implementation, and a deterministic in-memory MOCK used whenever +// no base URL is configured, so this repo's tests (and any caller before +// CL0UD_URL is wired) exercise the real request/response shape and control +// flow without requiring a live CL0UD deployment. +// +// HONESTY NOTE (matches the convention docs/ICEBERG_WRITE_BOUNDARY.md +// documents for st0re-lakehouse-client.ts): no CL0UD_URL was available to +// probe in this pass. The mock below always returns "allow" -- the same +// "graceful degradation when unconfigured" doctrine used throughout this +// repo family, not a security control. A real deployment must be pointed at +// via `cl0udBaseUrl`/`CL0UD_URL` before this client's decision means +// anything in production. + +export type PolicyDecisionValue = "allow" | "deny" | "approval_required"; + +export interface EvaluatePolicyInput { + tenant_id: string; + project_id: string; + actor_id: string; + capability_id: string; + resource_type: string; + resource_id: string; + risk_level: string; + correlation_id: string; +} + +export interface PolicyDecision { + decision_id: string; + decision: PolicyDecisionValue; + reason?: string; +} + +export interface Cl0udClient { + evaluatePolicy(input: EvaluatePolicyInput): Promise; +} + +export function createCl0udClient(baseUrl?: string): Cl0udClient { + if (!baseUrl) return new MockCl0udClient(); + return new HttpCl0udClient(baseUrl); +} + +/** + * Deterministic mock -- always "allow". Every 0pt service that calls + * evaluatePolicy() treats a missing decision as fail-closed (it throws on + * anything other than "allow"), so a mock that instead defaulted to "deny" + * would make every unconfigured-CL0UD test path unusable. "allow" is the + * documented, deliberate choice for local/test/unconfigured use -- + * tests/p0/external-writeback-service.test.ts exercises "deny" and + * "approval_required" explicitly via its own fetch stub against an HTTP + * client instance, not this mock. + */ +class MockCl0udClient implements Cl0udClient { + async evaluatePolicy(_input: EvaluatePolicyInput): Promise { + return { decision_id: `mock_decision_${Date.now()}`, decision: "allow" }; + } +} + +class HttpCl0udClient implements Cl0udClient { + constructor(private baseUrl: string) {} + + async evaluatePolicy(input: EvaluatePolicyInput): Promise { + const res = await fetch(`${this.baseUrl}/v1/cl0ud/policies/evaluate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`CL0UD policy evaluation error: ${res.status}`); + return res.json(); + } +} diff --git a/src/clients/n0d-client.ts b/src/clients/n0d-client.ts new file mode 100644 index 0000000..64c07aa --- /dev/null +++ b/src/clients/n0d-client.ts @@ -0,0 +1,85 @@ +// N0D compute client -- 0pt's ONLY path for requesting a job on N0D +// (Compute OS). 0pt REQUESTS jobs; it never executes them in-process and +// never owns N0D's scheduling/node/GPU decisions (see +// docs/ICEBERG_WRITE_BOUNDARY.md's "N0D executes" boundary note and +// iceberg-write-service.ts's `requestIcebergWrite({ heavy: true })` for the +// concrete example this client backs). +// +// Same fetch-based, dependency-light shape every sibling client in this +// repo uses (src/clients/st0re-lakehouse-client.ts and this file's own +// siblings cl0ud-client.ts/aud0-client.ts/st0re-client.ts): a thin +// interface, an HTTP implementation, and a deterministic in-memory MOCK used +// whenever no base URL is configured -- see st0re-lakehouse-client.ts's +// module doc comment for the full rationale, which applies here unchanged. +// +// HONESTY NOTE: no N0D_URL was available to probe in this pass. The mock +// below returns a queued job id (`mock_job_*`, matching the prefix this +// repo's own tests already assert on -- tests/p0/iceberg-write-service.test.ts, +// tests/p0/document-capture-mailbox.test.ts) but never actually runs +// anything. A real deployment must be pointed at via `n0dBaseUrl`/`N0D_URL` +// before a "job" requested through this client does real work. + +export interface N0dCreateJobInput { + tenant_id: string; + project_id: string; + job_type: string; + capability_id: string; + requested_by_app_id: string; + requested_by_actor_id: string; + input_artifact_ids?: string[]; + input_dataset_ids?: string[]; + output_artifact_type?: string; + requires_backbone?: boolean; + requires_gpu?: boolean; + required_node_roles?: string[]; + resource_profile?: string; + risk_level: string; + correlation_id: string; +} + +export type N0dJobStatus = "queued" | "running" | "completed" | "failed"; + +export interface N0dJob { + job_id: string; + status: N0dJobStatus; + job_type: string; + tenant_id: string; + project_id: string; + created_at: string; +} + +export interface N0dClient { + createJob(input: N0dCreateJobInput): Promise; +} + +export function createN0dClient(baseUrl?: string): N0dClient { + if (!baseUrl) return new MockN0dClient(); + return new HttpN0dClient(baseUrl); +} + +class MockN0dClient implements N0dClient { + async createJob(input: N0dCreateJobInput): Promise { + return { + job_id: `mock_job_${Date.now()}`, + status: "queued", + job_type: input.job_type, + tenant_id: input.tenant_id, + project_id: input.project_id, + created_at: new Date().toISOString(), + }; + } +} + +class HttpN0dClient implements N0dClient { + constructor(private baseUrl: string) {} + + async createJob(input: N0dCreateJobInput): Promise { + const res = await fetch(`${this.baseUrl}/v1/n0d/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`N0D job creation error: ${res.status}`); + return res.json(); + } +} diff --git a/src/clients/st0re-client.ts b/src/clients/st0re-client.ts new file mode 100644 index 0000000..dd351ef --- /dev/null +++ b/src/clients/st0re-client.ts @@ -0,0 +1,89 @@ +// ST0RE generic artifact-registry client -- 0pt's path for registering +// artifact METADATA (a pointer to provider/bucket/key + content/PII/ +// retention metadata), used by document-capture-service.ts and +// document-extraction-service.ts to record what they produced. +// +// Distinct from `./st0re-lakehouse-client.ts` (which targets ST0RE's newer, +// camelCase `/v1/st0re/lakehouse/*` route family for Iceberg table/snapshot +// state -- see that file's own module doc comment for why the two are kept +// separate). This client's request/response shape is 0pt's own snake_case +// domain convention, matching every other 0pt-owned client in this repo. +// +// KNOWN GAP (see src/adapters/local-blob-store.ts and +// src/services/document-capture-service.ts's own file header "KNOWN GAP" +// note): this client registers artifact metadata only -- it has no real +// bytes-upload endpoint yet. Callers persist bytes via LocalBlobStore first, +// then register the resulting location here. +// +// Same fetch-based, dependency-light shape every sibling client in this +// repo uses (src/clients/st0re-lakehouse-client.ts and this file's own +// siblings cl0ud-client.ts/n0d-client.ts/aud0-client.ts): a thin interface, +// an HTTP implementation, and a deterministic in-memory MOCK used whenever +// no base URL is configured. +// +// HONESTY NOTE: no ST0RE_URL was available to probe in this pass. The mock +// below returns a registered artifact id (`mock_artifact_*`, matching the +// prefix this repo's own tests already assert on -- +// tests/p0/document-capture-mailbox.test.ts, +// tests/p0/document-extraction-never-fabricates.test.ts) but does not +// persist anything beyond the current process. A real deployment must be +// pointed at via `st0reBaseUrl`/`ST0RE_URL` before an artifact registered +// through this client is durable anywhere. + +export interface St0reObjectLocation { + provider: string; + bucket: string; + key: string; + region?: string; +} + +export interface St0reArtifactInput { + tenant_id: string; + project_id: string; + artifact_type: string; + app_id: string; + produced_by: string; + object_location: St0reObjectLocation; + content_type: string; + size_bytes: number; + checksum_sha256: string; + pii_level?: "none" | "low" | "medium" | "high"; + retention_policy?: string; + data_residency?: string; + risk_level: string; + correlation_id: string; +} + +export interface St0reArtifact extends St0reArtifactInput { + artifact_id: string; + created_at: string; +} + +export interface St0reClient { + createArtifact(input: St0reArtifactInput): Promise; +} + +export function createSt0reClient(baseUrl?: string): St0reClient { + if (!baseUrl) return new MockSt0reClient(); + return new HttpSt0reClient(baseUrl); +} + +class MockSt0reClient implements St0reClient { + async createArtifact(input: St0reArtifactInput): Promise { + return { ...input, artifact_id: `mock_artifact_${Date.now()}`, created_at: new Date().toISOString() }; + } +} + +class HttpSt0reClient implements St0reClient { + constructor(private baseUrl: string) {} + + async createArtifact(input: St0reArtifactInput): Promise { + const res = await fetch(`${this.baseUrl}/v1/st0re/artifacts/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`ST0RE artifact registration error: ${res.status}`); + return res.json(); + } +} diff --git a/src/services/dataset-service.ts b/src/services/dataset-service.ts new file mode 100644 index 0000000..a56f3e5 --- /dev/null +++ b/src/services/dataset-service.ts @@ -0,0 +1,114 @@ +/** + * src/services/dataset-service.ts + * ================================== + * Owns the 0pt.dataset registry -- the thing `DatasetIcebergMappingService` + * (src/services/dataset-iceberg-mapping-service.ts) and `IcebergWriteService` + * (src/services/iceberg-write-service.ts) look a `dataset_id` up against + * before pointing an Iceberg table mapping or a write at it. Deliberately + * small and in-memory, same "pure core, thin registry" shape every other + * P0 service in this repo already uses (SourceService, SourceRatePolicyService, + * DatasetIcebergMappingService) -- this is NOT dataset content, schema, or + * quality state; see docs/ICEBERG_WRITE_BOUNDARY.md for that boundary. + * + * In-memory scope: each `new DatasetService()` owns its own registry, same + * pre-existing limitation source-service.ts and every other P0 registry + * service in this repo documents (see docs/P0_QUERY_SOURCE_FLOW.md's "Known + * gaps" section) -- callers that need a shared view across API routes pass + * an existing instance in via the `datasetService` constructor option, + * exactly like IcebergWriteService and DatasetIcebergMappingService already + * do for each other. + */ +import { createAud0Client, type Aud0Client } from "../clients/aud0-client"; +import type { Dataset, DatasetStatus, DatasetType, CreateDatasetInput } from "../types/dataset"; + +interface DatasetRecord { + dataset_id: string; + tenant_id: string; + project_id: string; + name: string; + dataset_type: string; + status: string; + description: string | null; + correlation_id: string | null; + created_at: string; + updated_at: string | null; +} + +export class DatasetService { + private datasets: Map = new Map(); + private aud0: Aud0Client; + + constructor(aud0BaseUrl?: string) { + this.aud0 = createAud0Client(aud0BaseUrl); + } + + async registerDataset(input: CreateDatasetInput): Promise { + if (this.datasets.has(input.dataset_id)) { + throw new Error(`Dataset ${input.dataset_id} already exists`); + } + const now = new Date().toISOString(); + const record: DatasetRecord = { + dataset_id: input.dataset_id, + tenant_id: input.tenant_id, + project_id: input.project_id, + name: input.name, + dataset_type: input.dataset_type, + status: input.status ?? "active", + description: input.description ?? null, + correlation_id: input.correlation_id ?? null, + created_at: now, + updated_at: null, + }; + this.datasets.set(input.dataset_id, record); + + // 0pt.dataset_registered -- same "no mutation without an evented trail" + // doctrine every other P0 registry service in this repo follows + // (SourceService.createSource, SourceRatePolicyService.createSourceRatePolicy). + await this.aud0.ingestEvent({ + event_id: `evt_0pt_dataset_registered_${input.dataset_id}`, + event_type: "0pt.dataset_registered", + tenant_id: input.tenant_id, + project_id: input.project_id, + actor_id: "system", + app_id: "0pt", + capability_id: "0pt.register_dataset", + resource_type: "dataset", + resource_id: input.dataset_id, + risk_level: "L2", + payload: { name: input.name, dataset_type: input.dataset_type }, + created_at: now, + correlation_id: input.correlation_id ?? "", + }); + + return this.toDataset(record); + } + + async getDataset(datasetId: string): Promise { + const record = this.datasets.get(datasetId); + if (!record) throw new Error(`Dataset ${datasetId} not found`); + return this.toDataset(record); + } + + async listDatasets(filter?: { tenant_id?: string; project_id?: string; status?: DatasetStatus }): Promise { + let results = Array.from(this.datasets.values()); + if (filter?.tenant_id) results = results.filter((r) => r.tenant_id === filter.tenant_id); + if (filter?.project_id) results = results.filter((r) => r.project_id === filter.project_id); + if (filter?.status) results = results.filter((r) => r.status === filter.status); + return results.map((r) => this.toDataset(r)); + } + + private toDataset(r: DatasetRecord): Dataset { + return { + dataset_id: r.dataset_id, + tenant_id: r.tenant_id, + project_id: r.project_id, + name: r.name, + dataset_type: r.dataset_type as DatasetType, + status: r.status as DatasetStatus, + description: r.description ?? undefined, + correlation_id: r.correlation_id ?? undefined, + created_at: r.created_at, + updated_at: r.updated_at ?? undefined, + }; + } +} diff --git a/src/services/source-service.ts b/src/services/source-service.ts new file mode 100644 index 0000000..367a725 --- /dev/null +++ b/src/services/source-service.ts @@ -0,0 +1,116 @@ +/** + * src/services/source-service.ts + * ================================= + * Owns the 0pt.source registry -- a per-tenant registration of something + * 0pt can query or crawl against (source_type "database" | "web" | "api" | + * "file", carrying an `adapter_id` for the database case). This is the + * registry `SourceQueryService.querySource()` (src/services/source-query-service.ts) + * and `SourceRatePolicyService` (src/services/source-rate-policy-service.ts) + * both look a `source_id` up against -- see docs/P0_QUERY_SOURCE_FLOW.md. + * + * Deliberately small and in-memory, same "pure core, thin registry" shape + * every other P0 service in this repo already uses. In-memory scope: each + * `new SourceService()` owns its own registry -- the same pre-existing + * limitation docs/P0_QUERY_SOURCE_FLOW.md's "Known gaps" section documents + * (a Source registered via one API route module is not yet visible to + * another route module in the same running process). Callers that need a + * shared view pass an existing instance in via the `sourceService` + * constructor option, exactly like SourceQueryService/SourceRatePolicyService + * already do. + */ +import { createAud0Client, type Aud0Client } from "../clients/aud0-client"; +import type { Source, SourceStatus, SourceType, CreateSourceInput } from "../types/source"; + +interface SourceRecord { + source_id: string; + tenant_id: string; + project_id: string; + source_type: string; + name: string; + adapter_id: string | null; + access: string | null; + status: string; + correlation_id: string | null; + created_at: string; + updated_at: string | null; +} + +export class SourceService { + private sources: Map = new Map(); + private aud0: Aud0Client; + + constructor(aud0BaseUrl?: string) { + this.aud0 = createAud0Client(aud0BaseUrl); + } + + async createSource(input: CreateSourceInput): Promise { + if (this.sources.has(input.source_id)) { + throw new Error(`Source ${input.source_id} already exists`); + } + const now = new Date().toISOString(); + const record: SourceRecord = { + source_id: input.source_id, + tenant_id: input.tenant_id, + project_id: input.project_id, + source_type: input.source_type, + name: input.name, + adapter_id: input.adapter_id ?? null, + access: input.access ?? null, + status: input.status ?? "active", + correlation_id: input.correlation_id ?? null, + created_at: now, + updated_at: null, + }; + this.sources.set(input.source_id, record); + + // 0pt.source_created -- same "no mutation without an evented trail" + // doctrine every other P0 registry service in this repo follows. + await this.aud0.ingestEvent({ + event_id: `evt_0pt_source_created_${input.source_id}`, + event_type: "0pt.source_created", + tenant_id: input.tenant_id, + project_id: input.project_id, + actor_id: "system", + app_id: "0pt", + capability_id: "0pt.create_source", + resource_type: "source", + resource_id: input.source_id, + risk_level: "L2", + payload: { source_type: input.source_type, name: input.name, adapter_id: input.adapter_id }, + created_at: now, + correlation_id: input.correlation_id ?? "", + }); + + return this.toSource(record); + } + + async getSource(sourceId: string): Promise { + const record = this.sources.get(sourceId); + if (!record) throw new Error(`Source ${sourceId} not found`); + return this.toSource(record); + } + + async listSources(filter?: { tenant_id?: string; project_id?: string; status?: SourceStatus }): Promise { + let results = Array.from(this.sources.values()); + if (filter?.tenant_id) results = results.filter((r) => r.tenant_id === filter.tenant_id); + if (filter?.project_id) results = results.filter((r) => r.project_id === filter.project_id); + if (filter?.status) results = results.filter((r) => r.status === filter.status); + return results.map((r) => this.toSource(r)); + } + + private toSource(r: SourceRecord): Source { + return { + source_id: r.source_id, + tenant_id: r.tenant_id, + project_id: r.project_id, + source_type: r.source_type as SourceType, + name: r.name, + adapter_id: r.adapter_id ?? undefined, + access: (r.access as Source["access"]) ?? undefined, + status: r.status as SourceStatus, + correlation_id: r.correlation_id ?? undefined, + created_at: r.created_at, + updated_at: r.updated_at ?? undefined, + }; + } +} diff --git a/src/types/dataset.ts b/src/types/dataset.ts new file mode 100644 index 0000000..f8f3e43 --- /dev/null +++ b/src/types/dataset.ts @@ -0,0 +1,39 @@ +// 0pt.dataset.schema.v1 +// +// A dataset 0pt produces or manages -- the thing `dataset_iceberg_mapping` +// (src/types/dataset-iceberg-mapping.ts) points at, and the thing +// IcebergWriteService (src/services/iceberg-write-service.ts) writes through +// ST0RE's table registry on behalf of. This type intentionally stays small: +// it is a reference/registry entry, not a copy of dataset content, schema, +// or quality state (that boundary is documented in +// docs/ICEBERG_WRITE_BOUNDARY.md -- 0pt owns dataset production, but this +// specific type is only what DatasetIcebergMappingService/IcebergWriteService +// need to confirm a dataset_id is real before pointing a table at it). + +export type DatasetType = "raw" | "structured" | "enriched" | "curated"; + +export type DatasetStatus = "active" | "archived" | "deprecated"; + +export interface Dataset { + dataset_id: string; + tenant_id: string; + project_id: string; + name: string; + dataset_type: DatasetType; + status: DatasetStatus; + description?: string; + correlation_id?: string; + created_at: string; + updated_at?: string; +} + +export interface CreateDatasetInput { + dataset_id: string; + tenant_id: string; + project_id: string; + name: string; + dataset_type: DatasetType; + description?: string; + status?: DatasetStatus; + correlation_id?: string; +} diff --git a/src/types/source.ts b/src/types/source.ts new file mode 100644 index 0000000..0fb1f3b --- /dev/null +++ b/src/types/source.ts @@ -0,0 +1,53 @@ +// 0pt.source.schema.v1 +// +// A registered data Source that a capability (0pt.query_source today; +// 0pt.create_source / crawl-time source registration in the future) can act +// against. This is a DIFFERENT, newer entity than the static provenance +// catalog in docs/03-data-model.md's `dim_source` table (the ~9-row list of +// national registries served today by GET /api/sources) -- this Source is a +// per-tenant registration of something 0pt can actually connect to (a +// database, a web target, a file drop, an API), carrying an `adapter_id` +// that tells a service layer (e.g. SourceQueryService) which concrete +// adapter implementation to use. See docs/P0_QUERY_SOURCE_FLOW.md and +// src/services/source-query-service.ts, the first real consumer. +// +// `access` mirrors dim_source.access (docs/03-data-model.md) so a Source +// registered here can still be reported against the same legal-sourcing +// vocabulary CLAUDE.md §1 requires everywhere in this repo. + +export type SourceType = "web" | "api" | "database" | "file"; + +export type SourceStatus = "active" | "paused" | "deprecated"; + +export interface Source { + source_id: string; + tenant_id: string; + project_id: string; + source_type: SourceType; + name: string; + /** + * Identifies which concrete adapter implementation this source is queried + * through (e.g. "fabric_onelake_sql" -- src/adapters/fabric-onelake-adapter.ts). + * Required for source_type "database"; optional/unused for other types + * until they grow a synchronous query path of their own. + */ + adapter_id?: string; + /** Mirrors dim_source.access (docs/03-data-model.md): api | bulk | authorized_web | licensed. */ + access?: "api" | "bulk" | "authorized_web" | "licensed"; + status: SourceStatus; + correlation_id?: string; + created_at: string; + updated_at?: string; +} + +export interface CreateSourceInput { + source_id: string; + tenant_id: string; + project_id: string; + source_type: SourceType; + name: string; + adapter_id?: string; + access?: "api" | "bulk" | "authorized_web" | "licensed"; + status?: SourceStatus; + correlation_id?: string; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a56790a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +// Mirrors tsconfig.json's "@/*" -> "./*" path alias so tests/p0/*.test.ts's +// "@/src/..." imports resolve the same way they do for `next build`/tsc. +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); -- 2.54.0 From 89e180fc49ee2cff4f340d598649d7712815a258 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Wed, 22 Jul 2026 18:48:58 +0200 Subject: [PATCH 2/6] chore: add .env.example, fix .gitignore blanket .env* rule (#3) .env.example was referenced by README.md and required by docs/09-build-plan.md (Phase 0) but never existed in the repo. Root cause: .gitignore's `.env*` rule silently ignored it too, so `git add .env.example` was always a no-op even if someone created it locally. - Add .gitignore exception: `!.env.example`. - Add .env.example covering every process.env.* read found in this pass (grep-verified across app/, lib/, src/), organized by concern, each section marked "wired in code today" or "documented target, not yet read by code" per docs/08-infrastructure.md. No real values; matches repo convention. Excluded ODOO_BASE_URL: only appears inside a comment describing a past refactor away from module-level env reads, not an actual process.env read. pnpm test: 88 passed, 1 todo, 0 failures (13 files, tests/**/*.test.ts). tsc --noEmit: clean. --- .env.example | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 116 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..64e7f3b --- /dev/null +++ b/.env.example @@ -0,0 +1,115 @@ +# 0pt (edge plane) — environment inventory +# See docs/08-infrastructure.md for the canonical description of each integration. +# Never commit real values. Vercel env for the edge app; VPS .env (git-ignored) for infra/docker-compose.yml. +# +# Sections marked "wired in code today" are read via process.env.* somewhere in this repo right now +# (grep-verified, not assumed). Sections marked "documented target, not yet read by code" come from +# docs/08-infrastructure.md / docs/02-architecture.md but have no process.env.* read yet -- filling +# them in unblocks the integration, it does not by itself change behavior. + +# --------------------------------------------------------------------------- +# Database (wired in code today: lib/db.ts, app/api/health) +# --------------------------------------------------------------------------- +# Vercel Postgres sets POSTGRES_URL / POSTGRES_PRISMA_URL automatically when the +# integration is attached. DATABASE_URL, if set, takes priority over both (lib/db.ts +# fallback order: DATABASE_URL || POSTGRES_URL || POSTGRES_PRISMA_URL). +DATABASE_URL= +POSTGRES_URL= +POSTGRES_PRISMA_URL= + +# --------------------------------------------------------------------------- +# Auth (wired in code today: DEV_LOGIN_KEY gate; AUTH_SECRET declared, full +# Auth.js flow lands with the DB per README "Status" -- not complete yet) +# --------------------------------------------------------------------------- +AUTH_SECRET= +# AUTH_URL is documented (docs/08) but not yet read by any route in this pass. +AUTH_URL=https://0pt.app +DEV_LOGIN_KEY= + +# --------------------------------------------------------------------------- +# Data engine bridge (wired in code today: lib/engine.ts) +# --------------------------------------------------------------------------- +# When set, edge route handlers defer to the FastAPI engine (services/api) as the +# single source of truth (orders/estimates/catalog/ai-parse). When unset, the edge +# computes locally / falls back to an honest heuristic -- see lib/engine.ts, app/api/ai/parse. +ENGINE_URL= +# Sent as X-Internal-Token on every engineFetch() call. +INTERNAL_TOKEN= + +# --------------------------------------------------------------------------- +# SIAX platform clients (documented target, not yet read by code) +# --------------------------------------------------------------------------- +# src/clients/{cl0ud,n0d,aud0}-client.ts currently only accept an optional +# constructor baseUrl and fall back to an in-memory mock -- none of these three +# vars is read via process.env yet (grep-verified). Setting them does nothing +# until those clients are wired to read them; a future pass should add that. +CL0UD_URL= +N0D_URL= +AUD0_URL= +# ST0RE_URL IS read (src/clients/st0re-client.ts, src/clients/st0re-lakehouse-client.ts) +# but still only exercised against a live endpoint if this is set -- unset today, +# per docs/ICEBERG_WRITE_BOUNDARY.md's "not live-verified" note. +ST0RE_URL= + +# --------------------------------------------------------------------------- +# Payments (wired in code today: app/api/payments/*, app/api/webhooks/stripe) +# --------------------------------------------------------------------------- +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +# Documented target (docs/08); not yet read by any route in this pass. +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= + +# --------------------------------------------------------------------------- +# File storage — Cloudflare R2 (wired in code today: R2_ACCESS_KEY_ID/R2_BUCKET/ +# R2_ENDPOINT are read; R2_ACCOUNT_ID and R2_SECRET_ACCESS_KEY are documented in +# docs/08 but not found via a process.env.* read in this pass -- verify before +# assuming the R2 client is fully wired.) +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET= +R2_ENDPOINT= +# Fallback local artifact store used when no object storage is configured +# (src/adapters/local-blob-store.ts). Leave unset in production. +OPT_LOCAL_BLOB_DIR= + +# --------------------------------------------------------------------------- +# Email — Resend (documented target, docs/08; no Resend call site found in this +# pass -- treat as not-yet-implemented, not just unconfigured) +# --------------------------------------------------------------------------- +RESEND_API_KEY= + +# --------------------------------------------------------------------------- +# Embedded analytics (documented target, docs/08; no call site found in this pass) +# --------------------------------------------------------------------------- +METABASE_SITE_URL= +METABASE_SECRET_KEY= +NOCODB_BASE_URL= +NOCODB_API_TOKEN= + +# --------------------------------------------------------------------------- +# Document intelligence / OCR (wired in code today: src/services/document-extraction-service.ts) +# --------------------------------------------------------------------------- +DOC_INTELLIGENCE_ENDPOINT= +DOC_INTELLIGENCE_KEY= +DOC_INTELLIGENCE_API_VERSION= + +# --------------------------------------------------------------------------- +# Customer DB-grant encryption (wired in code today: connections/db-grant flow) +# --------------------------------------------------------------------------- +# 32-byte key, AES-GCM. Encrypts customer-supplied read-only connection strings at rest. +GRANT_ENC_KEY= + +# --------------------------------------------------------------------------- +# Misc / public +# --------------------------------------------------------------------------- +NEXT_PUBLIC_APP_URL=https://0pt.app +# Documented target (docs/08); no call site found reading it in this pass. +NEXT_PUBLIC_API_URL=https://api.0pt.app + +# --------------------------------------------------------------------------- +# Cache / queue (wired in code today: app/api/health only checks presence; +# no other call site found reading it in this pass -- worker queue usage, if +# any, lives in services/api, outside this repo's scope) +# --------------------------------------------------------------------------- +REDIS_URL= diff --git a/.gitignore b/.gitignore index f650315..3c35968 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ yarn-error.log* # env files .env* +!.env.example # vercel .vercel -- 2.54.0 From 2902a26bac9479e7556da138c38edb522cef86e3 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Wed, 22 Jul 2026 18:49:01 +0200 Subject: [PATCH 3/6] docs: full accuracy overhaul, grounded claim-by-claim in real code (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: full accuracy overhaul, grounded claim-by-claim in real code Every doc in this repo (CLAUDE.md, README.md, SECURITY.md, docs/*.md) was audited and rewritten by a multi-pass process: independent rewrite per cluster, adversarial re-verification of that rewrite against the actual code, then a final cross-doc consistency pass — each stage re-checking claims against the repo directly rather than trusting the previous stage. Two corrections matter most: 1. docs/ICEBERG_WRITE_BOUNDARY.md, docs/11-lakehouse-write-plan.md, and docs/P0_QUERY_SOURCE_FLOW.md asserted src/services/quality-check-service.ts, quality-report-service.ts, src/data-platform/services/catalog-service.ts, and sql/002_p0_dataset_quality.sql/sql/003_*.sql as real, tested code. git log --all across every branch shows zero commits for any of these paths, ever. 0pt's data-quality/QA enforcement does not exist in code. Corrected everywhere, with a new docs/P0_DATASET_QUALITY_FLOW.md added as an explicit target-spec (not-yet-built) replacement. 2. github.com/sax3l/0pt (this repo) and git.siax.io/sax3l/0pt (Gitea) are two diverged remotes sharing part of the same history (services/api/ workers/adaptive_router.py is byte-identical between them). Gitea's main independently accumulated a WAF-bypass/proxy-rotation scraper stack in three 2026-07-13 commits, since removed via a Gitea PR; GitHub's main never had it. Which remote is canonical/deployed is unresolved — flagged consistently across the corpus, not decided here. Also newly and consistently documented: the tenant_id/project_id-read-from- client-body gap on the P0 lakehouse/rate-policy/source-query routes (no auth/session system exists anywhere in this repo yet); that every CL0UD/ N0D/AUD0/ST0RE platform client is real but mock-only (zero process.env reads for any of their *_URL vars); that two P0 mutation routes (lakehouse/mappings, sources/rate-policies) always fail over HTTP today because their prerequisite entity can never be created via any route; a schema bug in sql/004 (FK references to opt_datasets/opt_sources, which don't exist); and that 0pt.app's live deployment was independently confirmed (curled this pass) to be running this exact codebase with DATABASE_URL/REDIS_URL unset in production. New file: docs/12-edge-capability-api.md — the edge-native P0 TypeScript capability contract (app/api/0pt/*, /api/screen, /api/ai/parse), previously undocumented and distinct from docs/04-api.md's FastAPI-engine-only scope. Verified before commit: tsc --noEmit clean; vitest run 88 passed, 1 todo, 0 failures (13 files) — docs-only change, no source touched. Co-Authored-By: Claude Sonnet 5 * docs: reflect PR #5's lakehouse-mapping-registry + status-code fixes Follow-up to keep docs/12-edge-capability-api.md accurate once both PRs land: fix/0pt-full-bug-hunt (#5) fixed the shared-instance bug in the lakehouse mapping routes and added proper status-code mapping to the document extraction route. This doc was written against the pre-fix state in the same pass that added it, so it needs the same correction. Does not claim the lakehouse write path fully works now -- the deeper dataset-registration gap (no route ever calls DatasetService.registerDataset) is unchanged and still blocks it end-to-end; only the shared-instance half of the bug is fixed. --------- Co-authored-by: Claude Sonnet 5 --- CLAUDE.md | 18 +- README.md | 59 +++++-- SECURITY.md | 34 +++- docs/01-product.md | 10 +- docs/02-architecture.md | 142 ++++++++++++--- docs/03-data-model.md | 78 ++++++++- docs/04-api.md | 98 +++++++++-- docs/05-design-system.md | 122 ++++++++----- docs/06-data-engine.md | 189 +++++++++++++++++--- docs/07-dependencies.md | 100 ++++++----- docs/08-infrastructure.md | 300 ++++++++++++++++++++++++-------- docs/09-build-plan.md | 172 +++++++++++++++++- docs/10-lakehouse-decision.md | 72 ++++++-- docs/11-lakehouse-write-plan.md | 123 +++++++++---- docs/12-edge-capability-api.md | 202 +++++++++++++++++++++ docs/ICEBERG_WRITE_BOUNDARY.md | 186 ++++++++++++++++---- docs/P0_DATASET_QUALITY_FLOW.md | 180 +++++++++++++++++++ docs/P0_QUERY_SOURCE_FLOW.md | 217 ++++++++++++++++++++--- 18 files changed, 1954 insertions(+), 348 deletions(-) create mode 100644 docs/12-edge-capability-api.md create mode 100644 docs/P0_DATASET_QUALITY_FLOW.md diff --git a/CLAUDE.md b/CLAUDE.md index 9ee08c6..890ab8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Owned by SIAX Technology AB. Feeds internal products too (Protosell/FiveCRM, Veh 1. **Legal sourcing only.** The data engine uses, in strict priority: official APIs/bulk → authorized scraping of clearly public pages (respect `robots.txt` + rate limits) → licensed partner data → otherwise the field is delivered as **"not available / licensed only"**. We never invent values. - **Do NOT build, keep, or extend**: WAF/bot-detection bypass (incl. FlareSolverr, "stealth" browsers, undetected-* used to defeat detection), fingerprint/proxy rotation to evade bans, scraping behind login walls, or automated fake-account creation (Apollo etc.). - - If existing code (e.g. `src/workers/browser_worker.py`) contains a FlareSolverr/bypass path, **remove or disable it** and replace with compliant fetching. This is a product-positioning + compliance requirement, not a preference. "Honest accuracy" is the whole moat. + - **Path corrected this pass:** no `src/workers/browser_worker.py` exists anywhere in this checkout (verified via full-repo search) — the engine's actual workers live under `services/api/workers/` (`adaptive_router.py`, `pipeline.py`, `queue.py`, `resolve.py`, `run.py`), none of which contain a FlareSolverr/bypass path today. The precedent for this concern is real but lives on the *other* remote: Gitea's `git.siax.io/sax3l/0pt` `main` independently accumulated a WAF-bypass/proxy-rotation/fake-identity scraper stack (`services/scrapers/*`) in three 2026-07-13 commits, since removed via a Gitea PR (see README "Known issues"). If new code (from either remote) reintroduces a bypass path anywhere, **remove or disable it** and replace with compliant fetching. This is a product-positioning + compliance requirement, not a preference. "Honest accuracy" is the whole moat. 2. **Postgres is the backbone.** One Postgres is the source of truth. Catalog, portal, NocoDB views, Metabase dashboards, workers, exports all read/write the *same* DB. A tool may only enter the stack if it (a) reads that Postgres and (b) sits behind the same auth. 3. **Provenance per field.** Every fact carries source, freshness, confidence (`bridge_provenance`). This drives the UI signature too. 4. **One tool per job.** Do not add overlapping tools. The approved stack is in `docs/02-architecture.md` and `docs/07-dependencies.md`. Anything not listed needs a documented reason. @@ -25,7 +25,11 @@ Owned by SIAX Technology AB. Feeds internal products too (Protosell/FiveCRM, Veh ## Build on existing code -A working repo already exists (~145 files): FastAPI orchestrator (`src/orchestrator/main.py`, 25+ endpoints), workers (`src/workers/`: adaptive_router, browser_worker, ocr_worker, db), and country data for 20 markets. **Reconcile with it, don't blindly replace.** Where this documentation defines a target contract, migrate the existing code toward it. Flag conflicts instead of guessing. +A working engine repo already exists. **Corrected this pass — the previous description of it was wrong on every specific:** the FastAPI orchestrator lives at `services/api/orchestrator/main.py` (not `src/orchestrator/main.py`) and registers **12** route handlers (not "25+"); the workers directory is `services/api/workers/` and contains `adaptive_router.py`, `pipeline.py`, `queue.py`, `resolve.py`, and `run.py` (not `browser_worker`/`ocr_worker`/`db` — none of those three files exist anywhere in this repo); and `services/api/sources/sources.yaml` currently covers **7** markets (`SE`, `NO`, `DK`, `FI`, `GB`, `NL`, plus `global`), not 20. `services/api/` holds 11 Python source files total (excluding `__pycache__`). **Reconcile with the code as it actually is, not this description's prior claims.** Where this documentation defines a target contract, migrate the existing code toward it. Flag conflicts instead of guessing. + +**Two remotes claim to be this repo, and it's unresolved which is authoritative.** `origin` here is `github.com/sax3l/0pt`. A separate Gitea remote, `git.siax.io/sax3l/0pt`, shares at least part of the same history but has independently diverged (see README "Known issues" for specifics). Do not assume `origin` is the only copy of this codebase, and do not silently resolve the question of which is canonical — that is Simon's decision. + +**Before trusting any status claim in this doc corpus, read README's "Known issues" section.** It covers the auth/tenant-scoping gap, the platform-client mock-only status, and the underlying fact that 0pt's quality-check/QA enforcement code was never built despite once being described as real. **Corrected this pass:** `docs/ICEBERG_WRITE_BOUNDARY.md`, `docs/11-lakehouse-write-plan.md`, and `docs/P0_QUERY_SOURCE_FLOW.md` are now all consistently corrected — each carries its own "fabricated, not built" callout and none asserts `quality-check-service.ts`/`quality-report-service.ts`/etc. as real or tested anywhere in its current body (verified this pass by reading all three in full). `docs/P0_DATASET_QUALITY_FLOW.md` remains the explicit target-spec replacement for what that stage should look like once built. Treat any *other* doc claiming the QA stage already exists as wrong. ## How to work @@ -49,8 +53,14 @@ A working repo already exists (~145 files): FastAPI orchestrator (`src/orchestra | `docs/07-dependencies.md` | Repos/libraries to import (+ explicit exclusions) | | `docs/08-infrastructure.md` | Hosting, docker-compose, env, security, CI/CD | | `docs/09-build-plan.md` | Phased milestones with acceptance criteria | -| `docs/design/0pt-v0-build-brief.md` | Per-component v0 prompts (design reference) | -| `docs/design/0pt-design-coverage-and-visuals.md` | Full page/component inventory + visual system | +| `docs/10-lakehouse-decision.md` | Pointer to an ecosystem-wide lakehouse ADR made outside this repo; does not change `docs/02`/`docs/03` | +| `docs/11-lakehouse-write-plan.md` | 0pt's own follow-up task list for the Iceberg write boundary — **corrected**: item 4 (quality rules) is explicitly labeled NOT BUILT, and the old fabricated `quality-check-service.ts`/`quality-report-service.ts` claim is quoted only inside its own "Correction" callout, never asserted as fact | +| `docs/12-edge-capability-api.md` | **Added this pass.** Edge-native TypeScript P0 capability API contract (`app/api/0pt/*`, `/api/screen`, `/api/ai/parse`) — distinct from `docs/04-api.md`'s FastAPI-engine contract; documents several confirmed always-fails-over-HTTP routes and the tenant_id/in-memory gaps | +| `docs/ICEBERG_WRITE_BOUNDARY.md` | Iceberg write-path ownership record + "real vs. contract-only" verification status — **corrected**: its "Data quality: not yet built" section explicitly quotes and retracts the old fabricated claim rather than repeating it as fact | +| `docs/P0_QUERY_SOURCE_FLOW.md` | `0pt.query_source` bounded source-query flow — **corrected by a parallel pass this session**: no longer claims `quality-check-service.ts` exists, now points at the real precedent (`iceberg-write-service.ts`) instead | +| `docs/P0_DATASET_QUALITY_FLOW.md` | Added by that same parallel pass: an explicit **target-spec-only, not-yet-built** replacement for the fabricated quality-check/QA claims — read it as "should", never "does" | + +**Corrected this pass:** this table previously listed `docs/design/0pt-v0-build-brief.md` and `docs/design/0pt-design-coverage-and-visuals.md` as if they existed. They do not — confirmed via a full-repo search; there is no `docs/design/` directory at all. No `docs/` design-brief content is fabricated here to fill the gap. Either write those two docs or stop referencing them; until one of those happens, treat any prior claim that they exist as false. ## Definition of done (every screen, before real data) diff --git a/README.md b/README.md index 9ccf42a..ed89040 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ confidence; nothing is guessed. | Marketing | `/` (hero, filosofi, katalog, motorn, visualiseringar, täckning, editorial, bevis, priser, footer) | | Catalog | `/katalog`, `/katalog/[id]` | | Sourcing map | `/kallor` | -| Order lifecycle | `/bestall` → `/bestall/forskott` → `/order/[id]` → `/order/[id]/validera` | +| Order lifecycle | `/bestall` → `/bestall/beskriv` → `/bestall/forskott` → `/order/[id]` → `/order/[id]/validera` | | Customer portal | `/portal`, `/portal/subscriptions`, `/portal/files`, `/portal/integrationer`, `/portal/settings` | -| Admin portal | `/admin`, `/admin/sources`, `/admin/jobs`, `/admin/customers` | -| API | `/api/{health,estimates,catalog,sources,orders,orders/[id],subscriptions,admin/metrics,uploads,auth/*,payments/*,webhooks/stripe,connections/db-grant}` | +| Admin portal | `/admin`, `/admin/sources`, `/admin/jobs`, `/admin/customers`, `/admin/verktyg` | +| API | `/api/{health,estimates,catalog,sources,vehicles,screen,orders,orders/[id],subscriptions,admin/metrics,uploads,auth/*,ai/parse,payments/*,webhooks/stripe,connections/db-grant}` | +| Data platform (P0) | `/api/0pt`, `/api/0pt/documents/capture`, `/api/0pt/documents/[documentId]/extraction`, `/api/0pt/lakehouse/mappings[/[mappingId]/writes]`, `/api/0pt/sources/[sourceId]/query`, `/api/0pt/sources/rate-policies[/[id]]` — ⚠ these read `tenant_id`/`project_id` from the request body or query string, not a server session. See **Known issues** below. | ## Design system Mono/editorial, light, radius 0, Inter + JetBrains Mono, single red `#e40014`, animated grain, @@ -45,16 +46,54 @@ cd services/api && pip install -r requirements.txt && uvicorn orchestrator.main: ## Deploy - **Frontend** → Vercel (project `0pt`, scope *Simon Axelsson's projects*), domain `0pt.app`. - **Engine + DB + embeds** → one VPS via [`infra/docker-compose.yml`](infra/docker-compose.yml) (Postgres+pgvector, Redis, api, worker, NocoDB, Metabase, Caddy). -- Env inventory: [`.env.example`](.env.example). +- Env inventory: `.env.example` — **not on `main` yet.** It exists on branch/PR + [`feature/0pt-env-example`](https://github.com/sax3l/0pt/pull/3) (github.com/sax3l/0pt PR #3, unmerged as of + this pass), added after discovering `.gitignore`'s blanket `.env*` rule had silently kept it out of every + commit despite `docs/09-build-plan.md` Phase 0 requiring it. Until that PR merges, use + [`docs/08-infrastructure.md`](docs/08-infrastructure.md) as the source of truth for env var names. ## Status -Frontend, full order lifecycle, customer + admin portals, edge API, payment/auth/embed -infrastructure, the compliant data engine, schema and infra are all in place and building green. +Frontend (marketing, catalog, order lifecycle UI, customer + admin portals) and the edge API route +handlers build and typecheck green. That is a statement about the code compiling and running, **not** +about the platform being production-ready — read **Known issues** below before relying on any of this +against real tenants or real money. + Integrations are env-guarded and activate when their keys/DB are provided: -1. **Database** — Vercel → Storage → Postgres (sets `POSTGRES_URL`) → durable persistence (schema in [`sql/001_init.sql`](sql/001_init.sql)). -2. **Stripe + Resend** keys → payments + email. -3. **VPS** for the engine → set `ENGINE_URL`. -4. **`AUTH_SECRET` + `DEV_LOGIN_KEY`** → login (full OAuth via Auth.js lands with the DB). +1. **Database** — Vercel → Storage → Postgres (sets `POSTGRES_URL`) → durable persistence (schema in [`sql/001_init.sql`](sql/001_init.sql)). Without it, orders fall back to an in-memory, non-durable store (`lib/orders-store.ts`). +2. **Stripe** keys → payments (`app/api/payments/*`, `app/api/webhooks/stripe`) — this is wired code, not just a plan. **Resend** is documented as the email provider (`docs/08-infrastructure.md`) but has no call site anywhere in this repo yet — treat "+ email" as not-yet-implemented, not merely unconfigured. +3. **VPS** for the engine (`services/api`) → set `ENGINE_URL` so edge routes defer to it (`lib/engine.ts`). Unset, several routes (`/api/screen`, `/api/vehicles`, `/api/ai/parse`) fall back to calling public sources directly or computing locally instead. +4. **`AUTH_SECRET` + `DEV_LOGIN_KEY`** → a guarded **dev-only** login (`app/api/auth/login/route.ts`): an HMAC-signed session cookie (`lib/auth.ts`), not a real auth system. Full OAuth via Auth.js is Phase 3 of `docs/09-build-plan.md` and has not been built — see Known issues. + +## Known issues + +Blunt on purpose, per `CLAUDE.md`'s own house rule ("flag conflicts ... and propose the reconciliation +rather than guessing"). These are open, real gaps as of this pass — not silently patched, not guessed at. + +1. **No authentication or tenant scoping is wired anywhere in this repo.** The only thing that exists is + the `DEV_LOGIN_KEY` dev gate described above. There is no session-to-org/tenant binding at all — + `app/api/orders/route.ts`'s own `GET` handler carries the literal comment `// GET /api/orders — list + (org scoping added with auth).`, i.e. not yet. As a direct consequence, the newer P0 data-platform + routes read `tenant_id`/`project_id` **from the client-supplied request body or query string** instead + of a server-bound session: + - `app/api/0pt/lakehouse/mappings/route.ts` and `.../[mappingId]/writes/route.ts` + - `app/api/0pt/sources/rate-policies/route.ts` and `.../[sourceRatePolicyId]/route.ts` + - `app/api/0pt/sources/[sourceId]/query/route.ts` + - `src/services/document-capture-service.ts` (called from `app/api/0pt/documents/capture/route.ts`) + + This violates the platform-wide hard rule that `tenant_id` must be server-bound to an authenticated + session and never read from client input. It is not a deliberate two-tier design — it exists because no + auth/session system has been built yet. Do not expose these routes to untrusted callers before Phase 3 + (`docs/09-build-plan.md`) lands. + +2. **The CL0UD/N0D/AUD0/ST0RE platform clients are real code, but mock-only.** `src/clients/{cl0ud,n0d,aud0,st0re,st0re-lakehouse}-client.ts` are genuine, typechecked TypeScript with real tests — but every one of them runs against an in-memory mock today. **Corrected this pass:** `CL0UD_URL`/`N0D_URL`/`AUD0_URL`/`ST0RE_URL` are never read via `process.env` anywhere in this repo — verified by grepping the whole tree; the only hits are a code comment and a `TODO`/`it.todo` in `tests/p0/iceberg-write-service.test.ts` describing `ST0RE_URL` as future work, not an actual read. Every client only accepts an optional constructor `baseUrl` (`st0reBaseUrl`, `cl0udBaseUrl`, `n0dBaseUrl`, `aud0BaseUrl`), and every service that instantiates one (`src/services/*.ts`) is called from its route handler with no options, so all of them resolve to the in-memory mock today. Read this as "real code, mock-only, not live-verified, no env wiring at all yet" wherever it comes up — not "integrated" or "connected." + +3. **0pt's data-quality/QA enforcement code does not exist in this repo.** `src/services/quality-check-service.ts`, `src/services/quality-report-service.ts`, `src/types/quality-check.ts`, `src/types/quality-report.ts`, `src/data-platform/services/catalog-service.ts`, `sql/002_p0_dataset_quality.sql`, and `sql/003_*.sql` were at one point described, in `docs/ICEBERG_WRITE_BOUNDARY.md`, `docs/11-lakehouse-write-plan.md`, and `docs/P0_QUERY_SOURCE_FLOW.md`, as if they were already built and tested. `git log --all` for every one of those paths returns zero commits, on any branch, ever — they have never existed in this repository; the `sql/` directory today holds only `001_init.sql` and `004_iceberg_write_boundary.sql`. 0pt's QA/quality-check stage is **spec/target only** right now. **Corrected as of this pass:** all three of the docs above, plus `docs/12-edge-capability-api.md`, now consistently state this — each carries its own "fabricated, not built" callout, and `docs/P0_DATASET_QUALITY_FLOW.md` (added by an earlier parallel pass) is the explicit, clearly-labeled target-spec doc for what this stage should look like once built. Treat any *other* doc that asserts the QA stage already exists as wrong until it actually lands. + +4. **Two diverged git remotes both claim to be "the 0pt repo."** `origin` in this checkout is `github.com/sax3l/0pt`. A separate Gitea remote, `git.siax.io/sax3l/0pt`, shares at least part of the same history — `services/api/workers/adaptive_router.py` is byte-for-byte identical between GitHub's current `main` and a Gitea post-fix PR branch — but Gitea's `main` independently accumulated three 2026-07-13 commits adding a real WAF-bypass/proxy-rotation/fake-identity scraper stack (`services/scrapers/*`, since removed via a Gitea PR) that never touched GitHub's `main`. Which remote is authoritative, which is deployed to `0pt.app`, and whether the two should be kept in sync is **unresolved — Simon's decision**, not something this pass or any doc should silently assume. + +5. **0pt is registered as API-only in the platform's six-surface capability model, not "api/cli/mcp/ui/job/sdk" across the board.** 14 `0pt.*` capabilities are registered in `cl0ud`'s `packages/capability-registry/src/registry.ts` today (verified by name: `query_source`, `document_ocr`, `write_external_record`, `lakehouse_mapping_manage`, `lakehouse_write`, `source_rate_policy_manage`, `screen_sanctions`, `estimate_compute`, `vehicle_lookup`, `catalog_list`, `source_registry_list`, `order_create`, `order_payment`, `write_iceberg_table`). None has `cliEnabled`/`mcpEnabled` true. Surfaces, checked per-entry: 11 have `['api']` only, `lakehouse_write` and `write_iceberg_table` have `['api', 'job']`, and `write_external_record` has `['job']` only (no `api` surface at all) — so it is not quite "api everywhere plus job for the two heaviest," one capability is job-only. If any doc anywhere describes a live `siax 0pt ...` CLI or an 0pt MCP tool, it is describing a target, not current reality. + +6. **No evidence a "t_0pt" dedicated-node N0D auth pattern is built.** `sax3l/n0d` was searched in full (all branches, all commits, all files) — zero references to `t_0pt` exist anywhere in that repo. Treat any claim that this is already built/tested as unconfirmed. © 2026 SIAX Technology AB. Not affiliated with Protosell. diff --git a/SECURITY.md b/SECURITY.md index 0fd7efc..b5a4bb7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,9 +42,37 @@ Out of scope: - Automated scanning that generates excessive load against production systems without prior coordination. -Some known findings (for example authentication gaps on specific internal -API routes) are already tracked internally and pending an intentional, -reviewed fix; reporting them is still welcome and helps confirm priority. +Note: `github.com/sax3l/0pt` (this checkout's `origin`) and +`git.siax.io/sax3l/0pt` (Gitea) are two diverged remotes that share part of +this codebase's history — see README "Known issues" for what's known and +what isn't about the relationship between them. Which one is deployed to +0pt.app is currently unconfirmed; if your finding is remote-specific, say +which remote/branch you tested against. + +## Known, already-tracked gaps + +The following are **not** news to us — they are open, named gaps as of this +pass, not secrets, and reporting them again mainly helps confirm priority +rather than surfacing something new: + +- **No authentication or tenant-isolation system exists in this repo yet.** + The only gate in front of any route is a `DEV_LOGIN_KEY` dev-login scaffold + (`app/api/auth/login/route.ts`) — there is no session-to-tenant binding. + `app/api/orders/route.ts`'s `GET` handler carries the literal comment + `// GET /api/orders — list (org scoping added with auth).`, confirming this + directly. As a consequence, the P0 data-platform routes under `/api/0pt/*` + (`lakehouse/mappings`, `sources/rate-policies`, `sources/[sourceId]/query`) + and `src/services/document-capture-service.ts` accept `tenant_id`/ + `project_id` **from the client-supplied request body or query string** + rather than from a server-bound session — a violation of this platform's + own tenant-isolation rule (tenant_id must never be trusted from client + input). This is an open, prioritized gap pending real auth (Phase 3, + `docs/09-build-plan.md`), not an intentional design. Do not treat this repo + as tenant-isolated until that lands. +- Beyond that specific item, other authentication gaps on internal API + routes may still exist and are tracked internally pending an intentional, + reviewed fix; reporting any you find is still welcome and helps confirm + priority. ## Disclosure diff --git a/docs/01-product.md b/docs/01-product.md index 5274344..4b62110 100644 --- a/docs/01-product.md +++ b/docs/01-product.md @@ -6,7 +6,9 @@ ## The three flows (everything reduces to these) -Every service maps to one of three intake/delivery flows. The nine "categories" (scraping, structuring, datasets, ICP/leads, BI, ML, documents, subscriptions, custom) are **tags/filters within these flows**, not separate machinery. +Every service maps to one of three intake/delivery flows (`Flow` in `lib/services.ts`: `catalog` | `custom` | `subscription`). + +*Correction: this section previously said "nine categories (scraping, structuring, datasets, ICP/leads, BI, ML, documents, subscriptions, custom)" — that list doesn't match `lib/services.ts`'s actual catalog and never did (no `structuring`, `datasets`, `subscriptions`, or `custom` service id exists there). The real catalog today (`lib/services.ts`, `SERVICES`) has 11 entries: data delivery, web scraping, mapping & matching, normalization, consolidation → CRM/ERP, documents & OCR, enrichment, ICP & leads, ML jobs, BI & market analysis, feeds & monitoring.* These are **tags/filters within the three flows**, not separate machinery — each entry carries a `flow` field pointing back to `catalog` / `custom` / `subscription`. 1. **Catalog product** — ready-made, browse → sample → buy → deliver. Exists in Postgres or produced fast from the pipeline. Margin + scale. 2. **Custom job** — does not exist yet; must be scoped. `configure (AI-assist) → estimate → manual scope/quote confirm → produce → QA → validate → deliver`. The hero flow. Consulting in a portal. @@ -29,12 +31,14 @@ DRAFT → QUOTED → DEPOSIT_PENDING → IN_PRODUCTION → REVIEW - **DELIVERED**: exports + destinations unlocked; provenance log + quality report attached. - **SUBSCRIPTION_ACTIVE**: if converted to a feed. +This is real, working code today, not just a spec: `app/api/orders/[id]/route.ts`'s `PATCH` handler enforces exactly this sequence (lowercase: `draft`, `quoted`, `deposit_pending`, `in_production`, `review`, `revision`, `accepted`, `delivered`) as its allowed-status list. One gap: `SUBSCRIPTION_ACTIVE` is **not** in that allowed list yet — feed conversion (Phase 7, `docs/09-build-plan.md`) isn't wired into the order status machine. + ## Business / escrow model - **50 / 50.** Deposit (50 %) captured to start production. Balance (50 %) **held** and only captured/released on **ACCEPTED**. Revision never moves the held amount. - Pricing models: pay-per-job, volume, subscription (monthly), custom quote. - Estimate is a function of: selected field weights × markets × volume × entity multiplier × confidence-target multiplier + enrichment. Custom jobs get a manual scope/quote gate before DEPOSIT_PENDING. -- Stripe: deposit = capture now; balance = either a held authorization released on accept, or a second invoice on accept (implement as a second Checkout/invoice keyed to the order; see `docs/04-api.md`). +- Stripe: deposit = capture now (`app/api/payments/[order_id]/deposit/route.ts`); balance = **a second Stripe Checkout session created on accept**, keyed to the order (`app/api/payments/[order_id]/release/route.ts`). *Correction: earlier drafts of this doc left the balance mechanism open ("either a held authorization … or a second invoice"). The second-Checkout-session approach is what's actually implemented — there is no held-PaymentIntent-capture path in the code.* Both routes are env-guarded on `STRIPE_SECRET_KEY` and return `501` until it's configured (`lib/stripe.ts`). ## Customer DB access (designed, not a checkbox) @@ -46,4 +50,4 @@ SaaS-Martin (RevOps, wants clean ICP/leads), Bilhandlar-Bengt (vehicle + company ## First-launch scope (de-risked) -Do **not** ship "20 markets × 9 categories." Ship the thinnest sellable slice: **Nordic company data** (the open stuff — GLEIF + Brreg + verified sources) + **vehicle data** (VehIQ synergy) as a couple of catalog datasets, **plus the custom-job intake**, Stripe, and delivery to download + into FiveCRM/Data Loader. First customers are Protosell internally + existing dealer relationships — services you already deliver, productized. Then expand markets as the sourcing map fills in. +Do **not** ship "20 markets × 11 categories." Ship the thinnest sellable slice: **Nordic company data** (the open stuff — GLEIF + Brreg + verified sources) + **vehicle data** (VehIQ synergy) as a couple of catalog datasets, **plus the custom-job intake**, Stripe, and delivery to download + into FiveCRM/Data Loader. First customers are Protosell internally + existing dealer relationships — services you already deliver, productized. Then expand markets as the sourcing map fills in. diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 9fd1da1..278e3ff 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -1,9 +1,20 @@ # 02 · Architecture +> **Known issues affecting this doc, in brief** — full detail + evidence in [`README.md`](../README.md)'s +> "Known issues" section: (1) no auth/session system exists yet, so several newer routes trust +> client-supplied `tenant_id` (see "Known gaps" below); (2) the CL0UD/N0D/AUD0/ST0RE platform clients are +> real code but mock-only in this repo; (3) `docs/ICEBERG_WRITE_BOUNDARY.md` / `docs/11-lakehouse-write-plan.md` +> / `docs/P0_QUERY_SOURCE_FLOW.md` reference several `src/services/quality-*` and `sql/002`/`sql/003` files +> that were never actually committed to this repo (out of this doc's file scope to fix — flagged here so it +> isn't silently repeated); (4) two git remotes (`github.com/sax3l/0pt` and `git.siax.io/sax3l/0pt`) have +> diverged and it is unresolved which is authoritative — Simon's call, not assumed here. + ## Principle: one database, many lenses Postgres is the source of truth. Everything else is a **lens** on it. A tool enters the stack only if it (a) reads that Postgres and (b) sits behind the same auth. This is the difference between an app and a pile of separate logins. +This principle describes — and is largely realized by — the **original edge + engine plane**: marketing, catalog, order lifecycle, and the FastAPI engine. It does **not** describe the newer **P0 data-platform surface** (`app/api/0pt/*`), which is real, additive, and deliberately does not go through this same path. See "Two API surfaces" below. + ``` ┌──────────────────────────────────────────────┐ COLLECTION │ POSTGRES │ LENSES (customer + admin) @@ -16,37 +27,128 @@ Postgres is the source of truth. Everything else is a **lens** on it. A tool ent Customer upload│ │ Export: CSV/Parquet/JSON/API/FiveCRM └───────────────────▲──────────────────────────┘ │ - FastAPI orchestrator + workers + services/api — FastAPI orchestrator + workers (estimates, orders, jobs, AI-assist, webhooks, pipeline) ``` ## Components & responsibilities -- **Next.js (Vercel)** — all customer/admin UI: marketing site, catalog, configurator, checkout, production tracker, validation, portal, admin. Calls FastAPI over an internal token. Embeds NocoDB/Metabase via signed URLs (no separate login shown). -- **FastAPI orchestrator** (`src/orchestrator`) — REST contract in `docs/04-api.md`: estimates, orders, AI-assist, uploads, deliveries, webhooks, catalog, sources, job status/SSE. **Not on Vercel** (serverless timeouts kill long jobs). -- **Workers** (`src/workers`) — adaptive router + pipeline stages (sourcing → extraction/OCR → structuring → enrichment/fusion → QA). Write to Postgres with provenance. See `docs/06-data-engine.md`. -- **Postgres** — backbone (`docs/03-data-model.md`) incl. `pgvector` for matching/embeddings. -- **NocoDB** — Airtable-style views over existing tables (no data duplication). Embedded into portal as "Min data". -- **Metabase** — customer-facing dashboards. Embedded. -- **Cloudflare R2** — file storage (uploads + deliverables) via presigned URLs. -- **Stripe** — deposit capture + escrow balance release. -- **Resend** — transactional email (order confirmation, "din data är klar", feedback). -- **Auth.js** (NextAuth) with a Postgres adapter — single auth for the whole app; NocoDB/Metabase hidden behind it. +- **Next.js (Vercel)** — all customer/admin UI: marketing site, catalog, configurator, checkout, production tracker, validation, portal, admin. It also hosts **two distinct families** of API route handlers under `app/api/`: the original order/catalog/estimate surface (some of which defer to the FastAPI engine), and the newer P0 data-platform surface under `app/api/0pt/*` (does not defer to the engine at all — see below). +- **FastAPI orchestrator** (`services/api/orchestrator/main.py`) — REST contract in `docs/04-api.md`: estimates, orders, sources, vehicle lookup, sanctions screening, job run/status. **Not on Vercel** (serverless timeouts kill long jobs). *Correction: earlier drafts of this doc pointed at `src/orchestrator`; that was the pre-migration location. `docs/09-build-plan.md` Phase 0 called for moving it into `services/api`, and that move has happened — the code lives at `services/api/orchestrator` today.* +- **Workers** (`services/api/workers/`) — adaptive router (`adaptive_router.py`, verified: chooses only among compliant methods — official API → bulk → authorized web → licensed, no bypass/stealth/proxy-rotation tier exists in it, by design) + pipeline stages (`pipeline.py`, `queue.py`, `resolve.py`, `run.py`). See `docs/06-data-engine.md`. +- **Postgres** — backbone (`docs/03-data-model.md`) incl. `pgvector` for matching/embeddings, for the original edge + engine plane. Wired via `lib/db.ts` (env-guarded: `DATABASE_URL`/`POSTGRES_URL`; falls back to an in-memory, non-durable store when unset, e.g. `lib/orders-store.ts`). +- **NocoDB / Metabase** — both are defined as services in `infra/docker-compose.yml`; embedding them into the portal as "Min data" / customer dashboards is the target, not yet confirmed wired into a portal page in this pass. +- **Cloudflare R2** — file storage (uploads + deliverables) via presigned URLs. Env-guarded (`app/api/uploads/route.ts` checks `R2_ACCESS_KEY_ID`/`R2_BUCKET`/`R2_ENDPOINT` and returns `501` until set); the presign call itself is still a stub (`put_url: null`) even when the env vars are present — not wired end-to-end yet. +- **Stripe** — deposit capture + escrow balance release. This one **is** wired: `app/api/payments/[order_id]/deposit/route.ts` and `.../release/route.ts` are real Stripe Checkout calls (env-guarded on `STRIPE_SECRET_KEY`, `501` until configured). See the correction in `docs/01-product.md`'s business model section for exactly which escrow mechanism is implemented. +- **Resend** — documented as the transactional-email provider (`docs/08-infrastructure.md`) but has no call site anywhere in this repo yet. Treat "+ email" as not-yet-implemented, not merely unconfigured. +- **Auth.js (NextAuth) with a Postgres adapter** — this is the **target**, listed here as a stack decision, not a built component. What exists today is a lightweight signed-session scaffold (`lib/auth.ts`: HMAC-SHA256 via Web Crypto, no external deps) behind a guarded dev-only login gate (`DEV_LOGIN_KEY`). There is **no** real session-to-org/tenant binding anywhere in this repo. Full Auth.js + OAuth is Phase 3 of `docs/09-build-plan.md` and has not been built. This matters directly for the tenant-scoping gap described below. + +## Two API surfaces + +This repo currently contains **two real, distinct API surfaces**, not one. Earlier versions of this doc described the edge as a uniform thin proxy to the FastAPI engine — that was true for part of the original surface and remains true for that part, but a second, newer surface was added that is a deliberate, ADR-documented exception to it. Conflating the two is the single biggest way to misread this codebase's architecture. + +### 1. The original edge → engine surface + +A subset of the original `app/api/*` route handlers defer to the FastAPI engine (`services/api`) via `lib/engine.ts`'s `engineFetch()`/`hasEngine()` bridge when `ENGINE_URL` is set, and fall back to local computation when it isn't: + +- `app/api/estimates/route.ts` — defers to `POST {ENGINE_URL}/estimates`, falls back to `lib/data.ts`'s `computeEstimate`. +- `app/api/vehicles/route.ts`, `app/api/screen/route.ts`, `app/api/ai/parse/route.ts` — same pattern. + +Other original routes are **not** engine-backed at all today: `app/api/orders/*` reads/writes `lib/orders-store.ts` directly (Postgres when configured, else in-memory); `app/api/catalog/route.ts` returns a hardcoded static array. Both of these are entirely edge-local — there is no engine call in either path currently, not "thin proxy with a local fallback." + +This surface is documented in full in `docs/04-api.md` and matches the "one Postgres, many lenses" principle above (once `DATABASE_URL` is set). + +### 2. The P0 data-platform surface (`app/api/0pt/*`) + +A second, newer surface implements dataset registration, Iceberg lakehouse table mapping/writes, per-source rate-policy management, bounded synchronous source querying, and mailbox-based document capture — **directly in the edge app**, backed by `src/services/*` and `src/clients/*`. It does **not** call `lib/engine.ts`/`ENGINE_URL` and does **not** go through the FastAPI engine at all. This is deliberate: it is the concrete implementation of **ADR-0030** (Apache Iceberg as SIAX's canonical lakehouse table format, authored in the `n0d` repo) and the ownership split it defines. See `docs/10-lakehouse-decision.md` (the pointer to the ecosystem decision), `docs/11-lakehouse-write-plan.md` (the plan), and `docs/ICEBERG_WRITE_BOUNDARY.md` (the implementation record) for the full boundary — this doc only summarizes it. + +Real, existing routes today (verified against the filesystem, not against `app/api/0pt/route.ts`'s own advertised list — see the note below): + +| Route | Backed by | +|---|---| +| `GET /api/0pt` | static index (see note below) | +| `POST/GET /api/0pt/documents/capture` | `src/services/document-capture-service.ts` | +| `.../documents/{documentId}/extraction` | `src/services/document-extraction-service.ts` | +| `POST/GET /api/0pt/lakehouse/mappings` | `src/services/dataset-iceberg-mapping-service.ts`, `iceberg-write-service.ts` | +| `.../lakehouse/mappings/{mappingId}` | same | +| `POST .../lakehouse/mappings/{mappingId}/writes` | `src/services/iceberg-write-service.ts#requestIcebergWrite` | +| `POST/GET /api/0pt/sources/rate-policies` | `src/services/source-rate-policy-service.ts` | +| `.../sources/rate-policies/{sourceRatePolicyId}` | same | +| `POST /api/0pt/sources/{sourceId}/query` | `src/services/source-query-service.ts` (bounded, timeout-guarded ad-hoc SQL against a registered `database` source — see `docs/P0_QUERY_SOURCE_FLOW.md`) | + +**Code-level inconsistency worth knowing about**: `app/api/0pt/route.ts`'s own `GET` handler advertises an `endpoints` list including `/api/0pt/sources`, `/api/0pt/datasets`, `/api/0pt/data-contracts`, `/api/0pt/quality/checks`, `/api/0pt/quality/reports`, `/api/0pt/features/views` — **none of these exist as routes** (confirmed by listing `app/api/0pt/` on disk); the lakehouse-mapping and rate-policy routes that *do* exist aren't listed there either. This is the same "described but never built" pattern as the `quality-check-service.ts` fabrication flagged below — this doc's route table above reflects what is actually on disk, not what the index endpoint claims. + +**In-memory scope**: every P0 service (`DatasetService`, `SourceService`, `DatasetIcebergMappingService`, `SourceRatePolicyService`, `DocumentCaptureService`, …) holds its state in a plain `Map` inside the Next.js route module that instantiates it. Most route modules construct a fresh instance per module, so a record created via one route is not visible to a different route module in the same running process (each file's own header comment documents this; `document-capture-service.ts` worked around it for its own two routes with a shared singleton export). A Postgres migration exists (`sql/004_iceberg_write_boundary.sql`) but is not wired into any route handler yet. So the "one database, many lenses" principle above does not yet hold for this surface. + +## Six-surface capability model & 0pt's current coverage + +SIAX Cloud's target model (defined in `cl0ud`, `docs/architecture/SIAX_CLOUD_UNIFIED_ARCHITECTURE.md`) is that one capability can expose up to **six surfaces**: REST **api**, **cli** (`siax 0pt ...`), **mcp** (an agent tool), **ui** (a page/panel), **job** (an async N0D job type), and **sdk** (a generated client method). The capability registry's own `Surface` type (`cl0ud` `packages/capability-registry/src/types.ts`) currently encodes five of those six as a declarative field (`'api' | 'cli' | 'mcp' | 'ui' | 'job'`); `sdk` is generated separately, not yet a registry field. + +**0pt's actual coverage today** (verified against `cl0ud packages/capability-registry/src/registry.ts`): 14 distinct `0pt.*` capabilities are registered — + +`0pt.query_source`, `0pt.document_ocr`, `0pt.write_external_record`, `0pt.lakehouse_mapping_manage`, `0pt.lakehouse_write`, `0pt.source_rate_policy_manage`, `0pt.screen_sanctions`, `0pt.estimate_compute`, `0pt.vehicle_lookup`, `0pt.catalog_list`, `0pt.source_registry_list`, `0pt.order_create`, `0pt.order_payment`, `0pt.write_iceberg_table` + +— and, checked entry by entry: + +- **11** declare `surfaces: ['api']` only. +- **2** (`0pt.lakehouse_write`, `0pt.write_iceberg_table`) declare `surfaces: ['api', 'job']`. +- **1** (`0pt.write_external_record`) declares `surfaces: ['job']` **only** — `apiEnabled: false`, and its own comment states it has "NO dedicated HTTP route in the 0pt app yet"; it exists as a tested TS service (`src/services/external-writeback-service.ts`) invoked only via direct import today, not a live callable path. +- **Every single one** has `cliEnabled: false` and `mcpEnabled: false`. None has a `ui` surface entry either. + +In plain terms: **0pt is API-only today** (plus two job-backed writes, and one job-only capability with no live route at all). If any doc — inside or outside this repo — describes a live `siax 0pt ...` CLI command or an `0pt.*` MCP tool, it is describing a target, not current reality; correct it on sight. + +## Platform clients: CL0UD / N0D / AUD0 / ST0RE + +`src/clients/{cl0ud,n0d,aud0,st0re,st0re-lakehouse}-client.ts` are real, typechecked TypeScript with accompanying tests (`tests/p0/*`), each following the same shape: a thin interface, an HTTP implementation written against the target service's real, committed route contract, and a deterministic in-memory mock used whenever no `baseUrl` is supplied to the constructor. + +**Verified this pass**: none of the five client files, and none of their call sites in `src/services/*`, read `CL0UD_URL`, `N0D_URL`, `AUD0_URL`, or `ST0RE_URL` from `process.env` anywhere (`grep -rn "process.env" src/clients src/services` turns up nothing for any of those four names outside comments/TODOs). Every constructor call in this repo (`createCl0udClient(opts?.cl0udBaseUrl)`, etc.) is invoked with no `baseUrl` argument, so **every one of these clients runs against its in-memory mock today, in every code path that exists in this repo.** Read this as "real code, mock-only, not live-verified" — not "integrated" or "connected" — wherever platform-client status comes up. + +What each client is for, concretely: + +- **CL0UD** — policy evaluation (`evaluatePolicy`). `iceberg-write-service.ts` and `external-writeback-service.ts` call it before acting; the mock always returns `"allow"`. +- **N0D** — job requests (`createJob`). `document-capture-service.ts` requests document-extraction jobs; `iceberg-write-service.ts` requests heavy-write/compaction jobs. 0pt only *requests* N0D jobs — it never executes them, per `docs/ICEBERG_WRITE_BOUNDARY.md`'s "N0D executes" boundary. **No evidence supports a claim that N0D has a dedicated-node ("`t_0pt`") auth pattern already built for this**: `n0d`'s repository was searched in full (all branches, all commits, all files) and contains zero references to `t_0pt`. Treat any such claim as unconfirmed until proven otherwise. +- **AUD0** — audit/lineage events (`ingestEvent`, `ingestLineageEvent`). Every mutating P0 service emits these; the mock is a true no-op, matching AUD0's own documented stance that it has no live ingestion endpoint yet. +- **ST0RE / ST0RE-lakehouse** — artifact-metadata registration and Iceberg table/namespace/snapshot registration, respectively. `st0re-lakehouse-client.ts`'s HTTP path is written against `sax3l/st0re`'s actual shipped route contract (commit `9ea0abb`, read directly during a prior pass) — real code, but not executed against a running ST0RE in this repo's test environment either. + +## Known gaps + +### tenant_id is not server-bound anywhere in the P0 surface + +The platform-wide rule is that `tenant_id` must be bound to an authenticated session server-side and never trusted from client input. This repo violates that rule today, and it is **not** a deliberate two-tier design — it's a direct consequence of there being no auth/session system at all yet (see the Auth.js note above; `app/api/orders/route.ts`'s own `GET` handler literally comments `// org scoping added with auth`, i.e. not yet). + +Concretely: `tenant_id`/`project_id` are accepted straight from the client-supplied request body (or query string) at every P0 mutation entry point — + +- `app/api/0pt/lakehouse/mappings/route.ts` (POST) +- `app/api/0pt/sources/rate-policies/route.ts` (POST) +- `app/api/0pt/sources/[sourceId]/query/route.ts` (POST) +- `app/api/0pt/documents/capture/route.ts` → `src/services/document-capture-service.ts#captureFromMailbox` + +`app/api/0pt/lakehouse/mappings/[mappingId]/writes/route.ts` doesn't read `tenant_id` from its own body — but it resolves the tenant by looking up `mapping.tenant_id` from a mapping that was itself created via one of the unauthenticated routes above, so the trust boundary problem is inherited, not avoided. + +Do not expose any of these routes to untrusted callers before Phase 3 of `docs/09-build-plan.md` (Auth.js + org/session binding) lands. This is Simon's call on sequencing, not something to silently patch in a docs pass. + +### 0pt's data-quality/QA stage is a fabrication in sibling docs, not built + +Flagged so it is not repeated: `src/services/quality-check-service.ts`, `src/services/quality-report-service.ts`, `src/types/quality-check.ts`, `src/types/quality-report.ts`, `src/data-platform/services/catalog-service.ts`, `sql/002_p0_dataset_quality.sql`, and `sql/003_*.sql` were at one point referenced in `docs/ICEBERG_WRITE_BOUNDARY.md`, `docs/11-lakehouse-write-plan.md`, and `docs/P0_QUERY_SOURCE_FLOW.md` as if already built and tested. Verified this pass: `git log --all` for every one of those paths returns zero commits, on any branch, ever — `sql/` on disk today contains only `001_init.sql` and `004_iceberg_write_boundary.sql`. Those files have never existed in this repository. 0pt's data-quality/QA enforcement is **spec/target only** — a real, open build gap, not a doc typo. **All three of the docs above have since been corrected** (verified this pass by reading each in full) — none currently asserts those files as real; each carries its own explicit "fabricated, not built" callout, and `docs/P0_DATASET_QUALITY_FLOW.md` is the honest target-spec replacement. + +### Two diverged git remotes + +`origin` in local checkouts of this repo is `github.com/sax3l/0pt`. A separate Gitea remote, `git.siax.io/sax3l/0pt`, shares at least part of the same history (`services/api/workers/adaptive_router.py` is byte-for-byte identical between GitHub's current `main` and a Gitea post-fix PR branch) but independently accumulated commits GitHub's `main` never received — including, at one point, a WAF-bypass/proxy-rotation/fake-identity scraper stack that directly violated the CLAUDE.md §1 sourcing rule (since removed on Gitea via a separate PR). **Which remote is authoritative, which is actually deployed to `0pt.app`, and whether the two should be kept in sync is unresolved and is Simon's decision** — do not assume `origin`/GitHub is the sole copy of this codebase, here or in any other doc. ## Stack decisions — IN | Concern | Choice | Why | |---|---|---| | Frontend | Next.js App Router + TS + Tailwind + shadcn/ui | Vercel-native, matches existing skills | -| Backend/jobs | FastAPI + workers (existing) | already built; long-running jobs | +| Backend/jobs | FastAPI + workers (`services/api`) | already built; long-running jobs | | DB | Postgres (+ pgvector) | backbone; Neon free tier or on-VPS | | Airtable layer | **NocoDB** | sits on existing tables, no duplication | | BI | **Metabase** | customer-facing embedding, simple | | Files | Cloudflare R2 + presigned URLs | cheapest S3-compatible | -| Payments | Stripe Checkout + webhook → FastAPI | ~1 day, no SaaS boilerplate needed | -| Auth | Auth.js + Postgres adapter | one system | -| Email | Resend | best Next.js integration, free tier | -| Quality | Great Expectations / checks **inside workers** | scores shipped with each order | +| Payments | Stripe Checkout + webhook → Next.js API | ~1 day, no SaaS boilerplate needed | +| Auth | Auth.js + Postgres adapter | one system — **target, not yet built** (see Components above) | +| Email | Resend | best Next.js integration, free tier — **not yet wired** (no call site in this repo) | +| Quality | Great Expectations / checks **inside workers** | scores shipped with each order — **not yet built** for the P0 surface (see "Known gaps" above) | ## Stack decisions — OUT (do not add) @@ -58,13 +160,13 @@ Postgres is the source of truth. Everything else is a **lens** on it. A tool ent | Meilisearch (v1) | Postgres full-text suffices to a few thousand rows; add later | | Delta Sharing (v1) | unnecessary; customer pull via scoped role + exports | | Trigger.dev | the existing queue already schedules jobs; Resend covers mail | -| FlareSolverr / stealth browsers / undetected-* / proxy-fingerprint rotation / fake-account creation | **prohibited** — see CLAUDE.md §1. Replace with compliant fetching | +| FlareSolverr / stealth browsers / undetected-* / proxy-fingerprint rotation / fake-account creation | **prohibited** — see CLAUDE.md §1. Replace with compliant fetching. (The Gitea-only scraper stack described under "Two diverged git remotes" above is exactly this — removed there, never present on GitHub `main`.) | ## Hosting (cheapest coherent) - **Vercel** (Hobby/free) — Next.js + custom domain `0pt.app`. -- **One small VPS** (e.g. Hetzner ~€5/mo) running `docker-compose`: FastAPI, workers, NocoDB, Metabase, (Postgres or use Neon), optional Meilisearch later. Per-service managed platforms (Railway) get expensive once you have 4–5 always-on services; a single VPS + Vercel + R2 lands ≈ <€10/mo to start. -- **Cloudflare R2** — object storage. +- **One small VPS** (e.g. Hetzner ~€5/mo) running `docker-compose` (`infra/docker-compose.yml`): FastAPI, workers, NocoDB, Metabase, (Postgres or use Neon), optional Meilisearch later. Per-service managed platforms (Railway) get expensive once you have 4–5 always-on services; a single VPS + Vercel + R2 lands ≈ <€10/mo to start. +- **Cloudflare R2** — object storage (env-guarded, presign not yet wired — see Components above). - **Postgres** — on the VPS, or **Neon** free tier if you want it managed. -Full config, env, security, CI/CD: `docs/08-infrastructure.md`. +Full config, env, security, CI/CD: `docs/08-infrastructure.md`. Env var inventory: `.env.example` exists on branch/PR [`feature/0pt-env-example`](https://github.com/sax3l/0pt/pull/3) (unmerged as of this pass) — until it merges to `main`, `docs/08-infrastructure.md` is the source of truth for env var names. diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 25c3da5..361a7d3 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -1,6 +1,10 @@ # 03 · Data model -Postgres is the backbone. Two halves: **data entities** (what we sell) and **platform tables** (orders, users, jobs). Every fact links to `bridge_provenance`. Migrations via Alembic (Python) or `sql/` migrations checked into the repo — pick one and be consistent; align with existing `src/workers/db.py`. +Postgres is the backbone. Two halves: **data entities** (what we sell) and **platform tables** (orders, users, jobs). Every fact links to `bridge_provenance`. + +**Migrations are plain SQL files in `sql/`, applied in filename order.** There is no Alembic (or any other Python migration tool) anywhere in this repo, and no `src/workers/db.py` — that file does not exist (the Python engine's workers live in `services/api/workers/` — `adaptive_router.py`, `pipeline.py`, `queue.py`, `resolve.py`, `run.py` — none of them named `db.py`, and there is no TypeScript `src/workers/` directory either; `src/` only has `adapters/`, `clients/`, `services/`, `types/`). `infra/docker-compose.yml` mounts `../sql` as `/docker-entrypoint-initdb.d` on the `postgres` service, so Postgres auto-applies every `.sql` file in that directory, in name order, the first time that container initializes its data volume. + +**Only two migration files exist in this repo, ever:** `sql/001_init.sql` and `sql/004_iceberg_write_boundary.sql` (verified via `find sql/` — no `002` or `003`). Three other docs in this repo (`docs/ICEBERG_WRITE_BOUNDARY.md`, `docs/11-lakehouse-write-plan.md`, `docs/P0_QUERY_SOURCE_FLOW.md`) mention `sql/002_p0_dataset_quality.sql` and `sql/003_*.sql` by name — they don't exist, on any branch, ever (git history checked) — but all three have been corrected to say exactly that (each carries its own "never existed" callout, none claims the files are real as of this pass). This file only asserts what's true of `sql/001` and `sql/004`, which are real. ## Conventions @@ -11,6 +15,8 @@ Postgres is the backbone. Two halves: **data entities** (what we sell) and **pla ## Data entities +Mirrors `sql/001_init.sql` (verified against the file this pass; the migration additionally wraps every statement in `CREATE TABLE IF NOT EXISTS` for idempotent re-runs, and enables `pgcrypto` alongside `vector` — needed for `gen_random_uuid()`): + ```sql -- A company (the core registry entity) CREATE TABLE dim_company ( @@ -98,8 +104,12 @@ CREATE INDEX ON bridge_provenance (entity_type, entity_key); > Rule: **no value ships without a `bridge_provenance` row.** Level 0 = delivered as "not available / licensed", never a guessed value. +`dim_source` is a **different, older entity** than the per-tenant `Source` registry described below (`src/types/source.ts`) that `0pt.query_source` acts against — same name-ish concept, two separate schemas. `src/types/source.ts`'s own doc comment calls this out, but its "~9-row list of national registries served today by `GET /api/sources`" phrasing is easy to misread: `dim_source` itself, as a Postgres table, has **zero rows** — `sql/001_init.sql` only creates the table, there is no seed/`INSERT` statement anywhere in this repo's SQL or Python (grep-verified for `INSERT INTO dim_source`). The actual 9-entry national-registry list that comment is pointing at lives in a third, distinct artifact: the edge-native `GET /api/sources` route (`app/api/sources/route.ts`), a hardcoded array with a `confidence` field. That's different again from the FastAPI engine's `sources.yaml` (8 entries, no `confidence` field — see `docs/04-api.md`'s Sources section). So there are three separate "source list" artifacts in this repo — the empty `dim_source` SQL table, `sources.yaml` (8), and `app/api/sources/route.ts` (9) — none of which currently share data with each other; see "TypeScript-side P0 entities" below for the fourth, per-tenant `Source` type. + ## Platform tables +Mirrors `sql/001_init.sql` (verified this pass — same tables, same columns): + ```sql CREATE TABLE orgs ( org_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), @@ -191,11 +201,71 @@ CREATE TABLE payments ( ); ``` +> **Gap, verified this pass:** `sql/001_init.sql` does **not** contain `order_items`. It is documented here (and was already documented before this pass) as part of the target platform-table set, but there is no `CREATE TABLE order_items` in the migration file — check before relying on it existing in any real database. + +> **Gap, verified this pass:** this schema is real and (if `infra/docker-compose.yml`'s `postgres` service is actually run) gets created — but the FastAPI engine's own code never connects to it. `services/api/orchestrator/main.py` has no `asyncpg`/`psycopg`/`sqlalchemy` import anywhere (grep-verified across `services/api/`), and its `orders`/`jobs` state lives entirely in two module-level Python dicts (`_ORDERS`, `_JOBS`) that reset on every process restart and are never written to Postgres. The one `DATABASE_URL` reference in `main.py` (`GET /health`) only reports whether the env var is *set* — it never opens a connection. See `docs/04-api.md`'s implementation-status section for the full picture. Until that wiring lands, this table set is schema-only, not a live system of record. + +## P0 registry tables (`sql/004_iceberg_write_boundary.sql`) + +A second, newer migration adds two tables for the lakehouse write-path and crawl-compliance capabilities (`0pt.write_iceberg_table`, `0pt.source_rate_policy_manage` — see the new edge-API doc, `docs/12-edge-capability-api.md`). Verified against the file this pass: + +```sql +CREATE TABLE opt_dataset_iceberg_mappings ( + dataset_iceberg_mapping_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + project_id TEXT NOT NULL, + dataset_id TEXT NOT NULL REFERENCES opt_datasets (dataset_id), + iceberg_table_id TEXT NOT NULL, -- reference into ST0RE's table registry, NOT a foreign key + namespace TEXT, + table_name TEXT, + mapping_status TEXT NOT NULL DEFAULT 'active', + mapped_by TEXT, + correlation_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ +); + +CREATE TABLE opt_source_rate_policies ( + source_rate_policy_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + project_id TEXT NOT NULL, + source_id TEXT NOT NULL REFERENCES opt_sources (source_id), + rate_limit_per_minute NUMERIC NOT NULL, + crawl_budget NUMERIC NOT NULL, + compliance_notes TEXT, + domain TEXT, + robots_ok BOOLEAN, + access TEXT, + status TEXT NOT NULL DEFAULT 'active', + correlation_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ, + created_by TEXT +); +``` + +`iceberg_table_id` is deliberately **not** a foreign key — it references ST0RE's own `st0re_iceberg_tables.table_id`, a different service's database. 0pt holds only the reference, never a copy of ST0RE's table/catalog/snapshot state (see the module doc comment on `src/clients/st0re-lakehouse-client.ts`, and `docs/ICEBERG_WRITE_BOUNDARY.md` for the full ownership boundary — that doc is outside this file's scope to otherwise correct). + +> **Confirmed bug, found this pass — flagging plainly, not silently fixing it:** both `CREATE TABLE` statements above have a `REFERENCES` clause pointing at tables that **do not exist anywhere in this repo's SQL**: `opt_dataset_iceberg_mappings.dataset_id REFERENCES opt_datasets (dataset_id)` and `opt_source_rate_policies.source_id REFERENCES opt_sources (source_id)`. Neither `opt_datasets` nor `opt_sources` is created by `sql/001_init.sql`, `sql/004_iceberg_write_boundary.sql`, or any other file in `sql/` (grep-verified — there are only two migration files, full stop). If `sql/004_iceberg_write_boundary.sql` is ever actually run against a fresh database (e.g. via `infra/docker-compose.yml`'s `docker-entrypoint-initdb.d` mount), **it will fail** with `relation "opt_datasets" does not exist`. This is not a naming coincidence: `src/types/dataset.ts` and `src/types/source.ts` define exactly the `Dataset`/`Source` shapes those table names imply, and `DatasetService`/`SourceService` (`src/services/`) are their in-memory-only implementations — but no migration ever creates the SQL tables to back them. **Open question for Simon:** either author a migration that creates `opt_datasets`/`opt_sources` before `sql/004` (so the FK resolves), or drop those two `REFERENCES` clauses and enforce the dataset/source-exists check purely in the TypeScript service layer (which already does the check today, redundantly, in `DatasetIcebergMappingService.createMapping` and `SourceRatePolicyService.createSourceRatePolicy`) — this file does not choose between those for you. + +## TypeScript-side P0 entities without SQL backing + +Newer P0 capabilities (`docs/12-edge-capability-api.md`) introduced their own typed entities under `src/types/`, each with an in-memory-only service under `src/services/`. Only two of these (above) have a real migration. The rest have **no SQL table at all** — every instance of the owning service starts with an empty in-memory `Map` and loses all state on restart (or even between two `new XService()` calls in the same process — see `docs/12-edge-capability-api.md`'s "in-memory isolation" findings for exactly how badly this bites in practice today): + +| Type | File | Service (in-memory only) | SQL table | +|---|---|---|---| +| `Source` | `src/types/source.ts` | `src/services/source-service.ts` | none | +| `Dataset` | `src/types/dataset.ts` | `src/services/dataset-service.ts` | none | +| `DocumentRecord` | `src/types/document.ts` | `src/services/document-capture-service.ts` | none | +| `DocumentExtractionRecord` | `src/types/document-extraction.ts` | `src/services/document-extraction-service.ts` | none | +| `ExternalWriteRecord` | `src/types/external-writeback.ts` | `src/services/external-writeback-service.ts` | none | +| `DatasetIcebergMapping` | `src/types/dataset-iceberg-mapping.ts` | `src/services/dataset-iceberg-mapping-service.ts` | `opt_dataset_iceberg_mappings` (`sql/004`) | +| `SourceRatePolicy` | `src/types/source-rate-policy.ts` | `src/services/source-rate-policy-service.ts` | `opt_source_rate_policies` (`sql/004`) | + ## pgvector -`CREATE EXTENSION vector;` Use for entity matching/dedup and for the AI-assist (semantic match of NL request → catalog/config). Store embeddings in a `embeddings` table keyed by `(entity_type, entity_key)` or alongside catalog rows. +`CREATE EXTENSION vector;` — this is real (`sql/001_init.sql` line 3, along with `pgcrypto`). **Everything past the extension is target only:** there is no `embeddings` table, or any other table using the `vector` type, anywhere in `sql/001_init.sql` or `sql/004_iceberg_write_boundary.sql`. The intended use (entity matching/dedup, AI-assist semantic match of NL request → catalog/config) is a real design goal, not yet a real table. ## Row-level security & catalog -- Catalog products: a `catalog` table (or view) over curated datasets with sample rows, counts, coverage, price. -- Per-customer data isolation enforced by `org_id` filtering in the API and (where exposed via NocoDB/Metabase) by scoped views/connection params, plus Postgres RLS where practical. +**Target only — none of this exists in `sql/` today**, verified by grep for `ROW LEVEL SECURITY`, `CREATE POLICY`, and `catalog` across both migration files (no matches beyond a comment). There is no `catalog` table or view, and no Postgres RLS policy anywhere in this repo's SQL. Per-customer isolation is intended to be `org_id` filtering in the API plus RLS where practical — but as of this pass the FastAPI engine (`services/api/orchestrator/main.py`) has **no `org_id` concept in any request or response at all** (see `docs/04-api.md`'s implementation-status section: `GET /orders` returns every order in the process, unfiltered). This is the same platform-wide "no auth/session system exists yet" gap noted across this doc corpus — not a data-model-specific problem to solve here, but the schema above cannot be considered tenant-isolated until it is. diff --git a/docs/04-api.md b/docs/04-api.md index 2547565..4346085 100644 --- a/docs/04-api.md +++ b/docs/04-api.md @@ -1,25 +1,46 @@ # 04 · API contract (FastAPI) -Canonical REST contract. Reconcile the existing 25+ endpoints in `src/orchestrator/main.py` toward this. JSON in/out, `snake_case`. All amounts in **öre**. Auth: Next.js → FastAPI over a shared internal token (`X-Internal-Token`); customer identity passed as `org_id`/`user_id` resolved from the Auth.js session server-side. +Canonical REST contract for the **Python data engine** — `services/api/orchestrator/main.py`, run on a VPS/worker host, not on Vercel. JSON in/out, `snake_case`. All amounts in **öre**. -## Conventions -- Errors: `{ "error": { "code", "message" } }`, proper HTTP status. Interface-voice messages (Swedish) are produced in the frontend, not here. -- Idempotency: accept `Idempotency-Key` on POSTs that create orders/payments. -- Pagination: `?limit=&cursor=`. +**Scope note:** this doc covers *only* the FastAPI engine. The newer edge-native TypeScript P0 capability surface (`app/api/0pt/*`, `/api/screen`, `/api/ai/parse` — Next.js Route Handlers that run on Vercel) is a separate, additive layer with its own contract: see `docs/12-edge-capability-api.md`. The two are not the same service and, as of this pass, are not fully wired together (see "Existing-code reconciliation" below for a concrete case where the edge layer expects an engine route that doesn't exist). + +## Implementation status (verified this pass — read before trusting any endpoint below) + +The engine file is `services/api/orchestrator/main.py` (162 lines) — not `src/orchestrator/main.py`, which does not exist in this repo (older doc text, including CLAUDE.md's "Build on existing code" section, still uses that stale path). It implements **exactly 12 endpoints**, all listed below — not "25+" as CLAUDE.md and the previous version of this doc claimed. That number does not match anything in the current codebase; flagging it here since it's this doc's own error to fix (CLAUDE.md's copy of the same claim is outside this file's scope). + +Endpoints that exist in `main.py` today: `GET /health`, `GET /sources`, `GET /sources/{market}`, `GET /vehicles/{plate}`, `GET /screen`, `POST /estimates`, `POST /orders`, `GET /orders`, `GET /orders/{oid}`, `PATCH /orders/{oid}`, `POST /orders/{oid}/run`, `GET /orders/{oid}/status`. + +Everything else in this document — payments/escrow, uploads, customer DB-grant, deliveries, subscriptions, outbound webhooks, `/orders/{id}/events` (SSE), `/orders/{id}/scope|accept|revise` — is a **target contract for the engine**, not yet implemented in `main.py`. Some of it already exists as a *separate* Next.js edge route instead (payments, orders, catalog, estimates, vehicles, sources all have edge-layer counterparts under `app/api/` — not read in detail for this pass, since they're outside this doc's FastAPI scope; noted only so you don't assume they don't exist anywhere). + +Three structural gaps apply to **every** endpoint below that main.py does implement: + +- **No persistence.** `main.py` has no `asyncpg`/`psycopg`/`sqlalchemy` import anywhere. `_ORDERS` and `_JOBS` are plain module-level Python `dict`s — state is lost on every restart, and never touches the `orders`/`jobs` tables `docs/03-data-model.md` documents. `GET /health`'s `db` field only reports whether the `DATABASE_URL` env var is *set* (`bool(os.getenv(...))`) — it never opens a connection, so it does not indicate the database is actually reachable or in use. +- **No authentication, no tenant scoping.** `main.py` never reads any request header (no `X-Internal-Token` check, despite the edge layer sending one — see `lib/engine.ts`), and no request/response model anywhere carries `org_id` or `user_id`. Concretely: **`GET /orders` returns every order held by the process, for every org, with no filtering at all.** The "Auth: … customer identity passed as `org_id`/`user_id` resolved from the Auth.js session server-side" framing below is the target design, not current behavior — this is the same platform-wide "no auth/session system exists yet" gap noted across this doc corpus (see `docs/09-build-plan.md` Phase 3), not something specific to fix in this file. +- **No idempotency, no pagination.** Neither is implemented by any endpoint in `main.py` today, despite being listed as target conventions below. + +## Conventions (target contract — see gaps above for current reality) + +- Errors: `{ "error": { "code", "message" } }`, proper HTTP status. *(Not implemented in `main.py`: it raises plain FastAPI `HTTPException(status, "message")`, which serializes as `{"detail": "message"}`, not the `{error:{code,message}}` shape below.)* Interface-voice messages (Swedish) are produced in the frontend, not here. +- Idempotency: accept `Idempotency-Key` on POSTs that create orders/payments. *(Target only — not implemented.)* +- Pagination: `?limit=&cursor=`. *(Target only — not implemented; `GET /orders` returns the full unfiltered list.)* +- Auth: Next.js → FastAPI over a shared internal token (`X-Internal-Token`, `lib/engine.ts` sends it via `INTERNAL_TOKEN`) — the edge side of this is real, but `main.py` never validates the header, so today it has no effect. Customer identity as `org_id`/`user_id` resolved from an Auth.js session is target design; no such session system exists in this repo yet. ## Catalog + ``` GET /catalog -> [{id,title,markets,entity_type,count,updated,price_ore,confidence}] GET /catalog/{id} -> full dataset (overview, coverage, schema, quality summary) GET /catalog/{id}/sample?n=10 -> sample rows WITH per-field provenance ``` +**Status: target only.** No `/catalog` route exists in `main.py`. A separate `GET /api/catalog` exists at the edge (`app/api/catalog/route.ts`, registered in CL0UD's capability registry as `0pt.catalog_list`) — not read in detail for this pass; out of scope here. ## Estimate (configurator → price/time/confidence) ``` POST /estimates - body: { flow, markets[], entity_type, fields[], enrichment[], volume, frequency, confidence_target, formats[] } - -> { total_ore, deposit_ore, balance_ore, est_days, est_confidence, breakdown[] } + body: { markets[], entity_type, fields[], enrichment[], volume, frequency, confidence_target } + -> { total_ore, deposit_ore, balance_ore, est_days, est_confidence, recurring } ``` +**Status: implemented**, but the shape differs from the previous version of this doc: the request body has **no `flow` field**, and the response has **no `breakdown[]` field** — `main.py`'s `EstimateIn` model is exactly `{markets, entity_type, fields, enrichment, volume, frequency, confidence_target}` (defaults: `entity_type="Bolag"`, `volume=1000`, `frequency="Engångs"`, `confidence_target=92`), and `compute_estimate()` returns exactly `{total_ore, deposit_ore, balance_ore, est_days, est_confidence, recurring}` — `recurring` is a bool (`frequency != "Engångs"`), not a `breakdown[]` array. There is also no `formats[]` field on the request. ## AI-assist (NL → config) ``` @@ -27,16 +48,18 @@ POST /ai/parse body: { text } -- "5000 svenska SaaS-bolag med e-post och omsättning" -> { config: , rationale } -- prefilled configurator; uses pgvector match to catalog/fields ``` +**Status: not implemented — confirmed contract mismatch.** `main.py` has no `/ai/parse` route at all (grep-verified: only the 12 endpoints listed in "Implementation status" exist). This matters beyond "target only": the edge route `app/api/ai/parse/route.ts` **actively calls** `engineFetch("/ai/parse", {method:"POST", ...})` expecting the engine to have this route, checks `if (eng && eng.ok)`, and — because the engine 404s — silently falls through to a local keyword-heuristic parser every time, even when `ENGINE_URL` is configured and the engine is reachable. See `docs/12-edge-capability-api.md` for the edge side of this route. There is also no pgvector usage anywhere in `main.py` (no `embeddings` table exists either — see `docs/03-data-model.md`). ## Orders (lifecycle) ``` -POST /orders body: { flow, config } -> { order_id, status:'draft'|'quoted' } -GET /orders -> [orders for org] -GET /orders/{id} -> order + current job + delivery (if any) -POST /orders/{id}/scope (custom flow) body:{ notes } -> status:'quoted' (manual quote step) -POST /orders/{id}/accept customer approves delivery -> triggers balance release; status:'accepted' -POST /orders/{id}/revise body:{ notes } -> status:'revision'; escrow untouched +POST /orders body: { flow, config } -> { order_id, status:'draft', deposit_ore, total_ore } +GET /orders -> [orders] -- NOT filtered by org; returns every order in the process (no auth) +GET /orders/{id} -> { ...order, job } -- job is null if none exists yet; no "delivery" field (deliveries aren't implemented) +PATCH /orders/{id} body: { status } -> updates order.status to any caller-supplied string, no validation against a lifecycle enum +POST /orders/{id}/run -> runs the pipeline SYNCHRONOUSLY (awaits workers/pipeline.py:run_job), sets status "in_production" then "review", returns the job record +GET /orders/{id}/status -> the job record, or {stage:"queued", pct:0, state:"queued"} if none exists (never 404s) ``` +**Status: partially implemented, but shaped differently from the target below.** What actually exists: `POST /orders`, `GET /orders`, `GET /orders/{oid}`, `PATCH /orders/{oid}`, `POST /orders/{oid}/run`, `GET /orders/{oid}/status` — six endpoints, listed above with their real behavior. What the original target contract described — `POST /orders/{id}/scope` (manual quote step), `POST /orders/{id}/accept` (triggers balance release), `POST /orders/{id}/revise` — **do not exist in `main.py` at all.** Order-status transitions today happen only through the generic `PATCH /orders/{oid}` (accepts any string, no state-machine enforcement) or implicitly through `POST /orders/{oid}/run`. `POST /orders/{oid}/run` itself is undocumented in any prior version of this doc despite being the only way to actually trigger the sourcing pipeline — it has no payment/escrow gate of any kind (see Payments below). ## Payments (50/50 escrow) ``` @@ -44,19 +67,21 @@ POST /payments/{order_id}/deposit -> { checkout_url } (Stripe Checkout for 50 POST /payments/{order_id}/release -> captures/charges the held balance (called on accept) POST /webhooks/stripe (Stripe → here) verify signature; update payments + order status ``` -> Implement balance as either a manual capture on a held PaymentIntent, or a second Checkout/invoice keyed to `order_id`. Never charge balance before `accept`. +**Status: not implemented in the engine at all.** There is no Stripe reference anywhere in `services/api/` (grep-verified). `POST /orders/{oid}/run` — the engine's only way to start production — has no payment dependency whatsoever; nothing in `main.py` checks that a deposit was paid before running a job. If Stripe/escrow exists anywhere in this repo, it's at the edge (`app/api/payments/[order_id]/deposit`, `app/api/payments/[order_id]/release`, `app/api/webhooks/stripe` — registered in CL0UD's capability registry as `0pt.order_payment`, evidence comment notes both routes 501 when `STRIPE_SECRET_KEY` is unset) — not read in detail this pass, out of scope here. **Open question:** whether escrow/payment gating is meant to live in the FastAPI engine (as this doc's original framing implies) or stay purely at the edge is unresolved; this doc doesn't decide it for you. ## Jobs / live status ``` GET /orders/{id}/status -> { stage, pct, per_market[], confidence, eta, rows } GET /orders/{id}/events -> Server-Sent Events stream (preferred for the production tracker) ``` +**Status: `GET /orders/{id}/status` exists but returns a different, smaller shape** — the actual dict returned by `workers/pipeline.py:run_job()` is exactly `{stage, pct, state, metrics, records}` (verified by reading its `return` statement) — **not** `{stage, pct, per_market[], confidence, eta, rows}` as previously documented, and **not** `started_at`/`finished_at` either (those two column names exist only on the unused `jobs` SQL table in `docs/03-data-model.md` — the in-memory job dict `run_job` actually returns has no timestamps at all). `metrics` itself is `{rows, dropped_below_threshold, per_market, avg_confidence}`; `records` holds the delivered rows. `GET /orders/{id}/events` (SSE) **does not exist** — target only. ## Uploads (R2 presigned) ``` POST /uploads body:{ order_id, name, mime, bytes } -> { file_id, put_url } (client PUTs to R2) GET /orders/{id}/files -> [files] ``` +**Status: target only.** Neither route exists in `main.py`. ## Customer DB access (read-only grant) ``` @@ -65,18 +90,28 @@ POST /connections/db-grant -> { grant_id, status:'pending'|'verified' } DELETE /connections/db-grant/{id} -> revoke ``` +**Status: target only.** Neither route exists in `main.py`. The `db_grants` table exists in `sql/001_init.sql` (see `docs/03-data-model.md`) but nothing writes to it. ## Deliveries ``` GET /orders/{id}/delivery -> { formats, destinations, record_count, avg_confidence, quality_report, files[], provenance_export_url } POST /orders/{id}/delivery/destinations body:{ destinations[] } -> updates targets (CSV/Parquet/JSON/API/NocoDB/Metabase/FiveCRM/Sheets/webhook) ``` +**Status: target only.** Neither route exists in `main.py`. Note also that a "delivery" concept and a "quality_report" are described here as if built — per this doc corpus's broader honesty pass, the underlying quality/QA-check logic these would report on (`quality-check-service.ts`, `quality-report-service.ts` and friends) does not exist in this repo at all (see CLAUDE.md's pointer to README's "Known issues"). This section is aspirational on two levels: the route, and the quality data it would need to serve. ## Sources (the transparency moat) ``` -GET /sources -> [{source_id,name,market,access,licence,cadence,confidence}] -GET /sources/{market} -> sources + freshness for a market +GET /sources -> [{source_id,name,market,access,licence,cadence}] +GET /sources/{market} -> full sources.yaml entries (source_id,name,market,access,licence,cadence,url,entities,fields,base_url,robots_ok,notes) filtered to that market + 'global' ``` +**Status: implemented, with two shape corrections.** `GET /sources` does **not** return a `confidence` field — `main.py` explicitly projects only `source_id,name,market,access,licence,cadence` from `sources.yaml` (verified: `{k: s.get(k) for k in (...)}` with no `confidence` key). `GET /sources/{market}` returns the *entire* raw `sources.yaml` entry for each match (richer than `/sources` — includes `base_url`, `robots_ok`, `entities`, `fields`, `notes`), not a "sources + freshness" shape; there is no `freshness` field anywhere in `sources.yaml` or added by this endpoint. Both read from `services/api/sources/sources.yaml` (**8** entries as of this pass — verified by count: `gleif`, `brreg`, `companies_house`, `prh`, `cvr`, `bolagsverket`, `rdw`, `opensanctions`; no `traficom` entry exists in this file, unlike the edge-native `GET /api/sources` route below) — an entirely separate, static list from the per-tenant `Source` registry (`src/types/source.ts`) the edge-native `0pt.query_source`/`0pt.source_rate_policy_manage` capabilities act against; see `docs/03-data-model.md` and `docs/12-edge-capability-api.md`. **A third, separate static list exists too:** `app/api/sources/route.ts` is a hardcoded, `GET`-only Next.js route with its own 9-entry array (the same 8 markets plus `traficom`/FI, each with a `confidence` field sources.yaml does not have) — not part of this FastAPI contract, not wired to sources.yaml in any way, and not itself documented in detail here (out of this doc's FastAPI scope; see `docs/12-edge-capability-api.md`). + +## Vehicles & sanctions screening (implemented, previously undocumented) +``` +GET /vehicles/{plate} -> RDW (NL) lookup result, or { vehicle: null } if not found +GET /screen?q= -> { source: "opensanctions", matches: [{name, score, schema}] } +``` +These two endpoints exist in `main.py` today but were missing from every prior version of this doc. `GET /vehicles/{plate}` proxies `sources/connectors.py:rdw_vehicle()` (Dutch open vehicle data, technical/fleet fields only, no owner PII, per the connector's own comment). `GET /screen` proxies `sources/connectors.py:opensanctions_screen()` against `api.opensanctions.org` — the edge route `app/api/screen/route.ts` calls this same path via `engineFetch('/screen?q=...')` first and falls back to calling OpenSanctions directly if the engine isn't reachable, which is a working (not broken) reconciliation, unlike `/ai/parse` above. ## Subscriptions / feeds ``` @@ -84,14 +119,41 @@ POST /subscriptions body:{ order_id, cadence, webhook_url } -> { GET /subscriptions -> [subs] POST /subscriptions/{id}/pause | /resume ``` +**Status: target only.** No route exists in `main.py`. The `subscriptions` table exists in `sql/001_init.sql` but nothing writes to it from the engine. ## Webhooks out (to customers) - On delivery / feed update: POST to `subscriptions.webhook_url` with `{ order_id, delta_url, summary }`. +**Status: target only** — depends on Deliveries and Subscriptions above, neither of which exist yet. + ## Health ``` -GET /health -> { ok, db, workers, queue } +GET /health -> { ok, service, db, sources } ``` +**Status: implemented**, with a corrected shape: the real response is `{ok: true, service: "0pt-data-engine", db: bool(DATABASE_URL is set), sources: len(_SOURCES)}` — not `{ok, db, workers, queue}` as previously documented. There is no `workers` or `queue` field, and `db` is a presence check on an env var, not a live connectivity probe (see "Implementation status" above). ## Existing-code reconciliation -Map current endpoints (orders, estimates, ai-assistent, catalog, webhooks, health) onto the names above; rename for consistency, keep behavior. Add: `/orders/{id}/events` (SSE), `/payments/*` escrow split, `/connections/db-grant`, `/sources`, `/subscriptions`. Remove any endpoint that drives a bypass/stealth fetch path. + +| This doc's target | `main.py` reality | Status | +|---|---|---| +| `GET/POST /catalog*` | — | not implemented (edge-only, unverified) | +| `POST /estimates` | `POST /estimates` | implemented, different request/response fields (see above) | +| `POST /ai/parse` | — | not implemented; edge layer calls it anyway and silently falls back | +| `POST /orders`, `GET /orders`, `GET /orders/{id}` | same paths, exist | implemented, no auth/org filtering, in-memory only | +| `POST /orders/{id}/scope\|accept\|revise` | `PATCH /orders/{oid}`, `POST /orders/{oid}/run` | different shape: generic status PATCH + a synchronous run trigger, no escrow gate | +| `POST /payments/*`, `POST /webhooks/stripe` | — | not implemented in engine (may exist at the edge — unverified this pass) | +| `GET /orders/{id}/status` | `GET /orders/{oid}/status` | implemented, smaller/different shape | +| `GET /orders/{id}/events` (SSE) | — | not implemented | +| `POST /uploads`, `GET /orders/{id}/files` | — | not implemented | +| `POST/DELETE /connections/db-grant*` | — | not implemented | +| `GET/POST /orders/{id}/delivery*` | — | not implemented; underlying quality data doesn't exist either | +| `GET /sources`, `GET /sources/{market}` | same paths, exist | implemented, different projected fields (no `confidence`/`freshness`) | +| `POST/GET /subscriptions*` | — | not implemented | +| `GET /health` | `GET /health` | implemented, different shape (no `workers`/`queue`) | +| — | `GET /vehicles/{plate}`, `GET /screen` | implemented, previously undocumented | + +Remove any endpoint that drives a bypass/stealth fetch path (CLAUDE.md §1) — none of the 12 real endpoints in `main.py` do; the WAF-bypass concern in this repo's history is in `services/scrapers/*` on the Gitea remote, not here (see CLAUDE.md's note on the two diverged remotes — an open question for Simon, not resolved by this pass). + +## Related: the edge-native TypeScript capability surface + +`app/api/0pt/*`, `app/api/screen/route.ts`, and `app/api/ai/parse/route.ts` are **not** part of the FastAPI contract above — they're Next.js Route Handlers, run on Vercel, and implement several of the newer P0 capabilities registered in CL0UD's capability registry (`0pt.query_source`, `0pt.document_ocr`, `0pt.lakehouse_mapping_manage`, `0pt.lakehouse_write`, `0pt.source_rate_policy_manage`). Full route-by-route documentation, including several confirmed always-fails-over-HTTP bugs, lives in **`docs/12-edge-capability-api.md`**. diff --git a/docs/05-design-system.md b/docs/05-design-system.md index 5f6ff0b..25e29af 100644 --- a/docs/05-design-system.md +++ b/docs/05-design-system.md @@ -1,27 +1,42 @@ # 05 · Design system (mono) -Self-contained spec. Component-level v0 prompts live in `docs/design/0pt-v0-build-brief.md`; the full page/component inventory + visual rules in `docs/design/0pt-design-coverage-and-visuals.md`. This file is the source of truth for tokens + motion; build them once and reuse. +Self-contained spec. This file is the source of truth for tokens + motion; the +tokens and CSS below are **already implemented** in `app/globals.css` and +consumed across `components/` and `app/` — this is not aspirational, it's a +description of what ships today, kept here so new screens stay on-brand. -Derived from the **mono / EVASION** template (itself v0-generated). Light, editorial, monochrome, sharp, with a single red accent and animated grain + scroll-reveal motion. **No photography** — imagery is rendered data. +Derived from the **mono / EVASION** template (itself v0-generated). Light, editorial, monochrome, sharp, with a single red accent and animated grain + scroll-reveal motion. **No photography** — imagery is rendered data. (Verified: no `` or `next/image` usage anywhere in `app/` or `components/` except a single unused leftover component, see "Vestigial component, not on the 'no photography' path" further down.) + +> **Doc-index gap:** `CLAUDE.md`'s documentation index lists two files this doc used to point to — `docs/design/0pt-v0-build-brief.md` (per-component v0 prompts) and `docs/design/0pt-design-coverage-and-visuals.md` (full page/component inventory + visual rules). **Neither file exists anywhere in this repo** (confirmed via full-repo search). They are referenced but were never written. This doc no longer treats them as available references — anything they were supposed to cover (per-component prompts, the full visual-mapping table) is not documented anywhere right now. Whoever owns design docs next should either write them or remove the rows from `CLAUDE.md`'s index; not resolved in this pass. ## Tokens +Verified against `app/globals.css` (the file actually imported by `app/layout.tsx`): + ``` THEME (light, default): --background #ffffff --foreground #0a0a0a --muted-foreground #737373 --border #e5e5e5 --card #ffffff --input #f5f5f5 -THEME (dark sections — footer, one editorial block): +THEME (dark sections — footer, editorial/manifesto block, technology/engine block): --background #0a0a0a --foreground #fafafa --muted-foreground #a3a3a3 --border #262626 -ACCENT (single, sparing): red --accent #e40014 (one CTA / one number / one marker per screen) +ACCENT (single, sparing): red --accent-red #e40014 (one CTA / one number / one marker per screen) PROVENANCE squares (ONLY non-mono color besides the red CTA): level 0 #737373 (unavailable/licensed) · 1 #143aff (single source) · 2 #fcbb00 (partial) · 3 #00d294 (corroborated) -RADIUS: 0 everywhere (max 2px on tiny chips). No drop shadows; structure via 1px hairlines. -TYPE: Inter (everything; headlines weight 500–600, letter-spacing -0.02em, large) · JetBrains Mono (eyebrows, labels, numbers, codes, table headers — UPPERCASE, letter-spacing 0.12em, ~11px, #737373) +RADIUS: 0 everywhere (`--radius: 0rem` in app/globals.css; shadcn/ui components inherit --radius-sm/md/lg/xl derived from it, so they render square too). +TYPE: Inter (everything; headlines weight 500–600, letter-spacing -0.02em, large) · JetBrains Mono (eyebrows, labels, numbers, codes, table headers — UPPERCASE, letter-spacing mostly 0.08–0.22em but ranging down to 0.02em on some labels/table headers (e.g. `provenance-badge.tsx`, the source table headers in `app/kallor/page.tsx`), ~10–12px, #737373). Loaded via `next/font/google` in `app/layout.tsx` as CSS vars `--font-inter` / `--font-jetbrains-mono`. GRID: 8px base; graph-paper hairlines; generous whitespace; left-aligned headlines. -SECTION SPINE: real ordered indices 00–NN as mono eyebrows. +SECTION SPINE: real ordered indices 00–06 as mono eyebrows (hero=00, philosophy=01, catalog=02, [03 unused/reserved], collection/coverage=04, testimonials=05, pricing=06). The two dark sections (editorial "Manifesto", technology "The engine") intentionally break the numbered spine — they carry a text eyebrow instead of a number. ``` -## globals.css (add verbatim — guarantees identical motion + grain) +**Correction — a third section also breaks the spine, and it isn't dark:** verified render order in `app/page.tsx` is Hero(00) → Philosophy(01) → FeaturedProducts/Catalog(02) → **Technology (dark, no number)** → **GallerySection (no number)** → Collection(04) → **Editorial (dark, no number)** → Testimonials(05) → Pricing(06) → Footer(dark). `components/sections/gallery-section.tsx` sits exactly where a "03" would fall but carries a text eyebrow (`"— Visualizations"`) like the dark sections, plus its own unrelated local `01`–`05` indices on its five tiles (a per-tile counter, not a continuation of the page's section spine — don't confuse the two). So "03 unused/reserved" is accurate in that no section ever shows the numeral 03, but three sections break the numbered spine, not two — only two of those three happen to be dark-themed. + +**Correction vs. previous version of this doc:** the dark theme was previously described as appearing on "footer, one editorial block" (2 places). Verified in `components/sections/`: **three** sections actually use the dark theme — `footer-section.tsx`, `editorial-section.tsx` (the "Manifesto" block), and `technology-section.tsx` (the "#motorn" / "The engine" block, `id="motorn"`). All three set `className="dark ..." ` and a `bg-[#0a0a0a] text-[#fafafa]` pairing. + +**Implementation note (not yet reconciled in code):** `editorial-section.tsx` and `technology-section.tsx` use the documented dark border `#262626` consistently. `footer-section.tsx` instead hardcodes `#1f1f1f` for its borders — a different, undocumented shade. All three sections hardcode hex literals (`#0a0a0a`, `#fafafa`, `#737373`, `#a3a3a3`, plus the two different border shades) rather than referencing the `.dark` CSS-variable overrides that already exist in `app/globals.css`'s `--border`/`--foreground`/`--muted-foreground` tokens. This works today because the values happen to match (except the footer border), but it means the dark sections don't actually pick up `.dark`-scoped custom-property overrides — they're hand-pinned. Flagging as a small cleanup opportunity, not fixing here (out of scope for a docs pass). + +## globals.css (already live in `app/globals.css` — do not re-add elsewhere) + +The block below is the actual motion + grain CSS, condensed for reference. It ships today in `app/globals.css` (with the keyframes/classes spelled out at full length rather than this compact form — functionally identical): ```css @keyframes reveal-up{0%{opacity:0;transform:translateY(60px)}to{opacity:1;transform:translateY(0)}} @@ -44,44 +59,73 @@ SECTION SPINE: real ordered indices 00–NN as mono eyebrows. @media(prefers-reduced-motion:reduce){.reveal,.reveal-left,.reveal-right,.reveal-scale,.reveal-fade{opacity:1;animation:none}.animate-float,.grain-overlay::before{animation:none}} ``` -## useReveal hook +The provenance colors and `--accent-red` also live in `app/globals.css` as CSS custom properties (`--accent-red`, `--prov-0..3`), exposed to Tailwind via `@theme inline` as `--color-accent-red`, `--color-prov-0..3`. -```tsx -"use client"; -import { useEffect, useRef } from "react"; -export function useReveal() { - const ref = useRef(null); - useEffect(() => { - const el = ref.current; if (!el) return; - const io = new IntersectionObserver((es) => es.forEach((e) => { - if (e.isIntersecting) { - el.querySelectorAll(".reveal,.reveal-left,.reveal-right,.reveal-scale,.reveal-fade") - .forEach((n) => n.classList.add("in-view")); - io.unobserve(el); - } - }), { threshold: 0.15, rootMargin: "0px 0px -10% 0px" }); - io.observe(el); return () => io.disconnect(); - }, []); - return ref; -} -``` -Every section wraps in a tag using `useReveal()`; children get `reveal[/-left/-right/-scale/-fade]` + `delay-N`. +**Known leftover, unused file:** `styles/globals.css` also exists in this repo and is **not** imported anywhere (`app/layout.tsx` imports `./globals.css`, i.e. `app/globals.css`, only). `styles/globals.css` is a stray copy of the original shadcn/v0 scaffold — it uses `oklch()` colors, Geist/Geist Mono fonts, and `--radius: 0.625rem`, none of which match this design system. It is dead code. Don't edit it expecting it to affect the app, and consider deleting it in a future cleanup (not done in this pass — out of scope). + +## Reveal / motion system + +Three pieces work together; the doc previously only described one of them: + +1. **`useReveal()` hook** — `hooks/use-reveal.ts`. Wire a section's ref with it; descendants carrying `.reveal` (+ optional `.reveal-left/-right/-scale/-fade` and `.delay-N`) animate to `.in-view` via `IntersectionObserver` (`threshold: 0.15`, `rootMargin: "0px 0px -10% 0px"`) when the section scrolls into view. **Difference from a naive IntersectionObserver:** if the section's top edge is already inside the viewport on mount (e.g. direct navigation to a page whose hero is already visible), the hook applies `.in-view` immediately via `requestAnimationFrame` instead of waiting for a scroll event — this avoids a flash of invisible content on first paint. + ```tsx + "use client"; + import { useEffect, useRef } from "react"; + export function useReveal() { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + const applyReveal = () => { + el.querySelectorAll(".reveal, .reveal-left, .reveal-right, .reveal-scale, .reveal-fade") + .forEach((n) => n.classList.add("in-view")); + }; + const rect = el.getBoundingClientRect(); + if (rect.top < window.innerHeight * 0.9) { + requestAnimationFrame(applyReveal); + return; + } + const io = new IntersectionObserver((entries) => entries.forEach((e) => { + if (e.isIntersecting) { applyReveal(); io.unobserve(el); } + }), { threshold: 0.15, rootMargin: "0px 0px -10% 0px" }); + io.observe(el); + return () => io.disconnect(); + }, []); + return ref; + } + ``` +2. **``** — `components/route-reveal.tsx`, mounted once, globally, in `app/layout.tsx` inside the providers. On every client-side route change (via `usePathname()`), it force-applies `.in-view` to any `.reveal*` element whose top edge is already within 95% of the viewport height — covering standalone pages (e.g. `/bestall`, `/katalog/[id]`) that render fully visible on load, where per-section `IntersectionObserver`s might not fire before paint. Runs on an immediate pass, a `requestAnimationFrame` pass, and a 600ms-delayed pass (for async-loaded content). **This component was not documented in the previous version of this file** — it's load-bearing for anything not covered by `useReveal()`'s own "already visible" check (e.g. content that becomes visible slightly after mount). +3. **CSS keyframes/classes** in `app/globals.css` (above) define the actual animation. + +Every section wraps in a tag using `useReveal()`; children get `reveal[/-left/-right/-scale/-fade]` + `delay-N`. This pattern is used consistently across `components/sections/*.tsx`. ## Buttons & labels -- Primary: solid `#0a0a0a` bg, white text, square, mono uppercase 12px / spacing 0.1em, padding 14px 26px; hover → bg `#e40014`. -- Secondary: transparent, 1px `#0a0a0a` border; hover → bg `#0a0a0a`, white text. -- Eyebrow: mono uppercase 11px, spacing 0.14em, `#737373`, with a 2-digit index ("01 — KATALOG"). + +- Primary: solid `#0a0a0a` (`--foreground`) bg, `--background` text, square, mono uppercase ~12px, padding varies by placement (header CTA and hero CTAs use `px-5..7 py-2.5..4`, i.e. roughly 10–16px vertical / 20–28px horizontal — treat the doc's original "14px 26px" as an approximate baseline, not an exact contract); hover → bg `var(--accent-red)`. +- Secondary: transparent, 1px `border-foreground`; hover → bg `--foreground`, `--background` text. +- Eyebrow: mono uppercase 11px, spacing ~0.14–0.18em, `--muted-foreground`, with a 2-digit index where the section spine applies (e.g. `"01 — Philosophy"`). - Headlines: sentence case, never uppercase. +- **Implementation note:** there is no shared `Button`/`MonoButton` component implementing this pattern — `components/ui/button.tsx` is the generic, unmodified shadcn/ui button (rounded via `--radius-*` tokens, which happen to resolve to 0 here, but otherwise a stock `cva` variant set: default/destructive/outline/secondary/ghost/link). The mono square CTA look described above is hand-rolled per call site as inline Tailwind classes on raw ``/`