OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 3da3499

identity: declare the engine, and a parity harness whose control arm is the gate
Groundwork for running IdentityRegistry on autobee. This commit adds no autobee code — it adds the seam and the instrument that will judge it. `new IdentityRegistry(store, { engine })`, defaulting to 'autobase' so nothing on the fleet changes, and rejecting anything else eagerly rather than falling back — a typo that silently handed you the other engine is exactly the failure this option exists to make impossible. ⛔ The default is not a style choice. A mixed fleet is ONE-WAY and SILENT: a migrated writer keeps reading old peers, but nothing it writes is ever seen by them, with no error on either side. So the engine is opt-in per instance, and changing the default is a flag day, not a version bump. Greenfield consumers pay none of that, which is the whole reason for the option: favela-peer runs autobee itself and cannot pull autobase in beside it. test/engine-parity.test.js runs ONE contract against both engines. The autobase arm is the CONTROL — it exercises today's shipping path, and it must be green before the autobee arm means anything. An assertion that only ever ran against the new engine proves the new engine agrees with itself. Same methodology as the autobee spike on pgh's NameRegistry, and the reason that spike's result was trustworthy. ★ The harness was a FALSE GREEN when first written, and caught before it ran: an `engine` option that does not exist yet is silently ignored, so both arms built an autobase and both passed. It now asserts `registry.engine` is what was asked for, which is why even the control arm was red until this commit. Assertions chosen to cover the whole surface a view shim must cover, since autobee's hyperbee2 has no keyEncoding/valueEncoding and no put/del: a unicode round-trip, a secondary index, a uniqueness conflict that reads its own index mid-write, a range scan, and a tombstone. Behaviour only, never view bytes — see below. ⛔ The trap gets its own test. autobase takes addWriter(key, {indexer}); autobee takes addWriter(key, {isIndexer = true, weight = isIndexer ? 2 : 1}) — verified in autobee 2.1.0 lib/apply-calls.js:33. An unknown `indexer` key is IGNORED and isIndexer DEFAULTS TO TRUE, so a mechanical port silently grants every writer indexer standing — the thing identity-registry.js:83 warns against in its own comment. The view's audit row cannot guard this (it carries only {addedAt, addedBy, role}), so the test calls a normalized `isIndexerWriter` accessor the port still owes. Red on purpose. State: [autobase] contract 17/17 green. [autobee] contract red — engine unbuilt. [autobase] trap red — isIndexerWriter unbuilt. Existing suite 30/30, unaffected. ⚠ Pre-existing and NOT touched: identity-apply.js stamps Date.now() at ~16 sites inside apply, which is replayed per peer, so two peers replaying identical ops write different view bytes. Only 4 sites use the op.X || Date.now() pattern. An untracked test/apply-determinism.test.js catches two instances. That is why this harness asserts behaviour and never bytes — "same bytes" is not an available oracle today, on autobase alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3da3499c4d3cce37e43764005bfe19789ca04876
by plankatron <fleeky@gmail.com> · 2026-09-06T20:39:20.000Z

Changed files (2) +193 -0

lib/auth/identity-registry.js +19 -0
……@@ -28,6 +28,25 @@ export class IdentityRegistry {
2828 this._view = null
2929 this._ready = false
3030
31 // Which multi-writer engine backs this registry.
32 //
33 // ⛔ DEFAULTS TO 'autobase'. Every deployed consumer — pgv3, hypernote, pear-git,
34 // hyper-booking — holds a live log, and a mixed fleet is ONE-WAY and SILENT: a migrated writer
35 // keeps reading old peers, but nothing it writes is ever seen by them, with no error on either
36 // side (measured 2026-09-02). So the engine is opt-IN, per instance, and changing this default
37 // is a fleet flag day, not a version bump.
38 //
39 // 'autobee' is for GREENFIELD consumers, which pay none of that — no deployed writers, nothing
40 // to migrate. It is also the only way a consumer that runs autobee itself (favela-peer) can use
41 // this layer without pulling autobase in beside it.
42 //
43 // Rejected eagerly rather than falling back: a typo that silently gave you the other engine is
44 // exactly the failure this option exists to make impossible to have by accident.
45 this.engine = opts.engine || 'autobase'
46 if (this.engine !== 'autobase' && this.engine !== 'autobee') {
47 throw new Error('IdentityRegistry: unknown engine "' + this.engine + '" — expected autobase or autobee')
48 }
49
3150 // Separate Hyperbee for direct key lookups (identity records, pubkey index)
3251 // This is NOT the Autobase view — it's a direct-write store for identity metadata
3352 this.db = opts.db || null
test/engine-parity.test.js +174 -0
……@@ -0,0 +1,174 @@
1// Engine parity — the SAME contract, run against autobase and against autobee.
2//
3// ── Why this file exists ───────────────────────────────────────────────────────────────────────
4//
5// `IdentityRegistry` is moving from "an autobase" to "a log, engine selectable". The port is
6// near-mechanical (the apply body, every op handler and every read method are engine-independent),
7// which is exactly the kind of change that looks finished while being subtly wrong. So the autobase
8// arm is a CONTROL: it runs today's shipping code path with these same assertions, and it must be
9// green before the autobee arm means anything. An assertion that only ever ran against the new
10// engine proves the new engine agrees with itself.
11//
12// This is the methodology the autobee spike used on pgh's NameRegistry, and it is the reason that
13// spike's result was trustworthy.
14//
15// ── What is asserted, and what deliberately is not ────────────────────────────────────────────
16//
17// BEHAVIOUR, never view bytes. Two things follow from that choice:
18//
19// 1. It is the contract consumers actually depend on. pgv3, hypernote, pear-git and hyper-booking
20// call these methods; none of them reads the view's bytes.
21// 2. ⛔ A byte-for-byte comparison would be invalid TODAY, on autobase alone, because
22// `identity-apply.js` stamps `Date.now()` at ~16 sites inside apply. Apply is replayed per
23// peer, so those bytes already differ between two peers replaying identical ops. That is a
24// real pre-existing defect (test/apply-determinism.test.js catches two instances of it) and it
25// is NOT this file's business — but it does mean "same bytes" is not an available oracle.
26//
27// The assertions below are chosen to exercise the whole surface a view shim has to cover:
28// simple put/get, a secondary index, a uniqueness conflict, a range scan, and a tombstone.
29
30import test from 'brittle'
31import Corestore from 'corestore'
32import Hyperbee from 'hyperbee'
33import path from '#node:path'
34import os from '#node:os'
35import crypto from 'hypercore-crypto'
36import { IdentityRegistry } from '../lib/auth/identity-registry.js'
37
38// Engines under test. autobase FIRST and always — it is the control, and a run where only the
39// autobee arm executed is a run that proved nothing.
40const ENGINES = ['autobase', 'autobee']
41
42async function open (engine) {
43 const dir = path.join(os.tmpdir(), 'hyper-identity-parity-' + crypto.randomBytes(8).toString('hex'))
44 const store = new Corestore(dir)
45 await store.ready()
46 const db = new Hyperbee(store.get({ name: 'legacy-db' }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
47 await db.ready()
48
49 const registry = new IdentityRegistry(store, { db, engine })
50 await registry.ready()
51
52 // ⛔ Without this, the harness is a FALSE GREEN. An `engine` option that does not exist yet is
53 // silently ignored, both arms build an autobase, both pass, and the suite proves that autobase
54 // agrees with autobase. The registry must SAY which engine it constructed.
55 if (registry.engine !== engine) {
56 throw new Error('asked for engine "' + engine + '" and got "' + registry.engine + '"')
57 }
58
59 return {
60 registry,
61 async cleanup () {
62 await registry.close()
63 await db.close()
64 await store.close()
65 }
66 }
67}
68
69/**
70 * The contract. Every assertion here must hold identically on both engines.
71 *
72 * Kept as one function rather than one test per behaviour so the two arms cannot drift: adding an
73 * assertion for one engine adds it for the other, structurally.
74 */
75async function contract (t, registry) {
76 // ── a record round-trips through the view's encoding ────────────────────────────────────────
77 //
78 // The name carries non-ASCII and the picture is a long opaque string, because the autobee view is
79 // raw Buffers where the autobase view had keyEncoding:'utf-8'/valueEncoding:'json'. A shim that
80 // got the encoding subtly wrong passes for 'Alice' and fails here.
81 const alice = await registry.createUser({ name: 'Ada Lovelace — éè你好' })
82 t.ok(alice.userId, 'createUser returns a userId')
83 t.is(alice.name, 'Ada Lovelace — éè你好', 'unicode survives the view encoding')
84 t.ok(alice.globalPermissions.includes('admin'), 'the first user is promoted to admin, in apply')
85
86 const fetched = await registry.getUser(alice.userId)
87 t.is(fetched.userId, alice.userId, 'and reads back by key')
88 t.is(fetched.name, alice.name)
89
90 // ── a secondary index ───────────────────────────────────────────────────────────────────────
91 await registry.registerIdentity({
92 identityId: 'ident-1', method: 'web-identity', publicKey: 'pk-ada'
93 })
94 const ident = await registry.getIdentityByPublicKey('pk-ada')
95 t.ok(ident, 'an identity resolves by public key, not just by id')
96 t.is(ident.method, 'web-identity')
97
98 await registry.linkIdentityToUser('ident-1', alice.userId, { verified: true })
99 const byIdent = await registry.getUserByIdentity('pk-ada')
100 t.is(byIdent && byIdent.userId, alice.userId, 'and the link resolves back to the user')
101
102 // ── a uniqueness conflict ───────────────────────────────────────────────────────────────────
103 //
104 // The conflict path reads the index INSIDE the write, so it exercises read-after-write within one
105 // logical operation — the thing a batching shim is most likely to break.
106 await registry.setUsername(alice.userId, 'ada')
107 const byName = await registry.getUserByUsername('ada')
108 t.is(byName && byName.userId, alice.userId, 'username resolves')
109
110 const bob = await registry.createUser({ name: 'Grace' })
111 await t.exception(registry.setUsername(bob.userId, 'ada'), /taken/i, 'a taken username is refused')
112 t.is((await registry.getUserByUsername('ada')).userId, alice.userId, 'and the incumbent is untouched')
113
114 // ── the profile — the reason favela-peer wants this layer at all ────────────────────────────
115 await registry.updateUserProfile(alice.userId, { name: 'Ada L.', profilePicture: 'a'.repeat(64) })
116 const profiled = await registry.getUser(alice.userId)
117 t.is(profiled.name, 'Ada L.', 'the display name updates')
118 t.is(profiled.profilePicture, 'a'.repeat(64), 'and the picture is stored verbatim — an overlay oid')
119
120 // ── a range scan ────────────────────────────────────────────────────────────────────────────
121 //
122 // createReadStream over a prefix. hyperbee2 has no keyEncoding, so a shim must rebuild the range
123 // bounds itself; getting that wrong typically returns everything or nothing rather than erroring.
124 const users = await registry.listUsers()
125 const ids = users.map((u) => u.userId).sort()
126 t.is(ids.length, 2, 'the scan finds exactly the users written')
127 t.alike(ids, [alice.userId, bob.userId].sort(), 'and finds the right ones')
128
129 // ── a tombstone ─────────────────────────────────────────────────────────────────────────────
130 t.absent(await registry.isRevoked('pk-ada'), 'not revoked to begin with')
131 await registry.revokeIdentity('pk-ada')
132 t.ok(await registry.isRevoked('pk-ada'), 'revocation reads through')
133}
134
135for (const engine of ENGINES) {
136 test('[' + engine + '] the IdentityRegistry contract', async (t) => {
137 const { registry, cleanup } = await open(engine)
138 t.teardown(cleanup)
139 await contract(t, registry)
140 })
141}
142
143// ⛔ The one API break that fails SILENTLY, so it gets its own test.
144//
145// autobase takes `addWriter(key, { indexer })`. autobee takes
146// `addWriter(key, { isIndexer = true, weight = isIndexer ? 2 : 1 })` — verified in autobee 2.1.0
147// `lib/apply-calls.js:33`. An unknown `indexer` key is IGNORED and `isIndexer` DEFAULTS TO TRUE, so
148// a mechanical port silently grants every writer indexer standing.
149//
150// That is not a hypothetical: identity-registry.js:83 warns in its own comment against making every
151// device an indexer, "which deadlocks during manifest migration when going from 2→3 indexers".
152//
153// So the default is asserted rather than inherited, on both engines.
154for (const engine of ENGINES) {
155 test('[' + engine + '] a plain writer is NOT granted indexer standing', async (t) => {
156 const { registry, cleanup } = await open(engine)
157 t.teardown(cleanup)
158
159 const key = crypto.keyPair().publicKey.toString('hex')
160 await registry.addWriter(key, { role: 'writer' })
161
162 // The view's audit row is engine-independent — it is written by apply, from the op.
163 const rec = await registry.view.get('writer:' + key)
164 t.ok(rec, 'the writer record landed')
165 t.is((rec.value || rec).role, 'writer', 'and it recorded the role it was given')
166
167 // ⛔ The row is NOT the guard. It carries {addedAt, addedBy, role} and no indexer field, so
168 // asserting on it would pass whatever the engine actually granted. The real question is what
169 // standing the LOG gave the writer, which each engine spells differently — hence a normalized
170 // accessor, which the port owes. Red until it exists, deliberately.
171 t.is(await registry.isIndexerWriter(key), false,
172 'a writer is not an indexer — the default must be pinned, never inherited from the engine')
173 })
174}