diff --git a/src/core/store.ts b/src/core/store.ts index 338904e..4e54ecb 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -105,6 +105,24 @@ export class Collection { return updated; } + // Like update(), but silent: re-indexes the record without firing onUpdate or bumping + // updated_at. Mirrors production's updateWithSignIn — a raw, debounced DB write that + // stamps last_sign_in_at on sign-in without emitting a user.updated webhook or treating + // the login as a user edit. See https://github.com/workos/emulate/issues/55. + updateSilent(id: string, data: Partial): T | undefined { + const existing = this.items.get(id); + if (!existing) return undefined; + this.removeFromIndex(existing); + const updated = { + ...existing, + ...data, + id, + } as T; + this.items.set(id, updated); + this.addToIndex(updated); + return updated; + } + delete(id: string): boolean { const existing = this.items.get(id); if (!existing) return false; diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 30a465b..f665706 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -975,12 +975,14 @@ describe('Auth routes', () => { await signInWithMagicAuth('quiet@test.com'); const afterFirst = countUpdates(); - // The sign-up login both verifies the email and stamps last_sign_in_at — one write. + // The sign-up login verifies the email (a real attribute change), which emits + // user.updated. last_sign_in_at rides along in the same write. expect(afterFirst).toBe(1); await signInWithMagicAuth('quiet@test.com'); - // The second login only stamps last_sign_in_at; email_verified is already true. - expect(countUpdates()).toBe(2); + // The second login only stamps last_sign_in_at, which production writes silently via + // updateWithSignIn (no user.updated); email_verified is already true. See #55. + expect(countUpdates()).toBe(1); }); it('magic auth sign-up verifies the email and yields an org-less session', async () => { @@ -2294,6 +2296,22 @@ describe('authentication events (spec-named, spec-shaped)', () => { expect(event.data.expires_at).toBeTruthy(); }); + it('sign-in stamps last_sign_in_at without emitting a spurious user.updated', async () => { + // Production's sign-in stamps last_sign_in_at via a dedicated, silent updateWithSignIn + // path — a raw DB write that bypasses the event-emitting update() — so a login fires + // session.created alone, not user.updated. See https://github.com/workos/emulate/issues/55. + await registerUser('evt-no-user-updated@test.com', 'secret'); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-no-user-updated@test.com', password: 'secret' }), + }); + + expect(eventsNamed('session.created')).toHaveLength(1); + expect(eventsNamed('user.updated')).toHaveLength(0); + }); + it('MFA session falls back to auth_method: unknown when the pending token records no mapped primary', async () => { const user = await registerUser('evt-mfa@test.com', 'secret'); const ws = getWorkOSStore(store); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 5455673..4a90d14 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -759,16 +759,24 @@ export function authRoutes(ctx: RouteContext): void { // reuses the existing session, so it emits neither session.created nor an auth event. let session; if (isFreshLogin) { - // A redeemed magic-auth code proves mailbox ownership, so production marks the email - // verified. Folded into the sign-in write rather than done up in the grant: one - // user.updated per login instead of two, and nothing is persisted before the template - // gate above — which is what keeps a failed render from implying a login that never - // completed. Only set when it actually changes, so a repeat sign-in stays quiet. const verifyEmail = authMethod === 'MagicAuth' && !user.email_verified; - ws.users.update(user.id, { - last_sign_in_at: new Date().toISOString(), - ...(verifyEmail ? { email_verified: true } : {}), - }); + if (verifyEmail) { + // A redeemed magic-auth code proves mailbox ownership; production marks the email + // verified via the standard update path, which emits user.updated. Folded into the + // sign-in write so it is one write, one event, and nothing persists before the + // template gate above — which keeps a failed render from implying a login that + // never completed. + ws.users.update(user.id, { + last_sign_in_at: new Date().toISOString(), + email_verified: true, + }); + } else { + // No real attribute change: production stamps last_sign_in_at via a dedicated, + // silent updateWithSignIn path (a raw, debounced DB write) that bypasses the + // event-emitting update(), so a login fires session.created without a spurious + // user.updated. See https://github.com/workos/emulate/issues/55. + ws.users.updateSilent(user.id, { last_sign_in_at: new Date().toISOString() }); + } session = ws.sessions.insert({ object: 'session', user_id: user.id,