@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.
- package/README.md +48 -7
- package/dist/auth/native-auth.d.ts +9 -0
- package/dist/auth/native-auth.js +49 -2
- package/dist/auth/routes/login.js +24 -1
- package/dist/auth/routes/totp.d.ts +22 -0
- package/dist/auth/routes/totp.js +232 -0
- package/dist/auth/routes.js +14 -0
- package/dist/auth/token.js +2 -2
- package/dist/build/build-markdown.d.ts +15 -0
- package/dist/build/build-markdown.js +90 -0
- package/dist/build/build-server.d.ts +2 -1
- package/dist/build/build-server.js +12 -4
- package/dist/build/build.js +46 -5
- package/dist/build/scan.d.ts +1 -0
- package/dist/build/scan.js +2 -1
- package/dist/build/serve-static.js +2 -1
- package/dist/build/serve.js +131 -11
- package/dist/dev-server/config.js +18 -1
- package/dist/dev-server/index-html.d.ts +1 -0
- package/dist/dev-server/index-html.js +4 -1
- package/dist/dev-server/plugins/vite-plugin-llms.js +1 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +4 -3
- package/dist/dev-server/plugins/vite-plugin-loaders.js +4 -3
- package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
- package/dist/dev-server/server.js +146 -88
- package/dist/dev-server/ssr-render.js +10 -2
- package/dist/editor/ai/backend.js +11 -2
- package/dist/editor/ai/deepseek-client.d.ts +7 -0
- package/dist/editor/ai/deepseek-client.js +113 -0
- package/dist/editor/ai/opencode-client.d.ts +1 -1
- package/dist/editor/ai/opencode-client.js +21 -47
- package/dist/editor/ai/types.d.ts +1 -1
- package/dist/editor/ai/types.js +2 -2
- package/dist/editor/ai-chat-panel.js +27 -1
- package/dist/editor/editor-bridge.js +2 -1
- package/dist/editor/overlay-hmr.js +2 -1
- package/dist/llms/generate.d.ts +15 -1
- package/dist/llms/generate.js +54 -44
- package/dist/runtime/app-shell.d.ts +1 -1
- package/dist/runtime/app-shell.js +1 -0
- package/dist/runtime/communication.d.ts +65 -36
- package/dist/runtime/communication.js +117 -57
- package/dist/runtime/island.d.ts +16 -0
- package/dist/runtime/island.js +80 -0
- package/dist/runtime/router-hydration.js +9 -2
- package/dist/runtime/router.d.ts +3 -1
- package/dist/runtime/router.js +51 -3
- package/dist/runtime/webrtc.d.ts +44 -0
- package/dist/runtime/webrtc.js +263 -13
- package/dist/shared/dom-shims.js +4 -2
- package/dist/shared/html-to-markdown.d.ts +6 -0
- package/dist/shared/html-to-markdown.js +73 -0
- package/dist/shared/types.d.ts +1 -0
- package/dist/storage/adapters/s3.js +6 -3
- package/package.json +33 -7
- package/templates/blog/pages/index.ts +3 -3
- package/templates/blog/pages/posts/[slug].ts +17 -6
- package/templates/blog/pages/tag/[tag].ts +6 -6
- package/templates/dashboard/pages/index.ts +7 -7
- package/templates/default/pages/index.ts +3 -3
- package/templates/social/api/posts/[id].ts +0 -14
- package/templates/social/api/posts.ts +0 -11
- package/templates/social/api/profile/[username].ts +0 -10
- package/templates/social/api/upload.ts +0 -19
- package/templates/social/data/migrations/001_init.sql +0 -78
- package/templates/social/data/migrations/002_add_image_url.sql +0 -1
- package/templates/social/data/migrations/003_auth.sql +0 -7
- package/templates/social/docs/architecture.md +0 -76
- package/templates/social/docs/components.md +0 -100
- package/templates/social/docs/data.md +0 -89
- package/templates/social/docs/pages.md +0 -96
- package/templates/social/docs/theming.md +0 -52
- package/templates/social/lib/media.ts +0 -130
- package/templates/social/lumenjs.auth.ts +0 -21
- package/templates/social/lumenjs.config.ts +0 -3
- package/templates/social/package.json +0 -5
- package/templates/social/pages/_layout.ts +0 -239
- package/templates/social/pages/apps/[id].ts +0 -173
- package/templates/social/pages/apps/index.ts +0 -116
- package/templates/social/pages/auth/login.ts +0 -92
- package/templates/social/pages/bookmarks.ts +0 -57
- package/templates/social/pages/explore.ts +0 -73
- package/templates/social/pages/index.ts +0 -351
- package/templates/social/pages/messages.ts +0 -298
- package/templates/social/pages/new.ts +0 -77
- package/templates/social/pages/notifications.ts +0 -73
- package/templates/social/pages/post/[id].ts +0 -124
- package/templates/social/pages/profile/[username].ts +0 -100
- package/templates/social/pages/settings/accessibility.ts +0 -153
- package/templates/social/pages/settings/account.ts +0 -260
- package/templates/social/pages/settings/help.ts +0 -141
- package/templates/social/pages/settings/language.ts +0 -103
- package/templates/social/pages/settings/privacy.ts +0 -183
- package/templates/social/pages/settings/security.ts +0 -133
- 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 = {
|
|
81
|
-
|
|
82
|
+
static properties = { post: { type: Object } };
|
|
83
|
+
post: any = null;
|
|
82
84
|
|
|
83
85
|
render() {
|
|
84
|
-
return html`<h1>${this.
|
|
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 = {
|
|
114
|
-
|
|
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.
|
|
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. */
|
package/dist/auth/native-auth.js
CHANGED
|
@@ -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 (
|
|
48
|
-
updated_at TEXT NOT NULL DEFAULT (
|
|
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
|
+
}
|
package/dist/auth/routes.js
CHANGED
|
@@ -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
|
}
|
package/dist/auth/token.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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: {
|