Every link https, and a gate so it stays that way

1,237 of the URLs we ship were plain http — all of eBible.org, png.bible and alkitab.pw,
with the host spelled `eBible.org` on six of them. Every one of those hosts serves https
directly (sampled 12/12 at 200) and redirects http there anyway, so the plain link bought
nothing but a redirect hop and a downgraded scheme in a field worker's browser, on a page
that may itself be embedded over https.

NORMALISED WHERE A URL ENTERS THE INDEX, not per fetcher. Doing it in the eBible fetcher
first left 120 links behind — the curated scripture_url values, which fill-never-overwrite
preserves and which therefore never pass through a fetcher at all. One httpsify at every
emit point takes it to 3,548 https and 0 http.

Scheme and HOST only. The path is left exactly as published, because a path is
case-sensitive and "tidying" it would break the link rather than improve it.

★ THE GATE CAUGHT MY OWN DRIFT, ten seconds after I added the clause. Clause G failed —
but as clause E, "1237 table URL(s) exist in no pin record": I had rebuilt the pins and
not the table, so the two surfaces disagreed about every eBible link in the corpus. That
is precisely the class this gate was written for last tick, and it found it on its first
real use rather than three ticks later by hand.

Clause G is poisoned in the self-test alongside the rest: 7/7 clauses now fail on demand.

Verified: 0 plain-http URLs remain, six upgraded links sampled live all answer 200 over
https, the coverage gate passes, and pins, table, deep-link, tooltip and list probes all
still pass.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 06:15:58 +00:00
parent 799345cba2
commit 7b27bf1b83
12 changed files with 56 additions and 19 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -22,7 +22,13 @@
* D. the table covers EVERY code that has a resource — the "every link" claim;
* E. every resource_url in the table exists in the pin index, so the table cannot
* invent a link the map does not hold;
* F. each pin's tier agrees with the URLs its code actually carries.
* F. each pin's tier agrees with the URLs its code actually carries;
* G. no URL is plain http. Every host we ship serves https directly and redirects
* http there anyway, so a plain link only buys a redirect hop and a downgraded
* scheme in a field worker's browser. 1,237 of them shipped that way until the
* normalisation moved to the point where a URL ENTERS the index — doing it
* per-source had left the 120 curated links behind, since fill-never-overwrite
* preserves those and they never passed through a fetcher.
*
* ★ SELF-TEST: run with --poison to inject each defect in memory and confirm the clause
* catches it. A check that has never been shown to fail proves nothing, and five of the
@ -60,7 +66,7 @@ if (!rows?.length || !trows?.length || !Object.keys(codes).length) {
if (POISON) {
// one injected defect per clause, in memory only
codes.und = { l: 'Undetermined', d: [{ s: 'Open Bible Stories', u: 'https://example.invalid/und' }] } // A
codes.zzq = { l: '', s: 'http://ebible.org/zzq/' } // B
codes.zzq = { l: '', s: 'http://ebible.org/zzq/' } // B + G
trows = [{ ...trows[0], people_groups: (trows[0].people_groups || 0) + 999 }, // C
...trows.slice(1, -1),
{ ...trows[trows.length - 1], resource_url: 'https://example.invalid/invented' }] // E
@ -101,6 +107,15 @@ for (const v of Object.values(codes)) {
}
const invented = trows.filter((t) => t.resource_url && !known.has(t.resource_url))
if (invented.length) add('E', `${invented.length} table URL(s) exist in no pin record (e.g. ${invented[0].resource_url})`)
// G — no plain-http URL
const httpUrls = []
for (const [c, v] of Object.entries(codes)) {
for (const u of [v.s, v.f, ...(v.d || []).map((d) => d.u)]) {
if (u && /^http:\/\//i.test(u)) httpUrls.push(`${c} ${u}`)
}
}
if (httpUrls.length) add('G', `${httpUrls.length} plain-http URL(s) (e.g. ${httpUrls[0]})`)
// F — tier agrees with the URLs the code holds
let fBad = 0
for (const r of rows) {
@ -116,7 +131,7 @@ if (POISON) {
// resources dropped from a table titled "every link", and the most valuable clause is
// the worst one to leave unproven. The injected codes above (und, zzq) are absent from
// the table, so they poison D as well as A and B.
const want = ['A', 'B', 'C', 'D', 'E', 'F']
const want = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
const caught = want.filter((c) => clauses.has(c))
console.log(`COVERAGE-CONSISTENT --poison: ${caught.length}/${want.length} clause(s) caught their injected defect (${caught.join(',')})`)
if (caught.length !== want.length) {

View file

@ -52,10 +52,20 @@ function labelD43(items) {
for (const i of items) (bySubject[i.subject] ||= []).push(i)
return items.map((i) => ({
s: bySubject[i.subject].length > 1 && i.owner ? `${i.subject} (${i.owner})` : i.subject,
u: i.url,
u: httpsify(i.url),
}))
}
// ONE normalisation for every source, applied where a URL ENTERS the index rather than
// in each fetcher. Doing it per-source left 120 plain-http links behind — the curated
// scripture_url values, which fill-never-overwrite preserves and which therefore never
// passed through the catalogue's own normaliser. Scheme and host only; the path is
// case-sensitive and is left exactly as published.
const httpsify = (u) => {
const m = /^https?:\/\/([^/]+)(\/.*)?$/i.exec(String(u || '').trim())
return m ? `https://${m[1].toLowerCase()}${m[2] || ''}` : String(u || '').trim()
}
const rowsOf = (p) => {
const d = JSON.parse(readFileSync(p, 'utf8'))
if (Array.isArray(d)) return d
@ -81,7 +91,7 @@ try { arc = JSON.parse(readFileSync(ARC, 'utf8')).codes || {} } catch { arc = {}
// exists it replaces that URL entirely rather than sitting alongside it.
let share = {}
try { share = JSON.parse(readFileSync(ARC_SHARE, 'utf8')).codes || {} } catch { share = {} }
const filmUrl = (code, aa) => share[code] || (aa ? aa.u : '')
const filmUrl = (code, aa) => httpsify(share[code] || (aa ? aa.u : ''))
// codes: one entry per ISO 639-3 that actually carries something worth linking to.
const codes = {}
@ -103,9 +113,9 @@ for (const r of res) {
// link so the UI can say "Stories" rather than implying a Bible translation.
...(dd && dd.items.length ? { d: labelD43(dd.items) } : {}),
l: r.language || '',
...((s0 || ebUrl) && { s: s0 || ebUrl }),
...((s0 || ebUrl) && { s: httpsify(s0 || ebUrl) }),
...(!s0 && ebUrl ? { sv: ee.t || '', sf: ee.full ? 1 : 0 } : {}),
...((f || arcUrl) && { f: f || arcUrl }),
...((f || arcUrl) && { f: httpsify(f || arcUrl) }),
// has_audio carries a meaningful third state: 'unknown' means a pool we could not
// query (key-blocked), NOT a no. Collapsing it to a boolean would turn "we did not
// look" into "there is nothing" — the exact dishonesty the sheet's note guards.
@ -142,7 +152,7 @@ for (const [c, aa] of Object.entries(arc)) {
// An existing film link that is the ignored ?languageId= form is REPLACED where a real
// deep link exists: keeping it would mean curation preserving a URL that goes nowhere
// specific, which is not what the fill-never-overwrite rule is protecting.
if (codes[c]?.f && share[c] && /[?&]languageId=/.test(codes[c].f)) { codes[c].f = share[c]; upgraded++ }
if (codes[c]?.f && share[c] && /[?&]languageId=/.test(codes[c].f)) { codes[c].f = httpsify(share[c]); upgraded++ }
if (codes[c]) { if (!codes[c].f) codes[c].f = filmUrl(c, aa); continue }
codes[c] = { l: aa.n || '', f: filmUrl(c, aa), ck: 'arclight', un: 'ebible, door43, grn, api_bible, dbl, progress_bible' }
arcOnly++
@ -150,7 +160,7 @@ for (const [c, aa] of Object.entries(arc)) {
for (const [c, ee] of Object.entries(eb)) {
if (codes[c]) {
// code exists but came from the Door43-only pass above and has no scripture yet
if (!codes[c].s) { codes[c].s = ee.u; codes[c].sv = ee.t || ''; codes[c].sf = ee.full ? 1 : 0 }
if (!codes[c].s) { codes[c].s = httpsify(ee.u); codes[c].sv = ee.t || ''; codes[c].sf = ee.full ? 1 : 0 }
continue
}
codes[c] = { l: ee.ln || '', s: ee.u, sv: ee.t || '', sf: ee.full ? 1 : 0, ck: 'ebible', un: 'arclight, grn, api_bible, dbl, progress_bible' }

View file

@ -61,6 +61,18 @@ function parseCsv(s) {
return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ''])))
}
// HTTPS, and the host lower-cased. The catalogue publishes plain http:// for all 1,237
// of these and spells the host `eBible.org` on 6 of them. Every host here serves https
// directly (verified 12/12 on a sample) and redirects http there anyway, so the plain
// link only buys a redirect hop and a downgraded scheme in a field worker's browser.
// ONLY the scheme and the HOST are normalised — the path is left exactly as published,
// because a path is case-sensitive and "fixing" it would break the link.
const httpsify = (u) => {
const m = /^https?:\/\/([^/]+)(\/.*)?$/i.exec(String(u || '').trim())
if (!m) return String(u || '').trim()
return `https://${m[1].toLowerCase()}${m[2] || ''}`
}
const num = (v) => (String(v).trim().match(/^\d+$/) ? Number(v) : 0)
const rows = parseCsv(text)
// badUrl is kept SEPARATE from badDomain: they are different defects and folding them
@ -73,7 +85,7 @@ const stats = { total: rows.length, noCanon: 0, badUrl: 0, badDomain: 0, kept: 0
const best = {}
for (const r of rows) {
const code = (r.languageCode || '').trim()
const url = (r.publicationURL || '').trim()
const url = httpsify(r.publicationURL || '')
if (!code || code.length !== 3 || !url) continue
const books = num(r.OTbooks) + num(r.NTbooks)
if (books === 0) { stats.noCanon++; continue }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long