Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf36793de | ||
|
|
4284a3a39b | ||
|
|
0cb603fc62 | ||
|
|
5b3cda802f | ||
|
|
8be19c4b56 | ||
|
|
ffa9ca68eb | ||
|
|
20911a399f | ||
|
|
829150fd04 | ||
|
|
2a253bdb66 | ||
|
|
6d16589878 | ||
|
|
6fa5408cc1 | ||
|
|
a87eb534d4 | ||
|
|
f88d3b4161 | ||
|
|
3e186591a9 | ||
|
|
5468ef92f0 | ||
|
|
d4a9ffee62 | ||
|
|
1e5fafce1a | ||
|
|
67b0a205b7 | ||
|
|
576ae1585f | ||
|
|
1029a4c485 | ||
|
|
e0fb148fba | ||
|
|
a6c605ea7f | ||
|
|
0cac0266e0 | ||
|
|
3d2fc6f913 | ||
|
|
39fc5e3ce3 | ||
|
|
585bdd4b44 | ||
|
|
a4189333af | ||
|
|
fb072d47c7 | ||
|
|
e7cfdf47f1 | ||
|
|
dccdfed9bd | ||
|
|
925f5ca2fd | ||
|
|
2ea4062d09 | ||
|
|
6ae98c7034 | ||
|
|
54ff8a3958 | ||
|
|
869218fb8b | ||
|
|
1e79d0c4cf | ||
|
|
72d5876d23 | ||
|
|
596ca3f90e | ||
|
|
66a7e253eb | ||
|
|
a4e6d7d8e2 | ||
|
|
416d00fc46 | ||
|
|
d3b1964d3a | ||
|
|
59c0e008f5 | ||
|
|
20d66ea40d | ||
|
|
7ce87ffa01 | ||
|
|
d81325f27c | ||
|
|
d40360aabc | ||
|
|
c6a5102a2a | ||
|
|
62517aca49 | ||
|
|
db26daa979 | ||
|
|
c532cedb5e | ||
|
|
40202178a3 | ||
|
|
38ae3ddf0b | ||
|
|
a7a4295043 | ||
|
|
f1d47ffb2e | ||
|
|
8e7c228bd2 | ||
|
|
b1ae910d64 | ||
|
|
91f270ea19 | ||
|
|
ac3a95f320 | ||
|
|
fab0a53573 | ||
|
|
419c2d3f18 | ||
|
|
e96e015722 | ||
|
|
344decd285 | ||
|
|
32cd714126 | ||
|
|
ebfa17028d | ||
|
|
5a8d26eee6 | ||
|
|
d301f6e0cc | ||
|
|
85de12fdfe | ||
|
|
966a36f898 | ||
|
|
6b57f7cb9d | ||
|
|
c15b4088c3 | ||
|
|
fd73f24193 | ||
|
|
5795b3503f | ||
|
|
72d6d1e8d1 | ||
|
|
1b94cd7c80 | ||
|
|
9dcf3c24fa | ||
|
|
a0eba858ab | ||
|
|
5010a6853b | ||
|
|
b25c164837 | ||
|
|
be0acfd12c | ||
|
|
5a56cc9a6e | ||
|
|
f5600a25b9 | ||
|
|
d85f5ae559 | ||
|
|
246589bde8 | ||
|
|
fdba72bd35 | ||
|
|
9aef3cd97f | ||
|
|
34ef39e5f5 | ||
|
|
43ec05ade2 | ||
|
|
be6b92fb82 | ||
|
|
a0e701efc8 | ||
|
|
48fbe6b184 | ||
|
|
b90cded2d6 | ||
|
|
aba909c29a | ||
|
|
e00c7b0fc5 | ||
|
|
de76f3795b | ||
|
|
3421748f07 | ||
|
|
9ac681ee27 | ||
|
|
cdbb10b8e3 | ||
|
|
6bbbcf75d4 | ||
|
|
8741169259 | ||
|
|
58dca92d9f | ||
|
|
7ca7b98fb2 | ||
|
|
b269a05538 | ||
|
|
1b7c6c2de9 | ||
|
|
b7627a3a31 | ||
|
|
afd0b4ec46 | ||
|
|
4dbabd14ac | ||
|
|
b2499ace79 | ||
|
|
0213459f33 | ||
|
|
c91d2aaca6 | ||
|
|
1a581fde29 | ||
|
|
bfdc5d1326 | ||
|
|
831781f19d | ||
|
|
b97ad0b2f4 | ||
|
|
019cc23fa2 | ||
|
|
849e7ae802 | ||
|
|
04aeaa754b | ||
|
|
98039d3d68 | ||
|
|
8c91b90ae0 | ||
|
|
4f92cdd102 | ||
|
|
9ca16b7f6c | ||
|
|
a424cf1581 | ||
|
|
f77a4fec96 | ||
|
|
58f81a10b7 | ||
|
|
6ca6e8e0a6 | ||
|
|
cb0b951c42 | ||
|
|
415396f25d | ||
|
|
c46ebbf810 | ||
|
|
03cdb7815e | ||
|
|
116304849b | ||
|
|
c7966eb586 | ||
|
|
d5aea59482 | ||
|
|
ce6be94e50 | ||
|
|
4c20e5457f | ||
|
|
b29e4848b6 | ||
|
|
f53bcf8f50 | ||
|
|
76e3c0dc2d | ||
|
|
bcdfc536b7 | ||
|
|
77ddc0654d | ||
|
|
70444a369e |
@@ -58,6 +58,16 @@ 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
|
||||
|
||||
+21
-2
@@ -294,9 +294,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: SonarQube analysis (self-hosted sonar.siax.io)
|
||||
- name: SonarQube analysis (self-hosted sonar.siax.dev)
|
||||
env:
|
||||
SONAR_HOST_URL: https://sonar.siax.io
|
||||
SONAR_HOST_URL: https://sonar.siax.dev
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -309,3 +309,22 @@ 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']))})"
|
||||
|
||||
+123
-31
@@ -1,43 +1,135 @@
|
||||
name: Security Scan (SIAX Cloud)
|
||||
|
||||
# Security scanning on every push/PR + weekly re-run.
|
||||
name: security
|
||||
# CI-10 — gitleaks, Semgrep, OSV-Scanner, Trivy. Körs på PR, på push mot main
|
||||
# och nattligt. Scannrarna körs som containrar för att inte kräva att runnern
|
||||
# har verktygen installerade.
|
||||
#
|
||||
# This job runs the same Trivy invocation as ci.yml's `security` job and
|
||||
# previously carried the same defect: `--exit-code 0` meant it could report
|
||||
# success while holding a CRITICAL finding (measured 2026-09-08 with a
|
||||
# planted AWS secret key -- detected, still exit 0). Its context
|
||||
# "Security Scan (SIAX Cloud) / security" is NOT in branch protection's
|
||||
# required list, so this one never gated a merge either way; it is fixed
|
||||
# here so a green tick on this workflow means what a reader assumes it
|
||||
# means. See ci.yml's `security` job for the measurement and for the
|
||||
# services/scrapers unpinned-requirements coverage gap.
|
||||
# FÄLLA (verifierad i SIAX-utredningen 2026-11-11): tre fel gjorde att
|
||||
# security-gaten aldrig kunde bli grön och blockerade varje PR.
|
||||
# 1. `-v "$PWD:/repo"` monterar en sökväg som är tom inuti runner-containern,
|
||||
# så gitleaks rapporterade "no leaks found" på ett tomt träd och
|
||||
# OSV-Scanner avslutade med 128 "No package sources found".
|
||||
# 2. `--volumes-from "$HOSTNAME"` fungerar för gitleaks/trivy/osv men
|
||||
# Semgreps docker-wrapper kräver koden på /src och ger annars exit 2
|
||||
# ("Detected Docker environment without a code volume").
|
||||
# 3. actions/@v4 är en muterbar tag som Semgrep-regeln
|
||||
# github-actions-mutable-action-tag fäller; åtgärden pinnar samma SHA som
|
||||
# repots övriga jobb använder.
|
||||
# Åtgärden: arbetsträdet kopieras en gång till en namngiven volym som alla
|
||||
# scannrar monterar, och OSV hoppar över repos utan låsfil (då finns inget att
|
||||
# scanna — det är inte samma sak som en grön gate på riktigt).
|
||||
#
|
||||
# NOTE: findings on services/scrapers/* (FlareSolverr/Tor/curl_cffi) are
|
||||
# expected and out of scope for CI gating -- kept per the account owner's
|
||||
# dated, attributed decision (Simon Axelsson, 2026-08-11, interactive
|
||||
# prompt; no rationale beyond "keep it" given or invented). See SECURITY.md
|
||||
# and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6 for full context.
|
||||
# FÄLLA 2: ett stegnamn som innehåller ": " (kolon + mellanslag) får Giteas
|
||||
# YAML-parser att TYST kasta bort hela workflowen. Håll stegnamn i ren ASCII
|
||||
# utan kolon-mellanslag.
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push: { branches: [main] }
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: "0 5 * * 1" # weekly Monday re-scan
|
||||
|
||||
|
||||
- cron: "17 2 * * *"
|
||||
permissions: { contents: read }
|
||||
concurrency:
|
||||
group: security-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
security:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Trivy
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
|
||||
- name: Prepare scan volume
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
- name: Trivy fs scan (vuln + secrets -- fails on HIGH/CRITICAL)
|
||||
vol="siax-scan-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker volume create "$vol" >/dev/null
|
||||
for i in 1 2 3; do docker pull alpine:3.20 && break || sleep $((i*15)); done
|
||||
tar -cf - . | docker run --rm -i -v "$vol:/repo" -w /repo alpine:3.20 sh -c 'tar -xf -'
|
||||
docker run --rm -v "$vol:/repo" alpine:3.20 test -d /repo/.git \
|
||||
|| { echo "::error::scan volume saknar .git"; exit 1; }
|
||||
echo "SCAN_VOLUME=$vol" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pull scanner images
|
||||
run: |
|
||||
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
|
||||
set -e
|
||||
for i in 1 2 3; do docker pull alpine:3.20 && break || sleep $((i*15)); done
|
||||
for i in 1 2 3; do docker pull aquasec/trivy:0.76.2 && break || sleep $((i*15)); done
|
||||
for i in 1 2 3; do docker pull ghcr.io/google/osv-scanner:v2.7.1 && break || sleep $((i*15)); done
|
||||
for i in 1 2 3; do docker pull semgrep/semgrep:1.183.0 && break || sleep $((i*15)); done
|
||||
for i in 1 2 3; do docker pull zricethezav/gitleaks:v8.30.1 && break || sleep $((i*15)); done
|
||||
|
||||
# gitleaks i arbetstradet. Blockerar pa hemligheter som finns i koden nu (SEC-01).
|
||||
- name: gitleaks secret scan
|
||||
run: |
|
||||
set +e
|
||||
docker run --rm -v "$SCAN_VOLUME:/repo" -w /repo \
|
||||
zricethezav/gitleaks:v8.30.1 detect --no-git --source /repo --no-banner --exit-code 1 > /tmp/scan.log 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
tail -n 120 /tmp/scan.log
|
||||
if [ $rc -eq 0 ]; then echo "gitleaks secret scan ren"; exit 0; fi
|
||||
if [ $rc -eq 1 ]; then echo "::error::gitleaks secret scan hittade fynd"; exit 1; fi
|
||||
echo "::warning::gitleaks secret scan kunde inte köras (rc=$rc) - verktygsfel, inte fynd"; exit 0
|
||||
|
||||
# Historiken scannas informativt. Gamla hemligheter i tidigare commits
|
||||
# kraver historikomskrivning och blockerar darfor inte bygget.
|
||||
- name: gitleaks history informational
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm -v "$SCAN_VOLUME:/repo" -w /repo \
|
||||
-e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory \
|
||||
-e GIT_CONFIG_VALUE_0='*' \
|
||||
zricethezav/gitleaks:v8.30.1 detect --source /repo --no-banner --exit-code 1 \
|
||||
|| echo "::notice::historiska traffar loggas informativt"
|
||||
|
||||
# Trivy filsystem, blockerar pa HIGH och CRITICAL (SUP-*)
|
||||
- name: Trivy filesystem scan
|
||||
run: |
|
||||
set +e
|
||||
docker run --rm -v "$SCAN_VOLUME:/repo" -w /repo \
|
||||
aquasec/trivy:0.76.2 fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 --no-progress /repo > /tmp/scan.log 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
tail -n 120 /tmp/scan.log
|
||||
if [ $rc -eq 0 ]; then echo "Trivy filesystem scan ren"; exit 0; fi
|
||||
if [ $rc -eq 1 ]; then echo "::error::Trivy filesystem scan hittade fynd"; exit 1; fi
|
||||
echo "::warning::Trivy filesystem scan kunde inte köras (rc=$rc) - verktygsfel, inte fynd"; exit 0
|
||||
|
||||
# Kanda sarbarheter i lasfilen. osv-scanner 2.x hittar inte alltid
|
||||
# osv-scanner.toml sjalv, sa den skickas explicit nar den finns.
|
||||
- name: OSV Scanner
|
||||
run: |
|
||||
if ! find /repo -maxdepth 3 \( -name 'pnpm-lock.yaml' -o -name 'package-lock.json' \
|
||||
-o -name 'yarn.lock' -o -name 'poetry.lock' -o -name 'Pipfile.lock' \
|
||||
-o -name 'requirements.txt' -o -name 'go.sum' -o -name 'Cargo.lock' \
|
||||
-o -name 'composer.lock' -o -name 'Gemfile.lock' \) -print -quit | grep -q .; then
|
||||
echo "Inga lasfiler i repot - OSV-Scanner har inget att scanna."
|
||||
exit 0
|
||||
fi
|
||||
cfg=""
|
||||
if [ -f "/repo/osv-scanner.toml" ]; then cfg="--config=/repo/osv-scanner.toml"; fi
|
||||
set +e
|
||||
docker run --rm -v "$SCAN_VOLUME:/repo" -w /repo \
|
||||
ghcr.io/google/osv-scanner:v2.7.1 scan source --recursive $cfg /repo > /tmp/scan.log 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
tail -n 120 /tmp/scan.log
|
||||
if [ $rc -eq 0 ]; then echo "OSV ren"; exit 0; fi
|
||||
if [ $rc -eq 1 ]; then echo "::error::OSV Scanner hittade fynd"; exit 1; fi
|
||||
echo "::warning::OSV Scanner kunde inte koras (rc=$rc) - verktygsfel, inte fynd"; exit 0
|
||||
|
||||
# Statisk kodanalys, felallvarlighet bryter bygget. Semgrep kraver koden pa /src
|
||||
- name: Semgrep code scan
|
||||
run: |
|
||||
set +e
|
||||
docker run --rm -v "$SCAN_VOLUME:/src" -w /src \
|
||||
semgrep/semgrep:1.183.0 semgrep scan --config=p/ci --error --quiet > /tmp/scan.log 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
tail -n 120 /tmp/scan.log
|
||||
if [ $rc -eq 0 ]; then echo "Semgrep code scan ren"; exit 0; fi
|
||||
if [ $rc -eq 1 ]; then echo "::error::Semgrep code scan hittade fynd"; exit 1; fi
|
||||
echo "::warning::Semgrep code scan kunde inte köras (rc=$rc) - verktygsfel, inte fynd"; exit 0
|
||||
- name: Remove scan volume
|
||||
if: always()
|
||||
run: |
|
||||
docker volume rm -f "${SCAN_VOLUME:-none}" >/dev/null 2>&1 || true
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
|
||||
# 2026-11-11: samma canary-rad som ovan, men i den commit som squash-mergade
|
||||
# SEC-25-borttagningen (filen flyttades till ny blob). Värdet är en medveten
|
||||
# kanariefågel som testet använder för att bevisa att hemligheter inte läcker i
|
||||
# analyst-SQL-svaret. Falsk positiv.
|
||||
829150fd0481191b4c96b52475ecc822e46a5fe7:tests/integration/analyst-sql-clickhouse.integration.test.ts:generic-api-key:71
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"exemptions": [
|
||||
{
|
||||
"control": "REPO-01",
|
||||
"paths": [
|
||||
"siax/sbom/sbom-0pt-2026-09-29.json"
|
||||
],
|
||||
"justification": "Generated artifacts, not hand-editable: SBOM/release evidence lists the third-party dependency graph (npm registry, upstream project URLs) and the lockfiles pin registry tarball metadata. Rewriting them would falsify supply-chain evidence."
|
||||
},
|
||||
{
|
||||
"control": "REPO-01",
|
||||
"paths": [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"ONBOARDING.md",
|
||||
"PRODUCTION_EVIDENCE.json",
|
||||
"SECURITY.md",
|
||||
"docs/02-architecture.md",
|
||||
"docs/06-data-engine.md",
|
||||
"docs/07-dependencies.md",
|
||||
"docs/08-infrastructure.md",
|
||||
"docs/09-build-plan.md",
|
||||
"docs/P0_QUERY_SOURCE_FLOW.md",
|
||||
"docs/compliance/INVENTORY.md",
|
||||
"docs/compliance/SLUTRAPPORT.md"
|
||||
],
|
||||
"justification": "Documentation prose. The GitHub strings here are one of: the read-only GitHub mirror of the canonical Gitea repository (each such file states that git.siax.io is canonical), links to third-party upstream projects, or GitHub's own product/settings pages. None of them configures or names a SIAX git remote."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"allow":[{"pathPrefix":"components/states.tsx","reason":"0pt:s varumärkta EmptyState med DataArtifact-grid, EN/SV-copy och CTA; domänkomposition, KEEP_LOCAL enligt COMPONENT_STANDARD §2."},{"pathPrefix":"components/theme-provider.tsx","reason":"Tunn next-themes-adapter för App Router; ingen egen temaimplementation."},{"pathPrefix":"components/ui/toaster.tsx","reason":"Legacy lokal toast-state (shadcn); migration till @siax/ui kräver samtidig state-migrering. KEEP_LOCAL med backlog."}]}
|
||||
@@ -83,3 +83,45 @@ 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,3 +65,45 @@ 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 -->
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* app/api/0pt/canonical/facts/route.ts
|
||||
* ====================================
|
||||
* POST — canonical structured-observation write (entities/facts/relations).
|
||||
* GET — tenant-scoped read for consumers (products, agents, L0RE projection).
|
||||
*
|
||||
* 0pt owns observed facts; L0RE owns epistemic propositions/projections and
|
||||
* consumes this API. Tenant is always cross-checked against the verified
|
||||
* principal (assertTenantMatchesVerified), never trusted from the body alone.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { canonicalWriteSchema, parseBody } from "@/src/validation/0pt-schemas";
|
||||
import { canonicalFactService } from "@/src/services/canonical-fact-registry";
|
||||
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const parsed = parseBody(canonicalWriteSchema, await request.json());
|
||||
if (!parsed.ok) return NextResponse.json(parsed.body, { status: parsed.status });
|
||||
const body = parsed.data;
|
||||
const guard = assertTenantMatchesVerified(request, body.tenant_id);
|
||||
if (guard) return guard;
|
||||
const result = await canonicalFactService.write(body);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
return siaxErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tenantId = searchParams.get("tenant_id");
|
||||
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
|
||||
const guard = assertTenantMatchesVerified(request, tenantId);
|
||||
if (guard) return guard;
|
||||
const limitRaw = searchParams.get("limit");
|
||||
const facts = await canonicalFactService.query({
|
||||
tenant_id: tenantId,
|
||||
entity_id: searchParams.get("entity_id") ?? undefined,
|
||||
entity_type: searchParams.get("entity_type") ?? undefined,
|
||||
identity_namespace: searchParams.get("identity_namespace") ?? undefined,
|
||||
identity_value: searchParams.get("identity_value") ?? undefined,
|
||||
predicate: searchParams.get("predicate") ?? undefined,
|
||||
limit: limitRaw ? Number(limitRaw) : undefined,
|
||||
});
|
||||
return NextResponse.json({ facts });
|
||||
} catch (error: unknown) {
|
||||
return siaxErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ 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 {
|
||||
@@ -35,6 +36,11 @@ 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,6 +1,7 @@
|
||||
'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';
|
||||
|
||||
@@ -72,7 +73,6 @@ export default function DocumentsPage() {
|
||||
const [uploadNote, setUploadNote] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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.
|
||||
@@ -277,34 +277,16 @@ export default function DocumentsPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
<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);
|
||||
}}
|
||||
className="mt-4 flex cursor-pointer flex-col items-center justify-center border border-dashed border-border px-6 py-10 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className={label}>{t({ en: 'choose a file', sv: 'välj en fil' })}</span>
|
||||
<span className="mt-3 text-[15px]">
|
||||
{file
|
||||
? file.name
|
||||
: t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
uploadKey.current = null;
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
# 0pt — capability status (Master-DoD §56 status honesty)
|
||||
|
||||
> Classification scale (Master-DoD §2, exactly one per capability):
|
||||
> `MISSING → DOCUMENTED_ONLY → SKELETON → MOCK_ONLY → IMPLEMENTED → INTEGRATION_TESTED → LIVE_VERIFIED → PRODUCTION_READY`.
|
||||
> Only `PRODUCTION_READY` may be called DONE. Docs ≠ implementation.
|
||||
> Last full audit + re-classification: **2026-08-31** (SIAX Platforms P0 wave 2).
|
||||
|
||||
## How to read this
|
||||
|
||||
- **INTEGRATION_TESTED** here means: verified against a REAL dependency
|
||||
(live OpenSearch/ClickHouse container, or a recorded wire-contract double
|
||||
pinned to the sibling repo's real handler code on its `main`) in CI.
|
||||
- **LIVE_VERIFIED** requires a verification against the actual SIAX fleet
|
||||
deployment. No new capability in this wave claims it.
|
||||
- The unit/contract/integration suites are in `tests/` and run in
|
||||
`.gitea/workflows/ci.yml` (jobs `quality` + `integration`).
|
||||
|
||||
## Cross-product clients (Master-DoD §10 "Cross-product clients")
|
||||
|
||||
| Capability | Status | Evidence | Honest gaps |
|
||||
|---|---|---|---|
|
||||
| CL0UD client (policy evaluate, `POST /api/policy/evaluate`) | **INTEGRATION_TESTED** (contract) | `src/clients/cl0ud-client.ts`; `tests/contracts/cl0ud-client.contract.test.ts` pins the real camelCase `{data}` envelope; fail-closed (missing decision ⇒ deny) | Not yet exercised against a live CL0UD deployment (no reachable CL0UD URL this session ⇒ not LIVE_VERIFIED) |
|
||||
| CL0UD capability discovery (`GET /api/capabilities`) | **INTEGRATION_TESTED** (contract) | same suite | 0pt's own capabilities are not yet registered in CL0UD's registry (§3 gap) |
|
||||
| ST0RE client (artifact register, camelCase `/v1/st0re/artifacts/register`) | **INTEGRATION_TESTED** (contract) | `src/clients/st0re-client.ts`; `tests/contracts/st0re-client.contract.test.ts` | Raw-bytes upload still goes via LocalBlobStore interim (pre-existing, unchanged); presign upload path not consumed yet |
|
||||
| N0D client (create/get job, get result) | **INTEGRATION_TESTED** (contract) | `src/clients/n0d-client.ts`; `tests/contracts/n0d-client.contract.test.ts` pins `required_role`/`resource_requirements`/body `idempotency_key` | CloudEvents completion callback receiving endpoint not built in 0pt (poll path works) |
|
||||
| AUD0 client (events + OpenLineage ingest) | **INTEGRATION_TESTED** (contract) | `src/clients/aud0-client.ts`; `tests/contracts/aud0-client.contract.test.ts`; legacy lineage shape converts to real OpenLineage | No durable local outbox for AUD0 outages (§70 "durable local/outbox path" — emission failure is surfaced, not queued) |
|
||||
| **No production mock fallback (§3/§10)** | **INTEGRATION_TESTED** | `src/platform/dependency-mode.ts`; `tests/contracts/dependency-mode.contract.test.ts` drives NODE_ENV=production for all four factories ⇒ hard `DEPENDENCY_UNAVAILABLE` | — |
|
||||
|
||||
**Correction of prior state (2026-08-31):** the pre-existing clients' HTTP
|
||||
paths were verified against the sibling repos' actual `main` branches and
|
||||
THREE of four were wrong: cl0ud (`/v1/cl0ud/policies/evaluate` — never
|
||||
existed), st0re (snake_case body — would 400), aud0 lineage
|
||||
(`/v1/aud0/lineage/events` — never existed). All fixed and pinned by
|
||||
contract tests. The earlier MOCK_ONLY classification in the README was
|
||||
accurate.
|
||||
|
||||
## Data-OS capabilities (Master-DoD §10)
|
||||
|
||||
| Capability | Status | Evidence | Honest gaps |
|
||||
|---|---|---|---|
|
||||
| Source Registry — register/query/describe/health, rights/license/purpose, freshness, provenance | **IMPLEMENTED** | `src/services/source-registry-service.ts`, `src/types/source-registry.ts`; `tests/p0/source-registry.test.ts` (fail-closed purpose gate, cross-tenant NOT_FOUND) | In-memory persistence (per-process); no Postgres store yet — production boot must therefore configure one before this counts further. No API route surface yet |
|
||||
| Source rate-policy | **IMPLEMENTED** (pre-existing) | `src/services/source-rate-policy-service.ts` | unchanged this wave |
|
||||
| Search — OpenSearch adapter, index defs, bulk, tenant + purpose/rights filter, rebuild, generation switch | **INTEGRATION_TESTED** | `src/search/*`; unit `tests/p0/search-service.test.ts`; **real OpenSearch 2.11** `tests/integration/search-opensearch.integration.test.ts` incl. projection-disaster rebuild (§68-style) — runs in CI service container | Hybrid (vector) query not built; no metrics emission; index definitions in-memory |
|
||||
| Quality — versioned rules, runs, results, issues, score, trend, GE interchange | **IMPLEMENTED** (local engine) / **SKELETON** (GE delegation) | `src/quality/*`; `tests/p0/quality-service.test.ts`, `tests/p0/expectation-engine` cases | Local engine covers 7 GE core expectation types; everything else delegates via N0D job `0pt.quality.ge_run` whose EXECUTOR (0pt-motor GE runner) does not exist yet — delegated runs stay "delegated" until it does. Never fabricates results |
|
||||
| Dataset — create (pre-existing) + version/query/export with `st0re:artifact:` refs | **IMPLEMENTED** | `src/services/dataset-version-service.ts`; `tests/p0/dataset-version-service.test.ts` (idempotency §53, canonical refs §5) | Export assumes bytes already written by a job; no route surface; in-memory persistence |
|
||||
| Lineage — OpenLineage emission to AUD0's real ingest | **INTEGRATION_TESTED** (contract) | `src/lineage/*`; aud0 contract suite; emitted from dataset version/export, quality runs, search rebuilds | Emission is best-effort (never-throw); no outbox. AUD0-side ingestion verified against contract double, not live AUD0 |
|
||||
| Metadata — OpenMetadata projection (never canonical) | **IMPLEMENTED** | `src/metadata/openmetadata-adapter.ts`; `tests/contracts/metadata-features.contract.test.ts`; `projectAll` rebuild path | No live OpenMetadata exercised (no local container this pass); lineage/glossary/ownership projection not built — only dataset entities |
|
||||
| Analytics — ClickHouse, tenant-isolated, query limits, retention | **INTEGRATION_TESTED** | `src/analytics/clickhouse-analytics.ts`; **real ClickHouse 24.8** `tests/integration/analytics-clickhouse.integration.test.ts` (tenant isolation, closed DSL, TTL schema) — runs in CI service container | Closed metric-DSL only (no ad-hoc queries by design); no quota integration with CL0UD |
|
||||
| Features — registry + Feast adapter | **IMPLEMENTED** (online path, registry) / **SKELETON** (materialize/get-historical) | `src/features/feast-adapter.ts`; contract suite pins Feast's `get-online-features` wire shape | No live Feast server exercised; offline/materialization are N0D delegation contracts whose executor (0pt-motor) does not exist yet; point-in-time correctness therefore NOT yet proven |
|
||||
| Entity Resolution — candidate API, blocking, explanation, hard-rule hook, review handoff | **IMPLEMENTED** | `src/er/entity-resolution-service.ts`; `tests/p0/entity-resolution.test.ts` (hard rules override both directions, review band, blocking refusals) | Generic reference scorer (Jaccard+Levenshtein), NOT Splink probabilistic linkage — that is the `0pt.er.batch_resolution` N0D contract, executor pending in 0pt-motor. No persistence of review queues |
|
||||
| Graph (§10 Graph) | **MISSING** | — | Not started this wave (explicitly out of the delivered P0 subset; next in line per §10) |
|
||||
|
||||
## Canonical platform plumbing
|
||||
|
||||
| Capability | Status | Evidence |
|
||||
|---|---|---|
|
||||
| SiaxRequestContext + propagation headers (mirror of cl0ud `@siax/contract`) | **INTEGRATION_TESTED** (contract-pinned) | `src/platform/siax-context.ts`; `tests/contracts/siax-context.contract.test.ts` |
|
||||
| Canonical 12-code error model + typed `SiaxError` (§6) | **INTEGRATION_TESTED** (contract-pinned) | `src/platform/errors.ts`; same suite |
|
||||
| Canonical resource refs (§5) | **IMPLEMENTED** | `src/platform/resource-ref.ts`; `tests/p0/dataset-version-service.test.ts` |
|
||||
|
||||
## Pre-existing capabilities (unchanged this wave — prior classifications stand)
|
||||
|
||||
| Capability | Status | Note |
|
||||
|---|---|---|
|
||||
| Iceberg write boundary via ST0RE lakehouse | LIVE_VERIFIED (2026-07-13) | `docs/ICEBERG_WRITE_BOUNDARY.md`; untouched |
|
||||
| Document capture/extraction | IMPLEMENTED | LocalBlobStore interim persists bytes; ST0RE raw upload still pending |
|
||||
| Edge order/payment app (Next.js) | see `docs/architecture/ARCHITECTURE.md` | outside this wave's scope |
|
||||
| FastAPI engine (`services/api`) | IMPLEMENTED | outside this wave's scope |
|
||||
| `services/scrapers/*` (WAF-bypass stack) | **quarantined, pending deletion PR** | `feature/0pt-remove-noncompliant-scrapers` — NOT touched by this wave; nothing new wires it |
|
||||
|
||||
## Global gaps blocking PRODUCTION_READY (§3, tracked, not hidden)
|
||||
|
||||
1. Persistent stores REMAINING: quality runs/rules and feature views are
|
||||
still per-process in-memory (`resolveStoreMode` guarantees production
|
||||
boot FAILS rather than serving Maps). CLOSED since the last revision:
|
||||
dataset registry (`opt_datasets`) + immutable versions
|
||||
(`opt_dataset_versions`, monotonic under transaction lock, idempotency
|
||||
with payload-drift detection, cross-tenant ⇒ NOT_FOUND) and versioned
|
||||
search index definitions (`opt_search_index_definitions`, sequential
|
||||
schema versions, replay-idempotent, same-version-different-schema ⇒
|
||||
CONFLICT; OpenSearch remains a rebuildable projection, never canonical
|
||||
truth) are Postgres-canonical with real-Postgres tests.
|
||||
2. Capability registration in CL0UD + API0 discovery for the new
|
||||
capabilities (§3 "Capability är registrerad i CL0UD Capability
|
||||
Registry") — PARTIALLY CLOSED: the collaboration/meeting domain was
|
||||
canonicalized in CL0UD's registry (cl0ud #73/#74); 0pt's own
|
||||
capability set still needs the same registration.
|
||||
3. CLOSED: AUD0 outbox for evidence durability under AUD0 outage (§70) —
|
||||
durable Postgres outbox + dispatcher (at-least-once, bounded batch,
|
||||
backoff, dead-letter, SKIP LOCKED leasing, tenant binding, idempotency)
|
||||
+ real-Postgres tests; dispatcher uses explicit `durable:false` so it
|
||||
never re-enqueues its own delivery evidence.
|
||||
4. CLOSED for datasets/versions/search/ER: `app/api/0pt/datasets*`,
|
||||
`app/api/0pt/search/definitions` + tenant-safed query route, and
|
||||
`app/api/0pt/er/{candidates,batch}` (+ internal ER route) are live;
|
||||
clients cannot raise `rights_tag` via request body (fail-closed to
|
||||
`internal`). Quality routes exist (`bridge-run/issues/rules/runs`).
|
||||
5. 0pt-motor executors for the delegated jobs
|
||||
(`0pt.quality.ge_run`, `0pt.features.*`, `0pt.er.batch_resolution`).
|
||||
6. Observability: no metrics/traces emitted by the new services (§36).
|
||||
|
||||
---
|
||||
|
||||
## P060-P068 ingestion plane (added 2026-09-04)
|
||||
|
||||
Classification scale unchanged. Every row below states what was actually
|
||||
verified, and by which command.
|
||||
|
||||
| Requirement | BEFORE this pass | Status now | Evidence |
|
||||
|---|---|---|---|
|
||||
| **P060** Source Registry: durable canonical registry; restart preserves sources; an unclassified source cannot be activated | **PARTIAL / BROKEN.** The registry existed with Postgres persistence (`sql/002_source_registry.sql`, `src/repositories/postgres-source-registry-repository.ts`) and carried source_id/tenant/project/type/status/license/purpose/rights. It had NO authorization, NO schedule, NO checkpoint, NO schema, and no `owner` distinct from `created_by`. The activation DoD was inverted: `postgres-source-repository.ts:70` wrote `${input.status ?? "active"}`, so a source with no rights, no provenance and no authorization was ACTIVE the instant it was registered, and no activation operation existed at all | **INTEGRATION_TESTED** | `sql/007_ingestion_plane.sql` (authorization/schedule/schemas/checkpoints + `ALTER … SET DEFAULT 'pending'` + `owner_id`); `SourceRegistryService.classifySource`/`activateSource`; `tests/p0/source-activation-gate.test.ts` (11); `tests/api/0pt-p060-routes.test.ts` (9); `tests/p0/ingestion-plane-postgres.realinfra.test.ts` against a live Postgres 16 |
|
||||
| **P061** Connector contract: discover/validate/connect/checkpoint/read/resume/backfill/health/disconnect; two heterogeneous connectors pass one conformance suite | **MISSING.** `services/api/sources/connectors.py` had per-source `fetch_*()` functions only — no lifecycle, no cursor, no resume, no health, no disconnect, no conformance suite | **INTEGRATION_TESTED** | `src/connectors/connector.ts` (all nine operations); `src/connectors/conformance.ts` (15 checks); `HttpJsonConnector` (real node:http server, opaque cursor) and `JsonlFileConnector` (real file, line-offset cursor, CDC deletes) both pass the identical suite — `tests/p0/connector-conformance.test.ts`, which also proves the suite FAILS a fail-open connector |
|
||||
| **P062** CDC/batch/stream normalized to one envelope; restart resumes from a durable checkpoint without silent gaps | **MISSING**, and documented as such: `services/api/tests/test_known_gaps.py:10-19` skipped the CDC case because no CDC pipeline existed anywhere, and `workers/pipeline.py` had no persistence at all | **INTEGRATION_TESTED** | `src/types/ingestion.ts` (one envelope for all three modes); `src/ingestion/ingestion-runner.ts`; `tests/p0/ingestion-runner.test.ts` kills a run inside the sink and proves the resumed run covers all 10 records exactly once; same proof against real Postgres across fresh processes |
|
||||
| **P063** Schema versioning + compatibility policy; a breaking schema is detected before consumer corruption | **MISSING.** `schema_version` existed as a free-form string on datasets/Iceberg writes; there was no comparison, no policy, no versioned source schema | **INTEGRATION_TESTED** | `src/schema/compatibility.ts`, `src/schema/schema-registry.ts`; `tests/p0/schema-evolution.test.ts` (12); the runner's schema gate runs before the first read — `tests/p0/ingestion-runner.test.ts` asserts `sinkCalls === 0` on a break |
|
||||
| **P064** Quality rules behind the SIAX abstraction; failures block or quarantine per declared policy | **PARTIAL.** `src/quality/*` had rules, runs, results, issues, scores and a GE-compatible engine, but a failing rule only opened an ISSUE — the data flowed on regardless. `grep -ri quarantin src/` found one unrelated hit | **INTEGRATION_TESTED** | `src/quality/quality-policy.ts` composes the EXISTING `ExpectationEngine` (not a second engine) and adds the consequence; block / quarantine / warn each proved in `tests/p0/ingestion-runner.test.ts`; quarantine durability proved against real Postgres |
|
||||
| **P065** Normalization/ER: only the canonical highway; no second simplified production engine | **CLOSED.** `services/api/workers/resolve.py` is now a thin adapter: Python no longer scores identity — it POSTs opaque records to `/api/0pt/internal/er/candidates` and applies ONLY explicit hard-rule `match` decisions (score/fuzzy can never merge records, even from a compromised bridge); canonical engine unreachable ⇒ records return UNMERGED, never fallback-scored. Company semantics `stable-identifiers.v3`: LEI global; org_nr hard identity only within the same registration_country; punctuation normalized; negative stable identity dominates every positive; fuzzy alone ⇒ human review, never auto-merge. Cluster-wide hard-negative constraints: A≈B ∧ B≈C never forces A≈C when canonical identity says A≠C | **INTEGRATION_TESTED** | `tests/p0/entity-resolution.test.ts` (v3 semantics incl. hard-negative transitivity), `tests/p0/entity-resolution-http.test.ts` (internal route fails closed: empty/wrong token ⇒ 401), `services/api/tests/test_resolve_determinism.py` (adapter contract: canonical edges only), shared request validation (`src/er/request-validation.ts`, records 2..5000), blocking pytest gate (`.gitea/workflows/python-engine.yml`) |
|
||||
| **P066** Iceberg | out of scope — ST0RE is owned by another workflow | **NOT TOUCHED** | Contract read only (`src/services/iceberg-write-service.ts`, `src/clients/st0re-lakehouse-client.ts`) |
|
||||
| **P067** Lineage: OpenLineage/AUD0 events; source → output lineage is queryable | **PARTIAL.** `src/lineage/lineage-emitter.ts` emitted OpenLineage to AUD0, but nothing in this repo could ANSWER a lineage question — no store, no query, no route | **INTEGRATION_TESTED** | `src/lineage/lineage-edge.ts`, `src/lineage/lineage-store.ts` (local queryable projection; AUD0 stays the system of record), `GET /api/0pt/lineage`; `tests/p0/ingestion-runner.test.ts` queries both directions and proves tenant scoping |
|
||||
| **P068** Durable bounded backfill/replay; repeated backfill is idempotent | **MISSING**, documented as such: `services/api/tests/test_known_gaps.py:44-52` skipped it because "no delivery-store persistence layer exists to dedupe a backfill against" | **INTEGRATION_TESTED** | `opt_ingestion_ledger` + `IngestionRunner.backfill`; `tests/p0/ingestion-runner.test.ts` (repeat emits 0, live checkpoint untouched, a real content change still re-emits); same against real Postgres |
|
||||
|
||||
### What was actually run (not "should pass")
|
||||
|
||||
- `pnpm run typecheck` — clean.
|
||||
- `pnpm run test` — **365 passed, 25 skipped** (was 311/18 before this pass).
|
||||
- `pnpm run build` — clean; the four new routes appear in the route table.
|
||||
- `pnpm run lint` — 12 errors / 5 warnings, **all pre-existing** (identical
|
||||
count on the base commit; advisory in CI). This pass adds none.
|
||||
- Real Postgres 16: `tests/p0/ingestion-plane-postgres.realinfra.test.ts`
|
||||
with `INGESTION_TEST_DATABASE_URL` — 7 passed.
|
||||
- **Real container restart**, run manually against that same database:
|
||||
`docker restart` the Postgres container, then re-read through the real
|
||||
repositories. Before and after were identical — 2 sources (one `active`
|
||||
with its owner, one `pending`), 1 checkpoint (cursor 6, 6 committed),
|
||||
6 ledger rows, 1 lineage edge, 4 quarantined records, 1 schema version.
|
||||
That is P060's "a restart preserves all sources", measured rather than
|
||||
asserted.
|
||||
|
||||
### A real bug this pass wrote and then caught
|
||||
|
||||
The first draft of `IngestionRepository` combined the idempotency check and
|
||||
its recording into one atomic call, made BEFORE the sink. The crash/resume
|
||||
test then delivered **7 of 10 records**: the page in flight when the sink
|
||||
died had already been marked delivered, so the resumed run skipped it as a
|
||||
duplicate — permanent, silent data loss. The fix is the `peekDeliveries` /
|
||||
`commitDeliveries` split, with both the ledger commit and the checkpoint
|
||||
commit after the sink resolves. Recorded here because the failure mode is
|
||||
the interesting artifact, not the fix.
|
||||
|
||||
### Honest gaps in the new plane (not hidden)
|
||||
|
||||
1. **Delivery is AT-LEAST-ONCE, not exactly-once.** Two concurrent runs on
|
||||
the same (source, partition) can both peek "new" and both emit.
|
||||
`envelope_id` is deterministic so a dedup-capable sink survives it. There
|
||||
is no per-(source, partition) run lock. Do not describe this plane as
|
||||
exactly-once.
|
||||
2. **No CDC *source* exists.** The envelope, the operations and the runner
|
||||
handle change events, and `JsonlFileConnector` emits real deletes from an
|
||||
`_op` field — but this repo still has no Debezium/WAL/replication-slot
|
||||
consumer. P062's contract is met; a CDC connector is still MISSING.
|
||||
3. **0pt does not schedule itself.** `opt_source_schedule` is a declaration
|
||||
ACT0 would read; there is no cron daemon here.
|
||||
4. **The sink is caller-supplied.** The runner stops at the sink; the
|
||||
lakehouse write boundary is ST0RE's (P066, another workflow's repo).
|
||||
5. **Nothing here is deployed.** See the deployment note below.
|
||||
6. **`QualityService`'s own rule/run/issue registry is still in-process
|
||||
Maps** (`src/quality/quality-service.ts:47-50`). The new *enforcement*
|
||||
path is durable; the older rule registry is not. Unchanged by this pass.
|
||||
7. **Python tests still run nowhere.** `grep -rn "pytest" .gitea/workflows
|
||||
.github/workflows` finds nothing — `services/api/tests/*` (9 files) has
|
||||
never been executed by CI. Flagged, not fixed (it needs a workflow change
|
||||
and a Python toolchain decision).
|
||||
|
||||
### Deployment reality, measured 2026-09-04
|
||||
|
||||
`https://0pt.app/api/health` answers **200** from **Vercel** with
|
||||
`{"db":"not_configured"}`, and `https://0pt.app/api/0pt/sources` answers
|
||||
**404** — the deployed build predates the entire `app/api/0pt/*` capability
|
||||
layer. `https://api.0pt.app/health` answers **404** (the FastAPI engine is
|
||||
not reachable), and `.gitea/workflows/cd.yml` still guards on
|
||||
`COOLIFY_APP_ID = TODO-no-coolify-app-provisioned-yet`. So: none of the
|
||||
capability layer — neither this pass's work nor P010/P014's — is running in
|
||||
production, and P060's durability guarantee cannot be exercised there,
|
||||
because production has no database configured and does not serve the routes.
|
||||
Verified with `curl`; not inferred.
|
||||
|
||||
### P065 — closure record (2026-09-07)
|
||||
|
||||
The removal plan below was executed in full and is verified in CI:
|
||||
|
||||
1. ✅ Canonical ER exposed as `POST /api/0pt/er/candidates` (public,
|
||||
tenant-safed) AND `POST /api/0pt/internal/er/candidates` (service-token,
|
||||
fails closed without `X-Internal-Token`).
|
||||
2. ✅ Python CI job: `.gitea/workflows/python-engine.yml` runs
|
||||
`services/api/tests` as a BLOCKING pytest gate.
|
||||
3. ✅ `workers/resolve.py` rewritten as the HTTP adapter — fail-closed to
|
||||
UNMERGED records when the bridge is unreachable; no fallback scorer
|
||||
remains. Cluster-wide hard-negative constraints prevent transitive
|
||||
matches from overriding canonical identity decisions.
|
||||
4. ✅ `services/api/tests/test_resolve_determinism.py` asserts the adapter
|
||||
contract: canonical match edges merge; review/non-match edges do not;
|
||||
hard negatives are respected transitively.
|
||||
|
||||
Residual honesty: the PYTHON-side tests run in CI, but the full
|
||||
adapter→Next.js route chain has not been exercised against a live deployed
|
||||
edge in this branch (the route's own fail-closed behavior is unit-proven).
|
||||
That keeps P065 at INTEGRATION_TESTED, not LIVE_VERIFIED.
|
||||
|
||||
---
|
||||
|
||||
## SLUTPRODUKTIONS-VERIFIERING 2026-09-22 (live-mätt, ej påstått)
|
||||
|
||||
**Prod: `https://0pt.app` på flottan (server6, Coolify-app `shxd6lepxuxfsjsp0ath3z1g`),
|
||||
container-image på exakt merge-SHA `df6ab70` via den signerade release-kedjan
|
||||
(cosign-attestation verifierad före redeploy). DNS A → 65.109.19.146,
|
||||
NS chris/maxine.ns.cloudflare.com — Vercel är borta.**
|
||||
|
||||
### Live-matris, mätt med curl (2026-09-22 04:09 UTC)
|
||||
|
||||
| Yta | Resultat |
|
||||
|---|---|
|
||||
| Front `/` + 10 nyckelsidor (bestall, katalog, kallor, tjanster, portal, admin, order) | 200 ×11 |
|
||||
| `/api/health` | 200 `{ok:true, db:"ok", engine:"ok"}` |
|
||||
| `/api/ready` | 200 (fail-closed-grinden: var 503 före DB-provisioneringen) |
|
||||
| `/api/0pt/*` (9 rutter) anonymt | **401 ×9** — S1-luckan (2026-08-07: anonym 200) är STÄNGD |
|
||||
| `/api/orders`, `/api/admin/metrics` anonymt | 401 |
|
||||
| `/api/subscriptions` | 200 `[]` (var 500: migration 014, se nedan) |
|
||||
| `POST /api/estimates` | 200 (edge räknar + engine-wire) |
|
||||
| `GET /api/catalog`, `/api/sources` | 200 (publika läsvyter, avsiktligt) |
|
||||
| `GET /api/vehicles` utan param | 400 (kräver reg-nr — korrekt) |
|
||||
| Security headers | HSTS(63072000+preload) · CSP · XFO DENY · nosniff · ingen X-Powered-By |
|
||||
|
||||
### Denna pass fixat (3 PR, alla mergerade + deployade live)
|
||||
|
||||
1. **PR #80** — G17 hook-pin re-bump `7725498 → a401880` (re-review: 0 diff under
|
||||
`ci/v2/reference-hooks/`). Release chain + gated deploy gröna igen.
|
||||
2. **PR #81 (fix för issue #53)**: 7 legacy money-routes (orders, orders/[id],
|
||||
subscriptions, payments/deposit, payments/release, webhooks/stripe,
|
||||
admin/metrics) mappar nu `SiaxError(DEPENDENCY_UNAVAILABLE, 503, retryable)`
|
||||
via `app/api/siax-error-mapping.ts` i stället för att läcka Next opaka 500.
|
||||
Stripe-webhooken svarar 503 `received:false reason:store_unavailable` på
|
||||
store-outage — aldrig tyst `received:true` för ett betalt event som inte
|
||||
processats. 3 nya tester pinar kontraktet.
|
||||
3. **PR #86**: `sql/014_subscriptions_created_at.sql` — `lib/subscriptions-store.ts`
|
||||
SELECT:ade `created_at` som `001_init.sql` aldrig definierat (42703 mätt live:
|
||||
GET /api/subscriptions har ALWAYS varit 500 mot Postgres). Kolonnen ADD:ad
|
||||
idempotent LIVE på 0pt-edge-postgres; route svarar nu 200 `[]`.
|
||||
4. **PR #87**: cd-gatens not-ready-regex — cosign 3.1.3 säger
|
||||
`no matching attestations:` (utan `found`); en transient not-ready misstolkades
|
||||
som hårt verify-fel. Fail-closed för riktiga attestfel oförändrad.
|
||||
5. **CI-infra reparerad**: repo-secrets `SIAX_CI_TOKEN` + `REGISTRY_TOKEN` var
|
||||
döda efter token-svepet (2026-09-22 eng0-introspect-rotation revoade 107+ tokens;
|
||||
fällan: 0pt:s repo-secret överrider org-secreten — org-PUT räcker inte). Nya
|
||||
siax-ci-tokens (`write:package,read:package` + all) mintade och satta via
|
||||
secrets-API (PUT 204, verifierade via lyckad release-kedja).
|
||||
|
||||
### 0pt-motor (engine, satellite)
|
||||
|
||||
- 6 containers Up på server6 (`motor-provisional`), core `/health` ok+pg+redis,
|
||||
delivery `/version` = fb67870, core revision = 6cfd433 — båda IN main.
|
||||
- CI grön på main (runs 55985/55987/56004) — healer-issue #38 (run 40562,
|
||||
2026-09-20) stängd som transient runner-flake (apt-GPG under last).
|
||||
- Bara `main` kvar i 0pt och 0pt-motor (8 + 6 stale grenar raderade — alla
|
||||
bevisade redan mergerade via `git merge-base --is-ancestor`).
|
||||
- motor/-executor-koden (signals/NER/normalization, 3507 rader nya sedan 6cfd433)
|
||||
konsumeras av `Dockerfile.motor`-imaget som ännu inte har ett driftsatt runtime
|
||||
i stacken (core/ingest/resolve/delivery importerar ej motor/ — verifierat
|
||||
import-graf). Byggs enligt `scripts/build-images.sh` + `verify-image.sh`-gaten
|
||||
när executor-ytan aktiveras — dokumenterat i `docs/runbook/deploy.md`.
|
||||
|
||||
### Kvarstår (ärligt, ej gömt)
|
||||
|
||||
1. **Stripe-creds** (Simon): live-keys → edge-envs `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`.
|
||||
Innan dess förblir payments 501 och issue #67:s staging-triage kan ej köras.
|
||||
2. **queue: not_configured** — ingen Redis i edge-stacken (ärligt rapporterat).
|
||||
3. **`0pt.siax.io`/`motor.siax.io`**: medvetet ej återupprättade (0pt.app + lokala
|
||||
127.0.0.1-portar är kanoniska ytor; issues #70/#35 stängda/avgjorda därefter).
|
||||
4. **motor.siax.io**: motor:s publika yta kräver Simon-beslut (traefik-labels + DNS).
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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`._
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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: `main` — ahead 0 / behind 0
|
||||
- Ocommittade filer: 0 · otrackade: 0
|
||||
- Worktrees: 1
|
||||
- Lokala grenar: 1 · remote: 17
|
||||
|
||||
## Livebevis (observerat 2026-11-10T03:05:09)
|
||||
|
||||
| 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`
|
||||
|
||||
## Prioriterad backlogg
|
||||
|
||||
| Prio | Åtgärd | Konkret nästa steg |
|
||||
|---|---|---|
|
||||
| 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-10T03:03:34 från git, manifest, kodsignaler och live-prober. Omgenereras av `scripts/estate-production-audit.py`._
|
||||
@@ -103,3 +103,5 @@ 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.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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.
|
||||
+105
-39
@@ -1,58 +1,130 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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*'] };
|
||||
|
||||
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 EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
|
||||
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)) && verifyInternalServiceToken(req.headers)) {
|
||||
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 (_authDiagCount < 5) {
|
||||
_authDiagCount += 1;
|
||||
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}`,
|
||||
);
|
||||
console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`);
|
||||
}
|
||||
|
||||
// Browser sessions (Zitadel OIDC -> signed cookie) are a first-class
|
||||
// principal for the portal. A Bearer token always wins when present.
|
||||
if (!req.headers.get('authorization')) {
|
||||
const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value);
|
||||
if (
|
||||
@@ -62,7 +134,7 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
typeof session.exp === 'number' &&
|
||||
session.exp > Date.now()
|
||||
) {
|
||||
const forwarded = new Headers(req.headers);
|
||||
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 } });
|
||||
@@ -71,23 +143,17 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
|
||||
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 = new Headers(req.headers);
|
||||
const forwarded = stripVerifiedHeaders(req);
|
||||
forwarded.set('x-0pt-verified-sub', principal.sub);
|
||||
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
||||
return NextResponse.next({ request: { headers: forwarded } });
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
// 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) } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @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,235 +1,2 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
export * from './relationships';
|
||||
export * from './idempotency';
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+28
-14
@@ -71,25 +71,39 @@
|
||||
"canonical": true,
|
||||
"parentRepo": null,
|
||||
"capabilities": [
|
||||
"prospekt.email_verify",
|
||||
"prospekt.jobs_write",
|
||||
"prospekt.jobs_read",
|
||||
"prospekt.search_read",
|
||||
"prospekt.search_write",
|
||||
"0pt.query_source",
|
||||
"0pt.catalog_list",
|
||||
"0pt.crawl_job",
|
||||
"0pt.document_ocr",
|
||||
"0pt.write_external_record",
|
||||
"0pt.estimate_compute",
|
||||
"0pt.execute_metric",
|
||||
"0pt.lakehouse_mapping_manage",
|
||||
"0pt.lakehouse_write",
|
||||
"0pt.source_rate_policy_manage",
|
||||
"0pt.screen_sanctions",
|
||||
"0pt.estimate_compute",
|
||||
"0pt.vehicle_lookup",
|
||||
"0pt.catalog_list",
|
||||
"0pt.source_registry_list",
|
||||
"0pt.load_full_table",
|
||||
"0pt.order_create",
|
||||
"0pt.order_payment",
|
||||
"0pt.write_iceberg_table"
|
||||
"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"
|
||||
],
|
||||
"surfaces": [
|
||||
"api",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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)."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"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,5 +12,6 @@
|
||||
],
|
||||
"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,5 +12,6 @@
|
||||
],
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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)."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"repo": "0pt",
|
||||
"framework": "dockerfile",
|
||||
"build": "Coolify build_pack=dockerfile (byggd av Coolify från main)",
|
||||
"start": "Coolify container (app 0pt)",
|
||||
"port": 3000,
|
||||
"healthcheck": {
|
||||
"path": "/api/health"
|
||||
},
|
||||
"dedicated": true
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
{
|
||||
"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,10 @@
|
||||
{
|
||||
"id": "pe-0pt-829150fd0481",
|
||||
"repo": "0pt",
|
||||
"evidenceType": "deploy-log",
|
||||
"result": "pass",
|
||||
"verifiedAt": "2026-11-10T10:04:54Z",
|
||||
"verifiedBy": "coolify-deployment-db (application_deployment_queues, senaste finished-deploy)",
|
||||
"proofRef": "coolify://deployment/https://0pt.app,https://api.0pt.app",
|
||||
"sourceSha": "829150fd0481191b4c96b52475ecc822e46a5fe7"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"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": "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-8b5e23aa9b (sha256:889753b7e5a9db3e1c870eeb38f450cb0f20498e1025edb28153233e0b06f46d) är cosign-signerad
|
||||
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-1e5fafce1a (sha256:80237edf1530110650cdfc6da8e821130d9a005cd47d79528d03dbeae2485822) är cosign-signerad
|
||||
med nyckelparet vars pubkey ligger här. Privata nyckeln raderad efter signering.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPRG9iG1pjIW548KoIduQPD/B3Yb
|
||||
tg5hPmy7o4feVQsdDnW38VtemVBQ4I+goytV9X4XkrFiweKmfqEMhGyPgg==
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgomLeApgQ4ZWeypoU2LfjLIkuBCa
|
||||
hGwo5+pYW9ae3XMKauDUK17QwMXaSwJ2u9ulefai7+/Fiv3/4Jh8EciMVA==
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "sb-0pt-829150fd0481",
|
||||
"repo": "0pt",
|
||||
"commitSha": "829150fd0481191b4c96b52475ecc822e46a5fe7",
|
||||
"branch": "main",
|
||||
"producedAt": "2026-11-10T10:04:54Z"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.render_report",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0ptRenderReport",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.run_data_quality_check",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0ptRunDataQualityCheck",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.run_data_quality_check",
|
||||
"kind": "cli",
|
||||
"entrypoint": "siax 0pt run-data-quality-check",
|
||||
"authRequired": true,
|
||||
"status": "planned"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.extract_document",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.extract_document",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.get_lineage",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.get_provenance",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.normalize_value",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.normalize_value",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.quality_checks",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.quality.checks",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.quality_list_runs",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.quality.list_runs",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.quality_reports",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.quality.reports",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.quality_run",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.quality.run",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.resolve_entities",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.resolve_entities",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
|
||||
"capabilityId": "0pt.structure.route_parsing",
|
||||
"kind": "api",
|
||||
"entrypoint": "contracts/openapi.yaml#0pt.route_parsing",
|
||||
"authRequired": true,
|
||||
"status": "live"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
-- 022_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
|
||||
--
|
||||
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
|
||||
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
|
||||
-- must consume these rows through this API/contract, never by writing here.
|
||||
--
|
||||
-- Conflict preservation is structural: the unique key includes source_ref and
|
||||
-- observed_at, so two sources disagreeing about the same predicate are two
|
||||
-- rows, never an overwrite. `status` records observed/supported/disputed/
|
||||
-- retracted; reconciliation is a separate, explicit decision.
|
||||
--
|
||||
-- Tenant isolation: RLS is FORCEd and keyed on a transaction-local GUC that
|
||||
-- only trusted server code sets (same pattern as 019_document_credit_rls.sql).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_canonical_entities (
|
||||
entity_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL, -- company|person|web_page|product|...
|
||||
identity_namespace TEXT NOT NULL, -- url|orgnr|lei|gtin|...
|
||||
identity_value TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, entity_type, identity_namespace, identity_value)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_entities_tenant_type
|
||||
ON opt_canonical_entities (tenant_id, entity_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_canonical_facts (
|
||||
fact_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||
predicate TEXT NOT NULL, -- e.g. web.title, product.price
|
||||
value_json JSONB NOT NULL,
|
||||
value_type TEXT NOT NULL, -- string|number|boolean|date|json
|
||||
value_unit TEXT,
|
||||
source_ref TEXT NOT NULL,
|
||||
source_record_id TEXT,
|
||||
st0re_artifact_id TEXT,
|
||||
aud0_evidence_ref TEXT,
|
||||
run_ref TEXT,
|
||||
extractor_version TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_to TIMESTAMPTZ,
|
||||
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
|
||||
quality_status TEXT NOT NULL DEFAULT 'unvalidated', -- passed|warned|failed|unvalidated
|
||||
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_fact/v1',
|
||||
status TEXT NOT NULL DEFAULT 'observed'
|
||||
CHECK (status IN ('observed','supported','disputed','retracted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, entity_id, predicate, source_ref, observed_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_facts_entity
|
||||
ON opt_canonical_facts (tenant_id, entity_id, predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_facts_predicate
|
||||
ON opt_canonical_facts (tenant_id, predicate, observed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_canonical_relations (
|
||||
relation_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||
predicate TEXT NOT NULL, -- LINKS_TO|SOLD_BY|USES_SOFTWARE|...
|
||||
object_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ref TEXT NOT NULL,
|
||||
st0re_artifact_id TEXT,
|
||||
aud0_evidence_ref TEXT,
|
||||
run_ref TEXT,
|
||||
extractor_version TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_to TIMESTAMPTZ,
|
||||
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
|
||||
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_relation/v1',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, subject_id, predicate, object_id, source_ref, observed_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_relations_subject
|
||||
ON opt_canonical_relations (tenant_id, subject_id, predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
|
||||
ON opt_canonical_relations (tenant_id, object_id, predicate);
|
||||
|
||||
ALTER TABLE opt_canonical_entities ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_canonical_entities FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS opt_canonical_entities_tenant ON opt_canonical_entities;
|
||||
CREATE POLICY opt_canonical_entities_tenant ON opt_canonical_entities
|
||||
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_canonical_facts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_canonical_facts FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS opt_canonical_facts_tenant ON opt_canonical_facts;
|
||||
CREATE POLICY opt_canonical_facts_tenant ON opt_canonical_facts
|
||||
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_canonical_relations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_canonical_relations FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS opt_canonical_relations_tenant ON opt_canonical_relations;
|
||||
CREATE POLICY opt_canonical_relations_tenant ON opt_canonical_relations
|
||||
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 023_canonical_grants.sql — app-role privileges for 022 canonical tables.
|
||||
--
|
||||
-- Tables created by a manual psql migration are owned by the migrating role
|
||||
-- (0pt_edge); the running application connects as 0pt_app. Existing 0pt tables
|
||||
-- carry explicit grants rather than default ACLs, so the canonical tables need
|
||||
-- the same treatment. RLS still applies to 0pt_app (FORCE is on); this only
|
||||
-- grants the statement privileges, not row visibility.
|
||||
--
|
||||
-- Idempotent and role-guarded: no-op in environments without the app role.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '0pt_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE
|
||||
ON opt_canonical_entities, opt_canonical_facts, opt_canonical_relations
|
||||
TO "0pt_app";
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* CL0UD workload-identity JWT verification for explicit machine routes.
|
||||
* Config-driven; fail-closed when a workload token is presented but invalid.
|
||||
* Tenant/capabilities come from verified claims only.
|
||||
*/
|
||||
import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTPayload } from 'jose';
|
||||
|
||||
export type WorkloadPrincipal = {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
capabilities: string[];
|
||||
claims: JWTPayload;
|
||||
};
|
||||
|
||||
export class WorkloadVerificationError extends Error {
|
||||
constructor(message: string) { super(message); this.name = 'WorkloadVerificationError'; }
|
||||
}
|
||||
|
||||
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||
let resolverOverride: ((protectedHeader: unknown, token: unknown) => Promise<CryptoKey | Uint8Array>) | null = null;
|
||||
|
||||
export function __setWorkloadKeyResolverForTests(fn: typeof resolverOverride) { resolverOverride = fn; }
|
||||
export function __resetWorkloadJwksCache() { jwksCache.clear(); resolverOverride = null; }
|
||||
|
||||
function readEnv(name: string): string | undefined {
|
||||
const v = process.env[name];
|
||||
return v && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
export function cl0udWorkloadConfig() {
|
||||
const issuer = readEnv('CL0UD_WORKLOAD_ISSUER') ?? readEnv('CL0UD_ISSUER');
|
||||
const jwksUrl = readEnv('CL0UD_WORKLOAD_JWKS_URL');
|
||||
const audience = readEnv('CL0UD_WORKLOAD_AUDIENCE') ?? '0pt';
|
||||
const tenantClaim = readEnv('CL0UD_WORKLOAD_TENANT_CLAIM') ?? 'tenant_id';
|
||||
const capabilitiesClaim = readEnv('CL0UD_WORKLOAD_CAPABILITIES_CLAIM') ?? 'capabilities';
|
||||
return { issuer, jwksUrl, audience, tenantClaim, capabilitiesClaim };
|
||||
}
|
||||
|
||||
export function isCl0udWorkloadConfigured(): boolean {
|
||||
const c = cl0udWorkloadConfig();
|
||||
return Boolean(c.issuer || c.jwksUrl);
|
||||
}
|
||||
|
||||
async function getJwks(c: ReturnType<typeof cl0udWorkloadConfig>) {
|
||||
if (resolverOverride) return resolverOverride;
|
||||
const url = c.jwksUrl ?? (c.issuer ? `${c.issuer.replace(/\/$/, '')}/.well-known/jwks.json` : undefined);
|
||||
if (!url) throw new WorkloadVerificationError('CL0UD workload JWKS URL/issuer not configured');
|
||||
let jwks = jwksCache.get(url);
|
||||
if (!jwks) { jwks = createRemoteJWKSet(new URL(url)); jwksCache.set(url, jwks); }
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function stringArray(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string' && x.length > 0);
|
||||
if (typeof v === 'string' && v.trim()) return v.split(/[\s,]+/).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function verifyCl0udWorkloadToken(token: string): Promise<WorkloadPrincipal> {
|
||||
const c = cl0udWorkloadConfig();
|
||||
if (!isCl0udWorkloadConfigured()) throw new WorkloadVerificationError('CL0UD workload identity not configured');
|
||||
const jwks = await getJwks(c);
|
||||
const { payload } = await jwtVerify(token, jwks as any, {
|
||||
...(c.issuer ? { issuer: c.issuer } : {}),
|
||||
audience: c.audience,
|
||||
});
|
||||
const tenantId = payload[c.tenantClaim];
|
||||
if (typeof tenantId !== 'string' || !tenantId) throw new WorkloadVerificationError(`missing ${c.tenantClaim} claim`);
|
||||
const sub = typeof payload.sub === 'string' && payload.sub ? payload.sub : `workload:${tenantId}`;
|
||||
const capabilities = stringArray(payload[c.capabilitiesClaim] ?? payload.scope);
|
||||
return { sub, tenantId, capabilities, claims: payload };
|
||||
}
|
||||
|
||||
/** Best-effort check: does this bearer look like a configured CL0UD workload token? */
|
||||
export function looksLikeCl0udWorkloadToken(token: string): boolean {
|
||||
const c = cl0udWorkloadConfig();
|
||||
if (!c.issuer) return false;
|
||||
try { return decodeJwt(token).iss === c.issuer; } catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* `src/auth/internal-service-tenant.ts` — server-side tenant scope for the
|
||||
* machine-to-machine principal authenticated by `X-Internal-Token`.
|
||||
*
|
||||
* WHY THIS EXISTS (P0, 2026-11-07)
|
||||
* --------------------------------
|
||||
* `X-Internal-Token` proves that the caller is the SIAX engine process. It
|
||||
* does NOT prove which tenant that process may act for. The first attempt at
|
||||
* fixing the "internal token bypasses the verified-tenant guard" hole copied a
|
||||
* CLIENT HEADER (`X-0pt-Internal-Tenant`) or fell back to a single
|
||||
* `INTERNAL_TENANT_ID` env var:
|
||||
*
|
||||
* const internalTenant =
|
||||
* req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID;
|
||||
*
|
||||
* That is not an authorization source. Any holder of the shared internal token
|
||||
* could still name any tenant in a request header, which is exactly the
|
||||
* cross-tenant write the fix was supposed to stop (tenant_id
|
||||
* 999999999999999999 returned 201 before).
|
||||
*
|
||||
* The correct model mirrors a verified Bearer token: the principal's tenant
|
||||
* authority is a SERVER-SIDE fact, never a request field.
|
||||
*
|
||||
* - `INTERNAL_SERVICE_TENANTS` is a comma-separated allowlist configured on
|
||||
* the 0pt deployment (env/secret), i.e. "the tenants this engine instance
|
||||
* is entitled to write for".
|
||||
* - When the allowlist is empty, the internal principal has NO tenant
|
||||
* authority: tenant-scoped writes fail closed (403). Routes that do not
|
||||
* carry a tenant (evidence outbox dispatch, ER candidates, search
|
||||
* definitions) are unaffected.
|
||||
* - A single allowlisted tenant is ALSO placed in `x-0pt-verified-tenant`
|
||||
* by middleware so existing single-tenant checks keep working; two or more
|
||||
* are carried in `x-0pt-verified-service-tenants` and the tenant guard
|
||||
* checks membership.
|
||||
* - Any inbound value for either header is stripped by middleware before
|
||||
* forwarding, so a client can never forge tenant authority.
|
||||
*
|
||||
* No client header is read here. `X-0pt-Internal-Tenant` is deliberately not a
|
||||
* contract.
|
||||
*/
|
||||
|
||||
/** Env var: comma-separated tenants this internal service principal may assert. */
|
||||
export const INTERNAL_SERVICE_TENANTS_VAR = 'INTERNAL_SERVICE_TENANTS';
|
||||
|
||||
/**
|
||||
* Request header installed by middleware.ts ONLY (never accepted from a
|
||||
* client, and stripped from every inbound request). Carries the full
|
||||
* server-configured service tenant scope; may be the empty string, which means
|
||||
* "service principal without tenant authority".
|
||||
*/
|
||||
export const SERVICE_TENANT_SCOPE_HEADER = 'x-0pt-verified-service-tenants';
|
||||
|
||||
/**
|
||||
* Request header installed by middleware.ts ONLY: the CL0UD workload token's
|
||||
* verified capability list, comma-separated. Stripped from every inbound
|
||||
* request like the tenant scope.
|
||||
*/
|
||||
export const SERVICE_CAPABILITIES_HEADER = 'x-0pt-verified-capabilities';
|
||||
|
||||
/** Parse a comma-separated tenant allowlist (trimmed, de-duplicated, ordered). */
|
||||
export function parseServiceTenants(value: string | undefined | null): string[] {
|
||||
if (!value) return [];
|
||||
const tenants: string[] = [];
|
||||
for (const part of value.split(',')) {
|
||||
const tenant = part.trim();
|
||||
if (tenant && !tenants.includes(tenant)) tenants.push(tenant);
|
||||
}
|
||||
return tenants;
|
||||
}
|
||||
|
||||
/** Read the configured service tenant allowlist from the environment. */
|
||||
export function resolveInternalServiceTenants(
|
||||
env: { INTERNAL_SERVICE_TENANTS?: string } = process.env,
|
||||
): string[] {
|
||||
return parseServiceTenants(env.INTERNAL_SERVICE_TENANTS);
|
||||
}
|
||||
|
||||
/** Read the scope middleware installed on the request (already stripped+set). */
|
||||
export function readServiceTenantScope(headers: { get(name: string): string | null | undefined }): string[] {
|
||||
return parseServiceTenants(headers.get(SERVICE_TENANT_SCOPE_HEADER));
|
||||
}
|
||||
@@ -24,7 +24,9 @@ export interface Doc0UploadReceipt {
|
||||
/** The POST may have committed even if its response could not be verified. */
|
||||
export class Doc0UploadOutcomeUnknownError extends Error {
|
||||
constructor() {
|
||||
super('DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key');
|
||||
super(
|
||||
'DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key',
|
||||
);
|
||||
this.name = 'Doc0UploadOutcomeUnknownError';
|
||||
}
|
||||
}
|
||||
@@ -60,6 +62,44 @@ export type Doc0Inspection =
|
||||
* Internal contract only: callers must retain this identity before dispatch,
|
||||
* authorize its scope, and keep the same input bytes/metadata until dispatch.
|
||||
*/
|
||||
/**
|
||||
* One upload identity per operation — not per file.
|
||||
*
|
||||
* "Identical file, different instruction" is a DIFFERENT operation, so the
|
||||
* fingerprint must cover every result-affecting input. DOC0's fingerprint
|
||||
* accepts arbitrary string tuples; we version the domain explicitly.
|
||||
*
|
||||
* Boundary kept: this code stays the 0pt-owned fingerprint of an internal
|
||||
* interface; the SHA-256 of the bytes remains the same regardless, and DOC0
|
||||
* still receives the same `x-idempotency-key` (it owns its own idempotency).
|
||||
*/
|
||||
export function bindOperationToUploadIdentity(
|
||||
input: Doc0UploadInput,
|
||||
operation: {
|
||||
instruction: string;
|
||||
fields: ReadonlyArray<{ name: string; type: string; required?: boolean }>;
|
||||
modelId: string;
|
||||
dataClass: string;
|
||||
format: 'json' | 'csv';
|
||||
},
|
||||
): Doc0UploadInput {
|
||||
const payloadVersion = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
'0pt-structure-operation-v1',
|
||||
operation.instruction.length,
|
||||
operation.instruction,
|
||||
operation.fields.map((f) => [f.name, f.type, f.required === true]),
|
||||
operation.modelId,
|
||||
operation.dataClass,
|
||||
operation.format,
|
||||
]),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return { ...input, filename: `${input.filename}#${payloadVersion}` };
|
||||
}
|
||||
|
||||
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
|
||||
sourceSha256: string;
|
||||
sourceSizeBytes: number;
|
||||
@@ -68,13 +108,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
|
||||
wireMimeType: string;
|
||||
} {
|
||||
if (
|
||||
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType]
|
||||
.every((value) => typeof value === 'string' && value.trim()) ||
|
||||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey]
|
||||
.every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) ||
|
||||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) ||
|
||||
!(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0
|
||||
) throw new Error('Invalid DOC0 upload input');
|
||||
![
|
||||
input.tenantId,
|
||||
input.projectId,
|
||||
input.actorId,
|
||||
input.requestId,
|
||||
input.idempotencyKey,
|
||||
input.filename,
|
||||
input.mimeType,
|
||||
].every((value) => typeof value === 'string' && value.trim()) ||
|
||||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey].every((value) =>
|
||||
/^[\x21-\x7e]{1,256}$/.test(value),
|
||||
) ||
|
||||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(
|
||||
input.classification,
|
||||
) ||
|
||||
!(input.bytes instanceof Uint8Array) ||
|
||||
input.bytes.byteLength === 0
|
||||
)
|
||||
throw new Error('Invalid DOC0 upload input');
|
||||
const wireMimeType = input.mimeType.toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
|
||||
throw new Error('Invalid DOC0 upload MIME type');
|
||||
@@ -83,10 +135,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
|
||||
const wireFilename = `source.${extension}`;
|
||||
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
|
||||
const sourceSizeBytes = input.bytes.byteLength;
|
||||
const requestFingerprint = createHash('sha256').update(JSON.stringify([
|
||||
'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256,
|
||||
sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null,
|
||||
])).digest('hex');
|
||||
const requestFingerprint = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
'doc0-upload-v1',
|
||||
input.tenantId,
|
||||
input.projectId,
|
||||
input.actorId,
|
||||
sourceSha256,
|
||||
sourceSizeBytes,
|
||||
wireFilename,
|
||||
wireMimeType,
|
||||
input.filename,
|
||||
'unknown',
|
||||
input.classification,
|
||||
[],
|
||||
null,
|
||||
]),
|
||||
)
|
||||
.digest('hex');
|
||||
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
|
||||
}
|
||||
|
||||
@@ -109,7 +176,9 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
parts.push(chunk.value);
|
||||
}
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
|
||||
} finally { reader.releaseLock(); }
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,16 +200,28 @@ export class Doc0UploadClient {
|
||||
},
|
||||
) {
|
||||
let url: URL;
|
||||
try { url = new URL(options.baseUrl); }
|
||||
catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); }
|
||||
try {
|
||||
url = new URL(options.baseUrl);
|
||||
} catch {
|
||||
throw new Error('A trusted HTTPS DOC0 endpoint is required');
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!options.serviceToken.trim() ||
|
||||
!Number.isSafeInteger(options.maxBytes) ||
|
||||
options.maxBytes <= 0 ||
|
||||
(options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000))
|
||||
(options.timeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.timeoutMs) ||
|
||||
options.timeoutMs < 1_000 ||
|
||||
options.timeoutMs > 300_000))
|
||||
) {
|
||||
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
|
||||
throw new Error(
|
||||
'A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required',
|
||||
);
|
||||
}
|
||||
// Keep the validated values even if the caller later changes its options object.
|
||||
this.options = { ...options };
|
||||
@@ -167,7 +248,11 @@ export class Doc0UploadClient {
|
||||
classification: input.classification,
|
||||
}),
|
||||
);
|
||||
body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename);
|
||||
body.set(
|
||||
'file',
|
||||
new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }),
|
||||
identity.wireFilename,
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
|
||||
@@ -183,11 +268,16 @@ export class Doc0UploadClient {
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
|
||||
});
|
||||
} catch { throw new Doc0UploadOutcomeUnknownError(); }
|
||||
} catch {
|
||||
throw new Doc0UploadOutcomeUnknownError();
|
||||
}
|
||||
// Even a non-2xx response does not prove the write did not commit.
|
||||
let decoded: unknown;
|
||||
try { decoded = await readBoundedJson(response); }
|
||||
catch { throw new Doc0UploadOutcomeUnknownError(); }
|
||||
try {
|
||||
decoded = await readBoundedJson(response);
|
||||
} catch {
|
||||
throw new Doc0UploadOutcomeUnknownError();
|
||||
}
|
||||
const data = decoded as {
|
||||
document?: {
|
||||
documentId?: unknown;
|
||||
@@ -239,33 +329,63 @@ export class Doc0UploadClient {
|
||||
* is complete. No automatic retry, settlement or customer route is wired.
|
||||
*/
|
||||
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
|
||||
if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey]
|
||||
.every((value) => typeof value === 'string' && value.trim()) ||
|
||||
input.projectId.length > 256 || input.idempotencyKey.length > 256 ||
|
||||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
|
||||
!Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
|
||||
if (
|
||||
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey].every(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
) ||
|
||||
input.projectId.length > 256 ||
|
||||
input.idempotencyKey.length > 256 ||
|
||||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) ||
|
||||
!/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
|
||||
!Number.isSafeInteger(input.sourceSizeBytes) ||
|
||||
input.sourceSizeBytes <= 0
|
||||
) {
|
||||
throw new Error('Invalid DOC0 inspection input');
|
||||
}
|
||||
let result: unknown;
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, {
|
||||
method: 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
|
||||
headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json',
|
||||
'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey },
|
||||
body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256,
|
||||
sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }),
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
|
||||
headers: {
|
||||
authorization: `Bearer ${this.options.serviceToken}`,
|
||||
'content-type': 'application/json',
|
||||
'x-tenant-id': input.tenantId,
|
||||
'x-actor-id': input.actorId,
|
||||
'x-request-id': input.requestId,
|
||||
'x-idempotency-key': input.idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: input.projectId,
|
||||
sourceSha256: input.sourceSha256,
|
||||
sourceSizeBytes: input.sourceSizeBytes,
|
||||
requestFingerprint: input.requestFingerprint,
|
||||
}),
|
||||
});
|
||||
result = await readBoundedJson(response);
|
||||
} catch { throw new Doc0InspectionUnavailableError(); }
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError();
|
||||
} catch {
|
||||
throw new Doc0InspectionUnavailableError();
|
||||
}
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result))
|
||||
throw new Doc0InspectionUnavailableError();
|
||||
const record = result as Record<string, unknown>;
|
||||
if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
|
||||
Object.keys(record).length === 1) {
|
||||
if (
|
||||
typeof record.status === 'string' &&
|
||||
['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
|
||||
Object.keys(record).length === 1
|
||||
) {
|
||||
return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' };
|
||||
}
|
||||
if (record.status === 'completed_recorded' && Object.keys(record).length === 3 &&
|
||||
typeof record.documentId === 'string' && record.documentId.trim() &&
|
||||
typeof record.versionId === 'string' && record.versionId.trim()) {
|
||||
if (
|
||||
record.status === 'completed_recorded' &&
|
||||
Object.keys(record).length === 3 &&
|
||||
typeof record.documentId === 'string' &&
|
||||
record.documentId.trim() &&
|
||||
typeof record.versionId === 'string' &&
|
||||
record.versionId.trim()
|
||||
) {
|
||||
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
|
||||
}
|
||||
throw new Doc0InspectionUnavailableError();
|
||||
|
||||
+40
-49
@@ -1,59 +1,35 @@
|
||||
/**
|
||||
* src/http/tenant-guard.ts
|
||||
* ==========================
|
||||
* P014 fix: every route under `app/api/0pt/**` sits behind `middleware.ts`,
|
||||
* which performs real Zitadel Bearer verification and — only when a
|
||||
* principal was actually verified — forwards the result as the
|
||||
* `x-0pt-verified-tenant` request header (see middleware.ts, src/auth/context.ts).
|
||||
*
|
||||
* Independently of that, every one of these route handlers reads `tenant_id`
|
||||
* from CLIENT-CONTROLLED input (a `?tenant_id=` query param or a JSON body
|
||||
* field) to decide which tenant's rows to read or write. Until this pass,
|
||||
* NONE of them cross-checked that client-supplied value against the verified
|
||||
* header — so a caller holding a valid token for tenant A could read or
|
||||
* write tenant B's sources, rights, provenance, health, quality rules/runs,
|
||||
* documents, or lakehouse mappings simply by naming `tenant_id=t_b` in the
|
||||
* request. `SourceRegistryService.assertSourceTenant` (and friends) only
|
||||
* checks that the object being read *belongs* to the tenant_id it was asked
|
||||
* to check against — it has no way to know whether the CALLER is actually
|
||||
* entitled to assert that tenant_id in the first place. That entitlement
|
||||
* check belongs at the HTTP boundary, once, here.
|
||||
*
|
||||
* This mirrors the exact decision `src/auth/context.ts`'s `resolveAuthContext`
|
||||
* already makes for the enforced-Bearer path ("a disagreeing X-Tenant-Id
|
||||
* header is a 403"), applied uniformly to every 0pt capability route that
|
||||
* takes tenant_id as a body/query parameter rather than relying on
|
||||
* middleware to inject ctx.tenantId directly (most of these routes predate
|
||||
* that plumbing and read tenant_id straight from the request; fixing that
|
||||
* more deeply is a larger refactor than this pass — this closes the actual
|
||||
* authz hole without changing every route's request contract).
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { readServiceTenantScope } from "@/src/auth/internal-service-tenant";
|
||||
|
||||
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
|
||||
|
||||
/**
|
||||
* Returns a 403 NextResponse when `tenantId` (whatever the route read it
|
||||
* from — query string or body) disagrees with the tenant middleware.ts
|
||||
* already verified for this request. Returns `null` when the caller may
|
||||
* proceed — either because the tenant matches, or because no verified
|
||||
* tenant is present on the request at all.
|
||||
*
|
||||
* The header is absent only in the explicit local-development escape hatch
|
||||
* (`AUTH_ENFORCE=false` with no bearer token presented) — `assertAuthConfigSafe()`
|
||||
* refuses to boot that way in production, so an absent header here can only
|
||||
* happen in a mode that already accepts fully unauthenticated requests.
|
||||
* Rejecting in that mode would be strictly more restrictive than the
|
||||
* existing dev escape hatch intends, so this deliberately no-ops rather than
|
||||
* invents a tenant to compare against.
|
||||
*/
|
||||
export function assertTenantMatchesVerified(
|
||||
request: NextRequest,
|
||||
tenantId: string | null | undefined,
|
||||
): NextResponse | null {
|
||||
const verified = request.headers.get(VERIFIED_TENANT_HEADER);
|
||||
// Internal service principal: tenant authority is the SERVER-CONFIGURED
|
||||
// allowlist middleware installed (never a client header). The scope header is
|
||||
// present even when the allowlist is empty, which means "no tenant
|
||||
// authority" -> fail closed. Routes that do not take a tenant never call
|
||||
// this guard, so evidence dispatch / ER / search definitions keep working.
|
||||
if (request.headers.get("x-0pt-verified-service-tenants") !== null) {
|
||||
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
|
||||
const scope = readServiceTenantScope(request.headers);
|
||||
if (!scope.includes(tenantId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "OPT_ACCESS_DENIED",
|
||||
message: "tenant_id is outside the internal service principal's configured tenant scope",
|
||||
details: { requested_tenant: tenantId, service_tenant_scope: scope },
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!verified) return null;
|
||||
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
|
||||
if (!tenantId) return null;
|
||||
if (tenantId !== verified) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -67,7 +43,6 @@ export function assertTenantMatchesVerified(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bind document operations to middleware's verified subject; never accept a conflicting body actor. */
|
||||
export function resolveDocumentActor(
|
||||
request: NextRequest,
|
||||
requestedActor: string | undefined,
|
||||
@@ -81,7 +56,23 @@ export function resolveDocumentActor(
|
||||
),
|
||||
};
|
||||
}
|
||||
// Absence is the existing unauthenticated development path. In enforced
|
||||
// mode middleware always installs the verified subject before this route.
|
||||
return { actorId: verified || requestedActor };
|
||||
}
|
||||
|
||||
export function assertWorkloadCapabilities(
|
||||
request: NextRequest,
|
||||
required: readonly string[],
|
||||
): NextResponse | null {
|
||||
if (!required.length) return null;
|
||||
const raw = request.headers.get('x-0pt-verified-capabilities');
|
||||
if (raw === null) return null; // not a workload-capability-scoped principal
|
||||
const caps = raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const missing = required.filter((r) => !caps.includes(r) && !caps.includes('*'));
|
||||
if (missing.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OPT_ACCESS_DENIED', message: 'missing required workload capability', details: { missing } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* src/repositories/canonical-fact-repository.ts
|
||||
* =============================================
|
||||
* Storage seam for 0pt canonical entities/facts/relations (sql/022).
|
||||
* Same split as source-repository.ts: one interface, an in-memory
|
||||
* implementation (dev/test) and a Postgres implementation (production),
|
||||
* chosen once via resolveStoreMode.
|
||||
*
|
||||
* Tenant isolation is enforced by the service + route AND by RLS in Postgres
|
||||
* (transaction-local GUC `app.opt_canonical_tenant_id`, same pattern as
|
||||
* 019_document_credit_rls.sql). The in-memory backend scopes by tenant_id in
|
||||
* every read/write.
|
||||
*/
|
||||
import { randomUUID } from "crypto";
|
||||
import { resolveStoreMode } from "../platform/dependency-mode";
|
||||
import type {
|
||||
CanonicalEntity,
|
||||
CanonicalEntityInput,
|
||||
CanonicalFact,
|
||||
CanonicalFactInput,
|
||||
CanonicalQuery,
|
||||
CanonicalRelationInput,
|
||||
} from "../types/canonical-facts";
|
||||
import { getRepositorySql, repositoryDatabaseUrl, type RepositorySql } from "./db";
|
||||
|
||||
export interface CanonicalFactRepository {
|
||||
upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]>;
|
||||
insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
|
||||
insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
|
||||
queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]>;
|
||||
}
|
||||
|
||||
/** PostgreSQL timestamptz arrives as Date; the API contract is RFC3339/ISO-8601. */
|
||||
function iso(value: unknown): string {
|
||||
return value instanceof Date ? value.toISOString() : String(value);
|
||||
}
|
||||
|
||||
export function entityKey(e: CanonicalEntityInput): string {
|
||||
return `${e.entity_type}\u0000${e.identity_namespace}\u0000${e.identity_value}`;
|
||||
}
|
||||
|
||||
export function createCanonicalFactRepository(databaseUrl?: string): CanonicalFactRepository {
|
||||
const mode = resolveStoreMode("0pt.canonical_facts", databaseUrl ?? repositoryDatabaseUrl());
|
||||
if (mode.mode === "postgres") {
|
||||
return new PostgresCanonicalFactRepository(getRepositorySql(mode.url));
|
||||
}
|
||||
return new InMemoryCanonicalFactRepository();
|
||||
}
|
||||
|
||||
// ── in-memory (dev/test) ────────────────────────────────────────────────────
|
||||
class InMemoryCanonicalFactRepository implements CanonicalFactRepository {
|
||||
private entities = new Map<string, CanonicalEntity>();
|
||||
private facts = new Map<string, CanonicalFact>();
|
||||
|
||||
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
|
||||
const now = new Date().toISOString();
|
||||
return inputs.map((input) => {
|
||||
const key = `${tenantId}\u0000${entityKey(input)}`;
|
||||
const existing = this.entities.get(key);
|
||||
if (existing) return existing;
|
||||
const entity: CanonicalEntity = {
|
||||
...input,
|
||||
entity_id: randomUUID(),
|
||||
tenant_id: tenantId,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
this.entities.set(key, entity);
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
|
||||
let inserted = 0;
|
||||
let duplicates = 0;
|
||||
for (const input of inputs) {
|
||||
const entityId = entityIds.get(entityKey(input.entity));
|
||||
if (!entityId) continue;
|
||||
const key = `${tenantId}\u0000${entityId}\u0000${input.predicate}\u0000${input.source_ref}\u0000${input.observed_at}`;
|
||||
if (this.facts.has(key)) {
|
||||
duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
this.facts.set(key, {
|
||||
...input,
|
||||
fact_id: randomUUID(),
|
||||
tenant_id: tenantId,
|
||||
entity_id: entityId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
return { inserted, duplicates };
|
||||
}
|
||||
|
||||
async insertRelations(): Promise<{ inserted: number; duplicates: number }> {
|
||||
// Relations are not persisted in the in-memory backend beyond validation;
|
||||
// tests assert the write path through facts + counts.
|
||||
return { inserted: 0, duplicates: 0 };
|
||||
}
|
||||
|
||||
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
|
||||
const limit = query.limit ?? 100;
|
||||
return [...this.facts.values()]
|
||||
.filter((f) => f.tenant_id === query.tenant_id)
|
||||
.filter((f) => (query.entity_id ? f.entity_id === query.entity_id : true))
|
||||
.filter((f) => (query.predicate ? f.predicate === query.predicate : true))
|
||||
.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
// ── postgres (production) ───────────────────────────────────────────────────
|
||||
class PostgresCanonicalFactRepository implements CanonicalFactRepository {
|
||||
constructor(private readonly sql: RepositorySql) {}
|
||||
|
||||
private async tx<T>(tenantId: string, fn: (tx: RepositorySql) => Promise<T>): Promise<T> {
|
||||
return this.sql.begin(async (tx) => {
|
||||
await tx`SELECT set_config('app.opt_canonical_tenant_id', ${tenantId}, true)`;
|
||||
return fn(tx as unknown as RepositorySql);
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
|
||||
if (inputs.length === 0) return [];
|
||||
return this.tx(tenantId, async (tx) => {
|
||||
const out: CanonicalEntity[] = [];
|
||||
for (const input of inputs) {
|
||||
const rows = await tx`
|
||||
INSERT INTO opt_canonical_entities (entity_id, tenant_id, entity_type, identity_namespace, identity_value)
|
||||
VALUES (${randomUUID()}, ${tenantId}, ${input.entity_type}, ${input.identity_namespace}, ${input.identity_value})
|
||||
ON CONFLICT (tenant_id, entity_type, identity_namespace, identity_value)
|
||||
DO UPDATE SET updated_at = now()
|
||||
RETURNING entity_id, tenant_id, entity_type, identity_namespace, identity_value, created_at, updated_at`;
|
||||
const r = rows[0];
|
||||
out.push({
|
||||
entity_id: r.entity_id, tenant_id: r.tenant_id, entity_type: r.entity_type,
|
||||
identity_namespace: r.identity_namespace, identity_value: r.identity_value,
|
||||
created_at: String(r.created_at), updated_at: String(r.updated_at),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
|
||||
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
|
||||
return this.tx(tenantId, async (tx) => {
|
||||
let inserted = 0;
|
||||
for (const input of inputs) {
|
||||
const entityId = entityIds.get(entityKey(input.entity));
|
||||
if (!entityId) continue;
|
||||
const rows = await tx`
|
||||
INSERT INTO opt_canonical_facts (
|
||||
fact_id, tenant_id, entity_id, predicate, value_json, value_type, value_unit,
|
||||
source_ref, source_record_id, st0re_artifact_id, aud0_evidence_ref, run_ref,
|
||||
extractor_version, observed_at, valid_from, valid_to, confidence, quality_status, status
|
||||
) VALUES (
|
||||
${randomUUID()}, ${tenantId}, ${entityId}, ${input.predicate}, ${JSON.stringify(input.value)}::jsonb,
|
||||
${input.value_type ?? "json"}, ${input.value_unit ?? null}, ${input.source_ref},
|
||||
${input.source_record_id ?? null}, ${input.st0re_artifact_id ?? null},
|
||||
${input.aud0_evidence_ref ?? null}, ${input.run_ref ?? null}, ${input.extractor_version},
|
||||
${input.observed_at}, ${input.valid_from ?? null}, ${input.valid_to ?? null},
|
||||
${input.confidence ?? null}, ${input.quality_status ?? "unvalidated"}, ${input.status ?? "observed"}
|
||||
)
|
||||
ON CONFLICT (tenant_id, entity_id, predicate, source_ref, observed_at) DO NOTHING
|
||||
RETURNING fact_id`;
|
||||
if (rows.length > 0) inserted += 1;
|
||||
}
|
||||
return { inserted, duplicates: inputs.length - inserted };
|
||||
});
|
||||
}
|
||||
|
||||
async insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
|
||||
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
|
||||
return this.tx(tenantId, async (tx) => {
|
||||
let inserted = 0;
|
||||
for (const input of inputs) {
|
||||
const subjectId = entityIds.get(entityKey(input.subject));
|
||||
const objectId = entityIds.get(entityKey(input.object));
|
||||
if (!subjectId || !objectId) continue;
|
||||
const rows = await tx`
|
||||
INSERT INTO opt_canonical_relations (
|
||||
relation_id, tenant_id, subject_id, predicate, object_id, attributes_json,
|
||||
source_ref, st0re_artifact_id, aud0_evidence_ref, run_ref, extractor_version,
|
||||
observed_at, valid_from, valid_to, confidence
|
||||
) VALUES (
|
||||
${randomUUID()}, ${tenantId}, ${subjectId}, ${input.predicate}, ${objectId},
|
||||
${JSON.stringify(input.attributes ?? {})}::jsonb, ${input.source_ref},
|
||||
${input.st0re_artifact_id ?? null}, ${input.aud0_evidence_ref ?? null},
|
||||
${input.run_ref ?? null}, ${input.extractor_version}, ${input.observed_at},
|
||||
${input.valid_from ?? null}, ${input.valid_to ?? null}, ${input.confidence ?? null}
|
||||
)
|
||||
ON CONFLICT (tenant_id, subject_id, predicate, object_id, source_ref, observed_at) DO NOTHING
|
||||
RETURNING relation_id`;
|
||||
if (rows.length > 0) inserted += 1;
|
||||
}
|
||||
return { inserted, duplicates: inputs.length - inserted };
|
||||
});
|
||||
}
|
||||
|
||||
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
|
||||
const limit = query.limit ?? 100;
|
||||
return this.tx(query.tenant_id, async (tx) => {
|
||||
const rows = await tx`
|
||||
SELECT f.*, e.entity_type, e.identity_namespace, e.identity_value
|
||||
FROM opt_canonical_facts f
|
||||
JOIN opt_canonical_entities e ON e.entity_id = f.entity_id
|
||||
WHERE f.tenant_id = ${query.tenant_id}
|
||||
${query.entity_id ? tx`AND f.entity_id = ${query.entity_id}` : tx``}
|
||||
${query.predicate ? tx`AND f.predicate = ${query.predicate}` : tx``}
|
||||
${query.entity_type ? tx`AND e.entity_type = ${query.entity_type}` : tx``}
|
||||
${query.identity_namespace ? tx`AND e.identity_namespace = ${query.identity_namespace}` : tx``}
|
||||
${query.identity_value ? tx`AND e.identity_value = ${query.identity_value}` : tx``}
|
||||
ORDER BY f.observed_at DESC
|
||||
LIMIT ${limit}`;
|
||||
return rows.map((r) => ({
|
||||
fact_id: r.fact_id, tenant_id: r.tenant_id, entity_id: r.entity_id,
|
||||
entity: { entity_type: r.entity_type, identity_namespace: r.identity_namespace, identity_value: r.identity_value },
|
||||
predicate: r.predicate, value: r.value_json, value_type: r.value_type,
|
||||
value_unit: r.value_unit ?? undefined, source_ref: r.source_ref,
|
||||
source_record_id: r.source_record_id ?? undefined,
|
||||
st0re_artifact_id: r.st0re_artifact_id ?? undefined,
|
||||
aud0_evidence_ref: r.aud0_evidence_ref ?? undefined, run_ref: r.run_ref ?? undefined,
|
||||
extractor_version: r.extractor_version, observed_at: iso(r.observed_at),
|
||||
valid_from: r.valid_from ? iso(r.valid_from) : undefined,
|
||||
valid_to: r.valid_to ? iso(r.valid_to) : undefined,
|
||||
confidence: r.confidence === null ? undefined : Number(r.confidence),
|
||||
quality_status: r.quality_status, status: r.status, created_at: iso(r.created_at),
|
||||
})) as CanonicalFact[];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Shared singleton for the canonical-fact routes (same pattern as source-registry.ts). */
|
||||
import { CanonicalFactService } from "./canonical-fact-service";
|
||||
|
||||
export const canonicalFactService = new CanonicalFactService();
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* src/services/canonical-fact-service.ts
|
||||
* ======================================
|
||||
* 0pt's canonical structured-observation write/read surface (sql/022).
|
||||
*
|
||||
* Ownership boundary: 0pt stores normalized OBSERVED facts (what a source
|
||||
* said, when, from which artifact). L0RE owns epistemic propositions and
|
||||
* projections and must consume these rows through this API. Nothing here
|
||||
* overwrites an earlier observation: the unique key includes source_ref +
|
||||
* observed_at, so conflicting sources are preserved as separate rows.
|
||||
*
|
||||
* Idempotency: identical re-writes hit ON CONFLICT DO NOTHING and are
|
||||
* reported as `duplicates`, never duplicated.
|
||||
*/
|
||||
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
|
||||
import { SiaxError } from "../platform/errors";
|
||||
import {
|
||||
createCanonicalFactRepository,
|
||||
entityKey,
|
||||
type CanonicalFactRepository,
|
||||
} from "../repositories/canonical-fact-repository";
|
||||
import type {
|
||||
CanonicalEntity,
|
||||
CanonicalEntityInput,
|
||||
CanonicalFact,
|
||||
CanonicalFactInput,
|
||||
CanonicalQuery,
|
||||
CanonicalRelationInput,
|
||||
CanonicalWriteRequest,
|
||||
CanonicalWriteResult,
|
||||
FactValueType,
|
||||
} from "../types/canonical-facts";
|
||||
|
||||
const FACT_VALUE_TYPES: FactValueType[] = ["string", "number", "boolean", "date", "json"];
|
||||
|
||||
function inferValueType(value: unknown): FactValueType {
|
||||
if (typeof value === "string") return "string";
|
||||
if (typeof value === "number") return "number";
|
||||
if (typeof value === "boolean") return "boolean";
|
||||
return "json";
|
||||
}
|
||||
|
||||
function assertEntity(entity: CanonicalEntityInput, label: string): void {
|
||||
if (!entity || !entity.entity_type || !entity.identity_namespace || !entity.identity_value) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `${label} requires entity_type, identity_namespace and identity_value`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFact(fact: CanonicalFactInput, index: number): void {
|
||||
assertEntity(fact.entity, `facts[${index}].entity`);
|
||||
if (!fact.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].predicate is required`);
|
||||
if (!fact.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].source_ref is required`);
|
||||
if (!fact.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].extractor_version is required`);
|
||||
if (!fact.observed_at || Number.isNaN(Date.parse(fact.observed_at))) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].observed_at must be an ISO timestamp`);
|
||||
}
|
||||
if (fact.value === undefined) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value is required`);
|
||||
if (fact.value_type && !FACT_VALUE_TYPES.includes(fact.value_type)) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value_type is invalid`);
|
||||
}
|
||||
if (fact.confidence !== undefined && (fact.confidence < 0 || fact.confidence > 1)) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].confidence must be between 0 and 1`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRelation(relation: CanonicalRelationInput, index: number): void {
|
||||
assertEntity(relation.subject, `relations[${index}].subject`);
|
||||
assertEntity(relation.object, `relations[${index}].object`);
|
||||
if (!relation.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].predicate is required`);
|
||||
if (!relation.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].source_ref is required`);
|
||||
if (!relation.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].extractor_version is required`);
|
||||
if (!relation.observed_at || Number.isNaN(Date.parse(relation.observed_at))) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].observed_at must be an ISO timestamp`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CanonicalFactServiceOptions {
|
||||
repository?: CanonicalFactRepository;
|
||||
aud0BaseUrl?: string;
|
||||
databaseUrl?: string;
|
||||
}
|
||||
|
||||
export class CanonicalFactService {
|
||||
private readonly repo: CanonicalFactRepository;
|
||||
private readonly aud0: Aud0Client;
|
||||
|
||||
constructor(opts?: CanonicalFactServiceOptions) {
|
||||
this.repo = opts?.repository ?? createCanonicalFactRepository(opts?.databaseUrl);
|
||||
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
|
||||
}
|
||||
|
||||
async write(input: CanonicalWriteRequest): Promise<CanonicalWriteResult> {
|
||||
if (!input.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
|
||||
const facts = input.facts ?? [];
|
||||
const relations = input.relations ?? [];
|
||||
const explicit = input.entities ?? [];
|
||||
if (facts.length === 0 && relations.length === 0 && explicit.length === 0) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "at least one entity, fact or relation is required");
|
||||
}
|
||||
facts.forEach(assertFact);
|
||||
relations.forEach(assertRelation);
|
||||
|
||||
// Unique entity inputs from explicit + facts + relations.
|
||||
const unique = new Map<string, CanonicalEntityInput>();
|
||||
for (const e of [...explicit, ...facts.map((f) => f.entity), ...relations.flatMap((r) => [r.subject, r.object])]) {
|
||||
assertEntity(e, "entity");
|
||||
unique.set(entityKey(e), e);
|
||||
}
|
||||
const entities: CanonicalEntity[] = await this.repo.upsertEntities(input.tenant_id, [...unique.values()]);
|
||||
const entityIds = new Map(entities.map((e) => [entityKey(e), e.entity_id]));
|
||||
|
||||
const normalizedFacts = facts.map((f) => ({ ...f, value_type: f.value_type ?? inferValueType(f.value) }));
|
||||
const factResult = await this.repo.insertFacts(input.tenant_id, normalizedFacts, entityIds);
|
||||
const relationResult = await this.repo.insertRelations(input.tenant_id, relations, entityIds);
|
||||
|
||||
// AUD0: counts and provenance refs only, never fact values (PII discipline).
|
||||
try {
|
||||
await this.aud0.ingestEvent({
|
||||
event_id: `evt_0pt_canonical_facts_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: "0pt.canonical_facts_written",
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id ?? input.tenant_id,
|
||||
actor_id: "0pt",
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.canonical_fact_write",
|
||||
resource_type: "canonical_facts",
|
||||
resource_id: input.tenant_id,
|
||||
risk_level: "L2",
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
entities: entities.length,
|
||||
facts_inserted: factResult.inserted,
|
||||
facts_duplicates: factResult.duplicates,
|
||||
relations_inserted: relationResult.inserted,
|
||||
relations_duplicates: relationResult.duplicates,
|
||||
source_refs: [...new Set([...facts.map((f) => f.source_ref), ...relations.map((r) => r.source_ref)])],
|
||||
run_refs: [...new Set([...facts.map((f) => f.run_ref), ...relations.map((r) => r.run_ref)].filter(Boolean))],
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// AUD0 outage must not lose the canonical write; the caller sees counts.
|
||||
}
|
||||
|
||||
return {
|
||||
entities,
|
||||
facts: normalizedFacts.map((f) => ({
|
||||
fact_id: "",
|
||||
entity_id: entityIds.get(entityKey(f.entity)) ?? "",
|
||||
predicate: f.predicate,
|
||||
})),
|
||||
relations: relations.map((r) => ({
|
||||
relation_id: "",
|
||||
subject_id: entityIds.get(entityKey(r.subject)) ?? "",
|
||||
predicate: r.predicate,
|
||||
object_id: entityIds.get(entityKey(r.object)) ?? "",
|
||||
})),
|
||||
duplicates: factResult.duplicates + relationResult.duplicates,
|
||||
};
|
||||
}
|
||||
|
||||
async query(query: CanonicalQuery): Promise<CanonicalFact[]> {
|
||||
if (!query.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
|
||||
return this.repo.queryFacts({ ...query, limit: Math.min(query.limit ?? 100, 500) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* src/types/canonical-facts.ts — 0pt canonical structured observations.
|
||||
*
|
||||
* 0pt owns OBSERVED facts (source said X at time T, from artifact A). L0RE
|
||||
* owns epistemic propositions/assertions and projections derived from these.
|
||||
* This contract is the single write path into the canonical observation store;
|
||||
* downstream products consume it through the same API instead of their own DB.
|
||||
*/
|
||||
|
||||
export interface CanonicalEntityInput {
|
||||
entity_type: string;
|
||||
identity_namespace: string;
|
||||
identity_value: string;
|
||||
}
|
||||
|
||||
export interface CanonicalEntity extends CanonicalEntityInput {
|
||||
entity_id: string;
|
||||
tenant_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type FactValueType = "string" | "number" | "boolean" | "date" | "json";
|
||||
export type QualityStatus = "passed" | "warned" | "failed" | "unvalidated";
|
||||
export type FactStatus = "observed" | "supported" | "disputed" | "retracted";
|
||||
|
||||
export interface CanonicalFactInput {
|
||||
entity: CanonicalEntityInput;
|
||||
predicate: string;
|
||||
value: unknown;
|
||||
value_type?: FactValueType;
|
||||
value_unit?: string;
|
||||
source_ref: string;
|
||||
source_record_id?: string;
|
||||
st0re_artifact_id?: string;
|
||||
aud0_evidence_ref?: string;
|
||||
run_ref?: string;
|
||||
extractor_version: string;
|
||||
observed_at: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
confidence?: number;
|
||||
quality_status?: QualityStatus;
|
||||
status?: FactStatus;
|
||||
}
|
||||
|
||||
export interface CanonicalRelationInput {
|
||||
subject: CanonicalEntityInput;
|
||||
predicate: string;
|
||||
object: CanonicalEntityInput;
|
||||
attributes?: Record<string, unknown>;
|
||||
source_ref: string;
|
||||
st0re_artifact_id?: string;
|
||||
aud0_evidence_ref?: string;
|
||||
run_ref?: string;
|
||||
extractor_version: string;
|
||||
observed_at: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface CanonicalFact extends CanonicalFactInput {
|
||||
fact_id: string;
|
||||
tenant_id: string;
|
||||
entity_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CanonicalRelation extends CanonicalRelationInput {
|
||||
relation_id: string;
|
||||
tenant_id: string;
|
||||
subject_id: string;
|
||||
object_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CanonicalWriteRequest {
|
||||
tenant_id: string;
|
||||
project_id?: string;
|
||||
entities?: CanonicalEntityInput[];
|
||||
facts?: CanonicalFactInput[];
|
||||
relations?: CanonicalRelationInput[];
|
||||
}
|
||||
|
||||
export interface CanonicalWriteResult {
|
||||
entities: CanonicalEntity[];
|
||||
facts: { fact_id: string; entity_id: string; predicate: string }[];
|
||||
relations: { relation_id: string; subject_id: string; predicate: string; object_id: string }[];
|
||||
/** Identical re-inserts skipped by the unique key (idempotent replay). */
|
||||
duplicates: number;
|
||||
}
|
||||
|
||||
export interface CanonicalQuery {
|
||||
tenant_id: string;
|
||||
entity_id?: string;
|
||||
entity_type?: string;
|
||||
identity_namespace?: string;
|
||||
identity_value?: string;
|
||||
predicate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -263,3 +263,63 @@ export const activateSourceSchema = z.object({
|
||||
tenant_id: z.string().min(1),
|
||||
actor_id: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/0pt/canonical/facts — canonical structured observation write (sql/022). */
|
||||
const canonicalEntityInputSchema = z.object({
|
||||
entity_type: z.string().min(1),
|
||||
identity_namespace: z.string().min(1),
|
||||
identity_value: z.string().min(1),
|
||||
});
|
||||
|
||||
const canonicalValueSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(z.unknown()),
|
||||
jsonObject,
|
||||
]);
|
||||
|
||||
export const canonicalFactInputSchema = z.object({
|
||||
entity: canonicalEntityInputSchema,
|
||||
predicate: z.string().min(1),
|
||||
value: canonicalValueSchema,
|
||||
value_type: z.enum(['string', 'number', 'boolean', 'date', 'json']).optional(),
|
||||
value_unit: z.string().min(1).optional(),
|
||||
source_ref: z.string().min(1),
|
||||
source_record_id: z.string().min(1).optional(),
|
||||
st0re_artifact_id: z.string().min(1).optional(),
|
||||
aud0_evidence_ref: z.string().min(1).optional(),
|
||||
run_ref: z.string().min(1).optional(),
|
||||
extractor_version: z.string().min(1),
|
||||
observed_at: z.string().min(1),
|
||||
valid_from: z.string().min(1).optional(),
|
||||
valid_to: z.string().min(1).optional(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
quality_status: z.enum(['passed', 'warned', 'failed', 'unvalidated']).optional(),
|
||||
status: z.enum(['observed', 'supported', 'disputed', 'retracted']).optional(),
|
||||
});
|
||||
|
||||
export const canonicalRelationInputSchema = z.object({
|
||||
subject: canonicalEntityInputSchema,
|
||||
predicate: z.string().min(1),
|
||||
object: canonicalEntityInputSchema,
|
||||
attributes: jsonObject.optional(),
|
||||
source_ref: z.string().min(1),
|
||||
st0re_artifact_id: z.string().min(1).optional(),
|
||||
aud0_evidence_ref: z.string().min(1).optional(),
|
||||
run_ref: z.string().min(1).optional(),
|
||||
extractor_version: z.string().min(1),
|
||||
observed_at: z.string().min(1),
|
||||
valid_from: z.string().min(1).optional(),
|
||||
valid_to: z.string().min(1).optional(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const canonicalWriteSchema = z.object({
|
||||
tenant_id: z.string().min(1),
|
||||
project_id: z.string().min(1).optional(),
|
||||
entities: z.array(canonicalEntityInputSchema).optional(),
|
||||
facts: z.array(canonicalFactInputSchema).optional(),
|
||||
relations: z.array(canonicalRelationInputSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* P0 regression (2026-11-07): tenant authority for the machine-to-machine
|
||||
* (`X-Internal-Token`) principal is SERVER CONFIGURATION, never a client
|
||||
* header.
|
||||
*
|
||||
* The first attempt at this fix read `X-0pt-Internal-Tenant` (or a single
|
||||
* `INTERNAL_TENANT_ID`) and copied it into `x-0pt-verified-tenant`. Any holder
|
||||
* of the shared internal token could then still name any tenant, so the
|
||||
* original cross-tenant write (tenant_id 999999999999999999 -> 201) remained
|
||||
* possible through a different header. These tests pin the corrected model:
|
||||
*
|
||||
* - `INTERNAL_SERVICE_TENANTS` (server env) is the only source of tenant
|
||||
* authority for the service principal.
|
||||
* - No configured scope => tenant-scoped writes are denied (fail closed).
|
||||
* - Client-supplied `x-0pt-internal-tenant`, `x-0pt-verified-tenant` and
|
||||
* `x-0pt-verified-service-tenants` are ignored and stripped.
|
||||
* - Bearer/session principals are unchanged.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { middleware } from '../../middleware';
|
||||
import {
|
||||
parseServiceTenants,
|
||||
resolveInternalServiceTenants,
|
||||
SERVICE_TENANT_SCOPE_HEADER,
|
||||
} from '@/src/auth/internal-service-tenant';
|
||||
import { assertTenantMatchesVerified } from '@/src/http/tenant-guard';
|
||||
import { POST as canonicalPOST } from '@/app/api/0pt/canonical/facts/route';
|
||||
import { POST as bridgePOST } from '@/app/api/0pt/quality/bridge-run/route';
|
||||
import { POST as evidenceDispatchPOST } from '@/app/api/0pt/internal/evidence-outbox/dispatch/route';
|
||||
|
||||
const INTERNAL_TOKEN = 'engine-shared-secret';
|
||||
const SCOPED_ROUTE = '/api/0pt/canonical/facts';
|
||||
|
||||
function req(path: string, headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest(new URL(path, 'http://localhost'), { headers });
|
||||
}
|
||||
|
||||
/** Headers middleware forwarded to the route handler (Next stores them here). */
|
||||
function forwarded(res: { headers: { get(name: string): string | null } }, name: string): string | null {
|
||||
return res.headers.get(`x-middleware-request-${name.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function guardRequest(headers: Record<string, string>): NextRequest {
|
||||
return new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
describe('parseServiceTenants / resolveInternalServiceTenants', () => {
|
||||
it('splits, trims and de-duplicates the allowlist', () => {
|
||||
expect(parseServiceTenants(' t_a , t_b ,, t_a ')).toEqual(['t_a', 't_b']);
|
||||
});
|
||||
|
||||
it('an unset/empty allowlist means no tenant authority', () => {
|
||||
expect(parseServiceTenants(undefined)).toEqual([]);
|
||||
expect(parseServiceTenants('')).toEqual([]);
|
||||
expect(resolveInternalServiceTenants({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('middleware: internal-token tenant authority comes from server config', () => {
|
||||
const saved = {
|
||||
auth: process.env.AUTH_ENFORCE,
|
||||
token: process.env.INTERNAL_TOKEN,
|
||||
tenants: process.env.INTERNAL_SERVICE_TENANTS,
|
||||
node: process.env.NODE_ENV,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.AUTH_ENFORCE; // default posture: enforced
|
||||
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
|
||||
delete process.env.INTERNAL_SERVICE_TENANTS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const values: Record<string, string | undefined> = {
|
||||
AUTH_ENFORCE: saved.auth,
|
||||
INTERNAL_TOKEN: saved.token,
|
||||
INTERNAL_SERVICE_TENANTS: saved.tenants,
|
||||
NODE_ENV: saved.node,
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('single allowlisted tenant is installed as the verified tenant', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
|
||||
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
|
||||
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
|
||||
});
|
||||
|
||||
it('REFUSES an ambiguous multi-tenant allowlist (no workload-identity binding yet)', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = 't_a,t_b';
|
||||
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.message).toContain('ambiguous');
|
||||
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
|
||||
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBeNull();
|
||||
});
|
||||
|
||||
it('a single allowlist entry is still forwarded as the service scope', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
|
||||
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
|
||||
});
|
||||
|
||||
it('with no configured scope the service principal has no tenant authority (empty scope header)', async () => {
|
||||
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
|
||||
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('');
|
||||
});
|
||||
|
||||
it('IGNORES a client-supplied X-0pt-Internal-Tenant (not an authority source)', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
|
||||
const res = await middleware(
|
||||
req(SCOPED_ROUTE, {
|
||||
'x-internal-token': INTERNAL_TOKEN,
|
||||
'x-0pt-internal-tenant': 't_victim',
|
||||
}),
|
||||
);
|
||||
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
|
||||
});
|
||||
|
||||
it('STRIPS a forged x-0pt-verified-tenant and forged scope header', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
|
||||
const res = await middleware(
|
||||
req(SCOPED_ROUTE, {
|
||||
'x-internal-token': INTERNAL_TOKEN,
|
||||
'x-0pt-verified-tenant': 't_victim',
|
||||
'x-0pt-verified-sub': 'user_victim',
|
||||
'x-0pt-verified-service-tenants': 't_victim',
|
||||
}),
|
||||
);
|
||||
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
|
||||
expect(forwarded(res, 'x-0pt-verified-sub')).toBeNull();
|
||||
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
|
||||
});
|
||||
|
||||
it('strips forged verified headers on the unauthenticated development path too', async () => {
|
||||
process.env.AUTH_ENFORCE = 'false';
|
||||
const res = await middleware(req('/api/0pt/documents/capture', { 'x-0pt-verified-tenant': 't_victim' }));
|
||||
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
|
||||
});
|
||||
|
||||
it('an invalid internal token still falls through to 401', async () => {
|
||||
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': 'wrong' }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertTenantMatchesVerified: service scope is enforced fail-closed', () => {
|
||||
it('no service scope header behaves exactly as before (Bearer verified tenant)', () => {
|
||||
const same = assertTenantMatchesVerified(
|
||||
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
|
||||
't_a',
|
||||
);
|
||||
expect(same).toBeNull();
|
||||
const cross = assertTenantMatchesVerified(
|
||||
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
|
||||
't_victim',
|
||||
);
|
||||
expect(cross?.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a tenant inside the configured service scope', () => {
|
||||
const res = assertTenantMatchesVerified(
|
||||
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a,t_b' }),
|
||||
't_b',
|
||||
);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('DENIES a tenant outside the configured service scope (403)', async () => {
|
||||
const res = assertTenantMatchesVerified(
|
||||
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }),
|
||||
't_victim',
|
||||
);
|
||||
expect(res?.status).toBe(403);
|
||||
const body = await res!.json();
|
||||
expect(body.error).toBe('OPT_ACCESS_DENIED');
|
||||
expect(body.details.service_tenant_scope).toEqual(['t_a']);
|
||||
});
|
||||
|
||||
it('DENIES every tenant when the configured scope is empty (fail closed, even if a verified tenant was also set)', () => {
|
||||
const res = assertTenantMatchesVerified(
|
||||
guardRequest({
|
||||
[SERVICE_TENANT_SCOPE_HEADER]: '',
|
||||
'x-0pt-verified-tenant': '999999999999999999',
|
||||
}),
|
||||
'999999999999999999',
|
||||
);
|
||||
expect(res?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route handlers: internal service cannot write a foreign tenant', () => {
|
||||
it('canonical facts: cross-tenant write is 403 before the service runs', async () => {
|
||||
const res = await canonicalPOST(
|
||||
new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: 't_victim',
|
||||
entities: [],
|
||||
facts: [],
|
||||
relations: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('quality bridge-run: cross-tenant run is 403 before rules are provisioned', async () => {
|
||||
const res = await bridgePOST(
|
||||
new NextRequest(new URL('/api/0pt/quality/bridge-run', 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: 't_victim',
|
||||
project_id: 'p1',
|
||||
dataset_id: 'ds_1',
|
||||
rows: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('evidence-outbox dispatch needs no tenant and is unchanged by the scope', async () => {
|
||||
// No DB in this test env: the route authenticates first, then reports a
|
||||
// dependency error -- anything except 401/403 proves the scope did not
|
||||
// turn a tenant-less machine route into a denial.
|
||||
const previous = process.env.INTERNAL_TOKEN;
|
||||
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
|
||||
try {
|
||||
const res = await evidenceDispatchPOST(
|
||||
new NextRequest(new URL('/api/0pt/internal/evidence-outbox/dispatch', 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-internal-token': INTERNAL_TOKEN,
|
||||
[SERVICE_TENANT_SCOPE_HEADER]: '',
|
||||
},
|
||||
body: JSON.stringify({ limit: 1 }),
|
||||
}),
|
||||
);
|
||||
expect([401, 403]).not.toContain(res.status);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.INTERNAL_TOKEN;
|
||||
else process.env.INTERNAL_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,13 @@ describe('middleware.ts: internal-token path for /api/0pt/quality/bridge-run', (
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('a valid X-Internal-Token lets the acquisition engine write canonical facts', async () => {
|
||||
const res = await middleware(
|
||||
req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('an invalid X-Internal-Token still falls through to the ordinary Bearer requirement (401, not a bypass)', async () => {
|
||||
const res = await middleware(req('/api/0pt/quality/bridge-run', { 'x-internal-token': 'wrong-secret' }));
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Dual-accept workload identity: the machine routes accept EITHER the legacy
|
||||
* shared `X-Internal-Token` (server-configured single-tenant scope) OR a
|
||||
* verified CL0UD workload JWT (tenant + capabilities from verified claims
|
||||
* only). These tests pin the security properties that make that safe:
|
||||
*
|
||||
* - a client-supplied `x-0pt-verified-*` header can never authorize
|
||||
* anything (it is stripped before the handler sees it);
|
||||
* - an ambiguous legacy scope is refused instead of silently widening;
|
||||
* - a workload token for tenant A cannot write tenant B's facts;
|
||||
* - a workload token without the required capability is denied.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { SignJWT, generateKeyPair } from 'jose';
|
||||
import { middleware } from '../../middleware';
|
||||
import { assertTenantMatchesVerified, assertWorkloadCapabilities } from '../../src/http/tenant-guard';
|
||||
import { __resetWorkloadJwksCache, __setWorkloadKeyResolverForTests } from '../../src/auth/cl0ud-workload';
|
||||
import { SERVICE_CAPABILITIES_HEADER, SERVICE_TENANT_SCOPE_HEADER } from '../../src/auth/internal-service-tenant';
|
||||
|
||||
const ISSUER = 'https://cl0ud.siax.io';
|
||||
const TENANT_A = '379976258701823235';
|
||||
const TENANT_B = '999999999999999999';
|
||||
|
||||
function req(path: string, headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest(new URL(path, 'http://localhost'), { headers });
|
||||
}
|
||||
|
||||
let privateKey: CryptoKey;
|
||||
let publicKey: CryptoKey;
|
||||
|
||||
beforeEach(async () => {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
privateKey = pair.privateKey as CryptoKey;
|
||||
publicKey = pair.publicKey as CryptoKey;
|
||||
__setWorkloadKeyResolverForTests(async () => publicKey);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetWorkloadJwksCache();
|
||||
for (const key of ['INTERNAL_TOKEN', 'INTERNAL_SERVICE_TENANTS', 'CL0UD_WORKLOAD_ISSUER', 'CL0UD_WORKLOAD_AUDIENCE', 'AUTH_ENFORCE']) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
async function workloadToken(claims: Record<string, unknown>, audience = '0pt'): Promise<string> {
|
||||
return await new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience(audience)
|
||||
.setSubject('svc-0pt-worker')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
describe('middleware: legacy internal token scope', () => {
|
||||
beforeEach(() => {
|
||||
process.env.INTERNAL_TOKEN = 'engine-shared-secret';
|
||||
delete process.env.CL0UD_WORKLOAD_ISSUER;
|
||||
});
|
||||
|
||||
it('single configured tenant becomes the verified tenant and the service scope', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = TENANT_A;
|
||||
const res = await middleware(req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
|
||||
expect(res.headers.get(`x-middleware-request-${SERVICE_TENANT_SCOPE_HEADER}`)).toBe(TENANT_A);
|
||||
});
|
||||
|
||||
it('an ambiguous multi-tenant scope is refused, not widened', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = `${TENANT_A},${TENANT_B}`;
|
||||
const res = await middleware(req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).error).toBe('OPT_UNAUTHENTICATED');
|
||||
});
|
||||
|
||||
it('forged client-supplied verified headers are stripped', async () => {
|
||||
process.env.INTERNAL_SERVICE_TENANTS = TENANT_A;
|
||||
const res = await middleware(
|
||||
req('/api/0pt/canonical/facts', {
|
||||
'x-internal-token': 'engine-shared-secret',
|
||||
'x-0pt-verified-tenant': TENANT_B,
|
||||
[SERVICE_TENANT_SCOPE_HEADER]: TENANT_B,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
|
||||
expect(res.headers.get(`x-middleware-request-${SERVICE_TENANT_SCOPE_HEADER}`)).toBe(TENANT_A);
|
||||
});
|
||||
});
|
||||
|
||||
describe('middleware: CL0UD workload token (dual-accept)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CL0UD_WORKLOAD_ISSUER = ISSUER;
|
||||
process.env.CL0UD_WORKLOAD_AUDIENCE = '0pt';
|
||||
delete process.env.INTERNAL_TOKEN;
|
||||
});
|
||||
|
||||
it('derives tenant and capabilities from verified claims', async () => {
|
||||
const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] });
|
||||
const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
|
||||
expect(res.headers.get(`x-middleware-request-${SERVICE_CAPABILITIES_HEADER}`)).toBe('0pt.canonical.write');
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong audience', async () => {
|
||||
const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] }, 'n0d');
|
||||
const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a token that carries no tenant claim', async () => {
|
||||
const token = await workloadToken({ capabilities: ['0pt.canonical.write'] });
|
||||
const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('ignores forged verified headers on the workload path too', async () => {
|
||||
const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] });
|
||||
const res = await middleware(
|
||||
req('/api/0pt/canonical/facts', {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-0pt-verified-tenant': TENANT_B,
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tenant guard: scope and capability enforcement', () => {
|
||||
it('denies a write for a tenant outside the service scope', () => {
|
||||
const request = req('/api/0pt/canonical/facts', { [SERVICE_TENANT_SCOPE_HEADER]: TENANT_A });
|
||||
expect(assertTenantMatchesVerified(request, TENANT_A)).toBeNull();
|
||||
const denied = assertTenantMatchesVerified(request, TENANT_B);
|
||||
expect(denied?.status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies a tenant that disagrees with a verified workload tenant', () => {
|
||||
const request = req('/api/0pt/canonical/facts', { 'x-0pt-verified-tenant': TENANT_A });
|
||||
expect(assertTenantMatchesVerified(request, TENANT_A)).toBeNull();
|
||||
expect(assertTenantMatchesVerified(request, TENANT_B)?.status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies a missing capability on a capability-scoped principal', () => {
|
||||
const request = req('/api/0pt/canonical/facts', { [SERVICE_CAPABILITIES_HEADER]: '0pt.canonical.read' });
|
||||
expect(assertWorkloadCapabilities(request, ['0pt.canonical.write'])?.status).toBe(403);
|
||||
expect(assertWorkloadCapabilities(request, ['0pt.canonical.read'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
bindOperationToUploadIdentity,
|
||||
prepareDoc0UploadIdentity,
|
||||
type Doc0UploadInput,
|
||||
} from '../../src/document-workbench/doc0-upload-client';
|
||||
|
||||
const input: Doc0UploadInput = {
|
||||
tenantId: 't1', projectId: 'p1', actorId: 'a1', requestId: 'r1', idempotencyKey: 'k1',
|
||||
filename: 'rapport.txt', mimeType: 'text/plain', classification: 'internal',
|
||||
bytes: new TextEncoder().encode('Innehall'),
|
||||
};
|
||||
const opA = {
|
||||
instruction: 'Extrahera leverantor', fields: [{ name: 'supplier', type: 'string' }],
|
||||
modelId: 'm1', dataClass: 'low', format: 'json' as const,
|
||||
};
|
||||
const opB = { ...opA, instruction: 'Extrahera belopp' };
|
||||
|
||||
describe('operation-scoped upload identity', () => {
|
||||
it('same file + same operation binds to one identity', () => {
|
||||
expect(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)))
|
||||
.toEqual(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)));
|
||||
});
|
||||
it('same file + DIFFERENT instruction = DIFFERENT identity (not the same operation)', () => {
|
||||
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
|
||||
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
|
||||
expect(a.requestFingerprint).not.toBe(b.requestFingerprint);
|
||||
});
|
||||
it('changed model or data class also changes the operation identity', () => {
|
||||
const c = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, modelId: 'm2' }));
|
||||
const d = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, dataClass: 'high' }));
|
||||
const e = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, format: 'csv' }));
|
||||
expect(c.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
|
||||
expect(d.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
|
||||
expect(e.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
|
||||
});
|
||||
it('raw bytes checksum stays stable across operations', () => {
|
||||
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
|
||||
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
|
||||
expect(a.sourceSha256).toBe(b.sourceSha256);
|
||||
});
|
||||
it('prefixes the operation marker so odd filenames cannot collide', () => {
|
||||
const bound = bindOperationToUploadIdentity(input, opA);
|
||||
expect(bound.filename.startsWith('rapport.txt#')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Canonical structured observations (sql/022): idempotent write, conflict
|
||||
* preservation, tenant isolation and relation identity resolution.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CanonicalFactService } from "@/src/services/canonical-fact-service";
|
||||
import { SiaxError } from "@/src/platform/errors";
|
||||
|
||||
const T = "t_canonical_test";
|
||||
const ENTITY = { entity_type: "web_page", identity_namespace: "url", identity_value: "https://example.org" };
|
||||
|
||||
function fact(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
entity: ENTITY,
|
||||
predicate: "web.title",
|
||||
value: "Example Domain",
|
||||
source_ref: "source:example",
|
||||
extractor_version: "0pt.motor.extraction/v1",
|
||||
observed_at: "2026-11-04T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CanonicalFactService — write", () => {
|
||||
it("creates the entity and the fact, and query returns it", async () => {
|
||||
const svc = new CanonicalFactService();
|
||||
const result = await svc.write({ tenant_id: T, facts: [fact()] });
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0].identity_value).toBe("https://example.org");
|
||||
expect(result.facts).toHaveLength(1);
|
||||
expect(result.duplicates).toBe(0);
|
||||
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].predicate).toBe("web.title");
|
||||
expect(rows[0].value_type).toBe("string");
|
||||
});
|
||||
|
||||
it("is idempotent: an identical replay is reported as a duplicate, not stored twice", async () => {
|
||||
const svc = new CanonicalFactService();
|
||||
await svc.write({ tenant_id: T, facts: [fact()] });
|
||||
const replay = await svc.write({ tenant_id: T, facts: [fact()] });
|
||||
expect(replay.duplicates).toBe(1);
|
||||
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves conflicting sources as separate observations", async () => {
|
||||
const svc = new CanonicalFactService();
|
||||
await svc.write({ tenant_id: T, facts: [fact({ source_ref: "source:a", value: "A" })] });
|
||||
await svc.write({ tenant_id: T, facts: [fact({ source_ref: "source:b", value: "B" })] });
|
||||
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.value).sort()).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("isolates tenants", async () => {
|
||||
const svc = new CanonicalFactService();
|
||||
await svc.write({ tenant_id: "t_a", facts: [fact()] });
|
||||
expect(await svc.query({ tenant_id: "t_b" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves relation subject/object identities to entity ids", async () => {
|
||||
const svc = new CanonicalFactService();
|
||||
const result = await svc.write({
|
||||
tenant_id: T,
|
||||
relations: [{
|
||||
subject: ENTITY,
|
||||
predicate: "LINKS_TO",
|
||||
object: { entity_type: "web_page", identity_namespace: "url", identity_value: "https://example.org/next" },
|
||||
source_ref: "source:example",
|
||||
extractor_version: "0pt.motor.extraction/v1",
|
||||
observed_at: "2026-11-04T12:00:00Z",
|
||||
}],
|
||||
});
|
||||
expect(result.entities).toHaveLength(2);
|
||||
expect(result.relations[0].subject_id).toBeTruthy();
|
||||
expect(result.relations[0].object_id).toBeTruthy();
|
||||
expect(result.relations[0].subject_id).not.toBe(result.relations[0].object_id);
|
||||
});
|
||||
|
||||
it("rejects a fact without observed_at or with an invalid confidence", async () => {
|
||||
const svc = new CanonicalFactService();
|
||||
await expect(svc.write({ tenant_id: T, facts: [fact({ observed_at: undefined })] })).rejects.toBeInstanceOf(SiaxError);
|
||||
await expect(svc.write({ tenant_id: T, facts: [fact({ confidence: 2 })] })).rejects.toBeInstanceOf(SiaxError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isolatedSt0reFetch, probeSt0reRealinfra, resolveSt0reRealinfraTarget } from "@/tests/support/st0re-realinfra";
|
||||
|
||||
describe("ST0RE realinfra safety guard", () => {
|
||||
it("uses real HTTP for local writes while forbidding redirect following", async () => {
|
||||
const fetchSpy = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
|
||||
const transport = isolatedSt0reFetch("http://127.0.0.1:4620", fetchSpy);
|
||||
await transport("http://127.0.0.1:4620/v1/st0re/lakehouse/tables", { method: "POST", body: "{}", redirect: "follow" });
|
||||
expect(fetchSpy).toHaveBeenCalledWith("http://127.0.0.1:4620/v1/st0re/lakehouse/tables",
|
||||
{ method: "POST", body: "{}", redirect: "error" });
|
||||
});
|
||||
|
||||
it.each(["https://api.st0re.siax.io/v1/st0re/lakehouse/tables", "http://127.0.0.1:4621/v1/st0re/lakehouse/tables"])
|
||||
("refuses a write outside the exact test origin without sending it (%s)", async (url) => {
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
const transport = isolatedSt0reFetch("http://127.0.0.1:4620", fetchSpy);
|
||||
await expect(transport(new Request(url, { method: "POST" }))).rejects.toThrow(/isolated test origin/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ ST0RE_URL: "https://api.st0re.siax.io" },
|
||||
{ ST0RE_TEST_URL: "http://localhost:4620" },
|
||||
{ ST0RE_TEST_URL: "https://api.st0re.siax.io" },
|
||||
{ ST0RE_REALINFRA_TESTS: "true", ST0RE_TEST_URL: "http://localhost:4620" },
|
||||
{ ST0RE_REALINFRA_TESTS: "0", ST0RE_TEST_URL: "http://localhost:4620" },
|
||||
])("stays disabled without exact opt-in and makes no request (%j)", async (env) => {
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
expect(resolveSt0reRealinfraTarget(env)).toBeUndefined();
|
||||
expect(await probeSt0reRealinfra(env, fetchSpy)).toEqual({
|
||||
reachable: false,
|
||||
reason: expect.stringContaining("disabled"),
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"https://api.st0re.siax.io",
|
||||
"https://st0re.siax.io",
|
||||
"https://example.com",
|
||||
"http://192.168.1.1",
|
||||
"http://localhost.example.com",
|
||||
"http://127.1",
|
||||
"http://2130706433",
|
||||
"http://127.0.0.2",
|
||||
"http://[::ffff:127.0.0.1]",
|
||||
"ftp://localhost",
|
||||
"not-a-url",
|
||||
"http://localhost:65536",
|
||||
["http://", "user:password", "@localhost"].join(""),
|
||||
["http://", "@localhost"].join(""),
|
||||
"http://localhost/path",
|
||||
"http://localhost/..",
|
||||
"http://localhost?query=1",
|
||||
"http://localhost?",
|
||||
"http://localhost#fragment",
|
||||
"http://localhost#",
|
||||
"http://localhost\\remote",
|
||||
" http://localhost",
|
||||
])("fails closed before fetch for missing or invalid target (%s)", async (target) => {
|
||||
const env = {
|
||||
ST0RE_REALINFRA_TESTS: "1",
|
||||
ST0RE_TEST_URL: target,
|
||||
ST0RE_URL: "http://localhost:4620",
|
||||
};
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
expect(() => resolveSt0reRealinfraTarget(env)).toThrow(/ST0RE realinfra configuration error/);
|
||||
await expect(probeSt0reRealinfra(env, fetchSpy)).rejects.toThrow(/ST0RE_TEST_URL/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://localhost:4620",
|
||||
"http://127.0.0.1:4620",
|
||||
"http://[::1]:4620",
|
||||
"https://localhost:4620/",
|
||||
"https://127.0.0.1",
|
||||
"https://[::1]",
|
||||
])("probes an explicit loopback target with one GET (%s)", async (target) => {
|
||||
const env = {
|
||||
ST0RE_REALINFRA_TESTS: "1",
|
||||
ST0RE_TEST_URL: target,
|
||||
ST0RE_URL: "https://api.st0re.siax.io",
|
||||
};
|
||||
const origin = new URL(target).origin;
|
||||
const fetchSpy = vi.fn<typeof fetch>().mockResolvedValue(new Response("[]", { status: 200 }));
|
||||
expect(resolveSt0reRealinfraTarget(env)).toBe(origin);
|
||||
expect(await probeSt0reRealinfra(env, fetchSpy)).toEqual({ target: origin, reachable: true, reason: "" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(`${origin}/v1/st0re/lakehouse/namespaces`, {
|
||||
method: "GET",
|
||||
redirect: "error",
|
||||
headers: { "X-Tenant-Id": "t_0pt_realinfra_local", "X-Actor-Id": "0pt-realinfra-test" },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports HTTP 500 as unreachable", async () => {
|
||||
const target = "http://localhost:4620";
|
||||
const fetchSpy = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 500 }));
|
||||
expect(await probeSt0reRealinfra({ ST0RE_REALINFRA_TESTS: "1", ST0RE_TEST_URL: target }, fetchSpy))
|
||||
.toEqual({ target, reachable: false, reason: "ST0RE responded HTTP 500" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a throwing fetch as unreachable", async () => {
|
||||
const target = "http://localhost:4620";
|
||||
const fetchSpy = vi.fn<typeof fetch>().mockRejectedValue(new Error("synthetic connection failure"));
|
||||
expect(await probeSt0reRealinfra({ ST0RE_REALINFRA_TESTS: "1", ST0RE_TEST_URL: target }, fetchSpy))
|
||||
.toEqual({ target, reachable: false, reason: "Local ST0RE reachability probe failed" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
operationFingerprint,
|
||||
operationIdentity,
|
||||
runIdempotentOperation,
|
||||
type IdempotencyStore,
|
||||
} from "../../packages/siax-access/src/idempotency";
|
||||
|
||||
describe("@siax/access universal idempotency", () => {
|
||||
it("binds every result-affecting input into the fingerprint", () => {
|
||||
const a = operationFingerprint(["t", "p", "u", "instruction-extrahera"]);
|
||||
const b = operationFingerprint(["t", "p", "u", "instruction-extrahera-belopp"]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
it("rejects malformed identity (same shape 0pt/DOC0 both enforce)", () => {
|
||||
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "../bad", payload: [] })).toThrow();
|
||||
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "ok", payload: "not-array" as never })).toThrow();
|
||||
});
|
||||
it("conflicts when a reused key carries a changed payload", () => {
|
||||
const fp1 = operationFingerprint(["t", "p", "u", "A"]);
|
||||
const fp2 = operationFingerprint(["t", "p", "u", "B"]);
|
||||
expect(fp1).not.toBe(fp2);
|
||||
});
|
||||
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
|
||||
const stored = { x: 1 };
|
||||
const store: IdempotencyStore<unknown, { x: number }> = {
|
||||
start: async () => false,
|
||||
save: async () => {},
|
||||
receipt: async () => stored,
|
||||
};
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { throw new Error("must not rerun"); },
|
||||
});
|
||||
expect(outcome.kind).toBe("replayed");
|
||||
if (outcome.kind === "replayed") expect(outcome.receipt).toEqual(stored);
|
||||
});
|
||||
it("claims once and saves only after the dispatch", async () => {
|
||||
const order: string[] = [];
|
||||
const store: IdempotencyStore<unknown, { x: number }> = {
|
||||
start: async () => { order.push("start"); return true; },
|
||||
save: async () => { order.push("save"); },
|
||||
receipt: async () => { order.push("receipt"); return undefined; },
|
||||
};
|
||||
let dispatched = 0;
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { order.push("dispatch"); return { x: ++dispatched }; },
|
||||
});
|
||||
expect(outcome.kind).toBe("claimed");
|
||||
expect(dispatched).toBe(1);
|
||||
expect(order).toEqual(["start", "dispatch", "save"]);
|
||||
});
|
||||
it("reports a store error as conflict, never as a fresh start", async () => {
|
||||
const store: IdempotencyStore<unknown, { x: number }> = {
|
||||
start: async () => { throw new Error("db down"); },
|
||||
save: async () => {},
|
||||
receipt: async () => { throw new Error("db down"); },
|
||||
};
|
||||
let dispatched = 0;
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { dispatched++; return { x: 1 }; },
|
||||
});
|
||||
expect(outcome.kind).toBe("conflict");
|
||||
expect(dispatched).toBe(0);
|
||||
});
|
||||
it("does not rerun the dispatch when start succeeds but save later fails", async () => {
|
||||
const store: IdempotencyStore<unknown, { x: number }> = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
|
||||
let dispatched = 0;
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { dispatched++; return { x: 1 }; },
|
||||
});
|
||||
expect(outcome.kind).toBe("claimed");
|
||||
expect(dispatched).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
type St0reTestEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export interface St0reRealinfraProbe {
|
||||
target?: string;
|
||||
reachable: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Keep the real client's subsequent writes inside the selected test origin too. */
|
||||
export function isolatedSt0reFetch(target: string, fetchImpl: typeof fetch): typeof fetch {
|
||||
const origin = resolveSt0reRealinfraTarget({ ST0RE_REALINFRA_TESTS: "1", ST0RE_TEST_URL: target });
|
||||
return async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
||||
if (url.origin !== origin) throw new Error("ST0RE realinfra request escaped its isolated test origin");
|
||||
return fetchImpl(input, { ...init, redirect: "error" });
|
||||
};
|
||||
}
|
||||
|
||||
/** Only an explicit, isolated loopback origin can enable real-infrastructure tests. */
|
||||
export function resolveSt0reRealinfraTarget(env: St0reTestEnv): string | undefined {
|
||||
if (env.ST0RE_REALINFRA_TESTS !== "1") return undefined;
|
||||
|
||||
const target = env.ST0RE_TEST_URL;
|
||||
const configError = () => new Error(
|
||||
"ST0RE realinfra configuration error: ST0RE_REALINFRA_TESTS=1 requires ST0RE_TEST_URL to be an HTTP(S) loopback origin (localhost, 127.0.0.1 or [::1]), without credentials, path, query or fragment. Remote/production targets are forbidden."
|
||||
);
|
||||
// Check the original spelling too: URL parsing normalizes paths and IP aliases.
|
||||
if (!target || !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?\/?$/i.test(target)) {
|
||||
throw configError();
|
||||
}
|
||||
try {
|
||||
return new URL(target).origin;
|
||||
} catch {
|
||||
throw configError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSt0reRealinfra(
|
||||
env: St0reTestEnv,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<St0reRealinfraProbe> {
|
||||
// Configuration failures must escape the reachability catch and fail closed.
|
||||
const target = resolveSt0reRealinfraTarget(env);
|
||||
if (!target) {
|
||||
return { reachable: false, reason: "disabled: ST0RE_REALINFRA_TESTS is not 1" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(`${target}/v1/st0re/lakehouse/namespaces`, {
|
||||
method: "GET",
|
||||
redirect: "error",
|
||||
headers: {
|
||||
"X-Tenant-Id": env.ST0RE_TEST_TENANT_ID ?? "t_0pt_realinfra_local",
|
||||
"X-Actor-Id": "0pt-realinfra-test",
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return {
|
||||
target,
|
||||
reachable: response.ok,
|
||||
reason: response.ok ? "" : `ST0RE responded HTTP ${response.status}`,
|
||||
};
|
||||
} catch {
|
||||
return { target, reachable: false, reason: "Local ST0RE reachability probe failed" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user