import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { createApplyFunction } from './apply.js'
import { encodeOp, OpTypes } from './operations.js'
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]$/
/**
* IdentityRegistry — Autobase-backed user and identity management
*
* Provides CRUD operations for users, identities, and permissions
* using Autobase for multi-writer consensus.
*
* Generic: uses 'resources' instead of application-specific terms.
*/
export class BaseIdentityRegistry {
// Which engine this binding runs on. Subclasses set it; the base has none, because having one
// would mean the engine-free half had an opinion about engines.
static engine = null
get engine () { return this.constructor.engine }
constructor (store, opts = {}) {
this._store = store
this._prefix = opts.prefix || ''
this._base = null
this._view = null
this._ready = false
// Separate Hyperbee for direct key lookups (identity records, pubkey index)
// This is NOT the Autobase view — it's a direct-write store for identity metadata
this.db = opts.db || null
// Mirror support: when bootstrapKey is passed, join the primary's
// Autobase instead of creating a fresh one.
this._bootstrapKey = opts.bootstrapKey || null
// keyPair: when provided, the Autobase writer's local core uses
// this Ed25519 keypair instead of a corestore-derived one. This
// lets the caller pin the autobase ROOT to something stable
// (e.g. the hyperident device keypair) so the root is deterministic
// across machines and doesn't need to be shipped as a separate
// bootstrap key via bundles. The primary's autobase root ends up
// equal to keyPair.publicKey; mirrors pass that same publicKey as
// bootstrapKey to join.
this._keyPair = opts.keyPair || null
// fastForwardTo: the ORIGIN's `_system` core key (hex or buffer), obtained
// via `base.getSystemKey()` on the origin and shipped in the deployment
// bundle. A joining peer fast-forwards straight to that system core instead
// of deriving its own.
//
// This is what keet does on every join: the invitation carries
// `fastForwardTo: base.getSystemKey()` (keet-core/index.js:1991,1999) and
// the joiner passes `{ key }` into Autobase (tasks/pairing/keys.js:46 →
// index.js:1643). Without it a joiner must derive its own system state, and
// a peer that ends up with an empty indexer set derives a DIFFERENT
// `_system` core (its manifest is a function of the indexer set,
// autobase/lib/store.js:541-578) — after which the origin's signed
// checkpoints are for a core it does not hold, and it can never converge.
this._fastForwardTo = opts.fastForwardTo || null
}
/**
* Initialize Autobase with Hyperbee view
*/
async ready () {
if (this._ready) return
const apply = createApplyFunction({ prefix: this._prefix, adaptHost: this._adaptHost() })
const rootKey = this._bootstrapKey
? (typeof this._bootstrapKey === 'string'
? b4a.from(this._bootstrapKey, 'hex')
: this._bootstrapKey)
: null
this._base = await this._createBase({ apply, rootKey })
await this._base.ready()
this._view = this._base.view
this._ready = true
}
/**
* Build the multi-writer log. THE ONLY ENGINE-SPECIFIC METHOD IN THIS CLASS.
*
* Everything else here — 19 appends, 15 updates, every read, every op — is spelled identically by
* autobase and autobee, which is why the split is a subclass and not a fork.
*
* @abstract
* @param {{ apply: Function, rootKey: Buffer|null }} opts
*/
async _createBase (opts) {
throw new Error('BaseIdentityRegistry: subclass must implement _createBase — this class has no engine')
}
/**
* How this engine spells the core's writer-set vocabulary. See hyper-identity-core's README.
* Null means "speaks `{ isIndexer }` natively" — which is autobee, not autobase.
*/
_adaptHost () { return null }
/**
* Get the multi-writer log (for adding writers, etc.)
*/
get base () { return this._base }
/**
* Get Hyperbee view
*/
get view () { return this._view }
// ==================== USER OPERATIONS ====================
/**
* Create a new user
*/
async createUser (opts = {}) {
const userId = opts.userId || crypto.randomBytes(16).toString('hex')
// Timestamp-preserving create: when a caller (e.g. the identity-publish
// reconciler re-emitting a record it already holds) passes explicit
// createdAt/updatedAt, forward them so the replayed op does NOT bump the
// record's clock. The apply honours op.createdAt/op.updatedAt and falls
// back to its own Date.now() when absent, so the default path (no opts)
// is unchanged. Preserving timestamps is load-bearing for keyed LWW
// convergence — a re-announce must never out-clock a peer's newer edit.
const now = Date.now()
const op = {
type: OpTypes.CREATE_USER,
userId,
username: opts.username || null,
name: opts.name || 'Anonymous',
email: opts.email || null,
profilePicture: opts.profilePicture || null,
globalPermissions: opts.globalPermissions || ['read'],
resources: opts.resources || [{ name: '*', permissions: ['read'] }],
identities: opts.identities || [],
createdAt: opts.createdAt || now,
updatedAt: opts.updatedAt || opts.createdAt || now
}
await this._base.append(encodeOp(op))
await this._base.update()
// Read back from the view — apply may have modified the record (e.g. the
// first user is promoted to admin).
//
// The read-back is ALSO the proof that the write landed. `append()` resolves
// on a peer that can never linearize (admitted and `writable`, but with an
// empty indexer set nothing is ever confirmed), and the record simply never
// appears. The previous `return user || op` handed back the in-memory op,
// whose userId was minted locally a few lines above — indistinguishable from
// a persisted user. That turned a broken peer into SILENT DATA LOSS: every
// request re-ran TOFU, produced a fresh phantom userId, and the profile page
// read "Not authenticated yet" forever with no error logged anywhere.
//
// Never report success for a write that will not commit — the rule every
// durable autobase app follows (autobase skill, admission-patterns.md).
const user = await this.getUser(userId)
if (!user) {
const err = new Error('createUser: the write did not land — refusing to report success ' +
'for a user that was not persisted (userId=' + userId + ')')
err.code = 'EWRITE_NOT_PERSISTED'
throw err
}
return user
}
/**
* Get user by userId
*/
async getUser (userId) {
const entry = await this._view.get(`${this._prefix}user:${userId}`)
return entry ? entry.value : null
}
/**
* Get user by identity ID
*/
async getUserByIdentity (identityId) {
const link = await this._view.get(`${this._prefix}user-identity:${identityId}`)
if (!link) return null
return this.getUser(link.value.userId)
}
/**
* Get user by username (indexed lookup)
*/
async getUserByUsername (username) {
if (!username) return null
const entry = await this._view.get(`${this._prefix}username:${username}`)
if (!entry) return null
return this.getUser(entry.value.userId)
}
/**
* Update user
*/
async updateUser (userId, updates) {
const op = {
type: OpTypes.UPDATE_USER,
userId,
...updates
}
await this._base.append(encodeOp(op))
await this._base.update()
}
/**
* Delete user
*/
async deleteUser (userId) {
await this._base.append(encodeOp({ type: OpTypes.DELETE_USER, userId }))
await this._base.update()
}
/**
* List all users
*/
async listUsers () {
const users = []
for await (const entry of this._view.createReadStream({
gte: `${this._prefix}user:`,
lt: `${this._prefix}user:\xff`
})) {
if (!entry.value.mergedInto) users.push(entry.value)
}
return users
}
// ==================== IDENTITY OPERATIONS ====================
/**
* Register an identity record.
*
* Writes through the Autobase apply (`REGISTER_IDENTITY` op) so the
* full record + reverse indexes (pubkey, sshkey, noisekey) replicate
* to mirror peers via the autobase view. Keeps a parallel write to
* the local Hyperbee `db` for backward-compat with daemons that
* still read from there during the migration window.
*
* Pre-fix this method only wrote to `db` (local-only) — see
* pear-git-v3/test/identity-record-replication.test.js for the
* proof that putting it in the view fixes cross-daemon visibility.
*
* @param {Object} identity - { identityId, method, publicKey, credentials, ... }
*/
async registerIdentity (identity) {
if (!identity || !identity.identityId || !identity.method) {
throw new Error('registerIdentity requires { identityId, method, ... }')
}
// 1. Append op to autobase — view picks up record + reverse indexes
// via apply, replicates to all peers.
await this._base.append(encodeOp({
type: OpTypes.REGISTER_IDENTITY,
identity
}))
// 2. Mirror to local db too, so legacy code paths reading directly
// from db keep working until everything reads from the view.
// (This write is local-only; no harm if it diverges — view is
// the source of truth.)
if (this.db) {
try {
await this.db.put(`identity:${identity.identityId}`, identity)
if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
await this.db.put(`sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
} else if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
await this.db.put(`noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
}
if (identity.publicKey) {
await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
}
if (identity.method === 'atproto' && identity.credentials) {
if (identity.credentials.did) {
await this.db.put(`atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
}
if (identity.credentials.handle) {
await this.db.put(`atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
}
}
} catch {
// db is best-effort cache; failure here doesn't undo the autobase append
}
}
}
/**
* Get identity by ID. Try the autobase view first (replicated, source
* of truth post-fix); fall back to the local db (legacy/cache).
*/
async getIdentity (identityId) {
if (this._view) {
const entry = await this._view.get(`${this._prefix}identity:${identityId}`).catch(() => null)
if (entry && entry.value) return entry.value
}
if (!this.db) return null
const entry = await this.db.get(`identity:${identityId}`).catch(() => null)
return entry ? entry.value : null
}
/**
* Get identity by public key. View first, then db fallback.
*/
async getIdentityByPublicKey (publicKey) {
if (this._view) {
const idx = await this._view.get(`${this._prefix}pubkey:${publicKey}`).catch(() => null)
if (idx && idx.value && idx.value.identityId) {
const id = await this.getIdentity(idx.value.identityId)
if (id) return id
}
}
if (!this.db) return null
const entry = await this.db.get(`pubkey:${publicKey}`).catch(() => null)
if (!entry) return null
return this.getIdentity(entry.value.identityId)
}
/**
* Get identity by atproto did:plc. View first, then db fallback.
* (The `atproto` provider registers identityId = did, so this also
* covers direct getIdentity(did); the index makes it explicit + future-proof.)
*/
async getIdentityByDid (did) {
if (this._view) {
const idx = await this._view.get(`${this._prefix}atproto-did:${did}`).catch(() => null)
if (idx && idx.value && idx.value.identityId) {
const id = await this.getIdentity(idx.value.identityId)
if (id) return id
}
}
if (!this.db) return null
const entry = await this.db.get(`atproto-did:${did}`).catch(() => null)
if (!entry) return null
return this.getIdentity(entry.value.identityId)
}
/**
* Get identity by atproto handle (e.g. alice.7rnx.net). View first, then db.
* This is the multi-user-handle lookup: handle -> the bound identity.
*/
async getIdentityByAtprotoHandle (handle) {
if (this._view) {
const idx = await this._view.get(`${this._prefix}atproto-handle:${handle}`).catch(() => null)
if (idx && idx.value && idx.value.identityId) {
const id = await this.getIdentity(idx.value.identityId)
if (id) return id
}
}
if (!this.db) return null
const entry = await this.db.get(`atproto-handle:${handle}`).catch(() => null)
if (!entry) return null
return this.getIdentity(entry.value.identityId)
}
/**
* Get identity by SSH key fingerprint. View first, then db fallback.
*/
async getIdentityBySSHKey (fingerprint) {
if (this._view) {
const idx = await this._view.get(`${this._prefix}sshkey:${fingerprint}`).catch(() => null)
if (idx && idx.value && idx.value.identityId) {
const id = await this.getIdentity(idx.value.identityId)
if (id) return id
}
}
if (!this.db) return null
const entry = await this.db.get(`sshkey:${fingerprint}`).catch(() => null)
if (!entry) return null
return this.getIdentity(entry.value.identityId)
}
/**
* Get identity by Noise protocol public key. View first, then db fallback.
*/
async getIdentityByNoiseKey (noiseKey) {
if (this._view) {
const idx = await this._view.get(`${this._prefix}noisekey:${noiseKey}`).catch(() => null)
if (idx && idx.value && idx.value.identityId) {
const id = await this.getIdentity(idx.value.identityId)
if (id) return id
}
}
if (!this.db) return null
const entry = await this.db.get(`noisekey:${noiseKey}`).catch(() => null)
if (!entry) return null
return this.getIdentity(entry.value.identityId)
}
/**
* Link identity to user via Autobase
*
* SECURITY: This method moves an identity to a user account. Callers MUST
* verify ownership before calling (e.g., via challenge-response signature).
* The registry itself cannot verify ownership because it doesn't have the
* request context. Auth providers are responsible for proving key ownership
* before calling this.
*
* @param {string} identityId - Identity to link
* @param {string} userId - User to link to
* @param {Object} [opts] - Options
* @param {boolean} [opts.verified] - Set to true to confirm caller has verified ownership
*/
async linkIdentityToUser (identityId, userId, opts = {}) {
// Guard: require explicit verification flag to prevent accidental misuse
if (!opts.verified) {
throw new Error('linkIdentityToUser requires opts.verified=true — caller must verify key ownership first')
}
// Check if identity is already linked to a different user
if (this.db) {
const identity = await this.getIdentity(identityId)
if (identity && identity.userId && identity.userId !== userId) {
throw new Error(`Identity already linked to user ${identity.userId}`)
}
}
// Update direct-write db first (source of truth for identity records)
if (this.db) {
const identity = await this.getIdentity(identityId)
if (identity) {
identity.userId = userId
identity.updatedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
}
}
// Then append to Autobase for multi-writer consensus
await this._base.append(encodeOp({
type: OpTypes.LINK_IDENTITY,
identityId,
userId
}))
await this._base.update()
}
/**
* Add identity to user
*/
async addIdentityToUser (userId, identityId) {
await this._base.append(encodeOp({
type: OpTypes.ADD_IDENTITY,
userId,
identityId
}))
await this._base.update()
}
/**
* Remove identity from user
*/
async removeIdentityFromUser (userId, identityId) {
await this._base.append(encodeOp({
type: OpTypes.REMOVE_IDENTITY,
userId,
identityId
}))
await this._base.update()
}
/**
* List user's identities
*/
async listUserIdentities (userId) {
const user = await this.getUser(userId)
if (!user) return []
const identities = []
for (const id of user.identities) {
const identity = await this.getIdentity(id)
if (identity) identities.push(identity)
}
return identities
}
// ==================== PERMISSIONS ====================
/**
* Upgrade permissions for user on resource
* @param {string} userId
* @param {string} resourceName - Resource name or '*' for global
* @param {string[]} permissions - ['read', 'write', 'admin']
*/
async upgradePermissions (userId, resourceName, permissions) {
await this._base.append(encodeOp({
type: OpTypes.UPGRADE_PERMISSIONS,
userId,
resourceName,
permissions
}))
await this._base.update()
}
/**
* Check if any admin user exists
*/
async hasAnyAdmin () {
return this._hasAnyAdmin()
}
async _hasAnyAdmin () {
for await (const entry of this._view.createReadStream({
gte: `${this._prefix}user:`,
lt: `${this._prefix}user:\xff`
})) {
const user = entry.value
if (user.mergedInto) continue
if (user.globalPermissions && user.globalPermissions.includes('admin')) return true
}
return false
}
// ==================== DEACTIVATION ====================
/**
* Deactivate user — blocks auth while preserving data
* @param {string} userId
* @param {{ reason?: string }} opts
*/
async deactivateUser (userId, opts = {}) {
await this._base.append(encodeOp({
type: OpTypes.DEACTIVATE_USER,
userId,
reason: opts.reason || null
}))
await this._base.update()
}
/**
* Reactivate user — re-enable auth
* @param {string} userId
*/
async reactivateUser (userId) {
await this._base.append(encodeOp({
type: OpTypes.REACTIVATE_USER,
userId
}))
await this._base.update()
}
/**
* Check if user is deactivated
* @param {string} userId
* @returns {Promise<boolean>}
*/
async isDeactivated (userId) {
const user = await this.getUser(userId)
return !!(user && user.deactivatedAt)
}
// ==================== ACCOUNT MERGE ====================
/**
* Merge two accounts
*/
async mergeAccounts (primaryUserId, secondaryUserId) {
await this._base.append(encodeOp({
type: OpTypes.MERGE_ACCOUNTS,
primaryUserId,
secondaryUserId
}))
await this._base.update()
}
// ==================== REVOCATION ====================
/**
* Revoke an identity
*/
async revokeIdentity (identityId, opts = {}) {
await this._base.append(encodeOp({
type: OpTypes.REVOKE_IDENTITY,
identityId,
reason: opts.reason || null,
revokedAt: Date.now()
}))
await this._base.update()
// Also update direct-write store
if (this.db) {
const identity = await this.getIdentity(identityId)
if (identity) {
identity.revoked = true
identity.revokedAt = Date.now()
await this.db.put(`identity:${identityId}`, identity)
}
}
}
/**
* Restore a revoked identity
*/
async restoreIdentity (identityId) {
await this._base.append(encodeOp({
type: OpTypes.RESTORE_IDENTITY,
identityId
}))
await this._base.update()
if (this.db) {
const identity = await this.getIdentity(identityId)
if (identity) {
delete identity.revoked
delete identity.revokedAt
await this.db.put(`identity:${identityId}`, identity)
}
}
}
/**
* List revoked identities
*/
async listRevokedIdentities () {
if (!this.db) return []
const revoked = []
for await (const entry of this.db.createReadStream({
gte: 'identity:',
lt: 'identity:\xff'
})) {
if (entry.value.revoked) revoked.push(entry.value)
}
return revoked
}
// ==================== AUTO-REGISTRATION ====================
/**
* Auto-register: atomically create user + identity for first-time auth.
* First user gets admin, subsequent get read-only.
*
* @param {Object} identityData - { identityId, method, credentials, name? }
* @returns {{ user: Object, identity: Object }}
*/
async autoRegister (identityData) {
// SECURITY: Always request read-only permissions. The apply function
// deterministically promotes the FIRST CREATE_USER to admin based on
// Autobase linearization order. This eliminates the TOCTOU race
// condition where two writers both see "no admin" and both get admin.
const user = await this.createUser({
name: identityData.name || 'Anonymous',
globalPermissions: ['read'],
resources: [{ name: '*', permissions: ['read'] }]
})
// Register identity in direct-write store
const identity = {
identityId: identityData.identityId,
method: identityData.method,
publicKey: identityData.credentials ? identityData.credentials.publicKey : identityData.identityId,
userId: user.userId,
createdAt: Date.now()
}
await this.registerIdentity(identity)
// Link identity to user (safe: autoRegister is called after auth provider verification)
await this.linkIdentityToUser(identity.identityId, user.userId, { verified: true })
return { user, identity }
}
/**
* Check if any users exist
*/
async _hasAnyUsers () {
for await (const _ of this._view.createReadStream({ // eslint-disable-line no-unused-vars
gte: `${this._prefix}user:`,
lt: `${this._prefix}user:\xff`,
limit: 1
})) {
return true
}
return false
}
// ==================== USERNAME MANAGEMENT ====================
/**
* Validate username format
* @param {string} username
* @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' }
}
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
* @param {string} username
* @returns {Promise<Object>} Updated user from view
*/
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
}
// Check uniqueness in view
const existing = await this._view.get(`${this._prefix}username:${username}`)
if (existing && existing.value.userId !== userId) {
const err = new Error('Username already taken')
err.code = 'CONFLICT'
err.status = 409
throw err
}
// Emit Autobase op for multi-writer consensus
await this._base.append(encodeOp({
type: OpTypes.SET_USERNAME,
userId,
username
}))
await this._base.update()
// Also update direct-write db if present
if (this.db) {
const user = await this.getUser(userId)
if (user) {
if (user.username) {
await this.db.del(`username:${user.username}`)
}
await this.db.put(`username:${username}`, { userId })
}
}
return this.getUser(userId)
}
/**
* Check if username is available
* @param {string} username
* @returns {Promise<boolean>}
*/
async isUsernameAvailable (username) {
const validation = this.validateUsername(username)
if (!validation.valid) return false
const entry = await this._view.get(`${this._prefix}username:${username}`)
return !entry
}
/**
* List all identities from direct-write store (admin)
* @returns {Promise<Object[]>}
*/
async listAllIdentities () {
if (!this.db) return []
const identities = []
for await (const entry of this.db.createReadStream({
gte: 'identity:', lt: 'identity:\xff'
})) {
identities.push(entry.value)
}
return identities
}
// ==================== PROFILE UPDATES ====================
/**
* Update user profile (name, picture, theme)
* @param {string} userId
* @param {Object} updates - { name?, profilePicture?, theme? }
* @returns {Promise<Object>} Updated user
*/
async updateUserProfile (userId, updates) {
const filtered = {}
if (updates.name !== undefined) filtered.name = updates.name
if (updates.profilePicture !== undefined) filtered.profilePicture = updates.profilePicture
if (updates.theme !== undefined) filtered.theme = updates.theme
await this.updateUser(userId, filtered)
return this.getUser(userId)
}
// ==================== REVOCATION (ENHANCED) ====================
/**
* Check if identity is revoked (fast lookup via view index)
* @param {string} identityId
* @returns {Promise<boolean>}
*/
async isRevoked (identityId) {
// Check Autobase view revocation index
const revoked = await this._view.get(`${this._prefix}revoked:${identityId}`)
if (revoked) return true
// Fallback: check direct-write identity record
if (this.db) {
const identity = await this.getIdentity(identityId)
if (identity && identity.revoked) return true
}
return false
}
// ==================== USER QUERIES ====================
/**
* Get user with all linked identity objects resolved
* @param {string} userId
* @returns {Promise<{ user: Object, identities: Object[] } | null>}
*/
async getUserWithIdentities (userId) {
const user = await this.getUser(userId)
if (!user) return null
const identities = []
for (const id of (user.identities || [])) {
const identity = await this.getIdentity(id)
if (identity) identities.push(identity)
}
return { user, identities }
}
/**
* List users who have set a username (public directory)
* @returns {Promise<Object[]>} Users with username, name, createdAt
*/
async listPublicUsers () {
const users = []
for await (const entry of this._view.createReadStream({
gte: `${this._prefix}user:`,
lt: `${this._prefix}user:\xff`
})) {
const user = entry.value
if (user.username && !user.mergedInto) {
users.push({
userId: user.userId,
username: user.username,
name: user.name,
createdAt: user.createdAt
})
}
}
return users
}
/**
* Delete user and cascade cleanup (identities, indexes)
* @param {string} userId
*/
async deleteUserCascade (userId) {
const user = await this.getUser(userId)
if (!user) return
// Remove identity records and pubkey indexes from direct-write db
if (this.db) {
for (const identityId of (user.identities || [])) {
const identity = await this.getIdentity(identityId)
if (identity && identity.publicKey) {
await this.db.del(`pubkey:${identity.publicKey}`)
}
await this.db.del(`identity:${identityId}`)
}
// Remove username index
if (user.username) {
await this.db.del(`username:${user.username}`)
}
}
// Delete user via Autobase
await this.deleteUser(userId)
}
/**
* Import a keypair into an existing user account.
* Registers the new identity and links it to the user.
* Used for device linking — same person, new browser/device.
*
* @param {string} userId - Existing user to link to
* @param {string} publicKey - New public key (44-char base64)
* @param {string} method - Auth method (default: 'web-identity')
* @returns {Promise<Object>} The new identity record
*/
async importIdentity (userId, publicKey, method = 'web-identity') {
// Check user exists
const user = await this.getUser(userId)
if (!user) throw new Error('User not found')
// Check identity doesn't already exist
const existing = await this.getIdentityByPublicKey(publicKey)
if (existing) {
if (existing.userId === userId) return existing // Already linked
throw new Error('This key is already linked to a different user')
}
const identity = {
identityId: publicKey,
method,
publicKey,
userId,
createdAt: Date.now()
}
await this.registerIdentity(identity)
await this.linkIdentityToUser(publicKey, userId, { verified: true })
return identity
}
// ==================== LINK TOKENS (F.7.2) ====================
/**
* Generate a short-lived link token bound to a user. The token itself
* is returned to the caller; only the SHA-256 hash is stored in the
* Autobase view, so reading the registry can't replay tokens.
*
* @param {string} userId - The target user the token will link to
* @param {object} [opts]
* @param {number} [opts.ttlMs] - Token lifetime; default 5 min
* @returns {Promise<{token, tokenHash, expiresAt}>}
*/
async createLinkToken (userId, opts = {}) {
if (!userId) throw new Error('createLinkToken requires { userId }')
const ttl = typeof opts.ttlMs === 'number' ? opts.ttlMs : 5 * 60 * 1000
const token = crypto.randomBytes(16).toString('hex')
const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
const expiresAt = Date.now() + ttl
await this._base.append(encodeOp({
type: OpTypes.CREATE_LINK_TOKEN,
tokenHash,
userId,
expiresAt,
createdAt: Date.now()
}))
await this._base.update()
return { token, tokenHash, expiresAt }
}
/**
* Look up a link token row by its raw token (re-hashes for lookup).
* Returns the stored row or null.
*/
async getLinkToken (token) {
if (!token) return null
const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
const entry = await this._view.get(`${this._prefix}link-token:${tokenHash}`)
return entry ? entry.value : null
}
/**
* Consume a link token: validates expiry + single-use, attaches a new
* identity to the token's target user, optionally deactivates a
* throwaway user the new identity used to belong to. The whole sequence
* runs in a single Autobase op (CONSUME_LINK_TOKEN) so apply-time
* checks are atomic.
*
* @param {string} token - Raw token (re-hashed internally)
* @param {string} newIdentityId - Identity to attach to target user
* @param {object} [opts]
* @param {string} [opts.deactivateUserId] - Throwaway user to deactivate
* @returns {Promise<{userId, newIdentityId}>}
*/
async consumeLinkToken (token, newIdentityId, opts = {}) {
if (!token) throw new Error('consumeLinkToken requires { token }')
if (!newIdentityId) throw new Error('consumeLinkToken requires { newIdentityId }')
// Pre-flight: classify the failure reason for the caller. The apply
// function silently no-ops on the same conditions (defence in depth).
const stored = await this.getLinkToken(token)
if (!stored) throw new Error('link token not found')
if (stored.consumed) throw new Error('link token already consumed')
if (stored.expiresAt && stored.expiresAt < Date.now()) throw new Error('link token expired')
const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
await this._base.append(encodeOp({
type: OpTypes.CONSUME_LINK_TOKEN,
tokenHash,
newIdentityId,
deactivateUserId: opts.deactivateUserId || null
}))
await this._base.update()
// Update direct-write identity record so reverse lookups stay coherent
if (this.db) {
try {
const ident = await this.getIdentity(newIdentityId)
if (ident) {
ident.userId = stored.userId
ident.updatedAt = Date.now()
await this.db.put(`identity:${newIdentityId}`, ident)
}
} catch {}
}
return { userId: stored.userId, newIdentityId }
}
// ==================== FEDERATION & LIFECYCLE ====================
/**
* Add an Autobase writer via the op pipeline. Appends an ADD_WRITER
* op that (a) records the writer in the view for audit, and (b) is
* picked up by identity-apply.js to call `base.addWriter` on the
* Autobase host. Going through the op pipeline (instead of calling
* `_base.addWriter` directly) ensures the writer registration
* linearizes across replicas and is visible in the shared view.
*
* @param {string|Buffer} key - writer's local core key (hex or buffer)
* @param {object} [opts]
* @param {string} [opts.role='writer'] - 'writer' | 'indexer'
* @param {string} [opts.addedBy] - who added this writer (audit)
*/
async addWriter (key, opts = {}) {
const writerKey = typeof key === 'string'
? key
: b4a.toString(key, 'hex')
if (!writerKey || writerKey.length !== 64) {
throw new Error('addWriter requires a 32-byte (64-char hex) writer key')
}
const op = {
type: OpTypes.ADD_WRITER,
writerKey,
role: opts.role || 'writer',
addedBy: opts.addedBy || null
}
await this._base.append(encodeOp(op))
}
/**
* Announce the local-writer keys a linked device has on OTHER
* autobases in the enclosing app. Recorded in the view under
* `mirror-writers:<devicePubkey>`. The enclosing app is responsible
* for watching that key range and issuing canonical in-band
* addWriter ops on the matching autobases.
*
* @param {string} devicePubkey — this device's hyperident pubkey (hex)
* @param {Object<string,string>} keys — { namespace: localKeyHex, ... }
*/
/**
* True if this peer is a writer on the identity autobase (can append).
* Mirrors this check straight off the underlying autobase.
*/
get writable () {
return this._base ? this._base.writable : false
}
/**
* Subscribe to writable transitions. Callback fires when this peer
* becomes (or was already) a writer on the identity autobase — useful
* for apps that want to act the moment they can append.
* Returns an unsubscribe function.
*/
onWritable (cb) {
if (!this._base) return () => {}
if (this._base.writable) { queueMicrotask(() => { try { cb() } catch {} }) }
const handler = () => { try { cb() } catch {} }
this._base.on('writable', handler)
return () => this._base.off('writable', handler)
}
async registerMirrorWriters (devicePubkey, keys) {
if (!devicePubkey || typeof devicePubkey !== 'string' || devicePubkey.length !== 64) {
throw new Error('registerMirrorWriters requires 64-char hex devicePubkey')
}
if (!keys || typeof keys !== 'object') {
throw new Error('registerMirrorWriters requires a namespace→hex map')
}
const op = {
type: OpTypes.REGISTER_MIRROR_WRITERS,
devicePubkey,
keys
}
await this._base.append(encodeOp(op))
}
/**
* Read the most recent mirror-writer announcement for a device.
*/
async getMirrorWriters (devicePubkey) {
const entry = await this._view.get(`${this._prefix}mirror-writers:${devicePubkey}`)
return entry ? entry.value : null
}
/**
* Iterate all mirror-writer announcements (for watcher wiring).
*/
async * listMirrorWriters () {
for await (const entry of this._view.createReadStream({
gte: `${this._prefix}mirror-writers:`,
lt: `${this._prefix}mirror-writers:\xff`
})) {
const devicePubkey = entry.key.slice((this._prefix + 'mirror-writers:').length)
yield { devicePubkey, ...entry.value }
}
}
/**
* Remove an Autobase writer via the op pipeline. Symmetric with addWriter.
*/
async removeWriter (key) {
const writerKey = typeof key === 'string'
? key
: b4a.toString(key, 'hex')
const op = {
type: OpTypes.REMOVE_WRITER,
writerKey
}
await this._base.append(encodeOp(op))
}
/**
* Close registry
*/
async close () {
if (this._base) await this._base.close()
}
}
export default BaseIdentityRegistry