import crypto from 'hypercore-crypto'
/**
* Identity Registry
*
* Manages Users and Identities in Hyperbee.
*
* Key Concepts:
* - User: A person with permissions (can have multiple identities)
* - Identity: An authentication method (web keypair, keet-identity, token)
* - Linking: Multiple identities can link to the same user
*
* Storage Schema:
* - user:{userId} → User object
* - identity:{identityId} → Identity object
* - pubkey:{publicKey} → { identityId } (reverse lookup)
* - username:{username} → { userId } (reverse lookup, enforces uniqueness)
*/
const RESERVED_USERNAMES = [
'api', 'admin', 'web', 'sync', 'static', 'assets', 'auth',
'health', 'status', 'login', 'logout', 'register', 'settings'
]
const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/
export class IdentityRegistry {
constructor(hyperbee) {
this.db = hyperbee
}
/**
* Create a new User account
* @param {Object} data - User data
* @returns {Promise<Object>} Created user
*/
async createUser(data = {}) {
const userId = crypto.randomBytes(16).toString('hex')
const user = {
userId,
username: data.username || null,
name: data.name || 'Anonymous',
email: data.email || null,
profilePicture: data.profilePicture || null,
globalPermissions: data.permissions || ['read'],
notebooks: data.notebooks || [{ name: '*', permissions: ['read'] }],
identities: [],
createdAt: Date.now(),
updatedAt: Date.now()
}
await this.db.put(`user:${userId}`, user)
return user
}
/**
* Create an Identity (auth method)
* @param {Object} data - Identity data
* @returns {Promise<Object>} Created identity
*/
async createIdentity(data) {
const identity = {
identityId: data.identityId,
method: data.method,
userId: data.userId || null,
credentials: data.credentials || {},
createdAt: Date.now(),
updatedAt: Date.now()
}
await this.db.put(`identity:${data.identityId}`, identity)
// Create reverse lookups
if (data.method === 'web-identity' || data.method === 'keet-identity') {
const publicKey = data.credentials.publicKey || data.identityId
await this.db.put(`pubkey:${publicKey}`, { identityId: data.identityId })
}
return identity
}
/**
* Link an identity to a user
* @param {string} identityId - Identity ID
* @param {string} userId - User ID
* @returns {Promise<Object>} { user, identity }
*/
async linkIdentity(identityId, userId) {
const identity = await this.getIdentity(identityId)
const user = await this.getUser(userId)
if (!identity || !user) {
throw new Error('Identity or User not found')
}
// Update identity
identity.userId = userId
identity.updatedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
// Update user's identity list
if (!user.identities.includes(identityId)) {
user.identities.push(identityId)
user.updatedAt = Date.now()
await this.db.put(`user:${userId}`, user)
}
return { user, identity }
}
/**
* Auto-register: Create a new user account for a first-time identity.
*
* Every new identity gets its own separate user account.
* First user gets admin permissions, all subsequent get read-only.
* Admins can promote other users via POST /api/admin/promote.
*
* @param {Object} identityData - Identity data
* @returns {Promise<Object>} { user, identity }
*/
async autoRegister(identityData) {
const hasUsers = await this._hasAnyUsers()
// First user gets admin, subsequent get read-only
const permissions = hasUsers ? ['read'] : ['read', 'write', 'admin']
const notebooks = hasUsers
? [{ name: '*', permissions: ['read'] }]
: [{ name: '*', permissions: ['read', 'write', 'admin'] }]
const user = await this.createUser({
name: identityData.name || 'Anonymous',
permissions,
notebooks
})
const identity = await this.createIdentity({
identityId: identityData.identityId,
method: identityData.method,
userId: user.userId,
credentials: identityData.credentials
})
user.identities.push(identity.identityId)
await this.db.put(`user:${user.userId}`, user)
return { user, identity }
}
/**
* Find the first admin user
* @returns {Promise<Object|null>} Admin user or null
*/
async _findAdminUser() {
for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
const user = entry.value
if (user.globalPermissions && user.globalPermissions.includes('admin')) {
return user
}
}
return null
}
/**
* Get user by any linked identity
* @param {string} identityId - Identity ID
* @returns {Promise<Object|null>} User or null
*/
async getUserByIdentity(identityId) {
const identity = await this.getIdentity(identityId)
if (!identity || !identity.userId) {
return null
}
return this.getUser(identity.userId)
}
/**
* Get identity by ID
* @param {string} identityId - Identity ID
* @returns {Promise<Object|null>} Identity or null
*/
async getIdentity(identityId) {
const entry = await this.db.get(`identity:${identityId}`)
return entry?.value
}
/**
* Get user by ID
* @param {string} userId - User ID
* @returns {Promise<Object|null>} User or null
*/
async getUser(userId) {
const entry = await this.db.get(`user:${userId}`)
return entry?.value
}
/**
* Check if any users exist in the registry
* @returns {Promise<boolean>}
*/
async _hasAnyUsers() {
for await (const _ of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff', limit: 1 })) {
return true
}
return false
}
/**
* Check if any admin user exists
* @returns {Promise<boolean>}
*/
async _hasAnyAdmin() {
for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
const user = entry.value
if (user.globalPermissions && user.globalPermissions.includes('admin')) {
return true
}
}
return false
}
/**
* Lookup identity by public key (web-identity or keet-identity)
* @param {string} publicKey - Public key string
* @returns {Promise<Object|null>} Identity or null
*/
async getIdentityByPublicKey(publicKey) {
const entry = await this.db.get(`pubkey:${publicKey}`)
if (!entry) return null
return this.getIdentity(entry.value.identityId)
}
/**
* Upgrade user permissions
* @param {string} userId - User ID
* @param {string} notebookName - Notebook name (or '*' for global)
* @param {Array<string>} newPermissions - Permissions to add
* @returns {Promise<Object>} Updated user
*/
async upgradePermissions(userId, notebookName, newPermissions) {
const user = await this.getUser(userId)
if (!user) {
throw new Error('User not found')
}
// Find or create notebook-specific permissions
let notebook = user.notebooks.find(r => r.name === notebookName)
if (!notebook) {
notebook = { name: notebookName, permissions: [] }
user.notebooks.push(notebook)
}
// Merge permissions (add new, keep existing)
notebook.permissions = [...new Set([...notebook.permissions, ...newPermissions])]
// Also update globalPermissions when upgrading wildcard scope
if (notebookName === '*') {
user.globalPermissions = [...new Set([...user.globalPermissions, ...newPermissions])]
}
user.updatedAt = Date.now()
await this.db.put(`user:${userId}`, user)
return user
}
/**
* List all identities for a user
* @param {string} userId - User ID
* @returns {Promise<Array>} Array of identities
*/
async listUserIdentities(userId) {
const user = await this.getUser(userId)
if (!user) return []
const identities = []
for (const identityId of user.identities) {
const identity = await this.getIdentity(identityId)
if (identity) {
identities.push(identity)
}
}
return identities
}
/**
* Unlink an identity from a user
* @param {string} identityId - Identity ID
* @returns {Promise<Object>} Updated user
*/
async unlinkIdentity(identityId) {
const identity = await this.getIdentity(identityId)
if (!identity || !identity.userId) {
throw new Error('Identity not found or not linked')
}
const user = await this.getUser(identity.userId)
// Remove from user's identity list
user.identities = user.identities.filter(id => id !== identityId)
user.updatedAt = Date.now()
await this.db.put(`user:${user.userId}`, user)
// Clear userId from identity
identity.userId = null
identity.updatedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
return user
}
/**
* Revoke an identity (mark as revoked, preventing authentication)
* @param {string} identityId - Identity ID
* @param {Object} options - { reason, revokedBy }
* @returns {Promise<Object>} Revoked identity
*/
async revokeIdentity(identityId, options = {}) {
const identity = await this.getIdentity(identityId)
if (!identity) {
throw new Error('Identity not found')
}
if (identity.revoked) {
throw new Error('Identity already revoked')
}
// Mark as revoked
identity.revoked = true
identity.revokedAt = Date.now()
identity.revokedBy = options.revokedBy || 'system'
identity.revocationReason = options.reason || 'No reason provided'
identity.updatedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
// Add to revocation list for quick checks
await this.db.put(`revoked:${identityId}`, {
identityId,
revokedAt: identity.revokedAt,
revokedBy: identity.revokedBy,
reason: identity.revocationReason
})
console.log(`[Identity] Revoked ${identity.method}: ${identityId.substring(0, 8)}... (${options.reason || 'no reason'})`)
return identity
}
/**
* Restore a revoked identity
* @param {string} identityId - Identity ID
* @returns {Promise<Object>} Restored identity
*/
async restoreIdentity(identityId) {
const identity = await this.getIdentity(identityId)
if (!identity) {
throw new Error('Identity not found')
}
if (!identity.revoked) {
throw new Error('Identity not revoked')
}
// Remove revocation
delete identity.revoked
delete identity.revokedAt
delete identity.revokedBy
delete identity.revocationReason
identity.restoredAt = Date.now()
identity.updatedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
// Remove from revocation list
await this.db.del(`revoked:${identityId}`)
console.log(`[Identity] Restored ${identity.method}: ${identityId.substring(0, 8)}...`)
return identity
}
/**
* Check if identity is revoked
* @param {string} identityId - Identity ID
* @returns {Promise<boolean>} True if revoked
*/
async isRevoked(identityId) {
// Quick check in revocation list
const revoked = await this.db.get(`revoked:${identityId}`)
if (revoked) {
return true
}
// Fallback: Check identity object
const identity = await this.getIdentity(identityId)
return identity?.revoked === true
}
/**
* List all revoked identities
* @returns {Promise<Array>} Array of revoked identities
*/
async listRevokedIdentities() {
const revoked = []
for await (const entry of this.db.createReadStream({ gte: 'revoked:', lt: 'revoked:\xff' })) {
revoked.push(entry.value)
}
return revoked
}
/**
* Validate username format
* @param {string} username - Username to validate
* @returns {{ valid: boolean, reason?: string }}
*/
validateUsername(username) {
if (!username || typeof username !== 'string') {
return { valid: false, reason: 'Username is required' }
}
const lower = username.toLowerCase()
if (lower !== username) {
return { valid: false, reason: 'Username must be lowercase' }
}
if (username.length < 3 || username.length > 32) {
return { valid: false, reason: 'Username must be 3-32 characters' }
}
if (!USERNAME_REGEX.test(username)) {
return { valid: false, reason: 'Username must be lowercase alphanumeric with dashes, no leading/trailing dash or consecutive dashes' }
}
if (username.includes('--')) {
return { valid: false, reason: 'Username must not contain consecutive dashes' }
}
if (RESERVED_USERNAMES.includes(username)) {
return { valid: false, reason: 'Username is reserved' }
}
return { valid: true }
}
/**
* Set username for a user (unique, validated)
* @param {string} userId - User ID
* @param {string} username - Desired username
* @returns {Promise<Object>} Updated user
* @throws {Error} If username is invalid or taken
*/
async setUsername(userId, username) {
const validation = this.validateUsername(username)
if (!validation.valid) {
const err = new Error(validation.reason)
err.code = 'VALIDATION_ERROR'
err.status = 400
throw err
}
const user = await this.getUser(userId)
if (!user) throw new Error('User not found')
// Check uniqueness
const existing = await this.db.get(`username:${username}`)
if (existing && existing.value.userId !== userId) {
const err = new Error('Username already taken')
err.code = 'CONFLICT'
err.status = 409
throw err
}
// Remove old username reverse lookup if changing
if (user.username && user.username !== username) {
await this.db.del(`username:${user.username}`)
}
// Set new username
user.username = username
user.updatedAt = Date.now()
await this.db.put(`user:${userId}`, user)
await this.db.put(`username:${username}`, { userId })
return user
}
/**
* Get user by username
* @param {string} username - Username
* @returns {Promise<Object|null>} User or null
*/
async getUserByUsername(username) {
const entry = await this.db.get(`username:${username}`)
if (!entry) return null
return this.getUser(entry.value.userId)
}
/**
* Check if a username is available
* @param {string} username - Username to check
* @returns {Promise<boolean>} True if available
*/
async isUsernameAvailable(username) {
const validation = this.validateUsername(username)
if (!validation.valid) return false
const entry = await this.db.get(`username:${username}`)
return !entry
}
/**
* Update user profile (name, picture)
* @param {string} userId - User ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated user
*/
async updateUserProfile(userId, updates) {
const user = await this.getUser(userId)
if (!user) throw new Error('User not found')
if (updates.name !== undefined) {
user.name = updates.name
}
if (updates.theme !== undefined) {
user.theme = updates.theme
}
if (updates.profilePicture !== undefined) {
user.profilePicture = updates.profilePicture
}
user.updatedAt = Date.now()
await this.db.put(`user:${userId}`, user)
return user
}
/**
* List all users (admin only)
* @returns {Promise<Array>} Array of all users
*/
async listAllUsers() {
const users = []
for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
users.push(entry.value)
}
return users
}
/**
* List all identities (admin only)
* @returns {Promise<Array>} Array of all identities
*/
async listAllIdentities() {
const identities = []
for await (const entry of this.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
identities.push(entry.value)
}
return identities
}
/**
* Soft-delete a user. Marks the user deactivated and revokes every linked
* identity so it can no longer authenticate (providers reject revoked
* identities). NEVER hard-deletes — the user, identity, and username records
* are all preserved for audit and reactivation. (Hard `db.del` of identity
* rows previously destroyed 28 accounts; identity cleanup must be soft.)
* @param {string} userId
* @param {Object} options - { by, reason }
* @returns {Promise<Object>} The deactivated user
*/
async deactivateUser(userId, options = {}) {
const user = await this.getUser(userId)
if (!user) throw new Error('User not found')
if (user.deactivated) return user
// Revoke every linked identity — this is what actually blocks auth.
for (const identityId of (user.identities || [])) {
try {
if (!(await this.isRevoked(identityId))) {
await this.revokeIdentity(identityId, {
reason: options.reason || 'user deactivated',
revokedBy: options.by || 'admin'
})
}
} catch (err) {
console.error(`[Identity] deactivateUser: failed to revoke ${identityId}:`, err.message)
}
}
user.deactivated = true
user.deactivatedAt = Date.now()
user.deactivatedBy = options.by || 'admin'
user.deactivationReason = options.reason || null
user.updatedAt = Date.now()
await this.db.put(`user:${userId}`, user)
console.log(`[Identity] Deactivated user ${userId} (by ${user.deactivatedBy})`)
return user
}
/**
* Reverse deactivateUser: clear the flag and restore the user's identities.
* @param {string} userId
* @returns {Promise<Object>} The reactivated user
*/
async reactivateUser(userId) {
const user = await this.getUser(userId)
if (!user) throw new Error('User not found')
if (!user.deactivated) return user
for (const identityId of (user.identities || [])) {
try {
if (await this.isRevoked(identityId)) await this.restoreIdentity(identityId)
} catch (err) {
console.error(`[Identity] reactivateUser: failed to restore ${identityId}:`, err.message)
}
}
delete user.deactivated
delete user.deactivatedAt
delete user.deactivatedBy
delete user.deactivationReason
user.reactivatedAt = Date.now()
user.updatedAt = Date.now()
await this.db.put(`user:${userId}`, user)
console.log(`[Identity] Reactivated user ${userId}`)
return user
}
/**
* Merge two user accounts
* Transfers all identities, permissions, and data from secondary to primary
* @param {string} primaryUserId - The account to keep
* @param {string} secondaryUserId - The account to merge in (will be marked as merged)
* @returns {Promise<Object>} { primary, merged, stats }
*/
async mergeAccounts(primaryUserId, secondaryUserId) {
const primary = await this.getUser(primaryUserId)
const secondary = await this.getUser(secondaryUserId)
if (!primary) throw new Error('Primary user not found')
if (!secondary) throw new Error('Secondary user not found')
if (primaryUserId === secondaryUserId) throw new Error('Cannot merge account with itself')
const stats = {
identitiesTransferred: 0,
permissionsMerged: 0
}
// 1. Transfer all identities from secondary to primary
for (const identityId of secondary.identities) {
const identity = await this.getIdentity(identityId)
if (identity) {
if (identity.revoked) {
console.log(`[Identity] Skipping revoked identity during merge: ${identityId.substring(0, 8)}...`)
continue
}
identity.userId = primaryUserId
identity.updatedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
if (!primary.identities.includes(identityId)) {
primary.identities.push(identityId)
}
stats.identitiesTransferred++
}
}
// 2. Merge permissions (union of both)
for (const secondaryNotebook of secondary.notebooks) {
const existingNotebook = primary.notebooks.find(r => r.name === secondaryNotebook.name)
if (existingNotebook) {
existingNotebook.permissions = [...new Set([...existingNotebook.permissions, ...secondaryNotebook.permissions])]
} else {
primary.notebooks.push({ ...secondaryNotebook })
}
stats.permissionsMerged++
}
// Merge global permissions
primary.globalPermissions = [...new Set([...primary.globalPermissions, ...secondary.globalPermissions])]
// 3. Update primary user
primary.updatedAt = Date.now()
await this.db.put(`user:${primaryUserId}`, primary)
// 4. Mark secondary as merged (keep for audit trail)
secondary.mergedInto = primaryUserId
secondary.mergedAt = Date.now()
secondary.identities = []
secondary.notebooks = []
secondary.globalPermissions = []
secondary.updatedAt = Date.now()
await this.db.put(`user:${secondaryUserId}`, secondary)
console.log(`[Identity] Merged user ${secondaryUserId} into ${primaryUserId}:`, stats)
return {
primary,
merged: secondary,
stats
}
}
}