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
16 changes: 16 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,19 @@ jobs:
steps:
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
# A green deploy is not proof the site serves what we built: #110 shipped a "successful"
# deploy whose artifact silently omitted .well-known/, and only a manual curl caught it.
# Fail the run instead, on the canonical host the Worker actually fronts.
- name: smoke — the canonical URLs answer
run: |
set -uo pipefail
fail=0
for path in / /feed.xml /.well-known/security.txt /.well-known/pgp-security.asc; do
# -f makes a 4xx an error so --retry-all-errors keeps trying: the Worker caches its
# own 404 for 60s, so the first probe after a deploy can legitimately still miss.
code=$(curl -fsS -L -o /dev/null -w '%{http_code}' \
--retry 10 --retry-delay 10 --retry-all-errors \
"https://material-identity.eu${path}") || { code="${code:-000} FAILED"; fail=1; }
printf '%-34s %s\n' "$path" "$code"
done
exit $fail
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ section for the full explanation).
code): `pgp-security.asc` (public key only — never a private one) and `security.txt`
(RFC 9116). The worker types them and caps their cache at a day. An unnumbered validate check
fails the build when `Expires` is missing, past, or over a year out — renew it in place, it is
not under `published/`
not under `published/`. **Nothing emitted into `site/` may start with a dot**:
`upload-pages-artifact` tars with `--exclude=.[^/]*`, so the build writes `site/well-known/`
and the Worker rewrites `/.well-known/<x>` onto it (#110). A build test asserts the site is
dot-free, and `deploy.yml` smoke-tests the live URLs — a green deploy alone proved nothing
- `REVIEW.md` — what reviewers check beyond CI; read it before reviewing any publish PR
- `standards/` — local-only licensed docs; only its README is committed

Expand Down
6 changes: 5 additions & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const SCHEMA_PATH = join(LIB_DIR, '..', 'schema', 'dictionary-entry.schema.json'
const CONTEXT_PATH = join(LIB_DIR, '..', 'rdf', 'context.jsonld');
// RFC 8615 well-known URIs, copied wholesale so adding e.g. a security.txt needs no code (#107).
const WELL_KNOWN_PATH = join(LIB_DIR, '..', '.well-known');
// ...but emitted WITHOUT the dot (#110): actions/upload-pages-artifact tars with
// `--exclude=.[^/]*`, so a site/.well-known/ is silently dropped and the URL 404s in production.
// The Worker maps the canonical /.well-known/<x> onto this path; the origin is never advertised.
const WELL_KNOWN_OUT = 'well-known';

export interface BuildResult {
entries: number;
Expand Down Expand Up @@ -74,7 +78,7 @@ export function build(root: string, out: string): BuildResult {
// RDF track steps 1–2 (issue #98): the context is the semantic commitment, the Turtle is a
// derived second serialization. The canonical /def/<uuid>.json is untouched by both.
cpSync(CONTEXT_PATH, join(out, 'context.jsonld'));
if (existsSync(WELL_KNOWN_PATH)) cpSync(WELL_KNOWN_PATH, join(out, '.well-known'), { recursive: true });
if (existsSync(WELL_KNOWN_PATH)) cpSync(WELL_KNOWN_PATH, join(out, WELL_KNOWN_OUT), { recursive: true });
const issued = new Map([...releases].map(([path, release]) => [path, release.date]));
writeFileSync(join(out, 'dictionary.ttl'), renderTurtle(repo, refs, issued));
return { entries, out };
Expand Down
11 changes: 8 additions & 3 deletions test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,20 +332,25 @@ test('build publishes the JSON-LD context and the Turtle graph, and the entry JS
}
});

test('the tracked .well-known directory is copied into the site byte-for-byte (#107, #109)', () => {
test('the tracked .well-known directory is copied into the site byte-for-byte (#107, #109, #110)', () => {
const out = buildGreen();
try {
const source = readFileSync(join(here, '..', '.well-known', 'pgp-security.asc'));
assert.deepEqual(readFileSync(join(out, '.well-known', 'pgp-security.asc')), source);
assert.deepEqual(readFileSync(join(out, 'well-known', 'pgp-security.asc')), source);
// a public key block, never a private one
const text = source.toString('utf8');
assert.match(text, /^-----BEGIN PGP PUBLIC KEY BLOCK-----/);
assert.ok(!text.includes('PRIVATE KEY'), 'a private key must never be published');

// security.txt rides along on the same wholesale copy, no builder code of its own
const sec = readFileSync(join(out, '.well-known', 'security.txt'), 'utf8');
const sec = readFileSync(join(out, 'well-known', 'security.txt'), 'utf8');
assert.deepEqual(sec, readFileSync(join(here, '..', '.well-known', 'security.txt'), 'utf8'));
assert.match(sec, /^Canonical: https:\/\/material-identity\.eu\/\.well-known\/security\.txt$/m);

// Nothing in the emitted site may start with a dot: actions/upload-pages-artifact tars with
// `--exclude=.[^/]*`, so a dot-entry deploys as a 404 while every other test stays green (#110).
const dotted = readdirSync(out, { recursive: true, encoding: 'utf8' }).filter((p) => /(^|\/)\./.test(p));
assert.deepEqual(dotted, [], `these would be stripped from the Pages artifact: ${dotted.join(', ')}`);
} finally {
rmSync(out, { recursive: true, force: true });
}
Expand Down
13 changes: 10 additions & 3 deletions test/index-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,23 +98,30 @@ test('decide: JSON is the default; HTML only when Accept names text/html', () =>

test('decide: well-known URIs are typed and cached for a day, never immutable (#107, #109)', () => {
const key = decide('/.well-known/pgp-security.asc', '*/*');
assert.equal(key.originPath, '/.well-known/pgp-security.asc');
// undotted at the origin — the Pages artifact tar drops dot-directories (#110)
assert.equal(key.originPath, '/well-known/pgp-security.asc');
assert.equal(key.headers['content-type'], 'application/pgp-keys');
// a key can be rotated or revoked, so it must never inherit an entry's immutable caching
assert.equal(key.headers['cache-control'], 'public, max-age=86400');
assert.ok(!key.headers['cache-control'].includes('immutable'));

// RFC 9116 §3 requires security.txt to be served as text/plain with a charset
const sec = decide('/.well-known/security.txt', '*/*');
assert.equal(sec.originPath, '/.well-known/security.txt');
assert.equal(sec.originPath, '/well-known/security.txt');
assert.equal(sec.headers['content-type'], 'text/plain; charset=utf-8');
assert.equal(sec.headers['cache-control'], 'public, max-age=86400');

// anything else well-known gets the ceiling and the origin's own type
// anything else well-known gets the ceiling and the origin's own type, nested paths included
const other = decide('/.well-known/openpgpkey/hu/abc', '*/*');
assert.equal(other.originPath, '/well-known/openpgpkey/hu/abc');
assert.equal(other.headers['cache-control'], 'public, max-age=86400');
assert.equal(other.headers['content-type'], undefined);

// no origin path we serve may start with a dot, or the artifact tar drops it again (#110)
for (const p of ['/.well-known/security.txt', '/.well-known/pgp-security.asc', '/.well-known/a/b']) {
assert.ok(!decide(p, '*/*').originPath.includes('/.'), `${p} must not map to a dot-directory`);
}

// .asc / .txt anywhere else are not special-cased
assert.equal(decide('/def/whatever.asc', '*/*').headers['content-type'], undefined);
assert.equal(decide('/robots.txt', '*/*').headers['content-type'], undefined);
Expand Down
7 changes: 6 additions & 1 deletion worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ export function decide(pathname: string, accept: string | null): RouteDecision {
// RFC 8615 well-known URIs (#107, #109). Cacheable but never immutable like an entry: a key can
// be rotated or revoked and security.txt expires, so a day is the ceiling. Pages would serve
// .asc as a generic byte stream and .txt without a charset, hence the explicit types.
//
// The origin stores these under `well-known/`, undotted (#110): actions/upload-pages-artifact
// tars with `--exclude=.[^/]*` and silently drops any dot-directory, which 404'd the live URL
// while every local test passed. Rewriting here keeps the canonical, RFC-mandated URL intact —
// mapping a URI onto an origin file is this Worker's whole job.
if (pathname.startsWith('/.well-known/')) {
const headers: Record<string, string> = { 'cache-control': 'public, max-age=86400' };
if (pathname.endsWith('.asc')) headers['content-type'] = 'application/pgp-keys';
if (pathname.endsWith('.txt')) headers['content-type'] = 'text/plain; charset=utf-8'; // RFC 9116 §3
return { originPath: pathname, headers };
return { originPath: `/well-known/${pathname.slice('/.well-known/'.length)}`, headers };
}

// index, pagination, styles, raw origin files: pass through with a short cache
Expand Down