From 939ec45384bb26bd8d75596e1875243b5fec2a2c Mon Sep 17 00:00:00 2001 From: Hannes Stiebitzhofer Date: Mon, 14 Sep 2026 00:04:17 +0200 Subject: [PATCH 1/2] Serve the security contact's PGP key at /.well-known/pgp-security.asc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracked source at .well-known/, mirroring the served path; the build copies the whole directory, so a security.txt later needs no code change. It was sitting in site/, which is the gitignored build output that build() clears on every run — it would have been deleted before it ever deployed. The Worker serves /.well-known/* with max-age=86400 and no immutable flag: a key can be rotated or revoked, so a day is the ceiling, unlike an entry. .asc gets Content-Type: application/pgp-keys, which Pages would otherwise serve as a generic byte stream. Public key block only — S1Seven Security , RSA-4096, 25FF A4E5 240A E655 FEA3 9082 D57E 1BBD 422F DAD7, expires 2028-09-12. A test asserts the published bytes carry no private key. Closes #107 --- .well-known/pgp-security.asc | 53 ++++++++++++++++++++++++++++++++++++ scripts/build.ts | 5 +++- test/build.test.ts | 14 ++++++++++ test/index-worker.test.ts | 17 ++++++++++++ worker/index.ts | 9 ++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 .well-known/pgp-security.asc diff --git a/.well-known/pgp-security.asc b/.well-known/pgp-security.asc new file mode 100644 index 0000000..8f312d3 --- /dev/null +++ b/.well-known/pgp-security.asc @@ -0,0 +1,53 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +rcyCNmnF2BmeRfC4BZH01wauC3yhbk7zBfg= +=VrwE +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/build.ts b/scripts/build.ts index 19468d7..c28a6d2 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -2,7 +2,7 @@ // Build (plan §4 M3, redesigned per issue #57): repo model → site/ — canonical JSON + // human HTML per entry, one stylesheet. Deterministic transform, no network, content // never altered. Usage: npm run build [-- --root ] [-- --out ] -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadRepo } from './lib/repo.ts'; @@ -19,6 +19,8 @@ const LIB_DIR = dirname(fileURLToPath(import.meta.url)); // checks.ts uses for validation. 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'); export interface BuildResult { entries: number; @@ -72,6 +74,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/.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 }); const issued = new Map([...releases].map(([path, release]) => [path, release.date])); writeFileSync(join(out, 'dictionary.ttl'), renderTurtle(repo, refs, issued)); return { entries, out }; diff --git a/test/build.test.ts b/test/build.test.ts index 6d2f261..f04f639 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -332,6 +332,20 @@ 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)', () => { + 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); + // 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'); + } finally { + rmSync(out, { recursive: true, force: true }); + } +}); + test('index footer links to the tree view and the schema reference page', () => { const out = buildGreen(); try { diff --git a/test/index-worker.test.ts b/test/index-worker.test.ts index 81be706..f7644bf 100644 --- a/test/index-worker.test.ts +++ b/test/index-worker.test.ts @@ -96,6 +96,23 @@ test('decide: JSON is the default; HTML only when Accept names text/html', () => assert.equal(browser.headers.link, `; rel="canonical", ; rel="cite-as"`); }); +test('decide: well-known URIs are typed and cached for a day, never immutable (#107)', () => { + const key = decide('/.well-known/pgp-security.asc', '*/*'); + 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')); + + // other well-known resources get the same ceiling, and the origin's own type + const other = decide('/.well-known/security.txt', '*/*'); + assert.equal(other.headers['cache-control'], 'public, max-age=86400'); + assert.equal(other.headers['content-type'], undefined); + + // .asc anywhere else is not special-cased + assert.equal(decide('/def/whatever.asc', '*/*').headers['content-type'], undefined); +}); + test('decide: everything else passes through with a short cache; no /concept route', () => { const uuid = 'c38a85eb-1a37-416d-ab21-7ddcc599754d'; diff --git a/worker/index.ts b/worker/index.ts index e66104e..4772c2e 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -26,6 +26,15 @@ export function decide(pathname: string, accept: string | null): RouteDecision { headers.link = wantsHtml ? `; rel="canonical", ${citeAs}` : citeAs; return { originPath: `/def/${uuid}.${wantsHtml ? 'html' : 'json'}`, headers }; } + // RFC 8615 well-known URIs (#107). Cacheable but never immutable like an entry: a key can be + // rotated or revoked, so a day is the ceiling. Pages would serve .asc as a generic byte + // stream, hence the explicit type. + if (pathname.startsWith('/.well-known/')) { + const headers: Record = { 'cache-control': 'public, max-age=86400' }; + if (pathname.endsWith('.asc')) headers['content-type'] = 'application/pgp-keys'; + return { originPath: pathname, headers }; + } + // index, pagination, styles, raw origin files: pass through with a short cache return { originPath: pathname === '/' ? '/index.html' : pathname, headers: { 'cache-control': 'public, max-age=300' } }; } From 12c9b88d2050ba6adb720d0ccda761329c80bf9c Mon Sep 17 00:00:00 2001 From: Hannes Stiebitzhofer Date: Mon, 14 Sep 2026 00:11:30 +0200 Subject: [PATCH 2/2] Add /.well-known/security.txt with a freshness check (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9116. Contact, Expires, Encryption pointing at the key added in #107, Preferred-Languages and Canonical. The worker serves .txt under .well-known/ as text/plain; charset=utf-8, which RFC 9116 requires, alongside the .asc rule. An expired security.txt is worse than none: tooling reads it as an unmonitored contact, and a static file rots without anyone noticing. So this is the ratchet rather than a calendar reminder — a validate check refuses the next PR when Expires is missing, unparseable, past, or more than a year out, and when Encryption names a key the repo does not ship. It is deliberately unnumbered: checks 1-6 are the entry rules and 7 is the CI two-yes gate; this one guards the repo's own contact, and it stays green on fixture trees, which carry no .well-known at all. Closes #109 --- .well-known/security.txt | 9 +++++ CLAUDE.md | 5 +++ scripts/lib/checks.ts | 60 +++++++++++++++++++++++++++- test/build.test.ts | 7 +++- test/checks.test.ts | 82 ++++++++++++++++++++++++++++++++++++++- test/index-worker.test.ts | 15 +++++-- test/validate.test.ts | 5 ++- worker/index.ts | 7 ++-- 8 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 .well-known/security.txt diff --git a/.well-known/security.txt b/.well-known/security.txt new file mode 100644 index 0000000..aeebd8b --- /dev/null +++ b/.well-known/security.txt @@ -0,0 +1,9 @@ +# Security contact for material-identity.eu and the dictionary it serves (RFC 9116). +# Please report anything that would let someone alter, forge, or make unavailable a +# published entry — those identifiers are cited by passports and cannot be recalled. + +Contact: mailto:security@s1seven.com +Expires: 2027-09-01T00:00:00.000Z +Encryption: https://material-identity.eu/.well-known/pgp-security.asc +Preferred-Languages: en, de +Canonical: https://material-identity.eu/.well-known/security.txt diff --git a/CLAUDE.md b/CLAUDE.md index 473d515..1525f93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,11 @@ section for the full explanation). - `worker/index.ts` — the canonical interface (content negotiation + cache headers) — only `/def/`, no `/concept/` route; `worker/wrangler.toml` — route `material-identity.eu/*`; never `wrangler deploy` locally +- `.well-known/` — RFC 8615 URIs copied wholesale into `site/` by the builder (add a file, no + 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/` - `REVIEW.md` — what reviewers check beyond CI; read it before reviewing any publish PR - `standards/` — local-only licensed docs; only its README is committed diff --git a/scripts/lib/checks.ts b/scripts/lib/checks.ts index 60ae1bc..872ea12 100644 --- a/scripts/lib/checks.ts +++ b/scripts/lib/checks.ts @@ -13,6 +13,8 @@ const Ajv2019 = (Ajv2019Module as unknown as { default?: typeof Ajv2019Module }) const addFormats = (addFormatsModule as unknown as { default?: typeof addFormatsModule }).default ?? addFormatsModule; const SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'dictionary-entry.schema.json'); +/** RFC 9116 recommends an expiry under a year; past it, tooling treats the contact as stale. */ +const SECURITY_TXT_MAX_MONTHS = 12; /** * Internal references that must be version-pinned to a published file (R5, plan §2.3). @@ -180,6 +182,56 @@ export function checkPinning(repo: RepoModel): ValidationIssue[] { return issues; } +/** + * security.txt freshness (#109). An *expired* security.txt is worse than none — tooling reads + * it as an unmonitored contact — and a static file rots silently, so the ratchet (plan §2.7) + * turns "remember to renew it" into a check that refuses the next PR instead. + */ +export function checkSecurityTxt(root: string, now: Date = new Date()): ValidationIssue[] { + const check = 'security.txt'; + const file = '.well-known/security.txt'; + const path = join(root, file); + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + return []; // a repo without one is fine (fixture trees have none); a stale one is not + } + + const issues: ValidationIssue[] = []; + const field = (name: string): string | undefined => + new RegExp(`^${name}:\\s*(.+)$`, 'mi').exec(text)?.[1].trim(); + + for (const required of ['Contact', 'Expires']) { + if (field(required) === undefined) issues.push({ check, file, message: `missing required field "${required}" (RFC 9116)` }); + } + + const expires = field('Expires'); + if (expires !== undefined) { + const at = new Date(expires); + if (Number.isNaN(at.getTime())) { + issues.push({ check, file, message: `Expires "${expires}" is not a valid timestamp` }); + } else if (at <= now) { + issues.push({ check, file, message: `Expires ${at.toISOString()} has passed — renew it; an expired security.txt reads as an unmonitored contact` }); + } else { + const ceiling = new Date(now); + ceiling.setMonth(ceiling.getMonth() + SECURITY_TXT_MAX_MONTHS); + if (at > ceiling) issues.push({ check, file, message: `Expires ${at.toISOString()} is more than ${SECURITY_TXT_MAX_MONTHS} months out (RFC 9116 recommends less)` }); + } + } + + const encryption = field('Encryption'); + if (encryption !== undefined && encryption.startsWith('https://material-identity.eu/')) { + const local = join(dirname(path), encryption.replace('https://material-identity.eu/.well-known/', '')); + try { + readFileSync(local); + } catch { + issues.push({ check, file, message: `Encryption points at ${encryption}, which is not present in .well-known/` }); + } + } + return issues; +} + /** Check 1 — immutability (R6): only additions are allowed under published/. */ export function checkImmutability(diff: DiffEntry[]): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -256,7 +308,10 @@ export interface CheckResult { skipped?: string; } -/** Run all validate.ts checks (1–6). Checks 1 and 6 need a git context. Check 7 (two-yes gate) lives in CI only. */ +/** + * Run all validate.ts checks (1–6, plus the unnumbered security.txt guard). Checks 1 and 6 need + * a git context. Check 7 (two-yes gate) lives in CI only. + */ export function runChecks(repo: RepoModel, git?: GitContext): CheckResult[] { const noGit = 'no git context (base unresolvable or root is not a work-tree top level)'; return [ @@ -271,5 +326,8 @@ export function runChecks(repo: RepoModel, git?: GitContext): CheckResult[] { git ? { name: 'check 6 — move purity', issues: checkMovePurity(repo, git) } : { name: 'check 6 — move purity', issues: [], skipped: noGit }, + // Deliberately unnumbered: 1–6 are the entry rules and 7 is the CI two-yes gate. This one + // guards the repo's own security contact, not the dictionary (#109). + { name: 'security.txt — RFC 9116 freshness', issues: checkSecurityTxt(repo.root) }, ]; } diff --git a/test/build.test.ts b/test/build.test.ts index f04f639..d23732e 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -332,7 +332,7 @@ 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)', () => { +test('the tracked .well-known directory is copied into the site byte-for-byte (#107, #109)', () => { const out = buildGreen(); try { const source = readFileSync(join(here, '..', '.well-known', 'pgp-security.asc')); @@ -341,6 +341,11 @@ test('the tracked .well-known directory is copied into the site byte-for-byte (# 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'); + assert.deepEqual(sec, readFileSync(join(here, '..', '.well-known', 'security.txt'), 'utf8')); + assert.match(sec, /^Canonical: https:\/\/material-identity\.eu\/\.well-known\/security\.txt$/m); } finally { rmSync(out, { recursive: true, force: true }); } diff --git a/test/checks.test.ts b/test/checks.test.ts index ff0890d..9185790 100644 --- a/test/checks.test.ts +++ b/test/checks.test.ts @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadRepo, type RepoModel, type ValidationIssue } from '../scripts/lib/repo.ts'; -import { checkSchema, checkIdentity, checkReplaces, checkPinning, runChecks } from '../scripts/lib/checks.ts'; +import { checkSchema, checkIdentity, checkReplaces, checkPinning, checkSecurityTxt, runChecks } from '../scripts/lib/checks.ts'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); const load = (name: string): RepoModel => loadRepo(join(fixtures, name)); @@ -88,7 +90,7 @@ test('check 5 — isDefinedBy and replaces are exempt from pinning', () => { test('runChecks aggregates load errors and all checks 1–6', () => { const results = runChecks(load('red-yaml')); - assert.equal(results.length, 7); + assert.equal(results.length, 8); const loadResult = results.find((r) => r.name === 'load'); assert.ok(loadResult && loadResult.issues.length === 1); const failing = results.filter((r) => r.issues.length > 0); @@ -99,3 +101,79 @@ test('runChecks aggregates load errors and all checks 1–6', () => { ['check 1 — immutability', 'check 6 — move purity'], ); }); + +// ------------------------------------------------------------------ security.txt (#109) + +/** Build a throwaway repo root holding a .well-known/ with the given files. */ +function wellKnownRoot(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'sectxt-')); + mkdirSync(join(root, '.well-known')); + for (const [name, body] of Object.entries(files)) writeFileSync(join(root, '.well-known', name), body); + return root; +} + +function withRoot(files: Record, fn: (root: string) => void): void { + const root = wellKnownRoot(files); + try { + fn(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const NOW = new Date('2026-09-14T00:00:00.000Z'); +const GOOD = [ + '# a comment line, ignored', + 'Contact: mailto:security@s1seven.com', + 'Expires: 2027-06-01T00:00:00.000Z', + 'Encryption: https://material-identity.eu/.well-known/pgp-security.asc', + '', +].join('\n'); + +test('security.txt — the repo\'s own file is well-formed and unexpired today', () => { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + assert.deepEqual(checkSecurityTxt(repoRoot, new Date()), []); +}); + +test('security.txt — a valid file with a resolvable Encryption key passes', () => { + withRoot({ 'security.txt': GOOD, 'pgp-security.asc': 'not a real key, only presence is checked' }, (root) => { + assert.deepEqual(checkSecurityTxt(root, NOW), []); + }); +}); + +test('security.txt — absent is fine; fixture trees must not be forced to carry one', () => { + assert.deepEqual(checkSecurityTxt(join(fixtures, 'green'), NOW), []); +}); + +test('security.txt — an expired file fails, which is the whole point of the check', () => { + withRoot({ 'security.txt': GOOD.replace('2027-06-01', '2026-09-13'), 'pgp-security.asc': 'x' }, (root) => { + const issues = checkSecurityTxt(root, NOW); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /has passed/); + assert.equal(issues[0].file, '.well-known/security.txt'); + }); +}); + +test('security.txt — an expiry beyond a year, an unparseable one, and missing fields all fail', () => { + withRoot({ 'security.txt': GOOD.replace('2027-06-01', '2028-01-01'), 'pgp-security.asc': 'x' }, (root) => { + assert.match(checkSecurityTxt(root, NOW)[0].message, /more than 12 months out/); + }); + withRoot({ 'security.txt': 'Contact: mailto:a@b.c\nExpires: soon\n' }, (root) => { + assert.match(checkSecurityTxt(root, NOW)[0].message, /not a valid timestamp/); + }); + withRoot({ 'security.txt': '# nothing but a comment\n' }, (root) => { + const messages = checkSecurityTxt(root, NOW).map((i) => i.message); + assert.deepEqual(messages, [ + 'missing required field "Contact" (RFC 9116)', + 'missing required field "Expires" (RFC 9116)', + ]); + }); +}); + +test('security.txt — an Encryption URL pointing at a key we do not ship fails', () => { + withRoot({ 'security.txt': GOOD }, (root) => { + const issues = checkSecurityTxt(root, NOW); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /pgp-security\.asc, which is not present/); + }); +}); diff --git a/test/index-worker.test.ts b/test/index-worker.test.ts index f7644bf..6cca10f 100644 --- a/test/index-worker.test.ts +++ b/test/index-worker.test.ts @@ -96,7 +96,7 @@ test('decide: JSON is the default; HTML only when Accept names text/html', () => assert.equal(browser.headers.link, `; rel="canonical", ; rel="cite-as"`); }); -test('decide: well-known URIs are typed and cached for a day, never immutable (#107)', () => { +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'); assert.equal(key.headers['content-type'], 'application/pgp-keys'); @@ -104,13 +104,20 @@ test('decide: well-known URIs are typed and cached for a day, never immutable (# assert.equal(key.headers['cache-control'], 'public, max-age=86400'); assert.ok(!key.headers['cache-control'].includes('immutable')); - // other well-known resources get the same ceiling, and the origin's own type - const other = decide('/.well-known/security.txt', '*/*'); + // 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.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 + const other = decide('/.well-known/openpgpkey/hu/abc', '*/*'); assert.equal(other.headers['cache-control'], 'public, max-age=86400'); assert.equal(other.headers['content-type'], undefined); - // .asc anywhere else is not special-cased + // .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); }); test('decide: everything else passes through with a short cache; no /concept route', () => { diff --git a/test/validate.test.ts b/test/validate.test.ts index d287e51..5a67e83 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -10,8 +10,9 @@ test('runValidation reports OK for the green tree', () => { const { ok, lines } = runValidation(join(fixtures, 'green')); assert.equal(ok, true); assert.equal(lines.filter((l) => l.startsWith('FAIL')).length, 0); - // load + checks 2–5 pass; checks 1 and 6 are skipped (fixture trees have no git context) - assert.equal(lines.filter((l) => l.startsWith('ok')).length, 5); + // load + checks 2–5 + the security.txt guard pass (a fixture tree carries none, which is + // green); checks 1 and 6 are skipped (fixture trees have no git context) + assert.equal(lines.filter((l) => l.startsWith('ok')).length, 6); assert.equal(lines.filter((l) => l.startsWith('skip')).length, 2); }); diff --git a/worker/index.ts b/worker/index.ts index 4772c2e..d58b967 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -26,12 +26,13 @@ export function decide(pathname: string, accept: string | null): RouteDecision { headers.link = wantsHtml ? `; rel="canonical", ${citeAs}` : citeAs; return { originPath: `/def/${uuid}.${wantsHtml ? 'html' : 'json'}`, headers }; } - // RFC 8615 well-known URIs (#107). Cacheable but never immutable like an entry: a key can be - // rotated or revoked, so a day is the ceiling. Pages would serve .asc as a generic byte - // stream, hence the explicit type. + // 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. if (pathname.startsWith('/.well-known/')) { const headers: Record = { '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 }; }