@byline/db-mysql 4.8.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/LICENSE +373 -0
- package/README.md +283 -0
- package/dist/database/schema/auth.d.ts +951 -0
- package/dist/database/schema/auth.js +226 -0
- package/dist/database/schema/common.d.ts +168 -0
- package/dist/database/schema/common.js +130 -0
- package/dist/database/schema/index.d.ts +3419 -0
- package/dist/database/schema/index.js +895 -0
- package/dist/database/schema/schema-pins.test.node.d.ts +52 -0
- package/dist/database/schema/schema-pins.test.node.js +398 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +151 -0
- package/dist/lib/boot-check.d.ts +22 -0
- package/dist/lib/boot-check.js +42 -0
- package/dist/lib/boot-check.test.node.d.ts +8 -0
- package/dist/lib/boot-check.test.node.js +46 -0
- package/dist/lib/db-manager.d.ts +54 -0
- package/dist/lib/db-manager.js +49 -0
- package/dist/lib/test-db.d.ts +37 -0
- package/dist/lib/test-db.js +111 -0
- package/dist/lib/test-helper.d.ts +33 -0
- package/dist/lib/test-helper.js +68 -0
- package/dist/modules/admin/admin-permissions-repository.d.ts +35 -0
- package/dist/modules/admin/admin-permissions-repository.js +101 -0
- package/dist/modules/admin/admin-preferences-repository.d.ts +62 -0
- package/dist/modules/admin/admin-preferences-repository.js +156 -0
- package/dist/modules/admin/admin-roles-repository.d.ts +11 -0
- package/dist/modules/admin/admin-roles-repository.js +209 -0
- package/dist/modules/admin/admin-store.d.ts +20 -0
- package/dist/modules/admin/admin-store.js +30 -0
- package/dist/modules/admin/admin-users-repository.d.ts +11 -0
- package/dist/modules/admin/admin-users-repository.js +303 -0
- package/dist/modules/admin/index.d.ts +27 -0
- package/dist/modules/admin/index.js +27 -0
- package/dist/modules/admin/refresh-tokens-repository.d.ts +34 -0
- package/dist/modules/admin/refresh-tokens-repository.js +160 -0
- package/dist/modules/audit/audit-commands.d.ts +29 -0
- package/dist/modules/audit/audit-commands.js +40 -0
- package/dist/modules/audit/audit-queries.d.ts +41 -0
- package/dist/modules/audit/audit-queries.js +169 -0
- package/dist/modules/counters/counters-commands.d.ts +54 -0
- package/dist/modules/counters/counters-commands.js +121 -0
- package/dist/modules/counters/tests/counters-concurrency.test.d.ts +8 -0
- package/dist/modules/counters/tests/counters-concurrency.test.js +111 -0
- package/dist/modules/storage/classify-error.d.ts +34 -0
- package/dist/modules/storage/classify-error.js +50 -0
- package/dist/modules/storage/classify-error.test.node.d.ts +8 -0
- package/dist/modules/storage/classify-error.test.node.js +89 -0
- package/dist/modules/storage/normalize-row.d.ts +55 -0
- package/dist/modules/storage/normalize-row.js +135 -0
- package/dist/modules/storage/normalize-row.test.node.d.ts +8 -0
- package/dist/modules/storage/normalize-row.test.node.js +89 -0
- package/dist/modules/storage/storage-commands.d.ts +443 -0
- package/dist/modules/storage/storage-commands.js +1263 -0
- package/dist/modules/storage/storage-insert.d.ts +21 -0
- package/dist/modules/storage/storage-insert.js +152 -0
- package/dist/modules/storage/storage-queries.d.ts +805 -0
- package/dist/modules/storage/storage-queries.js +1815 -0
- package/dist/modules/storage/storage-store-manifest.d.ts +77 -0
- package/dist/modules/storage/storage-store-manifest.js +168 -0
- package/dist/modules/storage/storage-utils.d.ts +49 -0
- package/dist/modules/storage/storage-utils.js +76 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.d.ts +8 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.js +266 -0
- package/dist/modules/storage/tests/storage-commands.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-commands.test.js +324 -0
- package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-paths.test.js +214 -0
- package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-tree.test.js +361 -0
- package/dist/modules/storage/tests/storage-queries.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-queries.test.js +685 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.js +268 -0
- package/package.json +91 -0
|
@@ -0,0 +1,303 @@
|
|
|
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_ADMIN_USER_VERSION_CONFLICT, } from '@byline/admin/admin-users';
|
|
9
|
+
import { and, asc, desc, eq, inArray, like, or, sql } from 'drizzle-orm';
|
|
10
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
11
|
+
import { adminUsers } from '../../database/schema/auth.js';
|
|
12
|
+
import { affectedRowCount } from '../storage/storage-utils.js';
|
|
13
|
+
/**
|
|
14
|
+
* MySQL implementation of `AdminUsersRepository`. Ported from
|
|
15
|
+
* `packages/db-postgres/src/modules/admin/admin-users-repository.ts`.
|
|
16
|
+
*
|
|
17
|
+
* The DB column for the password hash is `password`; the public interface
|
|
18
|
+
* exposes it as `password_hash`. The mapping happens entirely inside this
|
|
19
|
+
* factory — callers speak the interface shape and never see the column
|
|
20
|
+
* name.
|
|
21
|
+
*
|
|
22
|
+
* Password hashing is *not* done here — the interface takes a pre-hashed
|
|
23
|
+
* PHC string. Callers (seed, admin-user commands) hash first via
|
|
24
|
+
* `hashPassword` from `@byline/admin/auth`.
|
|
25
|
+
*
|
|
26
|
+
* Divergences from the pg source (found live, not assumed):
|
|
27
|
+
* - `ilike` → `like`. `byline_admin_users` (like every table in this
|
|
28
|
+
* schema) uses the database's default `utf8mb4_0900_ai_ci` collation,
|
|
29
|
+
* which is itself accent- *and* case-insensitive — confirmed against
|
|
30
|
+
* the live container with a throwaway table (`'Alice@Example.com'`
|
|
31
|
+
* matched `LIKE '%alice%'`). So a plain `like()` already reproduces
|
|
32
|
+
* `ilike()`'s behaviour; MySQL has no `ILIKE` keyword to reach for.
|
|
33
|
+
* - `count(*)::int` → `CAST(COUNT(*) AS SIGNED)`, with a `Number()`
|
|
34
|
+
* normalisation at the result edge — mirrors the identical conversion
|
|
35
|
+
* in `storage-queries.ts` (mysql2 can return `BIGINT` aggregates as
|
|
36
|
+
* strings).
|
|
37
|
+
* - `.returning()` (4 sites: `create`, `update`, `setPasswordHash`,
|
|
38
|
+
* `delete`) → MySQL has no `RETURNING`. `create`'s id is app-generated
|
|
39
|
+
* UUIDv7 and every other column is either caller-supplied or a literal
|
|
40
|
+
* default, so the row is constructed in JS directly — the `created_at`/
|
|
41
|
+
* `updated_at` pair is captured once as `now` and used for *both* the
|
|
42
|
+
* `INSERT` values and the returned row, so there is no drift between
|
|
43
|
+
* what is persisted and what is returned (the failure mode the Task 9
|
|
44
|
+
* deferred-minor note warns about: two independent `new Date()` calls
|
|
45
|
+
* that can disagree). `update` and `setPasswordHash` re-`SELECT` the
|
|
46
|
+
* row after a successful guarded `UPDATE`, both statements sharing one
|
|
47
|
+
* `db.transaction()`, rather than merging the patch onto a pre-read
|
|
48
|
+
* snapshot — an earlier version of this file did the latter and was
|
|
49
|
+
* correct on accept/reject (the vid-guarded `UPDATE`'s affected-row
|
|
50
|
+
* count is still the sole accept/reject authority, exactly as pg's
|
|
51
|
+
* guarded `UPDATE … RETURNING` is) but not on return-value freshness:
|
|
52
|
+
* `recordLoginSuccess`/`recordLoginFailure` mutate `last_login`/
|
|
53
|
+
* `last_login_ip`/`failed_login_attempts` *without* bumping `vid` (by
|
|
54
|
+
* design, matching pg), so either racing against the pre-read left the
|
|
55
|
+
* returned object reporting stale values for those columns.
|
|
56
|
+
*
|
|
57
|
+
* A second version of this fix re-`SELECT`ed but as two separate
|
|
58
|
+
* statements outside a transaction — better, but still left a window: a
|
|
59
|
+
* `recordLoginSuccess`/`recordLoginFailure` committing *between* the
|
|
60
|
+
* guarded `UPDATE` and the re-`SELECT` would leak into the returned
|
|
61
|
+
* object. The transaction closes that too, and the mechanism is not a
|
|
62
|
+
* REPEATABLE-READ-snapshot argument (a snapshot would just as easily
|
|
63
|
+
* read the *pre*-mutation state) — it's that the guarded `UPDATE`
|
|
64
|
+
* acquires an exclusive lock on this row that InnoDB holds until the
|
|
65
|
+
* transaction commits, so `recordLoginSuccess`/`recordLoginFailure`
|
|
66
|
+
* issued against the same row block until after the re-`SELECT` has run
|
|
67
|
+
* and the transaction has committed. That is what makes "the re-`SELECT`
|
|
68
|
+
* closes the freshness gap entirely" actually true, rather than true
|
|
69
|
+
* modulo the specific window between the two statements. `delete` needs
|
|
70
|
+
* no read at all — it returns `void`, so the affected-row count is the
|
|
71
|
+
* entire contract.
|
|
72
|
+
*/
|
|
73
|
+
const PUBLIC_COLUMNS = {
|
|
74
|
+
id: adminUsers.id,
|
|
75
|
+
vid: adminUsers.vid,
|
|
76
|
+
given_name: adminUsers.given_name,
|
|
77
|
+
family_name: adminUsers.family_name,
|
|
78
|
+
username: adminUsers.username,
|
|
79
|
+
email: adminUsers.email,
|
|
80
|
+
remember_me: adminUsers.remember_me,
|
|
81
|
+
last_login: adminUsers.last_login,
|
|
82
|
+
last_login_ip: adminUsers.last_login_ip,
|
|
83
|
+
failed_login_attempts: adminUsers.failed_login_attempts,
|
|
84
|
+
is_super_admin: adminUsers.is_super_admin,
|
|
85
|
+
is_enabled: adminUsers.is_enabled,
|
|
86
|
+
is_email_verified: adminUsers.is_email_verified,
|
|
87
|
+
preferred_locale: adminUsers.preferred_locale,
|
|
88
|
+
created_at: adminUsers.created_at,
|
|
89
|
+
updated_at: adminUsers.updated_at,
|
|
90
|
+
};
|
|
91
|
+
const ORDER_COLUMN = {
|
|
92
|
+
given_name: adminUsers.given_name,
|
|
93
|
+
family_name: adminUsers.family_name,
|
|
94
|
+
email: adminUsers.email,
|
|
95
|
+
username: adminUsers.username,
|
|
96
|
+
created_at: adminUsers.created_at,
|
|
97
|
+
updated_at: adminUsers.updated_at,
|
|
98
|
+
};
|
|
99
|
+
export function createAdminUsersRepository(db) {
|
|
100
|
+
return {
|
|
101
|
+
async create(input) {
|
|
102
|
+
const id = uuidv7();
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const row = {
|
|
105
|
+
id,
|
|
106
|
+
vid: 1,
|
|
107
|
+
given_name: input.given_name ?? null,
|
|
108
|
+
family_name: input.family_name ?? null,
|
|
109
|
+
username: input.username ?? null,
|
|
110
|
+
email: input.email.toLowerCase(),
|
|
111
|
+
remember_me: false,
|
|
112
|
+
last_login: null,
|
|
113
|
+
last_login_ip: null,
|
|
114
|
+
failed_login_attempts: 0,
|
|
115
|
+
is_super_admin: input.is_super_admin ?? false,
|
|
116
|
+
is_enabled: input.is_enabled ?? false,
|
|
117
|
+
is_email_verified: input.is_email_verified ?? false,
|
|
118
|
+
preferred_locale: input.preferred_locale ?? null,
|
|
119
|
+
created_at: now,
|
|
120
|
+
updated_at: now,
|
|
121
|
+
};
|
|
122
|
+
await db.insert(adminUsers).values({
|
|
123
|
+
id: row.id,
|
|
124
|
+
email: row.email,
|
|
125
|
+
password: input.password_hash,
|
|
126
|
+
given_name: row.given_name,
|
|
127
|
+
family_name: row.family_name,
|
|
128
|
+
username: row.username,
|
|
129
|
+
is_super_admin: row.is_super_admin,
|
|
130
|
+
is_enabled: row.is_enabled,
|
|
131
|
+
is_email_verified: row.is_email_verified,
|
|
132
|
+
preferred_locale: row.preferred_locale,
|
|
133
|
+
created_at: now,
|
|
134
|
+
updated_at: now,
|
|
135
|
+
});
|
|
136
|
+
return row;
|
|
137
|
+
},
|
|
138
|
+
async getById(id) {
|
|
139
|
+
const [row] = await db.select(PUBLIC_COLUMNS).from(adminUsers).where(eq(adminUsers.id, id));
|
|
140
|
+
return row ?? null;
|
|
141
|
+
},
|
|
142
|
+
async getByIds(ids) {
|
|
143
|
+
if (ids.length === 0)
|
|
144
|
+
return [];
|
|
145
|
+
return db.select(PUBLIC_COLUMNS).from(adminUsers).where(inArray(adminUsers.id, ids));
|
|
146
|
+
},
|
|
147
|
+
async getByEmail(email) {
|
|
148
|
+
const [row] = await db
|
|
149
|
+
.select(PUBLIC_COLUMNS)
|
|
150
|
+
.from(adminUsers)
|
|
151
|
+
.where(eq(adminUsers.email, email.toLowerCase()));
|
|
152
|
+
return row ?? null;
|
|
153
|
+
},
|
|
154
|
+
async getByUsername(username) {
|
|
155
|
+
const [row] = await db
|
|
156
|
+
.select(PUBLIC_COLUMNS)
|
|
157
|
+
.from(adminUsers)
|
|
158
|
+
.where(eq(adminUsers.username, username));
|
|
159
|
+
return row ?? null;
|
|
160
|
+
},
|
|
161
|
+
async getByEmailForSignIn(email) {
|
|
162
|
+
const [row] = await db
|
|
163
|
+
.select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
|
|
164
|
+
.from(adminUsers)
|
|
165
|
+
.where(eq(adminUsers.email, email.toLowerCase()));
|
|
166
|
+
return row ?? null;
|
|
167
|
+
},
|
|
168
|
+
async getByIdForSignIn(id) {
|
|
169
|
+
const [row] = await db
|
|
170
|
+
.select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
|
|
171
|
+
.from(adminUsers)
|
|
172
|
+
.where(eq(adminUsers.id, id));
|
|
173
|
+
return row ?? null;
|
|
174
|
+
},
|
|
175
|
+
async list(options) {
|
|
176
|
+
const needle = options.query?.trim();
|
|
177
|
+
const filter = needle && needle.length > 0
|
|
178
|
+
? or(like(adminUsers.email, `%${needle}%`), like(adminUsers.given_name, `%${needle}%`), like(adminUsers.family_name, `%${needle}%`), like(adminUsers.username, `%${needle}%`))
|
|
179
|
+
: undefined;
|
|
180
|
+
const sortCol = ORDER_COLUMN[options.order];
|
|
181
|
+
const orderExpr = options.desc ? desc(sortCol) : asc(sortCol);
|
|
182
|
+
const offset = Math.max(0, (options.page - 1) * options.pageSize);
|
|
183
|
+
const query = db.select(PUBLIC_COLUMNS).from(adminUsers);
|
|
184
|
+
const filtered = filter ? query.where(filter) : query;
|
|
185
|
+
return filtered.orderBy(orderExpr).limit(options.pageSize).offset(offset);
|
|
186
|
+
},
|
|
187
|
+
async count(options) {
|
|
188
|
+
const needle = options?.query?.trim();
|
|
189
|
+
const filter = needle && needle.length > 0
|
|
190
|
+
? or(like(adminUsers.email, `%${needle}%`), like(adminUsers.given_name, `%${needle}%`), like(adminUsers.family_name, `%${needle}%`), like(adminUsers.username, `%${needle}%`))
|
|
191
|
+
: undefined;
|
|
192
|
+
const base = db.select({ value: sql `CAST(COUNT(*) AS SIGNED)` }).from(adminUsers);
|
|
193
|
+
const [row] = await (filter ? base.where(filter) : base);
|
|
194
|
+
return Number(row?.value ?? 0);
|
|
195
|
+
},
|
|
196
|
+
async update(id, expectedVid, patch) {
|
|
197
|
+
const now = new Date();
|
|
198
|
+
const updateSet = {
|
|
199
|
+
updated_at: now,
|
|
200
|
+
vid: sql `${adminUsers.vid} + 1`,
|
|
201
|
+
};
|
|
202
|
+
if (patch.given_name !== undefined)
|
|
203
|
+
updateSet.given_name = patch.given_name;
|
|
204
|
+
if (patch.family_name !== undefined)
|
|
205
|
+
updateSet.family_name = patch.family_name;
|
|
206
|
+
if (patch.username !== undefined)
|
|
207
|
+
updateSet.username = patch.username;
|
|
208
|
+
if (patch.email !== undefined)
|
|
209
|
+
updateSet.email = patch.email.toLowerCase();
|
|
210
|
+
if (patch.is_super_admin !== undefined)
|
|
211
|
+
updateSet.is_super_admin = patch.is_super_admin;
|
|
212
|
+
if (patch.is_enabled !== undefined)
|
|
213
|
+
updateSet.is_enabled = patch.is_enabled;
|
|
214
|
+
if (patch.is_email_verified !== undefined)
|
|
215
|
+
updateSet.is_email_verified = patch.is_email_verified;
|
|
216
|
+
if (patch.remember_me !== undefined)
|
|
217
|
+
updateSet.remember_me = patch.remember_me;
|
|
218
|
+
if (patch.preferred_locale !== undefined)
|
|
219
|
+
updateSet.preferred_locale = patch.preferred_locale;
|
|
220
|
+
// Guarded `UPDATE` + re-`SELECT` share one transaction — see this
|
|
221
|
+
// file's docblock for why the transaction boundary (not just the
|
|
222
|
+
// re-`SELECT`) is what actually closes the freshness gap.
|
|
223
|
+
return db.transaction(async (tx) => {
|
|
224
|
+
const result = await tx
|
|
225
|
+
.update(adminUsers)
|
|
226
|
+
.set(updateSet)
|
|
227
|
+
.where(and(eq(adminUsers.id, id), eq(adminUsers.vid, expectedVid)));
|
|
228
|
+
if (affectedRowCount(result) === 0)
|
|
229
|
+
throw ERR_ADMIN_USER_VERSION_CONFLICT();
|
|
230
|
+
const [fresh] = await tx
|
|
231
|
+
.select(PUBLIC_COLUMNS)
|
|
232
|
+
.from(adminUsers)
|
|
233
|
+
.where(eq(adminUsers.id, id));
|
|
234
|
+
if (!fresh)
|
|
235
|
+
throw ERR_ADMIN_USER_VERSION_CONFLICT();
|
|
236
|
+
return fresh;
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
async setPasswordHash(id, expectedVid, passwordHash) {
|
|
240
|
+
const now = new Date();
|
|
241
|
+
// Guarded `UPDATE` + re-`SELECT` share one transaction — see `update()`
|
|
242
|
+
// above and this file's docblock.
|
|
243
|
+
return db.transaction(async (tx) => {
|
|
244
|
+
const result = await tx
|
|
245
|
+
.update(adminUsers)
|
|
246
|
+
.set({
|
|
247
|
+
password: passwordHash,
|
|
248
|
+
updated_at: now,
|
|
249
|
+
vid: sql `${adminUsers.vid} + 1`,
|
|
250
|
+
})
|
|
251
|
+
.where(and(eq(adminUsers.id, id), eq(adminUsers.vid, expectedVid)));
|
|
252
|
+
if (affectedRowCount(result) === 0)
|
|
253
|
+
throw ERR_ADMIN_USER_VERSION_CONFLICT();
|
|
254
|
+
const [fresh] = await tx
|
|
255
|
+
.select(PUBLIC_COLUMNS)
|
|
256
|
+
.from(adminUsers)
|
|
257
|
+
.where(eq(adminUsers.id, id));
|
|
258
|
+
if (!fresh)
|
|
259
|
+
throw ERR_ADMIN_USER_VERSION_CONFLICT();
|
|
260
|
+
return fresh;
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
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));
|
|
268
|
+
},
|
|
269
|
+
async setPreferredLocale(id, locale) {
|
|
270
|
+
await db
|
|
271
|
+
.update(adminUsers)
|
|
272
|
+
.set({ preferred_locale: locale, updated_at: new Date(), vid: sql `${adminUsers.vid} + 1` })
|
|
273
|
+
.where(eq(adminUsers.id, id));
|
|
274
|
+
},
|
|
275
|
+
async recordLoginSuccess(id, ip) {
|
|
276
|
+
await db
|
|
277
|
+
.update(adminUsers)
|
|
278
|
+
.set({
|
|
279
|
+
last_login: new Date(),
|
|
280
|
+
last_login_ip: ip,
|
|
281
|
+
failed_login_attempts: 0,
|
|
282
|
+
updated_at: new Date(),
|
|
283
|
+
})
|
|
284
|
+
.where(eq(adminUsers.id, id));
|
|
285
|
+
},
|
|
286
|
+
async recordLoginFailure(id) {
|
|
287
|
+
await db
|
|
288
|
+
.update(adminUsers)
|
|
289
|
+
.set({
|
|
290
|
+
failed_login_attempts: sql `${adminUsers.failed_login_attempts} + 1`,
|
|
291
|
+
updated_at: new Date(),
|
|
292
|
+
})
|
|
293
|
+
.where(eq(adminUsers.id, id));
|
|
294
|
+
},
|
|
295
|
+
async delete(id, expectedVid) {
|
|
296
|
+
const result = await db
|
|
297
|
+
.delete(adminUsers)
|
|
298
|
+
.where(and(eq(adminUsers.id, id), eq(adminUsers.vid, expectedVid)));
|
|
299
|
+
if (affectedRowCount(result) === 0)
|
|
300
|
+
throw ERR_ADMIN_USER_VERSION_CONFLICT();
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* `@byline/db-mysql/admin` — MySQL implementations of the admin repository
|
|
10
|
+
* contracts declared in `@byline/admin`.
|
|
11
|
+
*
|
|
12
|
+
* Most callers want `createAdminStore(db)` — it bundles all five
|
|
13
|
+
* repositories into the `AdminStore` shape that `@byline/admin` consumers
|
|
14
|
+
* (the built-in `JwtSessionProvider`, `seedSuperAdmin`, admin-user and
|
|
15
|
+
* admin-role commands) expect. Individual factories remain exported for
|
|
16
|
+
* unusual cases (custom wiring, partial testing).
|
|
17
|
+
*
|
|
18
|
+
* No session-provider code lives here — `JwtSessionProvider` lives in
|
|
19
|
+
* `@byline/admin/auth`. This package only supplies the adapter-shaped
|
|
20
|
+
* pieces it implements. Mirrors `packages/db-postgres/src/modules/admin/index.ts`.
|
|
21
|
+
*/
|
|
22
|
+
export { createAdminPermissionsRepository } from './admin-permissions-repository.js';
|
|
23
|
+
export { createAdminPreferencesRepository } from './admin-preferences-repository.js';
|
|
24
|
+
export { createAdminRolesRepository } from './admin-roles-repository.js';
|
|
25
|
+
export { createAdminStore } from './admin-store.js';
|
|
26
|
+
export { createAdminUsersRepository } from './admin-users-repository.js';
|
|
27
|
+
export { createRefreshTokensRepository } from './refresh-tokens-repository.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* `@byline/db-mysql/admin` — MySQL implementations of the admin repository
|
|
10
|
+
* contracts declared in `@byline/admin`.
|
|
11
|
+
*
|
|
12
|
+
* Most callers want `createAdminStore(db)` — it bundles all five
|
|
13
|
+
* repositories into the `AdminStore` shape that `@byline/admin` consumers
|
|
14
|
+
* (the built-in `JwtSessionProvider`, `seedSuperAdmin`, admin-user and
|
|
15
|
+
* admin-role commands) expect. Individual factories remain exported for
|
|
16
|
+
* unusual cases (custom wiring, partial testing).
|
|
17
|
+
*
|
|
18
|
+
* No session-provider code lives here — `JwtSessionProvider` lives in
|
|
19
|
+
* `@byline/admin/auth`. This package only supplies the adapter-shaped
|
|
20
|
+
* pieces it implements. Mirrors `packages/db-postgres/src/modules/admin/index.ts`.
|
|
21
|
+
*/
|
|
22
|
+
export { createAdminPermissionsRepository } from './admin-permissions-repository.js';
|
|
23
|
+
export { createAdminPreferencesRepository } from './admin-preferences-repository.js';
|
|
24
|
+
export { createAdminRolesRepository } from './admin-roles-repository.js';
|
|
25
|
+
export { createAdminStore } from './admin-store.js';
|
|
26
|
+
export { createAdminUsersRepository } from './admin-users-repository.js';
|
|
27
|
+
export { createRefreshTokensRepository } from './refresh-tokens-repository.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { RefreshTokensRepository } from '@byline/admin/auth';
|
|
9
|
+
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
|
10
|
+
import type * as schema from '../../database/schema/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* MySQL implementation of `RefreshTokensRepository`, backing the built-in
|
|
13
|
+
* `JwtSessionProvider`. Ported from
|
|
14
|
+
* `packages/db-postgres/src/modules/admin/refresh-tokens-repository.ts`.
|
|
15
|
+
*
|
|
16
|
+
* `issue()`'s `.returning()` has no MySQL equivalent. Every field
|
|
17
|
+
* `RefreshTokenRow` exposes is either caller-supplied (`id`, `admin_user_id`,
|
|
18
|
+
* `token_hash`, `expires_at`, `user_agent`, `ip`) or a fresh row's literal
|
|
19
|
+
* default (`revoked_at`/`rotated_to_id`/`last_used_at` all `null`) — except
|
|
20
|
+
* `issued_at`, which the schema defaults to `CURRENT_TIMESTAMP(6)`. Rather
|
|
21
|
+
* than let the insert rely on that default and approximate the returned
|
|
22
|
+
* value with a second, independent `new Date()` call (the drift the Task 9
|
|
23
|
+
* deferred-minor note flags), `issued_at` — and the underlying table's
|
|
24
|
+
* `created_at`/`updated_at`, which `RefreshTokenRow` doesn't expose but
|
|
25
|
+
* which exist on the row — are set explicitly from one `now` captured
|
|
26
|
+
* before the insert and reused for the returned row, so there is no gap
|
|
27
|
+
* between what is persisted and what is returned. No re-`SELECT` needed.
|
|
28
|
+
*
|
|
29
|
+
* `revokeAllForUser` and `purgeExpired` only ever consumed
|
|
30
|
+
* `.returning({ id })` for its `.length` (the affected-row count); MySQL's
|
|
31
|
+
* driver result surfaces that directly via `affectedRows` — no row
|
|
32
|
+
* reconstruction needed at all.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createRefreshTokensRepository(db: MySql2Database<typeof schema>): RefreshTokensRepository;
|
|
@@ -0,0 +1,160 @@
|
|
|
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, lt } from 'drizzle-orm';
|
|
9
|
+
import { adminRefreshTokens } from '../../database/schema/auth.js';
|
|
10
|
+
import { affectedRowCount } from '../storage/storage-utils.js';
|
|
11
|
+
/**
|
|
12
|
+
* MySQL implementation of `RefreshTokensRepository`, backing the built-in
|
|
13
|
+
* `JwtSessionProvider`. Ported from
|
|
14
|
+
* `packages/db-postgres/src/modules/admin/refresh-tokens-repository.ts`.
|
|
15
|
+
*
|
|
16
|
+
* `issue()`'s `.returning()` has no MySQL equivalent. Every field
|
|
17
|
+
* `RefreshTokenRow` exposes is either caller-supplied (`id`, `admin_user_id`,
|
|
18
|
+
* `token_hash`, `expires_at`, `user_agent`, `ip`) or a fresh row's literal
|
|
19
|
+
* default (`revoked_at`/`rotated_to_id`/`last_used_at` all `null`) — except
|
|
20
|
+
* `issued_at`, which the schema defaults to `CURRENT_TIMESTAMP(6)`. Rather
|
|
21
|
+
* than let the insert rely on that default and approximate the returned
|
|
22
|
+
* value with a second, independent `new Date()` call (the drift the Task 9
|
|
23
|
+
* deferred-minor note flags), `issued_at` — and the underlying table's
|
|
24
|
+
* `created_at`/`updated_at`, which `RefreshTokenRow` doesn't expose but
|
|
25
|
+
* which exist on the row — are set explicitly from one `now` captured
|
|
26
|
+
* before the insert and reused for the returned row, so there is no gap
|
|
27
|
+
* between what is persisted and what is returned. No re-`SELECT` needed.
|
|
28
|
+
*
|
|
29
|
+
* `revokeAllForUser` and `purgeExpired` only ever consumed
|
|
30
|
+
* `.returning({ id })` for its `.length` (the affected-row count); MySQL's
|
|
31
|
+
* driver result surfaces that directly via `affectedRows` — no row
|
|
32
|
+
* reconstruction needed at all.
|
|
33
|
+
*/
|
|
34
|
+
export function createRefreshTokensRepository(db) {
|
|
35
|
+
return {
|
|
36
|
+
async issue(input) {
|
|
37
|
+
const now = new Date();
|
|
38
|
+
const row = {
|
|
39
|
+
id: input.id,
|
|
40
|
+
admin_user_id: input.admin_user_id,
|
|
41
|
+
token_hash: input.token_hash,
|
|
42
|
+
issued_at: now,
|
|
43
|
+
expires_at: input.expires_at,
|
|
44
|
+
revoked_at: null,
|
|
45
|
+
rotated_to_id: null,
|
|
46
|
+
last_used_at: null,
|
|
47
|
+
user_agent: input.user_agent ?? null,
|
|
48
|
+
ip: input.ip ?? null,
|
|
49
|
+
};
|
|
50
|
+
await db.insert(adminRefreshTokens).values({
|
|
51
|
+
id: row.id,
|
|
52
|
+
admin_user_id: row.admin_user_id,
|
|
53
|
+
token_hash: row.token_hash,
|
|
54
|
+
issued_at: now,
|
|
55
|
+
expires_at: row.expires_at,
|
|
56
|
+
user_agent: row.user_agent,
|
|
57
|
+
ip: row.ip,
|
|
58
|
+
created_at: now,
|
|
59
|
+
updated_at: now,
|
|
60
|
+
});
|
|
61
|
+
return row;
|
|
62
|
+
},
|
|
63
|
+
async findByHash(tokenHash) {
|
|
64
|
+
const [row] = await db
|
|
65
|
+
.select()
|
|
66
|
+
.from(adminRefreshTokens)
|
|
67
|
+
.where(eq(adminRefreshTokens.token_hash, tokenHash));
|
|
68
|
+
return row ?? null;
|
|
69
|
+
},
|
|
70
|
+
async findById(id) {
|
|
71
|
+
const [row] = await db.select().from(adminRefreshTokens).where(eq(adminRefreshTokens.id, id));
|
|
72
|
+
return row ?? null;
|
|
73
|
+
},
|
|
74
|
+
async touch(id, at = new Date()) {
|
|
75
|
+
await db
|
|
76
|
+
.update(adminRefreshTokens)
|
|
77
|
+
.set({ last_used_at: at, updated_at: new Date() })
|
|
78
|
+
.where(eq(adminRefreshTokens.id, id));
|
|
79
|
+
},
|
|
80
|
+
async markRotated(oldId, newId, at = new Date()) {
|
|
81
|
+
await db
|
|
82
|
+
.update(adminRefreshTokens)
|
|
83
|
+
.set({ revoked_at: at, rotated_to_id: newId, updated_at: new Date() })
|
|
84
|
+
.where(eq(adminRefreshTokens.id, oldId));
|
|
85
|
+
},
|
|
86
|
+
async revoke(id, at = new Date()) {
|
|
87
|
+
await db
|
|
88
|
+
.update(adminRefreshTokens)
|
|
89
|
+
.set({ revoked_at: at, updated_at: new Date() })
|
|
90
|
+
.where(and(eq(adminRefreshTokens.id, id), isNull(adminRefreshTokens.revoked_at)));
|
|
91
|
+
},
|
|
92
|
+
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;
|
|
117
|
+
},
|
|
118
|
+
async revokeAllForUser(adminUserId, at = new Date()) {
|
|
119
|
+
const result = await db
|
|
120
|
+
.update(adminRefreshTokens)
|
|
121
|
+
.set({ revoked_at: at, updated_at: new Date() })
|
|
122
|
+
.where(and(eq(adminRefreshTokens.admin_user_id, adminUserId), isNull(adminRefreshTokens.revoked_at)));
|
|
123
|
+
return affectedRowCount(result);
|
|
124
|
+
},
|
|
125
|
+
async purgeExpired(now = new Date()) {
|
|
126
|
+
const result = await db
|
|
127
|
+
.delete(adminRefreshTokens)
|
|
128
|
+
.where(lt(adminRefreshTokens.expires_at, now));
|
|
129
|
+
return affectedRowCount(result);
|
|
130
|
+
},
|
|
131
|
+
async listActiveForUser(adminUserId) {
|
|
132
|
+
return db
|
|
133
|
+
.select()
|
|
134
|
+
.from(adminRefreshTokens)
|
|
135
|
+
.where(and(eq(adminRefreshTokens.admin_user_id, adminUserId), isNull(adminRefreshTokens.revoked_at)));
|
|
136
|
+
},
|
|
137
|
+
async listAllForUser(adminUserId) {
|
|
138
|
+
return db
|
|
139
|
+
.select()
|
|
140
|
+
.from(adminRefreshTokens)
|
|
141
|
+
.where(eq(adminRefreshTokens.admin_user_id, adminUserId))
|
|
142
|
+
.orderBy(adminRefreshTokens.issued_at);
|
|
143
|
+
},
|
|
144
|
+
async listRotationChain(startId) {
|
|
145
|
+
const chain = [];
|
|
146
|
+
let cursor = startId;
|
|
147
|
+
for (let step = 0; cursor != null && step < 1000; step++) {
|
|
148
|
+
const [row] = await db
|
|
149
|
+
.select()
|
|
150
|
+
.from(adminRefreshTokens)
|
|
151
|
+
.where(eq(adminRefreshTokens.id, cursor));
|
|
152
|
+
if (!row)
|
|
153
|
+
break;
|
|
154
|
+
chain.push(row);
|
|
155
|
+
cursor = row.rotated_to_id;
|
|
156
|
+
}
|
|
157
|
+
return chain;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Append-only audit-log writes (docs/06-auth-and-security/02-auditability.md — Workstream 2). A deliberately
|
|
10
|
+
* dumb command: it inserts one row and knows nothing about *which* changes
|
|
11
|
+
* warrant an audit entry — that policy lives in the lifecycle services, which
|
|
12
|
+
* wrap the mutation + this append in `withTransaction` so they commit
|
|
13
|
+
* atomically. Runs on the `DBManager` executor, so an `append` issued inside a
|
|
14
|
+
* `withTransaction` boundary joins the ambient transaction. Mirrors
|
|
15
|
+
* `packages/db-postgres/src/modules/audit/audit-commands.ts` exactly — the
|
|
16
|
+
* transaction-join split is the load-bearing property, not the dialect.
|
|
17
|
+
*/
|
|
18
|
+
import type { AuditLogAppendInput, IAuditCommands } from '@byline/core';
|
|
19
|
+
import type { DBManager } from '../../lib/db-manager.js';
|
|
20
|
+
export declare class AuditCommands implements IAuditCommands {
|
|
21
|
+
private dbManager;
|
|
22
|
+
constructor(dbManager: DBManager);
|
|
23
|
+
/** Ambient transaction when a `withTransaction` boundary is open, else the pool. */
|
|
24
|
+
private get db();
|
|
25
|
+
append(input: AuditLogAppendInput): Promise<{
|
|
26
|
+
id: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
export declare function createAuditCommands(dbManager: DBManager): AuditCommands;
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { v7 as uuidv7 } from 'uuid';
|
|
9
|
+
import { auditLog } from '../../database/schema/index.js';
|
|
10
|
+
export class AuditCommands {
|
|
11
|
+
dbManager;
|
|
12
|
+
constructor(dbManager) {
|
|
13
|
+
this.dbManager = dbManager;
|
|
14
|
+
}
|
|
15
|
+
/** Ambient transaction when a `withTransaction` boundary is open, else the pool. */
|
|
16
|
+
get db() {
|
|
17
|
+
return this.dbManager.get();
|
|
18
|
+
}
|
|
19
|
+
async append(input) {
|
|
20
|
+
const id = uuidv7(); // time-ordered, so id ordering ≈ occurred_at ordering
|
|
21
|
+
await this.db.insert(auditLog).values({
|
|
22
|
+
id,
|
|
23
|
+
document_id: input.documentId ?? null,
|
|
24
|
+
collection_id: input.collectionId ?? null,
|
|
25
|
+
actor_id: input.actorId ?? null,
|
|
26
|
+
actor_realm: input.actorRealm,
|
|
27
|
+
action: input.action,
|
|
28
|
+
field: input.field ?? null,
|
|
29
|
+
// `?? null` only coerces undefined → SQL NULL; a real `false`/`0`/`''`
|
|
30
|
+
// before/after value is preserved.
|
|
31
|
+
before: input.before ?? null,
|
|
32
|
+
after: input.after ?? null,
|
|
33
|
+
// occurred_at defaults to CURRENT_TIMESTAMP(6) at the DB.
|
|
34
|
+
});
|
|
35
|
+
return { id };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function createAuditCommands(dbManager) {
|
|
39
|
+
return new AuditCommands(dbManager);
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Audit-log reads (docs/06-auth-and-security/02-auditability.md — Workstreams 3 & 4). Reads run on the pool
|
|
10
|
+
* directly — they never need to join an audit write's transaction — so this
|
|
11
|
+
* takes the raw `db` (drizzle over the pool) rather than the `DBManager`.
|
|
12
|
+
* Access scoping is the caller's responsibility (the document's own read
|
|
13
|
+
* gate); these queries do no scoping of their own. Mirrors
|
|
14
|
+
* `packages/db-postgres/src/modules/audit/audit-queries.ts` with the CAST /
|
|
15
|
+
* string-concat / result-shape adjustments the mysql2 driver requires — see
|
|
16
|
+
* inline comments at each divergence.
|
|
17
|
+
*/
|
|
18
|
+
import type { AuditLogPage, IAuditQueries } from '@byline/core';
|
|
19
|
+
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
|
20
|
+
import type * as schema from '../../database/schema/index.js';
|
|
21
|
+
type DatabaseConnection = MySql2Database<typeof schema>;
|
|
22
|
+
export declare class AuditQueries implements IAuditQueries {
|
|
23
|
+
private db;
|
|
24
|
+
constructor(db: DatabaseConnection);
|
|
25
|
+
getDocumentAuditLog(params: {
|
|
26
|
+
document_id: string;
|
|
27
|
+
page?: number;
|
|
28
|
+
page_size?: number;
|
|
29
|
+
}): Promise<AuditLogPage>;
|
|
30
|
+
findAuditLog(params: {
|
|
31
|
+
actorId?: string;
|
|
32
|
+
collectionId?: string;
|
|
33
|
+
action?: string;
|
|
34
|
+
from?: Date;
|
|
35
|
+
to?: Date;
|
|
36
|
+
page?: number;
|
|
37
|
+
page_size?: number;
|
|
38
|
+
}): Promise<AuditLogPage>;
|
|
39
|
+
}
|
|
40
|
+
export declare function createAuditQueries(db: DatabaseConnection): AuditQueries;
|
|
41
|
+
export {};
|