Skip to content

Commit ae4615e

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
feat(teams): clean archives stale keys to <project>--<env>-archive (CLI 0.11.0) (#262)
logicsrc teams clean <team> <project> <env> [--key K] [--dead] [--approve] moves keys out of a live vault into its archive vault. Nothing is deleted: a key leaves the live vault only after its archived copy reads back with the same fingerprint. --dead finds URL values whose host no longer resolves and Supabase JWTs whose project ref is such a host. A different value already archived under a name is kept; the new copy gets a __<UTC stamp> suffix. Each archived key has an __ARCHIVE__<NAME> entry (from, why, when, who). Dry run unless --approve. Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ae62988 commit ae4615e

4 files changed

Lines changed: 308 additions & 1 deletion

File tree

‎packages/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@logicsrc/cli",
3-
"version": "0.10.0",
3+
"version": "0.11.0",
44
"description": "LogicSRC CLI: every LogicSRC standard and tool as one command.",
55
"type": "module",
66
"main": "./dist/index.js",

‎packages/cli/src/index.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { sshAgentAction, sshListAction, sshPullAction, sshPushAction } from "./s
3838
import { credentialsRotateAction } from "./rotate.js";
3939
import { teamsRotateKeysAction, teamsRotationAction, teamsRotationSetAction } from "./rotate-keys.js";
4040
import { teamsAuditAction, teamsKeyImportAction, teamsRekeyAction, teamsRotateIdentityAction } from "./hardening.js";
41+
import { teamsCleanAction } from "./vault-clean.js";
4142
import { boards, tasks } from "./fixtures.js";
4243
import { print, type OutputFormat } from "./format.js";
4344
import { parsePositiveInteger } from "./numeric-options.js";
@@ -749,6 +750,7 @@ Quick start (<team> is e.g. profullstack; see yours with "logicsrc teams list"):
749750
logicsrc teams rekey <team> --all new vault keys, values re-encrypted
750751
logicsrc teams rotation <team> how each secret is rotated (agents: --format json)
751752
logicsrc teams rotate-keys <team> [project] [env] rotate values (dry run; --approve to write)
753+
logicsrc teams clean <team> <project> <env> --dead archive dead keys to <project>--<env>-archive (dry run; --approve)
752754
753755
Deploy boxes and CI (a machine API key: one team, scoped vaults, read-only, its own identity):
754756
logicsrc keys create <name> --team <team> --vault <project>--<env> --out deploy.key
@@ -970,6 +972,40 @@ After rotating: redeploy the consumers, install new SSH public keys, revoke the
970972
})
971973
);
972974

975+
teams
976+
.command("clean")
977+
.argument("<slug>", "Team slug")
978+
.argument("<project>", "Project name")
979+
.argument("<env>", "Environment name (prod, staging, …)")
980+
.option("-k, --key <names>", "archive these keys (comma-separated or repeated)", collectList)
981+
.option("--dead", "also archive keys whose URL host (or Supabase token's project) no longer resolves")
982+
.option("--reason <text>", "why the --key ones are archived, kept beside them")
983+
.option("--approve", "move them (dry run by default)")
984+
.option("--format <format>", "table, json, or markdown", "table")
985+
.description("Move stale keys out of a vault into <project>--<env>-archive. Never deletes: a key leaves only after its archived copy reads back.")
986+
.addHelpText(
987+
"after",
988+
`
989+
Dry run first, then apply:
990+
logicsrc teams clean profullstack threatcrush prod --dead
991+
logicsrc teams clean profullstack threatcrush prod --dead --key SUPABASE_DB_PASSWORD --reason "old Supabase cloud project" --approve
992+
993+
Archived keys keep their names in the archive vault; a name already holding a
994+
different value there gets a __<UTC stamp> suffix. Each one has an
995+
__ARCHIVE__<NAME> entry saying where it came from, why, when and who.
996+
Get them back: logicsrc teams pull profullstack threatcrush prod-archive --env archive.env
997+
`
998+
)
999+
.action((slug, project, env, options) =>
1000+
teamsCleanAction(slug, project, env, {
1001+
keys: options.key,
1002+
dead: Boolean(options.dead),
1003+
approve: Boolean(options.approve),
1004+
reason: options.reason,
1005+
format: options.format as OutputFormat
1006+
})
1007+
);
1008+
9731009
teams
9741010
.command("key")
9751011
.description("Print this machine's identity secret key, to read vault values in the web app.")
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "vitest";
2+
import { archiveSlot, archiveVaultName, findDead, jwtHosts, urlHosts } from "./vault-clean.js";
3+
4+
const jwt = (payload: object) =>
5+
`eyJhbGciOiJIUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.c2ln`;
6+
7+
describe("urlHosts", () => {
8+
it("finds hosts in URLs, connection strings and multi-URL values", () => {
9+
expect(urlHosts("https://abc.supabase.co")).toEqual(["abc.supabase.co"]);
10+
expect(urlHosts("postgres://u:p@db.abc.supabase.co:5432/postgres?sslmode=require")).toEqual(["db.abc.supabase.co"]);
11+
expect(urlHosts("a=https://One.example.com/x b=redis://two.example.org")).toEqual(["one.example.com", "two.example.org"]);
12+
});
13+
it("ignores values without a URL", () => {
14+
expect(urlHosts("sk_live_abc123")).toEqual([]);
15+
expect(urlHosts("http://localhost:3000")).toEqual([]);
16+
});
17+
});
18+
19+
describe("jwtHosts", () => {
20+
it("maps a Supabase key's ref to its project host", () => {
21+
expect(jwtHosts(jwt({ ref: "odhaoehucfyrqhanthyq", role: "anon" }))).toEqual(["odhaoehucfyrqhanthyq.supabase.co"]);
22+
});
23+
it("reads the issuer host", () => {
24+
expect(jwtHosts(jwt({ iss: "https://auth.example.com/v1" }))).toEqual(["auth.example.com"]);
25+
});
26+
it("returns nothing for non-JWTs and self-hosted keys without a ref", () => {
27+
expect(jwtHosts("not.a.jwt")).toEqual([]);
28+
expect(jwtHosts(jwt({ iss: "supabase", role: "service_role" }))).toEqual([]);
29+
});
30+
});
31+
32+
describe("findDead", () => {
33+
it("flags URL and token keys bound to a host that does not resolve, checking each host once", async () => {
34+
const asked: string[] = [];
35+
const check = async (h: string) => {
36+
asked.push(h);
37+
return h !== "goneabcdefghijklmnop.supabase.co";
38+
};
39+
const dead = await findDead(
40+
new Map([
41+
["SUPABASE_URL", "https://goneabcdefghijklmnop.supabase.co"],
42+
["ANON_KEY", jwt({ ref: "goneabcdefghijklmnop" })],
43+
["LIVE_URL", "https://supabase.example.com"],
44+
["PLAIN", "hunter2"]
45+
]),
46+
check
47+
);
48+
expect([...dead.keys()].sort()).toEqual(["ANON_KEY", "SUPABASE_URL"]);
49+
expect(asked.filter((h) => h === "goneabcdefghijklmnop.supabase.co")).toHaveLength(1);
50+
});
51+
});
52+
53+
describe("archiveSlot", () => {
54+
const now = new Date("2026-10-28T16:40:05.123Z");
55+
it("keeps the key's name when the archive has none", () => {
56+
expect(archiveSlot("K", "fp1", new Map(), now)).toBe("K");
57+
});
58+
it("skips a value the archive already holds", () => {
59+
expect(archiveSlot("K", "fp1", new Map([["K", "fp1"]]), now)).toBeNull();
60+
});
61+
it("never overwrites a different archived value", () => {
62+
expect(archiveSlot("K", "fp2", new Map([["K", "fp1"]]), now)).toBe("K__20261028T164005");
63+
expect(archiveSlot("K", "fp3", new Map([["K", "fp1"], ["K__20261028T164005", "fp2"]]), now)).toBe("K__20261028T164005_2");
64+
});
65+
});
66+
67+
it("names the archive vault after the live one", () => {
68+
expect(archiveVaultName("threatcrush", "prod")).toBe("threatcrush--prod-archive");
69+
});

‎packages/cli/src/vault-clean.ts‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { lookup } from "node:dns/promises";
2+
import { decryptValue, fingerprintValue, teamProvider, unwrapVaultKey, type RemoteSecret } from "@logicsrc/plugin-credential-sharing";
3+
import { print, printColumns, type OutputFormat } from "./format.js";
4+
import { authedClient, splitVaultName, teamEndpoint, vaultName } from "./teams.js";
5+
6+
/**
7+
* `logicsrc teams clean` moves stale keys out of a vault into its archive vault
8+
* (`<project>--<env>-archive`). Nothing is ever deleted outright: a key leaves
9+
* the live vault only after its copy in the archive reads back with the same
10+
* fingerprint. Dry run unless --approve.
11+
*
12+
* `--dead` finds keys that point somewhere that no longer exists: a URL whose
13+
* host has no DNS record, or a Supabase JWT whose project ref is such a host.
14+
*/
15+
16+
export const ARCHIVE_SUFFIX = "-archive";
17+
/** Archive entries describing where an archived key came from: `__ARCHIVE__<NAME>`. */
18+
export const ARCHIVE_ENTRY_PREFIX = "__ARCHIVE__";
19+
20+
const URL_HOST = /\b[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s"']+@)?([a-z0-9.-]+\.[a-z]{2,})(?=[:/?#\s"']|$)/gi;
21+
const JWT = /^eyJ[\w-]+\.([\w-]+)\.[\w-]*$/;
22+
23+
/** Hostnames of every URL in a value, lowercased, without duplicates. */
24+
export function urlHosts(value: string): string[] {
25+
const hosts = new Set<string>();
26+
for (const m of value.matchAll(URL_HOST)) hosts.add(m[1]!.toLowerCase());
27+
return [...hosts];
28+
}
29+
30+
/** Hosts a JWT is bound to: a Supabase key's `<ref>.supabase.co` and its `iss` host. */
31+
export function jwtHosts(value: string): string[] {
32+
const m = JWT.exec(value.trim());
33+
if (!m) return [];
34+
let payload: { ref?: unknown; iss?: unknown };
35+
try {
36+
payload = JSON.parse(Buffer.from(m[1]!, "base64url").toString("utf8"));
37+
} catch {
38+
return [];
39+
}
40+
const hosts: string[] = [];
41+
if (typeof payload.ref === "string" && /^[a-z0-9]{8,40}$/.test(payload.ref)) hosts.push(`${payload.ref}.supabase.co`);
42+
if (typeof payload.iss === "string") hosts.push(...urlHosts(payload.iss));
43+
return hosts;
44+
}
45+
46+
export function archiveVaultName(project: string, env: string): string {
47+
return vaultName(project, `${env}${ARCHIVE_SUFFIX}`);
48+
}
49+
50+
/**
51+
* The name a key is stored under in the archive: its own name, unless the
52+
* archive already holds a different value there, then `<KEY>__<UTC stamp>`.
53+
* `null` means the archive already holds this exact value under the key.
54+
*/
55+
export function archiveSlot(key: string, fingerprint: string, archived: Map<string, string>, now: Date): string | null {
56+
const existing = archived.get(key);
57+
if (existing === undefined) return key;
58+
if (existing === fingerprint) return null;
59+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
60+
let name = `${key}__${stamp}`;
61+
for (let n = 2; archived.has(name); n++) name = `${key}__${stamp}_${n}`;
62+
return name;
63+
}
64+
65+
export type HostCheck = (host: string) => Promise<boolean>;
66+
67+
/** True when the host resolves; only a definite "no such name" counts as dead. */
68+
export const resolves: HostCheck = async (host) => {
69+
try {
70+
await lookup(host);
71+
return true;
72+
} catch (err) {
73+
const code = (err as NodeJS.ErrnoException).code;
74+
return !(code === "ENOTFOUND" || code === "ENODATA");
75+
}
76+
};
77+
78+
/** Why each key is dead, from its value alone; keys that look fine are absent. */
79+
export async function findDead(values: Map<string, string>, check: HostCheck = resolves): Promise<Map<string, string>> {
80+
const cache = new Map<string, Promise<boolean>>();
81+
const alive = (h: string) => {
82+
if (!cache.has(h)) cache.set(h, check(h));
83+
return cache.get(h)!;
84+
};
85+
const dead = new Map<string, string>();
86+
for (const [key, value] of values) {
87+
for (const host of urlHosts(value)) {
88+
if (!(await alive(host))) {
89+
dead.set(key, `url host ${host} does not resolve`);
90+
break;
91+
}
92+
}
93+
if (dead.has(key)) continue;
94+
for (const host of jwtHosts(value)) {
95+
if (!(await alive(host))) {
96+
dead.set(key, `token for ${host}, which does not resolve`);
97+
break;
98+
}
99+
}
100+
}
101+
return dead;
102+
}
103+
104+
export interface CleanOptions {
105+
keys?: string[];
106+
dead: boolean;
107+
approve: boolean;
108+
reason?: string;
109+
format: OutputFormat;
110+
}
111+
112+
interface CleanRow {
113+
key: string;
114+
reason: string;
115+
archivedAs: string;
116+
status: "would-archive" | "archived" | "already-archived" | "failed";
117+
error?: string;
118+
}
119+
120+
export async function teamsCleanAction(slug: string, project: string, env: string, options: CleanOptions): Promise<void> {
121+
if (env.endsWith(ARCHIVE_SUFFIX)) throw new Error(`${vaultName(project, env)} is an archive vault; clean the live vault instead.`);
122+
if (!options.dead && !options.keys?.length) throw new Error("Say what to archive: --key NAME (repeatable) and/or --dead.");
123+
124+
const { client, identity } = authedClient();
125+
const source = vaultName(project, env);
126+
const archive = archiveVaultName(project, env);
127+
const { vaults } = await client.listVaults(slug);
128+
const src = vaults.find((v) => v.name === source);
129+
if (!src) throw new Error(`Vault ${slug}/${source} not found.`);
130+
if (!src.hasAccess) throw new Error(`You cannot decrypt ${slug}/${source}.`);
131+
const dek = await unwrapVaultKey((await client.getMyGrant(src.id)).wrappedDek, identity.keys);
132+
const { secrets } = await client.listSecrets(src.id);
133+
const byName = new Map<string, RemoteSecret>(secrets.map((s) => [s.name, s]));
134+
const values = new Map<string, string>();
135+
for (const s of secrets) values.set(s.name, await decryptValue({ nonce: s.nonce, ciphertext: s.ciphertext }, dek));
136+
137+
const picked = new Map<string, string>();
138+
for (const key of options.keys ?? []) {
139+
if (!byName.has(key)) throw new Error(`${key} is not in ${slug}/${source}.`);
140+
picked.set(key, options.reason ?? "named on the command line");
141+
}
142+
if (options.dead) for (const [key, why] of await findDead(values)) if (!picked.has(key)) picked.set(key, why);
143+
if (picked.size === 0) {
144+
console.error(`Nothing to archive in ${slug}/${source}.`);
145+
print({ team: slug, vault: source, archive, keys: [] }, options.format);
146+
return;
147+
}
148+
149+
// What the archive holds already, so a second run never stores a value twice.
150+
const arc = vaults.find((v) => v.name === archive);
151+
const archived = new Map<string, string>();
152+
if (arc?.hasAccess) for (const s of (await client.listSecrets(arc.id)).secrets) archived.set(s.name, s.fingerprint);
153+
else if (arc) throw new Error(`${slug}/${archive} exists but you cannot decrypt it; ask a member for a grant first.`);
154+
155+
const now = new Date();
156+
const rows: CleanRow[] = [];
157+
const upserts: Record<string, string> = {};
158+
for (const [key, reason] of picked) {
159+
const slot = archiveSlot(key, byName.get(key)!.fingerprint, archived, now);
160+
if (slot === null) {
161+
rows.push({ key, reason, archivedAs: key, status: "already-archived" });
162+
continue;
163+
}
164+
rows.push({ key, reason, archivedAs: slot, status: "would-archive" });
165+
upserts[slot] = values.get(key)!;
166+
upserts[`${ARCHIVE_ENTRY_PREFIX}${slot}`] = JSON.stringify({ from: source, key, reason, archivedAt: now.toISOString(), by: identity.email });
167+
}
168+
169+
if (options.approve) {
170+
try {
171+
if (Object.keys(upserts).length) {
172+
await teamProvider.write!({ endpoint: teamEndpoint(slug, archive), upserts, deletes: [], dryRun: false });
173+
}
174+
// Read the archive back: a key leaves the live vault only once its copy is there, byte for byte.
175+
const after = (await client.listVaults(slug)).vaults.find((v) => v.name === archive);
176+
const stored = new Map((after ? (await client.listSecrets(after.id)).secrets : []).map((s) => [s.name, s.fingerprint]));
177+
const safe: string[] = [];
178+
for (const row of rows) {
179+
if (stored.get(row.archivedAs) === fingerprintValue(values.get(row.key)!)) {
180+
safe.push(row.key);
181+
if (row.status === "would-archive") row.status = "archived";
182+
} else {
183+
row.status = "failed";
184+
row.error = "copy not found in the archive; left in place";
185+
}
186+
}
187+
if (safe.length) await client.putSecrets(src.id, [], safe);
188+
} catch (err) {
189+
for (const row of rows) if (row.status === "would-archive") Object.assign(row, { status: "failed", error: (err as Error).message });
190+
}
191+
}
192+
193+
if (options.format === "table") {
194+
printColumns(rows.map((r) => ({ key: r.key, status: r.status, "archived as": r.archivedAs, reason: r.error ?? r.reason })));
195+
const at = splitVaultName(archive);
196+
if (!options.approve) console.error(`\nDry run. Move them to ${slug}/${archive} with --approve.`);
197+
else console.error(`\nArchived in ${slug}/${archive}. Get them back with: logicsrc teams pull ${slug} ${at?.project} ${at?.env} --env archive.env`);
198+
} else {
199+
print({ team: slug, vault: source, archive, keys: rows }, options.format);
200+
}
201+
if (rows.some((r) => r.status === "failed")) process.exitCode = 1;
202+
}

0 commit comments

Comments
 (0)