OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / e41ea44

identity: run the shared contract instead of a private copy
engine-parity.test.js becomes contract.test.js. The contract comes from hyper-identity-core; hyper-identity2 runs the same function against autobee, so a divergence between the engines now shows up as a failing assertion rather than as two suites that never compared. This arm stays the CONTROL — it exercises the shipping path, so a failure localises to the new binding rather than to the contract. 122/122, 343/343. Green for the first time since the harness landed: the remaining red was [autobee], which is now a package with a suite of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e41ea4404eef09ff5419f16d870e693290f627c8
by plankatron <fleeky@gmail.com> · 2026-09-06T21:10:22.000Z

Changed files (2) +73 -202

test/contract.test.js +73 -0
……@@ -0,0 +1,73 @@
1// hyper-identity (autobase) must satisfy the shared contract, assertion for assertion.
2//
3// The contract lives in hyper-identity-core and is imported, never restated. hyper-identity2 runs
4// the SAME function against autobee — that is what makes "both engines agree" a fact rather than
5// two suites agreeing with themselves.
6//
7// This arm is also the CONTROL: it exercises the shipping path, so a divergence localises to the
8// new binding rather than to the contract.
9
10import test from 'brittle'
11import Corestore from 'corestore'
12import Hyperbee from 'hyperbee'
13import path from '#node:path'
14import os from '#node:os'
15import crypto from 'hypercore-crypto'
16import { contract } from 'hyper-identity-core/test/contract.js'
17import { IdentityRegistry } from '../lib/auth/identity-registry.js'
18import { adaptHost } from '../lib/auth/adapt-host.js'
19
20async function open () {
21 const dir = path.join(os.tmpdir(), 'hi-contract-' + crypto.randomBytes(8).toString('hex'))
22 const store = new Corestore(dir)
23 await store.ready()
24 const db = new Hyperbee(store.get({ name: 'legacy-db' }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
25 await db.ready()
26 const registry = new IdentityRegistry(store, { db })
27 await registry.ready()
28 return { registry, cleanup: async () => { await registry.close(); await db.close(); await store.close() } }
29}
30
31test('[autobase] the IdentityRegistry contract', async (t) => {
32 const { registry, cleanup } = await open()
33 t.teardown(cleanup)
34 // ⛔ The package IS the engine. This cannot be an option that lies about what was constructed —
35 // an earlier version of this suite had exactly that bug and compared autobase to autobase.
36 t.is(registry.engine, 'autobase', 'the package reports what it is')
37 await contract(t, registry)
38})
39
40// ⛔ The API break that fails SILENTLY, so it gets its own test.
41//
42// The core speaks `{ isIndexer }`. autobase spells it `{ indexer }`; autobee spells it
43// `{ isIndexer }` and DEFAULTS IT TO TRUE. So an option lost in translation makes a plain writer
44// here and an INDEXER there — the thing identity-registry warns deadlocks a manifest migration.
45//
46// An earlier version of this test asked the ENGINE whether a writer had become an indexer. Its own
47// positive control disproved it: autobase parks a granted indexer in `pendingIndexers` until its
48// core exists and acks, so a fresh key is in neither list and the accessor said "no" to everything,
49// including the case it existed to catch. The defect is a translation; test the translation.
50test('⛔ [autobase] adaptHost translates the core vocabulary, and pins the default', (t) => {
51 const seen = []
52 const host = adaptHost({
53 addWriter: (key, opts) => { seen.push(['add', key, opts]) },
54 removeWriter: (key) => { seen.push(['remove', key]) }
55 })
56
57 host.addWriter('k1', { isIndexer: true })
58 host.addWriter('k2', { isIndexer: false })
59 host.addWriter('k3', {})
60 host.removeWriter('k1')
61
62 t.alike(seen[0], ['add', 'k1', { indexer: true }], 'isIndexer:true becomes autobase indexer:true')
63 t.alike(seen[1], ['add', 'k2', { indexer: false }], 'and false stays false')
64 t.alike(seen[2], ['add', 'k3', { indexer: false }], 'an ABSENT flag is false, never an engine default')
65 t.alike(seen[3], ['remove', 'k1'], 'removeWriter passes through')
66 t.absent('isIndexer' in seen[0][2], 'the core spelling never reaches autobase')
67})
68
69test('adaptHost leaves a host with no writer controls alone', (t) => {
70 const bare = { somethingElse: 1 }
71 t.is(adaptHost(bare), bare, 'nothing to translate, nothing wrapped')
72 t.is(adaptHost(null), null)
73})
test/engine-parity.test.js +0 -202
……@@ -1,202 +0,0 @@
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 // ⚠ Keyed on the identityId, NOT the public key — `user-identity:<identityId>`
100 // (identity-registry.js:214). They coincide in pgv3, where registerIdentity passes the root
101 // pubkey as both, which is exactly why passing the wrong one reads as a library bug.
102 const byIdent = await registry.getUserByIdentity('ident-1')
103 t.is(byIdent && byIdent.userId, alice.userId, 'and the link resolves back to the user')
104
105 // ── a uniqueness conflict ───────────────────────────────────────────────────────────────────
106 //
107 // The conflict path reads the index INSIDE the write, so it exercises read-after-write within one
108 // logical operation — the thing a batching shim is most likely to break.
109 await registry.setUsername(alice.userId, 'ada')
110 const byName = await registry.getUserByUsername('ada')
111 t.is(byName && byName.userId, alice.userId, 'username resolves')
112
113 const bob = await registry.createUser({ name: 'Grace' })
114 await t.exception(registry.setUsername(bob.userId, 'ada'), /taken/i, 'a taken username is refused')
115 t.is((await registry.getUserByUsername('ada')).userId, alice.userId, 'and the incumbent is untouched')
116
117 // ── the profile — the reason favela-peer wants this layer at all ────────────────────────────
118 await registry.updateUserProfile(alice.userId, { name: 'Ada L.', profilePicture: 'a'.repeat(64) })
119 const profiled = await registry.getUser(alice.userId)
120 t.is(profiled.name, 'Ada L.', 'the display name updates')
121 t.is(profiled.profilePicture, 'a'.repeat(64), 'and the picture is stored verbatim — an overlay oid')
122
123 // ── a range scan ────────────────────────────────────────────────────────────────────────────
124 //
125 // createReadStream over a prefix. hyperbee2 has no keyEncoding, so a shim must rebuild the range
126 // bounds itself; getting that wrong typically returns everything or nothing rather than erroring.
127 const users = await registry.listUsers()
128 const ids = users.map((u) => u.userId).sort()
129 t.is(ids.length, 2, 'the scan finds exactly the users written')
130 t.alike(ids, [alice.userId, bob.userId].sort(), 'and finds the right ones')
131
132 // ── a tombstone ─────────────────────────────────────────────────────────────────────────────
133 t.absent(await registry.isRevoked('pk-ada'), 'not revoked to begin with')
134 await registry.revokeIdentity('pk-ada')
135 t.ok(await registry.isRevoked('pk-ada'), 'revocation reads through')
136}
137
138// An engine that cannot be constructed must FAIL, not abort.
139//
140// A throw out of `open()` kills the whole file, so every test after it silently never runs — the
141// report says nothing about them, which reads like coverage that does not exist. This turns an
142// unbuildable engine into one red line and lets the rest of the suite report.
143async function openOrFail (t, engine) {
144 try {
145 return await open(engine)
146 } catch (err) {
147 t.fail('cannot open the ' + engine + ' engine: ' + err.message)
148 return null
149 }
150}
151
152for (const engine of ENGINES) {
153 test('[' + engine + '] the IdentityRegistry contract', async (t) => {
154 const opened = await openOrFail(t, engine)
155 if (!opened) return
156 t.teardown(opened.cleanup)
157 await contract(t, opened.registry)
158 })
159}
160
161// ⛔ The one API break that fails SILENTLY, so it gets its own test.
162//
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.
168//
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.
172
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]) }
180 })
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})