@byline/db-mysql 4.19.0 → 5.1.0
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 +35 -1
- package/dist/database/schema/index.js +7 -2
- package/dist/database/schema/schema-pins.test.node.js +18 -2
- package/dist/index.js +4 -0
- package/dist/lib/db-manager.d.ts +10 -0
- package/dist/lib/db-manager.js +73 -16
- package/dist/lib/db-manager.test.node.d.ts +1 -0
- package/dist/lib/db-manager.test.node.js +82 -0
- package/dist/lib/revision-schema.d.ts +2 -0
- package/dist/lib/revision-schema.js +63 -0
- 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/dist/modules/storage/classify-error.js +3 -0
- package/dist/modules/storage/classify-error.test.node.js +8 -0
- package/dist/modules/storage/collection-registration.d.ts +9 -0
- package/dist/modules/storage/collection-registration.js +32 -0
- package/dist/modules/storage/document-revisions.d.ts +20 -0
- package/dist/modules/storage/document-revisions.js +197 -0
- package/dist/modules/storage/publish-schedules.d.ts +5 -0
- package/dist/modules/storage/publish-schedules.js +23 -3
- package/dist/modules/storage/read-snapshot.d.ts +3 -0
- package/dist/modules/storage/read-snapshot.js +12 -0
- package/dist/modules/storage/singletons.d.ts +1 -1
- package/dist/modules/storage/singletons.js +3 -20
- package/dist/modules/storage/storage-commands.d.ts +2 -0
- package/dist/modules/storage/storage-commands.js +31 -3
- package/dist/modules/storage/storage-queries.d.ts +4 -0
- package/dist/modules/storage/storage-queries.js +9 -0
- package/dist/modules/storage/tests/storage-live-path-queries.test.js +9 -2
- package/package.json +10 -10
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DBManagerImpl, TXManagerImpl } from './db-manager.js';
|
|
3
|
+
describe('adapter transaction ownership', () => {
|
|
4
|
+
it('does not leak one adapter transaction into another adapter pool', async () => {
|
|
5
|
+
const poolA = {};
|
|
6
|
+
const poolB = {};
|
|
7
|
+
const tx = {};
|
|
8
|
+
const a = new DBManagerImpl({ dbPool: poolA });
|
|
9
|
+
const b = new DBManagerImpl({ dbPool: poolB });
|
|
10
|
+
await a.runInTransaction(tx, async () => {
|
|
11
|
+
expect(a.get()).toBe(tx);
|
|
12
|
+
expect(a.isInTransaction()).toBe(true);
|
|
13
|
+
expect(b.get()).toBe(poolB);
|
|
14
|
+
expect(b.isInTransaction()).toBe(false);
|
|
15
|
+
expect(b.getTransactionScope()).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
expect(a.get()).toBe(poolA);
|
|
18
|
+
expect(a.getTransactionScope()).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
it('preserves root scope through savepoints and restores the parent executor', async () => {
|
|
21
|
+
const pool = {};
|
|
22
|
+
const outer = {};
|
|
23
|
+
const inner = {};
|
|
24
|
+
const manager = new DBManagerImpl({ dbPool: pool });
|
|
25
|
+
await manager.runInTransaction(outer, async () => {
|
|
26
|
+
const scope = manager.getTransactionScope();
|
|
27
|
+
await expect(manager.runInTransaction(inner, async () => {
|
|
28
|
+
expect(manager.get()).toBe(inner);
|
|
29
|
+
expect(manager.getTransactionScope()).toBe(scope);
|
|
30
|
+
throw new Error('rollback');
|
|
31
|
+
})).rejects.toThrow('rollback');
|
|
32
|
+
expect(manager.get()).toBe(outer);
|
|
33
|
+
expect(manager.getTransactionScope()).toBe(scope);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('owned transaction lock failures', () => {
|
|
38
|
+
const conflict = () => Object.assign(new Error('driver SQL detail'), { errno: 1213 });
|
|
39
|
+
for (const outcome of ['rollback', 'rollback failure', 'commit failure', 'nested']) {
|
|
40
|
+
it(`only marks confirmed whole rollback retryable: ${outcome}`, async () => {
|
|
41
|
+
const original = conflict();
|
|
42
|
+
const replacement = new Error('connection lost');
|
|
43
|
+
let rollbackCompleted = false;
|
|
44
|
+
const executor = {
|
|
45
|
+
transaction: async (fn) => {
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await fn(executor);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (outcome === 'rollback failure')
|
|
52
|
+
throw replacement;
|
|
53
|
+
rollbackCompleted = true;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (outcome === 'commit failure')
|
|
57
|
+
throw original;
|
|
58
|
+
return result;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const db = new DBManagerImpl({ dbPool: executor });
|
|
62
|
+
const manager = new TXManagerImpl({ db });
|
|
63
|
+
const operation = () => manager.withTransaction(async () => {
|
|
64
|
+
if (outcome !== 'commit failure')
|
|
65
|
+
throw original;
|
|
66
|
+
return 'ok';
|
|
67
|
+
});
|
|
68
|
+
const pending = outcome === 'nested' ? db.runInTransaction(executor, operation) : operation();
|
|
69
|
+
if (outcome === 'rollback') {
|
|
70
|
+
await expect(pending).rejects.toMatchObject({
|
|
71
|
+
code: 'ERR_LOCK_CONFLICT',
|
|
72
|
+
details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
|
|
73
|
+
cause: original,
|
|
74
|
+
});
|
|
75
|
+
expect(rollbackCompleted).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await expect(pending).rejects.toBe(outcome === 'rollback failure' ? replacement : original);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ERR_DATABASE } from '@byline/core';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
// Catalogue renderers add parentheses, casts, charset introducers, and escaped quotes.
|
|
4
|
+
// Compare complete normalized expressions, not just constraint names or bound substrings.
|
|
5
|
+
function normalize(clause) {
|
|
6
|
+
return clause
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/::(?:character varying|bigint|integer|text)(?:\[\])?/g, '')
|
|
9
|
+
.replace(/_utf8mb[34]/g, '')
|
|
10
|
+
.replace(/^check/, '')
|
|
11
|
+
.replace(/[\s()[\]`'"\\]/g, '');
|
|
12
|
+
}
|
|
13
|
+
export async function assertRevisionSchema(db) {
|
|
14
|
+
const upgrade = 'Byline document revision schema is incompatible. Fence all writers/workers, upgrade both packages and schema using 0005_document-revisions.sql, and rerun startup validation before reopening traffic.';
|
|
15
|
+
try {
|
|
16
|
+
const columnResult = await db.execute(sql `SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name,
|
|
17
|
+
CASE WHEN DATA_TYPE = 'bigint' AND COLUMN_TYPE NOT LIKE '%unsigned%' AND COLUMN_DEFAULT IS NULL
|
|
18
|
+
AND IS_NULLABLE = CASE WHEN COLUMN_NAME = 'revision' THEN 'NO' ELSE 'YES' END
|
|
19
|
+
AND EXTRA = '' THEN 1 ELSE 0 END AS valid
|
|
20
|
+
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND ((table_name = 'byline_documents' AND column_name = 'revision') OR (table_name = 'byline_document_publish_schedules' AND column_name = 'authorized_revision'))`);
|
|
21
|
+
const checkResult = await db.execute(sql `SELECT t.CONSTRAINT_NAME AS name, c.CHECK_CLAUSE AS clause, (t.ENFORCED = 'YES') AS valid
|
|
22
|
+
FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c
|
|
23
|
+
ON c.CONSTRAINT_SCHEMA = t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME = t.CONSTRAINT_NAME
|
|
24
|
+
WHERE t.CONSTRAINT_SCHEMA = DATABASE() AND t.TABLE_NAME IN ('byline_documents', 'byline_document_publish_schedules') AND t.CONSTRAINT_TYPE = 'CHECK'`);
|
|
25
|
+
const columns = columnResult[0];
|
|
26
|
+
const checks = checkResult[0];
|
|
27
|
+
if (columns.length !== 2 || columns.some((column) => Number(column.valid) !== 1))
|
|
28
|
+
throw new Error('Required BIGINT columns must have the correct nullability and no default.');
|
|
29
|
+
const required = [
|
|
30
|
+
[
|
|
31
|
+
'check_documents_revision',
|
|
32
|
+
[
|
|
33
|
+
'revision BETWEEN 1 AND 9007199254740991',
|
|
34
|
+
'revision >= 1 AND revision <= 9007199254740991',
|
|
35
|
+
],
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
'check_publish_schedules_authorized_revision',
|
|
39
|
+
[
|
|
40
|
+
'authorized_revision IS NULL OR authorized_revision BETWEEN 1 AND 9007199254740991',
|
|
41
|
+
'authorized_revision IS NULL OR authorized_revision >= 1 AND authorized_revision <= 9007199254740991',
|
|
42
|
+
],
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
'check_publish_schedules_suspended_reason',
|
|
46
|
+
[
|
|
47
|
+
"suspended_reason IS NULL OR suspended_reason IN ('content_edited', 'document_metadata_changed', 'upgrade_invalidated')",
|
|
48
|
+
"suspended_reason IS NULL OR suspended_reason = ANY (ARRAY['content_edited', 'document_metadata_changed', 'upgrade_invalidated'])",
|
|
49
|
+
],
|
|
50
|
+
],
|
|
51
|
+
];
|
|
52
|
+
for (const [name, expressions] of required) {
|
|
53
|
+
const check = checks.find((row) => row.name === name);
|
|
54
|
+
if (check == null ||
|
|
55
|
+
Number(check.valid) !== 1 ||
|
|
56
|
+
!expressions.some((expression) => normalize(expression) === normalize(check.clause)))
|
|
57
|
+
throw new Error(`Missing or incompatible enforced check: ${name}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
throw ERR_DATABASE({ message: upgrade, cause });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -50,6 +50,9 @@ export function classifyError(err) {
|
|
|
50
50
|
const match = typeof e.message === 'string' ? e.message.match(/CONSTRAINT [`']([^`']+)[`']/) : null;
|
|
51
51
|
return { code: DbErrorCodes.FOREIGN_KEY_VIOLATION, constraint: match?.[1] };
|
|
52
52
|
}
|
|
53
|
+
if (e.errno === 1213 || e.errno === 1205) {
|
|
54
|
+
return { code: DbErrorCodes.LOCK_CONFLICT };
|
|
55
|
+
}
|
|
53
56
|
e = e.cause;
|
|
54
57
|
}
|
|
55
58
|
return { code: DbErrorCodes.UNKNOWN };
|
|
@@ -99,3 +99,11 @@ runClassifyErrorContract([
|
|
|
99
99
|
unrelatedError: { code: 'ER_DATA_TOO_LONG', errno: 1406, sqlState: '22001' },
|
|
100
100
|
},
|
|
101
101
|
]);
|
|
102
|
+
describe('lock conflict classification', () => {
|
|
103
|
+
it.each([1213, 1205])('classifies driver lock failure %s through a cause', (value) => {
|
|
104
|
+
expect(classifyError({ cause: { errno: value } })).toEqual({ code: 'DB_LOCK_CONFLICT' });
|
|
105
|
+
});
|
|
106
|
+
it('does not classify connection loss as safe contention', () => {
|
|
107
|
+
expect(classifyError({ code: 'ECONNRESET', errno: 2013 })).toEqual({ code: 'DB_UNKNOWN' });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
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 { DBManager } from '../../lib/db-manager.js';
|
|
9
|
+
export declare function lockCollectionRegistration(dbManager: DBManager, collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { ERR_DATABASE, ERR_NOT_FOUND, ERR_VALIDATION } from '@byline/core';
|
|
9
|
+
import { sql } from 'drizzle-orm';
|
|
10
|
+
export async function lockCollectionRegistration(dbManager, collectionId, mode) {
|
|
11
|
+
if (mode !== 'shared' && mode !== 'exclusive') {
|
|
12
|
+
throw ERR_VALIDATION({ message: 'Invalid collection registration lock mode' });
|
|
13
|
+
}
|
|
14
|
+
if (!dbManager.isInTransaction()) {
|
|
15
|
+
throw ERR_DATABASE({
|
|
16
|
+
message: 'collection registration locks require an active transaction',
|
|
17
|
+
details: { collectionId },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const locked = await dbManager.get().execute(sql `
|
|
21
|
+
SELECT id FROM byline_collections
|
|
22
|
+
WHERE id = ${collectionId}
|
|
23
|
+
${mode === 'exclusive' ? sql `FOR UPDATE` : sql `FOR SHARE`}
|
|
24
|
+
`);
|
|
25
|
+
const rows = locked[0];
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
throw ERR_NOT_FOUND({
|
|
28
|
+
message: 'collection registration not found',
|
|
29
|
+
details: { collectionId },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type DocumentRevisionReceipt, type DocumentRevisionStore, type DocumentRevisionTarget, type DocumentStructuralState, type LockedDocumentRevision } from '@byline/core';
|
|
2
|
+
import type { DBManager } from '../../lib/db-manager.js';
|
|
3
|
+
export declare class DocumentRevisions implements DocumentRevisionStore {
|
|
4
|
+
private readonly manager;
|
|
5
|
+
private readonly issued;
|
|
6
|
+
private readonly lockOrder;
|
|
7
|
+
private readonly acquiring;
|
|
8
|
+
constructor(manager: DBManager);
|
|
9
|
+
readStructure(params: {
|
|
10
|
+
collectionId: string;
|
|
11
|
+
documentIds?: readonly string[];
|
|
12
|
+
parentDocumentId?: string;
|
|
13
|
+
}): Promise<DocumentStructuralState[]>;
|
|
14
|
+
isInTransaction(): boolean;
|
|
15
|
+
assertCompatibleSchema(): Promise<void>;
|
|
16
|
+
private scope;
|
|
17
|
+
lock(targets: readonly DocumentRevisionTarget[]): Promise<readonly LockedDocumentRevision[]>;
|
|
18
|
+
private lockSequentially;
|
|
19
|
+
advance(locked: LockedDocumentRevision): Promise<DocumentRevisionReceipt>;
|
|
20
|
+
}
|