@chidchanun/bcp 0.1.14 → 0.1.16

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.
@@ -66,6 +66,11 @@ switch (cliOptions.command) {
66
66
  break;
67
67
  }
68
68
 
69
+ case "db": {
70
+ await runDatabaseCommand();
71
+ break;
72
+ }
73
+
69
74
  case "help": {
70
75
  printHelp();
71
76
  break;
@@ -381,6 +386,86 @@ async function runUpdate() {
381
386
  });
382
387
  }
383
388
 
389
+ async function runDatabaseCommand() {
390
+ const action =
391
+ cliOptions.dbAction;
392
+
393
+ if (
394
+ action === undefined ||
395
+ action === "help"
396
+ ) {
397
+ printDatabaseHelp();
398
+ return;
399
+ }
400
+
401
+ const rootDirectory =
402
+ resolveProjectRoot(
403
+ cliOptions.rootDirectory
404
+ );
405
+
406
+ process.env.NODE_ENV =
407
+ process.env.NODE_ENV ??
408
+ "development";
409
+
410
+ const {
411
+ loadEnvironment,
412
+ } =
413
+ await import(
414
+ "../../env/src/index.js"
415
+ );
416
+
417
+ const environment =
418
+ loadEnvironment(
419
+ rootDirectory,
420
+ "development"
421
+ );
422
+
423
+ if (
424
+ environment.files.length > 0
425
+ ) {
426
+ console.log(
427
+ `[BCP Env] development: ${environment.files.join(", ")}`
428
+ );
429
+ }
430
+
431
+ const {
432
+ runDatabaseMigrationCommand,
433
+ } =
434
+ await import(
435
+ "./database-migrations.js"
436
+ );
437
+
438
+ if (
439
+ action === "create"
440
+ ) {
441
+ await runDatabaseMigrationCommand({
442
+ rootDirectory,
443
+ action,
444
+ name:
445
+ cliOptions.dbMigrationName,
446
+ });
447
+ return;
448
+ }
449
+
450
+ const {
451
+ db,
452
+ } =
453
+ await import(
454
+ "../../client/src/database.js"
455
+ );
456
+
457
+ try {
458
+ await runDatabaseMigrationCommand({
459
+ rootDirectory,
460
+ action,
461
+ database:
462
+ db,
463
+ });
464
+ } finally {
465
+ await db.close();
466
+ }
467
+ }
468
+
384
469
  function installShutdownHandlers(
385
470
  server: {
386
471
  stop(): Promise<void>;
@@ -521,6 +606,28 @@ function formatBytes(
521
606
  ).toFixed(2)} MiB`;
522
607
  }
523
608
 
609
+ function printDatabaseHelp() {
610
+ console.log(`
611
+ BCP Database Migrations
612
+
613
+ Usage:
614
+ bcp db <command> [arguments]
615
+
616
+ Commands:
617
+ create <name> Create a timestamped migration in migrations/
618
+ migrate Run all pending migrations
619
+ status Show applied and pending migrations
620
+ rollback Roll back the latest migration batch
621
+ help Show database command help
622
+
623
+ Examples:
624
+ bcp db create create_users
625
+ bcp db migrate
626
+ bcp db status
627
+ bcp db rollback
628
+ `);
629
+ }
630
+
524
631
  function printHelp() {
525
632
  console.log(`
526
633
  BCP Framework v${FRAMEWORK_VERSION}
@@ -534,6 +641,7 @@ Commands:
534
641
  start Start the standalone production build
535
642
  routes Print discovered page and API routes
536
643
  update [target] Update BCP Framework (default target: latest)
644
+ db <command> Manage database migrations
537
645
  help Show this help message
538
646
  version Show framework version
539
647
 
@@ -558,6 +666,9 @@ Examples:
558
666
  bcp routes
559
667
  bcp update
560
668
  bcp update --check
561
- bcp update 0.1.10
669
+ bcp db create create_users
670
+ bcp db migrate
671
+ bcp db status
672
+ bcp db rollback
562
673
  `);
563
674
  }
@@ -0,0 +1,14 @@
1
+ export {
2
+ auth,
3
+ createAuth,
4
+ getSession,
5
+ login,
6
+ logout,
7
+ rotateSession,
8
+
9
+ type AuthApi,
10
+ type AuthLoginOptions,
11
+ type AuthOptions,
12
+ type AuthSession,
13
+ type AuthUser,
14
+ } from "../../server/src/auth.js";
@@ -0,0 +1,376 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ import {
6
+ createSession,
7
+ destroySession,
8
+ getSession as readSession,
9
+ type SessionClaims,
10
+ type SessionCookieOptions,
11
+ } from "./session.js";
12
+
13
+ export interface AuthUser {
14
+ id: string | number;
15
+ }
16
+
17
+ export interface AuthSession<
18
+ TUser extends AuthUser = AuthUser,
19
+ TData extends object = Record<string, never>
20
+ > extends SessionClaims {
21
+ sid: string;
22
+ user: TUser;
23
+ data?: TData;
24
+ }
25
+
26
+ export interface AuthOptions
27
+ extends SessionCookieOptions {}
28
+
29
+ export interface AuthLoginOptions<
30
+ TData extends object = Record<string, never>
31
+ > extends AuthOptions {
32
+ data?: TData;
33
+ }
34
+
35
+ export interface AuthApi<
36
+ TUser extends AuthUser = AuthUser,
37
+ TData extends object = Record<string, never>
38
+ > {
39
+ auth(
40
+ options?: AuthOptions
41
+ ): Promise<AuthSession<TUser, TData> | null>;
42
+ getSession(
43
+ options?: AuthOptions
44
+ ): Promise<AuthSession<TUser, TData> | null>;
45
+ login(
46
+ user: TUser,
47
+ options?: AuthLoginOptions<TData>
48
+ ): Promise<AuthSession<TUser, TData>>;
49
+ logout(
50
+ options?: AuthOptions
51
+ ): Promise<void>;
52
+ rotateSession(
53
+ options?: AuthOptions
54
+ ): Promise<AuthSession<TUser, TData> | null>;
55
+ }
56
+
57
+ interface AuthSessionPayload<
58
+ TUser extends AuthUser,
59
+ TData extends object
60
+ > {
61
+ sid: string;
62
+ user: TUser;
63
+ data?: TData;
64
+ }
65
+
66
+ export async function auth<
67
+ TUser extends AuthUser = AuthUser,
68
+ TData extends object = Record<string, never>
69
+ >(
70
+ options: AuthOptions = {}
71
+ ): Promise<AuthSession<TUser, TData> | null> {
72
+ const session =
73
+ await readSession<
74
+ AuthSessionPayload<
75
+ TUser,
76
+ TData
77
+ >
78
+ >(
79
+ options
80
+ );
81
+
82
+ if (!session) {
83
+ return null;
84
+ }
85
+
86
+ if (
87
+ typeof session.sid !==
88
+ "string" ||
89
+ session.sid.trim().length ===
90
+ 0 ||
91
+ !isAuthUser(
92
+ session.user
93
+ )
94
+ ) {
95
+ return null;
96
+ }
97
+
98
+ if (
99
+ session.data !== undefined &&
100
+ !isPlainObject(
101
+ session.data
102
+ )
103
+ ) {
104
+ return null;
105
+ }
106
+
107
+ return session as
108
+ AuthSession<
109
+ TUser,
110
+ TData
111
+ >;
112
+ }
113
+
114
+ export async function getSession<
115
+ TUser extends AuthUser = AuthUser,
116
+ TData extends object = Record<string, never>
117
+ >(
118
+ options: AuthOptions = {}
119
+ ): Promise<AuthSession<TUser, TData> | null> {
120
+ return auth<
121
+ TUser,
122
+ TData
123
+ >(
124
+ options
125
+ );
126
+ }
127
+
128
+ export async function login<
129
+ TUser extends AuthUser,
130
+ TData extends object = Record<string, never>
131
+ >(
132
+ user: TUser,
133
+ options:
134
+ AuthLoginOptions<TData> = {}
135
+ ): Promise<AuthSession<TUser, TData>> {
136
+ assertAuthUser(
137
+ user
138
+ );
139
+
140
+ if (
141
+ options.data !== undefined &&
142
+ !isPlainObject(
143
+ options.data
144
+ )
145
+ ) {
146
+ throw new TypeError(
147
+ "BCP Auth: session data must be a plain object."
148
+ );
149
+ }
150
+
151
+ const {
152
+ data,
153
+ ...sessionOptions
154
+ } = options;
155
+ const payload:
156
+ AuthSessionPayload<
157
+ TUser,
158
+ TData
159
+ > = {
160
+ sid:
161
+ randomUUID(),
162
+ user,
163
+ ...(data === undefined
164
+ ? {}
165
+ : {
166
+ data,
167
+ }),
168
+ };
169
+
170
+ await createSession(
171
+ payload,
172
+ sessionOptions
173
+ );
174
+
175
+ const session =
176
+ await auth<
177
+ TUser,
178
+ TData
179
+ >(
180
+ sessionOptions
181
+ );
182
+
183
+ if (!session) {
184
+ throw new Error(
185
+ "BCP Auth: login created a session but it could not be read back."
186
+ );
187
+ }
188
+
189
+ return session;
190
+ }
191
+
192
+ export async function logout(
193
+ options: AuthOptions = {}
194
+ ): Promise<void> {
195
+ await destroySession(
196
+ options
197
+ );
198
+ }
199
+
200
+ export async function rotateSession<
201
+ TUser extends AuthUser = AuthUser,
202
+ TData extends object = Record<string, never>
203
+ >(
204
+ options: AuthOptions = {}
205
+ ): Promise<AuthSession<TUser, TData> | null> {
206
+ const current =
207
+ await auth<
208
+ TUser,
209
+ TData
210
+ >(
211
+ options
212
+ );
213
+
214
+ if (!current) {
215
+ return null;
216
+ }
217
+
218
+ return login<
219
+ TUser,
220
+ TData
221
+ >(
222
+ current.user,
223
+ {
224
+ ...options,
225
+ issuer:
226
+ options.issuer ??
227
+ current.iss,
228
+ audience:
229
+ options.audience ??
230
+ current.aud,
231
+ ...(current.data === undefined
232
+ ? {}
233
+ : {
234
+ data:
235
+ current.data,
236
+ }),
237
+ }
238
+ );
239
+ }
240
+
241
+ export function createAuth<
242
+ TUser extends AuthUser = AuthUser,
243
+ TData extends object = Record<string, never>
244
+ >(
245
+ defaults: AuthOptions = {}
246
+ ): AuthApi<TUser, TData> {
247
+ return {
248
+ auth: (
249
+ options = {}
250
+ ) =>
251
+ auth<
252
+ TUser,
253
+ TData
254
+ >({
255
+ ...defaults,
256
+ ...options,
257
+ }),
258
+ getSession: (
259
+ options = {}
260
+ ) =>
261
+ auth<
262
+ TUser,
263
+ TData
264
+ >({
265
+ ...defaults,
266
+ ...options,
267
+ }),
268
+ login: (
269
+ user,
270
+ options = {}
271
+ ) => {
272
+ const {
273
+ data,
274
+ ...sessionOptions
275
+ } = options;
276
+
277
+ return login<
278
+ TUser,
279
+ TData
280
+ >(
281
+ user,
282
+ {
283
+ ...defaults,
284
+ ...sessionOptions,
285
+ ...(data === undefined
286
+ ? {}
287
+ : {
288
+ data,
289
+ }),
290
+ }
291
+ );
292
+ },
293
+ logout: (
294
+ options = {}
295
+ ) =>
296
+ logout({
297
+ ...defaults,
298
+ ...options,
299
+ }),
300
+ rotateSession: (
301
+ options = {}
302
+ ) =>
303
+ rotateSession<
304
+ TUser,
305
+ TData
306
+ >({
307
+ ...defaults,
308
+ ...options,
309
+ }),
310
+ };
311
+ }
312
+
313
+ function assertAuthUser(
314
+ value: unknown
315
+ ): asserts value is AuthUser {
316
+ if (
317
+ !isAuthUser(
318
+ value
319
+ )
320
+ ) {
321
+ throw new TypeError(
322
+ "BCP Auth: user must be a plain object with a non-empty string or finite numeric id."
323
+ );
324
+ }
325
+ }
326
+
327
+ function isAuthUser(
328
+ value: unknown
329
+ ): value is AuthUser {
330
+ if (
331
+ !isPlainObject(
332
+ value
333
+ )
334
+ ) {
335
+ return false;
336
+ }
337
+
338
+ const id =
339
+ value.id;
340
+
341
+ return (
342
+ typeof id === "string" &&
343
+ id.trim().length > 0
344
+ ) || (
345
+ typeof id === "number" &&
346
+ Number.isFinite(
347
+ id
348
+ )
349
+ );
350
+ }
351
+
352
+ function isPlainObject(
353
+ value: unknown
354
+ ): value is Record<string, unknown> {
355
+ if (
356
+ value === null ||
357
+ typeof value !== "object" ||
358
+ Array.isArray(
359
+ value
360
+ )
361
+ ) {
362
+ return false;
363
+ }
364
+
365
+ const prototype =
366
+ Object.getPrototypeOf(
367
+ value
368
+ );
369
+
370
+ return (
371
+ prototype ===
372
+ Object.prototype ||
373
+ prototype ===
374
+ null
375
+ );
376
+ }