// Auth checks and request middleware — PORTED from pear-git-v3 `lib/auth/check.js`.
//
// Two layers, same as pgv3:
//
// 1. extractIdentity(req, sessions) — reads the bearer token from `Authorization: Bearer <token>`
// OR the `favela_session` cookie, resolves it against the SessionStore, returns the identity or
// null. Never throws; the caller decides whether anonymous is acceptable.
//
// 2. requireIdentity(ctx) / requireMember(ctx) — handler-side guards that THROW an AuthError.
// Having these as guards rather than as an inline `if (!viewer)` in every route is the actual
// lesson from pgv3: its read gate sits at ONE choke point above ~14 routes precisely because
// fourteen hand-written checks are fourteen chances to forget. favela already paid that bill —
// /api/post had no gate until 2026-08-11, when an anonymous request posted as wj62, while the
// images branch of the same handler DID check. A named guard makes the omission visible.
//
// WHAT WAS DELIBERATELY NOT PORTED, and why:
//
// - The `transport === 'uds'` bypass. pgv3 trusts a local caller because holding the UDS socket is
// itself proof of local access. favela-web serves HTTP only; inventing a "local is trusted" rule
// here would be a bypass with nothing behind it. (favela's loopback handoff in lib/auth.js is a
// different thing: it converts a token the caller ALREADY holds into cookie form, granting nothing.)
//
// - `globalPermissions` / IdentityRegistry. pgv3 resolves a pubkey to a User record with global
// perms. favela has no such registry, and it should not grow a server-side one: membership belongs
// in the community room (`add-member` / `ban` ops, DID-rooted), which is replicated P2P and is the
// same authority the feed's roster already reads. So `requireMember` takes an INJECTED policy
// rather than assuming a store.
//
// AUTHENTICATION IS NOT AUTHORISATION. A valid session proves only "this browser holds a sub-key that
// the DID delegated". Any atproto DID can prove that about ITSELF, so a session alone must never imply
// the right to write — that is what `requireMember` is for, and why it fails CLOSED with no policy.
export const COOKIE_NAME = 'favela_session'
export class AuthError extends Error {
constructor (message, status = 401) {
super(message)
this.name = 'AuthError'
this.status = status
this.code = 'EAUTH'
}
}
/** Extract the bearer token from a request, header first, then cookie. */
export function extractToken (req) {
if (!req || !req.headers) return null
const auth = req.headers.authorization
// Header before cookie: the cookie is what a browser attaches automatically, the header is what a
// client sets deliberately. When both are present, honour the deliberate one.
if (auth && auth.startsWith('Bearer ')) return auth.slice(7).trim() || null
const cookieHeader = req.headers.cookie
if (cookieHeader) {
for (const part of cookieHeader.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
if (part.slice(0, idx).trim() !== COOKIE_NAME) continue
const raw = part.slice(idx + 1).trim()
if (!raw) return null
try { return decodeURIComponent(raw) } catch { return raw }
}
}
return null
}
/**
* Resolve the request's identity via the SessionStore. Sliding renewal is applied by the store.
* @returns {{ did: string, subKey?: string } | null}
*/
export function extractIdentity (req, sessions) {
const token = extractToken(req)
if (!token || !sessions) return null
const entry = sessions.get(token)
return entry ? entry.identity : null
}
/**
* Is this device revoked?
*
* pgv3's twin (isIdentityRevoked) exists because revocation lived in a SEPARATE view index — the
* identity record's own flag always read false, so testing it silently missed every revocation. Here
* the authoritative source is hpp's device registry, injected as `isDeviceRevoked(subKey)`.
*
* FAILURE POLICY is explicit, because the right answer differs by caller. pgv3 fails OPEN so that a
* transient registry read cannot deny every caller at once, and that is right for reads. It is NOT
* right for a write: there, an unreachable registry must not wave through a device that may have been
* revoked. Hence `failOpen`, defaulting to pgv3's behaviour and flipped by the write guards.
*/
export const UNKNOWN = 'unknown'
export async function isRevoked ({ subKey, isDeviceRevoked, failOpen = true } = {}) {
if (!subKey || typeof isDeviceRevoked !== 'function') return false
try {
return !!(await isDeviceRevoked(subKey))
} catch {
// ⛔ A CHECK THAT COULD NOT RUN IS NOT A POSITIVE REVOCATION. This returned `true` on the write
// path, so a read-only instance — one with no hpp to ask — told a signed-in user "this device has
// been revoked". Their device was fine; nobody could check. Reported live on favela.7rnx.net.
// Fail-closed still DENIES; it must simply not assert something untrue about the user's device.
return failOpen ? false : UNKNOWN
}
}
/**
* Require an authenticated, non-revoked identity. Does NOT imply any right to write.
* For self-scoped reads (your own devices, your own bookmarks) this is the correct guard.
*/
export async function requireIdentity (ctx, opts = {}) {
const identity = ctx && ctx.identity
if (!identity || !identity.did) throw new AuthError('authentication required')
const verdict = await isRevoked({ subKey: identity.subKey, ...opts })
if (verdict === UNKNOWN) throw new AuthError('device status could not be verified', 403)
if (verdict) throw new AuthError('this device has been revoked', 403)
}
/**
* Require a member — favela's analog of pgv3's requireWriter.
*
* `isMember(did)` is injected: today nothing supplies it, and that is why this DENIES without one.
* A guard whose unconfigured state is "allow" is not a guard; the whole reason this exists is that a
* valid session proves identity and nothing else.
*
* Order matters: authentication → revocation → membership. Revocation outranks membership so that a
* revoked device belonging to a member is still refused, and a membership lookup that THROWS denies
* rather than admits (an unreachable room is not consent).
*/
export async function requireMember (ctx, opts = {}) {
await requireIdentity(ctx, { ...opts, failOpen: opts.failOpen === undefined ? false : opts.failOpen })
const { isMember } = opts
if (typeof isMember !== 'function') {
throw new AuthError('membership policy not configured', 403)
}
let member = false
try {
member = !!(await isMember(ctx.identity.did))
} catch {
throw new AuthError('membership could not be verified', 403)
}
if (!member) throw new AuthError('membership required', 403)
}
/**
* Require that the caller OWNS the repo being written to.
*
* ⛔ THE HOLE THIS CLOSES. `Writer` connects to ONE hpp control socket — the primary DID's — so
* `writer.post()` authors as THAT DID regardless of who is signed in. Meanwhile `auth.login()` admits
* any DID that proves a delegation from its own #atproto key, and unknown DIDs are registered on
* sight. Composed, those two facts mean any atproto account could sign in here and post as wj62.
* A session proves WHO YOU ARE; it must never be mistaken for a right to write as SOMEONE ELSE.
*
* `repoDid` is the DID the writer is actually bound to — ask the hpp (`writer.state().did`), never
* assume. An unknown target DENIES: not knowing whose history you are about to append to is the
* worst possible moment to guess.
*
* This does not YET permit a co-owner to write to a community DID — that needs per-DID socket routing
* (hpp already exposes a socket per DID). It is the safety property; routing is additive on top.
*/
export async function requireRepoOwner (ctx, opts = {}) {
await requireIdentity(ctx, { ...opts, failOpen: opts.failOpen === undefined ? false : opts.failOpen })
const repoDid = opts.repoDid
if (!repoDid) throw new AuthError('cannot determine which repo this node writes to', 403)
// Exact match. A DID is an opaque, case-sensitive identifier; near-misses are not owners.
if (ctx.identity.did !== repoDid) {
throw new AuthError('you may only write to your own repo', 403)
}
}
/**
* Non-throwing probe: may this caller write? For SSR — decide whether to RENDER the composer at all,
* rather than rendering it and letting the POST fail. Mirrors pgv3's isAdmin(). Never throws.
*/
export async function mayWrite (ctx, opts = {}) {
try {
await requireMember(ctx, opts)
return true
} catch {
return false
}
}
export default { UNKNOWN, extractToken, extractIdentity, requireIdentity, requireMember, requireRepoOwner, mayWrite, isRevoked, AuthError, COOKIE_NAME }