OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / basic.test.js

3660 lines · javascript ref:
import test from 'brittle'
import { mkdtempSync, statSync, readFileSync, existsSync, mkdirSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import process from '#node:process'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import createTestnet from '@hyperswarm/testnet'

import { Client } from 'hyper-svc'
import { parseSchemaVersionEntry } from '../lib/services/schema-guard.js'
import { startPearGit } from '../index.js'
import { loadOrCreateIdentity } from '../lib/identity.js'
import { sshPrincipal } from '../lib/auth/ssh-identity.js'
import { signTestEnvelope } from '../lib/auth/envelope.js'

// Phase 2a — envelope-signed /api/rpc helpers for tests.
async function genAnprotoKeypair () {
  const { an } = await import('anproto')
  return await an.gen()
}

async function callEnvelopeRpc (port, method, params, keypair) {
  const kp = keypair || await genAnprotoKeypair()
  const env = await signTestEnvelope({ keypair: kp, method, params: params || {} })
  const res = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)
  return { keypair: kp, status: res.status, body: res.body, setCookie: res.setCookie }
}

// ── Browser-emulating auth helper ─────────────────────────────
//
// Replicates the v3 browser-side flow in Node so tests can POST as an
// authenticated HTTP user. Uses hypercore-crypto (sodium-native under the
// hood) to compute Ed25519 signatures matching what the vendored
// browser-side ANProtoClient produces.

function genTestKeypair () {
  const kp = crypto.keyPair()
  return {
    publicKey: kp.publicKey,
    secretKey: kp.secretKey,
    publicKeyB64: b4a.toString(kp.publicKey, 'base64'),
    publicKeyHex: b4a.toString(kp.publicKey, 'hex')
  }
}

// Canonical ANProto sign: hash(challenge) → timestamp + hash → combined envelope
async function signChallengeCanonical (challenge, keypair) {
  const { an } = await import('anproto')
  const hash = await an.hash(challenge)
  const ts = Date.now().toString()
  const message = b4a.from(ts + hash, 'utf8')
  const sig = crypto.sign(message, keypair.secretKey)
  const combined = b4a.concat([sig, message])
  return keypair.publicKeyB64 + b4a.toString(combined, 'base64')
}

// Phase 2a: loginAsHttpUser uses the /api/rpc envelope path. Accepts
// either:
//   - no arg (generates a fresh canonical anproto keypair), or
//   - a hypercore-crypto-shape kp from genTestKeypair() (converted to
//     canonical anproto format: pubkey_b64(44) + secret_b64(88) = 132 chars)
// so existing tests that pass genTestKeypair() keep working.
//
// Returns the session cookie that /api/rpc minted (same cookie name as
// Phase 1) so SSR form POSTs that rely on cookie auth still work.
function keypairToCanonicalAnproto (kp) {
  if (!kp) return null
  if (typeof kp === 'string' && kp.length === 132) return kp
  // hypercore-crypto shape: { publicKey: Buffer(32), secretKey: Buffer(64), publicKeyB64, ... }
  if (kp.publicKey && kp.secretKey) {
    const pubB64 = kp.publicKeyB64 || b4a.toString(kp.publicKey, 'base64')
    const secB64 = b4a.toString(kp.secretKey, 'base64')
    return pubB64 + secB64
  }
  return null
}

async function loginAsHttpUser (port, kpIn) {
  let kp = keypairToCanonicalAnproto(kpIn)
  if (!kp) {
    const { an } = await import('anproto')
    kp = await an.gen()
  }
  const env = await signTestEnvelope({ keypair: kp, method: 'whoami', params: {} })
  const res = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)

  if (res.status !== 200 || !res.body || !res.body.ok) {
    throw new Error('envelope login failed: ' + res.status + ' ' + JSON.stringify(res.body))
  }

  const setCookie = res.setCookie
  let cookieHeader = null
  if (setCookie) {
    const first = Array.isArray(setCookie) ? setCookie[0] : setCookie
    cookieHeader = first.split(';')[0]
  }

  return {
    keypair: kp,  // canonical anproto 132-char
    publicKeyHex: res.body.identity.publicKeyHex,
    cookie: cookieHeader,
    becameOwner: res.body.identity.becameOwner
  }
}

function postJson (url, body, method = 'POST', headers = {}) {
  return new Promise((resolve, reject) => {
    const json = JSON.stringify(body || {})
    const u = new URL(url)
    const reqHeaders = { Accept: 'application/json', ...headers }
    if (method !== 'GET') {
      reqHeaders['Content-Type'] = 'application/json'
      reqHeaders['Content-Length'] = b4a.byteLength(json)
    }
    const req = http.request({
      method,
      hostname: u.hostname,
      port: u.port,
      path: u.pathname + u.search,
      headers: reqHeaders
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => {
        const text = b4a.concat(parts).toString('utf8')
        let parsed
        try { parsed = JSON.parse(text) } catch { parsed = text }
        resolve({
          status: res.statusCode,
          body: parsed,
          setCookie: res.headers['set-cookie'],
          headers: res.headers
        })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    if (method !== 'GET') req.write(json)
    req.end()
  })
}

function tmpStorage () {
  return mkdtempSync(join(tmpdir(), 'pear-git-v3-test-'))
}

function tmpSockPath () {
  return join(mkdtempSync(join(tmpdir(), 'pear-git-v3-sock-')), 'svc.sock')
}

async function makeNode (t, opts = {}) {
  const node = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: false, // tests are hermetic — no swarm unless explicitly passed
    // Tests are hermetic — disable SSH auto-import so the test runner's
    // ~/.ssh keys don't leak into trust-map assertions.
    autoImportSSH: false,
    ...opts
  })
  t.teardown(async () => { try { await node.close() } catch {} })
  return node
}

function fetchText (url) {
  return new Promise((resolve, reject) => {
    http.get(url, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        location: res.headers.location || null,
        body: b4a.concat(parts).toString('utf8')
      }))
      res.on('error', reject)
    }).on('error', reject)
  })
}

async function waitFor (predicate, timeoutMs) {
  const start = Date.now()
  while (Date.now() - start < timeoutMs) {
    try { if (await predicate()) return true } catch {}
    await new Promise(r => setTimeout(r, 50))
  }
  return false
}

function postForm (url, fields, extraHeaders = {}) {
  return new Promise((resolve, reject) => {
    const body = new URLSearchParams(fields).toString()
    const u = new URL(url)
    const req = http.request({
      method: 'POST',
      hostname: u.hostname,
      port: u.port,
      path: u.pathname,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': b4a.byteLength(body),
        ...extraHeaders
      }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        location: res.headers.location || null,
        body: b4a.concat(parts).toString('utf8')
      }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(body)
    req.end()
  })
}

// ── Boot ──────────────────────────────────────────────────────


// The OrgEventLog also carries the in-band schema-version marker that every
// boot announces (#52 Phase 4.4, `[schema-guard] announced schema v2`). These
// tests predate that feature and assert raw log LENGTH, so they now count the
// marker as an org entry. Count real org entries only.
async function orgEntries (log) {
  const out = []
  for await (const e of log.entries({ live: false, start: 0 })) {
    if (parseSchemaVersionEntry(e) !== null) continue
    out.push(e)
  }
  return out
}

test('boots with all transports', async (t) => {
  const node = await makeNode(t)
  t.is(node.svc.name, 'pear-git-v3')
  t.ok(node.svc.uds && node.svc.uds.listening)
  t.ok(node.svc.http && node.svc.http.listening)
  // P.2: issues is no longer a top-level mount — per-repo and per-org trackers
  // live on the IssueRegistry instead. The registry itself isn't a mount.
  // No v1 'overlay' mount: e0ecfb1 retired the path-addressed overlay, leaving
  // only the content-addressed overlayV2.
  t.alike(node.svc.listMounts(), ['identity', 'orgs', 'overlayV2'])
  t.ok(node.svc.listHandlers().includes('status'))
  t.ok(node.svc.listHandlers().includes('list-orgs'))
  t.ok(node.svc.listHandlers().includes('list-issues'))
  // 'list-files' was a v1 path-addressed overlay handler, retired in e0ecfb1
  // along with put-file/get-file/exists-file/stat-file/delete-file. Assert on
  // the content-addressed replacement instead.
  t.ok(node.svc.listHandlers().includes('repo-files'))
  // Phase 2a: auth-challenge / auth-verify removed. Identity is
  // established via /api/rpc signed envelopes; whoami is still
  // dispatched through svc.dispatch().
  t.ok(node.svc.listHandlers().includes('whoami'))
  t.ok(node.svc.listHandlers().includes('list-users'))
  t.ok(node.identity, 'has service identity')
  t.is(node.identity.publicKeyHex.length, 64)
  t.ok(node.identityRegistry, 'has identity registry')
  t.ok(node.sessions, 'has session store')
})

// ── RPC: status ───────────────────────────────────────────────

test('status RPC over UDS', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const status = await client.call('status', {})
  t.is(status.name, 'pear-git-v3')
  t.is(status.transport, 'uds')
  // v1 'overlay' mount retired in e0ecfb1 — see the note at listMounts above.
  t.alike(status.mounts.sort(), ['identity', 'orgs', 'overlayV2'])
  t.is(status.orgs, 0)
  t.alike(status.issues, { open: 0, closed: 0, total: 0, repos: 0, orgs: 0 })
  t.is(typeof status.identity, 'string', 'identity hex exposed')
  t.ok(status.users, 'users count exposed')
  t.ok(status.sessions, 'sessions info exposed')
})

test('status RPC over HTTP', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ http: `http://127.0.0.1:${node.svc.http.port}`, prefer: 'http' })
  t.teardown(async () => client.close())

  const status = await client.call('status', {})
  t.is(status.transport, 'http')
})

// ── Phase 2a: /api/rpc signed envelope end-to-end ─────────────

test('/api/rpc: envelope-signed whoami TOFU-registers + returns identity', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const r1 = await callEnvelopeRpc(port, 'whoami', {})
  t.is(r1.status, 200, 'first envelope call succeeds')
  t.ok(r1.body.ok, 'ok=true')
  t.ok(r1.body.result, 'dispatch result present')
  t.ok(r1.body.identity, 'identity block returned')
  t.ok(r1.body.identity.publicKeyHex.length === 64, 'publicKeyHex is 64 hex')
  t.ok(r1.body.identity.becameOwner, 'first user becomes owner (TOFU)')
})

test('/api/rpc: second envelope from same key skips TOFU, returns existing user', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = await genAnprotoKeypair()

  const r1 = await callEnvelopeRpc(port, 'whoami', {}, kp)
  t.is(r1.status, 200, 'first call ok')
  t.ok(r1.body.identity.becameOwner, 'first user is owner')

  const r2 = await callEnvelopeRpc(port, 'whoami', {}, kp)
  t.is(r2.status, 200, 'second call ok')
  t.is(r2.body.identity.publicKeyHex, r1.body.identity.publicKeyHex, 'same identity')
  t.not(r2.body.identity.becameOwner, 'not a fresh TOFU')
  t.not(r2.body.identity.newUser, 'not newUser')
})

test('/api/rpc: tampered payload rejected with HASH_MISMATCH', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = await genAnprotoKeypair()

  const env = await signTestEnvelope({ keypair: kp, method: 'whoami', params: {} })
  const tampered = { ...env, payload: env.payload + ' ' } // invalidate hash
  const res = await postJson('http://127.0.0.1:' + port + '/api/rpc', tampered)
  t.is(res.status, 401, '401 on tampered envelope')
  t.is(res.body.code, 'HASH_MISMATCH', 'HASH_MISMATCH code returned')
})

test('/api/rpc: replay within TTL rejected with NONCE_REPLAY', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = await genAnprotoKeypair()

  const env = await signTestEnvelope({ keypair: kp, method: 'whoami', params: {} })

  const r1 = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)
  t.is(r1.status, 200, 'first call ok')

  const r2 = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)
  t.is(r2.status, 401, 'replay rejected')
  t.is(r2.body.code, 'NONCE_REPLAY', 'NONCE_REPLAY code returned')
})

// ── RPC: orgs ─────────────────────────────────────────────────

test('create-org and list-orgs', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const created = await client.call('create-org', {
    name: 'my-team',
    type: 'group',
    displayName: 'My Team'
  })
  t.is(created.name, 'my-team')
  t.is(created.displayName, 'My Team')

  const list = await client.call('list-orgs', {})
  t.is(list.length, 1)
  t.is(list[0].name, 'my-team')

  const got = await client.call('get-org', { name: 'my-team' })
  t.is(got.name, 'my-team')
})

test('create-org rejects duplicate', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('create-org', { name: 'team', type: 'group' })
  try {
    await client.call('create-org', { name: 'team', type: 'group' })
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.toLowerCase().includes('already'))
  }
})

test('delete-org removes from list and rejects when resources attached', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('create-org', { name: 'temp-org', type: 'group' })
  let list = await client.call('list-orgs', {})
  t.is(list.length, 1, 'created')

  const res = await client.call('delete-org', { name: 'temp-org' })
  t.is(res.deleted, true)
  list = await client.call('list-orgs', {})
  t.is(list.length, 0, 'gone after delete')

  // delete-org refuses when resources still attached
  await client.call('create-org', { name: 'busy-org', type: 'group' })
  await client.call('add-org-resource', { org: 'busy-org', resource: 'repo-a' })
  try {
    await client.call('delete-org', { name: 'busy-org' })
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.toLowerCase().includes('resources'))
  }

  // missing name
  try {
    await client.call('delete-org', {})
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.toLowerCase().includes('requires'))
  }
})

test('add-org-member and add-org-resource', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // add-org-member resolves any identifier to a canonical userId; tests
  // must operate on a real User. TOFU-create one via the HTTP envelope
  // path, then use its userId.
  const alice = await loginAsHttpUser(port)
  await client.call('create-org', { name: 'team', type: 'group' })
  await client.call('add-org-member', { org: 'team', userId: alice.publicKeyHex, role: 'admin' })
  await client.call('add-org-resource', { org: 'team', resource: 'project-a' })

  const org = await client.call('get-org', { name: 'team' })
  t.is(org.members.length, 1)
  // Member is stored under canonical userId, not the identity hex we passed
  t.not(org.members[0].userId, alice.publicKeyHex, 'memberId is canonical userId, not identity hex')
  const aliceUser = await client.call('get-user', { userId: org.members[0].userId })
  t.ok(aliceUser.user.identities.includes(alice.publicKeyHex), 'canonical userId resolves back to alice')
  t.alike(org.resources, ['project-a'])
})

// ── RPC: issues ───────────────────────────────────────────────

test('create-issue and list-issues', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // P.2: issues require scope — pass { repo } or { org }
  const issue = await client.call('create-issue', {
    repo: 'demo',
    title: 'first bug',
    body: 'something is wrong',
    author: 'alice'
  })
  t.is(issue.id, 1)
  t.is(issue.title, 'first bug')
  t.is(issue.status, 'open')
  t.is(issue.repo, 'demo')

  const list = await client.call('list-issues', { repo: 'demo' })
  t.is(list.repo, 'demo')
  t.is(list.issues.length, 1)

  const counts = await client.call('issue-counts', { repo: 'demo' })
  t.is(counts.repo, 'demo')
  t.is(counts.total, 1)
  t.is(counts.open, 1)
})

test('comment-issue and get-issue returns comments', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('create-issue', { repo: 'demo', title: 'bug', author: 'alice' })
  const { commentId } = await client.call('comment-issue', {
    repo: 'demo', id: 1, body: 'I can repro', author: 'bob'
  })
  t.ok(commentId)

  const got = await client.call('get-issue', { repo: 'demo', id: 1 })
  t.is(got.comments.length, 1)
  t.is(got.comments[0].author, 'bob')
})

test('update-issue can close', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('create-issue', { repo: 'demo', title: 'fix me', author: 'alice' })
  const updated = await client.call('update-issue', { repo: 'demo', id: 1, status: 'closed' })
  t.is(updated.status, 'closed')

  const counts = await client.call('issue-counts', { repo: 'demo' })
  t.is(counts.total, 1)
  t.is(counts.closed, 1)
  t.is(counts.open, 0)
})

// ── SSR ───────────────────────────────────────────────────────

test('GET / redirects to /repos', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/`)
  t.is(res.status, 303)
  t.is(res.location, '/repos')
})

test('GET /repos renders the repos landing page', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos`)
  t.is(status, 200)
  t.ok(body.includes('pear-git-v3'))
  t.ok(body.includes('repos'))
  t.ok(body.includes('Sync service'))
})

test('GET /status renders the daemon status cards', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/status`)
  t.is(status, 200)
  t.ok(body.includes('Identity'))
  t.ok(body.includes('Service'))
  t.ok(body.includes('Data'))
  t.ok(body.includes('Bootstrap keys'))
})

test('GET /releases redirects to /deploy', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/releases`)
  t.is(res.status, 303)
  t.is(res.location, '/deploy')
})

test('GET /deploy renders the deploy page', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/deploy`)
  t.is(status, 200)
  t.ok(body.includes('Deploy service'))
})

test('GET /api/search/repos returns JSON shape', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/api/search/repos?q=x`)
  t.is(status, 200)
  const parsed = JSON.parse(body)
  t.ok(Array.isArray(parsed.repos))
  t.is(typeof parsed.query, 'string')
  t.is(typeof parsed.total, 'number')
})

test('nav contains new items and omits removed ones', async (t) => {
  const node = await makeNode(t)
  const { body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos`)
  // Present
  t.ok(body.includes('href="/repos"'), 'nav has Repos')
  t.ok(body.includes('href="/orgs"'), 'nav has Orgs')
  t.ok(body.includes('href="/deploy"'), 'nav has Deploy')
  t.ok(body.includes('id="nav-search-input"'), 'nav has search input')
  t.ok(body.includes('id="admin-nav"'), 'admin link present (hidden)')
  // P.9: anonymous visitors see an explicit Sign In button (not a silent
  // you-badge). Once authenticated, the badge returns. We assert either
  // is present since fetchText doesn't carry a session.
  t.ok(body.includes('id="signin-btn"') || body.includes('id="you-badge"'), 'sign-in button or you-badge present')
  t.ok(body.includes('id="welcome-modal"'), 'welcome modal markup present')
  // Removed from nav
  const navRegion = body.split('<div class="container">')[0]
  t.absent(/href="\/issues"/.test(navRegion), 'nav should not link /issues')
  t.absent(/href="\/files"/.test(navRegion), 'nav should not link /files')
  t.absent(/href="\/releases"/.test(navRegion), 'nav should not link /releases')
})

test('GET /orgs renders empty state', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  // After U36+U37-b the Create-org form only renders for owners, so
  // we auth as the first TOFU-promoted user before asserting on it.
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)
  const res = await new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path: '/orgs',
      headers: { Cookie: auth.cookie }
    }, (r) => {
      const parts = []
      r.on('data', c => parts.push(c))
      r.on('end', () => resolve({ status: r.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })
  t.is(res.status, 200)
  t.ok(res.body.includes('Create org'))
  t.ok(res.body.includes('no groups yet') || res.body.includes('0 group'))
})

test('POST /orgs/create then GET /orgs/:name (with auth)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Browser-emulating login
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  const create = await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'my-team',
    type: 'group',
    displayName: 'My Team',
    description: 'a test team'
  }, { Cookie: auth.cookie })
  t.is(create.status, 303)
  t.ok(create.location && create.location.startsWith('/orgs/my-team'), 'redirects to /orgs/my-team (+ optional flash tail)')

  const show = await fetchText(`http://127.0.0.1:${port}/orgs/my-team`)
  t.is(show.status, 200)
  t.ok(show.body.includes('my-team'))
  t.ok(show.body.includes('My Team'))
  t.ok(show.body.includes('a test team'))
})

test('GET /repos/:name/issues renders empty state', async (t) => {
  // Per-repo path is the canonical issue page after P.2 — hit it directly.
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Instantiate the per-repo tracker so the page has something to render
  await client.call('create-issue', { repo: 'demo', title: 'seed', author: 'bob' })

  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos/demo/issues?status=closed`)
  t.is(status, 200)
  t.ok(body.includes('Create issue'))
})

test('GET /issues redirects to /repos (P.2 — global tracker retired)', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/issues`)
  t.is(res.status, 303)
  t.is(res.location, '/repos')
})

test('POST /repos/:name/issues/create then GET /repos/:name/issues/:id (with auth)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  const create = await postForm(`http://127.0.0.1:${port}/repos/demo/issues/create`, {
    title: 'something is broken',
    body: 'details here',
    author: 'alice'
  }, { Cookie: auth.cookie })
  t.is(create.status, 303)
  t.ok(create.location && create.location.startsWith('/repos/demo/issues'), 'redirects to /repos/demo/issues (+ optional flash tail)')

  const show = await fetchText(`http://127.0.0.1:${port}/repos/demo/issues/1`)
  t.is(show.status, 200)
  t.ok(show.body.includes('something is broken'))
  t.ok(show.body.includes('alice'))
})

test('POST /repos/:name/issues/create with empty title renders error inline (U1)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Empty title — hyper-issues apply rejects. Before U1 this either
  // silently redirected or returned a plain-text 500 from the global
  // catch. After U1 the response is a 4xx HTML page carrying the error
  // message and a back link.
  const create = await postForm(`http://127.0.0.1:${port}/repos/demo/issues/create`, {
    title: '',
    body: 'no title was supplied',
    author: 'alice'
  }, { Cookie: auth.cookie })

  t.ok(create.status >= 400 && create.status < 500, 'status is 4xx (got ' + create.status + ')')
  t.absent(create.location, 'did not redirect on error')
  t.ok(create.body.includes('error'), 'response body contains "error"')
  t.ok(create.body.includes('back to issues'), 'response body includes back link')
})

test('repo Docs tab + inline markdown viewer (U38+U39)', async (t) => {
  const { execSync } = await import('../lib/_compat-exec.js')
  const reposDir = join(mkdtempSync(join(tmpdir(), 'pgv3-v3-docs-')), 'git-repos')
  mkdirSync(reposDir, { recursive: true })

  const work = mkdtempSync(join(tmpdir(), 'pgv3-v3-docs-work-'))
  const g = (args) => execSync(`git -C ${work} ${args}`, { stdio: 'ignore' })
  g('init --initial-branch=main')
  g('config user.email t@t')
  g('config user.name T')
  writeFileSync(join(work, 'README.md'), '# welcome\n\nthis is the readme.\n')
  mkdirSync(join(work, 'docs'), { recursive: true })
  writeFileSync(join(work, 'docs', 'intro.md'), '# intro\n\nhello docs.\n')
  writeFileSync(join(work, 'docs', 'advanced.markdown'), '# advanced\n\nthings.\n')
  g('add -A')
  g('commit -m seed')

  // Create a bare repo and push the work tree in. git init --bare
  // defaults HEAD to master; after pushing 'main' we have to repoint
  // HEAD manually or git show HEAD:path throws 'invalid object name'.
  execSync(`git init --bare ${join(reposDir, 'docsy.git')}`, { stdio: 'ignore' })
  g(`push ${join(reposDir, 'docsy.git')} main`)
  execSync(`git -C ${join(reposDir, 'docsy.git')} symbolic-ref HEAD refs/heads/main`, { stdio: 'ignore' })

  const node = await makeNode(t, { reposDir })
  const port = node.svc.http.port

  // Docs tab should appear in the tab row.
  const overview = await fetchText(`http://127.0.0.1:${port}/repos/docsy`)
  t.is(overview.status, 200)
  t.ok(overview.body.includes('href="/repos/docsy/docs"'), 'overview tab row includes Docs link')

  // /repos/docsy/docs should list the docs + render the README.
  const docs = await fetchText(`http://127.0.0.1:${port}/repos/docsy/docs`)
  t.is(docs.status, 200)
  t.ok(docs.body.includes('README.md'), 'docs page names README.md')
  // Two-panel layout (U38-b): left sidebar + right viewer pane.
  t.ok(docs.body.includes('docs-sidebar'), 'docs page has a left sidebar')
  t.ok(docs.body.includes('docs-viewer'), 'docs page has a right viewer pane')
  t.ok(docs.body.includes('data-doc-path="README.md"'), 'sidebar links README.md by data-doc-path')
  t.ok(docs.body.includes('docs/intro.md'), 'docs page lists docs/intro.md')
  t.ok(docs.body.includes('docs/advanced.markdown'), 'docs page lists .markdown extension too')

  // Each doc gets its own server-rendered pane; switching is a class
  // toggle. The old page shipped a JSON map inside an inline <script> —
  // that block was a SyntaxError (a template literal ate the backslashes
  // out of its regex), so the viewer was empty for EVERY repo.
  t.absent(docs.body.includes('var DOCS ='),
    'docs page does not inline a markdown map into a script')
  t.ok(docs.body.includes('data-md-src-b64'), 'docs panes carry base64 sources')
  t.ok(docs.body.includes('pgv3-docs-switch.js'), 'docs page loads the switcher')

  // Clicking a .md link → blob viewer renders it. marked.parse now lives
  // in static/pgv3-markdown.js (loaded by the layout), and the source
  // travels as base64 in a data attribute rather than as JSON inside an
  // inline <script> — which broke on any file containing `</script`.
  const blob = await fetchText(`http://127.0.0.1:${port}/repos/docsy/blob/docs/intro.md`)
  t.is(blob.status, 200)
  t.ok(blob.body.includes('md-preview'), 'blob viewer has markdown preview slot')
  t.ok(blob.body.includes('data-md-src-b64'), 'blob viewer carries a base64 source')
  t.ok(blob.body.includes('pgv3-markdown.js'), 'layout loads the shared markdown renderer')

  // .markdown extension also routes through the markdown path (U39).
  const blobMd = await fetchText(`http://127.0.0.1:${port}/repos/docsy/blob/docs/advanced.markdown`)
  t.is(blobMd.status, 200)
  t.ok(blobMd.body.includes('md-preview'), '.markdown extension renders as markdown')

  // A markdown file containing `</script` used to blank the viewer: the
  // inline `var src = ${JSON.stringify(...)}` let it close the element.
  writeFileSync(join(work, 'docs', 'hostile.md'), '# hostile\n\n<script>alert(1)</script>\n')
  g('add -A')
  g('commit -m hostile')
  g(`push ${join(reposDir, 'docsy.git')} main`)
  const hostile = await fetchText(`http://127.0.0.1:${port}/repos/docsy/blob/docs/hostile.md`)
  t.is(hostile.status, 200)
  t.ok(hostile.body.includes('data-md-src-b64'), 'hostile file still gets a source attribute')
  // The layout's own scripts close normally; what must NOT appear is the
  // file's terminator sitting inside a markdown payload.
  t.absent(/data-md-src-b64="[^"]*<\/script/.test(hostile.body),
    'no script terminator inside the markdown payload')
})

test('admin-only forms are hidden from non-owners (U36+U37-b)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Anonymous: no Create-org form should be rendered at all.
  const anonOrgs = await fetchText(`http://127.0.0.1:${port}/orgs`)
  t.is(anonOrgs.status, 200)
  t.absent(anonOrgs.body.includes('Create org'), 'anonymous does not see Create org form')

  // Anonymous: no Add-remote form on /peers either.
  const anonPeers = await fetchText(`http://127.0.0.1:${port}/peers`)
  t.is(anonPeers.status, 200)
  t.absent(anonPeers.body.includes('Add a sync remote'), 'anonymous does not see Add remote form')

  // First authenticated user gets TOFU-owner → should see both forms.
  const ownerKp = genTestKeypair()
  const ownerAuth = await loginAsHttpUser(port, ownerKp)
  const ownerOrgs = await fetchText(`http://127.0.0.1:${port}/orgs`, { Cookie: ownerAuth.cookie })
  // The anon helper doesn't pass cookies. Re-query with cookie:
  const ownerOrgsWithAuth = await new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path: '/orgs',
      headers: { Cookie: ownerAuth.cookie }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })
  t.is(ownerOrgsWithAuth.status, 200)
  t.ok(ownerOrgsWithAuth.body.includes('Create org'), 'owner sees Create org form')

  // Second user (writer, not owner) should NOT see the form.
  const writerKp = genTestKeypair()
  const writerAuth = await loginAsHttpUser(port, writerKp)
  const writerOrgs = await new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path: '/orgs',
      headers: { Cookie: writerAuth.cookie }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })
  t.is(writerOrgs.status, 200)
  t.absent(writerOrgs.body.includes('Create org'), 'writer (non-owner) does not see Create org form')
})

test('org creation requires owner permission (U36)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // First HTTP user is TOFU-promoted to owner — can create orgs.
  const ownerKp = genTestKeypair()
  const ownerAuth = await loginAsHttpUser(port, ownerKp)
  const owned = await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'o36-owner', type: 'group'
  }, { Cookie: ownerAuth.cookie })
  t.is(owned.status, 303, 'owner can create an org')
  t.ok(owned.location && owned.location.startsWith('/orgs/o36-owner'), 'redirects to the new org')

  // Second HTTP user gets only 'writer' (TOFU bootstrap only promotes
  // the FIRST user). A writer should NOT be able to create an org.
  const writerKp = genTestKeypair()
  const writerAuth = await loginAsHttpUser(port, writerKp)
  const denied = await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'o36-denied', type: 'group'
  }, { Cookie: writerAuth.cookie })
  // requireOwner throws status 403 on non-owners; the handler renders
  // that as an inline error page (the authRedirect path is for 401
  // unauthenticated, not 403 forbidden).
  // Our auth flow 303's to /login on 401/403, so check for either
  // a redirect OR an inline 4xx — both are "access denied" signals.
  t.ok(
    denied.status === 303 || (denied.status >= 400 && denied.status < 500),
    `writer denied (got ${denied.status})`
  )
  // Final verification: list-orgs should have the owner's org, not the writer's.
  const { Client } = await import('hyper-svc')
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())
  const list = await client.call('list-orgs', {})
  const names = list.map(o => o.name)
  t.ok(names.includes('o36-owner'), 'owner org exists')
  t.absent(names.includes('o36-denied'), 'writer org was blocked')
})

test('adding a sync remote requires owner permission (U37)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // First HTTP user = owner.
  const ownerKp = genTestKeypair()
  const ownerAuth = await loginAsHttpUser(port, ownerKp)

  // Second HTTP user = writer.
  const writerKp = genTestKeypair()
  const writerAuth = await loginAsHttpUser(port, writerKp)

  // Writer: should be blocked.
  const writerAttempt = await postForm(`http://127.0.0.1:${port}/peers/remote-add`, {
    name: 'denied', serverKey: 'a'.repeat(64)
  }, { Cookie: writerAuth.cookie })
  t.ok(
    writerAttempt.status === 303 || (writerAttempt.status >= 400 && writerAttempt.status < 500),
    `writer denied (got ${writerAttempt.status})`
  )

  // Even a writer being bounced shouldn't have added the remote.
  // (Owner path is covered if sync is active — makeNode has no swarm
  // so _sync.addRemote is a no-op; we just verify no write happened
  // without the owner check.)
  if (node.svc._sync) {
    t.absent(node.svc._sync.remotes.has('denied'), 'writer did not succeed in adding the remote')
  }
})

test('session-expiry + sign-in show deferred-flash toasts (U6+U7)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // pgv3-flash.js should now handle sessionStorage-deferred flashes
  // (used by pgv3-bootstrap.js after a window.location.reload() — no
  // URL query to pass through).
  const scriptRes = await new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${port}/static/pgv3-flash.js`, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        body: b4a.concat(parts).toString('utf8')
      }))
    }).on('error', reject)
  })
  t.is(scriptRes.status, 200)
  t.ok(scriptRes.body.includes('pgv3-flash-deferred'), 'flash script reads pgv3-flash-deferred from sessionStorage')
  t.ok(scriptRes.body.includes('readDeferredFlash'), 'flash script has readDeferredFlash helper')

  // pgv3-bootstrap.js should SET the deferred flash on sign-in (U7).
  // U6 "session renewed" is obsolete after Phase 2a — envelope auth has
  // no session to expire; the bootstrap either signs successfully or
  // shows the Sign In button.
  const boot = await new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${port}/static/pgv3-bootstrap.js`, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })
  t.is(boot.status, 200)
  t.ok(boot.body.includes('success:Signed in'), 'bootstrap stashes signed-in flash (U7)')
})

test('success flash appears on redirect + client script loaded (U34)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Successful org create should redirect WITH a ?flash=success:... tail.
  const create = await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'flashtest', type: 'group', displayName: 'Flash Test'
  }, { Cookie: auth.cookie })
  t.is(create.status, 303)
  t.ok(create.location && create.location.includes('flash=success'), 'Location includes flash=success tail')
  t.ok(decodeURIComponent(create.location).includes('Org "flashtest" created'), 'flash payload contains message')

  // The flash script is loaded on every SSR page (same layout as the
  // forms script). Verify it's also served with JS content type.
  const page = await fetchText(`http://127.0.0.1:${port}/orgs`)
  t.ok(page.body.includes('/static/pgv3-flash.js'), '/orgs includes pgv3-flash.js script tag')

  const scriptRes = await new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${port}/static/pgv3-flash.js`, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        body: b4a.concat(parts).toString('utf8')
      }))
    }).on('error', reject)
  })
  t.is(scriptRes.status, 200)
  t.ok(scriptRes.body.includes('stripFlashFromUrl'), 'script strips flash from URL after showing')
  // Script builds class names by concat, so the CSS class is defined in
  // the inline stylesheet. Verify the stylesheet has it.
  t.ok(page.body.includes('.pgv3-flash-success'), '.pgv3-flash-success CSS class is in layout styles')
})

test('peer with non-writable IdentityRegistry rejects HTTP writes', async (t) => {
  // When a peer replicates another peer's IdentityRegistry without
  // ADD_WRITER pre-auth, its base.writable is false. Unknown HTTP
  // callers get synthesized as a "Pending Writer" ephemeral user with
  // ['read'] perms only — requireWriter/requireOwner reject on write.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-pending-writer-test')

  const reposA = join(mkdtempSync(join(tmpdir(), 'pgv3-primary-repos-')), 'git-repos')
  mkdirSync(reposA, { recursive: true })

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    reposDir: reposA,
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    // Mount nodeA's identity autobase so nodeB REPLICATES it (rather than
    // creating its own writable one). This is what a real mirror gets:
    // expandDeploymentBundle maps the invite bundle's
    // identity.bootstrapKey onto mounts.identity. Without a deployment
    // bundle (no linked-device newDeviceKp pre-authed) nodeB's local
    // writer is not an ADD_WRITER on this autobase, so it stays a
    // non-writable follower — exactly the "Pending Writer" condition
    // under test.
    mirror: {
      primaryServerKey: nodeA.sync.serverKey,
      repoEventLogKey: nodeA.svc.bootstrapKeys.repoEventLog
    },
    mounts: {
      identity: { bootstrapKey: nodeA.svc.bootstrapKeys.identity }
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  t.absent(nodeB.svc.identityRegistry.base.writable,
    'nodeB IdentityRegistry is not writable (no ADD_WRITER yet)')

  const port = nodeB.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Unknown pubkey on a non-writable registry → ephemeral "Pending
  // Writer" user with ['read'] perms. create-org requires 'admin'
  // (requireOwner), so it fails at the auth gate.
  const blocked = await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'pending-writer-blocked', type: 'group'
  }, { Cookie: auth.cookie })
  t.ok(
    blocked.status === 303 || (blocked.status >= 400 && blocked.status < 500),
    `write rejected (got ${blocked.status})`
  )
  // nodeB's orgs module should not have received the blocked write.
  const list = await nodeB.orgs.list()
  t.absent(list.some(o => o.name === 'pending-writer-blocked'),
    'blocked write did not land in nodeB orgs')
})

test('form labels are paired via for/id (U3 a11y)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Seed an org so we can GET /orgs/:name (which has a bunch of label
  // -bearing forms: add member, add resource, settings).
  await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'a11ytest', type: 'group', displayName: 'A11y Test'
  }, { Cookie: auth.cookie })

  // After U36+U37-b these admin forms only render for owners, so
  // fetch with the auth cookie (the seed above TOFU-promoted us).
  const getAuthed = (path) => new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path,
      headers: { Cookie: auth.cookie }
    }, (r) => {
      const parts = []
      r.on('data', c => parts.push(c))
      r.on('end', () => resolve({ status: r.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })

  // Assert the specific for-id pairs we added.
  const orgsPage = await getAuthed('/orgs')
  t.ok(orgsPage.body.includes('for="new-org-name"'), '/orgs Create org name has for=')
  t.ok(orgsPage.body.includes('id="new-org-name"'), '/orgs Create org name has id=')
  t.ok(orgsPage.body.includes('for="new-org-type"'), '/orgs Create org type has for=')
  t.ok(orgsPage.body.includes('for="new-org-description"'), '/orgs Create org desc has for=')

  // After the org-tabs split, member/resource/settings forms live on
  // their own tab pages. Fetch each and assert the for/id pair there.
  const orgMembers = await getAuthed('/orgs/a11ytest/members')
  t.ok(orgMembers.body.includes('for="add-member-search"'), 'Add member pairs label→input (typeahead)')
  const orgRepos = await getAuthed('/orgs/a11ytest/repos')
  t.ok(orgRepos.body.includes('for="add-resource-name"'), 'Add resource pairs label→input')
  const orgSettings = await getAuthed('/orgs/a11ytest/settings')
  t.ok(orgSettings.body.includes('for="org-settings-display"'), 'Settings display pairs label→input')

  // Issues page — the issue-create form.
  const issues = await fetchText(`http://127.0.0.1:${port}/orgs/a11ytest/issues`)
  t.ok(issues.body.includes('for="new-issue-title"'), 'Issue create title pairs label→input')
  t.ok(issues.body.includes('for="new-issue-body"'), 'Issue create body pairs label→input')

  // No stray bare <label> left anywhere (site-wide invariant).
  for (const path of ['/orgs', '/orgs/a11ytest', '/peers', '/files']) {
    const page = await fetchText(`http://127.0.0.1:${port}${path}`)
    t.absent(/<label>(?!<)/.test(page.body), `${path} has no bare <label> without for=`)
  }
})

test('mobile responsive breakpoints present in layout (U4)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const r = await fetchText(`http://127.0.0.1:${port}/repos`)
  t.is(r.status, 200)
  // Both breakpoints should appear in the inline styles block.
  t.ok(r.body.includes('@media (max-width: 768px)'), '768px breakpoint present')
  t.ok(r.body.includes('@media (max-width: 480px)'), '480px breakpoint present')
  // Table-scroll helper class should be defined.
  t.ok(r.body.includes('.table-scroll'), '.table-scroll helper present')
})

test('issue mutation routes render inline errors (U27-U32)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Seed a real issue on the repo so comment/labels have a valid id to
  // target. Then trigger a validation error (empty body) on /comment —
  // comment-issue throws "requires { id, body }" when body is empty.
  // Before U27-U32 this threw to the global catch-all → plain-text 500.
  // After: a 4xx HTML error page with "back to issues" link.
  await postForm(`http://127.0.0.1:${port}/repos/demo/issues/create`, {
    title: 'seed for error path', author: 'alice'
  }, { Cookie: auth.cookie })

  const comment = await postForm(`http://127.0.0.1:${port}/repos/demo/issues/1/comment`, {
    body: '', author: 'alice'
  }, { Cookie: auth.cookie })
  t.ok(comment.status >= 400 && comment.status < 500, `comment: status ${comment.status}`)
  t.absent(comment.location, 'comment did not redirect on error')
  t.ok(comment.body.includes('back to issues'), 'comment error includes back link')
})

test('admin mutation routes render inline errors instead of 500 (U33)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Grant with an empty userId triggers a validation throw in
  // grant-permission. Before U33 this rethrew → plain-text 500 from
  // the global catch-all. After U33 it renders a styled error page
  // with a "back to admin" link.
  const grant = await postForm(`http://127.0.0.1:${port}/admin/user-grant`, {
    userId: '', permissions: 'writer'
  }, { Cookie: auth.cookie })
  t.ok(grant.status >= 400 && grant.status < 500, `grant: status ${grant.status}`)
  t.absent(grant.location, 'grant did not redirect on error')
  t.ok(grant.body.includes('back to admin'), 'grant error includes back link')
})

test('forms submit-lock script is loaded site-wide (U5)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Every SSR page loads the base layout → the forms script tag should
  // appear in every rendered page. Check a handful of distinct routes.
  // (There's no /login — sign-in is an automatic JS flow from
  //  pgv3-bootstrap.js on every page, not a dedicated route.)
  for (const path of ['/repos', '/orgs', '/status', '/peers']) {
    const r = await fetchText(`http://127.0.0.1:${port}${path}`)
    t.is(r.status, 200, `${path} still renders`)
    t.ok(
      r.body.includes('/static/pgv3-forms.js'),
      `${path} includes the pgv3-forms.js script tag`
    )
  }

  // The static route serves the file with a JS content type.
  const r = await new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${port}/static/pgv3-forms.js`, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        type: res.headers['content-type'],
        body: b4a.concat(parts).toString('utf8')
      }))
    }).on('error', reject)
  })
  t.is(r.status, 200)
  t.ok(r.type.includes('javascript'), 'content-type is javascript')
  t.ok(r.body.includes('pgv3Forms'), 'script exposes window.pgv3Forms')
  t.ok(r.body.includes('pgv3:done'), 'script listens for pgv3:done unlock event')
})

test('empty states point at the in-page create form (U2)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // /orgs with no orgs → copy should mention the "Create org" form, not just "(no groups yet)"
  // Owners see the anchor; authed as TOFU-owner via loginAsHttpUser above.
  const orgs = await new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path: '/orgs',
      headers: { Cookie: auth.cookie }
    }, (r) => {
      const parts = []
      r.on('data', c => parts.push(c))
      r.on('end', () => resolve({ status: r.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })
  t.is(orgs.status, 200)
  t.ok(orgs.body.includes('#create-org'), '/orgs empty state links to #create-org anchor')
  t.absent(orgs.body.includes('(no groups yet)'), '/orgs no longer shows terse "(no groups yet)"')

  // /repos/:name/issues with no issues → copy should mention the Create issue form
  // Seed a repo so the issues page has something to render, but don't create any issues.
  await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'empty-states', type: 'group', displayName: 'Empty States'
  }, { Cookie: auth.cookie })
  const issues = await fetchText(`http://127.0.0.1:${port}/orgs/empty-states/issues`)
  t.is(issues.status, 200)
  t.ok(issues.body.includes('#create-issue'), '/issues empty state links to #create-issue anchor')
  t.absent(issues.body.includes('<div class="empty">(no issues)</div>'), '/issues no longer shows terse "(no issues)"')

  // /repos with no repos (sync service inactive on makeNode — no swarm) → nothing to show;
  // make a second node with sync enabled to hit the new empty-state copy.
  const node2 = await makeNode(t)
  const repos = await fetchText(`http://127.0.0.1:${node2.svc.http.port}/repos`)
  t.is(repos.status, 200)
  // sync is inactive without swarm, but the page still renders the service card;
  // the empty-state only appears when sync is active. We just verify the page loads
  // without the old terse message.
  t.absent(repos.body.includes('(no repos found — place bare'), '/repos no longer shows terse old copy')
})

test('POST /orgs/:name/issues/create with empty title renders error inline (U1)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Seed an org so the handler proceeds past any org lookup guards.
  await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'shared', type: 'group', displayName: 'Shared'
  }, { Cookie: auth.cookie })

  const create = await postForm(`http://127.0.0.1:${port}/orgs/shared/issues/create`, {
    title: '',
    author: 'alice'
  }, { Cookie: auth.cookie })

  t.ok(create.status >= 400 && create.status < 500, 'status is 4xx (got ' + create.status + ')')
  t.absent(create.location, 'did not redirect on error')
  t.ok(create.body.includes('error'), 'response body contains "error"')
  t.ok(create.body.includes('/orgs/shared/issues'), 'back link points to org issues page')
})

test('SSR + RPC see the same data', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Create via SSR (browser flow — needs auth)
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)
  await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'shared', type: 'group', displayName: 'Shared'
  }, { Cookie: auth.cookie })

  // Read via RPC
  const list = await client.call('list-orgs', {})
  t.is(list.length, 1)
  t.is(list[0].name, 'shared')
  t.is(list[0].displayName, 'Shared')

  // Create via RPC (UDS — auto-trusted). P.2 requires scope.
  await client.call('create-issue', { repo: 'demo', title: 'rpc-issue', author: 'alice' })

  // Read via SSR on the per-repo issue page
  const issuesPage = await fetchText(`http://127.0.0.1:${port}/repos/demo/issues`)
  t.ok(issuesPage.body.includes('rpc-issue'))
})

// ── /rpc/* coexists with SSR ──────────────────────────────────

test('POST /rpc/list-orgs is refused anonymously (org #12), but works over UDS', async (t) => {
  // This asserted `200 { result: [] }` for an ANONYMOUS caller — which is the
  // vulnerability, not the contract. hyper-svc dispatched every registered
  // method to anyone over HTTP, and this daemon is internet-reachable via
  // https://7rnx.net/git/rpc/, so the whole surface was world-callable. The
  // anonymous gate (hyper-svc 0.1.8) now default-denies; list-orgs is not in
  // the opted-in public-browsing set.

  const node = await makeNode(t)
  const port = node.svc.http.port

  // Use a low-level POST since the form helper does form-encoded
  const result = await new Promise((resolve, reject) => {
    const body = '{}'
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/rpc/list-orgs',
      headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts).toString('utf8') }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(body)
    req.end()
  })

  t.is(result.status, 403, 'anonymous /rpc/list-orgs is refused')
  const parsed = JSON.parse(result.body)
  t.ok(/anonymous access denied/.test(parsed.error || ''),
    'refused by the gate, not by a handler error: ' + parsed.error)

  // The same call over UDS — the local operator — still works, so the gate
  // closed the internet-facing surface without breaking local tooling.
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => { try { await client.close() } catch {} })
  t.alike(await client.call('list-orgs', {}), [], 'list-orgs still answers over UDS')
})

// ── Identity (Phase 7b.1) ─────────────────────────────────────

test('identity is created on first boot', async (t) => {
  const dir = tmpStorage()
  const id = await loadOrCreateIdentity(dir)
  t.is(id.publicKey.length, 32)
  t.is(id.secretKey.length, 64)
  t.is(id.publicKeyHex.length, 64)
  t.ok(id.createdAt > 0)
})

test('identity persists across restarts and has 0o600 perms', async (t) => {
  const dir = tmpStorage()
  const a = await loadOrCreateIdentity(dir)
  const b = await loadOrCreateIdentity(dir)
  t.alike(a.publicKey, b.publicKey, 'same public key')
  t.alike(a.secretKey, b.secretKey, 'same secret key')
  t.is(a.createdAt, b.createdAt, 'createdAt preserved')

  // hyperident stores identity in the user-level vault (~/.hyperident/)
  // — intentionally NOT in the daemon's storage dir, so identity persists
  // across storage migrations (landed in 715602b). The same-keypair
  // check above is the real persistence test. On machines with no
  // hyperident vault the fallback writes identity.json to the storage
  // dir; we check 0o600 perms there.
  const hasLegacy = existsSync(join(dir, 'identity.json'))
  if (hasLegacy) {
    const stat = statSync(join(dir, 'identity.json'))
    t.is(stat.mode & 0o077, 0, 'no group/other perms')
    t.ok(stat.mode & 0o400, 'owner can read')
    t.ok(stat.mode & 0o200, 'owner can write')
  } else {
    t.is(a.source, 'hyperident',
      'no local identity.json → source should be hyperident (~/.hyperident/ vault)')
  }
})

test('identity has valid keypair', async (t) => {
  const dir = tmpStorage()
  const id = await loadOrCreateIdentity(dir)
  t.is(typeof id.publicKeyHex, 'string', 'has publicKeyHex')
  t.is(id.publicKeyHex.length, 64, 'publicKeyHex is 64 chars')
  t.is(id.publicKey.length, 32, 'publicKey is 32 bytes')
  t.is(id.secretKey.length, 64, 'secretKey is 64 bytes')
  t.ok(id.createdAt, 'has createdAt')
  t.ok(id.source, 'has source field: ' + id.source)
})

test('startPearGit reuses identity across restarts', async (t) => {
  const dir = tmpStorage()
  const sock1 = tmpSockPath()
  const node1 = await startPearGit({ storage: dir, uds: sock1, http: { port: 0 } })
  const id1 = node1.identity.publicKeyHex
  await node1.close()

  const sock2 = tmpSockPath()
  const node2 = await startPearGit({ storage: dir, uds: sock2, http: { port: 0 } })
  const id2 = node2.identity.publicKeyHex
  t.teardown(async () => { try { await node2.close() } catch {} })

  t.is(id1, id2, 'same daemon identity after restart')
})

// ── Overlay handlers (Phase 7b.1) — RETIRED, all skipped ──────
//
// These cover the v1 PATH-addressed overlay (put-file/get-file/list-files/
// exists-file/stat-file/delete-file, and the /files + /files/upload +
// /files/delete SSR routes). Commit e0ecfb1 ("overlay 0.3.0: retire v1
// path-addressed overlay; content-addressed attachments only") removed every
// one of those handlers and routes, but left these tests behind — so they
// called a method that no longer exists and threw an UNCAUGHT error, which
// killed the whole `brittle test/*.test.js` run partway through. Everything
// after them silently never ran.
//
// Skipped rather than deleted so the retired surface stays on record. The
// surviving `/files/raw` route is V2 content-addressed
// (?path=/attachments/<oid>) and is a different API from the one tested here.

test.skip('put-file then get-file round-trip', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const text = 'hello hyper-overlay\n'
  const base64 = b4a.toString(b4a.from(text, 'utf8'), 'base64')

  const put = await client.call('put-file', { path: '/notes/hi.txt', base64 })
  t.is(put.path, '/notes/hi.txt')
  t.is(put.size, text.length)
  t.ok(put.oid)

  const got = await client.call('get-file', { path: '/notes/hi.txt' })
  t.is(got.size, text.length)
  t.is(b4a.toString(b4a.from(got.base64, 'base64'), 'utf8'), text)
})

test.skip('list-files shows uploaded files', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const a = b4a.toString(b4a.from('a', 'utf8'), 'base64')
  const b = b4a.toString(b4a.from('bb', 'utf8'), 'base64')
  await client.call('put-file', { path: '/a.txt', base64: a })
  await client.call('put-file', { path: '/b.txt', base64: b })

  const list = await client.call('list-files', {})
  t.is(list.length, 2)
  const paths = list.map(f => f.path).sort()
  t.alike(paths, ['/a.txt', '/b.txt'])
})

test.skip('exists-file and stat-file', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const before = await client.call('exists-file', { path: '/x.txt' })
  t.is(before.exists, false)

  await client.call('put-file', {
    path: '/x.txt',
    base64: b4a.toString(b4a.from('xyz', 'utf8'), 'base64')
  })

  const after = await client.call('exists-file', { path: '/x.txt' })
  t.is(after.exists, true)

  const stat = await client.call('stat-file', { path: '/x.txt' })
  t.is(stat.type, 'file')
  t.is(stat.size, 3)
})

test.skip('delete-file removes a file', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('put-file', {
    path: '/gone.txt',
    base64: b4a.toString(b4a.from('bye', 'utf8'), 'base64')
  })
  await client.call('delete-file', { path: '/gone.txt' })

  const exists = await client.call('exists-file', { path: '/gone.txt' })
  t.is(exists.exists, false)
})

test.skip('get-file throws on missing path', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  try {
    await client.call('get-file', { path: '/nope.txt' })
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.toLowerCase().includes('not found'))
  }
})

// ── Overlay SSR (Phase 7b.1) ──────────────────────────────────

test.skip('GET /files renders empty state', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/files`)
  t.is(status, 200)
  t.ok(body.includes('Overlay'))
  t.ok(body.includes('Upload'))
})

test.skip('POST /files/upload then GET /files shows it (with auth)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  const upload = await postForm(`http://127.0.0.1:${port}/files/upload`, {
    path: '/notes/test.txt',
    content: 'hello from the form'
  }, { Cookie: auth.cookie })
  t.is(upload.status, 303)
  t.is(upload.location, '/files')

  const list = await fetchText(`http://127.0.0.1:${port}/files`)
  t.is(list.status, 200)
  t.ok(list.body.includes('/notes/test.txt'))
})

test.skip('GET /files/raw streams file bytes', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  await postForm(`http://127.0.0.1:${port}/files/upload`, {
    path: '/binary.bin',
    content: 'raw stream content'
  }, { Cookie: auth.cookie })

  const raw = await fetchText(`http://127.0.0.1:${port}/files/raw?path=${encodeURIComponent('/binary.bin')}`)
  t.is(raw.status, 200)
  t.is(raw.body, 'raw stream content')
})

test.skip('POST /files/delete removes a file (with auth)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  await postForm(`http://127.0.0.1:${port}/files/upload`, {
    path: '/temp.txt',
    content: 'will be deleted'
  }, { Cookie: auth.cookie })
  const del = await postForm(`http://127.0.0.1:${port}/files/delete`, {
    path: '/temp.txt'
  }, { Cookie: auth.cookie })
  t.is(del.status, 303)

  const list = await fetchText(`http://127.0.0.1:${port}/files`)
  t.ok(!list.body.includes('/temp.txt'))
})

test('status page mentions overlay and identity', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/status`)
  t.is(status, 200)
  t.ok(body.includes('overlay') || body.includes('Overlay'))
  t.ok(body.includes('Identity'))
})

// ── Auth flow (Phase 2a — signed-envelope RPC) ────────────────
//
// The old challenge+verify+cookie tests are gone. Identity is now
// established via /api/rpc signed envelopes — see:
//   test('/api/rpc: envelope-signed whoami TOFU-registers ...')
//   test('/api/rpc: tampered payload rejected with HASH_MISMATCH')
//   test('/api/rpc: replay within TTL rejected with NONCE_REPLAY')
//   test('/api/rpc: second envelope from same key skips TOFU ...')
// earlier in this file. Session cookies are minted server-side from
// the verified envelope so SSR form POSTs still authenticate via
// cookie during the Phase 2a transition.

test('first HTTP user becomes owner (TOFU via IdentityRegistry)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const usersBefore = await node.identityRegistry.listUsers()
  t.is(usersBefore.length, 0, 'registry empty before first login')

  const auth = await loginAsHttpUser(port)
  t.ok(auth.becameOwner, 'first user reported becameOwner=true')

  const usersAfter = await node.identityRegistry.listUsers()
  t.is(usersAfter.length, 1, 'one user created')
  const user = usersAfter[0]
  t.ok(user.globalPermissions.includes('admin'), 'first user is admin')

  // Identity record links back to user (method: 'anproto' post-Phase 2a)
  const identity = await node.identityRegistry.getIdentity(auth.publicKeyHex)
  t.ok(identity, 'identity record exists')
  t.is(identity.method, 'anproto', 'Phase 2a method string')
  t.is(identity.userId, user.userId)

  // whoami reports owner (wire-compat label for admin)
  const me = await postJson(`http://127.0.0.1:${port}/api/auth/me`, {}, 'GET', {
    Cookie: auth.cookie
  })
  t.is(me.status, 200)
  t.is(me.body.publicKey, auth.publicKeyHex)
  t.is(me.body.trust, 'owner')
  t.is(me.body.userId, user.userId)
})

test('second HTTP user becomes read-only (not admin)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const owner = await loginAsHttpUser(port, genTestKeypair())
  const second = await loginAsHttpUser(port, genTestKeypair())

  const me = await postJson(`http://127.0.0.1:${port}/api/auth/me`, {}, 'GET', {
    Cookie: second.cookie
  })
  // Second user has ['read'] only — no 'admin', no 'write' — so whoami
  // returns 'unknown' (the wire label for "not a writer or owner").
  t.is(me.body.trust, 'unknown')

  // sanity: owner is still owner
  const me1 = await postJson(`http://127.0.0.1:${port}/api/auth/me`, {}, 'GET', {
    Cookie: owner.cookie
  })
  t.is(me1.body.trust, 'owner')
})

test('mutating RPC over HTTP without cookie is rejected', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Try to create-org over HTTP RPC with no auth
  const res = await postJson(`http://127.0.0.1:${port}/rpc/create-org`, {
    name: 'unauth', type: 'group'
  })
  t.not(res.status, 200, 'should be rejected')

  // No org should have been created
  const list = await node.orgs.list()
  t.is(list.length, 0)
})

test('mutating RPC over HTTP with cookie works', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  const res = await postJson(`http://127.0.0.1:${port}/rpc/create-org`, {
    name: 'authed', type: 'group'
  }, 'POST', { Cookie: auth.cookie })
  t.is(res.status, 200)
  t.is(res.body.result.name, 'authed')

  const list = await node.orgs.list()
  t.is(list.length, 1)
})

test('mutating RPC via /api/rpc envelope works', async (t) => {
  // Phase 2a replacement for the bearer-token test. The envelope path
  // is the canonical auth — the /rpc/<method> cookie path is only
  // supported as a transition mechanism for in-flight pages.
  const node = await makeNode(t)
  const port = node.svc.http.port

  const r = await callEnvelopeRpc(port, 'create-org', { name: 'envelope-org', type: 'group' })
  t.is(r.status, 200)
  t.is(r.body.result.name, 'envelope-org')

  const list = await node.orgs.list()
  t.is(list.length, 1)
})

test('mutating SSR over HTTP without cookie redirects to /', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const res = await postForm(`http://127.0.0.1:${port}/orgs/create`, {
    name: 'noauth', type: 'group'
  })
  t.is(res.status, 303)
  t.ok(res.location.startsWith('/'))

  const list = await node.orgs.list()
  t.is(list.length, 0)
})

test('reads work without auth', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Create one org via UDS so reads have something to return
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())
  await client.call('create-org', { name: 'public-readable', type: 'group' })

  // Anonymous SSR read works
  const list = await fetchText(`http://127.0.0.1:${port}/orgs`)
  t.is(list.status, 200)
  t.ok(list.body.includes('public-readable'))

  // ...but the raw RPC surface is NOT open to anonymous callers (org #12).
  // Public browsing is served by SSR, which renders with the visitor's ctx and
  // is unaffected; /rpc/ is the internet-facing dispatch surface that used to
  // hand any registered method to anyone.
  const rpc = await postJson(`http://127.0.0.1:${port}/rpc/list-orgs`, {})
  t.is(rpc.status, 403, 'anonymous /rpc/list-orgs refused')
  t.ok(/anonymous access denied/.test((rpc.body && rpc.body.error) || ''),
    'refused by the anonymous gate')
})

test('logout clears the session cookie', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  // Confirm session cookie works
  const me1 = await postJson(`http://127.0.0.1:${port}/api/auth/me`, {}, 'GET', {
    Cookie: auth.cookie
  })
  t.is(me1.body.publicKey, auth.publicKeyHex)

  // /api/auth/logout returns ok + issues a Set-Cookie that clears
  // the session cookie. Phase 2a: the session is derived from the
  // envelope, so "log out" here means "drop the cookie cache". The
  // browser's envelope keypair is untouched — it's cleared client-
  // side by window.pgv3SignOut().
  const logout = await postJson(`http://127.0.0.1:${port}/api/auth/logout`, {}, 'POST', {
    Cookie: auth.cookie
  })
  t.is(logout.status, 200)
  t.ok(logout.body.ok, 'logout returned ok=true')
  t.ok(logout.setCookie, 'Set-Cookie header present')
  const clearCookie = Array.isArray(logout.setCookie) ? logout.setCookie[0] : logout.setCookie
  t.ok(clearCookie.includes('Max-Age=0'), 'cookie cleared via Max-Age=0')
})

test('UDS callers bypass auth checks', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // No login, no trust map entries — but UDS should just work.
  // Issues are scope-required (P.2) — use a nominal scope for this test.
  await client.call('create-org', { name: 'uds-only', type: 'group' })
  await client.call('create-issue', { repo: 'scratch', title: 'uds-issue', author: 'local' })

  const orgs = await node.orgs.list()
  const issues = await node.issueRegistry.counts()
  t.is(orgs.length, 1)
  t.is(issues.total, 1)

  // whoami over UDS reports local
  const me = await client.call('whoami', {})
  t.is(me.transport, 'uds')
  t.is(me.trust, 'local')
})

// ── User / identity handlers (F.6 replaces trust handlers) ─────

test('grant-permission over UDS elevates perms', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  await loginAsHttpUser(port) // TOFU as admin (first user)

  // Add a second user — will land as read-only
  const second = await loginAsHttpUser(port)

  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const users = await client.call('list-users', {})
  const secondUser = users.find(u => (u.identities || []).includes(second.publicKeyHex))
  t.ok(secondUser, 'second user exists')
  t.alike(secondUser.globalPermissions.sort(), ['read'])

  // Elevate to writer via grant-permission
  const after = await client.call('grant-permission', {
    userId: secondUser.userId,
    permissions: ['write', 'read']
  })
  t.ok(after.globalPermissions.includes('write'))
  t.ok(after.globalPermissions.includes('read'))
})

test('registry lists all users after multiple logins', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  await loginAsHttpUser(port, genTestKeypair()) // admin (first TOFU)
  await loginAsHttpUser(port, genTestKeypair()) // read-only

  const users = await node.identityRegistry.listUsers()
  t.is(users.length, 2, 'two users registered')

  const admins = users.filter(u => (u.globalPermissions || []).includes('admin'))
  t.is(admins.length, 1, 'exactly one admin (first user)')
})

// ── SSH auto-import (F.5) ──────────────────────────────────────

test('SSH auto-import: skipped when no owner exists', async (t) => {
  // Fake $HOME with a known .pub key so the scanner finds something,
  // then verify auto-import refuses to act without an owner User.
  const dir = join(tmpdir(), 'ssh-home-noowner-' + Math.random().toString(16).slice(2))
  mkdirSync(join(dir, '.ssh'), { recursive: true })
  writeFileSync(
    join(dir, '.ssh', 'id_ed25519.pub'),
    'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP test@example\n'
  )
  const origHome = process.env.HOME
  process.env.HOME = dir
  t.teardown(() => { process.env.HOME = origHome })

  const node = await makeNode(t, { autoImportSSH: true })

  const users = await node.identityRegistry.listUsers()
  t.is(users.length, 0, 'no user records because no TOFU has happened')
})

test('SSH auto-import: runs post-TOFU and links keys to the new owner', async (t) => {
  // Same fake $HOME
  const dir = join(tmpdir(), 'ssh-home-tofu-' + Math.random().toString(16).slice(2))
  mkdirSync(join(dir, '.ssh'), { recursive: true })
  writeFileSync(
    join(dir, '.ssh', 'id_ed25519.pub'),
    'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP test@example\n'
  )
  const origHome = process.env.HOME
  process.env.HOME = dir
  t.teardown(() => { process.env.HOME = origHome })

  const node = await makeNode(t, { autoImportSSH: true })
  const port = node.svc.http.port

  // First HTTP login triggers TOFU (user becomes admin) and kicks off
  // post-TOFU SSH auto-import as a fire-and-forget promise.
  await loginAsHttpUser(port, genTestKeypair())

  // Wait for the async auto-import to register the SSH identity.
  const ok = await waitFor(async () => {
    const users = await node.identityRegistry.listUsers()
    if (users.length === 0) return false
    const { identities } = await node.identityRegistry.getUserWithIdentities(users[0].userId)
    return identities.some(i => i.method === 'ssh-key')
  }, 3000)
  t.ok(ok, 'owner user has an ssh-key identity within 3s')

  const users = await node.identityRegistry.listUsers()
  t.is(users.length, 1, 'exactly one user (the owner)')
  const { identities } = await node.identityRegistry.getUserWithIdentities(users[0].userId)
  const sshEntry = identities.find(i => i.method === 'ssh-key')
  t.ok(sshEntry, 'SSH key identity registered')
  t.is(sshEntry.userId, users[0].userId, 'SSH identity linked to owner user')
  t.ok(sshEntry.credentials && sshEntry.credentials.fingerprint, 'fingerprint stored')
  t.is(sshEntry.credentials.type, 'ed25519')
})

// ── Static asset serving (Phase 7b.2) ─────────────────────────

test('GET /static/browser-identity.js serves vendored JS', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/static/browser-identity.js`)
  t.is(res.status, 200)
  t.ok(res.body.includes('BrowserIdentity'))
})

test('GET /static/vendor/noble-ed25519.js serves vendored noble', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/static/vendor/noble-ed25519.js`)
  t.is(res.status, 200)
  t.ok(res.body.includes('CURVE') || res.body.includes('ed25519'))
})

test('GET /static/etc/passwd is rejected (path traversal)', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/static/../package.json`)
  t.not(res.status, 200, 'path traversal blocked')
})

test('GET /static/some-random-thing.js returns 404', async (t) => {
  const node = await makeNode(t)
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/static/random-not-in-whitelist.js`)
  t.is(res.status, 404)
})

test('SSR pages include the bootstrap script', async (t) => {
  const node = await makeNode(t)
  // / redirects to /repos now, so fetch a real page for body assertions
  const res = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos`)
  t.ok(res.body.includes('/static/pgv3-bootstrap.js'))
})

// ── Swarm replication (Phase 7c.1) ────────────────────────────

test('dump-keys is UDS-only and returns the bootstrap keys', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Instantiate a per-repo issue tracker so keys.issues.repos has an entry
  await client.call('create-issue', { repo: 'demo', title: 'hi', author: 'bob' })

  const keys = await client.call('dump-keys', {})
  // Per-org-autobase migration (#35): there is no single shared orgs
  // autobase anymore, so dump-keys reports orgs.bootstrapKey === null.
  // Per-org + per-repo tracker keys are carried under keys.issues.
  t.is(keys.orgs.bootstrapKey, null, 'no single orgs autobase (per-org scope)')
  t.is(typeof keys.issues.repos.demo, 'string', 'per-repo issue bootstrap key')
  t.is(keys.issues.repos.demo.length, 64)
  t.is(typeof keys.identity.bootstrapKey, 'string')
  t.is(keys.identity.bootstrapKey.length, 64)

  // The per-repo + identity keys must show up on the status page (moved from /)
  const page = await fetchText(`http://127.0.0.1:${node.svc.http.port}/status`)
  t.ok(page.body.includes(keys.issues.repos.demo))
  t.ok(page.body.includes(keys.identity.bootstrapKey))
})

test('dump-keys over HTTP is rejected', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())
  const res = await postJson(`http://127.0.0.1:${port}/rpc/dump-keys`, {}, 'POST', {
    Cookie: auth.cookie
  })
  t.not(res.status, 200, 'should be rejected')
})

test('two daemons mirror per-org records and per-repo issues over swarm', async (t) => {
  // Per-org-autobase model (#35): there is no single shared orgs
  // autobase to mount. Mirror mode = the primary exposes listOrgKeys()
  // and the mirror mountOrg()s each → full replica of the org RECORD
  // autobases. Per-repo issue trackers mount by their own bootstrap key
  // exactly as before.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-replication-test')

  // Daemon A — primary, creates fresh state
  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // Seed some state on A: an org (its own autobase) and a per-repo issue.
  await nodeA.orgs.create('shared-team', 'group', { displayName: 'Shared' })
  const trackerA = await nodeA.issueRegistry.getForRepo('demo-repo')
  await trackerA.create({ title: 'shared bug', author: 'alice' })
  const demoRepoKey = trackerA.keyHex
  // Mirror mode: the primary's per-org autobase keys (the data the
  // OrgEventLog/list-org-keys loop delivers in production).
  const orgKeys = nodeA.orgs.listOrgKeys()

  // Daemon B — mounts the per-repo issue tracker by key …
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false,
    mounts: {
      issueRepos: { 'demo-repo': demoRepoKey }
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // … and mirror-mounts every one of A's per-org autobases.
  for (const { name, bootstrapKey } of orgKeys) await nodeB.orgs.mountOrg(name, bootstrapKey)

  // Wait for swarm replication
  const trackerB = await nodeB.issueRegistry.getForRepo('demo-repo')
  const ok = await waitFor(async () => {
    await nodeB.orgs.sync()
    const orgs = await nodeB.orgs.list()
    const counts = await trackerB.counts()
    return orgs.length === 1 && counts.total === 1
  }, 8000)
  t.ok(ok, 'B received A\'s org record and per-repo issues via swarm')

  const org = await nodeB.orgs.get('shared-team')
  t.is(org.displayName, 'Shared')

  const issuesList = await trackerB.list({ status: 'open' })
  t.is(issuesList.length, 1)
  t.is(issuesList[0].title, 'shared bug')
})

// Trust-map replication was retired with F.6. IdentityRegistry
// replication across peers is Autobase-backed and ships via the same
// shared swarm as orgs/issues, but the peer-join flow (passing
// identity bootstrap key via opts.mounts.identity) isn't wired yet —
// covered in a future phase. Keeping a placeholder skip so the test
// file's shape documents the intended coverage.
test.skip('two daemons replicate IdentityRegistry (TODO: F.7 follow-up)', () => {})

test('two daemons replicate per-org issues via swarm (P.2)', async (t) => {
  // Verifies the per-org tracker — new in P.2 — replicates between peers
  // using the issueOrgs bootstrap-key surface, exactly mirroring the
  // per-repo pattern above.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-org-issues-test')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // Seed an org-scoped tracker on A
  const trackerA = await nodeA.issueRegistry.getForOrg('acme')
  await trackerA.create({ title: 'proposal: governance', author: 'alice' })
  const acmeKey = trackerA.keyHex

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false,
    mounts: {
      issueOrgs: { acme: acmeKey }
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // On B, opening the same org-scoped tracker (by bootstrap key) should
  // receive A's issue via swarm replication.
  const trackerB = await nodeB.issueRegistry.getForOrg('acme')
  const ok = await waitFor(async () => {
    const counts = await trackerB.counts()
    return counts.total === 1
  }, 8000)
  t.ok(ok, 'B received A\'s per-org issue via swarm')

  const list = await trackerB.list({ status: 'open' })
  t.is(list.length, 1)
  t.is(list[0].title, 'proposal: governance')

  // Sanity: the per-org scope is genuinely separate from per-repo — no
  // cross-contamination from the other fixture.
  const counts = await nodeB.issueRegistry.counts()
  t.is(counts.orgs, 1, 'B has 1 org-scoped tracker')
  t.is(counts.repos, 0, 'B has 0 repo-scoped trackers')
})

// SKIPPED — needs porting to overlayV2, not just a rename.
//
// This calls `nodeA.overlay.put(path, buffer)`. The v1 path-addressed overlay
// was retired in e0ecfb1: startPearGit now returns `overlayV2` (pear-git.js:2400)
// and there is no `overlay`, so `nodeA.overlay` is undefined and `.put` threw an
// UNCAUGHT TypeError that ABORTED the whole `brittle test/*.test.js` run at this
// point (exit 134) — every file ordered after basic.test.js silently never ran,
// which is why the suite looked like "126 passing, 0 failing".
//
// It cannot be fixed by renaming: HyperOverlay v2 exposes
// registerFile(overlayPath, diskPath, meta) + fetchFile(contentHash) — no put(),
// and registerFile takes a DISK PATH rather than a buffer. Porting means writing
// the payload to a temp file, registerFile on A, fetchFile on B. The capability
// under test (content-hash blob fetch over swarm) is still live and worth
// covering, so this is a real TODO rather than dead weight.
test.skip('two daemons — overlay blob fetched by content hash over swarm', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-overlay-byhash-test')

  // Daemon A uploads a small blob to its overlay
  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const payload = crypto.randomBytes(64 * 1024) // 64KB (single-chunk, hash == fileHash)
  const { oid: hash } = await nodeA.overlay.put('/attachments/' + b4a.toString(crypto.hash(payload), 'hex'), payload)
  t.is(typeof hash, 'string')
  t.is(hash.length, 64, 'hash is 32-byte hex')

  // Daemon B joins the same swarm topic — no overlay mount, just peer discovery.
  // The /files/raw fall-through path is what makes getByHash reachable by URL,
  // but for this test we exercise the module API directly.
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // B never put this blob — it must travel over the swarm.
  const notLocal = await nodeB.overlay._registry.getChunkData(hash)
  t.is(notLocal, null, 'B has no local copy before fetch')

  // Poll until the peer connection is up and the blob is retrievable.
  // waitFor returns a bool — we keep a reference to the fetched buffer.
  let fetched = null
  const ok = await waitFor(async () => {
    try {
      fetched = await nodeB.overlay.getByHash(hash)
      return fetched !== null
    } catch {
      return false
    }
  }, 8000)

  t.ok(ok, 'B fetched the blob via mux')
  t.ok(fetched, 'getByHash returned non-null')
  t.alike(fetched, payload, 'bytes match what A uploaded')
})

test('two daemons — browser flow: POST /api/upload on A, GET /files/raw on B', async (t) => {
  // End-to-end browser-path coverage: upload a blob on daemon A via the
  // authenticated HTTP API, then fetch it on daemon B (a separate daemon
  // on the same swarm topic) via the unauthenticated /files/raw endpoint.
  // Exercises the full stack — swarm peering, SwarmTransport protomux
  // wiring, OverlayProtocol.requestChunkByHash, router.js fall-through.
  //
  // Stand-in for the "pfp uploaded on tabertron, rendered on stortron"
  // live check. Kept isolated (local testnet, local ports) so it runs in
  // CI without touching any production host.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-browser-flow-byhash')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // 1. Auth + upload on A (uses the existing browser-emulating login helper)
  const portA = nodeA.svc.http.port
  const auth = await loginAsHttpUser(portA, genTestKeypair())

  const payload = crypto.randomBytes(48 * 1024) // 48KB ≈ 5 FastCDC chunks (tier 0)
  const uploadRes = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port: portA,
      path: '/api/upload',
      headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Length': payload.length,
        Cookie: auth.cookie
      }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        body: JSON.parse(b4a.concat(parts).toString('utf8'))
      }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(payload)
    req.end()
  })
  t.is(uploadRes.status, 200, 'upload succeeded')
  const hash = uploadRes.body.hash
  const overlayPath = '/attachments/' + hash

  // 2. Fetch on B via /files/raw (no auth required). Poll to let the
  // swarm connection settle and the mux channel open.
  const portB = nodeB.svc.http.port
  const fetchBinary = () => new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${portB}/files/raw?path=${encodeURIComponent(overlayPath)}`, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts) }))
      res.on('error', reject)
    }).on('error', reject)
  })

  let finalRes = null
  const ok = await waitFor(async () => {
    finalRes = await fetchBinary()
    return finalRes.status === 200 && finalRes.body.length === payload.length
  }, 8000)

  t.ok(ok, 'B served the blob over HTTP via /files/raw')
  // Guard the dereference. When the wait times out, finalRes can still be null
  // (or a non-200), and reading .status threw an UNCAUGHT TypeError that killed
  // the whole `brittle test/*.test.js` process — so a genuine failure here was
  // silently truncating the suite instead of reporting itself. The t.ok above
  // already records the failure; bail before dereferencing.
  if (!ok || !finalRes) return
  t.is(finalRes.status, 200, 'HTTP 200')
  t.alike(finalRes.body, payload, 'response bytes match A\'s upload')
})

test('multi-writer: mirror joins a per-org autobase via blind-pairing, write propagates back', async (t) => {
  // Per-org-autobase model (#35) + blind-pairing rebuild: writability is
  // granted PER ORG via an invite. The owner (A) creates an org and mints a
  // CONTRIBUTOR pairing invite; the mirror (B) joins it → B is addWritered
  // DURING pairing over the swarm (no HTTP, no separate owner-enroll) and its
  // writes replicate back to A.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-multiwriter-test')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // A owns the org (its own autobase).
  await nodeA.orgs.create('shared-team', 'group', { displayName: 'Shared' })
  const [{ name: orgName }] = nodeA.orgs.listOrgKeys()

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // A mints a contributor invite; B joins → mounts the org AND is enrolled as
  // a writer DURING blind-pairing over the swarm (no separate enrollWriter).
  const { invitation } = await nodeA.orgs.createOrgInvite(orgName, {
    role: 'contributor', swarm: nodeA.svc.swarm.swarm
  })
  const joined = await nodeB.orgs.joinOrg(invitation, { swarm: nodeB.svc.swarm.swarm })
  t.is(joined.name, orgName, 'B joined the org via blind-pairing invite')

  // Wait for B to become writable on that org via pairing.
  const becameWritable = await waitFor(async () => {
    await nodeB.orgs.sync()
    const room = nodeB.orgs.getOrgRoom(orgName)
    return !!(room && room.writable)
  }, 8000)
  t.ok(becameWritable, 'B became writable on the org via blind-pairing')

  // B writes to the org (adds a resource).
  await nodeB.orgs.addResource(orgName, 'repo-from-b')

  // A should see B's write.
  const propagated = await waitFor(async () => {
    await nodeA.orgs.sync()
    const org = await nodeA.orgs.get(orgName)
    return !!(org && org.resources.includes('repo-from-b'))
  }, 8000)
  t.ok(propagated, 'A received B\'s write via swarm replication')
})

test('symmetric peers: mirror writes to a per-org autobase AND per-repo tracker after enrollment', async (t) => {
  // Per-org-autobase model (#35): a mirror earns write access PER
  // resource. The owner (A) creates an org and a per-repo tracker; the
  // mirror (B) joins the org via a blind-pairing invite (writer-enrolled
  // DURING pairing) and mounts the per-repo tracker by key + is addWritered
  // on it. B's writes to both replicate back — symmetric peer behavior
  // without any single shared autobase.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-sym-peers-test')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // Seed an org (its own autobase) and a per-repo issue tracker on A.
  await nodeA.orgs.create('shared-team', 'group', { displayName: 'Shared' })
  const [{ name: orgName }] = nodeA.orgs.listOrgKeys()
  const trackerA = await nodeA.issueRegistry.getForRepo('shared-repo')
  const repoIssuesKey = trackerA.keyHex

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }, deploy: false, sync: false,
    mounts: {
      issueRepos: { 'shared-repo': repoIssuesKey }
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // B joins the org via blind-pairing (writer-enrolled during pairing) and
  // mounts the per-repo tracker by key.
  const { invitation } = await nodeA.orgs.createOrgInvite(orgName, {
    role: 'contributor', swarm: nodeA.svc.swarm.swarm
  })
  await nodeB.orgs.joinOrg(invitation, { swarm: nodeB.svc.swarm.swarm })
  const trackerB = await nodeB.issueRegistry.getForRepo('shared-repo')

  // Wait for swarm connection + B's local key on the tracker (the org writer
  // grant already happened during join-org pairing).
  await waitFor(async () => {
    return trackerB.localKeyHex && nodeA.svc.swarm.connections >= 1
  }, 5000)

  // A addWriters B on the per-repo issue tracker; the org write-grant already
  // landed during the join-org blind-pairing above.
  await trackerA.addWriter('peer-b', { writerKey: trackerB.localKeyHex, role: 'writer' })

  // Both autobases should become writable on B.
  const bothWritable = await waitFor(async () => {
    await nodeB.orgs.sync()
    await trackerB.sync()
    const room = nodeB.orgs.getOrgRoom(orgName)
    return !!(room && room.writable) && trackerB.writable === true
  }, 8000)
  t.ok(bothWritable, 'B became writable on the org AND the issue tracker')

  // B writes to both — symmetric peer behavior.
  await nodeB.orgs.addResource(orgName, 'repo-from-b')
  await trackerB.create({ title: 'issue-from-b', author: 'peer-b' })

  // A should see both of B's writes.
  const converged = await waitFor(async () => {
    await nodeA.orgs.sync()
    const org = await nodeA.orgs.get(orgName)
    const issues = await trackerA.list({ status: 'open' })
    return !!(org && org.resources.includes('repo-from-b')) &&
           issues.some(i => i.title === 'issue-from-b')
  }, 8000)
  t.ok(converged, 'A received B\'s org write AND issue — symmetric peer convergence')
})

// ── Git push ACL (issue #37, item 2) ──────────────────────────

test('git ACL: HTTP receive-pack requires write; reads stay open; writer passes', async (t) => {
  // Only PUSH is gated (reuses the IdentityRegistry user-perm model);
  // clone/fetch stays open. Identity comes from the HTTP session cookie.
  const { execSync } = await import('../lib/_compat-exec.js')
  const reposDir = join(mkdtempSync(join(tmpdir(), 'pgv3-acl-repos-')), 'git-repos')
  mkdirSync(reposDir, { recursive: true })
  execSync(`git init --bare --initial-branch=main ${join(reposDir, 'gated.git')}`, { stdio: 'ignore' })

  const node = await makeNode(t, { reposDir })
  const port = node.svc.http.port
  const base = `http://127.0.0.1:${port}/gated.git`

  // Reads stay open — upload-pack advertisement, no auth → 200.
  const readAd = await postJson(`${base}/info/refs?service=git-upload-pack`, null, 'GET')
  t.is(readAd.status, 200, 'fetch advertisement is open without auth')

  // Push advertisement, NO credentials → 401 + a Basic challenge.
  //
  // This asserted 403 and had been failing ever since d4b42d2 (classic-git-auth
  // Phase 1, 0.1.110) deliberately changed it. 401 is the correct answer and
  // the 403 was the bug: stock `git push` sends the request unauthenticated
  // first, and only prompts for credentials when it gets 401 +
  // WWW-Authenticate. A 403 makes git give up instead of asking, so pushing
  // with a personal access token would be impossible. The challenge header is
  // the load-bearing part, so assert it rather than just the status.
  const pushAdAnon = await postJson(`${base}/info/refs?service=git-receive-pack`, null, 'GET')
  t.is(pushAdAnon.status, 401, 'push advertisement challenges when no credentials are presented')
  t.ok(/^Basic realm=/.test(pushAdAnon.headers['www-authenticate'] || ''),
    'and sends a Basic challenge so stock git prompts for a token')

  // receive-pack POST, no credentials → same challenge, before git is spawned.
  const pushPostAnon = await postJson(`${base}/git-receive-pack`, {}, 'POST')
  t.is(pushPostAnon.status, 401, 'receive-pack challenges without credentials')
  t.ok(/^Basic realm=/.test(pushPostAnon.headers['www-authenticate'] || ''),
    'receive-pack sends the Basic challenge too')

  // Credentials PRESENTED but not authorized → 403, and NO challenge — else
  // git would re-prompt in a loop. This is the actual "push is denied" branch
  // and the old test never covered it: it only ever exercised the
  // no-credentials path, so a regression here would have gone unnoticed.
  const badBasic = 'Basic ' + b4a.toString(b4a.from('x:pgv3_' + 'de'.repeat(24), 'utf8'), 'base64')
  const pushAdBadCreds = await postJson(
    `${base}/info/refs?service=git-receive-pack`, null, 'GET', { Authorization: badBasic }
  )
  t.is(pushAdBadCreds.status, 403, 'an invalid token is denied outright, not challenged')
  t.absent(pushAdBadCreds.headers['www-authenticate'],
    'no challenge on a rejected credential — avoids a 401 prompt loop')

  // First HTTP user is TOFU-promoted to owner (admin) → push allowed.
  const auth = await loginAsHttpUser(port)
  t.ok(auth.becameOwner, 'first user TOFU-promoted to owner')
  const pushAdAuthed = await postJson(
    `${base}/info/refs?service=git-receive-pack`, null, 'GET', { Cookie: auth.cookie }
  )
  t.is(pushAdAuthed.status, 200, 'writer passes the push advertisement gate')
})

// ── Deploy + Sync services (Phase 7c.2) ───────────────────────

test('list-releases returns inactive when no swarm', async (t) => {
  const node = await makeNode(t) // makeNode has no swarm
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const result = await client.call('list-releases', {})
  t.is(result.active, false)
  t.alike(result.files, [])
})

test('list-repos returns active even without swarm (local browsing)', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const result = await client.call('list-repos', {})
  t.is(result.active, true)
  t.is(result.repoCount, 0, 'no repos in test dir')
})

test('GET /deploy renders inactive state', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/deploy`)
  t.is(status, 200)
  t.ok(body.includes('Deploy service'))
  t.ok(body.includes('inactive'))
})

test('GET /repos renders active state', async (t) => {
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos`)
  t.is(status, 200)
  t.ok(body.includes('Sync service'))
  t.ok(body.includes('active'))
})

test('nav includes repos, orgs, and deploy links', async (t) => {
  const node = await makeNode(t)
  // / redirects to /repos now, so fetch /repos directly for nav content
  const { body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos`)
  t.ok(body.includes('href="/repos"'))
  t.ok(body.includes('href="/orgs"'))
  t.ok(body.includes('href="/deploy"'))
})

test('deploy + sync handlers are registered', async (t) => {
  const node = await makeNode(t)
  t.ok(node.svc.listHandlers().includes('list-releases'))
  t.ok(node.svc.listHandlers().includes('release-status'))
  t.ok(node.svc.listHandlers().includes('rescan-releases'), 'P.5: rescan-releases exposed')
  t.ok(node.svc.listHandlers().includes('list-repos'))
  t.ok(node.svc.listHandlers().includes('serve-repo'))
})

// ── Binary upload + issue attachments (Phase 7c.3) ────────────

test('POST /api/upload stores binary and returns hash', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  const data = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) // PNG header bytes
  const res = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/api/upload',
      headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Length': data.length,
        Cookie: auth.cookie
      }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({
        status: res.statusCode,
        body: JSON.parse(b4a.concat(parts).toString('utf8'))
      }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(data)
    req.end()
  })

  t.is(res.status, 200)
  t.is(typeof res.body.hash, 'string')
  t.ok(res.body.hash.length > 10)
  t.is(res.body.size, 8)
  t.ok(res.body.path.startsWith('/attachments/'))

  // Verify the file is retrievable
  const raw = await fetchText(`http://127.0.0.1:${port}/files/raw?path=${encodeURIComponent(res.body.path)}`)
  t.is(raw.status, 200)
})

test('POST /api/upload without auth is rejected', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const res = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/api/upload',
      headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': 4 }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(Buffer.from([1, 2, 3, 4]))
    req.end()
  })

  t.is(res.status, 401)
})

test('issue with images[] renders <img> tags', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())

  // Upload a fake image
  const fakeImage = Buffer.from('fake-png-data-for-test')
  const uploadRes = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/api/upload',
      headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Length': fakeImage.length,
        Cookie: auth.cookie
      }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => resolve(JSON.parse(b4a.concat(parts).toString('utf8'))))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(fakeImage)
    req.end()
  })

  // Create issue with the image hash via form POST (scope-required in P.2)
  await postForm(`http://127.0.0.1:${port}/repos/demo/issues/create`, {
    title: 'bug with screenshot',
    body: 'see attached',
    author: 'alice',
    images: uploadRes.hash
  }, { Cookie: auth.cookie })

  // Verify the issue page shows the image
  const page = await fetchText(`http://127.0.0.1:${port}/repos/demo/issues/1`)
  t.is(page.status, 200)
  t.ok(page.body.includes('bug with screenshot'))
  t.ok(page.body.includes('<img'))
  t.ok(page.body.includes(uploadRes.hash))
})

test('issue list shows attachment count', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Create issue with images via RPC (UDS bypasses auth). P.2: scope required.
  await client.call('create-issue', {
    repo: 'demo',
    title: 'issue with 2 images',
    author: 'bob',
    images: ['hash1', 'hash2']
  })

  const page = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos/demo/issues`)
  t.ok(page.body.includes('2 attachment'))
})

test('issues SSR includes upload script', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())
  // Instantiate a per-repo tracker so /repos/demo/issues has something to render
  await client.call('create-issue', { repo: 'demo', title: 'hi', author: 'bob' })
  const page = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos/demo/issues`)
  t.ok(page.body.includes('/static/pgv3-upload.js'))
  t.ok(page.body.includes('pgv3-upload-form'))
})

// ── P.3: repo detail tabs ───────────────────────────────────────

test('auto-build fires on git push via HTTP (P.5.3)', async (t) => {
  // End-to-end: git push to the daemon's HTTP git-receive-pack →
  // registerGitRoutes snapshots refs before/after, diffs them, and fires
  // buildAndPublish in the background. Tracked via svc._pendingBuilds so
  // svc.close() can drain in-flight jobs and not hang teardown.
  const { execSync } = await import('../lib/_compat-exec.js')

  const reposDir = join(mkdtempSync(join(tmpdir(), 'pear-git-v3-repos-')), 'git-repos')
  mkdirSync(reposDir, { recursive: true })
  execSync(`git init --bare --initial-branch=main ${join(reposDir, 'autobuild.git')}`, { stdio: 'ignore' })

  const node = await makeNode(t, { reposDir })
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Local work tree with a ci.yaml
  const work = mkdtempSync(join(tmpdir(), 'pear-git-v3-work-'))
  const g = (args) => execSync(`git -C ${work} ${args}`, { stdio: 'ignore' })
  g('init --initial-branch=main')
  g('config user.email ci@test')
  g('config user.name CI')
  mkdirSync(join(work, '.pear-git'), { recursive: true })
  writeFileSync(join(work, '.pear-git', 'ci.yaml'), `
steps:
  - name: build
    run: echo "bytes from auto-build" > built.txt

artifacts:
  - built.txt

channels:
  experimental:
    branches: ['main']
`)
  g('add -A')
  g('commit -m initial')

  // Push must authenticate — git-receive-pack now requires write access
  // (issue #37, item 2). The first HTTP user is TOFU-promoted to owner;
  // carry its session to the git client via http.extraHeader.
  const auth = await loginAsHttpUser(node.svc.http.port)

  // Push — must be async because git's HTTP push opens a connection back
  // to the daemon running in this same process; execSync would deadlock
  // the event loop before the server could accept.
  const remote = `http://127.0.0.1:${node.svc.http.port}/autobuild.git`
  const { spawn } = await import('#node:child_process')
  await new Promise((resolve, reject) => {
    const p = spawn('git', [
      '-C', work,
      '-c', `http.extraHeader=Cookie: ${auth.cookie}`,
      'push', remote, 'main'
    ], { stdio: 'ignore' })
    p.on('error', reject)
    p.on('exit', (code) => code === 0 ? resolve() : reject(new Error('git push exit ' + code)))
  })

  // Poll for the release directly. Polling pendingBuilds has a race where
  // a fast build finishes before the poller sees size > 0. The result
  // (release appears in the registry) is what we actually care about.
  const appeared = await waitFor(async () => {
    const list = await client.call('list-repo-releases', { repo: 'autobuild' }).catch(() => null)
    return list && list.versions.length > 0
  }, 15000)
  t.ok(appeared, 'auto-build produced a release within 15s of push')
  // Still drain pendingBuilds to make teardown safe
  if (node.svc._pendingBuilds && node.svc._pendingBuilds.size > 0) {
    await Promise.allSettled([...node.svc._pendingBuilds])
  }

  const list = await client.call('list-repo-releases', { repo: 'autobuild' })
  t.is(list.versions.length, 1, 'one version published from push')
  // Guard: on a timed-out build the array is empty — fail soft (the t.ok
  // above already recorded it) rather than crashing the whole process.
  if (!list.versions.length) return
  t.ok(/^main-[a-f0-9]{7}$/.test(list.versions[0].version), 'version is main-<shortSha>')
  t.is(list.channels.experimental?.version, list.versions[0].version, 'pinned to experimental via ci.yaml')

  const artifacts = list.versions[0].artifacts.map(a => a.path).sort()
  t.alike(artifacts, ['build.log', 'built.txt'])
})


test('link-identity ceremony round-trip + double-consume rejected (F.7.2)', async (t) => {
  // Single-daemon scenario, two HTTP sessions (Alice = primary,
  // Throwaway = the bystander identity that wants to merge into Alice).
  // The link token is a 5-min random hex; the registry stores only its
  // hash. Consuming the token attaches Throwaway's identity to Alice's
  // User and deactivates Throwaway.
  const node = await makeNode(t)
  const port = node.svc.http.port
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // 1. Alice signs in (TOFU → admin since first user)
  const aliceKp = genTestKeypair()
  const aliceSession = await loginAsHttpUser(port, aliceKp)
  const aliceUser = await client.call('get-user-by-identity', { identityId: aliceKp.publicKeyHex }).catch(async () => {
    // Fall back to scanning users
    const all = await client.call('list-users', {})
    return all.find(u => u.identities.includes(aliceKp.publicKeyHex))
  })
  t.ok(aliceUser, 'Alice user record exists')

  // 2. Alice generates a link token via her HTTP session
  const tokenRes = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/rpc/generate-link-token',
      headers: {
        'Content-Type': 'application/json',
        Cookie: aliceSession.cookie
      }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b4a.concat(parts).toString('utf8')) }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write('{}')
    req.end()
  })
  t.is(tokenRes.status, 200)
  const linkToken = tokenRes.body.result && tokenRes.body.result.token
  t.ok(linkToken, 'link token returned')
  t.is(linkToken.length, 32, 'token is 32 hex chars (16 random bytes)')

  // 3. Throwaway signs in from a separate "browser"
  const throwawayKp = genTestKeypair()
  const throwawaySession = await loginAsHttpUser(port, throwawayKp)
  const throwawayUser = (await client.call('list-users', { includeDeactivated: true }))
    .find(u => u.identities.includes(throwawayKp.publicKeyHex))
  t.ok(throwawayUser, 'throwaway user record exists')
  t.absent(throwawayUser.deactivatedAt, 'throwaway is active before link')

  // 4. Throwaway consumes the link token
  const consumeRes = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/rpc/consume-link-token',
      headers: {
        'Content-Type': 'application/json',
        Cookie: throwawaySession.cookie
      }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b4a.concat(parts).toString('utf8')) }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(JSON.stringify({ token: linkToken }))
    req.end()
  })
  t.is(consumeRes.status, 200, 'consume succeeded')
  t.is(consumeRes.body.result.linkedTo, aliceUser.userId, 'identity attached to Alice')

  // 5. Verify the merge: throwaway's identity is now on Alice's User,
  //    and the throwaway User is deactivated.
  const aliceAfter = await client.call('get-user', { userId: aliceUser.userId })
  t.ok(aliceAfter.user.identities.includes(throwawayKp.publicKeyHex), 'Alice now owns the throwaway identity')

  const throwawayAfter = (await client.call('list-users', { includeDeactivated: true }))
    .find(u => u.userId === throwawayUser.userId)
  t.ok(throwawayAfter.deactivatedAt, 'throwaway User is deactivated')
  t.is(throwawayAfter.deactivatedReason, 'merged-via-link-token')

  // 6. Double-consume rejected
  const dupKp = genTestKeypair()
  const dupSession = await loginAsHttpUser(port, dupKp)
  const dupRes = await new Promise((resolve, reject) => {
    const req = http.request({
      method: 'POST',
      hostname: '127.0.0.1',
      port,
      path: '/rpc/consume-link-token',
      headers: {
        'Content-Type': 'application/json',
        Cookie: dupSession.cookie
      }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: parts.length ? b4a.concat(parts).toString('utf8') : '' }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(JSON.stringify({ token: linkToken }))
    req.end()
  })
  t.not(dupRes.status, 200, 'second consume of the same token must fail')
})

test('CI runner parses ci.yaml + abort-on-first-failure (P.5.2)', async (t) => {
  const { loadCIConfig, runSteps, pickChannel, deriveVersion } = await import('../lib/ci-runner.js')
  const dir = mkdtempSync(join(tmpdir(), 'pear-git-v3-ci-'))
  mkdirSync(join(dir, '.pear-git'), { recursive: true })

  // 1. Parses valid config
  writeFileSync(join(dir, '.pear-git', 'ci.yaml'), `
steps:
  - name: ok-step
    run: echo "hello $REPO_NAME"
  - name: fail-step
    run: exit 7
  - name: never-runs
    run: echo "should not execute"

channels:
  production:
    tags: ['v*']
  experimental:
    branches: ['main']
`)
  const config = loadCIConfig(dir)
  t.is(config.steps.length, 3)
  t.is(config.channels.production.tags[0], 'v*')

  // 2. Channel picker
  t.is(pickChannel(config, 'v1.0.0', { isTag: true }), 'production')
  t.is(pickChannel(config, 'main', { isTag: false }), 'experimental')
  t.is(pickChannel(config, 'feature-x', { isTag: false }), null)

  // 3. Version derivation
  t.is(deriveVersion({ ref: 'v1.0.0', sha: 'abc1234567', isTag: true }), 'v1.0.0')
  t.is(deriveVersion({ ref: 'main', sha: 'abc1234567', isTag: false }), 'main-abc1234')

  // 4. Steps: first ok, second fails, third not run
  const result = await runSteps(dir, config, { repoName: 'test-repo' })
  t.absent(result.ok, 'build aborts on failure')
  t.is(result.steps.length, 2)
  t.is(result.steps[0].name, 'ok-step')
  t.is(result.steps[0].status, 'ok')
  t.ok(result.steps[0].output.includes('hello test-repo'), 'env var propagated')
  t.is(result.steps[1].name, 'fail-step')
  t.is(result.steps[1].status, 'failed')
})

test('collectArtifacts walks directories + skips symlinks', async (t) => {
  const { collectArtifacts } = await import('../lib/ci-runner.js')
  const { symlinkSync } = await import('#node:fs')

  const dir = mkdtempSync(join(tmpdir(), 'pear-git-v3-collect-'))

  // Layout:
  //   top.txt                       — file artifact
  //   dist/index.js                 — leaf in dir artifact
  //   dist/sub/x.txt                — nested leaf
  //   dist/link.txt → ../top.txt    — symlink, must be skipped (not followed)
  writeFileSync(join(dir, 'top.txt'), 'TOP')
  mkdirSync(join(dir, 'dist', 'sub'), { recursive: true })
  writeFileSync(join(dir, 'dist', 'index.js'), 'INDEX')
  writeFileSync(join(dir, 'dist', 'sub', 'x.txt'), 'X')
  symlinkSync('../top.txt', join(dir, 'dist', 'link.txt'))

  // 1. File artifact: works as before
  const onlyFile = await collectArtifacts(dir, { artifacts: ['top.txt'] })
  t.is(onlyFile.length, 1)
  t.is(onlyFile[0].path, 'top.txt')
  t.is(onlyFile[0].data.toString(), 'TOP')

  // 2. Directory artifact: walks recursively, preserves in-tree paths,
  //    skips the symlink at any depth, sorts deterministically
  const fromDir = await collectArtifacts(dir, { artifacts: ['dist'] })
  const paths = fromDir.map(a => a.path)
  t.alike(paths, ['dist/index.js', 'dist/sub/x.txt'], 'symlink skipped, recursive walk produces sorted leaves')
  const idx = fromDir.find(a => a.path === 'dist/index.js')
  t.is(idx.data.toString(), 'INDEX')

  // 3. Mixed: file + dir in one config, output preserves listing order
  //    (file artifact first, then dir leaves) and per-dir alphabetical
  const mixed = await collectArtifacts(dir, { artifacts: ['top.txt', 'dist'] })
  t.alike(mixed.map(a => a.path), ['top.txt', 'dist/index.js', 'dist/sub/x.txt'])

  // 4. Top-level symlink as artifact: rejected loudly
  const linkRoot = join(dir, 'rootlink.txt')
  symlinkSync('top.txt', linkRoot)
  await t.exception(() => collectArtifacts(dir, { artifacts: ['rootlink.txt'] }), /must not be a symlink/)
})

test('CI orchestrator builds and publishes from a git ref (P.5.2)', async (t) => {
  const { execSync } = await import('../lib/_compat-exec.js')
  // Seed a bare repo with a ci.yaml so the orchestrator has something real to build
  const reposDir = join(mkdtempSync(join(tmpdir(), 'pear-git-v3-repos-')), 'git-repos')
  mkdirSync(reposDir, { recursive: true })
  execSync(`git init --bare ${join(reposDir, 'ci-demo.git')}`, { stdio: 'ignore' })

  // Populate via a scratch work tree
  const work = mkdtempSync(join(tmpdir(), 'pear-git-v3-work-'))
  execSync(`git -C ${work} init --initial-branch=main`, { stdio: 'ignore' })
  execSync(`git -C ${work} config user.email "ci@test"`, { stdio: 'ignore' })
  execSync(`git -C ${work} config user.name "CI"`, { stdio: 'ignore' })
  mkdirSync(join(work, '.pear-git'), { recursive: true })
  writeFileSync(join(work, '.pear-git', 'ci.yaml'), `
steps:
  - name: build
    run: echo "release bytes" > out.txt

artifacts:
  - out.txt
`)
  execSync(`git -C ${work} add -A`, { stdio: 'ignore' })
  execSync(`git -C ${work} commit -m "initial"`, { stdio: 'ignore' })
  execSync(`git -C ${work} remote add origin ${join(reposDir, 'ci-demo.git')}`, { stdio: 'ignore' })
  execSync(`git -C ${work} push origin main`, { stdio: 'ignore' })

  const node = await makeNode(t, { reposDir })
  // Spin up sync manually (makeNode doesn't start swarm)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  const result = await client.call('build-release', {
    repo: 'ci-demo',
    ref: 'main'
  })
  t.ok(result.ok, 'build succeeded')
  t.is(result.steps.length, 1)
  t.is(result.steps[0].status, 'ok')
  t.ok(/^main-[a-f0-9]{7}$/.test(result.version), 'version is main-<shortSha>')

  // Verify artifacts + build.log present
  const list = await client.call('list-repo-releases', { repo: 'ci-demo' })
  t.is(list.versions.length, 1)
  const artifactPaths = list.versions[0].artifacts.map(a => a.path).sort()
  t.alike(artifactPaths, ['build.log', 'out.txt'])
})

test('ReleaseManager publishes, promotes, and lists per-repo versions (P.5.1)', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Publish v0.1 into "experimental"
  const published = await client.call('publish-release', {
    repo: 'demo',
    version: 'v0.1',
    channel: 'experimental',
    artifacts: [
      { path: 'app.tar.gz', data: Buffer.from('hello world — binary').toString('base64') }
    ],
    meta: { fromCommit: 'abc1234' }
  })
  t.is(published.version, 'v0.1')
  t.is(published.manifest.sizeBytes, Buffer.from('hello world — binary').length)

  // Publish v0.2 (no channel)
  await client.call('publish-release', {
    repo: 'demo',
    version: 'v0.2',
    artifacts: [{ path: 'app.tar.gz', data: Buffer.from('v0.2 bytes').toString('base64') }]
  })

  // List — expect both versions, one channel pointer
  const list = await client.call('list-repo-releases', { repo: 'demo' })
  t.is(list.versions.length, 2)
  t.is(list.versions[0].version, 'v0.2', 'newest-first sort')
  t.is(list.channels.experimental.version, 'v0.1')

  // Promote v0.2 → production
  await client.call('promote-release', { repo: 'demo', version: 'v0.2', toChannel: 'production' })
  const listAfter = await client.call('list-repo-releases', { repo: 'demo' })
  t.is(listAfter.channels.production.version, 'v0.2')
  t.is(listAfter.channels.experimental.version, 'v0.1', 'experimental unchanged')

  // Delete v0.1 (unpinned from experimental? it's still pinned — so this works but leaves dangling channel)
  // First unpin the channel, then delete
  await client.call('unpin-release-channel', { repo: 'demo', channel: 'experimental' })
  await client.call('delete-release', { repo: 'demo', version: 'v0.1' })
  const final = await client.call('list-repo-releases', { repo: 'demo' })
  t.is(final.versions.length, 1)
  t.is(final.versions[0].version, 'v0.2')
  t.absent(final.channels.experimental, 'experimental channel unpinned')
})

test('GC keeps pinned + N newest unpinned (P.5.1)', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Publish 5 versions, pin v1 to production
  for (const v of ['v1', 'v2', 'v3', 'v4', 'v5']) {
    await client.call('publish-release', {
      repo: 'gc',
      version: v,
      channel: v === 'v1' ? 'production' : undefined,
      artifacts: [{ path: 'f.bin', data: Buffer.from(v).toString('base64') }]
    })
  }

  // Dry run — should report v1 kept (pinned), v5/v4/v3 kept (newest 3 unpinned), v2 is stale
  const dry = await client.call('gc-releases', { repo: 'gc', dryRun: true })
  t.is(dry.candidates.length, 1)
  t.is(dry.candidates[0].version, 'v2')
  t.is(dry.deleted.length, 0, 'dry run does not delete')

  // Real run
  const real = await client.call('gc-releases', { repo: 'gc' })
  t.is(real.deleted.length, 1)
  t.is(real.deleted[0].version, 'v2')

  const after = await client.call('list-repo-releases', { repo: 'gc' })
  t.is(after.versions.length, 4, 'v2 gone, others kept')
})

test('GET /repos/:name/releases renders channels + versions (P.5.1)', async (t) => {
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('publish-release', {
    repo: 'uitest',
    version: 'v1.0',
    channel: 'production',
    artifacts: [{ path: 'out.bin', data: Buffer.from('ui test').toString('base64') }]
  })

  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/repos/uitest/releases`)
  t.is(status, 200)
  t.ok(body.includes('Channels'), 'channels section rendered')
  t.ok(body.includes('production'), 'production channel card')
  t.ok(body.includes('v1.0'), 'version string visible')
  t.ok(body.includes('/repos/uitest/releases/promote'), 'promote form wired')
  t.ok(body.includes('/repos/uitest/releases/gc'), 'gc form wired')
})

test('search nav + /search page wired for dual-mode (P.8)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Nav chrome on every page loads the autocomplete script + input markup
  const page = await fetchText(`http://127.0.0.1:${port}/repos`)
  t.ok(page.body.includes('nav-search-input'), 'nav search input present')
  t.ok(page.body.includes('pgv3-search.js'), 'search js bundle loaded')
  t.ok(page.body.includes('Names'), 'Names tab label')
  t.ok(page.body.includes('Content'), 'Content tab label')

  // Full /search page renders both modes
  const repo = await fetchText(`http://127.0.0.1:${port}/search?q=demo&type=repo`)
  t.is(repo.status, 200)
  t.ok(repo.body.includes('search'))

  const code = await fetchText(`http://127.0.0.1:${port}/search?q=function&type=code`)
  t.is(code.status, 200)
  t.ok(code.body.includes('search'))
})

test('admin users tab exposes grant / deactivate buttons (P.7)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // Creating the first HTTP user makes them admin via TOFU, so we also
  // need a second user to have someone to operate on.
  const adminKp = genTestKeypair()
  await loginAsHttpUser(port, adminKp)
  const otherKp = genTestKeypair()
  const other = await loginAsHttpUser(port, otherKp)

  // Admin accesses /admin — both users appear in the table
  const adminCookie = (await loginAsHttpUser(port, adminKp)).cookie
  const { status, body } = await fetchText(`http://127.0.0.1:${port}/admin?tab=trust`)
  t.is(status, 200)
  // Admin page is auth-gated — unauth'd fetchText won't have a session
  // Re-fetch with cookie
  const page = await new Promise((resolve, reject) => {
    http.get({ hostname: '127.0.0.1', port, path: '/admin?tab=trust', headers: { Cookie: adminCookie } }, (r) => {
      const parts = []
      r.on('data', c => parts.push(c))
      r.on('end', () => resolve({ status: r.statusCode, body: b4a.concat(parts).toString('utf8') }))
      r.on('error', reject)
    }).on('error', reject)
  })
  t.is(page.status, 200)
  t.ok(page.body.includes('/admin/user-grant'), 'grant form present')
  t.ok(page.body.includes('/admin/user-deactivate'), 'deactivate form present')
  t.ok(page.body.includes('make admin'), 'make-admin button label present')
})

test('org detail page shows member remove, resource remove, settings (P.6)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // Order matters: TOFU the OWNER first so they get admin perms (first
  // HTTP user → owner). Then TOFU the member (second user → read-only).
  // Then create the org and add the member by identity hex — the new
  // resolver maps it to the canonical userId.
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)
  const memberAuth = await loginAsHttpUser(port)
  await client.call('create-org', { name: 'acme', type: 'group', displayName: 'Acme Inc.' })
  await client.call('add-org-member', { org: 'acme', userId: memberAuth.publicKeyHex, role: 'member' })
  await client.call('add-org-resource', { org: 'acme', resource: 'demo-repo' })

  const fetchTab = (tabPath) => new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path: tabPath,
      headers: { Cookie: auth.cookie }
    }, (r) => {
      const parts = []
      r.on('data', c => parts.push(c))
      r.on('end', () => resolve({ status: r.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })

  // Overview tab: title + activity card + tab nav to other tabs.
  const overview = await fetchTab('/orgs/acme')
  t.is(overview.status, 200)
  t.ok(overview.body.includes('Activity'), 'overview has activity card')
  t.ok(overview.body.includes('/orgs/acme/issues'), 'overview links to per-org issues')
  t.ok(overview.body.includes('Acme Inc.'), 'overview shows displayName in header')

  // Members tab: lists members + admin-only remove forms.
  const members = await fetchTab('/orgs/acme/members')
  t.is(members.status, 200)
  t.ok(members.body.includes('member-remove'), 'members tab has remove form')
  t.ok(members.body.includes('Add member'), 'members tab has add-member form')

  // Repos tab: lists resources + admin-only remove forms.
  const repos = await fetchTab('/orgs/acme/repos')
  t.is(repos.status, 200)
  t.ok(repos.body.includes('resource-remove'), 'repos tab has resource-remove form')

  // Settings tab: pre-filled with displayName.
  const settings = await fetchTab('/orgs/acme/settings')
  t.is(settings.status, 200)
  t.ok(settings.body.includes('/orgs/acme/settings'), 'settings form posts back to itself')
  t.ok(settings.body.includes('Acme Inc.'), 'settings has displayName pre-filled')
})

test('POST /orgs/:name/settings updates display name (P.6)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const auth = await loginAsHttpUser(port, genTestKeypair())
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  await client.call('create-org', { name: 'beta', type: 'group', displayName: 'old' })

  const res = await postForm(`http://127.0.0.1:${port}/orgs/beta/settings`, {
    displayName: 'new-name',
    description: 'updated description',
    visibility: 'public'
  }, { Cookie: auth.cookie })
  t.is(res.status, 303)
  t.is(res.location, '/orgs/beta')

  const org = await client.call('get-org', { name: 'beta' })
  t.is(org.displayName, 'new-name')
  t.is(org.description, 'updated description')
})

test('GET /peers renders the daemon-wide dashboard (P.4)', async (t) => {
  // No swarm config — Live connections card is skipped, but This daemon +
  // Swarm (disabled empty state) + Sync remotes always render.
  const node = await makeNode(t)
  const { status, body } = await fetchText(`http://127.0.0.1:${node.svc.http.port}/peers`)
  t.is(status, 200)
  for (const section of ['This daemon', 'Swarm', 'Sync remotes']) {
    t.ok(body.includes(section), `peers page has ${section} card`)
  }
  t.ok(body.includes('identity'), 'shows daemon identity row')
  t.ok(body.includes('Swarm is not enabled'), 'swarm-disabled empty state shown')
})

test('repo pages render the shared tab chrome (P.3)', async (t) => {
  // Seed a real bare repo so the overview/files/commits/etc routes resolve.
  const { execSync } = await import('../lib/_compat-exec.js')
  const reposDir = join(mkdtempSync(join(tmpdir(), 'pear-git-v3-repos-')), 'git-repos')
  mkdirSync(reposDir, { recursive: true })
  execSync(`git init --bare ${join(reposDir, 'tabtest.git')}`, { stdio: 'ignore' })

  const node = await makeNode(t, { reposDir })
  const base = `http://127.0.0.1:${node.svc.http.port}/repos/tabtest`

  const overview = await fetchText(base)
  t.is(overview.status, 200)
  t.ok(overview.body.includes('repo-tabs'), 'overview has tab chrome')
  for (const tab of ['Overview', 'Files', 'Commits', 'Issues', 'Releases', 'Peers']) {
    t.ok(overview.body.includes(tab), `overview shows ${tab} tab`)
  }

  const peers = await fetchText(base + '/peers')
  t.is(peers.status, 200)
  t.ok(peers.body.includes('repo-tabs'), 'peers page has tab chrome')
  t.ok(peers.body.includes('server key'), 'peers page shows server key row')

  const releases = await fetchText(base + '/releases')
  t.is(releases.status, 200)
  t.ok(releases.body.includes('No releases published yet'), 'releases placeholder shown')
})

// ── Mirror-mode: repo discovery + auto-clone + issue auto-bind (M1/M2/M3) ─────
//
// The mirror daemon, given only the primary's serverKey + repoEventLogKey,
// must (1) learn which repos the primary serves, (2) auto-clone each one
// via hyper-sync.pull (throttled), and (3) dynamically bind the per-repo
// issue tracker bootstrap key so the mirror joins the same Autobase.
//
// These tests exercise the ORCHESTRATION layer — the actual git fetch via
// `punch://` is stubbed because git-remote-punch uses the public DHT and
// can't be pointed at a testnet. The stub records every pull attempt and
// creates an empty bare repo on disk to simulate success.

function stubPull (sync, reposDir) {
  const calls = []
  const ctl = { calls, delay: 0, fail: false }
  const originalPull = sync.pull.bind(sync)
  sync.pull = async (remoteName, repoName) => {
    calls.push({ remoteName, repoName, at: Date.now() })
    if (ctl.delay > 0) await new Promise(r => setTimeout(r, ctl.delay))
    if (ctl.fail) throw new Error('stubbed pull failure')
    if (reposDir) {
      const repoPath = join(reposDir, repoName + '.git')
      if (!existsSync(repoPath)) {
        const { execSync } = await import('../lib/_compat-exec.js')
        mkdirSync(reposDir, { recursive: true })
        execSync(`git init --bare ${repoPath}`, { stdio: 'ignore' })
      }
      try { await sync.scan() } catch {}
    }
    return { synced: [repoName], failed: [], skipped: [], elapsed: 0 }
  }
  ctl.restore = () => { sync.pull = originalPull }
  return ctl
}

// ── Test-coverage tail: HTTP POST routes that lacked test coverage ─────

test('issue close/reopen renders error on nonexistent issue', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  // Close a nonexistent issue — should 4xx (not 303 redirect or 500).
  // This tests the U27-U32 try/catch paths beyond the comment-only test.
  // Note: hyper-issues.update() is a no-op on unknown IDs (doesn't throw),
  // so we verify the route at least doesn't 500.
  const close = await postForm(`http://127.0.0.1:${port}/repos/demo/issues/99/close`, {}, { Cookie: auth.cookie })
  // Either 303 (no-op update succeeded) or 4xx (if the tracker throws)
  t.ok(close.status === 303 || (close.status >= 400 && close.status < 500), `close: ${close.status}`)
})

test('admin user-deactivate with empty userId returns inline error', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const kp = genTestKeypair()
  const auth = await loginAsHttpUser(port, kp)

  const deact = await postForm(`http://127.0.0.1:${port}/admin/user-deactivate`, {
    userId: ''
  }, { Cookie: auth.cookie })
  t.ok(deact.status >= 400 && deact.status < 500, `deactivate: ${deact.status}`)
  t.ok(deact.body.includes('back to admin'), 'error includes back link')
})

test('GET /nonexistent returns styled 404 (U24)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const r = await fetchText(`http://127.0.0.1:${port}/this-page-does-not-exist`)
  t.is(r.status, 404)
  t.ok(r.body.includes('does not exist'), '404 body is a styled page')
  t.ok(r.body.includes('back to repos'), '404 has a back link')
  t.ok(r.body.includes('<nav'), '404 has the nav bar')
})

test('GET /api/tokens returns 410 Gone (U23)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const r = await fetchText(`http://127.0.0.1:${port}/api/tokens`)
  t.is(r.status, 410)
  t.ok(r.body.includes('removed'), '410 body explains the endpoint was removed')
})

test('mirror mode: catches up on repos the primary was already serving (M1)', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-mirror-m1-test')
  const { execSync } = await import('../lib/_compat-exec.js')

  const reposA = join(mkdtempSync(join(tmpdir(), 'pgv3-primary-repos-')), 'git-repos')
  mkdirSync(reposA, { recursive: true })
  execSync(`git init --bare ${join(reposA, 'existing-repo.git')}`, { stdio: 'ignore' })

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    reposDir: reposA,
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  await nodeA.sync.serve('existing-repo')
  await waitFor(() => nodeA.repoEventLog.length >= 1, 5000)

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    mirror: {
      primaryServerKey: nodeA.sync.serverKey,
      repoEventLogKey: nodeA.svc.bootstrapKeys.repoEventLog
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  t.ok(nodeB.repoSync, 'mirror was instantiated on nodeB')

  const pullCtl = stubPull(nodeB.sync, nodeB.svc._reposDir)

  const sawPull = await waitFor(() =>
    pullCtl.calls.some(c => c.remoteName === 'primary' && c.repoName === 'existing-repo'),
    10000)
  t.ok(sawPull, 'mirror pulled existing-repo after replicating the log')

  pullCtl.restore()
})

test('mirror mode: auto-clones new repos announced after boot (M2)', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-mirror-m2-test')
  const { execSync } = await import('../lib/_compat-exec.js')

  const reposA = join(mkdtempSync(join(tmpdir(), 'pgv3-primary-repos-')), 'git-repos')
  mkdirSync(reposA, { recursive: true })

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    reposDir: reposA,
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    mirror: {
      primaryServerKey: nodeA.sync.serverKey,
      repoEventLogKey: nodeA.svc.bootstrapKeys.repoEventLog
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  const pullCtl = stubPull(nodeB.sync, nodeB.svc._reposDir)

  await waitFor(() => nodeB.svc.swarm && nodeB.svc.swarm.connections >= 1, 5000)

  execSync(`git init --bare ${join(reposA, 'late-repo.git')}`, { stdio: 'ignore' })
  await nodeA.sync.scan()
  await nodeA.sync.serve('late-repo')

  const sawPull = await waitFor(() =>
    pullCtl.calls.some(c => c.repoName === 'late-repo'),
    10000)
  t.ok(sawPull, 'mirror auto-cloned late-repo within the test window')

  pullCtl.restore()
})

test('mirror mode: issue-tracker bootstrap key auto-binds on clone (M3)', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-mirror-m3-test')
  const { execSync } = await import('../lib/_compat-exec.js')

  const reposA = join(mkdtempSync(join(tmpdir(), 'pgv3-primary-repos-')), 'git-repos')
  mkdirSync(reposA, { recursive: true })
  execSync(`git init --bare ${join(reposA, 'issue-repo.git')}`, { stdio: 'ignore' })

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    reposDir: reposA,
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const trackerA = await nodeA.issueRegistry.getForRepo('issue-repo')
  t.ok(trackerA.keyHex, 'primary tracker has a bootstrap key')

  await nodeA.sync.serve('issue-repo')
  await waitFor(() => nodeA.repoEventLog.length >= 1, 5000)

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    mirror: {
      primaryServerKey: nodeA.sync.serverKey,
      repoEventLogKey: nodeA.svc.bootstrapKeys.repoEventLog
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  const pullCtl = stubPull(nodeB.sync, nodeB.svc._reposDir)

  const sawPull = await waitFor(() =>
    pullCtl.calls.some(c => c.repoName === 'issue-repo'),
    10000)
  t.ok(sawPull, 'mirror pulled issue-repo')

  const bound = await waitFor(() => {
    const keys = nodeB.issueRegistry._repoBootstrapKeys
    return keys && keys['issue-repo'] === trackerA.keyHex
  }, 5000)
  t.ok(bound, 'mirror auto-bound the primary tracker key for issue-repo')

  pullCtl.restore()
})

test('mirror mode: throttles concurrent clones at maxConcurrentClones (D)', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-mirror-d-test')
  const { execSync } = await import('../lib/_compat-exec.js')

  const reposA = join(mkdtempSync(join(tmpdir(), 'pgv3-primary-repos-')), 'git-repos')
  mkdirSync(reposA, { recursive: true })
  for (let i = 0; i < 5; i++) {
    execSync(`git init --bare ${join(reposA, 'throt-' + i + '.git')}`, { stdio: 'ignore' })
  }

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    reposDir: reposA,
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  await nodeA.sync.serve() // announces all 5
  await waitFor(() => nodeA.repoEventLog.length >= 5, 5000)

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    http: { port: 0 },
    autoImportSSH: false,
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    mirror: {
      primaryServerKey: nodeA.sync.serverKey,
      repoEventLogKey: nodeA.svc.bootstrapKeys.repoEventLog,
      maxConcurrentClones: 3
    }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  const pullCtl = stubPull(nodeB.sync, nodeB.svc._reposDir)
  pullCtl.delay = 150

  let peakInflight = 0
  const sampler = setInterval(() => {
    if (!nodeB.repoSync) return
    const now = nodeB.repoSync.status().inflight
    if (now > peakInflight) peakInflight = now
  }, 20)
  t.teardown(() => clearInterval(sampler))

  const allPulled = await waitFor(() => pullCtl.calls.length >= 5, 15000)
  t.ok(allPulled, 'all 5 repos were pulled')
  t.ok(peakInflight <= 3, `concurrency capped at 3 (saw peak ${peakInflight})`)
  t.ok(peakInflight >= 2, `concurrency reached multi-inflight (saw peak ${peakInflight})`)

  pullCtl.restore()
})

test('IssueRegistry.listAllKnownRepos surfaces persisted repo names before any tracker is opened', async (t) => {
  // Per-repo issue trackers are SEPARATE autobases with a persisted index,
  // so their names survive a restart and surface via listAllKnownRepos —
  // used by setupMirrorWriterCoordination to open + announce each before
  // mirror-writer enrollment (fixes the stortron "Not writable" bug for
  // lazy-loaded repo trackers).
  //
  // Org issues now live in the org RECORD autobase (linked-core #42): there
  // is no separate org tracker index, so org names are NOT tracked here —
  // they replicate with the org and are announced as `org:<name>` from the
  // orgs view (see issues-mirror-writer.test.js).

  const storage = tmpStorage()

  // Boot 1: open a per-repo tracker. Persist the index.
  const nodeA = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  await nodeA.issueRegistry.getForRepo('alpha')
  await nodeA.close()

  // Boot 2: nothing opened yet — listAllKnownRepos must surface from disk index.
  const nodeB = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  const knownRepos = nodeB.issueRegistry.listAllKnownRepos()
  t.ok(knownRepos.includes('alpha'), 'persisted repo name surfaces from the disk index')
  // Boot activates swarm rendezvous for every known repo (pear-git.js:1338-1340),
  // which eager-opens its tracker — so listRepos() (in-memory opened) now
  // contains it too. (Pre-eager-open this was a control asserting length 0.)
  t.ok(nodeB.issueRegistry.listRepos().includes('alpha'),
    'boot eager-opened the persisted repo tracker for rendezvous activation')
  await nodeB.close()
})

test('IssueRegistry.keys() surfaces every tracker across restarts (device-invite bundle completeness)', async (t) => {
  // Regression test for the failed 2026-04-19 stortron rollout:
  // device-invite's deploymentBundle had `issueRegistry.repos = {}` and
  // `issueRegistry.orgs = {}` because keys() only reported lazily-opened
  // trackers. With nothing opened in memory at invite time, stortron got
  // zero issue bootstrap keys and per-repo issues couldn't sync.
  //
  // The fix persists tracker names to <storage>/issue-registry-index.json
  // whenever getForRepo/getForOrg creates one, so keys() merges disk +
  // memory and returns everything the daemon has ever opened.

  const storage = tmpStorage()

  // Boot 1: open a few trackers, then close.
  const nodeA = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  const r1 = await nodeA.issueRegistry.getForRepo('alpha')
  const r2 = await nodeA.issueRegistry.getForRepo('beta')
  const o1 = await nodeA.issueRegistry.getForOrg('acme')
  const alphaKey = r1.keyHex
  const betaKey = r2.keyHex
  const acmeKey = o1.keyHex
  await nodeA.close()

  // Boot 2: no getForRepo calls — simulating invite-time where nothing
  // has been lazily loaded yet. keys() must still return everything.
  const nodeB = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  const keys = await nodeB.issueRegistry.keys()

  t.is(keys.repos.alpha, alphaKey, 'alpha tracker key surfaced from disk index')
  t.is(keys.repos.beta, betaKey, 'beta tracker key surfaced from disk index')
  t.is(keys.orgs.acme, acmeKey, 'acme org tracker key surfaced from disk index')

  // Opening a tracker after-the-fact should still give the same key
  // (idempotent — we don't create a sibling autobase).
  const r1again = await nodeB.issueRegistry.getForRepo('alpha')
  t.is(r1again.keyHex, alphaKey, 'same tracker key after reopen')

  await nodeB.close()
})

test('create-org appends to OrgEventLog with issueBootstrapKey null (linked-core #42)', async (t) => {
  // Linked-core model (#42): org issues live IN the org record autobase, so
  // there is no separate issue-tracker key to convey. create-org appends an
  // op=0 entry with issueBootstrapKey:null (see lib/handlers/orgs.js). The
  // org Room itself is the tracker — getForOrg wraps it — and the org's key
  // replicates WITH the org record / join, not via this field.
  const node = await makeNode(t)
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())

  // No orgs yet → the log is empty (the boot backfill has nothing to do).
  t.is((await orgEntries(node.orgEventLog)).length, 0, 'log starts empty when there are no orgs')

  // create-org over UDS — requireAdmin bypasses for UDS callers.
  await client.call('create-org', {
    name: 'demo-org',
    type: 'group',
    displayName: 'Demo'
  })

  // getForOrg wraps the org Room (linked-core) — it has a key, but the
  // OrgEventLog entry does NOT carry a separate issue-tracker key.
  const tracker = await node.issueRegistry.getForOrg('demo-org')
  t.ok(tracker.keyHex, 'org tracker (the org Room) exists after create-org')

  t.is((await orgEntries(node.orgEventLog)).length, 1, 'one entry appended for the new org')
  const entries = await orgEntries(node.orgEventLog)
  t.is(entries[0].op, 0, 'op=0 (created)')
  t.is(entries[0].name, 'demo-org', 'name matches')
  t.is(entries[0].issueBootstrapKey, null,
    'issueBootstrapKey is null — org issues ride the org autobase, no separate tracker key')
})

test('boot backfills OrgEventLog for orgs created before the log existed (P3)', async (t) => {
  // Migration test: an existing daemon already had orgs (from a
  // previous version, or from orgs-autobase replication on a fresh
  // install) but no OrgEventLog entries. On boot the backfill walk
  // must append op=0 entries for every org so mirrors get the keys.

  const storage = tmpStorage()

  // Boot 1: create the org. Post-create hook appends an entry, so we
  // explicitly close + reopen below to simulate the pre-fix state by
  // creating WITHOUT going through the create-org handler.
  const nodeA = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  await nodeA.orgs.create('legacy-org', 'group', { displayName: 'Legacy' })
  // Direct module call skips the create-org handler, so OrgEventLog
  // sees nothing for this org. Mirrors the pre-P3 state.
  t.is((await orgEntries(nodeA.orgEventLog)).length, 0,
    'control: orgs.create alone does NOT append to OrgEventLog')
  await nodeA.close()

  // Boot 2: the boot backfill walk runs. For each org in orgs.list()
  // that's not already in the log, append op=0. After this, the log
  // is complete.
  const nodeB = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  t.is((await orgEntries(nodeB.orgEventLog)).length, 1, 'backfill appended one entry on boot')
  const entries = await orgEntries(nodeB.orgEventLog)
  t.is(entries[0].name, 'legacy-org', 'backfilled the legacy org')
  t.is(entries[0].issueBootstrapKey, null,
    'backfilled entry has issueBootstrapKey null — org issues ride the org autobase (#42)')

  // Boot 3: idempotent — already-logged orgs are not re-appended.
  await nodeB.close()
  const nodeC = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  t.is((await orgEntries(nodeC.orgEventLog)).length, 1, 'backfill is idempotent across reboots')
  await nodeC.close()
})

test('boot pre-mints an issue tracker for every org in the orgs view (P1)', async (t) => {
  // Org-issue propagation gap: a daemon that learned about an org via
  // orgs-autobase replication (e.g. stortron syncing tabertron's
  // hyper-fleeky-dev) had no per-org issue tracker until someone hit
  // the page. Lazy creation produced sibling autobases per peer, so
  // issues never converged. Fix: on boot, walk orgs.list() and call
  // issueRegistry.getForOrg(name) on every org so its tracker key
  // lives in the index and dump-keys / device-invite bundles include
  // it. After this lands, `pgh resync-from-origin` is a working
  // recovery path for every existing org without any other change.

  const storage = tmpStorage()

  // Boot 1: create an org but DO NOT touch its issues. Mirrors the
  // pre-fix state: org exists in the orgs view, no tracker, no index
  // entry.
  const nodeA = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  await nodeA.orgs.create('test-org', 'group', { displayName: 'Test Org' })
  const keysAfterCreate = await nodeA.issueRegistry.keys()
  t.absent(keysAfterCreate.orgs && keysAfterCreate.orgs['test-org'],
    'control: orgs.create alone does NOT mint an issue tracker (the bug)')
  await nodeA.close()

  // Boot 2: pre-mint walk runs. After boot, the org's tracker key is
  // in the index (and therefore in dump-keys output, device-invite
  // bundles, and resync-from-origin responses).
  const nodeB = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  const keysAfterBoot = await nodeB.issueRegistry.keys()
  t.ok(keysAfterBoot.orgs && keysAfterBoot.orgs['test-org'],
    'pre-mint walk created the test-org tracker on boot')
  // Guard: under per-org-autobase (#35) the org Room must be re-mounted at
  // boot for getForOrg to wrap it — that boot re-mount is unfinished on this
  // branch, so when the key is absent fail soft instead of crashing the suite.
  if (!(keysAfterBoot.orgs && keysAfterBoot.orgs['test-org'])) return
  t.is(typeof keysAfterBoot.orgs['test-org'], 'string',
    'tracker keyHex is a string')
  t.is(keysAfterBoot.orgs['test-org'].length, 64,
    'tracker keyHex is 32 bytes hex-encoded')

  // Idempotent: re-boot doesn't change the key.
  const expectedKey = keysAfterBoot.orgs['test-org']
  await nodeB.close()
  const nodeC = await startPearGit({
    storage, http: { port: 0 }, swarm: false, deploy: false, autoImportSSH: false
  })
  const keysAfterReboot = await nodeC.issueRegistry.keys()
  t.is(keysAfterReboot.orgs['test-org'], expectedKey,
    'pre-mint is idempotent — same key after restart')
  await nodeC.close()
})