From 81d15f412bf80db66749168a4af163c66d02ccd9 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 3 Sep 2026 21:36:08 -0400 Subject: [PATCH] feat: tell a team member what was done to their account Two halves of the same question -- what did the team do to me, and how do I find out. One is pull, the other push. The activity view (PUT-1746) was already user-scoped and already 404'd a non-member; what was missing is the load-bearing half. A member is told a reset happened, but a reset only matters alongside who signed in afterwards, so `SessionStore.listSignIns` merges their own sign-ins into the same stream, newest first, with a per-stream cursor. Without that row the audit says a credential was issued and never says whether it was used. The notices (PUT-1733) cover the two things a member cannot discover for themselves: their account being disabled, and their team being closed. Deliberately one each -- closing a team disables every account in it, so sending both would tell one person twice about one event. Both say plainly that nothing was deleted; the accounts persist, suspended, holding their files. Squashed because they are one change to a reviewer: same audience, same purpose, seven files, overlapping only in TeamService. Neither touches shared platform code. --- src/backend/clients/email/templates.ts | 27 ++ .../team/TeamController.http.test.ts | 37 +++ src/backend/services/team/TeamService.test.ts | 233 ++++++++++++++++++ src/backend/services/team/TeamService.ts | 210 +++++++++++++--- src/backend/stores/session/SessionStore.js | 38 +++ src/backend/stores/team/TeamStore.test.ts | 35 +++ src/backend/stores/team/TeamStore.ts | 10 +- 7 files changed, 554 insertions(+), 36 deletions(-) diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 95c727877..0eaf36730 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -362,6 +362,33 @@ choose your own the first time you sign in.

as you. Choose your own password promptly.

Sincerely,

+

Puter

+ `, + }, + team_account_disabled: { + subject: 'Your {{team_name}} account has been disabled', + html: ` +

Hi there,

+

{{team_name}} has disabled your Puter account {{username}}. You can no +longer sign in to it.

+

Nothing has been deleted. Your files, apps and data are as you left them, and +{{team_name}} can re-enable the account at any time. If you believe this is a +mistake, ask them.

+

Sincerely,

+

Puter

+ `, + }, + team_closed: { + subject: '{{team_name}} has been closed', + html: ` +

Hi there,

+

{{team_name}} has closed its Puter team, and your account +{{username}} has been disabled along with it. You can no longer sign in to +it.

+

Nothing has been deleted. Your files and data are still there; closing the +team does not destroy the accounts it created, and each one would have to be +deleted on its own request.

+

Sincerely,

Puter

`, }, diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts index dcaf93189..2c4b061d9 100644 --- a/src/backend/controllers/team/TeamController.http.test.ts +++ b/src/backend/controllers/team/TeamController.http.test.ts @@ -346,6 +346,17 @@ describe('team endpoints over HTTP', () => { expect(body.results[0].recipient).toBe(handle); }); + it('gives a non-member 404 on the member view, not an empty page', async () => { + const { team } = await makeTeam(); + + const res = await call( + 'GET', + `/teams/${team.uid}/audit/me`, + env.users.other.token, + ); + expect(res.status).toBe(404); + }); + // -- the forced-change gate --------------------------------------- /** @@ -482,6 +493,32 @@ describe('team endpoints over HTTP', () => { expect(JSON.stringify(body)).not.toContain(issued); }); + it('shows an activated seat its own reset and sign-in over the wire', async () => { + const { team } = await makeTeam(); + const seat = await signedInSeat(team.uid); + await changePassword(seat.token, seat.password, 'the-one-i-picked'); + + const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: { + action: string; + username: string | null; + ip: string | null; + user_agent: string | null; + }[]; + }; + expect(body.items.map((e) => e.action)).toContain('activate'); + + const tell = body.items.find((e) => e.action === 'sign_in'); + expect(tell?.username).toBe(seat.username); + expect(tell?.user_agent).toBe('puter-test-seat'); + // Only their own; the team owner's entries are not theirs to read. + expect( + body.items.every((e) => e.username === seat.username), + ).toBe(true); + }); + it('refuses a temporary password that was never used in time', async () => { const { team } = await makeTeam(); const seat = await signedInSeat(team.uid); diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index 761e2433a..8a2e5cb72 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -666,6 +666,135 @@ describe('TeamService', () => { expect(own[0]).not.toHaveProperty('actor_user_id'); }); + // -- the member's own view ---------------------------------------- + + /** + * A sign-in row of the shape `sessions` records for a browser session. + * `secondsLater` moves it off the audit rows' timestamp -- within one + * second the order of two different sources is arbitrary, and asserting on + * it would be asserting on the tie-break rather than on the timeline. + */ + const signIn = async (userId: number, ip: string, secondsLater = 0) => { + const created = (await server.stores.session.create(userId, { + kind: 'web', + last_ip: ip, + last_user_agent: 'Chrome/macOS', + })) as { uuid: string }; + if (secondsLater) { + await server.clients.db.write( + 'UPDATE `sessions` SET `created_at` = `created_at` + ? WHERE `uuid` = ?', + [secondsLater, created.uuid], + ); + } + return created; + }; + + it('shows the sign-in between a reset and the member noticing', async () => { + const { team } = await makeTeam(); + const username = `tell_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await service.resetMemberPassword(team.uid, owner.id, created.userId); + await signIn(created.userId, '203.0.113.7', 60); + + const { items } = await service.listOwnAudit(team.uid, created.userId); + const tell = items.find((e) => e.action === 'sign_in'); + expect(tell).toMatchObject({ + username, + actor_username: null, + ip: '203.0.113.7', + user_agent: 'Chrome/macOS', + }); + // Newest first, so the sign-in sits above the reset that preceded it. + expect(items.map((e) => e.action)).toEqual([ + 'sign_in', + 'reset_member_password', + 'provision', + ]); + }); + + it('shows no sign-in belonging to anyone else', async () => { + const { team } = await makeTeam(); + const mine = `mine_${Math.random().toString(36).slice(2, 9)}`; + const theirs = `thrs_${Math.random().toString(36).slice(2, 9)}`; + const a = await service.provisionAccount(team.uid, owner.id, { + username: mine, + email: `${mine}@test.local`, + }); + const b = await service.provisionAccount(team.uid, owner.id, { + username: theirs, + email: `${theirs}@test.local`, + }); + await signIn(b.userId, '198.51.100.4'); + + const { items } = await service.listOwnAudit(team.uid, a.userId); + expect(items.map((e) => e.action)).not.toContain('sign_in'); + expect(JSON.stringify(items)).not.toContain('198.51.100.4'); + expect(JSON.stringify(items)).not.toContain(theirs); + }); + + it('counts only browser sign-ins, not credentials derived from one', async () => { + const { team } = await makeTeam(); + const username = `drv_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await server.stores.session.create(created.userId, { + kind: 'access_token', + last_ip: '198.51.100.77', + }); + + const { items } = await service.listOwnAudit(team.uid, created.userId); + expect(items.map((e) => e.action)).not.toContain('sign_in'); + }); + + it('leaves the team audit free of sign-ins', async () => { + const { team } = await makeTeam(); + const username = `noss_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await signIn(created.userId, '192.0.2.9'); + + const { items } = await service.listAudit(team.uid, owner.id); + expect(items.map((e) => e.action)).not.toContain('sign_in'); + }); + + it('pages both streams rather than dropping one of them', async () => { + const { team } = await makeTeam(); + const username = `pgm_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await service.resetMemberPassword(team.uid, owner.id, created.userId); + await signIn(created.userId, '203.0.113.1'); + await signIn(created.userId, '203.0.113.2'); + + // Four entries: provision, reset, and two sign-ins. + const seen: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 8; page++) { + const result = await service.listOwnAudit(team.uid, created.userId, { + limit: 1, + cursor, + }); + seen.push(...result.items.map((e) => e.action)); + cursor = result.cursor; + if (!cursor) break; + } + expect(seen.sort()).toEqual([ + 'provision', + 'reset_member_password', + 'sign_in', + 'sign_in', + ]); + }); + it('keeps the audit from a member who is not the owner', async () => { const { team, member } = await makeTeam(); await expect( @@ -706,6 +835,110 @@ describe('TeamService', () => { expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false); }); + it('keeps memberships and group grants behind the deleted_at', async () => { + const { team } = await makeTeam(); + const username = `grnt_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await server.clients.db.write( + 'INSERT INTO `user_to_group_permissions` ' + + '(`user_id`, `group_id`, `permission`) VALUES (?, ?, ?)', + [owner.id, team.id, 'fs:some-uid:read'], + ); + + await service.deleteTeam(team.uid, owner.id); + + // A hard DELETE would cascade both of these away with no audit row. + const members = (await server.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` WHERE `group_id` = ?', + [team.id], + )) as { n: number }[]; + expect(Number(members[0].n)).toBeGreaterThanOrEqual(2); + + const grants = (await server.clients.db.read( + 'SELECT COUNT(*) AS n FROM `user_to_group_permissions` WHERE `group_id` = ?', + [team.id], + )) as { n: number }[]; + expect(Number(grants[0].n)).toBe(1); + + // And the account itself is disabled, not destroyed. + expect(await server.stores.user.getById(created.userId)).toBeTruthy(); + }); + + // -- notifications ------------------------------------------------- + + /** Captures what would go out, without standing up a transport. */ + const captureMail = () => { + const sent: { to: string; subject: string }[] = []; + const client = server.clients.email as unknown as { + sendRaw: (o: { to?: string; subject?: string }) => Promise; + }; + const original = client.sendRaw.bind(client); + client.sendRaw = async (options) => { + sent.push({ + to: String(options.to ?? ''), + subject: String(options.subject ?? ''), + }); + return null; + }; + return { sent, restore: () => (client.sendRaw = original) }; + }; + + it('tells a member their account was disabled', async () => { + const { team } = await makeTeam(); + const username = `dis_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + + const mail = captureMail(); + try { + await service.disableMember(team.uid, owner.id, created.userId); + } finally { + mail.restore(); + } + + expect(mail.sent).toHaveLength(1); + expect(mail.sent[0].to).toBe(`${username}@test.local`); + expect(mail.sent[0].subject).toContain('disabled'); + }); + + it('tells every member the team closed, and tells them once', async () => { + const { team } = await makeTeam(); + const names = [ + `cl1_${Math.random().toString(36).slice(2, 9)}`, + `cl2_${Math.random().toString(36).slice(2, 9)}`, + ]; + for (const username of names) { + await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + } + + const mail = captureMail(); + try { + await service.deleteTeam(team.uid, owner.id); + } finally { + mail.restore(); + } + + // The closure notice covers the disabling; two notices would be spam. + for (const username of names) { + const forMember = mail.sent.filter( + (m) => m.to === `${username}@test.local`, + ); + expect(forMember).toHaveLength(1); + expect(forMember[0].subject).toContain('closed'); + } + const ownerRow = await server.stores.user.getById(owner.id); + // The owner account is not the team's to close. + expect(mail.sent.map((m) => m.to)).not.toContain(ownerRow!.email); + }); + it('pages the audit rather than truncating it', async () => { const { team } = await makeTeam(); for (let i = 0; i < 2; i++) { diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index dce94c585..0743fad38 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -25,18 +25,29 @@ import { USERNAME_MAX_LENGTH, USERNAME_REGEX, } from '../../controllers/auth/AuthController.js'; +import type { EmailTemplateName } from '../../clients/email/templates.js'; import type { EventMap, TeamBillingContext, TeamBillingEvent, } from '../../clients/event/types'; import { HttpError } from '../../core/http/HttpError.js'; -import { checkHandle } from '../../stores/team/TeamStore.js'; +import { + AUDIT_PAGE_CAP, + AUDIT_PAGE_SIZE, + checkHandle, +} from '../../stores/team/TeamStore.js'; import type { TeamAuditRow, TeamMemberRow, TeamRow, } from '../../stores/team/TeamStore'; +import { + decodeCursor, + encodeCursor, + normalizeLimit, + type PageResult, +} from '../../util/pagination.js'; import type { UserRow } from '../../stores/user/UserStore'; import { cleanEmail } from '../../util/email.js'; import { @@ -67,6 +78,34 @@ const CAP_LOCK_TTL_SECONDS = 10; export const AUDIT_RESET_PASSWORD = 'reset_member_password'; export const AUDIT_ACTIVATE = 'activate'; +/** Not an audit row: synthesised from `sessions` for the member's own view. */ +export const SIGN_IN_ACTION = 'sign_in'; + +/** One line of a member's activity, whether recorded or a sign-in. */ +export interface MemberActivityEntry { + action: string; + reason: string | null; + /** Unix seconds, so the two sources sort on one axis on every dialect. */ + created_at: number; + username: string | null; + actor_username: string | null; + ip: string | null; + user_agent: string | null; +} + +/** `sessions` stores unix seconds; audit rows a timestamp the driver shapes. */ +const epochSeconds = (value: unknown): number => { + if (value instanceof Date) return Math.floor(value.getTime() / 1000); + if (typeof value === 'number') return Math.floor(value); + const text = String(value ?? ''); + // sqlite returns UTC 'YYYY-MM-DD HH:MM:SS', which Date.parse reads as local. + const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u.test(text) + ? `${text.replace(' ', 'T')}Z` + : text; + const parsed = Date.parse(iso); + return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000); +}; + export class TeamService extends PuterService { // -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ---- @@ -390,6 +429,14 @@ export class TeamService extends PuterService { username: member.username, held_bytes: held, }); + + // The team notice covers the disabling, so a member is + // told once rather than twice about the same event. + await this.#notifyMember( + member.user_id, + 'team_closed', + team, + ); } if (!page.cursor) break; page = await this.stores.team.listMembers(teamUid, { @@ -424,16 +471,95 @@ export class TeamService extends PuterService { ); } - /** The caller's own entries; the only reader who is not the actor. */ + /** + * The caller's own entries, interleaved with sign-ins to their account. The + * only reader who is not the actor, and the only place a sign-in between an + * administrator's reset and the member's own password change becomes + * visible to the person it concerns. + */ async listOwnAudit( teamUid: string, actorUserId: number, opts: { limit?: unknown; cursor?: string } = {}, - ) { + ): Promise> { const team = await this.requireMembership(teamUid, actorUserId); - return this.#withUsernames( - await this.stores.team.listAuditForUser(team.id, actorUserId, opts), + const limit = + normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ?? + AUDIT_PAGE_SIZE; + const cursor = + decodeCursor(opts.cursor, 'member activity cursor') ?? {}; + const fromAudit = typeof cursor.a === 'number' ? cursor.a : undefined; + const fromSignIn = typeof cursor.s === 'number' ? cursor.s : null; + + const audit = await this.stores.team.listAuditForUser( + team.id, + actorUserId, + { + limit, + cursor: + fromAudit === undefined + ? undefined + : encodeCursor({ id: fromAudit }), + }, ); + // One past the limit, so a page that consumes no sign-in still knows + // whether any remain. + const signIns = await this.stores.session.listSignIns(actorUserId, { + limit: limit + 1, + beforeId: fromSignIn, + }); + + const named = await this.#withUsernames(audit); + const self = + (await this.stores.user.getById(actorUserId))?.username ?? null; + const merged = [ + ...named.items.map((entry, i) => ({ + entry, + stream: 'a' as const, + id: audit.items[i].id, + })), + ...signIns.slice(0, limit).map((row) => ({ + entry: { + action: SIGN_IN_ACTION, + reason: null, + created_at: epochSeconds(row.created_at), + username: self, + actor_username: null, + ip: row.last_ip, + user_agent: row.last_user_agent, + } as MemberActivityEntry, + stream: 's' as const, + id: row.id, + })), + ].sort( + (x, y) => + y.entry.created_at - x.entry.created_at || + x.stream.localeCompare(y.stream) || + y.id - x.id, + ); + + const page = merged.slice(0, limit); + const more = + merged.length > limit || !!audit.cursor || signIns.length > limit; + // A stream that contributed nothing keeps its old position: everything + // it still holds is older than this page, so it resumes where it was. + const next = { + ...(page.some((row) => row.stream === 'a') + ? { a: page.findLast((row) => row.stream === 'a')!.id } + : fromAudit === undefined + ? {} + : { a: fromAudit }), + ...(page.some((row) => row.stream === 's') + ? { s: page.findLast((row) => row.stream === 's')!.id } + : fromSignIn === null + ? {} + : { s: fromSignIn }), + }; + + return { + items: page.map((row) => row.entry), + ...(more ? { cursor: encodeCursor(next) } : {}), + }; } /** Resolves a team the caller owns, soft-deleted or not. */ @@ -455,7 +581,10 @@ export class TeamService extends PuterService { } /** Internal user ids never reach the wire, as `toClientTeam` does for `id`. */ - async #withUsernames(page: { items: TeamAuditRow[]; cursor?: string }) { + async #withUsernames(page: { + items: TeamAuditRow[]; + cursor?: string; + }): Promise> { const ids = new Set(); for (const row of page.items) { ids.add(row.user_id_keep); @@ -466,12 +595,15 @@ export class TeamService extends PuterService { id === null ? null : (users.get(id)?.username ?? null); return { - items: page.items.map((row) => ({ + items: page.items.map((row): MemberActivityEntry => ({ action: row.action, reason: row.reason, - created_at: row.created_at, + created_at: epochSeconds(row.created_at), username: name(row.user_id_keep), actor_username: name(row.actor_user_id), + // Only a sign-in carries these; the shape stays uniform. + ip: null, + user_agent: null, })), ...(page.cursor ? { cursor: page.cursor } : {}), }; @@ -602,7 +734,7 @@ export class TeamService extends PuterService { // Returned once; forced change on first use is what bounds it. const temporaryPassword = await this.#issueTemporaryPassword(user.id); - await this.#notifyAccountCreated(user, team); + await this.#notifyUser(user, 'team_account_created', team); // Last: the seat is only chargeable once it exists and can be used. this.#emitBilling('team.account.created', { @@ -645,7 +777,7 @@ export class TeamService extends PuterService { }); const temporaryPassword = await this.#issueTemporaryPassword(targetUserId); - await this.#notifyAccountCreated(user, team); + await this.#notifyUser(user, 'team_account_created', team); return { temporaryPassword }; } @@ -673,7 +805,7 @@ export class TeamService extends PuterService { await this.#issueTemporaryPassword(targetUserId); // 2FA is deliberately untouched: a reset alone is not takeover. await this.#dropSessions(targetUserId); - await this.#notifyPasswordReset(user, team); + await this.#notifyUser(user, 'team_password_reset', team); return { temporaryPassword }; } @@ -725,38 +857,43 @@ export class TeamService extends PuterService { return temporaryPassword; } - /** Carries no credential -- the administrator delivers that out of band. */ - async #notifyPasswordReset(user: UserRow, team: TeamRow): Promise { - if (!this.clients.email || !user.email) return; + /** + * A notice about something the team did to a member's account. It + * carries no credential, so delivery is best effort -- nothing the caller + * did depends on it arriving, and an address the administrator supplied may + * not even reach its holder. + */ + async #notifyUser( + user: UserRow | null | undefined, + template: EmailTemplateName, + team: TeamRow, + ): Promise { + if (!this.clients.email || !user?.email) return; try { - await this.clients.email.send(user.email, 'team_password_reset', { + const sent = await this.clients.email.send(user.email, template, { username: user.username, team_name: team.name ?? 'Your team', }); + // `sendRaw` returns null with no transport rather than throwing. + if (sent === null) { + console.warn(`[team] no email transport for ${template}`); + } } catch (e) { - console.warn('[team-reset] notice failed:', e); + console.warn(`[team] ${template} notice failed:`, e); } } - /** A notice only -- it carries no credential, so delivery is best effort. */ - async #notifyAccountCreated(user: UserRow, team: TeamRow): Promise { - if (!this.clients.email || !user.email) return; - try { - const sent = await this.clients.email.send( - user.email, - 'team_account_created', - { - username: user.username, - team_name: team.name ?? 'Your team', - }, - ); - // `sendRaw` returns null with no transport rather than throwing. - if (sent === null) { - console.warn('[team-provision] no email transport configured'); - } - } catch (e) { - console.warn('[team-provision] notice failed:', e); - } + /** The same notice, for a caller holding only the member's id. */ + async #notifyMember( + userId: number, + template: EmailTemplateName, + team: TeamRow, + ): Promise { + await this.#notifyUser( + await this.stores.user.getById(userId), + template, + team, + ); } // -- Disable and re-enable ---- the whole of offboarding ------------ @@ -799,6 +936,9 @@ export class TeamService extends PuterService { username: membership.username, held_bytes: held, }); + + // Their sessions are gone, so email is the only channel left. + await this.#notifyMember(targetUserId, 'team_account_disabled', team); } /** Nothing was destroyed, so the account returns as it was. */ diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 05bba36d5..8e5aa8e63 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -128,6 +128,44 @@ export class SessionStore extends PuterStore { return rows.map((r) => this.#normalizeRow(r)).filter(Boolean); } + /** + * @typedef {object} SignInRow + * @property {number} id + * @property {number} created_at Unix seconds. + * @property {string | null} last_ip + * @property {string | null} last_user_agent + */ + + /** + * Interactive sign-ins for a user, newest first, for the account's own + * activity view. Revoked rows are included -- a session someone opened and + * closed is exactly what the reader is looking for. Derived kinds (app, + * access token, asset, worker) are not sign-ins and stay out. + * + * Uncached and keyset-paginated on `id`: the caller merges this with + * another stream, so it asks for one page at a time rather than the whole + * history. + * + * @param {number} userId + * @param {{ limit: number; beforeId?: number | null }} opts + * @returns {Promise} + */ + async listSignIns(userId, { limit, beforeId = null }) { + const rows = await this.clients.db.read( + 'SELECT `id`, `created_at`, `last_ip`, `last_user_agent` FROM `sessions` ' + + "WHERE `user_id` = ? AND `kind` = 'web'" + + (beforeId === null ? '' : ' AND `id` < ?') + + ' ORDER BY `id` DESC LIMIT ?', + beforeId === null ? [userId, limit] : [userId, beforeId, limit], + ); + return rows.map((row) => ({ + id: Number(row.id), + created_at: Number(row.created_at ?? 0), + last_ip: row.last_ip ?? null, + last_user_agent: row.last_user_agent ?? null, + })); + } + /** * Create a new session row. * diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index 6bb89b237..f970028e2 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -501,4 +501,39 @@ describe('TeamStore', () => { expect(paged.items).toHaveLength(2); expect(paged.cursor).toBeTruthy(); }); + it('returns audit timestamps as unix seconds, not a dialect datetime', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Stamps', + handle: freeHandle(), + }); + const member = await makeUser(); + await store.addMember(team.uid, member.id, { orgOwned: true }); + await store.appendAudit({ + teamId: team.id, + userId: member.id, + actorUserId: owner.id, + action: 'provision', + }); + + const page = await store.listAuditForUser(team.id, member.id); + const row = page.items[0]; + + // Merged with session rows, which are unix seconds; a dialect + // datetime sorts the two streams apart. + expect(typeof row.created_at).toBe('number'); + + // Against the database's own clock, not the host's: the postgres test + // engine is an emulator whose VM clock sits years off wall time. + const [{ db_now: dbNow }] = (await server.clients.db.read( + server.clients.db.case({ + postgres: + 'SELECT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP::timestamp)::bigint AS db_now', + mysql: 'SELECT UNIX_TIMESTAMP() AS db_now', + otherwise: "SELECT CAST(strftime('%s','now') AS INTEGER) AS db_now", + }), + [], + )) as Array<{ db_now: number }>; + expect(Math.abs(row.created_at - Number(dbNow))).toBeLessThan(300); + }); }); diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index d75331756..a7c04846a 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -627,8 +627,16 @@ export class TeamStore extends PuterStore { const page = decodeCursor(opts.cursor, 'team audit cursor'); const before = typeof page?.id === 'number' ? page.id : null; + // Unix seconds in SQL: the mysql driver reads a stored UTC datetime + // as local, which shifts every row away from the sign-ins. + const epoch = this.clients.db.case({ + postgres: "EXTRACT(EPOCH FROM `created_at`)::bigint", + mysql: 'UNIX_TIMESTAMP(`created_at`)', + otherwise: "CAST(strftime('%s', `created_at`) AS INTEGER)", + }); const rows = (await this.clients.db.read( - 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, `created_at` ' + + 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, ' + + `${epoch} AS \`created_at\` ` + `FROM \`audit_team_membership\` WHERE ${where}` + (before === null ? '' : ' AND `id` < ?') + ' ORDER BY `id` DESC LIMIT ?',