From 991586b17e81c13009d9c21d8e74c5393c48830b Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 26 May 2026 15:56:22 -0700 Subject: [PATCH 1/2] feat(identity): add lastActiveAt column for inactivity detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing `lastSeenDate` (DATE, NOT NULL) is only written at user creation and was intended to move on relay (it doesn't). Not useful for the 3-day inactivity check we want to drive off identity. Add a separate `lastActiveAt` (TIMESTAMP WITH TIME ZONE, nullable) that gets touched on every authenticated app open. Indexed for efficient `lastActiveAt < NOW() - INTERVAL '3 days'` queries. - Migration `20260526000000-add-last-active-at.js`: add column + `idx_users_lastActiveAt`. - Model: declare `lastActiveAt` on `Users`. - POST /user (signup): set initial value alongside `lastSeenDate`. - GET /user/email (post-auth startup ping fired by Hedgehog on every app open): fire-and-forget `User.update({ lastActiveAt: now })`. Logged on failure, never blocks the response. `lastSeenDate` writes are unchanged — this is purely additive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../20260526000000-add-last-active-at.js | 33 +++++++++++++++++++ packages/identity-service/src/models/user.js | 8 +++++ packages/identity-service/src/routes/user.js | 20 +++++++++-- 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js diff --git a/packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js b/packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js new file mode 100644 index 00000000000..a1f89d9d35c --- /dev/null +++ b/packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js @@ -0,0 +1,33 @@ +'use strict' + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn( + 'Users', + 'lastActiveAt', + { + type: Sequelize.DATE, + allowNull: true + }, + { transaction } + ) + + await queryInterface.addIndex('Users', ['lastActiveAt'], { + transaction, + name: 'idx_users_lastActiveAt' + }) + }) + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex('Users', 'idx_users_lastActiveAt', { + transaction + }) + await queryInterface.removeColumn('Users', 'lastActiveAt', { + transaction + }) + }) + } +} diff --git a/packages/identity-service/src/models/user.js b/packages/identity-service/src/models/user.js index 0f646f3cd2d..10cff697229 100644 --- a/packages/identity-service/src/models/user.js +++ b/packages/identity-service/src/models/user.js @@ -69,6 +69,14 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.DATE, allowNull: false }, + // Touched on every app open via the authenticated startup ping + // (GET /user/email). Used for inactivity detection — finer-grained + // and lazier than lastSeenDate, which only moves at user creation + // and on relay. + lastActiveAt: { + type: DataTypes.DATE, + allowNull: true + }, isGuest: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/packages/identity-service/src/routes/user.js b/packages/identity-service/src/routes/user.js index 746e38ea8e0..98f24b24bfd 100644 --- a/packages/identity-service/src/routes/user.js +++ b/packages/identity-service/src/routes/user.js @@ -65,11 +65,13 @@ module.exports = function (app) { try { const isDeliverable = await isEmailDeliverable(email, req.logger) + const now = new Date() await models.User.create({ email, // Store non checksummed wallet address walletAddress: body.walletAddress.toLowerCase(), - lastSeenDate: Date.now(), + lastSeenDate: now, + lastActiveAt: now, IP, isEmailDeliverable: isDeliverable, isGuest: body.isGuest @@ -148,19 +150,31 @@ module.exports = function (app) { ) /** - * Retrieve authenticated user's email address + * Retrieve authenticated user's email address. + * + * Doubles as the post-auth startup ping fired on every app open + * (after Hedgehog's GET /authentication restores credentials), so we + * use it as the write site for `lastActiveAt`. The update is + * fire-and-forget — never block the response on it. */ app.get( '/user/email', authMiddleware, handleResponse(async (req, _res, _next) => { - const { blockchainUserId } = req.user + const { id: userRowId, blockchainUserId } = req.user const userData = await models.User.findOne({ where: { blockchainUserId } }) + models.User.update( + { lastActiveAt: new Date() }, + { where: { id: userRowId } } + ).catch((err) => { + req.logger.error({ err }, 'Failed to update lastActiveAt') + }) + return successResponse({ email: userData.email }) From a8c90fc3d870fc5c01543ed997daf203c484e873 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 26 May 2026 16:03:25 -0700 Subject: [PATCH 2/2] fix(identity): move lastActiveAt write to /record_ip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit wrote lastActiveAt inside GET /user/email assuming it was the per-app-open auth ping. It isn't: grepping the clients, /user/email is only hit during OAuth login (web/src/pages/oauth- login-page/*) and the email-change flow (web change-email modal, mobile change-email screen). It does not fire on a returning user's cold start. The real every-app-open auth'd ping is POST /record_ip: - packages/common/src/store/account/sagas.ts:235 calls recordIPIfNotRecent from fetchAccountAsync (dispatched on app startup once we have a session). - recordIPIfNotRecent (sagas.ts:370-392) is throttled client-side to once per 24h per device, which is the right cadence for a 3-day inactivity signal — and gentler on the DB than touching every authenticated request. Move the User.update({lastActiveAt: now}) into the /record_ip handler (still fire-and-forget, still logged on failure). Revert /user/email back to its original implementation. Signup-time initialization in POST /user is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../identity-service/src/routes/idSignals.js | 13 ++++++++++++- packages/identity-service/src/routes/user.js | 16 ++-------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/identity-service/src/routes/idSignals.js b/packages/identity-service/src/routes/idSignals.js index a0adca05bfe..a901c9e85cf 100644 --- a/packages/identity-service/src/routes/idSignals.js +++ b/packages/identity-service/src/routes/idSignals.js @@ -77,7 +77,18 @@ module.exports = function (app) { '/record_ip', authMiddleware, handleResponse(async (req) => { - const { blockchainUserId, handle } = req.user + const { id: userRowId, blockchainUserId, handle } = req.user + + // Fired by the client's recordIPIfNotRecent saga on app open + // (throttled to once per 24h per device), so this is also our + // signal that the user is active. Fire-and-forget — never block + // the IP-record response on this side effect. + models.User.update( + { lastActiveAt: new Date() }, + { where: { id: userRowId } } + ).catch((err) => { + req.logger.error({ err }, 'Failed to update lastActiveAt') + }) try { const userIP = getIP(req) diff --git a/packages/identity-service/src/routes/user.js b/packages/identity-service/src/routes/user.js index 98f24b24bfd..b33efc897bc 100644 --- a/packages/identity-service/src/routes/user.js +++ b/packages/identity-service/src/routes/user.js @@ -150,31 +150,19 @@ module.exports = function (app) { ) /** - * Retrieve authenticated user's email address. - * - * Doubles as the post-auth startup ping fired on every app open - * (after Hedgehog's GET /authentication restores credentials), so we - * use it as the write site for `lastActiveAt`. The update is - * fire-and-forget — never block the response on it. + * Retrieve authenticated user's email address */ app.get( '/user/email', authMiddleware, handleResponse(async (req, _res, _next) => { - const { id: userRowId, blockchainUserId } = req.user + const { blockchainUserId } = req.user const userData = await models.User.findOne({ where: { blockchainUserId } }) - models.User.update( - { lastActiveAt: new Date() }, - { where: { id: userRowId } } - ).catch((err) => { - req.logger.error({ err }, 'Failed to update lastActiveAt') - }) - return successResponse({ email: userData.email })