vault + teams: filter secrets by category, export to CSV, simpler help (#195)

Every secret now has a category derived from its name (db, social, server,
api, cloud, finance, crypto, ai, email, messaging, storage, dns, analytics,
devtools, auth, config, other). One rule table in @logicsrc/opencreds serves
both vaults; services win over generic words, so STRIPE_WEBHOOK_SECRET is
finance, not auth. Checked against the 1,208 distinct key names in the
profullstack team: 74 fall to "other".

Team vaults (where the shared .env secrets live):
- teams categories [team]    the filter words, with per-category counts
- teams secrets <team> [project] [env] --category/-c --search/-s
                             names + categories, never decrypts; --format csv
- teams export  <team> [project] [env] --category -o file.csv [--yes]
                             decrypts into team,project,env,category,key,
                             value,updated_at (0600); skips vaults without a
                             grant and names them

Personal vault (OpenCreds):
- vault list --category, and the category column in list output
- vault export --format csv: one flat row per item, keeps key/account
  secrets that a Bitwarden CSV drops; --category on every export format

DX:
- examples in `logicsrc vault help` / -h / --help that start by saying which
  of the two vaults you want, plus examples on teams and each subcommand
- password prompts go to stderr, so eval "$(logicsrc vault unlock)" works
- hints name the command you actually ran (logicsrc vault init, not
  opencreds init)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-23 21:08:08 -07:00 • committed by GitHub
parent 156c9164a9
commit 3d155af970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 846 additions and 19 deletions

View file

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
SECRET_CATEGORIES,
SECRET_CATEGORY_IDS,
categorizeItem,
categorizeSecret,
csvLine,
parseCategories,
toSimpleCsv,
} from "./categories.js";
import { createItem } from "./items.js";
describe("categorizeSecret", () => {
it("puts every documented example in its own category", () => {
for (const category of SECRET_CATEGORIES) {
for (const example of category.examples) {
expect([example, categorizeSecret(example)]).toEqual([example, category.id]);
}
}
});
it.each([
["DATABASE_URL", "db"],
["SUPABASE_SERVICE_ROLE_KEY", "db"],
["SUPABAsE_URL", "db"],
["TWITTER_API_KEY", "social"],
["X_CLIENT_SECRET", "social"],
["SSH_PORT", "server"],
["SEED1_SUDO_PASSWORD", "server"],
["TMDB_API_KEY", "api"],
// a service beats the generic word: rotate Stripe, get its webhook secret too
["STRIPE_WEBHOOK_SECRET", "finance"],
["COINPAYPORTAL_API_KEY", "finance"],
["SMTP_HOST", "email"],
["DB_HOST", "db"],
["R2_SECRET_ACCESS_KEY", "storage"],
["SUPABASE_S3_SECRET_KEY", "storage"],
["SYSTEM_MNEMONIC_BTC", "crypto"],
["JWT_SECRET", "auth"],
["NODE_ENV", "config"],
["NEXT_PUBLIC_APP_URL", "config"],
["PLANETSCALE_DATABASE_URL", "db"],
["SOMETHING_ELSE", "other"],
])("%s is %s", (name, category) => {
expect(categorizeSecret(name)).toBe(category);
});
it("reads item names and hosts the same way as env names", () => {
expect(categorizeSecret("My Postgres")).toBe("db");
expect(categorizeSecret("api.stripe.com")).toBe("finance");
});
});
describe("categorizeItem", () => {
it("uses the item type where it is unambiguous", () => {
expect(categorizeItem(createItem("card", { name: "Visa" }))).toBe("finance");
expect(categorizeItem(createItem("key", { name: "laptop", key: { keyType: "ssh" } }))).toBe("server");
});
it("classifies a login by its URL when the name says nothing", () => {
const item = createItem("login", { name: "work", login: { uris: [{ uri: "https://www.reddit.com/login" }] } });
expect(categorizeItem(item)).toBe("social");
});
it("classifies an env key by its name", () => {
expect(categorizeItem(createItem("key", { name: "REDIS_URL", key: { keyType: "env", value: "redis://x" } }))).toBe("db");
});
});
describe("parseCategories", () => {
it("accepts commas, repeats and aliases", () => {
expect(parseCategories(["db,social", "payments"])).toEqual(new Set(["db", "social", "finance"]));
expect(parseCategories(" DB ")).toEqual(new Set(["db"]));
});
it("means no filter when nothing is given", () => {
expect(parseCategories(undefined)).toBeUndefined();
expect(parseCategories([])).toBeUndefined();
});
it("names the valid words when one is wrong", () => {
expect(() => parseCategories("databse")).toThrow(/Unknown category "databse".*db/);
});
it("lists other last so every secret has a category to filter on", () => {
expect(SECRET_CATEGORY_IDS.at(-1)).toBe("other");
});
});
describe("csv", () => {
it("quotes only what needs quoting, including multi-line keys", () => {
expect(csvLine(["a", 'b"c', "d,e", "-----BEGIN\nKEY-----", undefined])).toBe('a,"b""c","d,e","-----BEGIN\nKEY-----",');
});
it("keeps key and account secrets, which a Bitwarden CSV drops", () => {
const items = [
createItem("key", { name: "DATABASE_URL", key: { keyType: "env", value: "postgres://u:p@h/db" } }),
createItem("account", { name: "Mastodon", account: { provider: "mastodon", handle: "@me", accessToken: "tok" } }),
];
const lines = toSimpleCsv(items, []).trim().split("\n");
expect(lines[0]).toBe("folder,category,type,name,username,password,url,value,totp,notes");
expect(lines[1]).toBe(",db,key,DATABASE_URL,,,,postgres://u:p@h/db,,");
expect(lines[2]).toBe(",social,account,Mastodon,@me,,mastodon,tok,,");
});
});