@byline/db-mysql 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,6 +60,23 @@ export declare const adminUsers: import("drizzle-orm/mysql-core").MySqlTableWith
60
60
  identity: undefined;
61
61
  generated: undefined;
62
62
  }, {}, {}>;
63
+ session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
64
+ name: "session_version";
65
+ tableName: "byline_admin_users";
66
+ dataType: "number";
67
+ columnType: "MySqlInt";
68
+ data: number;
69
+ driverParam: string | number;
70
+ notNull: true;
71
+ hasDefault: true;
72
+ isPrimaryKey: false;
73
+ isAutoincrement: false;
74
+ hasRuntimeDefault: false;
75
+ enumValues: undefined;
76
+ baseColumn: never;
77
+ identity: undefined;
78
+ generated: undefined;
79
+ }, {}, {}>;
63
80
  vid: import("drizzle-orm/mysql-core").MySqlColumn<{
64
81
  name: "vid";
65
82
  tableName: "byline_admin_users";
@@ -720,6 +737,98 @@ export declare const adminPermissions: import("drizzle-orm/mysql-core").MySqlTab
720
737
  * at the replacement row; presenting a rotated token is treated as replay
721
738
  * and revokes the whole chain.
722
739
  */
740
+ export declare const adminLoginSessions: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
741
+ name: "byline_admin_login_sessions";
742
+ schema: undefined;
743
+ columns: {
744
+ id: import("drizzle-orm/mysql-core").MySqlColumn<{
745
+ name: "id";
746
+ tableName: "byline_admin_login_sessions";
747
+ dataType: "custom";
748
+ columnType: "MySqlCustomColumn";
749
+ data: string;
750
+ driverParam: string;
751
+ notNull: true;
752
+ hasDefault: false;
753
+ isPrimaryKey: true;
754
+ isAutoincrement: false;
755
+ hasRuntimeDefault: false;
756
+ enumValues: undefined;
757
+ baseColumn: never;
758
+ identity: undefined;
759
+ generated: undefined;
760
+ }, {}, {}>;
761
+ admin_user_id: import("drizzle-orm/mysql-core").MySqlColumn<{
762
+ name: "admin_user_id";
763
+ tableName: "byline_admin_login_sessions";
764
+ dataType: "custom";
765
+ columnType: "MySqlCustomColumn";
766
+ data: string;
767
+ driverParam: string;
768
+ notNull: true;
769
+ hasDefault: false;
770
+ isPrimaryKey: false;
771
+ isAutoincrement: false;
772
+ hasRuntimeDefault: false;
773
+ enumValues: undefined;
774
+ baseColumn: never;
775
+ identity: undefined;
776
+ generated: undefined;
777
+ }, {}, {}>;
778
+ session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
779
+ name: "session_version";
780
+ tableName: "byline_admin_login_sessions";
781
+ dataType: "number";
782
+ columnType: "MySqlInt";
783
+ data: number;
784
+ driverParam: string | number;
785
+ notNull: true;
786
+ hasDefault: false;
787
+ isPrimaryKey: false;
788
+ isAutoincrement: false;
789
+ hasRuntimeDefault: false;
790
+ enumValues: undefined;
791
+ baseColumn: never;
792
+ identity: undefined;
793
+ generated: undefined;
794
+ }, {}, {}>;
795
+ expires_at: import("drizzle-orm/mysql-core").MySqlColumn<{
796
+ name: "expires_at";
797
+ tableName: "byline_admin_login_sessions";
798
+ dataType: "date";
799
+ columnType: "MySqlDateTime";
800
+ data: Date;
801
+ driverParam: string | number;
802
+ notNull: true;
803
+ hasDefault: false;
804
+ isPrimaryKey: false;
805
+ isAutoincrement: false;
806
+ hasRuntimeDefault: false;
807
+ enumValues: undefined;
808
+ baseColumn: never;
809
+ identity: undefined;
810
+ generated: undefined;
811
+ }, {}, {}>;
812
+ revoked_at: import("drizzle-orm/mysql-core").MySqlColumn<{
813
+ name: "revoked_at";
814
+ tableName: "byline_admin_login_sessions";
815
+ dataType: "date";
816
+ columnType: "MySqlDateTime";
817
+ data: Date;
818
+ driverParam: string | number;
819
+ notNull: false;
820
+ hasDefault: false;
821
+ isPrimaryKey: false;
822
+ isAutoincrement: false;
823
+ hasRuntimeDefault: false;
824
+ enumValues: undefined;
825
+ baseColumn: never;
826
+ identity: undefined;
827
+ generated: undefined;
828
+ }, {}, {}>;
829
+ };
830
+ dialect: 'mysql';
831
+ }>;
723
832
  export declare const adminRefreshTokens: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
724
833
  name: "byline_admin_refresh_tokens";
725
834
  schema: undefined;
@@ -792,6 +901,23 @@ export declare const adminRefreshTokens: import("drizzle-orm/mysql-core").MySqlT
792
901
  identity: undefined;
793
902
  generated: undefined;
794
903
  }, {}, {}>;
904
+ sid: import("drizzle-orm/mysql-core").MySqlColumn<{
905
+ name: "sid";
906
+ tableName: "byline_admin_refresh_tokens";
907
+ dataType: "custom";
908
+ columnType: "MySqlCustomColumn";
909
+ data: string;
910
+ driverParam: string;
911
+ notNull: false;
912
+ hasDefault: false;
913
+ isPrimaryKey: false;
914
+ isAutoincrement: false;
915
+ hasRuntimeDefault: false;
916
+ enumValues: undefined;
917
+ baseColumn: never;
918
+ identity: undefined;
919
+ generated: undefined;
920
+ }, {}, {}>;
795
921
  token_hash: import("drizzle-orm/mysql-core").MySqlColumn<{
796
922
  name: "token_hash";
797
923
  tableName: "byline_admin_refresh_tokens";
@@ -809,6 +935,23 @@ export declare const adminRefreshTokens: import("drizzle-orm/mysql-core").MySqlT
809
935
  identity: undefined;
810
936
  generated: undefined;
811
937
  }, {}, {}>;
938
+ session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
939
+ name: "session_version";
940
+ tableName: "byline_admin_refresh_tokens";
941
+ dataType: "number";
942
+ columnType: "MySqlInt";
943
+ data: number;
944
+ driverParam: string | number;
945
+ notNull: true;
946
+ hasDefault: true;
947
+ isPrimaryKey: false;
948
+ isAutoincrement: false;
949
+ hasRuntimeDefault: false;
950
+ enumValues: undefined;
951
+ baseColumn: never;
952
+ identity: undefined;
953
+ generated: undefined;
954
+ }, {}, {}>;
812
955
  issued_at: import("drizzle-orm/mysql-core").MySqlColumn<{
813
956
  name: "issued_at";
814
957
  tableName: "byline_admin_refresh_tokens";
@@ -949,3 +1092,62 @@ export declare const adminPermissionsRelations: import("drizzle-orm").Relations<
949
1092
  export declare const adminRefreshTokensRelations: import("drizzle-orm").Relations<"byline_admin_refresh_tokens", {
950
1093
  user: import("drizzle-orm").One<"byline_admin_users", true>;
951
1094
  }>;
1095
+ /** Shared, expiring fixed-window counters for password sign-in admission. */
1096
+ export declare const adminSignInRateLimits: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
1097
+ name: "byline_admin_sign_in_rate_limits";
1098
+ schema: undefined;
1099
+ columns: {
1100
+ key: import("drizzle-orm/mysql-core").MySqlColumn<{
1101
+ name: "key";
1102
+ tableName: "byline_admin_sign_in_rate_limits";
1103
+ dataType: "string";
1104
+ columnType: "MySqlVarChar";
1105
+ data: string;
1106
+ driverParam: string | number;
1107
+ notNull: true;
1108
+ hasDefault: false;
1109
+ isPrimaryKey: true;
1110
+ isAutoincrement: false;
1111
+ hasRuntimeDefault: false;
1112
+ enumValues: [string, ...string[]];
1113
+ baseColumn: never;
1114
+ identity: undefined;
1115
+ generated: undefined;
1116
+ }, {}, {}>;
1117
+ attempts: import("drizzle-orm/mysql-core").MySqlColumn<{
1118
+ name: "attempts";
1119
+ tableName: "byline_admin_sign_in_rate_limits";
1120
+ dataType: "number";
1121
+ columnType: "MySqlInt";
1122
+ data: number;
1123
+ driverParam: string | number;
1124
+ notNull: true;
1125
+ hasDefault: false;
1126
+ isPrimaryKey: false;
1127
+ isAutoincrement: false;
1128
+ hasRuntimeDefault: false;
1129
+ enumValues: undefined;
1130
+ baseColumn: never;
1131
+ identity: undefined;
1132
+ generated: undefined;
1133
+ }, {}, {}>;
1134
+ expires_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1135
+ name: "expires_at";
1136
+ tableName: "byline_admin_sign_in_rate_limits";
1137
+ dataType: "date";
1138
+ columnType: "MySqlDateTime";
1139
+ data: Date;
1140
+ driverParam: string | number;
1141
+ notNull: true;
1142
+ hasDefault: false;
1143
+ isPrimaryKey: false;
1144
+ isAutoincrement: false;
1145
+ hasRuntimeDefault: false;
1146
+ enumValues: undefined;
1147
+ baseColumn: never;
1148
+ identity: undefined;
1149
+ generated: undefined;
1150
+ }, {}, {}>;
1151
+ };
1152
+ dialect: 'mysql';
1153
+ }>;
@@ -36,6 +36,7 @@ import { createdAt, timestamps, uuidChar } from './common.js';
36
36
  // ---------------------------------------------------------------------------
37
37
  export const adminUsers = mysqlTable('byline_admin_users', {
38
38
  id: uuidChar('id').primaryKey(),
39
+ session_version: int('session_version').notNull().default(0),
39
40
  vid: int('vid').notNull().default(1),
40
41
  given_name: varchar('given_name', { length: 100 }),
41
42
  family_name: varchar('family_name', { length: 100 }),
@@ -162,11 +163,27 @@ export const adminPermissions = mysqlTable('byline_admin_permissions', {
162
163
  * at the replacement row; presenting a rotated token is treated as replay
163
164
  * and revokes the whole chain.
164
165
  */
166
+ export const adminLoginSessions = mysqlTable('byline_admin_login_sessions', {
167
+ id: uuidChar('id').primaryKey(),
168
+ admin_user_id: uuidChar('admin_user_id').notNull(),
169
+ session_version: int('session_version').notNull(),
170
+ expires_at: datetime('expires_at', { fsp: 6 }).notNull(),
171
+ revoked_at: datetime('revoked_at', { fsp: 6 }),
172
+ }, (table) => [
173
+ foreignKey({
174
+ name: 'fk_admin_login_sessions_user',
175
+ columns: [table.admin_user_id],
176
+ foreignColumns: [adminUsers.id],
177
+ }).onDelete('cascade'),
178
+ index('idx_admin_login_sessions_user').on(table.admin_user_id),
179
+ ]);
165
180
  export const adminRefreshTokens = mysqlTable('byline_admin_refresh_tokens', {
166
181
  id: uuidChar('id').primaryKey(),
167
182
  admin_user_id: uuidChar('admin_user_id').notNull(),
168
183
  /** SHA-256 hex digest of the raw refresh-token string. 64 chars. */
184
+ sid: uuidChar('sid'),
169
185
  token_hash: varchar('token_hash', { length: 64 }).notNull().unique(),
186
+ session_version: int('session_version').notNull().default(-1),
170
187
  // fsp 6 throughout this table, matching pg — see `common.ts`'s
171
188
  // `auditTimestamp` docblock for why every temporal column in this
172
189
  // schema moved from fsp 3 to fsp 6.
@@ -183,11 +200,17 @@ export const adminRefreshTokens = mysqlTable('byline_admin_refresh_tokens', {
183
200
  ip: varchar('ip', { length: 45 }),
184
201
  ...timestamps,
185
202
  }, (table) => [
203
+ foreignKey({
204
+ name: 'fk_admin_refresh_tokens_sid',
205
+ columns: [table.sid],
206
+ foreignColumns: [adminLoginSessions.id],
207
+ }).onDelete('cascade'),
186
208
  foreignKey({
187
209
  name: 'fk_admin_refresh_tokens_admin_user_id',
188
210
  columns: [table.admin_user_id],
189
211
  foreignColumns: [adminUsers.id],
190
212
  }).onDelete('cascade'),
213
+ index('idx_admin_refresh_tokens_sid').on(table.sid),
191
214
  index('idx_byline_admin_refresh_tokens_user').on(table.admin_user_id),
192
215
  index('idx_byline_admin_refresh_tokens_token_hash').on(table.token_hash),
193
216
  ]);
@@ -224,3 +247,9 @@ export const adminRefreshTokensRelations = relations(adminRefreshTokens, ({ one
224
247
  references: [adminUsers.id],
225
248
  }),
226
249
  }));
250
+ /** Shared, expiring fixed-window counters for password sign-in admission. */
251
+ export const adminSignInRateLimits = mysqlTable('byline_admin_sign_in_rate_limits', {
252
+ key: varchar('key', { length: 64 }).primaryKey(),
253
+ attempts: int('attempts').notNull(),
254
+ expires_at: datetime('expires_at', { fsp: 3 }).notNull(),
255
+ }, (table) => [index('idx_admin_sign_in_rate_limits_expiry').on(table.expires_at)]);
@@ -4102,4 +4102,4 @@ export declare const recurringTasks: import("drizzle-orm/mysql-core").MySqlTable
4102
4102
  };
4103
4103
  dialect: 'mysql';
4104
4104
  }>;
4105
- export { adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
4105
+ export { adminLoginSessions, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
@@ -996,4 +996,4 @@ export const recurringTasks = mysqlTable('byline_recurring_tasks', {
996
996
  // Auth schema — byline_admin_users, byline_admin_roles, etc.
997
997
  // See ./auth.ts for definitions and rationale.
998
998
  // ---------------------------------------------------------------------------
999
- export { adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
999
+ export { adminLoginSessions, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
@@ -348,8 +348,10 @@ describe('schema pins — timestamp precision (spec §F.3)', () => {
348
348
  it('finds datetime columns to check (schema is not empty)', () => {
349
349
  expect(instantColumns.length).toBeGreaterThan(0);
350
350
  });
351
- it.each(instantColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s is datetime(6) — microsecond precision (pg parity)', (_label, column) => {
352
- expect(column.getSQLType()).toBe('datetime(6)');
351
+ it.each(instantColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s uses the PostgreSQL companion precision', (label, column) => {
352
+ // Admission windows intentionally share JavaScript millisecond precision on both adapters.
353
+ const precision = label === 'byline_admin_sign_in_rate_limits.expires_at' ? 3 : 6;
354
+ expect(column.getSQLType()).toBe(`datetime(${precision})`);
353
355
  });
354
356
  // `MySqlDate` (the `date()` builder, used by `datetimeStore.value_date`)
355
357
  // is the one temporal type genuinely outside this pin's scope — a
@@ -5,11 +5,15 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { eq } from 'drizzle-orm';
9
+ import { adminUsers } from '../../database/schema/auth.js';
8
10
  import { createAdminPermissionsRepository } from './admin-permissions-repository.js';
9
11
  import { createAdminPreferencesRepository } from './admin-preferences-repository.js';
10
12
  import { createAdminRolesRepository } from './admin-roles-repository.js';
11
13
  import { createAdminUsersRepository } from './admin-users-repository.js';
14
+ import { createLoginSessionsRepository } from './login-sessions-repository.js';
12
15
  import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
16
+ import { createSignInRateLimitStore } from './sign-in-rate-limit-store.js';
13
17
  /**
14
18
  * Wire the five admin repositories against a Drizzle handle and return the
15
19
  * `AdminStore` bundle expected by `@byline/admin` — specifically by the
@@ -21,6 +25,32 @@ import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
21
25
  */
22
26
  export function createAdminStore(db) {
23
27
  return {
28
+ async withSessionLock(adminUserId, work) {
29
+ return db.transaction(async (tx) => {
30
+ await tx
31
+ .select({ id: adminUsers.id })
32
+ .from(adminUsers)
33
+ .where(eq(adminUsers.id, adminUserId))
34
+ .for('update');
35
+ const scoped = createAdminStore(tx);
36
+ const user = await scoped.adminUsers.getByIdForSignIn(adminUserId);
37
+ return work(scoped, user);
38
+ });
39
+ },
40
+ async withSessionLocks(adminUserIds, work) {
41
+ return db.transaction(async (tx) => {
42
+ for (const id of [...new Set(adminUserIds)].sort()) {
43
+ await tx
44
+ .select({ id: adminUsers.id })
45
+ .from(adminUsers)
46
+ .where(eq(adminUsers.id, id))
47
+ .for('update');
48
+ }
49
+ return work(createAdminStore(tx));
50
+ });
51
+ },
52
+ loginSessions: createLoginSessionsRepository(db),
53
+ signInRateLimits: createSignInRateLimitStore(db),
24
54
  adminUsers: createAdminUsersRepository(db),
25
55
  adminRoles: createAdminRolesRepository(db),
26
56
  adminPermissions: createAdminPermissionsRepository(db),
@@ -10,6 +10,8 @@ import { and, asc, desc, eq, inArray, like, or, sql } from 'drizzle-orm';
10
10
  import { v7 as uuidv7 } from 'uuid';
11
11
  import { adminUsers } from '../../database/schema/auth.js';
12
12
  import { affectedRowCount } from '../storage/storage-utils.js';
13
+ import { createLoginSessionsRepository } from './login-sessions-repository.js';
14
+ import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
13
15
  /**
14
16
  * MySQL implementation of `AdminUsersRepository`. Ported from
15
17
  * `packages/db-postgres/src/modules/admin/admin-users-repository.ts`.
@@ -160,14 +162,22 @@ export function createAdminUsersRepository(db) {
160
162
  },
161
163
  async getByEmailForSignIn(email) {
162
164
  const [row] = await db
163
- .select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
165
+ .select({
166
+ ...PUBLIC_COLUMNS,
167
+ password_hash: adminUsers.password,
168
+ session_version: adminUsers.session_version,
169
+ })
164
170
  .from(adminUsers)
165
171
  .where(eq(adminUsers.email, email.toLowerCase()));
166
172
  return row ?? null;
167
173
  },
168
174
  async getByIdForSignIn(id) {
169
175
  const [row] = await db
170
- .select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
176
+ .select({
177
+ ...PUBLIC_COLUMNS,
178
+ password_hash: adminUsers.password,
179
+ session_version: adminUsers.session_version,
180
+ })
171
181
  .from(adminUsers)
172
182
  .where(eq(adminUsers.id, id));
173
183
  return row ?? null;
@@ -211,6 +221,9 @@ export function createAdminUsersRepository(db) {
211
221
  updateSet.is_super_admin = patch.is_super_admin;
212
222
  if (patch.is_enabled !== undefined)
213
223
  updateSet.is_enabled = patch.is_enabled;
224
+ if (patch.is_enabled === false) {
225
+ updateSet.session_version = sql `${adminUsers.session_version} + 1`;
226
+ }
214
227
  if (patch.is_email_verified !== undefined)
215
228
  updateSet.is_email_verified = patch.is_email_verified;
216
229
  if (patch.remember_me !== undefined)
@@ -233,6 +246,10 @@ export function createAdminUsersRepository(db) {
233
246
  .where(eq(adminUsers.id, id));
234
247
  if (!fresh)
235
248
  throw ERR_ADMIN_USER_VERSION_CONFLICT();
249
+ if (patch.is_enabled === false) {
250
+ await createRefreshTokensRepository(tx).revokeAllForUser(id);
251
+ await createLoginSessionsRepository(tx).revokeAllForUser(id);
252
+ }
236
253
  return fresh;
237
254
  });
238
255
  },
@@ -245,6 +262,7 @@ export function createAdminUsersRepository(db) {
245
262
  .update(adminUsers)
246
263
  .set({
247
264
  password: passwordHash,
265
+ session_version: sql `${adminUsers.session_version} + 1`,
248
266
  updated_at: now,
249
267
  vid: sql `${adminUsers.vid} + 1`,
250
268
  })
@@ -257,14 +275,27 @@ export function createAdminUsersRepository(db) {
257
275
  .where(eq(adminUsers.id, id));
258
276
  if (!fresh)
259
277
  throw ERR_ADMIN_USER_VERSION_CONFLICT();
278
+ await createRefreshTokensRepository(tx).revokeAllForUser(id);
279
+ await createLoginSessionsRepository(tx).revokeAllForUser(id);
260
280
  return fresh;
261
281
  });
262
282
  },
263
283
  async setEnabled(id, enabled) {
264
- await db
265
- .update(adminUsers)
266
- .set({ is_enabled: enabled, updated_at: new Date(), vid: sql `${adminUsers.vid} + 1` })
267
- .where(eq(adminUsers.id, id));
284
+ await db.transaction(async (tx) => {
285
+ await tx
286
+ .update(adminUsers)
287
+ .set({
288
+ is_enabled: enabled,
289
+ updated_at: new Date(),
290
+ vid: sql `${adminUsers.vid} + 1`,
291
+ ...(!enabled ? { session_version: sql `${adminUsers.session_version} + 1` } : {}),
292
+ })
293
+ .where(eq(adminUsers.id, id));
294
+ if (!enabled) {
295
+ await createRefreshTokensRepository(tx).revokeAllForUser(id);
296
+ await createLoginSessionsRepository(tx).revokeAllForUser(id);
297
+ }
298
+ });
268
299
  },
269
300
  async setPreferredLocale(id, locale) {
270
301
  await db
@@ -0,0 +1,11 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { LoginSessionsRepository } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ export declare function createLoginSessionsRepository(db: MySql2Database<typeof schema>): LoginSessionsRepository;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { and, eq, isNull } from 'drizzle-orm';
9
+ import { adminLoginSessions } from '../../database/schema/auth.js';
10
+ export function createLoginSessionsRepository(db) {
11
+ return {
12
+ async create(input) {
13
+ await db.insert(adminLoginSessions).values(input);
14
+ },
15
+ async findById(id) {
16
+ const [row] = await db.select().from(adminLoginSessions).where(eq(adminLoginSessions.id, id));
17
+ return row ?? null;
18
+ },
19
+ async extend(id, expiresAt) {
20
+ await db
21
+ .update(adminLoginSessions)
22
+ .set({ expires_at: expiresAt })
23
+ .where(and(eq(adminLoginSessions.id, id), isNull(adminLoginSessions.revoked_at)));
24
+ },
25
+ async revoke(id, at = new Date()) {
26
+ await db
27
+ .update(adminLoginSessions)
28
+ .set({ revoked_at: at })
29
+ .where(and(eq(adminLoginSessions.id, id), isNull(adminLoginSessions.revoked_at)));
30
+ },
31
+ async revokeAllForUser(adminUserId, at = new Date()) {
32
+ await db
33
+ .update(adminLoginSessions)
34
+ .set({ revoked_at: at })
35
+ .where(and(eq(adminLoginSessions.admin_user_id, adminUserId), isNull(adminLoginSessions.revoked_at)));
36
+ },
37
+ };
38
+ }
@@ -34,11 +34,16 @@ import { affectedRowCount } from '../storage/storage-utils.js';
34
34
  export function createRefreshTokensRepository(db) {
35
35
  return {
36
36
  async issue(input) {
37
+ if (!Number.isSafeInteger(input.session_version) || input.session_version < -1) {
38
+ throw new Error('issueRefreshToken: session_version must be an integer >= -1');
39
+ }
37
40
  const now = new Date();
38
41
  const row = {
39
42
  id: input.id,
40
43
  admin_user_id: input.admin_user_id,
41
44
  token_hash: input.token_hash,
45
+ session_version: input.session_version,
46
+ sid: input.sid ?? null,
42
47
  issued_at: now,
43
48
  expires_at: input.expires_at,
44
49
  revoked_at: null,
@@ -51,6 +56,8 @@ export function createRefreshTokensRepository(db) {
51
56
  id: row.id,
52
57
  admin_user_id: row.admin_user_id,
53
58
  token_hash: row.token_hash,
59
+ session_version: row.session_version,
60
+ sid: row.sid,
54
61
  issued_at: now,
55
62
  expires_at: row.expires_at,
56
63
  user_agent: row.user_agent,
@@ -90,30 +97,17 @@ export function createRefreshTokensRepository(db) {
90
97
  .where(and(eq(adminRefreshTokens.id, id), isNull(adminRefreshTokens.revoked_at)));
91
98
  },
92
99
  async revokeChain(startId, at = new Date()) {
93
- let cursor = startId;
94
- let touched = 0;
95
- // Bounded walk — chains in practice are short; 1000 is a safety ceiling.
96
- for (let step = 0; cursor != null && step < 1000; step++) {
97
- const [row] = await db
98
- .select({
99
- id: adminRefreshTokens.id,
100
- rotated_to_id: adminRefreshTokens.rotated_to_id,
101
- revoked_at: adminRefreshTokens.revoked_at,
102
- })
103
- .from(adminRefreshTokens)
104
- .where(eq(adminRefreshTokens.id, cursor));
105
- if (!row)
106
- break;
107
- if (row.revoked_at == null) {
108
- await db
109
- .update(adminRefreshTokens)
110
- .set({ revoked_at: at, updated_at: new Date() })
111
- .where(eq(adminRefreshTokens.id, row.id));
112
- touched++;
113
- }
114
- cursor = row.rotated_to_id;
115
- }
116
- return touched;
100
+ const [member] = await db
101
+ .select()
102
+ .from(adminRefreshTokens)
103
+ .where(eq(adminRefreshTokens.id, startId));
104
+ if (!member?.sid)
105
+ return 0; // Legacy credentials fail closed in the provider.
106
+ const result = await db
107
+ .update(adminRefreshTokens)
108
+ .set({ revoked_at: at, updated_at: at })
109
+ .where(and(eq(adminRefreshTokens.sid, member.sid), isNull(adminRefreshTokens.revoked_at)));
110
+ return affectedRowCount(result);
117
111
  },
118
112
  async revokeAllForUser(adminUserId, at = new Date()) {
119
113
  const result = await db
@@ -0,0 +1,2 @@
1
+ /** Retry only InnoDB deadlock victims: error 1213 guarantees transaction rollback. */
2
+ export declare function retryDeadlock<T>(operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,22 @@
1
+ /** Retry only InnoDB deadlock victims: error 1213 guarantees transaction rollback. */
2
+ export async function retryDeadlock(operation) {
3
+ for (let attempt = 0;; attempt++) {
4
+ try {
5
+ return await operation();
6
+ }
7
+ catch (error) {
8
+ let cause = error;
9
+ let deadlock = false;
10
+ for (let depth = 0; depth < 8 && cause && typeof cause === 'object'; depth++) {
11
+ if ('errno' in cause && cause.errno === 1213) {
12
+ deadlock = true;
13
+ break;
14
+ }
15
+ cause = 'cause' in cause ? cause.cause : undefined;
16
+ }
17
+ if (!deadlock || attempt >= 2)
18
+ throw error;
19
+ await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt + Math.random() * 10));
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { SignInRateLimitStore } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ export declare function createSignInRateLimitStore(db: MySql2Database<typeof schema>): SignInRateLimitStore;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { eq, sql } from 'drizzle-orm';
9
+ import { adminSignInRateLimits as counters } from '../../database/schema/auth.js';
10
+ import { retryDeadlock } from './retry-deadlock.js';
11
+ export function createSignInRateLimitStore(db) {
12
+ return {
13
+ async consume(key, limit, expiresAt) {
14
+ // The upsert holds the row lock until our read commits, including concurrent first inserts.
15
+ return retryDeadlock(() => db.transaction(async (tx) => {
16
+ await tx
17
+ .insert(counters)
18
+ .values({ key, attempts: 1, expires_at: expiresAt })
19
+ .onDuplicateKeyUpdate({
20
+ set: { attempts: sql `least(${counters.attempts} + 1, ${limit + 1})` },
21
+ });
22
+ const [row] = await tx
23
+ .select({ attempts: counters.attempts })
24
+ .from(counters)
25
+ .where(eq(counters.key, key))
26
+ .for('update');
27
+ if (!row)
28
+ throw new Error('Sign-in counter update returned no row');
29
+ return row.attempts <= limit;
30
+ }));
31
+ },
32
+ async purgeExpired(before) {
33
+ const [result] = await db.execute(sql `delete from ${counters} where ${counters.expires_at} < ${before.toISOString().slice(0, 23).replace('T', ' ')} limit 100`);
34
+ return result.affectedRows;
35
+ },
36
+ };
37
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-mysql",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "5.0.0",
5
+ "version": "5.1.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -48,25 +48,25 @@
48
48
  ],
49
49
  "dependencies": {
50
50
  "drizzle-orm": "^0.45.2",
51
- "mysql2": "^3.24.2",
51
+ "mysql2": "^3.24.4",
52
52
  "uuid": "^14.0.2",
53
- "@byline/admin": "5.0.0",
54
- "@byline/core": "5.0.0"
53
+ "@byline/admin": "5.1.1",
54
+ "@byline/core": "5.1.1"
55
55
  },
56
56
  "devDependencies": {
57
- "@biomejs/biome": "2.5.10",
58
- "@types/node": "^26.4.0",
57
+ "@biomejs/biome": "2.5.12",
58
+ "@types/node": "^26.5.0",
59
59
  "chokidar": "^5.0.0",
60
60
  "chokidar-cli": "^3.0.0",
61
61
  "dotenv": "^17.4.2",
62
62
  "drizzle-kit": "^0.31.10",
63
63
  "npm-run-all": "^4.1.5",
64
- "tsc-alias": "^1.9.2",
64
+ "tsc-alias": "^1.9.4",
65
65
  "typescript": "^7.0.2",
66
- "vitest": "^4.1.11",
67
- "@byline/client": "5.0.0",
68
- "@byline/auth": "5.0.0",
69
- "@byline/db-conformance": "0.0.10"
66
+ "vitest": "^5.0.0",
67
+ "@byline/auth": "5.1.1",
68
+ "@byline/db-conformance": "0.0.10",
69
+ "@byline/client": "5.1.1"
70
70
  },
71
71
  "publishConfig": {
72
72
  "access": "public",