// The served module graph must be CLOSED, and the hashing must need no secure context.
//
// ⛔ TWO FAILURES THAT LOOK THE SAME FROM A BROWSER. A missing transitive import and a missing
// `crypto.subtle` both surface as a sign-in page that does nothing — no server log, no useful console
// line, nothing the operator can act on. Both were real here:
//
// 1. The allowlist held only the three files anproto-client.js imports DIRECTLY, while two of those
// import ./noble-crypto.js. The entry point served fine and its dependency 404'd.
// 2. hash() used `crypto.subtle`, which exists only on HTTPS or localhost. Over plain HTTP at
// http://stortron.local:3401 — which is the whole point of this surface — it is undefined.
// ⛔ FIRST — ap-bridge's own globals shim, for the same reason index.js imports it first: deps touch
// TextEncoder at module load and bare does not define it.
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import { join } from 'bare-path'
import { staticAsset } from '../lib/admin-surface.js'
const ROOT = new URL('..', import.meta.url).pathname
const ENTRY = 'anproto-client.js'
// Every `import ... from './x.js'` / `import "./x.js"` in a served file.
function localImports (src) {
const out = new Set()
for (const m of src.matchAll(/import\s*(?:[^'"]*?from\s*)?['"]\.\/([^'"]+)['"]/g)) out.add(m[1])
return out
}
test('★★★ every module the client pulls in is itself served', (t) => {
// ⛔ WALKED, not listed. A hand-kept list of "the files it needs" is the thing that went wrong; this
// follows the imports so adding a dependency without serving it fails here rather than in a browser.
const seen = new Set()
const queue = [ENTRY]
while (queue.length) {
const name = queue.pop()
if (seen.has(name)) continue
seen.add(name)
const served = staticAsset(name, ROOT)
t.ok(served, name + ' is served')
if (!served) continue
for (const dep of localImports(String(served.body))) queue.push(dep)
}
t.ok(seen.size >= 4, 'the graph has real depth (' + seen.size + ' modules), so this is not vacuous')
t.ok(seen.has('noble-crypto.js'), 'including the transitive one the first list forgot')
})
// ⚠ Comments are stripped first. The claim is about what the client CALLS, and the file quite
// deliberately explains in prose why it no longer calls it — a grep over raw text fails on its own
// explanation, which is a test measuring the wrong thing rather than a real finding.
const codeOf = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/\/\/.*$/gm, '')
test('★★★ the client needs no secure context to hash', (t) => {
const code = codeOf(String(staticAsset(ENTRY, ROOT).body))
t.absent(/crypto\s*\.\s*subtle/.test(code),
'crypto.subtle exists only on HTTPS or localhost — this surface is reached on a LAN name over HTTP')
t.ok(/sha256\(/.test(code), 'it hashes with the vendored pure-JS implementation instead')
})
test('★★ nothing outside the graph is reachable', (t) => {
for (const evil of ['../lib/admin.js', '../package.json', '../../favela-peer/lib/browsers.js', 'index.js']) {
t.absent(staticAsset(evil, ROOT), evil + ' is not served')
}
})
test('★★ the vendored hash is the one the server verifies against', async (t) => {
// ⚠ If these ever diverge the signature VERIFIES and then fails the hash check — the failure lands
// one layer away from the cause, which is why it is asserted rather than assumed.
const { sha256 } = await import('../static/noble-sha256.js')
const { an } = await import('anproto')
const b4a = (await import('b4a')).default
for (const s of ['hello', '', 'a challenge string']) {
const mine = b4a.toString(b4a.from(sha256(new TextEncoder().encode(s))), 'base64')
t.is(mine, await an.hash(s), 'sha256 matches an.hash for ' + JSON.stringify(s))
}
})