@lifeaitools/clauth 1.3.1 → 1.4.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.
@@ -53,9 +53,347 @@ ALTER TABLE clauth_machines ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEF
53
53
  );
54
54
  ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
55
55
  },
56
+ {
57
+ version: 4,
58
+ name: "004_key_rotation",
59
+ description: "Key rotation tracking — expiry dates, rotation policy, rotation log",
60
+ type: "safe",
61
+ sql: `ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS expires_at timestamptz;
62
+ ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS rotation_policy text NOT NULL DEFAULT 'none';
63
+ ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS rotation_days integer NOT NULL DEFAULT 30;
64
+ ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS last_rotated_at timestamptz;
65
+ CREATE TABLE IF NOT EXISTS clauth_rotation_log (
66
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
67
+ service text NOT NULL,
68
+ action text NOT NULL,
69
+ status text NOT NULL,
70
+ detail text,
71
+ created_at timestamptz DEFAULT now()
72
+ );
73
+ ALTER TABLE clauth_rotation_log ENABLE ROW LEVEL SECURITY;`,
74
+ },
56
75
  ];
57
76
 
58
- const CURRENT_SCHEMA_VERSION = 3;
77
+ const CURRENT_SCHEMA_VERSION = 4;
78
+
79
+ // ── Key Rotation Config ─────────────────────────────────────────
80
+ // Per-service rotation capabilities. "auto" services can be rotated programmatically.
81
+ // "manual" services require dashboard/UI action. "none" means static keys.
82
+ const ROTATION_CONFIG = {
83
+ "cloudflare": {
84
+ policy: "auto",
85
+ defaultDays: 30,
86
+ description: "Cloudflare API Token",
87
+ async rotate(currentKey) {
88
+ // Step 1: List tokens to find current token's ID and permissions
89
+ const listResp = await fetch("https://api.cloudflare.com/client/v4/user/tokens", {
90
+ headers: { "Authorization": `Bearer ${currentKey}`, "Content-Type": "application/json" },
91
+ });
92
+ const listData = await listResp.json();
93
+ if (!listData.success) throw new Error(`CF list tokens: ${listData.errors?.[0]?.message || "failed"}`);
94
+
95
+ const currentToken = listData.result?.find(t => t.status === "active");
96
+ if (!currentToken) throw new Error("No active CF token found");
97
+
98
+ // Step 2: Create a new token with same policies
99
+ const createResp = await fetch("https://api.cloudflare.com/client/v4/user/tokens", {
100
+ method: "POST",
101
+ headers: { "Authorization": `Bearer ${currentKey}`, "Content-Type": "application/json" },
102
+ body: JSON.stringify({
103
+ name: `${currentToken.name} (rotated ${new Date().toISOString().slice(0, 10)})`,
104
+ policies: currentToken.policies,
105
+ expires_on: new Date(Date.now() + 30 * 86400000).toISOString(),
106
+ }),
107
+ });
108
+ const createData = await createResp.json();
109
+ if (!createData.success) throw new Error(`CF create token: ${createData.errors?.[0]?.message || "failed"}`);
110
+
111
+ const newKey = createData.result?.value;
112
+ if (!newKey) throw new Error("CF token created but no value returned");
113
+
114
+ // Step 3: Verify new token works
115
+ const verifyResp = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
116
+ headers: { "Authorization": `Bearer ${newKey}` },
117
+ });
118
+ const verifyData = await verifyResp.json();
119
+ if (!verifyData.success) throw new Error("New CF token failed verification");
120
+
121
+ // Step 4: Delete old token
122
+ await fetch(`https://api.cloudflare.com/client/v4/user/tokens/${currentToken.id}`, {
123
+ method: "DELETE",
124
+ headers: { "Authorization": `Bearer ${newKey}` },
125
+ });
126
+
127
+ return { newKey, expiresAt: createData.result.expires_on };
128
+ },
129
+ },
130
+ "cloudfare-dns": {
131
+ policy: "auto",
132
+ defaultDays: 30,
133
+ description: "Cloudflare DNS Token",
134
+ // Same rotation logic as cloudflare — they're both CF API tokens
135
+ async rotate(currentKey) {
136
+ return ROTATION_CONFIG["cloudflare"].rotate(currentKey);
137
+ },
138
+ },
139
+ "npm": {
140
+ policy: "auto",
141
+ defaultDays: 30,
142
+ description: "npm Granular Token",
143
+ async rotate(currentKey) {
144
+ // Step 1: Create new token
145
+ const createResp = await fetch("https://registry.npmjs.org/-/npm/v1/tokens", {
146
+ method: "POST",
147
+ headers: {
148
+ "Authorization": `Bearer ${currentKey}`,
149
+ "Content-Type": "application/json",
150
+ },
151
+ body: JSON.stringify({
152
+ password: "", // granular tokens don't need password for automation tokens
153
+ cidr_whitelist: [],
154
+ }),
155
+ });
156
+ if (!createResp.ok) throw new Error(`npm create token: HTTP ${createResp.status}`);
157
+ const createData = await createResp.json();
158
+ const newKey = createData.token;
159
+ if (!newKey) throw new Error("npm token created but no value returned");
160
+
161
+ // Step 2: Delete old token (get its key hash first)
162
+ const listResp = await fetch("https://registry.npmjs.org/-/npm/v1/tokens", {
163
+ headers: { "Authorization": `Bearer ${newKey}` },
164
+ });
165
+ if (listResp.ok) {
166
+ const listData = await listResp.json();
167
+ // The old token shows as a different key from the new one
168
+ const oldToken = listData.objects?.find(t => t.key !== createData.key);
169
+ if (oldToken) {
170
+ await fetch(`https://registry.npmjs.org/-/npm/v1/tokens/token/${oldToken.key}`, {
171
+ method: "DELETE",
172
+ headers: { "Authorization": `Bearer ${newKey}` },
173
+ });
174
+ }
175
+ }
176
+
177
+ return { newKey, expiresAt: null }; // npm tokens don't have built-in expiry
178
+ },
179
+ },
180
+ "vultr-api": {
181
+ policy: "auto",
182
+ defaultDays: 90,
183
+ description: "Vultr API Key",
184
+ async rotate(currentKey) {
185
+ const expiresAt = new Date(Date.now() + 90 * 86400000).toISOString();
186
+ const createResp = await fetch("https://api.vultr.com/v2/apikeys", {
187
+ method: "POST",
188
+ headers: {
189
+ "Authorization": `Bearer ${currentKey}`,
190
+ "Content-Type": "application/json",
191
+ },
192
+ body: JSON.stringify({
193
+ name: `clauth-rotated-${new Date().toISOString().slice(0, 10)}`,
194
+ expire: true,
195
+ date_expire: expiresAt,
196
+ }),
197
+ });
198
+ if (!createResp.ok) throw new Error(`Vultr create key: HTTP ${createResp.status}`);
199
+ const createData = await createResp.json();
200
+ const newKey = createData.api_key?.api_key;
201
+ if (!newKey) throw new Error("Vultr key created but no value returned");
202
+
203
+ // Delete old key
204
+ const listResp = await fetch("https://api.vultr.com/v2/apikeys", {
205
+ headers: { "Authorization": `Bearer ${newKey}` },
206
+ });
207
+ if (listResp.ok) {
208
+ const listData = await listResp.json();
209
+ const oldKey = listData.api_keys?.find(k => k.id !== createData.api_key?.id);
210
+ if (oldKey) {
211
+ await fetch(`https://api.vultr.com/v2/apikeys/${oldKey.id}`, {
212
+ method: "DELETE",
213
+ headers: { "Authorization": `Bearer ${newKey}` },
214
+ });
215
+ }
216
+ }
217
+
218
+ return { newKey, expiresAt };
219
+ },
220
+ },
221
+ // Manual-only services — track expiry but can't auto-rotate
222
+ "github": { policy: "manual", defaultDays: 90, description: "GitHub PAT (rotate in Settings > Developer settings)" },
223
+ "supabase-anon": { policy: "manual", defaultDays: 365, description: "Supabase anon key (rotate via Management API or dashboard)" },
224
+ "supabase-service": { policy: "manual", defaultDays: 365, description: "Supabase service key (rotate via Management API or dashboard)" },
225
+ "supabase-db": { policy: "none", defaultDays: 0, description: "Supabase DB connection string" },
226
+ "coolify-api": { policy: "manual", defaultDays: 365, description: "Coolify API token (rotate in dashboard)" },
227
+ "rocketreach": { policy: "manual", defaultDays: 365, description: "RocketReach API key" },
228
+ "namecheap": { policy: "manual", defaultDays: 0, description: "Namecheap API (no expiry, dashboard-only)" },
229
+ "neo4j": { policy: "none", defaultDays: 0, description: "Neo4j connection string" },
230
+ "r2": { policy: "manual", defaultDays: 365, description: "Cloudflare R2 access key" },
231
+ "r2-bucket": { policy: "none", defaultDays: 0, description: "R2 bucket endpoint" },
232
+ "google-client-id": { policy: "none", defaultDays: 0, description: "Google OAuth client ID (static)" },
233
+ "google-client-secret": { policy: "none", defaultDays: 0, description: "Google OAuth client secret (static)" },
234
+ "google-redirect-uri": { policy: "none", defaultDays: 0, description: "Google OAuth redirect URI (static)" },
235
+ "google-refresh-token": { policy: "none", defaultDays: 0, description: "Google OAuth refresh token (auto-refreshes)" },
236
+ };
237
+
238
+ // ── Rotation Engine ─────────────────────────────────────────────
239
+ // Runs inside createServer() as a background interval.
240
+ // Checks all services for upcoming expiry, auto-rotates if policy=auto.
241
+
242
+ function createRotationEngine(password, machineHash, logFile) {
243
+ const CHECK_INTERVAL_MS = 6 * 3600 * 1000; // check every 6 hours
244
+ const WARN_DAYS = 7; // warn when within 7 days of expiry
245
+ let rotationState = new Map(); // service → { expires_at, policy, rotation_days, last_rotated_at, status }
246
+ let rotationLog = []; // in-memory log of recent rotations (also persisted to DB)
247
+ let intervalHandle = null;
248
+
249
+ function getExpiryStatus(expiresAt) {
250
+ if (!expiresAt) return { status: "unknown", daysLeft: null, color: "gray" };
251
+ const days = Math.ceil((new Date(expiresAt) - Date.now()) / 86400000);
252
+ if (days < 0) return { status: "expired", daysLeft: days, color: "red" };
253
+ if (days <= WARN_DAYS) return { status: "expiring", daysLeft: days, color: "orange" };
254
+ if (days <= 30) return { status: "ok", daysLeft: days, color: "yellow" };
255
+ return { status: "ok", daysLeft: days, color: "green" };
256
+ }
257
+
258
+ async function loadState() {
259
+ // Load expiry metadata from clauth_config
260
+ try {
261
+ const { token, timestamp } = deriveToken(password, machineHash);
262
+ const result = await api.status(password, machineHash, token, timestamp);
263
+ if (!result.services) return;
264
+
265
+ for (const svc of result.services) {
266
+ const config = ROTATION_CONFIG[svc.name] || { policy: "none", defaultDays: 0 };
267
+ rotationState.set(svc.name, {
268
+ expires_at: svc.expires_at || null,
269
+ policy: svc.rotation_policy || config.policy,
270
+ rotation_days: svc.rotation_days || config.defaultDays,
271
+ last_rotated_at: svc.last_rotated_at || null,
272
+ status: getExpiryStatus(svc.expires_at).status,
273
+ });
274
+ }
275
+ } catch (err) {
276
+ const msg = `[${new Date().toISOString()}] Rotation engine: failed to load state: ${err.message}\n`;
277
+ try { fs.appendFileSync(logFile, msg); } catch {}
278
+ }
279
+ }
280
+
281
+ async function rotateService(serviceName) {
282
+ const config = ROTATION_CONFIG[serviceName];
283
+ if (!config || !config.rotate) {
284
+ return { ok: false, error: "No auto-rotation available for this service" };
285
+ }
286
+
287
+ const logEntry = { service: serviceName, action: "rotate", status: "started", detail: null, created_at: new Date().toISOString() };
288
+
289
+ try {
290
+ // Get current key
291
+ const { token, timestamp } = deriveToken(password, machineHash);
292
+ const retrieved = await api.retrieve(password, machineHash, token, timestamp, serviceName);
293
+ if (retrieved.error) throw new Error(retrieved.error);
294
+ const currentKey = retrieved.value;
295
+
296
+ // Execute rotation
297
+ const result = await config.rotate(currentKey);
298
+ if (!result.newKey) throw new Error("Rotation returned no new key");
299
+
300
+ // Write new key to vault
301
+ const { token: wt, timestamp: wts } = deriveToken(password, machineHash);
302
+ const writeResult = await api.write(password, machineHash, wt, wts, serviceName, result.newKey);
303
+ if (writeResult.error) throw new Error(`Write failed: ${writeResult.error}`);
304
+
305
+ // Update expiry in state
306
+ const newExpiry = result.expiresAt || new Date(Date.now() + (config.defaultDays || 30) * 86400000).toISOString();
307
+ rotationState.set(serviceName, {
308
+ ...rotationState.get(serviceName),
309
+ expires_at: newExpiry,
310
+ last_rotated_at: new Date().toISOString(),
311
+ status: "ok",
312
+ });
313
+
314
+ logEntry.status = "success";
315
+ logEntry.detail = `Rotated. New expiry: ${newExpiry}`;
316
+ rotationLog.unshift(logEntry);
317
+ if (rotationLog.length > 100) rotationLog.length = 100;
318
+
319
+ const msg = `[${new Date().toISOString()}] Rotation: ${serviceName} rotated successfully. Expires: ${newExpiry}\n`;
320
+ try { fs.appendFileSync(logFile, msg); } catch {}
321
+
322
+ return { ok: true, expiresAt: newExpiry };
323
+ } catch (err) {
324
+ logEntry.status = "failed";
325
+ logEntry.detail = err.message;
326
+ rotationLog.unshift(logEntry);
327
+ if (rotationLog.length > 100) rotationLog.length = 100;
328
+
329
+ const msg = `[${new Date().toISOString()}] Rotation: ${serviceName} FAILED: ${err.message}\n`;
330
+ try { fs.appendFileSync(logFile, msg); } catch {}
331
+
332
+ return { ok: false, error: err.message };
333
+ }
334
+ }
335
+
336
+ async function checkAndRotate() {
337
+ if (!password) return; // vault locked — skip
338
+ await loadState();
339
+
340
+ for (const [name, state] of rotationState) {
341
+ const config = ROTATION_CONFIG[name];
342
+ if (!config || config.policy !== "auto" || !config.rotate) continue;
343
+ if (!state.expires_at) continue;
344
+
345
+ const { daysLeft } = getExpiryStatus(state.expires_at);
346
+ if (daysLeft !== null && daysLeft <= WARN_DAYS) {
347
+ const msg = `[${new Date().toISOString()}] Rotation: ${name} expires in ${daysLeft} days — auto-rotating\n`;
348
+ try { fs.appendFileSync(logFile, msg); } catch {}
349
+ await rotateService(name);
350
+ }
351
+ }
352
+ }
353
+
354
+ function start() {
355
+ // Initial check after 30s (let daemon fully start)
356
+ setTimeout(() => {
357
+ checkAndRotate().catch(() => {});
358
+ }, 30000);
359
+ // Then every 6 hours
360
+ intervalHandle = setInterval(() => {
361
+ checkAndRotate().catch(() => {});
362
+ }, CHECK_INTERVAL_MS);
363
+ }
364
+
365
+ function stop() {
366
+ if (intervalHandle) clearInterval(intervalHandle);
367
+ }
368
+
369
+ function getState() {
370
+ const result = {};
371
+ for (const [name, state] of rotationState) {
372
+ const expiry = getExpiryStatus(state.expires_at);
373
+ result[name] = { ...state, ...expiry };
374
+ }
375
+ return result;
376
+ }
377
+
378
+ function getLog() {
379
+ return rotationLog;
380
+ }
381
+
382
+ // Set expiry for a service manually (from dashboard or API)
383
+ function setExpiry(serviceName, expiresAt, rotationDays) {
384
+ const existing = rotationState.get(serviceName) || {};
385
+ const config = ROTATION_CONFIG[serviceName] || { policy: "none", defaultDays: 0 };
386
+ rotationState.set(serviceName, {
387
+ ...existing,
388
+ expires_at: expiresAt,
389
+ rotation_days: rotationDays || config.defaultDays,
390
+ policy: config.policy,
391
+ status: getExpiryStatus(expiresAt).status,
392
+ });
393
+ }
394
+
395
+ return { start, stop, getState, getLog, rotateService, setExpiry, loadState, checkAndRotate };
396
+ }
59
397
 
60
398
  const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
61
399
  const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
@@ -801,6 +1139,7 @@ function renderServiceGrid(services) {
801
1139
  <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
802
1140
  <div class="card-type">\${s.key_type || "secret"}</div>
803
1141
  <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
1142
+ <span class="expiry-badge" id="expiry-\${s.name}" style="font-size:.65rem;border-radius:3px;padding:1px 6px;display:none"></span>
804
1143
  \${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${s.project}</span>\` : ""}
805
1144
  </div>
806
1145
  \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
@@ -815,6 +1154,7 @@ function renderServiceGrid(services) {
815
1154
  <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
816
1155
  <button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
817
1156
  <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
1157
+ <button class="btn-rotate" id="rotbtn-\${s.name}" style="display:none;background:#0e7490;border:1px solid #06b6d4;color:#cffafe;font-size:.75rem;padding:3px 8px;border-radius:4px;cursor:pointer" onclick="rotateKey('\${s.name}')">↻ Rotate</button>
818
1158
  </div>
819
1159
  <div class="project-edit" id="pe-\${s.name}">
820
1160
  <input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
@@ -843,6 +1183,8 @@ async function loadServices() {
843
1183
 
844
1184
  renderProjectTabs(allServices);
845
1185
  renderServiceGrid(allServices);
1186
+ // Load expiry data after grid renders
1187
+ loadExpiry();
846
1188
  } catch (e) {
847
1189
  err.textContent = "⚠ " + e.message;
848
1190
  err.style.display = "block";
@@ -851,6 +1193,92 @@ async function loadServices() {
851
1193
  }
852
1194
  }
853
1195
 
1196
+ // ── Expiry & Rotation ───────────────────────
1197
+ const ROTATION_CAPABLE = ["cloudflare","cloudfare-dns","npm","vultr-api"];
1198
+
1199
+ async function loadExpiry() {
1200
+ try {
1201
+ const data = await fetch(BASE + "/expiry").then(r => r.json());
1202
+ if (!data.services) return;
1203
+ for (const [name, info] of Object.entries(data.services)) {
1204
+ const el = document.getElementById("expiry-" + name);
1205
+ const rotBtn = document.getElementById("rotbtn-" + name);
1206
+ if (!el) continue;
1207
+
1208
+ // Show rotate button for auto-capable services
1209
+ if (rotBtn && ROTATION_CAPABLE.includes(name)) {
1210
+ rotBtn.style.display = "inline-block";
1211
+ }
1212
+
1213
+ if (!info.expires_at) {
1214
+ // Show policy type for services with rotation config
1215
+ if (info.policy === "auto") {
1216
+ el.style.display = "inline";
1217
+ el.style.color = "#94a3b8";
1218
+ el.style.background = "rgba(148,163,184,0.1)";
1219
+ el.style.border = "1px solid rgba(148,163,184,0.2)";
1220
+ el.textContent = "no expiry set";
1221
+ }
1222
+ continue;
1223
+ }
1224
+
1225
+ el.style.display = "inline";
1226
+ const colors = { green: { c: "#4ade80", bg: "rgba(74,222,128,0.1)", b: "rgba(74,222,128,0.2)" },
1227
+ yellow: { c: "#facc15", bg: "rgba(250,204,21,0.1)", b: "rgba(250,204,21,0.2)" },
1228
+ orange: { c: "#fb923c", bg: "rgba(251,146,60,0.1)", b: "rgba(251,146,60,0.2)" },
1229
+ red: { c: "#f87171", bg: "rgba(248,113,113,0.15)", b: "rgba(248,113,113,0.3)" },
1230
+ gray: { c: "#94a3b8", bg: "rgba(148,163,184,0.1)", b: "rgba(148,163,184,0.2)" } };
1231
+ const clr = colors[info.color] || colors.gray;
1232
+ el.style.color = clr.c;
1233
+ el.style.background = clr.bg;
1234
+ el.style.border = "1px solid " + clr.b;
1235
+
1236
+ if (info.daysLeft === null) {
1237
+ el.textContent = "unknown";
1238
+ } else if (info.daysLeft < 0) {
1239
+ el.textContent = "EXPIRED " + Math.abs(info.daysLeft) + "d ago";
1240
+ } else if (info.daysLeft === 0) {
1241
+ el.textContent = "EXPIRES TODAY";
1242
+ } else {
1243
+ el.textContent = info.daysLeft + "d left";
1244
+ }
1245
+ }
1246
+ } catch {}
1247
+ }
1248
+
1249
+ async function rotateKey(name) {
1250
+ if (!confirm("Rotate " + name + " key?\\n\\nA new key will be created and the old one deleted.")) return;
1251
+ const rotBtn = document.getElementById("rotbtn-" + name);
1252
+ if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
1253
+ try {
1254
+ const r = await fetch(BASE + "/rotate/" + name, { method: "POST" }).then(r => r.json());
1255
+ if (r.ok) {
1256
+ if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
1257
+ loadExpiry(); // refresh badges
1258
+ } else {
1259
+ alert("Rotation failed: " + (r.error || "unknown error"));
1260
+ if (rotBtn) { rotBtn.disabled = false; rotBtn.textContent = "↻ Rotate"; }
1261
+ }
1262
+ } catch (err) {
1263
+ alert("Rotation error: " + err.message);
1264
+ if (rotBtn) { rotBtn.disabled = false; rotBtn.textContent = "↻ Rotate"; }
1265
+ }
1266
+ }
1267
+
1268
+ async function setExpiry(name) {
1269
+ const days = prompt("Set expiry for " + name + " (days from now):", "30");
1270
+ if (!days) return;
1271
+ const expiresAt = new Date(Date.now() + parseInt(days) * 86400000).toISOString();
1272
+ try {
1273
+ await fetch(BASE + "/set-expiry/" + name, {
1274
+ method: "POST",
1275
+ headers: { "Content-Type": "application/json" },
1276
+ body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
1277
+ });
1278
+ loadExpiry();
1279
+ } catch {}
1280
+ }
1281
+
854
1282
  // ── Reveal ──────────────────────────────────
855
1283
  async function reveal(name, btn) {
856
1284
  const valEl = document.getElementById("val-" + name);
@@ -1854,6 +2282,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
1854
2282
  let password = initPassword || null; // null = locked; set via POST /auth
1855
2283
  const machineHash = getMachineHash();
1856
2284
 
2285
+ // Rotation engine — starts after unlock
2286
+ const rotationEngine = createRotationEngine(initPassword, machineHash, LOG_FILE);
2287
+
1857
2288
  const CORS = {
1858
2289
  "Access-Control-Allow-Origin": "*",
1859
2290
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -2040,9 +2471,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2040
2471
  }
2041
2472
  }
2042
2473
 
2043
- // Auto-start tunnel if vault is already unlocked (--pw flag)
2474
+ // Auto-start tunnel and rotation engine if vault is already unlocked (--pw flag)
2044
2475
  if (password) {
2045
2476
  startTunnel().catch(() => {});
2477
+ rotationEngine.start();
2046
2478
  }
2047
2479
 
2048
2480
  function strike(res, code, message) {
@@ -2859,6 +3291,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2859
3291
  password = pw; // unlock — store in process memory only
2860
3292
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
2861
3293
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
3294
+ // Start rotation engine on unlock
3295
+ rotationEngine.start();
2862
3296
  // Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
2863
3297
  // Only on Windows; only if autostart dir exists (i.e., install was run)
2864
3298
  if (os.platform() === "win32") {
@@ -2997,6 +3431,46 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2997
3431
  }
2998
3432
  }
2999
3433
 
3434
+ // ── Key Rotation Endpoints ──────────────────────────────────
3435
+
3436
+ // GET /expiry — expiry status for all services
3437
+ if (method === "GET" && reqPath === "/expiry") {
3438
+ if (lockedGuard(res)) return;
3439
+ const state = rotationEngine.getState();
3440
+ return ok(res, { services: state });
3441
+ }
3442
+
3443
+ // POST /rotate/:service — manually trigger rotation for a service
3444
+ if (method === "POST" && reqPath.startsWith("/rotate/")) {
3445
+ if (lockedGuard(res)) return;
3446
+ const service = reqPath.slice("/rotate/".length);
3447
+ if (!service) {
3448
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3449
+ return res.end(JSON.stringify({ error: "Service name required" }));
3450
+ }
3451
+ const result = await rotationEngine.rotateService(service);
3452
+ return ok(res, result);
3453
+ }
3454
+
3455
+ // POST /set-expiry/:service — set expiry date for a service
3456
+ if (method === "POST" && reqPath.startsWith("/set-expiry/")) {
3457
+ if (lockedGuard(res)) return;
3458
+ const service = reqPath.slice("/set-expiry/".length);
3459
+ let body;
3460
+ try { body = await readBody(req); } catch {
3461
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3462
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
3463
+ }
3464
+ rotationEngine.setExpiry(service, body.expires_at, body.rotation_days);
3465
+ return ok(res, { ok: true, service, expires_at: body.expires_at });
3466
+ }
3467
+
3468
+ // GET /rotation-log — recent rotation history
3469
+ if (method === "GET" && reqPath === "/rotation-log") {
3470
+ if (lockedGuard(res)) return;
3471
+ return ok(res, { log: rotationEngine.getLog() });
3472
+ }
3473
+
3000
3474
  // GET /tunnel/test — end-to-end tunnel health check (hits /ping through the tunnel)
3001
3475
  if (method === "GET" && reqPath === "/tunnel/test") {
3002
3476
  if (lockedGuard(res)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {