total-recall-brain 3.23.2 → 3.24.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/bin/total-recall.mjs +1 -0
- package/frontend/dist/assets/{index-c6GHGS_j.js → index-CtCDaZYZ.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/src/pages/SecretsPage.tsx +111 -52
- package/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/SKILL.md +199 -7
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +13 -4
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -2
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +7 -0
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +2461 -6
- package/src/cli/backfill.mjs +176 -0
- package/src/cli/init.mjs +1 -1
- package/src/cli/proposals.mjs +3 -3
- package/src/cli/secret.mjs +84 -0
- package/src/core/clarity-rewriter.mjs +25 -39
- package/src/core/conclusion-writer.mjs +20 -10
- package/src/core/crypto.mjs +86 -16
- package/src/core/daemon-loop.mjs +2 -2
- package/src/core/dream.mjs +49 -58
- package/src/core/env-import.mjs +18 -0
- package/src/core/inference-engine.mjs +15 -17
- package/src/core/migrate.mjs +5 -1
- package/src/core/okf-adapter.mjs +2 -0
- package/src/core/post-mortem.mjs +30 -26
- package/src/core/proposal-applier.mjs +26 -10
- package/src/core/repo-sync.mjs +51 -39
- package/src/core/scheduler.mjs +1 -0
- package/src/core/schema.mjs +24 -1
- package/src/core/search.mjs +1 -1
- package/src/core/secrets-env-diff.mjs +132 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/snapshot.mjs +32 -9
- package/src/core/ssss-kernel-bridge.mjs +46 -6
- package/src/core/task-envelope.mjs +1 -0
- package/src/core/validated-write.mjs +68 -5
- package/src/core/vault-backfill.mjs +259 -0
- package/src/core/vault.mjs +22 -4
- package/src/server/auth.mjs +42 -8
- package/src/server/health-coverage.mjs +74 -0
- package/src/server/index.mjs +60 -36
- package/src/server/routes/brains.mjs +1 -1
- package/src/server/routes/proposals.mjs +4 -4
- package/src/server/routes/secrets.mjs +1 -1
package/src/core/vault.mjs
CHANGED
|
@@ -3,9 +3,27 @@ import path from 'path';
|
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { logger } from './logger.mjs';
|
|
5
5
|
|
|
6
|
-
// Slug allowlist shared with validated-write.mjs — a slug is a filename
|
|
7
|
-
//
|
|
8
|
-
|
|
6
|
+
// Slug allowlist shared with validated-write.mjs — a slug is a filename, so it
|
|
7
|
+
// must not be able to escape its category directory.
|
|
8
|
+
//
|
|
9
|
+
// Dots are permitted in the interior because repository names legitimately
|
|
10
|
+
// contain them — any domain-style name (`example.com`) produces a slug with a
|
|
11
|
+
// dot on ingest, and the stricter rule rejected thousands of real nodes over an
|
|
12
|
+
// ordinary character. What enables traversal is a path separator or `..`, and
|
|
13
|
+
// neither is reachable here — separators are outside the character class, a
|
|
14
|
+
// leading dot or dash is excluded by the first-character rule, and `..` is
|
|
15
|
+
// rejected outright below.
|
|
16
|
+
const SAFE_NAME = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when `name` is safe to use as a path segment.
|
|
20
|
+
*/
|
|
21
|
+
export function isSafeVaultName(name) {
|
|
22
|
+
const value = String(name);
|
|
23
|
+
if (!SAFE_NAME.test(value)) return false;
|
|
24
|
+
if (value.includes('..')) return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
/**
|
|
11
29
|
* Atomic file write using write-then-rename to prevent partial corruption.
|
|
@@ -158,7 +176,7 @@ export async function writeNode(node, vaultDir, options = {}) {
|
|
|
158
176
|
* @returns {boolean}
|
|
159
177
|
*/
|
|
160
178
|
export function deleteNode(slug, vaultDir) {
|
|
161
|
-
if (!
|
|
179
|
+
if (!isSafeVaultName(slug)) return false;
|
|
162
180
|
if (!fs.existsSync(vaultDir)) return false;
|
|
163
181
|
|
|
164
182
|
// Scan category directories directly instead of loading entire vault
|
package/src/server/auth.mjs
CHANGED
|
@@ -355,6 +355,42 @@ export function requireSandboxEnabled(req, res, next) {
|
|
|
355
355
|
next();
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Cookie options for the dashboard session.
|
|
360
|
+
*
|
|
361
|
+
* `secure` has to describe the connection that is actually in use, not the
|
|
362
|
+
* connection the config wishes for. It was previously derived from
|
|
363
|
+
* `nodeEnv !== 'development' && require_https !== false`, and since nodeEnv
|
|
364
|
+
* defaults to 'production' and the server only ever calls app.listen() — there
|
|
365
|
+
* is no HTTPS listener anywhere in it — every login over the mesh bind issued a
|
|
366
|
+
* `Secure` cookie on a plain-HTTP response. Browsers drop those, so login
|
|
367
|
+
* returned 200, no session was stored, and the next request 401'd straight back
|
|
368
|
+
* to the login page. http://localhost is exempt as a trustworthy origin, which
|
|
369
|
+
* is why this looked fine locally and only bit when reaching the dashboard by
|
|
370
|
+
* its host or mesh IP.
|
|
371
|
+
*
|
|
372
|
+
* Express resolves `req.secure` through `trust proxy`, so a deployment behind
|
|
373
|
+
* an HTTPS-terminating reverse proxy still gets `Secure` set.
|
|
374
|
+
*/
|
|
375
|
+
function sessionCookieOptions(req, maxAge) {
|
|
376
|
+
const config = loadSecurityConfig();
|
|
377
|
+
const isSecureConnection = req?.secure === true;
|
|
378
|
+
|
|
379
|
+
if (config.network?.require_https !== false && !isSecureConnection) {
|
|
380
|
+
logger.warn('auth',
|
|
381
|
+
'require_https is set but this request arrived over plain HTTP; issuing the session '
|
|
382
|
+
+ 'cookie without Secure so login works. Terminate TLS in front of the dashboard, or '
|
|
383
|
+
+ 'set network.require_https: false to silence this.');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
httpOnly: true,
|
|
388
|
+
secure: isSecureConnection,
|
|
389
|
+
maxAge,
|
|
390
|
+
sameSite: 'lax',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
358
394
|
export async function loginHandler(req, res) {
|
|
359
395
|
const ip = req.ip || req.socket?.remoteAddress;
|
|
360
396
|
if (ip && watchdog.isIpBlocked(ip)) {
|
|
@@ -383,13 +419,9 @@ export async function loginHandler(req, res) {
|
|
|
383
419
|
|
|
384
420
|
const ttlHours = config.dashboard?.session_ttl_hours || 24;
|
|
385
421
|
const token = jwt.sign({ user: 'admin' }, JWT_SECRET, { expiresIn: `${ttlHours}h` });
|
|
386
|
-
|
|
387
|
-
res.cookie('session', token,
|
|
388
|
-
|
|
389
|
-
secure: nodeEnv !== 'development' && config.network?.require_https !== false,
|
|
390
|
-
maxAge: ttlHours * 60 * 60 * 1000,
|
|
391
|
-
sameSite: 'lax'
|
|
392
|
-
});
|
|
422
|
+
|
|
423
|
+
res.cookie('session', token, sessionCookieOptions(req, ttlHours * 60 * 60 * 1000));
|
|
424
|
+
|
|
393
425
|
|
|
394
426
|
const requiresReset = !!config.dashboard?.force_password_reset;
|
|
395
427
|
res.json({ success: true, requiresPasswordReset: requiresReset });
|
|
@@ -475,6 +507,8 @@ export async function verifyDashboardPassword(password) {
|
|
|
475
507
|
}
|
|
476
508
|
|
|
477
509
|
export function logoutHandler(req, res) {
|
|
478
|
-
|
|
510
|
+
// Attributes must match those the cookie was set with or the browser keeps it.
|
|
511
|
+
const { maxAge, ...clearOptions } = sessionCookieOptions(req, 0);
|
|
512
|
+
res.clearCookie('session', clearOptions);
|
|
479
513
|
res.json({ success: true });
|
|
480
514
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps /health cheap without making it dishonest.
|
|
3
|
+
*
|
|
4
|
+
* Embedding coverage is the expensive half of the health check: for every
|
|
5
|
+
* active brain it parses every node in that brain's vault and loads that
|
|
6
|
+
* brain's embeddings index — 12MB on one machine here. Computed inside the
|
|
7
|
+
* request it made /health take 25-57s on a loaded laptop while the dashboard
|
|
8
|
+
* answered in 116ms, so the server read as dead to everything that probes it.
|
|
9
|
+
* That is the worst endpoint to make slow: /health is what the watchdog polls,
|
|
10
|
+
* and a liveness probe that times out is indistinguishable from a server that
|
|
11
|
+
* is down — which is exactly how it was misread.
|
|
12
|
+
*
|
|
13
|
+
* A cache alone would trade one wrong answer for another, because the reading
|
|
14
|
+
* it holds is a criticality signal: a brain with nodes and no vectors serves
|
|
15
|
+
* keyword results that look like real ones. So the snapshot always carries the
|
|
16
|
+
* time it was taken, and reports "no reading yet" as null rather than as clean.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single-flight, time-bounded cache over an expensive async computation.
|
|
23
|
+
*
|
|
24
|
+
* Never blocks a caller: a stale value is served while a refresh runs behind
|
|
25
|
+
* it, and before the first value lands callers get null. `compute` errors are
|
|
26
|
+
* swallowed rather than left to reject an unawaited promise — a failed refresh
|
|
27
|
+
* keeps the previous reading and its original timestamp, so failure looks like
|
|
28
|
+
* staleness rather than success.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {() => Promise<any>} opts.compute the expensive work
|
|
32
|
+
* @param {number} [opts.ttlMs] how long a reading stays fresh
|
|
33
|
+
* @param {() => number} [opts.now] clock seam, for tests
|
|
34
|
+
*/
|
|
35
|
+
export function createCoverageCache({ compute, ttlMs = DEFAULT_TTL_MS, now = () => Date.now() }) {
|
|
36
|
+
let value = null;
|
|
37
|
+
let takenAt = 0;
|
|
38
|
+
let inFlight = null;
|
|
39
|
+
|
|
40
|
+
function refresh() {
|
|
41
|
+
// Single-flight: a second caller during a refresh joins it rather than
|
|
42
|
+
// starting a parallel full-vault parse.
|
|
43
|
+
if (inFlight) return inFlight;
|
|
44
|
+
inFlight = Promise.resolve()
|
|
45
|
+
.then(() => compute())
|
|
46
|
+
.then((next) => {
|
|
47
|
+
value = next;
|
|
48
|
+
takenAt = now();
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
// Keep the prior reading and its timestamp; a failed refresh must not
|
|
52
|
+
// be able to masquerade as a fresh clean result.
|
|
53
|
+
})
|
|
54
|
+
.finally(() => {
|
|
55
|
+
inFlight = null;
|
|
56
|
+
});
|
|
57
|
+
return inFlight;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
/** Last known reading, kicking off a background refresh when stale. */
|
|
62
|
+
snapshot() {
|
|
63
|
+
if (!value || now() - takenAt > ttlMs) refresh();
|
|
64
|
+
return value
|
|
65
|
+
? { coverage: value, as_of: new Date(takenAt).toISOString() }
|
|
66
|
+
: { coverage: null, as_of: null };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/** Resolves once any in-progress refresh settles. Used to warm at boot. */
|
|
70
|
+
async settled() {
|
|
71
|
+
while (inFlight) await inFlight;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './auth.mjs';
|
|
30
30
|
import { logger, logEvents } from "../core/logger.mjs";
|
|
31
31
|
import { readProcessCommand, entryPathHint, shouldHonorPidLock } from '../core/pid-lock.mjs';
|
|
32
|
+
import { createCoverageCache } from './health-coverage.mjs';
|
|
32
33
|
import { drainActiveEmbeddings } from './routes/sessions.mjs';
|
|
33
34
|
import { agentDir as configAgentDir, brainDir as configBrainDir, port as configPort, host as configHost, nodeEnv } from '../core/config.mjs';
|
|
34
35
|
import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs';
|
|
@@ -172,6 +173,56 @@ app.use(cookieParser());
|
|
|
172
173
|
|
|
173
174
|
// ─── Health Check ───────────────────────────────────────────────────────────────
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Embedding coverage is the expensive half of this endpoint — see
|
|
178
|
+
* health-coverage.mjs for why it is computed off the request path. The check
|
|
179
|
+
* itself is unchanged: a brain with nodes and no vectors still reports at
|
|
180
|
+
* critical severity, because recall silently answering from keyword matching
|
|
181
|
+
* is the failure this was written to catch.
|
|
182
|
+
*/
|
|
183
|
+
async function computeEmbeddingCoverage() {
|
|
184
|
+
const coverage = [];
|
|
185
|
+
try {
|
|
186
|
+
const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
|
|
187
|
+
const { getActiveBrains } = await import('../core/config.mjs');
|
|
188
|
+
const brains = getActiveBrains();
|
|
189
|
+
for (const b of Object.values(brains)) {
|
|
190
|
+
if (!b?.brainDir) continue;
|
|
191
|
+
const vaultPath = path.join(b.brainDir, 'memory-vault');
|
|
192
|
+
if (!fs.existsSync(vaultPath)) continue;
|
|
193
|
+
// Count only what the embedder actually indexes: type:memory nodes.
|
|
194
|
+
// Counting every .md under the vault compares against a denominator that
|
|
195
|
+
// includes proposals/ (optimizer tickets, never embedded by design), so a
|
|
196
|
+
// perfectly healthy brain reported "847/16701 — 5% coverage" and tripped
|
|
197
|
+
// a false degraded alarm. Ask the loader, don't count files.
|
|
198
|
+
let nodeCount = 0;
|
|
199
|
+
try {
|
|
200
|
+
const { loadNodes } = await import('../core/vault.mjs');
|
|
201
|
+
nodeCount = loadNodes(vaultPath).length;
|
|
202
|
+
} catch { /* unreadable vault */ }
|
|
203
|
+
let embedded = 0;
|
|
204
|
+
try {
|
|
205
|
+
embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
|
|
206
|
+
} catch { /* index unreadable — reported as 0, which is the honest value */ }
|
|
207
|
+
coverage.push({
|
|
208
|
+
layer: b.layer || 'unknown',
|
|
209
|
+
nodes: nodeCount,
|
|
210
|
+
embedded,
|
|
211
|
+
vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
coverage.push({ error: String(err?.message || err) });
|
|
216
|
+
}
|
|
217
|
+
return coverage;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const coverageCache = createCoverageCache({ compute: computeEmbeddingCoverage });
|
|
221
|
+
const embeddingCoverageSnapshot = () => coverageCache.snapshot();
|
|
222
|
+
|
|
223
|
+
// Warm it at boot so the first probe already has a real answer.
|
|
224
|
+
embeddingCoverageSnapshot();
|
|
225
|
+
|
|
175
226
|
app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
176
227
|
let disk = { free: 0, total: 0 };
|
|
177
228
|
try {
|
|
@@ -206,7 +257,7 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
206
257
|
// Check daemon status
|
|
207
258
|
const daemonStatus = getDaemonStatus();
|
|
208
259
|
|
|
209
|
-
// Embedding coverage per brain.
|
|
260
|
+
// Embedding coverage per brain, served from the background cache above.
|
|
210
261
|
//
|
|
211
262
|
// This existed nowhere before, and its absence is exactly why a brain with
|
|
212
263
|
// 2602 vault nodes and 0 embeddings looked healthy for weeks: compile
|
|
@@ -214,43 +265,13 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
214
265
|
// real results, and nothing anywhere printed "N of M nodes embedded".
|
|
215
266
|
// Vector search silently off is a CRITICAL failure, not a cosmetic one —
|
|
216
267
|
// recall answers from the wrong brain and the caller cannot tell.
|
|
217
|
-
const embeddingCoverage =
|
|
218
|
-
try {
|
|
219
|
-
const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
|
|
220
|
-
const { getActiveBrains } = await import('../core/config.mjs');
|
|
221
|
-
const brains = getActiveBrains();
|
|
222
|
-
for (const b of Object.values(brains)) {
|
|
223
|
-
if (!b?.brainDir) continue;
|
|
224
|
-
const vaultPath = path.join(b.brainDir, 'memory-vault');
|
|
225
|
-
if (!fs.existsSync(vaultPath)) continue;
|
|
226
|
-
// Count only what the embedder actually indexes: type:memory nodes.
|
|
227
|
-
// Counting every .md under the vault compares against a denominator that
|
|
228
|
-
// includes proposals/ (optimizer tickets, never embedded by design), so a
|
|
229
|
-
// perfectly healthy brain reported "847/16701 — 5% coverage" and tripped
|
|
230
|
-
// a false degraded alarm. Ask the loader, don't count files.
|
|
231
|
-
let nodeCount = 0;
|
|
232
|
-
try {
|
|
233
|
-
const { loadNodes } = await import('../core/vault.mjs');
|
|
234
|
-
nodeCount = loadNodes(vaultPath).length;
|
|
235
|
-
} catch { /* unreadable vault */ }
|
|
236
|
-
let embedded = 0;
|
|
237
|
-
try {
|
|
238
|
-
embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
|
|
239
|
-
} catch { /* index unreadable — reported as 0, which is the honest value */ }
|
|
240
|
-
embeddingCoverage.push({
|
|
241
|
-
layer: b.layer || 'unknown',
|
|
242
|
-
nodes: nodeCount,
|
|
243
|
-
embedded,
|
|
244
|
-
vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
} catch (err) {
|
|
248
|
-
embeddingCoverage.push({ error: String(err?.message || err) });
|
|
249
|
-
}
|
|
268
|
+
const { coverage: embeddingCoverage, as_of: coverageAsOf } = embeddingCoverageSnapshot();
|
|
250
269
|
|
|
251
270
|
// A brain with nodes but no vectors answers every query from keyword
|
|
252
|
-
// matching. Surface it at the same severity as a dead daemon.
|
|
253
|
-
|
|
271
|
+
// matching. Surface it at the same severity as a dead daemon. Before the
|
|
272
|
+
// first reading lands there is nothing to judge, and an unknown is reported
|
|
273
|
+
// as unknown rather than quietly counted as healthy.
|
|
274
|
+
const unembeddedBrains = (embeddingCoverage || []).filter(c => c.nodes > 0 && c.embedded === 0);
|
|
254
275
|
|
|
255
276
|
// Determine overall status
|
|
256
277
|
const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead' || cliAgents.length === 0
|
|
@@ -290,6 +311,9 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
290
311
|
cli_agents: cliAgents,
|
|
291
312
|
daemon: daemonStatus,
|
|
292
313
|
embedding_coverage: embeddingCoverage,
|
|
314
|
+
// When this reading was taken. Null means the first computation has not
|
|
315
|
+
// landed yet, so the coverage field is unknown rather than clean.
|
|
316
|
+
embedding_coverage_as_of: coverageAsOf,
|
|
293
317
|
// Explicit, greppable signal. A brain with nodes and no vectors serves
|
|
294
318
|
// keyword-only results that look exactly like real ones.
|
|
295
319
|
vector_search_degraded: unembeddedBrains.length > 0
|
|
@@ -218,7 +218,7 @@ router.get('/api/brains/:id/nodes', requireAuth, requireScope('ssss:read', 'memo
|
|
|
218
218
|
router.post('/api/brains/sync', requireAuth, requireScope('memory:write'), async (req, res) => {
|
|
219
219
|
try {
|
|
220
220
|
const { syncAllRepos } = await import('../../core/repo-sync.mjs');
|
|
221
|
-
const result = syncAllRepos();
|
|
221
|
+
const result = await syncAllRepos();
|
|
222
222
|
res.json({
|
|
223
223
|
ok: true,
|
|
224
224
|
totalIngested: result.totalIngested,
|
|
@@ -90,11 +90,11 @@ proposalsRouter.post('/api/proposals/:id/apply', requireAuth, requireScope('memo
|
|
|
90
90
|
}
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
proposalsRouter.post('/api/proposals/:id/reject', requireAuth, requireScope('memory:write'), (req, res) => {
|
|
93
|
+
proposalsRouter.post('/api/proposals/:id/reject', requireAuth, requireScope('memory:write'), async (req, res) => {
|
|
94
94
|
try {
|
|
95
95
|
const vaultDir = resolveVaultFromQuery(req);
|
|
96
96
|
const reason = req.body?.reason || 'Rejected via API.';
|
|
97
|
-
res.json(setProposalStatus(vaultDir, req.params.id, 'rejected', {
|
|
97
|
+
res.json(await setProposalStatus(vaultDir, req.params.id, 'rejected', {
|
|
98
98
|
rejection_reason: reason,
|
|
99
99
|
reviewed_by: req.body?.actor || 'api',
|
|
100
100
|
}));
|
|
@@ -104,9 +104,9 @@ proposalsRouter.post('/api/proposals/:id/reject', requireAuth, requireScope('mem
|
|
|
104
104
|
}
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
proposalsRouter.post('/api/proposals/:id/revert', requireAuth, requireScope('memory:write'), (req, res) => {
|
|
107
|
+
proposalsRouter.post('/api/proposals/:id/revert', requireAuth, requireScope('memory:write'), async (req, res) => {
|
|
108
108
|
try {
|
|
109
|
-
res.json(revertProposal(resolveVaultFromQuery(req), req.params.id));
|
|
109
|
+
res.json(await revertProposal(resolveVaultFromQuery(req), req.params.id));
|
|
110
110
|
} catch (err) {
|
|
111
111
|
if (/No undo snapshot|not found/i.test(err.message)) return res.status(404).json({ error: err.message });
|
|
112
112
|
serverError(res, err);
|
|
@@ -49,7 +49,7 @@ const router = express.Router();
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Provider secrets are a single host SSOT — NOT per project-vault brain.
|
|
52
|
-
* Selecting project
|
|
52
|
+
* Selecting any project:<name> scope for memory/graph must not empty the Secrets page.
|
|
53
53
|
* Repo binding lives in secret metadata (repos[]), not separate secrets.enc files.
|
|
54
54
|
*
|
|
55
55
|
* Override only via TR_SECRETS_BRAIN / TR_BRAIN env (deploy/ops).
|