Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c134527509 |
@@ -58,16 +58,6 @@ 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
|
||||
@@ -78,10 +68,6 @@ INTERNAL_SERVICE_TENANTS=
|
||||
#
|
||||
# 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.
|
||||
@@ -216,86 +202,3 @@ 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
|
||||
|
||||
+8
-16
@@ -6,26 +6,18 @@ name: Deploy to Coolify
|
||||
# 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.
|
||||
# STATUS (2026-08-09): no Coolify application has been provisioned for 0pt
|
||||
# yet -- siax.config.json's `deploy.target` was flipped from "vercel" to
|
||||
# "coolify" to state the intended platform, but there is no app UUID to
|
||||
# point at. COOLIFY_APP_ID below is a literal placeholder. The guard step
|
||||
# fails the job loudly (rather than silently POSTing to a bogus/empty URL)
|
||||
# until both the Coolify app exists and its real UUID replaces the
|
||||
# placeholder here. See docs/architecture/ARCHITECTURE.md §5/§8 for the
|
||||
# open "Vercel or fleet" question this resolves the config side of.
|
||||
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
|
||||
|
||||
+4
-67
@@ -17,16 +17,10 @@ name: CI (SIAX Cloud)
|
||||
# push/PR/weekly scanner (this file is push/PR-only).
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["**"]
|
||||
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
|
||||
@@ -36,8 +30,6 @@ jobs:
|
||||
- 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
|
||||
@@ -97,9 +89,6 @@ jobs:
|
||||
# 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
|
||||
@@ -137,8 +126,6 @@ jobs:
|
||||
- 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
|
||||
@@ -156,33 +143,6 @@ jobs:
|
||||
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: |
|
||||
@@ -274,13 +234,9 @@ jobs:
|
||||
- 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' .
|
||||
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 .
|
||||
|
||||
sonar:
|
||||
# SonarQube Community Build har inget separat PR-analysläge (en enda "main"-gren
|
||||
@@ -294,9 +250,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: SonarQube analysis (self-hosted sonar.siax.dev)
|
||||
- name: SonarQube analysis (self-hosted sonar.siax.io)
|
||||
env:
|
||||
SONAR_HOST_URL: https://sonar.siax.dev
|
||||
SONAR_HOST_URL: https://sonar.siax.io
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -309,22 +265,3 @@ jobs:
|
||||
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']))})"
|
||||
|
||||
@@ -8,21 +8,14 @@ on:
|
||||
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
|
||||
@@ -41,7 +34,6 @@ jobs:
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Trivy
|
||||
@@ -53,4 +45,4 @@ jobs:
|
||||
# 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' .
|
||||
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 .
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
@@ -29,8 +29,8 @@ name: Release chain (G17, ADR-W16-003)
|
||||
# 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
|
||||
# yet": no Coolify application provisioned for 0pt, no `/version`
|
||||
# endpoint), 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.
|
||||
|
||||
@@ -39,11 +39,6 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
|
||||
DOCKERFILE: Dockerfile
|
||||
|
||||
@@ -19,16 +19,12 @@ name: Security Scan (SIAX Cloud)
|
||||
# and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6 for full context.
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["**"]
|
||||
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
|
||||
@@ -40,4 +36,4 @@ jobs:
|
||||
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' .
|
||||
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 .
|
||||
|
||||
@@ -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
|
||||
@@ -52,6 +52,3 @@ 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/
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# gitleaks-fingerprints för verifierade falska positiver (REPO-15).
|
||||
# En post per exakt förekomst: <commit>:<fil>:<regel>:<rad>.
|
||||
# Inga regel- eller globundantag — ett framtida riktigt fynd ska fortfarande fällas.
|
||||
#
|
||||
# Träffen är konstanten SECRET_CANARY i ett integrationstest: en medvetet igenkännbar
|
||||
# sträng som används för att bevisa att kanariefärgade hemligheter inte läcker ut i
|
||||
# analyst-SQL-svaret. Att maskera den skulle göra testet oförmöget att mäta läckage.
|
||||
#
|
||||
|
||||
# generic-api-key tests/integration/analyst-sql-clickhouse.integration.test.ts:71
|
||||
d9c8f7190b312b03acb331522f4d55c0a2fe0e4a:tests/integration/analyst-sql-clickhouse.integration.test.ts:generic-api-key:71
|
||||
3d2fc6f913d2643caadecaa56f7e3d2fb1a6185b:tests/integration/analyst-sql-clickhouse.integration.test.ts:generic-api-key:71
|
||||
@@ -1,11 +0,0 @@
|
||||
# Build output and local worktrees are not source.
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
.worktrees/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.min.css
|
||||
pnpm-lock.yaml
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"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."}]}
|
||||
@@ -4,9 +4,9 @@ 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)'
|
||||
@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.
|
||||
|
||||
@@ -83,45 +83,3 @@ pnpm run lint # advisory
|
||||
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 -->
|
||||
|
||||
@@ -65,45 +65,3 @@ A working engine repo already exists. **Corrected this pass — the previous des
|
||||
## 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 -->
|
||||
|
||||
@@ -53,12 +53,7 @@ RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --ac
|
||||
# (`.` 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.
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"$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": []
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ documentId: string }> }) {
|
||||
try {
|
||||
@@ -14,16 +14,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
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(),
|
||||
actor_id: body.actor_id,
|
||||
correlation_id: body.correlation_id,
|
||||
model_id: body.model_id,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -13,8 +13,6 @@ export async function POST(request: NextRequest) {
|
||||
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: {
|
||||
@@ -31,9 +29,17 @@ export async function POST(request: NextRequest) {
|
||||
{
|
||||
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 ?? "",
|
||||
// documentCaptureSchema types these optional (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). CaptureMailboxContext requires actor_id as a
|
||||
// non-empty string and captureFromMailbox already throws a clear
|
||||
// "missing tenant_id/project_id/actor_id" error when it's falsy, so
|
||||
// `?? ""` changes no runtime behavior -- it only satisfies the type
|
||||
// checker, which previously never saw this file (nothing under
|
||||
// tests/ imported it before P014's cross-tenant regression tests).
|
||||
actor_id: body.actor_id ?? "",
|
||||
correlation_id: body.correlation_id ?? "",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ 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 {
|
||||
@@ -36,11 +35,6 @@ export async function POST(request: NextRequest) {
|
||||
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,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signSession, SESSION_COOKIE } from '@/lib/auth';
|
||||
import {
|
||||
OIDC_SESSION_TTL_SECONDS,
|
||||
OIDC_STATE_COOKIE,
|
||||
appBaseUrl,
|
||||
exchangeCode,
|
||||
readOidcConfig,
|
||||
verifyIdToken,
|
||||
} from '@/lib/oidc';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function safeNext(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/portal/dokument';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** GET /api/auth/callback — finish OIDC, verify the id_token, set the session. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readOidcConfig();
|
||||
const fail = (reason: string) =>
|
||||
NextResponse.redirect(`${appBaseUrl()}/portal/dokument?auth_error=${encodeURIComponent(reason)}`);
|
||||
if (!config) return fail('auth_not_configured');
|
||||
|
||||
const state = request.nextUrl.searchParams.get('state');
|
||||
const code = request.nextUrl.searchParams.get('code');
|
||||
const error = request.nextUrl.searchParams.get('error');
|
||||
if (error) return fail('provider_error');
|
||||
if (!state || !code) return fail('missing_code');
|
||||
|
||||
const raw = request.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
if (!raw) return fail('missing_state');
|
||||
let stored: { state?: unknown; nonce?: unknown; verifier?: unknown; next?: unknown; ts?: unknown };
|
||||
try {
|
||||
stored = JSON.parse(raw);
|
||||
} catch {
|
||||
return fail('bad_state');
|
||||
}
|
||||
if (
|
||||
stored.state !== state ||
|
||||
typeof stored.nonce !== 'string' ||
|
||||
typeof stored.verifier !== 'string' ||
|
||||
typeof stored.ts !== 'number' ||
|
||||
Date.now() - stored.ts > 10 * 60 * 1000
|
||||
) {
|
||||
return fail('bad_state');
|
||||
}
|
||||
|
||||
try {
|
||||
const { idToken, accessToken } = await exchangeCode(config, code, stored.verifier);
|
||||
const verified = await verifyIdToken(config, idToken, stored.nonce, accessToken);
|
||||
const session = await signSession({
|
||||
sub: verified.sub,
|
||||
tenantId: verified.tenantId,
|
||||
email: verified.email ?? '',
|
||||
exp: Date.now() + OIDC_SESSION_TTL_SECONDS * 1000,
|
||||
});
|
||||
const response = NextResponse.redirect(`${appBaseUrl()}${safeNext(stored.next)}`);
|
||||
response.cookies.set(SESSION_COOKIE, session, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: appBaseUrl().startsWith('https://'),
|
||||
path: '/',
|
||||
maxAge: OIDC_SESSION_TTL_SECONDS,
|
||||
});
|
||||
response.cookies.set(OIDC_STATE_COOKIE, '', { path: '/', maxAge: 0 });
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Codes only (e.g. token_exchange_failed), never tokens or claims.
|
||||
console.error(
|
||||
'[0pt-auth] oidc callback failed:',
|
||||
error instanceof Error ? error.message : 'unknown_error',
|
||||
);
|
||||
return fail('verification_failed');
|
||||
}
|
||||
}
|
||||
+13
-79
@@ -1,95 +1,29 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signSession, SESSION_COOKIE } from '@/lib/auth';
|
||||
import {
|
||||
OIDC_STATE_COOKIE,
|
||||
appBaseUrl,
|
||||
authorizeUrl,
|
||||
createOidcState,
|
||||
createPkce,
|
||||
readOidcConfig,
|
||||
} from '@/lib/oidc';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function safeNext(value: string | null): string {
|
||||
if (!value) return '/portal/dokument';
|
||||
if (!value.startsWith('/') || value.startsWith('//')) return '/portal/dokument';
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/login — start the Zitadel OIDC Authorization Code + PKCE flow.
|
||||
* Redirects back to /api/auth/callback, which sets the verified session cookie.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'auth_not_configured',
|
||||
message: 'Zitadel OIDC is not configured (ZITADEL_OIDC_CLIENT_ID/SECRET)',
|
||||
},
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const next = safeNext(request.nextUrl.searchParams.get('next'));
|
||||
const { state, nonce } = createOidcState();
|
||||
const { verifier, challenge } = createPkce();
|
||||
const response = NextResponse.redirect(authorizeUrl(config, state, nonce, challenge));
|
||||
response.cookies.set(OIDC_STATE_COOKIE, JSON.stringify({ state, nonce, verifier, next, ts: Date.now() }), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: appBaseUrl().startsWith('https://'),
|
||||
path: '/',
|
||||
maxAge: 600,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { signSession, SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
// POST /api/auth/login — guarded dev login (scaffold). Requires AUTH_SECRET +
|
||||
// a matching DEV_LOGIN_KEY. Never available in production; real login is GET.
|
||||
// a matching DEV_LOGIN_KEY. Production replaces this with Auth.js + OAuth.
|
||||
export async function POST(req: NextRequest) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'forbidden', message: 'Dev-login is disabled in production' } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const { email, key } = await req.json().catch(() => ({ email: undefined, key: undefined }));
|
||||
if (!process.env.AUTH_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'auth_not_configured', message: 'AUTH_SECRET saknas' } },
|
||||
{ status: 501 },
|
||||
);
|
||||
return NextResponse.json({ error: { code: "auth_not_configured", message: "AUTH_SECRET saknas" } }, { status: 501 });
|
||||
}
|
||||
const devKey = process.env.DEV_LOGIN_KEY;
|
||||
// Constant-time compare: a plain !== leaks the number of matching leading
|
||||
// bytes via response timing, letting an attacker recover DEV_LOGIN_KEY
|
||||
// byte-by-byte instead of an all-or-nothing guess.
|
||||
const keyBuf = Buffer.from(typeof key === 'string' ? key : '');
|
||||
const devKeyBuf = Buffer.from(devKey ?? '');
|
||||
const keysMatch =
|
||||
Boolean(devKey) && keyBuf.length === devKeyBuf.length && timingSafeEqual(keyBuf, devKeyBuf);
|
||||
// byte-by-byte instead of it being an all-or-nothing guess.
|
||||
const keyBuf = Buffer.from(typeof key === "string" ? key : "");
|
||||
const devKeyBuf = Buffer.from(devKey ?? "");
|
||||
const keysMatch = Boolean(devKey) && keyBuf.length === devKeyBuf.length && timingSafeEqual(keyBuf, devKeyBuf);
|
||||
if (!keysMatch) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'forbidden', message: 'Dev-login kräver giltig nyckel' } },
|
||||
{ status: 403 },
|
||||
);
|
||||
return NextResponse.json({ error: { code: "forbidden", message: "Dev-login kräver giltig nyckel" } }, { status: 403 });
|
||||
}
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: { code: 'bad_request', message: 'email krävs' } }, { status: 400 });
|
||||
return NextResponse.json({ error: { code: "bad_request", message: "email krävs" } }, { status: 400 });
|
||||
}
|
||||
const token = await signSession({ email, exp: Date.now() + 7 * 24 * 60 * 60 * 1000 });
|
||||
const token = await signSession({ email, iat: Date.now() });
|
||||
const res = NextResponse.json({ ok: true, email });
|
||||
res.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
res.cookies.set(SESSION_COOKIE, token, { httpOnly: true, sameSite: "lax", secure: true, path: "/", maxAge: 60 * 60 * 24 * 7 });
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySession, SESSION_COOKIE } from '@/lib/auth';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifySession, SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
// GET /api/auth/me — current session (or null).
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
const session = await verifySession(token);
|
||||
return NextResponse.json({
|
||||
user: session
|
||||
? {
|
||||
email: session.email ?? null,
|
||||
sub: typeof session.sub === 'string' ? session.sub : null,
|
||||
tenantId: typeof session.tenantId === 'string' ? session.tenantId : null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return NextResponse.json({ user: session ? { email: session.email } : null });
|
||||
}
|
||||
|
||||
+20
-23
@@ -1,30 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { engineFetch, hasEngine } from "@/lib/engine";
|
||||
import { engineFetch } from "@/lib/engine";
|
||||
|
||||
// An unavailable or malformed screening source is never evidence of no match.
|
||||
// GET /api/screen?q=Name — OpenSanctions screening (sanctions/PEP). VERIFY
|
||||
// commercial terms before production volume. Defers to engine when running.
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = req.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||
if (!q) return NextResponse.json({ error: { code: "bad_request", message: "q krävs" } }, { status: 400 });
|
||||
const unavailable = () => NextResponse.json({ error: { code: "source_unavailable", message: "Screeningkällan är inte tillgänglig" } }, { status: 503 });
|
||||
const q = req.nextUrl.searchParams.get("q") ?? "";
|
||||
if (!q) return NextResponse.json({ matches: [] });
|
||||
|
||||
const eng = await engineFetch(`/screen?q=${encodeURIComponent(q)}`);
|
||||
if (eng && eng.ok) return NextResponse.json(await eng.json());
|
||||
|
||||
try {
|
||||
if (hasEngine()) {
|
||||
const eng = await engineFetch(`/screen?q=${encodeURIComponent(q)}`);
|
||||
if (!eng?.ok) return unavailable();
|
||||
const data: unknown = await eng.json();
|
||||
if (!data || typeof data !== "object" || (!Array.isArray((data as { matches?: unknown }).matches) || (data as { source_status?: unknown }).source_status !== "ok")) return unavailable();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
const r = await fetch(`https://api.opensanctions.org/search/default?q=${encodeURIComponent(q)}&limit=5`, {
|
||||
headers: { "User-Agent": "0pt/0.1 (+https://0pt.app; SIAX)" }, cache: "no-store",
|
||||
headers: { "User-Agent": "0pt/0.1 (+https://0pt.app; SIAX)" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!r.ok) return unavailable();
|
||||
const data: unknown = await r.json();
|
||||
if (!data || typeof data !== "object" || !Array.isArray((data as { results?: unknown }).results)) return unavailable();
|
||||
const results = (data as { results: unknown[] }).results;
|
||||
if (results.some((item) => !item || typeof item !== "object" || Array.isArray(item))) return unavailable();
|
||||
return NextResponse.json({ source: "opensanctions", matches: results.map((item) => {
|
||||
const x = item as { caption?: unknown; score?: unknown; schema?: unknown };
|
||||
return { name: x.caption ?? null, score: x.score ?? null, schema: x.schema ?? null };
|
||||
}) });
|
||||
} catch { return unavailable(); }
|
||||
if (!r.ok) return NextResponse.json({ matches: [], source: "opensanctions" });
|
||||
const data = (await r.json()) as { results?: { caption?: string; score?: number; schema?: string }[] };
|
||||
return NextResponse.json({
|
||||
source: "opensanctions",
|
||||
matches: (data.results ?? []).map((x) => ({ name: x.caption ?? null, score: x.score ?? null, schema: x.schema ?? null })),
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ matches: [], source: "opensanctions" });
|
||||
}
|
||||
}
|
||||
|
||||
+25
-21
@@ -1,30 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { engineFetch, hasEngine } from "@/lib/engine";
|
||||
import { engineFetch } from "@/lib/engine";
|
||||
|
||||
// A missing record is distinct from an unavailable or malformed source.
|
||||
// GET /api/vehicles?plate=XX-99-YY — RDW (NL) open vehicle data (technical/fleet
|
||||
// only, no owner PII). Defers to the engine when running; else calls RDW directly.
|
||||
export async function GET(req: NextRequest) {
|
||||
const plate = (req.nextUrl.searchParams.get("plate") ?? "").toUpperCase().replace(/[-\s]/g, "").replace(/[^A-Z0-9]/g, "");
|
||||
if (!plate) return NextResponse.json({ error: { code: "bad_request", message: "plate krävs" } }, { status: 400 });
|
||||
const unavailable = () => NextResponse.json({ error: { code: "source_unavailable", message: "Fordonskällan är inte tillgänglig" } }, { status: 503 });
|
||||
|
||||
const eng = await engineFetch(`/vehicles/${plate}`);
|
||||
if (eng && eng.ok) return NextResponse.json(await eng.json());
|
||||
|
||||
try {
|
||||
if (hasEngine()) {
|
||||
const eng = await engineFetch(`/vehicles/${plate}`);
|
||||
if (!eng?.ok) return unavailable();
|
||||
const data: unknown = await eng.json();
|
||||
if (!data || typeof data !== "object" || Array.isArray(data) || (data as { source_status?: unknown }).source_status !== "ok") return unavailable();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
const r = await fetch(`https://opendata.rdw.nl/resource/m9d7-ebf2.json?kenteken=${encodeURIComponent(plate)}`, {
|
||||
headers: { "User-Agent": "0pt/0.1 (+https://0pt.app; SIAX)" }, cache: "no-store",
|
||||
headers: { "User-Agent": "0pt/0.1 (+https://0pt.app; SIAX)" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!r.ok) return unavailable();
|
||||
const data: unknown = await r.json();
|
||||
if (!Array.isArray(data) || data.some((item) => !item || typeof item !== "object" || Array.isArray(item))) return unavailable();
|
||||
if (!data.length) return NextResponse.json({ vehicle: null });
|
||||
const d = data[0] as Record<string, string>;
|
||||
if (d.datum_eerste_toelating != null && typeof d.datum_eerste_toelating !== "string") return unavailable();
|
||||
return NextResponse.json({ source: "rdw", vehicle: { reg_plate: plate, market: "NL", make: d.merk ?? null,
|
||||
model: d.handelsbenaming ?? null, year: (d.datum_eerste_toelating ?? "").slice(0, 4) || null },
|
||||
provenance: { level: 3, source: "rdw" } });
|
||||
} catch { return unavailable(); }
|
||||
if (!r.ok) return NextResponse.json({ vehicle: null }, { status: 200 });
|
||||
const arr = (await r.json()) as Record<string, string>[];
|
||||
if (!arr.length) return NextResponse.json({ vehicle: null });
|
||||
const d = arr[0];
|
||||
return NextResponse.json({
|
||||
source: "rdw",
|
||||
vehicle: {
|
||||
reg_plate: plate, market: "NL",
|
||||
make: d.merk ?? null, model: d.handelsbenaming ?? null,
|
||||
year: (d.datum_eerste_toelating ?? "").slice(0, 4) || null,
|
||||
},
|
||||
provenance: { level: 3, source: "rdw" },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ vehicle: null, error: "fetch_failed" }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/version — deployment identity for automated verification.
|
||||
*
|
||||
* Returns only non-sensitive build identity (no env values, no config). The
|
||||
* scheduled prod-smoke workflow compares `commit` with the current main SHA so
|
||||
* "the container is up" is never confused with "the intended commit is live".
|
||||
*/
|
||||
export async function GET() {
|
||||
const commit =
|
||||
process.env.SOURCE_COMMIT ?? process.env.GIT_COMMIT_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? null;
|
||||
return NextResponse.json(
|
||||
{
|
||||
service: '0pt-web',
|
||||
commit,
|
||||
built_at: process.env.BUILD_TIME ?? null,
|
||||
},
|
||||
{ headers: { 'cache-control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type Stripe from 'stripe';
|
||||
import { getStripe } from '@/lib/stripe';
|
||||
import { getOrder, setStatusIfCurrent } from '@/lib/orders-store';
|
||||
import { isSiaxError } from '../../siax-error-mapping';
|
||||
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||
import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store';
|
||||
import {
|
||||
StripeCreditVerificationError,
|
||||
verifyStripeCreditPurchase,
|
||||
} from '@/src/document-workbench/stripe-credit-verification';
|
||||
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Credit purchases are granted ONLY from Stripe's own re-read of the session
|
||||
* and payment intent. The webhook body is a trigger, never proof of price or
|
||||
* payment. The ledger command is idempotent, so duplicate deliveries are safe.
|
||||
*/
|
||||
async function handleCreditPurchase(
|
||||
stripe: NonNullable<ReturnType<typeof getStripe>>,
|
||||
session: Stripe.Checkout.Session,
|
||||
) {
|
||||
const tenantId = session.metadata?.tenant_id;
|
||||
const purchaseId = session.metadata?.purchase_id;
|
||||
if (!tenantId || !purchaseId) {
|
||||
return NextResponse.json({ error: 'unmatched_session' }, { status: 422 });
|
||||
}
|
||||
const url = repositoryDatabaseUrl();
|
||||
if (!url) {
|
||||
return NextResponse.json({ received: false, reason: 'credits_not_configured' }, { status: 503 });
|
||||
}
|
||||
try {
|
||||
const store = new PostgresCreditPurchaseStore(getRepositorySql(url));
|
||||
const snapshot = await store.get(tenantId, purchaseId);
|
||||
if (!snapshot) {
|
||||
// Do not acknowledge a paid purchase we cannot bind to a server snapshot.
|
||||
return NextResponse.json({ received: false, reason: 'purchase_not_found' }, { status: 503 });
|
||||
}
|
||||
if (snapshot.checkoutSessionId !== session.id) {
|
||||
return NextResponse.json({ error: 'session_mismatch' }, { status: 422 });
|
||||
}
|
||||
const command = await verifyStripeCreditPurchase(stripe, snapshot, { tenantId });
|
||||
const result = await new PostgresCreditLedger(getRepositorySql(url)).apply(command);
|
||||
return NextResponse.json({ received: true, credited: !result.replay, balance: result.balance });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Credit reconciliation failed';
|
||||
if (error instanceof StripeCreditVerificationError) {
|
||||
const terminal = ['mismatch', 'not_paid', 'refunded', 'unsupported'].includes(error.reason);
|
||||
return NextResponse.json(
|
||||
{ received: false, reason: error.reason, message },
|
||||
{ status: terminal ? 422 : 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ received: false, reason: 'ledger_unavailable', message }, { status: 503 });
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
|
||||
import { isSiaxError } from "../../siax-error-mapping";
|
||||
|
||||
// POST /api/webhooks/stripe (docs/04) — verify signature, advance order status.
|
||||
export async function POST(req: NextRequest) {
|
||||
const stripe = getStripe();
|
||||
const whSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
const sig = req.headers.get('stripe-signature');
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
if (!stripe || !whSecret) {
|
||||
// Never acknowledge an event that was not signature-verified and processed.
|
||||
// A non-2xx response keeps Stripe's delivery retry/reconciliation path open.
|
||||
return NextResponse.json({ received: false, reason: 'not_configured' }, { status: 503 });
|
||||
return NextResponse.json({ received: false, reason: "not_configured" }, { status: 503 });
|
||||
}
|
||||
if (!sig) {
|
||||
return NextResponse.json({ error: 'missing_signature' }, { status: 400 });
|
||||
return NextResponse.json({ error: "missing_signature" }, { status: 400 });
|
||||
}
|
||||
const body = await req.text();
|
||||
let event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, sig, whSecret);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'bad_signature' }, { status: 400 });
|
||||
return NextResponse.json({ error: "bad_signature" }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
event.type === 'checkout.session.completed' ||
|
||||
event.type === 'checkout.session.async_payment_succeeded'
|
||||
) {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const s = event.data.object as Stripe.Checkout.Session;
|
||||
const oid = s.metadata?.order_id;
|
||||
const kind = s.metadata?.kind;
|
||||
if (kind === '0pt_credit_purchase') {
|
||||
return handleCreditPurchase(stripe, s);
|
||||
if (!oid || (kind !== "deposit" && kind !== "balance")) {
|
||||
return NextResponse.json({ error: "unmatched_session" }, { status: 422 });
|
||||
}
|
||||
if (!oid || (kind !== 'deposit' && kind !== 'balance')) {
|
||||
return NextResponse.json({ error: 'unmatched_session' }, { status: 422 });
|
||||
}
|
||||
if (s.payment_status !== 'paid') {
|
||||
return NextResponse.json({ error: 'payment_not_confirmed' }, { status: 503 });
|
||||
if (s.payment_status !== "paid") {
|
||||
return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 });
|
||||
}
|
||||
// 0pt#53: durable-store failure -> documented non-2xx (never an opaque 500,
|
||||
// and never a silent received:true for a paid event that was NOT processed —
|
||||
@@ -100,52 +41,47 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
order = await getOrder(oid);
|
||||
} catch (err: unknown) {
|
||||
if (isSiaxError(err))
|
||||
return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 });
|
||||
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
||||
throw err;
|
||||
}
|
||||
if (!order) return NextResponse.json({ error: 'order_not_found' }, { status: 503 });
|
||||
if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 });
|
||||
if (!order.tenant_id || !order.buyer_subject) {
|
||||
// A legacy anonymous draft is not a payable order. Do not silently
|
||||
// acknowledge a paid event until an audited owner-remediation path exists.
|
||||
return NextResponse.json({ error: 'order_owner_missing' }, { status: 503 });
|
||||
return NextResponse.json({ error: "order_owner_missing" }, { status: 503 });
|
||||
}
|
||||
const expectedAmount = kind === 'balance' ? order.balance_ore : order.deposit_ore;
|
||||
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== 'sek') {
|
||||
return NextResponse.json({ error: 'payment_mismatch' }, { status: 422 });
|
||||
const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore;
|
||||
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") {
|
||||
return NextResponse.json({ error: "payment_mismatch" }, { status: 422 });
|
||||
}
|
||||
if (kind === 'balance') {
|
||||
if (order.status !== 'accepted' && order.status !== 'delivered') {
|
||||
return NextResponse.json({ error: 'invalid_order_state' }, { status: 409 });
|
||||
if (kind === "balance") {
|
||||
if (order.status !== "accepted" && order.status !== "delivered") {
|
||||
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
|
||||
}
|
||||
let delivered = false;
|
||||
try {
|
||||
delivered = order.status === 'accepted' && !!(await setStatusIfCurrent(oid, 'accepted', 'delivered'));
|
||||
delivered = order.status === "accepted" && !!(await setStatusIfCurrent(oid, "accepted", "delivered"));
|
||||
} catch (err: unknown) {
|
||||
if (isSiaxError(err))
|
||||
return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 });
|
||||
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
||||
throw err;
|
||||
}
|
||||
if (order.status === 'accepted' && !delivered) {
|
||||
return NextResponse.json({ error: 'order_update_failed' }, { status: 503 });
|
||||
if (order.status === "accepted" && !delivered) {
|
||||
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
|
||||
}
|
||||
} else {
|
||||
const alreadyAdvanced = ['in_production', 'review', 'revision', 'accepted', 'delivered'].includes(
|
||||
order.status,
|
||||
);
|
||||
if (order.status !== 'draft' && order.status !== 'deposit_pending' && !alreadyAdvanced) {
|
||||
return NextResponse.json({ error: 'invalid_order_state' }, { status: 409 });
|
||||
const alreadyAdvanced = ["in_production", "review", "revision", "accepted", "delivered"].includes(order.status);
|
||||
if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) {
|
||||
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
|
||||
}
|
||||
let advanced = true;
|
||||
try {
|
||||
advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, 'in_production'));
|
||||
advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, "in_production"));
|
||||
} catch (err: unknown) {
|
||||
if (isSiaxError(err))
|
||||
return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 });
|
||||
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
||||
throw err;
|
||||
}
|
||||
if (!advanced) {
|
||||
return NextResponse.json({ error: 'order_update_failed' }, { status: 503 });
|
||||
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FileDropzone } from '@siax/ui/components/file-dropzone';
|
||||
import { useT } from '@/components/locale-provider';
|
||||
import { PortalShell } from '@/components/portal-shell';
|
||||
|
||||
/**
|
||||
* /portal/dokument — the customer's document workspace.
|
||||
*
|
||||
* Three honest steps:
|
||||
* 1. Upload the original file (real bytes, multipart) into DOC0.
|
||||
* 2. Describe the structure you want and the fields to extract.
|
||||
* 3. Review the evidence-bound output and download JSON/CSV.
|
||||
*
|
||||
* Nothing on this page fabricates success: a 503 means the capability is not
|
||||
* configured, and an "outcome unknown" upload is shown as exactly that.
|
||||
*/
|
||||
|
||||
type Receipt = {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
checksum_sha256: string;
|
||||
pipeline_stage: string;
|
||||
};
|
||||
|
||||
type FieldRow = { name: string; type: 'string' | 'integer' | 'boolean' | 'decimal' | 'date' };
|
||||
|
||||
type Evidence = {
|
||||
field: string;
|
||||
sourceId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
transform: string;
|
||||
};
|
||||
|
||||
type StructureResponse = {
|
||||
status: 'mechanically_validated' | 'needs_review' | 'outcome_unknown';
|
||||
value?: Record<string, string | number | boolean>;
|
||||
evidence?: Evidence[];
|
||||
issues?: { field: string; code: string; row?: number }[];
|
||||
model?: {
|
||||
modelExecuted: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
credits?: { reserved: number; charged: number; balance?: { available: number; reserved: number } };
|
||||
export?: { media_type: string; content: string };
|
||||
message?: string;
|
||||
error?: string;
|
||||
reservation_id?: string;
|
||||
};
|
||||
|
||||
type ModelsResponse = {
|
||||
models: string[];
|
||||
data_classes: string[];
|
||||
destination: string;
|
||||
external_transfer_allowed: boolean;
|
||||
max_input_tokens: number;
|
||||
max_output_tokens: number;
|
||||
price_book_version: string | null;
|
||||
};
|
||||
|
||||
const TYPES: FieldRow['type'][] = ['string', 'integer', 'decimal', 'date', 'boolean'];
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const t = useT();
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [receipt, setReceipt] = useState<Receipt | null>(null);
|
||||
const [uploadNote, setUploadNote] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
|
||||
// One idempotency key per selected file: a retry (double click, network
|
||||
// error, reload-and-pick-the-same-file) returns the SAME document instead of
|
||||
// creating a duplicate. Picking a different file resets it.
|
||||
const uploadKey = useRef<string | null>(null);
|
||||
|
||||
const [instruction, setInstruction] = useState('');
|
||||
const [fields, setFields] = useState<FieldRow[]>([{ name: 'organization', type: 'string' }]);
|
||||
const [models, setModels] = useState<ModelsResponse | null>(null);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [modelId, setModelId] = useState('');
|
||||
const [dataClass, setDataClass] = useState('low');
|
||||
const [format, setFormat] = useState<'json' | 'csv'>('json');
|
||||
const [structuring, setStructuring] = useState(false);
|
||||
const [result, setResult] = useState<StructureResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('0pt.project_id');
|
||||
if (stored) setProjectId(stored);
|
||||
fetch('/api/0pt/documents/models', { cache: 'no-store' })
|
||||
.then(async (r) => {
|
||||
const body = (await r.json()) as ModelsResponse & { message?: string };
|
||||
if (!r.ok) {
|
||||
setModelsError(body.message ?? 'Modellpolicyn är inte konfigurerad');
|
||||
return;
|
||||
}
|
||||
setModels(body);
|
||||
setModelId((current) => current || body.models[0] || '');
|
||||
setDataClass((current) =>
|
||||
body.data_classes.includes(current) ? current : (body.data_classes[0] ?? 'low'),
|
||||
);
|
||||
})
|
||||
.catch(() => setModelsError('Kunde inte läsa modellpolicyn'));
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(async () => {
|
||||
if (!file || uploading) return;
|
||||
if (!projectId.trim()) {
|
||||
setError(t({ en: 'A project id is required', sv: 'Ett projekt-id krävs' }));
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setUploadNote(null);
|
||||
setUnknownOutcome(null);
|
||||
setReceipt(null);
|
||||
window.localStorage.setItem('0pt.project_id', projectId.trim());
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
form.set('project_id', projectId.trim());
|
||||
const key = uploadKey.current ?? crypto.randomUUID();
|
||||
uploadKey.current = key;
|
||||
form.set('idempotency_key', key);
|
||||
form.set('classification', 'restricted');
|
||||
try {
|
||||
const response = await fetch('/api/0pt/documents/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'x-request-id': crypto.randomUUID() },
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as Record<string, unknown> & {
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (response.status === 201) {
|
||||
setReceipt({
|
||||
document_id: String(body.document_id),
|
||||
version_id: String(body.version_id),
|
||||
checksum_sha256: String(body.checksum_sha256),
|
||||
pipeline_stage: String(body.pipeline_stage),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (response.status === 202) {
|
||||
setUnknownOutcome(body.observation ?? body);
|
||||
setUploadNote(
|
||||
t({
|
||||
en: 'The upload may have been accepted. It is not confirmed.',
|
||||
sv: 'Uppladdningen kan ha tagits emot. Den är inte bekräftad.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUploadNote(
|
||||
body.message ?? body.error ?? t({ en: 'Upload failed', sv: 'Uppladdningen misslyckades' }),
|
||||
);
|
||||
} catch {
|
||||
setUploadNote(t({ en: 'Upload failed', sv: 'Uppladdningen misslyckades' }));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [file, projectId, t, uploading]);
|
||||
|
||||
const structure = useCallback(async () => {
|
||||
if (!receipt || structuring) return;
|
||||
if (!instruction.trim()) {
|
||||
setError(t({ en: 'Describe the structure you want', sv: 'Beskriv strukturen du vill ha' }));
|
||||
return;
|
||||
}
|
||||
if (!modelId) {
|
||||
setError(t({ en: 'No approved model is configured', sv: 'Ingen godkänd modell är konfigurerad' }));
|
||||
return;
|
||||
}
|
||||
setStructuring(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetch('/api/0pt/documents/structure', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-request-id': crypto.randomUUID() },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId.trim(),
|
||||
document_id: receipt.document_id,
|
||||
version_id: receipt.version_id,
|
||||
checksum_sha256: receipt.checksum_sha256,
|
||||
pipeline_stage: receipt.pipeline_stage,
|
||||
instruction,
|
||||
fields,
|
||||
model_id: modelId,
|
||||
data_class: dataClass,
|
||||
format,
|
||||
}),
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as StructureResponse;
|
||||
if (response.ok || response.status === 202) {
|
||||
setResult(body);
|
||||
} else {
|
||||
setError(
|
||||
body.message ?? body.error ?? t({ en: 'Structuring failed', sv: 'Struktureringen misslyckades' }),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setError(t({ en: 'Structuring failed', sv: 'Struktureringen misslyckades' }));
|
||||
} finally {
|
||||
setStructuring(false);
|
||||
}
|
||||
}, [receipt, structuring, instruction, modelId, dataClass, projectId, fields, format, t]);
|
||||
|
||||
function download() {
|
||||
if (!result?.export) return;
|
||||
const blob = new Blob([result.export.content], { type: result.export.media_type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `0pt-${receipt?.document_id ?? 'result'}.${format}`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function addField() {
|
||||
if (fields.length >= 100) return;
|
||||
setFields((current) => [...current, { name: `field_${current.length + 1}`, type: 'string' }]);
|
||||
}
|
||||
|
||||
function updateField(index: number, patch: Partial<FieldRow>) {
|
||||
setFields((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
setFields((current) => current.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
const card = 'border border-border bg-background p-6';
|
||||
const label = 'font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground';
|
||||
const input =
|
||||
'mt-2 w-full border border-border bg-background px-3 py-2 font-mono text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
return (
|
||||
<PortalShell variant="customer" title={t({ en: 'Documents', sv: 'Dokument' })}>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<p className="max-w-2xl text-pretty text-[15px] leading-relaxed text-muted-foreground">
|
||||
{t({
|
||||
en: 'Upload a file, describe how it should be structured, and get evidence-bound data you can download.',
|
||||
sv: 'Ladda upp en fil, beskriv hur den ska struktureras och få evidensbunden data som du kan ladda ner.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* 1 · upload */}
|
||||
<section className={card}>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||
{t({ en: '1 · Source', sv: '1 · Underlag' })}
|
||||
</h2>
|
||||
{receipt ? (
|
||||
<span
|
||||
className="font-mono text-[10px] uppercase tracking-[0.18em]"
|
||||
style={{ color: 'var(--accent-red)' }}
|
||||
>
|
||||
{t({ en: 'uploaded', sv: 'uppladdad' })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className={label}>{t({ en: 'project id', sv: 'projekt-id' })}</span>
|
||||
<input
|
||||
className={input}
|
||||
value={projectId}
|
||||
onChange={(event) => setProjectId(event.target.value)}
|
||||
placeholder="prj_…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<FileDropzone
|
||||
className="mt-4"
|
||||
label={t({ en: 'choose a file', sv: 'välj en fil' })}
|
||||
hint={file ? file.name : t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
|
||||
disabled={uploading}
|
||||
onFiles={(files) => {
|
||||
uploadKey.current = null;
|
||||
setFile(files[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={upload}
|
||||
disabled={!file || uploading}
|
||||
className="mt-4 border border-foreground bg-foreground px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em] text-background disabled:opacity-40"
|
||||
>
|
||||
{uploading ? t({ en: 'uploading…', sv: 'laddar upp…' }) : t({ en: 'upload', sv: 'ladda upp' })}
|
||||
</button>
|
||||
|
||||
{receipt ? (
|
||||
<dl className="mt-4 grid gap-2 font-mono text-[11px] text-muted-foreground sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="uppercase tracking-[0.16em]">document</dt>
|
||||
<dd className="break-all">{receipt.document_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="uppercase tracking-[0.16em]">version</dt>
|
||||
<dd className="break-all">{receipt.version_id}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="uppercase tracking-[0.16em]">sha-256</dt>
|
||||
<dd className="break-all">{receipt.checksum_sha256}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{uploadNote ? <p className="mt-4 text-[14px] text-muted-foreground">{uploadNote}</p> : null}
|
||||
{unknownOutcome ? (
|
||||
<pre className="mt-3 overflow-auto border border-border bg-muted/30 p-3 font-mono text-[11px]">
|
||||
{JSON.stringify(unknownOutcome, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* 2 · structure */}
|
||||
<section className={card}>
|
||||
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||
{t({ en: '2 · Desired structure', sv: '2 · Önskad struktur' })}
|
||||
</h2>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className={label}>{t({ en: 'instruction', sv: 'instruktion' })}</span>
|
||||
<textarea
|
||||
className={`${input} min-h-[96px]`}
|
||||
value={instruction}
|
||||
maxLength={4000}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder={t({
|
||||
en: 'One row per agreement with parties, term, price and notice period.',
|
||||
sv: 'En rad per avtal med parter, avtalstid, pris och uppsägningstid.',
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-4">
|
||||
<span className={label}>{t({ en: 'fields', sv: 'fält' })}</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-[160px] flex-1 border border-border bg-background px-3 py-2 font-mono text-[12px]"
|
||||
value={field.name}
|
||||
onChange={(event) => updateField(index, { name: event.target.value })}
|
||||
aria-label={`field ${index + 1}`}
|
||||
/>
|
||||
<select
|
||||
className="border border-border bg-background px-3 py-2 font-mono text-[12px]"
|
||||
value={field.type}
|
||||
onChange={(event) => updateField(index, { type: event.target.value as FieldRow['type'] })}
|
||||
>
|
||||
{TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeField(index)}
|
||||
className="px-2 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="mt-2 font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground"
|
||||
>
|
||||
+ {t({ en: 'add field', sv: 'lägg till fält' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className={label}>{t({ en: 'model', sv: 'modell' })}</span>
|
||||
<select
|
||||
className={input}
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
disabled={!models}
|
||||
>
|
||||
{models?.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t({ en: 'format', sv: 'format' })}</span>
|
||||
<select
|
||||
className={input}
|
||||
value={format}
|
||||
onChange={(event) => setFormat(event.target.value as 'json' | 'csv')}
|
||||
>
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t({ en: 'data class', sv: 'dataklass' })}</span>
|
||||
<select
|
||||
className={input}
|
||||
value={dataClass}
|
||||
onChange={(event) => setDataClass(event.target.value)}
|
||||
disabled={!models}
|
||||
>
|
||||
{(models?.data_classes ?? [dataClass]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{t({
|
||||
en: 'External models are refused for high/restricted data.',
|
||||
sv: 'Externa modeller nekas för hög/restricted data.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
{modelsError ? <p className="mt-3 text-[13px] text-muted-foreground">{modelsError}</p> : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={structure}
|
||||
disabled={!receipt || structuring}
|
||||
className="mt-4 border border-foreground bg-foreground px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em] text-background disabled:opacity-40"
|
||||
>
|
||||
{structuring
|
||||
? t({ en: 'structuring…', sv: 'strukturerar…' })
|
||||
: t({ en: 'structure', sv: 'strukturera' })}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 3 · result */}
|
||||
{(result || error) && (
|
||||
<section className={card}>
|
||||
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||
{t({ en: '3 · Result', sv: '3 · Resultat' })}
|
||||
</h2>
|
||||
{error ? <p className="mt-4 text-[14px]">{error}</p> : null}
|
||||
|
||||
{result?.status === 'mechanically_validated' ? (
|
||||
<>
|
||||
<div className="mt-4 overflow-auto border border-border">
|
||||
<table className="w-full border-collapse text-left font-mono text-[12px]">
|
||||
<tbody>
|
||||
{Object.entries(result.value ?? {}).map(([key, value]) => (
|
||||
<tr key={key} className="border-b border-border last:border-b-0">
|
||||
<th className="px-3 py-2 font-normal uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{key}
|
||||
</th>
|
||||
<td className="px-3 py-2">{String(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{result.evidence?.length ?? 0} {t({ en: 'evidence spans', sv: 'evidensspann' })}
|
||||
{result.credits ? ` · ${result.credits.charged} credits` : ''}
|
||||
</p>
|
||||
{result.export ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={download}
|
||||
className="mt-4 border border-foreground px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||
>
|
||||
{t({ en: 'download', sv: 'ladda ner' })} {format.toUpperCase()}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{result?.status === 'needs_review' ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-[14px]">
|
||||
{t({
|
||||
en: 'The model output did not pass evidence validation. Nothing was exported.',
|
||||
sv: 'Modellens svar klarade inte evidenskontrollen. Inget exporterades.',
|
||||
})}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 font-mono text-[11px] text-muted-foreground">
|
||||
{result.issues?.map((issue, index) => (
|
||||
<li key={index}>
|
||||
{issue.field || '(root)'} · {issue.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result?.status === 'outcome_unknown' ? (
|
||||
<p className="mt-4 text-[14px]">{result.message}</p>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
+175
-5
@@ -1,10 +1,180 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, 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";
|
||||
|
||||
/**
|
||||
* The old `/portal/files` page only sent metadata and always hit the 501
|
||||
* storage stub. Real uploads now live at `/portal/dokument`; keep the old URL
|
||||
* working instead of leaving a second, non-functional upload surface.
|
||||
* /portal/files — kundens filer. En kvadratisk mono drag-drop-zon + fillista.
|
||||
* Vid släpp: POST /api/uploads { name, mime, bytes }. Om lagring inte är kopplad
|
||||
* svarar endpointen 501 → lugn not "aktiveras vid lagring", aldrig en feltoast.
|
||||
* Uppladdade filer matar matchning & berikning.
|
||||
*/
|
||||
|
||||
interface FileRow {
|
||||
file_id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
bytes: number;
|
||||
status: string; // received · matching · enriched
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
const PROV_BY_STATUS: Record<string, string> = {
|
||||
enriched: "var(--prov-3)",
|
||||
matching: "var(--prov-2)",
|
||||
received: "var(--prov-1)",
|
||||
};
|
||||
|
||||
function fmtBytes(b: number): string {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} kB`;
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
redirect('/portal/dokument');
|
||||
const t = useT();
|
||||
const [files, setFiles] = useState<FileRow[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [storageOff, setStorageOff] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false);
|
||||
try {
|
||||
const r = await fetch("/api/uploads", { cache: "no-store" });
|
||||
if (r.status === 501) { setStorageOff(true); setFiles([]); return; }
|
||||
setFiles(await r.json());
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const upload = useCallback(async (list: FileList | File[]) => {
|
||||
const arr = Array.from(list);
|
||||
if (arr.length === 0 || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const f of arr) {
|
||||
const r = await fetch("/api/uploads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: f.name, mime: f.type || "application/octet-stream", bytes: f.size }),
|
||||
});
|
||||
// Env-guarded: storage not wired yet → calm note, no error.
|
||||
if (r.status === 501) { setStorageOff(true); break; }
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup degrades to the storage note, never a hard error toast.
|
||||
setStorageOff(true);
|
||||
}
|
||||
setBusy(false);
|
||||
void load();
|
||||
}, [busy, load]);
|
||||
|
||||
function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer?.files?.length) void upload(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function openPicker() { inputRef.current?.click(); }
|
||||
|
||||
return (
|
||||
<PortalShell variant="customer" title={t({ en: "Files", sv: "Filer" })}>
|
||||
{/* dropzone */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t({ en: "Drop files to upload", sv: "Släpp filer för att ladda upp" })}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openPicker(); } }}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className="relative flex aspect-[3/1] w-full cursor-pointer flex-col items-center justify-center border border-dashed bg-background text-center transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
style={{
|
||||
borderColor: dragging ? "var(--accent-red)" : "var(--border)",
|
||||
backgroundColor: dragging ? "color-mix(in srgb, var(--accent-red) 4%, var(--background))" : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-5 inline-block h-3.5 w-3.5"
|
||||
style={{ backgroundColor: dragging ? "var(--accent-red)" : "var(--foreground)" }}
|
||||
/>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{busy
|
||||
? t({ en: "uploading…", sv: "laddar upp…" })
|
||||
: dragging
|
||||
? t({ en: "drop here", sv: "släpp här" })
|
||||
: t({ en: "drag here · or click", sv: "dra hit · eller klicka" })}
|
||||
</p>
|
||||
<p className="mt-3 max-w-sm text-pretty text-[15px] leading-relaxed text-muted-foreground">
|
||||
{t({ en: "CSV, Excel, lists.", sv: "CSV, Excel, listor." })}
|
||||
<br />{t({ en: "Fed straight into matching & enrichment.", sv: "Matas in i matchning & berikning." })}
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storageOff ? (
|
||||
<div className="mt-4 flex items-center gap-3 border border-border bg-secondary/40 px-4 py-3">
|
||||
<span aria-hidden className="inline-block h-2 w-2 shrink-0" style={{ backgroundColor: "var(--muted-foreground)" }} />
|
||||
<p className="font-mono text-[11px] leading-relaxed tracking-tight text-muted-foreground">
|
||||
{t({ en: "File upload activates once storage is connected.", sv: "Filuppladdning aktiveras när lagring kopplas." })}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* file list */}
|
||||
<div className="mt-8">
|
||||
{error ? (
|
||||
<ErrorState onRetry={load} />
|
||||
) : !files ? (
|
||||
<LoadingSkeleton rows={4} />
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t({ en: "No files yet.", sv: "Inga filer än." })}
|
||||
line={t({ en: "Drag in a list. It feeds straight into matching and enrichment.", sv: "Dra in en lista. Den matas direkt in i matchning och berikning." })}
|
||||
/>
|
||||
) : (
|
||||
<div className="border border-border">
|
||||
<div className="grid grid-cols-[2fr_1fr_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: "File", sv: "Fil" })}</span><span>{t({ en: "Type", sv: "Typ" })}</span><span>{t({ en: "Size", sv: "Storlek" })}</span><span>{t({ en: "Status", sv: "Status" })}</span>
|
||||
</div>
|
||||
{files.map((f) => (
|
||||
<div key={f.file_id} className="grid grid-cols-[2fr_1fr_1fr_1fr] items-center border-b border-border px-4 py-3 text-sm transition-colors duration-200 last:border-b-0 hover:bg-secondary">
|
||||
<span className="truncate text-[15px] tracking-tight">{f.name}</span>
|
||||
<span className="truncate font-mono text-[11px] text-muted-foreground">{f.mime.split("/").pop()}</span>
|
||||
<span className="font-mono text-[12px] tabular-nums text-muted-foreground">{fmtBytes(f.bytes)}</span>
|
||||
<span className="inline-flex items-center gap-2 font-mono text-[10.5px] uppercase tracking-[0.08em]">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 shrink-0"
|
||||
style={{ backgroundColor: PROV_BY_STATUS[f.status] ?? "var(--prov-0)" }}
|
||||
/>
|
||||
{f.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{t({ en: "Uploads are deduplicated against your file · no cost until you order", sv: "Uppladdat dedupliceras mot din fil · ingen kostnad förrän du beställer" })}{files && files.length ? ` · ${formatSEK(0)} ${t({ en: "so far", sv: "hittills" })}` : ""}
|
||||
</p>
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useT } from '@/components/locale-provider';
|
||||
import { PortalShell } from '@/components/portal-shell';
|
||||
import { formatSEK } from '@/lib/data';
|
||||
|
||||
type Pack = { id: string; credits: number; amount_minor: number };
|
||||
|
||||
export default function CreditsPage() {
|
||||
const t = useT();
|
||||
const [balance, setBalance] = useState<{ available: number; reserved: number } | null>(null);
|
||||
const [balanceNote, setBalanceNote] = useState<string | null>(null);
|
||||
const [packs, setPacks] = useState<Pack[]>([]);
|
||||
const [purchasesEnabled, setPurchasesEnabled] = useState(true);
|
||||
const [currency, setCurrency] = useState('SEK');
|
||||
const [packsNote, setPacksNote] = useState<string | null>(null);
|
||||
const [buying, setBuying] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/0pt/credits/balance', { cache: 'no-store' });
|
||||
const body = (await r.json()) as { available?: number; reserved?: number; message?: string };
|
||||
if (r.ok) setBalance({ available: body.available ?? 0, reserved: body.reserved ?? 0 });
|
||||
else
|
||||
setBalanceNote(
|
||||
body.message ?? t({ en: 'Credits are not available', sv: 'Krediter är inte tillgängliga' }),
|
||||
);
|
||||
} catch {
|
||||
setBalanceNote(t({ en: 'Could not read the balance', sv: 'Kunde inte läsa saldot' }));
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/0pt/credits/packs', { cache: 'no-store' });
|
||||
const body = (await r.json()) as {
|
||||
packs?: Pack[];
|
||||
currency?: string;
|
||||
purchases_enabled?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
if (r.ok && body.packs) {
|
||||
setPacks(body.packs);
|
||||
setCurrency(body.currency ?? 'SEK');
|
||||
setPurchasesEnabled(body.purchases_enabled !== false);
|
||||
} else {
|
||||
setPacksNote(
|
||||
body.message ??
|
||||
t({ en: 'Credit packs are not configured', sv: 'Kreditpaket är inte konfigurerade' }),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setPacksNote(t({ en: 'Could not read credit packs', sv: 'Kunde inte läsa kreditpaket' }));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = params.get('status');
|
||||
if (state === 'success') {
|
||||
setStatus(
|
||||
t({
|
||||
en: 'Payment received. Credits are added when Stripe confirms the payment.',
|
||||
sv: 'Betalning mottagen. Krediter läggs till när Stripe bekräftar betalningen.',
|
||||
}),
|
||||
);
|
||||
} else if (state === 'cancelled') {
|
||||
setStatus(t({ en: 'Checkout cancelled.', sv: 'Betalningen avbröts.' }));
|
||||
}
|
||||
}, [load, t]);
|
||||
|
||||
const buy = useCallback(
|
||||
async (packId: string) => {
|
||||
setBuying(packId);
|
||||
setStatus(null);
|
||||
try {
|
||||
const response = await fetch('/api/0pt/credits/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ pack_id: packId }),
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as { checkout_url?: string; message?: string };
|
||||
if (response.ok && body.checkout_url) {
|
||||
window.location.href = body.checkout_url;
|
||||
return;
|
||||
}
|
||||
setStatus(
|
||||
body.message ?? t({ en: 'Checkout is not available', sv: 'Betalning är inte tillgänglig' }),
|
||||
);
|
||||
} catch {
|
||||
setStatus(t({ en: 'Checkout is not available', sv: 'Betalning är inte tillgänglig' }));
|
||||
} finally {
|
||||
setBuying(null);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const card = 'border border-border bg-background p-6';
|
||||
|
||||
return (
|
||||
<PortalShell variant="customer" title={t({ en: 'Credits', sv: 'Krediter' })}>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<section className={card}>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t({ en: 'balance', sv: 'saldo' })}
|
||||
</span>
|
||||
{balance ? (
|
||||
<p className="mt-3 font-mono text-3xl tracking-[-0.02em]">
|
||||
{balance.available}
|
||||
<span className="ml-2 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t({ en: 'available', sv: 'tillgängliga' })} · {balance.reserved}{' '}
|
||||
{t({ en: 'reserved', sv: 'reserverade' })}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-[14px] text-muted-foreground">{balanceNote ?? '…'}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className="text-lg font-semibold tracking-[-0.02em]">
|
||||
{t({ en: 'Buy credits', sv: 'Köp krediter' })}
|
||||
</h2>
|
||||
{packs.length === 0 ? (
|
||||
<p className="mt-3 text-[14px] text-muted-foreground">{packsNote ?? '…'}</p>
|
||||
) : (
|
||||
<ul className="mt-4 divide-y divide-border border-y border-border">
|
||||
{packs.map((pack) => (
|
||||
<li key={pack.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-mono text-[13px]">{pack.credits} credits</p>
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatSEK(pack.amount_minor / 100)}
|
||||
</p>
|
||||
</div>
|
||||
{purchasesEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => buy(pack.id)}
|
||||
disabled={buying !== null}
|
||||
className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.18em] disabled:opacity-40"
|
||||
>
|
||||
{buying === pack.id
|
||||
? t({ en: 'opening…', sv: 'öppnar…' })
|
||||
: t({ en: 'buy', sv: 'köp' })}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!purchasesEnabled ? (
|
||||
<p className="mt-4 text-[14px] text-muted-foreground">
|
||||
{t({
|
||||
en: 'Billing is not active yet (pilot). No payment can be taken.',
|
||||
sv: 'Betalning är inte aktiverad än (pilot). Ingen betalning kan tas.',
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{status ? <p className="mt-4 text-[14px] text-muted-foreground">{status}</p> : null}
|
||||
<p className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{currency}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
+1
-2
@@ -36,8 +36,7 @@ const STATUS_LABEL: Record<string, { en: string; sv: string }> = {
|
||||
const NAV: { label: { en: string; sv: string }; href: string }[] = [
|
||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
||||
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
||||
{ label: { en: "Documents", sv: "Dokument" }, href: "/portal/dokument" },
|
||||
{ label: { en: "Credits", sv: "Krediter" }, href: "/portal/krediter" },
|
||||
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
|
||||
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
||||
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
||||
{ label: { en: "Admin", sv: "Admin" }, href: "/admin" },
|
||||
|
||||
+37
-52
@@ -1,27 +1,26 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useT } from '@/components/locale-provider';
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useT } from "@/components/locale-provider";
|
||||
|
||||
export type NavItem = { label: { en: string; sv: string }; href: string };
|
||||
|
||||
const CUSTOMER_NAV: NavItem[] = [
|
||||
{ label: { en: 'Overview', sv: 'Översikt' }, href: '/portal' },
|
||||
{ label: { en: 'Subscriptions', sv: 'Prenumerationer' }, href: '/portal/subscriptions' },
|
||||
{ label: { en: 'Documents', sv: 'Dokument' }, href: '/portal/dokument' },
|
||||
{ label: { en: 'Credits', sv: 'Krediter' }, href: '/portal/krediter' },
|
||||
{ label: { en: 'Integrations', sv: 'Integrationer' }, href: '/portal/integrationer' },
|
||||
{ label: { en: 'Settings', sv: 'Inställningar' }, href: '/portal/settings' },
|
||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
||||
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
||||
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
|
||||
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
||||
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
||||
];
|
||||
|
||||
const ADMIN_NAV: NavItem[] = [
|
||||
{ label: { en: 'Overview', sv: 'Översikt' }, href: '/admin' },
|
||||
{ label: { en: 'Sources', sv: 'Källor' }, href: '/admin/sources' },
|
||||
{ label: { en: 'Jobs', sv: 'Jobb' }, href: '/admin/jobs' },
|
||||
{ label: { en: 'Tools', sv: 'Verktyg' }, href: '/admin/verktyg' },
|
||||
{ label: { en: 'Customers', sv: 'Kunder' }, href: '/admin/customers' },
|
||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/admin" },
|
||||
{ label: { en: "Sources", sv: "Källor" }, href: "/admin/sources" },
|
||||
{ label: { en: "Jobs", sv: "Jobb" }, href: "/admin/jobs" },
|
||||
{ label: { en: "Tools", sv: "Verktyg" }, href: "/admin/verktyg" },
|
||||
{ label: { en: "Customers", sv: "Kunder" }, href: "/admin/customers" },
|
||||
];
|
||||
|
||||
export function PortalShell({
|
||||
@@ -29,24 +28,24 @@ export function PortalShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
variant: 'customer' | 'admin';
|
||||
variant: "customer" | "admin";
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useT();
|
||||
const pathname = usePathname();
|
||||
const nav = variant === 'admin' ? ADMIN_NAV : CUSTOMER_NAV;
|
||||
const nav = variant === "admin" ? ADMIN_NAV : CUSTOMER_NAV;
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
fetch("/api/auth/me")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setEmail(d?.user?.email ?? null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
setEmail(null);
|
||||
}
|
||||
|
||||
@@ -56,29 +55,23 @@ export function PortalShell({
|
||||
<div className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-background px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tracking-[-0.04em]">0pt</span>
|
||||
<span className="inline-block h-1 w-1" style={{ backgroundColor: 'var(--accent-red)' }} />
|
||||
<span className="inline-block h-1 w-1" style={{ backgroundColor: "var(--accent-red)" }} />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{variant === 'admin' ? 'admin' : 'portal'}
|
||||
{variant === "admin" ? "admin" : "portal"}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
{email ? (
|
||||
<>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{email}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t({ en: 'Log out', sv: 'Logga ut' })}
|
||||
<button onClick={logout} className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
{t({ en: "Log out", sv: "Logga ut" })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href="/api/auth/login?next=/portal/dokument"
|
||||
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t({ en: 'Log in', sv: 'Logga in' })}
|
||||
</a>
|
||||
<Link href="/portal/settings" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
{t({ en: "Log in", sv: "Logga in" })}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,30 +86,24 @@ export function PortalShell({
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="border-l-2 px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors"
|
||||
style={{
|
||||
borderColor: active ? 'var(--accent-red)' : 'transparent',
|
||||
color: active ? 'var(--foreground)' : 'var(--muted-foreground)',
|
||||
borderColor: active ? "var(--accent-red)" : "transparent",
|
||||
color: active ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{t(n.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{variant === 'customer' ? (
|
||||
<Link
|
||||
href="/bestall"
|
||||
className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]"
|
||||
>
|
||||
{t({ en: 'New order', sv: 'Ny beställning' })}
|
||||
{variant === "customer" ? (
|
||||
<Link href="/bestall" className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]">
|
||||
{t({ en: "New order", sv: "Ny beställning" })}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/portal"
|
||||
className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t({ en: '← Customer view', sv: '← Kundvy' })}
|
||||
<Link href="/portal" className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
{t({ en: "← Customer view", sv: "← Kundvy" })}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
@@ -134,11 +121,9 @@ export function PortalShell({
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const color =
|
||||
status === 'delivered' || status === 'accepted'
|
||||
? 'var(--prov-3)'
|
||||
: status === 'in_production' || status === 'review'
|
||||
? 'var(--accent-red)'
|
||||
: 'var(--muted-foreground)';
|
||||
status === "delivered" || status === "accepted" ? "var(--prov-3)"
|
||||
: status === "in_production" || status === "review" ? "var(--accent-red)"
|
||||
: "var(--muted-foreground)";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em]">
|
||||
<span className="inline-block h-2 w-2" style={{ backgroundColor: color }} />
|
||||
|
||||
-277
@@ -1,277 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,36 +0,0 @@
|
||||
# SCAFFOLD-TODO — vad som är härlett och vad som måste verifieras
|
||||
|
||||
**Genererad:** 2026-11-10 av `siax-scaffold` (siax-standard,
|
||||
`packages/scaffold`) mot Golden Standard v2.0.
|
||||
|
||||
Den här filen finns för att en mall inte får låtsas veta saker. Varje rad nedan
|
||||
är antingen **härledd ur repot** (källan står i kolumn 3) eller ett
|
||||
**konservativt defaultvärde** som en människa måste bekräfta. Radera raden när
|
||||
den är verifierad — filen ska till slut kunna tas bort helt.
|
||||
|
||||
## Härledda värden i `siax.repo.json`
|
||||
|
||||
| Fält | Värde | Härlett ur |
|
||||
|---|---|---|
|
||||
| `repo` | `0PT` | befintlig `siax.repo.json` (VERSALER per REPO-08) |
|
||||
| `codeowners` | `["@sax3l"]` | befintlig `siax.repo.json` |
|
||||
| `category` | `platform` | befintlig `siax.repo.json` |
|
||||
| `subcategory` | `data-os` | befintlig `siax.repo.json` |
|
||||
| `operationalState` | `running` | befintlig `siax.repo.json` |
|
||||
| `domains` | `2 st` | befintlig `siax.repo.json` |
|
||||
| `children` | `4 st` | befintlig `siax.repo.json` |
|
||||
| `packageName` | `@siax/0pt` | befintlig `siax.repo.json` |
|
||||
|
||||
## Måste verifieras av människa
|
||||
|
||||
- [ ] Rätta den BEFINTLIGA `siax.repo.json` — den validerar inte mot schemat (REPO-03/04) och scaffolden skriver inte över den (AR-1): (roten): obligatoriskt fält saknas: tier; (roten): obligatoriskt fält saknas: runtime; (roten): obligatoriskt fält saknas: canonical; (roten): obligatoriskt fält saknas: parentRepo; (roten): obligatoriskt fält saknas: capabilities; (roten): obligatoriskt fält saknas: surfaces; (roten): obligatoriskt fält saknas: identity; (roten): obligatoriskt fält saknas: persistence; (roten): obligatoriskt fält saknas: deployment; (roten): obligatoriskt fält saknas: observability; (roten): obligatoriskt fält saknas: reliability; (roten): obligatoriskt fält saknas: engines; (roten): obligatoriskt fält saknas: licenses; (roten): obligatoriskt fält saknas: productionEvidence; /standardVersion: måste vara "3.0", är "2.0"; (roten): okänt fält "codeowners" (additionalProperties: false); (roten): obligatoriskt fält saknas: canonical
|
||||
|
||||
## Kontroller som scaffolden AVSIKTLIGT lämnar röda
|
||||
|
||||
Inga — de tre fall den här sektionen annars varnar för (`appId` satt till `null`, ett genererat kontraktsskelett, ett nyskrivet `backup-restore.md` utan datum) gäller inte i denna körning. Se "Måste verifieras" ovan för vad som faktiskt återstår.
|
||||
|
||||
|
||||
---
|
||||
|
||||
_Genererad av `siax-scaffold` (siax-standard). Varje `TODO:` i filen måste
|
||||
fyllas i av en människa. Samlad lista: `docs/compliance/SCAFFOLD-TODO.md`._
|
||||
@@ -1,81 +0,0 @@
|
||||
# Document workbench — upload → structure → validated export
|
||||
|
||||
The customer-facing slice that turns an uploaded file into evidence-bound data.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose | Fails closed with |
|
||||
|---|---|---|
|
||||
| `POST /api/0pt/documents/upload` | multipart upload of the original bytes into DOC0, recorded in a durable attempt journal before dispatch | `503 DOC0_NOT_CONFIGURED`, `503 PROJECT_AUTHORIZATION_NOT_CONFIGURED`, `503 UPLOAD_JOURNAL_NOT_CONFIGURED` |
|
||||
| `POST /api/0pt/documents/structure` | re-reads the DOC0 segment index, calls INF0, validates every field against an exact source span, reserves/settles credits | `503 WORKBENCH_NOT_CONFIGURED`, `503 MODEL_POLICY_NOT_CONFIGURED`, `503 CREDITS_NOT_CONFIGURED`, `402 INSUFFICIENT_CREDITS` |
|
||||
| `GET /api/0pt/documents/models` | the server-approved model routes and limits | `503 MODEL_POLICY_NOT_CONFIGURED` |
|
||||
| `GET /api/0pt/credits/balance` | tenant credit balance | `503 CREDITS_NOT_CONFIGURED` |
|
||||
| `GET /api/0pt/credits/packs` | sellable packs from the server-owned price book | `503 PRICE_BOOK_NOT_CONFIGURED` |
|
||||
| `POST /api/0pt/credits/checkout` | Stripe Checkout session + server price snapshot | `503 PRICE_BOOK_NOT_CONFIGURED`, `503 STRIPE_NOT_CONFIGURED` |
|
||||
| `POST /api/webhooks/stripe` | re-reads the session and payment intent, then grants credits idempotently | non-2xx until processed |
|
||||
|
||||
UI: `/portal/dokument` (upload, structure, review, download) and `/portal/krediter`
|
||||
(balance, packs, checkout). The portal signs in through Zitadel OIDC
|
||||
(`/api/auth/login` → `/api/auth/callback`); the resulting signed session cookie
|
||||
is accepted by middleware as a verified principal (Bearer still wins).
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **Tenant and actor come only from middleware's verified headers.** A customer
|
||||
route never trusts `tenant_id`/`actor_id` from the body.
|
||||
2. **Project access is checked by OpenFGA** (`@siax/access`), the same primitive
|
||||
DOC0 uses. Until the central relationship store is deployed, an explicit
|
||||
server-owned grant list (`OPT_PROJECT_GRANTS`) can be used instead.
|
||||
Unconfigured or unavailable means *denied*, never allowed.
|
||||
3. **The model never sees unverified text.** `Doc0SourceClient` verifies
|
||||
version, tenant, checksum, segment hashes and that the index was not
|
||||
truncated before anything is sent to INF0.
|
||||
4. **Every returned value must point at an exact span** in the source; otherwise
|
||||
the result is `needs_review` and nothing is exported.
|
||||
5. **Credits are reserved before the model runs and settled to measured usage.**
|
||||
An unknown model outcome keeps the reservation for reconciliation instead of
|
||||
releasing it.
|
||||
6. **No price is ever read from the client.** The price book is a versioned,
|
||||
server-owned JSON document (`OPT_CREDIT_PRICE_BOOK`).
|
||||
7. **Trial credits are a grant, not a payment.** `OPT_TRIAL_CREDITS=N` grants N
|
||||
credits once per tenant (`kind='grant'`, idempotency key `trial:<tenant>`),
|
||||
so the ledger is fully exercised before any real purchase. It is never read
|
||||
from a client and never looks like a Stripe payment.
|
||||
8. **Pilot mode is explicit.** `OPT_CREDITS_MODE=off` skips reserve/settle and
|
||||
reports `credits: null`. It is not a silent fallback and it does not enable
|
||||
purchases; unset/`on` requires the price book and the ledger.
|
||||
|
||||
## Reusable packages
|
||||
|
||||
- `packages/siax-access` (`@siax/access`) — framework-neutral OpenFGA
|
||||
relationship checks and a fail-closed `isProjectAccessAllowed`. Intended for
|
||||
DOC0, N0D and every other SIAX product instead of per-repo copies of
|
||||
`project-relationships.ts`.
|
||||
- `src/document-workbench/*` — upload coordination, DOC0 source reading, INF0
|
||||
admission, structured-output validation, export and credit ledger. These
|
||||
modules have no Next.js imports and can be lifted into a published package
|
||||
unchanged.
|
||||
|
||||
## Which AI path is used
|
||||
|
||||
Order:
|
||||
1. **Decision server model** (`DECISION_OLLAMA_URL` + `DECISION_OLLAMA_MODEL`) —
|
||||
the self-hosted Ollama on server-decision-01, used while the external model
|
||||
API is on hold. Internal only, no provider key, no per-token cost.
|
||||
2. **Live LiteLLM gateway** (`INF0_GATEWAY_LITELLM_KEY`).
|
||||
3. Native `INF0_URL` surface.
|
||||
|
||||
Admission — tenant/project scope, model allow-list, data class,
|
||||
external-transfer policy and token limits — runs locally in every path before
|
||||
the request leaves the process. Evidence spans are computed deterministically
|
||||
from the returned values when a small local model omits offsets: a value must
|
||||
occur exactly once in the source, otherwise the result is `needs_review`.
|
||||
|
||||
## What is still deliberately not claimed
|
||||
|
||||
- The output is `mechanically_validated`, not proven complete or semantically
|
||||
correct. OCR quality and coverage require review.
|
||||
- Refunds and chargebacks do not automatically revoke credits; no such handler
|
||||
is wired.
|
||||
- Format breadth is limited by DOC0's parser. A file DOC0 cannot parse is
|
||||
reported, never guessed.
|
||||
@@ -1,92 +0,0 @@
|
||||
# 0pt — produktionsberedskap
|
||||
|
||||
> Standardiserad gap-analys enligt SIAX Master Definition of Done.
|
||||
> Dokumentet är evidens, inte ett leveransgodkännande. Endast `PRODUCTION_READY` får kallas klart.
|
||||
|
||||
## Identitet
|
||||
|
||||
- Repo: `https://git.siax.io/admin/0pt.git`
|
||||
- Manifest: `siax.repo.v3.json` — `0PT`, operationalState `running`
|
||||
- Default branch: `main`
|
||||
- Bedömd nivå: **LIVE_VERIFIED** (manifestets DoD-anspråk: ja)
|
||||
- UI: ja — app, public
|
||||
- CI-arbetsflöden: 9
|
||||
|
||||
## Git- och arbetsyteläge
|
||||
|
||||
- Aktuell branch: `chore/surface-posts-20261110` — ahead 1 / behind 0
|
||||
- Ocommittade filer: 0 · otrackade: 0
|
||||
- Worktrees: 1
|
||||
- Lokala grenar: 2 · remote: 19
|
||||
- Omergeade lokala grenar:
|
||||
- `chore/surface-posts-20261110` (+1 commits)
|
||||
|
||||
## Livebevis (observerat 2026-11-09T23:48:50)
|
||||
|
||||
| Värd | HTTP | Klass | Titel | SIAX-tokens |
|
||||
|---|---|---|---|---|
|
||||
| `0pt.app` | 200 | public-ok | 0pt — your data | nej |
|
||||
| `api.0pt.app` | 200 | public-ok | 0pt — your data | nej |
|
||||
|
||||
401/403 är förväntat för autentiserade ytor; det är inte ett produktionsfel.
|
||||
0 betyder DNS/TLS/anslutning misslyckades och är alltid ett gap. SIAX-tokens avser markörer i serverad HTML; ett SPA-bygge kan bära dem i CSS/JS och då är kolumnen indikativ, inte bevis.
|
||||
|
||||
## Kod- och UI-signaler
|
||||
|
||||
- Trackade filer: 954
|
||||
- Testfiler: 178 · E2E: 0 · Playwright: False · Cypress: False
|
||||
- API-kontrakt: contracts/openapi.yaml
|
||||
- Migrationer: 0 · Health-referenser: 69
|
||||
- `@siax/ui`/tokens: True · a11y-verktyg: False · design-conformance: True
|
||||
- UI-tillståndsfiler: app/error.tsx, app/not-found.tsx
|
||||
- TODO/FIXME: 10
|
||||
- Exempel:
|
||||
- `.gitea/CODEOWNERS:10:# TODO: lägg till migrationskatalogen när den finns, t.ex.`
|
||||
- `.gitea/workflows/cd.yml:15:# The placeholder guard below remains as a fail-closed guard, not as a TODO.`
|
||||
- `.gitea/workflows/cd.yml:34: COOLIFY_APP_ID: ${{ vars.COOLIFY_APP_ID || 'TODO-no-coolify-app-provisioned-yet' }}`
|
||||
- `.gitea/workflows/cd.yml:91: if [ "$COOLIFY_APP_ID" = "TODO-no-coolify-app-provisioned-yet" ]; then`
|
||||
- `README.md:85: `TODO-no-coolify-app-provisioned-yet` literal. Both guard conditions in`
|
||||
|
||||
## Gap mot PRODUCTION_READY
|
||||
|
||||
- `NO_BACKUP_POLICY`
|
||||
- `UNMERGED_LOCAL_BRANCHES`
|
||||
- `UNPUSHED_COMMITS`
|
||||
|
||||
## Prioriterad backlogg
|
||||
|
||||
| Prio | Åtgärd | Konkret nästa steg |
|
||||
|---|---|---|
|
||||
| P0 | Landa grenar | Kör PR-flöde per gren, verifiera CI och merga; radera grenen lokalt och på origin efter merge. |
|
||||
| P1 | Backup | Definiera frekvens, retention, mål och kryptering i `siax.backup`. |
|
||||
| P1 | E2E i webbläsare | Lägg Playwright-flöde för inloggning och kärnflöde mot riktig miljö. |
|
||||
| P1 | Tillgänglighetsgrind | Aktivera eslint-plugin-jsx-a11y/axe och en blockerande WCAG 2.2-kontroll. |
|
||||
| P2 | Statusdokument | Lägg `docs/STATUS.md` med nivå, bevis, kända gap och nästa steg. |
|
||||
| P2 | Teknisk skuld | Prioritera bland 10 TODO/FIXME-markeringar; stäng eller gör dem spårbara som issues. |
|
||||
|
||||
## Checklista före PRODUCTION_READY
|
||||
|
||||
- [ ] Canonical owner och produktgräns dokumenterad
|
||||
- [ ] Versionerat API-/eventkontrakt med drift-check
|
||||
- [ ] Tenant-isolering verifierad med negativt test
|
||||
- [ ] Observability: metrics, loggar, spårning och larm
|
||||
- [ ] Backup och verkligt återställningstest
|
||||
- [ ] Live-E2E med verklig konsument, inte mock
|
||||
- [ ] Deployad commit-SHA och build-ID utlästa ur runtime
|
||||
- [ ] UI/UX granskad mot DES1GN och WCAG 2.2 där UI finns
|
||||
|
||||
## Verifieringskommandon
|
||||
|
||||
```bash
|
||||
mise run check && mise run verify && mise run build
|
||||
siax-doctor design --repo . --fail-on=block
|
||||
curl -fsS https://<prod-host>/health && curl -fsS https://<prod-host>/ready
|
||||
```
|
||||
|
||||
## Standardreferenser
|
||||
|
||||
- `siax-standard/standard/MASTER-DEFINITION-OF-DONE.md`
|
||||
- `siax-standard/standard/SIAX-REPO-OPERATIONS-CONTRACT.md`
|
||||
- `siax-standard/standard/registry/REUSE_FIRST_POLICY.md`
|
||||
|
||||
_Genererad 2026-11-09T23:41:39 från git, manifest, kodsignaler och live-prober. Omgenereras av `scripts/estate-production-audit.py`._
|
||||
@@ -103,5 +103,3 @@ psql -d opt < backup # eller: pg_restore -d opt opt-YYYY-MM-DD.dump
|
||||
1. Provisionera tillfällig Postgres (pgvector:pg16).
|
||||
2. `pg_restore` senaste dump. 3. Sätt `DATABASE_URL` i dev-instans.
|
||||
4. Verifiera orderflöde + en källfråga. 5. Logga resultatet i `docs/compliance/EVIDENCE/`.
|
||||
|
||||
**Senast bevisad restore: 2026-09-03** — P014-restoreövningen i docs/runbook/backup-restore.md avsnitt P014 (exekverad 2026-09-03): verklig backup -> isolerad engångstarget (pgvector/pgvector:pg16) -> restore, körd mot faktisk infrastruktur, inga persistenta artefakter kvarlämnade.
|
||||
|
||||
+51
-72
@@ -1,84 +1,63 @@
|
||||
# Runbook: Deploy — 0pt
|
||||
|
||||
> Status 2026-11-02: **live on the SIAX fleet (Coolify)** at `https://0pt.app`.
|
||||
> The automatic chain and the manual path are both verified: a signed image is
|
||||
> attested, Coolify is called, the container restarts on the new commit, and
|
||||
> `/api/version` reports it.
|
||||
> Mål: sätta 0pt.app på SIAX-flottan (Coolify) och avveckla Vercel i ordning.
|
||||
> Status 2026-08-10: `siax.config.json` deklarerar `target: coolify`; ingen
|
||||
> Coolify-app provisionerad; live-instansen körs fortfarande på Vercel.
|
||||
|
||||
## Pipeline (automatic)
|
||||
## 1. Förutsättningar (blockerare för cutover)
|
||||
|
||||
```
|
||||
git push main
|
||||
├─ ci.yml quality (typecheck, contrast, tests, build)
|
||||
│ integration (live OpenSearch + ClickHouse)
|
||||
│ security (Trivy, blocking)
|
||||
├─ security.yml push/PR/weekly scan
|
||||
├─ release.yml build + SBOM + blocking vuln scan + cosign attest + push
|
||||
│ → git.cloud.siax.io/sax3l/0pt:sha-<12>
|
||||
└─ cd.yml waits for THIS commit's attestation, verifies it against
|
||||
cosign.pub, then POSTs to Coolify
|
||||
prod-smoke.yml every 30 min: ready + db/stripe + running commit == main
|
||||
```
|
||||
- [ ] Coolify-app provisionerad i `cloud.siax.io` (build_pack: dockerfile,
|
||||
repo `git.siax.io/sax3l/0pt`, gren `main`).
|
||||
- [ ] `COOLIFY_APP_ID` (appens UUID) ifylld i `.gitea/workflows/cd.yml`.
|
||||
- [ ] `COOLIFY_TOKEN`-secret satt på repot (Gitea → Settings → Actions → Secrets).
|
||||
- [ ] Env-secrets inlagda i Coolify-appen från Infisical-projektet `0pt`
|
||||
(se backup/restore + incident runbooks).
|
||||
- [ ] DNS-cutover planerad: `0pt.app` → Coolify-appens ingress (eller Caddy).
|
||||
|
||||
- One run per ref (`concurrency`); `push` is main-only, PRs are covered by the
|
||||
`pull_request` event. Deploys have their own non-cancelling group so two
|
||||
runs never race the same app.
|
||||
- Dependency caches: pnpm store (Node jobs), npm (doc0/n0d), pip (0pt-motor).
|
||||
## 1b. Signerad-artefakt-grind (tillagd 2026-09-14, samma mönster som cl0ud/st0re/h0st)
|
||||
|
||||
## Manual deploy (proven)
|
||||
`.gitea/workflows/release.yml` (nytt) bygger, SBOM:ar (syft), signerar
|
||||
(cosign) och evidensbokför varje push till `main`, och pushar till
|
||||
`git.cloud.siax.io/sax3l/0pt:sha-<12>`. `cd.yml`s `deploy`-jobb har nu ett
|
||||
extra steg (efter guard-steget i punkt 1 ovan) som kräver att exakt denna
|
||||
commits avbildning finns i registret och att dess cosign-signatur
|
||||
verifierar (mot committad `cosign.pub`) innan Coolify överhuvudtaget
|
||||
anropas.
|
||||
|
||||
Trigger the same, signed path — do not POST to Coolify directly unless the
|
||||
pipeline itself is broken:
|
||||
Detta är byggt FÖRE Coolify-appen finns (se blockerarna i punkt 1) — så att
|
||||
grinden redan är på plats den dag appen provisioneras, i stället för att bli
|
||||
ett separat uppföljningsarbete. Samma ärliga begränsning som cl0ud/st0re/
|
||||
h0st: när appen väl provisioneras som `dockerfile` build pack (så som
|
||||
`siax.config.json` föreskriver) kör Coolify sin EGEN oberoende
|
||||
`docker build` från git-källan, inte den signerade avbildningen —
|
||||
`/api/v1/deploy` saknar digest-parameter helt. Sann digest-pinning skulle
|
||||
kräva att appen provisioneras som Coolifys separata `dockerimage`-resurstyp
|
||||
i stället, ett beslut som inte är fattat än. Se `.gitea/workflows/cd.yml`s
|
||||
"SIGNED-ARTIFACT GATE"-kommentar för den fullständiga skrivningen.
|
||||
|
||||
**Inte satt på `sax3l/0pt` ännu:** repo-secrets `REGISTRY_USER`,
|
||||
`REGISTRY_TOKEN`, `COSIGN_PRIVATE_KEY`, `COSIGN_PASSWORD`, `SIAX_CI_TOKEN` —
|
||||
`release.yml` fail-closed:ar på sin första körning tills dessa finns.
|
||||
|
||||
## 2. Deploy (när appen finns)
|
||||
|
||||
1. Push till `main` → `.gitea/workflows/cd.yml` POSTar till
|
||||
`https://cloud.siax.io/api/v1/deploy?uuid=$COOLIFY_APP_ID&force=false`,
|
||||
efter att den signerade-artefakt-grinden i punkt 1b gått grön.
|
||||
2. Följ run i Gitea Actions; verifiera att den slutar grön.
|
||||
3. Verifiera hälsa: `curl https://0pt.app/api/health` → `ok:true` och
|
||||
`db: configured` (när Postgres kopplats in).
|
||||
|
||||
## 3. Manuell redeploy (nöd)
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"ref":"main"}' \
|
||||
https://git.siax.io/api/v1/repos/admin/0pt/actions/workflows/cd.yml/dispatches
|
||||
curl -fsS -X POST "https://cloud.siax.io/api/v1/deploy?uuid=<UUID>&force=true" \
|
||||
-H "Authorization: Bearer $COOLIFY_TOKEN"
|
||||
```
|
||||
|
||||
Emergency only (bypasses the attestation gate; still builds the branch head):
|
||||
## 4. Vercel-avveckling (EFFER att Coolify kör stabilt)
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $COOLIFY_API_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<COOLIFY_APP_ID>"}' https://cloud.siax.io/api/v1/deploy
|
||||
```
|
||||
|
||||
## Verify a deploy
|
||||
|
||||
```bash
|
||||
curl -s https://0pt.app/api/ready # {"ready":true,"db":"ok","stripe":"ok",...}
|
||||
curl -s https://0pt.app/api/health # liveness envelope
|
||||
curl -s https://0pt.app/api/version # {"commit":"<sha>"}
|
||||
```
|
||||
|
||||
`commit` must equal the current `main` SHA. `prod-smoke.yml` enforces exactly
|
||||
that every 30 minutes; a green deploy is never confused with a current service.
|
||||
|
||||
## Rollback
|
||||
|
||||
Coolify builds from the branch head, so a rollback is a normal revert:
|
||||
|
||||
```bash
|
||||
git revert --no-edit <bad-sha> && git push origin main
|
||||
```
|
||||
|
||||
That runs the same chain and deploys the reverted tree. Coolify's own
|
||||
"redeploy previous deployment" is the break-glass alternative.
|
||||
|
||||
## Configuration (names only — values live in Coolify/Infisical)
|
||||
|
||||
- Coolify app: `dockerfile` build pack, repo `git.siax.io/sax3l/0pt`, branch
|
||||
`main`, health check `/api/health`, domains `0pt.app` + `api.0pt.app`.
|
||||
- Repository variable: `COOLIFY_APP_ID`.
|
||||
- Repository secrets: `COOLIFY_TOKEN`, `REGISTRY_USER`, `REGISTRY_TOKEN`,
|
||||
`COSIGN_PRIVATE_KEY`, `COSIGN_PASSWORD`, `SIAX_CI_TOKEN`.
|
||||
- App env: `DATABASE_URL`, `STRIPE_SECRET_KEY`, `AUTH_SECRET`… see `.env.example`
|
||||
for the full, documented list (DOC0, INF0/decision Ollama, OpenFGA/project
|
||||
grants, model policy, credit price book, OIDC).
|
||||
|
||||
## Known limitation
|
||||
|
||||
Coolify rebuilds from source; `/api/v1/deploy` has no digest parameter, so the
|
||||
verified digest is recorded as provenance for the deploy rather than pinned as
|
||||
the running bytes. True digest pinning would require a Coolify `dockerimage`
|
||||
resource — an infrastructure decision, not a CI-YAML change.
|
||||
1. Verifiera att ingen verklig trafik/data ligger bakom (2026-08-10:
|
||||
`/api/health` visar `db/queue: not_configured` — marknads-/dev-läge).
|
||||
2. Peka DNS om till Coolify. 3. Behåll Vercel-projektet tills certifierad
|
||||
paritet (katalog, orderflöde, betalningar). 4. Avveckla först därefter.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Legacy instance audit — 0pt (server5) and L0RE (server5) — 2026-11-07
|
||||
|
||||
Canonical runtimes: **0pt on server6** (Coolify app `shxd6lepxuxfsjsp0ath3z1g`,
|
||||
`0pt.app`) and **L0RE on server9** (`l0re.siax.io`). This document records the
|
||||
older instances on server5 and their disposition. It is not production
|
||||
documentation for either product.
|
||||
|
||||
## 1. 0pt legacy (server5)
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Containers | `0pt-api` (`0pt-api:c46ebbf`, created 2026-11-05), `0pt-web` (`:18080`) |
|
||||
| Database | `0pt-pg`, user/db `opt`, 11 MB |
|
||||
| Exposure | `0pt-web` only on `100.124.7.55:18080`; no DNS record, no reverse proxy |
|
||||
| Consumers | none found (`/etc/siax`, `/opt/siax`, site0 and L0RE deploy configs) |
|
||||
| Unique data | 32 canonical facts (30 GLEIF web observations + `body_type` YDL405 + `passmark.find_prime_numbers.v10`) |
|
||||
|
||||
**Data reconciliation:** all 32 facts were exported and re-imported through the
|
||||
canonical API (`POST /api/0pt/canonical/facts`) into the server6 deployment.
|
||||
Replay returned `duplicates: 32`, so the import is idempotent. Production now
|
||||
holds 34 facts (32 migrated + 2 verification observations) and 4 entities.
|
||||
|
||||
**Backup:** `server5:/var/backups/0pt-legacy/opt_legacy_20261107.dump.zst`
|
||||
(custom format, 32 KB) + offsite `server11:/home/backup/0pt/`.
|
||||
|
||||
**Action taken:** `docker update --restart=no` + `docker stop` for `0pt-api` and
|
||||
`0pt-web`. `0pt-pg` left running for forensics/rollback. `0pt.app` verified 200
|
||||
after the stop (served by server6).
|
||||
|
||||
**Full removal (requires owner decision):** snapshot/destroy `0pt-pg` volume
|
||||
after the retention window, remove the legacy compose project, revoke the old
|
||||
`INTERNAL_TOKEN` if it was ever stored on server5.
|
||||
|
||||
## 2. L0RE orphan (server5)
|
||||
|
||||
Documented separately in `admin/l0re` PR #485
|
||||
(`docs/runbook/l0re-orphan-decommission-20261107.md`): API stopped with
|
||||
`restart=no`, dump + restore proof (8 propositions/62 assertions), no unique
|
||||
legitimate data (pilot facts already re-projected to production). `l0re-pg` and
|
||||
`l0re-opensearch` remain for forensics.
|
||||
|
||||
## 3. Single-runtime rule
|
||||
|
||||
One primary runtime per logical service. A container image of a product running
|
||||
outside its registered Coolify app is a finding, not a fallback. Both server5
|
||||
instances were reachable only from the host, which is why the split survived
|
||||
until this audit.
|
||||
@@ -1,78 +0,0 @@
|
||||
# 0pt workload identity — målbild, nuläge och övergång
|
||||
|
||||
Status: **design klar, provisionering blockerad** (2026-11-07).
|
||||
Ägare: CL0UD (identitet) / 0pt (konsument).
|
||||
|
||||
## 1. Nuläge
|
||||
|
||||
0pt autentiserar maskin-till-maskin med en **delad hemlighet**:
|
||||
|
||||
- `X-Internal-Token` mot `INTERNAL_TOKEN` (samma värde i engine och edge).
|
||||
- Tenant-auktoritet kommer från serverkonfigurationen `INTERNAL_SERVICE_TENANTS`
|
||||
(se `src/auth/internal-service-tenant.ts`), aldrig från en klientheader.
|
||||
- Tom allowlist ⇒ ingen tenant-auktoritet ⇒ tenant-scopade skrivningar 403.
|
||||
|
||||
Det här är säkert men grovkornigt: en enda hemlighet representerar hela
|
||||
engine-processen, och varje ny konsument kräver en manuell allowlist-post.
|
||||
|
||||
## 2. Målbild (CL0UD workload identity)
|
||||
|
||||
CL0UD äger redan maskineriet:
|
||||
|
||||
- `POST /account/service-accounts` skapar en service account (identitet).
|
||||
- `POST /account/service-accounts/{id}/workload-token` mints en **kortlivad**
|
||||
(10 min default, 1 h max) workload-token vars `capabilities` är en delmängd av
|
||||
service accountens `platform.cl0ud_service_account_scopes.allowedCapabilities`.
|
||||
- `@siax/platform-core` `auth/workload.ts` är verifieringsadaptern; publika
|
||||
nycklar via JWKS.
|
||||
- `packages/capability-registry/src/workload-identities.ts` deklarerar
|
||||
arbetsbelastningsnamn och `aud:*`-audiences. Modulen är uttryckligen **inte**
|
||||
provisionerad och **inte** inkopplad i utfärdande eller verifiering än.
|
||||
|
||||
För 0pt innebär det:
|
||||
|
||||
| Egenskap | Idag | Mål |
|
||||
|---|---|---|
|
||||
| Credential | delad `INTERNAL_TOKEN` | CL0UD-workload-token, `aud:0pt`, 10 min |
|
||||
| Tenant | serverkonfigurerad allowlist | `tenant_id`-claim i verifierad token |
|
||||
| Capabilities | route-lista i middleware | `capabilities`-claim (t.ex. `0pt.canonical.write`) |
|
||||
| Rotation | manuell | automatisk (kortlivad token) |
|
||||
| Spårbarhet | delad hemlighet | per-arbetsbelastning (`0pt-engine`, `site0-pipeline`) |
|
||||
|
||||
## 3. Integrationsdesign (när provisionering är möjlig)
|
||||
|
||||
1. **Verifiering före route-logik.** Middleware accepterar
|
||||
`Authorization: Bearer <workload-token>` för `INTERNAL_SERVICE_ROUTES` om
|
||||
signaturen verifieras mot CL0UD JWKS, `aud` innehåller `0pt` och `exp` är
|
||||
giltig. Ingen klientheader får bidra till tenant.
|
||||
2. **Tenant från claim, inte från allowlist.** `tenant_id` i den verifierade
|
||||
tokenen sätts som `x-0pt-verified-tenant`; `INTERNAL_SERVICE_TENANTS`
|
||||
används endast för legacy `X-Internal-Token`.
|
||||
3. **Capabilities per route.** Route-metadata (`0pt.canonical.write`,
|
||||
`0pt.quality.run`) jämförs med tokenens `capabilities`. Saknad capability ⇒
|
||||
403, inte 401 (identiteten är giltig, behörigheten räcker inte).
|
||||
4. **Dual-accept under övergång.** Båda credentialtyperna accepteras tills
|
||||
engine och site0-pipeline mintar workload-tokens; därefter tas
|
||||
`X-Internal-Token` bort i en egen release med negativt test.
|
||||
5. **Negativa tester i samma svit som idag**
|
||||
(`tests/auth/internal-token-tenant-scope.test.ts`): fel `aud` ⇒ 401, utgången
|
||||
token ⇒ 401, tenant utanför claim ⇒ 403, saknad capability ⇒ 403.
|
||||
|
||||
## 4. Blockerare
|
||||
|
||||
- `/account/service-accounts` svarar **307 → /login** utan operator-session;
|
||||
ingen giltig `SIAX_TOKEN` finns i Infisical-miljön.
|
||||
- Zitadel admin-PAT (`/opt/siax-access/zitadel-admin-pat`) är invalid
|
||||
(AUTH-7fs1e), så nya machine users kan inte mintas via det flödet.
|
||||
- `workload-identities.ts` är deklaration-only: ingen identitet är
|
||||
`provisioned: true`.
|
||||
|
||||
Åtgärd kräver en operatörsinloggning i CL0UD-konsolen (eller en ny giltig
|
||||
admin-PAT) och är därför **EXTERNALLY_BLOCKED** från agentsidan.
|
||||
|
||||
## 5. Vad som inte ska göras
|
||||
|
||||
- Ingen egen JWT-signering i 0pt.
|
||||
- Ingen parallell identitetsplattform.
|
||||
- Ingen klientheader som bär tenant.
|
||||
- Ingen långlivad delad hemlighet som "tillfällig" lösning utan slutdatum.
|
||||
+10
-98
@@ -18,8 +18,6 @@
|
||||
// fabricated check; reporting "not_configured" would
|
||||
// hide that a value was provided. This is its own
|
||||
// honest state rather than either of those two.
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { dbUrl, getSql } from "./db";
|
||||
import { getStripe } from "./stripe";
|
||||
import { hasEngine } from "./engine";
|
||||
@@ -137,96 +135,15 @@ export async function checkStripe(
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis/queue reachability.
|
||||
*
|
||||
* This used to report `not_integrated` because no code opened REDIS_URL. The
|
||||
* readiness probe now performs a real round trip (AUTH when the URL carries a
|
||||
* password, then PING -> +PONG) using only `node:net`/`node:tls`, so a queue
|
||||
* that is configured but dead fails readiness instead of hiding behind
|
||||
* `not_integrated`. The probe is injectable for tests.
|
||||
* Redis/queue: presence-only, honestly. No queue client library is a
|
||||
* dependency of this package (grep-verified against package.json) and no
|
||||
* code path in this repo reads REDIS_URL to open a connection -- so there is
|
||||
* no real check to run. Reporting "ok" would fabricate a probe that doesn't
|
||||
* exist; this reports "not_integrated" instead of silently upgrading to a
|
||||
* pretend pass.
|
||||
*/
|
||||
export type QueueProbe = (redisUrl: string, timeoutMs: number) => Promise<void>;
|
||||
|
||||
export function redisPing(redisUrl: string, timeoutMs: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(redisUrl);
|
||||
} catch {
|
||||
reject(new Error("Invalid REDIS_URL"));
|
||||
return;
|
||||
}
|
||||
if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
|
||||
reject(new Error("Unsupported REDIS_URL scheme"));
|
||||
return;
|
||||
}
|
||||
const port = url.port ? Number(url.port) : 6379;
|
||||
if (!Number.isSafeInteger(port) || port <= 0 || port > 65535) {
|
||||
reject(new Error("Invalid REDIS_URL port"));
|
||||
return;
|
||||
}
|
||||
const socket =
|
||||
url.protocol === "rediss:"
|
||||
? tls.connect({ host: url.hostname, port, servername: url.hostname })
|
||||
: net.connect({ host: url.hostname, port });
|
||||
let settled = false;
|
||||
const done = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const timer = setTimeout(() => done(new Error("redis ping timed out")), timeoutMs);
|
||||
let buffer = "";
|
||||
socket.on("error", (error: Error) => done(error));
|
||||
socket.on("close", () => {
|
||||
if (!settled) done(new Error("redis connection closed before PONG"));
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
if (buffer.includes("+PONG")) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
if (buffer.startsWith("-")) {
|
||||
done(new Error(`redis error: ${buffer.trim()}`));
|
||||
return;
|
||||
}
|
||||
// AUTH succeeded (or was unnecessary) but PING has not answered yet.
|
||||
});
|
||||
socket.on("connect", () => {
|
||||
const password = url.password ? decodeURIComponent(url.password) : "";
|
||||
if (url.username && url.username !== "default" && !password) {
|
||||
done(new Error("redis username without password is not supported"));
|
||||
return;
|
||||
}
|
||||
// RESP arrays: correct for any password content, unlike inline commands.
|
||||
const encode = (parts: string[]): string =>
|
||||
`*${parts.length}\r\n${parts
|
||||
.map((part) => `$${Buffer.byteLength(part, "utf8")}\r\n${part}\r\n`)
|
||||
.join("")}`;
|
||||
const payload =
|
||||
(password ? encode(["AUTH", password]) : "") + encode(["PING"]);
|
||||
socket.write(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkQueue(
|
||||
probe: QueueProbe = redisPing,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<DepResult> {
|
||||
const redisUrl = process.env.REDIS_URL;
|
||||
if (!redisUrl) return { status: "not_configured" };
|
||||
const start = Date.now();
|
||||
try {
|
||||
await withTimeout(probe(redisUrl, timeoutMs), timeoutMs + 250, "redis ping");
|
||||
return { status: "ok", latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { status: "error", detail: errMessage(err), latencyMs: Date.now() - start };
|
||||
}
|
||||
export function checkQueue(): DepResult {
|
||||
return { status: process.env.REDIS_URL ? "not_integrated" : "not_configured" };
|
||||
}
|
||||
|
||||
export interface DependencyResults {
|
||||
@@ -237,13 +154,8 @@ export interface DependencyResults {
|
||||
}
|
||||
|
||||
export async function checkAllDependencies(): Promise<DependencyResults> {
|
||||
const [db, engine, stripe, queue] = await Promise.all([
|
||||
checkDatabase(),
|
||||
checkEngine(),
|
||||
checkStripe(),
|
||||
checkQueue(),
|
||||
]);
|
||||
return { db, engine, stripe, queue };
|
||||
const [db, engine, stripe] = await Promise.all([checkDatabase(), checkEngine(), checkStripe()]);
|
||||
return { db, engine, stripe, queue: checkQueue() };
|
||||
}
|
||||
|
||||
/** True when any dependency that IS configured/integrated is broken. */
|
||||
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Zitadel OIDC Authorization Code + PKCE for the 0pt portal.
|
||||
*
|
||||
* The portal previously had no way to obtain a verified principal: the
|
||||
* `/api/0pt/*` routes require a Bearer token, but the browser only had the
|
||||
* dev-only HMAC session cookie. This module implements the standard web login
|
||||
* so a real user can reach the document workspace.
|
||||
*
|
||||
* Secrets (client id/secret, AUTH_SECRET) are server-only env values.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
||||
import { deriveTenantId, zitadelIssuer } from '@/src/auth/zitadel';
|
||||
|
||||
export const OIDC_STATE_COOKIE = '0pt_oidc';
|
||||
export const OIDC_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
export interface OidcConfig {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export function appBaseUrl(): string {
|
||||
const configured = process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (configured && /^https?:\/\//.test(configured)) return configured.replace(/\/+$/, '');
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function readOidcConfig(): OidcConfig | undefined {
|
||||
const issuer = (process.env.ZITADEL_ISSUER || zitadelIssuer()).replace(/\/+$/, '');
|
||||
const clientId = process.env.ZITADEL_OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.ZITADEL_OIDC_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret || !/^https:\/\//.test(issuer)) return undefined;
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: `${appBaseUrl()}/api/auth/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
function base64url(buffer: Buffer): string {
|
||||
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function createPkce(): { verifier: string; challenge: string } {
|
||||
const verifier = base64url(randomBytes(48));
|
||||
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export function createOidcState(): { state: string; nonce: string } {
|
||||
return { state: base64url(randomBytes(24)), nonce: base64url(randomBytes(24)) };
|
||||
}
|
||||
|
||||
export function authorizeUrl(config: OidcConfig, state: string, nonce: string, challenge: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email urn:zitadel:iam:user:resourceowner',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${config.issuer}/oauth/v2/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
token_type?: unknown;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
config: OidcConfig,
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<{ idToken: string; accessToken?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
const response = await fetch(`${config.issuer}/oauth/v2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error('token_exchange_failed');
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
if (typeof data.id_token !== 'string' || !data.id_token) {
|
||||
throw new Error('token_missing_id_token');
|
||||
}
|
||||
return {
|
||||
idToken: data.id_token,
|
||||
...(typeof data.access_token === 'string' ? { accessToken: data.access_token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
|
||||
let jwksIssuer: string | undefined;
|
||||
|
||||
function keySet(issuer: string) {
|
||||
if (!jwks || jwksIssuer !== issuer) {
|
||||
jwks = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`));
|
||||
jwksIssuer = issuer;
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface VerifiedIdToken {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
email?: string;
|
||||
claims: JWTPayload;
|
||||
}
|
||||
|
||||
export async function verifyIdToken(
|
||||
config: OidcConfig,
|
||||
idToken: string,
|
||||
expectedNonce: string,
|
||||
accessToken?: string,
|
||||
): Promise<VerifiedIdToken> {
|
||||
const { payload } = await jwtVerify(idToken, keySet(config.issuer), {
|
||||
issuer: config.issuer,
|
||||
audience: config.clientId,
|
||||
});
|
||||
if (typeof payload.sub !== 'string' || !payload.sub) {
|
||||
throw new Error('id_token_missing_sub');
|
||||
}
|
||||
if (expectedNonce && payload.nonce !== expectedNonce) {
|
||||
throw new Error('id_token_nonce_mismatch');
|
||||
}
|
||||
const tenantId = deriveTenantId(payload);
|
||||
if (!tenantId) throw new Error('id_token_missing_tenant');
|
||||
return {
|
||||
sub: payload.sub,
|
||||
tenantId,
|
||||
...(typeof payload.email === 'string' ? { email: payload.email } : {}),
|
||||
claims: payload,
|
||||
};
|
||||
}
|
||||
+32
-121
@@ -1,159 +1,70 @@
|
||||
/**
|
||||
* Next.js auth seam for /api/0pt. Browser/user calls use verified bearer/session.
|
||||
* Explicit machine routes accept: (1) legacy X-Internal-Token + server-configured
|
||||
* single-tenant scope, or (2) verified CL0UD workload JWT (tenant/capabilities
|
||||
* from claims, never client headers). Deny by default.
|
||||
* Next.js auth seam for the complete /api/0pt capability surface.
|
||||
*
|
||||
* Customer calls use verified Zitadel bearer identity and receive immutable
|
||||
* x-0pt-verified-* headers. Explicit machine routes may instead use the
|
||||
* shared X-Internal-Token convention; mutation handlers verify it again for
|
||||
* defence in depth.
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
||||
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
|
||||
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
||||
import {
|
||||
SERVICE_CAPABILITIES_HEADER,
|
||||
SERVICE_TENANT_SCOPE_HEADER,
|
||||
resolveInternalServiceTenants,
|
||||
} from '@/src/auth/internal-service-tenant';
|
||||
import { isCl0udWorkloadConfigured, looksLikeCl0udWorkloadToken, verifyCl0udWorkloadToken } from '@/src/auth/cl0ud-workload';
|
||||
|
||||
assertAuthConfigSafe();
|
||||
export const config = { matcher: ['/api/0pt', '/api/0pt/:path*'] };
|
||||
|
||||
const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
|
||||
export const config = {
|
||||
matcher: ['/api/0pt', '/api/0pt/:path*'],
|
||||
};
|
||||
|
||||
const EXEMPT_PATTERNS: RegExp[] = [
|
||||
/^\/api\/health(\/|$)/,
|
||||
/^\/api\/ready(\/|$)/,
|
||||
/^\/api\/admin\/metrics(\/|$)/,
|
||||
];
|
||||
|
||||
/** Routes that may be reached with a service token instead of end-user bearer auth. */
|
||||
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
|
||||
/^\/api\/0pt\/quality\/bridge-run$/,
|
||||
/^\/api\/0pt\/canonical\/facts$/,
|
||||
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
|
||||
/^\/api\/0pt\/internal\/er\/candidates$/,
|
||||
/^\/api\/0pt\/search\/definitions$/,
|
||||
];
|
||||
|
||||
let _authDiagCount = 0;
|
||||
let internalScopeWarned = false;
|
||||
|
||||
/**
|
||||
* Headers that only middleware may set. Anything a client sends under these
|
||||
* names is a forgery attempt and is removed before forwarding — including on
|
||||
* the internal-token path, which previously passed the original header map
|
||||
* through unchanged.
|
||||
*/
|
||||
const VERIFIED_HEADERS = [
|
||||
'x-0pt-verified-tenant',
|
||||
'x-0pt-verified-sub',
|
||||
SERVICE_TENANT_SCOPE_HEADER,
|
||||
SERVICE_CAPABILITIES_HEADER,
|
||||
] as const;
|
||||
|
||||
function stripVerifiedHeaders(req: NextRequest): Headers {
|
||||
const forwarded = new Headers(req.headers);
|
||||
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
function bearerToken(req: NextRequest): string | null {
|
||||
const h = req.headers.get('authorization');
|
||||
if (!h?.toLowerCase().startsWith('bearer ')) return null;
|
||||
return h.slice(7).trim() || null;
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const path = req.nextUrl.pathname;
|
||||
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
|
||||
|
||||
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path))) {
|
||||
// (1) Legacy shared internal token (engine/n8n). Tenant authority is SERVER
|
||||
// CONFIG (INTERNAL_SERVICE_TENANTS), never a client header: an inbound
|
||||
// `X-0pt-Internal-Tenant` is ignored and stripped. With no configured
|
||||
// tenants the principal has no tenant authority and tenant-scoped writes
|
||||
// fail closed with 403.
|
||||
if (verifyInternalServiceToken(req.headers)) {
|
||||
const forwarded = stripVerifiedHeaders(req);
|
||||
const tenants = resolveInternalServiceTenants();
|
||||
if (tenants.length > 1) {
|
||||
// The shared X-Internal-Token identifies ONE service process, not a set
|
||||
// of independently authenticated principals. A multi-tenant allowlist
|
||||
// would let any holder of that shared token choose between all listed
|
||||
// tenants, which is exactly the client-controlled authority this guard
|
||||
// exists to remove. Multi-tenant service access requires a verified
|
||||
// workload-identity binding (CL0UD delegation contract); until that
|
||||
// exists, an ambiguous configuration fails closed.
|
||||
console.error(
|
||||
'[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists',
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'OPT_UNAUTHENTICATED',
|
||||
message:
|
||||
'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists',
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(','));
|
||||
if (tenants.length === 1) {
|
||||
forwarded.set('x-0pt-verified-tenant', tenants[0]);
|
||||
} else if (tenants.length === 0 && !internalScopeWarned) {
|
||||
internalScopeWarned = true;
|
||||
console.warn(
|
||||
'[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied',
|
||||
);
|
||||
}
|
||||
return NextResponse.next({ request: { headers: forwarded } });
|
||||
}
|
||||
|
||||
// (2) CL0UD workload JWT (dual-accept): tenant and capabilities come from
|
||||
// verified claims only. Attempted only when the issuer is configured or the
|
||||
// presented token is issued by it; an invalid one is a 401, never a
|
||||
// fall-through to an unauthenticated dev path.
|
||||
const bt = bearerToken(req);
|
||||
if (bt && (isCl0udWorkloadConfigured() || looksLikeCl0udWorkloadToken(bt))) {
|
||||
try {
|
||||
const principal = await verifyCl0udWorkloadToken(bt);
|
||||
const forwarded = stripVerifiedHeaders(req);
|
||||
forwarded.set('x-0pt-verified-sub', principal.sub);
|
||||
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
||||
forwarded.set(SERVICE_CAPABILITIES_HEADER, principal.capabilities.join(','));
|
||||
return NextResponse.next({ request: { headers: forwarded } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'workload token verification failed';
|
||||
return NextResponse.json({ error: 'OPT_UNAUTHENTICATED', message }, { status: 401 });
|
||||
}
|
||||
}
|
||||
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (_authDiagCount < 5) {
|
||||
_authDiagCount += 1;
|
||||
console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`);
|
||||
}
|
||||
|
||||
if (!req.headers.get('authorization')) {
|
||||
const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value);
|
||||
if (
|
||||
session &&
|
||||
typeof session.sub === 'string' &&
|
||||
typeof session.tenantId === 'string' &&
|
||||
typeof session.exp === 'number' &&
|
||||
session.exp > Date.now()
|
||||
) {
|
||||
const forwarded = stripVerifiedHeaders(req);
|
||||
forwarded.set('x-0pt-verified-sub', session.sub);
|
||||
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
||||
return NextResponse.next({ request: { headers: forwarded } });
|
||||
}
|
||||
const hasAuth = !!req.headers.get('authorization');
|
||||
console.log(
|
||||
`[0pt-auth] middleware path=${path} auth_header=${hasAuth} ` +
|
||||
`auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} ` +
|
||||
`node_env=${process.env.NODE_ENV ?? 'unset'} request#${_authDiagCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
const decision = await resolveAuthContext(req.headers);
|
||||
if (!decision.ok) {
|
||||
return NextResponse.json({ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) }, { status: decision.status });
|
||||
return NextResponse.json(
|
||||
{ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) },
|
||||
{ status: decision.status },
|
||||
);
|
||||
}
|
||||
|
||||
const principal = decision.ctx.principal;
|
||||
if (principal) {
|
||||
const forwarded = stripVerifiedHeaders(req);
|
||||
const forwarded = new Headers(req.headers);
|
||||
forwarded.set('x-0pt-verified-sub', principal.sub);
|
||||
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
||||
return NextResponse.next({ request: { headers: forwarded } });
|
||||
}
|
||||
|
||||
// No verified principal (explicit AUTH_ENFORCE=false development mode).
|
||||
// Never forward a client-named verified header, even in dev.
|
||||
return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } });
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
Generated
+8372
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -52,15 +52,13 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.19",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@siax/0pt-decision": "workspace:*",
|
||||
"@siax/access": "workspace:*",
|
||||
"@siax/auth-zitadel": "0.1.1",
|
||||
"@siax/charts": "^0.5.0",
|
||||
"@siax/collaboration": "0.3.0",
|
||||
"@siax/config": "^0.2.1",
|
||||
"@siax/contracts": "^0.12.1",
|
||||
"@siax/import-export": "^0.2.2",
|
||||
"@siax/contracts": "^0.10.0",
|
||||
"@siax/import-export": "^0.2.0",
|
||||
"@siax/tokens": "^0.1.2",
|
||||
"@siax/ui": "^0.16.13",
|
||||
"@siax/ui": "^0.14.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -70,7 +68,7 @@
|
||||
"input-otp": "1.4.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "16.4.1",
|
||||
"next": "16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.8",
|
||||
@@ -104,8 +102,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"sharp@<0.35.5": "0.35.5",
|
||||
"source-map-js@<1.2.2": "1.2.2"
|
||||
"@siax/ui>@siax/contracts": "0.10.0",
|
||||
"@siax/ui>@siax/import-export": "0.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "@siax/access",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Universal, framework-neutral project/tenant relationship checks for SIAX Cloud (OpenFGA). Reused by 0pt, DOC0, N0D and other products instead of per-repo copies.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* @siax/access idempotency primitives — the universal SIAX contract for any
|
||||
* operation that must either happen exactly once or replay its receipt.
|
||||
*
|
||||
* This package is derived from the pattern proven in 0pt's durable upload
|
||||
* attempts (prepare -> claim -> dispatch -> store receipt -> replay), then
|
||||
* generalized so DOC0, N0D and future products can adopt the identical shape
|
||||
* instead of re-implementing it.
|
||||
*
|
||||
* Rules encoded here:
|
||||
* 1. An operation identity binds EVERY result-affecting input (tenant,
|
||||
* project, actor, payload version). A changed payload under the same key
|
||||
* is a CONFLICT, never a replay of someone else's receipt.
|
||||
* 2. One claimant per key: returning `false` from `start` means another
|
||||
* caller owns the dispatch, and only the stored receipt (or bounded
|
||||
* observation) may be answered.
|
||||
* 3. A stored receipt is replayed verbatim; the caller never invents one.
|
||||
* 4. Commit boundaries: NEVER write a network command before the claim is
|
||||
* durably persisted, and never lose the claim after the command may have
|
||||
* committed.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Stable JSON canonicalization (sorted array stays sorted; v-prefix bumped on contract change). */
|
||||
export function operationFingerprint(parts: readonly unknown[], version = "siax-idempotency-v1"): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([version, ...parts]))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export interface OperationIdentity {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
actorId: string;
|
||||
/** Client-generated, transport-level key (<=256 visible ASCII). */
|
||||
idempotencyKey: string;
|
||||
/** Supply result-affecting inputs; the helper adds the scope fields itself. */
|
||||
payload: readonly unknown[];
|
||||
}
|
||||
|
||||
export function operationIdentity(operation: OperationIdentity): {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
actorId: string;
|
||||
idempotencyKey: string;
|
||||
fingerprint: string;
|
||||
} {
|
||||
if (
|
||||
!operation ||
|
||||
![operation.tenantId, operation.projectId, operation.actorId, operation.idempotencyKey].every(
|
||||
(value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,255}$/.test(value),
|
||||
) ||
|
||||
!Array.isArray(operation.payload)
|
||||
) {
|
||||
throw new Error("Invalid idempotent operation identity");
|
||||
}
|
||||
void operation.payload;
|
||||
return {
|
||||
tenantId: operation.tenantId,
|
||||
projectId: operation.projectId,
|
||||
actorId: operation.actorId,
|
||||
idempotencyKey: operation.idempotencyKey,
|
||||
fingerprint: operationFingerprint([
|
||||
operation.tenantId,
|
||||
operation.projectId,
|
||||
operation.actorId,
|
||||
...operation.payload,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export type OperationOutcome<R> =
|
||||
| { kind: "claimed"; execute: () => Promise<R> }
|
||||
| { kind: "replayed"; receipt: R }
|
||||
| { kind: "conflict"; expected?: string };
|
||||
|
||||
/** Structural contract for an attempt store. Matched by Postgres adapters per product. */
|
||||
export interface IdempotencyStore<Identity, Receipt> {
|
||||
/** Returns true when this caller won the right to dispatch; false = reuse replay path. */
|
||||
start(identity: Identity, fingerprint: string): Promise<boolean>;
|
||||
/** Persist after a verified downstream commit. Must never be called before. */
|
||||
save(identity: Identity, fingerprint: string, receipt: Receipt): Promise<void>;
|
||||
/** Load a stored receipt for replay (exact fingerprint match). */
|
||||
receipt(identity: Identity, fingerprint: string): Promise<Receipt | undefined>;
|
||||
}
|
||||
|
||||
export type IdempotentStoreItemIdentity<I> = {
|
||||
identity: I;
|
||||
fingerprint: string;
|
||||
};
|
||||
|
||||
export async function runIdempotentOperation<I, R>(input: {
|
||||
store: IdempotencyStore<I, R>;
|
||||
identity: I;
|
||||
fingerprint: string;
|
||||
/** Must be safe to call exactly once; a second call is never attempted here. */
|
||||
dispatch: () => Promise<R>;
|
||||
}): Promise<OperationOutcome<R>> {
|
||||
if (typeof input.dispatch !== "function") throw new Error("Dispatch is required");
|
||||
try {
|
||||
const won = await input.store.start(input.identity, input.fingerprint);
|
||||
if (!won) {
|
||||
const stored = await input.store.receipt(input.identity, input.fingerprint);
|
||||
return stored !== undefined
|
||||
? { kind: "replayed", receipt: stored }
|
||||
: { kind: "replayed", receipt: undefined as unknown as R };
|
||||
}
|
||||
} catch (error) {
|
||||
return { kind: "conflict", expected: error instanceof Error ? error.message : "store_unavailable" };
|
||||
}
|
||||
const receipt = await input.dispatch();
|
||||
try {
|
||||
await input.store.save(input.identity, input.fingerprint, receipt);
|
||||
} catch {
|
||||
/* The downstream commit is real; a journal-write failure must not turn a
|
||||
real receipt into an error. The next retry replays via the stored receipt
|
||||
if the save ever succeeds before a downstream resend. */
|
||||
}
|
||||
return { kind: "claimed", execute: async () => receipt };
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './relationships';
|
||||
export * from './idempotency';
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
|
||||
*
|
||||
* This is the reusable extraction of the pattern first proven in DOC0's
|
||||
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
|
||||
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
|
||||
* consume the exact same authorization primitive instead of copying it:
|
||||
*
|
||||
* 0pt — document upload + structuring scope
|
||||
* DOC0 — document/version/signature scope
|
||||
* N0D — job submission scope
|
||||
* any app — tenant/project membership
|
||||
*
|
||||
* IMPORTANT: a relationship match is ONE input to authorization, never a
|
||||
* complete decision. The caller must independently bind tenant and actor to a
|
||||
* verified principal (never to request headers), enforce tenant/project
|
||||
* status, capability, data-handling policy and an authoritative tuple
|
||||
* lifecycle. `resolveRelationship` fails closed: only an explicit
|
||||
* `relationships_match` is treated as allowed.
|
||||
*/
|
||||
|
||||
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
|
||||
|
||||
/** The outcome of the composite project check. */
|
||||
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
|
||||
|
||||
export interface ProjectRelationshipScope {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
|
||||
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
||||
const maxResponseBytes = 16 * 1024;
|
||||
|
||||
export interface OpenFgaRelationshipsOptions {
|
||||
baseUrl: string;
|
||||
storeId: string;
|
||||
modelId: string;
|
||||
serviceToken: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
|
||||
testOnlyAllowLoopbackHttp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
|
||||
* and never retries: an unavailable authorization source must stay visible to
|
||||
* the caller so it can fail closed.
|
||||
*/
|
||||
export class OpenFgaRelationships {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly storeId: string;
|
||||
private readonly modelId: string;
|
||||
private readonly serviceToken: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(options: OpenFgaRelationshipsOptions) {
|
||||
const url = new URL(options.baseUrl);
|
||||
const loopbackTest =
|
||||
process.env.NODE_ENV === 'test' &&
|
||||
options.testOnlyAllowLoopbackHttp === true &&
|
||||
url.protocol === 'http:' &&
|
||||
url.hostname === '127.0.0.1';
|
||||
if (
|
||||
(!loopbackTest && url.protocol !== 'https:') ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
typeof options.storeId !== 'string' ||
|
||||
typeof options.modelId !== 'string' ||
|
||||
!ulid.test(options.storeId) ||
|
||||
!ulid.test(options.modelId) ||
|
||||
typeof options.serviceToken !== 'string' ||
|
||||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
|
||||
(options.timeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
|
||||
) {
|
||||
throw new Error('Invalid trusted OpenFGA configuration');
|
||||
}
|
||||
this.baseUrl = url.toString().replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.storeId = options.storeId;
|
||||
this.modelId = options.modelId;
|
||||
this.serviceToken = options.serviceToken;
|
||||
this.timeoutMs = options.timeoutMs ?? 5_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both checks are necessary: project membership alone may inherit from a
|
||||
* different parent tenant if bad tuples associate a project with two
|
||||
* tenants. Returns `relationships_match` only when both hold.
|
||||
*/
|
||||
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
|
||||
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
|
||||
return 'denied';
|
||||
}
|
||||
const { tenantId, projectId, actorId } = scope;
|
||||
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
|
||||
return 'denied';
|
||||
}
|
||||
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
|
||||
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
|
||||
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
|
||||
return parent === 'allowed' ? 'relationships_match' : parent;
|
||||
}
|
||||
|
||||
/** One raw relationship check. Public so products can map their own tuples. */
|
||||
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
|
||||
if (
|
||||
![user, relation, object].every(
|
||||
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
|
||||
)
|
||||
) {
|
||||
return 'denied';
|
||||
}
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${this.serviceToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authorization_model_id: this.modelId,
|
||||
consistency: 'HIGHER_CONSISTENCY',
|
||||
tuple_key: { user, relation, object },
|
||||
}),
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!response.ok || !response.body) return 'unavailable';
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
total += part.value.byteLength;
|
||||
if (total > maxResponseBytes) {
|
||||
void reader.cancel().catch(() => undefined);
|
||||
return 'unavailable';
|
||||
}
|
||||
chunks.push(part.value);
|
||||
}
|
||||
const result: unknown = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
|
||||
);
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
Array.isArray(result) ||
|
||||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
|
||||
) {
|
||||
return 'unavailable';
|
||||
}
|
||||
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical environment variable names used across the fleet. Reads are
|
||||
* explicit (no implicit defaults) so a missing value is visible.
|
||||
*/
|
||||
export const OPENFGA_ENV = {
|
||||
baseUrl: 'OPENFGA_API_URL',
|
||||
storeId: 'OPENFGA_STORE_ID',
|
||||
modelId: 'OPENFGA_MODEL_ID',
|
||||
serviceToken: 'OPENFGA_AUTH_TOKEN',
|
||||
} as const;
|
||||
|
||||
export interface ProjectAccessOptions {
|
||||
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
|
||||
relationships?: OpenFgaRelationships;
|
||||
/**
|
||||
* Optional extra policy (project status, capability, data-handling). It runs
|
||||
* only after the relationship check matched; a rejection is final.
|
||||
*/
|
||||
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
/** Fail closed: only an explicit match with no policy objection is allowed. */
|
||||
export async function isProjectAccessAllowed(
|
||||
scope: ProjectRelationshipScope,
|
||||
options: ProjectAccessOptions,
|
||||
): Promise<boolean> {
|
||||
if (!options.relationships) return false;
|
||||
const result = await options.relationships.checkProject(scope);
|
||||
if (result !== 'relationships_match') return false;
|
||||
if (options.additionalPolicy) {
|
||||
try {
|
||||
return (await options.additionalPolicy(scope)) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
|
||||
* Returns `undefined` (fail closed) when any value is missing or invalid.
|
||||
*/
|
||||
export function openFgaFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
fetchImpl?: typeof fetch,
|
||||
): OpenFgaRelationships | undefined {
|
||||
const baseUrl = env[OPENFGA_ENV.baseUrl];
|
||||
const storeId = env[OPENFGA_ENV.storeId];
|
||||
const modelId = env[OPENFGA_ENV.modelId];
|
||||
const serviceToken = env[OPENFGA_ENV.serviceToken];
|
||||
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
|
||||
try {
|
||||
return new OpenFgaRelationships({
|
||||
baseUrl,
|
||||
storeId,
|
||||
modelId,
|
||||
serviceToken,
|
||||
...(fetchImpl ? { fetchImpl } : {}),
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Generated
+100
-131
@@ -5,8 +5,8 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
sharp@<0.35.5: 0.35.5
|
||||
source-map-js@<1.2.2: 1.2.2
|
||||
'@siax/ui>@siax/contracts': 0.10.0
|
||||
'@siax/ui>@siax/import-export': 0.2.0
|
||||
|
||||
importers:
|
||||
|
||||
@@ -105,12 +105,6 @@ importers:
|
||||
'@siax/0pt-decision':
|
||||
specifier: workspace:*
|
||||
version: link:packages/decision
|
||||
'@siax/access':
|
||||
specifier: workspace:*
|
||||
version: link:packages/siax-access
|
||||
'@siax/auth-zitadel':
|
||||
specifier: 0.1.1
|
||||
version: 0.1.1
|
||||
'@siax/charts':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -121,17 +115,17 @@ importers:
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1(tailwindcss@4.3.3)
|
||||
'@siax/contracts':
|
||||
specifier: ^0.12.1
|
||||
version: 0.12.1
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
'@siax/import-export':
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
'@siax/tokens':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@siax/ui':
|
||||
specifier: ^0.16.13
|
||||
version: 0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.5.5(postcss@8.5.28)
|
||||
@@ -160,8 +154,8 @@ importers:
|
||||
specifier: ^0.454.0
|
||||
version: 0.454.0(react@19.2.8)
|
||||
next:
|
||||
specifier: 16.4.1
|
||||
version: 16.4.1(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
specifier: 16.3.6
|
||||
version: 16.3.6(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -252,8 +246,6 @@ importers:
|
||||
|
||||
packages/schemas: {}
|
||||
|
||||
packages/siax-access: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
@@ -757,53 +749,53 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@next/env@16.4.1':
|
||||
resolution: {integrity: sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==}
|
||||
'@next/env@16.3.6':
|
||||
resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.4.1':
|
||||
resolution: {integrity: sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==}
|
||||
'@next/swc-darwin-arm64@16.3.6':
|
||||
resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.4.1':
|
||||
resolution: {integrity: sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==}
|
||||
'@next/swc-darwin-x64@16.3.6':
|
||||
resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.4.1':
|
||||
resolution: {integrity: sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==}
|
||||
'@next/swc-linux-arm64-gnu@16.3.6':
|
||||
resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.4.1':
|
||||
resolution: {integrity: sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==}
|
||||
'@next/swc-linux-arm64-musl@16.3.6':
|
||||
resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.4.1':
|
||||
resolution: {integrity: sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==}
|
||||
'@next/swc-linux-x64-gnu@16.3.6':
|
||||
resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.4.1':
|
||||
resolution: {integrity: sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==}
|
||||
'@next/swc-linux-x64-musl@16.3.6':
|
||||
resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.4.1':
|
||||
resolution: {integrity: sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==}
|
||||
'@next/swc-win32-arm64-msvc@16.3.6':
|
||||
resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.4.1':
|
||||
resolution: {integrity: sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==}
|
||||
'@next/swc-win32-x64-msvc@16.3.6':
|
||||
resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1549,21 +1541,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@siax/analytical-context@0.5.1':
|
||||
resolution: {integrity: sha512-bqc2NGPyzLVcu4VUgz1XYsyghSM4c3ck5gBVFSzeRYMhe2cj0nf5Fm5n/sFaOuhMaKdDnX/TIR2FO6f21lgufg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fanalytical-context/-/0.5.1/analytical-context-0.5.1.tgz}
|
||||
|
||||
'@siax/auth-core@0.1.0':
|
||||
resolution: {integrity: sha512-oTfEO4S6pv3rQ6bSh44+fRWPiwGr9nYCf6uWSBF8g5PFUspW6nr4wKQZVAkLNwfC0Pu6o+CH8ltsuu+fY8gOQA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-core/-/0.1.0/auth-core-0.1.0.tgz}
|
||||
|
||||
'@siax/auth-zitadel@0.1.1':
|
||||
resolution: {integrity: sha512-SmoBswG5P7szZIaETqmo1NN5RhdXLq2RDR/1wObfxHM2yUEXNSZQJznZoAb8z3lm/PvBgQhmnHiOHCfidN1ZQg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-zitadel/-/0.1.1/auth-zitadel-0.1.1.tgz}
|
||||
|
||||
'@siax/charts@0.11.0':
|
||||
resolution: {integrity: sha512-IQnby+h6jVMaRAM7DcfUMuMbSUjd8B3+AGk9N4pXk2GDudMRMjt0bTtu75637Jc1Mf4iNonvgp8ytKlS7KdW4A==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.11.0/charts-0.11.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@siax/charts@0.5.0':
|
||||
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
|
||||
peerDependencies:
|
||||
@@ -1578,23 +1555,17 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: ^4.3.2
|
||||
|
||||
'@siax/contracts@0.11.1':
|
||||
resolution: {integrity: sha512-sn0ZlprJE8LcbKF4PF8wBOhdOfygOG/JbU7nB+NDFBBP/AhtvMXcxFGl1oX0GadifD7VqZrVSoCliQqY/V/HYg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.11.1/contracts-0.11.1.tgz}
|
||||
'@siax/contracts@0.10.0':
|
||||
resolution: {integrity: sha512-2WVHNsR80hkwWEfVxKvi9/lhW0CDpNGLvVvyFu39bakN4+IXfo5y16gCpqs3v9NC0/0+YGt7kAW1qhsvlm/VKw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.10.0/contracts-0.10.0.tgz}
|
||||
|
||||
'@siax/contracts@0.12.1':
|
||||
resolution: {integrity: sha512-gSvMYRSxfbPPAqUGRjUMO1s38E4LdmIzDEpiNNIiGVminDHHwCOCybnhf2YNYaIfm6VBYoq4rP4yCERrpJregA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.12.1/contracts-0.12.1.tgz}
|
||||
|
||||
'@siax/import-export@0.2.2':
|
||||
resolution: {integrity: sha512-mb2fM6lVjWh6cVdoBFOLTdyjYRtjMjlSxNtpNHZHdW51tDrNZRNZc0Mz+lVIFsJHQ79NbL2J8GsWpHmL/rVBzg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.2/import-export-0.2.2.tgz}
|
||||
|
||||
'@siax/import-export@0.2.4':
|
||||
resolution: {integrity: sha512-xYRLF/5B7EyK3jbZCsPvDcEhZRGC/72QQpkEIsFJQqKwUxsejiQ1RabjzLoF0wNjIKlubEYnkfxDBaJMeIgKrg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.4/import-export-0.2.4.tgz}
|
||||
'@siax/import-export@0.2.0':
|
||||
resolution: {integrity: sha512-QCY6gXNeazsV8hBY2cGClFRMVkNKUPbpch93ZQOQVtClxJ6+JfXdQObRu7o40bt3L4/OBlMdzVW+pqPB8AKPiA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.0/import-export-0.2.0.tgz}
|
||||
|
||||
'@siax/tokens@0.1.2':
|
||||
resolution: {integrity: sha512-pz4tP8cyzhWh5gGw9GQLHjl4E/w/WemfYR5x5rdejneg+OSJYEmf6pvGCirj7247DhPqX0BH19uJK8ZN8S/Hcg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Ftokens/-/0.1.2/tokens-0.1.2.tgz}
|
||||
|
||||
'@siax/ui@0.16.13':
|
||||
resolution: {integrity: sha512-qWmGexMa7kqol5O3MBDkraF/s5APAahNyGHQusDZ34YDZvGnlr3t7raWIVJMFHgz6D0Fsgyuak6210Ktn3SpHA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.16.13/ui-0.16.13.tgz}
|
||||
'@siax/ui@0.14.0':
|
||||
resolution: {integrity: sha512-cpQ+USiwShCY1oiA4oCp405OdOzYZkUUQenhZY2fPneLxpmm57I11KgF5TjgT7e7VZAIg2LZcmH/7GNo1TV4XA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.14.0/ui-0.14.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
@@ -1903,11 +1874,16 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
baseline-browser-mapping@2.11.26:
|
||||
resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
brace-expansion@5.0.8:
|
||||
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
browserslist@4.28.9:
|
||||
@@ -1926,6 +1902,9 @@ packages:
|
||||
caniuse-lite@1.0.30001810:
|
||||
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
|
||||
|
||||
caniuse-lite@1.0.30001814:
|
||||
resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2478,6 +2457,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
@@ -2487,8 +2471,8 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@16.4.1:
|
||||
resolution: {integrity: sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==}
|
||||
next@16.3.6:
|
||||
resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2752,6 +2736,10 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3476,30 +3464,30 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@next/env@16.4.1': {}
|
||||
'@next/env@16.3.6': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.4.1':
|
||||
'@next/swc-darwin-arm64@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.4.1':
|
||||
'@next/swc-darwin-x64@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.4.1':
|
||||
'@next/swc-linux-arm64-gnu@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.4.1':
|
||||
'@next/swc-linux-arm64-musl@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.4.1':
|
||||
'@next/swc-linux-x64-gnu@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.4.1':
|
||||
'@next/swc-linux-x64-musl@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.4.1':
|
||||
'@next/swc-win32-arm64-msvc@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.4.1':
|
||||
'@next/swc-win32-x64-msvc@16.3.6':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/number@1.1.3': {}
|
||||
@@ -4243,26 +4231,6 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@siax/analytical-context@0.5.1': {}
|
||||
|
||||
'@siax/auth-core@0.1.0': {}
|
||||
|
||||
'@siax/auth-zitadel@0.1.1':
|
||||
dependencies:
|
||||
'@siax/auth-core': 0.1.0
|
||||
|
||||
'@siax/charts@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
dependencies:
|
||||
'@siax/analytical-context': 0.5.1
|
||||
'@siax/config': 0.2.1(tailwindcss@4.3.3)
|
||||
clsx: 2.1.1
|
||||
echarts: 6.1.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tailwind-merge: 2.6.1
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -4277,21 +4245,15 @@ snapshots:
|
||||
dependencies:
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@siax/contracts@0.11.1': {}
|
||||
'@siax/contracts@0.10.0': {}
|
||||
|
||||
'@siax/contracts@0.12.1': {}
|
||||
|
||||
'@siax/import-export@0.2.2':
|
||||
'@siax/import-export@0.2.0':
|
||||
dependencies:
|
||||
'@siax/contracts': 0.11.1
|
||||
|
||||
'@siax/import-export@0.2.4':
|
||||
dependencies:
|
||||
'@siax/contracts': 0.12.1
|
||||
'@siax/contracts': 0.10.0
|
||||
|
||||
'@siax/tokens@0.1.2': {}
|
||||
|
||||
'@siax/ui@0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
'@siax/ui@0.14.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -4314,11 +4276,10 @@ snapshots:
|
||||
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@siax/analytical-context': 0.5.1
|
||||
'@siax/charts': 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
'@siax/charts': 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@siax/config': 0.2.1(tailwindcss@4.3.3)
|
||||
'@siax/contracts': 0.12.1
|
||||
'@siax/import-export': 0.2.4
|
||||
'@siax/contracts': 0.10.0
|
||||
'@siax/import-export': 0.2.0
|
||||
'@siax/tokens': 0.1.2
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@tanstack/react-virtual': 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -4353,7 +4314,7 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.2
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.3.3':
|
||||
@@ -4661,12 +4622,14 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.11.23: {}
|
||||
|
||||
brace-expansion@1.1.21:
|
||||
baseline-browser-mapping@2.11.26: {}
|
||||
|
||||
brace-expansion@1.1.16:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
brace-expansion@5.0.8:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -4684,6 +4647,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001810: {}
|
||||
|
||||
caniuse-lite@1.0.30001814: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -5195,11 +5160,11 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.12
|
||||
brace-expansion: 5.0.8
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
brace-expansion: 1.1.16
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -5207,6 +5172,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
@@ -5214,25 +5181,25 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
next@16.4.1(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
next@16.3.6(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@next/env': 16.4.1
|
||||
'@next/env': 16.3.6
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.23
|
||||
caniuse-lite: 1.0.30001810
|
||||
baseline-browser-mapping: 2.11.26
|
||||
caniuse-lite: 1.0.30001814
|
||||
postcss: 8.5.23
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
styled-jsx: 5.1.6(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.4.1
|
||||
'@next/swc-darwin-x64': 16.4.1
|
||||
'@next/swc-linux-arm64-gnu': 16.4.1
|
||||
'@next/swc-linux-arm64-musl': 16.4.1
|
||||
'@next/swc-linux-x64-gnu': 16.4.1
|
||||
'@next/swc-linux-x64-musl': 16.4.1
|
||||
'@next/swc-win32-arm64-msvc': 16.4.1
|
||||
'@next/swc-win32-x64-msvc': 16.4.1
|
||||
'@next/swc-darwin-arm64': 16.3.6
|
||||
'@next/swc-darwin-x64': 16.3.6
|
||||
'@next/swc-linux-arm64-gnu': 16.3.6
|
||||
'@next/swc-linux-arm64-musl': 16.3.6
|
||||
'@next/swc-linux-x64-gnu': 16.3.6
|
||||
'@next/swc-linux-x64-musl': 16.3.6
|
||||
'@next/swc-win32-arm64-msvc': 16.3.6
|
||||
'@next/swc-win32-x64-msvc': 16.3.6
|
||||
sharp: 0.35.5(@types/node@22.20.1)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -5299,7 +5266,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
@@ -5307,7 +5274,7 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres@3.4.9: {}
|
||||
|
||||
@@ -5518,6 +5485,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
source-map@0.5.7: {}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/** Run with DOC0_CHECKOUT=/absolute/path "$DOC0_CHECKOUT/node_modules/.bin/tsx" scripts/verify-doc0-source-wire.ts.
|
||||
* In-memory test only: proves the current DOC0 response shape, not production auth or storage. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { Doc0SourceClient } from '../src/document-workbench/doc0-source-client';
|
||||
import { structureDocumentBatch } from '../src/document-workbench/structure-document-batch';
|
||||
import { exportValidatedBatch } from '../src/document-workbench/structured-batch';
|
||||
|
||||
async function main() {
|
||||
const checkout = process.env.DOC0_CHECKOUT;
|
||||
if (!checkout || !isAbsolute(checkout)) throw new Error('DOC0_CHECKOUT must name an absolute local checkout');
|
||||
const load = (path: string) => import(pathToFileURL(resolve(checkout, path)).href);
|
||||
const [{ buildServer }, { InMemoryDocumentStore }, { InMemoryDocumentVersionStore }, { InMemoryParseStore }, { segmentText }] = await Promise.all([
|
||||
load('src/api/app.ts'), load('src/domains/document/store.ts'), load('src/domains/document-version/store.ts'),
|
||||
load('src/domains/parse/store.ts'), load('src/parsing/segments.ts'),
|
||||
]);
|
||||
process.env.AUTH_ENFORCE = 'false';
|
||||
Object.assign(process.env, { NODE_ENV: 'test' });
|
||||
const documents = new InMemoryDocumentStore(), versions = new InMemoryDocumentVersionStore(), parses = new InMemoryParseStore();
|
||||
const input = { tenantId: 't-test', projectId: 'p-test', actorId: 'actor', requestId: 'req', receipt: {
|
||||
documentId: 'doc', versionId: 'v1', checksumSha256: createHash('sha256').update('fixture bytes').digest('hex'), pipelineStage: 'parsed',
|
||||
} };
|
||||
await documents.create({ tenantId: 't-test', projectId: 'p-test', documentId: 'doc', appId: 'test', displayName: 'fixture', documentType: 'unknown', classification: 'internal', tags: [], createdBy: 'actor' });
|
||||
await versions.append({ tenantId: 't-test', documentId: 'doc', versionId: 'v1', versionNumber: 1, checksumSha256: input.receipt.checksumSha256, sizeBytes: 13, mimeType: 'text/plain', st0reArtifactRef: 'fixture', pipelineStage: 'parsed', extractedMetadata: {}, customMetadata: {}, createdBy: 'actor' });
|
||||
const parts = segmentText('Acme AB\n\nInvoice 123');
|
||||
await parses.appendDocumentText({ tenantId: 't-test', documentId: 'doc', versionId: 'v1', documentTextId: 'txt', parserRunId: 'run', charCount: parts.charCount, segmentCount: parts.segments.length, segmentsTruncated: parts.truncated, textSha256: parts.textSha256 });
|
||||
await parses.appendTextSegments(parts.segments.map((s: object, i: number) => ({ ...s, tenantId: 't-test', documentId: 'doc', versionId: 'v1', documentTextId: 'txt', parserRunId: 'run', segmentId: `seg-${i}`, sourceVersionSha256: input.receipt.checksumSha256 })));
|
||||
const app = buildServer({ documentStore: documents, versionStore: versions, parseStore: parses });
|
||||
const fetchImpl: typeof fetch = async (url, init) => {
|
||||
const target = new URL(String(url));
|
||||
const out = await app.inject({ method: 'GET', url: target.pathname + target.search, headers: Object.fromEntries(new Headers(init?.headers).entries()) });
|
||||
return new Response(out.body, { status: out.statusCode, headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
try {
|
||||
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.example', serviceToken: 'fixture', fetchImpl });
|
||||
const result = await client.read(input);
|
||||
if (result.sources.length !== 2 || result.sources[0]?.text !== 'Acme AB' || result.sources[1]?.text !== 'Invoice 123') throw new Error('DOC0 wire mismatch');
|
||||
const fields = [{ name: 'value', type: 'string' as const, required: true }];
|
||||
const records = result.sources.map((source) => ({ value: { value: source.text }, evidence: [{ field: 'value', sourceId: source.id, start: 0, end: source.text.length, transform: 'exact' }] }));
|
||||
const request = {
|
||||
context: { tenantId: 't-test', projectId: 'p-test', actorId: 'actor', capability: 'inf0.complete', correlationId: 'req' },
|
||||
admission: { tenantId: 't-test', projectId: 'p-test', jobId: 'j', modelId: 'local_only', dataClass: 'high', maxInputTokens: 200, maxOutputTokens: 100, maximumCredits: 5 },
|
||||
policy: { tenantId: 't-test', projectId: 'p-test', allowedModelIds: ['local_only'], allowedDataClasses: ['high'], destination: 'internal' as const, externalTransferAllowed: false, maxInputTokens: 200, maxOutputTokens: 100 },
|
||||
reservation: { tenantId: 't-test', projectId: 'p-test', jobId: 'j', status: 'reserved' as const, credits: 5 },
|
||||
};
|
||||
const structured = await structureDocumentBatch({ request, fields, sources: result.sources, instructions: 'Extract values', countTokens: async () => 50,
|
||||
gateway: { complete: async () => ({ status: 'model_stopped', content: JSON.stringify({ records }), modelExecuted: 'fixture', providerKind: 'local', promptTokens: 50, completionTokens: 20, totalTokens: 70 }) } });
|
||||
if (structured.status !== 'mechanically_validated') throw new Error('model fixture evidence failed');
|
||||
const exported = exportValidatedBatch({ format: 'json', fields, sources: result.sources, records: structured.records });
|
||||
if (exported.status !== 'ready' || JSON.parse(exported.content).length !== 2) throw new Error('export fixture failed');
|
||||
const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: checkout, encoding: 'utf8' }).trim();
|
||||
console.log(`DOC0 ${revision}: actual route through 0pt source and validated export; in-memory stores, test auth and model fixture`);
|
||||
} finally { await app.close(); }
|
||||
}
|
||||
main().catch((error: unknown) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
COPY app.py ingest_aud0.py ./
|
||||
EXPOSE 8096
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8096/health', timeout=3)"
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8096"]
|
||||
@@ -1,47 +0,0 @@
|
||||
# 0pt analytics HTTP surface
|
||||
|
||||
The missing read plane between API0's `/analytics/*` contract and the
|
||||
`opt_analytics` ClickHouse database.
|
||||
|
||||
## Surface
|
||||
|
||||
Serves the BI-P068 operation paths under `/api/0pt/analytics`:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /datasets`, `GET /datasets/{id}`
|
||||
- `GET /metrics`, `GET /metrics/{id}`, `GET /metrics/{id}/explain`
|
||||
- `POST /metrics/{id}/query`
|
||||
- `POST /query`, `POST /query/estimate`, `POST /query/explain`
|
||||
- `GET /query/{id}` (404: executions are not persisted yet)
|
||||
- `GET /dashboards`, `GET /reports` (empty until the BI metadata lane writes them)
|
||||
- `GET /search`
|
||||
|
||||
Identity: the caller's Keycloak token is verified against the portal realm
|
||||
JWKS; `tenant_id` is taken from the token and cross-checked against
|
||||
`X-Tenant-Id`. An `X-Internal-Token` path exists only for the ingestion worker.
|
||||
|
||||
## Ingest
|
||||
|
||||
`ingest_aud0.py` maps AUD0 audit events to `opt_analytics_event`
|
||||
(`dataset_id=aud0.events`, `metric=event_count`, dimensions event_type/app_id/
|
||||
capability_id/risk_level). It is idempotent per `event_id`.
|
||||
|
||||
```bash
|
||||
AUD0_TOKEN=... AUD0_TENANT_ID=... CLICKHOUSE_URL=http://opt-analytics-clickhouse:8123 \
|
||||
python ingest_aud0.py --limit 50000
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
CLICKHOUSE_URL=http://opt-analytics-clickhouse:8123 \
|
||||
KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal \
|
||||
uvicorn app:app --host 0.0.0.0 --port 8096
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
python -m pytest tests -q
|
||||
```
|
||||
@@ -1,464 +0,0 @@
|
||||
"""
|
||||
0pt analytics HTTP surface (BI-P068-compatible).
|
||||
|
||||
This is the missing read plane between API0's `/analytics/*` contract and the
|
||||
`opt_analytics` ClickHouse database. It does not invent a second analytics
|
||||
model:
|
||||
|
||||
* datasets/metrics are the distinct ids actually present in
|
||||
`opt_analytics_event` / `opt_metric_daily` / `opt_dataset_projection`;
|
||||
* query/estimate/explain are computed against the tenant-forced ClickHouse
|
||||
rows, never against a local cache;
|
||||
* dashboards/reports are honest empty lists until the 0pt BI metadata lane
|
||||
writes them;
|
||||
* every response is either real ClickHouse data or an explicit error.
|
||||
|
||||
IDENTITY. API0 verifies the caller and forwards the caller's own Keycloak
|
||||
token plus `X-Tenant-Id`. This service verifies the same token against the
|
||||
portal realm's JWKS and cross-checks `tenant_id` against `X-Tenant-Id`. A
|
||||
service-to-service `X-Internal-Token` is also accepted for the ingestion
|
||||
worker, but never for a request that reads tenant data without an explicit,
|
||||
already-verified `X-Tenant-Id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Any, Iterable, Literal
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import FastAPI, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
CLICKHOUSE_URL = os.getenv("CLICKHOUSE_URL", "").rstrip("/")
|
||||
CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
|
||||
CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
|
||||
CLICKHOUSE_DATABASE = os.getenv("CLICKHOUSE_DATABASE", "opt_analytics")
|
||||
KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", "").rstrip("/")
|
||||
KEYCLOAK_AUDIENCE = os.getenv("KEYCLOAK_AUDIENCE", "account")
|
||||
KEYCLOAK_AUTHORIZED_PARTY = os.getenv("KEYCLOAK_AUTHORIZED_PARTY", "siax-portal")
|
||||
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
||||
ALLOWED_DIMENSIONS = {"event_type", "app_id", "capability_id", "risk_level", "source_service"}
|
||||
|
||||
app = FastAPI(title="0pt analytics", version="0.1")
|
||||
|
||||
|
||||
class AnalyticsError(Exception):
|
||||
def __init__(self, code: str, message: str, status: int = 400):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@app.exception_handler(AnalyticsError)
|
||||
async def analytics_error_handler(_request: Request, exc: AnalyticsError) -> JSONResponse:
|
||||
return JSONResponse(status_code=exc.status, content={"error": {"code": exc.code, "message": exc.message}})
|
||||
|
||||
|
||||
# ── identity ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _jwks_client() -> jwt.PyJWKClient:
|
||||
if not KEYCLOAK_ISSUER:
|
||||
raise AnalyticsError("ANALYTICS_NOT_CONFIGURED", "KEYCLOAK_ISSUER is not set", 503)
|
||||
return jwt.PyJWKClient(f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs", cache_keys=True, lifespan=3600)
|
||||
|
||||
|
||||
def _bearer(request: Request) -> str | None:
|
||||
raw = request.headers.get("authorization", "")
|
||||
if not raw.lower().startswith("bearer "):
|
||||
return None
|
||||
return raw[7:].strip() or None
|
||||
|
||||
|
||||
def verified_identity(request: Request, x_tenant_id: str | None) -> str:
|
||||
"""Return the tenant the request is allowed to read.
|
||||
|
||||
An `X-Internal-Token` may only be used by the ingestion worker and never
|
||||
substitutes for a tenant claim. For user/API0 calls the JWT is the source
|
||||
of truth; a mismatching X-Tenant-Id is a hard 403, not a preference.
|
||||
"""
|
||||
if INTERNAL_TOKEN and request.headers.get("x-internal-token") == INTERNAL_TOKEN:
|
||||
if not x_tenant_id:
|
||||
raise AnalyticsError("TENANT_REQUIRED", "X-Tenant-Id is required for internal calls", 400)
|
||||
return x_tenant_id
|
||||
|
||||
token = _bearer(request)
|
||||
if not token:
|
||||
raise AnalyticsError("UNAUTHENTICATED", "A bearer token is required", 401)
|
||||
try:
|
||||
signing_key = _jwks_client().get_signing_key_from_jwt(token)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256", "RS384", "RS512"],
|
||||
audience=KEYCLOAK_AUDIENCE,
|
||||
issuer=KEYCLOAK_ISSUER,
|
||||
options={"require": ["exp", "iss", "sub"]},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AnalyticsError("UNAUTHENTICATED", f"Invalid bearer token: {exc}", 401) from exc
|
||||
|
||||
if KEYCLOAK_AUTHORIZED_PARTY and claims.get("azp") not in (None, KEYCLOAK_AUTHORIZED_PARTY):
|
||||
raise AnalyticsError("FORBIDDEN", "Token was issued for a different client", 403)
|
||||
tenant = claims.get("tenant_id")
|
||||
if not isinstance(tenant, str) or not tenant.strip():
|
||||
raise AnalyticsError("NO_TENANT", "Token is not bound to a tenant", 403)
|
||||
if x_tenant_id and x_tenant_id != tenant:
|
||||
raise AnalyticsError("TENANT_MISMATCH", "X-Tenant-Id does not match the verified tenant", 403)
|
||||
return tenant
|
||||
|
||||
|
||||
# ── clickhouse ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ch_client() -> httpx.Client:
|
||||
if not CLICKHOUSE_URL:
|
||||
raise AnalyticsError("ANALYTICS_NOT_CONFIGURED", "CLICKHOUSE_URL is not set", 503)
|
||||
auth = (CLICKHOUSE_USER, CLICKHOUSE_PASSWORD) if CLICKHOUSE_PASSWORD else None
|
||||
return httpx.Client(base_url=CLICKHOUSE_URL, auth=auth, timeout=20.0)
|
||||
|
||||
|
||||
def ch_json(sql: str, params: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
query_params = {
|
||||
"database": CLICKHOUSE_DATABASE,
|
||||
"output_format_json_quote_64bit_integers": "0",
|
||||
**{f"param_{key}": value for key, value in params.items()},
|
||||
}
|
||||
try:
|
||||
with _ch_client() as client:
|
||||
response = client.post("/", params=query_params, content=sql)
|
||||
except httpx.HTTPError as exc:
|
||||
raise AnalyticsError("CLICKHOUSE_UNREACHABLE", str(exc), 503) from exc
|
||||
if response.status_code != 200:
|
||||
raise AnalyticsError("CLICKHOUSE_REFUSED", response.text[:500], 502)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise AnalyticsError("CLICKHOUSE_BAD_RESPONSE", "ClickHouse did not return JSON", 502) from exc
|
||||
|
||||
|
||||
def _literal(value: str) -> str:
|
||||
# ClickHouse query parameters are preferable; the only values interpolated
|
||||
# here are identifiers from ALLOWED_DIMENSIONS, never caller text.
|
||||
if value not in ALLOWED_DIMENSIONS:
|
||||
raise AnalyticsError("INVALID_DIMENSION", f"Unsupported dimension: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def rows(sql: str, params: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
||||
return ch_json(sql, params).get("data", [])
|
||||
|
||||
|
||||
def scalar(sql: str, params: dict[str, str] | None = None) -> Any:
|
||||
data = rows(sql, params)
|
||||
if not data:
|
||||
return None
|
||||
return next(iter(data[0].values()))
|
||||
|
||||
|
||||
def build_query(
|
||||
*,
|
||||
tenant: str,
|
||||
dataset: str | None,
|
||||
metrics: Iterable[str],
|
||||
dimensions: Iterable[str],
|
||||
filters: dict[str, str] | None,
|
||||
from_time: str | None,
|
||||
to_time: str | None,
|
||||
limit: int,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
dims = [_literal(d) for d in dimensions]
|
||||
metric_list = [m for m in metrics if m.strip()]
|
||||
if not metric_list:
|
||||
raise AnalyticsError("INVALID_REQUEST", "At least one metric is required")
|
||||
where = ["tenant_id = {tenant:String}"]
|
||||
params: dict[str, str] = {"tenant": tenant, "limit": str(max(1, min(limit, 1000)))}
|
||||
if dataset:
|
||||
where.append("dataset_id = {dataset:String}")
|
||||
params["dataset"] = dataset
|
||||
metric_params = []
|
||||
for i, metric in enumerate(metric_list):
|
||||
key = f"metric{i}"
|
||||
metric_params.append(f"{{{key}:String}}")
|
||||
params[key] = metric
|
||||
where.append(f"metric IN ({', '.join(metric_params)})")
|
||||
for i, (key, value) in enumerate((filters or {}).items()):
|
||||
dim = _literal(key)
|
||||
p = f"filter{i}"
|
||||
where.append(f"dim_values[indexOf(dim_names, '{dim}')] = {{{p}:String}}")
|
||||
params[p] = value
|
||||
if from_time:
|
||||
where.append("event_time >= {from:String}")
|
||||
params["from"] = from_time
|
||||
if to_time:
|
||||
where.append("event_time <= {to:String}")
|
||||
params["to"] = to_time
|
||||
|
||||
select_dims = []
|
||||
for i, dim in enumerate(dims):
|
||||
select_dims.append(f"dim_values[indexOf(dim_names, '{dim}')] AS d{i}")
|
||||
select = ", ".join(select_dims + ["count() AS value"])
|
||||
group = f" GROUP BY {', '.join(f'd{i}' for i in range(len(dims)))}" if dims else ""
|
||||
sql = (
|
||||
f"SELECT {select} FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
|
||||
f"WHERE {' AND '.join(where)}{group} ORDER BY value DESC LIMIT {{limit:UInt32}} FORMAT JSON"
|
||||
)
|
||||
return sql, params
|
||||
|
||||
|
||||
# ── models ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
dataset: str | None = None
|
||||
metrics: list[str] = Field(default_factory=list)
|
||||
dimensions: list[str] = Field(default_factory=list)
|
||||
filters: dict[str, str] | None = None
|
||||
timeRange: dict[str, str] | None = None
|
||||
limit: int = 100
|
||||
|
||||
|
||||
# ── surface ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
try:
|
||||
ch_json("SELECT 1")
|
||||
return {"status": "ok", "providers": {"clickhouse": {"status": "ok"}}}
|
||||
except AnalyticsError as exc:
|
||||
return {"status": "error", "providers": {"clickhouse": {"status": "error", "detail": exc.message}}}
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/health")
|
||||
def analytics_health() -> dict[str, Any]:
|
||||
return health()
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/datasets")
|
||||
def list_datasets(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
rows_ = rows(
|
||||
f"SELECT dataset_id, count() AS events FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
|
||||
"WHERE tenant_id = {tenant:String} GROUP BY dataset_id ORDER BY dataset_id FORMAT JSON",
|
||||
{"tenant": tenant},
|
||||
)
|
||||
if not rows_:
|
||||
rows_ = rows(
|
||||
f"SELECT dataset_id, count() AS rows FROM {CLICKHOUSE_DATABASE}.opt_dataset_projection "
|
||||
"WHERE tenant_id = {tenant:String} GROUP BY dataset_id ORDER BY dataset_id FORMAT JSON",
|
||||
{"tenant": tenant},
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r["dataset_id"],
|
||||
"name": r["dataset_id"],
|
||||
"tier": "SERVING",
|
||||
"owner": "0pt",
|
||||
"source": "0pt analytics",
|
||||
"tenant_id": tenant,
|
||||
**({"events": r.get("events")} if "events" in r else {}),
|
||||
**({"rows": r.get("rows")} if "rows" in r else {}),
|
||||
}
|
||||
for r in rows_
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/datasets/{dataset_id}")
|
||||
def get_dataset(dataset_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
found = [d for d in list_datasets(request, x_tenant_id) if d["id"] == dataset_id]
|
||||
if not found:
|
||||
raise AnalyticsError("NOT_FOUND", f"dataset {dataset_id} does not exist for this tenant", 404)
|
||||
return found[0]
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/metrics")
|
||||
def list_metrics(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
rows_ = rows(
|
||||
f"SELECT metric, count() AS events FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
|
||||
"WHERE tenant_id = {tenant:String} GROUP BY metric ORDER BY metric FORMAT JSON",
|
||||
{"tenant": tenant},
|
||||
)
|
||||
if not rows_:
|
||||
rows_ = rows(
|
||||
f"SELECT metric, sum(count_state) AS events FROM {CLICKHOUSE_DATABASE}.opt_metric_daily "
|
||||
"WHERE tenant_id = {tenant:String} GROUP BY metric ORDER BY metric FORMAT JSON",
|
||||
{"tenant": tenant},
|
||||
)
|
||||
dims = rows(
|
||||
f"SELECT DISTINCT arrayJoin(dim_names) AS name FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
|
||||
"WHERE tenant_id = {tenant:String} FORMAT JSON",
|
||||
{"tenant": tenant},
|
||||
)
|
||||
dimension_defs = [{"name": d["name"], "type": "string"} for d in dims]
|
||||
return [
|
||||
{
|
||||
"id": r["metric"],
|
||||
"name": r["metric"],
|
||||
"description": f"0pt analytics metric {r['metric']}",
|
||||
"datasets": ["aud0.events"],
|
||||
"dimensions": dimension_defs,
|
||||
"tenant_id": tenant,
|
||||
**({"events": r.get("events")} if "events" in r else {}),
|
||||
}
|
||||
for r in rows_
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/metrics/{metric_id}")
|
||||
def get_metric(metric_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
found = [m for m in list_metrics(request, x_tenant_id) if m["id"] == metric_id]
|
||||
if not found:
|
||||
raise AnalyticsError("NOT_FOUND", f"metric {metric_id} does not exist for this tenant", 404)
|
||||
return found[0]
|
||||
|
||||
|
||||
def _run_query(request: Request, x_tenant_id: str | None, body: QueryRequest, metric: str | None = None) -> dict[str, Any]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
metrics = [metric] if metric else body.metrics
|
||||
dataset = body.dataset or "aud0.events"
|
||||
sql, params = build_query(
|
||||
tenant=tenant,
|
||||
dataset=dataset,
|
||||
metrics=metrics,
|
||||
dimensions=body.dimensions,
|
||||
filters=body.filters,
|
||||
from_time=(body.timeRange or {}).get("from"),
|
||||
to_time=(body.timeRange or {}).get("to"),
|
||||
limit=body.limit,
|
||||
)
|
||||
started = time.time()
|
||||
data = ch_json(sql, params)
|
||||
result_rows = data.get("data", [])
|
||||
duration_ms = int((time.time() - started) * 1000)
|
||||
columns = []
|
||||
if result_rows:
|
||||
columns = [{"name": key, "type": "string" if key.startswith("d") else "number"} for key in result_rows[0].keys()]
|
||||
return {
|
||||
"query_id": f"ch-{int(time.time() * 1000)}",
|
||||
"status": "succeeded",
|
||||
"engine": "clickhouse",
|
||||
"submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"duration": duration_ms,
|
||||
"rows": len(result_rows),
|
||||
"tenant_id": tenant,
|
||||
"result": {"columns": columns, "rows": result_rows, "truncated": len(result_rows) >= body.limit},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/0pt/analytics/query")
|
||||
def execute_query(body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
return _run_query(request, x_tenant_id, body)
|
||||
|
||||
|
||||
@app.post("/api/0pt/analytics/metrics/{metric_id}/query")
|
||||
def execute_metric(metric_id: str, body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
return _run_query(request, x_tenant_id, body, metric=metric_id)
|
||||
|
||||
|
||||
@app.post("/api/0pt/analytics/query/estimate")
|
||||
def estimate_query(body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
sql, params = build_query(
|
||||
tenant=tenant,
|
||||
dataset=body.dataset or "aud0.events",
|
||||
metrics=body.metrics or ["event_count"],
|
||||
dimensions=body.dimensions,
|
||||
filters=body.filters,
|
||||
from_time=(body.timeRange or {}).get("from"),
|
||||
to_time=(body.timeRange or {}).get("to"),
|
||||
limit=body.limit,
|
||||
)
|
||||
count = scalar(
|
||||
f"SELECT count() FROM {CLICKHOUSE_DATABASE}.opt_analytics_event WHERE tenant_id = {{tenant:String}} FORMAT JSON",
|
||||
{"tenant": tenant},
|
||||
)
|
||||
return {"estimated_rows_to_read": count or 0, "within_budget": True, "rows": count or 0}
|
||||
|
||||
|
||||
@app.post("/api/0pt/analytics/query/explain")
|
||||
def explain_query(body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
sql, _params = build_query(
|
||||
tenant=tenant,
|
||||
dataset=body.dataset or "aud0.events",
|
||||
metrics=body.metrics or ["event_count"],
|
||||
dimensions=body.dimensions,
|
||||
filters=body.filters,
|
||||
from_time=(body.timeRange or {}).get("from"),
|
||||
to_time=(body.timeRange or {}).get("to"),
|
||||
limit=body.limit,
|
||||
)
|
||||
return {"plan": sql, "forced_tenant_predicate": "tenant_id = {tenant:String}", "table": "opt_analytics_event"}
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/metrics/{metric_id}/explain")
|
||||
def explain_metric(metric_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
return explain_query(QueryRequest(metrics=[metric_id]), request, x_tenant_id)
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/dashboards")
|
||||
def list_dashboards(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
|
||||
verified_identity(request, x_tenant_id)
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/dashboards/{dashboard_id}")
|
||||
def get_dashboard(dashboard_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
verified_identity(request, x_tenant_id)
|
||||
raise AnalyticsError("NOT_FOUND", f"dashboard {dashboard_id} does not exist", 404)
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/reports")
|
||||
def list_reports(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
|
||||
verified_identity(request, x_tenant_id)
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/reports/{report_id}")
|
||||
def get_report(report_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
verified_identity(request, x_tenant_id)
|
||||
raise AnalyticsError("NOT_FOUND", f"report {report_id} does not exist", 404)
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/search")
|
||||
def search_catalog(
|
||||
request: Request,
|
||||
q: str = Query(default=""),
|
||||
types: str = Query(default=""),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
x_tenant_id: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
tenant = verified_identity(request, x_tenant_id)
|
||||
wanted = {t.strip() for t in types.split(",") if t.strip()}
|
||||
hits: list[dict[str, Any]] = []
|
||||
for dataset in list_datasets(request, x_tenant_id):
|
||||
if (not wanted or "dataset" in wanted) and (not q or q.lower() in dataset["id"].lower()):
|
||||
hits.append({**dataset, "type": "dataset", "canonical": False, "canonicalOperation": "getDataset"})
|
||||
for metric in list_metrics(request, x_tenant_id):
|
||||
if (not wanted or "metric" in wanted) and (not q or q.lower() in metric["id"].lower()):
|
||||
hits.append({**metric, "type": "metric", "canonical": False, "canonicalOperation": "getMetric"})
|
||||
return {"query": q, "derived": True, "hits": hits[:limit], "tenant_id": tenant}
|
||||
|
||||
|
||||
@app.get("/api/0pt/analytics/query/{query_id}")
|
||||
def get_query(query_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
verified_identity(request, x_tenant_id)
|
||||
raise AnalyticsError("NOT_FOUND", f"query {query_id} is not persisted", 404)
|
||||
|
||||
|
||||
@app.post("/api/0pt/analytics/query/{query_id}/cancel")
|
||||
def cancel_query(query_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
verified_identity(request, x_tenant_id)
|
||||
raise AnalyticsError("NOT_FOUND", f"query {query_id} is not running", 404)
|
||||
@@ -1,154 +0,0 @@
|
||||
"""
|
||||
Backfill/refresh: AUD0 events -> 0pt ClickHouse analytics events.
|
||||
|
||||
One audit event becomes one analytics event with metric `event_count` and
|
||||
dimensions `event_type`, `app_id`, `capability_id`, `risk_level`. The write is
|
||||
idempotent per event_id: already-present ids are skipped before the batch is
|
||||
inserted. No synthetic event is ever created; an empty AUD0 page writes
|
||||
nothing.
|
||||
|
||||
Usage:
|
||||
AUD0_TOKEN=... AUD0_TENANT_ID=379... CLICKHOUSE_URL=http://... \
|
||||
python ingest_aud0.py --limit 50000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
AUD0_API_URL = os.getenv("AUD0_API_URL", "https://api.aud0.siax.io").rstrip("/")
|
||||
AUD0_TOKEN = os.getenv("AUD0_TOKEN", "")
|
||||
AUD0_TENANT_ID = os.getenv("AUD0_TENANT_ID", "")
|
||||
CLICKHOUSE_URL = os.getenv("CLICKHOUSE_URL", "").rstrip("/")
|
||||
CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
|
||||
CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
|
||||
CLICKHOUSE_DATABASE = os.getenv("CLICKHOUSE_DATABASE", "opt_analytics")
|
||||
|
||||
|
||||
def dims(event: dict[str, Any]) -> tuple[list[str], list[str]]:
|
||||
names: list[str] = []
|
||||
values: list[str] = []
|
||||
for key in ("event_type", "app_id", "capability_id", "risk_level"):
|
||||
value = event.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
names.append(key)
|
||||
values.append(value)
|
||||
return names, values
|
||||
|
||||
|
||||
def to_row(tenant: str, event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
event_id = event.get("event_id")
|
||||
created_at = event.get("created_at")
|
||||
event_type = event.get("event_type")
|
||||
if not isinstance(event_id, str) or not event_id:
|
||||
return None
|
||||
if not isinstance(created_at, str) or not created_at:
|
||||
return None
|
||||
if not isinstance(event_type, str) or not event_type:
|
||||
return None
|
||||
names, values = dims(event)
|
||||
return {
|
||||
"tenant_id": tenant,
|
||||
"project_id": "aud0",
|
||||
"dataset_id": "aud0.events",
|
||||
"metric": "event_count",
|
||||
"event_id": event_id,
|
||||
"event_time": created_at,
|
||||
"value": 1,
|
||||
"dim_names": names,
|
||||
"dim_values": values,
|
||||
}
|
||||
|
||||
|
||||
def ch_insert(rows: Iterable[dict[str, Any]]) -> None:
|
||||
payload = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows)
|
||||
if not payload:
|
||||
return
|
||||
auth = (CLICKHOUSE_USER, CLICKHOUSE_PASSWORD) if CLICKHOUSE_PASSWORD else None
|
||||
with httpx.Client(base_url=CLICKHOUSE_URL, auth=auth, timeout=60.0) as client:
|
||||
response = client.post(
|
||||
"/",
|
||||
params={"database": CLICKHOUSE_DATABASE, "query": "INSERT INTO opt_analytics_event FORMAT JSONEachRow"},
|
||||
content=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def existing_ids(tenant: str, ids: list[str]) -> set[str]:
|
||||
if not ids:
|
||||
return set()
|
||||
auth = (CLICKHOUSE_USER, CLICKHOUSE_PASSWORD) if CLICKHOUSE_PASSWORD else None
|
||||
found: set[str] = set()
|
||||
sql = (
|
||||
f"SELECT event_id FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
|
||||
"WHERE tenant_id = {tenant:String} AND event_id IN ({ids:Array(String)}) FORMAT JSON"
|
||||
)
|
||||
with httpx.Client(base_url=CLICKHOUSE_URL, auth=auth, timeout=30.0) as client:
|
||||
# ClickHouse query parameters travel in the URL; 1000 ids overflow it.
|
||||
for start in range(0, len(ids), 50):
|
||||
chunk = ids[start : start + 50]
|
||||
response = client.post(
|
||||
"/",
|
||||
params={
|
||||
"database": CLICKHOUSE_DATABASE,
|
||||
"param_tenant": tenant,
|
||||
"param_ids": "[" + ",".join("'" + value.replace("'", "\\'") + "'" for value in chunk) + "]",
|
||||
},
|
||||
content=sql,
|
||||
)
|
||||
response.raise_for_status()
|
||||
found.update(row["event_id"] for row in response.json().get("data", []))
|
||||
return found
|
||||
|
||||
|
||||
def fetch_page(offset: int, limit: int) -> dict[str, Any]:
|
||||
if not AUD0_TOKEN or not AUD0_TENANT_ID:
|
||||
raise SystemExit("AUD0_TOKEN and AUD0_TENANT_ID are required")
|
||||
with httpx.Client(base_url=AUD0_API_URL, timeout=30.0) as client:
|
||||
response = client.get(
|
||||
"/v1/aud0/events",
|
||||
params={"tenant_id": AUD0_TENANT_ID, "limit": limit, "offset": offset},
|
||||
headers={"authorization": f"Bearer {AUD0_TOKEN}", "x-tenant-id": AUD0_TENANT_ID},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--limit", type=int, default=50_000, help="maximum events to read")
|
||||
parser.add_argument("--page", type=int, default=1000)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not CLICKHOUSE_URL:
|
||||
raise SystemExit("CLICKHOUSE_URL is required")
|
||||
|
||||
written = skipped = seen = 0
|
||||
for offset in range(0, args.limit, args.page):
|
||||
page = fetch_page(offset, min(args.page, args.limit - offset))
|
||||
events = page.get("events") or []
|
||||
if not events:
|
||||
break
|
||||
rows = [row for row in (to_row(AUD0_TENANT_ID, event) for event in events) if row]
|
||||
present = existing_ids(AUD0_TENANT_ID, [row["event_id"] for row in rows])
|
||||
fresh = [row for row in rows if row["event_id"] not in present]
|
||||
skipped += len(rows) - len(fresh)
|
||||
seen += len(events)
|
||||
if not args.dry_run:
|
||||
ch_insert(fresh)
|
||||
written += len(fresh)
|
||||
print(json.dumps({"offset": offset, "read": len(events), "written": len(fresh), "skipped": skipped}))
|
||||
if len(events) < args.page:
|
||||
break
|
||||
print(json.dumps({"seen": seen, "written": written, "skipped": skipped, "dry_run": args.dry_run}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +0,0 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
PyJWT[crypto]==2.14.0
|
||||
pytest==8.3.4
|
||||
@@ -1,52 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import app as analytics # noqa: E402
|
||||
|
||||
|
||||
def test_build_query_forces_tenant_and_dimension_whitelist():
|
||||
sql, params = analytics.build_query(
|
||||
tenant="379",
|
||||
dataset="aud0.events",
|
||||
metrics=["event_count"],
|
||||
dimensions=["event_type"],
|
||||
filters={"app_id": "portal"},
|
||||
from_time="2026-01-01T00:00:00Z",
|
||||
to_time=None,
|
||||
limit=50,
|
||||
)
|
||||
assert "tenant_id = {tenant:String}" in sql
|
||||
assert "dataset_id = {dataset:String}" in sql
|
||||
assert "dim_values[indexOf(dim_names, 'event_type')]" in sql
|
||||
assert params["tenant"] == "379"
|
||||
assert params["dataset"] == "aud0.events"
|
||||
assert params["metric0"] == "event_count"
|
||||
assert params["filter0"] == "portal"
|
||||
with pytest.raises(analytics.AnalyticsError):
|
||||
analytics.build_query(
|
||||
tenant="379",
|
||||
dataset=None,
|
||||
metrics=["event_count"],
|
||||
dimensions=["drop table"],
|
||||
filters=None,
|
||||
from_time=None,
|
||||
to_time=None,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
|
||||
def test_data_routes_require_a_verified_identity():
|
||||
client = TestClient(analytics.app)
|
||||
assert client.get("/api/0pt/analytics/datasets").status_code == 401
|
||||
assert client.get("/api/0pt/analytics/metrics").status_code == 401
|
||||
|
||||
|
||||
def test_health_is_always_a_liveness_answer():
|
||||
client = TestClient(analytics.app)
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert "providers" in response.json()
|
||||
@@ -169,18 +169,17 @@ async def sources_for(market: str):
|
||||
async def vehicle(plate: str):
|
||||
try:
|
||||
v = await rdw_vehicle(plate)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="vehicle source unavailable") from exc
|
||||
return {**(v or {"vehicle": None}), "source_status": "ok"}
|
||||
except Exception:
|
||||
v = None
|
||||
return v or {"vehicle": None}
|
||||
|
||||
|
||||
@app.get("/screen")
|
||||
async def screen(q: str):
|
||||
try:
|
||||
result = await opensanctions_screen(q)
|
||||
return {**result, "source_status": "ok"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="screening source unavailable") from exc
|
||||
return await opensanctions_screen(q)
|
||||
except Exception:
|
||||
return {"matches": [], "source": "opensanctions"}
|
||||
|
||||
|
||||
@app.post("/estimates")
|
||||
|
||||
@@ -268,10 +268,9 @@ async def rdw_vehicle(kenteken: str) -> dict[str, Any] | None:
|
||||
url = f"https://opendata.rdw.nl/resource/m9d7-ebf2.json?kenteken={plate}"
|
||||
async with _client() as c:
|
||||
r = await c.get(url)
|
||||
r.raise_for_status()
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
arr = r.json()
|
||||
if not isinstance(arr, list) or any(not isinstance(item, dict) for item in arr):
|
||||
raise ValueError("invalid RDW response")
|
||||
if not arr:
|
||||
return None
|
||||
d = arr[0]
|
||||
@@ -297,13 +296,9 @@ async def opensanctions_screen(name: str) -> dict[str, Any]:
|
||||
url = "https://api.opensanctions.org/search/default"
|
||||
async with _client() as c:
|
||||
r = await c.get(url, params={"q": name, "limit": 5})
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list) or any(
|
||||
not isinstance(item, dict) for item in payload["results"]
|
||||
):
|
||||
raise ValueError("invalid OpenSanctions response")
|
||||
results = payload["results"]
|
||||
if r.status_code != 200:
|
||||
return {"matches": [], "source": "opensanctions"}
|
||||
results = r.json().get("results", [])
|
||||
return {
|
||||
"source": "opensanctions",
|
||||
"matches": [{"name": x.get("caption"), "score": x.get("score"), "schema": x.get("schema")} for x in results],
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""A failed or malformed source must never become a verified empty result."""
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi.testclient import TestClient
|
||||
from orchestrator import main
|
||||
from sources.connectors import opensanctions_screen, rdw_vehicle
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [403, 429, 500])
|
||||
async def test_source_http_failure_raises(status):
|
||||
with respx.mock:
|
||||
respx.get("https://api.opensanctions.org/search/default").respond(status)
|
||||
respx.get("https://opendata.rdw.nl/resource/m9d7-ebf2.json").respond(status)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await opensanctions_screen("example")
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await rdw_vehicle("AB123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_and_valid_empty_are_distinct():
|
||||
with respx.mock:
|
||||
screen = respx.get("https://api.opensanctions.org/search/default").respond(200, json={})
|
||||
vehicle = respx.get("https://opendata.rdw.nl/resource/m9d7-ebf2.json").respond(200, json={})
|
||||
with pytest.raises(ValueError):
|
||||
await opensanctions_screen("example")
|
||||
with pytest.raises(ValueError):
|
||||
await rdw_vehicle("AB123")
|
||||
screen.respond(200, json={"results": []})
|
||||
vehicle.respond(200, json=[])
|
||||
assert (await opensanctions_screen("example"))["matches"] == []
|
||||
assert await rdw_vehicle("AB123") is None
|
||||
|
||||
|
||||
def test_routes_return_503_on_source_failure_and_mark_verified_empty(monkeypatch):
|
||||
async def failed(*args):
|
||||
raise RuntimeError("fixture failure")
|
||||
async def empty_screen(*args):
|
||||
return {"source": "opensanctions", "matches": []}
|
||||
async def empty_vehicle(*args):
|
||||
return None
|
||||
monkeypatch.setattr(main, "_INTERNAL_TOKEN", "")
|
||||
monkeypatch.setattr(main, "opensanctions_screen", failed)
|
||||
monkeypatch.setattr(main, "rdw_vehicle", failed)
|
||||
client = TestClient(main.app)
|
||||
assert client.get('/screen?q=example').status_code == 503
|
||||
assert client.get('/vehicles/AB123').status_code == 503
|
||||
monkeypatch.setattr(main, "opensanctions_screen", empty_screen)
|
||||
monkeypatch.setattr(main, "rdw_vehicle", empty_vehicle)
|
||||
assert client.get('/screen?q=example').json() == {"source": "opensanctions", "source_status": "ok", "matches": []}
|
||||
assert client.get('/vehicles/AB123').json() == {"vehicle": None, "source_status": "ok"}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.repo.schema.json",
|
||||
"repo": "0pt",
|
||||
"standardVersion": "2.0",
|
||||
"category": "platform",
|
||||
"subcategory": "data-os",
|
||||
"appId": "0PT",
|
||||
"capabilityPrefix": "0pt",
|
||||
"packageName": "@siax/0pt",
|
||||
"owner": "SIAX Technology AB",
|
||||
"codeowners": [
|
||||
"@sax3l"
|
||||
],
|
||||
"lifecycle": "active",
|
||||
"operationalState": "running",
|
||||
"gitHost": "git.siax.io",
|
||||
"node": null,
|
||||
"domains": [
|
||||
{
|
||||
"fqdn": "0pt.app",
|
||||
"env": "prod",
|
||||
"purpose": "Next.js edge plane -- marketing, catalog, order flow, customer/admin portal, capability API. LIVE på SIAX-flottan via signerad release-kedja (probe 2026-09-22: HTTP 200, /api/ready ready:true, db ok, engine ok). Tidigare Vercel 402-probe 2026-09-07 är föråldrad — cutover till flottan genomförd.",
|
||||
"registrar": "verifierad via release-kedjan (se docs/runbook/) — BACKLOG P0-15 kvarstår för registrar-autentisering"
|
||||
},
|
||||
{
|
||||
"fqdn": "api.0pt.app",
|
||||
"env": "prod",
|
||||
"purpose": "Target API host (Caddy) -- documented target in contracts/openapi.yaml servers list, never live. Probe 2026-09-07 `curl -sSI https://api.0pt.app/health` returned HTTP 404 with server: Vercel and x-vercel-error: DEPLOYMENT_NOT_FOUND",
|
||||
"registrar": "EJ VERIFIERAT fran detta repo -- se docs/architecture/ARCHITECTURE.md SS6/SS9 (BACKLOG P0-15)"
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"CL0UD",
|
||||
"ST0RE",
|
||||
"N0D",
|
||||
"AUD0",
|
||||
"INF0"
|
||||
],
|
||||
"consumedBy": [],
|
||||
"children": [
|
||||
{
|
||||
"path": "app",
|
||||
"purpose": "Next.js edge plane -- UI + capability/order/payment API route handlers",
|
||||
"lifecycle": "active"
|
||||
},
|
||||
{
|
||||
"path": "services/api",
|
||||
"purpose": "FastAPI data engine (orchestrator + workers) -- legal sourcing chain, estimates/orders/screening",
|
||||
"lifecycle": "active"
|
||||
},
|
||||
{
|
||||
"path": "services/scrapers",
|
||||
"purpose": "Scraper stack including WAF-bypass code -- kept per a dated, attributed account-owner decision under active review; NOT part of the compliant sourcing chain (see SECURITY.md, CLAUDE.md SS1)",
|
||||
"lifecycle": "active"
|
||||
},
|
||||
{
|
||||
"path": "packages/schemas",
|
||||
"purpose": "Shared JSON Schema definitions for 0pt data contracts (dataset/iceberg/lineage/signal/rate-policy)",
|
||||
"lifecycle": "active"
|
||||
}
|
||||
],
|
||||
"replacesSaaS": [
|
||||
"Apollo",
|
||||
"Cognism",
|
||||
"ZoomInfo",
|
||||
"Clearbit"
|
||||
],
|
||||
"contracts": {
|
||||
"openapi": "contracts/openapi.yaml"
|
||||
},
|
||||
"decoupled": false
|
||||
}
|
||||
+14
-28
@@ -71,39 +71,25 @@
|
||||
"canonical": true,
|
||||
"parentRepo": null,
|
||||
"capabilities": [
|
||||
"0pt.catalog_list",
|
||||
"0pt.crawl_job",
|
||||
"prospekt.email_verify",
|
||||
"prospekt.jobs_write",
|
||||
"prospekt.jobs_read",
|
||||
"prospekt.search_read",
|
||||
"prospekt.search_write",
|
||||
"0pt.query_source",
|
||||
"0pt.document_ocr",
|
||||
"0pt.estimate_compute",
|
||||
"0pt.execute_metric",
|
||||
"0pt.write_external_record",
|
||||
"0pt.lakehouse_mapping_manage",
|
||||
"0pt.lakehouse_write",
|
||||
"0pt.load_full_table",
|
||||
"0pt.source_rate_policy_manage",
|
||||
"0pt.screen_sanctions",
|
||||
"0pt.estimate_compute",
|
||||
"0pt.vehicle_lookup",
|
||||
"0pt.catalog_list",
|
||||
"0pt.source_registry_list",
|
||||
"0pt.order_create",
|
||||
"0pt.order_payment",
|
||||
"0pt.query_source",
|
||||
"0pt.render_report",
|
||||
"0pt.run_data_quality_check",
|
||||
"0pt.screen_sanctions",
|
||||
"0pt.source_rate_policy_manage",
|
||||
"0pt.source_registry_list",
|
||||
"0pt.structure.extract_document",
|
||||
"0pt.structure.get_lineage",
|
||||
"0pt.structure.normalize_value",
|
||||
"0pt.structure.quality_checks",
|
||||
"0pt.structure.quality_list_runs",
|
||||
"0pt.structure.quality_reports",
|
||||
"0pt.structure.quality_run",
|
||||
"0pt.structure.resolve_entities",
|
||||
"0pt.structure.route_parsing",
|
||||
"0pt.vehicle_lookup",
|
||||
"0pt.write_external_record",
|
||||
"0pt.write_iceberg_table",
|
||||
"prospekt.email_verify",
|
||||
"prospekt.jobs_read",
|
||||
"prospekt.jobs_write",
|
||||
"prospekt.search_read",
|
||||
"prospekt.search_write"
|
||||
"0pt.write_iceberg_table"
|
||||
],
|
||||
"surfaces": [
|
||||
"api",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"id": "0pt.crawl_job",
|
||||
"name": "0pt.crawl_job",
|
||||
"owner": "0pt",
|
||||
"version": "0.1.0",
|
||||
"riskLevel": "L2",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api",
|
||||
"cli",
|
||||
"mcp",
|
||||
"job"
|
||||
],
|
||||
"requiresJob": true,
|
||||
"tenantScoped": true,
|
||||
"operationId": "siax.acquisition.run",
|
||||
"description": "Ägare 0pt. Registrerad i CL0UD capability-registry (packages/capability-registry/src/acquisition-capabilities.ts, runtime B0TS). operationId är den faktiska kontraktsoperationen siax.acquisition.run, refererad även i 0pt/acquisition.consumer.json — ingen påhittad id."
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "0pt.execute_metric",
|
||||
"name": "0pt.execute_metric",
|
||||
"owner": "0pt",
|
||||
"version": "0.1.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "none",
|
||||
"surfaces": [
|
||||
"api",
|
||||
"cli"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0ptExecuteMetric",
|
||||
"description": "Ägare 0pt. Implementation: 0pt/services/analytics/app.py:366 execute_metric, rutt POST /api/0pt/analytics/metrics/{metric_id}/query. Konsumeras av ACT0 consulting_delivery_analytics (governed-metric-steg). operationId är registrerad kontraktsnamn i CL0UD capability-registry (consulting-capabilities.ts)."
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"id": "0pt.load_full_table",
|
||||
"name": "0pt.load_full_table",
|
||||
"owner": "0pt",
|
||||
"version": "0.1.0",
|
||||
"riskLevel": "L3",
|
||||
"piiLevel": "high",
|
||||
"surfaces": [
|
||||
"job"
|
||||
],
|
||||
"requiresJob": true,
|
||||
"tenantScoped": true,
|
||||
"description": "Ägare 0pt. Registrerad i CL0UD capability-registry (0pt-full-table-capabilities.ts); best befintliga jobbgräns 0pt.full_table_job, utförd av p0lytics.docstruct.full_table_worker. Endast jobb-ytan deklareras — ingen HTTP-operation existerar, därför medvetet ingen operationId (registret säger uttryckligen att ingen påhittas)."
|
||||
}
|
||||
@@ -12,6 +12,5 @@
|
||||
],
|
||||
"requiresJob": true,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.create_order",
|
||||
"description": "Risk tilldelad av RW-005b-regel (L3/low): Produktionsmutation av data (RAG-003 reg 1) — övervakning krävs. RAG-003-RISK-TAXONOMY + siax-tenancy deny-by-default."
|
||||
}
|
||||
|
||||
@@ -12,6 +12,5 @@
|
||||
],
|
||||
"requiresJob": true,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.query_source",
|
||||
"description": "Risk tilldelad av RW-005b-regel (L2/none): Läsande/beräknande — ingen mutation, begränsad påverkan. RAG-003-RISK-TAXONOMY + siax-tenancy deny-by-default."
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.render_report",
|
||||
"name": "0pt.render_report",
|
||||
"owner": "0pt",
|
||||
"version": "0.1.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "none",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0ptRenderReport",
|
||||
"description": "Ägare 0pt. ACT0 consulting_delivery_analytics render-steg (artifact-before-notice). Registrerad i CL0UD capability-registry (consulting-capabilities.ts); operationId är registrerad kontraktsnamn, inte en gissning."
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "0pt.run_data_quality_check",
|
||||
"name": "0pt.run_data_quality_check",
|
||||
"owner": "0pt",
|
||||
"version": "0.1.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "none",
|
||||
"surfaces": [
|
||||
"api",
|
||||
"cli"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0ptRunDataQualityCheck",
|
||||
"description": "Ägare 0pt. ACT0 consulting_delivery_analytics kvalitetsgrind + seedade analystmallar (tmpl_000010/000011/000575 använder samma capability-id). Registrerad i CL0UD capability-registry (consulting-capabilities.ts)."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.extract_document",
|
||||
"name": "0pt.structure.extract_document",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L2",
|
||||
"piiLevel": "high",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.extract_document",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.extract_document (owner 0pt). Verified against registry + openapi operationId 0pt.extract_document."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.get_lineage",
|
||||
"name": "0pt.structure.get_lineage",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.get_provenance",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.get_lineage (owner 0pt). Verified against registry + openapi operationId 0pt.get_provenance."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.normalize_value",
|
||||
"name": "0pt.structure.normalize_value",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.normalize_value",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.normalize_value (owner 0pt). Verified against registry + openapi operationId 0pt.normalize_value."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.quality_checks",
|
||||
"name": "0pt.structure.quality_checks",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.quality.checks",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.quality_checks (owner 0pt). Verified against registry + openapi operationId 0pt.quality.checks."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.quality_list_runs",
|
||||
"name": "0pt.structure.quality_list_runs",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.quality.list_runs",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.quality_list_runs (owner 0pt). Verified against registry + openapi operationId 0pt.quality.list_runs."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.quality_reports",
|
||||
"name": "0pt.structure.quality_reports",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.quality.reports",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.quality_reports (owner 0pt). Verified against registry + openapi operationId 0pt.quality.reports."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.quality_run",
|
||||
"name": "0pt.structure.quality_run",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.quality.run",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.quality_run (owner 0pt). Verified against registry + openapi operationId 0pt.quality.run."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.resolve_entities",
|
||||
"name": "0pt.structure.resolve_entities",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L2",
|
||||
"piiLevel": "high",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.resolve_entities",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.resolve_entities (owner 0pt). Verified against registry + openapi operationId 0pt.resolve_entities."
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "0pt.structure.route_parsing",
|
||||
"name": "0pt.structure.route_parsing",
|
||||
"owner": "0pt",
|
||||
"version": "1.0.0",
|
||||
"riskLevel": "L1",
|
||||
"piiLevel": "low",
|
||||
"surfaces": [
|
||||
"api"
|
||||
],
|
||||
"requiresJob": false,
|
||||
"tenantScoped": true,
|
||||
"operationId": "0pt.route_parsing",
|
||||
"description": "Canonical capability descriptor for 0pt.structure.route_parsing (owner 0pt). Verified against registry + openapi operationId 0pt.route_parsing."
|
||||
}
|
||||
@@ -1,821 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"repo": "0pt",
|
||||
"role": "consumer",
|
||||
"canonicalOwner": "sax3l/DES1GN",
|
||||
"sourceRoots": [
|
||||
"app",
|
||||
"components"
|
||||
],
|
||||
"packagePolicy": {
|
||||
"namespace": "@siax",
|
||||
"registry": "https://git.siax.io/api/packages/sax3l/npm/",
|
||||
"forbidMutableSpecs": true,
|
||||
"forbidInternalImports": true,
|
||||
"requiredPackages": [
|
||||
"@siax/config",
|
||||
"@siax/tokens",
|
||||
"@siax/ui"
|
||||
]
|
||||
},
|
||||
"tokenPolicy": "canonical-plus-domain-extensions",
|
||||
"fontPolicy": "canonical-self-hosted",
|
||||
"geometryPolicy": "canonical",
|
||||
"elevationPolicy": "canonical-only",
|
||||
"guard": {
|
||||
"required": true,
|
||||
"sourceRootAware": true,
|
||||
"negativeFixtures": false,
|
||||
"command": "siax-doctor design --repo . --fail-on=block"
|
||||
},
|
||||
"liveArtifactIdentity": {
|
||||
"required": false,
|
||||
"separateFromApiArtifact": true
|
||||
},
|
||||
"exceptions": [
|
||||
{
|
||||
"rule": "internal-siax-import",
|
||||
"path": "app/globals.css",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/accordion.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/alert-dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/alert.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/aspect-ratio.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/avatar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/badge.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/breadcrumb.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/button-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/button.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/calendar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/carousel.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/checkbox.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/collapsible.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/command.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/context-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/drawer.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/dropdown-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/field.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/form.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/hover-card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/input-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/input-otp.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/input.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/item.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/kbd.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/label.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/menubar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/navigation-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/pagination.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/popover.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/progress.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/radio-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/resizable.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/scroll-area.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/select.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/separator.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/sheet.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/sidebar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/skeleton.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/slider.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/sonner.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/spinner.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/switch.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/table.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/tabs.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/textarea.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toast.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toaster.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toggle-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toggle.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/tooltip.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/alert-dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/button-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/button.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/calendar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/checkbox.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/context-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/dropdown-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/hover-card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/input-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/input-otp.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/input.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/menubar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/navigation-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/popover.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/radio-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/select.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/sheet.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/sidebar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/slider.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/switch.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/tabs.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/textarea.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/toast.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/toggle-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/toggle.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/accordion.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/alert-dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/alert.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/badge.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/button-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/button.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/calendar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/command.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/context-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/dropdown-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/empty.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/field.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/hover-card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/input-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/input.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/item.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/kbd.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/menubar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/navigation-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/popover.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/select.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/sidebar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/skeleton.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/tabs.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/textarea.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/toast.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/toggle-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/toggle.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/tooltip.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "pe-sbom-0pt-f6a29da7e586",
|
||||
"repo": "0pt",
|
||||
"evidenceType": "sbom",
|
||||
"result": "pass",
|
||||
"verifiedAt": "2026-09-23T16:22:09Z",
|
||||
"verifiedBy": "syft (estate-kampanj issue #76, skannad vid deployad commit)",
|
||||
"proofRef": "siax/sbom/sbom-0pt-f6a29da7e586.json",
|
||||
"sourceSha": "f6a29da7e58601ce426181b11ea4959e90f07dcc",
|
||||
"tool": "syft",
|
||||
"sbomFormat": "cyclonedx-json"
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
{
|
||||
"name": "0pt",
|
||||
"slug": "0pt",
|
||||
"origin_url": "https://git.siax.io/admin/0pt.git",
|
||||
"manifest_file": "siax.repo.v3.json",
|
||||
"manifest_operational_state": "running",
|
||||
"assessed_level": "LIVE_VERIFIED",
|
||||
"observed_live": "LIVE_VERIFIED",
|
||||
"gaps": [
|
||||
"NO_BACKUP_POLICY",
|
||||
"UNMERGED_LOCAL_BRANCHES",
|
||||
"UNPUSHED_COMMITS"
|
||||
],
|
||||
"ui": true,
|
||||
"ui_dirs": [
|
||||
"app",
|
||||
"public"
|
||||
],
|
||||
"prod_domains": [
|
||||
"0pt.app",
|
||||
"api.0pt.app"
|
||||
],
|
||||
"live": [
|
||||
{
|
||||
"host": "0pt.app",
|
||||
"status": 200,
|
||||
"class": "public-ok",
|
||||
"title": "0pt — your data"
|
||||
},
|
||||
{
|
||||
"host": "api.0pt.app",
|
||||
"status": 200,
|
||||
"class": "public-ok",
|
||||
"title": "0pt — your data"
|
||||
}
|
||||
],
|
||||
"deep": {
|
||||
"tracked_files": 954,
|
||||
"test_files": 178,
|
||||
"e2e_files": 0,
|
||||
"has_playwright": false,
|
||||
"has_cypress": false,
|
||||
"api_contracts": [
|
||||
"contracts/openapi.yaml"
|
||||
],
|
||||
"migrations": 0,
|
||||
"health_refs": 69,
|
||||
"uses_siax_ui": true,
|
||||
"has_a11y_tooling": false,
|
||||
"has_design_conformance": true,
|
||||
"ui_state_files": [
|
||||
"app/error.tsx",
|
||||
"app/not-found.tsx"
|
||||
],
|
||||
"todo_count": 10,
|
||||
"has_runbook_docs": true,
|
||||
"has_architecture_docs": true,
|
||||
"has_status_docs": false
|
||||
},
|
||||
"backlog": [
|
||||
{
|
||||
"priority": "P0",
|
||||
"task": "Landa grenar",
|
||||
"next_step": "Kör PR-flöde per gren, verifiera CI och merga; radera grenen lokalt och på origin efter merge."
|
||||
},
|
||||
{
|
||||
"priority": "P1",
|
||||
"task": "Backup",
|
||||
"next_step": "Definiera frekvens, retention, mål och kryptering i `siax.backup`."
|
||||
},
|
||||
{
|
||||
"priority": "P1",
|
||||
"task": "E2E i webbläsare",
|
||||
"next_step": "Lägg Playwright-flöde för inloggning och kärnflöde mot riktig miljö."
|
||||
},
|
||||
{
|
||||
"priority": "P1",
|
||||
"task": "Tillgänglighetsgrind",
|
||||
"next_step": "Aktivera eslint-plugin-jsx-a11y/axe och en blockerande WCAG 2.2-kontroll."
|
||||
},
|
||||
{
|
||||
"priority": "P2",
|
||||
"task": "Statusdokument",
|
||||
"next_step": "Lägg `docs/STATUS.md` med nivå, bevis, kända gap och nästa steg."
|
||||
},
|
||||
{
|
||||
"priority": "P2",
|
||||
"task": "Teknisk skuld",
|
||||
"next_step": "Prioritera bland 10 TODO/FIXME-markeringar; stäng eller gör dem spårbara som issues."
|
||||
}
|
||||
],
|
||||
"standard": "SIAX Master Definition of Done"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
# 0pt — cosign-signeringskedjan (2026-09-29)
|
||||
|
||||
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-1e5fafce1a (sha256:80237edf1530110650cdfc6da8e821130d9a005cd47d79528d03dbeae2485822) är cosign-signerad
|
||||
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-09b9cc80bf (sha256:8a1649c7418c8728e6793ff885814a266f055114f950e31ca9d7b252645dedd3) är cosign-signerad
|
||||
med nyckelparet vars pubkey ligger här. Privata nyckeln raderad efter signering.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgomLeApgQ4ZWeypoU2LfjLIkuBCa
|
||||
hGwo5+pYW9ae3XMKauDUK17QwMXaSwJ2u9ulefai7+/Fiv3/4Jh8EciMVA==
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXES2u9EA2vFf6aQS5Hg/hsjudCJk
|
||||
mC1G/IFtBOzweyPlQD+A84UcYDJmxIR5Cfq5t8nvF3bY/ff18AhhK+WZmw==
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.crawl_job",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#siax.acquisition.run",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.crawl_job",
|
||||
"kind": "cli",
|
||||
"entrypoint": "siax 0pt crawl-job",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.crawl_job",
|
||||
"kind": "job",
|
||||
"entrypoint": "n0d job:0pt.crawl_job",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.crawl_job",
|
||||
"kind": "mcp",
|
||||
"entrypoint": "mcp:0pt.crawl_job",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.execute_metric",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0ptExecuteMetric",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.execute_metric",
|
||||
"kind": "cli",
|
||||
"entrypoint": "siax 0pt execute-metric",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.load_full_table",
|
||||
"kind": "job",
|
||||
"entrypoint": "n0d job:0pt.load_full_table",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user