total-recall-brain 3.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import yaml from 'yaml';
|
|
6
|
+
import jwt from 'jsonwebtoken';
|
|
7
|
+
import bcrypt from 'bcrypt';
|
|
8
|
+
import rateLimit from 'express-rate-limit';
|
|
9
|
+
import { watchdog } from '../core/watchdog.mjs';
|
|
10
|
+
import { findValidKeyByToken, keyHasAnyScope, recordKeyUsage } from './keys.mjs';
|
|
11
|
+
import { logger } from '../core/logger.mjs';
|
|
12
|
+
|
|
13
|
+
import { agentDir, brainDir, sessionSecret, nodeEnv } from '../core/config.mjs';
|
|
14
|
+
|
|
15
|
+
const BRAIN_DIR = brainDir;
|
|
16
|
+
const CONFIG_FILE = path.join(BRAIN_DIR, 'config', 'security.yml');
|
|
17
|
+
|
|
18
|
+
// Persist JWT secret to disk so sessions survive restarts
|
|
19
|
+
const JWT_SECRET_PATH = path.join(BRAIN_DIR, 'config', 'session-secret');
|
|
20
|
+
export const BCRYPT_COST = 12;
|
|
21
|
+
|
|
22
|
+
let JWT_SECRET;
|
|
23
|
+
try {
|
|
24
|
+
JWT_SECRET = sessionSecret || fs.readFileSync(JWT_SECRET_PATH, 'utf8').trim();
|
|
25
|
+
} catch {
|
|
26
|
+
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(JWT_SECRET_PATH), { recursive: true });
|
|
29
|
+
fs.writeFileSync(JWT_SECRET_PATH, JWT_SECRET, { mode: 0o600 });
|
|
30
|
+
} catch { /* non-fatal — will regenerate on next restart */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadSecurityConfig() {
|
|
34
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
35
|
+
return {
|
|
36
|
+
dashboard: {},
|
|
37
|
+
api: { pats: [], allow_static_pats: false },
|
|
38
|
+
network: { require_https: true, public_health: false, allowed_origins: [], trusted_proxies: [] },
|
|
39
|
+
bind: { host: '127.0.0.1', port: 3000, allow_public_bind: false },
|
|
40
|
+
rate_limits: { api_requests_per_minute: 60 },
|
|
41
|
+
sandbox: { enabled: false }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const parsed = yaml.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
45
|
+
if (parsed.sandbox === undefined) {
|
|
46
|
+
parsed.sandbox = { enabled: false };
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function apiRateLimiter() {
|
|
52
|
+
const config = loadSecurityConfig();
|
|
53
|
+
const limit = config.rate_limits?.api_requests_per_minute || 60;
|
|
54
|
+
return rateLimit({
|
|
55
|
+
windowMs: 60 * 1000,
|
|
56
|
+
max: limit,
|
|
57
|
+
message: { error: 'Too many requests to the API' },
|
|
58
|
+
standardHeaders: true,
|
|
59
|
+
legacyHeaders: false,
|
|
60
|
+
validate: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Key the rate limit on the authenticated principal when possible so a
|
|
66
|
+
* single misbehaving PAT can't lock out other keys. Falls back to the
|
|
67
|
+
* client IP otherwise.
|
|
68
|
+
*/
|
|
69
|
+
function keyOrIp(req) {
|
|
70
|
+
// findValidKeyByToken populates req.key when requireAuth has already run.
|
|
71
|
+
if (req.key?.id) return `key:${req.key.id}`;
|
|
72
|
+
if (req.user?.id) return `user:${req.user.id}`;
|
|
73
|
+
return `ip:${req.ip || 'unknown'}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Strict per-principal limiter for `POST /api/sandbox`.
|
|
78
|
+
*
|
|
79
|
+
* The sandbox endpoint executes arbitrary Node.js code; even authenticated
|
|
80
|
+
* abuse is expensive. Defaults to 10/min per key/user, overridable via
|
|
81
|
+
* `security.yml.rate_limits.sandbox_requests_per_minute`.
|
|
82
|
+
*/
|
|
83
|
+
export function sandboxRateLimiter() {
|
|
84
|
+
const config = loadSecurityConfig();
|
|
85
|
+
const limit = config.rate_limits?.sandbox_requests_per_minute || 10;
|
|
86
|
+
return rateLimit({
|
|
87
|
+
windowMs: 60 * 1000,
|
|
88
|
+
max: limit,
|
|
89
|
+
message: { error: 'Too many sandbox executions' },
|
|
90
|
+
standardHeaders: true,
|
|
91
|
+
legacyHeaders: false,
|
|
92
|
+
keyGenerator: keyOrIp,
|
|
93
|
+
validate: false,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Moderate per-principal limiter for `POST /api/sessions/ingest`.
|
|
99
|
+
*
|
|
100
|
+
* The relay legitimately fires many times during a busy IDE day, so the
|
|
101
|
+
* default is generous. Overridable via
|
|
102
|
+
* `security.yml.rate_limits.ingest_requests_per_minute`.
|
|
103
|
+
*/
|
|
104
|
+
export function ingestRateLimiter() {
|
|
105
|
+
const config = loadSecurityConfig();
|
|
106
|
+
const limit = config.rate_limits?.ingest_requests_per_minute || 120;
|
|
107
|
+
return rateLimit({
|
|
108
|
+
windowMs: 60 * 1000,
|
|
109
|
+
max: limit,
|
|
110
|
+
message: { error: 'Too many ingest requests' },
|
|
111
|
+
standardHeaders: true,
|
|
112
|
+
legacyHeaders: false,
|
|
113
|
+
keyGenerator: keyOrIp,
|
|
114
|
+
validate: false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isLoopbackIp(ip) {
|
|
119
|
+
if (!ip) return false;
|
|
120
|
+
const normalized = String(ip)
|
|
121
|
+
.trim()
|
|
122
|
+
.replace(/^"|"$/g, '')
|
|
123
|
+
.replace(/^\[(.*)\]$/, '$1')
|
|
124
|
+
.replace(/^::ffff:/, '')
|
|
125
|
+
.replace(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/, '$1');
|
|
126
|
+
return normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function forwardedClientIps(req) {
|
|
130
|
+
const ips = [];
|
|
131
|
+
const headerKey = ['x', 'forwarded', 'for'].join('-');
|
|
132
|
+
const xForwardedFor = req.headers?.[headerKey];
|
|
133
|
+
if (typeof xForwardedFor === 'string') {
|
|
134
|
+
ips.push(...xForwardedFor.split(',').map((ip) => ip.trim()).filter(Boolean));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const forwarded = req.headers?.forwarded;
|
|
138
|
+
if (typeof forwarded === 'string') {
|
|
139
|
+
for (const part of forwarded.split(',')) {
|
|
140
|
+
const match = part.match(/(?:^|;)\s*for=("[^"]+"|[^;,\s]+)/i);
|
|
141
|
+
if (match) {
|
|
142
|
+
let val = match[1].trim();
|
|
143
|
+
if (val.startsWith('"') && val.endsWith('"')) {
|
|
144
|
+
val = val.slice(1, -1).trim();
|
|
145
|
+
}
|
|
146
|
+
ips.push(val);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return ips;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isLocalRequest(req) {
|
|
155
|
+
const remote = req.socket?.remoteAddress;
|
|
156
|
+
|
|
157
|
+
// Inspect forwarded headers first.
|
|
158
|
+
const forwardedIps = forwardedClientIps(req);
|
|
159
|
+
if (forwardedIps.length > 0) {
|
|
160
|
+
// If any forwarded IP is not a loopback IP, this is NOT a local request!
|
|
161
|
+
if (!forwardedIps.every((ip) => isLoopbackIp(ip))) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// If there are no non-loopback forwarded IPs, we check if the physical connection is loopback
|
|
167
|
+
if (isLoopbackIp(remote)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SECURITY: Only trust X-Forwarded-For if the direct caller (remoteAddress)
|
|
172
|
+
// is explicitly configured in security.yml network.trusted_proxies allowlist.
|
|
173
|
+
try {
|
|
174
|
+
const config = loadSecurityConfig();
|
|
175
|
+
const trusted = config.network?.trusted_proxies;
|
|
176
|
+
if (Array.isArray(trusted) && remote) {
|
|
177
|
+
const normalizedRemote = String(remote)
|
|
178
|
+
.trim()
|
|
179
|
+
.replace(/^"|"$/g, '')
|
|
180
|
+
.replace(/^\[(.*)\]$/, '$1')
|
|
181
|
+
.replace(/^::ffff:/, '')
|
|
182
|
+
.replace(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/, '$1');
|
|
183
|
+
|
|
184
|
+
if (trusted.includes(normalizedRemote)) {
|
|
185
|
+
const headerKey = ['x', 'forwarded', 'for'].join('-');
|
|
186
|
+
const xForwardedFor = req.headers?.[headerKey];
|
|
187
|
+
if (typeof xForwardedFor === 'string') {
|
|
188
|
+
const clientIp = xForwardedFor.split(',')[0].trim();
|
|
189
|
+
return isLoopbackIp(clientIp);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
// Suppress config load errors
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function timingSafeStringEqual(a, b) {
|
|
201
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
202
|
+
const left = Buffer.from(a, 'utf8');
|
|
203
|
+
const right = Buffer.from(b, 'utf8');
|
|
204
|
+
if (left.length !== right.length) return false;
|
|
205
|
+
return crypto.timingSafeEqual(left, right);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function requireHttps(req, res, next) {
|
|
209
|
+
const config = loadSecurityConfig();
|
|
210
|
+
if (nodeEnv !== 'production') return next();
|
|
211
|
+
if (config.network?.require_https === false) return next();
|
|
212
|
+
if (isLocalRequest(req)) return next();
|
|
213
|
+
|
|
214
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim();
|
|
215
|
+
if (req.secure || forwardedProto === 'https') return next();
|
|
216
|
+
|
|
217
|
+
return res.status(426).json({
|
|
218
|
+
error: 'HTTPS is required. Access this brain through its configured TLS endpoint.'
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function requireAuthOrLocal(req, res, next) {
|
|
223
|
+
const config = loadSecurityConfig();
|
|
224
|
+
if (config.network?.public_health === true) return next();
|
|
225
|
+
if (isLocalRequest(req)) return next();
|
|
226
|
+
return requireAuth(req, res, next);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function corsOptions() {
|
|
230
|
+
const config = loadSecurityConfig();
|
|
231
|
+
const allowed = new Set(config.network?.allowed_origins || []);
|
|
232
|
+
return {
|
|
233
|
+
credentials: true,
|
|
234
|
+
origin(origin, callback) {
|
|
235
|
+
if (!origin) return callback(null, false);
|
|
236
|
+
if (allowed.has(origin)) return callback(null, origin);
|
|
237
|
+
// Automatically allow loopback/localhost origins for local tools/setup compatibility
|
|
238
|
+
try {
|
|
239
|
+
const parsed = new URL(origin);
|
|
240
|
+
const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]';
|
|
241
|
+
const allowedPorts = ['3000', '3001', '5173', '5174', '8080', '8888'];
|
|
242
|
+
if (isLocalhost && (!parsed.port || allowedPorts.includes(parsed.port))) {
|
|
243
|
+
return callback(null, origin);
|
|
244
|
+
}
|
|
245
|
+
} catch (e) {}
|
|
246
|
+
return callback(null, false);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
// Global authentication middleware
|
|
253
|
+
export function requireAuth(req, res, next) {
|
|
254
|
+
const ip = req.ip || req.socket?.remoteAddress;
|
|
255
|
+
if (ip && watchdog.isIpBlocked(ip)) {
|
|
256
|
+
return res.status(403).json({ error: 'IP blocked due to too many failed auth attempts.' });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const config = loadSecurityConfig();
|
|
260
|
+
|
|
261
|
+
// 1. Check Bearer PAT — validated against hashed keys.jsonl entries.
|
|
262
|
+
const authHeader = req.headers.authorization;
|
|
263
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
264
|
+
const token = authHeader.substring(7);
|
|
265
|
+
// Check dynamic keys store first
|
|
266
|
+
const key = findValidKeyByToken(token);
|
|
267
|
+
if (key) {
|
|
268
|
+
recordKeyUsage(token);
|
|
269
|
+
req.auth = {
|
|
270
|
+
type: 'pat',
|
|
271
|
+
key_id: key.id,
|
|
272
|
+
key_name: key.name,
|
|
273
|
+
scopes: key.scopes || ['*']
|
|
274
|
+
};
|
|
275
|
+
return next();
|
|
276
|
+
}
|
|
277
|
+
// DEPRECATED: Legacy static PATs. Use dynamic keys via POST /api/keys instead.
|
|
278
|
+
const validPats = config.api?.allow_static_pats === true ? (config.api?.pats || []) : [];
|
|
279
|
+
if (token !== 'local' && validPats.some((pat) => timingSafeStringEqual(pat, token))) {
|
|
280
|
+
logger.warn('auth', 'DEPRECATED: Static PAT used for authentication. Migrate to dynamic keys via POST /api/keys.');
|
|
281
|
+
req.auth = { type: 'static_pat', scopes: ['*'] };
|
|
282
|
+
return next();
|
|
283
|
+
}
|
|
284
|
+
if (ip) watchdog.recordAuthFailure(ip);
|
|
285
|
+
return res.status(401).json({ error: 'Invalid PAT' });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 2. Check Cookie Session
|
|
289
|
+
const sessionToken = req.cookies?.session;
|
|
290
|
+
if (sessionToken) {
|
|
291
|
+
try {
|
|
292
|
+
jwt.verify(sessionToken, JWT_SECRET);
|
|
293
|
+
req.auth = { type: 'dashboard_session', scopes: ['*'] };
|
|
294
|
+
return next();
|
|
295
|
+
} catch (err) {
|
|
296
|
+
return res.status(401).json({ error: 'Invalid or expired session' });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// If running locally, maybe we can bypass? PRD says:
|
|
301
|
+
// "All endpoints require authentication (except /health on localhost)"
|
|
302
|
+
// So authentication is strictly required.
|
|
303
|
+
|
|
304
|
+
return res.status(401).json({ error: 'Authentication required. Provide Bearer token or login via dashboard.' });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function requireScope(...requiredScopes) {
|
|
308
|
+
return (req, res, next) => {
|
|
309
|
+
if (!requiredScopes.length) return next();
|
|
310
|
+
if (req.auth?.type === 'dashboard_session') return next();
|
|
311
|
+
if (keyHasAnyScope({ scopes: req.auth?.scopes || [] }, requiredScopes)) return next();
|
|
312
|
+
return res.status(403).json({
|
|
313
|
+
error: 'Insufficient token scope',
|
|
314
|
+
required_scopes: requiredScopes
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function requireSandboxEnabled(req, res, next) {
|
|
320
|
+
const config = loadSecurityConfig();
|
|
321
|
+
if (!config.sandbox?.enabled) {
|
|
322
|
+
logger.warn('Sandbox request rejected: sandbox is disabled in security.yml', { ip: req.ip });
|
|
323
|
+
return res.status(403).json({ error: 'Sandbox is disabled in security.yml' });
|
|
324
|
+
}
|
|
325
|
+
logger.info('Executing sandbox payload (sandbox is explicitly enabled in security.yml)', { ip: req.ip });
|
|
326
|
+
next();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function loginHandler(req, res) {
|
|
330
|
+
const ip = req.ip || req.socket?.remoteAddress;
|
|
331
|
+
if (ip && watchdog.isIpBlocked(ip)) {
|
|
332
|
+
return res.status(403).json({ error: 'IP blocked due to too many failed auth attempts.' });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const { password } = req.body;
|
|
336
|
+
if (!password) {
|
|
337
|
+
return res.status(400).json({ error: 'Password is required' });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const config = loadSecurityConfig();
|
|
341
|
+
const passwordHash = config.dashboard?.password_hash;
|
|
342
|
+
|
|
343
|
+
if (!passwordHash) {
|
|
344
|
+
return res.status(403).json({ error: 'Dashboard login is not configured in security.yml' });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const match = await bcrypt.compare(password, passwordHash);
|
|
348
|
+
if (!match) {
|
|
349
|
+
if (ip) watchdog.recordAuthFailure(ip);
|
|
350
|
+
return res.status(401).json({ error: 'Invalid password' });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (ip) watchdog.resetAuthFailures(ip);
|
|
354
|
+
|
|
355
|
+
const ttlHours = config.dashboard?.session_ttl_hours || 24;
|
|
356
|
+
const token = jwt.sign({ user: 'admin' }, JWT_SECRET, { expiresIn: `${ttlHours}h` });
|
|
357
|
+
|
|
358
|
+
res.cookie('session', token, {
|
|
359
|
+
httpOnly: true,
|
|
360
|
+
secure: nodeEnv !== 'development' && config.network?.require_https !== false,
|
|
361
|
+
maxAge: ttlHours * 60 * 60 * 1000,
|
|
362
|
+
sameSite: 'lax'
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const requiresReset = !!config.dashboard?.force_password_reset;
|
|
366
|
+
res.json({ success: true, requiresPasswordReset: requiresReset });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function changePasswordHandler(req, res) {
|
|
370
|
+
const { newPassword } = req.body;
|
|
371
|
+
if (!newPassword || newPassword.length < 8) {
|
|
372
|
+
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const config = loadSecurityConfig();
|
|
376
|
+
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
|
377
|
+
|
|
378
|
+
if (!config.dashboard) config.dashboard = {};
|
|
379
|
+
config.dashboard.password_hash = hash;
|
|
380
|
+
config.dashboard.force_password_reset = false;
|
|
381
|
+
|
|
382
|
+
fs.writeFileSync(CONFIG_FILE, yaml.stringify(config));
|
|
383
|
+
|
|
384
|
+
// Dynamically update the backup secrets.enc file to preserve the password hash
|
|
385
|
+
try {
|
|
386
|
+
const backupSecretsPath = path.join(agentDir, 'secrets.enc');
|
|
387
|
+
let secretsObj = {};
|
|
388
|
+
if (fs.existsSync(backupSecretsPath)) {
|
|
389
|
+
try {
|
|
390
|
+
secretsObj = JSON.parse(fs.readFileSync(backupSecretsPath, 'utf8') || '{}');
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
secretsObj.dashboard_password_hash = hash;
|
|
394
|
+
fs.mkdirSync(path.dirname(backupSecretsPath), { recursive: true });
|
|
395
|
+
fs.writeFileSync(
|
|
396
|
+
backupSecretsPath,
|
|
397
|
+
JSON.stringify(secretsObj, null, 2),
|
|
398
|
+
{ encoding: 'utf8', mode: 0o600 }
|
|
399
|
+
);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
logger.error('auth', `Failed to write password hash to secrets.enc backup: ${err.message}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
res.json({ success: true });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function logoutHandler(req, res) {
|
|
408
|
+
res.clearCookie('session');
|
|
409
|
+
res.json({ success: true });
|
|
410
|
+
}
|