@axium/server 0.7.6 → 0.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.
Files changed (56) hide show
  1. package/dist/apps.d.ts +15 -0
  2. package/dist/apps.js +20 -0
  3. package/dist/auth.d.ts +63 -30
  4. package/dist/auth.js +110 -129
  5. package/dist/cli.js +33 -6
  6. package/dist/config.d.ts +241 -61
  7. package/dist/config.js +26 -2
  8. package/dist/database.d.ts +28 -37
  9. package/dist/database.js +124 -50
  10. package/dist/io.js +6 -2
  11. package/dist/plugins.d.ts +7 -24
  12. package/dist/plugins.js +9 -14
  13. package/dist/routes.d.ts +55 -0
  14. package/dist/routes.js +54 -0
  15. package/package.json +7 -15
  16. package/web/api/index.ts +7 -0
  17. package/web/api/metadata.ts +35 -0
  18. package/web/api/passkeys.ts +56 -0
  19. package/web/api/readme.md +1 -0
  20. package/web/api/register.ts +83 -0
  21. package/web/api/schemas.ts +22 -0
  22. package/web/api/session.ts +33 -0
  23. package/web/api/users.ts +340 -0
  24. package/web/api/utils.ts +66 -0
  25. package/web/auth.ts +1 -5
  26. package/web/hooks.server.ts +6 -1
  27. package/web/index.server.ts +0 -1
  28. package/web/lib/Dialog.svelte +3 -6
  29. package/web/lib/FormDialog.svelte +53 -14
  30. package/web/lib/Toast.svelte +8 -1
  31. package/web/lib/UserCard.svelte +1 -1
  32. package/web/lib/auth.ts +12 -0
  33. package/web/lib/icons/Icon.svelte +5 -7
  34. package/web/lib/index.ts +0 -2
  35. package/web/lib/styles.css +12 -1
  36. package/web/routes/+layout.svelte +1 -1
  37. package/web/routes/[...path]/+page.server.ts +13 -0
  38. package/web/routes/[appId]/[...page]/+page.server.ts +14 -0
  39. package/web/routes/_axium/default/+page.svelte +11 -0
  40. package/web/routes/account/+page.svelte +224 -0
  41. package/web/routes/api/[...path]/+server.ts +49 -0
  42. package/web/routes/login/+page.svelte +25 -0
  43. package/web/routes/logout/+page.svelte +13 -0
  44. package/web/routes/register/+page.svelte +21 -0
  45. package/web/tsconfig.json +2 -1
  46. package/web/utils.ts +9 -15
  47. package/web/actions.ts +0 -58
  48. package/web/lib/Account.svelte +0 -76
  49. package/web/lib/SignUp.svelte +0 -20
  50. package/web/lib/account.css +0 -36
  51. package/web/routes/+page.server.ts +0 -16
  52. package/web/routes/+page.svelte +0 -10
  53. package/web/routes/name/+page.server.ts +0 -5
  54. package/web/routes/name/+page.svelte +0 -20
  55. package/web/routes/signup/+page.server.ts +0 -10
  56. package/web/routes/signup/+page.svelte +0 -15
@@ -0,0 +1,35 @@
1
+ import type { Result } from '@axium/core/api';
2
+ import { requestMethods } from '@axium/core/requests';
3
+ import { config } from '@axium/server/config.js';
4
+ import { plugins } from '@axium/server/plugins.js';
5
+ import { addRoute, routes } from '@axium/server/routes.js';
6
+ import { error } from '@sveltejs/kit';
7
+ import pkg from '../../package.json' with { type: 'json' };
8
+
9
+ addRoute({
10
+ path: '/api/metadata',
11
+ async GET(): Result<'GET', 'metadata'> {
12
+ if (config.api.disable_metadata) {
13
+ error(401, { message: 'API metadata is disabled' });
14
+ }
15
+
16
+ return {
17
+ version: pkg.version,
18
+ routes: Object.fromEntries(
19
+ routes
20
+ .entries()
21
+ .filter(([path]) => path.startsWith('/api/'))
22
+ .map(([path, route]) => [
23
+ path,
24
+ {
25
+ params: Object.fromEntries(
26
+ Object.entries(route.params || {}).map(([key, type]) => [key, type ? type.def.type : null])
27
+ ),
28
+ methods: requestMethods.filter(m => m in route),
29
+ },
30
+ ])
31
+ ),
32
+ plugins: Object.fromEntries(plugins.values().map(plugin => [plugin.id, plugin.version])),
33
+ };
34
+ },
35
+ });
@@ -0,0 +1,56 @@
1
+ import type { Result } from '@axium/core/api';
2
+ import { PasskeyChangeable } from '@axium/core/schemas';
3
+ import { getPasskey } from '@axium/server/auth.js';
4
+ import { database as db } from '@axium/server/database.js';
5
+ import { addRoute } from '@axium/server/routes.js';
6
+ import { error } from '@sveltejs/kit';
7
+ import { omit } from 'utilium';
8
+ import z from 'zod/v4';
9
+ import { checkAuth, parseBody, withError } from './utils';
10
+
11
+ addRoute({
12
+ path: '/api/passkeys/:id',
13
+ params: {
14
+ id: z.string(),
15
+ },
16
+ async GET(event): Result<'GET', 'passkeys/:id'> {
17
+ const passkey = await getPasskey(event.params.id);
18
+ await checkAuth(event, passkey.userId);
19
+ return omit(passkey, 'counter', 'publicKey');
20
+ },
21
+ async PATCH(event): Result<'PATCH', 'passkeys/:id'> {
22
+ const body = await parseBody(event, PasskeyChangeable);
23
+ const passkey = await getPasskey(event.params.id);
24
+ await checkAuth(event, passkey.userId);
25
+ const result = await db
26
+ .updateTable('passkeys')
27
+ .set(body)
28
+ .where('id', '=', passkey.id)
29
+ .returningAll()
30
+ .executeTakeFirstOrThrow()
31
+ .catch(withError('Could not update passkey'));
32
+
33
+ return omit(result, 'counter', 'publicKey');
34
+ },
35
+ async DELETE(event): Result<'DELETE', 'passkeys/:id'> {
36
+ const passkey = await getPasskey(event.params.id);
37
+ await checkAuth(event, passkey.userId);
38
+
39
+ const { count } = await db
40
+ .selectFrom('passkeys')
41
+ .select(db.fn.countAll().as('count'))
42
+ .where('userId', '=', passkey.userId)
43
+ .executeTakeFirstOrThrow();
44
+
45
+ if (Number(count) <= 1) error(409, 'At least one passkey is required');
46
+
47
+ const result = await db
48
+ .deleteFrom('passkeys')
49
+ .where('id', '=', passkey.id)
50
+ .returningAll()
51
+ .executeTakeFirstOrThrow()
52
+ .catch(withError('Could not delete passkey'));
53
+
54
+ return omit(result, 'counter', 'publicKey');
55
+ },
56
+ });
@@ -0,0 +1 @@
1
+ This is the web-facing API, not a TypeScript API. In this directory you'll find the `addRoute` calls for the built-in `/api` routes along with the utilities and other helpers used.
@@ -0,0 +1,83 @@
1
+ /** Register a new user. */
2
+ import type { Result } from '@axium/core/api';
3
+ import { APIUserRegistration } from '@axium/core/schemas';
4
+ import { createPasskey, getUser, getUserByEmail } from '@axium/server/auth.js';
5
+ import config from '@axium/server/config.js';
6
+ import { database as db, type Schema } from '@axium/server/database.js';
7
+ import { addRoute } from '@axium/server/routes.js';
8
+ import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
9
+ import { error, type RequestEvent } from '@sveltejs/kit';
10
+ import { randomUUID } from 'node:crypto';
11
+ import z from 'zod/v4';
12
+ import { createSessionData, parseBody, withError } from './utils.js';
13
+
14
+ // Map of user ID => challenge
15
+ const registrations = new Map<string, string>();
16
+
17
+ async function OPTIONS(event: RequestEvent): Result<'OPTIONS', 'register'> {
18
+ const { name, email } = await parseBody(event, z.object({ name: z.string().optional(), email: z.email().optional() }));
19
+
20
+ const userId = randomUUID();
21
+ const user = await getUser(userId);
22
+ if (user) error(409, { message: 'Generated UUID is already in use, please retry.' });
23
+
24
+ const options = await generateRegistrationOptions({
25
+ rpName: config.auth.rp_name,
26
+ rpID: config.auth.rp_id,
27
+ userName: email ?? userId,
28
+ userDisplayName: name,
29
+ attestationType: 'none',
30
+ excludeCredentials: [],
31
+ authenticatorSelection: {
32
+ residentKey: 'preferred',
33
+ userVerification: 'preferred',
34
+ authenticatorAttachment: 'platform',
35
+ },
36
+ });
37
+
38
+ registrations.set(userId, options.challenge);
39
+
40
+ return { userId, options };
41
+ }
42
+
43
+ async function POST(event: RequestEvent): Result<'POST', 'register'> {
44
+ const { userId, email, name, response } = await parseBody(event, APIUserRegistration);
45
+
46
+ const existing = await getUserByEmail(email);
47
+ if (existing) error(409, { message: 'Email already in use' });
48
+
49
+ const expectedChallenge = registrations.get(userId);
50
+ if (!expectedChallenge) error(404, { message: 'No registration challenge found for this user' });
51
+ registrations.delete(userId);
52
+
53
+ const { verified, registrationInfo } = await verifyRegistrationResponse({
54
+ response,
55
+ expectedChallenge,
56
+ expectedOrigin: config.auth.origin,
57
+ }).catch(() => error(400, { message: 'Verification failed' }));
58
+
59
+ if (!verified || !registrationInfo) error(401, { message: 'Verification failed' });
60
+
61
+ await db
62
+ .insertInto('users')
63
+ .values({ id: userId, name, email } as Schema['users'])
64
+ .executeTakeFirstOrThrow()
65
+ .catch(withError('Failed to create user'));
66
+
67
+ await createPasskey({
68
+ transports: [],
69
+ ...registrationInfo.credential,
70
+ userId,
71
+ deviceType: registrationInfo.credentialDeviceType,
72
+ backedUp: registrationInfo.credentialBackedUp,
73
+ }).catch(e => error(500, { message: 'Failed to create passkey' + (config.debug ? `: ${e.message}` : '') }));
74
+
75
+ return await createSessionData(event, userId);
76
+ }
77
+
78
+ addRoute({
79
+ path: '/api/register',
80
+ params: {},
81
+ OPTIONS,
82
+ POST,
83
+ });
@@ -0,0 +1,22 @@
1
+ import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
2
+ import z from 'zod/v4';
3
+
4
+ const transports = ['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'] satisfies AuthenticatorTransportFuture[];
5
+
6
+ export const authenticatorAttachment = z.enum(['platform', 'cross-platform'] satisfies AuthenticatorAttachment[]).optional();
7
+
8
+ export const PasskeyRegistration = z.object({
9
+ id: z.string(),
10
+ rawId: z.string(),
11
+ response: z.object({
12
+ clientDataJSON: z.string(),
13
+ attestationObject: z.string(),
14
+ authenticatorData: z.string().optional(),
15
+ transports: z.array(z.enum(transports)).optional(),
16
+ publicKeyAlgorithm: z.number().optional(),
17
+ publicKey: z.string().optional(),
18
+ }),
19
+ authenticatorAttachment,
20
+ clientExtensionResults: z.record(z.any(), z.any()),
21
+ type: z.literal('public-key'),
22
+ });
@@ -0,0 +1,33 @@
1
+ import { authenticate } from '$lib/auth';
2
+ import type { Result } from '@axium/core/api';
3
+ import { connect, database as db } from '@axium/server/database.js';
4
+ import { addRoute } from '@axium/server/routes.js';
5
+ import { error } from '@sveltejs/kit';
6
+ import { omit } from 'utilium';
7
+ import { getToken, stripUser } from './utils';
8
+
9
+ addRoute({
10
+ path: '/api/session',
11
+ async GET(event): Result<'GET', 'session'> {
12
+ const result = await authenticate(event);
13
+
14
+ if (!result) error(404, 'Session does not exist');
15
+
16
+ return {
17
+ ...omit(result, 'token'),
18
+ user: stripUser(result.user, true),
19
+ };
20
+ },
21
+ async DELETE(event): Result<'DELETE', 'session'> {
22
+ const token = getToken(event);
23
+ connect();
24
+ const result = await db
25
+ .deleteFrom('sessions')
26
+ .where('sessions.token', '=', token)
27
+ .returningAll()
28
+ .executeTakeFirstOrThrow()
29
+ .catch((e: Error) => (e.message == 'no result' ? error(404, 'Session does not exist') : error(400, 'Invalid session')));
30
+
31
+ return omit(result, 'token');
32
+ },
33
+ });
@@ -0,0 +1,340 @@
1
+ /** Register a new passkey for a new or existing user. */
2
+ import type { Result } from '@axium/core/api';
3
+ import { PasskeyAuthenticationResponse, UserAuthOptions } from '@axium/core/schemas';
4
+ import { UserChangeable, type User } from '@axium/core/user';
5
+ import {
6
+ createPasskey,
7
+ createVerification,
8
+ getPasskey,
9
+ getPasskeysByUserId,
10
+ getSession,
11
+ getSessions,
12
+ getUser,
13
+ useVerification,
14
+ } from '@axium/server/auth.js';
15
+ import { config } from '@axium/server/config.js';
16
+ import { connect, database as db } from '@axium/server/database.js';
17
+ import { addRoute } from '@axium/server/routes.js';
18
+ import {
19
+ generateAuthenticationOptions,
20
+ generateRegistrationOptions,
21
+ verifyAuthenticationResponse,
22
+ verifyRegistrationResponse,
23
+ } from '@simplewebauthn/server';
24
+ import { error, type RequestEvent } from '@sveltejs/kit';
25
+ import { omit, pick } from 'utilium';
26
+ import z from 'zod/v4';
27
+ import { PasskeyRegistration } from './schemas.js';
28
+ import { checkAuth, createSessionData, parseBody, stripUser, withError } from './utils.js';
29
+
30
+ interface UserAuth {
31
+ data: string;
32
+ type: UserAuthOptions['type'];
33
+ }
34
+
35
+ const challenges = new Map<string, UserAuth>();
36
+
37
+ const params = { id: z.uuid() };
38
+
39
+ /**
40
+ * Resolve a user's UUID using their email (in the future this might also include handles)
41
+ */
42
+ addRoute({
43
+ path: '/api/user_id',
44
+ async POST(event): Result<'POST', 'user_id'> {
45
+ const { value } = await parseBody(event, z.object({ using: z.literal('email'), value: z.email() }));
46
+
47
+ connect();
48
+ const { id } = await db.selectFrom('users').select('id').where('email', '=', value).executeTakeFirst();
49
+ return { id };
50
+ },
51
+ });
52
+
53
+ addRoute({
54
+ path: '/api/users/:id',
55
+ params,
56
+ async GET(event): Result<'GET', 'users/:id'> {
57
+ const { id: userId } = event.params;
58
+
59
+ const authed = await checkAuth(event, userId)
60
+ .then(() => true)
61
+ .catch(() => false);
62
+
63
+ return stripUser(await getUser(userId), authed);
64
+ },
65
+ async PATCH(event): Result<'PATCH', 'users/:id'> {
66
+ const { id: userId } = event.params;
67
+ const body: UserChangeable & Pick<User, 'emailVerified'> = await parseBody(event, UserChangeable);
68
+
69
+ await checkAuth(event, userId);
70
+
71
+ const user = await getUser(userId);
72
+ if (!user) error(404, { message: 'User does not exist' });
73
+
74
+ if ('email' in body) body.emailVerified = null;
75
+
76
+ const result = await db
77
+ .updateTable('users')
78
+ .set(body)
79
+ .where('id', '=', userId)
80
+ .returningAll()
81
+ .executeTakeFirstOrThrow()
82
+ .catch(withError('Failed to update user'));
83
+
84
+ return stripUser(result, true);
85
+ },
86
+ async DELETE(event): Result<'DELETE', 'users/:id'> {
87
+ const { id: userId } = event.params;
88
+
89
+ await checkAuth(event, userId, true);
90
+
91
+ const user = await getUser(userId);
92
+ if (!user) error(404, { message: 'User does not exist' });
93
+
94
+ const result = await db
95
+ .deleteFrom('users')
96
+ .where('id', '=', userId)
97
+ .returningAll()
98
+ .executeTakeFirstOrThrow()
99
+ .catch(withError('Failed to delete user'));
100
+
101
+ return result;
102
+ },
103
+ });
104
+
105
+ addRoute({
106
+ path: '/api/users/:id/full',
107
+ params,
108
+ async GET(event): Result<'GET', 'users/:id/full'> {
109
+ const { id: userId } = event.params;
110
+
111
+ await checkAuth(event, userId);
112
+
113
+ const user = stripUser(await getUser(userId), true);
114
+
115
+ const sessions = await getSessions(userId);
116
+
117
+ return {
118
+ ...user,
119
+ sessions: sessions.map(s => omit(s, 'token')),
120
+ };
121
+ },
122
+ });
123
+
124
+ addRoute({
125
+ path: '/api/users/:id/auth',
126
+ params,
127
+ async OPTIONS(event): Result<'OPTIONS', 'users/:id/auth'> {
128
+ const { id: userId } = event.params;
129
+ const { type } = await parseBody(event, UserAuthOptions);
130
+
131
+ const user = await getUser(userId);
132
+ if (!user) error(404, { message: 'User does not exist' });
133
+
134
+ const passkeys = await getPasskeysByUserId(userId);
135
+
136
+ if (!passkeys) error(409, { message: 'No passkeys exists for this user' });
137
+
138
+ const options = await generateAuthenticationOptions({
139
+ rpID: config.auth.rp_id,
140
+ allowCredentials: passkeys.map(passkey => pick(passkey, 'id', 'transports')),
141
+ });
142
+
143
+ challenges.set(userId, { data: options.challenge, type });
144
+
145
+ return options;
146
+ },
147
+ async POST(event: RequestEvent): Result<'POST', 'users/:id/auth'> {
148
+ const { id: userId } = event.params;
149
+ const response = await parseBody(event, PasskeyAuthenticationResponse);
150
+
151
+ const auth = challenges.get(userId);
152
+ if (!auth) error(404, { message: 'No challenge found for this user' });
153
+ const { data: expectedChallenge, type } = auth;
154
+ challenges.delete(userId);
155
+
156
+ const user = await getUser(userId);
157
+ if (!user) error(404, { message: 'User does not exist' });
158
+
159
+ const passkey = await getPasskey(response.id);
160
+ if (!passkey) error(404, { message: 'Passkey does not exist' });
161
+
162
+ if (passkey.userId !== userId) error(403, { message: 'Passkey does not belong to this user' });
163
+
164
+ const { verified } = await verifyAuthenticationResponse({
165
+ response,
166
+ credential: passkey,
167
+ expectedChallenge,
168
+ expectedOrigin: config.auth.origin,
169
+ expectedRPID: config.auth.rp_id,
170
+ }).catch(withError('Verification failed', 400));
171
+
172
+ if (!verified) error(401, { message: 'Verification failed' });
173
+
174
+ switch (type) {
175
+ case 'login':
176
+ return await createSessionData(event, userId);
177
+ case 'action':
178
+ if ((Date.now() - passkey.createdAt.getTime()) / 60_000 < config.auth.passkey_probation)
179
+ error(403, { message: 'You can not authorize sensitive actions with a newly created passkey' });
180
+
181
+ return await createSessionData(event, userId, true);
182
+ }
183
+ },
184
+ });
185
+
186
+ // Map of user ID => challenge
187
+ const registrations = new Map<string, string>();
188
+
189
+ addRoute({
190
+ path: '/api/users/:id/passkeys',
191
+ params,
192
+ /**
193
+ * Get passkey registration options for a user.
194
+ */
195
+ async OPTIONS(event: RequestEvent): Result<'OPTIONS', 'users/:id/passkeys'> {
196
+ const { id: userId } = event.params;
197
+
198
+ const user = await getUser(userId);
199
+ if (!user) error(404, { message: 'User does not exist' });
200
+
201
+ const existing = await getPasskeysByUserId(userId);
202
+
203
+ await checkAuth(event, userId);
204
+
205
+ const options = await generateRegistrationOptions({
206
+ rpName: config.auth.rp_name,
207
+ rpID: config.auth.rp_id,
208
+ userName: userId,
209
+ userDisplayName: user.email,
210
+ attestationType: 'none',
211
+ excludeCredentials: existing.map(passkey => pick(passkey, 'id', 'transports')),
212
+ authenticatorSelection: {
213
+ residentKey: 'preferred',
214
+ userVerification: 'preferred',
215
+ authenticatorAttachment: 'platform',
216
+ },
217
+ });
218
+
219
+ registrations.set(userId, options.challenge);
220
+
221
+ return options;
222
+ },
223
+
224
+ /**
225
+ * Get passkeys for a user.
226
+ */
227
+ async GET(event: RequestEvent): Result<'GET', 'users/:id/passkeys'> {
228
+ const { id: userId } = event.params;
229
+
230
+ const user = await getUser(userId);
231
+ if (!user) error(404, { message: 'User does not exist' });
232
+
233
+ await checkAuth(event, userId);
234
+
235
+ const passkeys = await getPasskeysByUserId(userId);
236
+
237
+ return passkeys.map(p => omit(p, 'publicKey', 'counter'));
238
+ },
239
+
240
+ /**
241
+ * Register a new passkey for an existing user.
242
+ */
243
+ async PUT(event: RequestEvent): Result<'PUT', 'users/:id/passkeys'> {
244
+ const { id: userId } = event.params;
245
+ const response = await parseBody(event, PasskeyRegistration);
246
+
247
+ const user = await getUser(userId);
248
+ if (!user) error(404, { message: 'User does not exist' });
249
+
250
+ await checkAuth(event, userId);
251
+
252
+ const expectedChallenge = registrations.get(userId);
253
+ if (!expectedChallenge) error(404, { message: 'No registration challenge found for this user' });
254
+ registrations.delete(userId);
255
+
256
+ const { verified, registrationInfo } = await verifyRegistrationResponse({
257
+ response,
258
+ expectedChallenge,
259
+ expectedOrigin: config.auth.origin,
260
+ }).catch(withError('Verification failed', 400));
261
+
262
+ if (!verified || !registrationInfo) error(401, { message: 'Verification failed' });
263
+
264
+ const passkey = await createPasskey({
265
+ transports: [],
266
+ ...registrationInfo.credential,
267
+ userId,
268
+ deviceType: registrationInfo.credentialDeviceType,
269
+ backedUp: registrationInfo.credentialBackedUp,
270
+ }).catch(withError('Failed to create passkey'));
271
+
272
+ return omit(passkey, 'publicKey', 'counter');
273
+ },
274
+ });
275
+
276
+ addRoute({
277
+ path: '/api/users/:id/sessions',
278
+ params,
279
+ async GET(event): Result<'POST', 'users/:id/sessions'> {
280
+ const { id: userId } = event.params;
281
+
282
+ await checkAuth(event, userId);
283
+
284
+ return (await getSessions(userId).catch(e => error(503, 'Failed to get sessions' + (config.debug ? ': ' + e : '')))).map(s =>
285
+ pick(s, 'id', 'expires')
286
+ );
287
+ },
288
+ async DELETE(event: RequestEvent): Result<'DELETE', 'users/:id/sessions'> {
289
+ const { id: userId } = event.params;
290
+ const { id: sessionId } = await parseBody(event, z.object({ id: z.uuid() }));
291
+
292
+ await checkAuth(event, userId);
293
+
294
+ const session = await getSession(sessionId).catch(withError('Session does not exist', 404));
295
+
296
+ if (session.userId !== userId) error(403, { message: 'Session does not belong to the user' });
297
+
298
+ await db
299
+ .deleteFrom('sessions')
300
+ .where('sessions.id', '=', session.id)
301
+ .executeTakeFirstOrThrow()
302
+ .catch(withError('Failed to delete session'));
303
+
304
+ return;
305
+ },
306
+ });
307
+
308
+ addRoute({
309
+ path: '/api/users/:id/verify_email',
310
+ params,
311
+ async GET(event): Result<'GET', 'users/:id/verify_email'> {
312
+ const { id: userId } = event.params;
313
+
314
+ await checkAuth(event, userId);
315
+
316
+ const user = await getUser(userId);
317
+ if (!user) error(404, { message: 'User does not exist' });
318
+
319
+ if (user.emailVerified) error(409, { message: 'Email already verified' });
320
+
321
+ const verification = await createVerification('verify_email', userId, config.auth.verification_timeout * 60);
322
+
323
+ return omit(verification, 'token', 'role');
324
+ },
325
+ async POST(event: RequestEvent): Result<'POST', 'users/:id/verify_email'> {
326
+ const { id: userId } = event.params;
327
+ const { token } = await parseBody(event, z.object({ token: z.string() }));
328
+
329
+ await checkAuth(event, userId);
330
+
331
+ const user = await getUser(userId);
332
+ if (!user) error(404, { message: 'User does not exist' });
333
+
334
+ if (user.emailVerified) error(409, { message: 'Email already verified' });
335
+
336
+ await useVerification('verify_email', userId, token).catch(withError('Invalid or expired verification token', 400));
337
+
338
+ return {};
339
+ },
340
+ });
@@ -0,0 +1,66 @@
1
+ import { userProtectedFields, userPublicFields, type User } from '@axium/core/user';
2
+ import type { NewSessionResponse } from '@axium/core/api';
3
+ import { createSession, getSessionAndUser, type UserInternal } from '@axium/server/auth.js';
4
+ import { config } from '@axium/server/config.js';
5
+ import { error, type RequestEvent } from '@sveltejs/kit';
6
+ import { pick } from 'utilium';
7
+ import z from 'zod/v4';
8
+
9
+ export async function parseBody<const Schema extends z.ZodType, const Result extends z.infer<Schema> = z.infer<Schema>>(
10
+ event: RequestEvent,
11
+ schema: Schema
12
+ ): Promise<Result> {
13
+ const contentType = event.request.headers.get('content-type');
14
+ if (!contentType || !contentType.includes('application/json')) error(415, { message: 'Invalid content type' });
15
+
16
+ const body: unknown = await event.request.json().catch(() => error(415, { message: 'Invalid JSON' }));
17
+
18
+ try {
19
+ return schema.parse(body) as Result;
20
+ } catch (e: any) {
21
+ error(400, { message: z.prettifyError(e) });
22
+ }
23
+ }
24
+
25
+ export function getToken(event: RequestEvent, sensitive: boolean = false): string | undefined {
26
+ const header_token = event.request.headers.get('Authorization')?.replace('Bearer ', '');
27
+ if (header_token) return header_token;
28
+
29
+ if (config.debug || config.api.cookie_auth) {
30
+ return event.cookies.get(sensitive ? 'elevated_token' : 'session_token');
31
+ }
32
+ }
33
+
34
+ export async function checkAuth(event: RequestEvent, userId: string, sensitive: boolean = false): Promise<void> {
35
+ const token = getToken(event, sensitive);
36
+
37
+ if (!token) throw error(401, { message: 'Missing token' });
38
+
39
+ const { user, elevated } = await getSessionAndUser(token).catch(() => error(401, { message: 'Invalid or expired session' }));
40
+
41
+ if (user?.id !== userId /* && !user.isAdmin */) error(403, { message: 'User ID mismatch' });
42
+
43
+ if (!elevated && sensitive) error(403, 'This token can not be used for sensitive actions');
44
+ }
45
+
46
+ export async function createSessionData(event: RequestEvent, userId: string, elevated: boolean = false): Promise<NewSessionResponse> {
47
+ const { token } = await createSession(userId, elevated);
48
+
49
+ if (elevated) {
50
+ event.cookies.set('elevated_token', token, { httpOnly: true, path: '/', expires: new Date(Date.now() + 10 * 60_000) });
51
+ return { userId, token: '[[redacted:elevated]]' };
52
+ } else {
53
+ event.cookies.set('session_token', token, { httpOnly: config.auth.secure_cookies, path: '/' });
54
+ return { userId, token };
55
+ }
56
+ }
57
+
58
+ export function stripUser(user: UserInternal, includeProtected: boolean = false): User {
59
+ return pick(user, ...userPublicFields, ...(includeProtected ? userProtectedFields : []));
60
+ }
61
+
62
+ export function withError(text: string, code: number = 500) {
63
+ return function (e: Error) {
64
+ error(code, { message: text + (config.debug ? `: ${e.message}` : '') });
65
+ };
66
+ }
package/web/auth.ts CHANGED
@@ -1,12 +1,8 @@
1
- import { SvelteKitAuth } from '@auth/sveltekit';
1
+ import { allLogLevels } from 'logzen';
2
2
  import { createWriteStream } from 'node:fs';
3
3
  import { join } from 'node:path/posix';
4
- import { getConfig } from '../dist/auth.js';
5
4
  import config from '../dist/config.js';
6
5
  import { findDir, logger } from '../dist/io.js';
7
- import { allLogLevels } from 'logzen';
8
6
 
9
7
  logger.attach(createWriteStream(join(findDir(false), 'server.log')), { output: allLogLevels });
10
8
  await config.loadDefaults();
11
-
12
- export const { handle, signIn, signOut } = SvelteKitAuth(getConfig());
@@ -1 +1,6 @@
1
- export { handle } from './auth.js';
1
+ import { loadDefaultConfigs } from '@axium/server/config.js';
2
+ import { _markDefaults } from '@axium/server/routes.js';
3
+ import './api/index.js';
4
+
5
+ _markDefaults();
6
+ await loadDefaultConfigs();
@@ -1,2 +1 @@
1
- export * from './actions.js';
2
1
  export * from './utils.js';
@@ -1,12 +1,9 @@
1
1
  <script>
2
- let { show, children, onclose = () => (show = false), ...rest } = $props();
3
-
4
- let dialog = $state();
5
- $effect(() => show && dialog.showModal());
6
- $effect(() => !show && dialog.close());
2
+ let { children, dialog = $bindable(), ...rest } = $props();
3
+ import './styles.css';
7
4
  </script>
8
5
 
9
- <dialog bind:this={dialog} {onclose} {...rest}>
6
+ <dialog bind:this={dialog} {...rest}>
10
7
  {@render children()}
11
8
  </dialog>
12
9