- X-Internal-Token skickades av edgen men verifierades ALDRIG — gate som
raw-ASGI-middleware: INTERNAL_TOKEN satt => alla routes utom /health kräver
exakt token, 401 (constant-time) annars. Ospecificerad token = inert (dev).
- Dockerfile: ARG GIT_SHA + OCI-label + /health.reporting revision
- compose var redan skriven för INTERNAL_TOKEN — motorn hade bara aldrig
deployats med den
- tester: tests/test_internal_token_gate.py (3: off/public/401-pass) — hela
services/api-sviten 62 passed, 2 skipped (dokumenterade known-gaps), 0 fail
Two defects of the same kind: the repo asserted things about itself that were
not true, and nothing in it could tell.
A. CHANGELOG.md claimed CSP/HSTS/X-Frame-Options since 2026-08. `main`
contained none of it -- a repo-wide grep for
`content-security-policy|strict-transport-security|x-frame-options` matched
exactly one file, CHANGELOG.md itself. The only header on 0pt.app was
Vercel's own HSTS default, which does not survive the move to
Coolify/Traefik that siax.config.json and .gitea/workflows/cd.yml are
already pointed at.
- next.config.mjs: `headers()` on `/(.*)` with CSP (frame-ancestors 'none',
base-uri 'self', object-src 'none', form-action 'self'), HSTS,
X-Frame-Options DENY, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy; plus poweredByHeader: false. Same idiom as
sax3l/siax-io's next.config.mjs.
- Carve-outs re-derived against THIS tree, not inherited: the stale
`ci/security-headers-and-scanning` branch (8d2ff73, 142 commits behind)
carried a `form-action https://checkout.stripe.com` allowance justified
by a redirect that is actually `window.location.href`, which form-action
does not govern -- dropped rather than kept as decorative permission.
frame-src is derived from NOCODB_BASE_URL/METABASE_SITE_URL, the same env
the components/embed.tsx iframe is configured from, so wiring that
component up cannot be silently blanked by this header (the failure
sax3l/siax-io recorded as SIO-013). 'unsafe-eval' is dev-only.
- tests/security/security-headers.test.ts: blocking regression gate inside
`pnpm run test`, i.e. the required "CI (SIAX Cloud) / quality" context.
Asserts against expectations written out in the test, not imported from
the config. Proven to fail: removing X-Frame-Options -> `pnpm run test`
exits 1; weakening frame-ancestors to 'self' -> exits 1.
B. scripts/siax-doctor.mjs validated that siax.repo.json fields were PRESENT,
never what they said. Measured on this tree: operationalState "running"
(false) and "down" (true) both gave 40/40, exit 0, byte-identical output.
- Value checks (enums, git.siax.io const, fleet node pattern, bare-hostname
form), cross-file consistency (declared prod hostnames must appear in
this repo's own siax.config.json / contracts/openapi.yaml, and vice
versa), and GOV-06 substantiation: operationalState must be backed by a
dated probe record in evidence/operational-state.json that agrees with
it. Liveness is not knowable offline; whether a liveness CLAIM is backed
is. Offline, deterministic, no network -- CI can never go red for want of
an internet connection.
- `--live` (opt-in) probes the declared hosts for real and checks the
headers on the wire; `--write-evidence` refreshes the record. Not wired
into CI on purpose.
- tests/repo/siax-doctor.test.ts runs the doctor inside `pnpm run test` and
asserts the negative cases against fixture trees (SIAX_DOCTOR_ROOT), so
the "cannot fail" defect cannot come back.
C. CHANGELOG.md is now true. The header claim stands; the four claims it was
bundled with (Trivy --exit-code 1, gitleaks, SHA-pinned actions, SBOM +
license artifacts) are still false on main and are listed as not
implemented instead of left asserted. All four live in .gitea/workflows/**,
a protected path on this repo, so they need an authorised workflow change.
Nothing under .gitea/workflows/** or .gitignore is touched.
Verified: pnpm run typecheck exit 0; pnpm run test exit 0 (74 files / 965
tests passed, up from 72/945); pnpm run build exit 0 and the built
.next/routes-manifest.json carries all six headers; node scripts/siax-doctor.mjs
55/55 exit 0; --live 57/57 exit 0. eslint on the new files exit 0 (repo lint is
advisory and its 17 pre-existing problems are unchanged).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/p0/source-registry-postgres.realinfra.test.ts only applied
sql/002_source_registry.sql before running assertions. sql/007_ingestion_plane.sql
later added opt_sources.owner_id, and src/repositories/postgres-source-repository.ts's
INSERT has named that column ever since -- but nothing caught the drift because
SOURCE_REGISTRY_TEST_DATABASE_URL is never set in CI (.gitea/workflows/ci.yml has no
job that sets it), so this suite has silently skipped in every CI run.
Verified against a real disposable Postgres 16 container: before this fix, all 7
tests failed with `column "owner_id" of relation "opt_sources" does not exist`;
after replaying every sql/*.sql file in order (same pattern already used by
tests/analytics/analytics-domain-postgres.realinfra.test.ts), all 7 pass.
Found while auditing 0pt's Analytics/BI layer (MetricFlow/Cube/ClickHouse/
Trino/DuckDB) and standing up real Postgres/ClickHouse containers to verify
those claims for real; this bug is adjacent (source registry, not analytics)
but was directly encountered and is a small, safe, test-only fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recorded decision (Simon 2026-08-31): personal data may be stored under
legitimate interest ONLY as business contacts bound to a company/org entity.
Gate enforces it: legitimate_interest requires purpose=business_contact and
entity_linked_only=true; recordStored() requires entity_id (orgnr/rgs/LEI,
hashed) and refuses person-only rows. +1 test; suite 167 pass | 1 skip;
typecheck clean.
PiiGateService makes any future personal-data source (ratsit.se and other
directory domains) technically GDPR-OK before the first byte is stored:
- recorded legal basis (art. 6) with basis_ref evidence, no policy => fail-closed
- data minimisation via field allowlist (art. 5(1)(c))
- storage limitation + enforceRetention purge (art. 5(1)(e))
- sha256-only identity keys + log redaction (art. 25/32)
- accessStatement / erase subject-rights surfaces (art. 15/17)
- robots/rate confirmation required for web+api sources; no stealth/bypass
- audit trail via AUD0 at risk L3 (0pt.pii_*)
- gate enabled by default in SourceQueryService
10 new tests; full suite 166 pass | 1 skip; typecheck (blocking gate) clean.
0pt had no SBOM/sign/digest chain at all (re-confirmed live before this
change: no cosign/syft/sbom hit anywhere under .gitea/workflows/, only doc
mentions in CHANGELOG.md/docs/architecture/ARCHITECTURE.md). Applies the
same two-part pattern already proven on sax3l/cl0ud and sax3l/st0re
(fix/wire-signed-digest-to-deploy, 2026-09-14):
1. .gitea/workflows/release.yml (new): builds the image, pushes it to
git.cloud.siax.io/sax3l/0pt:sha-<12>, SBOMs it (syft), signs it (cosign,
key-based), verifies the signature in the same run, and commits
source-bundle/production-evidence records back to the repo.
2. .gitea/workflows/cd.yml: the `deploy` job now polls the registry for
this exact commit's release-chain image and cosign-verifies it (against
the committed cosign.pub) before calling Coolify's deploy endpoint --
fails closed (10 min timeout) if release.yml never produced a verifiable
artifact for this sha. Added AFTER the existing guard step, which still
fails closed first on 0pt's real, pre-existing blocker: no Coolify
Application is provisioned for 0pt yet (COOLIFY_APP_ID is a literal
placeholder). This gate is wired ahead of that so it's already in place
the day the app is provisioned, instead of a second follow-up change.
Honest limitation carried over from cl0ud/st0re/h0st: once provisioned,
0pt's Coolify Application is expected to use the `dockerfile` build pack
(per siax.config.json), so Coolify would still perform its own independent
docker build rather than pulling the signed image, and /api/v1/deploy has
no digest parameter to pin against -- true digest-pinning needs a
production Coolify resource-type decision, out of CI-YAML scope. This gate
proves a signed, verified build of the exact commit exists before
authorizing any deploy call; it does not prove byte-identical output from
Coolify's own rebuild.
cosign.pub is a fresh, 0pt-specific keypair generated offline for this
change. NOT YET SET on sax3l/0pt (release.yml/cd.yml's gate will fail
closed until present): REGISTRY_USER, REGISTRY_TOKEN, COSIGN_PRIVATE_KEY,
COSIGN_PASSWORD, SIAX_CI_TOKEN repo secrets.
Validated without live Coolify/registry access: YAML-parsed all 2 files,
bash -n syntax-checked all 19 embedded run: blocks, and dry-ran the actual
verification logic -- the RepoDigests filter and bounded poll-retry loop
against mocked scenarios, plus a real cosign sign+verify round-trip
(offline signing-config, local throwaway registry container) proving the
gate's cosign verify command genuinely passes on the correct key/digest and
fails closed on a wrong key and on an unsigned image.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
connection_config.sqlEndpoint on POST /api/0pt/sources/{sourceId}/query
was passed straight from the request body to the mssql TDS driver as the
outbound connection target, with only its http(s):// scheme stripped —
no hostname allowlist or private/loopback/link-local/cloud-metadata
guard. This route already requires a Zitadel bearer token + tenant match
(P014 tenant guard), so this was not anonymously exploitable, but it is
the exact SSRF shape found and exploited-in-principle elsewhere in this
estate tonight (press0), so it's worth closing as defense-in-depth.
Adds src/lib/security/ssrf-guard.ts: resolves the endpoint's hostname
and rejects any address in a private/loopback/link-local/cloud-metadata/
unspecified range (RFC1918, 127.0.0.0/8, 169.254.0.0/16 incl. the
169.254.169.254 metadata address, ::1, fc00::/7, fe80::/10, IPv4-mapped
IPv6), failing closed on DNS resolution errors. The range-check logic is
ported 1:1 from press0's src/lib/security/ssrf-guard.ts built the same
night for the same bug class against a fetch()-based URL there; this
module's surface differs because sqlEndpoint is a bare host[:port], not
a fetchable URL. The DNS lookup is injectable (mirrors this adapter's
existing fetchImpl pattern) so tests are hermetic.
FabricOneLakeAdapter.getPool() now calls the guard before loadDriver()/
getToken()/opening the TDS pool, so a blocked target never reaches a
real outbound connection attempt or spends an AAD token exchange.
Tests: new tests/p0/fabric-onelake-ssrf-guard.test.ts unit-tests the
guard module directly (range boundaries, IPv4-mapped-IPv6 bypass
attempt, fail-closed on DNS failure/empty answer). New adapter-level
tests in tests/p0/fabric-onelake-adapter.test.ts prove (a) a
private-IP-resolving sqlEndpoint is rejected before fetch/connect is
ever called, (b) a literal loopback sqlEndpoint is rejected the same
way, and (c) a normal external Fabric hostname still passes the guard
and completes the query — the guard doesn't false-block legitimate
targets. Existing adapter tests are updated to inject a deterministic
DNS resolver so they stay hermetic instead of depending on real DNS.
Full suite: 997 passed, 0 failed, 184 skipped (self-skipping realinfra
tests with no live backend this run). typecheck and lint clean on all
touched files.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per siax-standard conformance sweep: repo lacked the masterplan-lock CI guard
(.gitea/workflows/masterplan-lock.yml + scripts/check-masterplan-lock.sh) and a
repo-local PLAN/MASTERPLAN_INDEX.md. Copied from sax3l/st0re template (generic
POSIX sh check), stub PLAN index with locked architecture principles and
pointer to sax3l/siax-masterplan.
Refs: sax3l/siax-standard (conformance sweep 2026-09-10)
The `resolveStoreMode` kill switch (Master-DoD §3/§10) covered the newer
`src/repositories/*` stores but not two older paths, both of which are
live production execution paths today.
1. LEGACY NEXT.JS STORES. `lib/orders-store.ts` and
`lib/subscriptions-store.ts` predate `src/platform/dependency-mode.ts`
and called `getSql()` directly, silently falling back to module-global
`Map`s when no database is configured. For orders, payments and
subscriptions that is not a degraded mode, it is invisible loss of
money-bearing state: the API returns 201 and the row evaporates on the
next restart or on any other replica. The live 0pt deployment
(Coolify app 54) runs NODE_ENV=production with DATABASE_URL,
POSTGRES_URL and POSTGRES_PRISMA_URL all unset — so this is the
behavior in production right now, across 7 API routes.
These now go through `getSqlForDurableStore()`, which reuses the SAME
`resolveStoreMode` decision every `src/repositories/*` store already
uses rather than re-implementing a second production check. The reuse
is load-bearing: `isProductionRuntime` deliberately exempts
`NEXT_PHASE=phase-production-build`, because `next build` runs
page-data collection under NODE_ENV=production while serving nothing.
A naive `process.env.NODE_ENV === "production"` test here would have
failed the BUILD instead of protecting a request path.
2. LOCALBLOBSTORE. It writes document bytes to a container-local disk
while `document-capture-service` registers an artifact pointer
claiming the bytes are stored — durable object storage silently
swapped for ephemeral disk. It is the default blob store for both the
capture and extraction services. In production it now raises
DEPENDENCY_UNAVAILABLE (capability blocked: no ST0RE bytes path
exists yet) instead of fabricating success. Asserted per-operation,
not in the constructor, so merely wiring the services is not fatal —
only actually persisting or reading bytes is.
Evidence: full suite 956 passed / 184 skipped, typecheck clean,
dependency-cruiser 0 errors, lint identical to main's pre-existing
baseline (17 problems, none in changed files). Both guards asserted in
both directions and mutation-tested: bypassing resolveStoreMode -> 2
failed; dropping the write assert -> 2 failed; dropping the read assert
-> 1 failed; all restored -> 8 passed. 7 non-test call sites.
DEPLOYMENT NOTE: merging changes no runtime (no Gitea webhook exists on
any repo; deploys are manual). Deploying this build REQUIRES
provisioning DATABASE_URL for 0pt first, or the order/subscription
routes will correctly return DEPENDENCY_UNAVAILABLE instead of silently
accepting orders into memory. That provisioning is a Simon action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
services/api/workers/{resolve,quality_bridge}.py anropar båda rutterna med
X-Internal-Token sedan langt tillbaka; rutterna (app/api/0pt/internal/er/
candidates + app/api/0pt/quality/bridge-run) har alltid existerat men
kontraktet shipade dem aldrig. Wire ar sanning — kontraktet matchar nu:
- POST /api/0pt/internal/er/candidates (full request/response-schema ur
src/er/request-validation.ts + entity-resolution-service.ts)
- POST /api/0pt/quality/bridge-run (flyttad fran ops-losen deklaration till
internal-gruppen med internalToken-security)
- components.securitySchemes.internalToken (X-Internal-Token header,
verifyInternalServiceToken)