@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.
- package/dist/database/schema/auth.d.ts +202 -0
- package/dist/database/schema/auth.js +29 -0
- package/dist/database/schema/index.d.ts +1 -1
- package/dist/database/schema/index.js +1 -1
- package/dist/database/schema/schema-pins.test.node.js +4 -2
- package/dist/modules/admin/admin-store.js +30 -0
- package/dist/modules/admin/admin-users-repository.js +37 -6
- package/dist/modules/admin/login-sessions-repository.d.ts +11 -0
- package/dist/modules/admin/login-sessions-repository.js +38 -0
- package/dist/modules/admin/refresh-tokens-repository.js +18 -24
- package/dist/modules/admin/retry-deadlock.d.ts +2 -0
- package/dist/modules/admin/retry-deadlock.js +22 -0
- package/dist/modules/admin/sign-in-rate-limit-store.d.ts +11 -0
- package/dist/modules/admin/sign-in-rate-limit-store.js +37 -0
- package/package.json +11 -11
|
@@ -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
|
|
352
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,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.
|
|
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.
|
|
51
|
+
"mysql2": "^3.24.4",
|
|
52
52
|
"uuid": "^14.0.2",
|
|
53
|
-
"@byline/admin": "5.
|
|
54
|
-
"@byline/core": "5.
|
|
53
|
+
"@byline/admin": "5.1.1",
|
|
54
|
+
"@byline/core": "5.1.1"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@biomejs/biome": "2.5.
|
|
58
|
-
"@types/node": "^26.
|
|
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.
|
|
64
|
+
"tsc-alias": "^1.9.4",
|
|
65
65
|
"typescript": "^7.0.2",
|
|
66
|
-
"vitest": "^
|
|
67
|
-
"@byline/
|
|
68
|
-
"@byline/
|
|
69
|
-
"@byline/
|
|
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",
|