OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity-core commits / d9fe44a

core: own the contract, and normalize writer attribution
test/contract.js is the behaviour every engine binding must satisfy, extracted from hyper-identity's suite and imported by both bindings rather than restated. A contract kept inside one binding is that binding's opinion; kept here, "both engines agree" is a fact instead of two suites agreeing with themselves. ⛔ writerKeyOf — the SECOND thing the engines spell differently, and the more dangerous one. autobase attributes an op to `node.from.key`; autobee to `node.key` (favela-peer/lib/feed-base.js marks it as a trap it already hit). The old code read `node.from` unconditionally, so under autobee it is undefined and the contract permission check is skipped ENTIRELY — a gate that silently stops gating. Latent today only because `contractValidation` is opt-in. It would not have stayed latent: it is exactly the check a multi-writer deployment turns on first. Same shape as adaptHost — the core names the question, each binding answers it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d9fe44ad29dc5b0c2cb577e7830a7ae1bf42d0a9
by plankatron <fleeky@gmail.com> · 2026-09-06T21:10:22.000Z

Changed files (3) +111 -3

lib/apply.js +14 -2
……@@ -67,6 +67,17 @@ export function createApplyFunction (opts = {}) {
6767 // a convention every future caller has to remember.
6868 const adaptHost = opts.adaptHost || ((host) => host)
6969
70 // Which writer authored a node — the second thing the two engines spell differently.
71 //
72 // ⛔ autobase attributes on `node.from.key`; autobee on `node.key` (favela-peer/lib/feed-base.js
73 // marks this as a trap it hit). The default below is autobase's shape, so under autobee
74 // `node.from` is UNDEFINED and the contract check below is skipped entirely — a permission gate
75 // that silently stops gating. Latent today only because `contractValidation` is opt-in.
76 //
77 // Attribution must never come from the op body; it comes from whatever the engine vouches for,
78 // which is exactly why each binding names its own accessor.
79 const writerKeyOf = opts.writerKeyOf || ((node) => (node && node.from ? node.from.key : null))
80
7081 return async function apply (batch, view, rawHost) {
7182 const base = adaptHost(rawHost)
7283 const b = view.batch({ update: false })
……@@ -82,8 +93,9 @@ export function createApplyFunction (opts = {}) {
8293 }
8394
8495 // Contract validation: check writer permissions
85 if (enableContract && node.from) {
86 const writerKey = b4a.toString(node.from.key, 'hex')
96 const authorKey = writerKeyOf(node)
97 if (enableContract && authorKey) {
98 const writerKey = b4a.toString(authorKey, 'hex')
8799 const violation = await checkContract(b, prefix, writerKey, op)
88100 if (violation) {
89101 console.error(`[IdentityApply] Contract violation: ${violation} (writer: ${writerKey.slice(0, 16)}..., op: ${op.type})`)
package.json +2 -1
……@@ -8,7 +8,8 @@
88 ".": "./index.js",
99 "./apply": "./lib/apply.js",
1010 "./operations": "./lib/operations.js",
11 "./registry": "./lib/registry.js"
11 "./registry": "./lib/registry.js",
12 "./test/contract.js": "./test/contract.js"
1213 },
1314 "scripts": {
1415 "test": "brittle test/*.test.js",
test/contract.js +95 -0
……@@ -0,0 +1,95 @@
1// THE CONTRACT — what every engine binding must satisfy, identically.
2//
3// It lives in the core because the core owns the behaviour: hyper-identity (autobase) and
4// hyper-identity2 (autobee) share one registry, one apply and one op vocabulary, so there is
5// exactly one right answer and both must give it. A contract kept in one binding would become
6// that binding's opinion.
7//
8// It takes a ready registry and asserts BEHAVIOUR, never view bytes — the two engines store
9// different bytes by construction, and even within autobase alone bytes are not an oracle for ops
10// written before `timestamp` existed.
11//
12// Not a test file: no `test()` calls, so each binding registers it in its own suite.
13
14/**
15 * The contract. Every assertion here must hold identically on both engines.
16 *
17 * Kept as one function rather than one test per behaviour so the two arms cannot drift: adding an
18 * assertion for one engine adds it for the other, structurally.
19 */
20export async function contract (t, registry) {
21 // ── a record round-trips through the view's encoding ────────────────────────────────────────
22 //
23 // The name carries non-ASCII and the picture is a long opaque string, because the autobee view is
24 // raw Buffers where the autobase view had keyEncoding:'utf-8'/valueEncoding:'json'. A shim that
25 // got the encoding subtly wrong passes for 'Alice' and fails here.
26 const alice = await registry.createUser({ name: 'Ada Lovelace — éè你好' })
27 t.ok(alice.userId, 'createUser returns a userId')
28 t.is(alice.name, 'Ada Lovelace — éè你好', 'unicode survives the view encoding')
29 t.ok(alice.globalPermissions.includes('admin'), 'the first user is promoted to admin, in apply')
30
31 const fetched = await registry.getUser(alice.userId)
32 t.is(fetched.userId, alice.userId, 'and reads back by key')
33 t.is(fetched.name, alice.name)
34
35 // ── a secondary index ───────────────────────────────────────────────────────────────────────
36 await registry.registerIdentity({
37 identityId: 'ident-1', method: 'web-identity', publicKey: 'pk-ada'
38 })
39 const ident = await registry.getIdentityByPublicKey('pk-ada')
40 t.ok(ident, 'an identity resolves by public key, not just by id')
41 t.is(ident.method, 'web-identity')
42
43 await registry.linkIdentityToUser('ident-1', alice.userId, { verified: true })
44 // ⚠ Keyed on the identityId, NOT the public key — `user-identity:<identityId>`
45 // (identity-registry.js:214). They coincide in pgv3, where registerIdentity passes the root
46 // pubkey as both, which is exactly why passing the wrong one reads as a library bug.
47 const byIdent = await registry.getUserByIdentity('ident-1')
48 t.is(byIdent && byIdent.userId, alice.userId, 'and the link resolves back to the user')
49
50 // ── a uniqueness conflict ───────────────────────────────────────────────────────────────────
51 //
52 // The conflict path reads the index INSIDE the write, so it exercises read-after-write within one
53 // logical operation — the thing a batching shim is most likely to break.
54 await registry.setUsername(alice.userId, 'ada')
55 const byName = await registry.getUserByUsername('ada')
56 t.is(byName && byName.userId, alice.userId, 'username resolves')
57
58 const bob = await registry.createUser({ name: 'Grace' })
59 await t.exception(registry.setUsername(bob.userId, 'ada'), /taken/i, 'a taken username is refused')
60 t.is((await registry.getUserByUsername('ada')).userId, alice.userId, 'and the incumbent is untouched')
61
62 // ── the profile — the reason favela-peer wants this layer at all ────────────────────────────
63 await registry.updateUserProfile(alice.userId, { name: 'Ada L.', profilePicture: 'a'.repeat(64) })
64 const profiled = await registry.getUser(alice.userId)
65 t.is(profiled.name, 'Ada L.', 'the display name updates')
66 t.is(profiled.profilePicture, 'a'.repeat(64), 'and the picture is stored verbatim — an overlay oid')
67
68 // ── a range scan ────────────────────────────────────────────────────────────────────────────
69 //
70 // createReadStream over a prefix. hyperbee2 has no keyEncoding, so a shim must rebuild the range
71 // bounds itself; getting that wrong typically returns everything or nothing rather than erroring.
72 const users = await registry.listUsers()
73 const ids = users.map((u) => u.userId).sort()
74 t.is(ids.length, 2, 'the scan finds exactly the users written')
75 t.alike(ids, [alice.userId, bob.userId].sort(), 'and finds the right ones')
76
77 // ── a tombstone ─────────────────────────────────────────────────────────────────────────────
78 t.absent(await registry.isRevoked('pk-ada'), 'not revoked to begin with')
79 await registry.revokeIdentity('pk-ada')
80 t.ok(await registry.isRevoked('pk-ada'), 'revocation reads through')
81}
82
83// An engine that cannot be constructed must FAIL, not abort.
84//
85// A throw out of `open()` kills the whole file, so every test after it silently never runs — the
86// report says nothing about them, which reads like coverage that does not exist. This turns an
87// unbuildable engine into one red line and lets the rest of the suite report.
88async function openOrFail (t, engine) {
89 try {
90 return await open(engine)
91 } catch (err) {
92 t.fail('cannot open the ' + engine + ' engine: ' + err.message)
93 return null
94 }
95}