// notifications — verify the USER-SCOPED AppView read chain end to end, as a real client would.
//
// WHY THIS EXISTS. The chain behind app.bsky.notification.listNotifications spans four components and
// two gates, and until now the only way to exercise it was a hand-assembled curl pair plus a
// base64-decoded JWT — which is exactly the shape of thing that gets typed wrong under pressure and
// then believed. Same reasoning as `coowner-invite`: if doing it by hand means secrets on an argv line
// and a hex key pasted between two boxes, it should be a command.
//
// client --createSession--> bridge --------------------> peer (sessions.verifyAccess)
// client --listNotifications w/ bearer--> bridge
// -> appViewServiceAuth(req) -> jwtSub(bearer)
// -> RPC 'get-service-auth' on hyper-proto-peer/device/v1 [gate 1: canWrite(remote)]
// [gate 2: sessions.verifyAccess]
// -> Bearer attached -> AppView
//
// WHAT THE RESULT MEANS. This is the ONE call whose success is not ambiguous:
// * 200 with a `notifications` array — the peer minted a service-auth token, so BOTH gates passed.
// * an auth error from the AppView — the bridge got no token and fell back to the PUBLIC proxy.
// listNotifications is user-scoped, so the public path cannot answer it. This is the failure the
// whole chain used to produce silently, and it is indistinguishable from "not allowlisted yet"
// unless you look here.
//
// It also reports this DID's LOCAL writers count, because an empty allowlist is not a subtle failure:
// with no `writers` configured, canWrite is null and the get-service-auth responder is never even
// REGISTERED (serve-device.js gates the whole block on it), so the call fails before any token check.
//
// Read-only and outward-facing only in the sense that it makes two ordinary authenticated API calls
// as the account itself. It writes nothing, on either side.
import os from 'bare-os'
import process from 'bare-process'
import { loadConfig } from './config.js'
const DEFAULT_LXM = 'app.bsky.notification.listNotifications'
// Read a secret from stdin so it never lands in argv (and therefore never in `ps` or shell history).
// `echo "$PW" | hyper-proto-peer notifications <did> --password-stdin`
function readStdin () {
return new Promise((resolve) => {
let buf = ''
try {
process.stdin.on('data', (c) => { buf += c })
process.stdin.on('end', () => resolve(buf.trim()))
process.stdin.on('error', () => resolve(''))
if (typeof process.stdin.resume === 'function') process.stdin.resume()
} catch { resolve('') }
})
}
async function ensureFetch () {
if (typeof globalThis.fetch === 'function') return
// Bare has no global fetch; hpp already depends on bare-fetch for exactly this reason.
await import('bare-fetch/global')
}
// One account's full round trip. Returns a plain record — never throws, so a sweep over several DIDs
// reports every one instead of dying on the first.
async function checkOne ({ did, handle, pds, password, limit }) {
const out = { did, handle, pds, session: null, notifications: null, verdict: null }
const base = String(pds).replace(/\/$/, '')
let session
try {
const res = await fetch(base + '/xrpc/com.atproto.server.createSession', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identifier: handle || did, password })
})
const body = await res.json().catch(() => null)
out.session = { status: res.status, did: body && body.did, error: body && body.error }
if (!res.ok || !body || !body.accessJwt) {
// Do NOT read a cause into the status. The bridge maps peer-side RPC failures to a generic
// InternalError (server-bare.js), so a wrong app password surfaces as 500, not 401 — verified
// 2026-07-30. Saying "wrong password" on a 500 would be a guess presented as a diagnosis.
out.verdict = 'LOGIN FAILED — createSession returned no access token (status ' + res.status + ').' +
' The bridge masks peer errors as InternalError, so this is EITHER a wrong app password OR a' +
' peer-side session failure; check the peer log to tell them apart.'
return out
}
session = body
} catch (e) {
out.session = { error: String((e && e.message) || e) }
out.verdict = 'LOGIN FAILED — could not reach ' + base
return out
}
try {
const res = await fetch(base + '/xrpc/' + DEFAULT_LXM + '?limit=' + limit, {
headers: { authorization: 'Bearer ' + session.accessJwt }
})
const body = await res.json().catch(() => null)
const list = body && Array.isArray(body.notifications) ? body.notifications : null
out.notifications = {
status: res.status,
count: list ? list.length : null,
error: body && body.error,
message: body && body.message,
// The reasons present, as a cheap sanity check that this is a real user-scoped payload.
reasons: list ? Array.from(new Set(list.map((n) => n && n.reason).filter(Boolean))) : null
}
if (res.ok && list) {
out.verdict = 'OK — user-scoped read succeeded, so the peer minted service auth (both gates passed)'
} else {
// The informative failure. The AppView refusing for auth reasons means the bridge attached no
// Bearer, i.e. get-service-auth did not answer — allowlist, session config, or an old peer.
out.verdict = 'NO SERVICE AUTH — the bridge fell back to the PUBLIC proxy, which cannot answer a ' +
'user-scoped method. Check: (a) this DID lists the bridge\'s swarm key in `writers` — the bridge ' +
'logs {"msg":"swarm identity","publicKey":...} at boot; (b) the DID has appPasswordHash + ' +
'jwtSecret; (c) the peer is >= 0.3.96.'
}
} catch (e) {
out.notifications = { error: String((e && e.message) || e) }
out.verdict = 'NO RESULT — listNotifications did not complete'
}
return out
}
export async function notificationsCommand (args = []) {
const cfg = loadConfig()
const flag = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt }
const limit = Number(flag('--limit', 10)) || 10
const one = args.find((a) => a.startsWith('did:'))
// Only DIDs that can actually log in are candidates. A co-owned community feed (loser-club) has no
// appPasswordHash/jwtSecret ON PURPOSE — nobody logs in AS a feed — so the peer correctly refuses to
// mint for it. Silently attempting it would report a "failure" that is really correct behaviour.
const hosted = (cfg.dids || []).map((d) => ({
did: d.did,
handle: d.handle,
pds: d.pdsUrl || cfg.pdsUrl,
canLogin: !!(d.appPasswordHash && d.jwtSecret),
writers: Array.isArray(d.writers) ? d.writers.length : 0
}))
const targets = one ? hosted.filter((h) => h.did === one) : hosted.filter((h) => h.canLogin)
if (!targets.length) {
console.error('usage: hyper-proto-peer notifications [<did>] [--pds <url>] [--limit <n>] [--password-stdin]')
console.error(' Verifies the authenticated AppView read chain (createSession -> listNotifications)')
console.error(' through the keyless bridge, as a real client. A 200 with a notifications array is')
console.error(' proof the peer minted service auth; an auth error means the bridge fell back to the')
console.error(' public proxy. Password: PEER_APP_PASSWORD env, or --password-stdin (never argv).')
if (one) console.error(' ' + one + ' is not a hosted DID in ' + cfg.configPath)
else console.error(' No hosted DID in ' + cfg.configPath + ' has appPasswordHash + jwtSecret (canLogin).')
return 1
}
let password = process.env.PEER_APP_PASSWORD || ''
if (args.includes('--password-stdin')) password = await readStdin()
if (!password) {
console.error('no app password — set PEER_APP_PASSWORD, or pipe it with --password-stdin')
console.error('(deliberately NOT a --password flag: argv is visible in `ps` to every user on the box)')
return 1
}
await ensureFetch()
const results = []
for (const t of targets) {
const pds = flag('--pds', t.pds)
const r = await checkOne({ did: t.did, handle: t.handle, pds, password, limit })
r.localWritersConfigured = t.writers
// An empty allowlist is decisive on its own: canWrite null => the responder is never registered.
if (!t.writers) r.note = 'this DID has NO `writers` configured on THIS host — get-service-auth is not even registered here'
if (!t.canLogin) r.note = 'this DID has no appPasswordHash/jwtSecret — the peer refuses to mint by design (a feed, not an account)'
results.push(r)
}
console.log(JSON.stringify({
tool: 'hyper-proto-peer notifications',
host: (() => { try { return os.hostname() } catch { return null } })(),
at: new Date().toISOString(),
lxm: DEFAULT_LXM,
results
}, null, 2))
// Non-zero unless every checked account got a real user-scoped answer, so this is scriptable as a
// post-deploy gate rather than something a human has to read.
return results.every((r) => r.verdict && r.verdict.startsWith('OK')) ? 0 : 1
}