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.
This commit is contained in:
Juan Castro
2026-09-09 11:51:44 -04:00
parent f5b2365e83
commit 81d15f412b
7 changed files with 554 additions and 36 deletions
+27
View File
@@ -362,6 +362,33 @@ choose your own the first time you sign in.</p>
as you. Choose your own password promptly.</li>
</ul>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_account_disabled: {
subject: 'Your {{team_name}} account has been disabled',
html: `
<p>Hi there,</p>
<p>{{team_name}} has disabled your Puter account <b>{{username}}</b>. You can no
longer sign in to it.</p>
<p>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.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_closed: {
subject: '{{team_name}} has been closed',
html: `
<p>Hi there,</p>
<p>{{team_name}} has closed its Puter team, and your account
<b>{{username}}</b> has been disabled along with it. You can no longer sign in to
it.</p>
<p>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.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
@@ -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);
@@ -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<unknown>;
};
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++) {
+175 -35
View File
@@ -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<PageResult<MemberActivityEntry>> {
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<PageResult<MemberActivityEntry>> {
const ids = new Set<number>();
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<void> {
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<void> {
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<void> {
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<void> {
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. */
@@ -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<SignInRow[]>}
*/
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.
*
+35
View File
@@ -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);
});
});
+9 -1
View File
@@ -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 ?',