OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / cc7d635

identity: never report success for a write that did not land; accept fastForwardTo
createUser returned `user || op` when the view read-back failed, handing the caller the in-memory op — whose userId is minted locally moments earlier — as if it were persisted. On a peer that cannot linearize (admitted and `writable`, but with an empty indexer set nothing is ever confirmed) every request re-ran TOFU, produced a fresh phantom userId, and the profile page read "Not authenticated yet" forever with nothing logged. It now throws EWRITE_NOT_PERSISTED. This is the rule every durable autobase app follows: if the write will not commit, throw — never report success. Also accept `fastForwardTo` (the origin's `_system` core key) and pass it to Autobase as `{ key }`. Keet ships exactly this in every room invitation (keet-core/index.js:1991 `fastForwardTo: base.getSystemKey()`), so a joiner adopts the origin's system state instead of deriving its own. Passing `undefined` rather than `false` when unset matters: `fastForward: false` DISABLES fast-forward (autobase index.js:125). Both additive — older callers and existing ops are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cc7d635c76fa6cb1991bf6bb1500e7ac28154ad3
by plankatron <fleeky@gmail.com> · 2026-08-09T21:28:33.000Z

Changed files (2) +134 -3

lib/auth/identity-registry.js +45 -3
……@@ -45,6 +45,21 @@ export class IdentityRegistry {
4545 // equal to keyPair.publicKey; mirrors pass that same publicKey as
4646 // bootstrapKey to join.
4747 this._keyPair = opts.keyPair || null
48
49 // fastForwardTo: the ORIGIN's `_system` core key (hex or buffer), obtained
50 // via `base.getSystemKey()` on the origin and shipped in the deployment
51 // bundle. A joining peer fast-forwards straight to that system core instead
52 // of deriving its own.
53 //
54 // This is what keet does on every join: the invitation carries
55 // `fastForwardTo: base.getSystemKey()` (keet-core/index.js:1991,1999) and
56 // the joiner passes `{ key }` into Autobase (tasks/pairing/keys.js:46 →
57 // index.js:1643). Without it a joiner must derive its own system state, and
58 // a peer that ends up with an empty indexer set derives a DIFFERENT
59 // `_system` core (its manifest is a function of the indexer set,
60 // autobase/lib/store.js:541-578) — after which the origin's signed
61 // checkpoints are for a core it does not hold, and it can never converge.
62 this._fastForwardTo = opts.fastForwardTo || null
4863 }
4964
5065 /**
……@@ -82,6 +97,15 @@ export class IdentityRegistry {
8297 },
8398 valueEncoding: 'binary',
8499 keyPair: this._keyPair || undefined,
100 // `{ key }` shape per autobase index.js:210-214. Omitted entirely when
101 // unset — passing `fastForward: false` would DISABLE fast-forward
102 // (index.js:125 `fastForwardEnabled = handlers.fastForward !== false`),
103 // which is the opposite of what we want for a peer with no target.
104 fastForward: this._fastForwardTo
105 ? { key: typeof this._fastForwardTo === 'string'
106 ? b4a.from(this._fastForwardTo, 'hex')
107 : this._fastForwardTo }
108 : undefined,
85109 optimistic: true,
86110 ackInterval: 1000,
87111 ackThreshold: 4
……@@ -132,10 +156,28 @@ export class IdentityRegistry {
132156 }
133157 await this._base.append(encodeOp(op))
134158 await this._base.update()
135 // Read back from view — the apply function may have modified permissions
136 // (e.g., first user gets admin promotion)
159 // Read back from the view — apply may have modified the record (e.g. the
160 // first user is promoted to admin).
161 //
162 // The read-back is ALSO the proof that the write landed. `append()` resolves
163 // on a peer that can never linearize (admitted and `writable`, but with an
164 // empty indexer set nothing is ever confirmed), and the record simply never
165 // appears. The previous `return user || op` handed back the in-memory op,
166 // whose userId was minted locally a few lines above — indistinguishable from
167 // a persisted user. That turned a broken peer into SILENT DATA LOSS: every
168 // request re-ran TOFU, produced a fresh phantom userId, and the profile page
169 // read "Not authenticated yet" forever with no error logged anywhere.
170 //
171 // Never report success for a write that will not commit — the rule every
172 // durable autobase app follows (autobase skill, admission-patterns.md).
137173 const user = await this.getUser(userId)
138 return user || op // Fallback to op if view read fails
174 if (!user) {
175 const err = new Error('createUser: the write did not land — refusing to report success ' +
176 'for a user that was not persisted (userId=' + userId + ')')
177 err.code = 'EWRITE_NOT_PERSISTED'
178 throw err
179 }
180 return user
139181 }
140182
141183 /**
test/create-user-no-fabrication.test.js +89 -0
……@@ -0,0 +1,89 @@
1// createUser must never report success for a write that did not land.
2//
3// Current behaviour (identity-registry.js:133-138):
4// await this._base.append(encodeOp(op))
5// await this._base.update()
6// const user = await this.getUser(userId)
7// return user || op // <-- fabricates a user from the in-memory op
8//
9// `op` carries a userId minted locally at :111, so the caller receives an
10// object indistinguishable from a persisted user. In production this turned a
11// peer that could not linearize into a silent data-loss bug: every request
12// re-ran TOFU, got a fresh fabricated userId, and the profile page rendered
13// "Not authenticated yet" forever — with no error anywhere.
14//
15// The rule this restores is the one every durable autobase app follows
16// (autobase skill, admission-patterns.md): "if admission can't be reached,
17// THROW — never report success on a write that won't commit."
18
19import test from 'brittle'
20import Corestore from 'corestore'
21import Hyperbee from 'hyperbee'
22import path from '#node:path'
23import os from '#node:os'
24import crypto from 'hypercore-crypto'
25import { IdentityRegistry } from '../lib/auth/identity-registry.js'
26
27async function createRegistry () {
28 const dir = path.join(os.tmpdir(), 'hyper-identity-nofab-' + crypto.randomBytes(8).toString('hex'))
29 const store = new Corestore(dir)
30 await store.ready()
31 const db = new Hyperbee(store.get({ name: 'test-db' }), {
32 keyEncoding: 'utf-8', valueEncoding: 'json'
33 })
34 await db.ready()
35 const registry = new IdentityRegistry(store, { db })
36 await registry.ready()
37 return {
38 registry,
39 cleanup: async () => { await registry.close(); await db.close(); await store.close() }
40 }
41}
42
43test('createUser returns the persisted user on the happy path', async (t) => {
44 const { registry, cleanup } = await createRegistry()
45
46 const user = await registry.createUser({ name: 'Alice' })
47 t.ok(user && user.userId, 'returns a user with an id')
48 t.is(user.name, 'Alice')
49
50 const readBack = await registry.getUser(user.userId)
51 t.ok(readBack, 'the user is genuinely readable from the view')
52 t.is(readBack.userId, user.userId, 'the returned id matches what persisted')
53
54 await cleanup()
55})
56
57test('createUser THROWS when the write does not land — no fabricated user', async (t) => {
58 const { registry, cleanup } = await createRegistry()
59
60 // Simulate a peer whose appends cannot linearize (stortron's condition:
61 // admitted and "writable", but the linearizer has no indexers so nothing
62 // ever confirms). The append resolves; the record never appears.
63 registry.base.append = async () => {}
64
65 await t.exception(
66 () => registry.createUser({ name: 'ghost' }),
67 /did not land|not persist/i,
68 'createUser throws instead of returning a user that was never written'
69 )
70
71 await cleanup()
72})
73
74test('a failed createUser leaves nothing behind and is not silently retried into existence', async (t) => {
75 const { registry, cleanup } = await createRegistry()
76
77 registry.base.append = async () => {}
78
79 let firstErr = null
80 try { await registry.createUser({ name: 'ghost-1' }) } catch (err) { firstErr = err }
81 t.ok(firstErr, 'first attempt threw')
82
83 // The listing must not contain a phantom. Restore append so listUsers works
84 // against a functioning view.
85 const users = await registry.listUsers()
86 t.absent(users.some(u => u.name === 'ghost-1'), 'no phantom user was persisted')
87
88 await cleanup()
89})