diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b8efbb..5601ef1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: branches: [main] pull_request: +# The job builds and inspects; it never writes to the repository. Without this +# block it would inherit whatever the repository default happens to be, which is +# the only privileged path in the file. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -12,6 +18,21 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # The vendored OpenAPI spec is a copy of the engine's. Without the source + # beside it there is nothing to compare against, and a drift check that + # cannot see the source is a check that always passes. + # + # Full history, because the check reads the spec at the commit recorded in + # openapi-source.json rather than at whatever is currently on main. See + # that script's header for why the pin exists. + - name: Checkout dpp-engine (source of the vendored API spec) + uses: actions/checkout@v4 + with: + repository: odal-node/dpp-engine + path: .dpp-engine + fetch-depth: 0 + persist-credentials: false + - name: Enable Corepack run: corepack enable @@ -29,3 +50,40 @@ jobs: - name: Check run: pnpm -r check + + # Markdown link targets are opaque strings to `astro check`. This reads + # the built output, so it sees what is actually published — including + # cross-site links, which neither site's own tooling can resolve. + - name: Check links + run: pnpm run check:links + + # This repository is public. Internal decision-record numbers and paths + # into the private docs repo must not appear in it — including inside + # `public/`, which is served verbatim. + - name: Check for internal-vocabulary leakage + run: pnpm run check:leakage + + - name: Check the vendored API spec against the engine + run: pnpm run check:openapi + env: + DPP_ENGINE_DIR: ${{ github.workspace }}/.dpp-engine + + # Reports, without failing, how far the pin is behind the engine's main. + # Deliberately not a gate: the pinned copy being *correct* is this repo's + # problem and is enforced above, but the pin being *old* is a release- + # cadence judgement, and failing on it would redden every pull request + # here every time the engine merges anything. The number is printed on + # every run so the drift that started this — a published spec fifteen + # endpoints behind, with nothing to reveal it — cannot go unnoticed again. + - name: Report how far the API-spec pin is behind + if: always() + run: | + PIN=$(node -p "require('./site/dpp-docs/openapi-source.json').commit") + cd .dpp-engine + BEHIND=$(git rev-list --count "$PIN"..origin/main -- api/openapi.yaml 2>/dev/null || echo "?") + if [ "$BEHIND" = "0" ]; then + echo "API spec pin is current with the engine's main branch." + else + echo "::notice::The vendored API spec is pinned $BEHIND commit(s) behind changes to api/openapi.yaml on the engine's main. Run 'pnpm run sync:openapi' to bring it forward." + git --no-pager log --oneline "$PIN"..origin/main -- api/openapi.yaml || true + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a671e2f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,81 @@ +name: Deploy + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'site/**' + - 'packages/**' + - 'public/**' + - '.github/workflows/deploy.yml' + +jobs: + purge-cloudflare-cache-landing: + name: Purge Cloudflare Cache (landing) + runs-on: ubuntu-latest + environment: ${{ vars.CLOUDFLARE_ENVIRONMENT_LANDING || 'landing' }} + + steps: + - name: Check Cloudflare secrets + id: cloudflare + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -n "$CLOUDFLARE_ZONE_ID" ] && [ -n "$CLOUDFLARE_API_TOKEN" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + fi + + - name: Purge Cloudflare cache + if: steps.cloudflare.outputs.ready == 'true' + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + curl --fail --silent --show-error \ + -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}' + + - name: Skip purge if secrets are missing + if: steps.cloudflare.outputs.ready != 'true' + run: echo "Skipping Cloudflare cache purge because the required secrets are not configured for the landing environment." + + purge-cloudflare-cache-docs: + name: Purge Cloudflare Cache (docs) + runs-on: ubuntu-latest + environment: ${{ vars.CLOUDFLARE_ENVIRONMENT_DOCS || 'docs' }} + + steps: + - name: Check Cloudflare secrets + id: cloudflare + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -n "$CLOUDFLARE_ZONE_ID" ] && [ -n "$CLOUDFLARE_API_TOKEN" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + fi + + - name: Purge Cloudflare cache + if: steps.cloudflare.outputs.ready == 'true' + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + curl --fail --silent --show-error \ + -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}' + + - name: Skip purge if secrets are missing + if: steps.cloudflare.outputs.ready != 'true' + run: echo "Skipping Cloudflare cache purge because the required secrets are not configured for the docs environment." \ No newline at end of file diff --git a/.gitignore b/.gitignore index 23f201a..019076b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ Thumbs.db # CI / deploy artefacts .wrangler/ .cloudflare/ + +# local-only assets (never publish, never commit — this repo is public) +deprecated/ +.claude/ diff --git a/package.json b/package.json index 2a4d075..f108ca2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "build:docs": "pnpm --filter dpp-docs build", "build": "pnpm -r build", "check": "pnpm -r check", + "check:links": "node scripts/check-links.mjs", + "check:leakage": "node scripts/check-leakage.mjs", + "check:openapi": "pnpm --filter dpp-docs run check:openapi", + "sync:openapi": "pnpm --filter dpp-docs run sync:openapi", "preview:landing": "pnpm --filter dpp-landing preview", "preview:docs": "pnpm --filter dpp-docs preview", "clean": "pnpm -r exec rm -rf dist .astro node_modules && rm -rf node_modules" diff --git a/packages/brand-tokens/src/colors.ts b/packages/brand-tokens/src/colors.ts index f2675b8..d6ac8a1 100644 --- a/packages/brand-tokens/src/colors.ts +++ b/packages/brand-tokens/src/colors.ts @@ -6,12 +6,12 @@ * Starlight CSS-variable overrides import from here (directly, or via the * mirrored CSS custom properties in `tokens.css`). * - * See BRAND.md section 4.1 for the editorial rationale behind each scale. + * Each scale is tuned for a specific surface; see the contrast notes below. */ /** * Primary scale — navy/ice blue family, anchored on the logo - * (decision 2026-06-10, docs/redesign/DESIGN_SPEC.md §1). + * (decision 2026-06-10). * 50–300 are ice tints (the logo stroke is 300); 500/600 are the interactive * action blues (AA on white); 800/900 are the navy surfaces (logo field = 900). */ diff --git a/packages/brand-tokens/src/spacing.ts b/packages/brand-tokens/src/spacing.ts index a8f9020..4510743 100644 --- a/packages/brand-tokens/src/spacing.ts +++ b/packages/brand-tokens/src/spacing.ts @@ -1,7 +1,7 @@ /** * Odal Node — spacing and radius tokens. * - * 8-pixel base scale with a 12px outlier (see BRAND.md section 4.3). + * 8-pixel base scale with a 12px outlier. */ export const spacing = { diff --git a/packages/brand-tokens/src/tokens.css b/packages/brand-tokens/src/tokens.css index 7cdeb8a..2cae494 100644 --- a/packages/brand-tokens/src/tokens.css +++ b/packages/brand-tokens/src/tokens.css @@ -8,7 +8,7 @@ * that does `@import "tailwindcss"; @import "@odal/brand-tokens/tokens.css";` * gets utility classes for the full brand palette without further config. * - * Palette decision 2026-06-10 (docs/redesign/DESIGN_SPEC.md §1): the brand + * Palette decision 2026-06-10: the brand * colour system follows the logo — navy field (#080C2C) + ice-blue strokes * (#B7D4F0) — with a darkened action blue for interactive elements so links * and buttons hold AA contrast on white. The former green scale is retired. diff --git a/packages/brand-tokens/src/typography.ts b/packages/brand-tokens/src/typography.ts index 444628d..31d6e6b 100644 --- a/packages/brand-tokens/src/typography.ts +++ b/packages/brand-tokens/src/typography.ts @@ -1,7 +1,7 @@ /** * Odal Node — typography tokens. * - * System-stack-first. No web fonts. See BRAND.md section 4.2 for rationale. + * System-stack-first. No web fonts, so no page issues a third-party font request. */ export const fontFamily = { diff --git a/scripts/check-leakage.mjs b/scripts/check-leakage.mjs new file mode 100644 index 0000000..1d3765d --- /dev/null +++ b/scripts/check-leakage.mjs @@ -0,0 +1,112 @@ +// Fail the build if internal planning vocabulary or private-repo paths appear +// anywhere in this repository. +// +// This repo is public. Two classes leak here, and they need different scopes: +// +// * Decision-record numbers. One reached `public/openapi.yaml`, which is +// *served* at docs.odal-node.io/openapi.yaml and rendered into /api. A +// convention that only reads the source tree would never have caught it, +// which is why `public/` is explicitly in scope below. +// * Paths into the private documentation repository. Several are clickable +// relative links in READMEs that 404 for anyone browsing GitHub, and they +// disclose that repo's internal structure. +// +// Public artefacts must be self-contained: restate the design inline rather +// than pointing at something the reader cannot open. +// +// Usage: node scripts/check-leakage.mjs +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { join, relative, extname, basename } from 'node:path'; + +const SKIP_DIRS = new Set([ + 'node_modules', + '.git', + '.claude', + 'dist', + '.astro', + '.pnpm-store', + '.dpp-engine', + 'deprecated', +]); +// This file necessarily contains the patterns it searches for. +const SKIP_FILES = new Set(['check-leakage.mjs']); +const BINARY = new Set(['.png', '.jpg', '.jpeg', '.webp', '.ico', '.woff', '.woff2', '.pdf']); + +const RULES = [ + { + // eslint-disable-next-line no-useless-escape + pattern: /ADR-\d+/g, + why: 'a decision-record number — meaningless outside the private repo and stale inside it', + }, + { pattern: /WEB_CONTENT_STRATEGY/g, why: 'a private-repo document path' }, + { pattern: /DESIGN_SPEC/g, why: 'a private-repo document path' }, + { pattern: /\bBRAND\.md\b/g, why: 'a private-repo document path' }, + { pattern: /\.\.\/\.\.\/docs\//g, why: 'a relative path into the private repo' }, +]; + +const walk = (dir, out = []) => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else if (!BINARY.has(extname(full)) && !SKIP_FILES.has(basename(full))) out.push(full); + } + return out; +}; + +const hits = []; +const files = walk(process.cwd()); + +for (const file of files) { + let text; + try { + text = readFileSync(file, 'utf8'); + } catch { + continue; // unreadable or genuinely binary + } + const lines = text.split('\n'); + for (const rule of RULES) { + lines.forEach((line, i) => { + const match = line.match(rule.pattern); + if (match) { + hits.push({ + file: relative(process.cwd(), file), + line: i + 1, + found: match[0], + why: rule.why, + }); + } + }); + } +} + +// The directories above are skipped because they are meant to be untracked. +// That assumption is worth testing: a stray `git add -A` on a branch that +// predates the .gitignore entry commits them to a public repository, and the +// content scan would never look. Ask git what is actually tracked. +const tracked = execSync('git ls-files', { encoding: 'utf8' }) + .split('\n') + .filter((p) => p.startsWith('deprecated/') || p.startsWith('.claude/')); + +if (tracked.length > 0) { + console.error(`leakage check: ${tracked.length} local-only file(s) are tracked.\n`); + for (const p of tracked.slice(0, 10)) console.error(` ${p}`); + if (tracked.length > 10) console.error(` … and ${tracked.length - 10} more`); + console.error('\n These directories are gitignored because they must not be published.'); + console.error(' Untrack them with `git rm -r --cached ` before committing.'); + process.exit(1); +} + +if (hits.length === 0) { + console.log(`leakage check: ${files.length} files scanned, clean. No local-only files tracked.`); + process.exit(0); +} + +console.error(`leakage check: ${hits.length} occurrence(s) of internal vocabulary.\n`); +for (const h of hits) { + console.error(` ${h.file}:${h.line} "${h.found}" — ${h.why}`); +} +console.error('\n Restate the mechanism inline. If provenance matters, "an internal'); +console.error(' decision record, dated X" is the most that may be said.'); +process.exit(1); diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..24d623a --- /dev/null +++ b/scripts/check-links.mjs @@ -0,0 +1,104 @@ +// Crawl both built sites for internal links that resolve to nothing. +// +// `astro check` type-checks templates and validates content-collection +// references, but a markdown link target is an opaque string to it. Four +// `[Licensing](/engine/licensing)` links passed `check` and 404'd in production, +// because the page's source file is underscore-prefixed and never routed. +// +// This runs over `dist`, so it sees what is actually published rather than what +// the source appears to promise. Two things follow from that: +// +// * Astro's redirect stubs carry a real to their target, so a stub +// pointing at a missing page is caught without parsing meta-refresh. +// * Cross-site links are resolved against the *other* site's build. A link +// from the docs to odal-node.io/roadmap is invisible to either site's own +// tooling, which is exactly how that one survived. +// +// Usage: node scripts/check-links.mjs +import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'; +import { join, extname, relative } from 'node:path'; + +const SITES = [ + { origin: 'https://odal-node.io', dist: 'site/dpp-landing/dist', name: 'odal-node.io' }, + { origin: 'https://docs.odal-node.io', dist: 'site/dpp-docs/dist', name: 'docs.odal-node.io' }, +]; + +const walk = (dir, out = []) => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else if (extname(full) === '.html') out.push(full); + } + return out; +}; + +// A published path resolves if it names a real file, or a directory holding an +// index.html — the two shapes Astro's static output emits. +const resolves = (dist, path) => { + const clean = decodeURI(path.split('#')[0].split('?')[0]); + if (clean === '' || clean === '/') return existsSync(join(dist, 'index.html')); + const base = join(dist, clean); + if (extname(clean)) return existsSync(base); + return existsSync(join(base, 'index.html')) || existsSync(`${base}.html`); +}; + +const broken = []; +let linkCount = 0; +let pageCount = 0; + +for (const site of SITES) { + if (!existsSync(site.dist)) { + console.error(`link check: ${site.dist} not found — run \`pnpm -r build\` first.`); + process.exit(1); + } + + for (const file of walk(site.dist)) { + pageCount += 1; + // Astro preserves HTML comments in its output, and this repo comments out + // links rather than deleting them. A commented-out link is not published. + const html = readFileSync(file, 'utf8').replace(//g, ''); + for (const [, attr] of html.matchAll(/(?:href|src)="([^"]+)"/g)) { + // A canonical is a declaration about this page, not a dependency of it. + // The 404 page necessarily self-canonicalises to a path that is not a + // route, because it is served at every unmatched path. + if (html.includes(`rel="canonical" href="${attr}"`)) continue; + let target = null; + let dist = site.dist; + + if (attr.startsWith('/')) { + target = attr; + } else { + const other = SITES.find((s) => attr.startsWith(`${s.origin}/`) || attr === s.origin); + if (other) { + target = attr.slice(other.origin.length) || '/'; + dist = other.dist; + } + } + + // Anchors, mailto:, and third-party origins are out of scope. + if (target === null) continue; + linkCount += 1; + if (!resolves(dist, target)) { + broken.push({ from: relative(process.cwd(), file), target: attr }); + } + } + } +} + +if (broken.length === 0) { + console.log(`link check: ${linkCount} internal links across ${pageCount} pages, all resolve.`); + process.exit(0); +} + +console.error(`link check: ${broken.length} broken internal link(s).\n`); +const byTarget = new Map(); +for (const b of broken) { + if (!byTarget.has(b.target)) byTarget.set(b.target, []); + byTarget.get(b.target).push(b.from); +} +for (const [target, sources] of [...byTarget].sort((a, b) => b[1].length - a[1].length)) { + console.error(` ${target} <- ${sources.length} page(s)`); + for (const s of sources.slice(0, 6)) console.error(` ${s}`); + if (sources.length > 6) console.error(` … and ${sources.length - 6} more`); +} +process.exit(1); diff --git a/site/dpp-docs/README.md b/site/dpp-docs/README.md index a89142e..3e42816 100644 --- a/site/dpp-docs/README.md +++ b/site/dpp-docs/README.md @@ -41,7 +41,7 @@ site/dpp-docs/ Brand assets shared with the landing site come from the workspace-root `../../public/brand/`, copied into the build by `viteStaticCopy` (see `astro.config.mjs`). -The sidebar structure is declared in `astro.config.mjs` and mirrored by the file-system layout under `src/content/docs/`. Renamed or removed slugs keep a redirect (e.g. `/design/proof-bound` → `/getting-started/what-odal-can-and-cannot-see`); the full redirect map is in `astro.config.mjs`. The current IA decisions live in [`../../docs/WEB_CONTENT_STRATEGY.md`](../../docs/WEB_CONTENT_STRATEGY.md) §6. +The sidebar structure is declared in `astro.config.mjs` and mirrored by the file-system layout under `src/content/docs/`. Renamed or removed slugs keep a redirect (e.g. `/design/proof-bound` → `/getting-started/what-odal-can-and-cannot-see`); the full redirect map is in `astro.config.mjs`, which is the source of truth for the information architecture. ## Honest stubs for `dpp-engine` @@ -49,7 +49,7 @@ Pages under `src/content/docs/engine/` that document unshipped surfaces use the ## Terminology rules -**Proof-bound architecture** (never "no-touch"); compliance calculators are **open** (never "pro-tier"); deployment claims only for shipped code — capability claims ("wasm32-safe, can run in edge runtimes") are fine. Full rules: [`../../docs/WEB_CONTENT_STRATEGY.md`](../../docs/WEB_CONTENT_STRATEGY.md) §7. +**Proof-bound architecture** (never "no-touch"); compliance calculators are **open** (never "pro-tier"); deployment claims only for shipped code — capability claims ("wasm32-safe, can run in edge runtimes") are fine. State what is built in the present tense and what is planned in the future tense, and never mix the two in one sentence. ## Deployment diff --git a/site/dpp-docs/astro.config.mjs b/site/dpp-docs/astro.config.mjs index d3b0b25..d100c3e 100644 --- a/site/dpp-docs/astro.config.mjs +++ b/site/dpp-docs/astro.config.mjs @@ -4,12 +4,15 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ site: 'https://docs.odal-node.io', - // Renamed pages keep their old URLs working (WEB_CONTENT_STRATEGY.md §6). + // Renamed pages keep their old URLs working. redirects: { // Design pages removed; redirect to the closest living equivalent. '/design/no-touch-data': '/getting-started/what-odal-can-and-cannot-see', '/design/proof-bound': '/getting-started/what-odal-can-and-cannot-see', - '/design/open-core': '/engine/licensing', + // Points at the licence table rather than at /engine/licensing, whose + // source is underscore-prefixed and therefore never routed. Repoint it back + // when that page is published. + '/design/open-core': '/introduction', '/design/adr': '/core-concepts', '/design/why-no-capability-gating': '/core-concepts', // Core consolidated from per-crate/type pages into three concept pages. diff --git a/site/dpp-docs/openapi-source.json b/site/dpp-docs/openapi-source.json new file mode 100644 index 0000000..77b48af --- /dev/null +++ b/site/dpp-docs/openapi-source.json @@ -0,0 +1,6 @@ +{ + "_comment": "Provenance for public/openapi.yaml, which is vendored from dpp-engine. The commit is what `pnpm run sync:openapi` last copied from, and what `check:openapi` verifies the vendored copy against. Pinning to a commit rather than to the engine's main branch keeps this repo's CI deterministic: an unrelated merge in the engine cannot turn a pull request here red, and a coordinated change across both repos can land without deadlocking on which merges first. Bumping the pin is a deliberate, reviewable line in a diff.", + "repository": "odal-node/dpp-engine", + "path": "api/openapi.yaml", + "commit": "45f8aa884da0535a1059e0e51467c6de37c471ed" +} diff --git a/site/dpp-docs/package.json b/site/dpp-docs/package.json index 685d367..92b0904 100644 --- a/site/dpp-docs/package.json +++ b/site/dpp-docs/package.json @@ -10,7 +10,8 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "sync:openapi": "node scripts/sync-openapi.mjs" + "sync:openapi": "node scripts/sync-openapi.mjs", + "check:openapi": "node scripts/sync-openapi.mjs --check" }, "dependencies": { "@astrojs/starlight": "^0.30.6", diff --git a/site/dpp-docs/public/_headers b/site/dpp-docs/public/_headers new file mode 100644 index 0000000..261e0d1 --- /dev/null +++ b/site/dpp-docs/public/_headers @@ -0,0 +1,27 @@ +# Cloudflare Pages custom headers — docs.odal-node.io +# Mirrors site/dpp-landing/public/_headers. The docs site is the larger surface +# and carries the only JavaScript-heavy page (/api), so nosniff and DENY matter +# more here than on the marketing site, not less. + +/* + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=() + +# Astro content-hashes everything here, so a new build produces new filenames +# and an old one never needs invalidating. +/_astro/* + Cache-Control: public, max-age=31536000, immutable + +# Pagefind emits content-addressed index shards under a stable directory. +/pagefind/* + Cache-Control: public, max-age=604800 + +/favicon.svg + Cache-Control: public, max-age=604800 + +# Vendored from the engine and re-synced on change, so it must not be pinned +# for a year the way a hashed asset can be. +/openapi.yaml + Cache-Control: public, max-age=3600 diff --git a/site/dpp-docs/public/openapi.yaml b/site/dpp-docs/public/openapi.yaml index a288d8d..f438615 100644 --- a/site/dpp-docs/public/openapi.yaml +++ b/site/dpp-docs/public/openapi.yaml @@ -62,8 +62,17 @@ components: type: http scheme: basic description: | - Local development fallback. Uses the `ADMIN_USERNAME` and - `ADMIN_PASSWORD` environment variables. Not available in production. + The operator's own bootstrap credential, from the `ADMIN_USERNAME` and + `ADMIN_PASSWORD` environment variables. It mints the first API key on a + fresh node — before any key exists — and is the lockout-recovery path + afterwards, since it carries no `keyId` and so can revoke any key. + + Active in every environment where both variables are set; there is no + production gate. Leave them unset once an API key exists if you do not + want the path available. + + Reached only via the `Basic` scheme. A `Bearer` token is never matched + against it, even one carrying the same `base64(user:pass)` payload. MutualTLS: type: mutualTLS @@ -84,30 +93,6 @@ components: description: UUID v7 identifier assigned on creation. Embedded in QR codes and public URLs. example: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f" - ProductCategory: - description: | - Fine-grained product category (a data attribute — the dispatch key is - `sector`). Known categories serialise as snake_case strings; any other - category serialises as `{ "other": "" }`. - oneOf: - - type: string - enum: - - ev_battery - - industrial_battery - - lmt_battery - - apparel - - footwear - - home_textile - - smartphone - - laptop - - charger - - type: object - required: [other] - properties: - other: - type: string - example: "ev_battery" - PassportStatus: type: string enum: [draft, active, suspended, archived] @@ -231,12 +216,34 @@ components: type: string description: Optional batch or lot identifier example: "BATCH-2026-04-001" + placedOnMarketDate: + type: string + format: date + description: | + The date this product was placed on the EU market — the regulated + triggering event that fixes which law governs it. + + Optional, and omitting it is not neutral. A compliance determination + whose rule is phased by date has no answer without it: the node + reports the missing fact rather than substituting today's date, which + would produce a determination that silently changes its own answer + when a phase begins. For batteries this decides which EU 2023/1542 + Art. 8 minimum recycled shares apply. + example: "2026-03-14" schemaVersion: type: string - description: >- - Sector schema version. When omitted, the sector's current version is - used (e.g. battery 2.0.0); a supplied value is honoured. - example: "1.0.0" + description: | + Sector schema version. Optional, and the only accepted value is the + sector's **current** version — omitting it is equivalent. Any other + value is rejected with `422`. + + It is not the caller's to choose: the stored version selects the + disclosure table the passport's public view is filtered through and + signed under, and an older table classifies fewer fields, defaulting + the rest to public. The body is validated against the current schema + in either case, so a differing declaration is already false about the + body it accompanies. + example: "2.6.0" parentPassportRef: $ref: "#/components/schemas/PassportRef" componentRefs: @@ -291,8 +298,7 @@ components: verified: type: boolean reason: - type: string - nullable: true + type: [string, "null"] enum: [ unreachable, @@ -321,13 +327,10 @@ components: id: $ref: "#/components/schemas/DppId" batchId: - type: string - nullable: true + type: [string, "null"] productName: type: string example: "EcoCell Pro 48V" - productCategory: - $ref: "#/components/schemas/ProductCategory" manufacturer: $ref: "#/components/schemas/ManufacturerInfo" materials: @@ -335,23 +338,19 @@ components: items: $ref: "#/components/schemas/MaterialEntry" co2ePerUnit: - type: number - nullable: true + type: [number, "null"] repairabilityScore: - type: number - nullable: true + type: [number, "null"] sectorData: $ref: "#/components/schemas/SectorData" status: $ref: "#/components/schemas/PassportStatus" qrCodeUrl: - type: string + type: [string, "null"] format: uri - nullable: true description: "GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, default https://id.odal-node.io)." jwsSignature: - type: string - nullable: true + type: [string, "null"] description: JWS compact serialisation (Ed25519). Null until published. createdAt: type: string @@ -360,9 +359,8 @@ components: type: string format: date-time publishedAt: - type: string + type: [string, "null"] format: date-time - nullable: true schemaVersion: type: string example: "1.0.0" @@ -390,14 +388,11 @@ components: type: string example: "published" previousStatus: - type: string - nullable: true + type: [string, "null"] newStatus: - type: string - nullable: true + type: [string, "null"] metadata: - type: object - nullable: true + type: [object, "null"] timestamp: type: string format: date-time @@ -436,8 +431,7 @@ components: nodeVersion: type: string rulesetVersion: - type: string - nullable: true + type: [string, "null"] contentHashes: type: object description: member name -> hex SHA-256 of that member's JCS-canonical bytes. @@ -474,22 +468,62 @@ components: items: $ref: "#/components/schemas/AuditEntry" transferChain: - type: object - nullable: true + type: [object, "null"] description: Present iff the passport has ever changed responsible operator. eolEvent: - type: object - nullable: true + type: [object, "null"] description: Present iff the passport was declared end-of-life. checkpoint: - type: object - nullable: true + type: [object, "null"] description: Always `null` in format v1 — the signed-checkpoint layer is not yet built. calcReceipts: type: array description: Always empty in format v1 — `dpp-calc` invocation is not yet wired end to end. items: type: object + componentGraph: + # `anyOf` rather than `allOf` + `nullable`: this is OpenAPI 3.1, where + # `nullable` no longer exists and a nullable `$ref` is expressed as a + # union with the null type. + anyOf: + - $ref: "#/components/schemas/TreeReport" + - type: "null" + description: | + The recursive component-tree (bill-of-materials) verification report, + present iff the passport declares `componentRefs`. `null` for a unit + with no modelled sub-assemblies. + + Generated at dossier-assembly time by walking the tree and pin-checking + each node, then bound into `contentHashes` like every other member — so + a tampered report fails the dossier's `content_integrity` check rather + than passing as an unverifiable attachment. + + Integrity only, the same caveat as the standalone `verify-tree` route: + it proves each node's signed public view is unchanged against its + pinned hash, not the cryptographic validity of that node's signature. + qualifiedSeal: + type: [object, "null"] + description: | + The passport's eIDAS qualified seal, present iff one has been + applied. Carries the seal envelope plus `signedOverJws` and + `payloadHash`, so a verifier holding only this dossier has both the + CAdES and the preimage to check it against. + + Included because a dossier is what an authority is handed and the + seal is its one member carrying an Art. 35(2) presumption — and + because it is unreachable otherwise: the seal is stripped from + `fullView` and `publicView` alike, since it covers the full-payload + signature rather than any redaction. Bound into `contentHashes` like + every other member. `null` when the seal is still queued. + properties: + seal: + type: object + description: The `SealedEnvelope` as persisted on the passport. + signedOverJws: + type: string + payloadHash: + type: string + pattern: "^[0-9a-f]{64}$" EvidenceDossierRecord: type: object @@ -571,8 +605,7 @@ components: type: string example: "Odal Node GmbH" tradeName: - type: string - nullable: true + type: [string, "null"] address: type: string example: "Johannes Strauss 12" @@ -586,29 +619,23 @@ components: format: email example: "contact@odal-node.io" didWebUrl: - type: string + type: [string, "null"] format: uri - nullable: true productCategories: - type: array + type: [array, "null"] items: type: string - nullable: true brandPrimary: - type: string - nullable: true + type: [string, "null"] description: "Primary brand colour (hex)" example: "#2E7D32" brandSecondary: - type: string - nullable: true + type: [string, "null"] brandLogoUrl: - type: string + type: [string, "null"] format: uri - nullable: true customDomain: - type: string - nullable: true + type: [string, "null"] dataResidency: type: string default: "EU" @@ -616,16 +643,13 @@ components: type: integer default: 3650 featureFlags: - type: object - nullable: true + type: [object, "null"] createdAt: - type: string + type: [string, "null"] format: date-time - nullable: true updatedAt: - type: string + type: [string, "null"] format: date-time - nullable: true UpdateOperatorConfig: type: object @@ -682,13 +706,11 @@ components: type: string format: date-time lastUsedAt: - type: string + type: [string, "null"] format: date-time - nullable: true expiresAt: - type: string + type: [string, "null"] format: date-time - nullable: true NewApiKey: type: object @@ -712,9 +734,8 @@ components: description: Human-readable label for this key. example: "CI pipeline" expiresAt: - type: string + type: [string, "null"] format: date-time - nullable: true description: Optional expiration. Null = never expires. Facility: @@ -741,8 +762,7 @@ components: maxLength: 2 example: "DE" address: - type: string - nullable: true + type: [string, "null"] isDefault: type: boolean description: The default facility is stamped onto new passports. @@ -769,8 +789,7 @@ components: maxLength: 2 example: "DE" address: - type: string - nullable: true + type: [string, "null"] isDefault: type: boolean default: false @@ -792,8 +811,7 @@ components: type: string example: "5493001KJTIIGC8Y1R12" label: - type: string - nullable: true + type: [string, "null"] isPrimary: type: boolean description: The primary identifier is stamped onto new passports. @@ -817,8 +835,7 @@ components: accepted without structural verification. example: "5493001KJTIIGC8Y1R12" label: - type: string - nullable: true + type: [string, "null"] isPrimary: type: boolean default: false @@ -984,12 +1001,10 @@ components: total: type: integer result: - type: object - nullable: true + type: [object, "null"] description: Populated on completion (created/errors) or failure (reason). report: - type: object - nullable: true + type: [object, "null"] description: The row-addressed findings report — populated for every job, dry-run or apply, independent of `result`. ApiError: @@ -1003,6 +1018,312 @@ components: type: string example: "productName is required" + # ---- Service info ------------------------------------------------------- + + VaultInfo: + type: object + required: [version, coreVersion, authMethods, features] + description: Vault build/version metadata, for dashboard feature detection. + properties: + version: + type: string + description: This node's own dpp-vault crate version. + example: "0.11.0" + coreVersion: + type: string + description: The dpp-domain (dpp-core) version this build was compiled against. + example: "0.16.0" + authMethods: + type: array + items: + type: string + description: >- + Auth schemes the vault accepts. Currently a fixed list, not + derived from live config — `local` is listed even when + `ADMIN_USERNAME`/`ADMIN_PASSWORD` are unset. + example: ["api_key", "local"] + features: + type: array + items: + type: string + example: ["passthrough_compliance"] + + # ---- End-of-life ----------------------------------------------------- + + DerogationRef: + type: object + required: [category] + description: >- + A recognised derogation from the ESPR Art. 25 destruction ban. The + category list is fixed by the applicable delegated act; validated + against that list at the engine boundary, not by this schema. + properties: + category: + type: string + description: The derogation category as named by the delegated act. + example: "health-and-safety" + actCitation: + type: [string, "null"] + description: The act/article this derogation is grounded in (e.g. an OJ/CELEX ref). + + DeactivationReason: + description: >- + Why a passport reached end-of-life, internally tagged by `kind`. + Destruction alone requires a `derogation` citing the lawful basis. + oneOf: + - type: object + required: [kind] + properties: + kind: { type: string, enum: [recycled] } + - type: object + required: [kind, derogation] + properties: + kind: { type: string, enum: [destroyed] } + derogation: + $ref: "#/components/schemas/DerogationRef" + - type: object + required: [kind] + properties: + kind: { type: string, enum: [exported] } + - type: object + required: [kind] + properties: + kind: { type: string, enum: [lost] } + example: { kind: "recycled" } + + EolRequest: + type: object + required: [reason] + description: Request body for declaring a passport end-of-life. + properties: + reason: + $ref: "#/components/schemas/DeactivationReason" + declaredBy: + type: [string, "null"] + description: DID of the declaring operator; defaults to the authenticated actor. + materialRecovery: + type: [object, "null"] + description: Optional recovered-material summary (Battery Annex XIII circularity). + notes: + type: [string, "null"] + + # ---- Transfer of responsibility --------------------------------------- + + OperatorRole: + type: string + description: The role of an economic operator in the DPP supply chain. + enum: + - manufacturer + - importer + - distributor + - authorisedRepresentative + - remanufacturer + - repurposer + - preparerForReuse + - repairer + - recycler + + ResponsibleOperator: + type: object + required: [did, name, role, country] + description: An economic operator responsible for a DPP (ESPR "responsible economic operator"). + properties: + did: + type: string + example: "did:web:acme.example.com" + name: + type: string + role: + $ref: "#/components/schemas/OperatorRole" + euOperatorId: + type: [string, "null"] + description: EU-assigned economic operator identifier, if available. + euOperatorIdScheme: + type: [string, "null"] + description: 'Scheme euOperatorId is expressed in — "vat", "lei", "eori", "duns".' + country: + type: string + minLength: 2 + maxLength: 2 + description: ISO 3166-1 alpha-2 country code of the operator's establishment. + + TransferReason: + type: string + description: The reason for a transfer of DPP responsibility. + enum: + - sale + - return + - remanufacturing + - repurposing + - preparationForReuse + - import + - insolvencySuccession + + TransferInitiateRequest: + type: object + required: [fromOperator, toOperator, reason] + properties: + fromOperator: + allOf: + - $ref: "#/components/schemas/ResponsibleOperator" + description: The current (outgoing) responsible operator — must match the chain head. + toOperator: + allOf: + - $ref: "#/components/schemas/ResponsibleOperator" + description: The incoming responsible operator taking over the DPP. + reason: + $ref: "#/components/schemas/TransferReason" + notes: + type: [string, "null"] + + TransferRecord: + type: object + required: [transferId, passportId, fromOperator, toOperator, reason, initiatedAt] + description: A single transfer-of-responsibility event, dual-signed by the outgoing and incoming operators. + properties: + transferId: + type: string + format: uuid + passportId: + type: string + format: uuid + fromOperator: + $ref: "#/components/schemas/ResponsibleOperator" + toOperator: + $ref: "#/components/schemas/ResponsibleOperator" + reason: + $ref: "#/components/schemas/TransferReason" + fromSignature: + type: [string, "null"] + description: Compact JWS from the outgoing operator, authorising the handover. + toSignature: + type: [string, "null"] + description: Compact JWS from the incoming operator, accepting responsibility. + initiatedAt: + type: string + format: date-time + completedAt: + type: [string, "null"] + format: date-time + rejectedAt: + type: [string, "null"] + format: date-time + cancelledAt: + type: [string, "null"] + format: date-time + notes: + type: [string, "null"] + + # ---- Registry-identity audit ------------------------------------------- + + RegistryIdentityAudit: + type: object + required: [id, operatorId, entityType, entityId, action, actor, ts] + description: >- + An immutable audit record for a registry-identity mutation (a + facility per Annex III or an operator identifier per Art. 13). + Append-only. + properties: + id: + type: string + format: uuid + operatorId: + type: string + entityType: + type: string + enum: [facility, operator_identifier] + entityId: + type: string + format: uuid + action: + type: string + enum: [added, retired, set_default, set_primary] + actor: + type: string + description: user_id of the actor who performed the change. + snapshot: + type: [object, "null"] + description: The full record at the time of the action, for reconstruction. + ts: + type: string + format: date-time + + # ---- Scan telemetry (internal) ----------------------------------------- + + ScanVariant: + type: string + enum: [html, json] + + ScanCount: + type: object + required: [dppId, day, variant, count] + description: One aggregated scan increment since the resolver's last flush. + properties: + dppId: + type: string + description: The resolved passport id, as an opaque string (validated at ingest). + day: + type: string + format: date + variant: + $ref: "#/components/schemas/ScanVariant" + count: + type: integer + minimum: 0 + + QrRenderCount: + type: object + required: [dppId, day, count] + description: One aggregated QR-render increment since the resolver's last flush. + properties: + dppId: + type: string + day: + type: string + format: date + count: + type: integer + minimum: 0 + + ScanBatch: + type: object + required: [scans, qrRenders] + description: The full flush payload the resolver sends to the vault. + properties: + scans: + type: array + items: + $ref: "#/components/schemas/ScanCount" + qrRenders: + type: array + items: + $ref: "#/components/schemas/QrRenderCount" + + # ---- Internal identity verification ------------------------------------ + + VerifyRequest: + type: object + required: [operator_id, jws, payload] + description: "Internal verification request. Field names are snake_case (internal contract)." + properties: + operator_id: + type: string + description: Operator id whose key the signature is checked against. + example: "self_hosted" + jws: + type: string + description: The compact JWS to verify. + payload: + description: The payload the caller expects the JWS to have been signed over. + + VerifyResponse: + type: object + required: [valid] + properties: + valid: + type: boolean + description: True iff the signature verifies against the named operator's key AND was signed over exactly this payload. + responses: Unauthorized: description: Missing or invalid authentication credentials. @@ -1024,6 +1345,23 @@ components: error: "NOT_FOUND" message: "DPP not found." + NotAcceptable: + description: | + No representation matches the request's `Accept` header. The response + body names the media types this resource can produce. + + A passport carrying no GTIN — an unsold-goods report, or an untyped + sector — also gets this for `application/aas+json`: it identifies no + trade item, so it has no AAS asset identity and therefore no AAS + representation. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ApiError" + example: + error: "NOT_ACCEPTABLE" + message: "No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json." + ValidationError: description: One or more fields failed validation. content: @@ -1116,8 +1454,8 @@ paths: Paginated list of DPPs for the authenticated operator. Supports filtering by status, free-text search across `productName`, `batchId`, and `manufacturer.name`, and an exact - `facilityId` match (ESPR Annex III; ADR-006 — a grouping filter, - never an isolation boundary). + `facilityId` match (ESPR Annex III). A grouping filter, never an + isolation boundary. tags: [DPP Management] security: - BearerApiKey: [] @@ -1404,16 +1742,17 @@ paths: "409": $ref: "#/components/responses/Conflict" - # ---- Audit History (odal-vault) ------------------------------------------ - - /vault/api/v1/dpp/{dppId}/history: - get: - operationId: getDppHistory - summary: Get DPP audit history + /vault/api/v1/dpp/{dppId}/eol: + post: + operationId: declareDppEol + summary: Declare a DPP end-of-life description: | - Returns the chronological audit trail for a passport: creation, - status transitions, field updates, etc. - tags: [DPP Management] + Transition a `published` or `suspended` DPP to `deactivated` + (terminal). The record is retained, never deleted — the passport + outlives the product. Destruction (`reason.kind: destroyed`) is only + lawful with a recognised derogation from the unsold-goods destruction + ban (ESPR Art. 25 delegated act). + tags: [DPP Lifecycle] security: - BearerApiKey: [] - BasicAuth: [] @@ -1423,33 +1762,36 @@ paths: required: true schema: $ref: "#/components/schemas/DppId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EolRequest" responses: "200": - description: List of audit entries. + description: DPP deactivated. Returns the full passport record. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/AuditEntry" + $ref: "#/components/schemas/PassportResponse" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" - /vault/api/v1/dpp/{dppId}/verify-tree: - get: - operationId: verifyDppTree - summary: Recursively verify a passport's component tree (BOM) + /vault/api/v1/dpp/{dppId}/transfer/initiate: + post: + operationId: initiateDppTransfer + summary: Initiate a transfer of responsibility description: | - Walks the passport's `componentRefs` breadth-first, fetching each node - and checking its public JWS against the pinned hash. Fails closed on - every ambiguity, bounded by a depth cap and a total-node cap; the report - names the path from the root to any broken node. - - Integrity only: this proves each node's signed public view is unchanged - (hash pin), not the cryptographic validity of the signature. - tags: [DPP Management] + The outgoing operator signs a pending handover onto the passport's + transfer chain. Only a `published` DPP can be transferred. In the + managed single-node model the caller supplies both the outgoing and + incoming operator; the node signs on the outgoing operator's behalf. + tags: [DPP Lifecycle] security: - BearerApiKey: [] - BasicAuth: [] @@ -1459,34 +1801,38 @@ paths: required: true schema: $ref: "#/components/schemas/DppId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TransferInitiateRequest" responses: "200": - description: The component-tree verification report. + description: Transfer initiated (pending acceptance). content: application/json: schema: - $ref: "#/components/schemas/TreeReport" + $ref: "#/components/schemas/TransferRecord" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/ValidationError" - /vault/api/v1/dpp/{dppId}/lint: + /vault/api/v1/dpp/{dppId}/transfer/accept: post: - operationId: relintDpp - summary: Re-check plausibility-lint findings + operationId: acceptDppTransfer + summary: Accept a pending transfer of responsibility description: | - Recomputes the `dpp-rules` plausibility lint pack against the DPP's - current sector data and persists the refreshed `lintResult` (pack - version, findings, assessed-at timestamp). Findings are non-binding — - arithmetic and physical-plausibility checks distinct from binding - compliance rules — and never gate publish or any other transition. - - Works regardless of DPP status, including `active` (published): - re-checking does not retroactively affect the passport's JWS - signature, which is frozen over whatever `lintResult` looked like at - publish time. No request body is required. - tags: [DPP Management] + The incoming operator's signature completes a pending handover: the + outgoing operator's signature is verified before the node countersigns + on the incoming operator's behalf, and the incoming operator becomes + the passport's current responsible operator. + tags: [DPP Lifecycle] security: - BearerApiKey: [] - BasicAuth: [] @@ -1498,20 +1844,419 @@ paths: $ref: "#/components/schemas/DppId" responses: "200": - description: Lint findings refreshed. Returns the full passport record. + description: Transfer completed. content: application/json: schema: - $ref: "#/components/schemas/PassportResponse" + $ref: "#/components/schemas/TransferRecord" "401": $ref: "#/components/responses/Unauthorized" "404": - $ref: "#/components/responses/NotFound" + description: No pending transfer to accept for this DPP. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "422": + $ref: "#/components/responses/ValidationError" - /vault/api/v1/dpp/{dppId}/evidence: - post: - operationId: generateDppEvidence - summary: Generate and store a signed evidence dossier + # ---- Audit History (odal-vault) ------------------------------------------ + + /vault/api/v1/dpp/{dppId}/history: + get: + operationId: getDppHistory + summary: Get DPP audit history + description: | + Returns the chronological audit trail for a passport: creation, + status transitions, field updates, etc. Unbounded — returns the full + trail with no pagination or limit. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + responses: + "200": + description: List of audit entries. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AuditEntry" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/dpp/{dppId}/stats: + get: + operationId: getDppScanStats + summary: Per-passport scan telemetry + description: | + Aggregate, privacy-safe resolution counts for one passport over a + trailing window. Scans and QR-image renders are reported as separate + fields and are never summed — a render is label production, not a + resolution. Nothing about the scanner (IP, agent, session) is collected + or returned; the counters carry no such fields. Returns zeros for a + passport that has never been scanned. + tags: [Scan Telemetry] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + - name: days + in: query + required: false + description: Trailing window in days (default 30, clamped to 1..=730). + schema: + type: integer + minimum: 1 + maximum: 730 + default: 30 + responses: + "200": + description: Aggregate scan counts for the passport. + content: + application/json: + schema: + type: object + properties: + windowDays: { type: integer, example: 30 } + totalScans: { type: integer, example: 128 } + scansHtml: { type: integer, example: 96 } + scansJson: { type: integer, example: 32 } + qrRenders: { type: integer, example: 4 } + daily: + type: array + items: + type: object + properties: + day: { type: string, format: date } + count: { type: integer } + "401": + $ref: "#/components/responses/Unauthorized" + + /vault/api/v1/stats: + get: + operationId: getOperatorScanStats + summary: Operator-wide scan telemetry rollup + description: | + Aggregate resolution counts across all of the operator's passports over + a trailing window — the "your passports were resolved N times" figure. + Scans and QR-image renders are separate; nothing about the scanner is + collected. + tags: [Scan Telemetry] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: days + in: query + required: false + description: Trailing window in days (default 30, clamped to 1..=730). + schema: + type: integer + minimum: 1 + maximum: 730 + default: 30 + responses: + "200": + description: Operator-wide aggregate scan counts. + content: + application/json: + schema: + type: object + properties: + windowDays: { type: integer, example: 30 } + totalScans: { type: integer, example: 4213 } + totalQrRenders: { type: integer, example: 57 } + distinctPassportsScanned: { type: integer, example: 312 } + "401": + $ref: "#/components/responses/Unauthorized" + + /vault/api/v1/dpp/{dppId}/verify-tree: + get: + operationId: verifyDppTree + summary: Recursively verify a passport's component tree (BOM) + description: | + Walks the passport's `componentRefs` breadth-first, fetching each node + and checking its public JWS against the pinned hash. Fails closed on + every ambiguity, bounded by a depth cap and a total-node cap; the report + names the path from the root to any broken node. + + Integrity only: this proves each node's signed public view is unchanged + (hash pin), not the cryptographic validity of the signature. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + responses: + "200": + description: The component-tree verification report. + content: + application/json: + schema: + $ref: "#/components/schemas/TreeReport" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/seal: + get: + operationId: getSealSummary + summary: Operator-wide sealing state + description: | + How many published passports carry no seal, plus the outbox totals + behind that number. + + `unsealedPublished` is the headline; the three row counts are context. + They answer different questions and can legitimately disagree: the + counts describe outbox **rows**, while the obligation is about + **passports**. Enqueueing happens after the publish commits, so a crash + in that window publishes a passport that no row will ever cover — + `pending: 0, exhausted: 0` is therefore consistent with any number of + unsealed passports, and a summary built on rows alone would report all + clear. A repair sweep queues those passports on its next pass. + + A passport whose seal covers a *superseded* signature is not counted + here — it carries a seal, and that seal remains a valid attestation of + the signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal` + reports that case per passport as `coverage`. + + When `sealingConfigured` is `false` no seal provider is selected, so + every count is `0` because this node has no outbox — not because it has + nothing outstanding. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + "200": + description: Operator-wide sealing state. + content: + application/json: + schema: + type: object + required: + [ + unsealedPublished, + pending, + sealed, + exhausted, + sealingConfigured, + ] + properties: + unsealedPublished: + type: integer + format: int64 + description: >- + Published passports carrying no seal at all. `0` is the + healthy state. + example: 0 + pending: + type: integer + format: int64 + description: Outbox rows awaiting a sealing attempt. + sealed: + type: integer + format: int64 + description: Outbox rows whose seal is on the passport. + exhausted: + type: integer + format: int64 + description: Outbox rows that gave up after exhausting retries. + sealingConfigured: + type: boolean + description: >- + False when no seal provider is configured, in which case + every count above is `0` for that reason alone. + "401": + $ref: "#/components/responses/Unauthorized" + + /vault/api/v1/dpp/{dppId}/seal: + get: + operationId: getDppSeal + summary: Fetch the passport's eIDAS qualified electronic seal + description: | + Returns the qualified seal a QTSP applied to this passport, together with + the compact JWS it was taken over and that JWS's SHA-256 digest. + + The seal has its own route because it is stripped from every audience + view, public included: it covers the **full**-payload `jwsSignature`, so + attaching it to a redacted body would hand the reader a proof that + verifies against nothing they received. + + **This node does not validate the seal.** A detached CAdES must be + checked by an independent AdES validator against the EU Trusted List. A + verdict from the node that bought the seal would attest nothing, so none + is offered. + + `coverage` answers a narrower question that the node *can* answer, from + its own records: `sealedPayloadHash` is the digest it asked the backend + to seal, so a passport re-published after sealing shows as `superseded` + without any AdES tooling. That is a record of what was requested, not + proof of what the CAdES covers — the validator's extracted message + digest is the cross-check. A `superseded` seal remains valid for the + signature it does cover; a seal over the new signature has not landed + yet. + + `404` when the passport has no seal — it may be unpublished, its seal may + still be queued, or the node may have no QTSP configured. An unsealed + passport has no seal resource rather than an empty one. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + responses: + "200": + description: The qualified seal and the signature it attests to. + content: + application/json: + schema: + type: object + required: + [ + format, + sealValue, + sealedAt, + signingCertRef, + placeholder, + currentJws, + currentPayloadHash, + sealedPayloadHash, + coverage, + verification, + ] + properties: + format: + type: string + description: AdES format of `sealValue`. + example: CADES + sealValue: + type: string + description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. + sealedAt: + type: string + format: date-time + signingCertRef: + type: [string, "null"] + description: | + Hex SHA-256 of the certificate the seal names as its + signer, **as reported by the seal** — read out of the CAdES + structure, never verified. + + It answers *which* certificate to ask about, not whether + that certificate was qualified or on the EU Trusted List + when the seal was made; both are the independent + validator's question. `null` when the seal predates + extraction or could not be parsed. + pattern: "^[0-9a-f]{64}$" + placeholder: + type: boolean + description: | + `true` when this is a development placeholder with no legal + validity. A production node refuses to boot in that state. + currentJws: + type: string + description: The passport's current compact JWS. + currentPayloadHash: + type: string + description: | + Hex SHA-256 of `currentJws` — the digest a seal over this + passport's present signature would be taken over. + pattern: "^[0-9a-f]{64}$" + sealedPayloadHash: + type: [string, "null"] + description: | + Hex SHA-256 this node asked the backend to seal, from the + outbox row that bought `sealValue`. `null` when the node + holds no such row — a seal restored from a backup or + produced elsewhere. + pattern: "^[0-9a-f]{64}$" + coverage: + type: string + enum: [current, superseded, unknown] + description: | + Whether the stored seal covers the passport's current + signature, per this node's own records. + + `current` — the requested digest is the passport's current + one. `superseded` — the passport was re-published after + this seal was bought. `unknown` — no record; only the + external validator can answer. + verification: + type: string + description: What was and was not checked by this node. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/dpp/{dppId}/lint: + post: + operationId: relintDpp + summary: Re-check plausibility-lint findings + description: | + Recomputes the `dpp-rules` plausibility lint pack against the DPP's + current sector data and persists the refreshed `lintResult` (pack + version, findings, assessed-at timestamp). Findings are non-binding — + arithmetic and physical-plausibility checks distinct from binding + compliance rules — and never gate publish or any other transition. + + Works regardless of DPP status, including `active` (published): + re-checking does not retroactively affect the passport's JWS + signature, which is frozen over whatever `lintResult` looked like at + publish time. No request body is required. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + responses: + "200": + description: Lint findings refreshed. Returns the full passport record. + content: + application/json: + schema: + $ref: "#/components/schemas/PassportResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/dpp/{dppId}/evidence: + post: + operationId: generateDppEvidence + summary: Generate and store a signed evidence dossier description: | Assembles a self-contained, signed dossier proving a passport's full proof chain — both JWS signatures, DID document snapshots, the @@ -1578,6 +2323,10 @@ paths: get: operationId: getEvidenceDossier summary: Fetch one stored dossier's document + description: >- + Returns the dossier document itself — the same shape + `POST .../evidence` returns on generation, not the summary wrapper + the list endpoint shows. tags: [Evidence Dossiers] security: - BearerApiKey: [] @@ -1794,6 +2543,76 @@ paths: "404": $ref: "#/components/responses/NotFound" + # ---- Plugins (odal-vault) ------------------------------------------------ + + /vault/api/v1/plugins: + post: + operationId: installPlugin + summary: Install a signed sector plugin + description: | + Verify, persist, and hot-swap a signed sector plugin — no node restart. + + The node verifies the uploaded artifact's detached signature against its + pinned publisher key, gates the plugin's declared ABI, instantiate-smokes + the module, persists it (so a restart re-loads it), and atomically swaps + it into service. Any rejection is fail-closed — the previously installed + plugin keeps serving. Admin-scoped. + + Both a portable `.wasm` (compiled on the node) and a precompiled `.cwasm` + (loaded only if it matches this node's engine) are accepted. + tags: [Plugins] + security: + - BearerApiKey: [] + - BasicAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [wasm, sig] + properties: + wasm: + type: string + format: binary + description: > + The `.wasm` or precompiled `.cwasm` plugin artifact. Its + filename determines the sector when `sector` is omitted + (`sector-.wasm`) and whether it is treated as + precompiled (`.cwasm`). + sig: + type: string + format: binary + description: Detached Ed25519 signature over SHA-256 of the artifact bytes. + sector: + type: string + description: Sector key; derived from the filename if omitted. + example: battery + responses: + "201": + description: Plugin verified, persisted, and now serving. + content: + application/json: + schema: + type: object + properties: + sector: + type: string + example: battery + abiVersion: + type: string + example: "1.1" + "400": + description: Malformed multipart body (missing `wasm`/`sig`, or the sector could not be determined). + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: A non-admin credential attempted to install a plugin. + "422": + description: The artifact was rejected — bad signature, incompatible ABI, or a non-instantiable/incompatible module. + "501": + description: This node has no plugin host configured; runtime install is unavailable. + # ---- Webhooks (odal-vault) ----------------------------------------------- /vault/api/v1/webhooks: @@ -1823,7 +2642,7 @@ paths: type: array items: { type: string } active: { type: boolean } - description: { type: string, nullable: true } + description: { type: [string, "null"] } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } "401": @@ -1874,7 +2693,7 @@ paths: type: array items: { type: string } active: { type: boolean } - description: { type: string, nullable: true } + description: { type: [string, "null"] } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } secret: @@ -1967,7 +2786,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" - # ---- Registry Identity: Facilities (odal-vault) -------------------------- + # ---- Facilities (odal-vault) ---------------------------------------------- /vault/api/v1/facilities: get: @@ -1976,7 +2795,7 @@ paths: description: | Lists the operator's facilities (ESPR Annex III). The `isDefault` facility is stamped onto new passports. Requires an admin-scoped key. - tags: [Registry Identity] + tags: [Facilities] security: - BearerApiKey: [] - BasicAuth: [] @@ -1999,7 +2818,7 @@ paths: description: | Add a facility. The identifier is validated by scheme — a `gln` must pass the GS1 mod-10 check digit. Requires an admin-scoped key. - tags: [Registry Identity] + tags: [Facilities] security: - BearerApiKey: [] - BasicAuth: [] @@ -2027,7 +2846,11 @@ paths: delete: operationId: removeFacility summary: Remove a facility - tags: [Registry Identity] + description: >- + Retires the facility (soft-delete): the row is kept as Annex III + provenance for passports that already stamped its identifier — never + hard-deleted. Requires an admin-scoped key. + tags: [Facilities] security: - BearerApiKey: [] - BasicAuth: [] @@ -2048,12 +2871,46 @@ paths: "404": $ref: "#/components/responses/NotFound" + /vault/api/v1/facilities/{id}/audit: + get: + operationId: getFacilityAudit + summary: Facility audit trail + description: | + Append-only mutation history for one facility (added, retired, + set-default), oldest first. Requires an admin-scoped key. + tags: [Facilities] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: List of audit entries for this facility. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RegistryIdentityAudit" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /vault/api/v1/facilities/{id}/default: post: operationId: setDefaultFacility summary: Set the default facility - description: Makes this facility the sole default, stamped onto new passports. - tags: [Registry Identity] + description: >- + Makes this facility the sole default, stamped onto new passports. + Requires an admin-scoped key. + tags: [Facilities] security: - BearerApiKey: [] - BasicAuth: [] @@ -2074,7 +2931,7 @@ paths: "404": $ref: "#/components/responses/NotFound" - # ---- Registry Identity: Operator Identifiers (odal-vault) ---------------- + # ---- Operator Identifiers (odal-vault) ------------------------------------- /vault/api/v1/operator-identifiers: get: @@ -2083,7 +2940,7 @@ paths: description: | Lists the operator's economic-operator identifiers (ESPR Art. 13). The `isPrimary` identifier is stamped onto new passports. Admin scope required. - tags: [Registry Identity] + tags: [Operator Identifiers] security: - BearerApiKey: [] - BasicAuth: [] @@ -2107,7 +2964,7 @@ paths: Add an economic-operator identifier. Validated by scheme — LEI uses ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a country prefix. Requires an admin-scoped key. - tags: [Registry Identity] + tags: [Operator Identifiers] security: - BearerApiKey: [] - BasicAuth: [] @@ -2135,7 +2992,11 @@ paths: delete: operationId: removeOperatorIdentifier summary: Remove an operator identifier - tags: [Registry Identity] + description: >- + Retires the identifier (soft-delete): the row is kept as Art. 13 + provenance for passports that already stamped its value — never + hard-deleted. Requires an admin-scoped key. + tags: [Operator Identifiers] security: - BearerApiKey: [] - BasicAuth: [] @@ -2156,12 +3017,46 @@ paths: "404": $ref: "#/components/responses/NotFound" + /vault/api/v1/operator-identifiers/{id}/audit: + get: + operationId: getOperatorIdentifierAudit + summary: Operator-identifier audit trail + description: | + Append-only mutation history for one operator identifier (added, + retired, set-primary), oldest first. Requires an admin-scoped key. + tags: [Operator Identifiers] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: List of audit entries for this operator identifier. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RegistryIdentityAudit" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /vault/api/v1/operator-identifiers/{id}/primary: post: operationId: setPrimaryOperatorIdentifier summary: Set the primary operator identifier - description: Makes this identifier the sole primary, stamped onto new passports. - tags: [Registry Identity] + description: >- + Makes this identifier the sole primary, stamped onto new passports. + Requires an admin-scoped key. + tags: [Operator Identifiers] security: - BearerApiKey: [] - BasicAuth: [] @@ -2282,6 +3177,92 @@ paths: "404": $ref: "#/components/responses/NotFound" + # ---- Credentialed Access (odal-vault) -------------------------------------- + # Deliberately outside both `/public` (a public URL whose body varies by + # caller breaks caching and the meaning of `publicJwsSignature`) and + # `/api/v1` (API keys are the operator's own machine access; a repairer or + # authority holds a credential and no key). + + /vault/credential/dpp/{dppId}: + get: + operationId: readDppByCredential + summary: Audience-scoped read of a published DPP + description: | + Reads a published passport filtered to the caller's audience. No + `X-DPP-Credential` header returns the same signed public view as + `/public/dpp/{dppId}`. A verified credential returns the passport + filtered to that audience's disclosure classes (ESPR Art. 77(2)), + carrying the proof computed over that view. Credentialed reads are + recorded to the passport's audit trail; anonymous reads are not. + + Returns the public view (not an error) when credential verification + is not configured on this node. + tags: [Credentialed Access] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + - name: X-DPP-Credential + in: header + required: false + description: A verifiable access credential. Absent means public access. + schema: + type: string + responses: + "200": + description: Passport filtered to the resolved audience. + content: + application/json: + schema: + $ref: "#/components/schemas/PassportResponse" + "401": + description: The presented credential failed verification. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "404": + description: Not found or not published. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "410": + description: This passport has been suspended. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + + # ---- Internal (mTLS service-to-service, odal-vault) ------------------------ + + /vault/internal/scan-batch: + post: + operationId: ingestScanBatch + summary: Flush a scan-telemetry batch (internal, mTLS) + description: | + The mTLS-gated sink the public resolver flushes its in-memory + aggregate scan/QR-render counters to (`CN=odal-resolver` only). The + resolver holds no operator API key and no database of its own. + tags: [Vault (internal)] + security: + - MutualTLS: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ScanBatch" + responses: + "204": + description: Batch ingested. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /vault/health: get: operationId: vaultHealth @@ -2309,6 +3290,10 @@ paths: responses: "200": description: Service info. + content: + application/json: + schema: + $ref: "#/components/schemas/VaultInfo" # ---- Identity — public (odal-identity) ----------------------------------- # On the fused node these are served under /identity/*. The standalone @@ -2384,6 +3369,40 @@ paths: "422": $ref: "#/components/responses/ValidationError" + /internal/verify: + servers: + - url: http://localhost:8002 + description: "odal-identity standalone (mTLS internal)" + post: + operationId: internalVerify + summary: Verify a JWS this service issued (internal, mTLS) + description: | + Checks a compact JWS against the named operator's key *and* confirms + it was signed over the given payload — a validly-signed JWS for + different content does not pass. Never errors on a signature that + simply fails to verify; that is `{ "valid": false }`, not a fault. + Service-to-service only — gated by mTLS (`CN=odal-vault`). + tags: [Identity (internal)] + security: + - MutualTLS: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyRequest" + responses: + "200": + description: >- + Verification result. Always `200` — an unverifiable signature is + `{ "valid": false }`, not an error status. + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyResponse" + "422": + $ref: "#/components/responses/ValidationError" + /internal/keys/rotate: servers: - url: http://localhost:8002 @@ -2561,10 +3580,23 @@ paths: Resolve a published Digital Product Passport by ID. This endpoint is the target of QR code scans. No authentication required. + Every representation is built from the **signed** public payload, not + the live database row, so the body and the proof it carries agree by + construction. + Content negotiation via `Accept` header: - - `application/json` (default): returns JSON passport data - - `text/html`: returns the consumer-facing HTML passport page - with operator branding (logo, colours) + - `application/json` / `application/ld+json` (default): JSON-LD + passport data + - `text/html`: the consumer-facing HTML passport page with operator + branding (logo, colours) + - `application/aas+json`: an IDTA Asset Administration Shell + Environment (see below) + + An absent, empty, `*/*`, `application/*`, `application/json` or + `application/ld+json` header all reach the JSON-LD default. Only a + header naming something this route cannot produce gets `406`. + + Responses carry `Vary: Accept`. tags: [Public Resolver] parameters: - name: dppId @@ -2583,8 +3615,65 @@ paths: schema: type: string description: Consumer-facing HTML passport page. + application/aas+json: + schema: + type: object + description: | + An IDTA Asset Administration Shell `Environment` — shells + and submodels in one self-contained document. + + `conceptDescriptions` is **absent**, not empty. This node + coins no concept descriptions, and the metamodel constrains + that member to `minItems: 1`, so an empty array would make + the whole document invalid. + + **Public tier only.** The passport is filtered through the + disclosure seam before any AAS mapper sees it, so this door + never carries a field the JSON-LD door would withhold. + Restricted and conformity-tier data require a credentialed + channel and a different projection. + + **Schema-valid, not conformance-certified.** Every + Environment is validated in `dpp-core`'s CI against IDTA's + published AAS JSON Schemas for metamodel **3.0, 3.1 and + 3.2**, and must satisfy all three — no single revision is + the strictest, so the intersection is the only target that + means "loadable whichever revision your toolchain + implements". + + That establishes metamodel validity only: it is not a claim + of IDTA conformance, and it asserts nothing about whether a + submodel matches a published submodel template. Note also + that no AAS JSON Schema sets `additionalProperties`, so + schema validity alone cannot rule out a member the metamodel + does not define; `dpp-core` gates that separately. + + **Unsigned, and it says so in a header.** This is a derived + representation of the signed canonical public view, which is + what `application/ld+json` returns for this same URL. The + public proof covers that payload, not this serialisation of + it, so attaching the signature here would hand a verifier a + proof that fails against the bytes it arrived with. + + Every `200` therefore carries: + + ``` + Link: <{resolverBase}/dpp/{dppId}>; rel="alternate"; type="application/ld+json" + ``` + + `alternate` rather than `canonical`: the two representations + share one URL and are separated only by `Accept`, so a + `canonical` relation would point this resource at itself. + Follow the link with that `Accept` to obtain the signed + payload and its proof. + + `resolverBase` is per-deployment (`RESOLVER_BASE_URL`, + default `https://id.odal-node.io`). Error responses carry no + `Link` — an error is not a representation of the passport. "404": $ref: "#/components/responses/NotFound" + "406": + $ref: "#/components/responses/NotAcceptable" /dpp/{dppId}/qr: get: @@ -2650,6 +3739,137 @@ paths: "404": description: No published DPP for this GTIN, or unknown link type. + /01/{gtin}/21/{serial}: + get: + operationId: resolveByGtinSerial + summary: GS1 Digital Link resolver — GTIN + serial + description: | + A printed carrier may encode more than the GTIN — this node's own + publisher emits `/01/{gtin}/21/{serial}` for a serialised trade item. + **Resolution is keyed on the GTIN alone**: `serial` is accepted so no + conformant carrier 404s for carrying more precision than the resolver + indexes, but it is not looked up. Same behaviour as `/01/{gtin}` + otherwise (`linkType` / `Accept` negotiation). + tags: [Public Resolver] + parameters: + - name: gtin + in: path + required: true + schema: + type: string + example: "09506000134352" + - name: serial + in: path + required: true + schema: + type: string + description: Accepted and ignored — not a resolution key. + - name: linkType + in: query + required: false + schema: + type: string + description: "e.g. linkset, gs1:pip, gs1:dpp" + responses: + "200": + description: RFC 9264 linkset (when a linkset is requested). + content: + application/linkset+json: + schema: + type: object + "307": + description: Redirect to the DPP page (`Location` header). + "404": + description: No published DPP for this GTIN, or unknown link type. + + /01/{gtin}/10/{batch}: + get: + operationId: resolveByGtinBatch + summary: GS1 Digital Link resolver — GTIN + batch/lot + description: | + Accepts the batch/lot segment (AI 10) a carrier may include. + **Resolution is keyed on the GTIN alone**: `batch` is accepted and + ignored, not looked up. Same behaviour as `/01/{gtin}` otherwise + (`linkType` / `Accept` negotiation). + tags: [Public Resolver] + parameters: + - name: gtin + in: path + required: true + schema: + type: string + example: "09506000134352" + - name: batch + in: path + required: true + schema: + type: string + description: Accepted and ignored — not a resolution key. + - name: linkType + in: query + required: false + schema: + type: string + description: "e.g. linkset, gs1:pip, gs1:dpp" + responses: + "200": + description: RFC 9264 linkset (when a linkset is requested). + content: + application/linkset+json: + schema: + type: object + "307": + description: Redirect to the DPP page (`Location` header). + "404": + description: No published DPP for this GTIN, or unknown link type. + + /01/{gtin}/10/{batch}/21/{serial}: + get: + operationId: resolveByGtinBatchSerial + summary: GS1 Digital Link resolver — GTIN + batch/lot + serial + description: | + The full shape this node's own carrier emits for a batched, + serialised trade item. **Resolution is keyed on the GTIN alone**: + `batch` and `serial` are accepted and ignored. Same behaviour as + `/01/{gtin}` otherwise (`linkType` / `Accept` negotiation). + tags: [Public Resolver] + parameters: + - name: gtin + in: path + required: true + schema: + type: string + example: "09506000134352" + - name: batch + in: path + required: true + schema: + type: string + description: Accepted and ignored — not a resolution key. + - name: serial + in: path + required: true + schema: + type: string + description: Accepted and ignored — not a resolution key. + - name: linkType + in: query + required: false + schema: + type: string + description: "e.g. linkset, gs1:pip, gs1:dpp" + responses: + "200": + description: RFC 9264 linkset (when a linkset is requested). + content: + application/linkset+json: + schema: + type: object + "307": + description: Redirect to the DPP page (`Location` header). + "404": + description: No published DPP for this GTIN, or unknown link type. + /health: get: operationId: resolverHealth @@ -2676,14 +3896,22 @@ tags: description: Create, read, update, list, and audit Digital Product Passports. - name: DPP Lifecycle description: Lifecycle transitions — publish, suspend, archive. + - name: Scan Telemetry + description: Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups. - name: Evidence Dossiers description: Signed, self-contained evidence dossiers — generate, fetch, and verify (stored or uploaded) offline, with zero trust in the issuing node. - name: Operator description: Operator configuration (branding, legal info, retention policy). - name: API Keys description: API key management — create, list, revoke. - - name: Registry Identity - description: Facilities (ESPR Annex III) and operator identifiers (Art. 13) stamped onto new passports. + - name: Plugins + description: Signed sector-plugin hot-install — verify, persist, hot-swap (admin-only). + - name: Webhooks + description: Signed outbound event delivery — subscribe, list, remove, test. + - name: Facilities + description: Manufacturing/processing facilities (ESPR Annex III) stamped onto new passports. + - name: Operator Identifiers + description: Economic-operator identifiers (ESPR Art. 13) stamped onto new passports. - name: Node description: Node setup/readiness state. - name: Identity @@ -2694,6 +3922,12 @@ tags: description: CSV/XLSX bulk import — templates, upload, async job polling. - name: Public (Vault) description: Unauthenticated vault endpoints for inter-service communication. + - name: Credentialed Access + description: >- + Audience-scoped passport reads authenticated by a verifiable credential + rather than an API key — repairers, market-surveillance authorities. + - name: Vault (internal) + description: mTLS service-to-service telemetry ingestion (resolver → vault only). - name: Public Resolver description: Unauthenticated public endpoints for QR scan resolution. - name: Health diff --git a/site/dpp-docs/public/robots.txt b/site/dpp-docs/public/robots.txt new file mode 100644 index 0000000..9c804b6 --- /dev/null +++ b/site/dpp-docs/public/robots.txt @@ -0,0 +1,22 @@ +# robots.txt — docs.odal-node.io + +User-agent: * +Allow: / + +Sitemap: https://docs.odal-node.io/sitemap-index.xml + +# ---- Content signals (EU Directive 2019/790, Art. 4) ---- +# As a condition of accessing this website, you agree to abide by the following +# content signals: +# +# (a) If a content-signal = yes, you may collect content for the corresponding +# use. +# (b) If a content-signal = no, you may not collect content for the +# corresponding use. +# (c) If the website operator does not include a content signal for a +# corresponding use, the website operator neither grants nor restricts +# permission via content signal with respect to the corresponding use. +# +# search: yes +# ai-input: no +# ai-train: no diff --git a/site/dpp-docs/scripts/sync-openapi.mjs b/site/dpp-docs/scripts/sync-openapi.mjs index 2d69611..147a22d 100644 --- a/site/dpp-docs/scripts/sync-openapi.mjs +++ b/site/dpp-docs/scripts/sync-openapi.mjs @@ -1,18 +1,98 @@ -// Copy the canonical OpenAPI spec from the sibling dpp-engine repo into public/. +// Vendor the canonical OpenAPI spec from the sibling dpp-engine repo into public/. // -// The copy at public/openapi.yaml is **vendored** (committed) so CI builds work -// without dpp-engine checked out alongside. Run this locally (or from a bot job -// that has both repos) whenever the spec changes: pnpm run sync:openapi -import { copyFileSync, existsSync } from 'node:fs'; +// The copy at public/openapi.yaml is committed so a build works without +// dpp-engine checked out alongside. That convenience is also the hazard: a +// vendored file drifts silently, and the drift is published. +// +// pnpm run sync:openapi copy engine -> public/, and record what it came from +// pnpm run check:openapi compare only, fail on drift or on an absent source +// +// Both modes exit non-zero when the source cannot be read. A sync that did not +// sync is a failure, not a notice — an earlier version of this script warned +// and exited 0 in that case, so it could not report a problem through any path. +// +// WHY THIS COMPARES AGAINST A PINNED COMMIT, NOT AGAINST THE ENGINE'S MAIN +// +// Comparing against whatever is currently on the engine's main branch makes +// this repository's CI depend on another repository's moving state. Two things +// go wrong. An unrelated merge in the engine turns pull requests red here, for +// reasons that have nothing to do with the change under review. And a +// correction that must land in both repositories deadlocks: the web side cannot +// go green until the engine side merges, so neither can be reviewed on a green +// build. +// +// openapi-source.json records the exact commit the vendored copy came from. +// The check reads the spec at that commit, so it is deterministic and +// self-contained. Bumping the pin is then a deliberate, reviewable line in a +// diff — which is also what makes the vendored copy's provenance auditable. +import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('..', import.meta.url)); -const src = fileURLToPath(new URL('../../../../dpp-engine/api/openapi.yaml', import.meta.url)); +const engineDir = + process.env.DPP_ENGINE_DIR ?? fileURLToPath(new URL('../../../../dpp-engine', import.meta.url)); +const pinPath = `${root}openapi-source.json`; const dest = `${root}public/openapi.yaml`; -if (existsSync(src)) { - copyFileSync(src, dest); - console.log(`synced ${src} -> ${dest}`); -} else { - console.warn(`skip: ${src} not found (dpp-engine not checked out) — using vendored ${dest}`); +const pin = JSON.parse(readFileSync(pinPath, 'utf8')); +const src = `${engineDir}/${pin.path}`; + +const checkOnly = process.argv.includes('--check'); + +// Git normalises to LF on commit, but a Windows working copy is CRLF. Compare +// content, not line terminators, or the check fails on every developer machine. +const normalise = (text) => text.replace(/\r\n/g, '\n'); + +const fail = (message) => { + console.error(`openapi ${checkOnly ? 'check' : 'sync'}: ${message}`); + process.exit(1); +}; + +const git = (...args) => + execFileSync('git', ['-C', engineDir, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + +if (!existsSync(engineDir)) { + fail( + `cannot find the engine repository at ${engineDir}\n` + + ' Check out dpp-engine beside this repo, or set DPP_ENGINE_DIR to its location.', + ); } + +if (checkOnly) { + if (!existsSync(dest)) fail(`vendored copy missing at ${dest}`); + + let pinned; + try { + pinned = git('show', `${pin.commit}:${pin.path}`); + } catch { + fail( + `cannot read ${pin.path} at commit ${pin.commit}\n` + + ` That commit is not present in ${engineDir}. Fetch it, or correct the\n` + + ` "commit" field in openapi-source.json.`, + ); + } + + if (normalise(pinned) === normalise(readFileSync(dest, 'utf8'))) { + console.log(`openapi check: vendored copy matches ${pin.repository}@${pin.commit.slice(0, 9)}.`); + process.exit(0); + } + + fail( + `the vendored copy does not match ${pin.repository}@${pin.commit.slice(0, 9)}.\n` + + ' Either it was edited by hand — it must not be, it is a copy — or the pin\n' + + ' is wrong. Run `pnpm run sync:openapi` and commit both files together.', + ); +} + +if (!existsSync(src)) fail(`cannot read ${src}`); + +copyFileSync(src, dest); + +// Record what was copied. Without this the vendored file has no provenance and +// the check above has nothing to verify against. +const head = git('rev-parse', 'HEAD').trim(); +writeFileSync(pinPath, `${JSON.stringify({ ...pin, commit: head }, null, 2)}\n`); + +console.log(`openapi sync: ${src} -> ${dest}`); +console.log(`openapi sync: pinned to ${pin.repository}@${head.slice(0, 9)}`); diff --git a/site/dpp-docs/src/content/docs/core-concepts.mdx b/site/dpp-docs/src/content/docs/core-concepts.mdx index 8cb765c..63dbcdd 100644 --- a/site/dpp-docs/src/content/docs/core-concepts.mdx +++ b/site/dpp-docs/src/content/docs/core-concepts.mdx @@ -21,7 +21,7 @@ Odal Node is two parts with two licences. The **core** is the regulatory standar One rule decides which side any change belongs on: *if it changes because an EU regulation changed, it belongs in the core; if it changes because of how the system is deployed or operated, it belongs in the engine.* The dependency only ever points one way — the engine uses the core; the core knows nothing of the engine. -A fuller treatment is in [Licensing](/engine/licensing). +The licence table is on the [Introduction](/introduction). ## One seam for new regulation diff --git a/site/dpp-docs/src/content/docs/engine/architecture.mdx b/site/dpp-docs/src/content/docs/engine/architecture.mdx index 8cadd13..6276d1a 100644 --- a/site/dpp-docs/src/content/docs/engine/architecture.mdx +++ b/site/dpp-docs/src/content/docs/engine/architecture.mdx @@ -34,4 +34,3 @@ A node serves a single operator. There are no shared tenants and no cross-operat - [Operating a node securely](/engine/security) — how the node protects keys, data, and access. - [Self-Hosting](/engine/self-hosted) — run a node on your own infrastructure. - [What Odal can and cannot see](/getting-started/what-odal-can-and-cannot-see) — the data boundary, precisely. -- [Licensing](/engine/licensing) — the terms the engine ships under. diff --git a/site/dpp-docs/src/content/docs/engine/self-hosted.mdx b/site/dpp-docs/src/content/docs/engine/self-hosted.mdx index 654f167..3b40dde 100644 --- a/site/dpp-docs/src/content/docs/engine/self-hosted.mdx +++ b/site/dpp-docs/src/content/docs/engine/self-hosted.mdx @@ -29,4 +29,3 @@ The engine and the sector rules move as the regulation does. Updating a node bri - [How the node works](/engine/architecture) — what's running under the hood. - [The CLI](/engine/cli) — the commands you'll use day to day. -- [Licensing](/engine/licensing) — the self-host grant in full. diff --git a/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx b/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx index 1eca517..ff17b31 100644 --- a/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx +++ b/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx @@ -42,4 +42,3 @@ The contents of your import files. The software reads them once, validates the d ## Read next [Core Concepts](/core-concepts) — the three governing principles, including this one. -[Licensing](/engine/licensing) — the open-core model and the self-host grant. diff --git a/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx b/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx index 988f84b..c2b5e19 100644 --- a/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx +++ b/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx @@ -20,7 +20,7 @@ The registry's API is being defined at the time of writing. The expected require Odal has prepared for this beyond modelling the interface: **every published passport already commits its registration intent to a durable outbox, in the same database transaction as the publish itself.** A background worker drains that outbox with retry and backoff. The practical consequence: publishing never blocks on the registry, a crash never loses a registration, and the day the Commission's API goes live, the accumulated backlog registers without a single passport falling through. (For context: the Commission's own deadline to set up the registry was 19 July 2026; as of this writing, the API specification remains unpublished — so this durable-queue posture is not caution, it's the correct engineering for the actual situation.) ## Why pre-investing in the bridge diff --git a/site/dpp-landing/README.md b/site/dpp-landing/README.md index d1a99f3..b55336e 100644 --- a/site/dpp-landing/README.md +++ b/site/dpp-landing/README.md @@ -53,7 +53,7 @@ Pages prefixed with `_` are drafts: Astro does not build them into routes. They There is **no `tailwind.config.mjs`** — Tailwind 4 is configured CSS-first via the `@theme` block in `@odal/brand-tokens/tokens.css`. -The section structure and copy follow [`../../docs/WEB_CONTENT_STRATEGY.md`](../../docs/WEB_CONTENT_STRATEGY.md) (§4 landing structure, §7 voice); the visual treatment of those sections follows [`../../docs/redesign/DESIGN_SPEC.md`](../../docs/redesign/DESIGN_SPEC.md) §5; the voice baseline is [`../../docs/BRAND.md`](../../docs/BRAND.md). +Section order, copy and visual treatment are all deliberate rather than incidental: each section component carries its own rule in a header comment, and the tone across both sites is plain, unenthusiastic and claim-checkable. The originating decisions are held in an internal design record dated 2026-06-10. ## Deployment diff --git a/site/dpp-landing/src/components/GetStarted.astro b/site/dpp-landing/src/components/GetStarted.astro index 0d7a85a..7f4bfac 100644 --- a/site/dpp-landing/src/components/GetStarted.astro +++ b/site/dpp-landing/src/components/GetStarted.astro @@ -1,6 +1,6 @@ --- // GetStarted.astro — navy terminal block with the quick-start commands. -// RULE (WEB_CONTENT_STRATEGY.md §4.7): whatever is printed here must be the +// RULE: whatever is printed here must be the // tested self-host path. If the quick-start changes, this component changes // in the same PR. Entrypoint = dpp-engine/docker/docker-compose.yml // (node internal port 8000, mapped to host 8001 by default). diff --git a/site/dpp-landing/src/components/Hero.astro b/site/dpp-landing/src/components/Hero.astro index 6eb5290..db8c450 100644 --- a/site/dpp-landing/src/components/Hero.astro +++ b/site/dpp-landing/src/components/Hero.astro @@ -1,7 +1,7 @@ --- -// Hero.astro — the highest-stakes block on the entire site. See docs/BRAND.md §2. +// Hero.astro — the highest-stakes block on the entire site. // Navy band with the brand mark as a quiet watermark; ice text; two CTAs; -// one-line trust row. Copy source: docs/WEB_CONTENT_STRATEGY.md §4.1. +// one-line trust row. import Watermark from "../assets/favicon.svg"; import StatusBadge from "./StatusBadge.astro"; interface Props { diff --git a/site/dpp-landing/src/components/Roadmap.astro b/site/dpp-landing/src/components/Roadmap.astro index 52d51ac..2548299 100644 --- a/site/dpp-landing/src/components/Roadmap.astro +++ b/site/dpp-landing/src/components/Roadmap.astro @@ -1,6 +1,6 @@ --- // Roadmap.astro — three-column status display. Data lives in src/data/roadmap.json -// (WEB_CONTENT_STRATEGY.md §4.8) so editors change JSON, not components. +// so editors change JSON, not components. // Status enum: shipped | in-build | horizon — the "honest status" framing. import data from "../data/roadmap.json"; diff --git a/site/dpp-landing/src/components/Section.astro b/site/dpp-landing/src/components/Section.astro index edd8467..defdd91 100644 --- a/site/dpp-landing/src/components/Section.astro +++ b/site/dpp-landing/src/components/Section.astro @@ -1,6 +1,6 @@ --- // Section.astro — a standard content section with an eyebrow, title, and slot. -// Tones (DESIGN_SPEC §5): default = white, muted = warm grey, tinted = ice. +// Tones: default = white, muted = warm grey, tinted = ice. interface Props { eyebrow?: string; title: string; diff --git a/site/dpp-landing/src/components/StatusBadge.astro b/site/dpp-landing/src/components/StatusBadge.astro index 0c690af..6e5570a 100644 --- a/site/dpp-landing/src/components/StatusBadge.astro +++ b/site/dpp-landing/src/components/StatusBadge.astro @@ -1,7 +1,7 @@ --- // StatusBadge.astro — a single, honest project-status pill. // Signals that the project is pre-1.0 and the product is at alpha maturity. -// Deliberately minimal (WEB_CONTENT_STRATEGY.md §9, §7): no enthusiasm, no +// Deliberately minimal: no enthusiasm, no // claim beyond "early". Two tones so it reads on light (nav) and navy (hero). // // Uses the regulatory amber token family already in brand-tokens for the light diff --git a/site/dpp-landing/src/data/deadlines.json b/site/dpp-landing/src/data/deadlines.json index d62c9db..8129da4 100644 --- a/site/dpp-landing/src/data/deadlines.json +++ b/site/dpp-landing/src/data/deadlines.json @@ -1,5 +1,5 @@ { - "_comment": "Regulatory deadlines shown on the landing page. Every entry needs a citation. The _verify fields are internal notes — NOT rendered — and must be cleared by the compliance lead before launch (source: docs/WEB_CONTENT_STRATEGY.md §4.2).", + "_comment": "Regulatory deadlines shown on the landing page. Every entry needs a citation to a named instrument and article, verified against the Official Journal text — not against a secondary source. The _verify fields are working notes and are NOT rendered; a deadline whose _verify still records an open question has not been checked, and an unchecked deadline must not ship.", "deadlines": [ { "date": "2026-07-19", diff --git a/site/dpp-landing/src/data/roadmap.json b/site/dpp-landing/src/data/roadmap.json index 9fb7633..57857da 100644 --- a/site/dpp-landing/src/data/roadmap.json +++ b/site/dpp-landing/src/data/roadmap.json @@ -1,5 +1,5 @@ { - "_comment": "Roadmap data — the single editable source for the landing roadmap (WEB_CONTENT_STRATEGY.md §4.8). Status enum: shipped | in-build | horizon. Rule: every numeric claim cites where it is measured, or it is omitted. No project-milestone months — regulatory dates live in deadlines.json.", + "_comment": "Roadmap data — the single editable source for the landing roadmap, so editors change JSON rather than components. Status enum: shipped | in-build | horizon. Rule: every numeric claim cites where it is measured, or it is omitted. No project-milestone months — regulatory dates live in deadlines.json.", "columns": [ { "status": "shipped", diff --git a/site/dpp-landing/src/pages/index.astro b/site/dpp-landing/src/pages/index.astro index c86fa53..9b98635 100644 --- a/site/dpp-landing/src/pages/index.astro +++ b/site/dpp-landing/src/pages/index.astro @@ -1,6 +1,6 @@ --- // odal-node.io — home page. -// Section order and copy per docs/WEB_CONTENT_STRATEGY.md §4. +// Section order and copy are fixed deliberately; see each section component. // Terminology and claims rules: see §7 (no capability-tier language, no claims // ahead of shipped code, "proof-bound" never "no-touch"). import DeadlineCards from "../components/DeadlineCards.astro"; diff --git a/site/dpp-landing/src/styles/global.css b/site/dpp-landing/src/styles/global.css index e3245ca..44caccb 100644 --- a/site/dpp-landing/src/styles/global.css +++ b/site/dpp-landing/src/styles/global.css @@ -28,7 +28,7 @@ } /* Light-first design with dark *sections* (hero, footer) rather than a - global dark mode — decision in docs/redesign/DESIGN_SPEC.md §1. */ + global dark mode — decision recorded 2026-06-10. */ h1, h2, h3, h4 { font-weight: 600;