OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / notifications.js

180 lines · javascript ref:
// 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
}