OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 5fa6e1c

Add REGISTER_MIRROR_WRITERS op for multi-autobase coordination
New op type + apply + API method. Multi-writer apps that host OTHER autobases alongside IdentityRegistry (e.g. pear-git-v3 with orgs/issue trackers) use this as a control-plane: mirror (writer on IdentityRegistry) appends { type: 'register-mirror-writers', devicePubkey, keys: { ns: hex, ... } } → view records at `mirror-writers:<devicePubkey>` → enclosing app WATCHES that key range and issues canonical in-band addWriter ops on the matching autobases per namespace Apply stays deterministic (view-only; no side-effects on other bases). Public API: await registry.registerMirrorWriters(devicePubkey, keys) await registry.getMirrorWriters(devicePubkey) -> entry | null for await (const e of registry.listMirrorWriters()) ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5fa6e1cedc453c0a8f28b7022515586abe7f8eff
by plankatron <noreply@anthropic.com> · 2026-04-20T21:54:14.000Z

Changed files (3) +191 -7

lib/auth/identity-apply.js +45 -0
……@@ -394,6 +394,51 @@ export function createApplyFunction (opts = {}) {
394394 await b.put(`${prefix}username:${op.username}`, { userId: op.userId })
395395 break
396396 }
397
398 case OpTypes.ADD_WRITER: {
399 // Audit trail in the view
400 await b.put(`${prefix}writer:${op.writerKey}`, {
401 addedAt: Date.now(),
402 addedBy: op.addedBy || null,
403 role: op.role || 'writer'
404 })
405 // Propagate to the Autobase host so the new writer's core
406 // participates in linearization.
407 if (base && typeof base.addWriter === 'function') {
408 try {
409 await base.addWriter(b4a.from(op.writerKey, 'hex'), {
410 indexer: op.role === 'indexer'
411 })
412 } catch (err) {
413 console.error('[IdentityApply] base.addWriter failed:', err.message)
414 }
415 }
416 break
417 }
418
419 case OpTypes.REMOVE_WRITER: {
420 await b.del(`${prefix}writer:${op.writerKey}`)
421 if (base && typeof base.removeWriter === 'function') {
422 try {
423 await base.removeWriter(b4a.from(op.writerKey, 'hex'))
424 } catch (err) {
425 console.error('[IdentityApply] base.removeWriter failed:', err.message)
426 }
427 }
428 break
429 }
430
431 case OpTypes.REGISTER_MIRROR_WRITERS: {
432 // View-only record. The enclosing app watches this key
433 // range and issues in-band addWriter ops on the matching
434 // OTHER autobases it hosts. Apply must stay deterministic:
435 // no side effects on other autobases from here.
436 await b.put(`${prefix}mirror-writers:${op.devicePubkey}`, {
437 keys: op.keys,
438 recordedAt: Date.now()
439 })
440 break
441 }
397442 }
398443 } catch (err) {
399444 console.error('[IdentityApply] Skipping malformed op:', err.message)
lib/auth/identity-operations.js +38 -1
……@@ -21,7 +21,23 @@ export const OpTypes = {
2121 // user; CONSUME validates expiry + single-use, attaches a new identity
2222 // to the target user, and optionally deactivates an emptied throwaway.
2323 CREATE_LINK_TOKEN: 'create-link-token',
24 CONSUME_LINK_TOKEN: 'consume-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'
2541}
2642
2743/**
……@@ -115,6 +131,27 @@ export function validateOp (op) {
115131 if (!op.newIdentityId) return { valid: false, error: 'Missing newIdentityId' }
116132 return { valid: true }
117133
134 case OpTypes.ADD_WRITER:
135 case OpTypes.REMOVE_WRITER:
136 if (!op.writerKey || typeof op.writerKey !== 'string' || op.writerKey.length !== 64) {
137 return { valid: false, error: 'writerKey must be 64-char hex' }
138 }
139 return { valid: true }
140
141 case OpTypes.REGISTER_MIRROR_WRITERS:
142 if (!op.devicePubkey || typeof op.devicePubkey !== 'string' || op.devicePubkey.length !== 64) {
143 return { valid: false, error: 'devicePubkey must be 64-char hex' }
144 }
145 if (!op.keys || typeof op.keys !== 'object') {
146 return { valid: false, error: 'keys must be a namespace→hex map' }
147 }
148 for (const [ns, key] of Object.entries(op.keys)) {
149 if (typeof key !== 'string' || key.length !== 64) {
150 return { valid: false, error: `keys[${ns}] must be 64-char hex` }
151 }
152 }
153 return { valid: true }
154
118155 default:
119156 return { valid: false, error: `Unknown op type: ${op.type}` }
120157 }
lib/auth/identity-registry.js +108 -6
……@@ -31,6 +31,20 @@ export class IdentityRegistry {
3131 // Separate Hyperbee for direct key lookups (identity records, pubkey index)
3232 // This is NOT the Autobase view — it's a direct-write store for identity metadata
3333 this.db = opts.db || null
34
35 // Mirror support: when bootstrapKey is passed, join the primary's
36 // Autobase instead of creating a fresh one.
37 this._bootstrapKey = opts.bootstrapKey || null
38
39 // keyPair: when provided, the Autobase writer's local core uses
40 // this Ed25519 keypair instead of a corestore-derived one. This
41 // lets the caller pin the autobase ROOT to something stable
42 // (e.g. the hyperident device keypair) so the root is deterministic
43 // across machines and doesn't need to be shipped as a separate
44 // bootstrap key via bundles. The primary's autobase root ends up
45 // equal to keyPair.publicKey; mirrors pass that same publicKey as
46 // bootstrapKey to join.
47 this._keyPair = opts.keyPair || null
3448 }
3549
3650 /**
……@@ -41,7 +55,13 @@ export class IdentityRegistry {
4155
4256 const apply = createApplyFunction({ prefix: this._prefix })
4357
44 this._base = new Autobase(this._store, null, {
58 const rootKey = this._bootstrapKey
59 ? (typeof this._bootstrapKey === 'string'
60 ? Buffer.from(this._bootstrapKey, 'hex')
61 : this._bootstrapKey)
62 : null
63
64 this._base = new Autobase(this._store, rootKey, {
4565 apply,
4666 open (store) {
4767 return new Hyperbee(store.get('identity-view'), {
……@@ -50,7 +70,8 @@ export class IdentityRegistry {
5070 valueEncoding: 'json'
5171 })
5272 },
53 valueEncoding: 'binary'
73 valueEncoding: 'binary',
74 keyPair: this._keyPair || undefined
5475 })
5576
5677 await this._base.ready()
……@@ -856,11 +877,92 @@ export class IdentityRegistry {
856877 // ==================== FEDERATION & LIFECYCLE ====================
857878
858879 /**
859 * Add Autobase writer key
880 * Add an Autobase writer via the op pipeline. Appends an ADD_WRITER
881 * op that (a) records the writer in the view for audit, and (b) is
882 * picked up by identity-apply.js to call `base.addWriter` on the
883 * Autobase host. Going through the op pipeline (instead of calling
884 * `_base.addWriter` directly) ensures the writer registration
885 * linearizes across replicas and is visible in the shared view.
886 *
887 * @param {string|Buffer} key - writer's local core key (hex or buffer)
888 * @param {object} [opts]
889 * @param {string} [opts.role='writer'] - 'writer' | 'indexer'
890 * @param {string} [opts.addedBy] - who added this writer (audit)
891 */
892 async addWriter (key, opts = {}) {
893 const writerKey = typeof key === 'string'
894 ? key
895 : b4a.toString(key, 'hex')
896 if (!writerKey || writerKey.length !== 64) {
897 throw new Error('addWriter requires a 32-byte (64-char hex) writer key')
898 }
899 const op = {
900 type: OpTypes.ADD_WRITER,
901 writerKey,
902 role: opts.role || 'writer',
903 addedBy: opts.addedBy || null
904 }
905 await this._base.append(encodeOp(op))
906 }
907
908 /**
909 * Announce the local-writer keys a linked device has on OTHER
910 * autobases in the enclosing app. Recorded in the view under
911 * `mirror-writers:<devicePubkey>`. The enclosing app is responsible
912 * for watching that key range and issuing canonical in-band
913 * addWriter ops on the matching autobases.
914 *
915 * @param {string} devicePubkey — this device's hyperident pubkey (hex)
916 * @param {Object<string,string>} keys — { namespace: localKeyHex, ... }
917 */
918 async registerMirrorWriters (devicePubkey, keys) {
919 if (!devicePubkey || typeof devicePubkey !== 'string' || devicePubkey.length !== 64) {
920 throw new Error('registerMirrorWriters requires 64-char hex devicePubkey')
921 }
922 if (!keys || typeof keys !== 'object') {
923 throw new Error('registerMirrorWriters requires a namespace→hex map')
924 }
925 const op = {
926 type: OpTypes.REGISTER_MIRROR_WRITERS,
927 devicePubkey,
928 keys
929 }
930 await this._base.append(encodeOp(op))
931 }
932
933 /**
934 * Read the most recent mirror-writer announcement for a device.
935 */
936 async getMirrorWriters (devicePubkey) {
937 const entry = await this._view.get(`${this._prefix}mirror-writers:${devicePubkey}`)
938 return entry ? entry.value : null
939 }
940
941 /**
942 * Iterate all mirror-writer announcements (for watcher wiring).
860943 */
861 async addWriter (key) {
862 const keyBuf = typeof key === 'string' ? b4a.from(key, 'hex') : key
863 await this._base.addWriter(keyBuf)
944 async * listMirrorWriters () {
945 for await (const entry of this._view.createReadStream({
946 gte: `${this._prefix}mirror-writers:`,
947 lt: `${this._prefix}mirror-writers:\xff`
948 })) {
949 const devicePubkey = entry.key.slice((this._prefix + 'mirror-writers:').length)
950 yield { devicePubkey, ...entry.value }
951 }
952 }
953
954 /**
955 * Remove an Autobase writer via the op pipeline. Symmetric with addWriter.
956 */
957 async removeWriter (key) {
958 const writerKey = typeof key === 'string'
959 ? key
960 : b4a.toString(key, 'hex')
961 const op = {
962 type: OpTypes.REMOVE_WRITER,
963 writerKey
964 }
965 await this._base.append(encodeOp(op))
864966 }
865967
866968 /**