/**
* Profile RPC handlers.
*
* get-profile { userId? }
* Read-only. Defaults to the caller's own user (resolved from the
* session publicKey via the registry). Returns the User record,
* linked identities, and the subset of identities that are SSH keys
* with on-disk comment enrichment.
*
* update-profile { name?, profilePicture? }
* Writes to the caller's own user only. Ignores any userId field
* in params (server resolves it from session). Any authenticated
* user may edit their own profile — read-only globalPermissions
* do not block self-edits. UDS callers must pass { userId }.
*
* profilePicture is a hyper-overlay content hash (like `/attachments/<hash>`
* but stored as just the hash string). The frontend uploads via
* `/api/upload`, gets the hash back, and includes it in update-profile.
*/
import { AuthError } from '../auth/check.js'
import { listLinkedSSHKeys } from '../auth/ssh-identity.js'
export function profileHandlers (svc) {
const registry = () => svc.identityRegistry
/**
* Resolve the caller's own userId from session. Returns null on UDS
* (UDS has no user concept — caller must supply userId for UDS reads).
*/
async function resolveCallerUserId (ctx) {
if (!ctx || !ctx.identity || !ctx.identity.publicKey) return null
const user = await registry().getUserByIdentity(ctx.identity.publicKey).catch(() => null)
return user ? user.userId : null
}
return [
{
name: 'get-profile',
fn: async (ctx, params = {}) => {
let userId = params && params.userId
if (!userId) userId = await resolveCallerUserId(ctx)
if (!userId) {
if (ctx && ctx.transport === 'uds') {
throw new Error('get-profile requires { userId } when called over UDS')
}
throw new AuthError('not authenticated')
}
const result = await registry().getUserWithIdentities(userId).catch(() => null)
if (!result || !result.user) throw new Error('user not found: ' + userId)
const linkedSSH = await listLinkedSSHKeys(svc, userId).catch(() => [])
return { user: result.user, identities: result.identities, linkedSSH }
}
},
{
name: 'update-profile',
fn: async (ctx, params = {}) => {
// Resolve userId from session — IGNORE any userId in params. This
// prevents "update someone else's profile" via a crafted payload.
// UDS callers are a special case: they can target any user (they're
// the local daemon admin). HTTP callers can only edit their own
// profile, so no writer-permission check is needed — read-only
// users must still be able to set their display name + picture.
let userId
if (ctx && ctx.transport === 'uds') {
userId = params && params.userId
if (!userId) throw new Error('update-profile over UDS requires { userId }')
} else {
userId = await resolveCallerUserId(ctx)
if (!userId) throw new AuthError('not authenticated')
}
const updates = {}
if (typeof params.name === 'string') {
const trimmed = params.name.trim()
if (trimmed.length > 50) throw new Error('name exceeds 50 characters')
updates.name = trimmed
}
if (typeof params.profilePicture === 'string' || params.profilePicture === null) {
updates.profilePicture = params.profilePicture || null
}
if (Object.keys(updates).length === 0) {
throw new Error('update-profile requires at least one of { name, profilePicture }')
}
await registry().updateUser(userId, updates)
const fresh = await registry().getUser(userId)
return { user: fresh }
}
}
]
}