@nuraly/lumenjs 0.1.4 → 0.3.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 (96) hide show
  1. package/README.md +48 -7
  2. package/dist/auth/native-auth.d.ts +9 -0
  3. package/dist/auth/native-auth.js +49 -2
  4. package/dist/auth/routes/login.js +24 -1
  5. package/dist/auth/routes/totp.d.ts +22 -0
  6. package/dist/auth/routes/totp.js +232 -0
  7. package/dist/auth/routes.js +14 -0
  8. package/dist/auth/token.js +2 -2
  9. package/dist/build/build-markdown.d.ts +15 -0
  10. package/dist/build/build-markdown.js +90 -0
  11. package/dist/build/build-server.d.ts +2 -1
  12. package/dist/build/build-server.js +12 -4
  13. package/dist/build/build.js +46 -5
  14. package/dist/build/scan.d.ts +1 -0
  15. package/dist/build/scan.js +2 -1
  16. package/dist/build/serve-static.js +2 -1
  17. package/dist/build/serve.js +131 -11
  18. package/dist/dev-server/config.js +18 -1
  19. package/dist/dev-server/index-html.d.ts +1 -0
  20. package/dist/dev-server/index-html.js +4 -1
  21. package/dist/dev-server/plugins/vite-plugin-llms.js +1 -0
  22. package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +4 -3
  23. package/dist/dev-server/plugins/vite-plugin-loaders.js +4 -3
  24. package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
  25. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
  26. package/dist/dev-server/server.js +146 -88
  27. package/dist/dev-server/ssr-render.js +10 -2
  28. package/dist/editor/ai/backend.js +11 -2
  29. package/dist/editor/ai/deepseek-client.d.ts +7 -0
  30. package/dist/editor/ai/deepseek-client.js +113 -0
  31. package/dist/editor/ai/opencode-client.d.ts +1 -1
  32. package/dist/editor/ai/opencode-client.js +21 -47
  33. package/dist/editor/ai/types.d.ts +1 -1
  34. package/dist/editor/ai/types.js +2 -2
  35. package/dist/editor/ai-chat-panel.js +27 -1
  36. package/dist/editor/editor-bridge.js +2 -1
  37. package/dist/editor/overlay-hmr.js +2 -1
  38. package/dist/llms/generate.d.ts +15 -1
  39. package/dist/llms/generate.js +54 -44
  40. package/dist/runtime/app-shell.d.ts +1 -1
  41. package/dist/runtime/app-shell.js +1 -0
  42. package/dist/runtime/communication.d.ts +65 -36
  43. package/dist/runtime/communication.js +117 -57
  44. package/dist/runtime/island.d.ts +16 -0
  45. package/dist/runtime/island.js +80 -0
  46. package/dist/runtime/router-hydration.js +9 -2
  47. package/dist/runtime/router.d.ts +3 -1
  48. package/dist/runtime/router.js +51 -3
  49. package/dist/runtime/webrtc.d.ts +44 -0
  50. package/dist/runtime/webrtc.js +263 -13
  51. package/dist/shared/dom-shims.js +4 -2
  52. package/dist/shared/html-to-markdown.d.ts +6 -0
  53. package/dist/shared/html-to-markdown.js +73 -0
  54. package/dist/shared/types.d.ts +1 -0
  55. package/dist/storage/adapters/s3.js +6 -3
  56. package/package.json +33 -7
  57. package/templates/blog/pages/index.ts +3 -3
  58. package/templates/blog/pages/posts/[slug].ts +17 -6
  59. package/templates/blog/pages/tag/[tag].ts +6 -6
  60. package/templates/dashboard/pages/index.ts +7 -7
  61. package/templates/default/pages/index.ts +3 -3
  62. package/templates/social/api/posts/[id].ts +0 -14
  63. package/templates/social/api/posts.ts +0 -11
  64. package/templates/social/api/profile/[username].ts +0 -10
  65. package/templates/social/api/upload.ts +0 -19
  66. package/templates/social/data/migrations/001_init.sql +0 -78
  67. package/templates/social/data/migrations/002_add_image_url.sql +0 -1
  68. package/templates/social/data/migrations/003_auth.sql +0 -7
  69. package/templates/social/docs/architecture.md +0 -76
  70. package/templates/social/docs/components.md +0 -100
  71. package/templates/social/docs/data.md +0 -89
  72. package/templates/social/docs/pages.md +0 -96
  73. package/templates/social/docs/theming.md +0 -52
  74. package/templates/social/lib/media.ts +0 -130
  75. package/templates/social/lumenjs.auth.ts +0 -21
  76. package/templates/social/lumenjs.config.ts +0 -3
  77. package/templates/social/package.json +0 -5
  78. package/templates/social/pages/_layout.ts +0 -239
  79. package/templates/social/pages/apps/[id].ts +0 -173
  80. package/templates/social/pages/apps/index.ts +0 -116
  81. package/templates/social/pages/auth/login.ts +0 -92
  82. package/templates/social/pages/bookmarks.ts +0 -57
  83. package/templates/social/pages/explore.ts +0 -73
  84. package/templates/social/pages/index.ts +0 -351
  85. package/templates/social/pages/messages.ts +0 -298
  86. package/templates/social/pages/new.ts +0 -77
  87. package/templates/social/pages/notifications.ts +0 -73
  88. package/templates/social/pages/post/[id].ts +0 -124
  89. package/templates/social/pages/profile/[username].ts +0 -100
  90. package/templates/social/pages/settings/accessibility.ts +0 -153
  91. package/templates/social/pages/settings/account.ts +0 -260
  92. package/templates/social/pages/settings/help.ts +0 -141
  93. package/templates/social/pages/settings/language.ts +0 -103
  94. package/templates/social/pages/settings/privacy.ts +0 -183
  95. package/templates/social/pages/settings/security.ts +0 -133
  96. package/templates/social/pages/settings.ts +0 -185
package/README.md CHANGED
@@ -68,6 +68,8 @@ export class PageIndex extends LitElement {
68
68
 
69
69
  Export a `loader()` to fetch data server-side. It runs on SSR and via `/__nk_loader/<path>` during client-side navigation. Automatically stripped from client bundles.
70
70
 
71
+ Declare each returned key as its own property — the framework spreads loader data onto the element automatically.
72
+
71
73
  ```typescript
72
74
  // pages/blog/[slug].ts
73
75
  export async function loader({ params }) {
@@ -77,11 +79,11 @@ export async function loader({ params }) {
77
79
  }
78
80
 
79
81
  export class BlogPost extends LitElement {
80
- static properties = { loaderData: { type: Object } };
81
- loaderData: any = {};
82
+ static properties = { post: { type: Object } };
83
+ post: any = null;
82
84
 
83
85
  render() {
84
- return html`<h1>${this.loaderData.post?.title}</h1>`;
86
+ return html`<h1>${this.post?.title}</h1>`;
85
87
  }
86
88
  }
87
89
  ```
@@ -110,16 +112,20 @@ export function subscribe({ push }) {
110
112
  }
111
113
 
112
114
  export class PageDashboard extends LitElement {
113
- static properties = { liveData: { type: Object } };
114
- liveData: any = null;
115
+ static properties = {
116
+ time: { type: String },
117
+ count: { type: Number },
118
+ };
119
+ time = '';
120
+ count = 0;
115
121
 
116
122
  render() {
117
- return html`<p>Server time: ${this.liveData?.time}</p>`;
123
+ return html`<p>Server time: ${this.time}</p>`;
118
124
  }
119
125
  }
120
126
  ```
121
127
 
122
- Return a cleanup function — it runs when the client disconnects.
128
+ Each key from `push()` is spread as an individual property on the component — same as loader data. Return a cleanup function — it runs when the client disconnects.
123
129
 
124
130
  ## Layouts
125
131
 
@@ -186,6 +192,41 @@ npx lumenjs add tailwind # Tailwind CSS via @tailwindcss/vite
186
192
  export default { integrations: ['nuralyui'] };
187
193
  ```
188
194
 
195
+ ## Visual Editor
196
+
197
+ Start the dev server with `--editor-mode` to edit pages visually in the browser:
198
+
199
+ ```bash
200
+ npx lumenjs dev --editor-mode
201
+ ```
202
+
203
+ Click elements to select them, edit properties and styles in the side panel, double-click text to edit inline, or ask the AI assistant to make changes for you. Everything saves directly to your source files.
204
+
205
+ ### AI Backend
206
+
207
+ The editor includes an AI assistant that can modify your components. It supports three backends:
208
+
209
+ **Claude Code** (recommended) — uses your Pro/Max subscription, no API key needed:
210
+
211
+ ```bash
212
+ npm install -g @anthropic-ai/claude-code
213
+ claude login
214
+ npm install @anthropic-ai/claude-agent-sdk
215
+ npx lumenjs dev --editor-mode
216
+ ```
217
+
218
+ **OpenCode** — coding agent server, configure it with DeepSeek or any LLM provider:
219
+
220
+ ```bash
221
+ npm install -g opencode
222
+ opencode serve # terminal 1
223
+ npx lumenjs dev --editor-mode # terminal 2
224
+ ```
225
+
226
+ Configure the connection: `OPENCODE_URL` (default `http://localhost:4096`) and `OPENCODE_SERVER_PASSWORD` if auth is required.
227
+
228
+ Set `AI_BACKEND` to force a specific backend (`claude-code` or `opencode`). Without it, the editor auto-detects: Claude Code (if CLI logged in) → OpenCode (fallback).
229
+
189
230
  ## CLI
190
231
 
191
232
  ```
@@ -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) {
@@ -0,0 +1,15 @@
1
+ import type { BuildManifest } from '../shared/types.js';
2
+ import type { PageEntry } from './scan.js';
3
+ export interface MarkdownOptions {
4
+ serverDir: string;
5
+ clientDir: string;
6
+ pagesDir: string;
7
+ pageEntries: PageEntry[];
8
+ manifest: BuildManifest;
9
+ }
10
+ /**
11
+ * Generate static .md files for each page by SSR-rendering the component
12
+ * and converting the HTML to markdown. Written to clientDir so the
13
+ * production static file server picks them up (e.g., /docs/routing.md).
14
+ */
15
+ export declare function generateMarkdownPages(opts: MarkdownOptions): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { pathToFileURL } from 'url';
4
+ import { stripOuterLitMarkers, patchLoaderDataSpread } from '../shared/utils.js';
5
+ import { installDomShims } from '../shared/dom-shims.js';
6
+ import { htmlToMarkdown } from '../shared/html-to-markdown.js';
7
+ /**
8
+ * Generate static .md files for each page by SSR-rendering the component
9
+ * and converting the HTML to markdown. Written to clientDir so the
10
+ * production static file server picks them up (e.g., /docs/routing.md).
11
+ */
12
+ export async function generateMarkdownPages(opts) {
13
+ const { serverDir, clientDir, pagesDir, pageEntries, manifest } = opts;
14
+ // Skip if no pages
15
+ if (pageEntries.length === 0)
16
+ return;
17
+ // Load SSR runtime
18
+ const ssrRuntimePath = pathToFileURL(path.join(serverDir, 'ssr-runtime.js')).href;
19
+ let ssrRuntime;
20
+ try {
21
+ ssrRuntime = await import(ssrRuntimePath);
22
+ }
23
+ catch {
24
+ // No SSR runtime — skip markdown generation
25
+ return;
26
+ }
27
+ const { render, html, unsafeStatic } = ssrRuntime;
28
+ installDomShims();
29
+ let count = 0;
30
+ for (const page of pageEntries) {
31
+ // Skip dynamic routes (e.g., /blog/:slug)
32
+ if (page.routePath.includes(':'))
33
+ continue;
34
+ const moduleName = `pages/${page.name.replace(/\[(\w+)\]/g, '_$1_')}.js`;
35
+ let modulePath = path.join(serverDir, moduleName);
36
+ if (!fs.existsSync(modulePath)) {
37
+ modulePath = path.join(serverDir, moduleName.replace(/\[/g, '_').replace(/\]/g, '_'));
38
+ }
39
+ if (!fs.existsSync(modulePath))
40
+ continue;
41
+ try {
42
+ const mod = await import(pathToFileURL(modulePath).href);
43
+ // Run loader if present
44
+ let loaderData = undefined;
45
+ if (mod.loader && typeof mod.loader === 'function') {
46
+ loaderData = await mod.loader({ params: {}, query: {}, url: page.routePath, headers: {} });
47
+ if (loaderData?.__nk_redirect)
48
+ continue;
49
+ }
50
+ // Get tag name from manifest
51
+ const route = manifest.routes.find(r => r.path === page.routePath);
52
+ const tagName = route?.tagName;
53
+ if (!tagName)
54
+ continue;
55
+ patchLoaderDataSpread(tagName);
56
+ const tag = unsafeStatic(tagName);
57
+ const template = loaderData !== undefined
58
+ ? html `<${tag} .loaderData=${loaderData}></${tag}>`
59
+ : html `<${tag}></${tag}>`;
60
+ let rendered = '';
61
+ for (const chunk of render(template)) {
62
+ rendered += typeof chunk === 'string' ? chunk : String(chunk);
63
+ }
64
+ rendered = stripOuterLitMarkers(rendered);
65
+ const markdown = htmlToMarkdown(rendered);
66
+ if (!markdown.trim())
67
+ continue;
68
+ // Write to clientDir so static serving picks it up
69
+ // /docs/routing → clientDir/docs/routing.md
70
+ const mdPath = page.routePath === '/'
71
+ ? path.join(clientDir, 'index.md')
72
+ : path.join(clientDir, page.routePath + '.md');
73
+ // Skip if user provided their own .md file (copied from public/ during client build)
74
+ if (fs.existsSync(mdPath))
75
+ continue;
76
+ const mdDir = path.dirname(mdPath);
77
+ if (!fs.existsSync(mdDir))
78
+ fs.mkdirSync(mdDir, { recursive: true });
79
+ fs.writeFileSync(mdPath, markdown);
80
+ count++;
81
+ }
82
+ catch (err) {
83
+ // Skip pages that fail to render
84
+ console.warn(`[LumenJS] Markdown generation skipped for ${page.routePath}: ${err?.message}`);
85
+ }
86
+ }
87
+ if (count > 0) {
88
+ console.log(`[LumenJS] Generated ${count} markdown page(s) for /llms.txt`);
89
+ }
90
+ }
@@ -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: {