OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 4d251ff

createUser: honour explicit createdAt/updatedAt (reconciler prerequisite)
The identity-publish reconciler re-emits records a node already holds. For keyed last-write-wins convergence to be correct, a replayed CREATE_USER must NOT bump the record's clock — otherwise a re-announce would out-clock a peer's newer edit. createUser now forwards opts.createdAt/opts.updatedAt when provided (the apply already reads op.createdAt/op.updatedAt with a Date.now() fallback), defaulting to now exactly as before. Purely additive: no existing call-site passes these fields, so default behaviour is unchanged. Tests: createUser preserves explicit timestamps; defaults to now without opts. Note: two unrelated failures (linkIdentityToUser "identity record has userId", deleteUserCascade "pubkey index gone") pre-exist on HEAD and are untouched by this change — surfaced for separate follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4d251ff4d965a7592e1e0d94803ed18511a466e6
by plankatron <fleeky@gmail.com> · 2026-06-04T14:42:36.000Z

Changed files (2) +52 -1

lib/auth/identity-registry.js +10 -1
……@@ -109,6 +109,14 @@ export class IdentityRegistry {
109109 */
110110 async createUser (opts = {}) {
111111 const userId = opts.userId || crypto.randomBytes(16).toString('hex')
112 // Timestamp-preserving create: when a caller (e.g. the identity-publish
113 // reconciler re-emitting a record it already holds) passes explicit
114 // createdAt/updatedAt, forward them so the replayed op does NOT bump the
115 // record's clock. The apply honours op.createdAt/op.updatedAt and falls
116 // back to its own Date.now() when absent, so the default path (no opts)
117 // is unchanged. Preserving timestamps is load-bearing for keyed LWW
118 // convergence — a re-announce must never out-clock a peer's newer edit.
119 const now = Date.now()
112120 const op = {
113121 type: OpTypes.CREATE_USER,
114122 userId,
……@@ -119,7 +127,8 @@ export class IdentityRegistry {
119127 globalPermissions: opts.globalPermissions || ['read'],
120128 resources: opts.resources || [{ name: '*', permissions: ['read'] }],
121129 identities: opts.identities || [],
122 createdAt: Date.now()
130 createdAt: opts.createdAt || now,
131 updatedAt: opts.updatedAt || opts.createdAt || now
123132 }
124133 await this._base.append(encodeOp(op))
125134 await this._base.update()
test/identity-registry.test.js +42 -0
……@@ -504,3 +504,45 @@ test('listAllIdentities', async (t) => {
504504
505505 await cleanup()
506506})
507
508// --- Timestamp preservation (identity-publish-reconciler prerequisite) ---
509// The reconciler re-emits records it already holds. createUser must NOT bump
510// the record's clock when explicit timestamps are passed, or a re-announce
511// would out-clock a peer's newer edit and break keyed LWW convergence.
512
513test('createUser preserves explicit createdAt/updatedAt', async (t) => {
514 const { registry, cleanup } = await createRegistry()
515
516 // Bootstrap a first user so the replayed one isn't the first-admin (and to
517 // mirror the real reconcile path where the registry is already populated).
518 await registry.createUser({ name: 'Admin' })
519
520 const createdAt = 1700000000000
521 const updatedAt = 1700000500000
522 const user = await registry.createUser({
523 userId: 'replayed-uid-1',
524 name: 'Replayed',
525 createdAt,
526 updatedAt
527 })
528 t.is(user.createdAt, createdAt, 'createdAt preserved on return')
529 t.is(user.updatedAt, updatedAt, 'updatedAt preserved on return')
530
531 const fetched = await registry.getUser('replayed-uid-1')
532 t.is(fetched.createdAt, createdAt, 'createdAt persisted in view')
533 t.is(fetched.updatedAt, updatedAt, 'updatedAt persisted in view')
534
535 await cleanup()
536})
537
538test('createUser defaults timestamps to now when not provided', async (t) => {
539 const { registry, cleanup } = await createRegistry()
540
541 const before = Date.now()
542 const user = await registry.createUser({ name: 'NowUser' })
543 t.ok(user.createdAt >= before, 'createdAt defaulted to ~now')
544 t.ok(user.updatedAt >= before, 'updatedAt defaulted to ~now')
545 t.ok(typeof user.updatedAt === 'number', 'updatedAt is set even without opts')
546
547 await cleanup()
548})