OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity-core commits / f000604

hyper-identity-core 0.1.0 — the half with no engine
Extracted verbatim from hyper-identity: the op vocabulary (operations.js) and the apply that linearizes them (apply.js). Both were already separate files and already engine-independent; this makes that structural instead of incidental. It depends on NEITHER engine, and must not. hyper-identity binds it to autobase and hyper-identity2 will bind it to autobee — if the shared half lived inside either, the other would inherit that engine transitively, which is the entire reason for the split: favela-peer runs autobee and cannot carry autobase beside it. ⛔ One behavioural change, and it is the point of the exercise. The core now calls `host.addWriter(key, { isIndexer })` — ONE vocabulary — and takes an `adaptHost` hook so each engine package translates to its own spelling. The two engines disagree about this word silently and in OPPOSITE directions: autobase { indexer } unknown option → a plain writer where an indexer was meant autobee { isIndexer }, DEFAULT TRUE unknown option → EVERY writer becomes an indexer Neither errors. Both look fine. identity-registry.js warns in its own comment that making every device an indexer deadlocks a manifest migration at 2→3 indexers, so the autobee direction is the one that actually breaks a fleet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f0006048ded957811aa3c8c669d11b889fb6351b
by plankatron <fleeky@gmail.com> · 2026-09-06T21:00:15.000Z

Changed files (6) +899 -0

.gitignore +1 -0
……@@ -0,0 +1 @@
1node_modules/
README.md +44 -0
……@@ -0,0 +1,44 @@
1# hyper-identity-core
2
3The half of the identity layer that has no engine.
4
5`apply` and the op vocabulary are a pure function of the log: they read a batch of ops and write a
6KV view. Nothing here knows whether that log is an autobase or an autobee.
7
8## Why this is a package and not a directory
9
10It depends on **neither engine**, and it must not. `hyper-identity` binds it to autobase;
11`hyper-identity2` binds it to autobee. If the shared half lived inside either one, the other would
12inherit that engine transitively — and the entire point is that a consumer running autobee
13(favela-peer) must not carry autobase beside it.
14
15## The one contract that matters
16
17The core calls `host.addWriter(key, { isIndexer })`. **One vocabulary.** Each engine package
18supplies an `adaptHost` that translates to its own spelling, because the two engines disagree about
19this word silently and in opposite directions:
20
21| engine | spelling | what a wrong option does |
22|---|---|---|
23| autobase | `{ indexer }` | ignored → a plain writer where an indexer was meant |
24| autobee | `{ isIndexer }`, **default `true`** | ignored → **every writer becomes an indexer** |
25
26Neither errors. Both look fine. That is why the translation is a named, tested unit in each engine
27package rather than an object literal in a constructor.
28
29## The clock
30
31`encodeOp` stamps `timestamp` at append time, on the authoring peer, and apply reads
32`op.timestamp || Date.now()`. Apply is replayed on every boot and reorg, independently per peer, so
33a clock read *inside* it makes replay non-deterministic — two peers writing different view bytes
34from identical ops, and in the link-token expiry case reaching different STATE. Same convention as
35hyper-rooms (`lib/room.js:220,260,273` write it, `:418` reads it).
36
37⚠ Ops written before that field replay exactly as they used to. Unfixable without rewriting
38history; every new op converges.
39
40## Tests
41
42None here yet. The logic is exercised through `hyper-identity`'s suite, which imports it via
43re-export shims — so it is covered, but not locally, and `test/security.test.js` +
44`test/apply-determinism.test.js` arguably belong in this repo.
index.js +12 -0
……@@ -0,0 +1,12 @@
1// hyper-identity-core — the half of the identity layer that has no engine.
2//
3// `apply` and the op vocabulary are a pure function of the log: they read a batch of ops and write
4// a KV view. Nothing in here knows whether that log is an autobase or an autobee, and nothing in
5// here may learn — that is the property this package exists to hold still.
6//
7// It deliberately depends on NEITHER engine. A package that dragged autobase in would hand it to
8// every consumer transitively, which is the whole reason this split exists: `hyper-identity2` runs
9// autobee and must not carry autobase beside it.
10
11export { createApplyFunction } from './lib/apply.js'
12export { OpTypes, encodeOp, decodeOp, validateOp } from './lib/operations.js'
lib/apply.js +624 -0
……@@ -0,0 +1,624 @@
1import { decodeOp, OpTypes, validateOp } from './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 // How to reach the LOG's writer-set controls from whatever object the engine hands apply.
57 //
58 // ⛔ This exists because the two engines disagree about one word, silently. autobase takes
59 // `addWriter(key, { indexer })`; autobee takes `addWriter(key, { isIndexer = true, weight =
60 // isIndexer ? 2 : 1 })` (autobee 2.1.0 lib/apply-calls.js:33). An unknown `indexer` key is
61 // IGNORED and `isIndexer` DEFAULTS TO TRUE — so passing autobase's spelling to autobee grants
62 // every writer indexer standing, with no error, which is the thing hyper-identity's own
63 // constructor comment warns deadlocks a manifest migration at 2→3 indexers.
64 //
65 // So the CORE speaks one vocabulary — `{ isIndexer }` — and each engine package translates on the
66 // way out. The translation is one line, in the package that knows which engine it is, instead of
67 // a convention every future caller has to remember.
68 const adaptHost = opts.adaptHost || ((host) => host)
69
70 return async function apply (batch, view, rawHost) {
71 const base = adaptHost(rawHost)
72 const b = view.batch({ update: false })
73
74 try {
75 for (const node of batch) {
76 try {
77 const op = decodeOp(node.value)
78 const validation = validateOp(op)
79 if (!validation.valid) {
80 console.error(`[IdentityApply] Invalid op: ${validation.error}`)
81 continue
82 }
83
84 // Contract validation: check writer permissions
85 if (enableContract && node.from) {
86 const writerKey = b4a.toString(node.from.key, 'hex')
87 const violation = await checkContract(b, prefix, writerKey, op)
88 if (violation) {
89 console.error(`[IdentityApply] Contract violation: ${violation} (writer: ${writerKey.slice(0, 16)}..., op: ${op.type})`)
90 continue // Drop the operation
91 }
92 }
93
94 // The op's own clock, stamped by encodeOp on the authoring peer. Every timestamp and
95 // every time COMPARISON below uses it, so this apply is a pure function of the log.
96 //
97 // ⚠ The `at` fallback is for ops written BEFORE this field existed, already
98 // durable in deployed logs. Those replay non-deterministically exactly as they do today —
99 // no worse, and unfixable without rewriting history. Every NEW op converges.
100 const at = op.timestamp || Date.now()
101
102 switch (op.type) {
103 case OpTypes.CREATE_USER: {
104 const key = `${prefix}user:${op.userId}`
105 const existing = await b.get(key)
106 if (existing) continue // Don't overwrite
107
108 // Deterministic admin bootstrap: the FIRST user created gets
109 // admin permissions. All subsequent users get read-only.
110 // This decision happens in the apply function (deterministic,
111 // linearized by Autobase) — NOT in the caller (which would be
112 // racy in multi-writer scenarios).
113 const isFirstUser = !(await hasAnyUser(b, prefix))
114 const permissions = isFirstUser
115 ? ['read', 'write', 'admin']
116 : ['read']
117 const resources = isFirstUser
118 ? [{ name: '*', permissions: ['read', 'write', 'admin'] }]
119 : [{ name: '*', permissions: ['read'] }]
120
121 const user = {
122 userId: op.userId,
123 username: op.username || null,
124 name: op.name || 'Anonymous',
125 email: op.email || null,
126 profilePicture: op.profilePicture || null,
127 globalPermissions: permissions,
128 resources,
129 identities: op.identities || [],
130 createdAt: op.createdAt || at,
131 updatedAt: op.updatedAt || at
132 }
133 await b.put(key, user)
134
135 // Index identities
136 for (const id of user.identities) {
137 await b.put(`${prefix}user-identity:${id}`, { userId: op.userId })
138 }
139 break
140 }
141
142 case OpTypes.UPDATE_USER: {
143 const key = `${prefix}user:${op.userId}`
144 const existing = await b.get(key)
145 if (!existing) continue
146
147 const user = existing.value
148 if (op.username !== undefined) user.username = op.username
149 if (op.name !== undefined) user.name = op.name
150 if (op.email !== undefined) user.email = op.email
151 if (op.profilePicture !== undefined) user.profilePicture = op.profilePicture
152 if (op.theme !== undefined) user.theme = op.theme
153 if (op.globalPermissions !== undefined) user.globalPermissions = op.globalPermissions
154 user.updatedAt = at
155 await b.put(key, user)
156 break
157 }
158
159 case OpTypes.DELETE_USER: {
160 const key = `${prefix}user:${op.userId}`
161 const existing = await b.get(key)
162 if (!existing) continue
163
164 const user = existing.value
165 // Remove identity index entries AND the identity records + their
166 // reverse indexes FROM THE VIEW. Since the view-migration these
167 // records live in the view; deleteUserCascade only cleaned the
168 // legacy db, so the view's pubkey/sshkey/noisekey indexes
169 // survived a delete. Mirror REGISTER_IDENTITY's index writes in
170 // reverse so a cascade is complete and replicates to peers.
171 for (const id of (user.identities || [])) {
172 await b.del(`${prefix}user-identity:${id}`)
173 const idEntry = await b.get(`${prefix}identity:${id}`)
174 if (idEntry) {
175 const idRec = idEntry.value
176 if (idRec.publicKey) await b.del(`${prefix}pubkey:${idRec.publicKey}`)
177 if (idRec.method === 'ssh-key' && idRec.credentials && idRec.credentials.fingerprint) {
178 await b.del(`${prefix}sshkey:${idRec.credentials.fingerprint}`)
179 }
180 if (idRec.method === 'noise-key' && idRec.credentials && idRec.credentials.publicKey) {
181 await b.del(`${prefix}noisekey:${idRec.credentials.publicKey}`)
182 }
183 if (idRec.method === 'atproto' && idRec.credentials) {
184 if (idRec.credentials.did) await b.del(`${prefix}atproto-did:${idRec.credentials.did}`)
185 if (idRec.credentials.handle) await b.del(`${prefix}atproto-handle:${idRec.credentials.handle}`)
186 }
187 await b.del(`${prefix}identity:${id}`)
188 }
189 }
190 if (user.username) await b.del(`${prefix}username:${user.username}`)
191 await b.del(key)
192 break
193 }
194
195 case OpTypes.ADD_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 if (!user.identities.includes(op.identityId)) {
202 user.identities.push(op.identityId)
203 user.updatedAt = at
204 await b.put(key, user)
205 await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId })
206 }
207 break
208 }
209
210 case OpTypes.REMOVE_IDENTITY: {
211 const key = `${prefix}user:${op.userId}`
212 const existing = await b.get(key)
213 if (!existing) continue
214
215 const user = existing.value
216 user.identities = user.identities.filter(id => id !== op.identityId)
217 user.updatedAt = at
218 await b.put(key, user)
219 await b.del(`${prefix}user-identity:${op.identityId}`)
220 break
221 }
222
223 case OpTypes.LINK_IDENTITY: {
224 await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId })
225 const key = `${prefix}user:${op.userId}`
226 const existing = await b.get(key)
227 if (existing) {
228 const user = existing.value
229 if (!user.identities.includes(op.identityId)) {
230 user.identities.push(op.identityId)
231 user.updatedAt = at
232 await b.put(key, user)
233 }
234 }
235 // Stamp the owning userId onto the identity record IN THE VIEW.
236 // Since the view-migration, identity records live in the view and
237 // getIdentity reads it first; the registry method only wrote
238 // userId to the legacy db, leaving the view record without it
239 // (cross-daemon link ownership invisible). Idempotent.
240 const idKey = `${prefix}identity:${op.identityId}`
241 const idEntry = await b.get(idKey)
242 if (idEntry && idEntry.value.userId !== op.userId) {
243 const idRec = idEntry.value
244 idRec.userId = op.userId
245 idRec.updatedAt = at
246 await b.put(idKey, idRec)
247 }
248 break
249 }
250
251 case OpTypes.UPGRADE_PERMISSIONS: {
252 const key = `${prefix}user:${op.userId}`
253 const existing = await b.get(key)
254 if (!existing) continue
255
256 const user = existing.value
257 const resourceName = op.resourceName
258
259 if (resourceName === '*') {
260 // Global permissions
261 user.globalPermissions = op.permissions
262 } else {
263 // Resource-specific permissions
264 if (!user.resources) user.resources = []
265 const idx = user.resources.findIndex(r => r.name === resourceName)
266 if (idx >= 0) {
267 user.resources[idx].permissions = op.permissions
268 } else {
269 user.resources.push({ name: resourceName, permissions: op.permissions })
270 }
271 }
272 user.updatedAt = at
273 await b.put(key, user)
274 break
275 }
276
277 case OpTypes.MERGE_ACCOUNTS: {
278 const primaryKey = `${prefix}user:${op.primaryUserId}`
279 const secondaryKey = `${prefix}user:${op.secondaryUserId}`
280
281 const primary = await b.get(primaryKey)
282 const secondary = await b.get(secondaryKey)
283
284 if (!primary || !secondary) continue
285
286 const pUser = primary.value
287 const sUser = secondary.value
288
289 // Merge identities
290 for (const id of sUser.identities) {
291 if (!pUser.identities.includes(id)) {
292 pUser.identities.push(id)
293 }
294 await b.put(`${prefix}user-identity:${id}`, { userId: op.primaryUserId })
295 }
296
297 // Merge resources
298 for (const res of (sUser.resources || [])) {
299 const idx = (pUser.resources || []).findIndex(r => r.name === res.name)
300 if (idx < 0) {
301 if (!pUser.resources) pUser.resources = []
302 pUser.resources.push(res)
303 }
304 }
305
306 // Merge global permissions
307 const allPerms = new Set([...pUser.globalPermissions, ...sUser.globalPermissions])
308 pUser.globalPermissions = [...allPerms]
309
310 pUser.updatedAt = at
311 await b.put(primaryKey, pUser)
312
313 // Mark secondary as merged
314 sUser.mergedInto = op.primaryUserId
315 sUser.updatedAt = at
316 await b.put(secondaryKey, sUser)
317 break
318 }
319
320 case OpTypes.REVOKE_IDENTITY: {
321 // Write revocation record to view (consensus-visible)
322 // Direct-write db is updated separately in registry method
323 await b.put(`${prefix}revoked:${op.identityId}`, {
324 identityId: op.identityId,
325 revokedAt: op.revokedAt || at,
326 reason: op.reason || null
327 })
328 break
329 }
330
331 case OpTypes.RESTORE_IDENTITY: {
332 await b.del(`${prefix}revoked:${op.identityId}`)
333 break
334 }
335
336 case OpTypes.DEACTIVATE_USER: {
337 const key = `${prefix}user:${op.userId}`
338 const existing = await b.get(key)
339 if (!existing) continue
340
341 const user = existing.value
342
343 // Guard: can't deactivate the last admin
344 if (user.globalPermissions?.includes('admin')) {
345 let adminCount = 0
346 for await (const e of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff` })) {
347 if (e.value.globalPermissions?.includes('admin') && !e.value.deactivatedAt && !e.value.mergedInto) adminCount++
348 }
349 if (adminCount <= 1) continue // Silently skip — can't deactivate last admin
350 }
351
352 user.deactivatedAt = at
353 user.deactivatedReason = op.reason || null
354 user.updatedAt = at
355 await b.put(key, user)
356 break
357 }
358
359 case OpTypes.REACTIVATE_USER: {
360 const key = `${prefix}user:${op.userId}`
361 const existing = await b.get(key)
362 if (!existing) continue
363
364 const user = existing.value
365 delete user.deactivatedAt
366 delete user.deactivatedReason
367 user.updatedAt = at
368 await b.put(key, user)
369 break
370 }
371
372 case OpTypes.CREATE_LINK_TOKEN: {
373 // Store the token row keyed by its hash. Idempotent — a
374 // duplicate op for the same hash is silently dropped.
375 const key = `${prefix}link-token:${op.tokenHash}`
376 const existing = await b.get(key)
377 if (existing) break
378 await b.put(key, {
379 tokenHash: op.tokenHash,
380 userId: op.userId,
381 expiresAt: op.expiresAt,
382 createdAt: op.createdAt || at,
383 consumed: false
384 })
385 break
386 }
387
388 case OpTypes.CONSUME_LINK_TOKEN: {
389 const tokenKey = `${prefix}link-token:${op.tokenHash}`
390 const tokenEntry = await b.get(tokenKey)
391 if (!tokenEntry) break // unknown token
392 const token = tokenEntry.value
393 if (token.consumed) break // double-consume
394 if (token.expiresAt && token.expiresAt < at) break // expired
395
396 // 1. Mark token consumed (kept for audit, never deleted)
397 await b.put(tokenKey, {
398 ...token,
399 consumed: true,
400 consumedAt: at,
401 consumedBy: op.newIdentityId
402 })
403
404 // 2. Attach the new identity to the token's target user
405 const targetKey = `${prefix}user:${token.userId}`
406 const targetEntry = await b.get(targetKey)
407 if (targetEntry) {
408 const target = targetEntry.value
409 if (!target.identities.includes(op.newIdentityId)) {
410 target.identities.push(op.newIdentityId)
411 target.updatedAt = at
412 await b.put(targetKey, target)
413 }
414 // Always rewrite the user-identity index — if the identity
415 // was previously linked to a throwaway, this rebinds it.
416 await b.put(`${prefix}user-identity:${op.newIdentityId}`, { userId: token.userId })
417 }
418
419 // 3. Optionally deactivate the throwaway user the new
420 // identity used to belong to. Strip the identity from
421 // that user first so it isn't double-counted.
422 if (op.deactivateUserId && op.deactivateUserId !== token.userId) {
423 const dKey = `${prefix}user:${op.deactivateUserId}`
424 const dEntry = await b.get(dKey)
425 if (dEntry) {
426 const u = dEntry.value
427 u.identities = (u.identities || []).filter(id => id !== op.newIdentityId)
428 u.deactivatedAt = at
429 u.deactivatedReason = 'merged-via-link-token'
430 u.updatedAt = at
431 await b.put(dKey, u)
432 }
433 }
434 break
435 }
436
437 case OpTypes.SET_USERNAME: {
438 const key = `${prefix}user:${op.userId}`
439 const existing = await b.get(key)
440 if (!existing) continue
441
442 const user = existing.value
443
444 // Remove old username index
445 if (user.username) {
446 await b.del(`${prefix}username:${user.username}`)
447 }
448
449 // Set new username
450 user.username = op.username
451 user.updatedAt = at
452 await b.put(key, user)
453 await b.put(`${prefix}username:${op.username}`, { userId: op.userId })
454 break
455 }
456
457 case OpTypes.ADD_WRITER: {
458 // Audit trail in the view
459 await b.put(`${prefix}writer:${op.writerKey}`, {
460 addedAt: at,
461 addedBy: op.addedBy || null,
462 role: op.role || 'writer'
463 })
464 // Propagate to the Autobase host so the new writer's core
465 // participates in linearization.
466 if (base && typeof base.addWriter === 'function') {
467 try {
468 await base.addWriter(b4a.from(op.writerKey, 'hex'), {
469 isIndexer: op.role === 'indexer'
470 })
471 } catch (err) {
472 console.error('[IdentityApply] base.addWriter failed:', err.message)
473 }
474 }
475 break
476 }
477
478 case OpTypes.REMOVE_WRITER: {
479 await b.del(`${prefix}writer:${op.writerKey}`)
480 if (base && typeof base.removeWriter === 'function') {
481 try {
482 await base.removeWriter(b4a.from(op.writerKey, 'hex'))
483 } catch (err) {
484 console.error('[IdentityApply] base.removeWriter failed:', err.message)
485 }
486 }
487 break
488 }
489
490 case OpTypes.REGISTER_MIRROR_WRITERS: {
491 // View-only record. The enclosing app watches this key
492 // range and issues in-band addWriter ops on the matching
493 // OTHER autobases it hosts. Apply must stay deterministic:
494 // no side effects on other autobases from here.
495 await b.put(`${prefix}mirror-writers:${op.devicePubkey}`, {
496 keys: op.keys,
497 recordedAt: at
498 })
499 break
500 }
501
502 case OpTypes.REGISTER_IDENTITY: {
503 // Full identity record into the view (replicated to mirrors).
504 // Replaces the previous direct-write to a per-daemon
505 // Hyperbee (`db`), which was local-only. Mirror peers now
506 // see the full record + reverse indexes via standard
507 // autobase replication. See pear-git-v3/test/
508 // identity-record-replication.test.js for the proof.
509 const identity = op.identity
510 if (!identity || !identity.identityId) break
511 await b.put(`${prefix}identity:${identity.identityId}`, identity)
512 if (identity.publicKey) {
513 await b.put(`${prefix}pubkey:${identity.publicKey}`, { identityId: identity.identityId })
514 }
515 if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
516 await b.put(`${prefix}sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
517 }
518 if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
519 await b.put(`${prefix}noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
520 }
521 if (identity.method === 'atproto' && identity.credentials) {
522 if (identity.credentials.did) {
523 await b.put(`${prefix}atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
524 }
525 if (identity.credentials.handle) {
526 await b.put(`${prefix}atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
527 }
528 }
529 break
530 }
531 }
532 } catch (err) {
533 console.error('[IdentityApply] Skipping malformed op:', err.message)
534 continue
535 }
536 }
537
538 await b.flush()
539 } catch (err) {
540 console.error('[IdentityApply] Batch error:', err.message)
541 await b.close()
542 throw err
543 }
544 }
545}
546
547/**
548 * Check if a writer has permission to execute an operation.
549 *
550 * Resolves the writer's key to a user by scanning all users' identities,
551 * then checks if that user has the required permission level.
552 *
553 * @param {Object} b - Hyperbee batch
554 * @param {string} prefix - Key prefix
555 * @param {string} writerKey - Writer's Hypercore public key (hex)
556 * @param {Object} op - The operation being executed
557 * @returns {string|null} Violation reason, or null if authorized
558 */
559async function checkContract (b, prefix, writerKey, op) {
560 // CREATE_USER is always allowed (autoRegister handles first-user-admin)
561 if (op.type === OpTypes.CREATE_USER) return null
562
563 // Find the user associated with this writer key
564 const writerUser = await resolveWriterUser(b, prefix, writerKey)
565
566 // If no user found for this writer, check if ANY users exist yet.
567 // During bootstrap (empty registry), allow all ops from the first writer.
568 if (!writerUser) {
569 const hasUsers = await hasAnyUser(b, prefix)
570 if (!hasUsers) return null // Bootstrap: empty registry, first writer is trusted
571 return `Writer ${writerKey.slice(0, 16)} has no associated user`
572 }
573
574 const isAdmin = writerUser.globalPermissions && writerUser.globalPermissions.includes('admin')
575
576 // Admin-only operations
577 if (ADMIN_OPS.has(op.type)) {
578 if (!isAdmin) return `${op.type} requires admin permissions`
579 return null
580 }
581
582 // Self-or-admin operations
583 if (SELF_OR_ADMIN_OPS.has(op.type)) {
584 if (isAdmin) return null
585 if (op.userId === writerUser.userId) return null
586 return `${op.type} requires admin or self (writer is ${writerUser.userId}, op targets ${op.userId})`
587 }
588
589 return null // Unknown op types pass through (validated by validateOp)
590}
591
592/**
593 * Find the user associated with a writer's Hypercore key.
594 * Looks up the writer key as a pubkey identity, then resolves to user.
595 */
596async function resolveWriterUser (b, prefix, writerKey) {
597 // Check pubkey reverse lookup (writer key may be registered as an identity)
598 const pubkeyEntry = await b.get(`${prefix}pubkey:${writerKey}`)
599 if (pubkeyEntry) {
600 const identityLink = await b.get(`${prefix}user-identity:${pubkeyEntry.value.identityId}`)
601 if (identityLink) {
602 const userEntry = await b.get(`${prefix}user:${identityLink.value.userId}`)
603 if (userEntry) return userEntry.value
604 }
605 }
606
607 // Scan all users to find one with this writer key in their identities
608 // (fallback for when the key isn't indexed as a pubkey)
609 for await (const entry of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff` })) {
610 const user = entry.value
611 if (user.identities && user.identities.includes(writerKey)) {
612 return user
613 }
614 }
615
616 return null
617}
618
619async function hasAnyUser (b, prefix) {
620 for await (const _ of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff`, limit: 1 })) {
621 return true
622 }
623 return false
624}
lib/operations.js +196 -0
……@@ -0,0 +1,196 @@
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}
package.json +22 -0
……@@ -0,0 +1,22 @@
1{
2 "name": "hyper-identity-core",
3 "version": "0.1.0",
4 "description": "The engine-independent half of the identity layer — ops, and the apply that linearizes them. No autobase, no autobee, no view.",
5 "type": "module",
6 "main": "index.js",
7 "exports": {
8 ".": "./index.js",
9 "./apply": "./lib/apply.js",
10 "./operations": "./lib/operations.js"
11 },
12 "scripts": {
13 "test": "brittle test/*.test.js",
14 "test:bare": "bare ./node_modules/.bin/brittle test/*.test.js"
15 },
16 "dependencies": {
17 "b4a": "^1.6.4"
18 },
19 "devDependencies": {
20 "brittle": "^4.0.2"
21 }
22}