/**
* R3/R5/R6/R7 — gateway edge header & advertisement bundle.
*
* Pins the lane × ingress matrix documented in packages/core/gateway/edge-headers.js:
* - R3: the shared-origin path lane is stateless-only — Service-Worker-Allowed
* never reaches the wire, and a drive cannot set it through content
* (entry metadata is never mapped to headers; the commit-time guard strips
* even a header handed straight to writeHead).
* - R5: onion ingress (a `.onion` Host on the read plane) carries the exact
* restrictive CSP default; clearnet lanes carry none.
* - R6: COOP/CORP/Referrer-Policy on both lanes — CORP cross-origin on the
* compatibility path lane (matches its ACAO:* posture), same-origin on the
* isolated app-origin lane.
* - R7: Link: <hive://<key>/<path>>; rel="canonical" on every admitted
* path-lane response, completing the app-origin lane's existing hint.
*/
import test from 'brittle'
import http from 'http'
import { Readable } from 'stream'
import { HyperGateway } from 'p2p-hiverelay/gateway'
import { issueExactAppContext } from '../../packages/core/gateway/exact-app-context.js'
import {
ONION_READ_PLANE_CSP,
SERVICE_WORKER_ALLOWED_HEADER,
buildHivePathLinkHeader,
guardPathLaneStatelessHeaders,
isOnionReadPlaneHost
} from 'p2p-hiverelay/gateway/edge-headers.js'
const KEY = 'a'.repeat(64)
const OTHER_KEY = 'b'.repeat(64)
const ONION_HOST = 'ftp4zc2one5hc2e3sev5hoddho63hm2dfwfh7pkcpqdc4k5z5ykaaaaa.onion'
function fakeDrive (files = {}) {
return {
closed: false,
closing: false,
version: 1,
async ready () {},
async update () {},
async close () { this.closed = true },
async entry (filePath) {
const data = files[filePath]
if (!data) return null
return { value: { blob: { byteLength: data.length }, metadata: data.metadata || null } }
},
async get (filePath) {
return files[filePath] || null
},
createReadStream (filePath, opts = {}) {
const data = files[filePath] || Buffer.alloc(0)
const start = opts.start || 0
const end = opts.length == null ? data.length : start + opts.length
return Readable.from([data.subarray(start, end)])
},
checkout () { return fakeDrive(files) },
async * list () {}
}
}
async function bootGatewayWithDrive (t, drive, gatewayOpts = {}, seededEntryExtras = {}) {
const node = {
config: { gatewayPublicOnlyPrivacyTier: true },
seededApps: new Map([[KEY, { drive, blind: false, privacyTier: 'public', ...seededEntryExtras }]])
}
const gateway = new HyperGateway(node, gatewayOpts)
const server = http.createServer((req, res) => gateway.handle(req, res))
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
t.teardown(async () => {
if (typeof server.closeAllConnections === 'function') {
try { server.closeAllConnections() } catch (_) {}
}
await new Promise(resolve => server.close(resolve))
await gateway.close()
})
return {
gateway,
port: server.address().port,
path: (filePath = '/data.bin') => `/v1/hyper/${KEY}${filePath}`
}
}
async function bootGateway (t, files = { '/data.bin': Buffer.from('0123456789abcdef') }, gatewayOpts = {}) {
return bootGatewayWithDrive(t, fakeDrive(files), gatewayOpts)
}
async function bootExactGatewayWithEntry (
t,
entry,
files = { '/index.html': Buffer.from('<h1>hello</h1>') },
publicAppKeys = [KEY]
) {
const drive = fakeDrive(files)
const node = {
config: { gatewayPublicOnlyPrivacyTier: false, hiveAppPublicKeys: publicAppKeys },
seededApps: new Map([[KEY, { drive, ...entry }]])
}
const gateway = new HyperGateway(node)
const server = http.createServer((req, res) => {
const path = new URL(req.url, 'http://localhost').pathname
gateway.handle(req, res, issueExactAppContext({
appKey: KEY,
path,
byteMode: 'exact',
publicAppKeys
}))
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
t.teardown(async () => {
if (typeof server.closeAllConnections === 'function') {
try { server.closeAllConnections() } catch (_) {}
}
await new Promise(resolve => server.close(resolve))
await gateway.close()
})
return { gateway, port: server.address().port }
}
function request (port, method, path, headers = {}) {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port,
method,
path,
agent: false,
headers: { Connection: 'close', ...headers }
}, (res) => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const raw = Buffer.concat(chunks)
let body = raw
try { body = JSON.parse(raw.toString('utf8')) } catch (_) {}
resolve({ statusCode: res.statusCode, headers: res.headers, body, raw })
})
})
req.on('error', reject)
req.end()
})
}
function expectPathLaneEdgeHeaders (t, res, label) {
t.is(res.headers['cross-origin-opener-policy'], 'same-origin', `${label}: COOP isolates the browsing context`)
t.is(res.headers['cross-origin-resource-policy'], 'cross-origin', `${label}: CORP matches the compatibility lane's ACAO:* posture`)
t.is(res.headers['referrer-policy'], 'no-referrer', `${label}: Referrer-Policy never leaks the key/path being read`)
t.absent(res.headers['content-security-policy'], `${label}: clearnet path lane carries no gateway CSP`)
t.absent(res.headers['service-worker-allowed'], `${label}: no service-worker scope escape on the shared origin`)
}
test('path lane emits R6 edge headers + R7 canonical Link on every response class', async (t) => {
const html = Buffer.from('<script src="/assets/app.js"></script>')
const ctx = await bootGateway(t, {
'/data.bin': Buffer.from('0123456789abcdef'),
'/index.html': html
})
const file = await request(ctx.port, 'GET', ctx.path('/data.bin'))
t.is(file.statusCode, 200)
expectPathLaneEdgeHeaders(t, file, 'streamed file')
t.is(file.headers.link, `<hive://${KEY}/data.bin>; rel="canonical"`, 'R7 upgrade hint names the canonical hive:// URI')
const page = await request(ctx.port, 'GET', ctx.path('/index.html'))
t.is(page.statusCode, 200)
expectPathLaneEdgeHeaders(t, page, 'transformed HTML')
t.is(page.headers.link, `<hive://${KEY}/index.html>; rel="canonical"`)
const listing = await request(ctx.port, 'GET', ctx.path('/'))
t.is(listing.statusCode, 200)
expectPathLaneEdgeHeaders(t, listing, 'directory listing')
t.is(listing.headers.link, `<hive://${KEY}/>; rel="canonical"`, 'directory URLs hint at the canonical drive root')
const missing = await request(ctx.port, 'GET', ctx.path('/nope.txt'))
t.is(missing.statusCode, 404)
expectPathLaneEdgeHeaders(t, missing, 'JSON error')
t.is(missing.headers.link, `<hive://${KEY}/nope.txt>; rel="canonical"`)
const method = await request(ctx.port, 'POST', ctx.path('/data.bin'))
t.is(method.statusCode, 405)
expectPathLaneEdgeHeaders(t, method, 'method rejection')
t.absent(method.headers.link, 'no canonical hint before a key was parsed')
const traversal = await request(ctx.port, 'GET', ctx.path('/%252e%252e/etc/passwd'))
t.is(traversal.statusCode, 403)
t.absent(traversal.headers.link, 'rejected paths get no canonical hint')
t.is(traversal.headers['cross-origin-opener-policy'], 'same-origin')
const head = await request(ctx.port, 'HEAD', ctx.path('/data.bin'))
t.is(head.statusCode, 200)
expectPathLaneEdgeHeaders(t, head, 'HEAD')
t.is(head.headers.link, `<hive://${KEY}/data.bin>; rel="canonical"`)
})
test('path-lane Link hint survives admission refusals that point at P2P', async (t) => {
const ctx = await bootGatewayWithDrive(t, fakeDrive({ '/index.html': Buffer.from('<p>x</p>') }), {}, { blind: true })
const res = await request(ctx.port, 'GET', ctx.path('/index.html'))
t.is(res.statusCode, 403, 'blind content stays hard-403')
t.is(res.body.blind, true)
t.is(res.headers.link, `<hive://${KEY}/index.html>; rel="canonical"`,
'the refusal and the upgrade hint agree: use the native P2P transport')
expectPathLaneEdgeHeaders(t, res, 'blind refusal')
})
test('app-origin lane applies same-origin CORP and keeps its describedby Link', async (t) => {
const ctx = await bootExactGatewayWithEntry(t, {
blind: false,
privacyTier: 'public',
storageClass: 'persistent',
availabilityClass: 'always-on'
})
const file = await request(ctx.port, 'GET', '/index.html')
t.is(file.statusCode, 200)
t.is(file.headers['cross-origin-opener-policy'], 'same-origin')
t.is(file.headers['cross-origin-resource-policy'], 'same-origin',
'isolated app origins deny no-cors subresource embedding — they never inherit compatibility CORS')
t.is(file.headers['referrer-policy'], 'no-referrer')
t.is(file.headers['origin-agent-cluster'], '?1', 'existing app-origin posture unchanged')
t.absent(file.headers['content-security-policy'], 'clearnet app lane carries no gateway CSP')
t.is(file.headers.link, `<hive://${KEY}/index.html>; rel="canonical", </.well-known/hiverelay-app.json>; rel="describedby"`,
'the app lane keeps its existing canonical + describedby hint byte-for-byte')
const missing = await request(ctx.port, 'GET', '/nope.txt')
t.is(missing.statusCode, 404)
t.is(missing.headers['cross-origin-resource-policy'], 'same-origin', 'generated app-lane errors carry the same R6 posture')
const listing = await request(ctx.port, 'GET', '/')
t.is(listing.statusCode, 200)
t.is(listing.headers['cross-origin-resource-policy'], 'same-origin')
t.is(listing.headers.link, `<hive://${KEY}/>; rel="canonical", </.well-known/hiverelay-app.json>; rel="describedby"`)
})
test('onion ingress carries the documented CSP default; clearnet does not', async (t) => {
const ctx = await bootGateway(t, {
'/data.bin': Buffer.from('0123456789abcdef'),
'/index.html': Buffer.from('<script src="/assets/app.js"></script>')
})
const onion = await request(ctx.port, 'GET', ctx.path('/data.bin'), { Host: ONION_HOST })
t.is(onion.statusCode, 200)
t.is(onion.headers['content-security-policy'], ONION_READ_PLANE_CSP,
'the exact documented restrictive default — pinned byte-for-byte')
t.ok(onion.headers['content-security-policy'].includes("script-src 'none'"), 'no script execution on the read plane')
t.ok(onion.headers['content-security-policy'].includes("connect-src 'none'"), 'no network beacons on the read plane')
t.is(onion.headers['cross-origin-opener-policy'], 'same-origin')
t.is(onion.headers['cross-origin-resource-policy'], 'cross-origin')
t.is(onion.headers['referrer-policy'], 'no-referrer')
t.is(onion.headers.link, `<hive://${KEY}/data.bin>; rel="canonical"`)
t.absent(onion.headers['service-worker-allowed'])
const onionPage = await request(ctx.port, 'GET', ctx.path('/index.html'), { Host: ONION_HOST })
t.is(onionPage.statusCode, 200)
t.is(onionPage.headers['content-security-policy'], ONION_READ_PLANE_CSP,
'transformed HTML stays renderable (self + inline style) and scriptless')
const onionError = await request(ctx.port, 'GET', ctx.path('/nope.txt'), { Host: `${ONION_HOST}:9200` })
t.is(onionError.statusCode, 404)
t.is(onionError.headers['content-security-policy'], ONION_READ_PLANE_CSP,
'the policy covers generated errors too, port suffix and all')
const clearnet = await request(ctx.port, 'GET', ctx.path('/data.bin'), { Host: 'gateway.example' })
t.is(clearnet.statusCode, 200)
t.absent(clearnet.headers['content-security-policy'], 'clearnet path lane stays CSP-free (legacy compatibility)')
const lookalike = await request(ctx.port, 'GET', ctx.path('/data.bin'), { Host: 'not-really.onion.example.com' })
t.absent(lookalike.headers['content-security-policy'], 'a .onion lookalike suffix is not onion ingress')
const head = await request(ctx.port, 'HEAD', ctx.path('/data.bin'), { Host: ONION_HOST })
t.is(head.statusCode, 200)
t.is(head.headers['content-security-policy'], ONION_READ_PLANE_CSP, 'HEAD carries the same onion headers')
t.is(head.raw.length, 0)
})
test('R3: a drive cannot set Service-Worker-Allowed through content or metadata', async (t) => {
const swScript = Buffer.from('self.addEventListener("fetch", () => {})')
swScript.metadata = { 'Service-Worker-Allowed': '/' }
const html = Buffer.from('<script>navigator.serviceWorker.register("./sw.js", { scope: "/" })</script>')
const ctx = await bootGateway(t, { '/sw.js': swScript, '/index.html': html })
const sw = await request(ctx.port, 'GET', ctx.path('/sw.js'))
t.is(sw.statusCode, 200)
t.absent(sw.headers['service-worker-allowed'],
'entry metadata never becomes a response header — a worker script cannot widen its scope past its key prefix')
const page = await request(ctx.port, 'GET', ctx.path('/index.html'))
t.is(page.statusCode, 200)
t.absent(page.headers['service-worker-allowed'],
'page content cannot smuggle the header either; registration stays capped at the script\'s own directory')
})
test('R3: the commit-time guard strips the header even if gateway code sets it', async (t) => {
const server = http.createServer((req, res) => {
guardPathLaneStatelessHeaders(res)
res.setHeader(SERVICE_WORKER_ALLOWED_HEADER, '/')
if (req.url === '/object') {
res.writeHead(200, { [SERVICE_WORKER_ALLOWED_HEADER]: '/', 'X-Probe': 'object' })
} else {
res.writeHead(200, [SERVICE_WORKER_ALLOWED_HEADER, '/', 'X-Probe', 'array'])
}
res.end('ok')
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
t.teardown(async () => {
if (typeof server.closeAllConnections === 'function') {
try { server.closeAllConnections() } catch (_) {}
}
await new Promise(resolve => server.close(resolve))
})
const port = server.address().port
for (const path of ['/object', '/array']) {
const res = await request(port, 'GET', path)
t.is(res.statusCode, 200)
t.absent(res.headers['service-worker-allowed'], `${path}: stripped from both the setHeader queue and the writeHead argument`)
t.ok(res.headers['x-probe'], `${path}: unrelated headers pass through`)
t.is(res.raw.toString(), 'ok')
}
})
test('onion read-plane Host detection is strict', async (t) => {
t.ok(isOnionReadPlaneHost(ONION_HOST))
t.ok(isOnionReadPlaneHost(`${ONION_HOST}:80`), 'explicit port still counts')
t.ok(isOnionReadPlaneHost(ONION_HOST.toUpperCase()), 'case-insensitive')
t.ok(isOnionReadPlaneHost(`${ONION_HOST}.`), 'trailing root dot still counts (fail-safe toward the stricter policy)')
t.absent(isOnionReadPlaneHost('not-really.onion.example.com'))
t.absent(isOnionReadPlaneHost('onion.example.com'))
t.absent(isOnionReadPlaneHost('gateway.example'))
t.absent(isOnionReadPlaneHost('127.0.0.1:9100'))
t.absent(isOnionReadPlaneHost('[::1]:9100'), 'IPv6 literal is never an onion name')
t.absent(isOnionReadPlaneHost(''))
t.absent(isOnionReadPlaneHost(undefined))
t.absent(isOnionReadPlaneHost(null))
})
test('R7 path-lane Link builder percent-encodes like the app lane', async (t) => {
t.is(buildHivePathLinkHeader(KEY, '/data.bin'), `<hive://${KEY}/data.bin>; rel="canonical"`)
t.is(buildHivePathLinkHeader(KEY, '/'), `<hive://${KEY}/>; rel="canonical"`)
t.is(
buildHivePathLinkHeader(KEY, '/dir/some file.html'),
`<hive://${KEY}/dir/some%20file.html>; rel="canonical"`,
'paths are URI-encoded through the same URL machinery as the app lane'
)
t.is(buildHivePathLinkHeader(OTHER_KEY, '/data.bin'), `<hive://${OTHER_KEY}/data.bin>; rel="canonical"`)
})