Skip to content

Commit 87bbc50

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
Rotation on every surface: desktop rotator, Rotation page + run queue, MCP tools (CLI 0.15.0) (#269)
- method "desktop": dashboard-only keys (OpenAI, ElevenLabs, Brave Search, Civitai, Cloudflare Global API Key roll, Anthropic/Claude OAuth report-only) rotated by an agent on a W3BS Agentic Desktop Layer host (--desktop / LOGICSRC_DESKTOP_HOST). Agent browser only, old key by its last 4 chars, DOM-to-file capture (wl-paste fallback), two-phase revoke after health, rollback deletes the new key, remote file shredded. needs-login and busy are waits, not failures. Roll needs --allow-roll plus a capture self-test. - app.logicsrc.com: /teams/:slug/rotation plan page + rotation run queue (migration 010), API routes; runner: rotate-keys --from-queue. - MCP: rotation_plan, rotation_set, rotate_keys (dry run default), rotation_runs. - docs/rotation.md: desktop rotators and every surface. Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aec3e5f commit 87bbc50

23 files changed

Lines changed: 2191 additions & 35 deletions

‎apps/pwa/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
"version": "0.1.1",
44
"private": true,
55
"type": "module",
6-
"description": "LogicSRC credentials — Express app on Postgres (@profullstack/libsql-pg): auth + end-to-end-encrypted team credential sharing.",
6+
"description": "LogicSRC credentials \u2014 Express app on Postgres (@profullstack/libsql-pg): auth + end-to-end-encrypted team credential sharing.",
77
"scripts": {
88
"start": "bun src/server.mjs",
99
"dev": "bun --watch src/server.mjs",
1010
"migrate": "bun src/migrate.mjs",
1111
"test": "node --test"
1212
},
1313
"dependencies": {
14+
"@logicsrc/cli": "workspace:*",
1415
"@profullstack/footer": "0.5.0",
1516
"@profullstack/libsql-pg": "^0.1.4",
1617
"@profullstack/profile": "0.1.6",

‎apps/pwa/src/lib/rotation.mjs‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Key rotation: the plan per key and the run queue, for the Rotation page, the
2+
// API and the MCP tools.
3+
//
4+
// The server never sees a value (E2E rule). The plan is computed from what it
5+
// does see: key names, value fingerprints and update times, matched against the
6+
// same built-in catalog the CLI uses (@logicsrc/cli's rotation-catalog). A key's
7+
// own `__ROTATE__<KEY>` entry is encrypted, so the page can only say that one
8+
// exists; the CLI, which can decrypt it, is the authority on what it says.
9+
import { all } from "../db.mjs";
10+
import { planRotations, isoTime } from "@logicsrc/cli/dist/rotation-catalog.js";
11+
import { DESKTOP_RECIPES } from "@logicsrc/cli/dist/desktop-recipes.js";
12+
13+
const ROTATE_PREFIX = "__ROTATE__";
14+
15+
/** Vault names split the way the CLI does: <project>--<env>. */
16+
export function splitVaultName(name) {
17+
const i = String(name).lastIndexOf("--");
18+
return i > 0 ? { project: name.slice(0, i), env: name.slice(i + 2) } : { project: name, env: "" };
19+
}
20+
21+
/**
22+
* The rotation plan for a team: one row per distinct (key, value), like
23+
* `logicsrc teams rotation`. `vaultFilter(name)` limits it to vaults the caller
24+
* may see (a machine key's scope).
25+
*/
26+
export async function rotationPlan(teamId, { project, env, keys, vaultFilter = () => true } = {}) {
27+
const rows = (await all(
28+
`SELECT v.name AS vault, s.name AS key, s.fingerprint, s.updated_at
29+
FROM credshare_secrets s JOIN credshare_vaults v ON v.id = s.vault_id
30+
WHERE v.team_id = ?`,
31+
[teamId]
32+
)).filter((r) => vaultFilter(r.vault));
33+
const entries = new Set(rows.filter((r) => r.key.startsWith(ROTATE_PREFIX)).map((r) => `${r.vault}\u0000${r.key.slice(ROTATE_PREFIX.length)}`));
34+
// Every vault:key per fingerprint across the team (a value pasted under other names counts).
35+
const byFp = new Map();
36+
for (const r of rows) {
37+
if (r.key.startsWith(ROTATE_PREFIX)) continue;
38+
byFp.set(r.fingerprint, [...(byFp.get(r.fingerprint) ?? []), `${r.vault}:${r.key}`]);
39+
}
40+
const inFilter = (vault) => {
41+
const p = splitVaultName(vault);
42+
return (!project || p.project === project) && (!env || p.env === env);
43+
};
44+
const wanted = keys?.length ? new Set(keys.map((k) => String(k).toUpperCase())) : null;
45+
const plan = planRotations(
46+
rows.filter((r) => inFilter(r.vault)).map((r) => ({ vault: r.vault, key: r.key, fingerprint: r.fingerprint, updatedAt: Number(r.updated_at) })),
47+
new Map()
48+
).filter((p) => !wanted || wanted.has(p.key.toUpperCase()));
49+
return plan.map((p) => {
50+
const spec = p.spec ?? {};
51+
const recipe = spec.method === "desktop" ? DESKTOP_RECIPES[spec.recipe] : null;
52+
const everywhere = byFp.get(p.fingerprint) ?? [];
53+
return {
54+
key: p.key,
55+
method: p.method,
56+
...(spec.provider ? { provider: spec.provider } : {}),
57+
...(recipe ? { recipe: recipe.id, desktopMode: recipe.mode } : {}),
58+
automatic: p.automatic,
59+
owner: spec.owner ?? null,
60+
family: spec.family ?? null,
61+
url: spec.url ?? null,
62+
steps: spec.steps ?? [],
63+
consumers: spec.consumers ?? [],
64+
disruptive: spec.disruptive ?? null,
65+
never: Boolean(spec.never),
66+
note: spec.note ?? null,
67+
vaults: p.vaults,
68+
vaultCount: everywhere.length || p.vaults.length,
69+
alsoIn: everywhere.filter((vk) => !p.vaults.some((v) => vk === `${v}:${p.key}`)),
70+
lastRotated: isoTime(p.oldestUpdate),
71+
// The key's own encrypted instructions override the catalog; only the CLI can read them.
72+
hasEntry: p.vaults.some((v) => entries.has(`${v}\u0000${p.key}`))
73+
};
74+
});
75+
}
76+
77+
// ---- runs ------------------------------------------------------------------------------
78+
79+
const KEY_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
80+
const HOST = /^[A-Za-z0-9._@-]{1,100}$/;
81+
const RESULT_STATUSES = new Set(["rotated", "would-rotate", "todo", "failed", "rolled-back", "skipped", "covered", "needs-login", "busy"]);
82+
83+
export class RotationRunError extends Error {}
84+
85+
/** Validate a queue request. Throws RotationRunError with a message for the caller. */
86+
export function parseRunRequest(body = {}) {
87+
const keys = [...new Set((Array.isArray(body.keys) ? body.keys : String(body.keys ?? "").split(/[\s,]+/)).map((k) => String(k).trim()).filter(Boolean))];
88+
if (!keys.length) throw new RotationRunError("Pick at least one key to rotate.");
89+
if (keys.length > 200) throw new RotationRunError("At most 200 keys per run.");
90+
const bad = keys.find((k) => !KEY_NAME.test(k));
91+
if (bad) throw new RotationRunError(`Not a key name: ${bad.slice(0, 60)}`);
92+
const desktop = body.desktop ? String(body.desktop).trim() : "";
93+
if (desktop && !HOST.test(desktop)) throw new RotationRunError("desktop must be an ssh host alias");
94+
const name = (v) => (v ? String(v).trim() : "") || null;
95+
const project = name(body.project);
96+
const env = name(body.env);
97+
for (const v of [project, env]) if (v && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(v)) throw new RotationRunError(`Bad project/env name: ${v.slice(0, 60)}`);
98+
const truthy = (v) => v === true || v === "1" || v === "on" || v === "true";
99+
return {
100+
keys,
101+
project,
102+
env,
103+
approve: truthy(body.approve),
104+
options: {
105+
...(desktop ? { desktop } : {}),
106+
...(truthy(body.propagate) ? { propagate: true } : {}),
107+
...(truthy(body.allowRoll) ? { allowRoll: true } : {})
108+
}
109+
};
110+
}
111+
112+
/**
113+
* Keep only the fields a runner may report, so a buggy or hostile runner cannot
114+
* park arbitrary text (or a value) in the database: statuses, names, short notes.
115+
*/
116+
export function sanitizeResults(results) {
117+
if (!Array.isArray(results)) return [];
118+
const str = (v, n = 300) => (typeof v === "string" ? v.slice(0, n) : undefined);
119+
const names = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string").slice(0, 100).map((x) => x.slice(0, 200)) : undefined);
120+
return results.slice(0, 500).map((r) => ({
121+
key: str(r?.key, 128) ?? "?",
122+
method: str(r?.method, 64),
123+
status: RESULT_STATUSES.has(r?.status) ? r.status : "failed",
124+
vaults: names(r?.vaults),
125+
written: names(r?.written),
126+
sites: Array.isArray(r?.sites) ? r.sites.slice(0, 50).map((s) => ({ site: str(s?.site, 120), ok: typeof s?.ok === "boolean" ? s.ok : undefined, health: str(s?.health, 120) })) : undefined,
127+
note: str(r?.note),
128+
error: str(r?.error)
129+
}));
130+
}
131+
132+
export function runRow(r) {
133+
const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
134+
return {
135+
id: r.id,
136+
keys: parse(r.keys, []),
137+
project: r.project ?? null,
138+
env: r.env ?? null,
139+
approve: Boolean(Number(r.approve)),
140+
options: parse(r.options, {}),
141+
status: r.status,
142+
requestedBy: r.requested_by_email ?? r.requested_by,
143+
claimedBy: r.claimed_by ?? null,
144+
claimedAt: r.claimed_at ? Number(r.claimed_at) : null,
145+
finishedAt: r.finished_at ? Number(r.finished_at) : null,
146+
createdAt: Number(r.created_at),
147+
summary: r.summary ?? null,
148+
results: parse(r.results, null)
149+
};
150+
}
151+
152+
/** "3 rotated, 1 todo" from a result list. */
153+
export function summarize(results) {
154+
const counts = {};
155+
for (const r of results) counts[r.status] = (counts[r.status] ?? 0) + 1;
156+
return Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(", ") || "nothing";
157+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- Postgres copy of migrations/010_rotation_runs.sql (INTEGER -> bigint). Additive only.
2+
-- Key rotation runs queued from the Rotation page (or the API / MCP) and
3+
-- executed by a CLI or agent with `logicsrc teams rotate-keys --from-queue`.
4+
--
5+
-- The server never sees a secret value (E2E rule): a run records WHICH keys to
6+
-- rotate and how (dry run or approved, desktop host, propagate), and later the
7+
-- runner's per-key outcome (statuses, vault and site names, errors), never a
8+
-- value. Additive only.
9+
CREATE TABLE IF NOT EXISTS credshare_rotation_runs (
10+
id TEXT PRIMARY KEY,
11+
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
12+
requested_by TEXT NOT NULL REFERENCES users(id),
13+
keys TEXT NOT NULL, -- JSON array of key names
14+
project TEXT,
15+
env TEXT,
16+
approve bigint NOT NULL DEFAULT 0, -- 0 = dry run
17+
options TEXT, -- JSON: { desktop?, propagate?, allowRoll? }
18+
status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | failed | cancelled
19+
claimed_by TEXT, -- api key id or user id of the runner
20+
claimed_at bigint,
21+
finished_at bigint,
22+
summary TEXT,
23+
results TEXT, -- JSON array of per-key results (never values)
24+
created_at bigint NOT NULL
25+
);
26+
CREATE INDEX IF NOT EXISTS idx_rotation_runs_team ON credshare_rotation_runs(team_id, status, created_at);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Key rotation runs queued from the Rotation page (or the API / MCP) and
2+
-- executed by a CLI or agent with `logicsrc teams rotate-keys --from-queue`.
3+
--
4+
-- The server never sees a secret value (E2E rule): a run records WHICH keys to
5+
-- rotate and how (dry run or approved, desktop host, propagate), and later the
6+
-- runner's per-key outcome (statuses, vault and site names, errors), never a
7+
-- value. Additive only.
8+
CREATE TABLE IF NOT EXISTS credshare_rotation_runs (
9+
id TEXT PRIMARY KEY,
10+
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
11+
requested_by TEXT NOT NULL REFERENCES users(id),
12+
keys TEXT NOT NULL, -- JSON array of key names
13+
project TEXT,
14+
env TEXT,
15+
approve INTEGER NOT NULL DEFAULT 0, -- 0 = dry run
16+
options TEXT, -- JSON: { desktop?, propagate?, allowRoll? }
17+
status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | failed | cancelled
18+
claimed_by TEXT, -- api key id or user id of the runner
19+
claimed_at INTEGER,
20+
finished_at INTEGER,
21+
summary TEXT,
22+
results TEXT, -- JSON array of per-key results (never values)
23+
created_at INTEGER NOT NULL
24+
);
25+
CREATE INDEX IF NOT EXISTS idx_rotation_runs_team ON credshare_rotation_runs(team_id, status, created_at);

‎apps/pwa/src/routes/credshare.mjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async function actor(req) {
5050
* "write" allowed unless the key is read-only
5151
* "register" allowed even for a read-only key (its own public key)
5252
*/
53-
function api(handler, { machine = false } = {}) {
53+
export function api(handler, { machine = false } = {}) {
5454
return async (req, res) => {
5555
const found = await actor(req);
5656
if (!found) return res.status(401).json({ error: "Not authenticated, or the API key is revoked or expired. Run: logicsrc login" });
@@ -73,7 +73,7 @@ function api(handler, { machine = false } = {}) {
7373
};
7474
}
7575

76-
async function requireMember(res, slug, userId, key = null) {
76+
export async function requireMember(res, slug, userId, key = null) {
7777
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [slug]);
7878
if (!team) { res.status(404).json({ error: `Unknown team: ${slug}` }); return null; }
7979
if (isMachine(key) && key.team_id !== team.id) { res.status(403).json({ error: "This API key is scoped to another team." }); return null; }
@@ -96,7 +96,7 @@ function auditStatement(ev) {
9696
};
9797
}
9898

99-
async function audit(ev) {
99+
export async function audit(ev) {
100100
const st = auditStatement(ev);
101101
await run(st.sql, st.args);
102102
}

‎apps/pwa/src/routes/pages.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async function teamCard(team, uid) {
6969
<input type="email" name="email" placeholder="teammate@example.com" required style="flex:1">
7070
${me.role === "owner" ? `<select name="role" aria-label="Permission" style="width:auto"><option value="member">member</option><option value="admin">admin</option><option value="owner">owner</option></select>` : `<input type="hidden" name="role" value="member">`}
7171
<button class="btn">Invite</button></form>` : ""}
72-
<div class="label" style="margin:18px 0 6px">Vaults</div>
72+
<div class="label" style="margin:18px 0 6px">Vaults <a class="faint" style="margin-left:10px;font-weight:400" href="/teams/${esc(team.slug)}/rotation">Key rotation &rarr;</a></div>
7373
${vaults.length ? `<table><thead><tr><th>Vault</th><th>Secrets</th><th>Your access</th></tr></thead><tbody>${vaultRows.join("")}</tbody></table>`
7474
: `<p class="faint mono" style="font-size:.82rem">No vaults yet — create one from the CLI: <code>logicsrc teams push ${esc(team.slug)} &lt;project&gt; &lt;env&gt;</code></p>`}
7575
<div class="label" style="margin:18px 0 6px">Recent access <span class="faint">(full trail: <code>logicsrc teams audit ${esc(team.slug)}</code>)</span></div>

0 commit comments

Comments
 (0)