| … | … | @@ -45,6 +45,21 @@ export class IdentityRegistry { |
| 45 | 45 | // equal to keyPair.publicKey; mirrors pass that same publicKey as |
| 46 | 46 | // bootstrapKey to join. |
| 47 | 47 | 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 | |
| 48 | 63 | } |
| 49 | 64 | |
| 50 | 65 | /** |
| … | … | @@ -82,6 +97,15 @@ export class IdentityRegistry { |
| 82 | 97 | }, |
| 83 | 98 | valueEncoding: 'binary', |
| 84 | 99 | 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, | |
| 85 | 109 | optimistic: true, |
| 86 | 110 | ackInterval: 1000, |
| 87 | 111 | ackThreshold: 4 |
| … | … | @@ -132,10 +156,28 @@ export class IdentityRegistry { |
| 132 | 156 | } |
| 133 | 157 | await this._base.append(encodeOp(op)) |
| 134 | 158 | 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). | |
| 137 | 173 | 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 | |
| 139 | 181 | } |
| 140 | 182 | |
| 141 | 183 | /** |
| … | … | @@ -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 | ||
| 19 | import test from 'brittle' | |
| 20 | import Corestore from 'corestore' | |
| 21 | import Hyperbee from 'hyperbee' | |
| 22 | import path from '#node:path' | |
| 23 | import os from '#node:os' | |
| 24 | import crypto from 'hypercore-crypto' | |
| 25 | import { IdentityRegistry } from '../lib/auth/identity-registry.js' | |
| 26 | ||
| 27 | async 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 | ||
| 43 | test('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 | ||
| 57 | test('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 | ||
| 74 | test('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 | }) |