@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.
- package/docs/authentication.md +201 -0
- package/docs/database-migrations.md +105 -0
- package/docs/releases/0.1.16.md +53 -0
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +13 -3
- package/packages/cli/src/args.ts +71 -1
- package/packages/cli/src/database-migrations.ts +708 -0
- package/packages/cli/src/index.ts +112 -1
- package/packages/client/src/auth.ts +14 -0
- package/packages/server/src/auth.ts +376 -0
|
@@ -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
|
|
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,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
|
+
}
|