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