@axium/server 0.10.0 → 0.11.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.
@@ -34,7 +34,7 @@ async function OPTIONS(event) {
34
34
  }
35
35
  async function POST(event) {
36
36
  const { userId, email, name, response } = await parseBody(event, APIUserRegistration);
37
- const existing = await db.selectFrom('users').selectAll().where('email', '=', email).executeTakeFirst();
37
+ const existing = await db.selectFrom('users').selectAll().where('email', '=', email.toLowerCase()).executeTakeFirst();
38
38
  if (existing)
39
39
  error(409, { message: 'Email already in use' });
40
40
  const expectedChallenge = registrations.get(userId);
@@ -50,7 +50,7 @@ async function POST(event) {
50
50
  error(401, { message: 'Verification failed' });
51
51
  await db
52
52
  .insertInto('users')
53
- .values({ id: userId, name, email })
53
+ .values({ id: userId, name, email: email.toLowerCase() })
54
54
  .executeTakeFirstOrThrow()
55
55
  .catch(withError('Failed to create user'));
56
56
  await createPasskey({
@@ -60,7 +60,7 @@ async function POST(event) {
60
60
  deviceType: registrationInfo.credentialDeviceType,
61
61
  backedUp: registrationInfo.credentialBackedUp,
62
62
  }).catch(e => error(500, { message: 'Failed to create passkey' + (config.debug ? `: ${e.message}` : '') }));
63
- return await createSessionData(event, userId);
63
+ return await createSessionData(userId);
64
64
  }
65
65
  addRoute({
66
66
  path: '/api/register',
package/dist/api/users.js CHANGED
@@ -116,11 +116,11 @@ addRoute({
116
116
  error(401, { message: 'Verification failed' });
117
117
  switch (type) {
118
118
  case 'login':
119
- return await createSessionData(event, userId);
119
+ return await createSessionData(userId);
120
120
  case 'action':
121
121
  if ((Date.now() - passkey.createdAt.getTime()) / 60_000 < config.auth.passkey_probation)
122
122
  error(403, { message: 'You can not authorize sensitive actions with a newly created passkey' });
123
- return await createSessionData(event, userId, true);
123
+ return await createSessionData(userId, true);
124
124
  }
125
125
  },
126
126
  });
package/dist/auth.d.ts CHANGED
@@ -1,19 +1,15 @@
1
1
  import type { Passkey, Session, Verification } from '@axium/core/api';
2
2
  import type { User } from '@axium/core/user';
3
3
  import type { RequestEvent } from '@sveltejs/kit';
4
+ import { type Schema } from './database.js';
5
+ import type { Insertable } from 'kysely';
4
6
  export interface UserInternal extends User {
5
- password?: string | null;
6
- salt?: string | null;
7
+ isAdmin: boolean;
8
+ /** Tags are internal, roles are public */
9
+ tags: string[];
7
10
  }
8
11
  export declare function getUser(id: string): Promise<UserInternal>;
9
- export declare function updateUser({ id, ...user }: UserInternal): Promise<{
10
- id: string;
11
- name: string;
12
- email: string;
13
- emailVerified: Date | null | undefined;
14
- image: string | null | undefined;
15
- preferences: import("@axium/core/user").Preferences | undefined;
16
- }>;
12
+ export declare function updateUser({ id, ...user }: Insertable<Schema['users']>): Promise<UserInternal>;
17
13
  export interface SessionInternal extends Session {
18
14
  token: string;
19
15
  }
package/dist/cli.js CHANGED
@@ -51,17 +51,35 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  var e = new Error(message);
52
52
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
53
53
  });
54
+ import { formatDateRange } from '@axium/core/format';
54
55
  import { Argument, Option, program } from 'commander';
56
+ import { access } from 'node:fs/promises';
55
57
  import { join } from 'node:path/posix';
58
+ import { createInterface } from 'node:readline/promises';
56
59
  import { styleText } from 'node:util';
57
60
  import { getByString, isJSON, setByString } from 'utilium';
61
+ import z from 'zod/v4';
58
62
  import $pkg from '../package.json' with { type: 'json' };
59
63
  import { apps } from './apps.js';
60
64
  import config, { configFiles, saveConfigTo } from './config.js';
61
65
  import * as db from './database.js';
62
- import { _portActions, _portMethods, defaultOutput, exit, handleError, output, restrictedPorts } from './io.js';
66
+ import { _portActions, _portMethods, exit, handleError, output, restrictedPorts, setCommandTimeout, warn } from './io.js';
67
+ import { linkRoutes, listRouteLinks, unlinkRoutes } from './linking.js';
63
68
  import { getSpecifier, plugins, pluginText, resolvePlugin } from './plugins.js';
64
69
  import { serve } from './serve.js';
70
+ function readline() {
71
+ const rl = createInterface({
72
+ input: process.stdin,
73
+ output: process.stdout,
74
+ });
75
+ return Object.assign(rl, {
76
+ [Symbol.dispose]: rl.close.bind(rl),
77
+ });
78
+ }
79
+ function userText(user, bold = false) {
80
+ const text = `${user.name} <${user.email}> (${user.id})`;
81
+ return bold ? styleText('bold', text) : text;
82
+ }
65
83
  program
66
84
  .version($pkg.version)
67
85
  .name('axium')
@@ -89,13 +107,14 @@ const opts = {
89
107
  }),
90
108
  force: new Option('-f, --force', 'force the operation').default(false),
91
109
  global: new Option('-g, --global', 'apply the operation globally').default(false),
110
+ timeout: new Option('-t, --timeout <ms>', 'how long to wait for commands to complete.').default('1000').argParser(value => {
111
+ const timeout = parseInt(value);
112
+ if (!Number.isSafeInteger(timeout) || timeout < 0)
113
+ warn('Invalid timeout value, using default.');
114
+ setCommandTimeout(timeout);
115
+ }),
92
116
  };
93
- const axiumDB = program
94
- .command('db')
95
- .alias('database')
96
- .description('Manage the database')
97
- .option('-t, --timeout <ms>', 'how long to wait for commands to complete.', '1000')
98
- .addOption(opts.host);
117
+ const axiumDB = program.command('db').alias('database').description('Manage the database').addOption(opts.timeout).addOption(opts.host);
99
118
  axiumDB
100
119
  .command('init')
101
120
  .description('Initialize the database')
@@ -298,7 +317,7 @@ axiumPlugin
298
317
  exit(`Can't find a plugin matching "${search}"`);
299
318
  const specifier = getSpecifier(plugin);
300
319
  const _ = __addDisposableResource(env_4, db.connect(), true);
301
- await plugin.db_remove?.({ ...opt, output: defaultOutput }, db.database);
320
+ await plugin.hooks.remove?.(opt, db.database);
302
321
  for (const [path, data] of configFiles) {
303
322
  if (!data.plugins)
304
323
  continue;
@@ -317,6 +336,32 @@ axiumPlugin
317
336
  await result_4;
318
337
  }
319
338
  });
339
+ axiumPlugin
340
+ .command('init')
341
+ .alias('setup')
342
+ .alias('install')
343
+ .description('Initialize a plugin. This could include adding tables to the database or linking routes.')
344
+ .addOption(opts.timeout)
345
+ .argument('<plugin>', 'the plugin to initialize')
346
+ .action(async (search, opt) => {
347
+ const env_5 = { stack: [], error: void 0, hasError: false };
348
+ try {
349
+ const plugin = resolvePlugin(search);
350
+ if (!plugin)
351
+ exit(`Can't find a plugin matching "${search}"`);
352
+ const _ = __addDisposableResource(env_5, db.connect(), true);
353
+ await plugin.hooks.db_init?.({ force: false, ...opt, skip: true }, db.database);
354
+ }
355
+ catch (e_5) {
356
+ env_5.error = e_5;
357
+ env_5.hasError = true;
358
+ }
359
+ finally {
360
+ const result_5 = __disposeResources(env_5);
361
+ if (result_5)
362
+ await result_5;
363
+ }
364
+ });
320
365
  const axiumApps = program.command('apps').description('Manage Axium apps').addOption(opts.global);
321
366
  axiumApps
322
367
  .command('list')
@@ -338,19 +383,178 @@ axiumApps
338
383
  console.log(app.name, styleText('dim', `(${app.id})`));
339
384
  }
340
385
  });
386
+ const lookup = new Argument('<user>', 'the UUID or email of the user to operate on').argParser(async (lookup) => {
387
+ const value = await (lookup.includes('@') ? z.email() : z.uuid())
388
+ .parseAsync(lookup.toLowerCase())
389
+ .catch(() => exit('Invalid user ID or email.'));
390
+ db.connect();
391
+ const result = await db.database
392
+ .selectFrom('users')
393
+ .where(value.includes('@') ? 'email' : 'id', '=', value)
394
+ .selectAll()
395
+ .executeTakeFirst();
396
+ if (!result)
397
+ exit('No user with matching ID or email.');
398
+ return result;
399
+ });
400
+ /**
401
+ * Updates an array of strings by adding or removing items.
402
+ * Only returns whether the array was updated and diff text for what actually changed.
403
+ */
404
+ function diffUpdate(original, add, remove) {
405
+ const diffs = [];
406
+ // update the values
407
+ if (add) {
408
+ for (const role of add) {
409
+ if (original.includes(role))
410
+ continue;
411
+ original.push(role);
412
+ diffs.push(styleText('green', '+' + role));
413
+ }
414
+ }
415
+ if (remove)
416
+ original = original.filter(item => {
417
+ const allow = !remove.includes(item);
418
+ if (!allow)
419
+ diffs.push(styleText('red', '-' + item));
420
+ return allow;
421
+ });
422
+ return [!!diffs.length, original, diffs.join(', ')];
423
+ }
424
+ program
425
+ .command('user')
426
+ .description('Get or change information about a user')
427
+ .addArgument(lookup)
428
+ .option('-S, --sessions', 'show user sessions')
429
+ .option('-P, --passkeys', 'show user passkeys')
430
+ .option('--add-role <role...>', 'add roles to the user')
431
+ .option('--remove-role <role...>', 'remove roles from the user')
432
+ .option('--tag <tag...>', 'Add tags to the user')
433
+ .option('--untag <tag...>', 'Remove tags from the user')
434
+ .option('--delete', 'Delete the user')
435
+ .action(async (_user, opt) => {
436
+ const env_6 = { stack: [], error: void 0, hasError: false };
437
+ try {
438
+ let user = await _user;
439
+ const _ = __addDisposableResource(env_6, db.connect(), true);
440
+ const [updatedRoles, roles, rolesDiff] = diffUpdate(user.roles, opt.addRole, opt.removeRole);
441
+ const [updatedTags, tags, tagsDiff] = diffUpdate(user.tags, opt.tag, opt.untag);
442
+ if (updatedRoles || updatedTags) {
443
+ user = await db.database
444
+ .updateTable('users')
445
+ .set({ roles, tags })
446
+ .returningAll()
447
+ .executeTakeFirstOrThrow()
448
+ .then(u => {
449
+ if (updatedRoles && rolesDiff)
450
+ console.log(`> Updated roles: ${rolesDiff}`);
451
+ if (updatedTags && tagsDiff)
452
+ console.log(`> Updated tags: ${tagsDiff}`);
453
+ return u;
454
+ })
455
+ .catch(e => exit('Failed to update user roles: ' + e.message));
456
+ }
457
+ if (opt.delete) {
458
+ const env_7 = { stack: [], error: void 0, hasError: false };
459
+ try {
460
+ const rl = __addDisposableResource(env_7, readline(), false);
461
+ const confirmed = await rl
462
+ .question(`Are you sure you want to delete ${userText(user, true)}? (y/N) `)
463
+ .then(v => z.stringbool().parseAsync(v))
464
+ .catch(() => false);
465
+ if (!confirmed)
466
+ console.log(styleText('dim', '> Delete aborted.'));
467
+ else
468
+ await db.database
469
+ .deleteFrom('users')
470
+ .where('id', '=', user.id)
471
+ .executeTakeFirstOrThrow()
472
+ .then(() => console.log(styleText(['red', 'bold'], '> Deleted')))
473
+ .catch(e => exit('Failed to delete user: ' + e.message));
474
+ }
475
+ catch (e_6) {
476
+ env_7.error = e_6;
477
+ env_7.hasError = true;
478
+ }
479
+ finally {
480
+ __disposeResources(env_7);
481
+ }
482
+ }
483
+ console.log([
484
+ user.isAdmin && styleText('redBright', 'Administrator'),
485
+ 'UUID: ' + user.id,
486
+ 'Name: ' + user.name,
487
+ `Email: ${user.email}, ${user.emailVerified ? 'verified on ' + formatDateRange(user.emailVerified) : styleText(config.auth.email_verification ? 'yellow' : 'dim', 'not verified')}`,
488
+ 'Registered ' + formatDateRange(user.registeredAt),
489
+ `Roles: ${user.roles.length ? user.roles.join(', ') : styleText('dim', '(none)')}`,
490
+ `Tags: ${user.tags.length ? user.tags.join(', ') : styleText('dim', '(none)')}`,
491
+ ]
492
+ .filter(Boolean)
493
+ .join('\n'));
494
+ if (opt.sessions) {
495
+ const sessions = await db.database.selectFrom('sessions').where('userId', '=', user.id).selectAll().execute();
496
+ console.log(styleText('bold', 'Sessions:'));
497
+ if (!sessions.length)
498
+ console.log(styleText('dim', '(none)'));
499
+ else
500
+ for (const session of sessions) {
501
+ console.log(`\t${session.id}\tcreated ${formatDateRange(session.created).padEnd(40)}\texpires ${formatDateRange(session.expires).padEnd(40)}\t${session.elevated ? styleText('yellow', '(elevated)') : ''}`);
502
+ }
503
+ }
504
+ if (opt.passkeys) {
505
+ const passkeys = await db.database.selectFrom('passkeys').where('userId', '=', user.id).selectAll().execute();
506
+ console.log(styleText('bold', 'Passkeys:'));
507
+ for (const passkey of passkeys) {
508
+ console.log(`\t${passkey.id}: created ${formatDateRange(passkey.createdAt).padEnd(40)} used ${passkey.counter} times. ${passkey.deviceType}, ${passkey.backedUp ? '' : 'not '}backed up; transports are [${passkey.transports.join(', ')}], ${passkey.name ? 'named ' + JSON.stringify(passkey.name) : 'unnamed'}.`);
509
+ }
510
+ }
511
+ }
512
+ catch (e_7) {
513
+ env_6.error = e_7;
514
+ env_6.hasError = true;
515
+ }
516
+ finally {
517
+ const result_6 = __disposeResources(env_6);
518
+ if (result_6)
519
+ await result_6;
520
+ }
521
+ });
522
+ program
523
+ .command('toggle-admin')
524
+ .description('Toggle whether a user is an administrator')
525
+ .addArgument(lookup)
526
+ .action(async (_user) => {
527
+ const env_8 = { stack: [], error: void 0, hasError: false };
528
+ try {
529
+ const user = await _user;
530
+ const _ = __addDisposableResource(env_8, db.connect(), true);
531
+ const isAdmin = !user.isAdmin;
532
+ await db.database.updateTable('users').set({ isAdmin }).where('id', '=', user.id).executeTakeFirstOrThrow();
533
+ console.log(`${userText(user)} is ${isAdmin ? 'now' : 'no longer'} an administrator. (${styleText(['whiteBright', 'bold'], isAdmin.toString())})`);
534
+ }
535
+ catch (e_8) {
536
+ env_8.error = e_8;
537
+ env_8.hasError = true;
538
+ }
539
+ finally {
540
+ const result_7 = __disposeResources(env_8);
541
+ if (result_7)
542
+ await result_7;
543
+ }
544
+ });
341
545
  program
342
546
  .command('status')
343
547
  .alias('stats')
344
548
  .description('Get information about the server')
345
549
  .addOption(opts.host)
346
550
  .action(async () => {
347
- const env_5 = { stack: [], error: void 0, hasError: false };
551
+ const env_9 = { stack: [], error: void 0, hasError: false };
348
552
  try {
349
553
  console.log('Axium Server v' + program.version());
350
554
  console.log(styleText('whiteBright', 'Debug mode:'), config.debug ? styleText('yellow', 'enabled') : 'disabled');
351
555
  console.log(styleText('whiteBright', 'Loaded config files:'), config.files.keys().toArray().join(', '));
352
556
  process.stdout.write(styleText('whiteBright', 'Database: '));
353
- const _ = __addDisposableResource(env_5, db.connect(), true);
557
+ const _ = __addDisposableResource(env_9, db.connect(), true);
354
558
  try {
355
559
  console.log(await db.statusText());
356
560
  }
@@ -367,14 +571,14 @@ program
367
571
  console.log(await plugin.statusText());
368
572
  }
369
573
  }
370
- catch (e_5) {
371
- env_5.error = e_5;
372
- env_5.hasError = true;
574
+ catch (e_9) {
575
+ env_9.error = e_9;
576
+ env_9.hasError = true;
373
577
  }
374
578
  finally {
375
- const result_5 = __disposeResources(env_5);
376
- if (result_5)
377
- await result_5;
579
+ const result_8 = __disposeResources(env_9);
580
+ if (result_8)
581
+ await result_8;
378
582
  }
379
583
  });
380
584
  program
@@ -411,4 +615,18 @@ program
411
615
  console.log('Server is listening on port ' + port);
412
616
  });
413
617
  });
618
+ program.command('link').description('Link svelte page routes').action(linkRoutes);
619
+ program.command('unlink').description('Unlink svelte page routes').action(unlinkRoutes);
620
+ program
621
+ .command('list-links')
622
+ .description('List linked routes')
623
+ .action(async () => {
624
+ for (const link of listRouteLinks()) {
625
+ const idText = link.id.startsWith('#') ? `(${link.id.slice(1)})` : link.id;
626
+ const toColor = await access(link.to)
627
+ .then(() => 'white')
628
+ .catch(() => 'redBright');
629
+ console.log(`${idText}:\t ${styleText('cyanBright', link.from)}\t->\t${styleText(toColor, link.to)}`);
630
+ }
631
+ });
414
632
  program.parse();
package/dist/config.d.ts CHANGED
@@ -34,12 +34,15 @@ export interface Config extends Record<string, unknown> {
34
34
  console: boolean;
35
35
  };
36
36
  web: {
37
- prefix: string;
38
37
  assets: string;
39
- secure: boolean;
38
+ disable_cache: boolean;
40
39
  port: number;
40
+ prefix: string;
41
+ routes: string;
42
+ secure: boolean;
41
43
  ssl_key: string;
42
44
  ssl_cert: string;
45
+ template: string;
43
46
  };
44
47
  }
45
48
  export declare const configFiles: Map<string, File>;
@@ -92,12 +95,15 @@ export declare const File: z.ZodObject<{
92
95
  console: z.ZodOptional<z.ZodBoolean>;
93
96
  }, z.core.$strip>>;
94
97
  web: z.ZodOptional<z.ZodObject<{
95
- prefix: z.ZodOptional<z.ZodString>;
96
98
  assets: z.ZodOptional<z.ZodString>;
97
- secure: z.ZodOptional<z.ZodBoolean>;
99
+ disable_cache: z.ZodOptional<z.ZodBoolean>;
98
100
  port: z.ZodOptional<z.ZodNumber>;
101
+ prefix: z.ZodOptional<z.ZodString>;
102
+ routes: z.ZodOptional<z.ZodString>;
103
+ secure: z.ZodOptional<z.ZodBoolean>;
99
104
  ssl_key: z.ZodOptional<z.ZodString>;
100
105
  ssl_cert: z.ZodOptional<z.ZodString>;
106
+ template: z.ZodOptional<z.ZodString>;
101
107
  }, z.core.$strip>>;
102
108
  include: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
103
109
  plugins: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
package/dist/config.js CHANGED
@@ -51,12 +51,15 @@ export const config = _unique('config', {
51
51
  level: 'info',
52
52
  },
53
53
  web: {
54
- prefix: '',
55
54
  assets: '',
56
- secure: true,
55
+ disable_cache: false,
57
56
  port: 443,
57
+ prefix: '',
58
+ routes: 'routes',
59
+ secure: true,
58
60
  ssl_key: resolve(dirs[0], 'ssl_key.pem'),
59
61
  ssl_cert: resolve(dirs[0], 'ssl_cert.pem'),
62
+ template: join(import.meta.dirname, '../web/template.html'),
60
63
  },
61
64
  });
62
65
  export default config;
@@ -106,12 +109,15 @@ export const File = z
106
109
  .partial(),
107
110
  web: z
108
111
  .object({
109
- prefix: z.string(),
110
112
  assets: z.string(),
111
- secure: z.boolean(),
113
+ disable_cache: z.boolean(),
112
114
  port: z.number().min(1).max(65535),
115
+ prefix: z.string(),
116
+ routes: z.string(),
117
+ secure: z.boolean(),
113
118
  ssl_key: z.string(),
114
119
  ssl_cert: z.string(),
120
+ template: z.string(),
115
121
  })
116
122
  .partial(),
117
123
  include: z.array(z.string()).optional(),
@@ -152,7 +158,7 @@ export async function loadConfig(path, options = {}) {
152
158
  catch (e) {
153
159
  if (!options.optional)
154
160
  throw e;
155
- config.debug && output.debug(`Skipping config at ${path} (${e.message})`);
161
+ output.debug(`Skipping config at ${path} (${e.message})`);
156
162
  return;
157
163
  }
158
164
  const file = options.strict ? File.parse(json) : json;
@@ -184,7 +190,7 @@ export function saveConfigTo(path, changed) {
184
190
  setConfig(changed);
185
191
  const config = configFiles.get(path) ?? {};
186
192
  Object.assign(config, { ...changed, db: { ...config.db, ...changed.db } });
187
- config.debug && output.debug(`Wrote config to ${path}`);
193
+ output.debug(`Wrote config to ${path}`);
188
194
  writeFileSync(path, JSON.stringify(config));
189
195
  }
190
196
  /**
@@ -1,16 +1,19 @@
1
1
  import type { Preferences } from '@axium/core';
2
+ import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server';
2
3
  import { Kysely, type GeneratedAlways } from 'kysely';
3
4
  import type { VerificationRole } from './auth.js';
4
- import type { MaybeOutput, WithOutput } from './io.js';
5
- import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server';
6
5
  export interface Schema {
7
6
  users: {
8
- id: GeneratedAlways<string> & string;
7
+ id: string;
9
8
  email: string;
10
9
  name: string;
11
10
  image?: string | null;
12
11
  emailVerified?: Date | null;
13
- preferences?: Preferences;
12
+ preferences: Preferences;
13
+ isAdmin: boolean;
14
+ roles: string[];
15
+ tags: string[];
16
+ registeredAt: GeneratedAlways<Date>;
14
17
  };
15
18
  sessions: {
16
19
  id: GeneratedAlways<string>;
@@ -49,19 +52,16 @@ export interface Stats {
49
52
  export declare function count<const T extends keyof Schema>(table: T): Promise<number>;
50
53
  export declare function status(): Promise<Stats>;
51
54
  export declare function statusText(): Promise<string>;
52
- export interface OpOptions extends MaybeOutput {
53
- timeout: number;
55
+ export interface OpOptions {
54
56
  force: boolean;
55
57
  }
56
58
  export interface InitOptions extends OpOptions {
57
59
  skip: boolean;
58
60
  }
59
- export declare function shouldRecreate(opt: InitOptions & WithOutput): boolean;
60
- export declare function getHBA(opt: OpOptions & WithOutput): Promise<[content: string, writeBack: (newContent: string) => void]>;
61
- export interface PluginShortcuts {
62
- done: () => void;
63
- warnExists: (error: string | Error) => void;
64
- }
61
+ export declare function shouldRecreate(opt: InitOptions): boolean;
62
+ export declare function getHBA(opt: OpOptions): Promise<[content: string, writeBack: (newContent: string) => void]>;
63
+ /** Shortcut to output a warning if an error is thrown because relation already exists */
64
+ export declare const warnExists: (error: string | Error) => void;
65
65
  export declare function init(opt: InitOptions): Promise<void>;
66
66
  export declare function check(opt: OpOptions): Promise<void>;
67
67
  export declare function clean(opt: Partial<OpOptions>): Promise<void>;