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
6 changes: 3 additions & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
## Checklist

CI runs these on the pull request; run them locally first, since a push to
`staging` alone runs no CI. Links, spacing and llms.txt read the build, so build first.
`staging` alone runs no CI. Links, spacing, llms.txt and sitemaps read the build, so build first.

- [ ] `pnpm -r build` and `pnpm -r check`
- [ ] `pnpm run check:links`, `check:spacing`, `check:leakage` and `check:llms`
- [ ] `pnpm run test:scripts` and `pnpm --filter dpp-landing run test:verify`
- [ ] `pnpm run check:links`, `check:spacing`, `check:leakage`, `check:llms` and `check:sitemaps`
- [ ] `pnpm run test:scripts`, `pnpm --filter dpp-landing run test:verify` and `pnpm audit --audit-level high`
- [ ] If vendored data changed: `pnpm run check:openapi` and `pnpm --filter dpp-landing run check:core` (need the sibling repositories; see the README)
- [ ] No secrets, credentials, or `.env` files in the diff
185 changes: 64 additions & 121 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,161 +1,104 @@
# Odal Node Web

**Public-facing web properties: landing page and documentation**

[![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/)
[![Status: Active Development](https://img.shields.io/badge/Status-Active%20Development-green.svg)](https://odal-node.io/roadmap)

The two public-facing web properties for Odal Node, organised as a pnpm workspace and deployed as two independent Cloudflare Pages projects.
The two Odal Node websites, as one pnpm workspace deployed as two Cloudflare Pages projects.

| Project | Domain | Stack | Deploy target |
| Folder | Site | Stack | Pages project |
|---|---|---|---|
| `site/dpp-landing/` | `odal-node.io` | Astro + Tailwind 4 | Cloudflare Pages (`odal-node-landing`) |
| `site/dpp-docs/` | `docs.odal-node.io` | Astro + Starlight | Cloudflare Pages (`odal-node-docs`) |
| `packages/brand-tokens/` | — | Internal workspace package | Consumed by both sites |

Each Astro project has its own `package.json` and its own Cloudflare Pages project. They share the `@odal/brand-tokens` package — colour palette, typography, spacing — so brand polish stays in sync without effort.

---

## Independent Deploys, Shared Brand
| `site/dpp-landing/` | `odal-node.io`, `www.odal-node.io` | Astro + Tailwind 4 | `odal-node-landing` |
| `site/dpp-docs/` | `docs.odal-node.io` | Astro + Starlight, Scalar on `/api` | `odal-node-docs` |
| `packages/brand-tokens/` | | Colours, type, spacing for both sites | |

The architectural commitment of this repository is *independence at the deployment layer, coherence at the brand layer*. A promotion into `main` that only touches `site/dpp-docs/src/content/docs/quick-start.mdx` produces a single docs deploy and zero landing deploys. One that touches `packages/brand-tokens/` produces two deploys, because a token change genuinely should re-render both surfaces. This is enforced via Cloudflare Pages [build watch paths](https://developers.cloudflare.com/pages/configuration/build-watch-paths/) rather than at the Git layer.
Each site has its own README for its layout and Pages build settings.

---
## Develop

## Repository Layout
Node 22.13+ and pnpm (version pinned in `package.json`, via corepack).

```
dpp-web/
├── package.json # workspace root, scripts proxy to projects
├── pnpm-workspace.yaml # declares site/* projects and packages/*
├── pnpm-lock.yaml # single lockfile for the whole workspace
├── README.md # this file
│
├── scripts/ # CI gates: link crawler, leakage scan
│
├── packages/
│ └── brand-tokens/ # @odal/brand-tokens — colour, type, spacing
│
├── site/dpp-landing/ # odal-node.io (Tailwind 4, CSS-first — no tailwind.config)
│ ├── astro.config.mjs
│ └── src/{layouts,components,pages,data,styles}
│
└── site/dpp-docs/ # docs.odal-node.io (Starlight)
├── astro.config.mjs
└── src/{assets,content/docs,styles}
```bash
pnpm install
pnpm dev:landing # http://localhost:4321
pnpm dev:docs # http://localhost:4325; run both and they link to each other
```

---
## Checks

## Quick Start
CI (`.github/workflows/ci.yml`) runs all of these on every pull request and every push to `main`. Pushes to `staging` run no CI, so run them locally first. `check:links`, `check:spacing`, `check:llms` and `check:sitemaps` read the build, so build first.

```bash
git clone https://github.com/odal-node/dpp-web.git
cd dpp-web

# Install everything for the whole workspace
pnpm install

# Run a dev server for either site
pnpm dev:landing # http://localhost:4321 → site/dpp-landing
pnpm dev:docs # http://localhost:4325 → site/dpp-docs
# Run both at once and links between them point at each other.

# Build for production (same command Cloudflare runs)
pnpm -r build

# Type-check templates and content-collection references
pnpm -r check

# The gates CI runs. Links and spacing read the build, so build first.
pnpm run check:links # crawl both dist trees for internal links that 404
pnpm run check:spacing # words glued together across a line break in Astro markup
pnpm run check:leakage # internal vocabulary / private-repo paths, incl. public/
pnpm run check:openapi # vendored API spec still matches its pinned engine commit
pnpm --filter dpp-landing run check:core # vendored core records match their pin
pnpm --filter dpp-landing run test:verify # the browser verifier agrees with the node's
pnpm run test:scripts # the rate-limit and external-link scripts
pnpm -r check # types and content collections, not links
pnpm run check:links # internal links 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
pnpm run check:sitemaps # sitemaps and their lastmod dates
pnpm run test:scripts
pnpm --filter dpp-landing run check:core # vendored core data matches its pin (DPP_CORE_DIR)
pnpm --filter dpp-landing run test:verify # /verify agrees with the engine's verifier
pnpm run check:openapi # vendored API spec matches its pin (DPP_ENGINE_DIR)
pnpm audit --audit-level high

# Not a pull-request gate: run weekly by .github/workflows/external-links.yml.
pnpm run check:external # links to other sites (EUR-Lex, GitHub …); fails only on a 404 or 410
pnpm run check:external # outside links; weekly in CI, not a PR gate
```

`pnpm -r check` does **not** check links, and never did — a markdown link target is an opaque
string to `astro check`. That is why `check:links` exists separately and reads the built output
rather than the source: four `[Licensing](/engine/licensing)` links once passed `check` and
404'd in production.
## Vendored inputs

Prerequisites: Node.js 22.13+ (LTS 24 recommended — pnpm 11 requires `node:sqlite`, unavailable before 22.13) and pnpm (managed via [corepack](https://nodejs.org/api/corepack.html) — the exact version is pinned in `package.json` `packageManager`).
| What | Pin | Refresh |
|---|---|---|
| Product groups and EU acts from `dpp-core` | `site/dpp-landing/core-source.json` | `pnpm --filter dpp-landing run sync:core` |
| API spec from `dpp-engine` | `site/dpp-docs/openapi-source.json` | `pnpm run sync:openapi`, or the nightly workflow |

---
Never edit the vendored files by hand; bump the pin and sync.

## What Lives Elsewhere
## Releasing

This repository contains marketing copy and technical documentation, not source code for the Odal Node product itself.
`main` is what Cloudflare publishes. Its ruleset matches `dpp-core` and `dpp-engine`: pull request required, squash only, `build` must pass on an up-to-date branch, no force-push, no deletion, no bypass.

The [`dpp-core`](https://github.com/odal-node/dpp-core) repository (Apache-2.0) holds the regulatory-standard Rust library — domain types, port traits, cryptography, GS1 Digital Link, schema validation, the compliance calculators, the Wasm plugin ABI. The docs site documents `dpp-core`; it does not contain its source.
1. Land work on `staging`, through a pull request or a direct push once the checks pass.
2. Review the previews: `staging.odal-node-landing.pages.dev` and `staging.odal-node-docs.pages.dev`.
3. New or reworked pages get a hand screen-reader and keyboard pass, which `/accessibility` promises.
4. Open a pull request from `staging` to `main` and squash-merge it.
5. Reset `staging` to `main`, so the next pull request lists only new commits (a squash leaves the old ones on `staging` otherwise):
`git fetch origin && git push --force-with-lease=staging origin origin/main:staging`
6. If pages were added or moved, resubmit both sitemaps in Search Console.

The [`dpp-engine`](https://github.com/odal-node/dpp-engine) repository (BSL-1.1, with a production self-host grant) holds the deployment layer — HTTP services, persistence, authentication, telemetry, the public resolver, the Wasm plugin sandbox. The docs site documents `dpp-engine`; it does not contain its source.
A squash merge sets the sitemap `lastmod` of every page it touches to the merge date. Dates come from git history (`scripts/git-lastmod.mjs`).

The relationship between the repositories — the open-core boundary, the dependency direction, the licensing rationale — is covered on the docs site under [Core Concepts](https://docs.odal-node.io/core-concepts) and [Licensing](https://docs.odal-node.io/getting-started/licensing), and in the parent project's strategy documents.
## Cloudflare

---

## Status

The original phased build (workspace foundations → landing MVP → docs IA → polish) is complete through its first three phases, and the **June 2026 redesign** re-skinned both sites onto the navy/ice brand, replaced retired messaging with *"Signed by you. Verified by anyone."*, and moved editable content into data files. `LICENSE` is settled (Apache-2.0).

An **August 2026 audit** of both sites read every published page against primary regulatory text and against the engine's source. It found a delegated act that does not exist described as adopted, roughly twenty misattributed citations, four security-property claims the code contradicted, and a registry described as unbuilt eight months after it went live. Those are corrected; the findings register lives outside this repository.

The hand pass the landing's accessibility page describes (a person, with a screen reader and a keyboard alone) was done by the founder before promotion. Pages added or reworked later need the same pass before they are published. What remains before public launch: a named data controller in the privacy policy — which is blocked on a registered entity existing, not on a copy edit.

The keyboard pass is done. On 2026-09-27 every page of both sites was walked with Tab alone at 1280px and 375px in headless Chromium: every control shows a focus ring, nothing traps focus, the first Tab on the landing reaches "Skip to content", and the menus, the passport check and /verify work from the keyboard. axe-core 4.13 (WCAG 2.0–2.2 A and AA, plus best practice) ran on every page of both sites at both widths and, on the docs, in both themes. The landing is clean. What it still reports is inside Scalar's API reference on `/api` (ARIA attributes on the wrong roles, a few icon buttons with no name, a second banner landmark on a phone, a scrolling list that cannot take focus) and Expressive Code's unnamed code-block regions on the docs, a best-practice rule rather than a WCAG one. Those are third-party markup this repository does not render.

The API reference does not relay requests through a third party. Checked at runtime on 2026-09-27: in headless Chromium, "Test Request → Send" on `/api` went straight to the spec's server (`http://localhost:8001/vault/api/v1/dpp`), and the session made no request to any `scalar.com` host. That rests on `proxyUrl: ''` in `site/dpp-docs/src/scripts/mount-api-reference.ts`; re-check it after upgrading `@scalar/api-reference`.

## Rate limit on /verify

/verify checks files in the browser, so there is no server of ours to limit. The limit is a
Cloudflare rate-limiting rule on the `odal-node.io` zone: paths starting with `/verify`, 20
requests per 10 seconds per IP address, then 429 for 10 seconds. That is the most the Free plan
allows, and well above one person's use (a visit is the page plus at most five example files).
It covers the custom domain only; `*.pages.dev` previews are not in the zone.

The zone is not managed as code elsewhere, so `scripts/cloudflare-rate-limit.mjs` is the rule's
source of truth. It replaces only its own rule and keeps any other:

```bash
# Dry run: prints the ruleset it would write. The token needs "Zone WAF: Edit".
CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ZONE_ID=… pnpm run rate-limit:verify

# Write it
CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ZONE_ID=… pnpm run rate-limit:verify -- --apply
```
- **Pages:** each project builds from the repository root (see the site READMEs). Production is `main`. Every branch gets a preview at `<branch>.<project>.pages.dev`, which Cloudflare marks `noindex`.
- **Headers:** each site's `public/_headers` sets HSTS, a same-origin CSP and caching.
- **`no-transform` on HTML:** the free plan injects a bot-detection script into every HTML page, and it cannot be switched off. The script sets a `cf_clearance` cookie, which the privacy page says the sites do not set. `Cache-Control: no-transform` on HTML stops that injection and Cloudflare's email obfuscation, at the cost of Cloudflare no longer compressing HTML. Other files stay compressed.
- **Editing cache rules:** Pages joins two values of one header with a comma. A rule that sets its own `Cache-Control` must first detach the inherited one with `! Cache-Control`, and no two cache rules may match the same file.
- **Zone:** the `/verify` rate-limit rule (20 requests per 10 s per IP) is kept as code in `scripts/cloudflare-rate-limit.mjs` (`pnpm run rate-limit:verify`, add `-- --apply` to write; the token needs "Zone WAF: Edit"). It does not cover `pages.dev` previews.
- **Cache purge:** run `.github/workflows/deploy.yml` by hand.

## How changes land
## Automation

`main` is what Cloudflare Pages publishes, so nothing lands on it directly.
| Workflow | When | What |
|---|---|---|
| `ci.yml` | Pull requests, pushes to `main` | The checks above |
| `sync-openapi.yml` | Nightly, 03:17 UTC | Opens a pull request into `staging` when the engine's spec changes |
| `external-links.yml` | Mondays, 04:41 UTC | `check:external` |
| Dependabot | Weekly | npm and Actions updates. Its pull requests target `main`; retarget them to `staging` |

- **`staging`** is the integration branch. Work branches off it and merges back through a pull request.
- Promotion is a second pull request, `staging` → `main`, reviewed on its own.
- `main` carries a ruleset matching the other repositories: pull request required, squash-only, CI must pass, no force-push, no deletion, and **no bypass for anyone** — including the owner.
Scheduled workflows run from `main` only.

CI (`.github/workflows/ci.yml`) runs build, type-check, and the gates listed above on every pull request and every push to `main`, with the workflow token scoped to `contents: read` and every action pinned to a commit. A push to `staging` alone runs no CI, so run the gates locally before pushing there.
## Search and agents

---
Both sites publish `sitemap-index.xml`, `robots.txt` (content signals: `search=yes, ai-input=yes, ai-train=no`) and `llms.txt`. On the landing, `src/lib/site-map.ts` is the one list of pages behind the nav, footer, `/sitemap` and `llms.txt`; on the docs, `src/sidebar.mjs` is. Submit both sitemaps in Search Console.

## License
No page loads anything from another origin (the CSP allows only the site itself), and there is no analytics. The API reference sends "Test request" straight to the reader's node (`proxyUrl: ''` in `site/dpp-docs/src/scripts/mount-api-reference.ts`); check that this still holds after upgrading `@scalar/api-reference`.

[Apache License 2.0](LICENSE) — the source of both sites (markup, styling, docs prose, brand tokens) for consistency with `dpp-core` (one licence story, not two). The deployed sites' content is freely readable; the source licence governs reuse of the markup and styling work.
## Elsewhere

## Security
The product's source is in [`dpp-core`](https://github.com/odal-node/dpp-core) (Apache-2.0) and [`dpp-engine`](https://github.com/odal-node/dpp-engine) (BSL-1.1). This repository holds only the websites.

Do **not** open public issues for security vulnerabilities (e.g. XSS, exposed secrets, dependency CVEs). Report privately to **security@odal-node.io**.
## License and security

---
[Apache-2.0](LICENSE) for the sites' source. It grants no use of the Odal Node name or logo.

*Odal Node — built by [Odal Node](https://odal-node.io)*
Report vulnerabilities privately to **security@odal-node.io**, never in a public issue.
2 changes: 1 addition & 1 deletion site/dpp-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ site/dpp-docs/
├── astro.config.mjs # Starlight config: redirects, head (JSON-LD, share image), dev server on 4325
├── openapi-source.json # the dpp-engine commit public/openapi.yaml is vendored from
├── public/
│ ├── _headers # Cloudflare Pages headers: HSTS, CSP, caching
│ ├── _headers # Cloudflare Pages headers: HSTS, CSP, caching, no-transform on HTML (root README)
│ ├── favicon.svg # the brand mark
│ └── openapi.yaml # the engine's API spec, vendored (do not edit here)
├── scripts/
Expand Down
2 changes: 1 addition & 1 deletion site/dpp-landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ site/dpp-landing/
├── astro.config.mjs # Astro + Tailwind 4 (@tailwindcss/vite) + sitemap; dev server on 4321
├── core-source.json # the dpp-core commit the vendored records in src/data/ come from
├── public/
│ ├── _headers # Cloudflare Pages headers: HSTS, CSP, caching
│ ├── _headers # Cloudflare Pages headers: HSTS, CSP, caching, no-transform on HTML (root README)
│ ├── favicon.svg # the brand mark (also shown in the nav, hero and footer)
│ ├── apple-touch-icon.png # 180px PNG of the mark, for iOS
│ ├── og-image.png # site-wide share image
Expand Down
Loading