@algochad/archcoder 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +113 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +5108 -0
  5. package/bin/cli.test.js +56 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +67 -0
  12. package/dist/assets/MultiRunWindow-BZp3MjJP.js +1 -0
  13. package/dist/assets/SettingsWindow-DoGYXpX7.js +1 -0
  14. package/dist/assets/TerminalView-BN7BR5Ff.js +3 -0
  15. package/dist/assets/TimelineDialog-ZQ33oVQR.js +1 -0
  16. package/dist/assets/ToolOutputDialog-Blv3pnug.js +16 -0
  17. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  18. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  19. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  20. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  21. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  22. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  23. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  24. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  25. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  26. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  27. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  28. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  29. package/dist/assets/index-CtCEGYrr.css +1 -0
  30. package/dist/assets/index-o_d2wtWC.js +48 -0
  31. package/dist/assets/main-5QGBtzdq.css +1 -0
  32. package/dist/assets/main-B6oiMU86.js +8033 -0
  33. package/dist/assets/vendor--DbVqbJpV.css +1 -0
  34. package/dist/assets/vendor-.bun-HTKwyaEM.js +10086 -0
  35. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  36. package/dist/assets/worker-bqd4RMrj.js +155 -0
  37. package/dist/favicon-16.png +0 -0
  38. package/dist/favicon-32.png +0 -0
  39. package/dist/favicon.png +0 -0
  40. package/dist/favicon.svg +67 -0
  41. package/dist/index.html +533 -0
  42. package/dist/logo-dark-192x192.png +0 -0
  43. package/dist/logo-dark-512x512.svg +16 -0
  44. package/dist/logo-light-192x192.png +0 -0
  45. package/dist/logo-light-512x512.svg +16 -0
  46. package/dist/pwa-192.png +0 -0
  47. package/dist/pwa-512.png +0 -0
  48. package/dist/pwa-maskable-192.png +0 -0
  49. package/dist/pwa-maskable-512.png +0 -0
  50. package/dist/site.webmanifest +22 -0
  51. package/dist/sw.js +1 -0
  52. package/package.json +107 -0
  53. package/public/apple-touch-icon-120x120.png +0 -0
  54. package/public/apple-touch-icon-152x152.png +0 -0
  55. package/public/apple-touch-icon-167x167.png +0 -0
  56. package/public/apple-touch-icon-180x180.png +0 -0
  57. package/public/apple-touch-icon.png +0 -0
  58. package/public/apple-touch-icon.svg +67 -0
  59. package/public/favicon-16.png +0 -0
  60. package/public/favicon-32.png +0 -0
  61. package/public/favicon.png +0 -0
  62. package/public/favicon.svg +67 -0
  63. package/public/logo-dark-192x192.png +0 -0
  64. package/public/logo-dark-512x512.svg +16 -0
  65. package/public/logo-light-192x192.png +0 -0
  66. package/public/logo-light-512x512.svg +16 -0
  67. package/public/pwa-192.png +0 -0
  68. package/public/pwa-512.png +0 -0
  69. package/public/pwa-maskable-192.png +0 -0
  70. package/public/pwa-maskable-512.png +0 -0
  71. package/public/site.webmanifest +22 -0
  72. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  73. package/server/index.d.ts +37 -0
  74. package/server/index.js +14694 -0
  75. package/server/lib/cloudflare-tunnel.js +650 -0
  76. package/server/lib/git/DOCUMENTATION.md +146 -0
  77. package/server/lib/git/credentials.js +74 -0
  78. package/server/lib/git/identity-storage.js +110 -0
  79. package/server/lib/git/index.js +6 -0
  80. package/server/lib/git/service.js +3117 -0
  81. package/server/lib/github/DOCUMENTATION.md +170 -0
  82. package/server/lib/github/auth.js +307 -0
  83. package/server/lib/github/device-flow.js +50 -0
  84. package/server/lib/github/index.js +24 -0
  85. package/server/lib/github/octokit.js +10 -0
  86. package/server/lib/github/pr-status.js +478 -0
  87. package/server/lib/github/repo/index.js +55 -0
  88. package/server/lib/installer/desktop.js +289 -0
  89. package/server/lib/installer/download.js +208 -0
  90. package/server/lib/installer/index.js +45 -0
  91. package/server/lib/installer/platform.js +100 -0
  92. package/server/lib/notifications/DOCUMENTATION.md +61 -0
  93. package/server/lib/notifications/index.js +1 -0
  94. package/server/lib/notifications/message.js +49 -0
  95. package/server/lib/notifications/message.test.js +59 -0
  96. package/server/lib/opencode/DOCUMENTATION.md +59 -0
  97. package/server/lib/opencode/agents.js +634 -0
  98. package/server/lib/opencode/auth.js +81 -0
  99. package/server/lib/opencode/commands.js +339 -0
  100. package/server/lib/opencode/index.js +66 -0
  101. package/server/lib/opencode/mcp.js +206 -0
  102. package/server/lib/opencode/providers.js +96 -0
  103. package/server/lib/opencode/shared.js +527 -0
  104. package/server/lib/opencode/skills.js +480 -0
  105. package/server/lib/opencode/tunnel-auth.js +591 -0
  106. package/server/lib/opencode/ui-auth.js +510 -0
  107. package/server/lib/package-manager.js +505 -0
  108. package/server/lib/quota/DOCUMENTATION.md +55 -0
  109. package/server/lib/quota/index.js +24 -0
  110. package/server/lib/quota/providers/claude.js +107 -0
  111. package/server/lib/quota/providers/codex.js +113 -0
  112. package/server/lib/quota/providers/copilot.js +165 -0
  113. package/server/lib/quota/providers/google/api.js +92 -0
  114. package/server/lib/quota/providers/google/auth.js +108 -0
  115. package/server/lib/quota/providers/google/index.js +124 -0
  116. package/server/lib/quota/providers/google/transforms.js +109 -0
  117. package/server/lib/quota/providers/index.js +152 -0
  118. package/server/lib/quota/providers/interface.js +55 -0
  119. package/server/lib/quota/providers/kimi.js +108 -0
  120. package/server/lib/quota/providers/minimax-cn-coding-plan.js +15 -0
  121. package/server/lib/quota/providers/minimax-coding-plan.js +15 -0
  122. package/server/lib/quota/providers/minimax-shared.js +136 -0
  123. package/server/lib/quota/providers/nanogpt.js +124 -0
  124. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  125. package/server/lib/quota/providers/openai.js +91 -0
  126. package/server/lib/quota/providers/openrouter.js +92 -0
  127. package/server/lib/quota/providers/zai.js +91 -0
  128. package/server/lib/quota/utils/auth.js +46 -0
  129. package/server/lib/quota/utils/formatters.js +76 -0
  130. package/server/lib/quota/utils/index.js +10 -0
  131. package/server/lib/quota/utils/transformers.js +55 -0
  132. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  133. package/server/lib/skills-catalog/cache.js +32 -0
  134. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  135. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  136. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  137. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  138. package/server/lib/skills-catalog/curated-sources.js +21 -0
  139. package/server/lib/skills-catalog/git.js +77 -0
  140. package/server/lib/skills-catalog/index.js +42 -0
  141. package/server/lib/skills-catalog/install.js +294 -0
  142. package/server/lib/skills-catalog/scan.js +221 -0
  143. package/server/lib/skills-catalog/source.js +85 -0
  144. package/server/lib/terminal/DOCUMENTATION.md +114 -0
  145. package/server/lib/terminal/index.js +12 -0
  146. package/server/lib/terminal/input-ws-protocol.js +66 -0
  147. package/server/lib/terminal/input-ws-protocol.test.js +138 -0
  148. package/server/lib/tts/DOCUMENTATION.md +134 -0
  149. package/server/lib/tts/index.js +16 -0
  150. package/server/lib/tts/service.js +162 -0
  151. package/server/lib/tts/summarization.js +171 -0
  152. package/server/lib/tunnels/index.js +166 -0
  153. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  154. package/server/lib/tunnels/registry.js +51 -0
  155. package/server/lib/tunnels/types.js +219 -0
  156. package/server/lib/utils/lru.js +107 -0
  157. package/server/lib/utils/sse.js +121 -0
@@ -0,0 +1,510 @@
1
+ import crypto from 'crypto';
2
+ import { SignJWT, jwtVerify } from 'jose';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ const SESSION_COOKIE_NAME = 'oc_ui_session';
8
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
9
+
10
+ const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
11
+ const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
12
+ const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
13
+ const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
14
+ const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
15
+
16
+ const loginRateLimiter = new Map();
17
+ let rateLimitCleanupTimer = null;
18
+
19
+ const rateLimitLocks = new Map();
20
+
21
+ const getClientIp = (req) => {
22
+ const forwarded = req.headers['x-forwarded-for'];
23
+ if (typeof forwarded === 'string') {
24
+ const ip = forwarded.split(',')[0].trim();
25
+ if (ip.startsWith('::ffff:')) {
26
+ return ip.substring(7);
27
+ }
28
+ return ip;
29
+ }
30
+
31
+ const ip = req.ip || req.connection?.remoteAddress;
32
+ if (ip) {
33
+ if (ip.startsWith('::ffff:')) {
34
+ return ip.substring(7);
35
+ }
36
+ return ip;
37
+ }
38
+ return null;
39
+ };
40
+
41
+ const getRateLimitKey = (req) => {
42
+ const ip = getClientIp(req);
43
+ if (ip) return ip;
44
+ return 'rate-limit:no-ip';
45
+ };
46
+
47
+ const getRateLimitConfig = (key) => {
48
+ if (key === 'rate-limit:no-ip') {
49
+ return {
50
+ maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
51
+ windowMs: RATE_LIMIT_WINDOW_MS
52
+ };
53
+ }
54
+ return {
55
+ maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
56
+ windowMs: RATE_LIMIT_WINDOW_MS
57
+ };
58
+ };
59
+
60
+ const acquireRateLimitLock = async (key) => {
61
+ const prev = rateLimitLocks.get(key) || Promise.resolve();
62
+ const curr = prev.then(() => rateLimitLocks.delete(key));
63
+ rateLimitLocks.set(key, curr);
64
+ await curr;
65
+ };
66
+
67
+ const checkRateLimit = async (req) => {
68
+ const key = getRateLimitKey(req);
69
+ await acquireRateLimitLock(key);
70
+
71
+ const now = Date.now();
72
+ const { maxAttempts } = getRateLimitConfig(key);
73
+
74
+ let record;
75
+ try {
76
+ record = loginRateLimiter.get(key);
77
+ } catch (err) {
78
+ console.error('[RateLimit] Failed to get record', { key, error: err.message });
79
+ return {
80
+ allowed: true,
81
+ limit: maxAttempts,
82
+ remaining: maxAttempts,
83
+ reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
84
+ };
85
+ }
86
+
87
+ if (record?.lockedUntil && now < record.lockedUntil) {
88
+ return {
89
+ allowed: false,
90
+ retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
91
+ locked: true,
92
+ limit: maxAttempts,
93
+ remaining: 0,
94
+ reset: Math.ceil(record.lockedUntil / 1000)
95
+ };
96
+ }
97
+
98
+ if (record?.lockedUntil && now >= record.lockedUntil) {
99
+ try {
100
+ loginRateLimiter.delete(key);
101
+ } catch (err) {
102
+ console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
103
+ }
104
+ }
105
+
106
+ if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
107
+ return {
108
+ allowed: true,
109
+ limit: maxAttempts,
110
+ remaining: maxAttempts,
111
+ reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
112
+ };
113
+ }
114
+
115
+ if (record.count >= maxAttempts) {
116
+ const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
117
+ try {
118
+ loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
119
+ } catch (err) {
120
+ console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
121
+ }
122
+ return {
123
+ allowed: false,
124
+ retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
125
+ locked: true,
126
+ limit: maxAttempts,
127
+ remaining: 0,
128
+ reset: Math.ceil(lockedUntil / 1000)
129
+ };
130
+ }
131
+
132
+ const remaining = maxAttempts - record.count;
133
+ const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
134
+ return {
135
+ allowed: true,
136
+ limit: maxAttempts,
137
+ remaining,
138
+ reset
139
+ };
140
+ };
141
+
142
+ const recordFailedAttempt = async (req) => {
143
+ const key = getRateLimitKey(req);
144
+ await acquireRateLimitLock(key);
145
+
146
+ const now = Date.now();
147
+ const { maxAttempts } = getRateLimitConfig(key);
148
+ const record = loginRateLimiter.get(key);
149
+
150
+ if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
151
+ try {
152
+ loginRateLimiter.set(key, { count: 1, lastAttempt: now });
153
+ } catch (err) {
154
+ console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
155
+ }
156
+ } else {
157
+ const newCount = record.count + 1;
158
+ try {
159
+ loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
160
+ } catch (err) {
161
+ console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
162
+ }
163
+ }
164
+ };
165
+
166
+ const clearRateLimit = async (req) => {
167
+ const key = getRateLimitKey(req);
168
+ await acquireRateLimitLock(key);
169
+
170
+ try {
171
+ loginRateLimiter.delete(key);
172
+ } catch (err) {
173
+ console.error('[RateLimit] Failed to clear', { key, error: err.message });
174
+ }
175
+ };
176
+
177
+ const cleanupRateLimitRecords = () => {
178
+ const now = Date.now();
179
+ for (const [key, record] of loginRateLimiter.entries()) {
180
+ const isExpired = record.lockedUntil && now >= record.lockedUntil;
181
+ const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
182
+ if (isExpired || isStale) {
183
+ try {
184
+ loginRateLimiter.delete(key);
185
+ } catch (err) {
186
+ console.error('[RateLimit] Cleanup failed', { key, error: err.message });
187
+ }
188
+ }
189
+ }
190
+ };
191
+
192
+ const startRateLimitCleanup = () => {
193
+ if (!rateLimitCleanupTimer) {
194
+ rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
195
+ if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
196
+ rateLimitCleanupTimer.unref();
197
+ }
198
+ }
199
+ };
200
+
201
+ const stopRateLimitCleanup = () => {
202
+ if (rateLimitCleanupTimer) {
203
+ clearInterval(rateLimitCleanupTimer);
204
+ rateLimitCleanupTimer = null;
205
+ }
206
+ };
207
+
208
+ const isSecureRequest = (req) => {
209
+ if (req.secure) {
210
+ return true;
211
+ }
212
+ const forwardedProto = req.headers['x-forwarded-proto'];
213
+ if (typeof forwardedProto === 'string') {
214
+ const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
215
+ return firstProto === 'https';
216
+ }
217
+ return false;
218
+ };
219
+
220
+ const parseCookies = (cookieHeader) => {
221
+ if (!cookieHeader || typeof cookieHeader !== 'string') {
222
+ return {};
223
+ }
224
+
225
+ return cookieHeader.split(';').reduce((acc, segment) => {
226
+ const [name, ...rest] = segment.split('=');
227
+ if (!name) {
228
+ return acc;
229
+ }
230
+ const key = name.trim();
231
+ if (!key) {
232
+ return acc;
233
+ }
234
+ const value = rest.join('=').trim();
235
+ acc[key] = decodeURIComponent(value || '');
236
+ return acc;
237
+ }, {});
238
+ };
239
+
240
+ const buildCookie = ({
241
+ name,
242
+ value,
243
+ maxAge,
244
+ secure,
245
+ }) => {
246
+ const attributes = [
247
+ `${name}=${value}`,
248
+ 'Path=/',
249
+ 'HttpOnly',
250
+ 'SameSite=Strict',
251
+ ];
252
+
253
+ if (typeof maxAge === 'number') {
254
+ attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
255
+ }
256
+
257
+ const expires = maxAge === 0
258
+ ? 'Thu, 01 Jan 1970 00:00:00 GMT'
259
+ : new Date(Date.now() + maxAge * 1000).toUTCString();
260
+
261
+ attributes.push(`Expires=${expires}`);
262
+
263
+ if (secure) {
264
+ attributes.push('Secure');
265
+ }
266
+
267
+ return attributes.join('; ');
268
+ };
269
+
270
+ const normalizePassword = (candidate) => {
271
+ if (typeof candidate !== 'string') {
272
+ return '';
273
+ }
274
+ return candidate.normalize().trim();
275
+ };
276
+
277
+ const ARCHCODER_DATA_DIR = process.env.ARCHCODER_DATA_DIR
278
+ ? path.resolve(process.env.ARCHCODER_DATA_DIR)
279
+ : path.join(os.homedir(), '.config', 'archcoder');
280
+ const JWT_SECRET_FILE = path.join(ARCHCODER_DATA_DIR, 'jwt-secret');
281
+
282
+ function getOrCreateJwtSecret() {
283
+ const envSecret = process.env.OPENCODE_JWT_SECRET;
284
+ if (envSecret) {
285
+ return new TextEncoder().encode(envSecret);
286
+ }
287
+
288
+ try {
289
+ if (fs.existsSync(JWT_SECRET_FILE)) {
290
+ return new TextEncoder().encode(fs.readFileSync(JWT_SECRET_FILE, 'utf8').trim());
291
+ }
292
+ } catch (e) {
293
+ console.warn('[JWT] Failed to read secret file:', e.message);
294
+ }
295
+
296
+ const secret = crypto.randomBytes(32).toString('hex');
297
+ try {
298
+ fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
299
+ fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
300
+ console.log('[JWT] Generated and persisted new secret to', JWT_SECRET_FILE);
301
+ } catch (e) {
302
+ console.warn('[JWT] Failed to persist secret:', e.message);
303
+ }
304
+
305
+ return new TextEncoder().encode(secret);
306
+ }
307
+
308
+ export const createUiAuth = ({
309
+ password,
310
+ cookieName = SESSION_COOKIE_NAME,
311
+ sessionTtlMs = SESSION_TTL_MS,
312
+ } = {}) => {
313
+ const normalizedPassword = normalizePassword(password);
314
+
315
+ if (!normalizedPassword) {
316
+ const setSessionCookie = (req, res, token) => {
317
+ const secure = isSecureRequest(req);
318
+ const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
319
+ const header = buildCookie({
320
+ name: cookieName,
321
+ value: encodeURIComponent(token),
322
+ maxAge: maxAgeSeconds,
323
+ secure,
324
+ });
325
+ res.setHeader('Set-Cookie', header);
326
+ };
327
+
328
+ const ensureSessionToken = async (req, res) => {
329
+ const cookies = parseCookies(req.headers.cookie);
330
+ if (cookies[cookieName]) {
331
+ return cookies[cookieName];
332
+ }
333
+ const token = crypto.randomBytes(32).toString('base64url');
334
+ setSessionCookie(req, res, token);
335
+ return token;
336
+ };
337
+
338
+ return {
339
+ enabled: false,
340
+ requireAuth: (_req, _res, next) => next(),
341
+ handleSessionStatus: (_req, res) => {
342
+ res.json({ authenticated: true, disabled: true });
343
+ },
344
+ handleSessionCreate: (_req, res) => {
345
+ res.status(400).json({ error: 'UI password not configured' });
346
+ },
347
+ ensureSessionToken,
348
+ dispose: () => {
349
+
350
+ },
351
+ };
352
+ }
353
+
354
+ const salt = crypto.randomBytes(16);
355
+ const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
356
+ const JWT_SECRET = getOrCreateJwtSecret();
357
+
358
+ const getTokenFromRequest = (req) => {
359
+ const cookies = parseCookies(req.headers.cookie);
360
+ if (cookies[cookieName]) {
361
+ return cookies[cookieName];
362
+ }
363
+ return null;
364
+ };
365
+
366
+ const setSessionCookie = (req, res, token) => {
367
+ const secure = isSecureRequest(req);
368
+ const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
369
+ const header = buildCookie({
370
+ name: cookieName,
371
+ value: encodeURIComponent(token),
372
+ maxAge: maxAgeSeconds,
373
+ secure,
374
+ });
375
+ res.setHeader('Set-Cookie', header);
376
+ };
377
+
378
+ const clearSessionCookie = (req, res) => {
379
+ const secure = isSecureRequest(req);
380
+ const header = buildCookie({
381
+ name: cookieName,
382
+ value: '',
383
+ maxAge: 0,
384
+ secure,
385
+ });
386
+ res.setHeader('Set-Cookie', header);
387
+ };
388
+
389
+ const verifyPassword = (candidate) => {
390
+ if (!candidate) {
391
+ return false;
392
+ }
393
+ const normalizedCandidate = normalizePassword(candidate);
394
+ if (!normalizedCandidate) {
395
+ return false;
396
+ }
397
+ try {
398
+ const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
399
+ return crypto.timingSafeEqual(candidateHash, expectedHash);
400
+ } catch {
401
+ return false;
402
+ }
403
+ };
404
+
405
+ const isSessionValid = async (token) => {
406
+ if (!token) {
407
+ return false;
408
+ }
409
+ try {
410
+ await jwtVerify(token, JWT_SECRET);
411
+ return true;
412
+ } catch {
413
+ return false;
414
+ }
415
+ };
416
+
417
+ const issueSession = async (req, res) => {
418
+ const token = await new SignJWT({ type: 'ui-session' })
419
+ .setProtectedHeader({ alg: 'HS256' })
420
+ .setIssuedAt()
421
+ .setExpirationTime(sessionTtlMs / 1000 + 's')
422
+ .sign(JWT_SECRET);
423
+ setSessionCookie(req, res, token);
424
+ return token;
425
+ };
426
+
427
+ startRateLimitCleanup();
428
+
429
+ const respondUnauthorized = (req, res) => {
430
+ res.status(401);
431
+ const acceptsJson = req.headers.accept?.includes('application/json');
432
+ if (acceptsJson || req.path.startsWith('/api')) {
433
+ res.json({ error: 'UI authentication required', locked: true });
434
+ } else {
435
+ res.type('text/plain').send('Authentication required');
436
+ }
437
+ };
438
+
439
+ const requireAuth = async (req, res, next) => {
440
+ if (req.method === 'OPTIONS') {
441
+ return next();
442
+ }
443
+ const token = getTokenFromRequest(req);
444
+ if (await isSessionValid(token)) {
445
+ return next();
446
+ }
447
+ clearSessionCookie(req, res);
448
+ return respondUnauthorized(req, res);
449
+ };
450
+
451
+ const handleSessionStatus = async (req, res) => {
452
+ const token = getTokenFromRequest(req);
453
+ if (await isSessionValid(token)) {
454
+ res.json({ authenticated: true });
455
+ return;
456
+ }
457
+ clearSessionCookie(req, res);
458
+ res.status(401).json({ authenticated: false, locked: true });
459
+ };
460
+
461
+ const handleSessionCreate = async (req, res) => {
462
+ const rateLimitResult = await checkRateLimit(req);
463
+
464
+ res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
465
+ res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
466
+ res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
467
+
468
+ if (!rateLimitResult.allowed) {
469
+ res.setHeader('Retry-After', rateLimitResult.retryAfter);
470
+ res.status(429).json({
471
+ error: 'Too many login attempts, please try again later',
472
+ retryAfter: rateLimitResult.retryAfter
473
+ });
474
+ return;
475
+ }
476
+
477
+ const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
478
+ if (!verifyPassword(candidate)) {
479
+ await recordFailedAttempt(req);
480
+ clearSessionCookie(req, res);
481
+ res.status(401).json({ error: 'Invalid credentials' });
482
+ return;
483
+ }
484
+
485
+ await clearRateLimit(req);
486
+
487
+ await issueSession(req, res);
488
+ res.json({ authenticated: true });
489
+ };
490
+
491
+ const dispose = () => {
492
+ loginRateLimiter.clear();
493
+ if (rateLimitCleanupTimer) {
494
+ clearInterval(rateLimitCleanupTimer);
495
+ rateLimitCleanupTimer = null;
496
+ }
497
+ };
498
+
499
+ return {
500
+ enabled: true,
501
+ requireAuth,
502
+ handleSessionStatus,
503
+ handleSessionCreate,
504
+ ensureSessionToken: async (req, _res) => {
505
+ const token = getTokenFromRequest(req);
506
+ return (await isSessionValid(token)) ? token : null;
507
+ },
508
+ dispose,
509
+ };
510
+ };