/**
* Identity namespace apply handler for unified MetadataBase
*
* Extracted from lib/auth/identity-apply.js
* Processes single identity operations within the unified apply batch.
*
* Key schema (unified, no collisions):
* user:{userId} → { userId, username, name, email, profilePicture, globalPermissions, repos, identities, createdAt, updatedAt }
* identity:{identityId} → { identityId, method, userId, credentials, createdAt, updatedAt }
* pubkey:{publicKey} → { identityId }
* sshkey:{fingerprint} → { identityId }
* noisekey:{publicKey} → { identityId }
* username:{username} → { userId }
* revoked:{identityId} → { identityId, revokedAt, revokedBy, reason }
*/
/**
* Apply a single identity-namespace operation
*
* @param {object} op - Operation with { ns: 'identity', type, ... }
* @param {object} batch - Hyperbee batch (caller flushes)
* @param {object} base - Autobase host (for addWriter/removeWriter)
*/
export async function applyIdentityOp (op, batch, base) {
// Handle writer addition (Autobase side-effect, not a view update)
if (op.type === 'add-writer') {
if (op.writerKey) {
const key = typeof op.writerKey === 'string' ? Buffer.from(op.writerKey, 'hex') : op.writerKey
await base.addWriter(key, { indexer: true })
}
return
}
switch (op.type) {
case 'create-user': {
const existing = await batch.get(`user:${op.userId}`)
if (existing) break
const username = op.username || null
await batch.put(`user:${op.userId}`, {
userId: op.userId,
username,
name: op.name || op.username || 'Anonymous',
email: op.email || null,
profilePicture: op.profilePicture || null,
globalPermissions: op.globalPermissions || ['read'],
repos: op.repos || [{ name: '*', permissions: ['read'] }],
identities: [],
createdAt: op.timestamp,
updatedAt: op.timestamp
})
// Username uniqueness index
if (username) {
await batch.put(`username:${username}`, { userId: op.userId })
}
break
}
case 'update-user': {
const entry = await batch.get(`user:${op.userId}`)
if (!entry) break
const user = entry.value
if (op.username !== undefined && op.username !== user.username) {
// Remove old username index
if (user.username) await batch.del(`username:${user.username}`)
// Add new username index
if (op.username) await batch.put(`username:${op.username}`, { userId: op.userId })
user.username = op.username
}
if (op.name !== undefined) user.name = op.name
if (op.profilePicture !== undefined) user.profilePicture = op.profilePicture
if (op.email !== undefined) user.email = op.email
if (op.globalPermissions !== undefined) user.globalPermissions = op.globalPermissions
if (op.repos !== undefined) user.repos = op.repos
// Arbitrary flags (restoredFromAttestation, etc.)
if (op.flags) {
for (const [key, value] of Object.entries(op.flags)) {
user[key] = value
}
}
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
break
}
case 'deactivate-user': {
const entry = await batch.get(`user:${op.userId}`)
if (!entry) break
const user = entry.value
if (user.deactivatedAt) break // already deactivated
user.deactivatedAt = op.timestamp
user.deactivatedBy = op.deactivatedBy || 'system'
user.deactivationReason = op.reason || null
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
break
}
case 'reactivate-user': {
const entry = await batch.get(`user:${op.userId}`)
if (!entry) break
const user = entry.value
if (!user.deactivatedAt) break // not deactivated
delete user.deactivatedAt
delete user.deactivatedBy
delete user.deactivationReason
user.reactivatedAt = op.timestamp
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
break
}
case 'create-identity': {
const existing = await batch.get(`identity:${op.identityId}`)
if (existing) break
const identity = {
identityId: op.identityId,
method: op.method,
userId: op.userId || null,
credentials: op.credentials || {},
createdAt: op.timestamp,
updatedAt: op.timestamp
}
await batch.put(`identity:${op.identityId}`, identity)
// Create reverse lookups
if (op.method === 'ssh-key') {
const fingerprint = (op.credentials && op.credentials.fingerprint) || null
if (fingerprint) {
await batch.put(`sshkey:${fingerprint}`, { identityId: op.identityId })
}
} else if (op.method === 'noise-key') {
const publicKey = (op.credentials && op.credentials.publicKey) || null
if (publicKey) {
await batch.put(`noisekey:${publicKey}`, { identityId: op.identityId })
}
} else if (op.method === 'web-identity' || op.method === 'keet-identity') {
const publicKey = (op.credentials && op.credentials.publicKey) || op.identityId
await batch.put(`pubkey:${publicKey}`, { identityId: op.identityId })
}
// If userId provided, add to user's identities array
if (op.userId) {
const userEntry = await batch.get(`user:${op.userId}`)
if (userEntry) {
const user = userEntry.value
if (!user.identities.includes(op.identityId)) {
user.identities.push(op.identityId)
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
}
}
}
break
}
case 'link-identity': {
const identityEntry = await batch.get(`identity:${op.identityId}`)
if (!identityEntry) break
const userEntry = await batch.get(`user:${op.userId}`)
if (!userEntry) break
const identity = identityEntry.value
identity.userId = op.userId
identity.updatedAt = op.timestamp
await batch.put(`identity:${op.identityId}`, identity)
const user = userEntry.value
if (!user.identities.includes(op.identityId)) {
user.identities.push(op.identityId)
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
}
break
}
case 'unlink-identity': {
const identityEntry = await batch.get(`identity:${op.identityId}`)
if (!identityEntry) break
const identity = identityEntry.value
if (!identity.userId) break
const userEntry = await batch.get(`user:${identity.userId}`)
if (userEntry) {
const user = userEntry.value
user.identities = user.identities.filter(id => id !== op.identityId)
user.updatedAt = op.timestamp
await batch.put(`user:${identity.userId}`, user)
}
identity.userId = null
identity.updatedAt = op.timestamp
await batch.put(`identity:${op.identityId}`, identity)
break
}
case 'revoke-identity': {
const identityEntry = await batch.get(`identity:${op.identityId}`)
if (!identityEntry) break
const identity = identityEntry.value
if (identity.revoked) break
identity.revoked = true
identity.revokedAt = op.timestamp
identity.revokedBy = op.revokedBy || 'system'
identity.revocationReason = op.reason || 'No reason provided'
identity.updatedAt = op.timestamp
await batch.put(`identity:${op.identityId}`, identity)
await batch.put(`revoked:${op.identityId}`, {
identityId: op.identityId,
revokedAt: op.timestamp,
revokedBy: identity.revokedBy,
reason: identity.revocationReason
})
break
}
case 'restore-identity': {
const identityEntry = await batch.get(`identity:${op.identityId}`)
if (!identityEntry) break
const identity = identityEntry.value
if (!identity.revoked) break
delete identity.revoked
delete identity.revokedAt
delete identity.revokedBy
delete identity.revocationReason
identity.restoredAt = op.timestamp
identity.updatedAt = op.timestamp
await batch.put(`identity:${op.identityId}`, identity)
await batch.del(`revoked:${op.identityId}`)
// Restore reverse lookup
if (identity.method === 'web-identity' || identity.method === 'keet-identity') {
const pk = (identity.credentials && identity.credentials.publicKey) || op.identityId
await batch.put(`pubkey:${pk}`, { identityId: op.identityId })
} else if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
await batch.put(`sshkey:${identity.credentials.fingerprint}`, { identityId: op.identityId })
} else if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
await batch.put(`noisekey:${identity.credentials.publicKey}`, { identityId: op.identityId })
}
// Reactivate the parent user if it was deactivated
if (identity.userId) {
const userEntry = await batch.get(`user:${identity.userId}`)
if (userEntry && userEntry.value.deactivatedAt) {
const user = userEntry.value
delete user.deactivatedAt
delete user.deactivatedBy
delete user.deactivationReason
user.reactivatedAt = op.timestamp
user.updatedAt = op.timestamp
await batch.put(`user:${identity.userId}`, user)
}
}
break
}
case 'repair-identity': {
// Force-create or overwrite an identity record + reverse lookup + user link.
const identity = {
identityId: op.identityId,
method: op.method,
userId: op.userId || null,
credentials: op.credentials || {},
createdAt: op.timestamp,
updatedAt: op.timestamp
}
await batch.put(`identity:${op.identityId}`, identity)
// Recreate reverse lookup
if (op.method === 'web-identity' || op.method === 'keet-identity') {
const pk = (op.credentials && op.credentials.publicKey) || op.identityId
await batch.put(`pubkey:${pk}`, { identityId: op.identityId })
} else if (op.method === 'ssh-key' && op.credentials && op.credentials.fingerprint) {
await batch.put(`sshkey:${op.credentials.fingerprint}`, { identityId: op.identityId })
} else if (op.method === 'noise-key' && op.credentials && op.credentials.publicKey) {
await batch.put(`noisekey:${op.credentials.publicKey}`, { identityId: op.identityId })
}
// Remove any revocation record
await batch.del(`revoked:${op.identityId}`)
// Add to user's identities array if userId provided
if (op.userId) {
const userEntry = await batch.get(`user:${op.userId}`)
if (userEntry) {
const user = userEntry.value
if (!user.identities.includes(op.identityId)) {
user.identities.push(op.identityId)
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
}
}
}
break
}
case 'upgrade-permissions': {
const userEntry = await batch.get(`user:${op.userId}`)
if (!userEntry) break
const user = userEntry.value
let repo = user.repos.find(r => r.name === op.repoName)
if (!repo) {
repo = { name: op.repoName, permissions: [] }
user.repos.push(repo)
}
// Set union of permissions
repo.permissions = [...new Set([...repo.permissions, ...op.permissions])]
user.updatedAt = op.timestamp
await batch.put(`user:${op.userId}`, user)
break
}
case 'merge-accounts': {
const primaryEntry = await batch.get(`user:${op.primaryUserId}`)
const secondaryEntry = await batch.get(`user:${op.secondaryUserId}`)
if (!primaryEntry || !secondaryEntry) break
const primary = primaryEntry.value
const secondary = secondaryEntry.value
// Skip if already merged
if (secondary.mergedInto) break
// Transfer identities from secondary to primary (skip revoked)
for (const identityId of secondary.identities) {
const idEntry = await batch.get(`identity:${identityId}`)
if (!idEntry) continue
const identity = idEntry.value
if (identity.revoked) continue
identity.userId = op.primaryUserId
identity.updatedAt = op.timestamp
await batch.put(`identity:${identityId}`, identity)
if (!primary.identities.includes(identityId)) {
primary.identities.push(identityId)
}
}
// Merge repo permissions (Set union)
for (const secondaryRepo of secondary.repos) {
const existingRepo = primary.repos.find(r => r.name === secondaryRepo.name)
if (existingRepo) {
existingRepo.permissions = [...new Set([...existingRepo.permissions, ...secondaryRepo.permissions])]
} else {
primary.repos.push({ ...secondaryRepo })
}
}
// Merge global permissions
primary.globalPermissions = [...new Set([...primary.globalPermissions, ...secondary.globalPermissions])]
primary.updatedAt = op.timestamp
await batch.put(`user:${op.primaryUserId}`, primary)
// Mark secondary as merged
secondary.mergedInto = op.primaryUserId
secondary.mergedAt = op.timestamp
secondary.identities = []
secondary.repos = []
secondary.globalPermissions = []
secondary.updatedAt = op.timestamp
await batch.put(`user:${op.secondaryUserId}`, secondary)
break
}
}
}