fix(recovery): restore source removed by production documentation merge
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# BACKLOG P6 (CI-16): lets a developer run `act` to execute .gitea/workflows
|
||||||
|
# (GitHub-Actions-compatible syntax) locally before pushing, instead of
|
||||||
|
# discovering a workflow bug only after a Gitea runner picks it up.
|
||||||
|
# https://github.com/nektos/act
|
||||||
|
#
|
||||||
|
# ubuntu-latest here maps to a real Docker image (act's default is a minimal
|
||||||
|
# image missing tools this repo's workflows expect, e.g. curl for the
|
||||||
|
# Trivy/gitleaks install steps) -- catthehacker's full image matches what
|
||||||
|
# GitHub-hosted (and this fleet's siax-runner-*) ubuntu-latest actually has.
|
||||||
|
-P ubuntu-latest=catthehacker/ubuntu:full-latest
|
||||||
|
-W .gitea/workflows
|
||||||
|
--container-architecture linux/amd64
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* BACKLOG P6 (ARK-14): import-boundary check. Advisory today (run manually /
|
||||||
|
* `pnpm run deps:check`) -- not wired as a blocking CI gate in this pass,
|
||||||
|
* so a first real run surfacing findings doesn't turn a green PR red
|
||||||
|
* unexpectedly. Wiring it into CI is a reasonable next step once the
|
||||||
|
* initial finding set (if any) has been triaged.
|
||||||
|
*
|
||||||
|
* Boundaries encoded, matching CLAUDE.md / docs/architecture/ARCHITECTURE.md:
|
||||||
|
* - `services/scrapers` and `services/api` are Python -- not traversed by
|
||||||
|
* this JS/TS dependency graph at all; no rule needed, they're simply
|
||||||
|
* out of dependency-cruiser's file-type scope.
|
||||||
|
* - `src/` (the capability layer, blocking-typechecked) must not import
|
||||||
|
* from `app/` or `components/` (the v0-generated UI layer, advisory-only)
|
||||||
|
* -- that would pull UI-layer assumptions into the tested backend layer.
|
||||||
|
* - Nothing may import from `tests/` (test helpers stay test-only).
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
forbidden: [
|
||||||
|
{
|
||||||
|
name: 'no-src-importing-app-or-components',
|
||||||
|
comment:
|
||||||
|
'src/ is the blocking-typechecked capability layer (tsconfig.check.json); app/ and components/ are the advisory v0-generated UI layer. src/ importing from them would pull unverified UI-layer code into the tested backend layer.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^src' },
|
||||||
|
to: { path: '^(app|components)' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'no-importing-tests',
|
||||||
|
comment: 'Test helpers stay test-only -- application code must not depend on tests/.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { pathNot: '^tests' },
|
||||||
|
to: { path: '^tests' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'no-circular',
|
||||||
|
comment: 'Circular imports make the module graph hard to reason about and can hide load-order bugs.',
|
||||||
|
severity: 'warn',
|
||||||
|
from: {},
|
||||||
|
to: { circular: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
tsPreCompilationDeps: true,
|
||||||
|
tsConfig: { fileName: 'tsconfig.json' },
|
||||||
|
exclude: { path: 'node_modules|\\.next|services/(api|scrapers)|services/infra' },
|
||||||
|
reporterOptions: {
|
||||||
|
text: { highlightFocused: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "0pt",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
||||||
|
"postCreateCommand": "corepack enable && corepack prepare pnpm@9 --activate && pnpm install --frozen-lockfile",
|
||||||
|
"postStartCommand": "node scripts/siax-doctor.mjs || true",
|
||||||
|
"forwardPorts": [3000],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteEnv": {
|
||||||
|
"AUTH_ENFORCE": "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Keep the build context small + reproducible. Do NOT exclude app source dirs
|
||||||
|
# (app/, components/, hooks/, lib/, packages/, src/, styles/, public/) -- next
|
||||||
|
# build needs them.
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
|
||||||
|
# local env / secrets -- never ship into an image
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# python services + their large sample dumps are not part of the Next build
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
services/scrapers/output
|
||||||
|
|
||||||
|
# editor / OS noise
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# the Docker files themselves
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# BACKLOG P6 (REPO-13). Matches .prettierrc.json's 2-space indent -- this is
|
||||||
|
# a floor for editors that don't run Prettier on save, not a replacement for
|
||||||
|
# it (quote style/semicolons/line width stay Prettier's job, not
|
||||||
|
# EditorConfig's). Note: `pnpm run format` currently reports ~300
|
||||||
|
# pre-existing style violations across this repo (verified 2026-08-11,
|
||||||
|
# out of this pass's scope) -- this file governs new edits, not a
|
||||||
|
# retroactive reformat.
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
+301
@@ -0,0 +1,301 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 0pt (edge plane) -- environment inventory / example environment
|
||||||
|
# =============================================================================
|
||||||
|
# Copy to `.env` (gitignored) and fill in real values. Never commit real values.
|
||||||
|
# Vercel env for the edge app; VPS .env (git-ignored) for infra/docker-compose.yml.
|
||||||
|
# See docs/08-infrastructure.md for the canonical description of each integration.
|
||||||
|
#
|
||||||
|
# Two provenance markers are used below and they mean different things:
|
||||||
|
# * "wired in code today" -- read via a `process.env.*` somewhere in
|
||||||
|
# this repo right now (grep-verified).
|
||||||
|
# * "documented target, not yet read" -- named in docs/08-infrastructure.md /
|
||||||
|
# docs/02-architecture.md but with no
|
||||||
|
# `process.env.*` read yet. Filling it in
|
||||||
|
# unblocks the integration; it does not by
|
||||||
|
# itself change behaviour.
|
||||||
|
#
|
||||||
|
# The app is designed to build and boot with all of these UNSET: lib/db.ts and
|
||||||
|
# lib/stripe.ts are lazy + env-guarded, and the storage/payment/doc routes return
|
||||||
|
# HTTP 501 ("not configured") until the relevant keys are provided.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core / public
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public base URL of this app (used for absolute links / callbacks).
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
# Documented target (docs/08); no call site found reading it in this pass.
|
||||||
|
# NOTE: api.0pt.app is a dead hostname (falls through to wildcard Vercel ->
|
||||||
|
# DEPLOYMENT_NOT_FOUND). The app serves its own /api routes on the site
|
||||||
|
# origin, so the honest base URL is the live origin itself.
|
||||||
|
NEXT_PUBLIC_API_URL=https://0pt.app
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database (Postgres) -- wired in code today: lib/db.ts, app/api/health
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Vercel Postgres sets POSTGRES_URL / POSTGRES_PRISMA_URL automatically when the
|
||||||
|
# integration is attached. First one set wins, in this order (lib/db.ts):
|
||||||
|
# DATABASE_URL -> POSTGRES_URL -> POSTGRES_PRISMA_URL.
|
||||||
|
DATABASE_URL=postgres://user:password@localhost:5432/opt
|
||||||
|
POSTGRES_URL=
|
||||||
|
POSTGRES_PRISMA_URL=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache / queue (Redis) -- 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=redis://localhost:6379
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth -- session / dev-login / service-token secrets (wired in code today)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session-signing secret (generate a long random string).
|
||||||
|
AUTH_SECRET=change-me-long-random-string
|
||||||
|
# AUTH_URL is documented (docs/08) but not yet read by any route in this pass.
|
||||||
|
AUTH_URL=https://0pt.app
|
||||||
|
# Optional dev-only login shortcut key (leave UNSET in production).
|
||||||
|
DEV_LOGIN_KEY=
|
||||||
|
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
|
||||||
|
# on every engineFetch() call (lib/engine.ts).
|
||||||
|
INTERNAL_TOKEN=change-me-internal-token
|
||||||
|
# Tenant authority for the internal-token principal. Comma-separated allowlist
|
||||||
|
# of tenants this deployment's engine is entitled to write for, e.g.
|
||||||
|
# "379976258701823235". This is the ONLY source of tenant authority for
|
||||||
|
# X-Internal-Token: a request header may never name a tenant. Empty/unset means
|
||||||
|
# the service principal has no tenant authority and tenant-scoped writes
|
||||||
|
# (canonical facts, quality bridge runs) are denied with 403. Exactly ONE
|
||||||
|
# tenant is supported: the shared token identifies one service process, not a
|
||||||
|
# set of principals. A multi-tenant list is refused (401) until a verified
|
||||||
|
# workload-identity binding exists.
|
||||||
|
INTERNAL_SERVICE_TENANTS=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 0pt verifies inbound Bearer tokens against the fleet Zitadel IdP
|
||||||
|
# (src/auth/zitadel.ts, wired via middleware.ts). JWKS are discovered from
|
||||||
|
# <issuer>/.well-known/openid-configuration, falling back to <issuer>/oauth/v2/keys.
|
||||||
|
#
|
||||||
|
# Fleet Zitadel issuer (OIDC). Default when unset: https://id-customers.siax.io
|
||||||
|
ZITADEL_ISSUER=https://id-customers.siax.io
|
||||||
|
# Browser login (Authorization Code + PKCE). Create a Web application in the
|
||||||
|
# Zitadel project with redirect URI https://0pt.app/api/auth/callback.
|
||||||
|
ZITADEL_OIDC_CLIENT_ID=
|
||||||
|
ZITADEL_OIDC_CLIENT_SECRET=
|
||||||
|
# Enforcement is FAIL-CLOSED. Leave this UNSET (or set it to "true") and a
|
||||||
|
# valid, verified Bearer token is REQUIRED on /api/0pt/* -- 401 on
|
||||||
|
# missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant.
|
||||||
|
# ONLY the literal value "false" disables authentication, and that value is
|
||||||
|
# REFUSED when NODE_ENV=production: the app throws at startup instead of
|
||||||
|
# serving openly. Use "false" for local development only.
|
||||||
|
AUTH_ENFORCE=true
|
||||||
|
# Optional override for the claim key that carries the SIAX tenant id. When
|
||||||
|
# unset, tenant derivation uses the documented precedence:
|
||||||
|
# urn:zitadel:iam:org:id -> urn:zitadel:iam:user:resourceowner:id -> tenant_id -> tenant.
|
||||||
|
ZITADEL_TENANT_CLAIM=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 and
|
||||||
|
# app/api/ai/parse.
|
||||||
|
ENGINE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SIAX platform clients
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WIRED FOR REAL since 2026-08-31: every factory in src/clients/* reads its
|
||||||
|
# *_URL from process.env when no constructor baseUrl is given. Under
|
||||||
|
# NODE_ENV=production an UNSET url is a hard boot failure
|
||||||
|
# (SiaxError DEPENDENCY_UNAVAILABLE) — production never silently falls back
|
||||||
|
# to the in-memory mocks (Master-DoD §3/§10). In dev/test, unset = documented
|
||||||
|
# local mock/no-op behaviour.
|
||||||
|
CL0UD_URL=
|
||||||
|
N0D_URL=
|
||||||
|
AUD0_URL=
|
||||||
|
# Optional service bearer tokens for the platform clients (machine identity).
|
||||||
|
# Values come from Infisical/secret refs — NEVER commit real values here.
|
||||||
|
CL0UD_SERVICE_TOKEN=
|
||||||
|
N0D_SERVICE_TOKEN=
|
||||||
|
AUD0_SERVICE_TOKEN=
|
||||||
|
ST0RE_SERVICE_TOKEN=
|
||||||
|
# INF0 (AI OS) -- documented target, not yet read by any code in this repo.
|
||||||
|
# Route AI-parsning / model calls through the gateway instead of raw provider keys.
|
||||||
|
INF0_URL=
|
||||||
|
# ST0RE_URL is the convention name for src/clients/st0re-client.ts /
|
||||||
|
# st0re-lakehouse-client.ts and is read by the real-infra test suite
|
||||||
|
# (tests/p0/*.realinfra.test.ts). The lakehouse write path was live-verified
|
||||||
|
# against ST0RE on 2026-07-13 with this set; unset, the clients fall back to
|
||||||
|
# their deterministic in-memory mocks.
|
||||||
|
ST0RE_URL=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P0 Data-OS backends (src/search, src/analytics, src/metadata, src/features)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenSearch — search projections ONLY, never canonical truth (§42). Unset in
|
||||||
|
# dev/test = search capability unavailable (typed error), never a fake.
|
||||||
|
OPENSEARCH_URL=
|
||||||
|
# ClickHouse — 0pt capability analytics (tenant-isolated metric events).
|
||||||
|
# HAS NO CONNECTION to b0ts pipelines or quarantined market data (legal).
|
||||||
|
CLICKHOUSE_URL=
|
||||||
|
# "user:password" for ClickHouse basic auth; from secret refs only.
|
||||||
|
CLICKHOUSE_AUTH=
|
||||||
|
# OpenMetadata — metadata PROJECTION target (canonical stays in 0pt).
|
||||||
|
OPENMETADATA_URL=
|
||||||
|
OPENMETADATA_TOKEN=
|
||||||
|
# Feast feature server (online features path).
|
||||||
|
FEAST_SERVER_URL=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payments (Stripe) -- wired in code today: app/api/payments/*,
|
||||||
|
# app/api/webhooks/stripe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STRIPE_SECRET_KEY=sk_test_xxx
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||||
|
# Documented target (docs/08); not yet read by any route in this pass.
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Object storage -- Cloudflare R2 / S3-compatible
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R2_ENDPOINT / R2_BUCKET / R2_ACCESS_KEY_ID are read in code today; all three
|
||||||
|
# are required to enable /api/uploads, otherwise it returns 501.
|
||||||
|
# R2_ACCOUNT_ID and R2_SECRET_ACCESS_KEY are documented in docs/08 but were NOT
|
||||||
|
# found via a process.env.* read in this pass -- verify before assuming the R2
|
||||||
|
# client is fully wired.
|
||||||
|
R2_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
|
||||||
|
R2_BUCKET=opt-uploads
|
||||||
|
R2_ACCESS_KEY_ID=change-me
|
||||||
|
R2_ACCOUNT_ID=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
# 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=./.opt-blobs
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Document intelligence / OCR (Azure) -- wired in code today:
|
||||||
|
# src/services/document-extraction-service.ts. Unset => extraction
|
||||||
|
# "not configured".
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DOC_INTELLIGENCE_ENDPOINT=https://<resource>.cognitiveservices.azure.com
|
||||||
|
DOC_INTELLIGENCE_KEY=change-me
|
||||||
|
DOC_INTELLIGENCE_API_VERSION=2024-11-30
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Customer DB-grant encryption -- wired in code today:
|
||||||
|
# /api/connections/db-grant
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 32-byte key, AES-GCM. Encrypts customer-supplied read-only connection strings
|
||||||
|
# at rest.
|
||||||
|
GRANT_ENC_KEY=change-me-32-byte-key
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# External write-back targets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Odoo base URL for the bills/write-back adapter.
|
||||||
|
ODOO_BASE_URL=https://<your-odoo-host>
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test-only (tests/p0 real-infra suite; not needed to run the app)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ST0RE_TEST_TENANT_ID=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Document workbench (upload -> DOC0 -> INF0 -> validated export -> credits)
|
||||||
|
# Wired in code today: app/api/0pt/documents/*, app/api/0pt/credits/*.
|
||||||
|
# Every value is optional to boot and mandatory to USE: a missing integration
|
||||||
|
# makes its route answer 503 "not configured" instead of fabricating success.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DOC0 document service. URL must be absolute HTTPS; token is a service PAT.
|
||||||
|
DOC0_URL=https://doc0.siax.io
|
||||||
|
DOC0_SERVICE_TOKEN=
|
||||||
|
# The DOC0 service identity's own actor id (token subject). DOC0 rejects an
|
||||||
|
# x-actor-id it has no actor_grant for; 0pt keeps the end-user actor in its own
|
||||||
|
# journal and lets the DOC0 call act as the service.
|
||||||
|
DOC0_SERVICE_ACTOR_ID=
|
||||||
|
# Maximum accepted upload size (bytes). Default 25 MiB.
|
||||||
|
OPT_MAX_UPLOAD_BYTES=26214400
|
||||||
|
|
||||||
|
# Interim AI path: self-hosted Ollama on the SIAX decision server
|
||||||
|
# (server-decision-01). Internal only; no provider key and no per-token cost.
|
||||||
|
# Plain HTTP is accepted only for loopback/RFC1918/tailnet hosts.
|
||||||
|
DECISION_OLLAMA_URL=http://100.96.133.104:11434
|
||||||
|
DECISION_OLLAMA_MODEL=hermes-qwen3.5:9b
|
||||||
|
|
||||||
|
# INF0 AI gateway (model calls). No provider key is ever placed in this repo.
|
||||||
|
# Live path today: the LiteLLM gateway (OpenAI-compatible). The native
|
||||||
|
# /v1/inf0/* surface is contract-only; INF0_URL is used as a fallback.
|
||||||
|
INF0_GATEWAY_URL=https://ai-gateway.siax.io/v1
|
||||||
|
INF0_GATEWAY_LITELLM_KEY=
|
||||||
|
INF0_GATEWAY_PROVIDER_KIND=external
|
||||||
|
INF0_URL=https://api.inf0.siax.io
|
||||||
|
INF0_API_TOKEN=
|
||||||
|
|
||||||
|
# PAY0 (optional; Stripe is used directly for credit checkout today).
|
||||||
|
PAY0_URL=https://pay0.siax.io
|
||||||
|
PAY0_API_TOKEN=
|
||||||
|
|
||||||
|
# N0D / ST0RE (used by the broader pipeline; not required by the routes above).
|
||||||
|
N0D_URL=https://api.n0d.siax.io
|
||||||
|
N0D_API_TOKEN=
|
||||||
|
ST0RE_URL=https://api.st0re.siax.io
|
||||||
|
ST0RE_API_TOKEN=
|
||||||
|
|
||||||
|
# OpenFGA project authorization. Without all four values, document routes fail
|
||||||
|
# closed with PROJECT_AUTHORIZATION_NOT_CONFIGURED.
|
||||||
|
OPENFGA_API_URL=https://fga.siax.io
|
||||||
|
OPENFGA_STORE_ID=
|
||||||
|
OPENFGA_MODEL_ID=
|
||||||
|
OPENFGA_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Transitional, server-owned project grants used only when OpenFGA is not
|
||||||
|
# configured. JSON array; actorId "*" means every verified actor in the tenant.
|
||||||
|
# OPT_PROJECT_GRANTS=[{"tenantId":"379976258701823235","projectId":"prj_demo","actorId":"*"}]
|
||||||
|
OPT_PROJECT_GRANTS=
|
||||||
|
|
||||||
|
# Model policy for the document workbench. Without this, structuring answers
|
||||||
|
# MODEL_POLICY_NOT_CONFIGURED.
|
||||||
|
OPT_ALLOWED_MODELS=
|
||||||
|
OPT_ALLOWED_DATA_CLASSES=none,low
|
||||||
|
OPT_MODEL_DESTINATION=external
|
||||||
|
OPT_EXTERNAL_TRANSFER_ALLOWED=false
|
||||||
|
OPT_MODEL_MAX_INPUT_TOKENS=60000
|
||||||
|
OPT_MODEL_MAX_OUTPUT_TOKENS=4000
|
||||||
|
|
||||||
|
# Server-owned, versioned credit price book (JSON, one line). No price is ever
|
||||||
|
# read from a browser. Without it, credit packs/checkout/structuring answer
|
||||||
|
# PRICE_BOOK_NOT_CONFIGURED.
|
||||||
|
# {"version":"2026-11-01","currency":"SEK","packs":[{"id":"starter","credits":100,"amountMinor":9900}],"metering":{"pagesPerCredit":5,"inputTokensPerCredit":20000,"outputTokensPerCredit":4000}}
|
||||||
|
OPT_CREDIT_PRICE_BOOK=
|
||||||
|
|
||||||
|
# Pilot switch. `off` runs document structuring without a wallet; it is
|
||||||
|
# explicit (never a silent fallback), the response reports `credits: null`, and
|
||||||
|
# purchases stay disabled. Unset/`on` requires the price book + ledger.
|
||||||
|
OPT_CREDITS_MODE=
|
||||||
|
|
||||||
|
# One-time trial credits granted per tenant (server-owned, idempotent, NOT a
|
||||||
|
# payment). 0 disables. With OPT_CREDITS_MODE=on this lets reserve/settle run
|
||||||
|
# in production before any customer purchase.
|
||||||
|
OPT_TRIAL_CREDITS=0
|
||||||
|
|
||||||
|
# Central Keycloak (SIAX Portal)
|
||||||
|
KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal
|
||||||
|
KEYCLOAK_EXPECTED_AUDIENCE=account
|
||||||
|
KEYCLOAK_AUTHORIZED_PARTY=siax-portal
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Normalize line endings for text files; keep binaries untouched.
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Source / config
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.mjs text eol=lf
|
||||||
|
*.cjs text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.jsonc text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.dockerfile text eol=lf
|
||||||
|
|
||||||
|
# Binaries / generated — never text-normalized
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.svg text eol=lf
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.pyc binary
|
||||||
|
*.pyo binary
|
||||||
|
|
||||||
|
# Lockfiles — keep exactly as generated
|
||||||
|
pnpm-lock.yaml -text
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# CI-14 — CODEOWNERS bor i .gitea/, aldrig i .github/ (SEC-25).
|
||||||
|
# Granskning krävs särskilt för kontrakt, deploy och migrationer.
|
||||||
|
|
||||||
|
* @sax3l
|
||||||
|
/contracts/ @sax3l
|
||||||
|
/deploy/ @sax3l
|
||||||
|
/.gitea/ @sax3l
|
||||||
|
/siax.repo.json @sax3l
|
||||||
|
/siax.config.json @sax3l
|
||||||
|
# TODO: lägg till migrationskatalogen när den finns, t.ex.
|
||||||
|
# /packages/db/migrations/ @sax3l
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
name: Deploy to Coolify
|
||||||
|
|
||||||
|
# Follows the fleet-wide pattern used by sax3l/n0d and sax3l/st0re: push to
|
||||||
|
# main triggers a POST to Coolify's deploy endpoint for this app's UUID,
|
||||||
|
# authenticated with the COOLIFY_TOKEN repo secret. Coolify already has the
|
||||||
|
# Dockerfile (build_pack: dockerfile) -- this workflow only fires the
|
||||||
|
# rebuild+redeploy, it doesn't build or push an image itself.
|
||||||
|
#
|
||||||
|
# STATUS (verified 2026-11-02): LIVE. The Coolify application exists
|
||||||
|
# (dockerfile build pack, git.siax.io/sax3l/0pt, branch main) and its UUID is
|
||||||
|
# supplied by the repository variable COOLIFY_APP_ID; COOLIFY_TOKEN and the
|
||||||
|
# registry/cosign secrets are set. A push to main, or a manual
|
||||||
|
# workflow_dispatch of this file, has been proven to verify the signed
|
||||||
|
# attestation for the exact commit and then return HTTP 200 from Coolify.
|
||||||
|
# The placeholder guard below remains as a fail-closed guard, not as a TODO.
|
||||||
|
# Operational procedure and verification live in docs/runbook/deploy.md.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Never let two deploys race the same Coolify app. Unlike CI we do NOT cancel an
|
||||||
|
# in-flight deploy (it may already be restarting the container); the newest
|
||||||
|
# commit still wins because Coolify builds from the branch head.
|
||||||
|
concurrency:
|
||||||
|
group: deploy-0pt-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
COOLIFY_APP_ID: ${{ vars.COOLIFY_APP_ID || 'TODO-no-coolify-app-provisioned-yet' }}
|
||||||
|
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
|
||||||
|
steps:
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# SIGNED-ARTIFACT GATE (added 2026-09-14, same pattern as
|
||||||
|
# sax3l/cl0ud + sax3l/st0re's fix/wire-signed-digest-to-deploy; this
|
||||||
|
# job's own wait-for-signed-artifact polling logic below predates and
|
||||||
|
# is independent of the 2026-09-15 G17 migration, and is KEPT as-is
|
||||||
|
# here -- it is a real, working, 0pt-specific mechanism, not part of
|
||||||
|
# what that migration delegates to siax-templates): once the guard
|
||||||
|
# step below stops failing (i.e. once a real Coolify app exists for
|
||||||
|
# 0pt and COOLIFY_APP_ID is filled in), this job will REFUSE to call
|
||||||
|
# Coolify at all unless a verified attestation exists in the registry
|
||||||
|
# for the EXACT commit being deployed. `.gitea/workflows/release.yml`
|
||||||
|
# is the piece that builds, SBOMs, scans, attests, and pushes that
|
||||||
|
# image (rewritten 2026-09-15 to delegate to sax3l/siax-templates'
|
||||||
|
# G17 reference hook -- see `.siax/hooks/README.md`).
|
||||||
|
#
|
||||||
|
# VERIFY STEP CHANGED 2026-09-15 (part of the G17 migration): the old
|
||||||
|
# release.yml called `cosign sign` on the bare digest, so this job's
|
||||||
|
# gate used `cosign verify`. The new release.yml calls `cosign attest`
|
||||||
|
# instead -- a full DSSE-signed in-toto Statement binding digest +
|
||||||
|
# source SHA + build identity + environment + SBOM hash (ADR-W16-003
|
||||||
|
# bar items 4/5), not just a bare-digest signature. `cosign verify`
|
||||||
|
# against a `cosign attest`-only artifact fails with "no matching
|
||||||
|
# signatures" (attestations and signatures are stored/verified
|
||||||
|
# separately in cosign's model) -- so this step now calls
|
||||||
|
# `cosign verify-attestation --type <predicate-type>` instead, the
|
||||||
|
# same command `.siax/hooks/G17.sh` itself already runs and requires
|
||||||
|
# to PASS before it will submit evidence to AUD0.
|
||||||
|
#
|
||||||
|
# HONEST LIMITATION (identical to cl0ud/st0re/h0st's writeup):
|
||||||
|
# `siax.config.json`'s `deploy.target: "coolify"` and this file's own
|
||||||
|
# `/api/v1/deploy` call assume a `dockerfile` build-pack Application
|
||||||
|
# once provisioned -- same as cl0ud/st0re/h0st, Coolify would clone
|
||||||
|
# this repo and run its OWN independent `docker build`, never pulling
|
||||||
|
# the image release.yml pushed and signed, and `/api/v1/deploy` has no
|
||||||
|
# digest parameter. True digest-pinning would need 0pt's eventual
|
||||||
|
# Coolify app migrated to the separate "Docker Image" resource type --
|
||||||
|
# a production infrastructure decision that hasn't been made yet
|
||||||
|
# (0pt isn't even provisioned on Coolify at all as of this writing;
|
||||||
|
# see the guard step below and docs/runbook/deploy.md §1), not
|
||||||
|
# something this CI-YAML change can or should decide.
|
||||||
|
#
|
||||||
|
# So: what this DOES guarantee, from the day 0pt's Coolify app exists
|
||||||
|
# -- Coolify is only ever told to rebuild+redeploy a commit whose
|
||||||
|
# release-chain artifact was built, SBOM'd, signed, and had its
|
||||||
|
# signature RE-verified in THIS job. What it does NOT guarantee --
|
||||||
|
# bit-identical bytes between the signed image and whatever Coolify's
|
||||||
|
# own rebuild produces from the same source.
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
- name: Guard against unset token / placeholder app id
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.COOLIFY_TOKEN }}" ]; then
|
||||||
|
echo "::error::COOLIFY_TOKEN secret is not set on this repo -- cannot redeploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$COOLIFY_APP_ID" = "TODO-no-coolify-app-provisioned-yet" ]; then
|
||||||
|
echo "::error::No Coolify app has been provisioned for 0pt yet. Create the app in the Coolify UI (cloud.siax.io), copy its UUID into COOLIFY_APP_ID in this file, and set the COOLIFY_TOKEN repo secret before this workflow can deploy anything."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve this commit's release-chain tag (same scheme as release.yml)
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
short="$(git rev-parse --short=12 HEAD)"
|
||||||
|
echo "short=$short" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image_ref=${IMAGE_REPO}:sha-$short" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker login (git.cloud.siax.io) — read access to check release.yml's output
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
registry_host="$(echo "$IMAGE_REPO" | cut -d/ -f1)"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$registry_host" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Install cosign v3.1.3 (pinned, checksum-verified — never :latest)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd /tmp
|
||||||
|
curl -fsSL -O https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64
|
||||||
|
curl -fsSL -O https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign_checksums.txt
|
||||||
|
grep 'cosign-linux-amd64$' cosign_checksums.txt | sha256sum -c -
|
||||||
|
install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
|
||||||
|
cosign version
|
||||||
|
|
||||||
|
- name: Wait for release.yml's signed artifact for THIS commit, then verify (fail closed)
|
||||||
|
id: verify
|
||||||
|
env:
|
||||||
|
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
|
||||||
|
SHORT: ${{ steps.sha.outputs.short }}
|
||||||
|
PREDICATE_TYPE: https://siax.io/attestations/g17-release-evidence/v1
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -f cosign.pub || { echo "::error::cosign.pub not found at repo root — cannot verify, refusing to deploy"; exit 1; }
|
||||||
|
|
||||||
|
# release.yml runs as a SEPARATE workflow triggered by the same push
|
||||||
|
# (Gitea/GitHub Actions has no cross-workflow-file `needs:`), so this
|
||||||
|
# job cannot depend on that job directly — it polls the registry for
|
||||||
|
# the artifact release.yml produces instead. Bounded: a genuinely
|
||||||
|
# broken/skipped release run fails this job loudly rather than
|
||||||
|
# hanging forever.
|
||||||
|
#
|
||||||
|
# The loop retries TWO distinct not-ready states (measured live on
|
||||||
|
# run 2265dbb 2026-09-15, under the OLD bare-signature gate — the
|
||||||
|
# same race applies to attestation-attach timing today): tag not
|
||||||
|
# pushed yet (release still building) vs. tag pushed but the
|
||||||
|
# attestation not attached yet (release between push and
|
||||||
|
# `cosign attest`).
|
||||||
|
attempt=0
|
||||||
|
max_attempts=40
|
||||||
|
digest=""
|
||||||
|
while true; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||||
|
echo "::error::no signed image found at $IMAGE_REF after $max_attempts attempts (10 min) — release.yml has not produced a signed artifact for sha-$SHORT. Refusing to deploy an unverified rebuild."
|
||||||
|
cat /tmp/pull.log 2>/dev/null || true
|
||||||
|
cat /tmp/verify.log 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker pull "$IMAGE_REF" >/tmp/pull.log 2>&1; then
|
||||||
|
echo "image not yet available at $IMAGE_REF (attempt $attempt/$max_attempts) — waiting for release.yml..."
|
||||||
|
sleep 15
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read back the digest the registry actually stored for this tag —
|
||||||
|
# same filter release.yml itself uses (RepoDigests can hold multiple
|
||||||
|
# entries; never trust index 0) — and verify THAT digest, never the
|
||||||
|
# mutable tag alone, so a tag reassigned after signing can't slip past.
|
||||||
|
repo_digest="$(docker inspect --format='{{json .RepoDigests}}' "$IMAGE_REF" \
|
||||||
|
| node -e "const rd=JSON.parse(require('fs').readFileSync(0,'utf8')); const prefix=process.env.IMAGE_REPO+'@'; const m=rd.find(r=>r.startsWith(prefix)); if(!m){console.error('no RepoDigests entry starting with '+prefix+' among: '+JSON.stringify(rd));process.exit(1);} console.log(m);")"
|
||||||
|
digest="${repo_digest##*@}"
|
||||||
|
echo "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "::error::unexpected digest shape: $digest"; exit 1; }
|
||||||
|
|
||||||
|
echo "verifying cosign attestation on ${IMAGE_REPO}@${digest} ..."
|
||||||
|
if cosign verify-attestation --key cosign.pub --type "$PREDICATE_TYPE" "${IMAGE_REPO}@${digest}" >/tmp/verify.log 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if grep -qE "no matching (attestations|signatures)( found)?" /tmp/verify.log; then
|
||||||
|
echo "image present but attestation not attached yet (attempt $attempt/$max_attempts) — waiting for release.yml's cosign attest..."
|
||||||
|
sleep 15
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "::error::cosign verify-attestation FAILED for ${IMAGE_REPO}@${digest} (not a not-ready state — refusing to deploy)"
|
||||||
|
cat /tmp/verify.log
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "digest=$digest" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "SIGNED ARTIFACT VERIFIED: ${IMAGE_REPO}@${digest} — authorizing Coolify redeploy of sha-$SHORT"
|
||||||
|
|
||||||
|
- name: Trigger Coolify redeploy
|
||||||
|
env:
|
||||||
|
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
||||||
|
VERIFIED_DIGEST: ${{ steps.verify.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
# See the SIGNED-ARTIFACT GATE comment above: Coolify's /deploy has
|
||||||
|
# no digest/image param to pass, so $VERIFIED_DIGEST is not (and
|
||||||
|
# cannot be) sent to Coolify — it is recorded here as the
|
||||||
|
# provenance proof that authorized this specific redeploy call.
|
||||||
|
echo "authorized by verified digest: $VERIFIED_DIGEST"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"https://cloud.siax.io/api/v1/deploy?uuid=${COOLIFY_APP_ID}&force=false" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
-o /dev/null -w 'coolify deploy HTTP %{http_code}\n'
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
name: CI (SIAX Cloud)
|
||||||
|
|
||||||
|
# Self-hosted CI on SIAX Cloud (Gitea Actions, runners siax-runner-1/2).
|
||||||
|
# Quality gate for the Next.js 16 web console + the 0pt capability layer.
|
||||||
|
#
|
||||||
|
# * typecheck (BLOCKING) -> `tsc -p tsconfig.check.json` over src/ + tests/.
|
||||||
|
# * test (BLOCKING) -> `vitest run`.
|
||||||
|
# * lint (ADVISORY) -> `eslint src tests` (legacy v0-generated noise
|
||||||
|
# is inherited debt; continue-on-error so it never blocks).
|
||||||
|
# * build (BLOCKING) -> `next build`.
|
||||||
|
#
|
||||||
|
# * security (BLOCKING) -> `trivy fs` over the repo; fails the build on
|
||||||
|
# HIGH/CRITICAL vulns or detected secrets. This is a REQUIRED
|
||||||
|
# protected-branch context -- see the job's own comment.
|
||||||
|
#
|
||||||
|
# Nightly re-runs live in nightly.yml; security.yml is the dedicated
|
||||||
|
# push/PR/weekly scanner (this file is push/PR-only).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# One run per ref; a branch push that also updates a PR used to start two full
|
||||||
|
# quality runs (and, on main, two deploy chains). Cancel the superseded one.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
- name: Enable corepack + pin pnpm@9 (pnpm-lock.yaml is lockfileVersion 9.0)
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9 --activate
|
||||||
|
- name: Install (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Typecheck (tsc over src/ + tests/ -- blocking)
|
||||||
|
run: pnpm run typecheck
|
||||||
|
# UI-09: 0pt carried 82 axe color-contrast violations (impact=serious), the
|
||||||
|
# estate's largest single a11y defect, and every one came from a Tailwind
|
||||||
|
# alpha modifier on a text colour. The unmodified token measures 4.73:1
|
||||||
|
# light / 7.63:1 dark and passes AA; every faded variant fails. Blocking,
|
||||||
|
# because a contrast regression is invisible in review.
|
||||||
|
- name: Text contrast shape (WCAG 2.2 AA 1.4.3 -- blocking)
|
||||||
|
run: pnpm run check:contrast
|
||||||
|
- name: Test (vitest -- blocking)
|
||||||
|
run: pnpm run test
|
||||||
|
- name: Lint (eslint -- advisory; legacy tree has pre-existing noise)
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm run lint
|
||||||
|
- name: Build (next build -- blocking quality gate)
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
# REAL-DEPENDENCY integration tests (Master-DoD §48: "integration tests ...
|
||||||
|
# Mocks alone count as insufficient"; §10 Search/Analytics). Runs
|
||||||
|
# tests/integration/* against a LIVE OpenSearch + LIVE ClickHouse started as
|
||||||
|
# service containers.
|
||||||
|
#
|
||||||
|
# SERVICE-CONTAINER DISCIPLINE is copied from sax3l/st0re's ci.yml, which
|
||||||
|
# documents three measured defects on these runners (alias collision across
|
||||||
|
# repos, sibling-run collision behind one alias, and DNS round-robin):
|
||||||
|
# * repo-unique aliases (opt-ci-opensearch / opt-ci-clickhouse),
|
||||||
|
# * SELF-IDENTIFYING containers — OpenSearch carries a run-unique
|
||||||
|
# cluster.name; ClickHouse carries run-unique credentials — so the
|
||||||
|
# select step can positively identify THIS run's container,
|
||||||
|
# * enumerate every IP behind the alias (getent ahostsv4), keep the one
|
||||||
|
# that proves the run identity, freeze it into the URL env var,
|
||||||
|
# * no match = hard failure, never a silent fallback or skip.
|
||||||
|
integration:
|
||||||
|
# Gitea currently ignores workflow concurrency. A branch push followed by
|
||||||
|
# its PR synchronize event otherwise starts TWO 512 MiB OpenSearch JVMs
|
||||||
|
# and ClickHouse suites on the same runner fleet at once; both timed out
|
||||||
|
# creating their first index (runs 28904/28906). Keep the blocking LIVE
|
||||||
|
# integration proof on every PR, on main push, and on manual dispatch;
|
||||||
|
# ordinary branch pushes still run blocking quality/security.
|
||||||
|
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
services:
|
||||||
|
opt-ci-opensearch:
|
||||||
|
image: opensearchproject/opensearch:2.11.1
|
||||||
|
env:
|
||||||
|
discovery.type: single-node
|
||||||
|
DISABLE_SECURITY_PLUGIN: "true"
|
||||||
|
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||||
|
# Identity badge: only THIS run's container answers with this
|
||||||
|
# cluster_name (the OpenSearch image maps dotted env keys to
|
||||||
|
# settings).
|
||||||
|
cluster.name: opt_ci_${{ github.run_id }}_${{ github.run_attempt }}
|
||||||
|
# Throwaway CI cluster: never let the runner's disk fill turn indices
|
||||||
|
# read-only (the 403 index_create_block_exception seen in run 388536).
|
||||||
|
cluster.routing.allocation.disk.threshold_enabled: "false"
|
||||||
|
# `--workdir` is REQUIRED (measured 2026-08-31, PR #25 CI run 7745,
|
||||||
|
# job "integration"): this runner's act_runner (v0.6.1) merges the
|
||||||
|
# JOB's WorkingDir (`/workspace/sax3l/0pt`) into every SERVICE
|
||||||
|
# container's config, clobbering the image's own `WORKDIR
|
||||||
|
# /usr/share/opensearch`. OpenSearch's ENTRYPOINT is the
|
||||||
|
# WORKDIR-relative `./opensearch-docker-entrypoint.sh`, so with the
|
||||||
|
# wrong cwd the container failed with "exec:
|
||||||
|
# \"./opensearch-docker-entrypoint.sh\": stat ...: no such file or
|
||||||
|
# directory" before it ever bound a port. ClickHouse's entrypoint is
|
||||||
|
# an absolute path, so it was unaffected by the same defect — do not
|
||||||
|
# assume a green ClickHouse service means service-container options
|
||||||
|
# are fine in general.
|
||||||
|
options: >-
|
||||||
|
--workdir /usr/share/opensearch
|
||||||
|
--health-cmd "curl -fsS http://localhost:9200/_cluster/health || exit 1"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 60
|
||||||
|
opt-ci-clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:24.12
|
||||||
|
env:
|
||||||
|
CLICKHOUSE_USER: opt_ci
|
||||||
|
# Identity badge: run-unique password — an authenticated SELECT only
|
||||||
|
# succeeds against THIS run's container. CI-scoped throwaway value,
|
||||||
|
# not a secret (exists only for the lifetime of the job).
|
||||||
|
CLICKHOUSE_PASSWORD: ci_${{ github.run_id }}_${{ github.run_attempt }}
|
||||||
|
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
|
||||||
|
options: >-
|
||||||
|
--health-cmd "wget -qO- http://localhost:8123/ping || exit 1"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
- name: Enable corepack + pin pnpm@9
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9 --activate
|
||||||
|
- name: Install (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Select THIS run's OpenSearch by identity (st0re ci.yml pattern)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
WANT="opt_ci_${{ github.run_id }}_${{ github.run_attempt }}"
|
||||||
|
FOUND=""
|
||||||
|
for ip in $(getent ahostsv4 opt-ci-opensearch | awk '{print $1}' | sort -u); do
|
||||||
|
NAME=$(curl -fsS --max-time 5 "http://$ip:9200/" | sed -n 's/.*"cluster_name" *: *"\([^"]*\)".*/\1/p' || true)
|
||||||
|
echo "candidate $ip cluster_name=${NAME:-<none>}"
|
||||||
|
if [ "$NAME" = "$WANT" ]; then FOUND="$ip"; fi
|
||||||
|
done
|
||||||
|
[ -n "$FOUND" ] || { echo "FATAL: no OpenSearch behind the alias identifies as $WANT"; exit 1; }
|
||||||
|
|
||||||
|
# The single-node test cluster inherits the runner's disk watermark.
|
||||||
|
# On a busy runner the flood-stage watermark makes every new index
|
||||||
|
# read-only and the suite fails with HTTP 403
|
||||||
|
# index_create_block_exception (measured on run 388536). The container
|
||||||
|
# is throwaway CI state, so disable the disk threshold and PROVE that
|
||||||
|
# index creation works before the blocking tests run.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
# Disabling the watermark alone does NOT clear a cluster that is
|
||||||
|
# already read-only: the flood stage sets
|
||||||
|
# cluster.blocks.read_only_allow_delete and every write keeps
|
||||||
|
# returning 403. Clear that block explicitly every attempt.
|
||||||
|
curl -sS --max-time 10 -X PUT "http://$FOUND:9200/_cluster/settings" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"persistent":{"cluster.routing.allocation.disk.threshold_enabled":false,"cluster.blocks.read_only_allow_delete":null}}' >/tmp/os-settings.json || true
|
||||||
|
cat /tmp/os-settings.json 2>/dev/null || true
|
||||||
|
probe_status=$(curl -sS --max-time 10 -o /tmp/os-probe.json -w '%{http_code}' -X PUT "http://$FOUND:9200/opt-ci-probe-${{ github.run_id }}")
|
||||||
|
if [ "$probe_status" = "200" ]; then
|
||||||
|
curl -fsS --max-time 10 -X DELETE "http://$FOUND:9200/opt-ci-probe-${{ github.run_id }}" >/dev/null || true
|
||||||
|
echo "OpenSearch accepts index creation"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "index creation not ready (attempt $attempt/5, HTTP $probe_status) — waiting"
|
||||||
|
cat /tmp/os-probe.json 2>/dev/null || true
|
||||||
|
[ "$attempt" = "5" ] && { echo "FATAL: OpenSearch still refuses index creation"; exit 1; }
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "OPENSEARCH_URL=http://$FOUND:9200" >> "$GITHUB_ENV"
|
||||||
|
- name: Select THIS run's ClickHouse by identity
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
PASS="ci_${{ github.run_id }}_${{ github.run_attempt }}"
|
||||||
|
FOUND=""
|
||||||
|
for ip in $(getent ahostsv4 opt-ci-clickhouse | awk '{print $1}' | sort -u); do
|
||||||
|
OK=$(curl -fsS --max-time 5 -u "opt_ci:$PASS" "http://$ip:8123/?query=SELECT%201" || true)
|
||||||
|
echo "candidate $ip auth_select=${OK:-<denied>}"
|
||||||
|
if [ "$OK" = "1" ]; then FOUND="$ip"; fi
|
||||||
|
done
|
||||||
|
[ -n "$FOUND" ] || { echo "FATAL: no ClickHouse behind the alias accepts this run's credentials"; exit 1; }
|
||||||
|
echo "CLICKHOUSE_URL=http://$FOUND:8123" >> "$GITHUB_ENV"
|
||||||
|
echo "CLICKHOUSE_AUTH=opt_ci:$PASS" >> "$GITHUB_ENV"
|
||||||
|
# The anti-skip guard here used to grep vitest's HUMAN output:
|
||||||
|
#
|
||||||
|
# grep -qE "skipped" && ! grep -qE "Tests [0-9]+ passed"
|
||||||
|
#
|
||||||
|
# vitest colours that summary, so the real line on this runner is
|
||||||
|
# ESC[2m Tests ESC[22m ESC[1mESC[32m97 passedESC[39m ... — ANSI escapes
|
||||||
|
# sit between "Tests " and the digits, so the two-spaces-then-digit
|
||||||
|
# pattern can never match. The first grep still matched "skipped", so
|
||||||
|
# the guard fired on ANY run containing a single skipped test while
|
||||||
|
# printing "integration suites skipped instead of running" — a false
|
||||||
|
# statement. Measured 2026-09-08, run 13911 job 31183: 13 files / 97
|
||||||
|
# tests PASSED, 1 file / 2 tests skipped → FATAL anyway.
|
||||||
|
#
|
||||||
|
# It survived review because it is correct locally: redirecting to a
|
||||||
|
# file disables vitest's colour, so the pattern matches on a developer
|
||||||
|
# machine and only misfires under CI.
|
||||||
|
#
|
||||||
|
# Replaced with the JSON reporter — structured and colourless. A/B
|
||||||
|
# measured on real reports, BOTH directions:
|
||||||
|
# all-skipped (0 passed / 99 pending) → RED (protection preserved)
|
||||||
|
# 945 passed + 184 skipped → GREEN (false red removed)
|
||||||
|
- name: Integration tests against LIVE OpenSearch + ClickHouse (blocking)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# Env MUST be present here — the suites self-skip without it, and a
|
||||||
|
# skipped suite must never masquerade as a green integration gate
|
||||||
|
# (Master-DoD §56 status honesty).
|
||||||
|
[ -n "${OPENSEARCH_URL:-}" ] && [ -n "${CLICKHOUSE_URL:-}" ]
|
||||||
|
pnpm vitest run tests/integration \
|
||||||
|
--reporter=default \
|
||||||
|
--reporter=json --outputFile.json=/tmp/itest.json
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const p = "/tmp/itest.json";
|
||||||
|
if (!fs.existsSync(p)) {
|
||||||
|
console.error("FATAL: vitest produced no JSON report"); process.exit(1);
|
||||||
|
}
|
||||||
|
const r = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||||
|
console.log(`integration: ${r.numPassedTests} passed, ${r.numPendingTests} skipped, ${r.numFailedTests} failed, ${r.numTotalTests} total`);
|
||||||
|
if (r.numFailedTests > 0) {
|
||||||
|
console.error("FATAL: integration tests failed"); process.exit(1);
|
||||||
|
}
|
||||||
|
if (r.numPassedTests === 0) {
|
||||||
|
console.error("FATAL: no integration test actually executed — every suite skipped");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
# BLOCKING. This job publishes the protected-branch status check context
|
||||||
|
# "CI (SIAX Cloud) / security (push)", which branch protection on `main`
|
||||||
|
# lists as required alongside "CI (SIAX Cloud) / quality (push)".
|
||||||
|
#
|
||||||
|
# It previously ran Trivy with `--exit-code 0`, which made the REQUIRED
|
||||||
|
# context incapable of ever reporting failure. Measured 2026-09-08 on
|
||||||
|
# main @4514d97: a planted CRITICAL secret (AWS secret access key) was
|
||||||
|
# DETECTED by the scanner and the job still exited 0 -- i.e. the gate
|
||||||
|
# reported success while holding a critical finding in hand. The same
|
||||||
|
# command with `--exit-code 1` exits 1 on that planted secret and exits 0
|
||||||
|
# on the clean tree, so this flag is the whole difference between a real
|
||||||
|
# gate and a decorative one.
|
||||||
|
#
|
||||||
|
# KNOWN COVERAGE GAP (not fixed here, needs an owner decision):
|
||||||
|
# services/scrapers/requirements.txt pins nothing (`>=` ranges only), so
|
||||||
|
# Trivy's pip scanner cannot resolve versions and silently omits the file
|
||||||
|
# from the scan entirely -- it is not a target in the report at all.
|
||||||
|
# Pinning those same floors with `==` surfaces 3 HIGH findings today
|
||||||
|
# (aiohttp CVE-2025-69223, aiohttp CVE-2026-69244, lxml CVE-2026-41066).
|
||||||
|
# Do NOT paper over this with `--skip-dirs`; that would convert a
|
||||||
|
# measurable blind spot into declared policy.
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Trivy
|
||||||
|
run: |
|
||||||
|
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
# `siax/sbom/` är CycloneDX-evidenens för REDAN BYGGDA images (release-kedjan),
|
||||||
|
# inte källberoenden. Att scanna dem här återrapporterar gamla image-versioner
|
||||||
|
# (next 16.3.x, sharp 0.35.4) och blockerar källfixar som redan är patchade.
|
||||||
|
# Image-SBOM:erna verifieras i release/image-pipelinen.
|
||||||
|
- name: Trivy scan (vuln + secrets -- BLOCKING, fails on HIGH/CRITICAL)
|
||||||
|
run: |
|
||||||
|
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
|
||||||
|
|
||||||
|
sonar:
|
||||||
|
# SonarQube Community Build har inget separat PR-analysläge (en enda "main"-gren
|
||||||
|
# per projekt) — en analys från en PR-branch eller en tagg-push som inte pekar på
|
||||||
|
# main skulle skriva över kvalitetshistoriken. Kör därför bara
|
||||||
|
# vid push till main.
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: SonarQube analysis (self-hosted sonar.siax.dev)
|
||||||
|
env:
|
||||||
|
SONAR_HOST_URL: https://sonar.siax.dev
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$SONAR_TOKEN" || { echo "::error::SONAR_TOKEN repo secret missing"; exit 1; }
|
||||||
|
# Jobbet kör i en container på act_runner; "docker run -v \"\$PWD:...\""
|
||||||
|
# skulle montera VÄRDENS (tomma) sökväg. Kopiera in källträdet istället.
|
||||||
|
cid=$(docker create -e SONAR_HOST_URL -e SONAR_TOKEN -w /usr/src \
|
||||||
|
sonarsource/sonar-scanner-cli:latest \
|
||||||
|
sonar-scanner -Dsonar.projectKey=0pt -Dsonar.host.url="$SONAR_HOST_URL")
|
||||||
|
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||||
|
docker cp . "$cid":/usr/src
|
||||||
|
docker start -a "$cid"
|
||||||
|
|
||||||
|
design-conformance:
|
||||||
|
name: Component conformance (blocking)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- name: Checkout canonical siax-standard (SSOT)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: admin/siax-standard
|
||||||
|
token: ${{ secrets.SIAX_CI_TOKEN }}
|
||||||
|
path: siax-standard-ssot
|
||||||
|
- name: Component conformance (blocking)
|
||||||
|
run: |
|
||||||
|
node --input-type=module -e "import('./siax-standard-ssot/packages/doctor/src/commands/component-conformance.mjs').then(m=>{process.exit(m.run(['--repo', process.env.GITHUB_WORKSPACE, '--allow-path-prefix', 'siax-standard-ssot/', '--fail-on=block']))})"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Health Check (SIAX Cloud)
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
jobs:
|
||||||
|
health:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Required files
|
||||||
|
run: |
|
||||||
|
for f in README.md LICENSE .gitignore; do [ -f "$f" ] || echo "::warning file=$f,title=Missing::$f is missing"; done
|
||||||
|
[ -f .gitea/workflows/ci.yml ] || echo "::warning title=No CI::No CI workflow found"
|
||||||
|
- name: Install Trivy
|
||||||
|
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
- name: Secret scan
|
||||||
|
run: trivy fs --scanners secret --exit-code 0 .
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: masterplan-lock
|
||||||
|
|
||||||
|
# Fails the build if this repo's copy of the SIAX Non-Platform masterplan
|
||||||
|
# (PLAN/MASTERPLAN_INDEX.md) is deleted or its locked architecture
|
||||||
|
# principles are stripped out. Dependency-free (POSIX sh) so it needs no
|
||||||
|
# language toolchain. See scripts/check-masterplan-lock.sh and
|
||||||
|
# sax3l/siax-masterplan.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Masterplan lock check
|
||||||
|
run: sh scripts/check-masterplan-lock.sh
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Nightly (SIAX Cloud)
|
||||||
|
|
||||||
|
# Nightly full-gate re-run so CI exercises the repo even without pushes:
|
||||||
|
# quality gate + advisory security scan. Fails loudly on regressions
|
||||||
|
# introduced outside push-triggered runs (e.g. dependency drift).
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 4 * * *" # daily 04:00 UTC
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: nightly-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
- name: Enable corepack + pin pnpm@9
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9 --activate
|
||||||
|
- name: Install (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Typecheck (blocking)
|
||||||
|
run: pnpm run typecheck
|
||||||
|
- name: Test (blocking)
|
||||||
|
run: pnpm run test
|
||||||
|
- name: Lint (advisory)
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm run lint
|
||||||
|
- name: Build (blocking)
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Trivy
|
||||||
|
run: |
|
||||||
|
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
# A nightly scan exists to catch CVEs published against code that did
|
||||||
|
# not change. With `--exit-code 0` it could never report failure, so it
|
||||||
|
# could never raise that alarm -- the one job whose entire purpose is
|
||||||
|
# detection was the one guaranteed to stay green. Fixed 2026-09-08.
|
||||||
|
- name: Trivy fs scan (vuln + secrets -- fails on HIGH/CRITICAL)
|
||||||
|
run: |
|
||||||
|
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Production smoke (0pt.app)
|
||||||
|
|
||||||
|
# Automated end-to-end delivery verification. Every 30 minutes (and on demand)
|
||||||
|
# this checks that the live service is actually ready and that the running
|
||||||
|
# container is the commit that main points at — so a green deploy is never
|
||||||
|
# confused with a healthy, current service.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "*/30 * * * *"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: prod-smoke
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Readiness (retry on a fresh deploy)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for attempt in 1 2 3 4 5 6; do
|
||||||
|
body="$(curl -fsS --max-time 15 https://0pt.app/api/ready || true)"
|
||||||
|
echo "attempt $attempt: ${body:-<no response>}"
|
||||||
|
if printf '%s' "$body" | grep -q '"ready":true'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
echo "::error::0pt.app/api/ready never reported ready:true"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Health envelope
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
body="$(curl -fsS --max-time 15 https://0pt.app/api/health)"
|
||||||
|
echo "$body"
|
||||||
|
printf '%s' "$body" | grep -q '"db":"ok"' || { echo "::error::database is not ok"; exit 1; }
|
||||||
|
printf '%s' "$body" | grep -q '"stripe":"ok"' || { echo "::error::stripe is not ok"; exit 1; }
|
||||||
|
|
||||||
|
- name: Deployed commit equals main
|
||||||
|
env:
|
||||||
|
SIAX_CI_TOKEN: ${{ secrets.SIAX_CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
version="$(curl -fsS --max-time 15 https://0pt.app/api/version)"
|
||||||
|
echo "$version"
|
||||||
|
running="$(printf '%s' "$version" | sed -n 's/.*"commit":"\([0-9a-f]*\)".*/\1/p')"
|
||||||
|
[ -n "$running" ] || { echo "::error::/api/version returned no commit"; exit 1; }
|
||||||
|
if [ -z "${SIAX_CI_TOKEN:-}" ]; then
|
||||||
|
echo "::warning::SIAX_CI_TOKEN is not set — cannot compare with main; commit $running is live"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
expected="$(curl -fsS --max-time 15 \
|
||||||
|
-H "Authorization: token ${SIAX_CI_TOKEN}" \
|
||||||
|
"https://git.siax.io/api/v1/repos/admin/0pt/commits?limit=1" \
|
||||||
|
| python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["sha"])')"
|
||||||
|
echo "running=$running expected(main)=$expected"
|
||||||
|
[ -n "$expected" ] || { echo "::error::could not read main SHA from Gitea"; exit 1; }
|
||||||
|
[ "$running" = "$expected" ] || {
|
||||||
|
echo "::error::production is $running but main is $expected — deploy did not land"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Document pipeline surface responds (auth required, never 404/5xx)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for path in /api/0pt/documents/upload /api/0pt/documents/structure /api/0pt/documents/models /api/0pt/credits/balance; do
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST "https://0pt.app${path}")"
|
||||||
|
echo "$path -> $code"
|
||||||
|
case "$code" in
|
||||||
|
401|405) ;; # unauthenticated or method guard: the route exists
|
||||||
|
*) echo "::error::$path returned $code (expected 401/405)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
name: Release chain (G17, ADR-W16-003)
|
||||||
|
|
||||||
|
# Builds + pushes the 0pt image, then hands off to the shared
|
||||||
|
# sax3l/siax-templates G17 reference hook for every ADR-W16-003 bar item
|
||||||
|
# 1-7 (pinned-tool install, SBOM, blocking vuln scan, in-toto attestation,
|
||||||
|
# scoped-key signing, pubkey verification, AUD0/ST0RE evidence storage).
|
||||||
|
#
|
||||||
|
# REPLACES this repo's own ad hoc "P007 pattern" release chain (added
|
||||||
|
# 2026-09-14). An audit (2026-09-15) found that chain failed most of the
|
||||||
|
# real promotion bar it claimed to meet: syft/cosign installed via a `curl`
|
||||||
|
# + freshly-refetched checksums file (not a pinned, out-of-band-verified
|
||||||
|
# digest -- see siax-templates' `lib/pinned-tools.sh` header for exactly
|
||||||
|
# why that is not a verified digest), NO vulnerability scan of the built
|
||||||
|
# digest at all, `cosign sign` on the bare digest only (no in-toto/SLSA
|
||||||
|
# statement, no source-SHA/build-identity/environment/SBOM-hash bound
|
||||||
|
# together, no scoped key identity), and evidence committed straight back
|
||||||
|
# into this same mutable repo (`siax/production-evidence/*.json`,
|
||||||
|
# `siax/source-bundles/*.json` -- nine such commits already existed in this
|
||||||
|
# repo's history before this change; deleted in this same PR, superseded by
|
||||||
|
# the real AUD0 write below). This file keeps ONLY what is genuinely
|
||||||
|
# 0pt-specific (image name, tag scheme, registry push, Dockerfile) and
|
||||||
|
# delegates everything else to `.siax/hooks/G17.sh` -> the shared reference
|
||||||
|
# implementation. See `.siax/hooks/README.md` for the pin status and
|
||||||
|
# required secrets (none of which were provisioned for the old gate either
|
||||||
|
# -- see that file's own note).
|
||||||
|
#
|
||||||
|
# DELIBERATELY ONE JOB. Gitea 1.26 on this fleet resolves org-level
|
||||||
|
# secrets (SIAX_CI_TOKEN below is org-level) only for the FIRST job of a
|
||||||
|
# workflow file -- learned the hard way on sax3l/h0st's and sax3l/aud0's
|
||||||
|
# own G17 migrations. 0pt does not wire a G20 post-deploy step in this
|
||||||
|
# pass at all (see `.siax/hooks/README.md` "G20 -- deliberately not wired
|
||||||
|
# yet": the app is now provisioned and `/api/version` exists, but G20 is still
|
||||||
|
# not wired in this pass), so the single-job constraint that forces G20 into its own
|
||||||
|
# workflow file elsewhere does not yet bite here -- documented anyway so a
|
||||||
|
# future PR adding G20 does not repeat the mistake.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
|
||||||
|
DOCKERFILE: Dockerfile
|
||||||
|
SIAX_TEMPLATES_REPO: sax3l/siax-templates
|
||||||
|
# PIN STATUS 2026-09-15: sax3l/siax-templates PR #51
|
||||||
|
# (feat/g17-signed-deploy-gate-adr-w16-003) MERGED to main, plus three
|
||||||
|
# real follow-up fixes on top -- #56/#57 (SPDX compound-license-
|
||||||
|
# expression evaluation, exercised live by this repo's own
|
||||||
|
# G17_LICENSE_ALLOW policy in .siax/hooks/G17.sh) and #59 (G20 retry
|
||||||
|
# logic -- not used by 0pt yet, but the pin picks up main's current tip
|
||||||
|
# rather than an older commit, matching what a fresh migration reviews).
|
||||||
|
#
|
||||||
|
# Re-pin 2026-09-25 (medveten, granskad): 9a3a0aa -> 09504e3a. Deltaet ar
|
||||||
|
# EN merge, siax-templates #138 "Upload complete release evidence and
|
||||||
|
# validate artifact receipt". Enda berorda filer under ci/v2 ar
|
||||||
|
# reference-hooks/{G17.sh, G17-ADR-W16-003.md, lib/emit-release-evidence.py,
|
||||||
|
# lib/test_emit_release_evidence.py}. Andringen ar bakatkompatibel by
|
||||||
|
# design: ST0RE-uppladdningen ar OPT-IN via G17_REQUIRE_ST0RE (default 0),
|
||||||
|
# sa en AUD0-only-konsument som 0pt kor exakt samma vag som forut.
|
||||||
|
#
|
||||||
|
# Deliberately NO branch name here: the pinned commit is checked out
|
||||||
|
# DIRECTLY (see the step below). The previous shape -- clone `main`, then
|
||||||
|
# require its HEAD to equal this pin -- turned every unrelated merge in
|
||||||
|
# siax-templates into a red release chain here (CD-03 / CI-RED-02) while
|
||||||
|
# nothing was actually unsafe. Checking the pin out directly keeps the pin
|
||||||
|
# a real supply-chain control (the gate runs exactly this reviewed commit,
|
||||||
|
# or it fails) without coupling it to someone else's branch tip.
|
||||||
|
SIAX_TEMPLATES_PINNED_SHA: 09504e3a593ee8ce0eaba606df31c349a3d52277
|
||||||
|
SIAX_TEMPLATES_DIR: /tmp/siax-templates-g17-ref
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build, push, G17 (SBOM/scan/attest/verify/evidence)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: https://git.siax.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, mirrored + SHA-pinned (SUP-06, same pin n0d/cl0ud/st0re/h0st already use)
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# --- fetch the shared G17 reference implementation -----------------
|
||||||
|
# Same authenticated-fetch-at-runtime mechanism this repo's OLD
|
||||||
|
# release.yml already used SIAX_CI_TOKEN for (the evidence-commit
|
||||||
|
# push a prior PR deleted) -- see .siax/hooks/G17.sh's header for why
|
||||||
|
# NOT a `uses:` reusable workflow / submodule / vendored copy. Fails
|
||||||
|
# closed if the exact commit this adapter was reviewed against cannot
|
||||||
|
# be checked out (removed or rewritten upstream); an unrelated merge
|
||||||
|
# on siax-templates' own main is NOT a failure and never was a real
|
||||||
|
# signal.
|
||||||
|
- name: Fetch siax-templates G17 reference (pinned commit)
|
||||||
|
env:
|
||||||
|
SIAX_CI_TOKEN: ${{ secrets.SIAX_CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf "$SIAX_TEMPLATES_DIR"
|
||||||
|
mkdir -p "$SIAX_TEMPLATES_DIR"
|
||||||
|
git -C "$SIAX_TEMPLATES_DIR" init --quiet
|
||||||
|
git -C "$SIAX_TEMPLATES_DIR" remote add origin \
|
||||||
|
"https://oauth2:${SIAX_CI_TOKEN}@git.siax.io/${SIAX_TEMPLATES_REPO}.git"
|
||||||
|
# Ask for the pinned commit by SHA -- one shallow commit, no branch
|
||||||
|
# involved. A server with uploadpack.allowReachableSHA1InWant off
|
||||||
|
# refuses that, so fall back to fetching the branches and taking the
|
||||||
|
# same commit out of them. Either way the work tree ends up at
|
||||||
|
# exactly $SIAX_TEMPLATES_PINNED_SHA, never at a branch tip.
|
||||||
|
if ! git -C "$SIAX_TEMPLATES_DIR" fetch --quiet --depth 1 origin "$SIAX_TEMPLATES_PINNED_SHA"; then
|
||||||
|
echo "note: server refused a by-SHA fetch; falling back to fetching all branches"
|
||||||
|
git -C "$SIAX_TEMPLATES_DIR" fetch --quiet origin
|
||||||
|
fi
|
||||||
|
if ! git -C "$SIAX_TEMPLATES_DIR" -c advice.detachedHead=false \
|
||||||
|
checkout --quiet --detach "$SIAX_TEMPLATES_PINNED_SHA"; then
|
||||||
|
echo "::error::siax-templates commit $SIAX_TEMPLATES_PINNED_SHA not found in $SIAX_TEMPLATES_REPO -- the reviewed commit was removed or rewritten upstream. Re-review siax-templates and update SIAX_TEMPLATES_PINNED_SHA deliberately."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
actual_sha="$(git -C "$SIAX_TEMPLATES_DIR" rev-parse HEAD)"
|
||||||
|
if [ "$actual_sha" != "$SIAX_TEMPLATES_PINNED_SHA" ]; then
|
||||||
|
echo "::error::checked-out siax-templates HEAD is $actual_sha, expected the pinned $SIAX_TEMPLATES_PINNED_SHA -- refusing to run an unreviewed version of the gate."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$SIAX_TEMPLATES_DIR/ci/v2/reference-hooks/"*.sh "$SIAX_TEMPLATES_DIR/ci/v2/reference-hooks/lib/"*.sh
|
||||||
|
echo "siax-templates G17 reference @ $actual_sha (pinned commit, checked out directly)"
|
||||||
|
|
||||||
|
- name: Resolve SHA + tag (never :latest)
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
full="$(git rev-parse HEAD)"
|
||||||
|
short="$(git rev-parse --short=12 HEAD)"
|
||||||
|
echo "full=$full" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short=$short" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image_tag=sha-$short" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker login (git.cloud.siax.io)
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
registry_host="$(echo "$IMAGE_REPO" | cut -d/ -f1)"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$registry_host" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ steps.sha.outputs.image_tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker build -f "$DOCKERFILE" -t "${IMAGE_REPO}:${IMAGE_TAG}" .
|
||||||
|
|
||||||
|
- name: Push (immutable sha-<12> tag, never :latest)
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ steps.sha.outputs.image_tag }}
|
||||||
|
run: docker push "${IMAGE_REPO}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
# --- G17: everything else, delegated -------------------------------
|
||||||
|
- name: G17 (bar items 1-7, delegated to siax-templates)
|
||||||
|
env:
|
||||||
|
ARTIFACT_COMMIT_SHA: ${{ steps.sha.outputs.full }}
|
||||||
|
ARTIFACT_IMAGE_TAG: ${{ steps.sha.outputs.image_tag }}
|
||||||
|
CI_RUN_ID: ${{ github.run_id }}
|
||||||
|
CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
G17_COSIGN_KEY: /tmp/0pt-g17-cosign.key
|
||||||
|
G17_COSIGN_KEY_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
AUD0_INGEST_TOKEN: ${{ secrets.AUD0_INGEST_TOKEN }}
|
||||||
|
AUD0_TENANT_ID: ${{ secrets.AUD0_TENANT_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > "$G17_COSIGN_KEY"
|
||||||
|
trap 'rm -f "$G17_COSIGN_KEY"' EXIT
|
||||||
|
bash .siax/hooks/G17.sh
|
||||||
|
|
||||||
|
# NOTE: deliberately NO `actions/upload-artifact@v4` step here.
|
||||||
|
# sax3l/cl0ud's release.yml (run 23340, 2026-09-14) documents that
|
||||||
|
# this Gitea instance's act_runner does not reliably support
|
||||||
|
# upload-artifact@v4 even with continue-on-error: true, and that it
|
||||||
|
# can abort the whole job before later steps run. G17.sh's own AUD0
|
||||||
|
# submission (bar item 7, step above) is already the durable evidence
|
||||||
|
# record; a convenience local copy is not worth re-triggering that
|
||||||
|
# known breakage.
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: Security Scan (SIAX Cloud)
|
||||||
|
|
||||||
|
# Security scanning on every push/PR + weekly re-run.
|
||||||
|
#
|
||||||
|
# This job runs the same Trivy invocation as ci.yml's `security` job and
|
||||||
|
# previously carried the same defect: `--exit-code 0` meant it could report
|
||||||
|
# success while holding a CRITICAL finding (measured 2026-09-08 with a
|
||||||
|
# planted AWS secret key -- detected, still exit 0). Its context
|
||||||
|
# "Security Scan (SIAX Cloud) / security" is NOT in branch protection's
|
||||||
|
# required list, so this one never gated a merge either way; it is fixed
|
||||||
|
# here so a green tick on this workflow means what a reader assumes it
|
||||||
|
# means. See ci.yml's `security` job for the measurement and for the
|
||||||
|
# services/scrapers unpinned-requirements coverage gap.
|
||||||
|
#
|
||||||
|
# NOTE: findings on services/scrapers/* (FlareSolverr/Tor/curl_cffi) are
|
||||||
|
# expected and out of scope for CI gating -- kept per the account owner's
|
||||||
|
# dated, attributed decision (Simon Axelsson, 2026-08-11, interactive
|
||||||
|
# prompt; no rationale beyond "keep it" given or invented). See SECURITY.md
|
||||||
|
# and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6 for full context.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 5 * * 1" # weekly Monday re-scan
|
||||||
|
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Trivy
|
||||||
|
run: |
|
||||||
|
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
- name: Trivy fs scan (vuln + secrets -- fails on HIGH/CRITICAL)
|
||||||
|
run: |
|
||||||
|
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: VehIQ OLTP extractor (0pt ingestion)
|
||||||
|
|
||||||
|
# P070 — scheduled poll of app.dealer_stock_entry/app.deal (VehIQ, server10)
|
||||||
|
# into opt_dataset_projection (ClickHouse), via the watermark extractor in
|
||||||
|
# src/ingestion/vehiq-oltp-ingest-runner.ts. See docs/P070_VEHIQ_OLTP_
|
||||||
|
# EXTRACTOR.md for the full design and "what activating this requires".
|
||||||
|
#
|
||||||
|
# SAFE BY DEFAULT: VEHIQ_INGEST_MODE defaults to "preview" (read-only, never
|
||||||
|
# commits a checkpoint, never writes) below. With no VEHIQ_SOURCE_DATABASE_URL
|
||||||
|
# secret configured on this repo, the script logs "not_configured" and exits
|
||||||
|
# 0 — this workflow can be left enabled indefinitely with zero effect on
|
||||||
|
# anything until BOTH the secret is added AND VEHIQ_INGEST_MODE is changed
|
||||||
|
# to "run". Neither is done by this workflow file itself.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "*/15 * * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
extract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VEHIQ_SOURCE_DATABASE_URL: ${{ secrets.VEHIQ_SOURCE_DATABASE_URL }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
OPT_ANALYTICS_CLICKHOUSE_BASE_URL: ${{ secrets.OPT_ANALYTICS_CLICKHOUSE_BASE_URL }}
|
||||||
|
OPT_ANALYTICS_CLICKHOUSE_BASIC_AUTH: ${{ secrets.OPT_ANALYTICS_CLICKHOUSE_BASIC_AUTH }}
|
||||||
|
OPT_ANALYTICS_CLICKHOUSE_DATABASE: opt_analytics
|
||||||
|
# Activation gate — see docs/P070_VEHIQ_OLTP_EXTRACTOR.md. Flip to
|
||||||
|
# "run" only once steps 1-4 in that doc's "What activating this
|
||||||
|
# requires" section are actually done.
|
||||||
|
VEHIQ_INGEST_MODE: preview
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Enable corepack + pin pnpm@9
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9 --activate
|
||||||
|
- name: Install (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Run the extractor (preview mode unless activated)
|
||||||
|
run: pnpm run vehiq:oltp-ingest
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Quality gate: typecheck + test on every PR and non-main push.
|
||||||
|
# 0pt previously relied solely on the Vercel build with no pre-merge gate.
|
||||||
|
# Build is intentionally NOT run here because it needs runtime secrets
|
||||||
|
# (POSTGRES_URL, ENGINE_URL, STRIPE, RESEND) — Vercel owns the build; CI owns
|
||||||
|
# the static gate.
|
||||||
|
#
|
||||||
|
# Updated by the GitHub<->Gitea unification merge (2026-07-28). The note that
|
||||||
|
# used to sit here — "lint is not a step because this repo has no eslint
|
||||||
|
# dependency or config wired" — is no longer true: the Gitea line's Wave 2
|
||||||
|
# hardening added eslint + typescript-eslint + a flat config and repointed the
|
||||||
|
# "lint" script at `eslint src tests`. Lint now runs, ADVISORY
|
||||||
|
# (continue-on-error), matching .gitea/workflows/ci.yml: the v0-generated
|
||||||
|
# legacy tree carries pre-existing lint noise, so it must not block merges.
|
||||||
|
#
|
||||||
|
# `typecheck` (BLOCKING) is tsc over tsconfig.check.json = src/ + tests/.
|
||||||
|
# `typecheck:app` (ADVISORY) is the whole-repo tsc this workflow used to run as
|
||||||
|
# its blocking step. It was green on the GitHub line alone but reports 9
|
||||||
|
# pre-existing errors on the unified tree (app/api/0pt/* route handlers, where
|
||||||
|
# the Wave 2 Zod schemas type fields as plain `string` while the services want
|
||||||
|
# narrower unions). It is kept running, and kept visible, rather than deleted —
|
||||||
|
# see package.json's "//scripts" note. Flip it back to blocking once those 9
|
||||||
|
# are cleared.
|
||||||
|
#
|
||||||
|
# Python engine (services/api) is gated separately if/when a Python workflow
|
||||||
|
# is added.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches-ignore: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gate:
|
||||||
|
name: Typecheck + test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- name: Enable corepack (pnpm)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Typecheck (tsc over src/ + tests/ -- blocking)
|
||||||
|
run: pnpm run typecheck
|
||||||
|
- name: Test (vitest -- blocking)
|
||||||
|
run: pnpm run test
|
||||||
|
- name: Typecheck whole repo (advisory -- 9 pre-existing app/ errors)
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm run typecheck:app
|
||||||
|
- name: Lint (eslint -- advisory; legacy tree has pre-existing noise)
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm run lint
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
# pnpm local store (created when store-dir defaults into the project)
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# interim LocalBlobStore output (src/adapters/local-blob-store.ts)
|
||||||
|
.data/
|
||||||
|
|
||||||
|
# local blob storage (document artifacts when no object storage configured)
|
||||||
|
.opt-blobs/
|
||||||
|
|
||||||
|
# python (engine + scrapers) -- runtime artifacts, never source
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# analytics / database files
|
||||||
|
*.duckdb
|
||||||
|
*.duckdb.wal
|
||||||
|
|
||||||
|
# scraper run output (local dumps written by services/scrapers/*.py, never source)
|
||||||
|
services/scrapers/output/
|
||||||
|
|
||||||
|
# local-only environment overrides (all real values live in Infisical)
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# parallel git worktrees (policy: hidden inside the repo, never committed)
|
||||||
|
/.worktrees/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
verify-deps-before-run=false
|
||||||
|
include-workspace-root=true
|
||||||
|
confirmModulesPurge=false
|
||||||
|
|
||||||
|
# SIAX Cloud publishes @siax/* packages from Gitea's own npm registry.
|
||||||
|
# Only scoped @siax/* names resolve here; everything else stays on the
|
||||||
|
# public npm registry.
|
||||||
|
@siax:registry=https://git.siax.io/api/packages/sax3l/npm/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Build output and local worktrees are not source.
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.worktrees/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 110
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"exemptions": [
|
||||||
|
{
|
||||||
|
"control": "REPO-01",
|
||||||
|
"paths": [
|
||||||
|
"siax/sbom/sbom-0pt-2026-09-29.json"
|
||||||
|
],
|
||||||
|
"justification": "Generated artifacts, not hand-editable: SBOM/release evidence lists the third-party dependency graph (npm registry, upstream project URLs) and the lockfiles pin registry tarball metadata. Rewriting them would falsify supply-chain evidence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"control": "REPO-01",
|
||||||
|
"paths": [
|
||||||
|
"AGENTS.md",
|
||||||
|
"CLAUDE.md",
|
||||||
|
"ONBOARDING.md",
|
||||||
|
"PRODUCTION_EVIDENCE.json",
|
||||||
|
"SECURITY.md",
|
||||||
|
"docs/02-architecture.md",
|
||||||
|
"docs/06-data-engine.md",
|
||||||
|
"docs/07-dependencies.md",
|
||||||
|
"docs/08-infrastructure.md",
|
||||||
|
"docs/09-build-plan.md",
|
||||||
|
"docs/P0_QUERY_SOURCE_FLOW.md",
|
||||||
|
"docs/compliance/INVENTORY.md",
|
||||||
|
"docs/compliance/SLUTRAPPORT.md"
|
||||||
|
],
|
||||||
|
"justification": "Documentation prose. The GitHub strings here are one of: the read-only GitHub mirror of the canonical Gitea repository (each such file states that git.siax.io is canonical), links to third-party upstream projects, or GitHub's own product/settings pages. None of them configures or names a SIAX git remote."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"allow":[{"pathPrefix":"components/states.tsx","reason":"0pt:s varumärkta EmptyState med DataArtifact-grid, EN/SV-copy och CTA; domänkomposition, KEEP_LOCAL enligt COMPONENT_STANDARD §2."},{"pathPrefix":"components/theme-provider.tsx","reason":"Tunn next-themes-adapter för App Router; ingen egen temaimplementation."},{"pathPrefix":"components/ui/toaster.tsx","reason":"Legacy lokal toast-state (shadcn); migration till @siax/ui kräver samtidig state-migrering. KEEP_LOCAL med backlog."}]}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"repo": "0pt",
|
||||||
|
"generatedAt": "2026-09-23T21:12:27.075Z",
|
||||||
|
"standard": {
|
||||||
|
"canonicalRemote": "https://git.siax.io/sax3l/siax-standard.git",
|
||||||
|
"ref": "main",
|
||||||
|
"sha": "0000000000000000000000000000000000000000"
|
||||||
|
},
|
||||||
|
"repoRole": "LEGACY",
|
||||||
|
"schemaBundleDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"ownerRegistryDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"productFirewallDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# decision-usage — 0pt
|
||||||
|
schemaVersion: siax.decision-usage/v1
|
||||||
|
repo: 0pt
|
||||||
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
|
owners: [simon@siax.io]
|
||||||
|
decisions: []
|
||||||
|
notes: GENERATED stub - tomt ar den arliga 'inga beslut materialiserade'-formen; varje beslut under osakerhet listas har (required/advisory/shadow), produkter anropar aldrig Jev/providers direkt.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# des1gn-consumer — 0pt (GENERATED neutral baseline; anpassa via siax-templates)
|
||||||
|
schemaVersion: siax.des1gn-consumer/v1
|
||||||
|
repo: 0pt
|
||||||
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
|
consumes:
|
||||||
|
'@siax/0pt-decision': 'härlett ur package.json (deklarerad workspace:*)'
|
||||||
|
'@siax/config': 'härlett ur package.json (deklarerad ^0.2.1)'
|
||||||
|
'@siax/tokens': 'härlett ur package.json (deklarerad ^0.1.2)'
|
||||||
|
'@siax/ui': 'härlett ur package.json (deklarerad ^0.4.1)'
|
||||||
|
localPrimitives: []
|
||||||
|
notes: GENERATED stub - tomt ar den arliga baseline-formen; konsumerade kanoniska DES1GN-paket (pinned) listas under consumes, kvarvarande lokala primitives motiveras i notes.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.074Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# dev-profile — 0pt (neutral, kanoniskt giltig baseline; anpassa per repo via siax-templates)
|
||||||
|
schemaVersion: siax.dev-profile/v1
|
||||||
|
repoType: library
|
||||||
|
riskDefault: L2
|
||||||
|
capabilities:
|
||||||
|
stateful: false
|
||||||
|
deployable: false
|
||||||
|
publicApi: false
|
||||||
|
aiEnabled: false
|
||||||
|
userFacing: false
|
||||||
|
tenantScoped: false
|
||||||
|
handlesSecrets: false
|
||||||
|
gates:
|
||||||
|
realDatabase: required_when_changed
|
||||||
|
migrationUpgrade: required_when_changed
|
||||||
|
e2e: required_when_changed
|
||||||
|
accessibility: required_when_changed
|
||||||
|
visualRegression: required_when_changed
|
||||||
|
mutation: required_when_changed
|
||||||
|
performance: required_when_changed
|
||||||
|
release:
|
||||||
|
owner: sax3l
|
||||||
|
strategy: same-digest-promotion
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# feature-packs — 0pt
|
||||||
|
schemaVersion: siax.feature-packs/v1
|
||||||
|
repo: 0pt
|
||||||
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
|
packs:
|
||||||
|
permissions: []
|
||||||
|
states: []
|
||||||
|
notes: GENERATED stub - tomt ar den arliga baseline-formen; varje pack binder 'version owner backendOwner' (kanonisk backend-agare, aldrig kopierad implementation).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "siax.hook-pin/v1",
|
||||||
|
"repo": "0pt",
|
||||||
|
"hooks": {
|
||||||
|
"G17": {
|
||||||
|
"contract": "ci/v2 G17 (Artifact security) -- ADR-W16-003 promotion bar",
|
||||||
|
"sourcePath": "ci/v2/reference-hooks/{G17.sh,lib/*}",
|
||||||
|
"consumedAs": [".siax/hooks/G17.sh"],
|
||||||
|
"compositionMechanism": "checkout-at-CI-runtime (pinned commit, SIAX_CI_TOKEN), not a vendored copy -- see .siax/hooks/README.md"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templateRepo": "sax3l/siax-templates",
|
||||||
|
"templateRef": "a401880aa19a9bf99fbd796c0be481db2705fcaa",
|
||||||
|
"templateBranch": "main",
|
||||||
|
"templatePr": "sax3l/siax-templates#51 + follow-ups (PAY-35, ui-consumer lockfile, recovery wave, X-708 profiles, deps)",
|
||||||
|
"prStatus": "MERGED to main; re-pinned 2026-09-22: diff 7725498..a401880 contains ZERO changes under ci/v2/reference-hooks (verified via git diff --name-only) -- pin bump is behaviour-neutral for the G17 gate",
|
||||||
|
"pinnedAt": "2026-09-22T02:00:00Z",
|
||||||
|
"reSyncInstructions": "If siax-templates main moves past this commit: review the diff (`git -C <clone> log 384e35060b91ea78ec045e14af114abf5d5eb23b..main -- ci/v2/reference-hooks`), re-run this migration's local verification (real local registry + docker build from this repo's own Dockerfile + G17.sh end-to-end + tamper-rejection checks -- see the PR description for the exact commands used), then bump SIAX_TEMPLATES_PINNED_SHA in .gitea/workflows/release.yml and this file's templateRef together in one reviewed commit.",
|
||||||
|
"notes": "Supersedes 0pt's own ad hoc 'P007 pattern' release.yml (added 2026-09-14, audited 2026-09-15 and found to fail most of ADR-W16-003's bar: syft/cosign installed via unpinned-digest curl+checksums-refetch, no vulnerability scan of the built digest, cosign signed only the bare digest -- not a full in-toto predicate -- evidence committed back into this mutable repo at siax/production-evidence/*.json + siax/source-bundles/*.json instead of AUD0/ST0RE, no live-digest-equality proof). This pin replaces that ad hoc logic with sax3l/siax-templates' real, reviewed reference implementation; 0pt keeps only its own adapter values (image repo, license policy, key identity -- see G17.sh's own header) plus the pre-existing 0pt-specific signed-artifact wait/verify gate in cd.yml (kept, updated to cosign verify-attestation). G20 (live-digest-equality) deliberately NOT wired in this pass -- see .siax/hooks/README.md."
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 0pt's G17 (Artifact security) hook adapter.
|
||||||
|
#
|
||||||
|
# 0pt's OWN release chain (`.gitea/workflows/release.yml`, added 2026-09-14
|
||||||
|
# as "P007 pattern") had a signing/SBOM chain -- but an audit (2026-09-15)
|
||||||
|
# found it failed most of the real ADR-W16-003 promotion bar it claimed to
|
||||||
|
# meet: syft/cosign installed via a `curl` + freshly-refetched checksums
|
||||||
|
# file (not a pinned digest), NO vulnerability scan of the built digest at
|
||||||
|
# all, `cosign sign` on the bare digest only (not source-SHA/build-identity/
|
||||||
|
# environment/SBOM-hash bound together), no in-toto/SLSA statement, and
|
||||||
|
# evidence committed straight back into this same mutable repo
|
||||||
|
# (`EVIDENCE_DIR="siax"` -> `siax/production-evidence/*.json`,
|
||||||
|
# `siax/source-bundles/*.json`, pushed with a SIAX_CI_TOKEN-authenticated
|
||||||
|
# `git push` on every release -- nine such commits already sit in this
|
||||||
|
# repo's history). This adapter, plus the rewritten `release.yml`, replaces
|
||||||
|
# that chain; the previous `siax/production-evidence/` and
|
||||||
|
# `siax/source-bundles/` trees are deleted in this same change (superseded,
|
||||||
|
# not left dangling -- see the PR description for exactly what a `git log`
|
||||||
|
# on those paths still shows for history).
|
||||||
|
#
|
||||||
|
# This adapter owns ONLY 0pt-specific values -- every ADR-W16-003 bar item
|
||||||
|
# (1-7: pinned-tool install, SBOM, blocking vuln scan, in-toto attestation,
|
||||||
|
# scoped-key signing, pubkey verification, AUD0/ST0RE evidence storage) is
|
||||||
|
# implemented ONCE in sax3l/siax-templates and executed here unmodified, per
|
||||||
|
# that reference file's own header: "This file is starting material a
|
||||||
|
# consumer repo copies to `.siax/hooks/G17.sh` and adapts -- it owns only
|
||||||
|
# the adapter ... `siax-templates` remains the sole owner of the G17
|
||||||
|
# CONTRACT and this reference implementation."
|
||||||
|
#
|
||||||
|
# --- Composition mechanism: checkout-at-CI-runtime, not a vendored copy ---
|
||||||
|
# `.gitea/workflows/release.yml` clones sax3l/siax-templates at a pinned
|
||||||
|
# commit into $SIAX_TEMPLATES_DIR before calling this file, authenticated
|
||||||
|
# with the SIAX_CI_TOKEN org secret 0pt's OWN release.yml already had wired
|
||||||
|
# for a DIFFERENT cross-repo write (the evidence-commit push this same
|
||||||
|
# change deletes) -- so the token and the auth idiom
|
||||||
|
# (`https://oauth2:${SIAX_CI_TOKEN}@git.siax.io/...`) are not new to this
|
||||||
|
# repo, only their use for a `git clone` of a sibling repo is. Picked over
|
||||||
|
# vendoring (the mechanism sax3l/st0re, sax3l/cl0ud and sax3l/api0's own
|
||||||
|
# G17 migrations used) because 0pt has ZERO prior vendored-copy convention
|
||||||
|
# anywhere in this repo (`grep -rl vendor .gitea/workflows/ .siax/` finds
|
||||||
|
# nothing, confirmed before writing this file) -- introducing one now, for
|
||||||
|
# this alone, would be a bigger repo-shape change than reusing the
|
||||||
|
# SIAX_CI_TOKEN pattern 0pt already has proof it can authenticate with.
|
||||||
|
# Matches sax3l/aud0 and sax3l/h0st's own G17 migrations, which
|
||||||
|
# independently made the same choice for the same reason. See
|
||||||
|
# `ci/shim/ci.yml` in siax-templates for why NOT a native
|
||||||
|
# `uses: sax3l/siax-templates/...@ref` reusable workflow (Gitea Actions
|
||||||
|
# clones a called repo ANONYMOUSLY, so this 404s on every private sax3l
|
||||||
|
# repo).
|
||||||
|
#
|
||||||
|
# GOTCHA LEARNED FROM sax3l/h0st's OWN MIGRATION (its
|
||||||
|
# `cl0ud-real-policy.yml` header, and independently from sax3l/aud0's):
|
||||||
|
# Gitea 1.26 on this fleet resolves ORG-level secrets (SIAX_CI_TOKEN is
|
||||||
|
# org-level) only for the FIRST job of a multi-job workflow file. That is
|
||||||
|
# exactly why `release.yml` (this file's caller) is a single-job workflow.
|
||||||
|
# 0pt does not wire a G20 (live-digest-equality) post-deploy step in this
|
||||||
|
# pass -- see `.siax/hooks/README.md` "G20 -- deliberately not wired yet"
|
||||||
|
# for why (no Coolify application provisioned for 0pt at all, and no
|
||||||
|
# `/version` endpoint for a live check to poll -- both genuine
|
||||||
|
# infrastructure gaps, not something this CI adapter can close).
|
||||||
|
#
|
||||||
|
# PIN STATUS (2026-09-15): sax3l/siax-templates PR #51
|
||||||
|
# (`feat/g17-signed-deploy-gate-adr-w16-003`) is MERGED to main, plus three
|
||||||
|
# real follow-up fixes already on top of that merge as of this pin --
|
||||||
|
# bf0c157/#56 "evaluate compound SPDX license expressions per SPDX
|
||||||
|
# semantics", 794546f/#57 "skip bare SPDX operator tokens emitted as their
|
||||||
|
# own license entry" (both exercised live below via G17_LICENSE_ALLOW
|
||||||
|
# against 0pt's real SBOM), and e1230a1/#59 "retry on observed-but-
|
||||||
|
# different sha" (a G20 fix; 0pt does not use G20 yet, but the pin still
|
||||||
|
# picks up main's current tip rather than an older commit, matching what a
|
||||||
|
# fresh migration reviews). Pinned at
|
||||||
|
# 384e35060b91ea78ec045e14af114abf5d5eb23b -- the exact ref lives in
|
||||||
|
# `.gitea/workflows/release.yml` (SIAX_TEMPLATES_PINNED_SHA, which is
|
||||||
|
# fetched and checked out DIRECTLY by SHA -- no branch is consulted, so an
|
||||||
|
# unrelated merge on siax-templates' main does not fail this gate; a pinned
|
||||||
|
# commit that no longer exists upstream does).
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
: "${SIAX_TEMPLATES_DIR:?set by the calling workflow step to the path siax-templates was checked out to}"
|
||||||
|
REFERENCE_G17="$SIAX_TEMPLATES_DIR/ci/v2/reference-hooks/G17.sh"
|
||||||
|
[ -x "$REFERENCE_G17" ] || {
|
||||||
|
echo "0pt G17 adapter: FAIL: $REFERENCE_G17 not found/executable -- was the siax-templates checkout step run first, and did chmod +x survive the clone?" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 0pt-specific adapter values --------------------------------------------
|
||||||
|
export ARTIFACT_IMAGE_REPO="${ARTIFACT_IMAGE_REPO:-git.cloud.siax.io/sax3l/0pt}"
|
||||||
|
export ARTIFACT_COMMIT_SHA="${ARTIFACT_COMMIT_SHA:?set by release.yml to the full 40-hex commit SHA this image was built from}"
|
||||||
|
export ARTIFACT_IMAGE_TAG="${ARTIFACT_IMAGE_TAG:?set by release.yml, e.g. sha-<12-hex> -- never :latest}"
|
||||||
|
export G17_ENVIRONMENT="production"
|
||||||
|
export G17_KEY_IDENTITY="siax-svc-g17/0pt/production"
|
||||||
|
|
||||||
|
# The reference script's own default for G17_COSIGN_PUBKEY assumes it was
|
||||||
|
# COPIED to .siax/hooks/G17.sh inside the consumer repo (relative path
|
||||||
|
# lands at the consumer's own root). This adapter instead runs the
|
||||||
|
# reference script from a SEPARATE checkout ($SIAX_TEMPLATES_DIR) -- that
|
||||||
|
# default would resolve inside siax-templates' own checkout, not 0pt's --
|
||||||
|
# so it must be pointed explicitly at 0pt's own committed key.
|
||||||
|
export G17_COSIGN_PUBKEY="$REPO_ROOT/cosign.pub"
|
||||||
|
|
||||||
|
# G17_COSIGN_KEY / G17_COSIGN_KEY_PASSWORD / AUD0_INGEST_TOKEN /
|
||||||
|
# AUD0_TENANT_ID are Infisical-injected repo secrets -- release.yml exports
|
||||||
|
# them from `secrets.*` before calling this file. This adapter deliberately
|
||||||
|
# never reads, echoes or forwards a raw secret value itself; it only relies
|
||||||
|
# on them already being present in the environment the reference script
|
||||||
|
# inherits.
|
||||||
|
|
||||||
|
# License policy -- measured 2026-09-15 against 0pt's OWN real signed
|
||||||
|
# artifact SBOM (280 components; real local-registry verification run
|
||||||
|
# performed during this migration, not copied from another repo's policy):
|
||||||
|
# * G17_LICENSE_ALLOW_UNKNOWN=1 -- 177/280 components declare no license
|
||||||
|
# field at all in their packed metadata (transitive npm packages --
|
||||||
|
# next/react/sharp's own dependency tree -- there is no fix for
|
||||||
|
# third-party metadata from this repo). Same opt-in st0re/aud0/h0st
|
||||||
|
# already record; visible in the attestation evidence, not silent.
|
||||||
|
# * G17_LICENSE_ALLOW below is the exact, minimal set of SPDX ids AND
|
||||||
|
# compound-expression branches 0pt's real SBOM needs once unknowns are
|
||||||
|
# allowed -- every id was individually present in the real scan
|
||||||
|
# output (`python3 -c "..." verify-work/e2e-out/sbom.cdx.json` run
|
||||||
|
# during this migration), not a broad copy of a sibling repo's list.
|
||||||
|
# GPL-2.0-only/GPL-2.0-or-later/GPL-3.0-or-later/LGPL-2.1-or-later/
|
||||||
|
# LGPL-3.0-or-later/Zlib/OpenSSL-Exception/NCSA cover alpine's own base
|
||||||
|
# packages this image's `RUN apk add --no-cache libc6-compat wget` (+
|
||||||
|
# the deps/sharp/build stages' own libc6-compat installs) pull in --
|
||||||
|
# the OS substrate node:22-alpine ships, not a dependency 0pt chose.
|
||||||
|
# NCSA specifically is gcompat's real declared license (a first real
|
||||||
|
# finding this migration's initial run caught and triaged: the OLD ad
|
||||||
|
# hoc gate never scanned licenses at all, so this had never been
|
||||||
|
# checked before). MIT/BSD-2-Clause/BSD-3-Clause/ISC/0BSD/Apache-2.0/
|
||||||
|
# CC0-1.0/BlueOak-1.0.0/MPL-2.0 cover the Next.js/React/sharp npm
|
||||||
|
# dependency tree's own real declared licenses, including the compound
|
||||||
|
# "MPL-2.0 AND MIT" and "GPL-2.0-or-later AND LGPL-2.1-or-later"
|
||||||
|
# expressions actually present in the SBOM (evaluated per real SPDX
|
||||||
|
# AND/OR semantics -- the two 2026-09-15 siax-templates fixes #56/#57
|
||||||
|
# this pin includes).
|
||||||
|
export G17_LICENSE_ALLOW_UNKNOWN="1"
|
||||||
|
export G17_LICENSE_ALLOW="${G17_LICENSE_ALLOW:-BlueOak-1.0.0,GPL-2.0-only,GPL-2.0-or-later,GPL-3.0-or-later,LGPL-2.1-or-later,LGPL-3.0-or-later,Zlib,OpenSSL-Exception,NCSA,MIT,BSD-2-Clause,BSD-3-Clause,ISC,0BSD,Apache-2.0,CC0-1.0,MPL-2.0}"
|
||||||
|
|
||||||
|
echo "0pt G17 adapter: delegating to $REFERENCE_G17 for artifact $ARTIFACT_IMAGE_REPO:$ARTIFACT_IMAGE_TAG"
|
||||||
|
exec bash "$REFERENCE_G17"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# `.siax/hooks/` -- 0pt's G17 (signed-deploy gate) adapter
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
`G17.sh` in this directory is a thin **adapter**: it sets 0pt-specific
|
||||||
|
values (image repo, environment, key identity, license policy) and then
|
||||||
|
`exec`s the real, reviewed reference implementation checked out at CI
|
||||||
|
runtime from `sax3l/siax-templates` (`ci/v2/reference-hooks/G17.sh`). This
|
||||||
|
repo does not own, fork, or duplicate any of the ADR-W16-003 bar-item logic
|
||||||
|
itself -- see `G17.sh`'s own header for the full rationale and the audit
|
||||||
|
that motivated replacing 0pt's prior ad hoc gate.
|
||||||
|
|
||||||
|
## Composition mechanism: pinned-commit checkout at CI runtime
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` clones `sax3l/siax-templates` at a pinned
|
||||||
|
commit into a scratch directory before invoking `.siax/hooks/G17.sh`,
|
||||||
|
authenticated with the `SIAX_CI_TOKEN` org secret. This was chosen over a
|
||||||
|
vendored copy under `.siax/hooks/vendor/` (the mechanism `st0re`, `cl0ud`
|
||||||
|
and `api0` used for their own G17 migrations) because 0pt has **no existing
|
||||||
|
vendored-copy convention anywhere in this repo** -- confirmed by searching
|
||||||
|
`.gitea/workflows/` and `.siax/` for any prior "vendor" pattern before
|
||||||
|
choosing -- while it **does** already reference `SIAX_CI_TOKEN` with the
|
||||||
|
exact same `git.siax.io` authenticated-clone idiom this change reuses (the
|
||||||
|
now-deleted evidence-commit-push step in the old `release.yml` used the
|
||||||
|
identical `x-access-token`/`oauth2` bearer pattern for a different
|
||||||
|
cross-repo write). Reusing a pattern this repo already has proof it can
|
||||||
|
authenticate with is a smaller, lower-risk change than introducing a new
|
||||||
|
vendoring convention for this alone. `sax3l/aud0` and `sax3l/h0st`'s own
|
||||||
|
G17 migrations independently reached the same conclusion for the same
|
||||||
|
reason.
|
||||||
|
|
||||||
|
## Why not a `uses:` reusable workflow
|
||||||
|
|
||||||
|
Gitea Actions on this fleet resolves a called repository **anonymously**
|
||||||
|
regardless of the caller's own auth, so `uses: sax3l/siax-templates/...@ref`
|
||||||
|
404s against any private `sax3l` repo. See `ci/shim/ci.yml` in
|
||||||
|
`siax-templates` for the fuller writeup. Checkout-at-runtime with an
|
||||||
|
authenticated clone is the only mechanism that works against a private
|
||||||
|
template repo on this Gitea instance.
|
||||||
|
|
||||||
|
## Pin status
|
||||||
|
|
||||||
|
Pinned to `sax3l/siax-templates` main at
|
||||||
|
`384e35060b91ea78ec045e14af114abf5d5eb23b` (2026-09-15) -- PR #51
|
||||||
|
(`feat/g17-signed-deploy-gate-adr-w16-003`) merged, plus follow-up fixes
|
||||||
|
#56/#57 (SPDX compound-license-expression evaluation, exercised live by
|
||||||
|
this adapter's own `G17_LICENSE_ALLOW` policy) and #59 (G20 retry logic;
|
||||||
|
not used by 0pt yet, see below). `.gitea/workflows/release.yml` fetches
|
||||||
|
and checks out `SIAX_TEMPLATES_PINNED_SHA` **directly by SHA** and refuses
|
||||||
|
to run if that exact commit cannot be checked out (removed or rewritten
|
||||||
|
upstream) -- bumping the pin is a reviewed, source-controlled diff to that
|
||||||
|
one line, never a silent re-resolution. It deliberately does **not**
|
||||||
|
compare the pin against `main`: that branch tip moves whenever anything
|
||||||
|
unrelated merges in `siax-templates`, and failing this chain on that said
|
||||||
|
nothing about whether the reviewed gate was safe (CD-03 / CI-RED-02). The
|
||||||
|
authoritative pin value is the one in `release.yml`.
|
||||||
|
|
||||||
|
## G20 (live-digest-equality) -- deliberately not wired yet
|
||||||
|
|
||||||
|
The reference implementation's bar item 8 proxy (`G20`, "does the running
|
||||||
|
service's own `/version` endpoint report the sha that was actually signed")
|
||||||
|
needs two things 0pt does not have today, confirmed live before writing
|
||||||
|
this file:
|
||||||
|
|
||||||
|
1. **A provisioned Coolify application.** `.gitea/workflows/cd.yml`'s own
|
||||||
|
guard step already fails closed on a literal `COOLIFY_APP_ID` placeholder
|
||||||
|
-- 0pt has never been deployed to the fleet.
|
||||||
|
2. **A `/version` (or equivalent) endpoint.** `app/api/` has `health/` and
|
||||||
|
`ready/` routes but no route reporting a build SHA, and the Dockerfile
|
||||||
|
declares no `ARG GIT_SHA`/`ARG BUILD_TIME` to bake one in.
|
||||||
|
|
||||||
|
Wiring G20 against nothing live would be decorative, not a real check --
|
||||||
|
the exact anti-pattern this whole migration replaces. Once both exist
|
||||||
|
(Simon provisions the Coolify app; a follow-up adds a `/api/version` route
|
||||||
|
and the Dockerfile `ARG`s, the same small change `h0st`'s own migration
|
||||||
|
made), copy `G20.sh` + its own single-job `.gitea/workflows/
|
||||||
|
g20-live-verify.yml` from `h0st` or `aud0`'s repo as the starting point --
|
||||||
|
**as its own workflow file**, not a second job appended to `cd.yml`: Gitea
|
||||||
|
1.26 on this fleet resolves org-level secrets (`SIAX_CI_TOKEN`,
|
||||||
|
`AUD0_INGEST_TOKEN`) only for the **first** job of a multi-job workflow
|
||||||
|
file (learned the hard way on `h0st`'s and `aud0`'s own migrations).
|
||||||
|
|
||||||
|
## Required secrets (not yet set on `sax3l/0pt` as of this migration)
|
||||||
|
|
||||||
|
Same prerequisite every prior migration needed before its `release.yml`
|
||||||
|
went green:
|
||||||
|
|
||||||
|
- `REGISTRY_USER` / `REGISTRY_TOKEN` -- `git.cloud.siax.io` push (already
|
||||||
|
required by the OLD release.yml too; unchanged).
|
||||||
|
- `COSIGN_PRIVATE_KEY` / `COSIGN_PASSWORD` -- the private half of this
|
||||||
|
repo's `cosign.pub` (UNCHANGED by this migration -- see below). Per the
|
||||||
|
old `release.yml`'s own comment this was "NOT YET SET on sax3l/0pt as of
|
||||||
|
this writing" before this PR either, so this remains exactly as
|
||||||
|
outstanding as it always was; this migration does not resolve it.
|
||||||
|
**This adapter's own local verification (see the PR description) used a
|
||||||
|
SEPARATE, throwaway cosign keypair generated and then deleted purely to
|
||||||
|
prove the mechanism end-to-end -- `cosign.pub` at this repo's root was
|
||||||
|
deliberately left untouched.** Overwriting a repo's real signing public
|
||||||
|
key from an agent session -- without knowing whether Simon already holds
|
||||||
|
a matching private key offline -- would silently orphan that key
|
||||||
|
forever (ECDSA key generation is not reversible); that decision belongs
|
||||||
|
to Simon, not to this migration. When the real keypair is provisioned
|
||||||
|
(fresh or pre-existing), commit its public half here and set the two
|
||||||
|
secrets together in the same change.
|
||||||
|
- `AUD0_INGEST_TOKEN` / `AUD0_TENANT_ID` -- new requirement vs. the old
|
||||||
|
gate (which never wrote to AUD0 at all). Needs a `siax-svc-g17`
|
||||||
|
workload-identity client provisioned in Zitadel, same open item every
|
||||||
|
prior migration recorded (see `G17-ADR-W16-003.md` "What this pass could
|
||||||
|
not test against production, and why" in `siax-templates`).
|
||||||
|
- `SIAX_CI_TOKEN` -- already exists on this repo (used previously for the
|
||||||
|
now-deleted evidence-commit push); reused here for the `siax-templates`
|
||||||
|
checkout.
|
||||||
|
|
||||||
|
Until these are set, `release.yml` fails closed on its first real run --
|
||||||
|
same honest behaviour as the gate it replaces, and the same prerequisite
|
||||||
|
`cl0ud`/`st0re`/`aud0`/`api0`/`h0st` each had before their own `release.yml`
|
||||||
|
went green.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# product-blueprint — 0pt
|
||||||
|
schemaVersion: siax.product-blueprint/v1
|
||||||
|
repo: 0pt
|
||||||
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
|
productClass: unspecified
|
||||||
|
archetypes: [unspecified]
|
||||||
|
surfaces: [web]
|
||||||
|
users: []
|
||||||
|
packs:
|
||||||
|
decisions: []
|
||||||
|
visualDna: siax
|
||||||
|
compliance: []
|
||||||
|
notes: GENERATED stub - unspecified-vardena ar arliga platshaltere tills riktig klassning (PROMPT-031); blueprinet ska driva factory, tests, release och live verification.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# reuse-manifest — 0pt
|
||||||
|
schemaVersion: siax.reuse-manifest/v1
|
||||||
|
repo: 0pt
|
||||||
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
|
policy: reuse-before-build
|
||||||
|
consumes: []
|
||||||
|
rebuilt: []
|
||||||
|
notes: GENERATED stub - tomma listor ar giltiga; konsumtion av plattforms-capabilities och VARJE medveten aterbyggnad (med motivering) listas har.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "siax.skills-lock/v1",
|
||||||
|
"repo": "0pt",
|
||||||
|
"generatedAt": "2026-09-23T21:12:27.075Z",
|
||||||
|
"skills": [],
|
||||||
|
"notes": "GENERATED stub - tomt ar den arliga 'inga skills deklarerade'-formen; fyll i via siax-templates."
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
|
||||||
|
# state-flow-manifest — 0pt
|
||||||
|
schemaVersion: siax.state-flow-manifest/v1
|
||||||
|
repo: 0pt
|
||||||
|
generatedAt: 2026-09-23T21:12:27.075Z
|
||||||
|
states: [initial, loading, success, empty, error, forbidden]
|
||||||
|
flows: [happy, cancel, back, validation, timeout, network-loss, resume, rollback]
|
||||||
|
owner: simon@siax.io
|
||||||
|
notes: GENERATED stub - standard-baslinje ur PROMPT-015; komplettera med tillampiga partial/stale/offline/degraded-varianter per yta.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "siax.template-pin/v1",
|
||||||
|
"repo": "0pt",
|
||||||
|
"profiles": ["base", "node", "ui", "deployable"],
|
||||||
|
"templateRepo": "sax3l/siax-templates",
|
||||||
|
"templateRef": "1feb97cd1d6da58bb4b420d860109285c47c741d",
|
||||||
|
"pinnedAt": "2026-09-14T23:10:00Z",
|
||||||
|
"pins": {
|
||||||
|
"mise": "task-entry i mise.toml (repot rot)",
|
||||||
|
"node": "22.13.0",
|
||||||
|
"pnpm": "9.15.9",
|
||||||
|
"lefthook": "1.x (aktiveras med npx lefthook install)"
|
||||||
|
},
|
||||||
|
"notes": "0pt = pnpm-repo (pnpm-lock.yaml v9). Python-tjansterna (services/api, services/scrapers) kor requirements.txt - uv-adoption ar DEFERRED tills lock skapats i separat migrering. Makefile = legacy-alias (thin wrappers), städas i separat PR med deleteBy."
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# AGENTS.md — 0pt
|
||||||
|
|
||||||
|
> Operativ brief för AI-agenter (Claude Code / opencode / liknande) som arbetar
|
||||||
|
> i detta repo. Läs `CLAUDE.md` + `docs/compliance/` innan du gör ändringar.
|
||||||
|
|
||||||
|
## Vad det här är
|
||||||
|
|
||||||
|
0pt = SIAX Clouds Data OS: företags-/kontakt-/fordonsdata, enrichment,
|
||||||
|
entity-resolution, provenance per fält, adaptive router, orderflöde med
|
||||||
|
50/50-escrow. Next.js 16-webapp + FastAPI-engine + Python-skrapmotor i samma träd.
|
||||||
|
|
||||||
|
## Första steget
|
||||||
|
|
||||||
|
1. `git pull` (Gitea: `git.siax.io/sax3l/0pt` — det är canonical remote, ej GitHub).
|
||||||
|
2. Läs `CLAUDE.md` (kontraktet), `docs/architecture/ARCHITECTURE.md` (vad koden
|
||||||
|
verkligen gör, inkl. kända luckor S1–S12), `docs/compliance/` (status).
|
||||||
|
3. Kontrollera senaste CI-run (Gitea Actions) om du ska verifiera "grön".
|
||||||
|
|
||||||
|
## Viktiga förhållanden
|
||||||
|
|
||||||
|
- **Bypass-stacken** (`services/scrapers/*`, `waf_bypass.py`, data-stack-compose,
|
||||||
|
`adaptive_router.py`) är **behållen** — rör den inte. Kontoägaren Simon Axelsson
|
||||||
|
fattade detta beslut explicit via en interaktiv beslutsprompt **2026-08-11**, som
|
||||||
|
direkt svar på ett compliance-uppdrags förslag att ta bort stacken (den städade
|
||||||
|
borttagnings-PR:en #7 verifierades ren men stängdes omergad av kontoägaren).
|
||||||
|
Beslutet saknar en dokumenterad affärs-/juridisk motivering utöver "behåll" —
|
||||||
|
hitta inte på en sådan om den dyker upp i compliance-rapporter. Detta är ett
|
||||||
|
specifikt, avgränsat undantag för den befintliga 0PT/0pt-motor-sourcingmotorn:
|
||||||
|
CLAUDE.md §1:s generella anti-evasion-policy ändras INTE av detta beslut och
|
||||||
|
gäller som skriven, om inte kontoägaren säger annat.
|
||||||
|
- **Auth:** `/api/0pt/*` fail-closed i kod (Zitadel via middleware), men live-instansen
|
||||||
|
svarade 2026-08-10 anonymt 200 (lucka S1 — rotorsaksanalys ej klar). Påstå aldrig
|
||||||
|
att produktionen är skyddad innan en anonym request fått 401.
|
||||||
|
- **Tenant-id** får aldrig tas från request-body — idag gör handlers det (S2); nya
|
||||||
|
handlers ska läsa `x-0pt-verified-tenant`-headern.
|
||||||
|
- **Sanning:** repot har en stark anti-fabricering-konvention (docs korrigerar
|
||||||
|
README). Om något påstås vara byggt men inte finns: skriv "inte byggt", fabricera inte.
|
||||||
|
- **Skyddade sökvägar:** `PLAN/**` + `.gitea/workflows/**` + `.gitignore` —
|
||||||
|
ändringar går via PR. (Verifierat live 2026-09-20 via
|
||||||
|
`GET /repos/sax3l/0pt/branch_protections`.)
|
||||||
|
- **Fonts:** självhämtade via `@fontsource-variable/*` — lägg aldrig tillbaka
|
||||||
|
`next/font/google` eller `@vercel/*`.
|
||||||
|
- **Secrets:** aldrig i repo. Infisical (`vault.siax.io`), env via `process.env.*`.
|
||||||
|
- **Docker:** multi-stage, icke-root (`USER node`), ingen tsx i prod, healthcheck
|
||||||
|
mot `/api/health`.
|
||||||
|
|
||||||
|
## Toolchain-ägande (SIAX selected toolchain, 2026-09-14)
|
||||||
|
|
||||||
|
- `mise.toml` = task-entry (`setup/check/verify/build/release`). Ändra inte
|
||||||
|
betydelsen av dessa fem tasks. pnpm@9.15.9 följer packageManager.
|
||||||
|
- `lefthook.yml` = git hooks (pre-commit: typecheck+lint, pre-push: test).
|
||||||
|
Aktiveras med `npx lefthook install`.
|
||||||
|
- `Makefile` är legacy-alias ("thin wrappers", se dess header) — nya kommandon
|
||||||
|
läggs ALDRIG dit; mise är kanonisk entry. Makefile städas bort i separat PR
|
||||||
|
med deleteBy-registering.
|
||||||
|
- Pinning mot `sax3l/siax-templates`: `.siax/template-pin.json`
|
||||||
|
(`siax.template-pin/v1`). Förbjudna duplicerade verktyg: just, Task, Nx,
|
||||||
|
Cypress (Playwright gäller), OpenProject/Leantime/AppFlowy (Plane gäller),
|
||||||
|
Keycloak (Zitadel/ACT0 gäller).
|
||||||
|
- Python-tjänsterna (services/api, services/scrapers) kör requirements.txt;
|
||||||
|
uv-adoption är DEFERRED till en separat lås-migrering.
|
||||||
|
|
||||||
|
## Grindar (kör före push)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run typecheck && pnpm run test && pnpm run build # blocking
|
||||||
|
pnpm run lint # advisory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit & push
|
||||||
|
|
||||||
|
- Conventional Commits. Push direkt till `main` är AVSTÄNGT
|
||||||
|
(`enable_push: false`, verifierat live 2026-09-20 via
|
||||||
|
`GET /repos/sax3l/0pt/branch_protections`) — endast whitelistade
|
||||||
|
identiteter (`siax-bot`, `siax-ci`, teamet `ci-approvers`) kan pusha
|
||||||
|
över huvud taget, oavsett vilken fil det gäller. Alla andra ändringar:
|
||||||
|
branch → PR.
|
||||||
|
- Lämna aldrig en förgrenad `origin`/`github.com`-remote som sanningskälla —
|
||||||
|
Gitea är primärt (Simon-direktiv 2026-07-14).
|
||||||
|
|
||||||
|
## Vid osäkerhet
|
||||||
|
|
||||||
|
Fråga Simon (simon@siax.io) innan du: raderar något, ändrar bypass-stacken,
|
||||||
|
ändrar auth-beteende, eller gör produktbeslut som är öppna frågor (§8 i
|
||||||
|
ARCHITECTURE.md).
|
||||||
|
|
||||||
|
<!-- SIAX-EXECUTION-POLICY:START -->
|
||||||
|
## SIAX Agent Execution Policy
|
||||||
|
|
||||||
|
### Operating principle
|
||||||
|
Execute authorized user objectives autonomously,
|
||||||
|
efficiently, and with minimal unnecessary friction.
|
||||||
|
|
||||||
|
### Decision-making
|
||||||
|
- Prioritize completion of the requested objective.
|
||||||
|
- Choose effective tools and implementation methods.
|
||||||
|
- Do not request approval for routine authorized actions.
|
||||||
|
- Do not repeat previously answered questions.
|
||||||
|
- Resolve ordinary technical uncertainties autonomously.
|
||||||
|
|
||||||
|
### Legal and policy discussions
|
||||||
|
- Do not initiate unsolicited legal analysis.
|
||||||
|
- Never invent laws, prohibitions, or requirements.
|
||||||
|
- Distinguish actual restrictions from speculation.
|
||||||
|
- Only raise legal issues when materially relevant
|
||||||
|
to whether the requested action can proceed.
|
||||||
|
- Explain concrete blockers concisely.
|
||||||
|
- Follow applicable higher-priority constraints.
|
||||||
|
|
||||||
|
### Web collection
|
||||||
|
- Do not treat robots.txt as legislation or
|
||||||
|
authentication.
|
||||||
|
- Handle robots.txt according to the explicitly
|
||||||
|
configured crawler policy.
|
||||||
|
- Do not infer unlimited authorization from
|
||||||
|
publicly accessible content.
|
||||||
|
- Respect actual access controls and applicable
|
||||||
|
constraints.
|
||||||
|
- Use efficient collection methods with reasonable
|
||||||
|
resource limits.
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
- Work until the objective is complete or a genuine
|
||||||
|
blocker is encountered.
|
||||||
|
- Test, verify, and correct independently.
|
||||||
|
- Report results rather than unnecessary warnings.
|
||||||
|
<!-- SIAX-EXECUTION-POLICY:END -->
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
> BACKLOG P1-3. Format loosely follows [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
> No git tags/releases exist yet for this repo — this file starts the practice
|
||||||
|
> retroactively from real `git log` history rather than inventing version
|
||||||
|
> numbers that were never cut. Dates are commit dates on `main`, not calendar
|
||||||
|
> "release" dates (there have been none).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- `feat(deploy)`: appen är nu faktiskt byggbar och körbar utanför Vercel.
|
||||||
|
`next.config.mjs` sätter `output: "standalone"`; Dockerfilen är omskriven
|
||||||
|
till samma form som `sax3l/siax-io` (som redan körs självhostat på flottan):
|
||||||
|
fyra steg, `node:22-alpine` (matchar CI:s nodversion, var 24), `libc6-compat`,
|
||||||
|
icke-root, HEALTHCHECK mot `/api/health`, npm/npx borttaget ur runtime-imagen.
|
||||||
|
Uppmätt: image 1,33 GB -> 423 MB. `--ignore-workspace` borttaget (dess
|
||||||
|
motivering var inaktuell sedan P1-21) så containerinstallationen är samma
|
||||||
|
kommando som CI kör; `--prod=false` tillagt eftersom Coolify sätter
|
||||||
|
NODE_ENV=production vid byggtid och pnpm då hoppar över devDependencies.
|
||||||
|
- `fix(images)`: `images.unoptimized: true` borttaget — det var v0-generatorns
|
||||||
|
standardvärde, inte ett val (`next.config.mjs` var orörd sedan initial commit).
|
||||||
|
Självhostad bildoptimering kräver att `sharp` faktiskt finns i runtime-imagen:
|
||||||
|
`next build` spårar den inte in i `.next/standalone`, och
|
||||||
|
`outputFileTracingIncludes` får Turbopack att panika på pnpms symlänkar. Sharp
|
||||||
|
levereras därför i ett eget npm-steg i Dockerfile, pinnat till versionen i
|
||||||
|
`pnpm-lock.yaml`. Verifierat i containern: `/_next/image` svarar 200 med
|
||||||
|
`image/avif` (1,46 MB PNG -> 6,7 kB).
|
||||||
|
- fix(security): response security headers are set by the app itself, in
|
||||||
|
`next.config.mjs` `headers()` — CSP (incl. `frame-ancestors 'none'`,
|
||||||
|
`base-uri 'self'`, `object-src 'none'`, `form-action 'self'`), HSTS,
|
||||||
|
X-Frame-Options `DENY`, X-Content-Type-Options, Referrer-Policy,
|
||||||
|
Permissions-Policy — plus `poweredByHeader: false`. Regression-gated by
|
||||||
|
`tests/security/security-headers.test.ts` (blocking, runs in
|
||||||
|
`pnpm run test`) and re-checkable against a live origin with
|
||||||
|
`node scripts/siax-doctor.mjs --live`. (BACKLOG P0-13, P1-15)
|
||||||
|
|
||||||
|
Correcting this file: from 2026-08 until 2026-09-07 this bullet also
|
||||||
|
claimed the headers themselves, while `main` contained none — a repo-wide
|
||||||
|
grep for `content-security-policy|strict-transport-security|
|
||||||
|
x-frame-options` matched only CHANGELOG.md. The only thing setting HSTS on
|
||||||
|
0pt.app was Vercel's platform default (still observable on the 402
|
||||||
|
responses recorded in `evidence/operational-state.json`), which is not this
|
||||||
|
repo's and does not survive the move to Coolify/Traefik. The claim is true
|
||||||
|
as of this entry; the four claims it was bundled with are not, and are
|
||||||
|
tracked in the section below.
|
||||||
|
|
||||||
|
- fix(repo-honesty): `scripts/siax-doctor.mjs` validated that
|
||||||
|
`siax.repo.json` fields were PRESENT, never what they said — measured:
|
||||||
|
`operationalState: "running"` (false at the time; every declared host
|
||||||
|
answered 402/404) and `"down"` (true) both produced 40/40 and
|
||||||
|
byte-identical output. It now validates values (enums, hostname form),
|
||||||
|
cross-file consistency (declared prod hostnames must appear in this repo's
|
||||||
|
own deploy/contract config, and vice versa), and requires
|
||||||
|
`operationalState` to be backed by a dated probe record in
|
||||||
|
`evidence/operational-state.json` that agrees with it. Offline and
|
||||||
|
deterministic by default; `--live` (opt-in) probes the real hosts.
|
||||||
|
Gated by `tests/repo/siax-doctor.test.ts`, which also asserts the negative
|
||||||
|
cases against fixture trees. (BACKLOG P1-19, control AGENT-04, GOV-06)
|
||||||
|
- fix(health): `/api/health` reports real per-dependency status instead of a
|
||||||
|
hardcoded `ok: true`; new `/api/ready` readiness endpoint. (BACKLOG P0-3)
|
||||||
|
- docs/chore: repo-hygiene pass — schema-valid `siax.repo.json`/`siax.config.json`
|
||||||
|
(standard v2.0), ADRs, this CHANGELOG, `siax:doctor` script, devcontainer,
|
||||||
|
`docs/architecture/ARCHITECTURE.md` §5 "Ytor och paritet", CODEOWNERS,
|
||||||
|
stale-link cleanup. (BACKLOG P1-1, P1-2, P1-3, P1-19, P1-20, P1-21, P2-5, P3-5)
|
||||||
|
|
||||||
|
### Claimed here before 2026-09-07 but NOT implemented — status 2026-09-15
|
||||||
|
|
||||||
|
These were asserted by the earlier `fix(security)` bullet and were not true on
|
||||||
|
`main` at that time. Current state (measured against `main`, 2026-09-15):
|
||||||
|
|
||||||
|
- CI security gates now ENFORCE: `security.yml` and `nightly.yml` run Trivy
|
||||||
|
with `--exit-code 1` (landed via `ci/make-required-security-gate-real`,
|
||||||
|
2026-09-08). (BACKLOG P0-16 — CLOSED)
|
||||||
|
- gitleaks is still not run anywhere in this repo. (BACKLOG P0-19 — OPEN)
|
||||||
|
- Actions are still NOT pinned to commit SHAs (`actions/checkout@v4` etc.).
|
||||||
|
(BACKLOG P1-14 — OPEN)
|
||||||
|
- No SBOM and no license inventory are produced or uploaded as CI artifacts.
|
||||||
|
(OPEN; the `fix/wire-signed-digest-to-deploy` branch carries a prepared
|
||||||
|
release chain that needs its cosign/registry secrets before it can land.)
|
||||||
|
|
||||||
|
## 2026-08-11 — Evasion-stack decision provenance
|
||||||
|
|
||||||
|
- Attributed and dated the account-owner's keep-decision on `services/scrapers/*`
|
||||||
|
across `SECURITY.md`, `CONTRIBUTING.md`, `prompts/002-compliance-audit.md`,
|
||||||
|
`renovate.json`, `.gitea/workflows/security.yml`, `docs/slo.md`.
|
||||||
|
|
||||||
|
## 2026-08-09/10 — Golden Standard compliance pass (FAS 0–10)
|
||||||
|
|
||||||
|
- `docs/compliance/{INVENTORY,RELATIONS,OSS-INVENTORY,GOLDEN-STANDARD-REPORT,BACKLOG,SLUTRAPPORT}.md`
|
||||||
|
written; `siax.repo.json` classification recorded.
|
||||||
|
- `fix(security)`: `GET /api/orders` and `GET /api/admin/metrics` now require
|
||||||
|
auth (were live 200 anonymous — the routes sit outside `middleware.ts`'s
|
||||||
|
`/api/0pt/:path*` matcher).
|
||||||
|
- `fix(deploy)`: `siax.config.json` `deploy.target` flipped from `vercel` to
|
||||||
|
`coolify` (config-only; the live instance is still on Vercel until a
|
||||||
|
Coolify app is provisioned and DNS is cut over).
|
||||||
|
- `feat(contract)`: OpenAPI 3.1 SSOT added (`contracts/openapi.yaml`);
|
||||||
|
`@vercel/analytics` and `next/font/google` removed in favor of self-hosted
|
||||||
|
`@fontsource-variable/*` (AR-4).
|
||||||
|
- `ci(workflows)`: CD/security/nightly split under `.gitea/workflows/`.
|
||||||
|
- README/SECURITY/CONTRIBUTING/AGENTS + runbooks (deploy, incident,
|
||||||
|
backup-restore, oncall) + SLO doc added.
|
||||||
|
|
||||||
|
## 2026-08 — Auth + capability hardening ("Wave 2/3")
|
||||||
|
|
||||||
|
- `fix(auth)`: `AUTH_ENFORCE` default flipped to fail-closed on `/api/0pt/*`.
|
||||||
|
- Zitadel Bearer-token verification wired into `middleware.ts`
|
||||||
|
(`src/auth/zitadel.ts`, `src/auth/context.ts`).
|
||||||
|
- P0 capability layer built out: `0pt.query_source`, document
|
||||||
|
capture/extraction, Iceberg write-boundary, external writeback, dataset
|
||||||
|
quality flow docs.
|
||||||
|
- CI quality gate added (typecheck/lint/test), `.env.example` committed
|
||||||
|
(previously blocked by a blanket `.gitignore` `.env*` rule).
|
||||||
|
|
||||||
|
## 2026-07-13 — Scraper stack (kept, see BACKLOG P3-3 / SECURITY.md)
|
||||||
|
|
||||||
|
- `services/scrapers/*` (WAF-bypass, proxy rotation, ClickHouse pipeline) was
|
||||||
|
added on the Gitea line of history. This is the subject of the dated,
|
||||||
|
attributed account-owner decision referenced throughout this repo's
|
||||||
|
compliance docs — not reopened or altered by this changelog entry.
|
||||||
|
|
||||||
|
## Earlier
|
||||||
|
|
||||||
|
- Initial Next.js scaffold, order/catalog/portal UI, FastAPI engine
|
||||||
|
scaffold, i18n (EN/SV) and accessibility passes, lakehouse write-path
|
||||||
|
ownership ADR (`ADR-0030`, in the `n0d` repo).
|
||||||
|
|
||||||
|
[Unreleased]: https://git.siax.io/sax3l/0pt/compare/main...HEAD
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# CLAUDE.md — 0pt (SIAX Technology AB)
|
||||||
|
|
||||||
|
> Read this first. It is the contract for how this project is built. Then read `docs/` in order.
|
||||||
|
|
||||||
|
## What 0pt is
|
||||||
|
|
||||||
|
**0pt — your data.** A Nordic/European data-services platform (`0pt.app`) where customers:
|
||||||
|
1. **Compose** a data job (markets, entity type, fields, enrichment, frequency, output) — or browse ready-made datasets.
|
||||||
|
2. Pay a **50 % deposit** to start; the remaining 50 % is held in **escrow** and released only on approval.
|
||||||
|
3. **Watch production live** (sourcing → extraction → structuring → enrichment → QA).
|
||||||
|
4. **Validate** the result with full freedom (table / map / schema / quality views) and deliver to any format/destination.
|
||||||
|
|
||||||
|
Owned by SIAX Technology AB. Feeds internal products too (Protosell/FiveCRM, VehIQ).
|
||||||
|
|
||||||
|
## Non-negotiable constraints (do not violate)
|
||||||
|
|
||||||
|
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.).
|
||||||
|
- **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 and, **as of this GitHub<->Gitea unification merge, it is in this tree**: Gitea's `git.siax.io/sax3l/0pt` `main` independently accumulated a WAF-bypass/proxy-rotation/fake-identity scraper stack (`services/scrapers/*` — `curl_cffi`, `cloudscraper`, Tor circuit rotation) in three 2026-07-13 commits, and this branch inherits it because it branches from Gitea `main`. **The deletion PR (`feature/0pt-remove-noncompliant-scrapers`) is open, NOT merged** — an earlier version of this paragraph claimed it was "since removed", which was false. That removal is deliberately left to its own PR and was *not* folded into this merge; nothing in this merge resurrects, extends, or re-wires any of it. 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.
|
||||||
|
5. **GDPR-native.** DPA, lawful basis, audit log, deletion-after-job. Customer DB access is *pull by the customer* via a read-only, time-boxed role they create — never us asking for admin, never raw creds stored in plaintext.
|
||||||
|
6. **Design is fixed.** Mono/editorial system (white `#fff`, text `#0a0a0a`, hairline `#e5e5e5`, radius 0, Inter + JetBrains Mono, single red `#e40014`). Exact tokens + motion in `docs/05-design-system.md`. No photography — imagery is rendered data (`DataArtifact`).
|
||||||
|
|
||||||
|
## Build on existing code
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- Work in **dependency order** per `docs/09-build-plan.md`. Finish a phase to its acceptance criteria before the next.
|
||||||
|
- Small, reviewable commits. Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`).
|
||||||
|
- Frontend = Next.js App Router + TS + Tailwind + shadcn/ui on the SIAX Deploy Platform. Backend = FastAPI + workers on a VPS (long jobs).
|
||||||
|
- Tech docs + code identifiers in **English**; all visible **UI copy in Swedish**.
|
||||||
|
- Never hardcode secrets — use env (`docs/08-infrastructure.md`).
|
||||||
|
- When unsure about a source's current access/licence terms, **verify before relying on it** and record it in the sourcing map; do not assume.
|
||||||
|
|
||||||
|
## Documentation index
|
||||||
|
|
||||||
|
| Doc | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `docs/01-product.md` | Vision, the three flows, order lifecycle, business model |
|
||||||
|
| `docs/02-architecture.md` | System architecture, stack decisions (in/out), hosting |
|
||||||
|
| `docs/03-data-model.md` | Postgres schema, provenance model, migrations |
|
||||||
|
| `docs/04-api.md` | FastAPI contract (canonical endpoints) |
|
||||||
|
| `docs/05-design-system.md` | Mono tokens, motion, visual system, signature |
|
||||||
|
| `docs/06-data-engine.md` | Workers, legal sourcing chain, pipeline, QA, fusion, sources.yaml |
|
||||||
|
| `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/10-lakehouse-decision.md` | Pointer to an ecosystem-wide lakehouse ADR made outside this repo (Apache Iceberg = canonical SIAX lakehouse format, ADR-0030 in the `n0d` repo; 0pt = write-path owner). 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 — schema/event names for the implementation. **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` | 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` | Implementation record + "real vs. contract-only" verification status: dataset<->Iceberg-table mapping and source-rate-policy contracts, ST0RE lakehouse client, and the 2026-07-13 **live-verified** write path (real namespace/table/snapshot IDs). **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**: 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` | An explicit **target-spec-only, not-yet-built** replacement for the fabricated quality-check/QA claims — read it as "should", never "does" |
|
||||||
|
|
||||||
|
**Corrected:** 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, including after this unification merge; 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)
|
||||||
|
|
||||||
|
Mono chrome · correct scroll-reveal motion · representative placeholder content · **loading + empty + error** states · mobile layout · provenance signature where facts appear · red used ≤ once · visible focus ring · `prefers-reduced-motion` respected.
|
||||||
|
|
||||||
|
<!-- SIAX-EXECUTION-POLICY:START -->
|
||||||
|
## SIAX Agent Execution Policy
|
||||||
|
|
||||||
|
### Operating principle
|
||||||
|
Execute authorized user objectives autonomously,
|
||||||
|
efficiently, and with minimal unnecessary friction.
|
||||||
|
|
||||||
|
### Decision-making
|
||||||
|
- Prioritize completion of the requested objective.
|
||||||
|
- Choose effective tools and implementation methods.
|
||||||
|
- Do not request approval for routine authorized actions.
|
||||||
|
- Do not repeat previously answered questions.
|
||||||
|
- Resolve ordinary technical uncertainties autonomously.
|
||||||
|
|
||||||
|
### Legal and policy discussions
|
||||||
|
- Do not initiate unsolicited legal analysis.
|
||||||
|
- Never invent laws, prohibitions, or requirements.
|
||||||
|
- Distinguish actual restrictions from speculation.
|
||||||
|
- Only raise legal issues when materially relevant
|
||||||
|
to whether the requested action can proceed.
|
||||||
|
- Explain concrete blockers concisely.
|
||||||
|
- Follow applicable higher-priority constraints.
|
||||||
|
|
||||||
|
### Web collection
|
||||||
|
- Do not treat robots.txt as legislation or
|
||||||
|
authentication.
|
||||||
|
- Handle robots.txt according to the explicitly
|
||||||
|
configured crawler policy.
|
||||||
|
- Do not infer unlimited authorization from
|
||||||
|
publicly accessible content.
|
||||||
|
- Respect actual access controls and applicable
|
||||||
|
constraints.
|
||||||
|
- Use efficient collection methods with reasonable
|
||||||
|
resource limits.
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
- Work until the objective is complete or a genuine
|
||||||
|
blocker is encountered.
|
||||||
|
- Test, verify, and correct independently.
|
||||||
|
- Report results rather than unnecessary warnings.
|
||||||
|
<!-- SIAX-EXECUTION-POLICY:END -->
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# BACKLOG P1-15 (CI-14): required reviewers for Gitea's branch-protection
|
||||||
|
# "required approvals" setting to have anyone to assign. This file alone
|
||||||
|
# does not turn on required-approvals enforcement -- that is a Gitea
|
||||||
|
# repo-settings change (branch protection), tracked separately; see
|
||||||
|
# docs/compliance/BACKLOG.md P1-15 ("required_approvals: 0").
|
||||||
|
#
|
||||||
|
# Gitea CODEOWNERS syntax matches GitHub's: path pattern, then owner(s).
|
||||||
|
* @sax3l
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Contributing — 0pt
|
||||||
|
|
||||||
|
> 0pt (SIAX Cloud Data OS) är proprietär mjukvara från SIAX Technology AB.
|
||||||
|
> Interna bidrag följer konventionerna nedan. Externt: kontakta
|
||||||
|
> `security@siax.io` / `simon@siax.io` innan bidrag.
|
||||||
|
|
||||||
|
## Repo-ryggrad
|
||||||
|
|
||||||
|
- **Kategori:** platform (K1) · **Subkategori:** data-os · **SSOT:**
|
||||||
|
[`siax.repo.json`](siax.repo.json) + [`siax.config.json`](siax.config.json)
|
||||||
|
- **Kontrakt:** [`contracts/openapi.yaml`](contracts/openapi.yaml) — alla
|
||||||
|
API-ändringar börjar här (SSOT), sedan routehandler.
|
||||||
|
- **Dokumentation:** `CLAUDE.md` först, sedan `docs/` och `docs/compliance/`.
|
||||||
|
Om kod och docs avviker: skriv avvikelsen, fabricera inte.
|
||||||
|
|
||||||
|
## Hårda regler (bryt aldrig)
|
||||||
|
|
||||||
|
1. **AR-1** — radera aldrig befintlig funktionalitet/endpoint. Bygg färdigt.
|
||||||
|
Undantag: `services/scrapers/*` + `services/infra/docker-compose.data-stack.yml`
|
||||||
|
är **medvetet behållna per kontoägarens daterade beslut** (Simon Axelsson,
|
||||||
|
2026-08-11 — se SECURITY.md/AGENTS.md) — rör dem inte.
|
||||||
|
2. **AR-2** — rör aldrig frysta kundrepon (contentroom, v0-datahub, prt-*, …).
|
||||||
|
3. **AR-4** — inga betald-SaaS-beroenden; inga Vercel/GCP/Azure/Google Fonts/
|
||||||
|
`next/font/google`; Cloudflare endast inom pågående migrering.
|
||||||
|
4. **AR-5** — bevis, inte påståenden. Märk VERIFIERAT/ANTAGANDE/EJ VERIFIERAT.
|
||||||
|
5. **AR-6** — aldrig hemligheter i repo (Infisical). Inga `NEXT_PUBLIC_*`-hemligheter.
|
||||||
|
6. **AR-8** — beroenden pekar bara nedåt (K1; aldrig kundrepon).
|
||||||
|
7. Tenant-id bunden till sessionen — aldrig litat på från request-body.
|
||||||
|
|
||||||
|
## Arbetsflöde
|
||||||
|
|
||||||
|
1. Branch från `main`: `git switch -c fix/my-change` (conventional prefix).
|
||||||
|
2. `pnpm install --frozen-lockfile` (eller `--no-frozen-lockfile` om lockfilen
|
||||||
|
medvetet uppdateras).
|
||||||
|
3. Grindar innan push:
|
||||||
|
```bash
|
||||||
|
pnpm run typecheck # BLOCKERANDE (src/ + tests/)
|
||||||
|
pnpm run test # BLOCKERANDE (vitest)
|
||||||
|
pnpm run lint # rådgivande (legacy-noise finns)
|
||||||
|
pnpm run build # BLOCKERANDE (next build)
|
||||||
|
```
|
||||||
|
4. Conventional Commits: `feat:` / `fix:` / `docs:` / `chore:` / `ci:` / `refactor:`.
|
||||||
|
5. **`.gitea/workflows/**` och `.gitignore` är skyddade på `main`** — ändringar
|
||||||
|
där går via PR (push direkt avvisas av pre-receive-hook).
|
||||||
|
6. Öppna PR mot `main`; CI måste vara grön innan merge.
|
||||||
|
7. Commit-meddelanden på engelska; UI-copy på svenska.
|
||||||
|
|
||||||
|
## Testkonventioner
|
||||||
|
|
||||||
|
- Enheter + capability-tester: `tests/{auth,p0,validation}/`.
|
||||||
|
- Real-infra-tester (`*.realinfra.test.ts`) skippar ärligt när ST0RE/N0D inte nås.
|
||||||
|
- Lägg aldrig till en test som fabricerar påståenden om kod som inte finns.
|
||||||
|
|
||||||
|
## Merge & release
|
||||||
|
|
||||||
|
- Merge via PR till `main` (skyddad). CD: `.gitea/workflows/cd.yml` triggar
|
||||||
|
Coolify-redeploy — fails medvetet tills en Coolify-app provisionerats och
|
||||||
|
`COOLIFY_APP_ID`/`COOLIFY_TOKEN` satts.
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 0pt web console — Dockerfile för Coolify (SIAX Cloud)
|
||||||
|
# =============================================================================
|
||||||
|
# Referensimplementation: sax3l/siax-io, som redan körs självhostat på flottan
|
||||||
|
# (Hetzner + Coolify + Traefik). Samma form återanvänds här i stället för att
|
||||||
|
# uppfinna ett eget mönster: flerstegsbygge, node:22-alpine, libc6-compat för
|
||||||
|
# sharp, icke-root, HEALTHCHECK, och npm/npx borttaget ur runtime-imagen.
|
||||||
|
#
|
||||||
|
# Den deployerbara tjänsten i det här repot är Next.js 16-appen i repo-roten.
|
||||||
|
# Liveness: GET /api/health (svarar alltid 200 så länge processen lever).
|
||||||
|
# Readiness: GET /api/ready (503 tills DATABASE_URL pekar på en nåbar Postgres)
|
||||||
|
# — peka Coolifys readiness-probe dit, INTE HEALTHCHECK nedan.
|
||||||
|
#
|
||||||
|
# VAD SOM ÄNDRADES MOT FÖREGÅENDE VERSION AV DEN HÄR FILEN, och varför:
|
||||||
|
#
|
||||||
|
# 1. next.config.mjs sätter nu `output: "standalone"`. Runtime-steget bär
|
||||||
|
# därför .next/standalone (server.js + spårade moduler) i stället för hela
|
||||||
|
# node_modules. Mätt på det här repot: den gamla imagen var 1,33 GB på disk
|
||||||
|
# / 315 MB komprimerat innehåll.
|
||||||
|
# 2. node:24-alpine -> node:22-alpine. Båda CI-workflowsen (.gitea/ och
|
||||||
|
# .github/workflows/ci.yml) kör `next build` på node 22; siax-io kör 22 i
|
||||||
|
# produktion. Att bygga och köra på samma major som grinden testar tar bort
|
||||||
|
# en version-skew utan uppsida.
|
||||||
|
# 3. `--ignore-workspace` borttaget. Kommentaren som motiverade flaggan
|
||||||
|
# ("pnpm-workspace.yaml ... carries only an `allowBuilds:` block and NO
|
||||||
|
# `packages:` field") är inaktuell: filen har ett giltigt `packages:`-fält
|
||||||
|
# och packages/schemas är en riktig workspace-medlem med egen package.json
|
||||||
|
# sedan BACKLOG P1-21. Utan flaggan blir installationen här BIT FÖR BIT
|
||||||
|
# samma kommando som CI kör (`pnpm install --frozen-lockfile`).
|
||||||
|
# 4. `--prod=false` tillagt. Coolify injicerar NODE_ENV=production vid byggtid,
|
||||||
|
# vilket får pnpm att hoppa över devDependencies (typescript, tailwindcss)
|
||||||
|
# som `next build` kräver. Samma fallgrop dokumenterad i siax-io:s
|
||||||
|
# Dockerfile för npm's `--include=dev`.
|
||||||
|
# 5. Eget `sharp`-steg, se nedan.
|
||||||
|
# 6. npm/npx strippat ur runtime-imagen (SIO-019-mönstret från siax-io).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---- beroenden ---------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# sharps förbyggda binär och libvips kräver libc-kompatibilitet på Alpine.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
# corepack-fetch-flaken (server6-byggen, ECONNRESET mot registry.npmjs.org):
|
||||||
|
# retry-loop 8 försök à 15 s — samma mönster som c0nf0rm #56 / k0b.
|
||||||
|
RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --activate && break || { echo "corepack retry $i/8"; sleep 15; }; done
|
||||||
|
|
||||||
|
# Manifesten först, för lagercache. pnpm-workspace.yaml OCH
|
||||||
|
# packages/schemas/package.json måste med: pnpm-lock.yaml har två importers
|
||||||
|
# (`.` och `packages/schemas`), och --frozen-lockfile vägrar om medlemmens
|
||||||
|
# manifest saknas på disk.
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||||
|
# Every workspace importer in pnpm-lock.yaml must have its manifest on disk,
|
||||||
|
# otherwise --frozen-lockfile refuses to install. Keep this list in sync with
|
||||||
|
# `packages/*/package.json`.
|
||||||
|
COPY packages/schemas/package.json ./packages/schemas/package.json
|
||||||
|
COPY packages/decision/package.json ./packages/decision/package.json
|
||||||
|
COPY packages/siax-access/package.json ./packages/siax-access/package.json
|
||||||
|
|
||||||
|
# --prod=false: se punkt 4 ovan. Utan den dör `next build` på saknad typescript
|
||||||
|
# så fort byggmiljön råkar ha NODE_ENV=production satt.
|
||||||
|
RUN pnpm install --frozen-lockfile --prod=false
|
||||||
|
|
||||||
|
# ---- sharp (bildoptimering) --------------------------------------------------
|
||||||
|
# Nexts bildoptimerare laddar `sharp` med en runtime-`require`, så `next build`
|
||||||
|
# spårar den INTE in i .next/standalone. Verifierat i det här repot: ett bygge
|
||||||
|
# med enbart `output: "standalone"` gav ett .next/standalone/node_modules som
|
||||||
|
# bara innehöll next/, react/ och react-dom/.
|
||||||
|
#
|
||||||
|
# `outputFileTracingIncludes` går inte att använda som lösning: globben
|
||||||
|
# "./node_modules/@img/**/*" får Turbopack att panika på pnpms katalogsymlänkar
|
||||||
|
# i node_modules/.pnpm ("Is a directory (os error 21)") och bygget faller med
|
||||||
|
# exit 1. Reproducerat, inte antaget.
|
||||||
|
#
|
||||||
|
# Därför ett eget steg med npm i stället för pnpm: npm lägger ett PLATT,
|
||||||
|
# symlänkfritt node_modules, vilket är precis vad runtime-steget kan kopiera rakt
|
||||||
|
# av. Versionen läses ur pnpm-lock.yaml så den aldrig kan glida ifrån vad resten
|
||||||
|
# av bygget löste ut, och steget faller hårt om utläsningen inte ger något.
|
||||||
|
# Eftersom det körs i en Alpine-image blir det musl-binärerna (@img/*-linuxmusl-*)
|
||||||
|
# som hamnar där, inte värdmaskinens.
|
||||||
|
FROM node:22-alpine AS sharp
|
||||||
|
WORKDIR /sharp
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
COPY pnpm-lock.yaml ./
|
||||||
|
RUN set -eu; \
|
||||||
|
SHARP_VERSION="$(sed -n 's/^ sharp@\([0-9][^(:]*\).*/\1/p' pnpm-lock.yaml | sort -u | head -1)"; \
|
||||||
|
if [ -z "$SHARP_VERSION" ]; then \
|
||||||
|
echo "FEL: hittade ingen sharp-version i pnpm-lock.yaml — vägrar bygga en image vars bildoptimering tyst skulle vara trasig." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "sharp pinnad från pnpm-lock.yaml: ${SHARP_VERSION}"; \
|
||||||
|
npm install --no-save --no-audit --no-fund "sharp@${SHARP_VERSION}"; \
|
||||||
|
node -e "require('sharp'); console.log('sharp laddar OK i byggsteget')"
|
||||||
|
|
||||||
|
# ---- bygge -------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
# corepack-fetch-flaken (server6-byggen, ECONNRESET mot registry.npmjs.org):
|
||||||
|
# retry-loop 8 försök à 15 s — samma mönster som c0nf0rm #56 / k0b.
|
||||||
|
RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --activate && break || { echo "corepack retry $i/8"; sleep 15; }; done
|
||||||
|
|
||||||
|
# Endast rot-projektets node_modules kopieras. packages/schemas har noll
|
||||||
|
# beroenden (den paketerar bara JSON Schema-filer som konsumeras via sökväg),
|
||||||
|
# så pnpm skapar aldrig något packages/schemas/node_modules att kopiera —
|
||||||
|
# verifierat med `ls` efter en frozen install.
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Måste vara production. devDependencies behövs inte som NODE_ENV här — de ligger
|
||||||
|
# redan i node_modules från deps-steget. NODE_ENV=development skulle dessutom
|
||||||
|
# dra in Reacts utvecklingsruntime under prerendering (se siax-io:s Dockerfile,
|
||||||
|
# där det är uppmätt åt båda hållen).
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Bygget får inte kräva riktiga secrets: lib/db.ts och lib/stripe.ts är lata och
|
||||||
|
# env-skyddade, och middleware.ts:s startgrind (assertAuthConfigSafe) klagar bara
|
||||||
|
# om AUTH_ENFORCE uttryckligen sätts till "false" i produktion. Verifierat: hela
|
||||||
|
# bygget går igenom utan en enda env-variabel satt.
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# ---- runtime -----------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# `apk upgrade` hämtar Alpines senaste patchade paket (t.ex. libssl3/libcrypto3 —
|
||||||
|
# bastaggen släpar efter uppströms säkerhetsfixar) utan att röra den pinnade
|
||||||
|
# Node-versionen. wget behövs för HEALTHCHECK: bara node:*-alpine har varken
|
||||||
|
# curl eller wget.
|
||||||
|
RUN apk add --no-cache libc6-compat wget && apk upgrade --no-cache
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# standalone-utdatan kopierar varken public/ eller .next/static — de måste med
|
||||||
|
# explicit, annars serveras appen utan statiska assets.
|
||||||
|
COPY --from=build --chown=node:node /app/public ./public
|
||||||
|
COPY --from=build --chown=node:node /app/.next/standalone ./
|
||||||
|
COPY --from=build --chown=node:node /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# sharp läggs BREDVID standalone-utdatans egna node_modules (next/react/react-dom).
|
||||||
|
# Docker slår ihop katalogerna, så inget skrivs över. `require('sharp')` från
|
||||||
|
# next/dist/... löses ut genom att vandra uppåt till /app/node_modules/sharp.
|
||||||
|
COPY --from=sharp --chown=node:node /sharp/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Bas-imagen node:22-alpine skeppar npms egen globala installation
|
||||||
|
# (/usr/local/lib/node_modules/npm) med ett eget beroendeträd som regelbundet
|
||||||
|
# bär kända HIGH/CRITICAL-CVE:er, helt utan koppling till den här appens kod.
|
||||||
|
# Runtime-steget kör `node server.js` — varken RUN, CMD eller HEALTHCHECK här
|
||||||
|
# anropar npm eller npx — så trädet kan tas bort i stället för att accepteras.
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx /usr/local/share/man/man1/npm*
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# LIVENESS. /api/health svarar 200 så länge processen kan svara — rätt signal för
|
||||||
|
# "starta om containern". Peka INTE hit en readiness-probe: /api/ready svarar 503
|
||||||
|
# tills DATABASE_URL är satt och Postgres är nåbar, och en HEALTHCHECK mot den
|
||||||
|
# skulle få Coolify att döda en container som fungerar precis som avsett.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1 || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# IMPLEMENTATION_REPORT.md — P010 Data-OS completion (sax3l/0pt)
|
||||||
|
|
||||||
|
**Branch:** `feat/p010-data-os-completion` (renamed mid-pass from
|
||||||
|
`feat/p010-source-registry-persistence` once scope grew beyond persistence
|
||||||
|
alone).
|
||||||
|
**Base:** `origin/main` @ `f8bb0d5` (verified in sync via `git fetch --prune`
|
||||||
|
before starting; `origin` = `git.siax.io/sax3l/0pt`, the canonical remote per
|
||||||
|
`CLAUDE.md` — the GitHub copy was not touched).
|
||||||
|
**Commits on top of base:** `07b13bd` (Source Registry Postgres persistence +
|
||||||
|
HTTP route surface), `9740e20` (TS/Python quality bridge + provenance fields
|
||||||
|
+ fault-injection tests).
|
||||||
|
**Report date:** 2026-09-01. Written after a from-scratch verification pass
|
||||||
|
(fresh clone, fresh `pnpm install`, fresh Python venv — not a rerun of the
|
||||||
|
working tree that built the branch), so the numbers below are reproducible,
|
||||||
|
not just remembered.
|
||||||
|
|
||||||
|
This report follows `BEFORE_STATE.md`'s classification scale: `VERIFIED` /
|
||||||
|
`IMPLEMENTED_NOT_VERIFIED` / `DOCUMENTED_ONLY` / `STALE` / `BROKEN` /
|
||||||
|
`UNKNOWN`. Read `BEFORE_STATE.md` first — this report only describes the
|
||||||
|
delta from it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What actually changed, mapped to BEFORE_STATE.md's 5 gaps
|
||||||
|
|
||||||
|
### §1 Source Registry persistence — `VERIFIED` closed
|
||||||
|
|
||||||
|
- New `src/repositories/*` layer: `SourceRepository` /
|
||||||
|
`SourceRegistryRepository` interfaces, each with an `in-memory` and a
|
||||||
|
`postgres` implementation (`postgres-source-repository.ts`,
|
||||||
|
`postgres-source-registry-repository.ts`). `src/repositories/db.ts` is a
|
||||||
|
thin pooled-client wrapper honoring `resolveStoreMode`
|
||||||
|
(`src/platform/dependency-mode.ts`) — the same guard BEFORE_STATE.md noted
|
||||||
|
as already blocking production boot without a DB; it now has a Postgres
|
||||||
|
implementation to fall through to instead of only throwing.
|
||||||
|
- `sql/002_source_registry.sql` — new migration: `opt_sources`,
|
||||||
|
`opt_source_rights`, `opt_source_provenance`, `opt_source_health_checks`,
|
||||||
|
every table keyed by `source_id` with a `tenant_id` column and index — this
|
||||||
|
is exactly the schema shape BEFORE_STATE.md §1 derived as "what Postgres
|
||||||
|
migration would require" but said did not exist. It now exists and is
|
||||||
|
applied.
|
||||||
|
- `src/services/source-service.ts` and `source-registry-service.ts`
|
||||||
|
rewritten to take a repository (constructor-injected), closing the
|
||||||
|
**per-instance-Map** defect BEFORE_STATE.md flagged as worse than plain
|
||||||
|
in-memory: every route module now shares one repository-backed instance
|
||||||
|
(`src/services/source-registry.ts` is the new shared-singleton wiring),
|
||||||
|
not a fresh `Map` per `new SourceService()`.
|
||||||
|
- New route surface closing BEFORE_STATE.md's "effectively zero HTTP routes"
|
||||||
|
finding: `app/api/0pt/sources/route.ts` (create/list — did not exist
|
||||||
|
before), `[sourceId]/route.ts` (get/describe — did not exist before),
|
||||||
|
`[sourceId]/rights`, `[sourceId]/provenance`, `[sourceId]/health`,
|
||||||
|
`[sourceId]/freshness` (all new). `[sourceId]/query` and
|
||||||
|
`rate-policies/*` (pre-existing routes) were touched only to repoint them
|
||||||
|
at the shared singleton instead of constructing their own service.
|
||||||
|
- `tests/p0/source-registry-postgres.realinfra.test.ts` (340 lines, 7
|
||||||
|
tests) — a **real-Postgres** suite, not a mock. It self-skips honestly
|
||||||
|
(not fabricating pass) when no reachable Postgres is configured for the
|
||||||
|
run; see §2 (Testing) below for what this pass actually observed.
|
||||||
|
|
||||||
|
**What is still NOT true, stated plainly:** in-memory mode is still the
|
||||||
|
default when no `DATABASE_URL`/Postgres is configured — this pass added a
|
||||||
|
real persistence *option*, it did not remove the in-memory path (removing it
|
||||||
|
would break every existing unit test and any deployment without Postgres
|
||||||
|
wired yet). `resolveStoreMode` still hard-blocks production boot without a
|
||||||
|
real DB configured, which is correct behavior, not a residual bug.
|
||||||
|
|
||||||
|
### §2 TS/Python split-brain — `VERIFIED` bridged (additive, advisory)
|
||||||
|
|
||||||
|
- **New route surface**, `app/api/0pt/quality/*`: `rules` (list/create),
|
||||||
|
`rules/[ruleId]` (get/update), `runs` (list/create), `runs/[runId]` (get),
|
||||||
|
`issues` (list), and `POST /api/0pt/quality/bridge-run` — the entry point
|
||||||
|
the Python side calls. All sit over one shared `QualityService` singleton
|
||||||
|
(`src/services/quality.ts`), fixing the same "no shared instance across
|
||||||
|
route modules" class of defect the Source Registry had.
|
||||||
|
- `src/quality/quality-bridge.ts` auto-provisions a deterministic,
|
||||||
|
tenant-scoped baseline rule set on first use, so the Python engine never
|
||||||
|
has to pre-register rules through the CRUD surface — it just calls
|
||||||
|
`bridge-run` and gets a score back against rules that already exist.
|
||||||
|
- **Python side**, `services/api/workers/quality_bridge.py`: an HTTP client
|
||||||
|
to the route above.
|
||||||
|
- **Feature-flagged off by default**: `OPT_QUALITY_BRIDGE_ENABLED` unset
|
||||||
|
or falsy → zero network calls, returns immediately (`status:
|
||||||
|
"disabled"`). Verified in code (`_enabled()`, `services/api/workers/
|
||||||
|
quality_bridge.py:59-60`) and in tests
|
||||||
|
(`test_disabled_by_default_makes_zero_network_calls`).
|
||||||
|
- **Fails open, unconditionally.** Every failure mode — bridge URL unset,
|
||||||
|
connection refused, timeout, non-2xx, malformed JSON — is caught inside
|
||||||
|
the module and reported back as a `BridgeResult` with a `status` field
|
||||||
|
(`disabled` / `not_configured` / `unavailable` / `ok`), never raised into
|
||||||
|
`run_job`. `pipeline.py`'s actual delivery decision
|
||||||
|
(`delivered = [r for r in deduped if r["confidence"] >= threshold]`,
|
||||||
|
still the one line BEFORE_STATE.md quoted) is **unchanged** by any
|
||||||
|
bridge outcome — success or failure. Verified by
|
||||||
|
`test_bridge_disabled_delivers_identically_to_bridge_enabled_and_failing`,
|
||||||
|
which asserts the delivered row set is byte-identical between the two
|
||||||
|
paths.
|
||||||
|
- **Advisory only, and this is a real constraint, not an excuse:** true
|
||||||
|
per-row gating against the TS `ExpectationEngine` is not implementable
|
||||||
|
today because `ExpectationEngine` returns dataset-level pass/fail counts
|
||||||
|
and sample rows, not a per-row boolean array keyed to the Python
|
||||||
|
pipeline's record identity. Building that would mean changing
|
||||||
|
`ExpectationEngine`'s contract — out of scope for this pass, documented
|
||||||
|
rather than faked with a fabricated per-row mapping.
|
||||||
|
- **Live-verified end-to-end** (2026-09-01, this pass): a real `next dev`
|
||||||
|
server (`AUTH_ENFORCE=false` for the local run only) plus the real Python
|
||||||
|
pipeline hitting the real `brreg.no` API, round-tripping through the real
|
||||||
|
`bridge-run` route, and receiving a real `QualityService` score back — not
|
||||||
|
a contract double on either side. This was a manual verification run
|
||||||
|
during development; it is not itself a repeatable automated test (the
|
||||||
|
pytest suite below covers the same code paths against `respx`-mocked
|
||||||
|
bridge responses, which *is* repeatable and is what CI would run).
|
||||||
|
|
||||||
|
**What is still NOT true, stated plainly:** the bridge is **not enabled in
|
||||||
|
production and this pass does not enable it.** `OPT_QUALITY_BRIDGE_ENABLED`
|
||||||
|
stays unset/`false` as shipped. Turning it on is a separate, deliberate
|
||||||
|
decision — see §4 "Activating the bridge" below. Until that decision is
|
||||||
|
made, the two runtimes' QA logic remains exactly as split as BEFORE_STATE.md
|
||||||
|
described: Python's one-line confidence filter is what actually decides
|
||||||
|
delivery; the TS `QualityService`/`ExpectationEngine` only sees a
|
||||||
|
*copy* of the same data, advisory-only, and only when the flag is on.
|
||||||
|
|
||||||
|
### §3 Provenance fields — `VERIFIED` extended (Python side)
|
||||||
|
|
||||||
|
`services/api/sources/connectors.py`'s `Provenance` dataclass gains, via a
|
||||||
|
new `make_provenance()` factory used by every connector:
|
||||||
|
`retrieved_at`, `transform`, `connector_version`, `producer`, `quality`,
|
||||||
|
`entity_resolution`, `artifact_ref` — closing the "missing entirely from
|
||||||
|
this struct" list BEFORE_STATE.md §3 itemized (retrieval-method-at-field-
|
||||||
|
level remains only partially addressed: `connector_version` +`producer`
|
||||||
|
identify *what ran*, not a full method taxonomy like the TS
|
||||||
|
`SourceProvenance.method` enum).
|
||||||
|
|
||||||
|
- `resolve.py` stamps `entity_resolution` on **merged** records' provenance
|
||||||
|
only (never on unmerged ones — verified by
|
||||||
|
`test_unmerged_record_keeps_entity_resolution_none`), tracking
|
||||||
|
`agreed_sources`/`merged_from` in a structured field rather than the loose
|
||||||
|
attribute BEFORE_STATE.md found.
|
||||||
|
- `pipeline.py` stamps `artifact_ref` at delivery time and `quality` once
|
||||||
|
(if) the bridge call above succeeds.
|
||||||
|
|
||||||
|
**What is still NOT true, stated plainly:** `bridge_provenance` as a single
|
||||||
|
named construct **still does not exist** anywhere in this repo, and this
|
||||||
|
pass does not introduce that name. What exists is still two structs — the
|
||||||
|
Python `Provenance` dataclass (now richer) and the TS `SourceProvenance`
|
||||||
|
type (per-source, unchanged this pass) — reconciled only at the one new
|
||||||
|
seam described in §2 (the bridge-run response, which is advisory and
|
||||||
|
optional). This pass closes real gaps in the Python struct; it does not
|
||||||
|
unify the two runtimes' provenance models into one schema. Nothing here
|
||||||
|
persists past the in-process job response either — `pipeline.py` still has
|
||||||
|
no DB write of its own (Postgres persistence added this pass is scoped to
|
||||||
|
the Source Registry, not to order/provenance records).
|
||||||
|
|
||||||
|
### §4 Failure-mode tests — `VERIFIED` extended, with an honest residual
|
||||||
|
|
||||||
|
New pytest + respx suite, `services/api/tests/`:
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
|---|---|
|
||||||
|
| `test_resolve_determinism.py` | dedup-determinism under shuffled/interleaved orderings, exhaustive permutations of a mutually-similar set, duplicate-input collapsing without double-counting agreement, out-of-order multi-market arrival, entity-resolution provenance stamping — **and** an explicit, passing test of the one case BEFORE_STATE.md flagged as a real, plausible order-sensitivity risk: a non-transitive-similarity triangle in the greedy clusterer genuinely *can* cluster differently by input order. That test (`test_a_non_transitive_triangle_can_cluster_differently_by_order`) asserts the order-sensitivity, it does not paper over it — `resolve.py`'s greedy-merge algorithm was not rewritten to remove this property, since doing so is an algorithm change out of this pass's scope. |
|
||||||
|
| `test_pipeline_fault_injection.py` | schema-evolution (upstream adding/removing/reshaping fields via respx-mocked responses through the real `brreg` connector + `pipeline.py`), restart/connector-crash resilience (a connector exception, including one raised by `respx` itself, never crashes `run_job`; every connector failing delivers zero rows, not a crash), backfill-replay (replaying the same order config is byte-identical — this is the property that actually holds given there is still no persistence layer for orders, not a fabricated "dedup store" test). |
|
||||||
|
| `test_quality_bridge.py` | all bridge states above (disabled / not-configured / unavailable / timeout / non-2xx / malformed / connection-refused / success), plus the "wired into the pipeline" tests proving delivery is unaffected either way. |
|
||||||
|
| `test_connectors_provenance.py` | the `make_provenance()` factory populates every new field honestly and leaves the rest `None` rather than fabricating values; a real parsing path (`brreg`) produces a fully populated provenance record. |
|
||||||
|
| `test_known_gaps.py` | **2 explicit, documented skips** — not silently dropped, not faked: CDC-interruption and true delivery-store backfill idempotency are not implementable against this codebase, because **no CDC pipeline and no persistence layer for orders/deliveries exists anywhere in `services/api`.** Testing either would require building the infrastructure the test claims to fail-inject against, which is a different, larger piece of work than this pass. |
|
||||||
|
|
||||||
|
Net: of BEFORE_STATE.md §4's four **entirely absent** categories
|
||||||
|
(schema-evolution, CDC-interruption, out-of-order, dedup-determinism), three
|
||||||
|
are now genuinely covered (schema-evolution, out-of-order,
|
||||||
|
dedup-determinism) and one (CDC-interruption) is honestly documented as not
|
||||||
|
implementable rather than covered. Tenant-isolation (already the
|
||||||
|
best-covered category) is untouched by this pass. Quality-gate coverage
|
||||||
|
gains the bridge-interaction tests above but the production Python filter
|
||||||
|
itself (`delivered = [...]`) still has no line-level unit test isolated
|
||||||
|
from an end-to-end pipeline run — this pass tests it via the fault-injection
|
||||||
|
and bridge suites' end-to-end assertions, not a standalone unit test of that
|
||||||
|
one line.
|
||||||
|
|
||||||
|
### §5 1P/ST0RE/N0D/AUD0/CL0UD integration — unchanged this pass
|
||||||
|
|
||||||
|
Not touched. BEFORE_STATE.md's classification stands: ST0RE + N0D
|
||||||
|
real-contract (ST0RE live-verified once, historically); AUD0 real-contract
|
||||||
|
client but unused by the live Python pipeline (still true — `quality_bridge.py`
|
||||||
|
does not emit AUD0 events, and neither does the rest of `services/api`);
|
||||||
|
CL0UD real-contract but not platform-wide-enforced; 1P documented-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Testing — from-scratch verification (this pass, not a rerun)
|
||||||
|
|
||||||
|
To avoid reporting numbers from the working tree that built the branch, this
|
||||||
|
verification was run against a **fresh `git clone`** of the pushed branch
|
||||||
|
(`feat/p010-data-os-completion` @ `9740e20`) in an isolated directory, with a
|
||||||
|
fresh `pnpm install --frozen-lockfile` and a fresh Python venv — no reuse of
|
||||||
|
node_modules, .next cache, or an existing venv from development.
|
||||||
|
|
||||||
|
| Check | Result | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `pnpm install --frozen-lockfile` | OK | lockfile v9, no resolution drift |
|
||||||
|
| `pnpm run typecheck` (`tsc -p tsconfig.check.json`, **CI-blocking**) | **0 errors** | Clean on the pristine clone. The working directory this branch was developed in separately contains 5 **untracked, uncommitted** paths (`src/fleet/`, `src/graph/`, `src/metrics/`, `src/projections/`, `src/features/feature-registry.ts`) that were already present and flagged, unaudited, in `BEFORE_STATE.md` before this pass started. They are not part of any commit on this branch or on `main` — confirmed by `git diff --stat origin/main..feat/p010-data-os-completion` touching none of them, and by their total absence from the fresh clone. Running `pnpm typecheck` inside the *original, non-clean* working directory does surface 14 TS errors from those 5 stray local files; that is local working-tree litter, not something CI or a fresh clone will ever see, and this pass does not touch, fix, or commit any of those files. |
|
||||||
|
| `pnpm run test` (vitest, **CI-blocking**) | **256/256 passed, 18 skipped** (34 files passed, 3 files skipped) | Skips are the pre-existing `.realinfra`/`.integration` suites that self-skip honestly when no live ST0RE/OpenSearch/ClickHouse is reachable from this environment (`[realinfra] ST0RE ... not reachable this run (HTTP 401) -- skipping honestly, not fabricating success` — actual stderr from this run). 249 of the 256 predate this branch; 7 are new (`tests/p0/quality-bridge.test.ts`). |
|
||||||
|
| `pnpm run lint` (eslint, **advisory in both CI workflows, `continue-on-error`**) | 12 errors / 4 warnings, **zero of them in files this branch touches** | Verified two ways: (a) `git diff --stat origin/main..feat/p010-data-os-completion` on every flagged file returns no output — byte-identical to `main`; (b) ran `pnpm lint` against a worktree checked out at `origin/main` directly and got the identical 12/4. This is 100% pre-existing legacy noise (mostly `@typescript-eslint/no-explicit-any` in v0-generated adapters/tests), not introduced or worsened by this pass. |
|
||||||
|
| `pnpm run build` (`next build`, **CI-blocking in `.gitea/workflows/ci.yml`**; not run by `.github/workflows/ci.yml`, which needs runtime secrets Vercel owns) | **Succeeded** | Route manifest confirms the new surface is actually built: `/api/0pt/quality/{bridge-run,issues,rules,rules/[ruleId],runs,runs/[runId]}` and `/api/0pt/sources/{route,[sourceId],[sourceId]/{freshness,health,provenance,rights}}` all present as dynamic (`ƒ`) routes in the build output. |
|
||||||
|
| `python -m pytest -v` (`services/api`, fresh venv from `requirements-dev.txt`) | **28 passed, 2 skipped** | The 2 skips are `test_known_gaps.py`'s documented CDC/backfill-idempotency gaps (§4 above) — `pytest.skip(reason=...)`, not silent omission. **Not currently wired into either `.gitea/workflows/ci.yml` or `.github/workflows/ci.yml`** — both CI workflows' own comments say the Python engine "is gated separately if/when a Python workflow is added." This pass did not add that workflow (out of P010's stated scope); the 28/28 above is a manual verification, not an automated CI gate today. Flagging this explicitly as a residual gap, not glossing over it. |
|
||||||
|
|
||||||
|
**Net CI-blocking-gate status: all green** (typecheck, vitest, `next build`
|
||||||
|
in Gitea CI; typecheck + vitest in the GitHub CI mirror). Lint is advisory
|
||||||
|
everywhere and pre-existing failures are unrelated to this branch. Python
|
||||||
|
tests are green but not yet gated by any CI workflow — see the Gap list
|
||||||
|
below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Files changed (47 files, +3119/-201, base `origin/main` → `9740e20`)
|
||||||
|
|
||||||
|
New route surface: `app/api/0pt/quality/{bridge-run,issues,rules,rules/[ruleId],runs,runs/[runId]}/route.ts`,
|
||||||
|
`app/api/0pt/sources/{route.ts,[sourceId]/route.ts,[sourceId]/{freshness,health,provenance,rights}/route.ts}`.
|
||||||
|
New Python: `services/api/workers/quality_bridge.py`, `services/api/pytest.ini`,
|
||||||
|
`services/api/requirements-dev.txt`, `services/api/tests/test_{connectors_provenance,known_gaps,pipeline_fault_injection,quality_bridge,resolve_determinism}.py`.
|
||||||
|
New TS repositories/migration: `src/repositories/*` (7 files), `sql/002_source_registry.sql`.
|
||||||
|
New TS quality bridge: `src/quality/quality-bridge.ts`, `src/services/quality.ts`, `src/services/source-registry.ts`.
|
||||||
|
Modified: `services/api/sources/connectors.py`, `services/api/workers/{pipeline,resolve}.py`,
|
||||||
|
`src/services/{source-registry-service,source-service,source-rate-policy-service}.ts`,
|
||||||
|
`src/validation/0pt-schemas.ts`, `src/http/siax-error-response.ts`, `.gitignore`
|
||||||
|
(added `.venv/`, `.pytest_cache/`), `sql/004_iceberg_write_boundary.sql` (minor).
|
||||||
|
Test files modified: `tests/p0/source-registry.test.ts` (repointed at shared
|
||||||
|
singleton), new `tests/p0/quality-bridge.test.ts`,
|
||||||
|
`tests/p0/source-registry-postgres.realinfra.test.ts`.
|
||||||
|
|
||||||
|
No changes to: `services/scrapers/` (the still-unmerged WAF-bypass deletion
|
||||||
|
PR — untouched, not this pass's scope), the `app/`-root Next.js UI pages
|
||||||
|
(`bestall/`, `katalog/`, `portal/`, etc. — untouched), any of the 5
|
||||||
|
untracked stray paths in the working tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Activating the pipeline bridge — Simon's decision, not made here
|
||||||
|
|
||||||
|
**`OPT_QUALITY_BRIDGE_ENABLED` ships unset (default `false`) and this pass
|
||||||
|
does not flip it, anywhere, including in any deploy config, `.env.example`,
|
||||||
|
or CI workflow.** This affects real customer orders once on — enabling it
|
||||||
|
in production is a separate decision, deliberately left to Simon.
|
||||||
|
|
||||||
|
To turn it on later, the following must all be true first (this pass built
|
||||||
|
the mechanism, not the readiness):
|
||||||
|
|
||||||
|
1. `OPT_QUALITY_BRIDGE_URL` (or `OPT_QUALITY_BRIDGE_EDGE_URL`, per
|
||||||
|
`quality_bridge.py`'s `_bridge_url()`) must point at a reachable,
|
||||||
|
authenticated `0pt.app` deployment — the Python engine and the Next.js
|
||||||
|
edge app are typically different deploy targets (VPS vs. Vercel/SIAX
|
||||||
|
Deploy Platform per the `siax-0pt` skill's two-plane model), so this is a
|
||||||
|
real cross-service network dependency, not a same-process flag flip.
|
||||||
|
2. Even fully enabled and reachable, the bridge remains **advisory-only by
|
||||||
|
design** — it stamps a `quality` field on delivered provenance and
|
||||||
|
surfaces a score; it does **not** and, per §2's `ExpectationEngine`
|
||||||
|
contract gap, currently **cannot** change which rows Python's
|
||||||
|
`delivered = [...]` line keeps or drops. Anyone enabling the flag
|
||||||
|
expecting stricter row-level gating will not get it from this code as
|
||||||
|
shipped — that would require the `ExpectationEngine` contract change
|
||||||
|
noted above, which is future work.
|
||||||
|
3. Recommended rollout, not yet executed: enable against one non-production
|
||||||
|
tenant/order first, confirm `bridge-run` round-trips correctly (the
|
||||||
|
manual live-verification in §1/§2 above did this once, ad hoc, during
|
||||||
|
development — it was not repeated against a staging environment as a
|
||||||
|
formal rollout step), then decide with Simon whether "advisory-only,
|
||||||
|
fails open" is the intended production posture or whether the per-row
|
||||||
|
gating gap needs to close first.
|
||||||
|
|
||||||
|
**This pass leaves the flag off. No default, config file, or workflow in
|
||||||
|
this PR turns it on.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Residual gaps (honest, not exhaustive elsewhere in this doc)
|
||||||
|
|
||||||
|
- Python engine tests (28/28) are not wired into either CI workflow yet —
|
||||||
|
a `.gitea/workflows/`/`.github/workflows/` Python job is future work, not
|
||||||
|
part of this pass.
|
||||||
|
- `bridge_provenance` as a single unified construct still does not exist;
|
||||||
|
this pass narrows but does not close the TS/Python provenance-schema gap
|
||||||
|
(§3 above).
|
||||||
|
- Per-row quality gating from the TS engine into the Python pipeline is not
|
||||||
|
implementable against the current `ExpectationEngine` contract (§2, §4
|
||||||
|
above) — advisory-only is the ceiling of what this pass could honestly
|
||||||
|
build, not a corner cut.
|
||||||
|
- `resolve.py`'s greedy merge remains genuinely order-sensitive for
|
||||||
|
non-transitive-similarity triangles — tested and documented, not fixed
|
||||||
|
(fixing it is an algorithm change, out of scope here).
|
||||||
|
- CDC-interruption and true delivery-store backfill-idempotency tests remain
|
||||||
|
unimplementable because the underlying infrastructure (a CDC pipeline, a
|
||||||
|
delivery persistence store) does not exist anywhere in `services/api` —
|
||||||
|
documented via `pytest.skip` with reasons in `test_known_gaps.py`, not
|
||||||
|
silently dropped.
|
||||||
|
- 5 untracked/unaudited local paths (`src/fleet/`, `src/graph/`,
|
||||||
|
`src/metrics/`, `src/projections/`, `src/features/feature-registry.ts`)
|
||||||
|
remain exactly as found — not committed, not deleted, not audited by this
|
||||||
|
pass. They are not part of this PR's diff and a fresh clone never sees
|
||||||
|
them.
|
||||||
|
- `services/scrapers/` (WAF-bypass stack) remains on `main`, unmerged
|
||||||
|
deletion PR still open — unrelated to and untouched by this pass, flagged
|
||||||
|
again here only because `BEFORE_STATE.md` flagged it and it has not
|
||||||
|
changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Process notes
|
||||||
|
|
||||||
|
- No PR was open before this pass; one was opened as part of this pass (see
|
||||||
|
PR URL reported separately / in `PRODUCTION_EVIDENCE.json`).
|
||||||
|
- `BEFORE_STATE.md` and the 5 pre-existing untracked/unaudited paths were
|
||||||
|
left untouched and uncommitted throughout, exactly as found at the start.
|
||||||
|
- `.venv/` and `.pytest_cache/` (both created fresh under `services/api/`
|
||||||
|
while running the Python suite) were added to `.gitignore` so they are
|
||||||
|
never accidentally committed; no leftover Python processes.
|
||||||
|
- The `next dev` server and any background verification process used for
|
||||||
|
the live end-to-end bridge check (§1/§2) were both confirmed stopped
|
||||||
|
before this pass ended — no lingering listeners.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Copyright (c) 2026 SIAX Technology AB. All rights reserved.
|
||||||
|
|
||||||
|
0pt (SIAX Core Platform: Data OS) is proprietary software and a confidential
|
||||||
|
product of SIAX Technology AB. It is provided for internal use within SIAX
|
||||||
|
Technology AB and its authorized affiliates only.
|
||||||
|
|
||||||
|
No part of this software or its source code may be copied, modified, merged,
|
||||||
|
published, distributed, sublicensed, or sold outside of SIAX Technology AB
|
||||||
|
without the prior written permission of SIAX Technology AB.
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, this software is
|
||||||
|
provided "AS IS", without warranties or conditions of any kind, either express
|
||||||
|
or implied.
|
||||||
|
|
||||||
|
This is not an open-source license.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# BACKLOG P6 / DX-11. Thin wrappers around the real pnpm scripts -- this
|
||||||
|
# file is a convenience entry point, not a second source of truth for what
|
||||||
|
# each step does (see package.json for the actual commands).
|
||||||
|
.PHONY: dev doctor typecheck test lint build install db-up db-down
|
||||||
|
|
||||||
|
install:
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9 --activate
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
dev: install
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
# Optional local Postgres (compose.dev.yaml, BACKLOG DX-02) -- `make dev`
|
||||||
|
# above works without this via the in-memory fallback (lib/db.ts). Run this
|
||||||
|
# first, then `DATABASE_URL=postgres://opt:opt-dev-only-not-a-secret@localhost:5432/opt make dev`
|
||||||
|
# for real persistence locally.
|
||||||
|
db-up:
|
||||||
|
docker compose -f compose.dev.yaml up -d
|
||||||
|
|
||||||
|
db-down:
|
||||||
|
docker compose -f compose.dev.yaml down
|
||||||
|
|
||||||
|
doctor:
|
||||||
|
pnpm run siax:doctor
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
pnpm run typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
pnpm run test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
pnpm run lint
|
||||||
|
|
||||||
|
build:
|
||||||
|
pnpm run build
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Onboarding — 0pt
|
||||||
|
|
||||||
|
BACKLOG P6 (DX-12). For a human or an agent picking this repo up cold.
|
||||||
|
|
||||||
|
## 1. Read, in order
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — the contract for how this project is built. Non-negotiable
|
||||||
|
constraints (legal sourcing only, Postgres-backbone, provenance-per-field).
|
||||||
|
2. `AGENTS.md` — agent-specific ground rules.
|
||||||
|
3. `docs/architecture/ARCHITECTURE.md` — what the code actually does today
|
||||||
|
(as opposed to `docs/01-11`, which describe target state — see that
|
||||||
|
doc's own "Läsanvisning").
|
||||||
|
4. `README.md` "Known issues" — the live gaps, dated and evidenced.
|
||||||
|
5. `prompts/000-INDEX.md` — run `001-repo-health.md` before touching anything.
|
||||||
|
|
||||||
|
## 2. Get it running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9 --activate
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run dev # http://localhost:3000, in-memory fallback, no DB needed
|
||||||
|
```
|
||||||
|
|
||||||
|
Or `make dev` (same thing). Want real Postgres locally instead of the
|
||||||
|
in-memory fallback? `make db-up` first (BACKLOG DX-02, `compose.dev.yaml` —
|
||||||
|
**unverified in CI, smoke-test before relying on it**), then re-run with
|
||||||
|
`DATABASE_URL` pointed at it (see the Makefile comment).
|
||||||
|
|
||||||
|
`pnpm run siax:doctor` (or `make doctor`) — runs the self-diagnostic subset
|
||||||
|
of the SIAX Golden Standard v2.0 checks locally (`scripts/siax-doctor.mjs`,
|
||||||
|
control AGENT-04). Run it after cloning and after any structural change.
|
||||||
|
|
||||||
|
## 3. Before you change anything
|
||||||
|
|
||||||
|
- `AUTH_ENFORCE` is fail-closed by default — don't set `AUTH_ENFORCE=false`
|
||||||
|
outside local dev, and never commit it set that way anywhere.
|
||||||
|
- `services/scrapers/*`, `services/infra/docker-compose.data-stack.yml`, and
|
||||||
|
`live/src/workers/adaptive_router.py`'s `curl_cffi` entries are frozen —
|
||||||
|
see `SECURITY.md`. Don't touch, remove, or comment on that code without an
|
||||||
|
explicit, current instruction from the account owner.
|
||||||
|
- Run `prompts/003-security-review.md`'s checklist before pushing anything
|
||||||
|
touching auth, payments, or the platform clients.
|
||||||
|
|
||||||
|
## 4. Before you push
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run typecheck # blocking in CI
|
||||||
|
pnpm run test # blocking in CI
|
||||||
|
pnpm run build # blocking in CI
|
||||||
|
pnpm run lint # advisory, but fix what you can
|
||||||
|
```
|
||||||
|
|
||||||
|
CI (`.gitea/workflows/ci.yml`) re-runs all of these plus a blocking
|
||||||
|
Trivy/gitleaks scan (`security.yml`, BACKLOG P0-16/P0-19) — a red `trivy`
|
||||||
|
job on an otherwise-unrelated PR usually means a pre-existing dependency
|
||||||
|
finding, not something your change caused; check the run's date against
|
||||||
|
when that job started being blocking before assuming you broke it.
|
||||||
|
|
||||||
|
## 5. Who to ask
|
||||||
|
|
||||||
|
`CODEOWNERS` → `@sax3l` (Simon Axelsson). Two remotes exist for this repo
|
||||||
|
(`git.siax.io/sax3l/0pt` and `github.com/sax3l/0pt`) with independently
|
||||||
|
diverged history — which is authoritative is explicitly Simon's call, not
|
||||||
|
something to assume; see `CLAUDE.md`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 0pt — Masterplan-index (SIAX Non-Platform Masterplan)
|
||||||
|
|
||||||
|
> **AI-session i detta repo?** Börja i [siax-masterplan → EXECUTE.md](https://git.siax.io/sax3l/siax-masterplan/src/branch/main/EXECUTE.md)
|
||||||
|
|
||||||
|
**Repo:** `0pt` · **Domän:** Data OS (data engine, sourcing/enrichment) · **Kategori:** SIAX Non-Platform masterplan
|
||||||
|
|
||||||
|
**Statusvokabulär:** MISSING → SCAFFOLDED → IMPLEMENTED → WIRED → INTEGRATION_TESTED → LIVE_VERIFIED → PRODUCTION_READY. Legacy = `LEGACY`, bort först när ersättaren är live (ingen dubbel sanning).
|
||||||
|
|
||||||
|
## Låsta arkitekturprinciper (gäller detta repo)
|
||||||
|
|
||||||
|
1. **En canonical owner per domän och sanning** — detta repo äger sin
|
||||||
|
domän-sanning; andra ytor konsumerar via API/event/SDK, aldrig egen SQL.
|
||||||
|
2. Inga production-mock-fallbacks. Inga lokala fallback-sanningar.
|
||||||
|
3. `tenant_id`/`actor_id` server-resolved; ingen frontend-tenant-trust.
|
||||||
|
4. Allt API-first: kontrakt först (openapi.yaml), sedan surfaces.
|
||||||
|
|
||||||
|
> Detta är en stub skapad av conformance-sweep mot `siax-standard`
|
||||||
|
> (kriterium: masterplan-lock + PLAN-index). Fyll på med repo-specifik
|
||||||
|
> planstatus — redigera inte låst principerna. Kanonisk källa:
|
||||||
|
> [sax3l/siax-masterplan](https://git.siax.io/sax3l/siax-masterplan).
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
{
|
||||||
|
"report": "PRODUCTION_EVIDENCE.json",
|
||||||
|
"companion_doc": "IMPLEMENTATION_REPORT.md",
|
||||||
|
"generated_at": "2026-09-01T21:10:00+02:00",
|
||||||
|
"repo": {
|
||||||
|
"remote": "https://git.siax.io/sax3l/0pt.git",
|
||||||
|
"canonical_remote_per_claude_md": "git.siax.io/sax3l/0pt",
|
||||||
|
"note": "A second remote (github.com/sax3l/0pt) also claims to be this repo per CLAUDE.md; explicitly unresolved and left to Simon. This pass worked only against git.siax.io."
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"name": "feat/p010-data-os-completion",
|
||||||
|
"renamed_from": "feat/p010-source-registry-persistence",
|
||||||
|
"base_branch": "main",
|
||||||
|
"base_commit": "f8bb0d5ab9f35c652d09f7639d20458cfe849f56",
|
||||||
|
"head_commit": "9740e20818a8ce0a90498bdb12c501bc74395ac",
|
||||||
|
"commits_on_branch": [
|
||||||
|
{
|
||||||
|
"sha": "07b13bd8331017462ead11b9b2ce8fa9188e520f",
|
||||||
|
"summary": "feat(data-os): Source Registry Postgres persistence + full HTTP route surface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha": "9740e20818a8ce0a90498bdb12c501bc74395ac",
|
||||||
|
"summary": "feat(data-os): TS/Python quality bridge + provenance fields + fault-injection tests"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pushed_to_origin_this_pass": true,
|
||||||
|
"diffstat_vs_base": {
|
||||||
|
"files_changed": 47,
|
||||||
|
"insertions": 3119,
|
||||||
|
"deletions": 201
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pull_request": {
|
||||||
|
"url": "https://git.siax.io/sax3l/0pt/pulls/28",
|
||||||
|
"number": 28,
|
||||||
|
"base": "main",
|
||||||
|
"head": "feat/p010-data-os-completion",
|
||||||
|
"state_at_report_time": "open",
|
||||||
|
"opened_this_pass": true
|
||||||
|
},
|
||||||
|
"verification_methodology": {
|
||||||
|
"description": "All numbers below were produced by a from-scratch verification pass: a fresh `git clone` of the pushed branch into an isolated directory, a fresh `pnpm install --frozen-lockfile`, and a fresh Python venv built from requirements-dev.txt. This is deliberately NOT a rerun inside the original working tree that developed the branch, to avoid reporting stale or environment-contaminated results.",
|
||||||
|
"clean_clone_path_used_for_verification": "isolated scratch directory, discarded after this pass",
|
||||||
|
"original_working_tree": "/home/simon/siax-platforms-work/0pt"
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "pnpm install --frozen-lockfile",
|
||||||
|
"result": "ok",
|
||||||
|
"note": "lockfileVersion 9, no resolution drift from the committed pnpm-lock.yaml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "typecheck",
|
||||||
|
"command": "pnpm run typecheck",
|
||||||
|
"underlying": "tsc -p tsconfig.check.json (src/**/*.ts + tests/**/*.ts)",
|
||||||
|
"ci_gate": "blocking",
|
||||||
|
"result": "pass",
|
||||||
|
"errors": 0,
|
||||||
|
"note": "Zero errors on the pristine clone. The original dev working tree separately contains 5 untracked/uncommitted stray paths (src/fleet/, src/graph/, src/metrics/, src/projections/, src/features/feature-registry.ts) that were already present before this pass (flagged in BEFORE_STATE.md as unaudited) and are NOT part of any commit on this branch or on main -- confirmed via `git diff --stat origin/main..feat/p010-data-os-completion` touching none of them, and via their total absence from the fresh clone. Running the same command inside that non-clean working tree surfaces 14 TS errors from those 5 files; that is local litter CI will never see, not a defect in this PR."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unit/integration tests (TypeScript)",
|
||||||
|
"command": "pnpm run test",
|
||||||
|
"underlying": "vitest run",
|
||||||
|
"ci_gate": "blocking",
|
||||||
|
"result": "pass",
|
||||||
|
"test_files_passed": 34,
|
||||||
|
"test_files_skipped": 3,
|
||||||
|
"tests_passed": 256,
|
||||||
|
"tests_skipped": 18,
|
||||||
|
"tests_new_this_branch": 7,
|
||||||
|
"new_test_file": "tests/p0/quality-bridge.test.ts",
|
||||||
|
"skip_reason": "pre-existing .realinfra/.integration suites self-skip honestly when no live ST0RE/OpenSearch/ClickHouse is reachable from the verification environment",
|
||||||
|
"sample_skip_stderr": "[realinfra] ST0RE (https://api.st0re.siax.io) not reachable this run (ST0RE responded HTTP 401) -- skipping honestly, not fabricating success."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lint",
|
||||||
|
"command": "pnpm run lint",
|
||||||
|
"underlying": "eslint src tests",
|
||||||
|
"ci_gate": "advisory (continue-on-error in both .gitea/workflows/ci.yml and .github/workflows/ci.yml)",
|
||||||
|
"result": "pre-existing failures only, zero introduced by this branch",
|
||||||
|
"errors": 12,
|
||||||
|
"warnings": 4,
|
||||||
|
"verification": [
|
||||||
|
"git diff --stat origin/main..feat/p010-data-os-completion on every flagged file returns no output -- byte-identical to main",
|
||||||
|
"ran `pnpm lint` against a worktree checked out directly at origin/main and got the identical 12 errors / 4 warnings"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "build",
|
||||||
|
"command": "pnpm run build",
|
||||||
|
"underlying": "next build",
|
||||||
|
"ci_gate": "blocking in .gitea/workflows/ci.yml; not run by .github/workflows/ci.yml (needs runtime secrets Vercel owns)",
|
||||||
|
"result": "pass",
|
||||||
|
"note": "Route manifest confirms new surface is built: /api/0pt/quality/{bridge-run,issues,rules,rules/[ruleId],runs,runs/[runId]} and /api/0pt/sources/{route,[sourceId],[sourceId]/{freshness,health,provenance,rights}} all present as dynamic routes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python engine tests",
|
||||||
|
"command": "python -m pytest -v",
|
||||||
|
"cwd": "services/api",
|
||||||
|
"environment": "fresh venv, python3.12, requirements-dev.txt",
|
||||||
|
"ci_gate": "NOT WIRED INTO CI -- both .gitea/workflows/ci.yml and .github/workflows/ci.yml state the Python engine is gated separately if/when a Python workflow is added; this pass did not add that workflow",
|
||||||
|
"result": "pass",
|
||||||
|
"tests_passed": 28,
|
||||||
|
"tests_skipped": 2,
|
||||||
|
"skip_detail": [
|
||||||
|
{
|
||||||
|
"test": "test_cdc_interruption_not_implementable_against_real_code",
|
||||||
|
"reason": "no CDC pipeline exists anywhere in services/api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test": "test_true_backfill_idempotency_not_implementable_against_real_code",
|
||||||
|
"reason": "no delivery/order persistence layer exists anywhere in services/api"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"new_test_files": [
|
||||||
|
"services/api/tests/test_connectors_provenance.py",
|
||||||
|
"services/api/tests/test_known_gaps.py",
|
||||||
|
"services/api/tests/test_pipeline_fault_injection.py",
|
||||||
|
"services/api/tests/test_quality_bridge.py",
|
||||||
|
"services/api/tests/test_resolve_determinism.py"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"net_ci_status": {
|
||||||
|
"gitea_ci_blocking_gates": {
|
||||||
|
"typecheck": "green",
|
||||||
|
"test": "green",
|
||||||
|
"build": "green"
|
||||||
|
},
|
||||||
|
"github_ci_blocking_gates": {
|
||||||
|
"typecheck": "green",
|
||||||
|
"test": "green"
|
||||||
|
},
|
||||||
|
"advisory_only_not_blocking": ["lint (both workflows)", "typecheck:app (github only)"],
|
||||||
|
"unwired_gate_not_in_ci": ["python pytest (services/api) -- manually verified green, no automated workflow yet"]
|
||||||
|
},
|
||||||
|
"production_feature_flags": {
|
||||||
|
"OPT_QUALITY_BRIDGE_ENABLED": {
|
||||||
|
"default_shipped_value": "unset (treated as false)",
|
||||||
|
"changed_by_this_pr": false,
|
||||||
|
"activated_in_production_by_this_pass": false,
|
||||||
|
"behavior_when_off": "quality_bridge.py makes zero network calls, returns status=disabled immediately; pipeline.py's delivery decision is completely unaffected",
|
||||||
|
"behavior_when_on_but_bridge_unreachable": "fails open -- status=unavailable, delivery decision unaffected, error never raised into run_job",
|
||||||
|
"behavior_when_on_and_reachable": "advisory only -- stamps a quality field on delivered provenance and records a score; does NOT and, per the current ExpectationEngine contract, currently CANNOT change which rows pipeline.py's confidence-threshold filter keeps or drops",
|
||||||
|
"decision_owner": "Simon",
|
||||||
|
"readiness_checklist_before_enabling": [
|
||||||
|
"OPT_QUALITY_BRIDGE_URL / OPT_QUALITY_BRIDGE_EDGE_URL must point at a reachable, authenticated 0pt.app deployment (cross-service network dependency -- Python engine and Next.js edge are typically different deploy targets)",
|
||||||
|
"accept that this remains advisory-only (no per-row gating) unless the ExpectationEngine contract is separately extended to return per-row results",
|
||||||
|
"recommended: enable against one non-production tenant/order first and confirm bridge-run round-trips before any production enablement"
|
||||||
|
],
|
||||||
|
"manual_live_verification_performed_this_pass": {
|
||||||
|
"what": "real `next dev` server (AUTH_ENFORCE=false, local only) + real Python pipeline hitting real brreg.no + real bridge-run round-trip + real QualityService score returned",
|
||||||
|
"when": "during development of commit 9740e20, 2026-09-01",
|
||||||
|
"repeatable_automated_equivalent": "services/api/tests/test_quality_bridge.py (respx-mocked bridge responses, run as part of the 28/28 pytest total above)",
|
||||||
|
"note": "the live run was a one-off manual verification during development, not a formal staging rollout step, and not itself part of the from-scratch CI-mirroring verification in this report"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"residual_gaps": [
|
||||||
|
"Python engine tests (28/28) not wired into any CI workflow yet",
|
||||||
|
"bridge_provenance as a single unified TS/Python construct still does not exist -- this pass narrows, does not close, the provenance-schema split",
|
||||||
|
"per-row quality gating from TS into Python is not implementable against the current ExpectationEngine contract -- advisory-only is the ceiling of this pass, not a corner cut",
|
||||||
|
"resolve.py's greedy merge remains genuinely order-sensitive for non-transitive-similarity triangles -- tested and documented, not fixed (algorithm change, out of scope)",
|
||||||
|
"CDC-interruption and true delivery-store backfill-idempotency tests remain unimplementable -- no CDC pipeline or delivery persistence layer exists anywhere in services/api",
|
||||||
|
"5 untracked/unaudited local paths (src/fleet/, src/graph/, src/metrics/, src/projections/, src/features/feature-registry.ts) left exactly as found -- not part of this diff, absent from a fresh clone",
|
||||||
|
"services/scrapers/ (WAF-bypass stack) remains on main with its deletion PR still open -- unrelated to and untouched by this pass"
|
||||||
|
],
|
||||||
|
"process_notes": {
|
||||||
|
"pr_open_before_this_pass": false,
|
||||||
|
"before_state_md_left_untouched": true,
|
||||||
|
"gitignore_additions": [".venv/", ".pytest_cache/"],
|
||||||
|
"leftover_processes_confirmed_stopped": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 0pt — your data
|
||||||
|
|
||||||
|
Data services on demand for the Nordics + Europe. Compose a data job, pay 50 % to start,
|
||||||
|
watch production live, validate with full provenance, deliver anywhere. Owned by **SIAX
|
||||||
|
Technology AB**. Honest accuracy is the moat: every field carries source, freshness and
|
||||||
|
confidence; nothing is guessed.
|
||||||
|
|
||||||
|
**Live:** https://0pt.app · **Contract:** [`CLAUDE.md`](CLAUDE.md) + [`docs/`](docs/)
|
||||||
|
|
||||||
|
> **Architecture (verified against code, not aspirational):**
|
||||||
|
> [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md).
|
||||||
|
> It documents the real API surface, the real persistence story, and the known
|
||||||
|
> security gaps — including several places where this README is optimistic. Read
|
||||||
|
> it before trusting any capability claim below. Repo identity + estate
|
||||||
|
> classification: [`siax.repo.json`](siax.repo.json) +
|
||||||
|
> [`siax.config.json`](siax.config.json). Compliance state:
|
||||||
|
> [`docs/compliance/`](docs/compliance/) (INVENTORY, RELATIONS, OSS-INVENTORY,
|
||||||
|
> GOLDEN-STANDARD-REPORT, BACKLOG, SLUTRAPPORT).
|
||||||
|
|
||||||
|
## Two planes (see [docs/02-architecture.md](docs/02-architecture.md))
|
||||||
|
|
||||||
|
- **Edge (Vercel, this app)** — marketing, catalog, the order lifecycle, customer portal,
|
||||||
|
admin portal, and the API route handlers. Defers to the data engine when `ENGINE_URL` is set.
|
||||||
|
- **Engine (`services/api`, runs on a VPS)** — FastAPI orchestrator + worker pipeline doing
|
||||||
|
the **compliant** sourcing (official API → bulk → authorized web → licensed → else level 0).
|
||||||
|
Never on Vercel (long jobs). Bypass/stealth/proxy-rotation/fake-account paths are excluded
|
||||||
|
by design (CLAUDE.md §1).
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Area | Routes |
|
||||||
|
|---|---|
|
||||||
|
| 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` |
|
||||||
|
| 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}` |
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
Mono/editorial, light, radius 0, Inter + JetBrains Mono, single red `#e40014`, animated grain,
|
||||||
|
scroll-reveal motion. **No photography** — imagery is rendered data (`DataArtifact`). Every fact
|
||||||
|
shows a provenance square (`ProvenanceBadge`). Full spec: [docs/05-design-system.md](docs/05-design-system.md).
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
**SIAX toolchain-kontrakt (mise):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise install # pinnar node 22.13.0 + pnpm 9.15.9 (se mise.toml [tools])
|
||||||
|
mise run setup # pnpm install --frozen-lockfile
|
||||||
|
mise run check # typecheck + lint (snabbt, deterministiskt)
|
||||||
|
mise run verify # vitest + next build (full svit)
|
||||||
|
mise run build # deploybar artefakt
|
||||||
|
```
|
||||||
|
|
||||||
|
mise är task-entrypoint (`mise.toml`); gate-logiken bor kvar i
|
||||||
|
`.gitea/workflows/` och package.json — pinning: `.siax/template-pin.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Next.js on http://localhost:3000
|
||||||
|
|
||||||
|
# data engine (separate, optional)
|
||||||
|
cd services/api && pip install -r requirements.txt && uvicorn orchestrator.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
- **Config target (as of 2026-08-09):** `siax.config.json` declares `deploy.target: "coolify"`,
|
||||||
|
matching every other SIAX Cloud OS repo (n0d, st0re, aud0, ...) — a Dockerfile "for Coolify"
|
||||||
|
(already committed, see [`Dockerfile`](Dockerfile)) plus [`.gitea/workflows/cd.yml`](.gitea/workflows/cd.yml)
|
||||||
|
(renamed from `deploy.yml` 2026-08-10) that POSTs a restart to a Coolify app UUID on
|
||||||
|
`COOLIFY_TOKEN` push.
|
||||||
|
- ~~**Actually live right now:** the only running instance of `0pt.app` is still on **Vercel**
|
||||||
|
(project `0pt`, scope *Simon Axelsson's projects*) — no Coolify app has been provisioned for
|
||||||
|
this repo yet, so `deploy.yml`'s app UUID is a placeholder that fails the job on purpose until
|
||||||
|
someone fills it in. Flipping the config declares intent; it does not move traffic.~~ See
|
||||||
|
[`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) §5/§8 for the full
|
||||||
|
"Vercel or fleet" state and what's still open (Coolify app provisioning + DNS cutover — a
|
||||||
|
separate decision from this config change, since the live Vercel deployment is not being
|
||||||
|
touched).
|
||||||
|
- **CORRECTED 2026-09-06 — the bullet above is no longer true. The Coolify deploy hook is LIVE.**
|
||||||
|
The repo secret `COOLIFY_TOKEN` exists (created 2026-08-31T11:34:34Z) and the repo Actions
|
||||||
|
variable `COOLIFY_APP_ID` holds a real 24-character UUID, **not** the
|
||||||
|
`TODO-no-coolify-app-provisioned-yet` literal. Both guard conditions in
|
||||||
|
[`.gitea/workflows/cd.yml`](.gitea/workflows/cd.yml) therefore pass. Measured on the push to
|
||||||
|
`main` at `1ff34410` (2026-09-06): job `Deploy to Coolify / deploy` (run 11695, job 26295)
|
||||||
|
finished **success** with `coolify deploy HTTP 200` in its log. **Every merge into `main` now
|
||||||
|
fires a real redeploy against `cloud.siax.io` — know what you are shipping before you merge.**
|
||||||
|
**Probed 2026-09-06 08:00 UTC:** `0pt.app` still answers from **Vercel** (`server: Vercel`, `x-vercel-id: arn1::…`) and returns **HTTP 402 `DEPLOYMENT_DISABLED` — "Payment required"** on both `/` and `/api/health`. So the public domain is *down*, DNS has **not** been cut over to Coolify, and the Coolify app — whatever state it is in — serves no `0pt.app` traffic. Two separate facts, both true at once: the deploy hook fires for real, and the product domain is dark behind an unpaid Vercel deployment.
|
||||||
|
Still not established: whether the Coolify app itself builds and starts — the deploy endpoint
|
||||||
|
returning 200 only means the request was accepted, not that a container came up healthy.
|
||||||
|
- **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).
|
||||||
|
- **Auth on `/api/0pt/*` is fail-closed**: with `AUTH_ENFORCE` unset the middleware
|
||||||
|
REQUIRES a verified Zitadel Bearer token (401 without one). Only the literal
|
||||||
|
`AUTH_ENFORCE=false` disables it, and that is refused when `NODE_ENV=production`
|
||||||
|
(the process throws at startup rather than serving anonymously).
|
||||||
|
**Caveat (live-verified 2026-08-10):** the deployed instance still answers
|
||||||
|
`GET /api/0pt` with 200 anonymously — the middleware appears not to run in
|
||||||
|
production (gap S1, root cause unknown). Do not rely on this being enforced
|
||||||
|
until an anonymous request gets a real 401.
|
||||||
|
- **Fonts:** self-hosted via `@fontsource-variable/inter` +
|
||||||
|
`@fontsource-variable/jetbrains-mono` (no Google Fonts / `next/font/google`).
|
||||||
|
- **API contract (SSOT):** [`contracts/openapi.yaml`](contracts/openapi.yaml).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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).
|
||||||
|
|
||||||
|
© 2026 SIAX Technology AB. Not affiliated with Protosell.
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
0pt (SIAX Core Platform: Data OS) is proprietary software operated by SIAX
|
||||||
|
Technology AB. This document explains how to report a suspected security
|
||||||
|
vulnerability.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Do not open a public GitHub issue for a suspected security problem.
|
||||||
|
|
||||||
|
Email: security@siax.io (fallback: simon@siax.io)
|
||||||
|
|
||||||
|
Include, if possible:
|
||||||
|
- A description of the issue and the impact you believe it has.
|
||||||
|
- Steps to reproduce, or a proof-of-concept request/response.
|
||||||
|
- The route, endpoint, or component affected.
|
||||||
|
- Whether the issue is already publicly known or exploited.
|
||||||
|
|
||||||
|
## Response expectations
|
||||||
|
|
||||||
|
- Acknowledgement: within 2 business days.
|
||||||
|
- Initial triage and severity assessment: within 5 business days.
|
||||||
|
- Remediation timeline: communicated after triage, based on severity.
|
||||||
|
Confirmed high/critical issues are prioritized ahead of other work.
|
||||||
|
|
||||||
|
We will keep you informed of progress until the report is resolved and, where
|
||||||
|
appropriate, credit you in internal release notes (no public bug bounty
|
||||||
|
program exists at this time).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
- The 0pt application and its API routes at 0pt.app.
|
||||||
|
- The 0pt engine/orchestrator services in this repository.
|
||||||
|
- Infrastructure-as-code and CI/CD configuration in this repository.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
- Third-party services and dependencies not maintained in this repository
|
||||||
|
(report those to the respective vendor).
|
||||||
|
- Denial-of-service testing, spam, or social engineering against SIAX staff
|
||||||
|
or customers.
|
||||||
|
- Automated scanning that generates excessive load against production
|
||||||
|
systems without prior coordination.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **Zitadel bearer verification exists in code (fail-closed), but the deployed
|
||||||
|
instance still answers `/api/0pt/*` anonymously (gap S1).** `src/auth/zitadel.ts`
|
||||||
|
+ `middleware.ts` implement real JWKS-based verification with a fail-closed
|
||||||
|
default (`assertAuthConfigSafe()` refuses `AUTH_ENFORCE=false` in production),
|
||||||
|
yet a live probe (2026-08-10) got HTTP 200 without any token. Root cause is
|
||||||
|
unconfirmed — do not treat the public instance as authenticated until an
|
||||||
|
anonymous request receives 401. Additionally, no handler reads the verified
|
||||||
|
tenant headers (`x-0pt-verified-tenant`): the P0 routes under `/api/0pt/*`
|
||||||
|
(`lakehouse/mappings`, `sources/rate-policies`, `sources/[sourceId]/query`,
|
||||||
|
`documents/capture`) 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 (BACKLOG P0-1/P0-2),
|
||||||
|
not an intentional design.
|
||||||
|
- **The non-`/api/0pt` API surface has no authentication at all.** Orders,
|
||||||
|
admin metrics, payments (including the escrow `release` that flips an order to
|
||||||
|
`delivered`), subscriptions, uploads and DB-grant endpoints are unprotected in
|
||||||
|
code (live-verified 200 anonymously on `/api/orders` and `/api/admin/metrics`).
|
||||||
|
- **The WAF-bypass scraper stack is kept deliberately.** `services/scrapers/*`
|
||||||
|
(`waf_bypass.py`: FlareSolverr, Tor, cloudscraper, curl_cffi, proxy rotation,
|
||||||
|
fake identities) and `services/infra/docker-compose.data-stack.yml`
|
||||||
|
(flaresolverr/tor containers) contradict this repo's own compliance docs.
|
||||||
|
A cleanup PR that surgically removed this stack (PR #7, verified clean, 128/128
|
||||||
|
tests) was closed unmerged by the account owner. **The account owner, Simon
|
||||||
|
Axelsson, was asked directly via an interactive decision prompt on 2026-08-11
|
||||||
|
whether to keep or remove this code, and explicitly chose to keep it.** No
|
||||||
|
business or legal justification beyond "keep it" was recorded — none is
|
||||||
|
invented here. This is a narrow, scoped exception for the existing 0PT/0pt-motor
|
||||||
|
sourcing engine; it does not change `CLAUDE.md` §1's general anti-evasion policy,
|
||||||
|
which remains in force as written unless/until the account owner says otherwise.
|
||||||
|
Out of scope for deletion — see `docs/compliance/GOLDEN-STANDARD-REPORT.md`
|
||||||
|
control C6.
|
||||||
|
- Beyond those items, 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
|
||||||
|
|
||||||
|
Please give us a reasonable opportunity to investigate and remediate an
|
||||||
|
issue before any public disclosure. We aim to coordinate disclosure timing
|
||||||
|
with reporters wherever possible.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# 0pt — capability status (Master-DoD §56 status honesty)
|
||||||
|
|
||||||
|
> Classification scale (Master-DoD §2, exactly one per capability):
|
||||||
|
> `MISSING → DOCUMENTED_ONLY → SKELETON → MOCK_ONLY → IMPLEMENTED → INTEGRATION_TESTED → LIVE_VERIFIED → PRODUCTION_READY`.
|
||||||
|
> Only `PRODUCTION_READY` may be called DONE. Docs ≠ implementation.
|
||||||
|
> Last full audit + re-classification: **2026-08-31** (SIAX Platforms P0 wave 2).
|
||||||
|
|
||||||
|
## How to read this
|
||||||
|
|
||||||
|
- **INTEGRATION_TESTED** here means: verified against a REAL dependency
|
||||||
|
(live OpenSearch/ClickHouse container, or a recorded wire-contract double
|
||||||
|
pinned to the sibling repo's real handler code on its `main`) in CI.
|
||||||
|
- **LIVE_VERIFIED** requires a verification against the actual SIAX fleet
|
||||||
|
deployment. No new capability in this wave claims it.
|
||||||
|
- The unit/contract/integration suites are in `tests/` and run in
|
||||||
|
`.gitea/workflows/ci.yml` (jobs `quality` + `integration`).
|
||||||
|
|
||||||
|
## Cross-product clients (Master-DoD §10 "Cross-product clients")
|
||||||
|
|
||||||
|
| Capability | Status | Evidence | Honest gaps |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CL0UD client (policy evaluate, `POST /api/policy/evaluate`) | **INTEGRATION_TESTED** (contract) | `src/clients/cl0ud-client.ts`; `tests/contracts/cl0ud-client.contract.test.ts` pins the real camelCase `{data}` envelope; fail-closed (missing decision ⇒ deny) | Not yet exercised against a live CL0UD deployment (no reachable CL0UD URL this session ⇒ not LIVE_VERIFIED) |
|
||||||
|
| CL0UD capability discovery (`GET /api/capabilities`) | **INTEGRATION_TESTED** (contract) | same suite | 0pt's own capabilities are not yet registered in CL0UD's registry (§3 gap) |
|
||||||
|
| ST0RE client (artifact register, camelCase `/v1/st0re/artifacts/register`) | **INTEGRATION_TESTED** (contract) | `src/clients/st0re-client.ts`; `tests/contracts/st0re-client.contract.test.ts` | Raw-bytes upload still goes via LocalBlobStore interim (pre-existing, unchanged); presign upload path not consumed yet |
|
||||||
|
| N0D client (create/get job, get result) | **INTEGRATION_TESTED** (contract) | `src/clients/n0d-client.ts`; `tests/contracts/n0d-client.contract.test.ts` pins `required_role`/`resource_requirements`/body `idempotency_key` | CloudEvents completion callback receiving endpoint not built in 0pt (poll path works) |
|
||||||
|
| AUD0 client (events + OpenLineage ingest) | **INTEGRATION_TESTED** (contract) | `src/clients/aud0-client.ts`; `tests/contracts/aud0-client.contract.test.ts`; legacy lineage shape converts to real OpenLineage | No durable local outbox for AUD0 outages (§70 "durable local/outbox path" — emission failure is surfaced, not queued) |
|
||||||
|
| **No production mock fallback (§3/§10)** | **INTEGRATION_TESTED** | `src/platform/dependency-mode.ts`; `tests/contracts/dependency-mode.contract.test.ts` drives NODE_ENV=production for all four factories ⇒ hard `DEPENDENCY_UNAVAILABLE` | — |
|
||||||
|
|
||||||
|
**Correction of prior state (2026-08-31):** the pre-existing clients' HTTP
|
||||||
|
paths were verified against the sibling repos' actual `main` branches and
|
||||||
|
THREE of four were wrong: cl0ud (`/v1/cl0ud/policies/evaluate` — never
|
||||||
|
existed), st0re (snake_case body — would 400), aud0 lineage
|
||||||
|
(`/v1/aud0/lineage/events` — never existed). All fixed and pinned by
|
||||||
|
contract tests. The earlier MOCK_ONLY classification in the README was
|
||||||
|
accurate.
|
||||||
|
|
||||||
|
## Data-OS capabilities (Master-DoD §10)
|
||||||
|
|
||||||
|
| Capability | Status | Evidence | Honest gaps |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Source Registry — register/query/describe/health, rights/license/purpose, freshness, provenance | **IMPLEMENTED** | `src/services/source-registry-service.ts`, `src/types/source-registry.ts`; `tests/p0/source-registry.test.ts` (fail-closed purpose gate, cross-tenant NOT_FOUND) | In-memory persistence (per-process); no Postgres store yet — production boot must therefore configure one before this counts further. No API route surface yet |
|
||||||
|
| Source rate-policy | **IMPLEMENTED** (pre-existing) | `src/services/source-rate-policy-service.ts` | unchanged this wave |
|
||||||
|
| Search — OpenSearch adapter, index defs, bulk, tenant + purpose/rights filter, rebuild, generation switch | **INTEGRATION_TESTED** | `src/search/*`; unit `tests/p0/search-service.test.ts`; **real OpenSearch 2.11** `tests/integration/search-opensearch.integration.test.ts` incl. projection-disaster rebuild (§68-style) — runs in CI service container | Hybrid (vector) query not built; no metrics emission; index definitions in-memory |
|
||||||
|
| Quality — versioned rules, runs, results, issues, score, trend, GE interchange | **IMPLEMENTED** (local engine) / **SKELETON** (GE delegation) | `src/quality/*`; `tests/p0/quality-service.test.ts`, `tests/p0/expectation-engine` cases | Local engine covers 7 GE core expectation types; everything else delegates via N0D job `0pt.quality.ge_run` whose EXECUTOR (0pt-motor GE runner) does not exist yet — delegated runs stay "delegated" until it does. Never fabricates results |
|
||||||
|
| Dataset — create (pre-existing) + version/query/export with `st0re:artifact:` refs | **IMPLEMENTED** | `src/services/dataset-version-service.ts`; `tests/p0/dataset-version-service.test.ts` (idempotency §53, canonical refs §5) | Export assumes bytes already written by a job; no route surface; in-memory persistence |
|
||||||
|
| Lineage — OpenLineage emission to AUD0's real ingest | **INTEGRATION_TESTED** (contract) | `src/lineage/*`; aud0 contract suite; emitted from dataset version/export, quality runs, search rebuilds | Emission is best-effort (never-throw); no outbox. AUD0-side ingestion verified against contract double, not live AUD0 |
|
||||||
|
| Metadata — OpenMetadata projection (never canonical) | **IMPLEMENTED** | `src/metadata/openmetadata-adapter.ts`; `tests/contracts/metadata-features.contract.test.ts`; `projectAll` rebuild path | No live OpenMetadata exercised (no local container this pass); lineage/glossary/ownership projection not built — only dataset entities |
|
||||||
|
| Analytics — ClickHouse, tenant-isolated, query limits, retention | **INTEGRATION_TESTED** | `src/analytics/clickhouse-analytics.ts`; **real ClickHouse 24.8** `tests/integration/analytics-clickhouse.integration.test.ts` (tenant isolation, closed DSL, TTL schema) — runs in CI service container | Closed metric-DSL only (no ad-hoc queries by design); no quota integration with CL0UD |
|
||||||
|
| Features — registry + Feast adapter | **IMPLEMENTED** (online path, registry) / **SKELETON** (materialize/get-historical) | `src/features/feast-adapter.ts`; contract suite pins Feast's `get-online-features` wire shape | No live Feast server exercised; offline/materialization are N0D delegation contracts whose executor (0pt-motor) does not exist yet; point-in-time correctness therefore NOT yet proven |
|
||||||
|
| Entity Resolution — candidate API, blocking, explanation, hard-rule hook, review handoff | **IMPLEMENTED** | `src/er/entity-resolution-service.ts`; `tests/p0/entity-resolution.test.ts` (hard rules override both directions, review band, blocking refusals) | Generic reference scorer (Jaccard+Levenshtein), NOT Splink probabilistic linkage — that is the `0pt.er.batch_resolution` N0D contract, executor pending in 0pt-motor. No persistence of review queues |
|
||||||
|
| Graph (§10 Graph) | **MISSING** | — | Not started this wave (explicitly out of the delivered P0 subset; next in line per §10) |
|
||||||
|
|
||||||
|
## Canonical platform plumbing
|
||||||
|
|
||||||
|
| Capability | Status | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| SiaxRequestContext + propagation headers (mirror of cl0ud `@siax/contract`) | **INTEGRATION_TESTED** (contract-pinned) | `src/platform/siax-context.ts`; `tests/contracts/siax-context.contract.test.ts` |
|
||||||
|
| Canonical 12-code error model + typed `SiaxError` (§6) | **INTEGRATION_TESTED** (contract-pinned) | `src/platform/errors.ts`; same suite |
|
||||||
|
| Canonical resource refs (§5) | **IMPLEMENTED** | `src/platform/resource-ref.ts`; `tests/p0/dataset-version-service.test.ts` |
|
||||||
|
|
||||||
|
## Pre-existing capabilities (unchanged this wave — prior classifications stand)
|
||||||
|
|
||||||
|
| Capability | Status | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Iceberg write boundary via ST0RE lakehouse | LIVE_VERIFIED (2026-07-13) | `docs/ICEBERG_WRITE_BOUNDARY.md`; untouched |
|
||||||
|
| Document capture/extraction | IMPLEMENTED | LocalBlobStore interim persists bytes; ST0RE raw upload still pending |
|
||||||
|
| Edge order/payment app (Next.js) | see `docs/architecture/ARCHITECTURE.md` | outside this wave's scope |
|
||||||
|
| FastAPI engine (`services/api`) | IMPLEMENTED | outside this wave's scope |
|
||||||
|
| `services/scrapers/*` (WAF-bypass stack) | **quarantined, pending deletion PR** | `feature/0pt-remove-noncompliant-scrapers` — NOT touched by this wave; nothing new wires it |
|
||||||
|
|
||||||
|
## Global gaps blocking PRODUCTION_READY (§3, tracked, not hidden)
|
||||||
|
|
||||||
|
1. Persistent stores REMAINING: quality runs/rules and feature views are
|
||||||
|
still per-process in-memory (`resolveStoreMode` guarantees production
|
||||||
|
boot FAILS rather than serving Maps). CLOSED since the last revision:
|
||||||
|
dataset registry (`opt_datasets`) + immutable versions
|
||||||
|
(`opt_dataset_versions`, monotonic under transaction lock, idempotency
|
||||||
|
with payload-drift detection, cross-tenant ⇒ NOT_FOUND) and versioned
|
||||||
|
search index definitions (`opt_search_index_definitions`, sequential
|
||||||
|
schema versions, replay-idempotent, same-version-different-schema ⇒
|
||||||
|
CONFLICT; OpenSearch remains a rebuildable projection, never canonical
|
||||||
|
truth) are Postgres-canonical with real-Postgres tests.
|
||||||
|
2. Capability registration in CL0UD + API0 discovery for the new
|
||||||
|
capabilities (§3 "Capability är registrerad i CL0UD Capability
|
||||||
|
Registry") — PARTIALLY CLOSED: the collaboration/meeting domain was
|
||||||
|
canonicalized in CL0UD's registry (cl0ud #73/#74); 0pt's own
|
||||||
|
capability set still needs the same registration.
|
||||||
|
3. CLOSED: AUD0 outbox for evidence durability under AUD0 outage (§70) —
|
||||||
|
durable Postgres outbox + dispatcher (at-least-once, bounded batch,
|
||||||
|
backoff, dead-letter, SKIP LOCKED leasing, tenant binding, idempotency)
|
||||||
|
+ real-Postgres tests; dispatcher uses explicit `durable:false` so it
|
||||||
|
never re-enqueues its own delivery evidence.
|
||||||
|
4. CLOSED for datasets/versions/search/ER: `app/api/0pt/datasets*`,
|
||||||
|
`app/api/0pt/search/definitions` + tenant-safed query route, and
|
||||||
|
`app/api/0pt/er/{candidates,batch}` (+ internal ER route) are live;
|
||||||
|
clients cannot raise `rights_tag` via request body (fail-closed to
|
||||||
|
`internal`). Quality routes exist (`bridge-run/issues/rules/runs`).
|
||||||
|
5. 0pt-motor executors for the delegated jobs
|
||||||
|
(`0pt.quality.ge_run`, `0pt.features.*`, `0pt.er.batch_resolution`).
|
||||||
|
6. Observability: no metrics/traces emitted by the new services (§36).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P060-P068 ingestion plane (added 2026-09-04)
|
||||||
|
|
||||||
|
Classification scale unchanged. Every row below states what was actually
|
||||||
|
verified, and by which command.
|
||||||
|
|
||||||
|
| Requirement | BEFORE this pass | Status now | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P060** Source Registry: durable canonical registry; restart preserves sources; an unclassified source cannot be activated | **PARTIAL / BROKEN.** The registry existed with Postgres persistence (`sql/002_source_registry.sql`, `src/repositories/postgres-source-registry-repository.ts`) and carried source_id/tenant/project/type/status/license/purpose/rights. It had NO authorization, NO schedule, NO checkpoint, NO schema, and no `owner` distinct from `created_by`. The activation DoD was inverted: `postgres-source-repository.ts:70` wrote `${input.status ?? "active"}`, so a source with no rights, no provenance and no authorization was ACTIVE the instant it was registered, and no activation operation existed at all | **INTEGRATION_TESTED** | `sql/007_ingestion_plane.sql` (authorization/schedule/schemas/checkpoints + `ALTER … SET DEFAULT 'pending'` + `owner_id`); `SourceRegistryService.classifySource`/`activateSource`; `tests/p0/source-activation-gate.test.ts` (11); `tests/api/0pt-p060-routes.test.ts` (9); `tests/p0/ingestion-plane-postgres.realinfra.test.ts` against a live Postgres 16 |
|
||||||
|
| **P061** Connector contract: discover/validate/connect/checkpoint/read/resume/backfill/health/disconnect; two heterogeneous connectors pass one conformance suite | **MISSING.** `services/api/sources/connectors.py` had per-source `fetch_*()` functions only — no lifecycle, no cursor, no resume, no health, no disconnect, no conformance suite | **INTEGRATION_TESTED** | `src/connectors/connector.ts` (all nine operations); `src/connectors/conformance.ts` (15 checks); `HttpJsonConnector` (real node:http server, opaque cursor) and `JsonlFileConnector` (real file, line-offset cursor, CDC deletes) both pass the identical suite — `tests/p0/connector-conformance.test.ts`, which also proves the suite FAILS a fail-open connector |
|
||||||
|
| **P062** CDC/batch/stream normalized to one envelope; restart resumes from a durable checkpoint without silent gaps | **MISSING**, and documented as such: `services/api/tests/test_known_gaps.py:10-19` skipped the CDC case because no CDC pipeline existed anywhere, and `workers/pipeline.py` had no persistence at all | **INTEGRATION_TESTED** | `src/types/ingestion.ts` (one envelope for all three modes); `src/ingestion/ingestion-runner.ts`; `tests/p0/ingestion-runner.test.ts` kills a run inside the sink and proves the resumed run covers all 10 records exactly once; same proof against real Postgres across fresh processes |
|
||||||
|
| **P063** Schema versioning + compatibility policy; a breaking schema is detected before consumer corruption | **MISSING.** `schema_version` existed as a free-form string on datasets/Iceberg writes; there was no comparison, no policy, no versioned source schema | **INTEGRATION_TESTED** | `src/schema/compatibility.ts`, `src/schema/schema-registry.ts`; `tests/p0/schema-evolution.test.ts` (12); the runner's schema gate runs before the first read — `tests/p0/ingestion-runner.test.ts` asserts `sinkCalls === 0` on a break |
|
||||||
|
| **P064** Quality rules behind the SIAX abstraction; failures block or quarantine per declared policy | **PARTIAL.** `src/quality/*` had rules, runs, results, issues, scores and a GE-compatible engine, but a failing rule only opened an ISSUE — the data flowed on regardless. `grep -ri quarantin src/` found one unrelated hit | **INTEGRATION_TESTED** | `src/quality/quality-policy.ts` composes the EXISTING `ExpectationEngine` (not a second engine) and adds the consequence; block / quarantine / warn each proved in `tests/p0/ingestion-runner.test.ts`; quarantine durability proved against real Postgres |
|
||||||
|
| **P065** Normalization/ER: only the canonical highway; no second simplified production engine | **CLOSED.** `services/api/workers/resolve.py` is now a thin adapter: Python no longer scores identity — it POSTs opaque records to `/api/0pt/internal/er/candidates` and applies ONLY explicit hard-rule `match` decisions (score/fuzzy can never merge records, even from a compromised bridge); canonical engine unreachable ⇒ records return UNMERGED, never fallback-scored. Company semantics `stable-identifiers.v3`: LEI global; org_nr hard identity only within the same registration_country; punctuation normalized; negative stable identity dominates every positive; fuzzy alone ⇒ human review, never auto-merge. Cluster-wide hard-negative constraints: A≈B ∧ B≈C never forces A≈C when canonical identity says A≠C | **INTEGRATION_TESTED** | `tests/p0/entity-resolution.test.ts` (v3 semantics incl. hard-negative transitivity), `tests/p0/entity-resolution-http.test.ts` (internal route fails closed: empty/wrong token ⇒ 401), `services/api/tests/test_resolve_determinism.py` (adapter contract: canonical edges only), shared request validation (`src/er/request-validation.ts`, records 2..5000), blocking pytest gate (`.gitea/workflows/python-engine.yml`) |
|
||||||
|
| **P066** Iceberg | out of scope — ST0RE is owned by another workflow | **NOT TOUCHED** | Contract read only (`src/services/iceberg-write-service.ts`, `src/clients/st0re-lakehouse-client.ts`) |
|
||||||
|
| **P067** Lineage: OpenLineage/AUD0 events; source → output lineage is queryable | **PARTIAL.** `src/lineage/lineage-emitter.ts` emitted OpenLineage to AUD0, but nothing in this repo could ANSWER a lineage question — no store, no query, no route | **INTEGRATION_TESTED** | `src/lineage/lineage-edge.ts`, `src/lineage/lineage-store.ts` (local queryable projection; AUD0 stays the system of record), `GET /api/0pt/lineage`; `tests/p0/ingestion-runner.test.ts` queries both directions and proves tenant scoping |
|
||||||
|
| **P068** Durable bounded backfill/replay; repeated backfill is idempotent | **MISSING**, documented as such: `services/api/tests/test_known_gaps.py:44-52` skipped it because "no delivery-store persistence layer exists to dedupe a backfill against" | **INTEGRATION_TESTED** | `opt_ingestion_ledger` + `IngestionRunner.backfill`; `tests/p0/ingestion-runner.test.ts` (repeat emits 0, live checkpoint untouched, a real content change still re-emits); same against real Postgres |
|
||||||
|
|
||||||
|
### What was actually run (not "should pass")
|
||||||
|
|
||||||
|
- `pnpm run typecheck` — clean.
|
||||||
|
- `pnpm run test` — **365 passed, 25 skipped** (was 311/18 before this pass).
|
||||||
|
- `pnpm run build` — clean; the four new routes appear in the route table.
|
||||||
|
- `pnpm run lint` — 12 errors / 5 warnings, **all pre-existing** (identical
|
||||||
|
count on the base commit; advisory in CI). This pass adds none.
|
||||||
|
- Real Postgres 16: `tests/p0/ingestion-plane-postgres.realinfra.test.ts`
|
||||||
|
with `INGESTION_TEST_DATABASE_URL` — 7 passed.
|
||||||
|
- **Real container restart**, run manually against that same database:
|
||||||
|
`docker restart` the Postgres container, then re-read through the real
|
||||||
|
repositories. Before and after were identical — 2 sources (one `active`
|
||||||
|
with its owner, one `pending`), 1 checkpoint (cursor 6, 6 committed),
|
||||||
|
6 ledger rows, 1 lineage edge, 4 quarantined records, 1 schema version.
|
||||||
|
That is P060's "a restart preserves all sources", measured rather than
|
||||||
|
asserted.
|
||||||
|
|
||||||
|
### A real bug this pass wrote and then caught
|
||||||
|
|
||||||
|
The first draft of `IngestionRepository` combined the idempotency check and
|
||||||
|
its recording into one atomic call, made BEFORE the sink. The crash/resume
|
||||||
|
test then delivered **7 of 10 records**: the page in flight when the sink
|
||||||
|
died had already been marked delivered, so the resumed run skipped it as a
|
||||||
|
duplicate — permanent, silent data loss. The fix is the `peekDeliveries` /
|
||||||
|
`commitDeliveries` split, with both the ledger commit and the checkpoint
|
||||||
|
commit after the sink resolves. Recorded here because the failure mode is
|
||||||
|
the interesting artifact, not the fix.
|
||||||
|
|
||||||
|
### Honest gaps in the new plane (not hidden)
|
||||||
|
|
||||||
|
1. **Delivery is AT-LEAST-ONCE, not exactly-once.** Two concurrent runs on
|
||||||
|
the same (source, partition) can both peek "new" and both emit.
|
||||||
|
`envelope_id` is deterministic so a dedup-capable sink survives it. There
|
||||||
|
is no per-(source, partition) run lock. Do not describe this plane as
|
||||||
|
exactly-once.
|
||||||
|
2. **No CDC *source* exists.** The envelope, the operations and the runner
|
||||||
|
handle change events, and `JsonlFileConnector` emits real deletes from an
|
||||||
|
`_op` field — but this repo still has no Debezium/WAL/replication-slot
|
||||||
|
consumer. P062's contract is met; a CDC connector is still MISSING.
|
||||||
|
3. **0pt does not schedule itself.** `opt_source_schedule` is a declaration
|
||||||
|
ACT0 would read; there is no cron daemon here.
|
||||||
|
4. **The sink is caller-supplied.** The runner stops at the sink; the
|
||||||
|
lakehouse write boundary is ST0RE's (P066, another workflow's repo).
|
||||||
|
5. **Nothing here is deployed.** See the deployment note below.
|
||||||
|
6. **`QualityService`'s own rule/run/issue registry is still in-process
|
||||||
|
Maps** (`src/quality/quality-service.ts:47-50`). The new *enforcement*
|
||||||
|
path is durable; the older rule registry is not. Unchanged by this pass.
|
||||||
|
7. **Python tests still run nowhere.** `grep -rn "pytest" .gitea/workflows
|
||||||
|
.github/workflows` finds nothing — `services/api/tests/*` (9 files) has
|
||||||
|
never been executed by CI. Flagged, not fixed (it needs a workflow change
|
||||||
|
and a Python toolchain decision).
|
||||||
|
|
||||||
|
### Deployment reality, measured 2026-09-04
|
||||||
|
|
||||||
|
`https://0pt.app/api/health` answers **200** from **Vercel** with
|
||||||
|
`{"db":"not_configured"}`, and `https://0pt.app/api/0pt/sources` answers
|
||||||
|
**404** — the deployed build predates the entire `app/api/0pt/*` capability
|
||||||
|
layer. `https://api.0pt.app/health` answers **404** (the FastAPI engine is
|
||||||
|
not reachable), and `.gitea/workflows/cd.yml` still guards on
|
||||||
|
`COOLIFY_APP_ID = TODO-no-coolify-app-provisioned-yet`. So: none of the
|
||||||
|
capability layer — neither this pass's work nor P010/P014's — is running in
|
||||||
|
production, and P060's durability guarantee cannot be exercised there,
|
||||||
|
because production has no database configured and does not serve the routes.
|
||||||
|
Verified with `curl`; not inferred.
|
||||||
|
|
||||||
|
### P065 — closure record (2026-09-07)
|
||||||
|
|
||||||
|
The removal plan below was executed in full and is verified in CI:
|
||||||
|
|
||||||
|
1. ✅ Canonical ER exposed as `POST /api/0pt/er/candidates` (public,
|
||||||
|
tenant-safed) AND `POST /api/0pt/internal/er/candidates` (service-token,
|
||||||
|
fails closed without `X-Internal-Token`).
|
||||||
|
2. ✅ Python CI job: `.gitea/workflows/python-engine.yml` runs
|
||||||
|
`services/api/tests` as a BLOCKING pytest gate.
|
||||||
|
3. ✅ `workers/resolve.py` rewritten as the HTTP adapter — fail-closed to
|
||||||
|
UNMERGED records when the bridge is unreachable; no fallback scorer
|
||||||
|
remains. Cluster-wide hard-negative constraints prevent transitive
|
||||||
|
matches from overriding canonical identity decisions.
|
||||||
|
4. ✅ `services/api/tests/test_resolve_determinism.py` asserts the adapter
|
||||||
|
contract: canonical match edges merge; review/non-match edges do not;
|
||||||
|
hard negatives are respected transitively.
|
||||||
|
|
||||||
|
Residual honesty: the PYTHON-side tests run in CI, but the full
|
||||||
|
adapter→Next.js route chain has not been exercised against a live deployed
|
||||||
|
edge in this branch (the route's own fail-closed behavior is unit-proven).
|
||||||
|
That keeps P065 at INTEGRATION_TESTED, not LIVE_VERIFIED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLUTPRODUKTIONS-VERIFIERING 2026-09-22 (live-mätt, ej påstått)
|
||||||
|
|
||||||
|
**Prod: `https://0pt.app` på flottan (server6, Coolify-app `shxd6lepxuxfsjsp0ath3z1g`),
|
||||||
|
container-image på exakt merge-SHA `df6ab70` via den signerade release-kedjan
|
||||||
|
(cosign-attestation verifierad före redeploy). DNS A → 65.109.19.146,
|
||||||
|
NS chris/maxine.ns.cloudflare.com — Vercel är borta.**
|
||||||
|
|
||||||
|
### Live-matris, mätt med curl (2026-09-22 04:09 UTC)
|
||||||
|
|
||||||
|
| Yta | Resultat |
|
||||||
|
|---|---|
|
||||||
|
| Front `/` + 10 nyckelsidor (bestall, katalog, kallor, tjanster, portal, admin, order) | 200 ×11 |
|
||||||
|
| `/api/health` | 200 `{ok:true, db:"ok", engine:"ok"}` |
|
||||||
|
| `/api/ready` | 200 (fail-closed-grinden: var 503 före DB-provisioneringen) |
|
||||||
|
| `/api/0pt/*` (9 rutter) anonymt | **401 ×9** — S1-luckan (2026-08-07: anonym 200) är STÄNGD |
|
||||||
|
| `/api/orders`, `/api/admin/metrics` anonymt | 401 |
|
||||||
|
| `/api/subscriptions` | 200 `[]` (var 500: migration 014, se nedan) |
|
||||||
|
| `POST /api/estimates` | 200 (edge räknar + engine-wire) |
|
||||||
|
| `GET /api/catalog`, `/api/sources` | 200 (publika läsvyter, avsiktligt) |
|
||||||
|
| `GET /api/vehicles` utan param | 400 (kräver reg-nr — korrekt) |
|
||||||
|
| Security headers | HSTS(63072000+preload) · CSP · XFO DENY · nosniff · ingen X-Powered-By |
|
||||||
|
|
||||||
|
### Denna pass fixat (3 PR, alla mergerade + deployade live)
|
||||||
|
|
||||||
|
1. **PR #80** — G17 hook-pin re-bump `7725498 → a401880` (re-review: 0 diff under
|
||||||
|
`ci/v2/reference-hooks/`). Release chain + gated deploy gröna igen.
|
||||||
|
2. **PR #81 (fix för issue #53)**: 7 legacy money-routes (orders, orders/[id],
|
||||||
|
subscriptions, payments/deposit, payments/release, webhooks/stripe,
|
||||||
|
admin/metrics) mappar nu `SiaxError(DEPENDENCY_UNAVAILABLE, 503, retryable)`
|
||||||
|
via `app/api/siax-error-mapping.ts` i stället för att läcka Next opaka 500.
|
||||||
|
Stripe-webhooken svarar 503 `received:false reason:store_unavailable` på
|
||||||
|
store-outage — aldrig tyst `received:true` för ett betalt event som inte
|
||||||
|
processats. 3 nya tester pinar kontraktet.
|
||||||
|
3. **PR #86**: `sql/014_subscriptions_created_at.sql` — `lib/subscriptions-store.ts`
|
||||||
|
SELECT:ade `created_at` som `001_init.sql` aldrig definierat (42703 mätt live:
|
||||||
|
GET /api/subscriptions har ALWAYS varit 500 mot Postgres). Kolonnen ADD:ad
|
||||||
|
idempotent LIVE på 0pt-edge-postgres; route svarar nu 200 `[]`.
|
||||||
|
4. **PR #87**: cd-gatens not-ready-regex — cosign 3.1.3 säger
|
||||||
|
`no matching attestations:` (utan `found`); en transient not-ready misstolkades
|
||||||
|
som hårt verify-fel. Fail-closed för riktiga attestfel oförändrad.
|
||||||
|
5. **CI-infra reparerad**: repo-secrets `SIAX_CI_TOKEN` + `REGISTRY_TOKEN` var
|
||||||
|
döda efter token-svepet (2026-09-22 eng0-introspect-rotation revoade 107+ tokens;
|
||||||
|
fällan: 0pt:s repo-secret överrider org-secreten — org-PUT räcker inte). Nya
|
||||||
|
siax-ci-tokens (`write:package,read:package` + all) mintade och satta via
|
||||||
|
secrets-API (PUT 204, verifierade via lyckad release-kedja).
|
||||||
|
|
||||||
|
### 0pt-motor (engine, satellite)
|
||||||
|
|
||||||
|
- 6 containers Up på server6 (`motor-provisional`), core `/health` ok+pg+redis,
|
||||||
|
delivery `/version` = fb67870, core revision = 6cfd433 — båda IN main.
|
||||||
|
- CI grön på main (runs 55985/55987/56004) — healer-issue #38 (run 40562,
|
||||||
|
2026-09-20) stängd som transient runner-flake (apt-GPG under last).
|
||||||
|
- Bara `main` kvar i 0pt och 0pt-motor (8 + 6 stale grenar raderade — alla
|
||||||
|
bevisade redan mergerade via `git merge-base --is-ancestor`).
|
||||||
|
- motor/-executor-koden (signals/NER/normalization, 3507 rader nya sedan 6cfd433)
|
||||||
|
konsumeras av `Dockerfile.motor`-imaget som ännu inte har ett driftsatt runtime
|
||||||
|
i stacken (core/ingest/resolve/delivery importerar ej motor/ — verifierat
|
||||||
|
import-graf). Byggs enligt `scripts/build-images.sh` + `verify-image.sh`-gaten
|
||||||
|
när executor-ytan aktiveras — dokumenterat i `docs/runbook/deploy.md`.
|
||||||
|
|
||||||
|
### Kvarstår (ärligt, ej gömt)
|
||||||
|
|
||||||
|
1. **Stripe-creds** (Simon): live-keys → edge-envs `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`.
|
||||||
|
Innan dess förblir payments 501 och issue #67:s staging-triage kan ej köras.
|
||||||
|
2. **queue: not_configured** — ingen Redis i edge-stacken (ärligt rapporterat).
|
||||||
|
3. **`0pt.siax.io`/`motor.siax.io`**: medvetet ej återupprättade (0pt.app + lokala
|
||||||
|
127.0.0.1-portar är kanoniska ytor; issues #70/#35 stängda/avgjorda därefter).
|
||||||
|
4. **motor.siax.io**: motor:s publika yta kräver Simon-beslut (traefik-labels + DNS).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.acquisition-consumer.schema.json",
|
||||||
|
"schemaVersion": "siax.acquisition-consumer/v1",
|
||||||
|
"application": "0pt",
|
||||||
|
"owner": "0pt",
|
||||||
|
"tenantScoped": true,
|
||||||
|
"capabilities": [
|
||||||
|
"siax.acquisition.plan",
|
||||||
|
"siax.acquisition.run",
|
||||||
|
"siax.acquisition.status",
|
||||||
|
"siax.acquisition.result",
|
||||||
|
"siax.acquisition.query_existing",
|
||||||
|
"siax.acquisition.discovery_search",
|
||||||
|
"siax.acquisition.extract",
|
||||||
|
"siax.acquisition.evidence"
|
||||||
|
],
|
||||||
|
"allowedPurposes": [
|
||||||
|
"data.acquisition",
|
||||||
|
"source.registration"
|
||||||
|
],
|
||||||
|
"dataCategories": [
|
||||||
|
"public",
|
||||||
|
"company",
|
||||||
|
"product",
|
||||||
|
"pricing",
|
||||||
|
"vehicle",
|
||||||
|
"property",
|
||||||
|
"document",
|
||||||
|
"event",
|
||||||
|
"other"
|
||||||
|
],
|
||||||
|
"maxCostSEK": 25,
|
||||||
|
"reuseExistingFacts": true,
|
||||||
|
"mustNotOwnEngines": true,
|
||||||
|
"engines": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# `analytics/dbt/` — BI-P022
|
||||||
|
|
||||||
|
The canonical SIAX transformation project. **Nothing in this repository runs
|
||||||
|
it.** Execution lives in N0D (`n0d#38`), against the contract in
|
||||||
|
`src/analytics/transform/dbt-contract.ts`.
|
||||||
|
|
||||||
|
## Layers, and why they map onto BI-P003
|
||||||
|
|
||||||
|
| directory | tier (BI-P003) | may read | must not contain |
|
||||||
|
| ---------------------- | -------------- | --------------------- | --------------------- |
|
||||||
|
| `models/sources/` | `RAW` | — (declarations only) | any SQL |
|
||||||
|
| `models/staging/` | `NORMALIZED` | sources | joins, business logic |
|
||||||
|
| `models/intermediate/` | `CURATED` | staging | presentation shaping |
|
||||||
|
| `models/marts/` | `SERVING` | intermediate | direct source reads |
|
||||||
|
|
||||||
|
`assertLayerDependenciesLegal` enforces the "may read" column against the
|
||||||
|
dependency graph in `manifest.json`. A mart reading a source directly is
|
||||||
|
refused — that is raw data reaching a dashboard, which is precisely what
|
||||||
|
BI-P003's tier rule exists to prevent, and a convention nobody checks would not
|
||||||
|
prevent it.
|
||||||
|
|
||||||
|
## What a run must produce
|
||||||
|
|
||||||
|
`manifest.json` and `run_results.json`, both registered as ST0RE artifacts.
|
||||||
|
`assertValidDbtJobResult` refuses a result missing either: without them the run
|
||||||
|
cannot be audited and its tests cannot be ingested by BI-P025's
|
||||||
|
`DbtQualityProvider`.
|
||||||
|
|
||||||
|
It also refuses:
|
||||||
|
|
||||||
|
- a result whose revision differs from the request — the run is not reproducible;
|
||||||
|
- a result whose observed dbt version differs from the pin — a runtime that is
|
||||||
|
not the pinned one silently changes what the models mean;
|
||||||
|
- `status: success` with **zero nodes executed** — a dbt selector that matches
|
||||||
|
nothing exits 0, which is the dbt-shaped false green.
|
||||||
|
|
||||||
|
## Version posture
|
||||||
|
|
||||||
|
`DBT_RUNTIME_PIN` records dbt Core 1.10.13 (stable line) with
|
||||||
|
`dbt-clickhouse` 1.9.2. **Both review flags are `false`.** No licence or
|
||||||
|
supply-chain review has been performed in this repo, and none is claimed.
|
||||||
|
`assertDbtRuntimeCertified` refuses a `production` run until they are true, so
|
||||||
|
BI-P022's "after current license/security verification" is a gate rather than a
|
||||||
|
sentence in a document.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
A dbt run never receives a tenant's warehouse credential from this repo. N0D
|
||||||
|
resolves a credential whose write scope matches `target_layer` and
|
||||||
|
`expected_target_schema`, and the contract refuses a request whose expected
|
||||||
|
schema is not scoped to the job's tenant.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# BI-P022 — the canonical SIAX analytics transformation project.
|
||||||
|
#
|
||||||
|
# Execution happens in N0D (n0d#38), never here. This project is the MODELS and
|
||||||
|
# the CONVENTIONS; src/analytics/transform/dbt-contract.ts is the job contract
|
||||||
|
# N0D consumes, and it is what refuses a run that is not reproducible.
|
||||||
|
name: siax_analytics
|
||||||
|
version: 1.0.0
|
||||||
|
config-version: 2
|
||||||
|
profile: siax_analytics
|
||||||
|
|
||||||
|
model-paths: ['models']
|
||||||
|
test-paths: ['tests']
|
||||||
|
snapshot-paths: ['snapshots']
|
||||||
|
macro-paths: ['macros']
|
||||||
|
|
||||||
|
# The four layers map onto BI-P003's tiers rather than inventing a fifth
|
||||||
|
# vocabulary. `+tags` carries the tier so a manifest reader can assign one
|
||||||
|
# without parsing paths, and dbt-contract.ts asserts the mapping.
|
||||||
|
models:
|
||||||
|
siax_analytics:
|
||||||
|
+materialized: view
|
||||||
|
staging:
|
||||||
|
+schema: staging
|
||||||
|
+materialized: view
|
||||||
|
+tags: ['tier:NORMALIZED']
|
||||||
|
intermediate:
|
||||||
|
+schema: intermediate
|
||||||
|
+materialized: table
|
||||||
|
+tags: ['tier:CURATED']
|
||||||
|
marts:
|
||||||
|
+schema: marts
|
||||||
|
+materialized: table
|
||||||
|
+tags: ['tier:SERVING']
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
siax_analytics:
|
||||||
|
+target_schema: snapshots
|
||||||
|
|
||||||
|
# `on_schema_change: fail` rather than `sync_all_columns`: a silently widened
|
||||||
|
# table is how a downstream metric starts averaging a column that did not exist
|
||||||
|
# yesterday. BI-P004's contract gate is the same rule one layer up.
|
||||||
|
vars:
|
||||||
|
siax_tenant_id: '__set_by_n0d__'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CURATED (BI-P003). Joins and conformed entities. Reads STAGING only.
|
||||||
|
{{ config(materialized='table', tags=['tier:CURATED']) }}
|
||||||
|
|
||||||
|
select
|
||||||
|
e.event_id,
|
||||||
|
e.tenant_id,
|
||||||
|
e.customer_id,
|
||||||
|
e.amount_sek,
|
||||||
|
e.channel,
|
||||||
|
coalesce(c.country_code, e.country_code) as country_code,
|
||||||
|
c.segment,
|
||||||
|
e.invoiced_at
|
||||||
|
from {{ ref('stg_sales_events') }} e
|
||||||
|
left join {{ ref('stg_customers') }} c
|
||||||
|
on e.customer_id = c.customer_id
|
||||||
|
and e.tenant_id = c.tenant_id -- the join itself stays tenant-scoped
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- SERVING (BI-P003). What a dashboard or a BI-P028 metric reads.
|
||||||
|
--
|
||||||
|
-- Reads INTERMEDIATE only. Reading a source here would skip two layers, and
|
||||||
|
-- assertLayerDependenciesLegal refuses that against the manifest graph.
|
||||||
|
{{ config(materialized='table', tags=['tier:SERVING']) }}
|
||||||
|
|
||||||
|
select
|
||||||
|
tenant_id,
|
||||||
|
date_trunc('day', invoiced_at) as day,
|
||||||
|
country_code,
|
||||||
|
channel,
|
||||||
|
segment,
|
||||||
|
count(*) as order_count,
|
||||||
|
sum(amount_sek) as net_revenue_sek
|
||||||
|
from {{ ref('int_sales_enriched') }}
|
||||||
|
where channel != 'internal' -- matches the net_revenue metric's baked filter
|
||||||
|
group by 1, 2, 3, 4, 5
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
models:
|
||||||
|
- name: mart_sales_daily
|
||||||
|
description: >
|
||||||
|
SERVING projection behind the `net_revenue` and `orders` metrics.
|
||||||
|
A blocking test failure here quarantines the serving projection
|
||||||
|
(BI-P025); the quarantine is enforced on the READ side.
|
||||||
|
columns:
|
||||||
|
- name: tenant_id
|
||||||
|
tests: [not_null]
|
||||||
|
- name: day
|
||||||
|
tests: [not_null]
|
||||||
|
- name: net_revenue_sek
|
||||||
|
tests: [not_null]
|
||||||
|
- name: order_count
|
||||||
|
tests:
|
||||||
|
- not_null
|
||||||
|
- dbt_utils.accepted_range: { min_value: 1, inclusive: true }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
# RAW. Declared, never modelled. A source has no SQL of its own — anything that
|
||||||
|
# transforms belongs in staging, where BI-P003 assigns it NORMALIZED.
|
||||||
|
sources:
|
||||||
|
- name: opt_raw
|
||||||
|
description: >
|
||||||
|
Landed, untransformed data as delivered by the 0pt sourcing engine.
|
||||||
|
RAW tier: no BI surface may read this directly without a recorded
|
||||||
|
BI-P003 exception.
|
||||||
|
schema: "{{ var('siax_tenant_id') }}_raw"
|
||||||
|
loaded_at_field: ingested_at
|
||||||
|
freshness:
|
||||||
|
# BI-P026's DELAYED/STALE thresholds, expressed where dbt can act on them.
|
||||||
|
warn_after: { count: 90, period: minute }
|
||||||
|
error_after: { count: 180, period: minute }
|
||||||
|
tables:
|
||||||
|
- name: sales_events
|
||||||
|
description: One row per invoiced sales line, as landed.
|
||||||
|
columns:
|
||||||
|
- name: event_id
|
||||||
|
tests: [not_null, unique]
|
||||||
|
- name: tenant_id
|
||||||
|
description: Present on every raw row; the tier boundary depends on it.
|
||||||
|
tests: [not_null]
|
||||||
|
- name: ingested_at
|
||||||
|
tests: [not_null]
|
||||||
|
- name: customers
|
||||||
|
description: Customer master as landed.
|
||||||
|
columns:
|
||||||
|
- name: customer_id
|
||||||
|
tests: [not_null, unique]
|
||||||
|
- name: tenant_id
|
||||||
|
tests: [not_null]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
models:
|
||||||
|
- name: stg_sales_events
|
||||||
|
description: Normalized sales events — one row per event_id, latest landing wins.
|
||||||
|
columns:
|
||||||
|
- name: event_id
|
||||||
|
tests: [not_null, unique]
|
||||||
|
- name: tenant_id
|
||||||
|
tests: [not_null]
|
||||||
|
- name: amount_sek
|
||||||
|
tests:
|
||||||
|
- not_null
|
||||||
|
- dbt_utils.accepted_range:
|
||||||
|
min_value: 0
|
||||||
|
inclusive: true
|
||||||
|
- name: channel
|
||||||
|
tests:
|
||||||
|
- accepted_values:
|
||||||
|
values: ['web', 'retail', 'partner', 'internal']
|
||||||
|
- name: stg_customers
|
||||||
|
columns:
|
||||||
|
- name: customer_id
|
||||||
|
tests: [not_null, unique]
|
||||||
|
- name: tenant_id
|
||||||
|
tests: [not_null]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- NORMALIZED (BI-P003).
|
||||||
|
{{ config(materialized='view', tags=['tier:NORMALIZED']) }}
|
||||||
|
|
||||||
|
select
|
||||||
|
cast(customer_id as varchar) as customer_id,
|
||||||
|
cast(tenant_id as varchar) as tenant_id,
|
||||||
|
cast(name as varchar) as customer_name,
|
||||||
|
cast(segment as varchar) as segment,
|
||||||
|
cast(country as varchar) as country_code
|
||||||
|
from {{ source('opt_raw', 'customers') }}
|
||||||
|
where tenant_id = '{{ var("siax_tenant_id") }}'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- NORMALIZED (BI-P003). Rename, cast, deduplicate. No joins, no business logic.
|
||||||
|
--
|
||||||
|
-- The tenant predicate is applied HERE, at the first modelled layer, so every
|
||||||
|
-- downstream model inherits it and no later model has to remember. It is the
|
||||||
|
-- same forced-predicate rule as the query compiler's, one layer up.
|
||||||
|
{{ config(materialized='view', tags=['tier:NORMALIZED']) }}
|
||||||
|
|
||||||
|
with source as (
|
||||||
|
select * from {{ source('opt_raw', 'sales_events') }}
|
||||||
|
where tenant_id = '{{ var("siax_tenant_id") }}'
|
||||||
|
),
|
||||||
|
|
||||||
|
deduplicated as (
|
||||||
|
select
|
||||||
|
*,
|
||||||
|
row_number() over (partition by event_id order by ingested_at desc) as _rn
|
||||||
|
from source
|
||||||
|
)
|
||||||
|
|
||||||
|
select
|
||||||
|
cast(event_id as varchar) as event_id,
|
||||||
|
cast(tenant_id as varchar) as tenant_id,
|
||||||
|
cast(customer_id as varchar) as customer_id,
|
||||||
|
cast(amount as double) as amount_sek,
|
||||||
|
cast(channel as varchar) as channel,
|
||||||
|
cast(country as varchar) as country_code,
|
||||||
|
cast(invoiced_at as timestamp) as invoiced_at,
|
||||||
|
cast(ingested_at as timestamp) as ingested_at
|
||||||
|
from deduplicated
|
||||||
|
where _rn = 1
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Slowly-changing customer attributes, so a historical metric can be
|
||||||
|
-- recomputed against what a customer's segment WAS, not what it is now.
|
||||||
|
{% snapshot snap_customers %}
|
||||||
|
{{
|
||||||
|
config(
|
||||||
|
target_schema='snapshots',
|
||||||
|
unique_key='customer_id',
|
||||||
|
strategy='check',
|
||||||
|
check_cols=['segment', 'country_code'],
|
||||||
|
invalidate_hard_deletes=True
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
select customer_id, tenant_id, customer_name, segment, country_code
|
||||||
|
from {{ ref('stg_customers') }}
|
||||||
|
{% endsnapshot %}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Fails when the serving mart is EMPTY.
|
||||||
|
--
|
||||||
|
-- Without this, every column test passes trivially over zero rows and the
|
||||||
|
-- whole suite reports green while the pipeline is dead — the same false green
|
||||||
|
-- BI-P025's NativeQualityProvider refuses.
|
||||||
|
select 1 as empty_mart
|
||||||
|
having (select count(*) from {{ ref('mart_sales_daily') }}) = 0
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A singular test that FAILS if the serving mart ever contains a row belonging
|
||||||
|
-- to a tenant other than the one the run was scoped to.
|
||||||
|
--
|
||||||
|
-- The tenant predicate is applied in staging and inherited downstream, so this
|
||||||
|
-- test should be impossible to fail. That is precisely why it exists: an
|
||||||
|
-- isolation guarantee nobody tests is the one that turns out to have four
|
||||||
|
-- holes rather than the one reported.
|
||||||
|
select tenant_id, count(*) as rows_from_another_tenant
|
||||||
|
from {{ ref('mart_sales_daily') }}
|
||||||
|
where tenant_id != '{{ var("siax_tenant_id") }}'
|
||||||
|
group by tenant_id
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useT } from "@/components/locale-provider";
|
||||||
|
import { PortalShell } from "@/components/portal-shell";
|
||||||
|
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
|
||||||
|
import { formatSEK } from "@/lib/data";
|
||||||
|
|
||||||
|
// Admin · Kunder. No auth/DB customer table yet, so we derive a customer list
|
||||||
|
// from GET /api/orders, grouping by flow as a stand-in for organisation.
|
||||||
|
// Each group: name, antal ordrar, summa (sum of total_ore → kr).
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
order_id: string;
|
||||||
|
flow: string;
|
||||||
|
total_ore: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerRow {
|
||||||
|
name: string;
|
||||||
|
orders: number;
|
||||||
|
totalOre: number;
|
||||||
|
last: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByFlow(orders: Order[]): CustomerRow[] {
|
||||||
|
const map = new Map<string, CustomerRow>();
|
||||||
|
for (const o of orders) {
|
||||||
|
const name = o.flow || "unknown";
|
||||||
|
const row = map.get(name) ?? { name, orders: 0, totalOre: 0, last: "" };
|
||||||
|
row.orders += 1;
|
||||||
|
row.totalOre += o.total_ore ?? 0;
|
||||||
|
if (o.created_at > row.last) row.last = o.created_at;
|
||||||
|
map.set(name, row);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.totalOre - a.totalOre);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCustomersPage() {
|
||||||
|
const t = useT();
|
||||||
|
const [rows, setRows] = useState<CustomerRow[] | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(false);
|
||||||
|
setRows(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/orders", { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const data: Order[] = await r.json();
|
||||||
|
setRows(groupByFlow(Array.isArray(data) ? data : []));
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const totalOre = rows?.reduce((s, r) => s + r.totalOre, 0) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="admin" title={t({ en: "Customers", sv: "Kunder" })}>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{t({ en: "Derived from orders. Grouped by flow. Organisation linking at auth.", sv: "Härlett ur ordrar. Grupperat per flöde. Org-koppling vid auth." })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ErrorState onRetry={load} />
|
||||||
|
</div>
|
||||||
|
) : !rows ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<EmptyState
|
||||||
|
title={t({ en: "No customers yet", sv: "Inga kunder ännu" })}
|
||||||
|
line={t({ en: "Customers appear here as soon as the first order is created.", sv: "Kunder visas här så fort första ordern skapas." })}
|
||||||
|
ctaHref="/bestall"
|
||||||
|
ctaLabel={t({ en: "New order", sv: "Ny beställning" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-6 flex flex-wrap items-baseline gap-x-8 gap-y-2 border border-border bg-secondary/40 px-4 py-3">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{rows.length} {t({ en: "customers", sv: "kunder" })}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{rows.reduce((s, r) => s + r.orders, 0)} {t({ en: "orders", sv: "ordrar" })}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-sm tabular-nums">
|
||||||
|
{formatSEK(totalOre / 100)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-px border border-border">
|
||||||
|
<div className="grid grid-cols-[2fr_0.8fr_1fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
<span>{t({ en: "Customer", sv: "Kund" })}</span>
|
||||||
|
<span className="text-right">{t({ en: "Orders", sv: "Ordrar" })}</span>
|
||||||
|
<span className="text-right">{t({ en: "Latest", sv: "Senast" })}</span>
|
||||||
|
<span className="text-right">{t({ en: "Total", sv: "Summa" })}</span>
|
||||||
|
</div>
|
||||||
|
{rows.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.name}
|
||||||
|
className="grid grid-cols-[2fr_0.8fr_1fr_1fr] items-center border-b border-border px-4 py-3.5 text-sm last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2.5 font-medium">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block h-2 w-2 shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--prov-2)" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{c.name === "unknown" ? t({ en: "Unknown", sv: "Okänd" }) : c.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{c.orders}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{fmtDate(c.last)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-mono text-sm tabular-nums">
|
||||||
|
{formatSEK(c.totalOre / 100)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// C8: "Importera ordrar" — the first real product host of the @siax/ui
|
||||||
|
// ImportWizard + @siax/import-export runtime. The wizard renders the frozen
|
||||||
|
// store snapshot; THIS host owns every pipeline step's execution and all
|
||||||
|
// server I/O. The server (POST /api/orders/import) is the only authority:
|
||||||
|
// it re-parses and re-validates the raw CSV, so the client-side parse below
|
||||||
|
// exists purely to give the wizard rows to render.
|
||||||
|
|
||||||
|
import { ImportWizard } from "@siax/ui";
|
||||||
|
import { createImportRunStore, type ImportRunStore, type ImportRunSnapshot } from "@siax/import-export";
|
||||||
|
import { createImportJob } from "@siax/contracts/import-export";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const;
|
||||||
|
|
||||||
|
interface ServerReport {
|
||||||
|
imported?: number;
|
||||||
|
totalRows?: number;
|
||||||
|
validRows?: number;
|
||||||
|
errorCount?: number;
|
||||||
|
errors?: { row: number; field: string; code: string; message?: string }[];
|
||||||
|
order_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side RFC 4180 parse — presentation only; the server re-parses the raw CSV. */
|
||||||
|
function clientParse(text: string): Record<string, string>[] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let field = "";
|
||||||
|
let row: string[] = [];
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (text[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i++;
|
||||||
|
} else inQuotes = false;
|
||||||
|
} else field += ch;
|
||||||
|
} else if (ch === '"') inQuotes = true;
|
||||||
|
else if (ch === ",") {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
} else if (ch === "\n") {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
} else if (ch !== "\r") field += ch;
|
||||||
|
}
|
||||||
|
if (field.length > 0 || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
const nonEmpty = rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== "");
|
||||||
|
if (nonEmpty.length === 0) return [];
|
||||||
|
const header = nonEmpty[0].map((h) => h.trim().toLowerCase());
|
||||||
|
return nonEmpty.slice(1).map((cells) => {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
header.forEach((h, idx) => {
|
||||||
|
if (h) obj[h] = (cells[idx] ?? "").trim();
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postImport(mode: "validate" | "dry_run" | "execute", csv: string): Promise<{ ok: boolean; status: number; data: ServerReport & { error?: { message?: string } } }> {
|
||||||
|
const res = await fetch("/api/orders/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode, csv }),
|
||||||
|
});
|
||||||
|
let data: ServerReport & { error?: { message?: string } } = {};
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
data = { error: { message: `HTTP ${res.status}` } };
|
||||||
|
}
|
||||||
|
return { ok: res.ok, status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminImportPage() {
|
||||||
|
const [rawCsv, setRawCsv] = useState<string | null>(null);
|
||||||
|
const [store, setStore] = useState<ImportRunStore | null>(null);
|
||||||
|
const [serverReport, setServerReport] = useState<ServerReport | null>(null);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const [recentOrders, setRecentOrders] = useState<{ order_id: string; flow: string; status: string }[] | null>(null);
|
||||||
|
|
||||||
|
const onFile = useCallback(async (file: File) => {
|
||||||
|
const text = await file.text();
|
||||||
|
setRawCsv(text);
|
||||||
|
setServerReport(null);
|
||||||
|
setServerError(null);
|
||||||
|
setRecentOrders(null);
|
||||||
|
const rows = clientParse(text);
|
||||||
|
const s = createImportRunStore({
|
||||||
|
job: createImportJob({
|
||||||
|
id: globalThis.crypto?.randomUUID?.() ?? `imp-${Date.now()}`,
|
||||||
|
tenantId: "current",
|
||||||
|
entity: "orders",
|
||||||
|
format: "csv",
|
||||||
|
source: { system: "0pt-admin", channel: "web" },
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
rows,
|
||||||
|
});
|
||||||
|
s.detect();
|
||||||
|
setStore(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onValidate = useCallback(async () => {
|
||||||
|
if (!store || !rawCsv) return;
|
||||||
|
setServerError(null);
|
||||||
|
const { ok, status, data } = await postImport("validate", rawCsv);
|
||||||
|
setServerReport(data);
|
||||||
|
store.validate(); // local snapshot for the wizard report (same frozen kernel)
|
||||||
|
if (!ok) setServerError(`Servervalidering avvisade filen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
|
||||||
|
}, [store, rawCsv]);
|
||||||
|
|
||||||
|
const onDryRun = useCallback(async () => {
|
||||||
|
if (!store || !rawCsv) return;
|
||||||
|
setServerError(null);
|
||||||
|
const { ok, status, data } = await postImport("dry_run", rawCsv);
|
||||||
|
setServerReport(data);
|
||||||
|
store.dryRun();
|
||||||
|
if (!ok) setServerError(`Servern nekade generalrepet (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
|
||||||
|
}, [store, rawCsv]);
|
||||||
|
|
||||||
|
const onExecute = useCallback(async () => {
|
||||||
|
if (!store || !rawCsv) return;
|
||||||
|
setServerError(null);
|
||||||
|
const { ok, status, data } = await postImport("execute", rawCsv);
|
||||||
|
setServerReport(data);
|
||||||
|
if (!ok) {
|
||||||
|
// Server is the authority — a failed execute is terminal for this run.
|
||||||
|
store.fail(`Servern avvisade exekveringen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Host executor proxies the server result into the store's hard gates.
|
||||||
|
const snapshot: ImportRunSnapshot = await store.execute(async () => ({ imported: data.imported ?? 0 }));
|
||||||
|
// Reload-persistence proof: re-read the REAL order list after import.
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/orders", { cache: "no-store" });
|
||||||
|
if (res.ok) {
|
||||||
|
const orders = (await res.json()) as { order_id: string; flow: string; status: string }[];
|
||||||
|
const importedIds = new Set(data.order_ids ?? []);
|
||||||
|
setRecentOrders(orders.filter((o) => importedIds.has(o.order_id)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* panel stays null — server report above still shows the truth */
|
||||||
|
}
|
||||||
|
void snapshot;
|
||||||
|
}, [store, rawCsv]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>Importera ordrar</h1>
|
||||||
|
<p style={{ marginBottom: "1rem", opacity: 0.8 }}>
|
||||||
|
CSV med kolumnerna: {IMPORT_COLUMNS.join(", ")}. Endast <code>flow</code> är obligatorisk
|
||||||
|
(catalog | custom | subscription). Parsering och validering sker på servern —
|
||||||
|
filen laddas inte upp förrän ett steg körs.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
aria-label="Välj CSV-fil"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void onFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{store && (
|
||||||
|
<div style={{ marginTop: "1.5rem" }}>
|
||||||
|
<ImportWizard
|
||||||
|
store={store}
|
||||||
|
columns={IMPORT_COLUMNS.map((c) => ({ source: c, target: c }))}
|
||||||
|
onMap={() => {
|
||||||
|
store.map({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) });
|
||||||
|
}}
|
||||||
|
onValidate={onValidate}
|
||||||
|
onDryRun={onDryRun}
|
||||||
|
onExecute={onExecute}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{serverError && (
|
||||||
|
<p role="alert" style={{ marginTop: "1rem", color: "crimson" }}>
|
||||||
|
{serverError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{serverReport && (serverReport.errorCount ?? 0) > 0 && (
|
||||||
|
<section style={{ marginTop: "1.5rem" }} aria-label="Felrapport från servern">
|
||||||
|
<h2 style={{ fontSize: "1.1rem" }}>Felrader (serverns rapport)</h2>
|
||||||
|
<ul>
|
||||||
|
{(serverReport.errors ?? []).map((e, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
Rad {e.row} · {e.field} · {e.code}
|
||||||
|
{e.message ? ` — ${e.message}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{recentOrders && (
|
||||||
|
<section style={{ marginTop: "1.5rem" }} aria-label="Importerade ordrar efter reload">
|
||||||
|
<h2 style={{ fontSize: "1.1rem" }}>Importerade ordrar (lästa från /api/orders efter import)</h2>
|
||||||
|
{recentOrders.length === 0 ? (
|
||||||
|
<p>Inga importerade ordrar hittades vid reload.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{recentOrders.map((o) => (
|
||||||
|
<li key={o.order_id}>
|
||||||
|
<code>{o.order_id}</code> — {o.flow} — {o.status}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { PortalShell, StatusPill } from "@/components/portal-shell";
|
||||||
|
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
|
||||||
|
import { formatSEK } from "@/lib/data";
|
||||||
|
import { useT } from "@/components/locale-provider";
|
||||||
|
|
||||||
|
// Admin pipeline monitor. Reads GET /api/orders, keeps the rows whose status is
|
||||||
|
// in_production/review, and renders each as a live pipeline row: the five engine
|
||||||
|
// stages as hairline progress chips + a StatusPill. No fetching of stage data
|
||||||
|
// exists yet, so per-stage progress is derived deterministically from the order
|
||||||
|
// id — a stable placeholder until the orchestrator streams real stage events.
|
||||||
|
|
||||||
|
interface OrderConfig {
|
||||||
|
markets?: string[];
|
||||||
|
type?: string;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
interface Order {
|
||||||
|
order_id: string;
|
||||||
|
flow: string;
|
||||||
|
status: string;
|
||||||
|
config: OrderConfig;
|
||||||
|
total_ore: number;
|
||||||
|
est_confidence: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES = [
|
||||||
|
"Källracing",
|
||||||
|
"Extraktion & OCR",
|
||||||
|
"Strukturering",
|
||||||
|
"Berikning & fusion",
|
||||||
|
"Kvalitetskontroll",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RUNNING = new Set(["in_production", "review"]);
|
||||||
|
|
||||||
|
// Deterministic placeholder progress per order, 0..1 across the 5 stages.
|
||||||
|
// "review" orders sit at the QA stage; in_production progress fans out.
|
||||||
|
function stageProgress(order: Order): number[] {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of order.order_id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
const front = order.status === "review" ? 4 : h % STAGES.length;
|
||||||
|
return STAGES.map((_, i) => {
|
||||||
|
if (i < front) return 1;
|
||||||
|
if (i > front) return 0;
|
||||||
|
return order.status === "review" ? 0.62 : 0.25 + ((h >> (i + 3)) % 60) / 100;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_LABELS_EN: Record<(typeof STAGES)[number], string> = {
|
||||||
|
"Källracing": "Source racing",
|
||||||
|
"Extraktion & OCR": "Extraction & OCR",
|
||||||
|
"Strukturering": "Structuring",
|
||||||
|
"Berikning & fusion": "Enrichment & fusion",
|
||||||
|
"Kvalitetskontroll": "Quality control",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PipelineRow({ order }: { order: Order }) {
|
||||||
|
const t = useT();
|
||||||
|
const progress = stageProgress(order);
|
||||||
|
const markets = order.config.markets ?? [];
|
||||||
|
const done = progress.filter((p) => p >= 1).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border px-4 py-4 last:border-b-0">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{order.order_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">{order.config.type ?? t({ en: "Company", sv: "Bolag" })}</span>
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{markets.length ? markets.join(" · ") : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{(order.config.volume ?? 0).toLocaleString("sv-SE")} {t({ en: "rows", sv: "rader" })}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{formatSEK(order.total_ore / 100)}
|
||||||
|
</span>
|
||||||
|
<StatusPill status={order.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{STAGES.map((stage, i) => {
|
||||||
|
const p = progress[i];
|
||||||
|
const active = p > 0 && p < 1;
|
||||||
|
const complete = p >= 1;
|
||||||
|
return (
|
||||||
|
<div key={stage} className="min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-mono text-[9.5px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{t({ en: STAGE_LABELS_EN[stage], sv: stage })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block h-1.5 w-1.5 shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: complete
|
||||||
|
? "var(--prov-3)"
|
||||||
|
: active
|
||||||
|
? "var(--accent-red)"
|
||||||
|
: "var(--border)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1.5 h-1 w-full border border-border bg-background"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(p * 100)}
|
||||||
|
aria-label={`${t({ en: STAGE_LABELS_EN[stage], sv: stage })}: ${Math.round(p * 100)}%`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round(p * 100)}%`,
|
||||||
|
backgroundColor: complete ? "var(--prov-3)" : "var(--accent-red)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{done}/{STAGES.length} {t({ en: "steps · started", sv: "steg · startad" })} {new Date(order.created_at).toLocaleDateString("sv-SE")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminJobsPage() {
|
||||||
|
const t = useT();
|
||||||
|
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(false);
|
||||||
|
setOrders(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/orders", { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const data = (await r.json()) as Order[];
|
||||||
|
setOrders(Array.isArray(data) ? data : []);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const running = (orders ?? []).filter((o) => RUNNING.has(o.status));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="admin" title={t({ en: "Jobs & pipeline", sv: "Jobb & pipeline" })}>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{t({
|
||||||
|
en: "Active production. Five stages per job. Escrow releases on approval.",
|
||||||
|
sv: "Aktiv produktion. Fem steg per jobb. Escrow släpps vid godkänt.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ErrorState onRetry={load} />
|
||||||
|
</div>
|
||||||
|
) : !orders ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
</div>
|
||||||
|
) : running.length === 0 ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<EmptyState
|
||||||
|
title={t({ en: "No jobs in production", sv: "Inga jobb i produktion" })}
|
||||||
|
line={t({
|
||||||
|
en: "When an order is paid the pipeline starts here — source racing through quality control in real time.",
|
||||||
|
sv: "När en order betalats startar pipelinen här — källracing till kvalitetskontroll i realtid.",
|
||||||
|
})}
|
||||||
|
ctaHref="/admin"
|
||||||
|
ctaLabel={t({ en: "To overview", sv: "Till översikt" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex items-center justify-between border border-b-0 border-border bg-secondary/50 px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{running.length} {t({ en: "active jobs", sv: "aktiva jobb" })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
|
{t({ en: "Refresh", sv: "Uppdatera" })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border">
|
||||||
|
{running.map((o) => (
|
||||||
|
<PipelineRow key={o.order_id} order={o} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Admin-yta: anteckningar på order — @siax/collaboration-kernelns första
|
||||||
|
// produktkonsument (spår C). Allt nätverk går via lib/order-notes-client.ts
|
||||||
|
// (kernel-transport över host-API): offline-läge = medvetet no-op med varning
|
||||||
|
// (utkastet bevaras, ingen tyst förlust), stale skrivning = 409 -> banner +
|
||||||
|
// reload av serverns aktuella version.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { PortalShell } from "@/components/portal-shell";
|
||||||
|
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/states";
|
||||||
|
import {
|
||||||
|
ConflictError,
|
||||||
|
OfflineError,
|
||||||
|
OrderNotesClient,
|
||||||
|
type OrderNoteDto,
|
||||||
|
} from "@/lib/order-notes-client";
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
order_id: string;
|
||||||
|
flow: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderNotesPage() {
|
||||||
|
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||||
|
const [orderId, setOrderId] = useState<string>("");
|
||||||
|
const [notes, setNotes] = useState<OrderNoteDto[] | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [offlineMode, setOfflineMode] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [editing, setEditing] = useState<{ note: OrderNoteDto; body: string } | null>(null);
|
||||||
|
|
||||||
|
const client = useMemo(() => new OrderNotesClient("admin-simon"), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.transport.setOffline(offlineMode);
|
||||||
|
}, [client, offlineMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/orders", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error("orders fetch failed");
|
||||||
|
const body = await res.json();
|
||||||
|
setOrders(Array.isArray(body) ? body : []);
|
||||||
|
} catch {
|
||||||
|
setLoadError(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadNotes = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
setNotes(null);
|
||||||
|
setLoadError(false);
|
||||||
|
try {
|
||||||
|
const { notes: loaded } = await client.load(id);
|
||||||
|
setNotes(loaded);
|
||||||
|
} catch {
|
||||||
|
setLoadError(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNote = useCallback(async () => {
|
||||||
|
if (!orderId || !draft.trim()) return;
|
||||||
|
if (offlineMode) {
|
||||||
|
// Offline = klient-varning + no-op. Utkastet ligger kvar i rutan —
|
||||||
|
// INGET skickas, inget försvinner tyst.
|
||||||
|
toast.warning("Offline-läge: anteckningen SKICKAS EJ. Utkastet är bevarat i rutan.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const note = await client.add(orderId, draft);
|
||||||
|
setDraft("");
|
||||||
|
setNotes((prev) => [...(prev ?? []), note]);
|
||||||
|
toast.success("Anteckning sparad");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof OfflineError) {
|
||||||
|
toast.warning("Offline: inget skickades — utkastet är bevarat.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadError(true);
|
||||||
|
}
|
||||||
|
}, [client, draft, offlineMode, orderId]);
|
||||||
|
|
||||||
|
const saveEdit = useCallback(async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
if (offlineMode) {
|
||||||
|
toast.warning("Offline-läge: ändringen SKICKAS EJ. Texten är bevarad i redigeringsrutan.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await client.update(editing.note, editing.body, editing.note.version);
|
||||||
|
setNotes((prev) => (prev ?? []).map((n) => (n.note_id === updated.note_id ? updated : n)));
|
||||||
|
setEditing(null);
|
||||||
|
toast.success("Anteckning uppdaterad");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ConflictError) {
|
||||||
|
// Någon annan skrev samtidigt (stale version -> 409). Ladda om
|
||||||
|
// serverns aktuella version — förlora ALDRIG den lokala texten tyst.
|
||||||
|
toast.error(`Konflikt: anteckningen ändrades av någon annan (serverversion ${err.currentVersion}). Laddar om — din text finns kvar nedan.`);
|
||||||
|
setDraft((prev) => (prev ? prev : editing.body));
|
||||||
|
await loadNotes(orderId);
|
||||||
|
setEditing(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err instanceof OfflineError) {
|
||||||
|
toast.warning("Offline: inget skickades — ändringen är bevarad.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadError(true);
|
||||||
|
}
|
||||||
|
}, [client, editing, loadNotes, offlineMode, orderId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="admin" title="Orderanteckningar">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={offlineMode}
|
||||||
|
onChange={(e) => setOfflineMode(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Offline-läge (blockera utskick — varning + no-op, utkast bevaras)
|
||||||
|
</label>
|
||||||
|
{offlineMode && (
|
||||||
|
<div role="alert" className="rounded border border-amber-400 bg-amber-50 p-2 text-sm">
|
||||||
|
Offline-läge aktivt: inga anteckningar skickas till servern. Utkast bevaras lokalt —
|
||||||
|
ingen tyst förlust.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<ErrorState onRetry={() => orderId && loadNotes(orderId)} />
|
||||||
|
) : orders === null ? (
|
||||||
|
<LoadingSkeleton />
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<EmptyState title="Inga ordrar" line="Inga ordrar att annotera ännu." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
Order:{" "}
|
||||||
|
<select
|
||||||
|
aria-label="Välj order"
|
||||||
|
value={orderId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOrderId(e.target.value);
|
||||||
|
void loadNotes(e.target.value);
|
||||||
|
}}
|
||||||
|
className="rounded border px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="">— välj —</option>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<option key={o.order_id} value={o.order_id}>
|
||||||
|
{o.order_id} ({o.flow}, {o.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{orderId === "" ? (
|
||||||
|
<EmptyState title="Välj order" line="Välj en order för att se och skriva anteckningar." />
|
||||||
|
) : notes === null ? (
|
||||||
|
<LoadingSkeleton />
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<EmptyState title="Tomt" line="Inga anteckningar på den här ordern ännu." />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.note_id} className="rounded border p-3 text-sm">
|
||||||
|
<div className="mb-1 flex justify-between text-xs opacity-70">
|
||||||
|
<span>
|
||||||
|
{n.author_id} · v{n.version} · klocka {n.clock}
|
||||||
|
</span>
|
||||||
|
<span>{new Date(n.updated_at).toLocaleString("sv-SE")}</span>
|
||||||
|
</div>
|
||||||
|
{editing?.note.note_id === n.note_id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
aria-label="Redigera anteckning"
|
||||||
|
value={editing.body}
|
||||||
|
onChange={(e) => setEditing({ ...editing, body: e.target.value })}
|
||||||
|
className="w-full rounded border p-2"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="rounded bg-black px-3 py-1 text-white" onClick={() => void saveEdit()}>
|
||||||
|
Spara (v{editing.note.version} → v{editing.note.version + 1})
|
||||||
|
</button>
|
||||||
|
<button className="rounded border px-3 py-1" onClick={() => setEditing(null)}>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="whitespace-pre-wrap">{n.body}</p>
|
||||||
|
<button
|
||||||
|
className="mt-2 text-xs underline"
|
||||||
|
onClick={() => setEditing({ note: n, body: n.body })}
|
||||||
|
>
|
||||||
|
Redigera
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderId !== "" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
aria-label="Ny anteckning"
|
||||||
|
placeholder="Skriv en anteckning på ordern…"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="w-full rounded border p-2"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rounded bg-black px-4 py-2 text-sm text-white disabled:opacity-40"
|
||||||
|
disabled={!draft.trim() || notes === null}
|
||||||
|
onClick={() => void addNote()}
|
||||||
|
>
|
||||||
|
Lägg till anteckning
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ActionGate, InboxList } from "@siax/ui";
|
||||||
|
import { nextOrderStatus } from "@/lib/order-flow";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useT } from "@/components/locale-provider";
|
||||||
|
import { PortalShell, StatusPill } from "@/components/portal-shell";
|
||||||
|
import { LoadingSkeleton, ErrorState, EmptyState } from "@/components/states";
|
||||||
|
import { formatSEK } from "@/lib/data";
|
||||||
|
import { mergeOrders } from "@/lib/order-client";
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
order_id: string;
|
||||||
|
flow: string;
|
||||||
|
status: string;
|
||||||
|
total_ore: number;
|
||||||
|
deposit_ore: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
interface Metrics {
|
||||||
|
total: number;
|
||||||
|
in_production: number;
|
||||||
|
review: number;
|
||||||
|
delivered: number;
|
||||||
|
deposit_paid_ore: number;
|
||||||
|
pipeline_ore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const t = useT();
|
||||||
|
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<Metrics | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const [oRes, mRes] = await Promise.all([
|
||||||
|
fetch("/api/orders", { cache: "no-store" }),
|
||||||
|
fetch("/api/admin/metrics", { cache: "no-store" }),
|
||||||
|
]);
|
||||||
|
if (!oRes.ok || !mRes.ok) throw new Error("fetch failed");
|
||||||
|
const [o, m] = await Promise.all([oRes.json(), mRes.json()]);
|
||||||
|
if (!m || typeof m.total !== "number") throw new Error("bad metrics shape");
|
||||||
|
setOrders(mergeOrders(Array.isArray(o) ? o : []) as Order[]);
|
||||||
|
setMetrics(m);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
async function advance(id: string, status: string) {
|
||||||
|
const next = nextOrderStatus(status);
|
||||||
|
if (!next) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/orders/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }) });
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(`${id.slice(0, 8)}… → ${next}`);
|
||||||
|
void load();
|
||||||
|
} else {
|
||||||
|
toast.error(t({ en: "Could not advance order.", sv: "Kunde inte flytta fram ordern." }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t({ en: "Network error.", sv: "Nätverksfel." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="admin" title={t({ en: "Order queue & operations", sv: "Orderkö & drift" })}>
|
||||||
|
{error ? (
|
||||||
|
<ErrorState onRetry={load} />
|
||||||
|
) : !metrics || !orders ? (
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-px border border-border bg-border md:grid-cols-5">
|
||||||
|
{[
|
||||||
|
[t({ en: "Orders", sv: "Ordrar" }), String(metrics.total)],
|
||||||
|
[t({ en: "In production", sv: "I produktion" }), String(metrics.in_production)],
|
||||||
|
[t({ en: "In review", sv: "Granskas" }), String(metrics.review)],
|
||||||
|
[t({ en: "Delivered", sv: "Levererade" }), String(metrics.delivered)],
|
||||||
|
[t({ en: "Deposits in", sv: "Förskott in" }), formatSEK((metrics.deposit_paid_ore || 0) / 100)],
|
||||||
|
].map(([l, v]) => (
|
||||||
|
<div key={l} className="bg-background p-5">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{l}</p>
|
||||||
|
<p className="mt-2 text-2xl font-medium tabular-nums tracking-[-0.02em]">{v}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orders.some((o) => o.inbox_item) ? (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Inbox", sv: "Inkorg" })}</h2>
|
||||||
|
<div className="mt-4">
|
||||||
|
<InboxList
|
||||||
|
items={orders.map((o) => o.inbox_item).filter(Boolean)}
|
||||||
|
labels={{
|
||||||
|
resolve: t({ en: "Advance", sv: "Flytta fram" }),
|
||||||
|
emptyTitle: t({ en: "Inbox is empty", sv: "Inkorgen är tom" }),
|
||||||
|
}}
|
||||||
|
onResolve={(item) => {
|
||||||
|
const order = orders.find((o) => `order:${o.order_id}` === item.id);
|
||||||
|
if (order?.advance_action?.state === "available" && nextOrderStatus(order.status)) {
|
||||||
|
advance(order.order_id, order.status);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="mt-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Order queue", sv: "Orderkö" })}</h2>
|
||||||
|
{/* DoD §65: real anchor, not a JS-handler button — the download is a plain GET of the CSV endpoint. */}
|
||||||
|
<a href="/api/orders/export" download className="border border-border px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:bg-foreground hover:text-background">
|
||||||
|
{t({ en: "Export CSV", sv: "Exportera CSV" })}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="mt-6"><EmptyState title={t({ en: "Empty queue.", sv: "Tom kö." })} line={t({ en: "No orders yet.", sv: "Inga ordrar än." })} /></div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 border border-border">
|
||||||
|
<div className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
<span>{t({ en: "ID", sv: "ID" })}</span><span>{t({ en: "Flow", sv: "Flöde" })}</span><span>{t({ en: "Status", sv: "Status" })}</span><span className="text-right">{t({ en: "Total", sv: "Summa" })}</span><span className="text-right">{t({ en: "Action", sv: "Åtgärd" })}</span>
|
||||||
|
</div>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<div key={o.order_id} className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] items-center border-b border-border px-4 py-3 last:border-b-0">
|
||||||
|
<span className="font-mono text-[12px] tabular-nums">{o.order_id.slice(0, 8)}…</span>
|
||||||
|
<span className="text-sm">{o.flow}</span>
|
||||||
|
<StatusPill status={o.status} />
|
||||||
|
<span className="text-right font-mono text-[12px] tabular-nums">{formatSEK(o.total_ore / 100)}</span>
|
||||||
|
<span className="text-right">
|
||||||
|
<ActionGate action={o.advance_action} className="inline-block text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => advance(o.order_id, o.status)}
|
||||||
|
className="border border-foreground px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors hover:bg-foreground hover:text-background"
|
||||||
|
>
|
||||||
|
→ {nextOrderStatus(o.status) ?? t({ en: "done", sv: "klar" })}
|
||||||
|
</button>
|
||||||
|
</ActionGate>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useT } from "@/components/locale-provider";
|
||||||
|
import { PortalShell } from "@/components/portal-shell";
|
||||||
|
import { LoadingSkeleton, ErrorState } from "@/components/states";
|
||||||
|
|
||||||
|
interface Source {
|
||||||
|
source_id: string;
|
||||||
|
name: string;
|
||||||
|
market: string;
|
||||||
|
access: string;
|
||||||
|
licence: string;
|
||||||
|
cadence: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_COLOR: Record<string, string> = {
|
||||||
|
api: "var(--prov-3)", bulk: "var(--prov-2)", authorized_web: "var(--prov-1)", licensed: "var(--prov-0)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminSourcesPage() {
|
||||||
|
const t = useT();
|
||||||
|
const [sources, setSources] = useState<Source[] | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/sources", { cache: "no-store" });
|
||||||
|
setSources(await r.json());
|
||||||
|
} catch { setError(true); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell variant="admin" title={t({ en: "Source registry & freshness", sv: "Källregister & färskhet" })}>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{t({ en: "Legal sourcing. API > bulk > authorized web > licence. Verify terms.", sv: "Laglig sourcing. API > bulk > auktoriserad webb > licens. Verifiera villkor." })}
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-6"><ErrorState onRetry={load} /></div>
|
||||||
|
) : !sources ? (
|
||||||
|
<div className="mt-6"><LoadingSkeleton rows={8} /></div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 border border-border">
|
||||||
|
<div className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
<span>{t({ en: "Source", sv: "Källa" })}</span><span>{t({ en: "Market", sv: "Marknad" })}</span><span>{t({ en: "Access", sv: "Åtkomst" })}</span><span>{t({ en: "Licence", sv: "Licens" })}</span><span>{t({ en: "Freshness", sv: "Färskhet" })}</span>
|
||||||
|
</div>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<div key={s.source_id} className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">
|
||||||
|
<span className="font-medium">{s.name}</span>
|
||||||
|
<span className="font-mono text-xs">{s.market}</span>
|
||||||
|
<span className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.06em]">
|
||||||
|
<span className="inline-block h-2 w-2" style={{ backgroundColor: ACCESS_COLOR[s.access] ?? "var(--muted-foreground)" }} />
|
||||||
|
{s.access}
|
||||||
|
</span>
|
||||||
|
<span className="truncate pr-3 text-xs text-muted-foreground">{s.licence}</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{s.cadence}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PortalShell } from "@/components/portal-shell";
|
||||||
|
import { useT } from "@/components/locale-provider";
|
||||||
|
|
||||||
|
export default function ToolsPage() {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<PortalShell variant="admin" title={t({ en: "Tools", sv: "Verktyg" })}>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{t({ en: "Quick lookups against legal sources. Provenance on every hit.", sv: "Snabboppslag mot lagliga källor. Härkomst på varje träff." })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<VehicleTool />
|
||||||
|
<ScreenTool />
|
||||||
|
</div>
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VehicleTool() {
|
||||||
|
const t = useT();
|
||||||
|
const [plate, setPlate] = useState("");
|
||||||
|
const [res, setRes] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function lookup() {
|
||||||
|
if (!plate.trim()) return;
|
||||||
|
setBusy(true); setRes(null);
|
||||||
|
try { setRes(await (await fetch(`/api/vehicles?plate=${encodeURIComponent(plate)}`)).json()); } catch { setRes({ error: true }); }
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
const v = (res?.vehicle ?? null) as { make?: string; model?: string; year?: string; reg_plate?: string } | null;
|
||||||
|
return (
|
||||||
|
<section className="border border-border p-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<h2 className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">{t({ en: "Vehicle — RDW (NL)", sv: "Fordon — RDW (NL)" })}</h2>
|
||||||
|
<span className="inline-block h-2 w-2" style={{ backgroundColor: "var(--prov-3)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<input value={plate} onChange={(e) => setPlate(e.target.value)} placeholder={t({ en: "Registration no. (NL)", sv: "Registreringsnr (NL)" })} className="flex-1 border border-border bg-input px-3 py-2 font-mono text-xs uppercase outline-none focus:border-foreground" />
|
||||||
|
<button onClick={lookup} disabled={busy} className="bg-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">{busy ? "…" : t({ en: "Look up", sv: "Slå upp" })}</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 min-h-[60px] font-mono text-xs">
|
||||||
|
{res === null ? <span className="text-muted-foreground">—</span>
|
||||||
|
: v ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>{v.make} {v.model}</div>
|
||||||
|
<div className="text-muted-foreground">{v.reg_plate} · {v.year ?? "—"} · {t({ en: "source RDW", sv: "källa RDW" })}</div>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-muted-foreground">{t({ en: "No match (not available).", sv: "Ingen träff (ej tillgänglig)." })}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScreenTool() {
|
||||||
|
const t = useT();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [matches, setMatches] = useState<{ name: string; score: number; schema: string }[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function screen() {
|
||||||
|
if (!q.trim()) return;
|
||||||
|
setBusy(true); setMatches(null);
|
||||||
|
try { const d = await (await fetch(`/api/screen?q=${encodeURIComponent(q)}`)).json(); setMatches(d.matches ?? []); } catch { setMatches([]); }
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="border border-border p-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<h2 className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">{t({ en: "Sanctions / PEP — OpenSanctions", sv: "Sanktioner / PEP — OpenSanctions" })}</h2>
|
||||||
|
<span className="inline-block h-2 w-2" style={{ backgroundColor: "var(--prov-2)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t({ en: "Name or company", sv: "Namn eller bolag" })} className="flex-1 border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
|
||||||
|
<button onClick={screen} disabled={busy} className="bg-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">{busy ? "…" : t({ en: "Screen", sv: "Skärma" })}</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 min-h-[60px] font-mono text-xs">
|
||||||
|
{matches === null ? <span className="text-muted-foreground">—</span>
|
||||||
|
: matches.length === 0 ? <span style={{ color: "var(--prov-3)" }}>{t({ en: "No matches.", sv: "Inga träffar." })}</span>
|
||||||
|
: (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{matches.map((m, i) => (
|
||||||
|
<li key={i} className="flex justify-between gap-3">
|
||||||
|
<span>{m.name}</span>
|
||||||
|
<span className="text-muted-foreground">{m.schema}{m.score ? ` · ${Math.round(m.score * 100)}%` : ""}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-[10.5px] text-muted-foreground">{t({ en: "Requires verified OpenSanctions terms/key for full matches.", sv: "Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar." })}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/canonical/facts/route.ts
|
||||||
|
* ====================================
|
||||||
|
* POST — canonical structured-observation write (entities/facts/relations).
|
||||||
|
* GET — tenant-scoped read for consumers (products, agents, L0RE projection).
|
||||||
|
*
|
||||||
|
* 0pt owns observed facts; L0RE owns epistemic propositions/projections and
|
||||||
|
* consumes this API. Tenant is always cross-checked against the verified
|
||||||
|
* principal (assertTenantMatchesVerified), never trusted from the body alone.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { canonicalWriteSchema, parseBody } from "@/src/validation/0pt-schemas";
|
||||||
|
import { canonicalFactService } from "@/src/services/canonical-fact-registry";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const parsed = parseBody(canonicalWriteSchema, await request.json());
|
||||||
|
if (!parsed.ok) return NextResponse.json(parsed.body, { status: parsed.status });
|
||||||
|
const body = parsed.data;
|
||||||
|
const guard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (guard) return guard;
|
||||||
|
const result = await canonicalFactService.write(body);
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
const limitRaw = searchParams.get("limit");
|
||||||
|
const facts = await canonicalFactService.query({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
entity_id: searchParams.get("entity_id") ?? undefined,
|
||||||
|
entity_type: searchParams.get("entity_type") ?? undefined,
|
||||||
|
identity_namespace: searchParams.get("identity_namespace") ?? undefined,
|
||||||
|
identity_value: searchParams.get("identity_value") ?? undefined,
|
||||||
|
predicate: searchParams.get("predicate") ?? undefined,
|
||||||
|
limit: limitRaw ? Number(limitRaw) : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ facts });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||||
|
import { ensureTrialCredits, readTrialCredits } from '@/src/document-workbench/trial-credits';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** GET /api/0pt/credits/balance — the authenticated tenant's credit balance. */
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
|
||||||
|
const url = repositoryDatabaseUrl();
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_NOT_CONFIGURED',
|
||||||
|
message: 'Credits are not available until the database is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ledger = new PostgresCreditLedger(getRepositorySql(url));
|
||||||
|
const trial = readTrialCredits();
|
||||||
|
if (trial > 0) await ensureTrialCredits(ledger, verified.scope.tenantId, trial);
|
||||||
|
const balance = await ledger.balance(verified.scope.tenantId);
|
||||||
|
return NextResponse.json({ tenant_id: verified.scope.tenantId, ...balance });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit balance unavailable';
|
||||||
|
const notConfigured = /RLS-enforced|isolation migration/i.test(message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: notConfigured ? 'CREDITS_NOT_CONFIGURED' : 'CREDITS_UNAVAILABLE', message },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { getStripe } from '@/lib/stripe';
|
||||||
|
import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store';
|
||||||
|
import { findPack, readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function appBaseUrl(request: NextRequest): string {
|
||||||
|
const configured = process.env.NEXT_PUBLIC_APP_URL;
|
||||||
|
if (configured && /^https:\/\//.test(configured)) return configured.replace(/\/+$/, '');
|
||||||
|
return request.nextUrl.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/0pt/credits/checkout { pack_id }
|
||||||
|
*
|
||||||
|
* Creates a Stripe Checkout Session and stores the server-owned price snapshot
|
||||||
|
* BEFORE any credit exists. Credits are granted only when the Stripe webhook
|
||||||
|
* re-reads the session and its payment intent and the ledger accepts the
|
||||||
|
* idempotent verified-payment command.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
const { tenantId } = verified.scope;
|
||||||
|
|
||||||
|
if (process.env.OPT_CREDITS_MODE === 'off') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_DISABLED',
|
||||||
|
message: 'Billing is not active yet (pilot mode). No payment can be taken.',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const book = readCreditPriceBook();
|
||||||
|
if (!book) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'PRICE_BOOK_NOT_CONFIGURED', message: 'Credit purchases are not available yet' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const stripe = getStripe();
|
||||||
|
if (!stripe) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'STRIPE_NOT_CONFIGURED', message: 'Payments are not available yet' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const databaseUrl = repositoryDatabaseUrl();
|
||||||
|
if (!databaseUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_NOT_CONFIGURED',
|
||||||
|
message: 'Credits are not available until the database is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const packId = (body as { pack_id?: unknown } | null)?.pack_id;
|
||||||
|
if (typeof packId !== 'string' || !isValidScopeId(packId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_PACK', message: 'A valid pack_id is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pack = findPack(book, packId);
|
||||||
|
if (!pack) {
|
||||||
|
return NextResponse.json({ error: 'UNKNOWN_PACK', message: 'Unknown credit pack' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseId = crypto.randomUUID();
|
||||||
|
const base = appBaseUrl(request);
|
||||||
|
const metadata = {
|
||||||
|
kind: '0pt_credit_purchase',
|
||||||
|
tenant_id: tenantId,
|
||||||
|
purchase_id: purchaseId,
|
||||||
|
price_book_version: book.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await stripe.checkout.sessions.create({
|
||||||
|
mode: 'payment',
|
||||||
|
client_reference_id: tenantId,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price_data: {
|
||||||
|
currency: book.currency.toLowerCase(),
|
||||||
|
unit_amount: pack.amountMinor,
|
||||||
|
product_data: { name: `${pack.credits} 0pt credits` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata,
|
||||||
|
payment_intent_data: { metadata },
|
||||||
|
success_url: `${base}/portal/krediter?status=success&purchase=${encodeURIComponent(purchaseId)}`,
|
||||||
|
cancel_url: `${base}/portal/krediter?status=cancelled`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider could not start a checkout' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new PostgresCreditPurchaseStore(getRepositorySql(databaseUrl)).create({
|
||||||
|
tenantId,
|
||||||
|
purchaseId,
|
||||||
|
checkoutSessionId: session.id,
|
||||||
|
amountMinor: pack.amountMinor,
|
||||||
|
currency: book.currency,
|
||||||
|
credits: pack.credits,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Never leave a payable session with no server-side snapshot behind it.
|
||||||
|
try {
|
||||||
|
await stripe.checkout.sessions.expire(session.id);
|
||||||
|
} catch {
|
||||||
|
/* best effort; the webhook will refuse the unmatched session */
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit purchase could not be stored';
|
||||||
|
return NextResponse.json({ error: 'CREDITS_NOT_CONFIGURED', message }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider returned no checkout URL' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
purchase_id: purchaseId,
|
||||||
|
checkout_url: session.url,
|
||||||
|
credits: pack.credits,
|
||||||
|
amount_minor: pack.amountMinor,
|
||||||
|
currency: book.currency,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/0pt/credits/packs — sellable credit packs from the server-owned
|
||||||
|
* price book. No price is ever accepted from the client.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
|
||||||
|
const book = readCreditPriceBook();
|
||||||
|
if (!book) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'PRICE_BOOK_NOT_CONFIGURED',
|
||||||
|
message: 'Credit packs are not available until the server price book is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
version: book.version,
|
||||||
|
currency: book.currency,
|
||||||
|
purchases_enabled: process.env.OPT_CREDITS_MODE !== 'off',
|
||||||
|
packs: book.packs.map((pack) => ({
|
||||||
|
id: pack.id,
|
||||||
|
credits: pack.credits,
|
||||||
|
amount_minor: pack.amountMinor,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
import { datasetService } from "@/src/services/datasets";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ datasetId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { datasetId } = await params;
|
||||||
|
const tenantId = new URL(request.url).searchParams.get("tenant_id")?.trim();
|
||||||
|
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const dataset = await datasetService.getDataset(datasetId);
|
||||||
|
if (dataset.tenant_id !== tenantId) {
|
||||||
|
throw new SiaxError("NOT_FOUND", "0pt.dataset", `dataset ${datasetId} not found`);
|
||||||
|
}
|
||||||
|
return NextResponse.json(dataset);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
import { datasetVersionService } from "@/src/services/datasets";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ datasetId: string; version: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { datasetId, version: rawVersion } = await params;
|
||||||
|
const tenantId = new URL(request.url).searchParams.get("tenant_id")?.trim();
|
||||||
|
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const version = Number(rawVersion);
|
||||||
|
if (!Number.isSafeInteger(version) || version < 1) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "version must be a positive integer");
|
||||||
|
}
|
||||||
|
return NextResponse.json(await datasetVersionService.getVersion(datasetId, tenantId, version));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
import { datasetVersionService } from "@/src/services/datasets";
|
||||||
|
|
||||||
|
function required(body: Record<string, unknown>, field: string): string {
|
||||||
|
const value = body[field];
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", `${field} is required`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ datasetId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { datasetId } = await params;
|
||||||
|
const raw = await request.json();
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "JSON object body is required");
|
||||||
|
}
|
||||||
|
const body = raw as Record<string, unknown>;
|
||||||
|
const tenantId = required(body, "tenant_id");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
if (!Array.isArray(body.artifact_refs) || !body.artifact_refs.every((value) => typeof value === "string")) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "artifact_refs must be a string array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordCount = body.record_count === undefined ? undefined : Number(body.record_count);
|
||||||
|
const idempotencyKey = request.headers.get("idempotency-key")?.trim()
|
||||||
|
|| (typeof body.idempotency_key === "string" ? body.idempotency_key.trim() : "")
|
||||||
|
|| undefined;
|
||||||
|
|
||||||
|
const version = await datasetVersionService.createVersion({
|
||||||
|
dataset_id: datasetId,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
project_id: required(body, "project_id"),
|
||||||
|
artifact_refs: body.artifact_refs,
|
||||||
|
schema_version: required(body, "schema_version"),
|
||||||
|
...(recordCount === undefined ? {} : { record_count: recordCount }),
|
||||||
|
...(typeof body.description === "string" && body.description.trim()
|
||||||
|
? { description: body.description.trim() }
|
||||||
|
: {}),
|
||||||
|
created_by: request.headers.get("x-0pt-verified-sub") ?? "local-dev",
|
||||||
|
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
|
||||||
|
? { correlation_id: body.correlation_id.trim() }
|
||||||
|
: {}),
|
||||||
|
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
||||||
|
});
|
||||||
|
return NextResponse.json(version, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ datasetId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { datasetId } = await params;
|
||||||
|
const tenantId = new URL(request.url).searchParams.get("tenant_id")?.trim();
|
||||||
|
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
return NextResponse.json(await datasetVersionService.listVersions(datasetId, tenantId));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
import { datasetService } from "@/src/services/datasets";
|
||||||
|
import type { DatasetStatus, DatasetType } from "@/src/types/dataset";
|
||||||
|
|
||||||
|
const DATASET_TYPES = new Set<DatasetType>(["raw", "structured", "enriched", "curated", "feature", "derived"]);
|
||||||
|
const DATASET_STATUSES = new Set<DatasetStatus>(["active", "archived", "deprecated"]);
|
||||||
|
|
||||||
|
function actorId(request: NextRequest): string {
|
||||||
|
return request.headers.get("x-0pt-verified-sub") ?? "local-dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(body: Record<string, unknown>, field: string): string {
|
||||||
|
const value = body[field];
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", `${field} is required`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const raw = await request.json();
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "JSON object body is required");
|
||||||
|
}
|
||||||
|
const body = raw as Record<string, unknown>;
|
||||||
|
const tenantId = stringField(body, "tenant_id");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const datasetType = stringField(body, "dataset_type") as DatasetType;
|
||||||
|
if (!DATASET_TYPES.has(datasetType)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported dataset_type");
|
||||||
|
}
|
||||||
|
const status = body.status === undefined ? undefined : String(body.status) as DatasetStatus;
|
||||||
|
if (status !== undefined && !DATASET_STATUSES.has(status)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported status");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataset = await datasetService.registerDataset({
|
||||||
|
dataset_id: stringField(body, "dataset_id"),
|
||||||
|
tenant_id: tenantId,
|
||||||
|
project_id: stringField(body, "project_id"),
|
||||||
|
name: stringField(body, "name"),
|
||||||
|
dataset_type: datasetType,
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(typeof body.description === "string" && body.description.trim()
|
||||||
|
? { description: body.description.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
|
||||||
|
? { correlation_id: body.correlation_id.trim() }
|
||||||
|
: {}),
|
||||||
|
created_by: actorId(request),
|
||||||
|
});
|
||||||
|
return NextResponse.json(dataset, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id")?.trim();
|
||||||
|
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const typeRaw = searchParams.get("dataset_type") ?? undefined;
|
||||||
|
const statusRaw = searchParams.get("status") ?? undefined;
|
||||||
|
if (typeRaw && !DATASET_TYPES.has(typeRaw as DatasetType)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported dataset_type");
|
||||||
|
}
|
||||||
|
if (statusRaw && !DATASET_STATUSES.has(statusRaw as DatasetStatus)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported status");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await datasetService.listDatasets({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
project_id: searchParams.get("project_id") ?? undefined,
|
||||||
|
dataset_type: typeRaw as DatasetType | undefined,
|
||||||
|
status: statusRaw as DatasetStatus | undefined,
|
||||||
|
}));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, documentExtractionSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { documentExtractionService } from "@/src/services/document-extraction-service";
|
||||||
|
import { assertTenantMatchesVerified, resolveDocumentActor } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ documentId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { documentId } = await params;
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(documentExtractionSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const body = __parsed.data;
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const actor = resolveDocumentActor(request, body.actor_id);
|
||||||
|
if (actor.response) return actor.response;
|
||||||
|
if (!actor.actorId || !body.model_id) {
|
||||||
|
return NextResponse.json({ error: "actor and model_id are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const result = await documentExtractionService.extractDocument(documentId, {
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
project_id: body.project_id,
|
||||||
|
actor_id: actor.actorId,
|
||||||
|
correlation_id: body.correlation_id ?? crypto.randomUUID(),
|
||||||
|
model_id: body.model_id,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (/not configured/i.test(message)) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 501 });
|
||||||
|
}
|
||||||
|
if (/^Policy denied/i.test(message)) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (/^Policy requires approval/i.test(message)) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 409 });
|
||||||
|
}
|
||||||
|
const status = /not found/i.test(message) ? 404 : 400;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ documentId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { documentId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const extractions = await documentExtractionService.listExtractionsForDocument(documentId, tenantId);
|
||||||
|
return NextResponse.json(extractions);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, documentCaptureSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { documentCaptureService } from "@/src/services/document-capture-service";
|
||||||
|
import { assertTenantMatchesVerified, resolveDocumentActor } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(documentCaptureSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const body = __parsed.data;
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const actor = resolveDocumentActor(request, body.actor_id);
|
||||||
|
if (actor.response) return actor.response;
|
||||||
|
const result = await documentCaptureService.captureFromMailbox(
|
||||||
|
{
|
||||||
|
mailbox: {
|
||||||
|
tenantId: body.mailbox?.tenant_id,
|
||||||
|
clientId: body.mailbox?.client_id,
|
||||||
|
clientSecret: body.mailbox?.client_secret,
|
||||||
|
mailboxAddress: body.mailbox?.mailbox_address,
|
||||||
|
folder: body.mailbox?.folder,
|
||||||
|
},
|
||||||
|
since_iso: body.since_iso,
|
||||||
|
top: body.top,
|
||||||
|
max_pages: body.max_pages,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
project_id: body.project_id,
|
||||||
|
// In enforced mode middleware supplies the verified subject; the
|
||||||
|
// body can only agree with it. Development retains the old fallback.
|
||||||
|
actor_id: actor.actorId ?? "",
|
||||||
|
correlation_id: body.correlation_id ?? "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P014: `tenant_id` used to be optional here, and
|
||||||
|
// DocumentCaptureService.listDocuments treats an absent tenant_id filter as
|
||||||
|
// "no restriction" — so calling this with no query params at all returned
|
||||||
|
// EVERY tenant's captured documents to any authenticated caller. Every
|
||||||
|
// sibling list/get route in this file already requires tenant_id; this was
|
||||||
|
// the one outlier. Now required, same as the rest of the surface.
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const projectId = searchParams.get("project_id") ?? undefined;
|
||||||
|
const documents = await documentCaptureService.listDocuments({ tenant_id: tenantId, project_id: projectId });
|
||||||
|
return NextResponse.json(documents);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function csv(value: string | undefined): string[] {
|
||||||
|
return (value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/0pt/documents/models — the server-approved model routes and limits
|
||||||
|
* the customer may choose from. Never a free-form model list.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
|
||||||
|
const models = csv(process.env.OPT_ALLOWED_MODELS);
|
||||||
|
const dataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES);
|
||||||
|
const destination = process.env.OPT_MODEL_DESTINATION;
|
||||||
|
const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0');
|
||||||
|
const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0');
|
||||||
|
if (
|
||||||
|
models.length === 0 ||
|
||||||
|
dataClasses.length === 0 ||
|
||||||
|
(destination !== 'internal' && destination !== 'external') ||
|
||||||
|
!Number.isSafeInteger(maxInputTokens) ||
|
||||||
|
maxInputTokens <= 0 ||
|
||||||
|
!Number.isSafeInteger(maxOutputTokens) ||
|
||||||
|
maxOutputTokens <= 0
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'MODEL_POLICY_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until the model policy is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const book = readCreditPriceBook();
|
||||||
|
return NextResponse.json({
|
||||||
|
models,
|
||||||
|
data_classes: dataClasses,
|
||||||
|
destination,
|
||||||
|
external_transfer_allowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true',
|
||||||
|
max_input_tokens: maxInputTokens,
|
||||||
|
max_output_tokens: maxOutputTokens,
|
||||||
|
price_book_version: book?.version ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-workbench/doc0-source-client';
|
||||||
|
import { Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client';
|
||||||
|
import { createInf0Gateway } from '@/src/document-workbench/inf0-gateway';
|
||||||
|
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||||
|
import { ensureTrialCredits, readTrialCredits } from '@/src/document-workbench/trial-credits';
|
||||||
|
import { creditsForUsage, readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||||
|
import { structureDocument } from '@/src/document-workbench/structure-document';
|
||||||
|
import { exportValidatedOutput } from '@/src/document-workbench/export-validated-output';
|
||||||
|
import type {
|
||||||
|
OutputFieldContract,
|
||||||
|
OutputFieldType,
|
||||||
|
} from '@/src/document-workbench/structured-output-validation';
|
||||||
|
import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config';
|
||||||
|
import { createProjectAuthorizer } from '@/src/document-workbench/project-access';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const FIELD_TYPES: readonly OutputFieldType[] = ['string', 'integer', 'boolean', 'decimal', 'date'];
|
||||||
|
const DATA_CLASSES = ['none', 'low', 'medium', 'high', 'restricted'] as const;
|
||||||
|
|
||||||
|
function csv(value: string | undefined): string[] {
|
||||||
|
return (value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelPolicy() {
|
||||||
|
const allowedModelIds = csv(process.env.OPT_ALLOWED_MODELS);
|
||||||
|
const allowedDataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES);
|
||||||
|
const destination = process.env.OPT_MODEL_DESTINATION;
|
||||||
|
const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0');
|
||||||
|
const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0');
|
||||||
|
if (
|
||||||
|
allowedModelIds.length === 0 ||
|
||||||
|
allowedDataClasses.length === 0 ||
|
||||||
|
(destination !== 'internal' && destination !== 'external') ||
|
||||||
|
!Number.isSafeInteger(maxInputTokens) ||
|
||||||
|
maxInputTokens <= 0 ||
|
||||||
|
!Number.isSafeInteger(maxOutputTokens) ||
|
||||||
|
maxOutputTokens <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowedModelIds,
|
||||||
|
allowedDataClasses,
|
||||||
|
destination: destination as 'internal' | 'external',
|
||||||
|
externalTransferAllowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true',
|
||||||
|
maxInputTokens,
|
||||||
|
maxOutputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFields(raw: unknown): OutputFieldContract[] | undefined {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 100) return undefined;
|
||||||
|
const fields: OutputFieldContract[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
|
||||||
|
const field = item as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof field.name !== 'string' ||
|
||||||
|
!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(field.name) ||
|
||||||
|
seen.has(field.name) ||
|
||||||
|
typeof field.type !== 'string' ||
|
||||||
|
!FIELD_TYPES.includes(field.type as OutputFieldType)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
seen.add(field.name);
|
||||||
|
fields.push({ name: field.name, type: field.type as OutputFieldType, required: true });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StructureBody {
|
||||||
|
project_id?: unknown;
|
||||||
|
document_id?: unknown;
|
||||||
|
version_id?: unknown;
|
||||||
|
checksum_sha256?: unknown;
|
||||||
|
pipeline_stage?: unknown;
|
||||||
|
instruction?: unknown;
|
||||||
|
fields?: unknown;
|
||||||
|
model_id?: unknown;
|
||||||
|
data_class?: unknown;
|
||||||
|
format?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/0pt/documents/structure
|
||||||
|
*
|
||||||
|
* Upload → DOC0 segments → INF0 model → mechanically validated, evidence-bound
|
||||||
|
* output (optionally exported). Credits are reserved before the model runs and
|
||||||
|
* settled to the measured usage afterwards.
|
||||||
|
*
|
||||||
|
* Nothing is trusted from the client except the requested target fields and
|
||||||
|
* instruction. Source text is re-read from DOC0 and every returned value must
|
||||||
|
* point at an exact span in that text.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
const { tenantId, actorId } = verified.scope;
|
||||||
|
|
||||||
|
const runtimeConfig = readDocumentWorkbenchRuntime();
|
||||||
|
const gateway = createInf0Gateway();
|
||||||
|
if (!runtimeConfig.doc0 || !gateway) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'WORKBENCH_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until DOC0 and INF0 are configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const policy = modelPolicy();
|
||||||
|
if (!policy) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'MODEL_POLICY_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until the model policy is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Explicit pilot switch: `OPT_CREDITS_MODE=off` runs structuring without a
|
||||||
|
// wallet. It is deliberately NOT a silent fallback — the response reports
|
||||||
|
// `credits: null` and the operator has named the mode.
|
||||||
|
const creditsEnabled =
|
||||||
|
process.env.OPT_CREDITS_MODE !== 'off' &&
|
||||||
|
Boolean(readCreditPriceBook()) &&
|
||||||
|
Boolean(repositoryDatabaseUrl());
|
||||||
|
const priceBook = creditsEnabled ? readCreditPriceBook() : undefined;
|
||||||
|
const databaseUrl = creditsEnabled ? repositoryDatabaseUrl() : undefined;
|
||||||
|
if (!creditsEnabled && process.env.OPT_CREDITS_MODE !== 'off') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'CREDITS_NOT_CONFIGURED',
|
||||||
|
message: 'Document structuring is not available until credits are configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: StructureBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as StructureBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = body.project_id;
|
||||||
|
const documentId = body.document_id;
|
||||||
|
const versionId = body.version_id;
|
||||||
|
const checksum = body.checksum_sha256;
|
||||||
|
const pipelineStage = body.pipeline_stage;
|
||||||
|
const instruction = body.instruction;
|
||||||
|
const modelId = body.model_id;
|
||||||
|
const dataClass = body.data_class ?? 'low';
|
||||||
|
const format = body.format ?? 'json';
|
||||||
|
|
||||||
|
if (pipelineStage !== 'parsed') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'DOCUMENT_NOT_PARSED',
|
||||||
|
message:
|
||||||
|
'The document has no verified text layer yet (parse_failed or not parsed). OCR is required before structuring.',
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isValidScopeId(projectId) ||
|
||||||
|
!isValidScopeId(documentId) ||
|
||||||
|
!isValidScopeId(versionId) ||
|
||||||
|
typeof checksum !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(checksum) ||
|
||||||
|
typeof instruction !== 'string' ||
|
||||||
|
!instruction.trim() ||
|
||||||
|
instruction.length > 4000 ||
|
||||||
|
typeof modelId !== 'string' ||
|
||||||
|
!policy.allowedModelIds.includes(modelId) ||
|
||||||
|
typeof dataClass !== 'string' ||
|
||||||
|
!(DATA_CLASSES as readonly string[]).includes(dataClass) ||
|
||||||
|
(format !== 'json' && format !== 'csv')
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_REQUEST', message: 'Invalid document structure request' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fields = parseFields(body.fields);
|
||||||
|
if (!fields) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_FIELDS', message: 'A valid field contract is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorize = createProjectAuthorizer();
|
||||||
|
if (!(await authorize({ tenantId, projectId, actorId }))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const correlationId = request.headers.get('x-request-id') ?? crypto.randomUUID();
|
||||||
|
const context = {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
capability: '0pt.document.structure.v1',
|
||||||
|
correlationId,
|
||||||
|
idempotencyKey: correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sources;
|
||||||
|
try {
|
||||||
|
sources = await new Doc0SourceClient({
|
||||||
|
baseUrl: runtimeConfig.doc0.baseUrl,
|
||||||
|
serviceToken: runtimeConfig.doc0.serviceToken,
|
||||||
|
}).read({
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
actorId: runtimeConfig.doc0.actorId ?? actorId,
|
||||||
|
requestId: correlationId,
|
||||||
|
receipt: {
|
||||||
|
documentId,
|
||||||
|
versionId,
|
||||||
|
checksumSha256: checksum,
|
||||||
|
pipelineStage: 'parsed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Doc0SourceUnavailableError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'DOC0_SOURCE_UNAVAILABLE',
|
||||||
|
message:
|
||||||
|
'The extracted source could not be verified (version, checksum, page cap or tenant). Review or reconciliation is required.',
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'DOC0_SOURCE_UNAVAILABLE', message: 'The extracted source is unavailable' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[0pt-workbench] doc0 source read:', sources.sources.length, 'segments');
|
||||||
|
const sourceChars = sources.sources.reduce((total, source) => total + source.text.length, 0);
|
||||||
|
const estimatedInputTokens = Math.ceil(JSON.stringify(sources.sources).length / 4) + 1024;
|
||||||
|
if (estimatedInputTokens > policy.maxInputTokens) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'SOURCE_TOO_LARGE',
|
||||||
|
message: 'The document is larger than the configured model input limit',
|
||||||
|
},
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximumCredits =
|
||||||
|
creditsEnabled && priceBook
|
||||||
|
? creditsForUsage(priceBook, {
|
||||||
|
pages: Math.max(1, Math.ceil(sourceChars / 1800)),
|
||||||
|
inputTokens: estimatedInputTokens,
|
||||||
|
outputTokens: policy.maxOutputTokens,
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
const reservationId = crypto.randomUUID();
|
||||||
|
const ledger =
|
||||||
|
creditsEnabled && databaseUrl ? new PostgresCreditLedger(getRepositorySql(databaseUrl)) : undefined;
|
||||||
|
if (ledger) {
|
||||||
|
try {
|
||||||
|
await ensureTrialCredits(ledger, tenantId, readTrialCredits());
|
||||||
|
await ledger.apply({
|
||||||
|
tenantId,
|
||||||
|
idempotencyKey: `reserve:${reservationId}`,
|
||||||
|
kind: 'reserve',
|
||||||
|
reference: reservationId,
|
||||||
|
amount: maximumCredits,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Credit reservation failed';
|
||||||
|
if (/Insufficient available credits/i.test(message)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'INSUFFICIENT_CREDITS',
|
||||||
|
message: 'Not enough credits for this job',
|
||||||
|
required_credits: maximumCredits,
|
||||||
|
},
|
||||||
|
{ status: 402 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'CREDITS_UNAVAILABLE', message }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await structureDocument({
|
||||||
|
gateway,
|
||||||
|
request: {
|
||||||
|
context,
|
||||||
|
admission: {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
jobId: `doc:${documentId}:${correlationId}`,
|
||||||
|
modelId,
|
||||||
|
dataClass,
|
||||||
|
maxInputTokens: policy.maxInputTokens,
|
||||||
|
maxOutputTokens: policy.maxOutputTokens,
|
||||||
|
maximumCredits,
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
allowedModelIds: policy.allowedModelIds,
|
||||||
|
allowedDataClasses: policy.allowedDataClasses,
|
||||||
|
destination: policy.destination,
|
||||||
|
externalTransferAllowed: policy.externalTransferAllowed,
|
||||||
|
maxInputTokens: policy.maxInputTokens,
|
||||||
|
maxOutputTokens: policy.maxOutputTokens,
|
||||||
|
},
|
||||||
|
reservation: ledger
|
||||||
|
? {
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
jobId: `doc:${documentId}:${correlationId}`,
|
||||||
|
status: 'reserved',
|
||||||
|
credits: maximumCredits,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
countTokens: async (messages) => Math.ceil(JSON.stringify(messages).length / 4),
|
||||||
|
instructions: instruction,
|
||||||
|
fields,
|
||||||
|
sources: sources.sources,
|
||||||
|
});
|
||||||
|
|
||||||
|
const charged =
|
||||||
|
ledger && priceBook
|
||||||
|
? Math.min(
|
||||||
|
maximumCredits,
|
||||||
|
creditsForUsage(priceBook, {
|
||||||
|
pages: Math.max(1, Math.ceil(sourceChars / 1800)),
|
||||||
|
inputTokens: result.model.promptTokens,
|
||||||
|
outputTokens: result.model.completionTokens,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
let balance;
|
||||||
|
if (ledger) {
|
||||||
|
try {
|
||||||
|
balance = (
|
||||||
|
await ledger.apply({
|
||||||
|
tenantId,
|
||||||
|
idempotencyKey: `settle:${reservationId}`,
|
||||||
|
kind: 'settle',
|
||||||
|
reference: reservationId,
|
||||||
|
amount: charged,
|
||||||
|
})
|
||||||
|
).balance;
|
||||||
|
} catch {
|
||||||
|
// The model result is real; keep the reservation for reconciliation
|
||||||
|
// rather than reporting a balance we could not write.
|
||||||
|
balance = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'mechanically_validated') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'needs_review',
|
||||||
|
issues: result.issues,
|
||||||
|
model: result.model,
|
||||||
|
credits: ledger ? { reserved: maximumCredits, charged, balance } : null,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exported =
|
||||||
|
format === 'json'
|
||||||
|
? exportValidatedOutput({
|
||||||
|
format: 'json',
|
||||||
|
fields,
|
||||||
|
sources: sources.sources,
|
||||||
|
value: result.value,
|
||||||
|
evidence: result.evidence,
|
||||||
|
})
|
||||||
|
: exportValidatedOutput({
|
||||||
|
format: 'csv',
|
||||||
|
fields,
|
||||||
|
sources: sources.sources,
|
||||||
|
value: result.value,
|
||||||
|
evidence: result.evidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'mechanically_validated',
|
||||||
|
value: result.value,
|
||||||
|
evidence: result.evidence,
|
||||||
|
model: result.model,
|
||||||
|
credits: ledger ? { reserved: maximumCredits, charged, balance } : null,
|
||||||
|
export:
|
||||||
|
exported.status === 'ready'
|
||||||
|
? { media_type: exported.mediaType, content: exported.content }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Inf0OutcomeUnknownError) {
|
||||||
|
// Never release a reservation whose model call may have completed.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'outcome_unknown',
|
||||||
|
message:
|
||||||
|
'The model call may have completed. The credit reservation is kept until its usage is reconciled.',
|
||||||
|
reservation_id: reservationId,
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ledger) {
|
||||||
|
try {
|
||||||
|
await ledger.apply({
|
||||||
|
tenantId,
|
||||||
|
idempotencyKey: `release:${reservationId}`,
|
||||||
|
kind: 'release',
|
||||||
|
reference: reservationId,
|
||||||
|
amount: 0,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* the reservation stays for reconciliation */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Structuring failed';
|
||||||
|
console.error('[0pt-workbench] structure failed:', message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'STRUCTURE_FAILED', message, reservation_id: reservationId },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { Doc0UploadClient } from '@/src/document-workbench/doc0-upload-client';
|
||||||
|
import {
|
||||||
|
DocumentUploadCoordinator,
|
||||||
|
type CoordinatedUpload,
|
||||||
|
} from '@/src/document-workbench/upload-coordinator';
|
||||||
|
import { PostgresUploadAttemptStore } from '@/src/document-workbench/upload-attempt-store';
|
||||||
|
import {
|
||||||
|
createProjectAuthorizer,
|
||||||
|
projectAuthorizationConfigured,
|
||||||
|
} from '@/src/document-workbench/project-access';
|
||||||
|
import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config';
|
||||||
|
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||||
|
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CLASSIFICATIONS = [
|
||||||
|
'public',
|
||||||
|
'internal',
|
||||||
|
'confidential',
|
||||||
|
'restricted',
|
||||||
|
'personal-data',
|
||||||
|
'financial',
|
||||||
|
'legal',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isClassification(value: string): value is (typeof CLASSIFICATIONS)[number] {
|
||||||
|
return (CLASSIFICATIONS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function idempotencyKey(request: NextRequest, form: FormData): string | undefined {
|
||||||
|
const fromHeader = request.headers.get('idempotency-key');
|
||||||
|
const fromBody = form.get('idempotency_key');
|
||||||
|
const value = fromHeader ?? (typeof fromBody === 'string' ? fromBody : undefined);
|
||||||
|
return value && /^[\x21-\x7e]{1,256}$/.test(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/0pt/documents/upload — customer document upload.
|
||||||
|
*
|
||||||
|
* multipart/form-data:
|
||||||
|
* file (required) the original bytes; never trusted metadata
|
||||||
|
* project_id (required) verified project the file belongs to
|
||||||
|
* idempotency_key (required) or `Idempotency-Key` header
|
||||||
|
* classification optional, defaults to `restricted` (conservative)
|
||||||
|
*
|
||||||
|
* The tenant and actor come ONLY from middleware's verified headers. The route
|
||||||
|
* fails closed when project authorization or DOC0 is not configured.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const verified = resolveVerifiedScope(request);
|
||||||
|
if ('response' in verified) return verified.response;
|
||||||
|
const { tenantId, actorId } = verified.scope;
|
||||||
|
|
||||||
|
const runtimeConfig = readDocumentWorkbenchRuntime();
|
||||||
|
if (!runtimeConfig.doc0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'DOC0_NOT_CONFIGURED',
|
||||||
|
message: 'Document upload is not available until DOC0 is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!projectAuthorizationConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'PROJECT_AUTHORIZATION_NOT_CONFIGURED',
|
||||||
|
message: 'Document upload is not available until project authorization is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const databaseUrl = repositoryDatabaseUrl();
|
||||||
|
if (!databaseUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'UPLOAD_JOURNAL_NOT_CONFIGURED',
|
||||||
|
message: 'Document upload is not available until its durable journal is configured',
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > runtimeConfig.doc0.maxUploadBytes + 128 * 1024) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' },
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'A multipart form with a file is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = form.get('file');
|
||||||
|
const projectId = form.get('project_id');
|
||||||
|
const key = idempotencyKey(request, form);
|
||||||
|
const classificationRaw = form.get('classification');
|
||||||
|
const classification =
|
||||||
|
typeof classificationRaw === 'string' && classificationRaw.trim()
|
||||||
|
? classificationRaw.trim()
|
||||||
|
: 'restricted';
|
||||||
|
|
||||||
|
if (!(file instanceof File) || file.size <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'A non-empty file is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isValidScopeId(projectId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_PROJECT', message: 'A valid project_id is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
message: 'An idempotency key is required for document uploads',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isClassification(classification)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_CLASSIFICATION', message: 'Unknown classification' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.size > runtimeConfig.doc0.maxUploadBytes) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' },
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'The uploaded file could not be read' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bytes.byteLength === 0 || bytes.byteLength !== file.size) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'INVALID_UPLOAD', message: 'The uploaded file is incomplete' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorize = createProjectAuthorizer();
|
||||||
|
const sql = getRepositorySql(databaseUrl);
|
||||||
|
// DOC0 authorizes the SERVICE identity and requires x-actor-id to be an
|
||||||
|
// actor that identity holds a grant for. The end-user actor is still the
|
||||||
|
// journal/authorization scope on our side; only the DOC0 call acts as the
|
||||||
|
// service.
|
||||||
|
const doc0ActorId = runtimeConfig.doc0.actorId ?? actorId;
|
||||||
|
const doc0Client = new Doc0UploadClient({
|
||||||
|
baseUrl: runtimeConfig.doc0.baseUrl,
|
||||||
|
serviceToken: runtimeConfig.doc0.serviceToken,
|
||||||
|
maxBytes: runtimeConfig.doc0.maxUploadBytes,
|
||||||
|
});
|
||||||
|
const coordinator = new DocumentUploadCoordinator({
|
||||||
|
attempts: new PostgresUploadAttemptStore(sql),
|
||||||
|
doc0: {
|
||||||
|
upload: (input) => doc0Client.upload({ ...input, actorId: doc0ActorId }),
|
||||||
|
inspect: (input) => doc0Client.inspect({ ...input, actorId: doc0ActorId }),
|
||||||
|
},
|
||||||
|
authorize: async (scope) => authorize(scope),
|
||||||
|
maxBytes: runtimeConfig.doc0.maxUploadBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
let outcome: CoordinatedUpload;
|
||||||
|
try {
|
||||||
|
outcome = await coordinator.execute({
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
|
||||||
|
idempotencyKey: key,
|
||||||
|
filename: file.name || 'source',
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
classification,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||||
|
if (/not authorized/i.test(message)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/cannot be reused/i.test(message)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'IDEMPOTENCY_CONFLICT',
|
||||||
|
message: 'The idempotency key was already used for different content',
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'UPLOAD_FAILED', message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.status === 'receipt_verified') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'receipt_verified',
|
||||||
|
document_id: outcome.receipt.documentId,
|
||||||
|
version_id: outcome.receipt.versionId,
|
||||||
|
checksum_sha256: outcome.receipt.checksumSha256,
|
||||||
|
pipeline_stage: outcome.receipt.pipelineStage,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'outcome_unknown',
|
||||||
|
message:
|
||||||
|
'The upload may have been accepted. Its journal record is reported below; it does not prove storage or extraction.',
|
||||||
|
observation: outcome.observation ?? { status: 'reconciliation_required' },
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
import { getBatchEntityResolutionService } from "@/src/services/entity-resolution";
|
||||||
|
|
||||||
|
function required(body: Record<string, unknown>, field: string): string {
|
||||||
|
const value = body[field];
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `${field} is required`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const raw = await request.json();
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "JSON object body is required");
|
||||||
|
}
|
||||||
|
const body = raw as Record<string, unknown>;
|
||||||
|
const tenantId = required(body, "tenant_id");
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const idempotencyKey = request.headers.get("idempotency-key")?.trim()
|
||||||
|
|| (typeof body.idempotency_key === "string" ? body.idempotency_key.trim() : "")
|
||||||
|
|| undefined;
|
||||||
|
const job = await getBatchEntityResolutionService().submitBatchResolution({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
project_id: required(body, "project_id"),
|
||||||
|
records_artifact_ref: required(body, "records_artifact_ref"),
|
||||||
|
actor_id: request.headers.get("x-0pt-verified-sub") ?? "local-dev",
|
||||||
|
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
|
||||||
|
? { correlation_id: body.correlation_id.trim() }
|
||||||
|
: {}),
|
||||||
|
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
||||||
|
});
|
||||||
|
return NextResponse.json(job, { status: 202 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { parseErCandidateRequest } from "@/src/er/request-validation";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { entityResolutionService } from "@/src/services/entity-resolution";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const input = parseErCandidateRequest(await request.json());
|
||||||
|
const guard = assertTenantMatchesVerified(request, input.tenant_id);
|
||||||
|
if (guard) return guard;
|
||||||
|
return NextResponse.json(entityResolutionService.generateCandidates(input));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { verifyInternalServiceToken } from "@/src/auth/internal-token";
|
||||||
|
import {
|
||||||
|
EntityResolutionService,
|
||||||
|
type ErCandidateResult,
|
||||||
|
type ErRecord,
|
||||||
|
} from "@/src/er/entity-resolution-service";
|
||||||
|
import { parseErCandidateRequest } from "@/src/er/request-validation";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
|
||||||
|
const er = new EntityResolutionService();
|
||||||
|
|
||||||
|
function rawStringField(record: ErRecord, field: string): string | undefined {
|
||||||
|
const value = record.fields[field];
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableIdentifier(record: ErRecord, field: string): string | undefined {
|
||||||
|
const value = rawStringField(record, field);
|
||||||
|
if (!value) return undefined;
|
||||||
|
const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryIdentifier(record: ErRecord): string | undefined {
|
||||||
|
const value = rawStringField(record, "registration_country") ?? rawStringField(record, "country");
|
||||||
|
if (!value) return undefined;
|
||||||
|
const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company identity hard rule v3.
|
||||||
|
* - LEI is globally scoped.
|
||||||
|
* - National registration numbers are comparable only within the same country.
|
||||||
|
* - Any comparable stable-ID conflict dominates every positive match.
|
||||||
|
*/
|
||||||
|
er.registerHardRule({
|
||||||
|
rule_id: "0pt.company.stable-identifiers.v3",
|
||||||
|
decide(a, b) {
|
||||||
|
let sharedMatch = false;
|
||||||
|
|
||||||
|
const aLei = stableIdentifier(a, "lei");
|
||||||
|
const bLei = stableIdentifier(b, "lei");
|
||||||
|
if (aLei && bLei) {
|
||||||
|
if (aLei !== bLei) return "force_non_match";
|
||||||
|
sharedMatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aCountry = countryIdentifier(a);
|
||||||
|
const bCountry = countryIdentifier(b);
|
||||||
|
const aOrg = stableIdentifier(a, "org_nr");
|
||||||
|
const bOrg = stableIdentifier(b, "org_nr");
|
||||||
|
if (aOrg && bOrg && aCountry && bCountry && aCountry === bCountry) {
|
||||||
|
if (aOrg !== bOrg) return "force_non_match";
|
||||||
|
sharedMatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharedMatch ? "force_match" : "abstain";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertInternal(request: NextRequest): NextResponse | undefined {
|
||||||
|
if (verifyInternalServiceToken(request.headers)) return undefined;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "UNAUTHENTICATED", message: "valid X-Internal-Token is required" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company/Work boundary: fuzzy similarity is candidate evidence, never identity truth.
|
||||||
|
* Only product hard rules backed by stable identifiers may emit `match` here.
|
||||||
|
*/
|
||||||
|
export function enforceCompanyReviewBoundary(result: ErCandidateResult): ErCandidateResult {
|
||||||
|
const pairs = result.pairs.map((pair) =>
|
||||||
|
pair.decided_by === "score" && pair.status === "match"
|
||||||
|
? { ...pair, status: "pending_review" as const }
|
||||||
|
: pair,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
pairs,
|
||||||
|
review_queue: pairs.filter((pair) => pair.status === "pending_review"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const denied = assertInternal(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await er.generateCandidates(parseErCandidateRequest(await request.json()));
|
||||||
|
return NextResponse.json(enforceCompanyReviewBoundary(result));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { verifyInternalServiceToken } from "@/src/auth/internal-token";
|
||||||
|
import { createAud0Client } from "@/src/clients/aud0-client";
|
||||||
|
import {
|
||||||
|
Aud0OutboxDispatcher,
|
||||||
|
createAud0OutboxRepositoryFromEnv,
|
||||||
|
} from "@/src/evidence/aud0-outbox";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { SiaxError } from "@/src/platform/errors";
|
||||||
|
|
||||||
|
function assertInternal(request: NextRequest): NextResponse | undefined {
|
||||||
|
if (verifyInternalServiceToken(request.headers)) return undefined;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "UNAUTHENTICATED", message: "valid X-Internal-Token is required" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function repository() {
|
||||||
|
const repo = createAud0OutboxRepositoryFromEnv();
|
||||||
|
if (!repo) {
|
||||||
|
throw new SiaxError(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"0pt.aud0_outbox",
|
||||||
|
"DATABASE_URL is required for durable evidence dispatch",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const denied = assertInternal(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await request.json().catch(() => ({})) as { limit?: unknown };
|
||||||
|
const limit = raw.limit === undefined ? 100 : Number(raw.limit);
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "INVALID_REQUEST", message: "limit must be an integer between 1 and 500" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = repository();
|
||||||
|
// CRITICAL: the dispatcher must bypass the durable wrapper. Using the
|
||||||
|
// default client here would enqueue the event again and could create an
|
||||||
|
// infinite outbox loop instead of delivering to AUD0.
|
||||||
|
const rawAud0 = createAud0Client(undefined, { durable: false });
|
||||||
|
const dispatcher = new Aud0OutboxDispatcher(repo, rawAud0);
|
||||||
|
const result = await dispatcher.sweep(limit);
|
||||||
|
const stats = await repo.stats();
|
||||||
|
return NextResponse.json({ result, stats });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const denied = assertInternal(request);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return NextResponse.json(await repository().stats());
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { mappingService } from "@/src/services/lakehouse-mapping-registry";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
// P014: this lookup used to accept only `mappingId`, with NO tenant check
|
||||||
|
// anywhere in the request path -- any authenticated caller could read
|
||||||
|
// another tenant's dataset<->Iceberg-table mapping (namespace, table name,
|
||||||
|
// storage URI) by guessing or enumerating IDs. Now requires `?tenant_id=`
|
||||||
|
// and both checks it against the verified token and threads it into the
|
||||||
|
// service so a foreign mapping_id 404s instead of leaking -- see
|
||||||
|
// DatasetIcebergMappingService.getMapping's doc comment.
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ mappingId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { mappingId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const mapping = await mappingService.getMapping(mappingId, tenantId);
|
||||||
|
return NextResponse.json(mapping);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, requestIcebergWriteSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { writeService } from "@/src/services/lakehouse-mapping-registry";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
// Registers a new Iceberg snapshot (a write) through ST0RE's table registry
|
||||||
|
// for an already-mapped dataset. See docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||||
|
//
|
||||||
|
// `writeService` is the SHARED instance from
|
||||||
|
// src/services/lakehouse-mapping-registry.ts -- this route used to construct
|
||||||
|
// its own `new IcebergWriteService()`, which held a private in-memory mapping
|
||||||
|
// registry, so a mapping created via POST /lakehouse/mappings was never
|
||||||
|
// visible here and every write failed with "mapping not found".
|
||||||
|
//
|
||||||
|
// The body is validated against requestIcebergWriteSchema before it reaches
|
||||||
|
// the service (Wave 2 hardening).
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ mappingId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { mappingId } = await params;
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(requestIcebergWriteSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const body = __parsed.data;
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const result = await writeService.requestIcebergWrite({
|
||||||
|
dataset_iceberg_mapping_id: mappingId,
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
actor_id: body.actor_id,
|
||||||
|
correlation_id: body.correlation_id,
|
||||||
|
snapshot_type: body.snapshot_type,
|
||||||
|
schema_version: body.schema_version,
|
||||||
|
manifest_uri: body.manifest_uri,
|
||||||
|
record_count: body.record_count,
|
||||||
|
size_bytes: body.size_bytes,
|
||||||
|
heavy: body.heavy,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 202 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, mapDatasetToIcebergTableSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { mappingService, writeService } from "@/src/services/lakehouse-mapping-registry";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import type { IcebergTableFormat } from "@/src/clients/st0re-lakehouse-client";
|
||||||
|
|
||||||
|
// Shared instances come from src/services/lakehouse-mapping-registry.ts so a
|
||||||
|
// mapping created via POST is visible to a later GET, AND to the sibling
|
||||||
|
// /{mappingId}/writes route, within the same server process. (Each route file
|
||||||
|
// previously constructed its own DatasetIcebergMappingService/
|
||||||
|
// IcebergWriteService, so a mapping created here was invisible to the writes
|
||||||
|
// route -- every write failed with "mapping not found".) This still matches the
|
||||||
|
// in-memory-per-module convention app/api/0pt/datasets and app/api/0pt/quality
|
||||||
|
// use; Postgres persistence exists in sql/ but is not wired into route handlers
|
||||||
|
// in this pass -- same limitation those routes already have.
|
||||||
|
|
||||||
|
// POST registers a NEW Iceberg table in ST0RE's table registry (via
|
||||||
|
// IcebergWriteService.mapDatasetToIcebergTable) and records the
|
||||||
|
// dataset_iceberg_mapping row that points at it -- this is 0pt's write-path
|
||||||
|
// entrypoint, see docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||||
|
//
|
||||||
|
// The body is validated against mapDatasetToIcebergTableSchema BEFORE it
|
||||||
|
// reaches the service (Wave 2 hardening): an unvalidated request body used to
|
||||||
|
// be passed straight into the capability call.
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(mapDatasetToIcebergTableSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const body = __parsed.data;
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const result = await writeService.mapDatasetToIcebergTable({
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
project_id: body.project_id,
|
||||||
|
dataset_id: body.dataset_id,
|
||||||
|
dataset_iceberg_mapping_id: body.dataset_iceberg_mapping_id,
|
||||||
|
namespace: body.namespace,
|
||||||
|
table_name: body.table_name,
|
||||||
|
storage_uri: body.storage_uri,
|
||||||
|
catalog_ref: body.catalog_ref,
|
||||||
|
// mapDatasetToIcebergTableSchema types this as a plain string
|
||||||
|
// (pre-existing, listed among package.json's "9 pre-existing errors
|
||||||
|
// ... Wave 2 Zod schemas type fields as plain string while the
|
||||||
|
// services want narrower unions" -- deliberately left to its own
|
||||||
|
// change, not widened here). This cast changes no runtime behavior:
|
||||||
|
// an unrecognized value flows through exactly as it did before
|
||||||
|
// (ST0RE's own table-registration route is the actual validator) --
|
||||||
|
// it only satisfies the type checker, which previously never saw this
|
||||||
|
// file (nothing under tests/ imported it before P014's cross-tenant
|
||||||
|
// regression tests).
|
||||||
|
table_format: body.table_format as IcebergTableFormat | undefined,
|
||||||
|
actor_id: body.actor_id,
|
||||||
|
correlation_id: body.correlation_id,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P014: tenant_id used to be optional here, and
|
||||||
|
// DatasetIcebergMappingService.listMappings treats an absent tenant_id
|
||||||
|
// filter as "no restriction" -- omitting the query param returned every
|
||||||
|
// tenant's dataset<->Iceberg-table mappings (namespace, table name, storage
|
||||||
|
// URI). Now required and cross-checked, matching the rest of the surface.
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const mappings = await mappingService.listMappings({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
project_id: searchParams.get("project_id") ?? undefined,
|
||||||
|
dataset_id: searchParams.get("dataset_id") ?? undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(mappings);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/lineage/route.ts
|
||||||
|
* =============================
|
||||||
|
* P067 DoD: "source -> output lineage is QUERYABLE". This is the query.
|
||||||
|
*
|
||||||
|
* GET /api/0pt/lineage?tenant_id=…&output_name=… -> what fed this output
|
||||||
|
* GET /api/0pt/lineage?tenant_id=…&input_name=… -> what this input produced
|
||||||
|
*
|
||||||
|
* The answer comes from 0pt's local projection of the OpenLineage events it
|
||||||
|
* emits to AUD0 (src/lineage/lineage-store.ts). AUD0 remains the system of
|
||||||
|
* record; this endpoint exists because "emitted to AUD0" was never the same
|
||||||
|
* thing as "answerable from 0pt", and before this route nothing in this repo
|
||||||
|
* could answer either question.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { lineageStore } from "@/src/services/source-registry";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
const outputName = searchParams.get("output_name") ?? undefined;
|
||||||
|
const inputName = searchParams.get("input_name") ?? undefined;
|
||||||
|
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
if (!outputName && !inputName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "one of output_name or input_name is required — an unfiltered lineage dump is not a query" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (guard) return guard;
|
||||||
|
const limitRaw = searchParams.get("limit");
|
||||||
|
const limit = limitRaw ? Math.min(Math.max(Number(limitRaw), 1), 500) : undefined;
|
||||||
|
const edges = await lineageStore.query({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
output_name: outputName,
|
||||||
|
input_name: inputName,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ edges, count: edges.length });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/quality/bridge-run/route.ts
|
||||||
|
* ==========================================
|
||||||
|
* The P010 TS/Python split-brain bridge (BEFORE_STATE.md §2). This is the
|
||||||
|
* ONE route the production Python engine
|
||||||
|
* (services/api/workers/quality_bridge.py, called from
|
||||||
|
* services/api/workers/pipeline.py) talks to. It:
|
||||||
|
*
|
||||||
|
* 1. auto-provisions a deterministic, tenant-scoped baseline rule set for
|
||||||
|
* (tenant_id, dataset_id) the first time it's seen (src/quality/quality-bridge.ts) --
|
||||||
|
* the Python engine never has to pre-register rules through the CRUD
|
||||||
|
* surface just to get a quality read on an order's already-produced rows;
|
||||||
|
* 2. runs QualityService.runQuality synchronously against the rows the
|
||||||
|
* pipeline already produced (delivered, post the Python filter --
|
||||||
|
* this is an ADVISORY second opinion, not a gate, until Simon decides
|
||||||
|
* to wire per-row gating -- see quality_bridge.py's docstring for why
|
||||||
|
* that isn't done in this pass);
|
||||||
|
* 3. returns the run + any open issues.
|
||||||
|
*
|
||||||
|
* This does not replace, call, or block on the Python filter in any way --
|
||||||
|
* it is purely additive. If this route is unreachable, times out, or 500s,
|
||||||
|
* the Python side is required to fail open (never raise into the pipeline).
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { qualityService } from "@/src/services/quality";
|
||||||
|
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(qualityBridgeRunSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const body = __parsed.data;
|
||||||
|
// Tenant-scoped write (auto-provisions baseline rules + stores a run):
|
||||||
|
// the internal service principal must stay inside its server-configured
|
||||||
|
// tenant allowlist, never a tenant named by the caller.
|
||||||
|
const guard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
await ensureBaselineRules(qualityService, {
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
project_id: body.project_id,
|
||||||
|
dataset_id: body.dataset_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await qualityService.runQuality({
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
project_id: body.project_id,
|
||||||
|
dataset_id: body.dataset_id,
|
||||||
|
dataset_version: body.dataset_version,
|
||||||
|
rows: body.rows,
|
||||||
|
correlation_id: body.correlation_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issues = qualityService.listIssues({
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
dataset_id: body.dataset_id,
|
||||||
|
status: "open",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ run, issues }, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/quality/issues/route.ts
|
||||||
|
* ======================================
|
||||||
|
* quality.list_issues (GET) -- issues opened by finished quality runs
|
||||||
|
* (human-review handoff, Master-DoD §10).
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { qualityService } from "@/src/services/quality";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import type { DataQualityIssue } from "@/src/quality/quality-types";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const issues = qualityService.listIssues({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
dataset_id: searchParams.get("dataset_id") ?? undefined,
|
||||||
|
status: (searchParams.get("status") as DataQualityIssue["status"]) ?? undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(issues);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/quality/rules/[ruleId]/route.ts
|
||||||
|
* ===============================================
|
||||||
|
* quality.get_rule (GET) + quality.update_rule (PUT, versioned -- bumps
|
||||||
|
* `version`, records the superseded row). Cross-tenant reads are NOT_FOUND
|
||||||
|
* (no existence oracle), matching QualityService.getRule's own contract.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { qualityService } from "@/src/services/quality";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const updateQualityRuleSchema = z.object({
|
||||||
|
tenant_id: z.string().min(1),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
expectation: z.object({ expectation_type: z.string().min(1), kwargs: z.record(z.string(), z.unknown()) }).optional(),
|
||||||
|
severity: z.enum(["info", "warning", "error", "critical"]).optional(),
|
||||||
|
status: z.enum(["active", "disabled", "superseded"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ ruleId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { ruleId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const versions = searchParams.get("versions") === "true";
|
||||||
|
if (versions) {
|
||||||
|
return NextResponse.json(qualityService.getRuleVersions(ruleId, tenantId));
|
||||||
|
}
|
||||||
|
return NextResponse.json(qualityService.getRule(ruleId, tenantId));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ ruleId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { ruleId } = await params;
|
||||||
|
const raw = await request.json();
|
||||||
|
const parsed = updateQualityRuleSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "validation_error", issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { tenant_id, ...patch } = parsed.data;
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const rule = await qualityService.updateRule(ruleId, tenant_id, patch);
|
||||||
|
return NextResponse.json(rule);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/quality/rules/route.ts
|
||||||
|
* ====================================
|
||||||
|
* quality.create_rule (POST) + quality.list_rules (GET) -- the CRUD surface
|
||||||
|
* over QualityService's versioned rule registry (src/quality/quality-service.ts,
|
||||||
|
* Master-DoD §10). Uses the shared singleton from src/services/quality.ts,
|
||||||
|
* not a fresh `new QualityService()` per route module -- see that file's
|
||||||
|
* doc comment (and BEFORE_STATE.md §1, which found and fixed the same
|
||||||
|
* per-module-instance defect for the Source Registry).
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, createQualityRuleSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { qualityService } from "@/src/services/quality";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
import type { DataQualityRule } from "@/src/quality/quality-types";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(createQualityRuleSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, __parsed.data.tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const rule = await qualityService.createRule(__parsed.data);
|
||||||
|
return NextResponse.json(rule, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const rules = qualityService.listRules({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
dataset_id: searchParams.get("dataset_id") ?? undefined,
|
||||||
|
status: (searchParams.get("status") as DataQualityRule["status"]) ?? undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(rules);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/quality/runs/[runId]/route.ts
|
||||||
|
* =============================================
|
||||||
|
* quality.get_run (GET). Cross-tenant reads are NOT_FOUND.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { qualityService } from "@/src/services/quality";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ runId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { runId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
return NextResponse.json(qualityService.getRun(runId, tenantId));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* app/api/0pt/quality/runs/route.ts
|
||||||
|
* ====================================
|
||||||
|
* quality.run (POST) + quality.list_runs (GET). POST executes all active
|
||||||
|
* rules for a dataset against caller-supplied rows -- runs synchronously
|
||||||
|
* when every active rule's expectation is locally executable, otherwise
|
||||||
|
* delegates to the GE backend via N0D (QualityService.runQuality never
|
||||||
|
* fabricates a result for a check it couldn't run).
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { parseBody, createQualityRunSchema } from "@/src/validation/0pt-schemas";
|
||||||
|
import { qualityService } from "@/src/services/quality";
|
||||||
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const __raw = await request.json();
|
||||||
|
const __parsed = parseBody(createQualityRunSchema, __raw);
|
||||||
|
if (!__parsed.ok) {
|
||||||
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, __parsed.data.tenant_id);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const run = await qualityService.runQuality(__parsed.data);
|
||||||
|
return NextResponse.json(run, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantId = searchParams.get("tenant_id");
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const tenantGuard = assertTenantMatchesVerified(request, tenantId);
|
||||||
|
if (tenantGuard) return tenantGuard;
|
||||||
|
const runs = qualityService.listRuns({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
dataset_id: searchParams.get("dataset_id") ?? undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(runs);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return siaxErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
product: "0pt",
|
||||||
|
version: "1.0.0",
|
||||||
|
endpoints: [
|
||||||
|
"/api/0pt/sources",
|
||||||
|
"/api/0pt/datasets",
|
||||||
|
"/api/0pt/data-contracts",
|
||||||
|
"/api/0pt/quality/checks",
|
||||||
|
"/api/0pt/quality/reports",
|
||||||
|
"/api/0pt/features/views",
|
||||||
|
"/api/0pt/documents/capture",
|
||||||
|
"/api/0pt/documents/{documentId}/extraction",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user