Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.18.0
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
[![CI](https://github.com/odal-node/dpp-web/actions/workflows/ci.yml/badge.svg)](https://github.com/odal-node/dpp-web/actions/workflows/ci.yml)
[![Node 22.13+](https://img.shields.io/badge/Node-22.13%2B-brightgreen.svg)](https://nodejs.org/)
[![Node 22.23.4+](https://img.shields.io/badge/Node-22.23.4%2B-brightgreen.svg)](https://nodejs.org/)

The two Odal Node websites, as one pnpm workspace deployed as two Cloudflare Pages projects.

Expand All @@ -16,7 +16,7 @@ Each site has its own README for its layout and Pages build settings.

## Develop

Node 22.13+ and pnpm (version pinned in `package.json`, via corepack).
Node 22.23.4+ (CI and Cloudflare build with the version in `.node-version`) and pnpm (version pinned in `package.json`, via corepack).

```bash
pnpm install
Expand All @@ -31,7 +31,7 @@ CI (`.github/workflows/ci.yml`) runs all of these on every pull request and ever
```bash
pnpm -r build
pnpm -r check # types and content collections, not links
pnpm run check:links # internal links in both builds
pnpm run check:links # internal links and anchors in both builds
pnpm run check:spacing # words glued across a line break
pnpm run check:leakage # internal names and private paths
pnpm run check:llms # llms.txt matches the pages
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"type": "module",
"packageManager": "pnpm@11.5.1",
"engines": {
"node": ">=22.13"
"node": ">=22.23.4"
},
"scripts": {
"dev:landing": "pnpm --filter dpp-landing dev",
Expand Down
4,425 changes: 1,981 additions & 2,444 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion scripts/check-external-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ export async function fetchStatus(url, { timeoutMs = 20000, fetchImpl = fetch }
const res = await fetchImpl(url, {
redirect: "follow",
signal: AbortSignal.timeout(timeoutMs),
headers: { "User-Agent": "odal-node-link-check (+https://github.com/odal-node/dpp-web)" },
headers: {
"User-Agent": "odal-node-link-check (+https://github.com/odal-node/dpp-web)",
// Ask for a page the way a browser does. crates.io answers 404 to a
// request that does not accept text/html, and a reader's click does.
Accept: "text/html,application/xhtml+xml,*/*;q=0.8",
},
});
await res.body?.cancel();
return res.status;
Expand Down
17 changes: 17 additions & 0 deletions scripts/check-external-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,20 @@ test("a real request: redirects are followed, a missing page is a 404, a stalled
server.close();
}
});

test("a page that only answers a request for HTML is not reported broken", async () => {
// crates.io does this: a crate page answers 404 unless the request accepts
// text/html, so a checker that does not ask for HTML reports a live page gone.
const server = createServer((req, res) => {
const wantsHtml = (req.headers.accept ?? "").includes("text/html");
res.writeHead(wantsHtml ? 200 : 404).end();
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
try {
assert.equal(await fetchStatus(`${base}/crates/dpp-domain`), 200);
} finally {
server.closeAllConnections();
server.close();
}
});
66 changes: 57 additions & 9 deletions scripts/check-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
// * 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.
// * A `#fragment` must name an id on the page it lands on, so a reworded
// heading cannot leave its old anchor links pointing at nothing.
//
// Usage: node scripts/check-links.mjs
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
Expand All @@ -33,17 +35,41 @@ const walk = (dir, 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) => {
// index.html: the two shapes Astro's static output emits. Returns that file,
// or null.
const resolveFile = (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 candidates =
clean === '' || clean === '/'
? [join(dist, 'index.html')]
: extname(clean)
? [join(dist, clean)]
: [join(dist, clean, 'index.html'), `${join(dist, clean)}.html`];
return candidates.find((c) => existsSync(c)) ?? null;
};

// A fragment resolves if the page it lands on has an element with that id. A
// renamed heading changes its id, and the link to it still finds the page, so
// a path-only check passes it while the reader lands at the top. Pages whose
// anchors exist only after a script runs are listed here and skipped.
const SCRIPT_ANCHORED = new Set([join('site/dpp-docs/dist', 'api', 'index.html')]);
const idCache = new Map();
const idsOf = (file) => {
if (!idCache.has(file)) {
const html = readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
idCache.set(file, new Set([...html.matchAll(/\s(?:id|name)="([^"]+)"/g)].map((m) => m[1])));
}
return idCache.get(file);
};
const fragmentOf = (attr) => {
const hash = attr.indexOf('#');
if (hash === -1 || hash === attr.length - 1) return null;
return decodeURIComponent(attr.slice(hash + 1));
};

const broken = [];
let linkCount = 0;
let anchorCount = 0;
let pageCount = 0;

for (const site of SITES) {
Expand All @@ -65,6 +91,17 @@ for (const site of SITES) {
let target = null;
let dist = site.dist;

if (attr.startsWith('#')) {
// An anchor on this page: only the fragment can be wrong.
const fragment = fragmentOf(attr);
if (fragment === null) continue;
anchorCount += 1;
if (!idsOf(file).has(fragment)) {
broken.push({ from: relative(process.cwd(), file), target: attr });
}
continue;
}

if (attr.startsWith('/')) {
target = attr;
} else {
Expand All @@ -75,18 +112,29 @@ for (const site of SITES) {
}
}

// Anchors, mailto:, and third-party origins are out of scope.
// mailto: and third-party origins are out of scope.
if (target === null) continue;
linkCount += 1;
if (!resolves(dist, target)) {
const landed = resolveFile(dist, target);
if (landed === null) {
broken.push({ from: relative(process.cwd(), file), target: attr });
continue;
}
const fragment = fragmentOf(target);
if (fragment !== null && extname(landed) === '.html' && !SCRIPT_ANCHORED.has(landed)) {
anchorCount += 1;
if (!idsOf(landed).has(fragment)) {
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.`);
console.log(
`link check: ${linkCount} internal links and ${anchorCount} anchors across ${pageCount} pages, all resolve.`,
);
process.exit(0);
}

Expand Down
2 changes: 1 addition & 1 deletion site/dpp-docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export default defineConfig({
'@id': 'https://odal-node.io/#core',
name: 'Odal Node core library (dpp-core)',
description:
'The open-source Rust library Odal Node is built on: the EU Digital Product Passport model and its versioned schemas, validation and product-group rules, signing and verification, GS1 Digital Link parsing, the Asset Administration Shell export and the EU registry types. Apache-2.0; anyone may use it.',
'The open-source Rust library Odal Node is built on: the EU Digital Product Passport model and its versioned schemas, validation and product-group rules, signing and verification, product identifiers and their digital links (GS1 Digital Link, identification link, DID), the Asset Administration Shell export and the EU registry types. Apache-2.0; anyone may use it.',
codeRepository: 'https://github.com/odal-node/dpp-core',
programmingLanguage: 'Rust',
license: 'https://www.apache.org/licenses/LICENSE-2.0',
Expand Down
14 changes: 7 additions & 7 deletions site/dpp-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
"check:openapi": "node scripts/sync-openapi.mjs --check"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.3",
"@astrojs/starlight": "^0.41.7",
"@astrojs/sitemap": "^3.7.4",
"@astrojs/starlight": "^0.42.7",
"@odal/brand-tokens": "workspace:*",
"@scalar/api-reference": "^1.62.5",
"astro": "^7.3.5",
"vue": "^3.5.39"
"@scalar/api-reference": "^1.76.0",
"astro": "^7.4.2",
"vue": "^3.5.44"
},
"devDependencies": {
"@astrojs/check": "^0.9",
"typescript": "^5"
"@astrojs/check": "^0.9.12",
"typescript": "^6.0.3"
}
}
43 changes: 30 additions & 13 deletions site/dpp-docs/src/content/docs/core-concepts.mdx
Original file line number Diff line number Diff line change
@@ -1,36 +1,53 @@
---
title: Core Concepts
description: The three ideas behind Odal Node's design, namely the proof-bound architecture, the split between open core and engine, and the single extension point for new regulation.
description: The three rules behind Odal Node's design. Data is signed where it is produced, regulation and operation live in separate parts, and new regulation plugs into one extension point.
---

import { Aside } from "@astrojs/starlight/components";

Odal Node's design rests on three ideas. Each section below links to a fuller page.
Odal Node's design follows three rules. Each section links to a fuller page.

## Proof-bound: the data stays yours
## Signed at the source, verified anywhere

The operator's product data never reaches Odal. The manufacturer validates a passport on their own node and signs it with their own key, and the node stores it there. What the node serves is the signed passport: the product data the operator chose to publish, with every field bound to a proof and filtered by the reader's access rights.
The operator's product data never reaches Odal. The manufacturer validates each passport
on its own node, signs it with its own Ed25519 key, and stores it there. The node serves
the signed passport: the product data the operator chose to publish, with each field
bound to a signature and filtered by the reader's access rights.

This is how the software works, so it does not depend on trusting us. Verifying a passport needs only the signed passport and the manufacturer's public identity. Odal does not need to be online, or to exist.
Verifying a passport needs only the signed passport and the manufacturer's public
`did:web` document. Odal does not need to be online, or to exist.

[What Odal Node can and cannot see](/getting-started/what-odal-can-and-cannot-see) covers the data boundary in full.
[What Odal can and cannot see](/getting-started/what-odal-can-and-cannot-see) covers the
data boundary in full.

## Open core: regulation in one part, operation in the other
## Regulation in one part, operation in the other

Odal Node has two parts under two licences. The **core** is the regulatory library, open source under Apache-2.0: the rules a compliant passport must satisfy. The **engine** is the service that runs those rules in production, source-available under BSL-1.1 with a grant that allows self-hosting.
Odal Node has two parts under two licences. The **core** is the regulatory library, open
source under Apache-2.0: the rules a compliant passport must satisfy. The **engine** is
the service that runs those rules in production, source-available under BSL-1.1 with a
grant that allows self-hosting.

One rule decides where a change belongs. If it changes because an EU regulation changed, it goes in the core. If it changes because of how the system is deployed or operated, it goes in the engine. The engine depends on the core; the core does not know the engine exists.
One rule decides where a change belongs. If it changes because an EU regulation changed,
it goes in the core. If it changes because of how the system is deployed or operated, it
goes in the engine. The engine depends on the core; the core does not depend on the
engine.

[Licensing](/getting-started/licensing) covers what each licence allows.

## One extension point for new regulation

Regulation arrives one product group at a time, and it keeps changing. The core therefore has a single extension point: each product group's compliance logic plugs in there, and new product groups and revised rules are added without changing anything around them. See [Product groups & plugins](/core/sectors).
Regulation arrives one product group at a time, and it keeps changing. The core has a
single extension point for it: each product group's compliance logic plugs in there, so
new product groups and revised rules are added without changing anything around them.
See [Product groups & plugins](/core/sectors).

<Aside type="note" title="Not a paywall">
The extension point is part of the architecture, not a pricing tier. The built-in product groups and any added later use the same mechanism, and all of it is open. New regulatory coverage is never a paid add-on.
<Aside type="note" title="The same mechanism for every product group">
The extension point is part of the architecture, not a pricing tier. The built-in product
groups and any added later use the same mechanism, and all of it is Apache-2.0.
</Aside>

## Where to go next

To run a node, start with the [Quick Start](/quick-start). To see what Odal Node can and cannot see, read [What Odal Node can and cannot see](/getting-started/what-odal-can-and-cannot-see). To see how the software maps to the law, start with the [ESPR Overview](/regulatory/espr).
To run a node, start with the [Quick Start](/quick-start). To use the library on its own,
read [What the core does](/core/overview). To see how the software maps to the law, start
with the [ESPR overview](/regulatory/espr).
66 changes: 64 additions & 2 deletions site/dpp-docs/src/content/docs/core/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,69 @@ title: What the core does
description: "The regulatory library Odal Node is built on: what a passport is, how it is checked and signed, and the pieces anyone can build on."
---

The core is the regulatory library: the rules a compliant Digital Product Passport must satisfy, written as code. It is open source under Apache-2.0, has no infrastructure of its own (no database, no server, no configuration), and anyone may build on it. Its crates are published on crates.io.
The core is the regulatory library: the rules a compliant Digital Product Passport must satisfy, written as code. It is open source under Apache-2.0, has no infrastructure of its own (no database, no server, no configuration), and anyone may build on it. Its crates are published on [crates.io](https://crates.io/crates/dpp-domain), with API documentation on [docs.rs](https://docs.rs/dpp-domain).

## Try it

Validate product data against its regulatory schema, sign it, and verify the signature, using only the published crates. The first build takes about a minute.

```bash
cargo new hello-dpp && cd hello-dpp
cargo add dpp-domain dpp-crypto serde_json anyhow
```

Replace `src/main.rs` with:

```rust
use dpp_crypto::{jws::signer, keystore::KeyStore};
use dpp_domain::schemas::VersionedSchemaRegistry;
use serde_json::json;

fn main() -> anyhow::Result<()> {
// 1. Validate product data against the embedded, versioned JSON Schema.
let schemas = VersionedSchemaRegistry::new();
let (version, _) = schemas.latest("textile").expect("textile schema is embedded");

let data = json!({
"productIdentifier": { "scheme": "gs1", "gtin": "09506000134352" },
"fibreComposition": [{ "fibre": "organic cotton", "pct": 100.0 }],
"countryOfOrigin": "PT",
"careInstructions": "Machine wash 30°C",
"chemicalComplianceStandard": "OEKO-TEX Standard 100"
});

if let Err(e) = schemas.validate("textile", version, &data) {
for err in &e.errors {
println!("invalid at '{}': {}", err.field, err.message);
}
anyhow::bail!("validation failed");
}
println!("valid against textile schema v{version}");

// 2. Sign it as a compact JWS with an Ed25519 key from an encrypted keystore.
let store = KeyStore::open(std::env::temp_dir().join("hello-keys.json"), "change-me")?;
if store.public_key("issuer").is_none() {
store.generate_key("issuer")?;
}
let kid = store.public_key("issuer").expect("generated above").thumbprint_uri()?;
let jws = signer::sign(&store, "issuer", &data, &kid)?;

// 3. Verify the signature.
println!("signature verifies: {}", signer::verify(&store, "issuer", &jws)?);
Ok(())
}
```

```bash
cargo run
```

```text
valid against textile schema v1.4.0
signature verifies: true
```

Remove `"countryOfOrigin"` and the run stops with `invalid at '': "countryOfOrigin" is a required property`.

## What it gives you

Expand All @@ -13,7 +75,7 @@ The core is the regulatory library: the rules a compliant Digital Product Passpo
- **A lifecycle**: draft, published, suspended, superseded, retired and end of life. Every transition is recorded, a published passport never returns to draft, and retirement and end of life are final, so a passport's history cannot be rewritten unnoticed.
- **The EU instrument catalog**: which acts reach which product group, whether each requires a passport, and from when, with every date marked as read from the text or assumed. The website's [regulations page](https://odal-node.io/regulations) shows it.
- **Access control**: which reader may see which part of a passport, and the verifiable credentials that prove a reader's role.
- **GS1 Digital Link, Asset Administration Shell and registry types**: what a passport needs to be resolved from a barcode, read by Industry 4.0 systems and registered with the EU.
- **Product identifiers, digital links, Asset Administration Shell and registry types**: what a passport needs to be identified and resolved from the code on the product (a GS1 Digital Link, an identification link or a DID), read by Industry 4.0 systems and registered with the EU.
- **Calculators**: carbon footprint, repairability and recycled-content calculations, each with a receipt of the method and version used, published for anyone to use.
- **The plugin SDK**: how a product group's rules are written as a sandboxed plugin. See [Product groups & plugins](/core/sectors).

Expand Down
4 changes: 2 additions & 2 deletions site/dpp-docs/src/content/docs/core/sectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ Each group has a versioned schema, a list of the product categories it distingui
odal product-group list # every group, whether a passport is required and from when
odal product-group show battery # one group in full, with the acts behind it
odal schema list # the schema version this node serves for each group
odal template battery # the CSV header row an import expects
odal template battery-ev # the CSV header row an import expects (batteries: one per category)
```

## One interface for every product group

Each group's rules sit behind a single shared interface. Adding a group, or updating one when the regulation changes, means supplying new logic through that interface without changing the rest of the system. The core repository has ten plugins, covering every group except mattresses.
Each group's rules sit behind a single shared interface. Adding a group, or updating one when the regulation changes, means supplying new logic through that interface without changing the rest of the system. The core repository has eleven plugins, one for each group.

A group with no plugin loaded still works: its passports are checked against the schema, but no group-specific rules run and no findings are produced. The node reports this at start-up, so "no findings" is not mistaken for "checked and clean". A node started with the production profile refuses to run without a plugin for every group whose passport obligation is live.

Expand Down
Loading
Loading