OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / users.js

564 lines · javascript ref:
/**
 * User RPC handlers — registry-backed replacements for trust-map handlers.
 *
 *   list-users     — list all registered users (read-only)
 *   get-user       — single user by userId
 *   grant-permission — owner-only, edit globalPermissions OR resources[]
 *   revoke-identity  — owner-only, revoke a linked identity (ssh-key, etc.)
 *
 * These replace trust-map's list-trust/add-trust/revoke-trust, which are
 * still registered during the F.6 migration window for backward compat
 * with old CLI clients.
 */

import { requireAdmin, AuthError } from '../auth/check.js'
import { resolveUserId } from '../auth/user-resolver.js'

export function userHandlers (svc) {
  const registry = () => svc.identityRegistry

  return [
    {
      // Lists active Users by default. Deactivated Users are hidden
      // unless `includeDeactivated: true` is passed (admins use this
      // to find deactivated accounts for reactivation).
      name: 'list-users',
      fn: async (ctx, params = {}) => {
        const all = await registry().listUsers()
        if (params.includeDeactivated) return all
        return all.filter(u => !u.deactivatedAt)
      }
    },

    {
      // Typeahead-friendly user search. Matches the query against:
      //   - userId (prefix)
      //   - username (substring, case-insensitive)
      //   - name (substring, case-insensitive)
      //   - linked identity pubkey hex (prefix)
      // Returns at most `limit` results (default 10, max 50). Deactivated
      // users are excluded. Each result is a thin row — userId, name,
      // username, primaryIdentity, globalPermissions — small enough for a
      // dropdown.
      name: 'search-users',
      fn: async (ctx, params = {}) => {
        const q = typeof params.query === 'string' ? params.query.trim() : ''
        const limit = Math.min(Math.max(parseInt(params.limit, 10) || 10, 1), 50)
        if (!q) return []

        const qLower = q.toLowerCase()
        const all = await registry().listUsers()
        const matches = []

        for (const u of all) {
          if (u.deactivatedAt) continue
          let hit = false
          if (u.userId && u.userId.startsWith(q)) hit = true
          else if (u.username && u.username.toLowerCase().includes(qLower)) hit = true
          else if (u.name && u.name !== 'Anonymous' && u.name.toLowerCase().includes(qLower)) hit = true
          else if (Array.isArray(u.identities)) {
            for (const id of u.identities) {
              const hex = typeof id === 'string' ? id : (id && id.publicKey)
              if (hex && hex.startsWith(q)) { hit = true; break }
            }
          }
          if (!hit) continue
          matches.push({
            userId: u.userId,
            name: u.name && u.name !== 'Anonymous' ? u.name : null,
            username: u.username || null,
            primaryIdentity: Array.isArray(u.identities) && u.identities.length
              ? (typeof u.identities[0] === 'string' ? u.identities[0] : u.identities[0].publicKey)
              : null,
            globalPermissions: Array.isArray(u.globalPermissions) ? u.globalPermissions : []
          })
          if (matches.length >= limit) break
        }
        return matches
      }
    },

    {
      // Generic identifier-shape → canonical userId resolver. Useful for
      // CLI tools that accept "any identifier" (username, identity hex,
      // userId) and want to confirm a match before performing a write.
      // Returns { userId, user } on match, or { userId: null } when no
      // active user maps to the input. Throws only on registry errors.
      name: 'resolve-user',
      fn: async (ctx, params = {}) => {
        if (typeof params.input !== 'string') {
          throw new Error('resolve-user requires { input: string }')
        }
        const userId = await resolveUserId(registry(), params.input)
        if (!userId) return { userId: null }
        const user = await registry().getUser(userId).catch(() => null)
        return { userId, user }
      }
    },

    {
      name: 'get-user',
      fn: async (ctx, params = {}) => {
        if (!params.userId) throw new Error('get-user requires { userId }')
        const result = await registry().getUserWithIdentities(params.userId)
        if (!result || !result.user) throw new Error('user not found: ' + params.userId)
        return result
      }
    },

    {
      name: 'grant-permission',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        // Accept either { userId } or { identity } (pubkey hex). The
        // identity form spares CLI callers a chained getUserByIdentity
        // lookup — used by `pgh user promote <hex>`.
        if (!params.userId && params.identity) {
          const u = await registry().getUserByIdentity(params.identity).catch(() => null)
          if (!u) throw new Error('no user linked to identity ' + params.identity.slice(0, 16))
          params.userId = u.userId
        }
        if (!params.userId) throw new Error('grant-permission requires { userId } or { identity }')
        if (!Array.isArray(params.permissions)) {
          throw new Error('grant-permission requires { permissions: [...] }')
        }

        if (params.resource && params.resource !== '*') {
          // Per-resource grant — merge into User.resources[]
          const user = await registry().getUser(params.userId)
          if (!user) throw new Error('user not found: ' + params.userId)
          const resources = Array.isArray(user.resources) ? [...user.resources] : []
          const idx = resources.findIndex(r => r.name === params.resource)
          if (idx >= 0) resources[idx] = { name: params.resource, permissions: params.permissions }
          else resources.push({ name: params.resource, permissions: params.permissions })
          await registry().updateUser(params.userId, { resources })
        } else {
          // Global grant — update User.globalPermissions
          await registry().updateUser(params.userId, { globalPermissions: params.permissions })
        }
        return registry().getUser(params.userId)
      }
    },

    {
      name: 'revoke-identity',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        if (!params.identityId) throw new Error('revoke-identity requires { identityId }')
        await registry().revokeIdentity(params.identityId, { reason: params.reason || null })
        return { revoked: params.identityId }
      }
    },

    {
      // UDS-only admin rebind. Advances toward the full Stage 3
      // rebind-identity op (see spec/identity-ops.md). The full op
      // takes a signed challenge from the claimed identity; this
      // variant bypasses the signature because UDS = local operator.
      // Behaviour:
      //   - removes identityId from fromUserId's identities[] (via
      //     Autobase REMOVE_IDENTITY op)
      //   - adds identityId to toUserId's identities[] (ADD_IDENTITY op)
      //   - updates Identity.userId in the direct-write db atomically
      // Reversible: call again with from/to swapped.
      name: 'admin-rebind-identity',
      fn: async (ctx, params = {}) => {
        if (!ctx || ctx.transport !== 'uds') {
          const err = new Error('admin-rebind-identity is UDS-only')
          err.code = 'EAUTH'
          err.status = 403
          throw err
        }
        const { identityId, fromUserId, toUserId } = params || {}
        if (!identityId || !fromUserId || !toUserId) {
          throw new Error('admin-rebind-identity requires { identityId, fromUserId, toUserId }')
        }

        const identity = await registry().getIdentity(identityId)
        if (!identity) throw new Error('identity not found: ' + identityId)
        if (identity.userId !== fromUserId) {
          throw new Error(`identity is not linked to ${fromUserId} (actual: ${identity.userId})`)
        }

        // 1. Autobase ops for both Users' identities[] arrays
        await registry().removeIdentityFromUser(fromUserId, identityId)
        await registry().addIdentityToUser(toUserId, identityId)

        // 2. Direct-write db update for Identity.userId (the Autobase
        //    side doesn't carry this field — it's maintained by the
        //    direct-write store for fast reverse lookup)
        identity.userId = toUserId
        identity.updatedAt = Date.now()
        identity.rebindedAt = Date.now()
        identity.rebindedFrom = fromUserId
        if (registry().db) await registry().db.put('identity:' + identityId, identity)

        return { identityId, fromUserId, toUserId, rebindedAt: identity.rebindedAt }
      }
    },

    {
      // Deactivate a User record. Reversible via reactivate-user.
      // Preserves the record in the Autobase view (no `b.del()`).
      name: 'deactivate-user',
      fn: async (ctx, params = {}) => {
        if (ctx && ctx.transport !== 'uds') {
          // HTTP callers must be admins
          await requireAdmin(ctx, registry())
        }
        if (!params.userId) throw new Error('deactivate-user requires { userId }')
        await registry().deactivateUser(params.userId, { reason: params.reason || null })
        return { deactivated: params.userId }
      }
    },

    {
      name: 'reactivate-user',
      fn: async (ctx, params = {}) => {
        if (ctx && ctx.transport !== 'uds') {
          await requireAdmin(ctx, registry())
        }
        if (!params.userId) throw new Error('reactivate-user requires { userId }')
        await registry().reactivateUser(params.userId)
        return { reactivated: params.userId }
      }
    },

    // ──────────── F.7.2 — link-identity ceremony ────────────

    {
      // Generate a 5-min link token bound to the caller's User. The raw
      // token is returned ONCE; only its hash is stored in the registry.
      // The user copies it to another browser/device to merge them.
      name: 'generate-link-token',
      fn: async (ctx) => {
        if (!ctx || !ctx.identity || !ctx.identity.publicKey) {
          throw Object.assign(new Error('authentication required'), { code: 'EAUTH', status: 401 })
        }
        const me = await registry().getUserByIdentity(ctx.identity.publicKey)
        if (!me) throw new Error('current session has no User record')
        if (me.deactivatedAt) throw new Error('cannot generate link token on a deactivated account')

        const result = await registry().createLinkToken(me.userId)
        return {
          token: result.token,
          expiresAt: result.expiresAt,
          userId: me.userId,
          name: me.name || null
        }
      }
    },

    {
      // Consume a link token: attach the caller's session identity to the
      // token's target User, and (by default) deactivate the throwaway
      // User the session was previously bound to.
      name: 'consume-link-token',
      fn: async (ctx, params = {}) => {
        if (!ctx || !ctx.identity || !ctx.identity.publicKey) {
          throw Object.assign(new Error('authentication required'), { code: 'EAUTH', status: 401 })
        }
        if (!params.token) throw new Error('consume-link-token requires { token }')

        const myCurrentUser = await registry().getUserByIdentity(ctx.identity.publicKey).catch(() => null)
        const deactivateUserId = myCurrentUser && !myCurrentUser.deactivatedAt
          ? myCurrentUser.userId
          : null

        const result = await registry().consumeLinkToken(
          params.token,
          ctx.identity.publicKey,
          { deactivateUserId }
        )
        return {
          ok: true,
          linkedTo: result.userId,
          identity: result.newIdentityId,
          deactivated: deactivateUserId
        }
      }
    },

    // ──────────── admin-migrate-identities ────────────
    //
    // One-shot: iterate the local Hyperbee `db` and re-register every
    // identity record through the autobase view (REGISTER_IDENTITY op).
    // Run once on the primary daemon after deploying the cross-daemon
    // identity-record-replication fix — it backfills records that were
    // previously written to db only, so mirror peers see them via
    // replication. Idempotent.
    {
      name: 'admin-migrate-identities',
      fn: async (ctx) => {
        if (!ctx || ctx.transport !== 'uds') {
          throw Object.assign(new Error('admin-migrate-identities is UDS-only'), { code: 'EFORBIDDEN', status: 403 })
        }
        const reg = registry()
        if (!reg.db) return { migrated: 0, errors: 0, note: 'no local db' }
        let migrated = 0, skipped = 0, errors = 0
        const errs = []
        try {
          for await (const entry of reg.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
            const identity = entry.value
            if (!identity || !identity.identityId || !identity.method) { skipped++; continue }
            try {
              await reg.registerIdentity(identity)
              migrated++
            } catch (err) {
              errors++
              errs.push({ identityId: identity.identityId, error: err.message })
            }
          }
        } catch (err) {
          return { migrated, skipped, errors, errs, fatal: err.message }
        }
        return { migrated, skipped, errors, errs }
      }
    },

    // ──────────── admin-normalize-org-members ────────────
    //
    // One-shot: walk every `group` org and rewrite its members list so
    // every memberId is the canonical userId (resolved via resolveUserId).
    // Collapses duplicates that exist because two different identifiers
    // for the same User were added separately (e.g. identity-hex AND
    // username for the same person). When collapsing, admin role wins
    // over member role.
    //
    // UDS-only because it's a bulk rewrite — the daemon admin is the
    // only one with the operational mandate to flatten member rows.
    // Idempotent: a second run on already-canonical data is a no-op.
    //
    // Members whose memberId resolves to nothing are left untouched
    // (kept verbatim) and reported in `unresolved` — keeping orphans
    // visible is safer than silently dropping them; a follow-up
    // remove-org-member is the explicit fix.
    {
      name: 'admin-normalize-org-members',
      fn: async (ctx, params = {}) => {
        if (!ctx || ctx.transport !== 'uds') {
          throw Object.assign(new Error('admin-normalize-org-members is UDS-only'), { code: 'EFORBIDDEN', status: 403 })
        }
        const dryRun = !!params.dryRun
        const orgs = svc.modules.orgs
        if (!orgs) return { orgs: 0, error: 'orgs module not mounted' }

        const allOrgs = await orgs.list()
        const report = { orgs: 0, rewrites: 0, dupes: 0, unresolved: 0, perOrg: [] }

        for (const org of allOrgs) {
          if (org.type !== 'group' || !Array.isArray(org.members)) continue
          report.orgs++

          // Bucket original members by resolved userId. Track unresolved.
          const buckets = new Map() // canonical userId → { role, sources: [memberId] }
          const unresolved = [] // memberIds with no match
          for (const m of org.members) {
            const canonical = await resolveUserId(registry(), m.userId)
            if (!canonical) {
              unresolved.push(m.userId)
              continue
            }
            const existing = buckets.get(canonical)
            if (existing) {
              if (m.role === 'admin') existing.role = 'admin'
              existing.sources.push(m.userId)
            } else {
              buckets.set(canonical, { role: m.role || 'member', sources: [m.userId] })
            }
          }

          // Determine which rows need rewriting.
          const removes = [] // memberIds to remove (anything not already canonical or that dupes)
          const adds = []    // { userId, role } to add (canonical that isn't present verbatim)
          for (const [canonical, info] of buckets) {
            if (info.sources.length > 1) report.dupes += info.sources.length - 1
            const canonicalAlreadyPresent = info.sources.includes(canonical)
            for (const source of info.sources) {
              if (source !== canonical) removes.push(source)
            }
            if (!canonicalAlreadyPresent) {
              adds.push({ userId: canonical, role: info.role })
            } else {
              // Canonical is already a member — but a different bucket entry
              // might have a higher role. addMember is idempotent and
              // role-updating, so push only if the bucket role beats the
              // current row's role.
              const currentRow = org.members.find(m => m.userId === canonical)
              if (currentRow && currentRow.role !== 'admin' && info.role === 'admin') {
                adds.push({ userId: canonical, role: 'admin' })
              }
            }
          }

          report.unresolved += unresolved.length
          const perOrg = {
            name: org.name,
            removed: removes,
            added: adds,
            unresolved
          }
          report.perOrg.push(perOrg)

          if (dryRun) continue
          for (const id of removes) {
            await orgs.removeMember(org.name, id)
            report.rewrites++
          }
          for (const a of adds) {
            await orgs.addMember(org.name, a.userId, a.role)
            report.rewrites++
          }
        }

        return report
      }
    },

    // ──────────── admin-replay (data migration) ────────────
    //
    // One-shot bulk replay used to re-hydrate a fresh autobase from a
    // previously-exported snapshot (see /tmp/pgv3-export.mjs). UDS-only;
    // skips identity attestation chains (users re-sign in) and device
    // linking (re-do `pgh device invite/join`). Replays:
    //   - users:      svc.identityRegistry.createUser per record
    //   - orgs:       svc.modules.orgs.create per record
    //   - repos:      svc.issueRegistry binds bootstrap keys (or mints
    //                 fresh ones when the export only has repo names)
    //   - issues:     per-repo issues.create with title/body preserved
    //
    // Returns counts so the caller can reconcile against export totals.
    {
      name: 'admin-replay',
      fn: async (ctx, params = {}) => {
        if (!ctx || !ctx.transport || ctx.transport !== 'uds') {
          throw Object.assign(new Error('admin-replay is UDS-only'), { code: 'EFORBIDDEN', status: 403 })
        }
        const exp = params.export || params
        if (!exp || !Array.isArray(exp.users)) {
          throw new Error('admin-replay requires { export: <export.json contents> }')
        }

        const result = { users: { ok: 0, skip: 0, fail: 0 }, orgs: { ok: 0, skip: 0, fail: 0 }, repos: { ok: 0, skip: 0, fail: 0 }, issues: { ok: 0, fail: 0 }, errors: [] }

        // Users — createUser, then updateUser to restore exported permissions
        // (createUser's apply applies a "first user gets admin, others get
        // read" override that overwrites passed-in globalPermissions).
        for (const u of exp.users) {
          try {
            const existing = await registry().getUser(u.userId).catch(() => null)
            if (!existing) {
              await registry().createUser({
                userId: u.userId,
                username: u.username || null,
                name: u.name || 'Anonymous',
                email: u.email || null,
                profilePicture: u.profilePicture || null,
                globalPermissions: u.globalPermissions || ['read'],
                resources: u.resources || [{ name: '*', permissions: ['read'] }],
                identities: [],
                createdAt: u.createdAt
              })
              result.users.ok++
            } else {
              result.users.skip++
            }
            // Restore perms (the apply override may have downgraded them).
            const desired = u.globalPermissions || ['read']
            const current = (await registry().getUser(u.userId)).globalPermissions || []
            const same = current.length === desired.length && desired.every(p => current.includes(p))
            if (!same) {
              await registry().updateUser(u.userId, { globalPermissions: desired })
            }
            // Restore resources too if present.
            if (Array.isArray(u.resources) && u.resources.length) {
              await registry().updateUser(u.userId, { resources: u.resources })
            }
          } catch (err) {
            result.users.fail++
            result.errors.push({ kind: 'user', id: u.userId, error: err.message })
          }
        }

        // Orgs (export.orgs is array of org records)
        for (const o of (exp.orgs || [])) {
          try {
            const existing = await svc.modules.orgs.get(o.name).catch(() => null)
            if (existing) { result.orgs.skip++; continue }
            await svc.modules.orgs.create(o.name, o.type || 'group', {
              displayName: o.displayName,
              description: o.description,
              userId: o.userId || null,
              visibility: o.visibility || 'public'
            })
            result.orgs.ok++
          } catch (err) {
            result.orgs.fail++
            result.errors.push({ kind: 'org', name: o.name, error: err.message })
          }
        }

        // Repos — register each name in the issueRegistry. Without the
        // original bootstrap keys (export only has names), each repo gets
        // a fresh tracker autobase. That's correct for the "fresh start"
        // story: the repo identity is preserved by NAME, the per-repo
        // tracker autobase is rebuilt.
        for (const name of (exp.issueRepos || [])) {
          try {
            await svc.issueRegistry.getForRepo(name)
            result.repos.ok++
          } catch (err) {
            result.repos.fail++
            result.errors.push({ kind: 'repo', name, error: err.message })
          }
        }

        // Issues — replay each into its repo's tracker. Dedupe by title:
        // tracker.create mints fresh sequential IDs, so re-running the
        // replay would otherwise produce duplicates. Skip an issue if a
        // current issue in the tracker has the same title+author.
        result.issues.skip = 0
        for (const [repoName, listResp] of Object.entries(exp.issues || {})) {
          const issues = (listResp && Array.isArray(listResp.issues)) ? listResp.issues
                       : Array.isArray(listResp) ? listResp : []
          if (!issues.length) continue
          let tracker
          try { tracker = await svc.issueRegistry.getForRepo(repoName) }
          catch (err) {
            result.errors.push({ kind: 'issue-tracker-open', repo: repoName, error: err.message })
            continue
          }
          // Build a set of existing (title|author) keys for idempotency.
          let existingKeys = new Set()
          try {
            const list = await tracker.list({ limit: 10000 }).catch(() => [])
            for (const e of (Array.isArray(list) ? list : (list.issues || []))) {
              existingKeys.add(`${e.title}|${e.author || ''}`)
            }
          } catch {}
          for (const issue of issues) {
            const k = `${issue.title}|${issue.author || 'replay'}`
            if (existingKeys.has(k)) { result.issues.skip++; continue }
            try {
              await tracker.create({
                title: issue.title,
                body: issue.body || '',
                author: issue.author || 'replay',
                labels: issue.labels || [],
                images: issue.images || []
              })
              existingKeys.add(k)
              result.issues.ok++
            } catch (err) {
              result.issues.fail++
              result.errors.push({ kind: 'issue', repo: repoName, id: issue.id, error: err.message })
            }
          }
        }

        return result
      }
    }
  ]
}