OverviewFilesCommitsIssuesDocsReleasesPeers

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

323 lines · javascript ref:
/**
 * Device RPC handlers — invite/join/list/revoke/label (Phase 2c + 2d).
 *
 * Phase 2c:
 *   device-invite / device-invite-status — blind-pairing invite flow
 *
 * Phase 2d:
 *   list-my-devices — all identities linked to the caller's user
 *   revoke-device — mark a device as revoked (envelope verifier rejects)
 *   label-device — rename a device
 */

import b4a from 'b4a'
import { DeviceLinker } from '../device-linker.js'
import { requireAdmin, requireWriter, requireIdentity } from '../auth/check.js'

/**
 * This daemon's identity-autobase `_system` core key, hex, or null.
 *
 * Shipped in the deployment bundle so a joining mirror can fast-forward onto
 * OUR system state rather than deriving its own — the same thing keet puts in
 * a room invitation (keet-core/index.js:1991). Deriving independently is how a
 * mirror can end up on a divergent `_system` core (its manifest is a function
 * of the indexer set) from which it can never converge.
 *
 * Fail-soft: `getSystemKey()` is autobase 7.x (index.js:271). If it is absent
 * or throws, the field is null and the joiner behaves exactly as before.
 */
function identitySystemKeyHex (svc) {
  try {
    const base = svc.identityRegistry && svc.identityRegistry.base
    if (!base || typeof base.getSystemKey !== 'function') return null
    const key = base.getSystemKey()
    return key ? b4a.toString(key, 'hex') : null
  } catch {
    return null
  }
}

export function deviceHandlers (svc) {
  // Active invite sessions — keyed by inviteHex. Cleaned up on completion
  // or timeout. At most one active invite at a time is typical, but we
  // don't enforce a limit.
  const activeInvites = new Map()

  return [
    {
      name: 'device-invite',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, svc.identityRegistry)

        // Load the daemon's device chain fresh from the hyperident vault.
        // We do NOT cache this — the chain may change if the daemon's
        // hyperident identity is re-bootstrapped.
        let deviceChain, rootPubkeyHex
        try {
          const { IdentityManager } = await import('hyperident/lib/identity-manager.js')
          deviceChain = await IdentityManager.loadDeviceChain()
          const identity = await IdentityManager.loadIdentity()
          rootPubkeyHex = identity.publicKey
        } catch (err) {
          throw new Error('hyperident vault not available: ' + err.message)
        }

        if (!deviceChain || !deviceChain.keyPair || !deviceChain.proof) {
          throw new Error('daemon has no device chain — hyperident identity may not be bootstrapped')
        }
        if (!rootPubkeyHex) {
          throw new Error('could not load identity root pubkey from hyperident')
        }

        const label = params.label || 'linked-device-' + Date.now()

        // If the caller requests a mirror invite (params.mirror === true),
        // gather the daemon's bootstrap keys + swarm topic so the target
        // daemon can boot joined to this deployment's rooms. Pure user
        // device linking (browser/laptop) leaves this off.
        //
        // Note: pgh's release-registry lives at ~/.config/pgh/ (user-level,
        // outside the daemon's corestore). Mirror of releases is pgh-side
        // concern; add to this bundle once the daemon absorbs that store
        // per the pgh/pear-git-v3 convergence plan (pgh/NEXT-STEPS.md).
        let deploymentBundle = null
        if (params.mirror) {
          const issueKeys = await (svc.issueRegistry && svc.issueRegistry.keys
            ? svc.issueRegistry.keys()
            : { repos: {}, orgs: {} })
          deploymentBundle = {
            orgs:          { bootstrapKey: svc.bootstrapKeys.orgs },
            // fastForwardTo = THIS daemon's `_system` core key. The joiner
            // fast-forwards onto our system state instead of deriving its own —
            // exactly what keet puts in a room invitation
            // (keet-core/index.js:1991 `fastForwardTo: e.base.getSystemKey()`).
            // Best-effort: getSystemKey() is autobase 7.x (index.js:271); if it
            // is missing the field is null and the joiner behaves as before.
            identity: {
              bootstrapKey: svc.bootstrapKeys.identity,
              fastForwardTo: identitySystemKeyHex(svc)
            },
            issueRegistry: issueKeys,
            repoEventLog:  { coreKey: svc.bootstrapKeys.repoEventLog },
            orgEventLog:   { coreKey: svc.bootstrapKeys.orgEventLog },
            // Lets a NEWLY paired mirror follow release events from its first
            // boot; already-paired mirrors acquire it via resync-from-origin.
            releaseEventLog: { coreKey: svc.bootstrapKeys.releaseEventLog },
            swarm:         { topic: svc.swarm ? Buffer.from(svc.swarm.topic).toString('hex') : null },
            sourceServerKey: svc.identity.publicKeyHex
          }
        }

        const linker = new DeviceLinker({
          deviceChain,
          rootPubkeyHex
        })

        const { inviteHex, waitForPeer, autobaseLocalKeyHex, newDevicePubkeyHex } =
          await linker.createInvite({ label, deploymentBundle })

        // Pre-authorize the target on IdentityRegistry only. That
        // autobase uses identity.keyPair as its local-writer keypair,
        // so the target's local writer core is manifest-derived from
        // newDeviceKp.publicKey → matches autobaseLocalKeyHex. One
        // keyPair, one autobase, no collision.
        //
        // Orgs / issue trackers use auto-gen local writers (canonical
        // upstream pattern — see pear-git.js). Their writer keys are
        // unpredictable until the mirror boots, so ADD_WRITER for them
        // happens post-boot via a mirror-registration RPC (follow-up
        // work — mirrors stay read-only on orgs/issues until that).
        // role: 'writer' (canonical keet pattern). Non-indexer writers
        // append optimistically thanks to `optimistic: true` on the
        // IdentityRegistry's Autobase, and their blocks linearize via
        // the primary's auto-ack (`ackInterval: 1000`). Promotion to
        // indexer is deliberate and admin-gated — see Keet's
        // handleStartIndexing pattern. Earlier the comment here said
        // role='indexer' was needed because ackInterval was forbidden;
        // that constraint was incorrect — ackInterval is canonical for
        // a stable autobase. See test/keet-pattern-e2e.js.
        svc.identityRegistry.addWriter(autobaseLocalKeyHex, {
          role: 'writer',
          addedBy: svc.identity.publicKeyHex
        }).catch(err => {
          // eslint-disable-next-line no-console
          console.error('[pear-git-v3] ADD_WRITER(identity) append failed:', err.message)
        })

        // Track the session so we can report status.
        const session = {
          inviteHex,
          createdAt: Date.now(),
          status: 'waiting',
          devicePublicKeyHex: null
        }
        activeInvites.set(inviteHex, session)

        // Background: wait for peer, then cleanup.
        waitForPeer().then(({ devicePublicKeyHex }) => {
          session.status = 'linked'
          session.devicePublicKeyHex = devicePublicKeyHex
          // eslint-disable-next-line no-console
          console.log('[pear-git-v3] device linked via invite: ' + devicePublicKeyHex)
        }).catch(err => {
          session.status = 'failed'
          session.error = err.message
          // eslint-disable-next-line no-console
          console.error('[pear-git-v3] device-invite failed:', err.message)
        }).finally(() => {
          linker.close().catch(() => {})
          // Keep the session record for 10 min so the caller can poll status.
          setTimeout(() => activeInvites.delete(inviteHex), 10 * 60 * 1000)
        })

        return {
          inviteHex,
          status: 'waiting',
          note: 'Paste this hex into `pgh device join <hex>` on the target device. Expires in 5 minutes.'
        }
      }
    },

    {
      name: 'device-invite-status',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, svc.identityRegistry)
        const hex = params.inviteHex
        if (!hex) return { error: 'inviteHex required' }
        const session = activeInvites.get(hex)
        if (!session) return { error: 'invite not found or expired' }
        return {
          status: session.status,
          devicePublicKeyHex: session.devicePublicKeyHex,
          createdAt: session.createdAt,
          error: session.error || null
        }
      }
    },

    // ── Phase 2d: device management ──────────────────────────────

    {
      name: 'list-my-devices',
      fn: async (ctx, params = {}) => {
        // SELF-SCOPED: resolves the caller's OWN user below and returns only
        // that user's identities, so this leaks nothing cross-user and does
        // not need write permission. requireWriter locked out read-only users
        // — the default perms for a fresh TOFU registration are ['read'] — so
        // a new identity could not see its own devices or copy its link
        // invite. requireIdentity still rejects anonymous and revoked callers.
        await requireIdentity(ctx, svc.identityRegistry)

        const user = await svc.identityRegistry.getUserByIdentity(
          ctx.identity.publicKey
        )
        if (!user) return { devices: [] }

        const identities = await svc.identityRegistry.listUserIdentities(user.userId)
        return {
          devices: identities.map(id => ({
            identityId: id.identityId,
            publicKey: id.publicKey,
            method: id.method || 'unknown',
            label: id.label || null,
            firstLinkedAt: id.firstLinkedAt || id.createdAt || null,
            lastSeenAt: id.lastSeenAt || null,
            revoked: !!id.revoked,
            revokedAt: id.revokedAt || null
          }))
        }
      }
    },

    {
      name: 'revoke-device',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, svc.identityRegistry)

        const { devicePubkey, reason } = params
        if (!devicePubkey) throw new Error('revoke-device requires { devicePubkey }')

        // Don't allow revoking your own current device. `ctx.identity.device`
        // is the device pubkey that signed this envelope (chain-bearing
        // envelopes) — for self-rooted envelopes it equals .publicKey.
        const currentDevice = (ctx.identity && ctx.identity.device) ||
                              (ctx.identity && ctx.identity.publicKey)
        if (currentDevice && currentDevice === devicePubkey) {
          throw new Error('cannot revoke your own current device')
        }

        // Verify the device belongs to the caller's user.
        const user = await svc.identityRegistry.getUserByIdentity(
          ctx.identity.publicKey
        )
        if (!user) throw new Error('user not found')

        const targetUser = await svc.identityRegistry.getUserByIdentity(devicePubkey)
          .catch(() => null)
        if (!targetUser || targetUser.userId !== user.userId) {
          throw new Error('device not linked to your account')
        }

        await svc.identityRegistry.revokeIdentity(devicePubkey, {
          reason: reason || 'revoked via device-revoke RPC'
        })

        // Stamp revokedByDevice so audit trail shows WHO revoked.
        if (svc.identityRegistry.db) {
          try {
            const identity = await svc.identityRegistry.getIdentity(devicePubkey)
            if (identity) {
              identity.revokedByDevice = ctx.identity.publicKey
              await svc.identityRegistry.db.put(`identity:${devicePubkey}`, identity)
            }
          } catch {}
        }

        return { ok: true, revoked: devicePubkey }
      }
    },

    {
      name: 'label-device',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, svc.identityRegistry)

        const { devicePubkey, label } = params
        if (!devicePubkey) throw new Error('label-device requires { devicePubkey }')
        if (!label || typeof label !== 'string') throw new Error('label-device requires { label }')

        // Verify the device belongs to the caller's user.
        const user = await svc.identityRegistry.getUserByIdentity(
          ctx.identity.publicKey
        )
        if (!user) throw new Error('user not found')

        const targetUser = await svc.identityRegistry.getUserByIdentity(devicePubkey)
          .catch(() => null)
        if (!targetUser || targetUser.userId !== user.userId) {
          throw new Error('device not linked to your account')
        }

        // Apply the label change through the canonical autobase path
        // (REGISTER_IDENTITY op) so it lands in the view and replicates
        // to mirrors. The pre-fix code wrote only to the local Hyperbee
        // `db`; subsequent reads of `listUserIdentities` go
        // `getIdentity → view first, db fallback`, so the labeled
        // record was invisible — the view's old (unlabeled) entry won
        // every read. Test surface: `pgh peer-client: envelope
        // round-trip to pear-git-v3 /api/rpc` → "label persisted via
        // remote call" assertion.
        const identity = await svc.identityRegistry.getIdentity(devicePubkey)
        if (!identity) {
          throw new Error('identity not found for ' + devicePubkey)
        }
        identity.label = label
        identity.updatedAt = Date.now()
        await svc.identityRegistry.registerIdentity(identity)

        return { ok: true, devicePubkey, label }
      }
    }
  ]
}