// Auth checks — ported from pear-git-v3 lib/auth/check.js.
//
// favela had ONE extraction path (a regex over the cookie header, inline in bin/favela-web.js) and no
// require* guards at all: every route hand-rolled `if (!viewer) return 401`. That is how /api/post
// shipped with no gate at all until 2026-08-11, when an anonymous curl posted as wj62 — the images
// branch of the SAME handler had a check, so the omission was invisible by inspection.
//
// Run: node test/check.test.mjs
import { SessionStore } from '../lib/sessions.js'
import {
AuthError, COOKIE_NAME, extractToken, extractIdentity,
isRevoked, UNKNOWN, requireIdentity, requireMember, requireRepoOwner, mayWrite
} from '../lib/check.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`)
const rejects = async (fn, status, m) => {
try { await fn() } catch (e) { return ok(e instanceof AuthError && e.status === status, m + ` (threw ${e.status} ${e.message})`) }
ok(false, m + ' (did NOT throw)')
}
const req = (headers) => ({ headers })
const DID = 'did:plc:checktest22222222222222'
const ID = { did: DID, subKey: 'beef' }
// ── extractToken ────────────────────────────────────────────────────
{
eq(extractToken(req({ authorization: 'Bearer abc123' })), 'abc123', 'reads a Bearer token')
eq(extractToken(req({ cookie: `${COOKIE_NAME}=tok999` })), 'tok999', 'reads the session cookie')
eq(extractToken(req({ cookie: `other=1; ${COOKIE_NAME}=tok999; x=2` })), 'tok999', 'finds the cookie among others')
eq(extractToken(req({ cookie: `${COOKIE_NAME}=a%2Fb` })), 'a/b', 'url-decodes the cookie value')
eq(extractToken(req({})), null, 'no credential → null')
eq(extractToken(req({ authorization: 'Basic zzz' })), null, 'a non-Bearer Authorization is ignored')
eq(extractToken(req({ cookie: 'malformed' })), null, 'a cookie header with no = does not throw')
eq(extractToken(null), null, 'a null request does not throw')
// The cookie is what a BROWSER sends automatically; the header is what an API client sets
// deliberately. When both are present the deliberate one wins.
eq(extractToken(req({ authorization: 'Bearer hdr', cookie: `${COOKIE_NAME}=cke` })), 'hdr', 'Bearer takes precedence over the cookie')
}
// ── extractIdentity ─────────────────────────────────────────────────
{
const sessions = new SessionStore()
const token = sessions.create(ID)
const got = extractIdentity(req({ cookie: `${COOKIE_NAME}=${token}` }), sessions)
eq(got.did, DID, 'a live cookie session resolves to its identity')
eq(extractIdentity(req({ authorization: `Bearer ${token}` }), sessions).did, DID, 'and so does the Bearer form')
eq(extractIdentity(req({ cookie: `${COOKIE_NAME}=bogus` }), sessions), null, 'an unknown token resolves to null, not a throw')
eq(extractIdentity(req({}), sessions), null, 'anonymous → null')
sessions.close()
}
// ── isRevoked + its failure policy ──────────────────────────────────
{
eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: async () => true }), true, 'a revoked device reports revoked')
eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: async () => false }), false, 'a live device reports not-revoked')
eq(await isRevoked({ subKey: 'beef' }), false, 'no revocation source configured → not revoked')
// The policy split that the pgv3 audit called out: a transient registry error must not lock every
// reader out, but it must not wave a WRITE through either.
const boom = async () => { throw new Error('registry unreachable') }
eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: boom }), false, 'read path FAILS OPEN on a registry error (pgv3 behaviour)')
// ⛔ A CHECK THAT COULD NOT RUN IS NOT A POSITIVE REVOCATION. Returning `true` here made the write
// path answer "this device has been revoked" to a user whose device was fine — the real cause was a
// read-only instance with no hpp to ask. It still DENIES; it must just say something true.
eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: boom, failOpen: false }), UNKNOWN,
'write path reports UNKNOWN, not revoked, when the check itself fails')
}
// ── requireIdentity ─────────────────────────────────────────────────
{
await rejects(() => requireIdentity({ identity: null }), 401, 'anonymous is refused')
await rejects(() => requireIdentity({}), 401, 'a ctx with no identity is refused')
await rejects(() => requireIdentity({ identity: { subKey: 'x' } }), 401, 'an identity with no DID is refused')
await requireIdentity({ identity: ID })
ok(true, 'a signed-in identity passes')
await rejects(() => requireIdentity({ identity: ID }, { isDeviceRevoked: async () => true }), 403, 'a REVOKED device is refused even with a valid session')
// Both deny — but a user must never be told they were revoked when nobody could check.
try {
await requireIdentity({ identity: ID }, { isDeviceRevoked: async () => { throw new Error('no hpp') }, failOpen: false })
ok(false, 'an unverifiable device should be refused')
} catch (e) {
ok(/could not be verified/i.test(e.message) && !/revoked/i.test(e.message),
`an unverifiable device is refused WITHOUT claiming revocation (got: ${e.message})`)
}
}
// ── requireMember — the seat for the invite gate ─────────────────────
{
await rejects(() => requireMember({ identity: ID }), 403, 'NO membership policy configured → DENY (fails closed)')
await rejects(() => requireMember({ identity: ID }, { isMember: async () => false }), 403, 'a non-member is refused')
await rejects(() => requireMember({ identity: null }, { isMember: async () => true }), 401, 'anonymous is refused BEFORE membership is consulted')
await requireMember({ identity: ID }, { isMember: async () => true })
ok(true, 'a member passes')
// Revocation outranks membership: a revoked device belonging to a member is still refused.
await rejects(
() => requireMember({ identity: ID }, { isMember: async () => true, isDeviceRevoked: async () => true }),
403, 'a revoked device is refused even though the DID is a member')
// A membership policy that throws must not become an open door.
await rejects(
() => requireMember({ identity: ID }, { isMember: async () => { throw new Error('room unreachable') } }),
403, 'a membership lookup that ERRORS denies rather than admits')
}
// ── requireRepoOwner — write ATTRIBUTION ─────────────────────────────
// The hole this closes: Writer talks to ONE hpp socket (the primary DID's), so writer.post() authors
// as that DID no matter who is signed in — while auth.login() accepts ANY did that proves a
// delegation from its own #atproto key. Composed: any atproto account could sign in and post as wj62.
{
const OTHER = 'did:plc:someoneelse999999999999'
await rejects(() => requireRepoOwner({ identity: ID }, { repoDid: OTHER }), 403,
'a signed-in stranger may NOT write to the repo this node signs for')
await rejects(() => requireRepoOwner({ identity: null }, { repoDid: DID }), 401,
'anonymous is refused first')
await requireRepoOwner({ identity: ID }, { repoDid: DID })
ok(true, 'the repo owner may write to their own repo')
// If we cannot establish WHICH repo the writer is bound to, we must not guess — an unknown target
// is the case where a wrong answer writes into someone else's history.
await rejects(() => requireRepoOwner({ identity: ID }, {}), 403, 'unknown repoDid → deny, never assume')
await rejects(() => requireRepoOwner({ identity: ID }, { repoDid: null }), 403, 'null repoDid → deny')
// DIDs are case-sensitive identifiers; a near-match is not a match.
await rejects(() => requireRepoOwner({ identity: { did: DID.toUpperCase(), subKey: 'x' } }, { repoDid: DID }), 403,
'a case-variant DID does not pass as the owner')
}
// ── mayWrite — non-throwing probe for SSR ────────────────────────────
{
eq(await mayWrite({ identity: null }, { isMember: async () => true }), false, 'anonymous may not write')
eq(await mayWrite({ identity: ID }, { isMember: async () => true }), true, 'a member may write')
eq(await mayWrite({ identity: ID }, { isMember: async () => false }), false, 'a non-member may not write')
eq(await mayWrite({ identity: ID }), false, 'no policy → false, matching requireMember')
eq(await mayWrite({ identity: ID }, { isMember: async () => { throw new Error('boom') } }), false, 'never throws — it is a rendering decision')
}
console.log(`\n${pass} assertions passed`)