Skip to content

Commit bb09c14

Browse files
ralyodioclaude
andauthored
fix(cli): vault names join with "--", not "/" — the server rejects slashes (#112)
#109 addressed vaults as <project>/<env> and shipped broken: every push failed with Vault name must be lowercase letters, numbers, and dashes. The vault-create endpoint slugifies through /^[a-z0-9][a-z0-9-]{0,62}$/ (apps/pwa/src/routes/credshare.mjs), so a "/" join is refused outright. Nothing in the CLI ever saw it, because the tests exercised vaultName and splitVaultName in isolation and never made a request — the one assumption that mattered, that the server takes an arbitrary vault name, was the one left unverified. Switches the separator to "--", which is inside the allowed character set and still splits unambiguously since neither half may contain one. A single dash would not: "a-b" + "c" and "a" + "b-c" would collide. Also validates the joined name against the server's own regex before the request, so a bad name fails locally with a useful message rather than a 422 after the .env has been read. The tests now assert the produced name matches that regex, so the separator cannot drift back out of the allowed set without failing. Verified end to end against app.logicsrc.com: push, then pull into a scratch file and diff — keys and values both round-trip losslessly. Then 49 repos pushed under the profullstack team; server reports 49 vaults, 169 secrets, 0 failures. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f41abbd commit bb09c14

4 files changed

Lines changed: 86 additions & 27 deletions

File tree

docs/credential-sharing.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,9 @@ re-wraps (seals) it to the new member's public key. The private key lives only i
183183
logicsrc login
184184

185185
# Owner: create a team, push a local .env into an encrypted vault, invite people.
186-
# A vault is addressed as <project> <env>, stored as the vault name project/env.
186+
# A vault is addressed as <project> <env>, stored as the vault name
187+
# project--env (a double dash: the server slugs vault names through
188+
# /^[a-z0-9][a-z0-9-]{0,62}$/, so a "/" would be rejected).
187189
logicsrc teams create acme --name "Acme Inc"
188190
logicsrc teams push acme web prod --env .env # encrypt + upload
189191
logicsrc teams invite acme teammate@example.com # emails an accept link

packages/cli/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ teams
615615
.argument("<env>", "Environment name (prod, staging, …)")
616616
.option("--env <path>", "Source .env file", ".env")
617617
.option("--format <format>", "table, json, or markdown", "table")
618-
.description("Encrypt and push a local .env into a team vault (<project>/<env>).")
618+
.description("Encrypt and push a local .env into a team vault (<project>--<env>).")
619619
.action((slug, project, env, options) => teamsPushAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));
620620

621621
teams
@@ -625,7 +625,7 @@ teams
625625
.argument("<env>", "Environment name (prod, staging, …)")
626626
.option("--env <path>", "Destination .env file", ".env")
627627
.option("--format <format>", "table, json, or markdown", "table")
628-
.description("Pull a team vault (<project>/<env>) and decrypt it into a local .env.")
628+
.description("Pull a team vault (<project>--<env>) and decrypt it into a local .env.")
629629
.action((slug, project, env, options) => teamsPullAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));
630630

631631
const accounts = program.command("accounts").description("Manage connected social and email accounts.");

packages/cli/src/teams.test.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
import { describe, expect, it } from "vitest";
2-
import { splitVaultName, vaultName } from "./teams.js";
2+
import { splitVaultName, vaultName, VAULT_SEP } from "./teams.js";
33

44
// A vault is addressed as <project> <env> on the command line and stored as a
5-
// single `project/env` name server-side. The join is the only thing keeping
6-
// those two halves apart, so it has to reject anything that would make the
7-
// name ambiguous — a wrong split would point a push at the wrong vault.
5+
// single name server-side. Two things have to hold: the join must survive the
6+
// server's own validation, and it must split back unambiguously — a wrong split
7+
// would point a push at the wrong vault.
8+
//
9+
// The server slugifies vault names through /^[a-z0-9][a-z0-9-]{0,62}$/. The
10+
// first cut of this used "/" as the separator, which that regex rejects, so
11+
// every push failed with a 422 the unit tests never saw. SERVER_SLUG below is
12+
// that regex, asserted directly, so the separator can't drift out of the
13+
// allowed character set again without a test failing.
14+
const SERVER_SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
815

916
describe("vaultName", () => {
10-
it("joins project and env with a slash", () => {
11-
expect(vaultName("web", "prod")).toBe("web/prod");
17+
it("joins project and env with the separator", () => {
18+
expect(vaultName("web", "prod")).toBe(`web${VAULT_SEP}prod`);
19+
});
20+
21+
it("produces a name the server will accept", () => {
22+
expect(vaultName("web", "prod")).toMatch(SERVER_SLUG);
23+
expect(vaultName("food-delivery-multivendor-enatega-multivendor-backend", "prod")).toMatch(SERVER_SLUG);
24+
});
25+
26+
it("never uses a separator the server rejects", () => {
27+
// The regression that shipped: "/" is not in [a-z0-9-].
28+
expect(VAULT_SEP).toMatch(/^[a-z0-9-]+$/);
29+
expect(vaultName("web", "prod")).not.toContain("/");
1230
});
1331

1432
it("keeps distinct envs of one project apart", () => {
@@ -19,9 +37,26 @@ describe("vaultName", () => {
1937
expect(vaultName("api", "prod")).not.toBe(vaultName("web", "prod"));
2038
});
2139

22-
it("rejects a slash in either half", () => {
23-
expect(() => vaultName("web/api", "prod")).toThrow(/cannot contain/);
24-
expect(() => vaultName("web", "prod/eu")).toThrow(/cannot contain/);
40+
it("keeps a dashed project distinct from a dashed env", () => {
41+
// "a-b" + "c" and "a" + "b-c" must not collide — the reason the separator
42+
// is a double dash rather than a single one.
43+
expect(vaultName("a-b", "c")).not.toBe(vaultName("a", "b-c"));
44+
});
45+
46+
it("rejects the separator inside either half", () => {
47+
expect(() => vaultName(`web${VAULT_SEP}api`, "prod")).toThrow(/cannot contain/);
48+
expect(() => vaultName("web", `prod${VAULT_SEP}eu`)).toThrow(/cannot contain/);
49+
});
50+
51+
it("rejects characters the server would refuse", () => {
52+
expect(() => vaultName("web/api", "prod")).toThrow(/not a valid vault name/);
53+
expect(() => vaultName("Web", "prod")).toThrow(/not a valid vault name/);
54+
expect(() => vaultName("web_api", "prod")).toThrow(/not a valid vault name/);
55+
expect(() => vaultName("-web", "prod")).toThrow(/not a valid vault name/);
56+
});
57+
58+
it("rejects a combined name past the server's 63-character limit", () => {
59+
expect(() => vaultName("a".repeat(60), "prod")).toThrow(/at most 63/);
2560
});
2661

2762
it("rejects empty or blank halves", () => {
@@ -36,15 +71,21 @@ describe("splitVaultName", () => {
3671
expect(splitVaultName(vaultName("web", "prod"))).toEqual({ project: "web", env: "prod" });
3772
});
3873

74+
it("round-trips halves that contain single dashes", () => {
75+
expect(splitVaultName(vaultName("playground-encryptfiles-web", "prod")))
76+
.toEqual({ project: "playground-encryptfiles-web", env: "prod" });
77+
});
78+
3979
it("returns null for legacy single-word names", () => {
4080
// Vaults created before the split are still listable; they just don't
4181
// decompose, so `teams vaults` shows the raw name instead of guessing.
4282
expect(splitVaultName("prod")).toBeNull();
83+
expect(splitVaultName("web-prod")).toBeNull();
4384
});
4485

4586
it("returns null rather than guessing at an ambiguous name", () => {
46-
expect(splitVaultName("a/b/c")).toBeNull();
47-
expect(splitVaultName("/prod")).toBeNull();
48-
expect(splitVaultName("web/")).toBeNull();
87+
expect(splitVaultName(`a${VAULT_SEP}b${VAULT_SEP}c`)).toBeNull();
88+
expect(splitVaultName(`${VAULT_SEP}prod`)).toBeNull();
89+
expect(splitVaultName(`web${VAULT_SEP}`)).toBeNull();
4990
});
5091
});

packages/cli/src/teams.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,22 @@ class DeviceFlowUnsupported extends Error {
176176
constructor() { super("device flow not supported by this server"); }
177177
}
178178

179-
// A vault is addressed as <project>/<env>, so one team can hold web/prod,
179+
// A vault is addressed as <project> <env>, so one team can hold web/prod,
180180
// web/staging and api/prod side by side. The split lives entirely in the CLI —
181-
// the server still stores a single opaque vault name — so this join and
181+
// the server stores a single opaque vault name — so this join and
182182
// splitVaultName() below are the only places that know about the convention.
183-
// Neither half may contain a slash, which keeps the join unambiguous and makes
184-
// splitVaultName a true inverse.
183+
//
184+
// The separator is "--", NOT "/". The server slugifies vault names through
185+
// /^[a-z0-9][a-z0-9-]{0,62}$/ and rejects anything else, so a "/" join is
186+
// refused outright with "Vault name must be lowercase letters, numbers, and
187+
// dashes." A double dash is inside the allowed character set and still splits
188+
// unambiguously, because neither half may contain one.
189+
export const VAULT_SEP = "--";
190+
191+
// Mirrors the server's slugify(). Enforced here so a bad name fails locally
192+
// with a useful message instead of a 422 after the file has been read.
193+
const VAULT_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
194+
185195
export function vaultName(project: string, env: string): string {
186196
const parts: ReadonlyArray<readonly [string, string]> = [
187197
["project", project],
@@ -191,20 +201,26 @@ export function vaultName(project: string, env: string): string {
191201
if (!value || !value.trim()) {
192202
throw new Error(`Missing ${label}. Usage: logicsrc teams push <team> <project> <env>`);
193203
}
194-
if (value.includes("/")) {
195-
throw new Error(`The ${label} "${value}" cannot contain "/" — it separates project from env in a vault name.`);
204+
if (value.includes(VAULT_SEP)) {
205+
throw new Error(`The ${label} "${value}" cannot contain "${VAULT_SEP}" — it separates project from env in a vault name.`);
196206
}
197207
}
198-
return `${project}/${env}`;
208+
const name = `${project}${VAULT_SEP}${env}`;
209+
if (!VAULT_NAME.test(name)) {
210+
throw new Error(
211+
`"${name}" is not a valid vault name. Project and env must be lowercase letters, numbers and dashes, and together at most 63 characters.`
212+
);
213+
}
214+
return name;
199215
}
200216

201217
/** Inverse of vaultName; null for names that predate the convention. */
202218
export function splitVaultName(name: string): { project: string; env: string } | null {
203-
const slash = name.indexOf("/");
204-
if (slash <= 0 || slash === name.length - 1) return null;
205-
const env = name.slice(slash + 1);
206-
if (env.includes("/")) return null;
207-
return { project: name.slice(0, slash), env };
219+
const at = name.indexOf(VAULT_SEP);
220+
if (at <= 0) return null;
221+
const env = name.slice(at + VAULT_SEP.length);
222+
if (!env || env.includes(VAULT_SEP)) return null;
223+
return { project: name.slice(0, at), env };
208224
}
209225

210226
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {

0 commit comments

Comments
 (0)