@nuraly/lumenjs 0.1.4 → 0.2.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 (76) hide show
  1. package/dist/auth/native-auth.d.ts +9 -0
  2. package/dist/auth/native-auth.js +49 -2
  3. package/dist/auth/routes/login.js +24 -1
  4. package/dist/auth/routes/totp.d.ts +22 -0
  5. package/dist/auth/routes/totp.js +232 -0
  6. package/dist/auth/routes.js +14 -0
  7. package/dist/auth/token.js +2 -2
  8. package/dist/build/build-server.d.ts +2 -1
  9. package/dist/build/build-server.js +10 -1
  10. package/dist/build/build.js +13 -4
  11. package/dist/build/scan.d.ts +1 -0
  12. package/dist/build/scan.js +2 -1
  13. package/dist/build/serve.js +131 -11
  14. package/dist/dev-server/config.js +18 -1
  15. package/dist/dev-server/index-html.d.ts +1 -0
  16. package/dist/dev-server/index-html.js +4 -1
  17. package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
  18. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
  19. package/dist/dev-server/server.js +146 -88
  20. package/dist/dev-server/ssr-render.js +10 -2
  21. package/dist/editor/ai/backend.js +11 -2
  22. package/dist/editor/ai/deepseek-client.d.ts +7 -0
  23. package/dist/editor/ai/deepseek-client.js +113 -0
  24. package/dist/editor/ai/opencode-client.d.ts +1 -1
  25. package/dist/editor/ai/opencode-client.js +21 -47
  26. package/dist/editor/ai-chat-panel.js +27 -1
  27. package/dist/editor/editor-bridge.js +2 -1
  28. package/dist/editor/overlay-hmr.js +2 -1
  29. package/dist/runtime/app-shell.d.ts +1 -1
  30. package/dist/runtime/app-shell.js +1 -0
  31. package/dist/runtime/island.d.ts +16 -0
  32. package/dist/runtime/island.js +80 -0
  33. package/dist/runtime/router-hydration.js +9 -2
  34. package/dist/runtime/router.d.ts +3 -1
  35. package/dist/runtime/router.js +49 -1
  36. package/dist/runtime/webrtc.d.ts +44 -0
  37. package/dist/runtime/webrtc.js +263 -13
  38. package/dist/shared/dom-shims.js +4 -2
  39. package/dist/shared/types.d.ts +1 -0
  40. package/dist/storage/adapters/s3.js +6 -3
  41. package/package.json +33 -7
  42. package/templates/social/api/posts/[id].ts +0 -14
  43. package/templates/social/api/posts.ts +0 -11
  44. package/templates/social/api/profile/[username].ts +0 -10
  45. package/templates/social/api/upload.ts +0 -19
  46. package/templates/social/data/migrations/001_init.sql +0 -78
  47. package/templates/social/data/migrations/002_add_image_url.sql +0 -1
  48. package/templates/social/data/migrations/003_auth.sql +0 -7
  49. package/templates/social/docs/architecture.md +0 -76
  50. package/templates/social/docs/components.md +0 -100
  51. package/templates/social/docs/data.md +0 -89
  52. package/templates/social/docs/pages.md +0 -96
  53. package/templates/social/docs/theming.md +0 -52
  54. package/templates/social/lib/media.ts +0 -130
  55. package/templates/social/lumenjs.auth.ts +0 -21
  56. package/templates/social/lumenjs.config.ts +0 -3
  57. package/templates/social/package.json +0 -5
  58. package/templates/social/pages/_layout.ts +0 -239
  59. package/templates/social/pages/apps/[id].ts +0 -173
  60. package/templates/social/pages/apps/index.ts +0 -116
  61. package/templates/social/pages/auth/login.ts +0 -92
  62. package/templates/social/pages/bookmarks.ts +0 -57
  63. package/templates/social/pages/explore.ts +0 -73
  64. package/templates/social/pages/index.ts +0 -351
  65. package/templates/social/pages/messages.ts +0 -298
  66. package/templates/social/pages/new.ts +0 -77
  67. package/templates/social/pages/notifications.ts +0 -73
  68. package/templates/social/pages/post/[id].ts +0 -124
  69. package/templates/social/pages/profile/[username].ts +0 -100
  70. package/templates/social/pages/settings/accessibility.ts +0 -153
  71. package/templates/social/pages/settings/account.ts +0 -260
  72. package/templates/social/pages/settings/help.ts +0 -141
  73. package/templates/social/pages/settings/language.ts +0 -103
  74. package/templates/social/pages/settings/privacy.ts +0 -183
  75. package/templates/social/pages/settings/security.ts +0 -133
  76. package/templates/social/pages/settings.ts +0 -185
@@ -66,6 +66,15 @@ export declare function decodeResetTokenUserId(token: string): string | null;
66
66
  export declare function updatePassword(db: Db, userId: string, newPassword: string, minLength?: number): Promise<void>;
67
67
  /** Find a user by email. Returns { id, email, name } or null. */
68
68
  export declare function findUserIdByEmail(db: Db, email: string): Promise<string | null>;
69
+ export declare function encryptTotpSecret(secret: string, sessionSecret: string): Promise<string>;
70
+ export declare function decryptTotpSecret(encrypted: string, sessionSecret: string): Promise<string>;
71
+ export declare function saveTotpSecret(db: Db, userId: string, encryptedSecret: string): Promise<void>;
72
+ export declare function enableTotp(db: Db, userId: string): Promise<void>;
73
+ export declare function disableTotp(db: Db, userId: string): Promise<void>;
74
+ export declare function getTotpState(db: Db, userId: string): Promise<{
75
+ totpEnabled: boolean;
76
+ encryptedSecret: string | null;
77
+ }>;
69
78
  /** Set sessions_revoked_at to now, invalidating all sessions created before this moment. */
70
79
  export declare function revokeAllSessions(db: Db, userId: string): Promise<void>;
71
80
  /** Get the epoch-seconds timestamp of the last logout-all, or null if never revoked. */
@@ -44,8 +44,8 @@ export async function ensureUsersTable(db) {
44
44
  password_hash TEXT NOT NULL,
45
45
  email_verified INTEGER NOT NULL DEFAULT 0,
46
46
  roles TEXT NOT NULL DEFAULT '[]',
47
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
48
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ created_at TEXT NOT NULL DEFAULT NOW(),
48
+ updated_at TEXT NOT NULL DEFAULT NOW()
49
49
  )`);
50
50
  // Add email_verified column if table already exists without it
51
51
  try {
@@ -59,6 +59,17 @@ export async function ensureUsersTable(db) {
59
59
  }
60
60
  catch { }
61
61
  ;
62
+ // Add TOTP columns
63
+ try {
64
+ await db.exec('ALTER TABLE _nk_auth_users ADD COLUMN totp_secret TEXT');
65
+ }
66
+ catch { }
67
+ ;
68
+ try {
69
+ await db.exec('ALTER TABLE _nk_auth_users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0');
70
+ }
71
+ catch { }
72
+ ;
62
73
  }
63
74
  /**
64
75
  * Register a new user with email/password.
@@ -279,6 +290,42 @@ export async function findUserIdByEmail(db, email) {
279
290
  const row = await db.get('SELECT id FROM _nk_auth_users WHERE email = ?', email);
280
291
  return row?.id || null;
281
292
  }
293
+ // ── TOTP helpers ─────────────────────────────────────────────────
294
+ const TOTP_IV_LEN = 12;
295
+ const TOTP_ALGO = 'aes-256-gcm';
296
+ function deriveTotpKey(sessionSecret) {
297
+ return Buffer.from(crypto.hkdfSync('sha256', sessionSecret, 'totp-key', '', 32));
298
+ }
299
+ export async function encryptTotpSecret(secret, sessionSecret) {
300
+ const key = deriveTotpKey(sessionSecret);
301
+ const iv = crypto.randomBytes(TOTP_IV_LEN);
302
+ const cipher = crypto.createCipheriv(TOTP_ALGO, key, iv);
303
+ const enc = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
304
+ const tag = cipher.getAuthTag();
305
+ return `${iv.toString('base64url')}.${enc.toString('base64url')}.${tag.toString('base64url')}`;
306
+ }
307
+ export async function decryptTotpSecret(encrypted, sessionSecret) {
308
+ const [ivB64, encB64, tagB64] = encrypted.split('.');
309
+ if (!ivB64 || !encB64 || !tagB64)
310
+ throw new Error('Invalid TOTP secret format');
311
+ const key = deriveTotpKey(sessionSecret);
312
+ const decipher = crypto.createDecipheriv(TOTP_ALGO, key, Buffer.from(ivB64, 'base64url'));
313
+ decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
314
+ return decipher.update(Buffer.from(encB64, 'base64url')).toString('utf8') + decipher.final('utf8');
315
+ }
316
+ export async function saveTotpSecret(db, userId, encryptedSecret) {
317
+ await db.run('UPDATE _nk_auth_users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', encryptedSecret, userId);
318
+ }
319
+ export async function enableTotp(db, userId) {
320
+ await db.run('UPDATE _nk_auth_users SET totp_enabled = 1 WHERE id = ?', userId);
321
+ }
322
+ export async function disableTotp(db, userId) {
323
+ await db.run('UPDATE _nk_auth_users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', userId);
324
+ }
325
+ export async function getTotpState(db, userId) {
326
+ const row = await db.get('SELECT totp_enabled, totp_secret FROM _nk_auth_users WHERE id = ?', userId);
327
+ return { totpEnabled: !!row?.totp_enabled, encryptedSecret: row?.totp_secret || null };
328
+ }
282
329
  // ── Session Revocation (Logout All) ─────────────────────────────
283
330
  /** Set sessions_revoked_at to now, invalidating all sessions created before this moment. */
284
331
  export async function revokeAllSessions(db, userId) {
@@ -53,7 +53,7 @@ export async function handleNativeLogin(config, req, res, url, db) {
53
53
  sendJson(res, 400, { error: 'Email and password required' });
54
54
  return true;
55
55
  }
56
- const { authenticateUser, isEmailVerified } = await import('../native-auth.js');
56
+ const { authenticateUser, isEmailVerified, getTotpState } = await import('../native-auth.js');
57
57
  const user = await authenticateUser(db, email, password);
58
58
  if (!user) {
59
59
  sendJson(res, 401, { error: 'Invalid credentials' });
@@ -64,6 +64,29 @@ export async function handleNativeLogin(config, req, res, url, db) {
64
64
  sendJson(res, 403, { error: 'Please verify your email before signing in', code: 'EMAIL_NOT_VERIFIED' });
65
65
  return true;
66
66
  }
67
+ // Check TOTP — if enabled, issue a short-lived pending cookie instead of a full session
68
+ const totpState = await getTotpState(db, user.sub);
69
+ if (totpState.totpEnabled) {
70
+ const pendingData = {
71
+ accessToken: `totp-pending:${user.sub}`,
72
+ expiresAt: Math.floor(Date.now() / 1000) + 300,
73
+ user: { sub: user.sub, roles: [] },
74
+ provider: 'native',
75
+ createdAt: Math.floor(Date.now() / 1000),
76
+ };
77
+ const pendingEncrypted = await encryptSession(pendingData, config.session.secret);
78
+ const pendingCookie = createSessionCookie('nk-totp-pending', pendingEncrypted, 300, config.session.secure);
79
+ const returnTo = safeReturnTo(url.searchParams.get('returnTo'), config.routes.postLogin);
80
+ if (req.headers.accept?.includes('application/json')) {
81
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': pendingCookie });
82
+ res.end(JSON.stringify({ requires2fa: true, returnTo }));
83
+ }
84
+ else {
85
+ res.writeHead(302, { Location: `/auth/totp-challenge?returnTo=${encodeURIComponent(returnTo)}`, 'Set-Cookie': pendingCookie });
86
+ res.end();
87
+ }
88
+ return true;
89
+ }
67
90
  // Create session — same as OIDC callback
68
91
  const sessionData = {
69
92
  accessToken: `native:${user.sub}`,
@@ -0,0 +1,22 @@
1
+ import type { IncomingMessage, ServerResponse } from 'http';
2
+ import type { ResolvedAuthConfig } from '../types.js';
3
+ /**
4
+ * POST /__nk_auth/totp/setup
5
+ * Generates a new TOTP secret for the authenticated user and returns a QR code.
6
+ */
7
+ export declare function handleTotpSetup(config: ResolvedAuthConfig, req: IncomingMessage, res: ServerResponse, db?: any): Promise<boolean>;
8
+ /**
9
+ * POST /__nk_auth/totp/verify-setup
10
+ * Confirms setup by verifying the first 6-digit code and enables TOTP.
11
+ */
12
+ export declare function handleTotpVerifySetup(config: ResolvedAuthConfig, req: IncomingMessage, res: ServerResponse, db?: any): Promise<boolean>;
13
+ /**
14
+ * POST /__nk_auth/totp/disable
15
+ * Disables TOTP after verifying a valid code.
16
+ */
17
+ export declare function handleTotpDisable(config: ResolvedAuthConfig, req: IncomingMessage, res: ServerResponse, db?: any): Promise<boolean>;
18
+ /**
19
+ * POST /__nk_auth/totp/challenge
20
+ * Exchanges a pending-2FA cookie + valid TOTP code for a full session cookie.
21
+ */
22
+ export declare function handleTotpChallenge(config: ResolvedAuthConfig, req: IncomingMessage, res: ServerResponse, db?: any): Promise<boolean>;
@@ -0,0 +1,232 @@
1
+ import { sendJson, readBody } from './utils.js';
2
+ import { encryptSession, createSessionCookie, decryptSession } from '../session.js';
3
+ import { encryptTotpSecret, decryptTotpSecret, saveTotpSecret, enableTotp, disableTotp, getTotpState, } from '../native-auth.js';
4
+ function parseCookies(req) {
5
+ const cookies = {};
6
+ const header = req.headers.cookie || '';
7
+ for (const part of header.split(';')) {
8
+ const [k, ...v] = part.trim().split('=');
9
+ if (k)
10
+ cookies[k.trim()] = decodeURIComponent(v.join('='));
11
+ }
12
+ return cookies;
13
+ }
14
+ /**
15
+ * POST /__nk_auth/totp/setup
16
+ * Generates a new TOTP secret for the authenticated user and returns a QR code.
17
+ */
18
+ export async function handleTotpSetup(config, req, res, db) {
19
+ const user = req.nkAuth?.user;
20
+ if (!user?.sub) {
21
+ sendJson(res, 401, { error: 'Not authenticated' });
22
+ return true;
23
+ }
24
+ if (!db) {
25
+ sendJson(res, 500, { error: 'Database unavailable' });
26
+ return true;
27
+ }
28
+ try {
29
+ const { authenticator } = await import('otplib');
30
+ const QRCode = await import('qrcode');
31
+ const secret = authenticator.generateSecret();
32
+ const appName = config.totp?.appName || 'Nuraly';
33
+ const otpauthUri = authenticator.keyuri(user.email || user.sub, appName, secret);
34
+ const qrDataUrl = await QRCode.default.toDataURL(otpauthUri);
35
+ const encrypted = await encryptTotpSecret(secret, config.session.secret);
36
+ await saveTotpSecret(db, user.sub, encrypted);
37
+ sendJson(res, 200, { qrDataUrl, otpauthUri });
38
+ }
39
+ catch (err) {
40
+ sendJson(res, 500, { error: err.message || 'Setup failed' });
41
+ }
42
+ return true;
43
+ }
44
+ /**
45
+ * POST /__nk_auth/totp/verify-setup
46
+ * Confirms setup by verifying the first 6-digit code and enables TOTP.
47
+ */
48
+ export async function handleTotpVerifySetup(config, req, res, db) {
49
+ const user = req.nkAuth?.user;
50
+ if (!user?.sub) {
51
+ sendJson(res, 401, { error: 'Not authenticated' });
52
+ return true;
53
+ }
54
+ if (!db) {
55
+ sendJson(res, 500, { error: 'Database unavailable' });
56
+ return true;
57
+ }
58
+ let body;
59
+ try {
60
+ body = JSON.parse(await readBody(req));
61
+ }
62
+ catch {
63
+ sendJson(res, 400, { error: 'Invalid JSON' });
64
+ return true;
65
+ }
66
+ const { code } = body;
67
+ if (!code) {
68
+ sendJson(res, 400, { error: 'Code required' });
69
+ return true;
70
+ }
71
+ try {
72
+ const { authenticator } = await import('otplib');
73
+ const state = await getTotpState(db, user.sub);
74
+ if (!state.encryptedSecret) {
75
+ sendJson(res, 400, { error: 'No pending TOTP setup' });
76
+ return true;
77
+ }
78
+ const secret = await decryptTotpSecret(state.encryptedSecret, config.session.secret);
79
+ const valid = authenticator.check(code, secret);
80
+ if (!valid) {
81
+ sendJson(res, 400, { error: 'Invalid code — check your authenticator app and try again' });
82
+ return true;
83
+ }
84
+ await enableTotp(db, user.sub);
85
+ sendJson(res, 200, { ok: true });
86
+ }
87
+ catch (err) {
88
+ sendJson(res, 500, { error: err.message || 'Verification failed' });
89
+ }
90
+ return true;
91
+ }
92
+ /**
93
+ * POST /__nk_auth/totp/disable
94
+ * Disables TOTP after verifying a valid code.
95
+ */
96
+ export async function handleTotpDisable(config, req, res, db) {
97
+ const user = req.nkAuth?.user;
98
+ if (!user?.sub) {
99
+ sendJson(res, 401, { error: 'Not authenticated' });
100
+ return true;
101
+ }
102
+ if (!db) {
103
+ sendJson(res, 500, { error: 'Database unavailable' });
104
+ return true;
105
+ }
106
+ let body;
107
+ try {
108
+ body = JSON.parse(await readBody(req));
109
+ }
110
+ catch {
111
+ sendJson(res, 400, { error: 'Invalid JSON' });
112
+ return true;
113
+ }
114
+ const { code } = body;
115
+ if (!code) {
116
+ sendJson(res, 400, { error: 'Code required' });
117
+ return true;
118
+ }
119
+ try {
120
+ const { authenticator } = await import('otplib');
121
+ const state = await getTotpState(db, user.sub);
122
+ if (!state.totpEnabled || !state.encryptedSecret) {
123
+ sendJson(res, 400, { error: '2FA is not enabled' });
124
+ return true;
125
+ }
126
+ const secret = await decryptTotpSecret(state.encryptedSecret, config.session.secret);
127
+ const valid = authenticator.check(code, secret);
128
+ if (!valid) {
129
+ sendJson(res, 400, { error: 'Invalid code' });
130
+ return true;
131
+ }
132
+ await disableTotp(db, user.sub);
133
+ sendJson(res, 200, { ok: true });
134
+ }
135
+ catch (err) {
136
+ sendJson(res, 500, { error: err.message || 'Failed to disable 2FA' });
137
+ }
138
+ return true;
139
+ }
140
+ /**
141
+ * POST /__nk_auth/totp/challenge
142
+ * Exchanges a pending-2FA cookie + valid TOTP code for a full session cookie.
143
+ */
144
+ export async function handleTotpChallenge(config, req, res, db) {
145
+ if (!db) {
146
+ sendJson(res, 500, { error: 'Database unavailable' });
147
+ return true;
148
+ }
149
+ const cookies = parseCookies(req);
150
+ const pendingEncrypted = cookies['nk-totp-pending'];
151
+ if (!pendingEncrypted) {
152
+ sendJson(res, 401, { error: 'No pending 2FA session' });
153
+ return true;
154
+ }
155
+ let body;
156
+ try {
157
+ body = JSON.parse(await readBody(req));
158
+ }
159
+ catch {
160
+ sendJson(res, 400, { error: 'Invalid JSON' });
161
+ return true;
162
+ }
163
+ const { code } = body;
164
+ if (!code) {
165
+ sendJson(res, 400, { error: 'Code required' });
166
+ return true;
167
+ }
168
+ try {
169
+ // Decrypt and validate the pending session
170
+ const pending = await decryptSession(pendingEncrypted, config.session.secret);
171
+ if (!pending || !pending.accessToken.startsWith('totp-pending:')) {
172
+ sendJson(res, 401, { error: 'Invalid pending session' });
173
+ return true;
174
+ }
175
+ if (pending.expiresAt < Math.floor(Date.now() / 1000)) {
176
+ sendJson(res, 401, { error: '2FA session expired — please log in again' });
177
+ return true;
178
+ }
179
+ const userId = pending.accessToken.slice('totp-pending:'.length);
180
+ // Fetch full user row
181
+ const row = await db.get('SELECT * FROM _nk_auth_users WHERE id = ?', userId);
182
+ if (!row) {
183
+ sendJson(res, 401, { error: 'User not found' });
184
+ return true;
185
+ }
186
+ // Verify TOTP code
187
+ const { authenticator } = await import('otplib');
188
+ if (!row.totp_enabled || !row.totp_secret) {
189
+ sendJson(res, 400, { error: '2FA not enabled for this account' });
190
+ return true;
191
+ }
192
+ const secret = await decryptTotpSecret(row.totp_secret, config.session.secret);
193
+ const valid = authenticator.check(code, secret);
194
+ if (!valid) {
195
+ sendJson(res, 400, { error: 'Invalid code — check your authenticator app and try again' });
196
+ return true;
197
+ }
198
+ // Build full user object
199
+ let roles = [];
200
+ try {
201
+ roles = JSON.parse(row.roles);
202
+ }
203
+ catch { }
204
+ const user = { sub: row.id, email: row.email, name: row.name, roles, provider: 'native' };
205
+ // Issue full session cookie
206
+ const sessionData = {
207
+ accessToken: `native:${user.sub}`,
208
+ expiresAt: Math.floor(Date.now() / 1000) + config.session.maxAge,
209
+ user,
210
+ provider: 'native',
211
+ createdAt: Math.floor(Date.now() / 1000),
212
+ };
213
+ const encrypted = await encryptSession(sessionData, config.session.secret);
214
+ const sessionCookie = createSessionCookie(config.session.cookieName, encrypted, config.session.maxAge, config.session.secure);
215
+ // Clear the pending cookie
216
+ const clearPending = `nk-totp-pending=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${config.session.secure ? '; Secure' : ''}`;
217
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
218
+ const returnTo = url.searchParams.get('returnTo') || config.routes.postLogin;
219
+ if (req.headers.accept?.includes('application/json')) {
220
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': [sessionCookie, clearPending] });
221
+ res.end(JSON.stringify({ user, returnTo }));
222
+ }
223
+ else {
224
+ res.writeHead(302, { Location: returnTo, 'Set-Cookie': [sessionCookie, clearPending] });
225
+ res.end();
226
+ }
227
+ }
228
+ catch (err) {
229
+ sendJson(res, 500, { error: err.message || 'Challenge failed' });
230
+ }
231
+ return true;
232
+ }
@@ -6,6 +6,7 @@ import { handleLogout, handleLogoutAll } from './routes/logout.js';
6
6
  import { handleVerifyEmail } from './routes/verify.js';
7
7
  import { handleForgotPassword, handleResetPassword, handleChangePassword } from './routes/password.js';
8
8
  import { handleTokenRefresh, handleTokenRevoke } from './routes/token.js';
9
+ import { handleTotpSetup, handleTotpVerifySetup, handleTotpDisable, handleTotpChallenge } from './routes/totp.js';
9
10
  /**
10
11
  * Validate Origin header on POST requests to prevent CSRF.
11
12
  * Returns true if the request is safe to proceed.
@@ -106,5 +107,18 @@ export async function handleAuthRoutes(config, req, res, db) {
106
107
  if (pathname === '/__nk_auth/revoke' && req.method === 'POST') {
107
108
  return handleTokenRevoke(req, res, db);
108
109
  }
110
+ // ── TOTP 2FA ──────────────────────────────────────────────────
111
+ if (pathname === '/__nk_auth/totp/setup' && req.method === 'POST') {
112
+ return handleTotpSetup(config, req, res, db);
113
+ }
114
+ if (pathname === '/__nk_auth/totp/verify-setup' && req.method === 'POST') {
115
+ return handleTotpVerifySetup(config, req, res, db);
116
+ }
117
+ if (pathname === '/__nk_auth/totp/disable' && req.method === 'POST') {
118
+ return handleTotpDisable(config, req, res, db);
119
+ }
120
+ if (pathname === '/__nk_auth/totp/challenge' && req.method === 'POST') {
121
+ return handleTotpChallenge(config, req, res, db);
122
+ }
109
123
  return false;
110
124
  }
@@ -58,11 +58,11 @@ export function hashRefreshToken(token) {
58
58
  }
59
59
  export async function ensureRefreshTokenTable(db) {
60
60
  await db.exec(`CREATE TABLE IF NOT EXISTS _nk_auth_refresh_tokens (
61
- id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ id SERIAL PRIMARY KEY,
62
62
  token_hash TEXT NOT NULL UNIQUE,
63
63
  user_id TEXT NOT NULL,
64
64
  expires_at TEXT NOT NULL,
65
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
65
+ created_at TEXT NOT NULL DEFAULT NOW()
66
66
  )`);
67
67
  }
68
68
  export async function storeRefreshToken(db, token, userId, ttlSeconds) {
@@ -1,11 +1,12 @@
1
1
  import { type UserConfig, type Plugin } from 'vite';
2
- import type { PageEntry, LayoutEntry, ApiEntry } from './scan.js';
2
+ import type { PageEntry, LayoutEntry, ApiEntry, MiddlewareEntry } from './scan.js';
3
3
  export interface BuildServerOptions {
4
4
  projectDir: string;
5
5
  serverDir: string;
6
6
  pageEntries: PageEntry[];
7
7
  layoutEntries: LayoutEntry[];
8
8
  apiEntries: ApiEntry[];
9
+ middlewareEntries: MiddlewareEntry[];
9
10
  hasAuthConfig: boolean;
10
11
  authConfigPath: string;
11
12
  shared: {
@@ -2,7 +2,7 @@ import { build as viteBuild } from 'vite';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
4
  export async function buildServer(opts) {
5
- const { projectDir, serverDir, pageEntries, layoutEntries, apiEntries, hasAuthConfig, authConfigPath, shared } = opts;
5
+ const { projectDir, serverDir, pageEntries, layoutEntries, apiEntries, middlewareEntries, hasAuthConfig, authConfigPath, shared } = opts;
6
6
  console.log('[LumenJS] Building server bundle...');
7
7
  // Collect server entry points (pages with loaders + layouts with loaders + API routes)
8
8
  const serverEntries = {};
@@ -20,6 +20,10 @@ export async function buildServer(opts) {
20
20
  for (const entry of apiEntries) {
21
21
  serverEntries[`api/${entry.name}`] = entry.filePath;
22
22
  }
23
+ for (const entry of middlewareEntries) {
24
+ const entryName = entry.dir ? `middleware/${entry.dir}/_middleware` : 'middleware/_middleware';
25
+ serverEntries[entryName] = entry.filePath;
26
+ }
23
27
  if (hasAuthConfig) {
24
28
  serverEntries['auth-config'] = authConfigPath;
25
29
  }
@@ -77,6 +81,11 @@ export async function buildServer(opts) {
77
81
  'worker_threads', 'cluster', 'dns', 'tls', 'assert', 'constants',
78
82
  // Native addons — must not be bundled, loaded from node_modules at runtime
79
83
  'better-sqlite3',
84
+ // AWS SDK — optional peer dep, keep as runtime import so app can provide it
85
+ '@aws-sdk/client-s3',
86
+ '@aws-sdk/s3-request-presigner',
87
+ /^@aws-sdk\//,
88
+ /^@smithy\//,
80
89
  ],
81
90
  },
82
91
  },
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import { getSharedViteConfig } from '../dev-server/server.js';
4
4
  import { readProjectConfig } from '../dev-server/config.js';
5
5
  import { filePathToTagName } from '../shared/utils.js';
6
- import { scanPages, scanLayouts, scanApiRoutes, getLayoutDirsForPage } from './scan.js';
6
+ import { scanPages, scanLayouts, scanApiRoutes, scanMiddleware, getLayoutDirsForPage } from './scan.js';
7
7
  import { buildClient } from './build-client.js';
8
8
  import { buildServer } from './build-server.js';
9
9
  import { prerenderPages } from './build-prerender.js';
@@ -22,10 +22,11 @@ export async function buildProject(options) {
22
22
  fs.mkdirSync(outDir, { recursive: true });
23
23
  const { title, integrations, i18n: i18nConfig, prefetch: prefetchStrategy, prerender: globalPrerender } = readProjectConfig(projectDir);
24
24
  const shared = getSharedViteConfig(projectDir, { mode: 'production', integrations });
25
- // Scan pages, layouts, and API routes for the manifest
25
+ // Scan pages, layouts, API routes, and middleware for the manifest
26
26
  const pageEntries = scanPages(pagesDir);
27
27
  const layoutEntries = scanLayouts(pagesDir);
28
28
  const apiEntries = scanApiRoutes(apiDir);
29
+ const middlewareEntries = scanMiddleware(pagesDir);
29
30
  // Check for auth config
30
31
  const authConfigPath = path.join(projectDir, 'lumenjs.auth.ts');
31
32
  const hasAuthConfig = fs.existsSync(authConfigPath);
@@ -52,6 +53,7 @@ export async function buildProject(options) {
52
53
  pageEntries,
53
54
  layoutEntries,
54
55
  apiEntries,
56
+ middlewareEntries,
55
57
  hasAuthConfig,
56
58
  authConfigPath,
57
59
  shared,
@@ -77,11 +79,12 @@ export async function buildProject(options) {
77
79
  const relPath = path.relative(pagesDir, e.filePath).replace(/\\/g, '/');
78
80
  return {
79
81
  path: e.routePath,
80
- module: (e.hasLoader || e.hasSubscribe || e.prerender) ? `pages/${e.name}.js` : '',
82
+ module: (e.hasLoader || e.hasSubscribe || e.hasSocket || e.prerender) ? `pages/${e.name.replace(/\[(\w+)\]/g, '_$1_')}.js` : '',
81
83
  hasLoader: e.hasLoader,
82
84
  hasSubscribe: e.hasSubscribe,
83
85
  tagName: filePathToTagName(relPath),
84
86
  ...(routeLayouts.length > 0 ? { layouts: routeLayouts } : {}),
87
+ ...(e.hasSocket ? { hasSocket: true } : {}),
85
88
  ...(e.hasAuth ? { hasAuth: true } : {}),
86
89
  ...(e.hasMeta ? { hasMeta: true } : {}),
87
90
  ...(e.hasStandalone ? { hasStandalone: true } : {}),
@@ -90,7 +93,7 @@ export async function buildProject(options) {
90
93
  }),
91
94
  apiRoutes: apiEntries.map(e => ({
92
95
  path: `/api/${e.routePath}`,
93
- module: `api/${e.name}.js`,
96
+ module: `api/${e.name.replace(/\[(\w+)\]/g, '_$1_')}.js`,
94
97
  hasLoader: false,
95
98
  hasSubscribe: false,
96
99
  })),
@@ -100,6 +103,12 @@ export async function buildProject(options) {
100
103
  hasLoader: e.hasLoader,
101
104
  hasSubscribe: e.hasSubscribe,
102
105
  })),
106
+ ...(middlewareEntries.length > 0 ? {
107
+ middlewares: middlewareEntries.map(e => ({
108
+ dir: e.dir,
109
+ module: e.dir ? `middleware/${e.dir}/_middleware.js` : 'middleware/_middleware.js',
110
+ })),
111
+ } : {}),
103
112
  ...(i18nConfig ? { i18n: i18nConfig } : {}),
104
113
  ...(hasAuthConfig ? { auth: { configModule: 'auth-config.js' } } : {}),
105
114
  prefetch: prefetchStrategy,
@@ -4,6 +4,7 @@ export interface PageEntry {
4
4
  routePath: string;
5
5
  hasLoader: boolean;
6
6
  hasSubscribe: boolean;
7
+ hasSocket: boolean;
7
8
  hasAuth: boolean;
8
9
  hasMeta: boolean;
9
10
  hasStandalone: boolean;
@@ -17,6 +17,7 @@ function analyzePageFile(filePath) {
17
17
  return {
18
18
  hasLoader: hasExportBefore(/export\s+(async\s+)?function\s+loader\s*\(/),
19
19
  hasSubscribe: hasExportBefore(/export\s+(async\s+)?function\s+subscribe\s*\(/),
20
+ hasSocket: /export\s+(function|const)\s+socket[\s(=]/.test(content),
20
21
  hasAuth: hasExportBefore(/export\s+const\s+auth\s*=/),
21
22
  hasMeta: hasExportBefore(/export\s+(const\s+meta\s*=|(async\s+)?function\s+meta\s*\()/),
22
23
  hasStandalone: hasExportBefore(/export\s+const\s+standalone\s*=/),
@@ -24,7 +25,7 @@ function analyzePageFile(filePath) {
24
25
  };
25
26
  }
26
27
  catch {
27
- return { hasLoader: false, hasSubscribe: false, hasAuth: false, hasMeta: false, hasStandalone: false, prerender: false };
28
+ return { hasLoader: false, hasSubscribe: false, hasSocket: false, hasAuth: false, hasMeta: false, hasStandalone: false, prerender: false };
28
29
  }
29
30
  }
30
31
  export function scanPages(pagesDir) {