@axium/server 0.10.0 → 0.11.1

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/plugins.d.ts CHANGED
@@ -1,23 +1,37 @@
1
1
  import z from 'zod/v4';
2
- export declare const fn: z.ZodCustom<(...args: unknown[]) => any, (...args: unknown[]) => any>;
2
+ import type { Database, InitOptions, OpOptions } from './database.js';
3
+ export declare const PluginMetadata: z.ZodObject<{
4
+ name: z.ZodString;
5
+ version: z.ZodString;
6
+ description: z.ZodOptional<z.ZodString>;
7
+ routes: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$loose>;
3
9
  export declare const Plugin: z.ZodObject<{
4
10
  name: z.ZodString;
5
11
  version: z.ZodString;
6
12
  description: z.ZodOptional<z.ZodString>;
13
+ routes: z.ZodOptional<z.ZodString>;
7
14
  statusText: z.ZodCustom<z.core.$InferInnerFunctionTypeAsync<z.core.$ZodTuple<[], null>, z.ZodString>, z.core.$InferInnerFunctionTypeAsync<z.core.$ZodTuple<[], null>, z.ZodString>>;
8
- db_init: z.ZodOptional<z.ZodCustom<(...args: unknown[]) => any, (...args: unknown[]) => any>>;
9
- db_remove: z.ZodOptional<z.ZodCustom<(...args: unknown[]) => any, (...args: unknown[]) => any>>;
10
- db_wipe: z.ZodOptional<z.ZodCustom<(...args: unknown[]) => any, (...args: unknown[]) => any>>;
11
- db_clean: z.ZodOptional<z.ZodCustom<(...args: unknown[]) => any, (...args: unknown[]) => any>>;
12
- }, z.core.$strip>;
15
+ hooks: z.ZodOptional<z.ZodRecord<z.ZodUnion<[z.ZodLiteral<"remove" | "db_init" | "db_wipe" | "clean">, z.ZodNever]>, z.ZodCustom<(...args: any[]) => any, (...args: any[]) => any>>>;
16
+ }, z.core.$loose>;
13
17
  declare const kSpecifier: unique symbol;
14
- export interface Plugin extends z.infer<typeof Plugin> {
18
+ export type Plugin = z.infer<typeof Plugin>;
19
+ interface PluginInternal extends Plugin {
15
20
  [kSpecifier]: string;
21
+ hooks: Hooks;
22
+ }
23
+ export interface Hooks {
24
+ db_init?: (opt: InitOptions, db: Database) => void | Promise<void>;
25
+ remove?: (opt: {
26
+ force?: boolean;
27
+ }, db: Database) => void | Promise<void>;
28
+ db_wipe?: (opt: OpOptions, db: Database) => void | Promise<void>;
29
+ clean?: (opt: Partial<OpOptions>, db: Database) => void | Promise<void>;
16
30
  }
17
- export declare const plugins: Set<Plugin>;
18
- export declare function resolvePlugin(search: string): Plugin | undefined;
19
- export declare function pluginText(plugin: Plugin): string;
31
+ export declare const plugins: Set<PluginInternal>;
32
+ export declare function resolvePlugin(search: string): PluginInternal | undefined;
33
+ export declare function pluginText(plugin: PluginInternal): string;
20
34
  export declare function loadPlugin(specifier: string): Promise<void>;
21
- export declare function getSpecifier(plugin: Plugin): string;
35
+ export declare function getSpecifier(plugin: PluginInternal): string;
22
36
  export declare function loadPlugins(dir: string): Promise<void>;
23
37
  export {};
package/dist/plugins.js CHANGED
@@ -5,22 +5,23 @@ import { styleText } from 'node:util';
5
5
  import z from 'zod/v4';
6
6
  import { output } from './io.js';
7
7
  import { _unique } from './state.js';
8
- export const fn = z.custom(data => typeof data === 'function');
9
- export const Plugin = z.object({
8
+ export const PluginMetadata = z.looseObject({
10
9
  name: z.string(),
11
10
  version: z.string(),
12
11
  description: z.string().optional(),
12
+ routes: z.string().optional(),
13
+ });
14
+ const hookNames = ['db_init', 'remove', 'db_wipe', 'clean'];
15
+ const fn = z.custom(data => typeof data === 'function');
16
+ export const Plugin = PluginMetadata.extend({
13
17
  statusText: zAsyncFunction(z.function({ input: [], output: z.string() })),
14
- db_init: fn.optional(),
15
- db_remove: fn.optional(),
16
- db_wipe: fn.optional(),
17
- db_clean: fn.optional(),
18
+ hooks: z.partialRecord(z.literal(hookNames), fn).optional(),
18
19
  });
19
20
  const kSpecifier = Symbol('specifier');
20
21
  export const plugins = _unique('plugins', new Set());
21
22
  export function resolvePlugin(search) {
22
23
  for (const plugin of plugins) {
23
- if (plugin.name.startsWith(search))
24
+ if (plugin.name === search)
24
25
  return plugin;
25
26
  }
26
27
  }
@@ -29,24 +30,26 @@ export function pluginText(plugin) {
29
30
  styleText('whiteBright', plugin.name),
30
31
  `Version: ${plugin.version}`,
31
32
  `Description: ${plugin.description ?? styleText('dim', '(none)')}`,
32
- `Database integration: ${[plugin.db_init, plugin.db_remove, plugin.db_wipe]
33
- .filter(Boolean)
34
- .map(fn => fn?.name.slice(3))
35
- .join(', ') || styleText('dim', '(none)')}`,
33
+ `Hooks: ${Object.keys(plugin.hooks).join(', ') || styleText('dim', '(none)')}`,
34
+ // @todo list the routes when debug output is enabled
35
+ `Routes: ${plugin.routes || styleText('dim', '(none)')}`,
36
36
  ].join('\n');
37
37
  }
38
38
  export async function loadPlugin(specifier) {
39
39
  try {
40
40
  const imported = await import(/* @vite-ignore */ specifier);
41
41
  const maybePlugin = 'default' in imported ? imported.default : imported;
42
- const plugin = Object.assign(await Plugin.parseAsync(maybePlugin).catch(e => {
42
+ const plugin = Object.assign({ hooks: {}, [kSpecifier]: specifier }, await Plugin.parseAsync(maybePlugin).catch(e => {
43
43
  throw z.prettifyError(e);
44
- }), { [kSpecifier]: specifier });
44
+ }));
45
+ if (plugin.name.startsWith('#') || plugin.name.includes(' ')) {
46
+ throw 'Invalid plugin name. Plugin names can not start with a hash or contain spaces.';
47
+ }
45
48
  plugins.add(plugin);
46
49
  output.debug(`Loaded plugin: ${plugin.name} ${plugin.version}`);
47
50
  }
48
51
  catch (e) {
49
- output.debug(`Failed to load plugin from ${specifier}: ${e.message || e}`);
52
+ output.debug(`Failed to load plugin from ${specifier}: ${e ? (e instanceof Error ? e.message : e.toString()) : e}`);
50
53
  }
51
54
  }
52
55
  export function getSpecifier(plugin) {
@@ -1,11 +1,14 @@
1
- import type { NewSessionResponse } from '@axium/core/api';
2
1
  import { type User } from '@axium/core/user';
3
2
  import { type HttpError, type RequestEvent } from '@sveltejs/kit';
4
3
  import z from 'zod/v4';
5
4
  import { type SessionAndUser, type UserInternal } from './auth.js';
6
5
  export declare function parseBody<const Schema extends z.ZodType, const Result extends z.infer<Schema> = z.infer<Schema>>(event: RequestEvent, schema: Schema): Promise<Result>;
7
6
  export declare function getToken(event: RequestEvent, sensitive?: boolean): string | undefined;
8
- export declare function checkAuth(event: RequestEvent, userId: string, sensitive?: boolean): Promise<SessionAndUser>;
9
- export declare function createSessionData(event: RequestEvent, userId: string, elevated?: boolean): Promise<NewSessionResponse>;
7
+ export interface AuthResult extends SessionAndUser {
8
+ /** The user authenticating the request. */
9
+ accessor: UserInternal;
10
+ }
11
+ export declare function checkAuth(event: RequestEvent, userId: string, sensitive?: boolean): Promise<AuthResult>;
12
+ export declare function createSessionData(userId: string, elevated?: boolean): Promise<Response>;
10
13
  export declare function stripUser(user: UserInternal, includeProtected?: boolean): User;
11
14
  export declare function withError(text: string, code?: number): (e: Error | HttpError) => never;
package/dist/requests.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { userProtectedFields, userPublicFields } from '@axium/core/user';
2
- import { error } from '@sveltejs/kit';
2
+ import { error, json } from '@sveltejs/kit';
3
+ import { serialize as serializeCookie } from 'cookie';
3
4
  import { pick } from 'utilium';
4
5
  import z from 'zod/v4';
5
- import { createSession, getSessionAndUser } from './auth.js';
6
+ import { createSession, getSessionAndUser, getUser } from './auth.js';
6
7
  import { config } from './config.js';
7
8
  export async function parseBody(event, schema) {
8
9
  const contentType = event.request.headers.get('content-type');
@@ -29,22 +30,30 @@ export async function checkAuth(event, userId, sensitive = false) {
29
30
  if (!token)
30
31
  throw error(401, { message: 'Missing token' });
31
32
  const session = await getSessionAndUser(token).catch(() => error(401, { message: 'Invalid or expired session' }));
32
- if (session.user?.id !== userId /* && !user.isAdmin */)
33
- error(403, { message: 'User ID mismatch' });
33
+ if (session.userId !== userId) {
34
+ if (!session.user?.isAdmin)
35
+ error(403, { message: 'User ID mismatch' });
36
+ // Admins are allowed to manage other users.
37
+ const accessor = session.user;
38
+ session.user = await getUser(userId).catch(() => error(404, { message: 'Target user not found' }));
39
+ return Object.assign(session, { accessor });
40
+ }
34
41
  if (!session.elevated && sensitive)
35
42
  error(403, 'This token can not be used for sensitive actions');
36
- return session;
43
+ return Object.assign(session, { accessor: session.user });
37
44
  }
38
- export async function createSessionData(event, userId, elevated = false) {
39
- const { token } = await createSession(userId, elevated);
40
- if (elevated) {
41
- event.cookies.set('elevated_token', token, { httpOnly: true, path: '/', expires: new Date(Date.now() + 10 * 60_000) });
42
- return { userId, token: '[[redacted:elevated]]' };
43
- }
44
- else {
45
- event.cookies.set('session_token', token, { httpOnly: config.auth.secure_cookies, path: '/' });
46
- return { userId, token };
47
- }
45
+ export async function createSessionData(userId, elevated = false) {
46
+ const { token, expires } = await createSession(userId, elevated);
47
+ const response = json({ userId, token: elevated ? '[[redacted:elevated]]' : token }, { status: 201 });
48
+ const cookies = serializeCookie(elevated ? 'elevated_token' : 'session_token', token, {
49
+ httpOnly: true,
50
+ path: '/',
51
+ expires,
52
+ secure: config.auth.secure_cookies,
53
+ sameSite: 'lax',
54
+ });
55
+ response.headers.set('Set-Cookie', cookies);
56
+ return response;
48
57
  }
49
58
  export function stripUser(user, includeProtected = false) {
50
59
  return pick(user, ...userPublicFields, ...(includeProtected ? userProtectedFields : []));
package/dist/sveltekit.js CHANGED
@@ -1,6 +1,6 @@
1
- import { error, json, redirect } from '@sveltejs/kit';
1
+ import { error, json } from '@sveltejs/kit';
2
2
  import { readFileSync } from 'node:fs';
3
- import { join } from 'node:path/posix';
3
+ import { styleText } from 'node:util';
4
4
  import { render } from 'svelte/server';
5
5
  import z from 'zod/v4';
6
6
  import { config } from './config.js';
@@ -34,29 +34,31 @@ async function handleAPIRequest(event, route) {
34
34
  function handleError(e) {
35
35
  if ('body' in e)
36
36
  return json(e.body, { status: e.status });
37
+ if ('location' in e)
38
+ return Response.redirect(e.location, e.status);
37
39
  console.error(e);
38
40
  return json({ message: 'Internal Error' + (config.debug ? ': ' + e.message : '') }, { status: 500 });
39
41
  }
40
- const templatePath = join(import.meta.dirname, '../web/template.html');
41
- const template = readFileSync(templatePath, 'utf-8');
42
+ let template = null;
42
43
  function fillTemplate({ head, body }, env = {}, nonce = '') {
44
+ template ||= readFileSync(config.web.template, 'utf-8');
43
45
  return (template
44
46
  .replace('%sveltekit.head%', head)
45
47
  .replace('%sveltekit.body%', body)
46
48
  .replace(/%sveltekit\.assets%/g, config.web.assets)
47
49
  // Unused for now.
48
50
  .replace(/%sveltekit\.nonce%/g, nonce)
49
- .replace(/%sveltekit\.env\.([^%]+)%/g, (_match, capture) => env[capture] ?? ''));
51
+ .replace(/%sveltekit\.env\.([^%]+)%/g, (_match, key) => env[key] ?? ''));
50
52
  }
51
53
  /**
52
54
  * @internal
53
55
  */
54
56
  export async function handle({ event, resolve, }) {
55
57
  const route = resolveRoute(event);
56
- if (!route && event.url.pathname === '/')
57
- redirect(303, '/_axium/default');
58
+ if (!route && event.url.pathname === '/' && config.debug)
59
+ return new Response(null, { status: 303, headers: { Location: '/_axium/default' } });
58
60
  if (config.debug)
59
- console.log(event.request.method.padEnd(7), route ? route.path : event.url.pathname);
61
+ console.log(styleText('blueBright', event.request.method.padEnd(7)), route ? route.path : event.url.pathname);
60
62
  if (!route)
61
63
  return await resolve(event).catch(handleError);
62
64
  if (route.server == true) {
@@ -79,12 +81,14 @@ export async function handle({ event, resolve, }) {
79
81
  const data = await route.load?.(event);
80
82
  const body = fillTemplate(render(route.page));
81
83
  return new Response(body, {
82
- headers: {
83
- 'Content-Type': 'text/html; charset=utf-8',
84
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
85
- Pragma: 'no-cache',
86
- Expires: '0',
87
- },
84
+ headers: config.web.disable_cache
85
+ ? {
86
+ 'Content-Type': 'text/html; charset=utf-8',
87
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
88
+ Pragma: 'no-cache',
89
+ Expires: '0',
90
+ }
91
+ : {},
88
92
  status: 200,
89
93
  });
90
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/server",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "author": "James Prevett <axium@jamespre.dev> (https://jamespre.dev)",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -20,12 +20,15 @@
20
20
  "exports": {
21
21
  ".": "./dist/index.js",
22
22
  "./*": "./dist/*.js",
23
- "./web/*": "./web/*",
23
+ "./lib/*": "./web/lib/*",
24
+ "./$hooks": "./web/hooks.server.ts",
24
25
  "./$routes": "./routes",
26
+ "./$template": "./web/template.html",
25
27
  "./svelte.config.js": "./svelte.config.js"
26
28
  },
27
29
  "files": [
28
30
  "assets",
31
+ "build",
29
32
  "dist",
30
33
  "routes",
31
34
  "web",
@@ -38,8 +41,8 @@
38
41
  "build": "tsc"
39
42
  },
40
43
  "peerDependencies": {
41
- "@axium/client": ">=0.0.2",
42
- "@axium/core": ">=0.3.0",
44
+ "@axium/client": ">=0.1.0",
45
+ "@axium/core": ">=0.4.0",
43
46
  "utilium": "^2.3.8",
44
47
  "zod": "^3.25.61"
45
48
  },
@@ -49,14 +52,15 @@
49
52
  "@sveltejs/kit": "^2.20.2",
50
53
  "@types/pg": "^8.11.11",
51
54
  "commander": "^13.1.0",
55
+ "cookie": "^1.0.2",
52
56
  "kysely": "^0.28.0",
53
57
  "logzen": "^0.7.0",
54
58
  "mime": "^4.0.7",
55
- "pg": "^8.14.1"
59
+ "pg": "^8.14.1",
60
+ "svelte": "^5.25.3"
56
61
  },
57
62
  "devDependencies": {
58
63
  "@sveltejs/adapter-node": "^5.2.12",
59
- "svelte": "^5.25.3",
60
64
  "vite-plugin-mkcert": "^1.17.8"
61
65
  }
62
66
  }
@@ -191,11 +191,12 @@
191
191
  </div>
192
192
  <FormDialog
193
193
  bind:dialog={dialogs['logout#' + session.id]}
194
- submit={() =>
195
- logout(user.id, session.id).then(() => {
196
- if (session.id == currentSession.id) goto('/');
197
- else sessions.splice(sessions.indexOf(session), 1);
198
- })}
194
+ submit={async () => {
195
+ await logout(user.id, session.id);
196
+ dialogs['logout#' + session.id].remove();
197
+ sessions.splice(sessions.indexOf(session), 1);
198
+ if (session.id == currentSession.id) goto('/');
199
+ }}
199
200
  submitText="Logout"
200
201
  >
201
202
  <p>Are you sure you want to log out this session?</p>
package/svelte.config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import node from '@sveltejs/adapter-node';
2
2
  import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
3
  import { join } from 'node:path/posix';
4
+ import config from '@axium/server/config';
4
5
 
5
6
  /**
6
7
  * Paths relative to the directory of this file.
@@ -24,7 +25,7 @@ export default {
24
25
  $lib: fixed('web/lib'),
25
26
  },
26
27
  files: {
27
- routes: 'routes',
28
+ routes: config.web.routes,
28
29
  lib: fixed('web/lib'),
29
30
  assets: fixed('assets'),
30
31
  appTemplate: fixed('web/template.html'),
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import Icon from './icons/Icon.svelte';
3
+ import { iconForMime, iconForPath } from './icons';
4
+
5
+ let { files = $bindable(), name = 'files', ...rest }: { files?: FileList; name?: string; multiple?: any; required?: any } = $props();
6
+
7
+ let input = $state<HTMLInputElement>();
8
+
9
+ const id = 'input:' + Math.random().toString(36).slice(2);
10
+ </script>
11
+
12
+ <div>
13
+ {#if files?.length}
14
+ <label for={id} class="file">
15
+ {#each files as file}
16
+ <Icon i={iconForMime(file.type) || iconForPath(file.name) || 'file'} />
17
+ <span>{file.name}</span>
18
+ <button
19
+ onclick={e => {
20
+ e.preventDefault();
21
+ const dt = new DataTransfer();
22
+ for (let f of files) if (file !== f) dt.items.add(f);
23
+ input.files = files = dt.files;
24
+ }}
25
+ style:display="contents"
26
+ >
27
+ <Icon i="trash" />
28
+ </button>
29
+ {/each}
30
+ </label>
31
+ {:else}
32
+ <label for={id}><Icon i="upload" />Upload</label>
33
+ {/if}
34
+
35
+ <input bind:this={input} {name} {id} type="file" bind:files {...rest} />
36
+ </div>
37
+
38
+ <style>
39
+ input {
40
+ display: none;
41
+ }
42
+
43
+ label {
44
+ padding: 0.5em 1em;
45
+ border: 1px solid #cccc;
46
+ cursor: pointer;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 0.5em;
50
+ border-radius: 0.5em;
51
+ width: 20em;
52
+ }
53
+
54
+ label.file {
55
+ display: grid;
56
+ grid-template-columns: 2em 1fr 2em;
57
+ }
58
+ </style>
@@ -3,8 +3,11 @@ import mimeIcons from './mime.json' with { type: 'json' };
3
3
 
4
4
  export { default as Icon } from './Icon.svelte';
5
5
 
6
- export function iconFor(path: string): string {
7
- type K = keyof typeof mimeIcons;
6
+ export function iconForMime(mimeType: string): string {
7
+ return mimeIcons[mimeType as keyof typeof mimeIcons] || 'file';
8
+ }
9
+
10
+ export function iconForPath(path: string): string {
8
11
  const type = mime.getType(path) || 'application/octet-stream';
9
- return mimeIcons[type as K] || mimeIcons[type.split('/')[0] as K] || 'file';
12
+ return iconForMime(type);
10
13
  }
@@ -21,7 +21,8 @@
21
21
  "text/css": "css",
22
22
  "text/csv": "file-csv",
23
23
  "text/html": "code",
24
- "text/javascript": "square-js",
24
+ "text/javascript": "brands/square-js",
25
+ "application/x-javascript": "brands/square-js",
25
26
  "text/plain": "file-lines",
26
27
  "text/xml": "code",
27
28
  "video": "clapperboard-play"
package/web/tsconfig.json CHANGED
@@ -1,5 +1,4 @@
1
1
  {
2
- "$schema": "https://json.schemastore.org/tsconfig",
3
2
  "extends": "../.svelte-kit/tsconfig.json",
4
3
  "compilerOptions": {
5
4
  "target": "ES2023",