| … | … | @@ -52,7 +52,7 @@ import crypto from 'hypercore-crypto' |
| 52 | 52 | import bareWs from 'bare-ws' |
| 53 | 53 | import { timelinePage, profilePage, tagPage, renderAtom, esc } from '../lib/render.js' |
| 54 | 54 | import { FavelaAuth, handoffDecision } from '../lib/auth.js' |
| 55 | | import { extractToken, extractIdentity } from '../lib/check.js' |
| 55 | import { extractToken, extractIdentity, requireIdentity, requireRepoOwner } from '../lib/check.js' |
| 56 | 56 | import { joinPairInvite, createPairInvite } from '../lib/pairing.js' |
| 57 | 57 | |
| 58 | 58 | // Live device-invite tunnels, did -> { inviteHex, state, done, src }. In memory because a blind-pairing |
| … | … | @@ -971,9 +971,35 @@ const COOKIE = 'favela_session' |
| 971 | 971 | // from the query string, never from here. |
| 972 | 972 | const parseCookie = (req) => extractToken(req) |
| 973 | 973 | const viewerOf = (req) => extractIdentity(req, auth.sessions) |
| 974 | |
| 975 | // WHICH repo does this node write to? Asked of the hpp, never assumed — and cached, because the write |
| 976 | // gate consults it on every mutating request and it is a property of the node, not of the request. |
| 977 | // Unresolved stays null so the gate DENIES: an instance with no write transport (the stortron lens) |
| 978 | // must refuse writes, not guess a target. |
| 979 | let _repoDid = null |
| 980 | const repoDidOnce = async () => { |
| 981 | if (_repoDid) return _repoDid |
| 982 | try { _repoDid = ((await writer.state()) || {}).did || null } catch { _repoDid = null } |
| 983 | return _repoDid |
| 984 | } |
| 974 | 985 | const SESSION_COOKIE = (token) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}` |
| 975 | 986 | const CLEAR_COOKIE = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` |
| 976 | 987 | |
| 988 | // Write-gate tiers (see the gate itself, at the top of the request handler). Listed here so the |
| 989 | // classification of a route is one grep away, not buried mid-file. |
| 990 | // |
| 991 | // OPEN: these ESTABLISH a session — gating them on having one would be circular. |
| 992 | const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logout', '/api/auth/challenge']) |
| 993 | // IDENTITY: self-scoped or node-local. /api/devices/* act on the caller's OWN delegated sub-keys; |
| 994 | // track/bookmark/mute mutate this node's local index and stores only — nothing is signed into a repo, |
| 995 | // so repo ownership is not the relevant question, but anonymity still is. |
| 996 | const IDENTITY_POST = new Set([ |
| 997 | '/api/devices/pair', '/api/devices/invite', '/api/devices/revoke', |
| 998 | '/api/track', '/api/bookmark', '/api/mute' |
| 999 | ]) |
| 1000 | // Everything else POSTed authors a record (or a blob) into the repo and falls through to the |
| 1001 | // repo-owner check. Deliberately NOT enumerated: an unlisted route must fail closed. |
| 1002 | |
| 977 | 1003 | const html = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', ...(extra || {}) }); res.end(body) } |
| 978 | 1004 | const json = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'application/json', ...(extra || {}) }); res.end(body) } |
| 979 | 1005 | const xml = (res, status, body, type) => { res.writeHead(status, { 'content-type': type }); res.end(body) } |
| … | … | @@ -982,7 +1008,38 @@ const server = http.createServer(async (req, res) => { |
| 982 | 1008 | try { |
| 983 | 1009 | const url = new URL(req.url, 'http://localhost') |
| 984 | 1010 | const path = url.pathname |
| 985 | | const viewer = viewerOf(req) // { did } from the session cookie, or null (anonymous) |
| 1011 | const viewer = viewerOf(req) // { did, subKey } from the session cookie/Bearer, or null (anonymous) |
| 1012 | |
| 1013 | // ── WRITE GATE — one choke point, default-deny ────────────────────────────────────────────── |
| 1014 | // |
| 1015 | // Every mutating route used to carry (or forget) its own `if (!viewer)`. /api/post forgot, and on |
| 1016 | // 2026-08-11 an anonymous request created a post in wj62; the images branch of that same handler |
| 1017 | // had the check, which is why inspection missed it. pgv3 puts its read gate at ONE point above |
| 1018 | // ~14 routes for exactly this reason: per-route checks are per-route chances to forget, and a |
| 1019 | // route added later defaults to open. Here the default is DENY — a new POST route is gated as a |
| 1020 | // repo write until someone deliberately lists it otherwise. |
| 1021 | // |
| 1022 | // Tiers, most open first: |
| 1023 | // OPEN — establish the session; requiring one would be circular. |
| 1024 | // IDENTITY — self-scoped: acts on the caller's own devices, or on state that never leaves |
| 1025 | // this node. A live, non-revoked session is the whole requirement. |
| 1026 | // (default) — AUTHORS A RECORD INTO THE REPO. Requires that the caller OWN that repo. |
| 1027 | // |
| 1028 | // ⛔ The repo owner check is the load-bearing one. `Writer` speaks to a single hpp control socket, |
| 1029 | // so a write authors as THAT DID whoever is signed in — and sign-in admits any DID that can prove |
| 1030 | // a delegation from its own #atproto key. Without this, any atproto account posts as wj62. |
| 1031 | if (req.method === 'POST' && !OPEN_POST.has(path)) { |
| 1032 | const ctx = { identity: viewer } |
| 1033 | const isDeviceRevoked = (k) => writer.isDeviceRevoked(k) |
| 1034 | try { |
| 1035 | if (IDENTITY_POST.has(path)) await requireIdentity(ctx, { isDeviceRevoked, failOpen: false }) |
| 1036 | else await requireRepoOwner(ctx, { repoDid: await repoDidOnce(), isDeviceRevoked }) |
| 1037 | } catch (e) { |
| 1038 | const status = e.status || 403 |
| 1039 | console.log(JSON.stringify({ msg: 'write refused', path, viewer: viewer ? viewer.did : null, status, reason: e.message })) |
| 1040 | return json(res, status, JSON.stringify({ error: e.message })) |
| 1041 | } |
| 1042 | } |
| 986 | 1043 | |
| 987 | 1044 | // ── auth (the browser client drives these; favela verifies keylessly) ── |
| 988 | 1045 | if (path === '/api/auth/challenge') { |