diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 64a48bf..1dc8960 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -183,15 +183,16 @@ re-wraps (seals) it to the new member's public key. The private key lives only i logicsrc login # Owner: create a team, push a local .env into an encrypted vault, invite people. +# A vault is addressed as , stored as the vault name project/env. logicsrc teams create acme --name "Acme Inc" -logicsrc teams push acme prod --env .env # encrypt + upload +logicsrc teams push acme web prod --env .env # encrypt + upload logicsrc teams invite acme teammate@example.com # emails an accept link # Teammate: accept, then get granted, then pull + decrypt locally. logicsrc login logicsrc teams accept -# …an existing member runs: logicsrc teams grant acme prod teammate@example.com -logicsrc teams pull acme prod --env .env # download + decrypt +# …an existing member runs: logicsrc teams grant acme web prod teammate@example.com +logicsrc teams pull acme web prod --env .env # download + decrypt # Inspect / manage logicsrc teams list diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bf14294..60613bf 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -601,29 +601,32 @@ teams teams .command("grant") .argument("", "Team slug") - .argument("", "Vault name") + .argument("", "Project name") + .argument("", "Environment name (prod, staging, …)") .argument("", "Teammate email to grant vault access") .option("--format ", "table, json, or markdown", "table") .description("Grant a member decryption access to a vault (re-wraps the vault key to their key).") - .action((slug, vault, email, options) => teamsGrantAction(slug, vault, email, options.format as OutputFormat)); + .action((slug, project, env, email, options) => teamsGrantAction(slug, project, env, email, options.format as OutputFormat)); teams .command("push") .argument("", "Team slug") - .argument("", "Vault name") + .argument("", "Project name") + .argument("", "Environment name (prod, staging, …)") .option("--env ", "Source .env file", ".env") .option("--format ", "table, json, or markdown", "table") - .description("Encrypt and push a local .env into a team vault.") - .action((slug, vault, options) => teamsPushAction(slug, vault, { env: options.env, format: options.format as OutputFormat })); + .description("Encrypt and push a local .env into a team vault (/).") + .action((slug, project, env, options) => teamsPushAction(slug, project, env, { env: options.env, format: options.format as OutputFormat })); teams .command("pull") .argument("", "Team slug") - .argument("", "Vault name") + .argument("", "Project name") + .argument("", "Environment name (prod, staging, …)") .option("--env ", "Destination .env file", ".env") .option("--format ", "table, json, or markdown", "table") - .description("Pull a team vault and decrypt it into a local .env.") - .action((slug, vault, options) => teamsPullAction(slug, vault, { env: options.env, format: options.format as OutputFormat })); + .description("Pull a team vault (/) and decrypt it into a local .env.") + .action((slug, project, env, options) => teamsPullAction(slug, project, env, { env: options.env, format: options.format as OutputFormat })); const accounts = program.command("accounts").description("Manage connected social and email accounts."); diff --git a/packages/cli/src/teams.test.ts b/packages/cli/src/teams.test.ts new file mode 100644 index 0000000..c606407 --- /dev/null +++ b/packages/cli/src/teams.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { splitVaultName, vaultName } from "./teams.js"; + +// A vault is addressed as on the command line and stored as a +// single `project/env` name server-side. The join is the only thing keeping +// those two halves apart, so it has to reject anything that would make the +// name ambiguous — a wrong split would point a push at the wrong vault. + +describe("vaultName", () => { + it("joins project and env with a slash", () => { + expect(vaultName("web", "prod")).toBe("web/prod"); + }); + + it("keeps distinct envs of one project apart", () => { + expect(vaultName("web", "staging")).not.toBe(vaultName("web", "prod")); + }); + + it("keeps distinct projects in one env apart", () => { + expect(vaultName("api", "prod")).not.toBe(vaultName("web", "prod")); + }); + + it("rejects a slash in either half", () => { + expect(() => vaultName("web/api", "prod")).toThrow(/cannot contain/); + expect(() => vaultName("web", "prod/eu")).toThrow(/cannot contain/); + }); + + it("rejects empty or blank halves", () => { + expect(() => vaultName("", "prod")).toThrow(/Missing project/); + expect(() => vaultName("web", "")).toThrow(/Missing env/); + expect(() => vaultName(" ", "prod")).toThrow(/Missing project/); + }); +}); + +describe("splitVaultName", () => { + it("round-trips a name built by vaultName", () => { + expect(splitVaultName(vaultName("web", "prod"))).toEqual({ project: "web", env: "prod" }); + }); + + it("returns null for legacy single-word names", () => { + // Vaults created before the split are still listable; they just don't + // decompose, so `teams vaults` shows the raw name instead of guessing. + expect(splitVaultName("prod")).toBeNull(); + }); + + it("returns null rather than guessing at an ambiguous name", () => { + expect(splitVaultName("a/b/c")).toBeNull(); + expect(splitVaultName("/prod")).toBeNull(); + expect(splitVaultName("web/")).toBeNull(); + }); +}); diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 9ab3b92..40d1097 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -176,11 +176,48 @@ class DeviceFlowUnsupported extends Error { constructor() { super("device flow not supported by this server"); } } +// A vault is addressed as /, so one team can hold web/prod, +// web/staging and api/prod side by side. The split lives entirely in the CLI — +// the server still stores a single opaque vault name — so this join and +// splitVaultName() below are the only places that know about the convention. +// Neither half may contain a slash, which keeps the join unambiguous and makes +// splitVaultName a true inverse. +export function vaultName(project: string, env: string): string { + const parts: ReadonlyArray = [ + ["project", project], + ["env", env] + ]; + for (const [label, value] of parts) { + if (!value || !value.trim()) { + throw new Error(`Missing ${label}. Usage: logicsrc teams push `); + } + if (value.includes("/")) { + throw new Error(`The ${label} "${value}" cannot contain "/" — it separates project from env in a vault name.`); + } + } + return `${project}/${env}`; +} + +/** Inverse of vaultName; null for names that predate the convention. */ +export function splitVaultName(name: string): { project: string; env: string } | null { + const slash = name.indexOf("/"); + if (slash <= 0 || slash === name.length - 1) return null; + const env = name.slice(slash + 1); + if (env.includes("/")) return null; + return { project: name.slice(0, slash), env }; +} + async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise { const { vaults } = await client.listVaults(slug); const found = vaults.find((v) => v.name === vault); - if (!found) throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.`); - return found.id; + if (found) return found.id; + // Vault names were a single word before they became /, so a + // team can still hold legacy rows. Name them instead of silently retargeting + // — picking a different vault than the one asked for would mean pushing + // secrets somewhere the caller didn't say. + const known = vaults.map((v) => v.name); + const hint = known.length ? ` Existing vaults: ${known.join(", ")}.` : ""; + throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.${hint}`); } export async function loginAction(options: { apiUrl?: string; token?: string; device?: boolean; web?: boolean }): Promise { @@ -288,13 +325,25 @@ export async function teamsVaultsAction(slug: string, format: OutputFormat): Pro const { client } = authedClient(); const { vaults } = await client.listVaults(slug); print( - vaults.length ? vaults.map((v) => ({ vault: v.name, secrets: v.secretCount, youHaveAccess: v.hasAccess })) : [{ note: "No vaults yet. Push to create one: logicsrc teams push " }], + vaults.length + ? vaults.map((v) => { + const parts = splitVaultName(v.name); + return { + vault: v.name, + project: parts?.project ?? v.name, + env: parts?.env ?? "—", + secrets: v.secretCount, + youHaveAccess: v.hasAccess + }; + }) + : [{ note: "No vaults yet. Push to create one: logicsrc teams push " }], format ); } -export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise { +export async function teamsGrantAction(slug: string, project: string, env: string, email: string, format: OutputFormat): Promise { const { client, identity } = authedClient(); + const vault = vaultName(project, env); const vaultId = await resolveVaultId(client, slug, vault); // Unwrap the vault DEK with our own key, then re-wrap it to the target member. @@ -314,44 +363,48 @@ export async function teamsGrantAction(slug: string, vault: string, email: strin if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`); await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey)); - console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${vault}`); - print({ granted: email, team: slug, vault }, format); + console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${project} ${env}`); + print({ granted: email, team: slug, project, env, vault }, format); } function teamEndpoint(slug: string, vault: string): CredentialEndpoint { return { provider: "team", project: slug, config: vault }; } -export async function teamsPushAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise { +// Note the two different "env"s: `envName` is the environment half of the vault +// address (prod, staging), while `options.env` is the local .env file path. +export async function teamsPushAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise { requireAuth(); + const vault = vaultName(project, envName); const engine = createCredentialEngine(); const from: CredentialEndpoint = { provider: "env", path: options.env }; const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) }); if (plan.changes.length === 0) { console.error(`${slug}/${vault} is already up to date with ${options.env}.`); - print({ team: slug, vault, changes: 0 }, options.format); + print({ team: slug, project, env: envName, vault, changes: 0 }, options.format); return; } const approval = engine.approveCredentialSync(plan.id); const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); const applied = run.results.filter((r) => r.applied).length; console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`); - print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); + print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); } -export async function teamsPullAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise { +export async function teamsPullAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise { requireAuth(); + const vault = vaultName(project, envName); const engine = createCredentialEngine(); const to: CredentialEndpoint = { provider: "env", path: options.env }; const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to }); if (plan.changes.length === 0) { console.error(`${options.env} is already up to date with ${slug}/${vault}.`); - print({ team: slug, vault, changes: 0 }, options.format); + print({ team: slug, project, env: envName, vault, changes: 0 }, options.format); return; } const approval = engine.approveCredentialSync(plan.id); const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); const applied = run.results.filter((r) => r.applied).length; console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`); - print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); + print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); }