@lifeaitools/clauth 2.15.6 → 2.15.8
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.
|
@@ -354,15 +354,26 @@ class ClauthSurfacesPanel extends window.ClauthPanelElement {
|
|
|
354
354
|
const needsHelpLine = needsHelp
|
|
355
355
|
? "Stopped auto-restarting after " + (surface.consecutive_reconcile_failures || "several") + " failed attempts — check the log, fix the cause, then Start/Restart to resume auto-repair."
|
|
356
356
|
: null;
|
|
357
|
+
// Command-set drift (2026-09-06): the surface's OWN code (read live via
|
|
358
|
+
// its health response) disagrees with what its manifest declares --
|
|
359
|
+
// exactly the "note says X, code doesn't enforce X" defect class. Only
|
|
360
|
+
// rendered when TRUE -- a surface that hasn't been retrofitted with the
|
|
361
|
+
// live commands field yet (the common case today) reports no drift, not
|
|
362
|
+
// a false one.
|
|
363
|
+
const driftLine = surface.command_drift
|
|
364
|
+
? "Command-set drift: manifest declares " + (surface.declared_commands || []).join(", ") + "; surface's own code reports " + (surface.last_health_commands || []).join(", ") + "."
|
|
365
|
+
: null;
|
|
357
366
|
return '<div class="' + rowClass + (needsHelp ? " needs-help" : "") + '" data-surface-id="' + htmlEscape(compositeId) + '"' + rowAction + '>' +
|
|
358
367
|
'<div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.name || compositeId) + '</span>' +
|
|
359
368
|
healthPill +
|
|
360
369
|
supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(stateLabel, stateKind) +
|
|
370
|
+
(surface.command_drift ? supervisorBadge("COMMAND DRIFT", "warn") : "") +
|
|
361
371
|
openBtn +
|
|
362
372
|
'</div>' +
|
|
363
373
|
'<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
|
|
364
374
|
'<div class="supervisor-meta">' + htmlEscape(healthLine) + '</div>' +
|
|
365
375
|
(needsHelpLine ? '<div class="supervisor-meta critical">' + htmlEscape(needsHelpLine) + '</div>' : '') +
|
|
376
|
+
(driftLine ? '<div class="supervisor-meta critical">' + htmlEscape(driftLine) + '</div>' : '') +
|
|
366
377
|
'</div>';
|
|
367
378
|
}
|
|
368
379
|
|
package/cli/fingerprint.js
CHANGED
|
@@ -15,7 +15,33 @@ import path from "path";
|
|
|
15
15
|
// Cache path — avoids re-querying WMI/CIM on every daemon start.
|
|
16
16
|
// This eliminates the spawnSync cmd.exe ETIMEDOUT crash that occurs
|
|
17
17
|
// when PowerShell/WMI is slow on first call after boot.
|
|
18
|
-
|
|
18
|
+
//
|
|
19
|
+
// Durable as of 2026-09-06 (was os.tmpdir(), see LEGACY_CACHE_FILE below).
|
|
20
|
+
// getMachineId() derives (primary, secondary) from TWO independent execSync
|
|
21
|
+
// calls, each wrapped in its own try/catch that falls through silently on
|
|
22
|
+
// failure (see the comments at those calls). If either call transiently
|
|
23
|
+
// timed out, the resulting pairing was composed differently than a
|
|
24
|
+
// successful run, producing a DIFFERENT sha256 machine hash for the exact
|
|
25
|
+
// same physical machine — surfacing server-side as a false
|
|
26
|
+
// machine_not_found lockout, not a security event. A cache in system temp
|
|
27
|
+
// does not survive Windows Disk Cleanup or some machines' reboot behavior,
|
|
28
|
+
// which re-exposed the flaky first-computation path on any later restart
|
|
29
|
+
// where WMI happened to be slow. Matches the durable pattern boot.key
|
|
30
|
+
// already uses (getBootKeyPath() in cli/commands/serve.js) — same
|
|
31
|
+
// AppData/Roaming/clauth (win32) / ~/.config/clauth (else) directory.
|
|
32
|
+
function getCacheDir() {
|
|
33
|
+
if (os.platform() === "win32") {
|
|
34
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "clauth");
|
|
35
|
+
}
|
|
36
|
+
return path.join(os.homedir(), ".config", "clauth");
|
|
37
|
+
}
|
|
38
|
+
const CACHE_FILE = path.join(getCacheDir(), "machine.cache");
|
|
39
|
+
// Pre-2026-09-06 location. An already-registered machine may still have a
|
|
40
|
+
// valid cache here; migrate it into the durable location on first read
|
|
41
|
+
// instead of recomputing via WMI — recomputing is exactly the flaky path
|
|
42
|
+
// this fix exists to avoid, and the durable write means this only ever
|
|
43
|
+
// happens once per machine.
|
|
44
|
+
const LEGACY_CACHE_FILE = path.join(os.tmpdir(), "clauth-machine.cache");
|
|
19
45
|
|
|
20
46
|
function readCache() {
|
|
21
47
|
try {
|
|
@@ -23,12 +49,24 @@ function readCache() {
|
|
|
23
49
|
// Validate: must be two non-empty lines (primary:secondary)
|
|
24
50
|
const [primary, secondary] = raw.split("\n");
|
|
25
51
|
if (primary && secondary) return { primary: primary.trim(), secondary: secondary.trim() };
|
|
26
|
-
} catch { /* cache miss */ }
|
|
52
|
+
} catch { /* durable cache miss */ }
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(LEGACY_CACHE_FILE, "utf8").trim();
|
|
55
|
+
const [primary, secondary] = raw.split("\n");
|
|
56
|
+
if (primary && secondary) {
|
|
57
|
+
const migrated = { primary: primary.trim(), secondary: secondary.trim() };
|
|
58
|
+
writeCache(migrated.primary, migrated.secondary);
|
|
59
|
+
return migrated;
|
|
60
|
+
}
|
|
61
|
+
} catch { /* no legacy cache either */ }
|
|
27
62
|
return null;
|
|
28
63
|
}
|
|
29
64
|
|
|
30
65
|
function writeCache(primary, secondary) {
|
|
31
|
-
try {
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(getCacheDir(), { recursive: true });
|
|
68
|
+
fs.writeFileSync(CACHE_FILE, `${primary}\n${secondary}`, "utf8");
|
|
69
|
+
} catch { /* best effort */ }
|
|
32
70
|
}
|
|
33
71
|
|
|
34
72
|
function getMachineId() {
|
|
@@ -140,4 +178,8 @@ export function deriveSeedHash(machineHash, password) {
|
|
|
140
178
|
.digest("hex");
|
|
141
179
|
}
|
|
142
180
|
|
|
181
|
+
// Exported for test path introspection only — not part of the public
|
|
182
|
+
// CLI/API surface.
|
|
183
|
+
export { CACHE_FILE, LEGACY_CACHE_FILE, getCacheDir };
|
|
184
|
+
|
|
143
185
|
export default { getMachineHash, deriveToken, deriveSeedHash };
|
package/cli/fingerprint.test.js
CHANGED
|
@@ -4,7 +4,12 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
|
|
7
|
-
import { getMachineHash, deriveToken, deriveSeedHash } from "./fingerprint.js";
|
|
7
|
+
import { getMachineHash, deriveToken, deriveSeedHash, CACHE_FILE, LEGACY_CACHE_FILE } from "./fingerprint.js";
|
|
8
|
+
|
|
9
|
+
// CACHE_FILE/LEGACY_CACHE_FILE are the REAL production paths (durable
|
|
10
|
+
// AppData/Roaming/clauth on win32, formerly os.tmpdir()) — read-only in this
|
|
11
|
+
// suite. Every test below only ever reads mtimeMs, never asserts on or
|
|
12
|
+
// writes machine-identifying content into either file.
|
|
8
13
|
|
|
9
14
|
// getMachineId() normally shells out to WMI/registry/ioreg, which this harness
|
|
10
15
|
// must never do (slow, platform-dependent, pollutes nothing but still real
|
|
@@ -84,12 +89,31 @@ test("deriveSeedHash changes with the password", () => {
|
|
|
84
89
|
assert.notEqual(deriveSeedHash(hash, "one"), deriveSeedHash(hash, "two"));
|
|
85
90
|
});
|
|
86
91
|
|
|
87
|
-
test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file", () => {
|
|
92
|
+
test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file (durable or legacy)", () => {
|
|
88
93
|
// Positive control that the cache mechanism exists and this test can see it,
|
|
89
94
|
// so "the cache file was untouched" below actually means something.
|
|
90
|
-
const
|
|
91
|
-
|
|
95
|
+
const before = {
|
|
96
|
+
durable: fs.existsSync(CACHE_FILE) ? fs.statSync(CACHE_FILE).mtimeMs : null,
|
|
97
|
+
legacy: fs.existsSync(LEGACY_CACHE_FILE) ? fs.statSync(LEGACY_CACHE_FILE).mtimeMs : null,
|
|
98
|
+
};
|
|
92
99
|
withMachineId("container-fast-path", () => getMachineHash());
|
|
93
|
-
const after =
|
|
94
|
-
|
|
100
|
+
const after = {
|
|
101
|
+
durable: fs.existsSync(CACHE_FILE) ? fs.statSync(CACHE_FILE).mtimeMs : null,
|
|
102
|
+
legacy: fs.existsSync(LEGACY_CACHE_FILE) ? fs.statSync(LEGACY_CACHE_FILE).mtimeMs : null,
|
|
103
|
+
};
|
|
104
|
+
assert.deepEqual(before, after, "the container-id fast path must not read or write either cache file");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("cache paths are durable (AppData/Roaming/clauth or ~/.config/clauth), never os.tmpdir()", () => {
|
|
108
|
+
// The whole point of this fix: CACHE_FILE must NOT live in system temp,
|
|
109
|
+
// which Windows Disk Cleanup (or equivalent) can wipe, re-exposing the
|
|
110
|
+
// flaky first-computation WMI path on the next restart.
|
|
111
|
+
assert.ok(!CACHE_FILE.startsWith(os.tmpdir()), `CACHE_FILE must be durable, got: ${CACHE_FILE}`);
|
|
112
|
+
assert.equal(path.basename(CACHE_FILE), "machine.cache");
|
|
113
|
+
const dir = path.dirname(CACHE_FILE);
|
|
114
|
+
assert.ok(dir.endsWith(path.join("clauth")), `cache dir must be a clauth-owned config directory, got: ${dir}`);
|
|
115
|
+
// LEGACY_CACHE_FILE is the pre-fix location, kept only so readCache() can
|
|
116
|
+
// migrate an already-registered machine's existing cache instead of
|
|
117
|
+
// recomputing via WMI (see fingerprint.js).
|
|
118
|
+
assert.equal(LEGACY_CACHE_FILE, path.join(os.tmpdir(), "clauth-machine.cache"));
|
|
95
119
|
});
|
|
@@ -163,20 +163,56 @@ function appendSupervisorEvent(event) {
|
|
|
163
163
|
appendJsonl(file("events.jsonl"), { ts: now(), ...event });
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// The command set a surface's OWN manifest actually declares — derived from
|
|
167
|
+
// which of the real action fields carry a non-empty command array. "reconcile"
|
|
168
|
+
// and "test" are structural (reconcile is start-or-restart chosen by clauth;
|
|
169
|
+
// test is the candidate-testing path), not something a manifest declares, so
|
|
170
|
+
// they are excluded here on purpose.
|
|
171
|
+
const DECLARABLE_ACTIONS = ["start", "stop", "restart", "promote", "rollback"];
|
|
172
|
+
function declaredCommands(surface) {
|
|
173
|
+
return DECLARABLE_ACTIONS.filter((action) => Array.isArray(surface?.[action]) && surface[action].length > 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sameCommandSet(a, b) {
|
|
177
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
178
|
+
if (a.length !== b.length) return false;
|
|
179
|
+
const setA = new Set(a);
|
|
180
|
+
return b.every((item) => setA.has(item));
|
|
181
|
+
}
|
|
182
|
+
|
|
166
183
|
async function probeSurfaceHealth(url, fetchImpl, timeoutMs) {
|
|
167
184
|
try {
|
|
168
185
|
const controller = new AbortController();
|
|
169
186
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
170
187
|
try {
|
|
171
188
|
const response = await fetchImpl(url, { signal: controller.signal });
|
|
172
|
-
return response?.
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
if (!response?.ok) return { healthy: false, error: `HTTP ${response?.status ?? "unknown"}`, liveCommands: null };
|
|
190
|
+
// Command-set contract (2026-09-06): a surface MAY include a
|
|
191
|
+
// `commands` array in its health response — the set of actions ITS
|
|
192
|
+
// OWN code actually supports, read live rather than trusted from the
|
|
193
|
+
// manifest's static declaration ("from code not text so we know there
|
|
194
|
+
// is no drift" — Dave, 2026-09-06). Piggybacked on the health probe
|
|
195
|
+
// clauth already runs every reconcile tick, so retrofitting a surface
|
|
196
|
+
// costs it nothing extra: no new endpoint, no new round trip. A
|
|
197
|
+
// surface that doesn't (yet) include this field is unaffected —
|
|
198
|
+
// liveCommands stays null and callers fall back to the manifest's
|
|
199
|
+
// declared set, exactly as before this contract existed.
|
|
200
|
+
let liveCommands = null;
|
|
201
|
+
try {
|
|
202
|
+
// No .clone() needed — this is the only read of this response, real
|
|
203
|
+
// or mocked. Test fixtures across this suite mock fetchImpl with
|
|
204
|
+
// plain { ok, status } objects with no .json() at all; the try/catch
|
|
205
|
+
// already treats that identically to "not JSON" — both normal, not
|
|
206
|
+
// an error — so existing tests are unaffected without special-casing.
|
|
207
|
+
const body = typeof response.json === "function" ? await response.json() : null;
|
|
208
|
+
if (Array.isArray(body?.commands)) liveCommands = body.commands.filter((c) => typeof c === "string");
|
|
209
|
+
} catch { /* not JSON, or no commands field — both normal, not an error */ }
|
|
210
|
+
return { healthy: true, error: null, liveCommands };
|
|
175
211
|
} finally {
|
|
176
212
|
clearTimeout(timer);
|
|
177
213
|
}
|
|
178
214
|
} catch (err) {
|
|
179
|
-
return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err) };
|
|
215
|
+
return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err), liveCommands: null };
|
|
180
216
|
}
|
|
181
217
|
}
|
|
182
218
|
|
|
@@ -198,8 +234,21 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
|
|
|
198
234
|
const error = health.error;
|
|
199
235
|
|
|
200
236
|
if (healthy) {
|
|
201
|
-
|
|
202
|
-
|
|
237
|
+
const declared = declaredCommands(surface);
|
|
238
|
+
const commandDrift = Array.isArray(health.liveCommands) && !sameCommandSet(health.liveCommands, declared);
|
|
239
|
+
updateSurfaceState(id, {
|
|
240
|
+
state: "current",
|
|
241
|
+
last_health_at: observedAt,
|
|
242
|
+
last_health_ok: true,
|
|
243
|
+
last_health_error: null,
|
|
244
|
+
consecutive_reconcile_failures: 0,
|
|
245
|
+
last_health_commands: health.liveCommands,
|
|
246
|
+
command_drift: commandDrift,
|
|
247
|
+
});
|
|
248
|
+
if (commandDrift) {
|
|
249
|
+
appendSupervisorEvent({ kind: "surface_command_drift", surface_id: id, declared, live: health.liveCommands });
|
|
250
|
+
}
|
|
251
|
+
inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt, command_drift: commandDrift });
|
|
203
252
|
continue;
|
|
204
253
|
}
|
|
205
254
|
|
|
@@ -365,6 +414,15 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
|
|
|
365
414
|
url ? probeSurfaceHealth(url, fetchImpl, timeoutMs) : null,
|
|
366
415
|
openUrl ? probeSurfaceHealth(openUrl, fetchImpl, timeoutMs) : null,
|
|
367
416
|
]);
|
|
417
|
+
// Command-set drift (2026-09-06): computed here too, not just in
|
|
418
|
+
// reconcileSurfaceHealth, because THIS sweep is the only one that ever
|
|
419
|
+
// probes external/remote surfaces — reconcile only looks at enabled
|
|
420
|
+
// clauth-owned local pm2 surfaces. null when the surface hasn't declared
|
|
421
|
+
// liveCommands (not retrofitted yet, or down) — that is a normal,
|
|
422
|
+
// unretrofitted surface, not a drift finding.
|
|
423
|
+
const declared = declaredCommands(surface);
|
|
424
|
+
const liveCommands = health?.healthy ? health.liveCommands : null;
|
|
425
|
+
const commandDrift = Array.isArray(liveCommands) ? !sameCommandSet(liveCommands, declared) : null;
|
|
368
426
|
return {
|
|
369
427
|
surface_id,
|
|
370
428
|
health: !url ? "no_probe" : (health.healthy ? "healthy" : "down"),
|
|
@@ -372,6 +430,8 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
|
|
|
372
430
|
error: url && !health.healthy ? (health.error || null) : null,
|
|
373
431
|
open_url: openUrl || null,
|
|
374
432
|
open_ok: open ? open.healthy : null,
|
|
433
|
+
live_commands: liveCommands,
|
|
434
|
+
command_drift: commandDrift,
|
|
375
435
|
observed_at: now(),
|
|
376
436
|
};
|
|
377
437
|
}));
|
|
@@ -396,6 +456,8 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
|
|
|
396
456
|
last_health_at: entry.observed_at,
|
|
397
457
|
last_health_ok: entry.health === "healthy",
|
|
398
458
|
last_health_error: entry.health === "healthy" ? null : entry.error,
|
|
459
|
+
last_health_commands: entry.live_commands,
|
|
460
|
+
command_drift: entry.command_drift,
|
|
399
461
|
};
|
|
400
462
|
});
|
|
401
463
|
saveSupervisorState(state);
|
|
@@ -834,6 +896,7 @@ const PRODUCT_REPO_MANIFESTS = [
|
|
|
834
896
|
{ repo: "regen-root", manifest: "mcp-servers/regen-media/clauth-plugin.json" },
|
|
835
897
|
{ repo: "regen-root", manifest: "mcp-servers/web-research/clauth-plugin.json" },
|
|
836
898
|
{ repo: "rdc-skills", manifest: "clauth-plugin.json" },
|
|
899
|
+
{ repo: "rdc-harness", manifest: "fsm-daemon/clauth-plugin.json" },
|
|
837
900
|
];
|
|
838
901
|
|
|
839
902
|
// Sweep outcomes that mean "nothing was there to sync", as distinct from
|
|
@@ -853,6 +916,12 @@ export const SYNC_REPO_NAMES = Object.freeze([...new Set(PRODUCT_REPO_MANIFESTS.
|
|
|
853
916
|
function defaultRepoRoot(repo) {
|
|
854
917
|
if (repo === "regen-root") return process.env.REGEN_ROOT || process.env.LIFEAI_REPO_ROOT || "C:/Dev/regen-root";
|
|
855
918
|
if (repo === "rdc-skills") return process.env.RDC_SKILLS_ROOT || "C:/Dev/rdc-skills";
|
|
919
|
+
// fsm-daemon's clauth-plugin.json lives inside rdc-harness, not published
|
|
920
|
+
// separately -- it was previously reachable only via a one-off manual
|
|
921
|
+
// `clauth plugin register` pinned to whichever machine ran it, invisible to
|
|
922
|
+
// this sweep and to any box that registers fsm-daemon fresh. Same env-var
|
|
923
|
+
// + hardcoded-default shape as the two entries above, not a new pattern.
|
|
924
|
+
if (repo === "rdc-harness") return process.env.RDC_HARNESS_ROOT || "C:/Dev/rdc-harness";
|
|
856
925
|
return null;
|
|
857
926
|
}
|
|
858
927
|
|
|
@@ -1193,6 +1262,17 @@ export function setPluginEnabled(id, enabled, actor = "localhost") {
|
|
|
1193
1262
|
const prior = (state.plugins || []).find((plugin) => plugin.id === id);
|
|
1194
1263
|
if (!prior) return { error: "plugin_not_found" };
|
|
1195
1264
|
if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
|
|
1265
|
+
// A core:true plugin auto-enables at discovery (discoverPlugins()) and,
|
|
1266
|
+
// per the same rule, is never allowed BACK into awaiting_enable — "core
|
|
1267
|
+
// surfaces just run" is a guarantee, not a default a dashboard toggle can
|
|
1268
|
+
// quietly undo. Approved: direct operator instruction, 2026-09-06 —
|
|
1269
|
+
// "core: true surfaces only" get the no-gate treatment, applied uniformly
|
|
1270
|
+
// to every destination (local AND remote, same rule). Disabling a core
|
|
1271
|
+
// plugin is rejected outright rather than silently ignored, so a caller
|
|
1272
|
+
// finds out immediately rather than wondering why the toggle didn't stick.
|
|
1273
|
+
if (!enabled && prior.core === true) {
|
|
1274
|
+
return { error: "core_plugin_cannot_be_disabled", plugin_id: id };
|
|
1275
|
+
}
|
|
1196
1276
|
const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
|
|
1197
1277
|
state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
|
|
1198
1278
|
state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
|
|
@@ -314,6 +314,29 @@ test("trusted managed core plugins auto-enable while user plugins remain opt-in"
|
|
|
314
314
|
assert.equal(userPlugin.state, "awaiting_enable");
|
|
315
315
|
}));
|
|
316
316
|
|
|
317
|
+
test("setPluginEnabled refuses to disable a core plugin — 'core surfaces just run' is a guarantee, not a default", () => withTempSupervisor((root) => {
|
|
318
|
+
const managed = path.join(root, "managed");
|
|
319
|
+
const user = path.join(root, "user");
|
|
320
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
321
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = user;
|
|
322
|
+
writePlugin(root, "managed", "core-mcp", baseManifest("core-mcp", { core: true, enable_default: true }));
|
|
323
|
+
writePlugin(root, "managed", "noncore-mcp", baseManifest("noncore-mcp", { core: false }));
|
|
324
|
+
discoverPlugins();
|
|
325
|
+
|
|
326
|
+
const rejected = setPluginEnabled("core-mcp", false);
|
|
327
|
+
assert.equal(rejected.error, "core_plugin_cannot_be_disabled");
|
|
328
|
+
// Positive control: the plugin's actual state is UNCHANGED by the
|
|
329
|
+
// rejected call, not silently flipped anyway.
|
|
330
|
+
assert.equal(listPlugins().find((p) => p.id === "core-mcp").enabled, true);
|
|
331
|
+
assert.equal(listPlugins().find((p) => p.id === "core-mcp").state, "current");
|
|
332
|
+
|
|
333
|
+
// Regression guard: a NON-core plugin can still be disabled exactly as
|
|
334
|
+
// before -- this fix narrows the gate, it does not remove it.
|
|
335
|
+
const disabled = setPluginEnabled("noncore-mcp", false);
|
|
336
|
+
assert.equal(disabled.resulting_state.enabled, false);
|
|
337
|
+
assert.equal(listPlugins().find((p) => p.id === "noncore-mcp").state, "awaiting_enable");
|
|
338
|
+
}));
|
|
339
|
+
|
|
317
340
|
test("plugin test marks a private candidate and never creates a public route", () => withTempSupervisor((root) => {
|
|
318
341
|
const managed = path.join(root, "managed");
|
|
319
342
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
@@ -452,6 +475,65 @@ test("health reconciliation marks a failed clauth surface and repairs it through
|
|
|
452
475
|
}
|
|
453
476
|
});
|
|
454
477
|
|
|
478
|
+
test("a healthy surface's live-reported commands are recorded, and drift against the manifest is detected", async () => {
|
|
479
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-commands-"));
|
|
480
|
+
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
481
|
+
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
482
|
+
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
483
|
+
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
484
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
485
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
486
|
+
try {
|
|
487
|
+
// Manifest declares start+stop only (no restart command).
|
|
488
|
+
writePlugin(root, "managed", "commands-demo", baseManifest("commands-demo", {
|
|
489
|
+
core: true,
|
|
490
|
+
enable_default: true,
|
|
491
|
+
surfaces: [{
|
|
492
|
+
id: "primary",
|
|
493
|
+
destination: "local/clauth/pm2",
|
|
494
|
+
lifecycle_owner: "clauth",
|
|
495
|
+
port: 39125,
|
|
496
|
+
health: "/health",
|
|
497
|
+
start: [process.execPath, "--version"],
|
|
498
|
+
stop: [process.execPath, "--version"],
|
|
499
|
+
}],
|
|
500
|
+
}));
|
|
501
|
+
discoverPlugins();
|
|
502
|
+
|
|
503
|
+
// Surface's OWN code reports a DIFFERENT set live (it also implements
|
|
504
|
+
// restart, which the manifest never declared) -- this is the drift case.
|
|
505
|
+
const drifted = await reconcileSurfaceHealth({
|
|
506
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ status: "ok", commands: ["start", "stop", "restart"] }) }),
|
|
507
|
+
});
|
|
508
|
+
assert.equal(drifted.inspected[0].command_drift, true);
|
|
509
|
+
const afterDrift = listSurfaces().find((s) => s.plugin_id === "commands-demo");
|
|
510
|
+
assert.deepEqual(afterDrift.last_health_commands, ["start", "stop", "restart"]);
|
|
511
|
+
assert.equal(afterDrift.command_drift, true);
|
|
512
|
+
|
|
513
|
+
// Surface's code now matches the manifest exactly -- no drift.
|
|
514
|
+
const matched = await reconcileSurfaceHealth({
|
|
515
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ status: "ok", commands: ["stop", "start"] }) }),
|
|
516
|
+
});
|
|
517
|
+
assert.equal(matched.inspected[0].command_drift, false);
|
|
518
|
+
assert.equal(listSurfaces().find((s) => s.plugin_id === "commands-demo").command_drift, false);
|
|
519
|
+
|
|
520
|
+
// A surface that hasn't been retrofitted with the commands field at all
|
|
521
|
+
// must NOT be flagged as drifted -- no live evidence is never treated as
|
|
522
|
+
// disagreeing evidence.
|
|
523
|
+
const unretrofitted = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: true }) });
|
|
524
|
+
assert.equal(unretrofitted.inspected[0].command_drift, false, "no liveCommands reported must never read as drift");
|
|
525
|
+
assert.equal(listSurfaces().find((s) => s.plugin_id === "commands-demo").last_health_commands, null);
|
|
526
|
+
} finally {
|
|
527
|
+
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
528
|
+
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
529
|
+
if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
530
|
+
else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
|
|
531
|
+
if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
532
|
+
else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
|
|
533
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
455
537
|
test("health reconciliation does not claim current when a successful command leaves health down", async () => {
|
|
456
538
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-post-health-"));
|
|
457
539
|
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
@@ -894,6 +976,7 @@ function withTempProductRepos(fn) {
|
|
|
894
976
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sync-repos-"));
|
|
895
977
|
const regenRoot = path.join(base, "regen-root");
|
|
896
978
|
const rdcSkills = path.join(base, "rdc-skills");
|
|
979
|
+
const rdcHarness = path.join(base, "rdc-harness");
|
|
897
980
|
const writeManifest = (repoRoot, relPath, manifest) => {
|
|
898
981
|
const full = path.join(repoRoot, relPath);
|
|
899
982
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
@@ -905,8 +988,9 @@ function withTempProductRepos(fn) {
|
|
|
905
988
|
writeManifest(regenRoot, "mcp-servers/regen-media/clauth-plugin.json", baseManifest("regen-media"));
|
|
906
989
|
writeManifest(regenRoot, "mcp-servers/web-research/clauth-plugin.json", baseManifest("web-research"));
|
|
907
990
|
writeManifest(rdcSkills, "clauth-plugin.json", baseManifest("rdc-skills"));
|
|
991
|
+
writeManifest(rdcHarness, "fsm-daemon/clauth-plugin.json", baseManifest("fsm-daemon"));
|
|
908
992
|
try {
|
|
909
|
-
return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot, "rdc-skills": rdcSkills } });
|
|
993
|
+
return fn({ base, regenRoot, rdcSkills, rdcHarness, writeManifest, roots: { "regen-root": regenRoot, "rdc-skills": rdcSkills, "rdc-harness": rdcHarness } });
|
|
910
994
|
} finally {
|
|
911
995
|
fs.rmSync(base, { recursive: true, force: true });
|
|
912
996
|
}
|
|
@@ -914,7 +998,7 @@ function withTempProductRepos(fn) {
|
|
|
914
998
|
|
|
915
999
|
test("plugin sync inherits registerPlugin idempotence — a second sweep reports every manifest unchanged", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
|
|
916
1000
|
const first = syncPluginsFromRepos(roots, "test");
|
|
917
|
-
assert.equal(first.length,
|
|
1001
|
+
assert.equal(first.length, 6, "one receipt per attempted manifest");
|
|
918
1002
|
assert.deepEqual(
|
|
919
1003
|
first.filter((entry) => !entry.ok).map((entry) => `${entry.repo}:${entry.state}`),
|
|
920
1004
|
[],
|
|
@@ -925,7 +1009,7 @@ test("plugin sync inherits registerPlugin idempotence — a second sweep reports
|
|
|
925
1009
|
// registerPlugin sha256-compares before writing; sync must not defeat that by
|
|
926
1010
|
// re-writing or re-hashing on its own.
|
|
927
1011
|
const second = syncPluginsFromRepos(roots, "test");
|
|
928
|
-
assert.equal(second.length,
|
|
1012
|
+
assert.equal(second.length, 6);
|
|
929
1013
|
assert.equal(second.every((entry) => entry.ok && entry.state === "unchanged"), true, "re-sweeping identical content must be a no-op");
|
|
930
1014
|
})));
|
|
931
1015
|
|
|
@@ -937,7 +1021,7 @@ test("plugin sync warns and continues over a missing repo root instead of throwi
|
|
|
937
1021
|
assert.doesNotThrow(() => {
|
|
938
1022
|
receipts = syncPluginsFromRepos({ "regen-root": absent, "rdc-skills": rdcSkills }, "test");
|
|
939
1023
|
});
|
|
940
|
-
assert.equal(receipts.length,
|
|
1024
|
+
assert.equal(receipts.length, 6, "a skipped repo still yields a receipt per attempted manifest");
|
|
941
1025
|
const missing = receipts.filter((entry) => entry.state === "repo_root_missing");
|
|
942
1026
|
assert.equal(missing.length, 4, "all four regen-root manifests report the missing root");
|
|
943
1027
|
assert.equal(missing.every((entry) => entry.ok === false), true);
|
|
@@ -951,15 +1035,15 @@ test("plugin sync registers the remaining manifests when one is malformed", () =
|
|
|
951
1035
|
writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", "{ this is not valid json");
|
|
952
1036
|
|
|
953
1037
|
const receipts = syncPluginsFromRepos(roots, "test");
|
|
954
|
-
assert.equal(receipts.length,
|
|
1038
|
+
assert.equal(receipts.length, 6);
|
|
955
1039
|
const bad = receipts.find((entry) => entry.path.includes("dev-center"));
|
|
956
1040
|
assert.equal(bad.ok, false);
|
|
957
1041
|
assert.equal(bad.state, "manifest_invalid");
|
|
958
1042
|
const good = receipts.filter((entry) => entry !== bad);
|
|
959
|
-
assert.equal(good.length,
|
|
1043
|
+
assert.equal(good.length, 5);
|
|
960
1044
|
assert.equal(good.every((entry) => entry.ok && entry.state === "registered"), true, "one bad manifest must not abort the sweep");
|
|
961
1045
|
const ids = new Set(listPlugins().map((plugin) => plugin.id));
|
|
962
|
-
for (const id of ["codeflow-mcp", "regen-media", "web-research", "rdc-skills"]) {
|
|
1046
|
+
for (const id of ["codeflow-mcp", "regen-media", "web-research", "rdc-skills", "fsm-daemon"]) {
|
|
963
1047
|
assert.ok(ids.has(id), `${id} must still be registered`);
|
|
964
1048
|
}
|
|
965
1049
|
})));
|
|
@@ -1201,7 +1285,7 @@ test("plugin sync reports an unknown repo-root override instead of silently swee
|
|
|
1201
1285
|
assert.equal(rejected[0].ok, false);
|
|
1202
1286
|
assert.match(rejected[0].error, /unknown repo name/);
|
|
1203
1287
|
assert.equal(SYNC_SKIP_STATES.includes("unknown_repo_name"), false, "a typo'd repo name must fail the sweep, not be skipped");
|
|
1204
|
-
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["rdc-skills", "regen-root"]);
|
|
1288
|
+
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["rdc-harness", "rdc-skills", "regen-root"]);
|
|
1205
1289
|
})));
|
|
1206
1290
|
|
|
1207
1291
|
test("plugin sync never throws on a malformed repo root — the contract absence must not break", () => withTempSupervisor(() => withTempProductRepos(({ rdcSkills }) => {
|
|
@@ -1212,7 +1296,7 @@ test("plugin sync never throws on a malformed repo root — the contract absence
|
|
|
1212
1296
|
assert.doesNotThrow(() => {
|
|
1213
1297
|
receipts = syncPluginsFromRepos({ "regen-root": badRoot, "rdc-skills": rdcSkills }, "test");
|
|
1214
1298
|
}, `root ${JSON.stringify(badRoot)} must not throw`);
|
|
1215
|
-
assert.equal(receipts.length,
|
|
1299
|
+
assert.equal(receipts.length, 6, "every attempted manifest still yields a receipt");
|
|
1216
1300
|
assert.equal(
|
|
1217
1301
|
receipts.filter((entry) => entry.repo === "regen-root" && entry.state === "repo_root_unknown").length,
|
|
1218
1302
|
4,
|
package/package.json
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
// clauth — auth-vault Edge Function
|
|
2
|
-
//
|
|
1
|
+
// clauth — auth-vault Edge Function v3
|
|
2
|
+
// IP whitelist + machine lockout (fail_count + locked) remain. Rate limiting
|
|
3
|
+
// and audit logging (clauth_audit) removed 2026-09-03 -- see the comments at
|
|
4
|
+
// validateHMAC and where auditLog used to be defined for why: the rate-limit
|
|
5
|
+
// check was an unindexed COUNT against clauth_audit run on EVERY request,
|
|
6
|
+
// pre-auth, and every rejection it produced also wrote an audit row -- a
|
|
7
|
+
// feedback loop that took the whole project down under sustained load. The
|
|
8
|
+
// security value it provided was already covered, tighter, by the
|
|
9
|
+
// per-machine 5-failed-attempts lockout below.
|
|
3
10
|
|
|
4
11
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
5
12
|
|
|
@@ -11,8 +18,6 @@ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
|
|
|
11
18
|
const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
|
|
12
19
|
.split(",").map(s => s.trim()).filter(Boolean);
|
|
13
20
|
|
|
14
|
-
const RATE_LIMIT_MAX = 30;
|
|
15
|
-
const RATE_LIMIT_WINDOW = 60;
|
|
16
21
|
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
17
22
|
const MAX_FAIL_COUNT = 5;
|
|
18
23
|
const DEFAULT_INSTALL_ID = "default";
|
|
@@ -55,20 +60,36 @@ function checkIP(ip: string): { allowed: boolean; reason?: string } {
|
|
|
55
60
|
return { allowed: false, reason: `IP not whitelisted: ${ip}` };
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
// rate-limiting removed 2026-09-03 -- see supervisor-registry.js's own
|
|
64
|
+
// state.json lock history for the shape of this bug: checkRateLimit() ran
|
|
65
|
+
// this exact query -- a COUNT against clauth_audit filtered by machine_hash
|
|
66
|
+
// and created_at -- on EVERY request, BEFORE authentication (validateHMAC
|
|
67
|
+
// below), against a table with no index on either column and no retention.
|
|
68
|
+
// As the table grew (174,385 rows, unbounded), the scan cost grew with it;
|
|
69
|
+
// under sustained traffic the queries started stacking, Postgres killed them
|
|
70
|
+
// at statement_timeout, and Cloudflare killed the stacked connections at its
|
|
71
|
+
// own 90s ceiling (522s) -- and every one of those rejections ALSO wrote an
|
|
72
|
+
// audit row (see auditLog's removal below), so the failure fed itself. The
|
|
73
|
+
// actual security protection this duplicated is already provided, tighter,
|
|
74
|
+
// by the per-machine lockout in validateHMAC below (5 failed attempts locks
|
|
75
|
+
// the machine -- well under the 30-per-60s this used to allow). Rate
|
|
76
|
+
// limiting for a genuine runaway client belongs at Cloudflare, in front of
|
|
77
|
+
// this function, not as a synchronous Postgres query on the hot path of
|
|
78
|
+
// every request.
|
|
70
79
|
async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
|
|
71
80
|
const now = Date.now();
|
|
81
|
+
|
|
82
|
+
// A caller that omits machine_hash entirely used to fall straight through
|
|
83
|
+
// to the clauth_machines lookup below with body.machine_hash === undefined.
|
|
84
|
+
// PostgREST's .single() then answers "0 rows" for a query that can never
|
|
85
|
+
// match, which comes back as an HTTP 406 -- 1,438 of these in 24h
|
|
86
|
+
// (2026-09-06), every one a wasted round trip for a request already known
|
|
87
|
+
// to be malformed. Reject before the DB call: same auth_failed response
|
|
88
|
+
// the caller already gets, no query, no spurious 406 in the logs.
|
|
89
|
+
if (typeof body.machine_hash !== "string" || !body.machine_hash) {
|
|
90
|
+
return { valid: false, reason: "machine_hash_missing" };
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
|
|
73
94
|
|
|
74
95
|
const { data: machine, error } = await sb.from("clauth_machines")
|
|
@@ -99,20 +120,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
|
|
|
99
120
|
return { valid: true };
|
|
100
121
|
}
|
|
101
122
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
// auditLog() removed 2026-09-03 alongside checkRateLimit() -- it wrote a row
|
|
124
|
+
// to clauth_audit on every path through this function, including rejections
|
|
125
|
+
// (rate-limited, auth-denied, IP-blocked), which is what turned "the table
|
|
126
|
+
// got slow" into a feedback loop (more rejections -> more rows -> slower
|
|
127
|
+
// scans -> more rejections). No retention policy ever existed for this
|
|
128
|
+
// table. If durable audit logging is wanted back, it needs its own design --
|
|
129
|
+
// write-only, indexed for its actual read pattern (if any), with a real
|
|
130
|
+
// retention/partition policy -- not a bare insert-on-every-call with no cap.
|
|
106
131
|
async function handleRetrieve(sb: any, body: any, mh: string) {
|
|
107
132
|
const { service } = body;
|
|
108
133
|
if (!service) return { error: "service required" };
|
|
109
134
|
const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
|
|
110
|
-
if (!svc)
|
|
111
|
-
if (!svc.enabled)
|
|
112
|
-
if (!svc.vault_key)
|
|
135
|
+
if (!svc) return { error: "service_not_found" };
|
|
136
|
+
if (!svc.enabled) return { error: "service_disabled" };
|
|
137
|
+
if (!svc.vault_key) return { error: "no_key_stored" };
|
|
113
138
|
const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
|
|
114
139
|
await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
|
|
115
|
-
await auditLog(sb, mh, service, "retrieve", "success");
|
|
116
140
|
return { service, key_type: svc.key_type, value: secret };
|
|
117
141
|
}
|
|
118
142
|
|
|
@@ -121,9 +145,8 @@ async function handleWrite(sb: any, body: any, mh: string) {
|
|
|
121
145
|
if (!service || !value) return { error: "service and value required" };
|
|
122
146
|
const vaultKey = `clauth.${service}`;
|
|
123
147
|
const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
|
|
124
|
-
if (error)
|
|
148
|
+
if (error) return { error: error.message };
|
|
125
149
|
await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
|
|
126
|
-
await auditLog(sb, mh, service, "write", "success");
|
|
127
150
|
return { success: true, service, vault_key: vaultKey };
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -133,7 +156,6 @@ async function handleEnable(sb: any, body: any, mh: string) {
|
|
|
133
156
|
q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
|
|
134
157
|
const { error } = await q;
|
|
135
158
|
if (error) return { error: error.message };
|
|
136
|
-
await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
|
|
137
159
|
return { success: true, service, enabled };
|
|
138
160
|
}
|
|
139
161
|
|
|
@@ -144,7 +166,6 @@ async function handleAdd(sb: any, body: any, mh: string) {
|
|
|
144
166
|
if (project) row.project = project;
|
|
145
167
|
const { error } = await sb.from("clauth_services").insert(row);
|
|
146
168
|
if (error) return { error: error.message };
|
|
147
|
-
await auditLog(sb, mh, name, "add", "success");
|
|
148
169
|
return { success: true, name, label, key_type, project: project || null };
|
|
149
170
|
}
|
|
150
171
|
|
|
@@ -157,7 +178,6 @@ async function handleUpdate(sb: any, body: any, mh: string) {
|
|
|
157
178
|
if (description !== undefined) updates.description = description || null;
|
|
158
179
|
const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
|
|
159
180
|
if (error) return { error: error.message };
|
|
160
|
-
await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
|
|
161
181
|
return { success: true, service, ...updates };
|
|
162
182
|
}
|
|
163
183
|
|
|
@@ -166,7 +186,6 @@ async function handleRemove(sb: any, body: any, mh: string) {
|
|
|
166
186
|
if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
|
|
167
187
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
168
188
|
await sb.from("clauth_services").delete().eq("name", service);
|
|
169
|
-
await auditLog(sb, mh, service, "remove", "success");
|
|
170
189
|
return { success: true, service };
|
|
171
190
|
}
|
|
172
191
|
|
|
@@ -182,7 +201,6 @@ async function handleRevoke(sb: any, body: any, mh: string) {
|
|
|
182
201
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
183
202
|
await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
|
|
184
203
|
}
|
|
185
|
-
await auditLog(sb, mh, service, "revoke", "success");
|
|
186
204
|
return { success: true, service };
|
|
187
205
|
}
|
|
188
206
|
|
|
@@ -193,7 +211,6 @@ async function handleStatus(sb: any, body: any, mh: string) {
|
|
|
193
211
|
.order("name");
|
|
194
212
|
if (body.project) q = q.eq("project", body.project);
|
|
195
213
|
const { data: services } = await q;
|
|
196
|
-
await auditLog(sb, mh, "all", "status", "success");
|
|
197
214
|
return { services: services || [] };
|
|
198
215
|
}
|
|
199
216
|
|
|
@@ -204,7 +221,6 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
|
204
221
|
.update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
|
|
205
222
|
.eq("machine_hash", mh);
|
|
206
223
|
if (error) return { error: error.message };
|
|
207
|
-
await auditLog(sb, mh, "system", "change-password", "success");
|
|
208
224
|
return { success: true };
|
|
209
225
|
}
|
|
210
226
|
|
|
@@ -231,12 +247,8 @@ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
|
|
|
231
247
|
created_by_machine_hash: mh,
|
|
232
248
|
expires_at,
|
|
233
249
|
});
|
|
234
|
-
if (error) {
|
|
235
|
-
await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
|
|
236
|
-
return { error: error.message };
|
|
237
|
-
}
|
|
250
|
+
if (error) return { error: error.message };
|
|
238
251
|
|
|
239
|
-
await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
|
|
240
252
|
return { success: true, enrollment_code: code, install_id, expires_at, label };
|
|
241
253
|
}
|
|
242
254
|
|
|
@@ -274,7 +286,6 @@ async function handleRedeemEnrollment(sb: any, body: any) {
|
|
|
274
286
|
if (consumeError) return { error: consumeError.message };
|
|
275
287
|
if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
|
|
276
288
|
|
|
277
|
-
await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
|
|
278
289
|
return { success: true, machine_hash, install_id };
|
|
279
290
|
}
|
|
280
291
|
|
|
@@ -314,21 +325,11 @@ Deno.serve(async (req: Request) => {
|
|
|
314
325
|
|
|
315
326
|
const ipCheck = checkIP(ip);
|
|
316
327
|
if (!ipCheck.allowed) {
|
|
317
|
-
await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
|
|
318
328
|
return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
|
|
319
329
|
}
|
|
320
330
|
|
|
321
|
-
if (body.machine_hash) {
|
|
322
|
-
const rateCheck = await checkRateLimit(sb, body.machine_hash);
|
|
323
|
-
if (!rateCheck.allowed) {
|
|
324
|
-
await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
|
|
325
|
-
return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
331
|
const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
|
|
330
332
|
if (!authResult.valid) {
|
|
331
|
-
await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
|
|
332
333
|
return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
|
|
333
334
|
}
|
|
334
335
|
|