/**
* 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 }
}
}
]
}