OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 648dc19

identity: bind to hyper-identity-core, and isolate the translation that fails silently
identity-operations.js and identity-apply.js move to the new `hyper-identity-core` package and are left here as re-export shims, so every importer — index.js, the registry, three test files, four downstream apps — keeps working unchanged. ⚠ Operationally this adds one transitive file: dependency. Consumers (pear-git-v3, favela-web, hyperident-shell, hypermosh-shell) need a reinstall to resolve `hyper-identity-core`; none of their CODE changes. Linked here by symlink, per the sibling convention — never `npm install` across siblings. ★ lib/auth/adapt-host.js is the trap, isolated so it can be tested. The core speaks `{ isIndexer }`; this package is the autobase binding, so it translates to `{ indexer }`. An absent flag is pinned to false EXPLICITLY rather than inherited, because autobee defaults it to true and an option lost in translation would silently promote every writer. ## I had the wrong oracle, and its own control caught it The trap test first asked the ENGINE whether a writer had ended up an indexer, via an `isIndexerWriter` accessor I added for the purpose. The positive control I wrote to check that accessor could say YES is what proved it wrong: autobase leaves a granted indexer in `pendingIndexers` until its core exists and acks, and those entries do not even expose `.key`, so a freshly granted writer is in neither list and the accessor answered "no" to everything — including the case it existed to catch. A test that cannot say YES means nothing by NO. The defect is a translation, so the test is now a spy on adaptHost: it sees exactly what the engine would have been handed, with none of the writer-lifecycle noise that made the first oracle useless. `isIndexerWriter` is removed — it was API invented for a test, and a poor answer at that. Suite 122/123, 342/343. The only red is [autobee] contract, which is the engine this whole split exists to make buildable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
648dc19013b83ec51057ae7dae127de365d720d4
by plankatron <fleeky@gmail.com> · 2026-09-06T21:00:30.000Z

Changed files (7) +181 -843

lib/auth/adapt-host.js +27 -0
……@@ -0,0 +1,27 @@
1// The autobase spelling of the core's writer-set contract.
2//
3// ⛔ THIS FILE IS THE TRAP, ISOLATED SO IT CAN BE TESTED.
4//
5// hyper-identity-core's apply calls `host.addWriter(key, { isIndexer })` — one vocabulary, so the
6// core never has to know which engine it is running on. autobase spells the same option
7// `{ indexer }`. autobee spells it `{ isIndexer }` and, crucially, DEFAULTS IT TO TRUE
8// (autobee 2.1.0 lib/apply-calls.js:33: `addWriter(key, { isIndexer = true, weight = isIndexer ? 2 : 1 })`).
9//
10// So a mechanical port in either direction fails SILENTLY and in opposite ways: autobase receives
11// an option it does not know and quietly makes a plain writer where an indexer was meant; autobee
12// receives an option it does not know and quietly makes an INDEXER out of every writer — the thing
13// identity-registry.js warns in its own comment deadlocks a manifest migration at 2→3 indexers.
14//
15// Neither engine errors. Both look fine. That is why the translation is a named unit with a test
16// rather than an object literal buried in a constructor.
17
18export function adaptHost (host) {
19 if (!host || typeof host.addWriter !== 'function') return host
20 return {
21 ...host,
22 addWriter: (key, opts = {}) => host.addWriter(key, { indexer: !!opts.isIndexer }),
23 removeWriter: (key) => host.removeWriter(key)
24 }
25}
26
27export default adaptHost
lib/auth/identity-apply.js +7 -609
……@@ -1,609 +1,7 @@
1import { decodeOp, OpTypes, validateOp } from './identity-operations.js'
2import b4a from 'b4a'
3
4/**
5 * Contract validation — checks that the writer who appended an operation
6 * has permission to execute it. This prevents rogue Autobase writers from
7 * injecting admin-level operations.
8 *
9 * Permission model:
10 * - CREATE_USER: any writer (but first user gets admin via autoRegister)
11 * - UPDATE_USER: writer must be the user themselves or an admin
12 * - DELETE_USER: admin only
13 * - ADD_IDENTITY / REMOVE_IDENTITY / LINK_IDENTITY: admin or self
14 * - UPGRADE_PERMISSIONS: admin only
15 * - MERGE_ACCOUNTS: admin only
16 * - REVOKE_IDENTITY / RESTORE_IDENTITY: admin only
17 * - SET_USERNAME: writer must be the user themselves or an admin
18 *
19 * Note: The first writer (bootstrapper) is always trusted for initial setup.
20 * Contract validation is skipped if opts.contractValidation is false (default for
21 * backwards compatibility during migration).
22 */
23
24// Operations that require admin permissions
25const ADMIN_OPS = new Set([
26 OpTypes.DELETE_USER,
27 OpTypes.UPGRADE_PERMISSIONS,
28 OpTypes.MERGE_ACCOUNTS,
29 OpTypes.REVOKE_IDENTITY,
30 OpTypes.RESTORE_IDENTITY,
31 OpTypes.DEACTIVATE_USER,
32 OpTypes.REACTIVATE_USER
33])
34
35// Operations that require admin OR self (userId in op matches writer's user)
36const SELF_OR_ADMIN_OPS = new Set([
37 OpTypes.UPDATE_USER,
38 OpTypes.ADD_IDENTITY,
39 OpTypes.REMOVE_IDENTITY,
40 OpTypes.LINK_IDENTITY,
41 OpTypes.SET_USERNAME
42])
43
44/**
45 * Create the Autobase apply function for identity registry
46 *
47 * @param {Object} opts - Options
48 * @param {string} opts.prefix - Key prefix for namespacing (default: '')
49 * @param {boolean} opts.contractValidation - Enable contract validation (default: false for migration compat)
50 * @returns {Function} Autobase apply function
51 */
52export function createApplyFunction (opts = {}) {
53 const prefix = opts.prefix || ''
54 const enableContract = opts.contractValidation === true
55
56 return async function apply (batch, view, base) {
57 const b = view.batch({ update: false })
58
59 try {
60 for (const node of batch) {
61 try {
62 const op = decodeOp(node.value)
63 const validation = validateOp(op)
64 if (!validation.valid) {
65 console.error(`[IdentityApply] Invalid op: ${validation.error}`)
66 continue
67 }
68
69 // Contract validation: check writer permissions
70 if (enableContract && node.from) {
71 const writerKey = b4a.toString(node.from.key, 'hex')
72 const violation = await checkContract(b, prefix, writerKey, op)
73 if (violation) {
74 console.error(`[IdentityApply] Contract violation: ${violation} (writer: ${writerKey.slice(0, 16)}..., op: ${op.type})`)
75 continue // Drop the operation
76 }
77 }
78
79 // The op's own clock, stamped by encodeOp on the authoring peer. Every timestamp and
80 // every time COMPARISON below uses it, so this apply is a pure function of the log.
81 //
82 // ⚠ The `at` fallback is for ops written BEFORE this field existed, already
83 // durable in deployed logs. Those replay non-deterministically exactly as they do today —
84 // no worse, and unfixable without rewriting history. Every NEW op converges.
85 const at = op.timestamp || Date.now()
86
87 switch (op.type) {
88 case OpTypes.CREATE_USER: {
89 const key = `${prefix}user:${op.userId}`
90 const existing = await b.get(key)
91 if (existing) continue // Don't overwrite
92
93 // Deterministic admin bootstrap: the FIRST user created gets
94 // admin permissions. All subsequent users get read-only.
95 // This decision happens in the apply function (deterministic,
96 // linearized by Autobase) — NOT in the caller (which would be
97 // racy in multi-writer scenarios).
98 const isFirstUser = !(await hasAnyUser(b, prefix))
99 const permissions = isFirstUser
100 ? ['read', 'write', 'admin']
101 : ['read']
102 const resources = isFirstUser
103 ? [{ name: '*', permissions: ['read', 'write', 'admin'] }]
104 : [{ name: '*', permissions: ['read'] }]
105
106 const user = {
107 userId: op.userId,
108 username: op.username || null,
109 name: op.name || 'Anonymous',
110 email: op.email || null,
111 profilePicture: op.profilePicture || null,
112 globalPermissions: permissions,
113 resources,
114 identities: op.identities || [],
115 createdAt: op.createdAt || at,
116 updatedAt: op.updatedAt || at
117 }
118 await b.put(key, user)
119
120 // Index identities
121 for (const id of user.identities) {
122 await b.put(`${prefix}user-identity:${id}`, { userId: op.userId })
123 }
124 break
125 }
126
127 case OpTypes.UPDATE_USER: {
128 const key = `${prefix}user:${op.userId}`
129 const existing = await b.get(key)
130 if (!existing) continue
131
132 const user = existing.value
133 if (op.username !== undefined) user.username = op.username
134 if (op.name !== undefined) user.name = op.name
135 if (op.email !== undefined) user.email = op.email
136 if (op.profilePicture !== undefined) user.profilePicture = op.profilePicture
137 if (op.theme !== undefined) user.theme = op.theme
138 if (op.globalPermissions !== undefined) user.globalPermissions = op.globalPermissions
139 user.updatedAt = at
140 await b.put(key, user)
141 break
142 }
143
144 case OpTypes.DELETE_USER: {
145 const key = `${prefix}user:${op.userId}`
146 const existing = await b.get(key)
147 if (!existing) continue
148
149 const user = existing.value
150 // Remove identity index entries AND the identity records + their
151 // reverse indexes FROM THE VIEW. Since the view-migration these
152 // records live in the view; deleteUserCascade only cleaned the
153 // legacy db, so the view's pubkey/sshkey/noisekey indexes
154 // survived a delete. Mirror REGISTER_IDENTITY's index writes in
155 // reverse so a cascade is complete and replicates to peers.
156 for (const id of (user.identities || [])) {
157 await b.del(`${prefix}user-identity:${id}`)
158 const idEntry = await b.get(`${prefix}identity:${id}`)
159 if (idEntry) {
160 const idRec = idEntry.value
161 if (idRec.publicKey) await b.del(`${prefix}pubkey:${idRec.publicKey}`)
162 if (idRec.method === 'ssh-key' && idRec.credentials && idRec.credentials.fingerprint) {
163 await b.del(`${prefix}sshkey:${idRec.credentials.fingerprint}`)
164 }
165 if (idRec.method === 'noise-key' && idRec.credentials && idRec.credentials.publicKey) {
166 await b.del(`${prefix}noisekey:${idRec.credentials.publicKey}`)
167 }
168 if (idRec.method === 'atproto' && idRec.credentials) {
169 if (idRec.credentials.did) await b.del(`${prefix}atproto-did:${idRec.credentials.did}`)
170 if (idRec.credentials.handle) await b.del(`${prefix}atproto-handle:${idRec.credentials.handle}`)
171 }
172 await b.del(`${prefix}identity:${id}`)
173 }
174 }
175 if (user.username) await b.del(`${prefix}username:${user.username}`)
176 await b.del(key)
177 break
178 }
179
180 case OpTypes.ADD_IDENTITY: {
181 const key = `${prefix}user:${op.userId}`
182 const existing = await b.get(key)
183 if (!existing) continue
184
185 const user = existing.value
186 if (!user.identities.includes(op.identityId)) {
187 user.identities.push(op.identityId)
188 user.updatedAt = at
189 await b.put(key, user)
190 await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId })
191 }
192 break
193 }
194
195 case OpTypes.REMOVE_IDENTITY: {
196 const key = `${prefix}user:${op.userId}`
197 const existing = await b.get(key)
198 if (!existing) continue
199
200 const user = existing.value
201 user.identities = user.identities.filter(id => id !== op.identityId)
202 user.updatedAt = at
203 await b.put(key, user)
204 await b.del(`${prefix}user-identity:${op.identityId}`)
205 break
206 }
207
208 case OpTypes.LINK_IDENTITY: {
209 await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId })
210 const key = `${prefix}user:${op.userId}`
211 const existing = await b.get(key)
212 if (existing) {
213 const user = existing.value
214 if (!user.identities.includes(op.identityId)) {
215 user.identities.push(op.identityId)
216 user.updatedAt = at
217 await b.put(key, user)
218 }
219 }
220 // Stamp the owning userId onto the identity record IN THE VIEW.
221 // Since the view-migration, identity records live in the view and
222 // getIdentity reads it first; the registry method only wrote
223 // userId to the legacy db, leaving the view record without it
224 // (cross-daemon link ownership invisible). Idempotent.
225 const idKey = `${prefix}identity:${op.identityId}`
226 const idEntry = await b.get(idKey)
227 if (idEntry && idEntry.value.userId !== op.userId) {
228 const idRec = idEntry.value
229 idRec.userId = op.userId
230 idRec.updatedAt = at
231 await b.put(idKey, idRec)
232 }
233 break
234 }
235
236 case OpTypes.UPGRADE_PERMISSIONS: {
237 const key = `${prefix}user:${op.userId}`
238 const existing = await b.get(key)
239 if (!existing) continue
240
241 const user = existing.value
242 const resourceName = op.resourceName
243
244 if (resourceName === '*') {
245 // Global permissions
246 user.globalPermissions = op.permissions
247 } else {
248 // Resource-specific permissions
249 if (!user.resources) user.resources = []
250 const idx = user.resources.findIndex(r => r.name === resourceName)
251 if (idx >= 0) {
252 user.resources[idx].permissions = op.permissions
253 } else {
254 user.resources.push({ name: resourceName, permissions: op.permissions })
255 }
256 }
257 user.updatedAt = at
258 await b.put(key, user)
259 break
260 }
261
262 case OpTypes.MERGE_ACCOUNTS: {
263 const primaryKey = `${prefix}user:${op.primaryUserId}`
264 const secondaryKey = `${prefix}user:${op.secondaryUserId}`
265
266 const primary = await b.get(primaryKey)
267 const secondary = await b.get(secondaryKey)
268
269 if (!primary || !secondary) continue
270
271 const pUser = primary.value
272 const sUser = secondary.value
273
274 // Merge identities
275 for (const id of sUser.identities) {
276 if (!pUser.identities.includes(id)) {
277 pUser.identities.push(id)
278 }
279 await b.put(`${prefix}user-identity:${id}`, { userId: op.primaryUserId })
280 }
281
282 // Merge resources
283 for (const res of (sUser.resources || [])) {
284 const idx = (pUser.resources || []).findIndex(r => r.name === res.name)
285 if (idx < 0) {
286 if (!pUser.resources) pUser.resources = []
287 pUser.resources.push(res)
288 }
289 }
290
291 // Merge global permissions
292 const allPerms = new Set([...pUser.globalPermissions, ...sUser.globalPermissions])
293 pUser.globalPermissions = [...allPerms]
294
295 pUser.updatedAt = at
296 await b.put(primaryKey, pUser)
297
298 // Mark secondary as merged
299 sUser.mergedInto = op.primaryUserId
300 sUser.updatedAt = at
301 await b.put(secondaryKey, sUser)
302 break
303 }
304
305 case OpTypes.REVOKE_IDENTITY: {
306 // Write revocation record to view (consensus-visible)
307 // Direct-write db is updated separately in registry method
308 await b.put(`${prefix}revoked:${op.identityId}`, {
309 identityId: op.identityId,
310 revokedAt: op.revokedAt || at,
311 reason: op.reason || null
312 })
313 break
314 }
315
316 case OpTypes.RESTORE_IDENTITY: {
317 await b.del(`${prefix}revoked:${op.identityId}`)
318 break
319 }
320
321 case OpTypes.DEACTIVATE_USER: {
322 const key = `${prefix}user:${op.userId}`
323 const existing = await b.get(key)
324 if (!existing) continue
325
326 const user = existing.value
327
328 // Guard: can't deactivate the last admin
329 if (user.globalPermissions?.includes('admin')) {
330 let adminCount = 0
331 for await (const e of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff` })) {
332 if (e.value.globalPermissions?.includes('admin') && !e.value.deactivatedAt && !e.value.mergedInto) adminCount++
333 }
334 if (adminCount <= 1) continue // Silently skip — can't deactivate last admin
335 }
336
337 user.deactivatedAt = at
338 user.deactivatedReason = op.reason || null
339 user.updatedAt = at
340 await b.put(key, user)
341 break
342 }
343
344 case OpTypes.REACTIVATE_USER: {
345 const key = `${prefix}user:${op.userId}`
346 const existing = await b.get(key)
347 if (!existing) continue
348
349 const user = existing.value
350 delete user.deactivatedAt
351 delete user.deactivatedReason
352 user.updatedAt = at
353 await b.put(key, user)
354 break
355 }
356
357 case OpTypes.CREATE_LINK_TOKEN: {
358 // Store the token row keyed by its hash. Idempotent — a
359 // duplicate op for the same hash is silently dropped.
360 const key = `${prefix}link-token:${op.tokenHash}`
361 const existing = await b.get(key)
362 if (existing) break
363 await b.put(key, {
364 tokenHash: op.tokenHash,
365 userId: op.userId,
366 expiresAt: op.expiresAt,
367 createdAt: op.createdAt || at,
368 consumed: false
369 })
370 break
371 }
372
373 case OpTypes.CONSUME_LINK_TOKEN: {
374 const tokenKey = `${prefix}link-token:${op.tokenHash}`
375 const tokenEntry = await b.get(tokenKey)
376 if (!tokenEntry) break // unknown token
377 const token = tokenEntry.value
378 if (token.consumed) break // double-consume
379 if (token.expiresAt && token.expiresAt < at) break // expired
380
381 // 1. Mark token consumed (kept for audit, never deleted)
382 await b.put(tokenKey, {
383 ...token,
384 consumed: true,
385 consumedAt: at,
386 consumedBy: op.newIdentityId
387 })
388
389 // 2. Attach the new identity to the token's target user
390 const targetKey = `${prefix}user:${token.userId}`
391 const targetEntry = await b.get(targetKey)
392 if (targetEntry) {
393 const target = targetEntry.value
394 if (!target.identities.includes(op.newIdentityId)) {
395 target.identities.push(op.newIdentityId)
396 target.updatedAt = at
397 await b.put(targetKey, target)
398 }
399 // Always rewrite the user-identity index — if the identity
400 // was previously linked to a throwaway, this rebinds it.
401 await b.put(`${prefix}user-identity:${op.newIdentityId}`, { userId: token.userId })
402 }
403
404 // 3. Optionally deactivate the throwaway user the new
405 // identity used to belong to. Strip the identity from
406 // that user first so it isn't double-counted.
407 if (op.deactivateUserId && op.deactivateUserId !== token.userId) {
408 const dKey = `${prefix}user:${op.deactivateUserId}`
409 const dEntry = await b.get(dKey)
410 if (dEntry) {
411 const u = dEntry.value
412 u.identities = (u.identities || []).filter(id => id !== op.newIdentityId)
413 u.deactivatedAt = at
414 u.deactivatedReason = 'merged-via-link-token'
415 u.updatedAt = at
416 await b.put(dKey, u)
417 }
418 }
419 break
420 }
421
422 case OpTypes.SET_USERNAME: {
423 const key = `${prefix}user:${op.userId}`
424 const existing = await b.get(key)
425 if (!existing) continue
426
427 const user = existing.value
428
429 // Remove old username index
430 if (user.username) {
431 await b.del(`${prefix}username:${user.username}`)
432 }
433
434 // Set new username
435 user.username = op.username
436 user.updatedAt = at
437 await b.put(key, user)
438 await b.put(`${prefix}username:${op.username}`, { userId: op.userId })
439 break
440 }
441
442 case OpTypes.ADD_WRITER: {
443 // Audit trail in the view
444 await b.put(`${prefix}writer:${op.writerKey}`, {
445 addedAt: at,
446 addedBy: op.addedBy || null,
447 role: op.role || 'writer'
448 })
449 // Propagate to the Autobase host so the new writer's core
450 // participates in linearization.
451 if (base && typeof base.addWriter === 'function') {
452 try {
453 await base.addWriter(b4a.from(op.writerKey, 'hex'), {
454 indexer: op.role === 'indexer'
455 })
456 } catch (err) {
457 console.error('[IdentityApply] base.addWriter failed:', err.message)
458 }
459 }
460 break
461 }
462
463 case OpTypes.REMOVE_WRITER: {
464 await b.del(`${prefix}writer:${op.writerKey}`)
465 if (base && typeof base.removeWriter === 'function') {
466 try {
467 await base.removeWriter(b4a.from(op.writerKey, 'hex'))
468 } catch (err) {
469 console.error('[IdentityApply] base.removeWriter failed:', err.message)
470 }
471 }
472 break
473 }
474
475 case OpTypes.REGISTER_MIRROR_WRITERS: {
476 // View-only record. The enclosing app watches this key
477 // range and issues in-band addWriter ops on the matching
478 // OTHER autobases it hosts. Apply must stay deterministic:
479 // no side effects on other autobases from here.
480 await b.put(`${prefix}mirror-writers:${op.devicePubkey}`, {
481 keys: op.keys,
482 recordedAt: at
483 })
484 break
485 }
486
487 case OpTypes.REGISTER_IDENTITY: {
488 // Full identity record into the view (replicated to mirrors).
489 // Replaces the previous direct-write to a per-daemon
490 // Hyperbee (`db`), which was local-only. Mirror peers now
491 // see the full record + reverse indexes via standard
492 // autobase replication. See pear-git-v3/test/
493 // identity-record-replication.test.js for the proof.
494 const identity = op.identity
495 if (!identity || !identity.identityId) break
496 await b.put(`${prefix}identity:${identity.identityId}`, identity)
497 if (identity.publicKey) {
498 await b.put(`${prefix}pubkey:${identity.publicKey}`, { identityId: identity.identityId })
499 }
500 if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
501 await b.put(`${prefix}sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
502 }
503 if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
504 await b.put(`${prefix}noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
505 }
506 if (identity.method === 'atproto' && identity.credentials) {
507 if (identity.credentials.did) {
508 await b.put(`${prefix}atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
509 }
510 if (identity.credentials.handle) {
511 await b.put(`${prefix}atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
512 }
513 }
514 break
515 }
516 }
517 } catch (err) {
518 console.error('[IdentityApply] Skipping malformed op:', err.message)
519 continue
520 }
521 }
522
523 await b.flush()
524 } catch (err) {
525 console.error('[IdentityApply] Batch error:', err.message)
526 await b.close()
527 throw err
528 }
529 }
530}
531
532/**
533 * Check if a writer has permission to execute an operation.
534 *
535 * Resolves the writer's key to a user by scanning all users' identities,
536 * then checks if that user has the required permission level.
537 *
538 * @param {Object} b - Hyperbee batch
539 * @param {string} prefix - Key prefix
540 * @param {string} writerKey - Writer's Hypercore public key (hex)
541 * @param {Object} op - The operation being executed
542 * @returns {string|null} Violation reason, or null if authorized
543 */
544async function checkContract (b, prefix, writerKey, op) {
545 // CREATE_USER is always allowed (autoRegister handles first-user-admin)
546 if (op.type === OpTypes.CREATE_USER) return null
547
548 // Find the user associated with this writer key
549 const writerUser = await resolveWriterUser(b, prefix, writerKey)
550
551 // If no user found for this writer, check if ANY users exist yet.
552 // During bootstrap (empty registry), allow all ops from the first writer.
553 if (!writerUser) {
554 const hasUsers = await hasAnyUser(b, prefix)
555 if (!hasUsers) return null // Bootstrap: empty registry, first writer is trusted
556 return `Writer ${writerKey.slice(0, 16)} has no associated user`
557 }
558
559 const isAdmin = writerUser.globalPermissions && writerUser.globalPermissions.includes('admin')
560
561 // Admin-only operations
562 if (ADMIN_OPS.has(op.type)) {
563 if (!isAdmin) return `${op.type} requires admin permissions`
564 return null
565 }
566
567 // Self-or-admin operations
568 if (SELF_OR_ADMIN_OPS.has(op.type)) {
569 if (isAdmin) return null
570 if (op.userId === writerUser.userId) return null
571 return `${op.type} requires admin or self (writer is ${writerUser.userId}, op targets ${op.userId})`
572 }
573
574 return null // Unknown op types pass through (validated by validateOp)
575}
576
577/**
578 * Find the user associated with a writer's Hypercore key.
579 * Looks up the writer key as a pubkey identity, then resolves to user.
580 */
581async function resolveWriterUser (b, prefix, writerKey) {
582 // Check pubkey reverse lookup (writer key may be registered as an identity)
583 const pubkeyEntry = await b.get(`${prefix}pubkey:${writerKey}`)
584 if (pubkeyEntry) {
585 const identityLink = await b.get(`${prefix}user-identity:${pubkeyEntry.value.identityId}`)
586 if (identityLink) {
587 const userEntry = await b.get(`${prefix}user:${identityLink.value.userId}`)
588 if (userEntry) return userEntry.value
589 }
590 }
591
592 // Scan all users to find one with this writer key in their identities
593 // (fallback for when the key isn't indexed as a pubkey)
594 for await (const entry of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff` })) {
595 const user = entry.value
596 if (user.identities && user.identities.includes(writerKey)) {
597 return user
598 }
599 }
600
601 return null
602}
603
604async function hasAnyUser (b, prefix) {
605 for await (const _ of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff`, limit: 1 })) {
606 return true
607 }
608 return false
609}
1// Moved to `hyper-identity-core`. Kept as a re-export — see identity-operations.js.
2//
3// ⚠ The core's apply speaks `addWriter(key, { isIndexer })`. This package is the AUTOBASE binding,
4// so it passes an `adaptHost` that translates to autobase's `{ indexer }` — see
5// identity-registry.js. Calling the core's apply from here WITHOUT that adapter would hand autobase
6// an option it does not know, and autobase would create a plain writer where an indexer was meant.
7export { createApplyFunction } from 'hyper-identity-core/apply'
lib/auth/identity-operations.js +4 -196
……@@ -1,196 +1,4 @@
1import b4a from 'b4a'
2
3/**
4 * Identity operation types for Autobase
5 */
6export const OpTypes = {
7 CREATE_USER: 'create-user',
8 UPDATE_USER: 'update-user',
9 DELETE_USER: 'delete-user',
10 ADD_IDENTITY: 'add-identity',
11 REMOVE_IDENTITY: 'remove-identity',
12 LINK_IDENTITY: 'link-identity',
13 UPGRADE_PERMISSIONS: 'upgrade-permissions',
14 MERGE_ACCOUNTS: 'merge-accounts',
15 REVOKE_IDENTITY: 'revoke-identity',
16 RESTORE_IDENTITY: 'restore-identity',
17 SET_USERNAME: 'set-username',
18 DEACTIVATE_USER: 'deactivate-user',
19 REACTIVATE_USER: 'reactivate-user',
20 // F.7.2 — link-identity ceremony. CREATE stores a hashed token tied to a
21 // user; CONSUME validates expiry + single-use, attaches a new identity
22 // to the target user, and optionally deactivates an emptied throwaway.
23 CREATE_LINK_TOKEN: 'create-link-token',
24 CONSUME_LINK_TOKEN: 'consume-link-token',
25 // Multi-writer — expand the Autobase writer set when a new device is
26 // linked to the identity. Recorded in the `writer:<key>` view for
27 // audit/introspection, and propagated to the Autobase host via
28 // host.addWriter in the apply function so the new device's local
29 // core contributes to the linearized view (true p2p, not hub-spoke).
30 ADD_WRITER: 'add-writer',
31 REMOVE_WRITER: 'remove-writer',
32 // Multi-autobase coordination. A linked device running a mirror of a
33 // multi-autobase app (e.g. pear-git-v3 with orgs/issue-tracker
34 // autobases alongside this one) appends this op with the auto-gen
35 // local.key of each OTHER autobase it opens. The primary daemon
36 // (which IS a writer on those other autobases) watches the view and
37 // issues a canonical in-band `addWriter` op per namespace. This op
38 // is a control-plane message for the enclosing app — the apply
39 // here only records it, no side effects. See pear-git-v3 docs.
40 REGISTER_MIRROR_WRITERS: 'register-mirror-writers',
41 // Full identity record register. Writes the identity record (method,
42 // publicKey, credentials, fingerprint reverse-lookups) into the
43 // Autobase view so mirror peers replicate it. Replaces the previous
44 // direct-write to a per-daemon Hyperbee, which was local-only and
45 // broke cross-daemon visibility (mirrors saw user.identities=[<id>]
46 // but had no full record → "Linked identities (0)" / "No SSH keys
47 // linked" symptom).
48 // See pear-git-v3/test/identity-record-replication.test.js for the
49 // mechanical proof + control case.
50 REGISTER_IDENTITY: 'register-identity'
51}
52
53/**
54 * Encode operation for Autobase
55 * @param {Object} op - Operation object
56 * @returns {Buffer}
57 */
58export function encodeOp (op) {
59 // ⛔ THE CLOCK IS STAMPED HERE, ONCE, ON THE AUTHORING PEER — never inside apply.
60 //
61 // apply is replayed on every boot and every reorg, and each peer replays independently. A
62 // `Date.now()` inside it means two peers replaying the SAME ops write DIFFERENT view bytes, and
63 // in the one place apply branched on the clock (link-token expiry) it meant they reached
64 // different STATE — one peer consuming a token the other rejected.
65 //
66 // Putting it in the op makes it part of the bytes every peer agrees on, so replay is a pure
67 // function of the log. Same fix hyper-rooms uses (lib/room.js:220,260,273 write it, :418 reads
68 // `op.timestamp || Date.now()`).
69 //
70 // The field is `timestamp` to match hyper-rooms (lib/room.js:220,260,273), the reference
71 // implementation on this same substrate — one convention across the stack, not a second one.
72 //
73 // `...op` comes AFTER so an explicit timestamp wins: the timestamp-preserving paths (a reconciler
74 // re-emitting a record it already holds) must not have their clock bumped by a re-encode.
75 return b4a.from(JSON.stringify({ timestamp: Date.now(), ...op }))
76}
77
78/**
79 * Decode operation from Autobase
80 * @param {Buffer} buf - Encoded operation
81 * @returns {Object}
82 */
83export function decodeOp (buf) {
84 return JSON.parse(b4a.toString(buf))
85}
86
87/**
88 * Validate operation structure
89 * @param {Object} op - Operation to validate
90 * @returns {{ valid: boolean, error?: string }}
91 */
92export function validateOp (op) {
93 if (!op || !op.type) return { valid: false, error: 'Missing op type' }
94
95 switch (op.type) {
96 case OpTypes.CREATE_USER:
97 if (!op.userId) return { valid: false, error: 'Missing userId' }
98 if (!op.name) return { valid: false, error: 'Missing name' }
99 return { valid: true }
100
101 case OpTypes.UPDATE_USER:
102 if (!op.userId) return { valid: false, error: 'Missing userId' }
103 return { valid: true }
104
105 case OpTypes.DELETE_USER:
106 if (!op.userId) return { valid: false, error: 'Missing userId' }
107 return { valid: true }
108
109 case OpTypes.ADD_IDENTITY:
110 case OpTypes.REMOVE_IDENTITY:
111 if (!op.userId) return { valid: false, error: 'Missing userId' }
112 if (!op.identityId) return { valid: false, error: 'Missing identityId' }
113 return { valid: true }
114
115 case OpTypes.LINK_IDENTITY:
116 if (!op.identityId) return { valid: false, error: 'Missing identityId' }
117 if (!op.userId) return { valid: false, error: 'Missing userId' }
118 return { valid: true }
119
120 case OpTypes.UPGRADE_PERMISSIONS:
121 if (!op.userId) return { valid: false, error: 'Missing userId' }
122 if (!op.resourceName) return { valid: false, error: 'Missing resourceName' }
123 if (!Array.isArray(op.permissions)) return { valid: false, error: 'permissions must be array' }
124 return { valid: true }
125
126 case OpTypes.MERGE_ACCOUNTS:
127 if (!op.primaryUserId) return { valid: false, error: 'Missing primaryUserId' }
128 if (!op.secondaryUserId) return { valid: false, error: 'Missing secondaryUserId' }
129 return { valid: true }
130
131 case OpTypes.REVOKE_IDENTITY:
132 case OpTypes.RESTORE_IDENTITY:
133 if (!op.identityId) return { valid: false, error: 'Missing identityId' }
134 return { valid: true }
135
136 case OpTypes.SET_USERNAME:
137 if (!op.userId) return { valid: false, error: 'Missing userId' }
138 if (!op.username) return { valid: false, error: 'Missing username' }
139 return { valid: true }
140
141 case OpTypes.DEACTIVATE_USER:
142 if (!op.userId) return { valid: false, error: 'Missing userId' }
143 return { valid: true }
144
145 case OpTypes.REACTIVATE_USER:
146 if (!op.userId) return { valid: false, error: 'Missing userId' }
147 return { valid: true }
148
149 case OpTypes.CREATE_LINK_TOKEN:
150 if (!op.tokenHash) return { valid: false, error: 'Missing tokenHash' }
151 if (!op.userId) return { valid: false, error: 'Missing userId' }
152 if (typeof op.expiresAt !== 'number') return { valid: false, error: 'Missing expiresAt' }
153 return { valid: true }
154
155 case OpTypes.CONSUME_LINK_TOKEN:
156 if (!op.tokenHash) return { valid: false, error: 'Missing tokenHash' }
157 if (!op.newIdentityId) return { valid: false, error: 'Missing newIdentityId' }
158 return { valid: true }
159
160 case OpTypes.ADD_WRITER:
161 case OpTypes.REMOVE_WRITER:
162 if (!op.writerKey || typeof op.writerKey !== 'string' || op.writerKey.length !== 64) {
163 return { valid: false, error: 'writerKey must be 64-char hex' }
164 }
165 return { valid: true }
166
167 case OpTypes.REGISTER_MIRROR_WRITERS:
168 if (!op.devicePubkey || typeof op.devicePubkey !== 'string' || op.devicePubkey.length !== 64) {
169 return { valid: false, error: 'devicePubkey must be 64-char hex' }
170 }
171 if (!op.keys || typeof op.keys !== 'object') {
172 return { valid: false, error: 'keys must be a namespace→hex map' }
173 }
174 for (const [ns, key] of Object.entries(op.keys)) {
175 if (typeof key !== 'string' || key.length !== 64) {
176 return { valid: false, error: `keys[${ns}] must be 64-char hex` }
177 }
178 }
179 return { valid: true }
180
181 case OpTypes.REGISTER_IDENTITY:
182 if (!op.identity || typeof op.identity !== 'object') {
183 return { valid: false, error: 'identity object required' }
184 }
185 if (!op.identity.identityId || typeof op.identity.identityId !== 'string') {
186 return { valid: false, error: 'identity.identityId required' }
187 }
188 if (!op.identity.method || typeof op.identity.method !== 'string') {
189 return { valid: false, error: 'identity.method required' }
190 }
191 return { valid: true }
192
193 default:
194 return { valid: false, error: `Unknown op type: ${op.type}` }
195 }
196}
1// Moved to `hyper-identity-core`. Kept as a re-export so nothing that imported this path has to
2// change — the ops are engine-independent and now live where both engine packages can share ONE
3// copy of them rather than drifting apart.
4export { OpTypes, encodeOp, decodeOp, validateOp } from 'hyper-identity-core/operations'
lib/auth/identity-registry.js +2 -1
……@@ -3,6 +3,7 @@ import Hyperbee from 'hyperbee'
33import crypto from 'hypercore-crypto'
44import b4a from 'b4a'
55import { createApplyFunction } from './identity-apply.js'
6import { adaptHost } from './adapt-host.js'
67import { encodeOp, OpTypes } from './identity-operations.js'
78
89const RESERVED_USERNAMES = [
……@@ -94,7 +95,7 @@ export class IdentityRegistry {
9495 async ready () {
9596 if (this._ready) return
9697
97 const apply = createApplyFunction({ prefix: this._prefix })
98 const apply = createApplyFunction({ prefix: this._prefix, adaptHost })
9899
99100 const rootKey = this._bootstrapKey
100101 ? (typeof this._bootstrapKey === 'string'
package.json +2 -1
……@@ -46,7 +46,8 @@
4646 "hyperident": "file:../../repos/hyperident",
4747 "hyperswarm": "^4.16.0",
4848 "keet-identity-key": "github:holepunchto/keet-identity-key",
49 "sodium-native": "^5.0.0"
49 "sodium-native": "^5.0.0",
50 "hyper-identity-core": "file:../hyper-identity-core"
5051 },
5152 "scripts": {
5253 "test": "brittle test/*.test.js",
test/apply-determinism.test.js +102 -0
……@@ -0,0 +1,102 @@
1// Autobase requires apply to be DETERMINISTIC: it is replayed on every boot
2// and every reorg, and each peer runs it independently to build its own view.
3// If apply stamps non-deterministic values, two peers replaying identical ops
4// write DIFFERENT view bytes and their views can never agree.
5//
6// hyper-rooms (lib/room.js) — the reference implementation on this same
7// substrate — puts the timestamp in the OP (:220,260,273) and has apply read
8// `op.timestamp || Date.now()` (:418). This module must do the same.
9//
10// CONTROL first: an op that stores only op-provided fields must replay
11// byte-identically, proving the harness itself is deterministic.
12
13import test from 'brittle'
14import Corestore from 'corestore'
15import Hyperbee from 'hyperbee'
16import path from '#node:path'
17import os from '#node:os'
18import crypto from 'hypercore-crypto'
19import b4a from 'b4a'
20import { createApplyFunction } from '../lib/auth/identity-apply.js'
21import { encodeOp, OpTypes } from '../lib/auth/identity-operations.js'
22
23const stubHost = { addWriter: async () => {}, removeWriter: async () => {} }
24const node = (op) => ({ value: encodeOp(op), from: { key: b4a.alloc(32) } })
25const sleep = (ms) => new Promise(r => setTimeout(r, ms))
26
27// Run the real apply over a batch into a fresh in-memory view, return the
28// stored value at `key`.
29async function applyAndRead (batch, key) {
30 const store = new Corestore(
31 path.join(os.tmpdir(), 'hyper-identity-determinism-' + crypto.randomBytes(8).toString('hex'))
32 )
33 await store.ready()
34 const bee = new Hyperbee(store.get({ name: 'identity-view' }), {
35 keyEncoding: 'utf-8', valueEncoding: 'json'
36 })
37 await bee.ready()
38
39 const apply = createApplyFunction({})
40 await apply(batch, bee, stubHost)
41
42 const entry = await bee.get(key)
43 const value = entry ? entry.value : null
44 await bee.close()
45 await store.close()
46 return value
47}
48
49test('CONTROL: an op storing only op-provided fields replays byte-identically', async (t) => {
50 const identity = {
51 identityId: 'ident-control',
52 method: 'web-identity',
53 publicKey: 'ff'.repeat(32),
54 userId: 'user-control',
55 createdAt: 1700000000000
56 }
57 const batch = [node({ type: OpTypes.REGISTER_IDENTITY, identity })]
58
59 const a = await applyAndRead(batch, 'identity:ident-control')
60 await sleep(25)
61 const b = await applyAndRead(batch, 'identity:ident-control')
62
63 t.ok(a && b, 'both replays stored the record')
64 t.alike(a, b, 'control replays byte-identical — harness is deterministic')
65})
66
67test('ADD_WRITER replays deterministically', async (t) => {
68 const writerKey = 'ab'.repeat(32)
69 const batch = [node({
70 type: OpTypes.ADD_WRITER,
71 writerKey,
72 role: 'writer',
73 addedBy: 'test',
74 timestamp: 1700000000001
75 })]
76
77 const a = await applyAndRead(batch, 'writer:' + writerKey)
78 await sleep(25)
79 const b = await applyAndRead(batch, 'writer:' + writerKey)
80
81 t.ok(a && b, 'both replays stored the writer record')
82 t.alike(a, b, 'identical ADD_WRITER op produces identical view bytes on replay')
83 t.is(a.addedAt, 1700000000001, 'addedAt comes from the OP, not from apply')
84})
85
86test('REGISTER_MIRROR_WRITERS replays deterministically', async (t) => {
87 const devicePubkey = 'cd'.repeat(32)
88 const batch = [node({
89 type: OpTypes.REGISTER_MIRROR_WRITERS,
90 devicePubkey,
91 keys: { orgs: 'ef'.repeat(32) },
92 timestamp: 1700000000002
93 })]
94
95 const a = await applyAndRead(batch, 'mirror-writers:' + devicePubkey)
96 await sleep(25)
97 const b = await applyAndRead(batch, 'mirror-writers:' + devicePubkey)
98
99 t.ok(a && b, 'both replays stored the announcement')
100 t.alike(a, b, 'identical REGISTER_MIRROR_WRITERS op produces identical view bytes')
101 t.is(a.recordedAt, 1700000000002, 'recordedAt comes from the OP, not from apply')
102})
test/engine-parity.test.js +37 -36
……@@ -160,42 +160,43 @@ for (const engine of ENGINES) {
160160
161161// ⛔ The one API break that fails SILENTLY, so it gets its own test.
162162//
163// autobase takes `addWriter(key, { indexer })`. autobee takes
164// `addWriter(key, { isIndexer = true, weight = isIndexer ? 2 : 1 })` — verified in autobee 2.1.0
165// `lib/apply-calls.js:33`. An unknown `indexer` key is IGNORED and `isIndexer` DEFAULTS TO TRUE, so
166// a mechanical port silently grants every writer indexer standing.
163// The first version of this test asked the ENGINE whether a writer had ended up an indexer. That
164// was the wrong oracle, and its own positive control proved it: autobase leaves a granted indexer
165// in `pendingIndexers` until its core exists and acks, so a freshly granted key is in NEITHER list
166// and the accessor answered "no" to everything — including the case it was supposed to catch. A
167// test that cannot say YES cannot mean anything by NO.
167168//
168// That is not a hypothetical: identity-registry.js:83 warns in its own comment against making every
169// device an indexer, "which deadlocks during manifest migration when going from 2→3 indexers".
170//
171// So the default is asserted rather than inherited, on both engines.
172for (const engine of ENGINES) {
173 test('[' + engine + '] a plain writer is NOT granted indexer standing', async (t) => {
174 const opened = await openOrFail(t, engine)
175 if (!opened) return
176 const registry = opened.registry
177 t.teardown(opened.cleanup)
169// The defect is a TRANSLATION, so test the translation. `adaptHost` is the one function in each
170// engine package that knows its engine's spelling, and a spy sees exactly what the engine would
171// have been handed — with none of the writer-lifecycle noise that made the first oracle useless.
178172
179 const key = crypto.keyPair().publicKey.toString('hex')
180 await registry.addWriter(key, { role: 'writer' })
181
182 // The view's audit row is engine-independent — it is written by apply, from the op.
183 const rec = await registry.view.get('writer:' + key)
184 t.ok(rec, 'the writer record landed')
185 t.is((rec.value || rec).role, 'writer', 'and it recorded the role it was given')
186
187 // ⛔ The row is NOT the guard. It carries {addedAt, addedBy, role} and no indexer field, so
188 // asserting on it would pass whatever the engine actually granted. The real question is what
189 // standing the LOG gave the writer, which each engine spells differently — hence a normalized
190 // accessor, which the port owes. Red until it exists, deliberately.
191 // Reported as a failure rather than called blindly: a missing method throws a TypeError, which
192 // aborts the whole FILE and silently drops every test after it. Red must report, never conceal.
193 if (typeof registry.isIndexerWriter !== 'function') {
194 t.fail('registry.isIndexerWriter is not implemented — the port owes a normalized accessor ' +
195 'for writer standing, because each engine spells it differently')
196 } else {
197 t.is(await registry.isIndexerWriter(key), false,
198 'a writer is not an indexer — the default must be pinned, never inherited from the engine')
199 }
173import { adaptHost } from '../lib/auth/adapt-host.js'
174
175test('⛔ [autobase] adaptHost translates the core vocabulary, and pins the default', (t) => {
176 const seen = []
177 const host = adaptHost({
178 addWriter: (key, opts) => { seen.push(['add', key, opts]) },
179 removeWriter: (key) => { seen.push(['remove', key]) }
200180 })
201}
181
182 host.addWriter('k1', { isIndexer: true })
183 host.addWriter('k2', { isIndexer: false })
184 host.addWriter('k3', {})
185 host.removeWriter('k1')
186
187 t.alike(seen[0], ['add', 'k1', { indexer: true }], 'isIndexer:true becomes autobase indexer:true')
188 t.alike(seen[1], ['add', 'k2', { indexer: false }], 'and false stays false')
189
190 // ⛔ The one that bites. autobee DEFAULTS isIndexer to TRUE, so an option that goes missing in
191 // translation silently promotes every writer. The absent case must be explicit, never inherited.
192 t.alike(seen[2], ['add', 'k3', { indexer: false }], 'an ABSENT flag is false, never the engine default')
193
194 t.alike(seen[3], ['remove', 'k1'], 'removeWriter passes through')
195 t.absent('isIndexer' in seen[0][2], 'the core spelling never reaches autobase')
196})
197
198test('adaptHost leaves a host with no writer controls alone', (t) => {
199 const bare = { somethingElse: 1 }
200 t.is(adaptHost(bare), bare, 'nothing to translate, nothing wrapped')
201 t.is(adaptHost(null), null)
202})