@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.
- 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-server.d.ts +2 -1
- package/dist/build/build-server.js +10 -1
- package/dist/build/build.js +13 -4
- package/dist/build/scan.d.ts +1 -0
- package/dist/build/scan.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-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-chat-panel.js +27 -1
- package/dist/editor/editor-bridge.js +2 -1
- package/dist/editor/overlay-hmr.js +2 -1
- package/dist/runtime/app-shell.d.ts +1 -1
- package/dist/runtime/app-shell.js +1 -0
- 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 +49 -1
- 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/types.d.ts +1 -0
- package/dist/storage/adapters/s3.js +6 -3
- package/package.json +33 -7
- 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
|
@@ -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) {
|
|
@@ -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
|
},
|
package/dist/build/build.js
CHANGED
|
@@ -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,
|
|
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,
|
package/dist/build/scan.d.ts
CHANGED
package/dist/build/scan.js
CHANGED
|
@@ -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) {
|