@dotdrelle/wiki-manager 0.15.93 → 0.15.94

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.
@@ -0,0 +1,132 @@
1
+ import { qrSvg } from './qrCode.js';
2
+
3
+ function escapeHtml(value) {
4
+ return String(value ?? '')
5
+ .replace(/&/g, '&')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;');
9
+ }
10
+
11
+ /*
12
+ The single TOTP login page, served by the runtime. Enrollment shows the QR
13
+ code, the base32 secret and the otpauth:// URI; afterwards the same page is
14
+ a plain 6-digit code form. English UI chrome, like every other manager
15
+ surface. Verifying here also sets the shared `wiki_session` cookie on the
16
+ runtime's origin (server.js), so the same browser reuses the session on
17
+ serve when it runs on this host; serve sets its own cookie when a browser
18
+ reaches it first.
19
+ */
20
+ export function loginPageHtml({ enrolled = false, secret = null, uri = null, error = null, sessionExpiresAt = null } = {}) {
21
+ const enrollBlock = !enrolled && secret
22
+ ? `
23
+ <section class="card">
24
+ <h2>Enroll your authenticator</h2>
25
+ <p>Scan the QR code with your authenticator app, or enter the secret manually:</p>
26
+ <div class="qr">${qrSvg(uri)}</div>
27
+ <code class="secret">${escapeHtml(secret)}</code>
28
+ <p class="hint">Manual entry: add an account, choose <strong>TOTP</strong> / time-based code, and paste the secret above.</p>
29
+ </section>`
30
+ : '';
31
+ const successBlock = sessionExpiresAt
32
+ ? `
33
+ <section class="card success">
34
+ <h2>Session active</h2>
35
+ <p>You are signed in until <strong>${escapeHtml(new Date(sessionExpiresAt).toLocaleString())}</strong>.</p>
36
+ <p class="hint">You can close this page.</p>
37
+ </section>`
38
+ : '';
39
+ return `<!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <title>wikiLLM — login</title>
45
+ <style>
46
+ :root { color-scheme: light dark; }
47
+ body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #f4f5f7; color: #1c1f26; display: flex; min-height: 100vh; align-items: center; justify-content: center; }
48
+ @media (prefers-color-scheme: dark) { body { background: #12141a; color: #e7e9ee; } }
49
+ .box { width: 100%; max-width: 380px; padding: 1.2rem; }
50
+ .brand { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; }
51
+ .brand-mark { width: 2rem; height: 2rem; border-radius: 8px; background: #2563eb; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; }
52
+ .brand-name { font-weight: 700; font-size: 1.05rem; }
53
+ .card { background: #fff; border: 1px solid #d9dce3; border-radius: 12px; padding: 1.1rem 1.2rem; margin-bottom: .9rem; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
54
+ @media (prefers-color-scheme: dark) { .card { background: #1b1e26; border-color: #333843; } }
55
+ .card.success { border-color: #22c55e; }
56
+ h2 { margin: 0 0 .5rem; font-size: 1rem; }
57
+ p { margin: 0 0 .8rem; font-size: .85rem; line-height: 1.45; }
58
+ p:last-child { margin-bottom: 0; }
59
+ .hint { color: #6b7280; font-size: .78rem; }
60
+ .qr { display: flex; justify-content: center; padding: .5rem 0; }
61
+ .qr svg { width: 180px; height: 180px; }
62
+ .secret { display: block; text-align: center; font-family: ui-monospace, monospace; font-size: .82rem; letter-spacing: .08em; background: #f1f2f5; border-radius: 8px; padding: .5rem; margin: .4rem 0 .7rem; user-select: all; }
63
+ @media (prefers-color-scheme: dark) { .secret { background: #111319; } }
64
+ form { display: flex; gap: .5rem; }
65
+ input[type="text"] { flex: 1; min-width: 0; font: inherit; font-size: 1.15rem; letter-spacing: .35em; text-align: center; padding: .55rem .4rem; border: 1px solid #c9cdd6; border-radius: 8px; background: #fff; color: inherit; }
66
+ @media (prefers-color-scheme: dark) { input[type="text"] { background: #12141a; border-color: #3a3f4b; } }
67
+ input[type="text"]:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
68
+ button { font: inherit; font-weight: 700; padding: .55rem 1rem; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
69
+ button:hover { background: #1d4fd7; }
70
+ button:disabled { opacity: .55; cursor: default; }
71
+ .error { color: #dc2626; font-size: .8rem; margin-top: .6rem; }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <div class="box">
76
+ <div class="brand"><span class="brand-mark">W</span><span class="brand-name">wikiLLM</span></div>
77
+ ${enrollBlock}
78
+ ${successBlock}
79
+ ${sessionExpiresAt ? '' : `
80
+ <form id="login-form" autocomplete="off">
81
+ <input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="000000" aria-label="Verification code" autofocus required>
82
+ <button type="submit" id="submit">Verify</button>
83
+ </form>
84
+ <p class="error" id="error" hidden></p>`}
85
+ </div>
86
+ <script>
87
+ (function () {
88
+ var form = document.getElementById('login-form');
89
+ if (!form) return;
90
+ var input = document.getElementById('code');
91
+ var submit = document.getElementById('submit');
92
+ var error = document.getElementById('error');
93
+ input.addEventListener('input', function () {
94
+ input.value = input.value.replace(/\\D/g, '').slice(0, 6);
95
+ });
96
+ form.addEventListener('submit', async function (event) {
97
+ event.preventDefault();
98
+ var code = input.value.replace(/\\D/g, '');
99
+ if (code.length !== 6) return;
100
+ submit.disabled = true;
101
+ error.hidden = true;
102
+ try {
103
+ var response = await fetch('/login/verify', {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ code: code })
107
+ });
108
+ var payload = await response.json().catch(function () { return {}; });
109
+ if (response.ok && payload.ok) {
110
+ document.querySelector('.box').innerHTML = payload.page;
111
+ } else {
112
+ error.textContent = payload.error || 'Verification failed.';
113
+ error.hidden = false;
114
+ input.value = '';
115
+ input.focus();
116
+ }
117
+ } catch (err) {
118
+ error.textContent = 'The login service is not answering.';
119
+ error.hidden = false;
120
+ } finally {
121
+ submit.disabled = false;
122
+ }
123
+ });
124
+ })();
125
+ </script>
126
+ </body>
127
+ </html>`;
128
+ }
129
+
130
+ export function loginSuccessHtml(sessionExpiresAt) {
131
+ return loginPageHtml({ enrolled: true, sessionExpiresAt });
132
+ }
@@ -0,0 +1,131 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { startRuntimeServer } from './server.js';
7
+ import { enrollment, issueSessionWithTotp } from './loginSession.js';
8
+ import { totpCode } from './totp.js';
9
+
10
+ /*
11
+ HTTP-level contract of the TOTP login surface: public before the bearer
12
+ gate, enrollment on the first code, session verification behind the bearer,
13
+ revocation. The store is stubbed — none of these routes touch it.
14
+ */
15
+
16
+ let stateDir;
17
+ let server;
18
+
19
+ test.before(async () => {
20
+ stateDir = mkdtempSync(join(tmpdir(), 'login-routes-'));
21
+ process.env.WIKI_MANAGER_STATE_DIR = stateDir;
22
+ server = await startRuntimeServer({ host: '127.0.0.1', port: 0, store: { dbPath: null } });
23
+ });
24
+
25
+ test.after(async () => {
26
+ await server.close();
27
+ delete process.env.WIKI_MANAGER_STATE_DIR;
28
+ rmSync(stateDir, { recursive: true, force: true });
29
+ });
30
+
31
+ function baseUrl() {
32
+ return `http://127.0.0.1:${server.port}`;
33
+ }
34
+
35
+ test('GET /login shows the enrollment QR before any code was verified', async () => {
36
+ const response = await fetch(`${baseUrl()}/login`);
37
+ assert.equal(response.status, 200);
38
+ const html = await response.text();
39
+ assert.match(html, /Enroll your authenticator/);
40
+ assert.match(html, /<svg/); // the QR code
41
+ assert.match(html, /GE|base32/i);
42
+ const status = await (await fetch(`${baseUrl()}/login/status`)).json();
43
+ assert.deepEqual({ enabled: status.enabled, enrolled: status.enrolled }, { enabled: true, enrolled: false });
44
+ });
45
+
46
+ test('the first verified code enrolls and issues a session', async () => {
47
+ const pending = enrollment();
48
+ const code = totpCode(pending.secret);
49
+ const response = await fetch(`${baseUrl()}/login/verify`, {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/json' },
52
+ body: JSON.stringify({ code }),
53
+ });
54
+ assert.equal(response.status, 200);
55
+ const payload = await response.json();
56
+ assert.equal(payload.ok, true);
57
+ assert.ok(payload.token.length >= 32);
58
+ assert.ok(payload.expiresAt > Date.now());
59
+ assert.match(payload.page, /Session active/);
60
+ // The session must reach the browser as a cookie, on the runtime's origin:
61
+ // that is what lets serve on the same host reuse the ShellUI login instead
62
+ // of asking for a second TOTP code.
63
+ const setCookie = response.headers.get('set-cookie') ?? '';
64
+ assert.match(setCookie, /wiki_session=/);
65
+ assert.match(setCookie, /HttpOnly/);
66
+ assert.match(setCookie, /SameSite=Lax/);
67
+ assert.ok(setCookie.includes(payload.token));
68
+
69
+ const status = await (await fetch(`${baseUrl()}/login/status`)).json();
70
+ assert.equal(status.enrolled, true);
71
+ assert.equal(status.sessionActive, true);
72
+ });
73
+
74
+ test('a wrong code is refused and repeated tries are rate-limited', async () => {
75
+ for (let attempt = 0; attempt < 10; attempt++) {
76
+ const response = await fetch(`${baseUrl()}/login/verify`, {
77
+ method: 'POST',
78
+ headers: { 'content-type': 'application/json' },
79
+ body: JSON.stringify({ code: '000000' }),
80
+ });
81
+ assert.equal(response.status, 401, `attempt ${attempt}`);
82
+ assert.equal((await response.json()).error, 'Invalid verification code.');
83
+ }
84
+ const refused = await fetch(`${baseUrl()}/login/verify`, {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body: JSON.stringify({ code: '000000' }),
88
+ });
89
+ assert.equal(refused.status, 429);
90
+ });
91
+
92
+ test('GET /session/verify checks the issued token and slides it', async () => {
93
+ const enrolledSecret = JSON.parse(readFileSync(join(stateDir, 'totp.json'), 'utf8')).secret;
94
+ const issued = issueSessionWithTotp(totpCode(enrolledSecret));
95
+ assert.equal(issued.ok, true);
96
+ const response = await fetch(`${baseUrl()}/session/verify?token=${encodeURIComponent(issued.token)}`);
97
+ assert.equal(response.status, 200);
98
+ const payload = await response.json();
99
+ assert.equal(payload.ok, true);
100
+ assert.equal(payload.ident, 'human');
101
+ assert.ok(payload.expiresAt > Date.now());
102
+
103
+ const unknown = await fetch(`${baseUrl()}/session/verify?token=nope`);
104
+ assert.equal((await unknown.json()).ok, false);
105
+ });
106
+
107
+ test('POST /logout without the session token does not revoke it', async () => {
108
+ const enrolledSecret = JSON.parse(readFileSync(join(stateDir, 'totp.json'), 'utf8')).secret;
109
+ const issued = issueSessionWithTotp(totpCode(enrolledSecret));
110
+ assert.equal(issued.ok, true);
111
+ const response = await fetch(`${baseUrl()}/logout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
112
+ assert.equal(response.status, 200);
113
+ assert.equal((await response.json()).revoked, false);
114
+ const after = await (await fetch(`${baseUrl()}/login/status`)).json();
115
+ assert.equal(after.sessionActive, true, 'this route sits before the bearer gate — a caller with no proof of the token must not be able to force the session out');
116
+ });
117
+
118
+ test('POST /logout with the session token revokes it', async () => {
119
+ const enrolledSecret = JSON.parse(readFileSync(join(stateDir, 'totp.json'), 'utf8')).secret;
120
+ const issued = issueSessionWithTotp(totpCode(enrolledSecret));
121
+ assert.equal(issued.ok, true);
122
+ const response = await fetch(`${baseUrl()}/logout`, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({ token: issued.token }),
126
+ });
127
+ assert.equal(response.status, 200);
128
+ assert.equal((await response.json()).revoked, true);
129
+ const after = await (await fetch(`${baseUrl()}/login/status`)).json();
130
+ assert.equal(after.sessionActive, false);
131
+ });
@@ -0,0 +1,226 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { defaultRuntimeStateDir } from '../core/env.js';
5
+ import { digestEqual, generateTotpSecret, otpauthUri, verifyTotp } from './totp.js';
6
+
7
+ /*
8
+ The TOTP login authority: one secret, one active session, stored in the
9
+ manager runtime state directory (0600). The session slides — every verified
10
+ check pushes the expiry forward — so "valid for the session" means 12 hours
11
+ of inactivity at most. Nothing here logs secrets or tokens.
12
+ */
13
+
14
+ const SESSION_TTL_HOURS = Number(process.env.WIKI_MANAGER_SESSION_TTL_HOURS ?? 12);
15
+ export const SESSION_TTL_MS = (Number.isFinite(SESSION_TTL_HOURS) && SESSION_TTL_HOURS > 0 ? SESSION_TTL_HOURS : 12) * 60 * 60 * 1000;
16
+
17
+ // A pending enrollment lives only in memory: the secret is shown on the login
18
+ // page BEFORE its first successful code, and only that code persists it.
19
+ let pendingEnrollment = null;
20
+ let pendingEnrollmentSince = 0;
21
+ const PENDING_ENROLLMENT_TTL_MS = 30 * 60 * 1000;
22
+
23
+ export function isTotpEnabled() {
24
+ const value = String(process.env.WIKI_MANAGER_TOTP ?? '').trim().toLowerCase();
25
+ return !['0', 'false', 'no', 'off'].includes(value);
26
+ }
27
+
28
+ function stateDir() {
29
+ return resolve(process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir());
30
+ }
31
+
32
+ function totpPath() {
33
+ return join(stateDir(), 'totp.json');
34
+ }
35
+
36
+ function sessionPath() {
37
+ return join(stateDir(), 'session.json');
38
+ }
39
+
40
+ function writePrivate(path, value) {
41
+ mkdirSync(resolve(path, '..'), { recursive: true });
42
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
43
+ try { chmodSync(path, 0o600); } catch { /* best-effort on platforms without chmod semantics */ }
44
+ }
45
+
46
+ function readJsonFile(path) {
47
+ try {
48
+ return JSON.parse(readFileSync(path, 'utf8'));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function currentTotpSecret() {
55
+ const record = readJsonFile(totpPath());
56
+ return record && typeof record.secret === 'string' && record.secret ? record : null;
57
+ }
58
+
59
+ export function loginStatus() {
60
+ const session = readJsonFile(sessionPath());
61
+ const sessionActive = Boolean(session && Date.now() < session.expiresAt);
62
+ return {
63
+ enabled: isTotpEnabled(),
64
+ enrolled: currentTotpSecret() !== null,
65
+ sessionActive,
66
+ sessionExpiresAt: sessionActive ? session.expiresAt : null,
67
+ };
68
+ }
69
+
70
+ export function enrollment() {
71
+ if (!isTotpEnabled()) return null;
72
+ if (currentTotpSecret()) return null;
73
+ const now = Date.now();
74
+ if (!pendingEnrollment || now - pendingEnrollmentSince > PENDING_ENROLLMENT_TTL_MS) {
75
+ const secret = generateTotpSecret();
76
+ pendingEnrollment = { secret, label: 'wiki', issuer: 'wikiLLM', createdAt: now };
77
+ pendingEnrollmentSince = now;
78
+ }
79
+ return {
80
+ secret: pendingEnrollment.secret,
81
+ uri: otpauthUri(pendingEnrollment.secret, { label: pendingEnrollment.label, issuer: pendingEnrollment.issuer }),
82
+ };
83
+ }
84
+
85
+ export function isLoopbackAddress(address) {
86
+ // "::" is the unspecified/any address (a bind address, never a real peer's
87
+ // remote address) — only "::1" is actually loopback. Treating it as
88
+ // loopback would trust a connection whose address we failed to read as if
89
+ // it proved local origin.
90
+ const value = String(address ?? '').replace(/^::ffff:/, '').trim();
91
+ if (value === '::1') return true;
92
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(value)) return false;
93
+ return value.startsWith('127.');
94
+ }
95
+
96
+ /*
97
+ Verifies a 6-digit code and issues the session. The FIRST successful code
98
+ while nothing is enrolled ENROLLS: it persists the pending secret — never a
99
+ code that failed verification. Returns { ok, enrolled, token, expiresAt } or
100
+ { ok: false, error }.
101
+ */
102
+ // remoteAddress defaults to loopback, not "unknown": the only production
103
+ // caller (server.js's /login/verify) always resolves and passes an explicit
104
+ // value (including an explicit null when the socket itself reports none),
105
+ // so this default is only ever exercised by a direct/programmatic call with
106
+ // no HTTP request behind it at all (tests, internal callers) — which really
107
+ // is local, unlike an HTTP request whose remote address could not be read.
108
+ export function issueSessionWithTotp(code, { timestamp = Date.now(), remoteAddress = '127.0.0.1' } = {}) {
109
+ if (!isTotpEnabled()) return { ok: false, error: 'totp_disabled' };
110
+ const existing = currentTotpSecret();
111
+ const enrolled = Boolean(existing);
112
+ const secret = existing ? existing.secret : pendingEnrollment?.secret;
113
+ if (!secret) return { ok: false, error: 'no_enrollment' };
114
+ // Enrollment shows the secret in the page: only the host itself may claim
115
+ // it. An address we could not determine is not proof of loopback origin —
116
+ // refuse it, the same as any other non-loopback address.
117
+ if (!enrolled && !isLoopbackAddress(remoteAddress)) {
118
+ return { ok: false, error: 'enrollment_requires_loopback' };
119
+ }
120
+ if (!verifyTotp(secret, code, { timestamp })) return { ok: false, error: 'invalid_code' };
121
+ if (!enrolled) {
122
+ writePrivate(totpPath(), {
123
+ secret,
124
+ issuer: pendingEnrollment?.issuer ?? 'wikiLLM',
125
+ label: pendingEnrollment?.label ?? 'wiki',
126
+ enrolledAt: new Date(timestamp).toISOString(),
127
+ });
128
+ pendingEnrollment = null;
129
+ }
130
+ const token = randomBytes(32).toString('hex');
131
+ const issuedAt = timestamp;
132
+ const session = { token, ident: 'human', issuedAt, lastSeen: issuedAt, expiresAt: issuedAt + SESSION_TTL_MS };
133
+ writePrivate(sessionPath(), session);
134
+ return { ok: true, enrolled: true, token, expiresAt: session.expiresAt };
135
+ }
136
+
137
+ let lastSessionWriteMs = 0;
138
+
139
+ export function verifySessionToken(token, { timestamp = Date.now(), renew = true } = {}) {
140
+ if (!token) return { ok: false, reason: 'missing_token' };
141
+ const session = readJsonFile(sessionPath());
142
+ if (!session || !digestEqual(session.token, token)) return { ok: false, reason: 'invalid_token' };
143
+ if (!(timestamp < session.expiresAt)) return { ok: false, reason: 'expired' };
144
+ if (renew && timestamp - session.lastSeen > 60_000) {
145
+ session.lastSeen = timestamp;
146
+ session.expiresAt = timestamp + SESSION_TTL_MS;
147
+ // Persist at most once a minute; the slide is generous on purpose.
148
+ if (timestamp - lastSessionWriteMs > 60_000) {
149
+ lastSessionWriteMs = timestamp;
150
+ writePrivate(sessionPath(), session);
151
+ }
152
+ }
153
+ return { ok: true, ident: session.ident, issuedAt: session.issuedAt, lastSeen: session.lastSeen, expiresAt: session.expiresAt };
154
+ }
155
+
156
+ // Requires the session's own token as proof of possession. POST /logout sits
157
+ // before the bearer gate by design (the door, not a room) — an unauthenticated
158
+ // caller who does not already hold the token must not be able to force the
159
+ // legitimate session out.
160
+ export function revokeSession(token) {
161
+ const session = readJsonFile(sessionPath());
162
+ if (!session) return false;
163
+ if (!token || !digestEqual(session.token, token)) return false;
164
+ rmSync(sessionPath(), { force: true });
165
+ return true;
166
+ }
167
+
168
+ // The local host CLI (`wiki-manager logout`) has direct access to this state
169
+ // directory, same as `resetTotpEnrollment` below — it reads the active
170
+ // token itself rather than calling /logout without proof of possession.
171
+ export function currentSessionToken() {
172
+ const session = readJsonFile(sessionPath());
173
+ return session && typeof session.token === 'string' ? session.token : null;
174
+ }
175
+
176
+ /*
177
+ Re-enrollment after a lost authenticator: wipes the enrolled secret AND the
178
+ active session, so the next login page shows a fresh QR code. Reachable only
179
+ from the host CLI (`wiki-manager login --reset`): the runtime reads these
180
+ files fresh on every request, so the deletion takes effect immediately, and
181
+ no HTTP route can trigger it.
182
+ */
183
+ export function resetTotpEnrollment() {
184
+ pendingEnrollment = null;
185
+ pendingEnrollmentSince = 0;
186
+ rmSync(totpPath(), { force: true });
187
+ rmSync(sessionPath(), { force: true });
188
+ }
189
+
190
+ // ── Attempt rate limiting (in-memory) ────────────────────────────────────────
191
+ // 6 digits is a small space: refuse after too many tries per address.
192
+ const ATTEMPT_LIMIT = 10;
193
+ const ATTEMPT_WINDOW_MS = 10 * 60 * 1000;
194
+ const attempts = new Map();
195
+
196
+ export function loginAttemptAllowed(address) {
197
+ const now = Date.now();
198
+ const key = String(address ?? 'unknown');
199
+ const record = attempts.get(key);
200
+ if (!record || now >= record.resetAt) {
201
+ attempts.set(key, { count: 1, resetAt: now + ATTEMPT_WINDOW_MS });
202
+ return { ok: true, remaining: ATTEMPT_LIMIT - 1 };
203
+ }
204
+ if (record.count >= ATTEMPT_LIMIT) {
205
+ const retryAfterSeconds = Math.max(1, Math.ceil((record.resetAt - now) / 1000));
206
+ return { ok: false, retryAfterSeconds };
207
+ }
208
+ record.count += 1;
209
+ return { ok: true, remaining: ATTEMPT_LIMIT - record.count };
210
+ }
211
+
212
+ export function resetLoginAttempts(address) {
213
+ attempts.delete(String(address ?? 'unknown'));
214
+ }
215
+
216
+ // Housekeeping: drop records whose window has passed (keeps the map bounded).
217
+ export function pruneLoginAttempts() {
218
+ const now = Date.now();
219
+ for (const [key, record] of attempts) {
220
+ if (now >= record.resetAt) attempts.delete(key);
221
+ }
222
+ }
223
+
224
+ export function sessionExists() {
225
+ return existsSync(sessionPath());
226
+ }
@@ -0,0 +1,143 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { totpCode } from './totp.js';
7
+ import {
8
+ enrollment,
9
+ isTotpEnabled,
10
+ issueSessionWithTotp,
11
+ loginAttemptAllowed,
12
+ loginStatus,
13
+ resetTotpEnrollment,
14
+ revokeSession,
15
+ SESSION_TTL_MS,
16
+ verifySessionToken,
17
+ } from './loginSession.js';
18
+
19
+ // Each test gets a fresh in-memory enrollment and a fresh state dir.
20
+ function freshStateDir() {
21
+ const dir = mkdtempSync(join(tmpdir(), 'login-session-'));
22
+ process.env.WIKI_MANAGER_STATE_DIR = dir;
23
+ return dir;
24
+ }
25
+
26
+ test.afterEach(() => {
27
+ const dir = process.env.WIKI_MANAGER_STATE_DIR;
28
+ if (dir && dir.startsWith(tmpdir())) rmSync(dir, { recursive: true, force: true });
29
+ delete process.env.WIKI_MANAGER_STATE_DIR;
30
+ });
31
+
32
+ test('enrollment publishes a secret and URI, then the first code persists it', () => {
33
+ freshStateDir();
34
+ const before = enrollment();
35
+ assert.ok(before.secret.match(/^[A-Z2-7]{32}$/));
36
+ assert.ok(before.uri.startsWith('otpauth://totp/'));
37
+ assert.equal(loginStatus().enrolled, false);
38
+
39
+ // The SAME pending secret is shown again (the page reloads, not re-rolls).
40
+ assert.equal(enrollment().secret, before.secret);
41
+
42
+ const result = issueSessionWithTotp(totpCode(before.secret));
43
+ assert.equal(result.ok, true);
44
+ assert.equal(result.enrolled, true);
45
+ assert.ok(result.token.length >= 32);
46
+ assert.equal(loginStatus().enrolled, true);
47
+ assert.equal(enrollment(), null);
48
+ });
49
+
50
+ test('a wrong code never enrolls and never issues a session', () => {
51
+ freshStateDir();
52
+ const pending = enrollment();
53
+ const wrong = totpCode(pending.secret) === '000000' ? '111111' : '000000';
54
+ const result = issueSessionWithTotp(wrong);
55
+ assert.equal(result.ok, false);
56
+ assert.equal(result.error, 'invalid_code');
57
+ assert.equal(loginStatus().enrolled, false);
58
+ });
59
+
60
+ test('enrollment is refused from a non-loopback address', () => {
61
+ freshStateDir();
62
+ const pending = enrollment();
63
+ const result = issueSessionWithTotp(totpCode(pending.secret), { remoteAddress: '192.168.1.10' });
64
+ assert.equal(result.ok, false);
65
+ assert.equal(result.error, 'enrollment_requires_loopback');
66
+ // From loopback the same code succeeds.
67
+ const ok = issueSessionWithTotp(totpCode(pending.secret), { remoteAddress: '127.0.0.1' });
68
+ assert.equal(ok.ok, true);
69
+ });
70
+
71
+ test('a verified session slides instead of expiring', () => {
72
+ freshStateDir();
73
+ const pending = enrollment();
74
+ const issued = issueSessionWithTotp(totpCode(pending.secret));
75
+ assert.ok(issued.token);
76
+
77
+ // Right after issue: valid.
78
+ assert.equal(verifySessionToken(issued.token).ok, true);
79
+ // Near the end of the TTL: still valid, and the slide pushes expiry forward.
80
+ const late = issued.expiresAt - 1;
81
+ const check = verifySessionToken(issued.token, { timestamp: late });
82
+ assert.equal(check.ok, true);
83
+ assert.ok(check.expiresAt > issued.expiresAt);
84
+ // Past the NEW expiry the token is refused.
85
+ const past = check.expiresAt + 1;
86
+ assert.equal(verifySessionToken(issued.token, { timestamp: past, renew: false }).ok, false);
87
+ });
88
+
89
+ test('an unknown token is refused and revocation invalidates the real one', () => {
90
+ freshStateDir();
91
+ const pending = enrollment();
92
+ const issued = issueSessionWithTotp(totpCode(pending.secret));
93
+
94
+ assert.equal(verifySessionToken('nope').ok, false);
95
+ assert.equal(revokeSession(issued.token), true);
96
+ assert.equal(verifySessionToken(issued.token).ok, false);
97
+ assert.equal(loginStatus().sessionActive, false);
98
+ // Revoking twice is a no-op, not an error.
99
+ assert.equal(revokeSession(issued.token), false);
100
+ });
101
+
102
+ test('resetTotpEnrollment wipes the secret and the session for re-enrollment', () => {
103
+ freshStateDir();
104
+ const pending = enrollment();
105
+ const issued = issueSessionWithTotp(totpCode(pending.secret));
106
+ assert.equal(loginStatus().enrolled, true);
107
+ assert.equal(loginStatus().sessionActive, true);
108
+
109
+ resetTotpEnrollment();
110
+
111
+ const status = loginStatus();
112
+ assert.equal(status.enrolled, false);
113
+ assert.equal(status.sessionActive, false);
114
+ assert.equal(verifySessionToken(issued.token).ok, false);
115
+ // A fresh enrollment secret is generated for the next page.
116
+ const fresh = enrollment();
117
+ assert.ok(fresh.secret);
118
+ assert.notEqual(fresh.secret, pending.secret);
119
+ });
120
+
121
+ test('login attempts are rate-limited per address', () => {
122
+ for (let index = 0; index < 10; index++) {
123
+ const allowed = loginAttemptAllowed('127.0.0.1');
124
+ assert.equal(allowed.ok, true, `attempt ${index}`);
125
+ }
126
+ const refused = loginAttemptAllowed('127.0.0.1');
127
+ assert.equal(refused.ok, false);
128
+ assert.ok(refused.retryAfterSeconds > 0);
129
+ // Another address is unaffected.
130
+ assert.equal(loginAttemptAllowed('10.0.0.2').ok, true);
131
+ });
132
+
133
+ test('TOTP can be disabled from the environment', () => {
134
+ freshStateDir();
135
+ process.env.WIKI_MANAGER_TOTP = 'off';
136
+ assert.equal(isTotpEnabled(), false);
137
+ assert.equal(enrollment(), null);
138
+ delete process.env.WIKI_MANAGER_TOTP;
139
+ });
140
+
141
+ test('the default session TTL is 12 hours', () => {
142
+ assert.equal(SESSION_TTL_MS, 12 * 60 * 60 * 1000);
143
+ });
@@ -0,0 +1,15 @@
1
+ import qrcodeGenerator from './vendor/qrcode.cjs';
2
+
3
+ /*
4
+ Thin wrapper over the vendored MIT qrcode-generator (Kazuhiko Arase, 2009 —
5
+ see vendor/qrcode.cjs header). The login page renders the enrollment QR as a
6
+ responsive SVG; the otpauth:// URI is ASCII-only, so the generator's default
7
+ latin-1 byte conversion is sufficient.
8
+ */
9
+
10
+ export function qrSvg(content, { cellSize = 4, margin = 8 } = {}) {
11
+ const qr = qrcodeGenerator(0, 'M');
12
+ qr.addData(String(content), 'Byte');
13
+ qr.make();
14
+ return qr.createSvgTag({ cellSize, margin, scalable: true, alt: { text: 'QR code' } });
15
+ }
@@ -63,7 +63,12 @@ export function conversationSeed(session, currentInput, { limit = 12, maxChars =
63
63
  const conversation = Array.isArray(session.agentProjection?.conversation)
64
64
  ? session.agentProjection.conversation
65
65
  : [];
66
+ // Everything before the last compact (conversation_reset) is forgotten for
67
+ // grounding, while it stays visible in the displayed thread. The boundary is
68
+ // an index into the same array, so no message is actually removed.
69
+ const seedStart = Math.max(0, Number(session.agentProjection?.conversationSeedStart) || 0);
66
70
  const seed = conversation
71
+ .slice(seedStart)
67
72
  .filter((message) => ['user', 'assistant'].includes(message?.role) && String(message?.content ?? '').trim())
68
73
  .slice(-limit)
69
74
  .map((message) => ({ role: message.role, content: String(message.content).slice(0, maxChars) }));
@@ -71,6 +76,13 @@ export function conversationSeed(session, currentInput, { limit = 12, maxChars =
71
76
  // drop it from the seed to avoid sending it twice.
72
77
  const last = seed.at(-1);
73
78
  if (last && last.role === 'user' && last.content === String(currentInput ?? '').slice(0, maxChars)) seed.pop();
79
+ // A compact does not just cut the older turns — it replaces them with a
80
+ // short summary (see /conversation/compact), so what was agreed there is
81
+ // not lost to the grounding window entirely, only condensed.
82
+ const summary = String(session.agentProjection?.conversationSummary ?? '').trim();
83
+ if (summary) {
84
+ seed.unshift({ role: 'user', content: `[Summary of earlier conversation, compacted]\n${summary.slice(0, maxChars)}` });
85
+ }
74
86
  return seed;
75
87
  }
76
88