From cf73fe5af2b7b3c7372f6ec6a6d8c3cd6dab3eee Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 27 Jun 2026 15:24:30 +0000 Subject: [PATCH] feat(credential-sharing): implement the Credential Sharing OpenSpec (M1-M3) New @logicsrc/plugin-credential-sharing: a provider-neutral secret-sync engine with env/.env, Doppler, Railway, and GitHub Secrets adapters behind one CredentialProvider contract. - engine: inspect -> diff -> plan -> approve -> sync -> rollback -> audit/export - dry-run is the default for sync; --approve writes; destructive changes gated - fingerprint-based diffs (salted SHA-256); raw values never printed or stored in plans/runs/audit; rollback pre-image kept in a 0600 .logicsrc vault (gitignored) - github-secrets is write-only for values (sealed-box via libsodium), so it cannot be a sync source or value-restoring rollback target - CLI: real `logicsrc credentials ` (replaces the prior stub) - 4 JSON schemas registered in @logicsrc/validators - flip logicsrc.com/credential-sharing band from coming-soon to available - 37 tests pass; full env->env lifecycle verified; artifacts schema-validate Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + apps/logicsrc-web/src/lib/page-markup.ts | 22 +- docs/credential-sharing.md | 42 +- package-lock.json | 40 ++ package.json | 2 +- packages/cli/package.json | 1 + packages/cli/src/index.ts | 171 +++++++-- packages/cli/src/registry.ts | 3 +- packages/schemas/package.json | 6 +- ...ogicsrc-credential-audit-event.schema.json | 32 ++ .../logicsrc-credential-provider.schema.json | 28 ++ .../logicsrc-credential-sync-plan.schema.json | 56 +++ .../logicsrc-credential-sync-run.schema.json | 35 ++ packages/validators/src/schemas.ts | 10 +- plugins/credential-sharing/package.json | 21 + plugins/credential-sharing/src/engine.test.ts | 145 +++++++ plugins/credential-sharing/src/engine.ts | 358 ++++++++++++++++++ plugins/credential-sharing/src/fingerprint.ts | Bin 0 -> 1908 bytes plugins/credential-sharing/src/index.ts | 66 ++++ plugins/credential-sharing/src/manifest.ts | 22 ++ .../src/providers/doppler.ts | 75 ++++ .../src/providers/env.test.ts | 30 ++ .../credential-sharing/src/providers/env.ts | 142 +++++++ .../src/providers/github-secrets.ts | 116 ++++++ .../credential-sharing/src/providers/http.ts | 23 ++ .../credential-sharing/src/providers/index.ts | 25 ++ .../src/providers/railway.ts | 88 +++++ plugins/credential-sharing/src/store.ts | 121 ++++++ plugins/credential-sharing/src/types.ts | 198 ++++++++++ plugins/credential-sharing/tsconfig.json | 8 + 30 files changed, 1849 insertions(+), 38 deletions(-) create mode 100644 packages/schemas/schemas/logicsrc-credential-audit-event.schema.json create mode 100644 packages/schemas/schemas/logicsrc-credential-provider.schema.json create mode 100644 packages/schemas/schemas/logicsrc-credential-sync-plan.schema.json create mode 100644 packages/schemas/schemas/logicsrc-credential-sync-run.schema.json create mode 100644 plugins/credential-sharing/package.json create mode 100644 plugins/credential-sharing/src/engine.test.ts create mode 100644 plugins/credential-sharing/src/engine.ts create mode 100644 plugins/credential-sharing/src/fingerprint.ts create mode 100644 plugins/credential-sharing/src/index.ts create mode 100644 plugins/credential-sharing/src/manifest.ts create mode 100644 plugins/credential-sharing/src/providers/doppler.ts create mode 100644 plugins/credential-sharing/src/providers/env.test.ts create mode 100644 plugins/credential-sharing/src/providers/env.ts create mode 100644 plugins/credential-sharing/src/providers/github-secrets.ts create mode 100644 plugins/credential-sharing/src/providers/http.ts create mode 100644 plugins/credential-sharing/src/providers/index.ts create mode 100644 plugins/credential-sharing/src/providers/railway.ts create mode 100644 plugins/credential-sharing/src/store.ts create mode 100644 plugins/credential-sharing/src/types.ts create mode 100644 plugins/credential-sharing/tsconfig.json diff --git a/.gitignore b/.gitignore index cb3ad9e..3a51433 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ playwright-report test-results *.tsbuildinfo .commandboard +.logicsrc supabase/.temp diff --git a/apps/logicsrc-web/src/lib/page-markup.ts b/apps/logicsrc-web/src/lib/page-markup.ts index 4bccada..8a4da00 100644 --- a/apps/logicsrc-web/src/lib/page-markup.ts +++ b/apps/logicsrc-web/src/lib/page-markup.ts @@ -50,10 +50,10 @@ const credentialProviders = [ ]; const credentialSurfaces = [ - { name: "CLI", detail: "`logicsrc credentials` for provider listing, dry-run plans, diffs, approvals, sync, and audit exports." }, - { name: "TUI", detail: "Review key diffs, target providers, approval prompts, fingerprints, and failure states without showing raw secrets." }, - { name: "SDKs", detail: "Rust, Bun, Node, Python, and curl APIs share the same credential source, target, policy, and audit objects." }, - { name: "PWA", detail: "Provider connection health, dry-run previews, approval history, and redacted sync evidence." } + { name: "CLI", detail: "`logicsrc credentials` ships provider listing, dry-run plans, diffs, approvals, sync, rollback, and audit exports today." }, + { name: "SDK", detail: "`createCredentialEngine()` in `@logicsrc/plugin-credential-sharing` exposes the same source, target, policy, and audit objects for Node/TS." }, + { name: "TUI", detail: "A Credential Sharing panel reviews key diffs, providers, approval prompts, fingerprints, and failure states without showing raw secrets." }, + { name: "PWA", detail: "Planned: provider connection health, dry-run previews, approval history, and redacted sync evidence." } ]; const hireUsWork = [ @@ -248,10 +248,10 @@ npm --workspace @logicsrc/cli run dev -- \\ -
+

Credential Sharing

-

A coming-soon LogicSRC OpenSpec for replacing closed credential-sharing workflows with auditable, provider-neutral secret sync.

+

An available LogicSRC OpenSpec for replacing closed credential-sharing workflows with auditable, provider-neutral secret sync. Shipped in @logicsrc/plugin-credential-sharing.

@@ -259,8 +259,14 @@ npm --workspace @logicsrc/cli run dev -- \\

Open replacement architecture for secrets

LogicSRC defines the credential source, target, diff, approval, sync, rollback, and audit objects. External tools can consume the contract, but LogicSRC remains the open standards CLI and does not call out to proprietary product commands.

logicsrc credentials providers
-logicsrc credentials plan --from env --to railway
-logicsrc credentials plan --from doppler --to github-secrets
+logicsrc credentials diff --from env --from-path .env --to railway \\ + --to-project <projectId> --to-config <environmentId> +logicsrc credentials plan --from env --from-path .env --to doppler \\ + --to-project <project> --to-config <config> +logicsrc credentials sync --plan <planId> # dry-run, no writes +logicsrc credentials sync --plan <planId> --approve # writes to the target +logicsrc credentials audit --run <runId> --format markdown +logicsrc credentials rollback --run <runId>
${credentialProviders.map((item) => ` diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 277752b..1de4658 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -1,9 +1,49 @@ # Credential Sharing OpenSpec -Status: coming soon +Status: reference implementation available (`@logicsrc/plugin-credential-sharing`) Slug: `credential-sharing` +## Reference Implementation + +The spec below is implemented by `plugins/credential-sharing` and surfaced through +`logicsrc credentials `. All four first providers (`env`, `doppler`, +`railway`, `github-secrets`) ship as provider adapters. + +```bash +# List adapters and their capabilities (which can read values vs. write-only) +logicsrc credentials providers + +# Inspect an endpoint — redacted key names + value fingerprints, never raw values +logicsrc credentials inspect --provider env --path .env + +# Diff a source against a target without moving anything +logicsrc credentials diff --from env --from-path .env --to railway \ + --to-project --to-config + +# Build a plan (stored under .logicsrc/credentials), then dry-run, then apply +logicsrc credentials plan --from env --from-path .env --to doppler \ + --to-project --to-config +logicsrc credentials sync --plan # dry-run (no writes) +logicsrc credentials sync --plan --approve # writes to the target + +# Audit and reverse a run (rollback emits a NEW plan) +logicsrc credentials audit --run --format markdown +logicsrc credentials rollback --run +``` + +SDK usage mirrors the spec via `createCredentialEngine()` from the plugin package. + +Implementation notes: +- Value fingerprints are salted SHA-256 (truncated) so two endpoints can be diffed + without revealing values; they are equality/integrity markers, not secret storage. +- `github-secrets` is write-only for values (GitHub never returns secret values), so + it cannot be a sync source or a value-restoring rollback target. Secret writes are + libsodium sealed-box encrypted against the repo/org/environment public key. +- Rollback captures the target's prior values into a 0600 vault under `.logicsrc/` + (gitignored) — the only place raw values touch disk. Plans, runs, and audit records + contain fingerprints only. + Credential Sharing is a LogicSRC OpenSpec for portable, auditable secret synchronization across local files and infrastructure providers. It is intended to replace closed, proprietary credential-sharing workflows with a provider-neutral contract. LogicSRC defines the open objects, CLI commands, SDK calls, TUI states, PWA states, provider adapter capabilities, and audit records. External products may consume this contract, but LogicSRC does not call out to product-specific commands. diff --git a/package-lock.json b/package-lock.json index c04d8d1..cb782bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1428,6 +1428,10 @@ "resolved": "packages/plugin-core", "link": true }, + "node_modules/@logicsrc/plugin-credential-sharing": { + "resolved": "plugins/credential-sharing", + "link": true + }, "node_modules/@logicsrc/plugin-email-accounts": { "resolved": "plugins/email-accounts", "link": true @@ -2457,6 +2461,13 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/libsodium-wrappers": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz", + "integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", @@ -3809,6 +3820,21 @@ "dayjs": "^1.11.7" } }, + "node_modules/libsodium": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.16.tgz", + "integrity": "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz", + "integrity": "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.7.16" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -6076,6 +6102,7 @@ "@logicsrc/account-core": "file:../account-core", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-core": "file:../plugin-core", + "@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing", "@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts", "@logicsrc/plugin-feed-discovery": "file:../../plugins/feed-discovery", "@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts", @@ -6195,6 +6222,19 @@ "vitest": "^4.0.8" } }, + "plugins/credential-sharing": { + "name": "@logicsrc/plugin-credential-sharing", + "version": "0.1.0", + "dependencies": { + "@logicsrc/account-core": "file:../../packages/account-core", + "@logicsrc/plugin-core": "file:../../packages/plugin-core", + "libsodium-wrappers": "^0.7.15" + }, + "devDependencies": { + "@types/libsodium-wrappers": "^0.7.14", + "vitest": "^4.0.8" + } + }, "plugins/email-accounts": { "name": "@logicsrc/plugin-email-accounts", "version": "0.1.0", diff --git a/package.json b/package.json index 2781603..53f288b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", diff --git a/packages/cli/package.json b/packages/cli/package.json index eaddef5..09d6bde 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,6 +17,7 @@ "@logicsrc/account-core": "file:../account-core", "@logicsrc/plugin-core": "file:../plugin-core", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", + "@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing", "@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts", "@logicsrc/plugin-feed-discovery": "file:../../plugins/feed-discovery", "@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 59f7dd8..73ad771 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core"; import { Command } from "commander"; +import { createCredentialEngine, listCredentialProviders, type CredentialEndpoint } from "@logicsrc/plugin-credential-sharing"; import { listEmailAccountProviders } from "@logicsrc/plugin-email-accounts"; import { discoverFeeds, listFeedProviders, probeSite, renderDiscoveryOutput, validateFeed, type FeedKind, type FeedOutputFormat } from "@logicsrc/plugin-feed-discovery"; import { listSocialAccountProviders } from "@logicsrc/plugin-social-accounts"; @@ -375,33 +376,153 @@ program.command("plugins").option("--format ", "table, json, or markdown print(snapshot.plugins, options.format as OutputFormat); }); -const credentials = program.command("credentials").alias("creds").description("Credential-sharing OpenSpec commands."); +const credentials = program.command("credentials").alias("creds").description("Credential Sharing OpenSpec: portable, auditable secret sync."); -credentials.command("providers").option("--format ", "table, json, or markdown", "table").description("List credential sharing provider targets.").action((options) => { - print( - [ - { id: "env", target: ".env files", mode: "read/write" }, - { id: "doppler", target: "Doppler projects/configs", mode: "sync" }, - { id: "railway", target: "Railway service variables", mode: "sync" }, - { id: "github-secrets", target: "GitHub Actions and environment secrets", mode: "sync" } - ], - options.format as OutputFormat - ); -}); +function endpointFromOptions(options: Record, prefix: "" | "from" | "to"): CredentialEndpoint { + const pick = (name: string) => { + const key = prefix ? `${prefix}${name[0].toUpperCase()}${name.slice(1)}` : name; + const value = options[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; + }; + // The provider id is stored under the bare flag: options.from / options.to / options.provider. + const providerValue = options[prefix ? prefix : "provider"]; + const provider = (typeof providerValue === "string" && providerValue.length > 0 ? providerValue : undefined) ?? (prefix === "to" ? "railway" : "env"); + return { provider, path: pick("path"), project: pick("project"), config: pick("config"), service: pick("service"), scope: pick("scope") }; +} -credentials.command("plan").option("--from ", "Source provider", "env").option("--to ", "Destination provider", "railway").option("--format ", "table, json, or markdown", "table").description("Describe a credential sync plan without moving secrets.").action((options) => { - print( - { - type: "logicsrc.credential_sync_plan", - from: options.from, - to: options.to, - policy: "redact-values", - approval: "required-before-write", - audit: "write target, key names, fingerprints, and timestamps; never write raw secret values" - }, - options.format as OutputFormat - ); -}); +function withEndpointOptions(command: import("commander").Command, prefix: "" | "from" | "to", help: string) { + const flag = (name: string) => (prefix ? `--${prefix}-${name}` : `--${name}`); + return command + .option(`${flag("path")} `, `${help} .env file path`) + .option(`${flag("project")} `, `${help} project (Doppler project, Railway projectId, GitHub owner)`) + .option(`${flag("config")} `, `${help} config (Doppler config, Railway environmentId, GitHub environment)`) + .option(`${flag("service")} `, `${help} service (Railway serviceId, GitHub repo)`) + .option(`${flag("scope")} `, `${help} scope (GitHub: repo|org|environment)`); +} + +function credentialEngine() { + return createCredentialEngine(); +} + +credentials + .command("providers") + .option("--format ", "table, json, or markdown", "table") + .description("List credential provider adapters and their capabilities.") + .action((options) => { + print( + listCredentialProviders().map((p) => ({ + id: p.id, + name: p.name, + status: p.status, + reads_values: p.capabilities.readValues, + writes: p.capabilities.write, + auth: p.authRequirements.join(", ") || "none" + })), + options.format as OutputFormat + ); + }); + +withEndpointOptions( + credentials.command("inspect").requiredOption("--provider ", "Provider id"), + "", + "Source" +) + .option("--format ", "table, json, or markdown", "table") + .description("Inspect an endpoint: redacted key names and value fingerprints, never raw values.") + .action(async (options) => { + const snapshot = await credentialEngine().inspectCredentialSource(endpointFromOptions(options, "")); + print(options.format === "json" ? snapshot : snapshot.keys, options.format as OutputFormat); + }); + +withEndpointOptions( + withEndpointOptions( + credentials.command("diff").requiredOption("--from ", "Source provider").requiredOption("--to ", "Destination provider"), + "from", + "Source" + ), + "to", + "Target" +) + .option("--redact", "Explicitly redact values (always on; accepted for spec parity)") + .option("--format ", "table, json, or markdown", "table") + .description("Diff secrets between a source and target without moving anything.") + .action(async (options) => { + const diff = await credentialEngine().diffCredentialEndpoints(endpointFromOptions(options, "from"), endpointFromOptions(options, "to")); + print(options.format === "json" ? diff : diff.entries, options.format as OutputFormat); + }); + +withEndpointOptions( + withEndpointOptions( + credentials.command("plan").requiredOption("--from ", "Source provider").requiredOption("--to ", "Destination provider"), + "from", + "Source" + ), + "to", + "Target" +) + .option("--format ", "table, json, or markdown", "json") + .description("Build a redacted sync plan (stored for later approve/sync).") + .action(async (options) => { + const plan = await credentialEngine().createCredentialSyncPlan({ + from: endpointFromOptions(options, "from"), + to: endpointFromOptions(options, "to") + }); + print(plan, options.format as OutputFormat); + }); + +credentials + .command("approve") + .requiredOption("--plan ", "Sync plan id") + .option("--keys ", "Comma-separated keys to approve (default: all changes)") + .option("--format ", "table, json, or markdown", "json") + .description("Record an approval for a sync plan.") + .action((options) => { + const approval = credentialEngine().approveCredentialSync(options.plan, { keys: splitOption(options.keys) }); + print(approval, options.format as OutputFormat); + }); + +credentials + .command("sync") + .requiredOption("--plan ", "Sync plan id") + .option("--approve", "Approve and apply the plan (writes secrets)") + .option("--apply", "Apply the plan (alias for committing the write)") + .option("--format ", "table, json, or markdown", "json") + .description("Run a sync plan. Dry-run by default; --approve/--apply writes to the target.") + .action(async (options) => { + const engine = credentialEngine(); + const apply = Boolean(options.approve || options.apply); + const approval = apply ? engine.approveCredentialSync(options.plan) : undefined; + const run = await engine.runCredentialSync(options.plan, { dryRun: !apply, approval }); + print(run, options.format as OutputFormat); + }); + +credentials + .command("rollback") + .requiredOption("--run ", "Sync run id to reverse") + .option("--format ", "table, json, or markdown", "json") + .description("Create a new sync plan that restores a run's captured pre-image.") + .action(async (options) => { + const plan = await credentialEngine().rollbackCredentialSync(options.run); + print(plan, options.format as OutputFormat); + }); + +credentials + .command("audit") + .requiredOption("--run ", "Sync run id") + .option("--format ", "table, json, or markdown", "table") + .description("Export the audit trail for a run (key names, targets, fingerprints, timestamps).") + .action((options) => { + print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat); + }); + +credentials + .command("export") + .requiredOption("--run ", "Sync run id") + .option("--format ", "table, json, or markdown", "json") + .description("Alias for audit: export a run's audit events.") + .action((options) => { + print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat); + }); const accounts = program.command("accounts").description("Manage connected social and email accounts."); diff --git a/packages/cli/src/registry.ts b/packages/cli/src/registry.ts index 090a5f0..10c38e1 100644 --- a/packages/cli/src/registry.ts +++ b/packages/cli/src/registry.ts @@ -1,10 +1,11 @@ import { createPluginRegistry } from "@logicsrc/plugin-core"; import { coinPayPlugin } from "@logicsrc/plugin-coinpay"; +import { credentialSharingPlugin } from "@logicsrc/plugin-credential-sharing"; import { emailAccountsPlugin } from "@logicsrc/plugin-email-accounts"; import { feedDiscoveryPlugin } from "@logicsrc/plugin-feed-discovery"; import { socialAccountsPlugin } from "@logicsrc/plugin-social-accounts"; import { uGigPlugin } from "@logicsrc/plugin-ugig"; export function defaultPluginRegistry() { - return createPluginRegistry([coinPayPlugin, uGigPlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin]); + return createPluginRegistry([coinPayPlugin, uGigPlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin, credentialSharingPlugin]); } diff --git a/packages/schemas/package.json b/packages/schemas/package.json index fe4d7b1..11c821f 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -32,7 +32,11 @@ "./agentad-ad-response": "./schemas/agentad-ad-response.schema.json", "./agentad-impression": "./schemas/agentad-impression.schema.json", "./agentad-click": "./schemas/agentad-click.schema.json", - "./agentad-campaign": "./schemas/agentad-campaign.schema.json" + "./agentad-campaign": "./schemas/agentad-campaign.schema.json", + "./credential-provider": "./schemas/logicsrc-credential-provider.schema.json", + "./credential-sync-plan": "./schemas/logicsrc-credential-sync-plan.schema.json", + "./credential-sync-run": "./schemas/logicsrc-credential-sync-run.schema.json", + "./credential-audit-event": "./schemas/logicsrc-credential-audit-event.schema.json" }, "files": [ "schemas" diff --git a/packages/schemas/schemas/logicsrc-credential-audit-event.schema.json b/packages/schemas/schemas/logicsrc-credential-audit-event.schema.json new file mode 100644 index 0000000..bd5382c --- /dev/null +++ b/packages/schemas/schemas/logicsrc-credential-audit-event.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-credential-audit-event.schema.json", + "title": "LogicSRC Credential Audit Event", + "type": "object", + "required": ["type", "id", "provider", "action", "key", "target", "principal", "decision", "dryRun", "createdAt"], + "additionalProperties": false, + "properties": { + "type": { "const": "logicsrc.credential_audit_event" }, + "id": { "type": "string", "minLength": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "planId": { "type": "string", "minLength": 1 }, + "provider": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "action": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)+$" }, + "key": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "fingerprint": { "type": "string" }, + "principal": { + "type": "object", + "required": ["type", "id"], + "additionalProperties": false, + "properties": { + "type": { "enum": ["user", "agent", "workflow", "plugin"] }, + "id": { "type": "string", "minLength": 1 }, + "trusted": { "type": "boolean" } + } + }, + "decision": { "enum": ["allow", "approval_required", "deny"] }, + "dryRun": { "type": "boolean" }, + "createdAt": { "type": "string", "format": "date-time" } + } +} diff --git a/packages/schemas/schemas/logicsrc-credential-provider.schema.json b/packages/schemas/schemas/logicsrc-credential-provider.schema.json new file mode 100644 index 0000000..1bdb167 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-credential-provider.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-credential-provider.schema.json", + "title": "LogicSRC Credential Provider", + "type": "object", + "required": ["id", "name", "description", "capabilities", "authRequirements", "status"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "capabilities": { + "type": "object", + "required": ["readValues", "readNames", "write", "delete", "rollback", "audit"], + "additionalProperties": false, + "properties": { + "readValues": { "type": "boolean" }, + "readNames": { "type": "boolean" }, + "write": { "type": "boolean" }, + "delete": { "type": "boolean" }, + "rollback": { "type": "boolean" }, + "audit": { "type": "boolean" } + } + }, + "authRequirements": { "type": "array", "items": { "type": "string" } }, + "status": { "enum": ["available", "planned"] } + } +} diff --git a/packages/schemas/schemas/logicsrc-credential-sync-plan.schema.json b/packages/schemas/schemas/logicsrc-credential-sync-plan.schema.json new file mode 100644 index 0000000..0cbb1f8 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-credential-sync-plan.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-credential-sync-plan.schema.json", + "title": "LogicSRC Credential Sync Plan", + "type": "object", + "required": ["type", "id", "from", "to", "policy", "changes", "requiresApproval", "createdAt"], + "additionalProperties": false, + "$defs": { + "endpoint": { + "type": "object", + "required": ["provider"], + "additionalProperties": false, + "properties": { + "provider": { "type": "string", "minLength": 1 }, + "path": { "type": "string" }, + "project": { "type": "string" }, + "config": { "type": "string" }, + "service": { "type": "string" }, + "scope": { "type": "string" }, + "metadata": { "type": "object" } + } + }, + "change": { + "type": "object", + "required": ["key", "op", "destructive"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "minLength": 1 }, + "op": { "enum": ["add", "update", "remove", "unchanged", "unknown"] }, + "sourceFingerprint": { "type": "string" }, + "targetFingerprint": { "type": "string" }, + "destructive": { "type": "boolean" } + } + } + }, + "properties": { + "type": { "const": "logicsrc.credential_sync_plan" }, + "id": { "type": "string", "minLength": 1 }, + "from": { "$ref": "#/$defs/endpoint" }, + "to": { "$ref": "#/$defs/endpoint" }, + "policy": { + "type": "object", + "required": ["redactValues", "requireApprovalForDestructive"], + "additionalProperties": false, + "properties": { + "redactValues": { "const": true }, + "requireApprovalForDestructive": { "type": "boolean" }, + "denyKeys": { "type": "array", "items": { "type": "string" } } + } + }, + "changes": { "type": "array", "items": { "$ref": "#/$defs/change" } }, + "requiresApproval": { "type": "boolean" }, + "rollbackOfRunId": { "type": "string", "minLength": 1 }, + "createdAt": { "type": "string", "format": "date-time" } + } +} diff --git a/packages/schemas/schemas/logicsrc-credential-sync-run.schema.json b/packages/schemas/schemas/logicsrc-credential-sync-run.schema.json new file mode 100644 index 0000000..7ade6bc --- /dev/null +++ b/packages/schemas/schemas/logicsrc-credential-sync-run.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-credential-sync-run.schema.json", + "title": "LogicSRC Credential Sync Run", + "type": "object", + "required": ["type", "id", "planId", "status", "dryRun", "results", "auditEventIds", "reversible", "startedAt", "finishedAt"], + "additionalProperties": false, + "properties": { + "type": { "const": "logicsrc.credential_sync_run" }, + "id": { "type": "string", "minLength": 1 }, + "planId": { "type": "string", "minLength": 1 }, + "status": { "enum": ["planned", "dry_run", "applied", "partial", "failed", "rolled_back"] }, + "dryRun": { "type": "boolean" }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "op", "applied", "dryRun"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "minLength": 1 }, + "op": { "enum": ["add", "update", "remove", "unchanged", "unknown"] }, + "applied": { "type": "boolean" }, + "dryRun": { "type": "boolean" }, + "targetFingerprint": { "type": "string" }, + "error": { "type": "string" } + } + } + }, + "auditEventIds": { "type": "array", "items": { "type": "string" } }, + "reversible": { "type": "boolean" }, + "startedAt": { "type": "string", "format": "date-time" }, + "finishedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index 748dc98..ab6e73a 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -18,6 +18,10 @@ import agentadAdResponseSchema from "../../schemas/schemas/agentad-ad-response.s import agentadImpressionSchema from "../../schemas/schemas/agentad-impression.schema.json" with { type: "json" }; import agentadClickSchema from "../../schemas/schemas/agentad-click.schema.json" with { type: "json" }; import agentadCampaignSchema from "../../schemas/schemas/agentad-campaign.schema.json" with { type: "json" }; +import credentialProviderSchema from "../../schemas/schemas/logicsrc-credential-provider.schema.json" with { type: "json" }; +import credentialSyncPlanSchema from "../../schemas/schemas/logicsrc-credential-sync-plan.schema.json" with { type: "json" }; +import credentialSyncRunSchema from "../../schemas/schemas/logicsrc-credential-sync-run.schema.json" with { type: "json" }; +import credentialAuditEventSchema from "../../schemas/schemas/logicsrc-credential-audit-event.schema.json" with { type: "json" }; export const schemas = { agent: agentSchema, @@ -39,7 +43,11 @@ export const schemas = { "agentad-ad-response": agentadAdResponseSchema, "agentad-impression": agentadImpressionSchema, "agentad-click": agentadClickSchema, - "agentad-campaign": agentadCampaignSchema + "agentad-campaign": agentadCampaignSchema, + "credential-provider": credentialProviderSchema, + "credential-sync-plan": credentialSyncPlanSchema, + "credential-sync-run": credentialSyncRunSchema, + "credential-audit-event": credentialAuditEventSchema } as const; export type SchemaKind = keyof typeof schemas; diff --git a/plugins/credential-sharing/package.json b/plugins/credential-sharing/package.json new file mode 100644 index 0000000..f7db1e3 --- /dev/null +++ b/plugins/credential-sharing/package.json @@ -0,0 +1,21 @@ +{ + "name": "@logicsrc/plugin-credential-sharing", + "version": "0.1.0", + "description": "LogicSRC Credential Sharing OpenSpec plugin: portable, auditable secret sync across .env, Doppler, Railway, and GitHub Secrets.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run src --passWithNoTests" + }, + "dependencies": { + "@logicsrc/account-core": "file:../../packages/account-core", + "@logicsrc/plugin-core": "file:../../packages/plugin-core", + "libsodium-wrappers": "^0.7.15" + }, + "devDependencies": { + "@types/libsodium-wrappers": "^0.7.14", + "vitest": "^4.0.8" + } +} diff --git a/plugins/credential-sharing/src/engine.test.ts b/plugins/credential-sharing/src/engine.test.ts new file mode 100644 index 0000000..dafe7d4 --- /dev/null +++ b/plugins/credential-sharing/src/engine.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { CredentialEngine } from "./engine.js"; +import { createMemoryCredentialStore } from "./store.js"; +import { fingerprintValue } from "./fingerprint.js"; +import type { CredentialProvider, CredentialValueBag } from "./types.js"; + +/** In-memory provider backed by a mutable bag — stands in for env/doppler/railway. */ +function memoryProvider(id: string, initial: CredentialValueBag, opts: { readValues?: boolean } = {}): CredentialProvider & { store: CredentialValueBag } { + const store: CredentialValueBag = { ...initial }; + const readValues = opts.readValues ?? true; + return { + id, + name: id, + description: id, + capabilities: { readValues, readNames: true, write: true, delete: true, rollback: readValues, audit: false }, + authRequirements: [], + status: "available", + store, + async inspect(endpoint) { + return { + provider: id, + endpoint, + valuesReadable: readValues, + keys: Object.keys(store) + .sort() + .map((name) => ({ name, present: true, fingerprint: readValues ? fingerprintValue(store[name]) : undefined })), + inspectedAt: new Date().toISOString() + }; + }, + async readValues(_endpoint, keys) { + return Object.fromEntries(keys.filter((k) => k in store).map((k) => [k, store[k]])); + }, + async write({ upserts, deletes, dryRun }) { + const results = [ + ...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })), + ...deletes.map((key) => ({ key, applied: !dryRun })) + ]; + if (!dryRun) { + Object.assign(store, upserts); + for (const key of deletes) delete store[key]; + } + return results; + } + }; +} + +function makeEngine(providers: CredentialProvider[]) { + let counter = 0; + return new CredentialEngine({ + providers: new Map(providers.map((p) => [p.id, p])), + store: createMemoryCredentialStore(), + now: () => new Date("2026-06-27T00:00:00.000Z"), + idFactory: (prefix) => `${prefix}_${++counter}` + }); +} + +describe("CredentialEngine diff + plan", () => { + it("classifies add / update / unchanged by fingerprint", async () => { + const source = memoryProvider("env", { A: "1", B: "2", SAME: "x" }); + const target = memoryProvider("railway", { B: "old", SAME: "x" }); + const engine = makeEngine([source, target]); + + const diff = await engine.diffCredentialEndpoints({ provider: "env" }, { provider: "railway" }); + const byKey = Object.fromEntries(diff.entries.map((e) => [e.key, e.op])); + expect(byKey).toEqual({ A: "add", B: "update", SAME: "unchanged" }); + }); + + it("never includes raw values in a plan", async () => { + const source = memoryProvider("env", { SECRET: "super-secret-value" }); + const target = memoryProvider("railway", {}); + const engine = makeEngine([source, target]); + + const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } }); + expect(JSON.stringify(plan)).not.toContain("super-secret-value"); + expect(plan.changes[0]).toMatchObject({ key: "SECRET", op: "add" }); + expect(plan.changes[0].sourceFingerprint).toBe(fingerprintValue("super-secret-value")); + }); +}); + +describe("CredentialEngine sync safety", () => { + it("dry-run does not mutate the target", async () => { + const source = memoryProvider("env", { A: "1" }); + const target = memoryProvider("railway", {}); + const engine = makeEngine([source, target]); + + const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } }); + const run = await engine.runCredentialSync(plan.id, { dryRun: true }); + expect(run.status).toBe("dry_run"); + expect(target.store).toEqual({}); + }); + + it("requires approval before a destructive apply", async () => { + const source = memoryProvider("env", { A: "new" }); + const target = memoryProvider("railway", { A: "old" }); + const engine = makeEngine([source, target]); + + const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } }); + expect(plan.requiresApproval).toBe(true); + await expect(engine.runCredentialSync(plan.id, { dryRun: false })).rejects.toThrow(/requires approval/); + + const approval = engine.approveCredentialSync(plan.id); + const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); + expect(run.status).toBe("applied"); + expect(target.store.A).toBe("new"); + }); + + it("refuses a write-only provider as a sync source", async () => { + const source = memoryProvider("github-secrets", { A: "1" }, { readValues: false }); + const target = memoryProvider("railway", {}); + const engine = makeEngine([source, target]); + await expect(engine.createCredentialSyncPlan({ from: { provider: "github-secrets" }, to: { provider: "railway" } })).rejects.toThrow(/cannot read values/); + }); +}); + +describe("CredentialEngine rollback + audit", () => { + it("rolls back updates to the pre-image and removes newly added keys", async () => { + const source = memoryProvider("env", { A: "new", ADDED: "fresh" }); + const target = memoryProvider("railway", { A: "original" }); + const engine = makeEngine([source, target]); + + const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } }); + const approval = engine.approveCredentialSync(plan.id); + const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); + expect(target.store).toEqual({ A: "new", ADDED: "fresh" }); + + const rollbackPlan = await engine.rollbackCredentialSync(run.id); + expect(rollbackPlan.rollbackOfRunId).toBe(run.id); + const rollbackApproval = engine.approveCredentialSync(rollbackPlan.id); + await engine.runCredentialSync(rollbackPlan.id, { dryRun: false, approval: rollbackApproval }); + expect(target.store).toEqual({ A: "original" }); + }); + + it("writes audit events with fingerprints, never raw values", async () => { + const source = memoryProvider("env", { TOKEN: "raw-token-abc" }); + const target = memoryProvider("railway", {}); + const engine = makeEngine([source, target]); + + const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } }); + const run = await engine.runCredentialSync(plan.id, { dryRun: false }); + const audit = engine.exportCredentialAudit(run.id); + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ key: "TOKEN", action: "credentials:add", fingerprint: fingerprintValue("raw-token-abc") }); + expect(JSON.stringify(audit)).not.toContain("raw-token-abc"); + }); +}); diff --git a/plugins/credential-sharing/src/engine.ts b/plugins/credential-sharing/src/engine.ts new file mode 100644 index 0000000..5796743 --- /dev/null +++ b/plugins/credential-sharing/src/engine.ts @@ -0,0 +1,358 @@ +import type { LogicSrcPrincipal, LogicSrcPolicyDecision } from "@logicsrc/account-core"; +import { fingerprintValue, fingerprintsEqual } from "./fingerprint.js"; +import { createFileCredentialStore, type CredentialStore } from "./store.js"; +import { credentialProviderRegistry } from "./providers/index.js"; +import type { + CredentialEndpoint, + CredentialProvider, + CredentialSnapshot, + CredentialDiff, + CredentialDiffEntry, + CredentialPolicy, + CredentialSyncPlan, + CredentialApproval, + CredentialSyncRun, + CredentialKeyResult, + CredentialAuditEvent, + CredentialValueBag, + CredentialProviderManifest +} from "./types.js"; + +export interface CredentialEngineOptions { + providers?: Map; + store?: CredentialStore; + principal?: LogicSrcPrincipal; + now?: () => Date; + idFactory?: (prefix: string) => string; +} + +export const DEFAULT_CREDENTIAL_POLICY: CredentialPolicy = { + redactValues: true, + requireApprovalForDestructive: true +}; + +const DEFAULT_PRINCIPAL: LogicSrcPrincipal = { type: "user", id: "local" }; + +function defaultId(prefix: string): string { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +export class CredentialEngine { + private readonly providers: Map; + private readonly store: CredentialStore; + private readonly principal: LogicSrcPrincipal; + private readonly now: () => Date; + private readonly id: (prefix: string) => string; + + constructor(options: CredentialEngineOptions = {}) { + this.providers = options.providers ?? credentialProviderRegistry; + this.store = options.store ?? createFileCredentialStore(); + this.principal = options.principal ?? DEFAULT_PRINCIPAL; + this.now = options.now ?? (() => new Date()); + this.id = options.idFactory ?? defaultId; + } + + private iso(): string { + return this.now().toISOString(); + } + + private requireProvider(id: string): CredentialProvider { + const provider = this.providers.get(id); + if (!provider) { + throw new Error(`Unknown credential provider: ${id}. Run "logicsrc credentials providers" to list available adapters.`); + } + return provider; + } + + listCredentialProviders(): CredentialProviderManifest[] { + return [...this.providers.values()].map(({ id, name, description, capabilities, authRequirements, status }) => ({ + id, + name, + description, + capabilities, + authRequirements, + status + })); + } + + async inspectCredentialSource(endpoint: CredentialEndpoint): Promise { + return this.requireProvider(endpoint.provider).inspect(endpoint); + } + + /** Compare a source against a target without moving any secret. */ + async diffCredentialEndpoints(from: CredentialEndpoint, to: CredentialEndpoint): Promise { + const source = this.requireProvider(from.provider); + const target = this.requireProvider(to.provider); + const [sourceSnapshot, targetSnapshot] = await Promise.all([source.inspect(from), target.inspect(to)]); + + const targetByName = new Map(targetSnapshot.keys.map((key) => [key.name, key])); + const entries: CredentialDiffEntry[] = []; + + for (const sourceKey of sourceSnapshot.keys) { + const targetKey = targetByName.get(sourceKey.name); + if (!targetKey) { + entries.push({ key: sourceKey.name, op: "add", sourceFingerprint: sourceKey.fingerprint, destructive: false }); + continue; + } + const comparable = sourceKey.fingerprint !== undefined && targetKey.fingerprint !== undefined; + if (comparable && fingerprintsEqual(sourceKey.fingerprint, targetKey.fingerprint)) { + entries.push({ + key: sourceKey.name, + op: "unchanged", + sourceFingerprint: sourceKey.fingerprint, + targetFingerprint: targetKey.fingerprint, + destructive: false + }); + continue; + } + entries.push({ + key: sourceKey.name, + // Present in target but values are not comparable (write-only target): "unknown". + op: comparable ? "update" : "unknown", + sourceFingerprint: sourceKey.fingerprint, + targetFingerprint: targetKey.fingerprint, + destructive: true + }); + } + + return { from, to, redacted: true, entries, createdAt: this.iso() }; + } + + /** Build a sync plan from source -> target. Source must expose readable values. */ + async createCredentialSyncPlan(input: { + from: CredentialEndpoint; + to: CredentialEndpoint; + policy?: Partial; + }): Promise { + const source = this.requireProvider(input.from.provider); + const target = this.requireProvider(input.to.provider); + + if (!source.capabilities.readValues) { + throw new Error(`Provider "${source.id}" cannot read values, so it cannot be a sync source.`); + } + if (!target.capabilities.write) { + throw new Error(`Provider "${target.id}" is read-only and cannot be a sync target.`); + } + + const policy: CredentialPolicy = { ...DEFAULT_CREDENTIAL_POLICY, ...input.policy }; + const diff = await this.diffCredentialEndpoints(input.from, input.to); + + const denied = new Set(policy.denyKeys ?? []); + const changes = diff.entries.filter((entry) => entry.op !== "unchanged" && !denied.has(entry.key)); + const requiresApproval = policy.requireApprovalForDestructive && changes.some((change) => change.destructive); + + const plan: CredentialSyncPlan = { + type: "logicsrc.credential_sync_plan", + id: this.id("cred_plan"), + from: input.from, + to: input.to, + policy, + changes, + requiresApproval, + createdAt: this.iso() + }; + this.store.savePlan(plan); + return plan; + } + + approveCredentialSync(planId: string, approval: { approver?: LogicSrcPrincipal; keys?: string[] } = {}): CredentialApproval { + const plan = this.store.getPlan(planId); + if (!plan) { + throw new Error(`Unknown credential sync plan: ${planId}`); + } + return { + type: "logicsrc.credential_approval", + id: this.id("cred_approval"), + planId, + approver: approval.approver ?? this.principal, + approvedKeys: approval.keys ?? [], + approvedAt: this.iso() + }; + } + + /** Execute a plan. Dry-run by default — pass `dryRun: false` to write. */ + async runCredentialSync( + planId: string, + options: { dryRun?: boolean; approval?: CredentialApproval } = {} + ): Promise { + const plan = this.store.getPlan(planId); + if (!plan) { + throw new Error(`Unknown credential sync plan: ${planId}`); + } + const dryRun = options.dryRun ?? true; + + if (!dryRun && plan.requiresApproval) { + this.assertApprovalCovers(plan, options.approval); + } + + const target = this.requireProvider(plan.to.provider); + const upsertKeys = plan.changes.filter((c) => c.op === "add" || c.op === "update" || c.op === "unknown").map((c) => c.key); + const deleteKeys = plan.changes.filter((c) => c.op === "remove").map((c) => c.key); + + // Resolve the values to write. A rollback plan pulls from the origin run's vault. + const upserts = await this.resolveSourceValues(plan, upsertKeys); + + // Capture a rollback pre-image of the target's current values, when readable. + const reversible = target.capabilities.readValues && target.capabilities.write && upsertKeys.length > 0; + const runId = this.id("cred_run"); + if (reversible && !dryRun && target.readValues) { + const preImage = await target.readValues(plan.to, [...upsertKeys, ...deleteKeys]); + this.store.saveVault(runId, preImage); + } + + const writeResults = await target.write({ endpoint: plan.to, upserts, deletes: deleteKeys, dryRun }); + const writeByKey = new Map(writeResults.map((r) => [r.key, r])); + + const decision: LogicSrcPolicyDecision = dryRun ? "allow" : plan.requiresApproval ? "approval_required" : "allow"; + const targetLabel = endpointLabel(plan.to); + const results: CredentialKeyResult[] = []; + const auditEvents: CredentialAuditEvent[] = []; + + for (const change of plan.changes) { + const written = writeByKey.get(change.key); + const applied = !dryRun && (written?.applied ?? false); + const fingerprint = upserts[change.key] !== undefined ? fingerprintValue(upserts[change.key]) : change.sourceFingerprint; + results.push({ + key: change.key, + op: change.op, + applied, + dryRun, + targetFingerprint: change.op === "remove" ? undefined : fingerprint, + error: written?.error + }); + auditEvents.push({ + type: "logicsrc.credential_audit_event", + id: this.id("cred_audit"), + runId, + planId: plan.id, + provider: plan.to.provider, + action: `credentials:${change.op}`, + key: change.key, + target: targetLabel, + fingerprint: change.op === "remove" ? undefined : fingerprint, + principal: options.approval?.approver ?? this.principal, + decision, + dryRun, + createdAt: this.iso() + }); + } + + const anyError = results.some((r) => r.error); + const status = dryRun + ? "dry_run" + : anyError + ? results.some((r) => r.applied) + ? "partial" + : "failed" + : "applied"; + + const run: CredentialSyncRun = { + type: "logicsrc.credential_sync_run", + id: runId, + planId: plan.id, + status, + dryRun, + results, + auditEventIds: auditEvents.map((e) => e.id), + reversible: reversible && !dryRun, + startedAt: plan.createdAt, + finishedAt: this.iso() + }; + this.store.saveRun(run); + this.store.saveAudit(runId, auditEvents); + return run; + } + + /** Produce a NEW plan that reverses a run by restoring its captured pre-image. */ + async rollbackCredentialSync(runId: string): Promise { + const run = this.store.getRun(runId); + if (!run) { + throw new Error(`Unknown credential sync run: ${runId}`); + } + if (!run.reversible) { + throw new Error(`Run ${runId} was not reversible (no pre-image captured). Rollbacks require a value-readable target.`); + } + const preImage = this.store.getVault(runId); + if (!preImage) { + throw new Error(`No rollback pre-image found for run ${runId}.`); + } + const originPlan = this.store.getPlan(run.planId); + if (!originPlan) { + throw new Error(`Origin plan ${run.planId} for run ${runId} is missing.`); + } + + // Restore prior values for keys that existed before the run... + const restores: CredentialDiffEntry[] = Object.keys(preImage) + .sort() + .map((key) => ({ key, op: "update", sourceFingerprint: fingerprintValue(preImage[key]), destructive: true })); + // ...and delete keys the run newly added (no prior value to restore). + const deletions: CredentialDiffEntry[] = run.results + .filter((result) => result.op === "add" && result.applied && !(result.key in preImage)) + .map((result) => ({ key: result.key, op: "remove" as const, destructive: true })); + const changes: CredentialDiffEntry[] = [...restores, ...deletions]; + + const plan: CredentialSyncPlan = { + type: "logicsrc.credential_sync_plan", + id: this.id("cred_plan"), + from: { provider: originPlan.to.provider, metadata: { rollbackVault: runId } }, + to: originPlan.to, + policy: { ...DEFAULT_CREDENTIAL_POLICY }, + changes, + requiresApproval: true, + rollbackOfRunId: runId, + createdAt: this.iso() + }; + this.store.savePlan(plan); + return plan; + } + + exportCredentialAudit(runId: string): CredentialAuditEvent[] { + return this.store.getAudit(runId); + } + + // --- internals ----------------------------------------------------------- + + private assertApprovalCovers(plan: CredentialSyncPlan, approval?: CredentialApproval): void { + if (!approval) { + throw new Error(`Plan ${plan.id} requires approval before writing. Run "logicsrc credentials approve --plan ${plan.id}".`); + } + if (approval.planId !== plan.id) { + throw new Error(`Approval ${approval.id} is for plan ${approval.planId}, not ${plan.id}.`); + } + if (approval.approvedKeys.length === 0) { + return; // empty = approve all changes + } + const approved = new Set(approval.approvedKeys); + const missing = plan.changes.filter((c) => c.destructive && !approved.has(c.key)).map((c) => c.key); + if (missing.length > 0) { + throw new Error(`Approval does not cover destructive keys: ${missing.join(", ")}`); + } + } + + private async resolveSourceValues(plan: CredentialSyncPlan, keys: string[]): Promise { + if (keys.length === 0) { + return {}; + } + if (plan.rollbackOfRunId) { + const preImage = this.store.getVault(plan.rollbackOfRunId); + if (!preImage) { + throw new Error(`Rollback plan ${plan.id} references missing vault for run ${plan.rollbackOfRunId}.`); + } + return Object.fromEntries(keys.filter((k) => k in preImage).map((k) => [k, preImage[k]])); + } + const source = this.requireProvider(plan.from.provider); + if (!source.readValues) { + throw new Error(`Provider "${source.id}" cannot read values needed to apply the plan.`); + } + return source.readValues(plan.from, keys); + } +} + +export function endpointLabel(endpoint: CredentialEndpoint): string { + const parts = [endpoint.provider]; + if (endpoint.path) parts.push(endpoint.path); + if (endpoint.project) parts.push(endpoint.project); + if (endpoint.config) parts.push(endpoint.config); + if (endpoint.service) parts.push(endpoint.service); + return parts.join(":"); +} diff --git a/plugins/credential-sharing/src/fingerprint.ts b/plugins/credential-sharing/src/fingerprint.ts new file mode 100644 index 0000000000000000000000000000000000000000..72c23f21f2343c04a7e7ede0b680ef7483a85c40 GIT binary patch literal 1908 zcmbVNU2oeq6y39b#f_h&Op(_CD+XsSkT&TW3`++$*o$Edv~*>$l}U}H>==oG{fPa- z{gRz4*^ZEAdDx3VQsn(O=iFnxs;vujPnlyC_=ECgLP4*zS$dyyiPM7tobs=zsY`fzdf z{SU7xxW;7YFQ;JX)oUvGjzCq5x7}x|s&LB4B$IkZ@b#O^r?`;3xA+CXD6+qM`_a#O5ml_ zVLM`>7z>HEQtk`8q|K(NR^D%{%c+Da=%2s-CIdno>VQ;82U3FAMr|dT2Sxz0CBq93 zqd{hk5A^=}=I5*FZ`13W+dnQp{(5nJLlYWScBwP(vJ~X)b(8j}P1mnP9MUJ$S%AEt zt?47)sw`@CGDdRfVHqD5bUz?89U5nPx{ijvR1m{3PMbQ17^1IBJAN8|^LVXIlF+aT z#g916^^$#vhR_gpzS0>--z890d_LH{ShH`(DN@HeAE+@oqi@a=T0D1*o)*?t49cV_ zT~KtQ=;47*7V@joip?^VbTXOHV!w&ot49P1Re3(g`Gx45(UE z9KQ(Ajo{p&eUQp*{xT@DG)<)*KmUJN9NX(=d3tqu@$UBOQYLtIP{DlI**HzPu2jY( zCb9P+_zWxp%+p3grI14_9hv3MYbX+YJpO8ggtg|4e;$PHAyn|PK&G6*v3}|dA=w+l zn^q@X3$_V$1E&Au;)L#ZGAmM5)V%; +} + +async function fetchSecrets(endpoint: CredentialEndpoint): Promise { + const data = await httpJson(`${DOPPLER_API}/configs/config/secrets${scopeQuery(endpoint)}`, { + headers: { Authorization: auth(), accept: "application/json" }, + expect: "Doppler list secrets" + }); + const out: CredentialValueBag = {}; + for (const [name, value] of Object.entries(data.secrets ?? {})) { + out[name] = value.raw ?? value.computed ?? ""; + } + return out; +} + +export const dopplerProvider: CredentialProvider = { + id: "doppler", + name: "Doppler", + description: "Sync project/config scoped secrets.", + capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: false }, + authRequirements: ["DOPPLER_TOKEN"], + status: "available", + + async inspect(endpoint) { + const values = await fetchSecrets(endpoint); + return { provider: "doppler", endpoint, valuesReadable: true, keys: keysFromValues(values), inspectedAt: new Date().toISOString() }; + }, + + async readValues(endpoint, keys) { + const values = await fetchSecrets(endpoint); + return Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]])); + }, + + async write({ endpoint, upserts, deletes, dryRun }) { + const results: CredentialWriteResult[] = [ + ...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })), + ...deletes.map((key) => ({ key, applied: !dryRun })) + ]; + if (dryRun) { + return results; + } + // Doppler deletes a secret when its value is set to null. + const secrets: Record = { ...upserts }; + for (const key of deletes) { + secrets[key] = null; + } + await httpJson(`${DOPPLER_API}/configs/config/secrets`, { + method: "POST", + headers: { Authorization: auth(), "content-type": "application/json" }, + body: JSON.stringify({ project: endpoint.project, config: endpoint.config, secrets }), + expect: "Doppler update secrets" + }); + return results; + } +}; diff --git a/plugins/credential-sharing/src/providers/env.test.ts b/plugins/credential-sharing/src/providers/env.test.ts new file mode 100644 index 0000000..8895ce4 --- /dev/null +++ b/plugins/credential-sharing/src/providers/env.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseEnv, applyEnv } from "./env.js"; + +describe("env provider parsing", () => { + it("parses plain, quoted, and exported lines and ignores comments", () => { + const body = ["# comment", "A=1", 'B="two words"', "export C=three", "", "D='quoted'"].join("\n"); + expect(parseEnv(body)).toEqual({ A: "1", B: "two words", C: "three", D: "quoted" }); + }); + + it("decodes escaped newlines inside double quotes", () => { + expect(parseEnv('KEY="line1\\nline2"')).toEqual({ KEY: "line1\nline2" }); + }); +}); + +describe("env provider merge", () => { + it("updates existing keys in place and appends new ones", () => { + const body = "# header\nA=1\nB=2\n"; + const next = applyEnv(body, { A: "10", C: "3" }, []); + expect(next).toBe("# header\nA=10\nB=2\nC=3\n"); + }); + + it("removes deleted keys but preserves comments", () => { + const body = "# header\nA=1\nB=2\n"; + expect(applyEnv(body, {}, ["B"])).toBe("# header\nA=1\n"); + }); + + it("quotes values that need it", () => { + expect(applyEnv("", { A: "two words" }, [])).toBe('A="two words"\n'); + }); +}); diff --git a/plugins/credential-sharing/src/providers/env.ts b/plugins/credential-sharing/src/providers/env.ts new file mode 100644 index 0000000..fa227db --- /dev/null +++ b/plugins/credential-sharing/src/providers/env.ts @@ -0,0 +1,142 @@ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { keysFromValues } from "../fingerprint.js"; +import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js"; + +const QUOTED = /^(['"])(.*)\1$/s; + +/** Parse a `.env` file body into a value bag. Supports quotes and `export` prefixes. */ +export function parseEnv(body: string): CredentialValueBag { + const out: CredentialValueBag = {}; + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line; + const eq = withoutExport.indexOf("="); + if (eq === -1) { + continue; + } + const key = withoutExport.slice(0, eq).trim(); + if (!key) { + continue; + } + let value = withoutExport.slice(eq + 1).trim(); + const quoted = QUOTED.exec(value); + if (quoted) { + value = quoted[2]; + if (quoted[1] === '"') { + value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"'); + } + } + out[key] = value; + } + return out; +} + +function needsQuoting(value: string): boolean { + return /[\s#'"=]|^$/.test(value) || value.includes("\n"); +} + +function serializeValue(value: string): string { + if (!needsQuoting(value)) { + return value; + } + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`; +} + +/** Merge upserts/deletes into an existing `.env` body, preserving comments and order. */ +export function applyEnv(body: string, upserts: CredentialValueBag, deletes: string[]): string { + const deleteSet = new Set(deletes); + const remaining = new Map(Object.entries(upserts)); + const lines = body.split(/\r?\n/); + const output: string[] = []; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("#")) { + output.push(rawLine); + continue; + } + const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length) : trimmed; + const eq = withoutExport.indexOf("="); + const key = eq === -1 ? "" : withoutExport.slice(0, eq).trim(); + if (key && deleteSet.has(key)) { + continue; + } + if (key && remaining.has(key)) { + output.push(`${key}=${serializeValue(remaining.get(key) as string)}`); + remaining.delete(key); + continue; + } + output.push(rawLine); + } + + if (remaining.size > 0) { + // Drop the blank line(s) a trailing newline left behind so new keys append cleanly. + while (output.length > 0 && output[output.length - 1].trim() === "") { + output.pop(); + } + for (const [key, value] of remaining) { + output.push(`${key}=${serializeValue(value)}`); + } + } + + let result = output.join("\n"); + if (!result.endsWith("\n")) { + result += "\n"; + } + return result; +} + +function endpointPath(endpoint: CredentialEndpoint): string { + return resolve(process.cwd(), endpoint.path ?? ".env"); +} + +function readBody(endpoint: CredentialEndpoint): string { + const file = endpointPath(endpoint); + return existsSync(file) ? readFileSync(file, "utf8") : ""; +} + +export const envProvider: CredentialProvider = { + id: "env", + name: "Local .env file", + description: "Read, diff, redact, and write local environment files.", + capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: false }, + authRequirements: [], + status: "available", + + async inspect(endpoint) { + const values = parseEnv(readBody(endpoint)); + return { + provider: "env", + endpoint, + valuesReadable: true, + keys: keysFromValues(values), + inspectedAt: new Date().toISOString() + }; + }, + + async readValues(endpoint, keys) { + const values = parseEnv(readBody(endpoint)); + return Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]])); + }, + + async write({ endpoint, upserts, deletes, dryRun }) { + const results: CredentialWriteResult[] = [ + ...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })), + ...deletes.map((key) => ({ key, applied: !dryRun })) + ]; + if (dryRun) { + return results; + } + const next = applyEnv(readBody(endpoint), upserts, deletes); + writeFileSync(endpointPath(endpoint), next, { mode: 0o600 }); + return results; + }, + + async rollback({ endpoint, preImage, dryRun }) { + return this.write({ endpoint, upserts: preImage, deletes: [], dryRun }); + } +}; diff --git a/plugins/credential-sharing/src/providers/github-secrets.ts b/plugins/credential-sharing/src/providers/github-secrets.ts new file mode 100644 index 0000000..4df865d --- /dev/null +++ b/plugins/credential-sharing/src/providers/github-secrets.ts @@ -0,0 +1,116 @@ +import { keysFromNames } from "../fingerprint.js"; +import { httpJson, requireEnv } from "./http.js"; +import type { CredentialEndpoint, CredentialProvider, CredentialWriteResult } from "../types.js"; + +const GITHUB_API = "https://api.github.com"; + +function headers(): Record { + const token = requireEnv("GITHUB_TOKEN", "Export a GitHub token with secrets:write scope as GITHUB_TOKEN."); + return { + Authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28" + }; +} + +/** + * Resolve the Actions-secrets base path for an endpoint. + * repo (default): project=owner, service=repo + * org: scope="org", project=org + * environment: scope="environment", project=owner, service=repo, config=environment + */ +function basePath(endpoint: CredentialEndpoint): string { + const scope = endpoint.scope ?? "repo"; + if (scope === "org") { + if (!endpoint.project) throw new Error('GitHub org secrets need project=.'); + return `/orgs/${endpoint.project}/actions/secrets`; + } + if (!endpoint.project || !endpoint.service) { + throw new Error('GitHub repo secrets need project= and service=.'); + } + if (scope === "environment") { + if (!endpoint.config) throw new Error('GitHub environment secrets need config=.'); + return `/repos/${endpoint.project}/${endpoint.service}/environments/${endpoint.config}/secrets`; + } + return `/repos/${endpoint.project}/${endpoint.service}/actions/secrets`; +} + +interface SecretsList { + secrets: Array<{ name: string; updated_at?: string }>; +} + +interface PublicKey { + key_id: string; + key: string; +} + +async function sealValue(value: string, publicKeyB64: string): Promise { + const sodiumModule = (await import("libsodium-wrappers")) as unknown as { default?: SodiumLike } & SodiumLike; + const sodium: SodiumLike = sodiumModule.default ?? sodiumModule; + await sodium.ready; + const key = sodium.from_base64(publicKeyB64, sodium.base64_variants.ORIGINAL); + const sealed = sodium.crypto_box_seal(sodium.from_string(value), key); + return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL); +} + +interface SodiumLike { + ready: Promise; + base64_variants: { ORIGINAL: number }; + from_base64(input: string, variant: number): Uint8Array; + to_base64(input: Uint8Array, variant: number): string; + from_string(input: string): Uint8Array; + crypto_box_seal(message: Uint8Array, publicKey: Uint8Array): Uint8Array; +} + +export const githubSecretsProvider: CredentialProvider = { + id: "github-secrets", + name: "GitHub Secrets", + description: "Sync repository, organization, and environment secrets.", + // GitHub never returns secret values — names only. So values are not readable + // and a github-secrets endpoint cannot be a sync source or a rollback target. + capabilities: { readValues: false, readNames: true, write: true, delete: true, rollback: false, audit: false }, + authRequirements: ["GITHUB_TOKEN"], + status: "available", + + async inspect(endpoint) { + const list = await httpJson(`${GITHUB_API}${basePath(endpoint)}?per_page=100`, { + headers: headers(), + expect: "GitHub list secrets" + }); + const keys = keysFromNames((list.secrets ?? []).map((s) => s.name)); + for (const secret of list.secrets ?? []) { + const key = keys.find((k) => k.name === secret.name); + if (key) key.lastModifiedAt = secret.updated_at; + } + return { provider: "github-secrets", endpoint, valuesReadable: false, keys, inspectedAt: new Date().toISOString() }; + }, + + async write({ endpoint, upserts, deletes, dryRun }) { + const results: CredentialWriteResult[] = [ + ...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })), + ...deletes.map((key) => ({ key, applied: !dryRun })) + ]; + if (dryRun) { + return results; + } + const base = basePath(endpoint); + const visibility = endpoint.scope === "org" ? { visibility: "all" as const } : {}; + let publicKey: PublicKey | undefined; + if (Object.keys(upserts).length > 0) { + publicKey = await httpJson(`${GITHUB_API}${base}/public-key`, { headers: headers(), expect: "GitHub public key" }); + } + for (const [name, value] of Object.entries(upserts)) { + const encrypted_value = await sealValue(value, (publicKey as PublicKey).key); + await httpJson(`${GITHUB_API}${base}/${name}`, { + method: "PUT", + headers: { ...headers(), "content-type": "application/json" }, + body: JSON.stringify({ encrypted_value, key_id: (publicKey as PublicKey).key_id, ...visibility }), + expect: `GitHub put secret ${name}` + }); + } + for (const name of deletes) { + await httpJson(`${GITHUB_API}${base}/${name}`, { method: "DELETE", headers: headers(), expect: `GitHub delete secret ${name}` }); + } + return results; + } +}; diff --git a/plugins/credential-sharing/src/providers/http.ts b/plugins/credential-sharing/src/providers/http.ts new file mode 100644 index 0000000..e525882 --- /dev/null +++ b/plugins/credential-sharing/src/providers/http.ts @@ -0,0 +1,23 @@ +/** Minimal fetch helper shared by the network credential providers. */ +export function requireEnv(name: string, hint: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name}. ${hint}`); + } + return value; +} + +export async function httpJson( + url: string, + init: RequestInit & { expect?: string } = {} +): Promise { + const response = await fetch(url, init); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`${init.expect ?? "Request"} failed: ${response.status} ${response.statusText} ${body.slice(0, 300)}`.trim()); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} diff --git a/plugins/credential-sharing/src/providers/index.ts b/plugins/credential-sharing/src/providers/index.ts new file mode 100644 index 0000000..031816c --- /dev/null +++ b/plugins/credential-sharing/src/providers/index.ts @@ -0,0 +1,25 @@ +import type { CredentialProvider, CredentialProviderManifest } from "../types.js"; +import { envProvider } from "./env.js"; +import { dopplerProvider } from "./doppler.js"; +import { railwayProvider } from "./railway.js"; +import { githubSecretsProvider } from "./github-secrets.js"; + +export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider]; + +export const credentialProviderRegistry: Map = new Map( + credentialProviders.map((provider) => [provider.id, provider]) +); + +export function listCredentialProviderManifests(): CredentialProviderManifest[] { + return credentialProviders.map(({ id, name, description, capabilities, authRequirements, status }) => ({ + id, + name, + description, + capabilities, + authRequirements, + status + })); +} + +export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider }; +export { parseEnv, applyEnv } from "./env.js"; diff --git a/plugins/credential-sharing/src/providers/railway.ts b/plugins/credential-sharing/src/providers/railway.ts new file mode 100644 index 0000000..261699a --- /dev/null +++ b/plugins/credential-sharing/src/providers/railway.ts @@ -0,0 +1,88 @@ +import { keysFromValues } from "../fingerprint.js"; +import { httpJson, requireEnv } from "./http.js"; +import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js"; + +const RAILWAY_API = "https://backboard.railway.app/graphql/v2"; + +function auth(): string { + const token = requireEnv("RAILWAY_TOKEN", "Create a Railway account/project token and export it as RAILWAY_TOKEN."); + return `Bearer ${token}`; +} + +/** Railway addresses variables by project/environment(/service). project=projectId, config=environmentId, service=serviceId. */ +function scope(endpoint: CredentialEndpoint): { projectId: string; environmentId: string; serviceId?: string } { + if (!endpoint.project || !endpoint.config) { + throw new Error('Railway endpoint needs project (projectId) and config (environmentId), e.g. --to-project --to-config .'); + } + return { projectId: endpoint.project, environmentId: endpoint.config, serviceId: endpoint.service }; +} + +async function gql(query: string, variables: Record, expect: string): Promise { + const data = await httpJson<{ data?: T; errors?: Array<{ message: string }> }>(RAILWAY_API, { + method: "POST", + headers: { Authorization: auth(), "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + expect + }); + if (data.errors?.length) { + throw new Error(`${expect} failed: ${data.errors.map((e) => e.message).join("; ")}`); + } + return data.data as T; +} + +async function fetchVariables(endpoint: CredentialEndpoint): Promise { + const { projectId, environmentId, serviceId } = scope(endpoint); + const data = await gql<{ variables: Record }>( + `query Variables($projectId: String!, $environmentId: String!, $serviceId: String) { + variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) + }`, + { projectId, environmentId, serviceId }, + "Railway list variables" + ); + return data.variables ?? {}; +} + +export const railwayProvider: CredentialProvider = { + id: "railway", + name: "Railway", + description: "Sync service variables.", + capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: false }, + authRequirements: ["RAILWAY_TOKEN"], + status: "available", + + async inspect(endpoint) { + const values = await fetchVariables(endpoint); + return { provider: "railway", endpoint, valuesReadable: true, keys: keysFromValues(values), inspectedAt: new Date().toISOString() }; + }, + + async readValues(endpoint, keys) { + const values = await fetchVariables(endpoint); + return Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]])); + }, + + async write({ endpoint, upserts, deletes, dryRun }) { + const results: CredentialWriteResult[] = [ + ...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })), + ...deletes.map((key) => ({ key, applied: !dryRun })) + ]; + if (dryRun) { + return results; + } + const { projectId, environmentId, serviceId } = scope(endpoint); + for (const [name, value] of Object.entries(upserts)) { + await gql( + `mutation Upsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }`, + { input: { projectId, environmentId, serviceId, name, value } }, + `Railway upsert ${name}` + ); + } + for (const name of deletes) { + await gql( + `mutation Delete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, + { input: { projectId, environmentId, serviceId, name } }, + `Railway delete ${name}` + ); + } + return results; + } +}; diff --git a/plugins/credential-sharing/src/store.ts b/plugins/credential-sharing/src/store.ts new file mode 100644 index 0000000..6b73cc5 --- /dev/null +++ b/plugins/credential-sharing/src/store.ts @@ -0,0 +1,121 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import type { CredentialSyncPlan, CredentialSyncRun, CredentialAuditEvent, CredentialValueBag } from "./types.js"; + +/** + * File-backed store so the CLI can reference plans/runs by id across invocations. + * + * Layout under the base dir (default `$LOGICSRC_CREDENTIAL_HOME` or + * `/.logicsrc/credentials`): + * plans/.json redacted sync plans (fingerprints only) + * runs/.json run records (fingerprints only) + * audit/.json audit events (fingerprints only) + * vault/.json rollback pre-image — RAW prior target values, mode 0600 + * + * The vault is the only place raw values touch disk, and only to make rollback + * possible. It is written 0600 and lives under a `.logicsrc` dir that callers + * should gitignore. Audit and plan records never contain raw values. + */ +export interface CredentialStore { + baseDir: string; + savePlan(plan: CredentialSyncPlan): void; + getPlan(id: string): CredentialSyncPlan | undefined; + saveRun(run: CredentialSyncRun): void; + getRun(id: string): CredentialSyncRun | undefined; + saveAudit(runId: string, events: CredentialAuditEvent[]): void; + getAudit(runId: string): CredentialAuditEvent[]; + saveVault(runId: string, preImage: CredentialValueBag): void; + getVault(runId: string): CredentialValueBag | undefined; +} + +export function defaultCredentialHome(): string { + if (process.env.LOGICSRC_CREDENTIAL_HOME) { + return resolve(process.env.LOGICSRC_CREDENTIAL_HOME); + } + if (process.env.LOGICSRC_HOME) { + return resolve(process.env.LOGICSRC_HOME, "credentials"); + } + return resolve(process.cwd(), ".logicsrc", "credentials"); +} + +function readJson(file: string): T | undefined { + if (!existsSync(file)) { + return undefined; + } + return JSON.parse(readFileSync(file, "utf8")) as T; +} + +export function createFileCredentialStore(baseDir = defaultCredentialHome()): CredentialStore { + const dirs = { + plans: join(baseDir, "plans"), + runs: join(baseDir, "runs"), + audit: join(baseDir, "audit"), + vault: join(baseDir, "vault") + }; + + function ensure(dir: string, mode = 0o700) { + mkdirSync(dir, { recursive: true, mode }); + } + + return { + baseDir, + savePlan(plan) { + ensure(dirs.plans); + writeFileSync(join(dirs.plans, `${plan.id}.json`), JSON.stringify(plan, null, 2)); + }, + getPlan(id) { + return readJson(join(dirs.plans, `${id}.json`)); + }, + saveRun(run) { + ensure(dirs.runs); + writeFileSync(join(dirs.runs, `${run.id}.json`), JSON.stringify(run, null, 2)); + }, + getRun(id) { + return readJson(join(dirs.runs, `${id}.json`)); + }, + saveAudit(runId, events) { + ensure(dirs.audit); + writeFileSync(join(dirs.audit, `${runId}.json`), JSON.stringify(events, null, 2)); + }, + getAudit(runId) { + return readJson(join(dirs.audit, `${runId}.json`)) ?? []; + }, + saveVault(runId, preImage) { + ensure(dirs.vault, 0o700); + writeFileSync(join(dirs.vault, `${runId}.json`), JSON.stringify(preImage, null, 2), { mode: 0o600 }); + }, + getVault(runId) { + return readJson(join(dirs.vault, `${runId}.json`)); + } + }; +} + +/** In-memory store for tests and ephemeral SDK usage (no disk writes). */ +export function createMemoryCredentialStore(): CredentialStore { + const plans = new Map(); + const runs = new Map(); + const audit = new Map(); + const vault = new Map(); + return { + baseDir: ":memory:", + savePlan: (plan) => void plans.set(plan.id, plan), + getPlan: (id) => plans.get(id), + saveRun: (run) => void runs.set(run.id, run), + getRun: (id) => runs.get(id), + saveAudit: (runId, events) => void audit.set(runId, events), + getAudit: (runId) => audit.get(runId) ?? [], + saveVault: (runId, preImage) => void vault.set(runId, preImage), + getVault: (runId) => vault.get(runId) + }; +} + +export function listPlanIds(store: CredentialStore): string[] { + const dir = join(store.baseDir, "plans"); + if (store.baseDir === ":memory:" || !existsSync(dir)) { + return []; + } + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); +} diff --git a/plugins/credential-sharing/src/types.ts b/plugins/credential-sharing/src/types.ts new file mode 100644 index 0000000..655a95e --- /dev/null +++ b/plugins/credential-sharing/src/types.ts @@ -0,0 +1,198 @@ +import type { LogicSrcPrincipal, LogicSrcPolicyDecision } from "@logicsrc/account-core"; + +/** + * LogicSRC Credential Sharing OpenSpec — core object + provider-contract types. + * + * Security invariants (see docs/credential-sharing.md): + * - Raw secret values never appear in any object returned to a caller for display. + * - Audit records carry key names, targets, timestamps, actor identity, and value + * fingerprints — never raw values. + * - Every write supports dry-run. + * - Adapters declare read/write capabilities before a plan is generated. + */ + +export type CredentialProviderId = "env" | "doppler" | "railway" | "github-secrets" | (string & {}); + +export interface CredentialProviderCapabilities { + /** Adapter can read raw secret values (enables value-level fingerprint diffs). */ + readValues: boolean; + /** Adapter can enumerate key names (even if values are write-only). */ + readNames: boolean; + /** Adapter can write/update secret values. */ + write: boolean; + /** Adapter can delete keys. */ + delete: boolean; + /** Adapter can restore a captured pre-image. */ + rollback: boolean; + /** Adapter exposes a native audit trail. */ + audit: boolean; +} + +/** A `credential_source` / `credential_target`: a provider plus its addressing scope. */ +export interface CredentialEndpoint { + provider: CredentialProviderId; + /** Local file path for the `env` provider. */ + path?: string; + /** Provider project handle (Doppler project, Railway project id, GitHub owner). */ + project?: string; + /** Provider config/environment (Doppler config, Railway environment, GitHub environment). */ + config?: string; + /** Optional sub-scope (Railway service id, GitHub repo, GitHub org). */ + service?: string; + /** Provider scope label used in GitHub Secrets: "repo" | "org" | "environment". */ + scope?: string; + metadata?: Record; +} + +/** A redacted view of a single key — names + fingerprints only, never values. */ +export interface CredentialKey { + name: string; + present: boolean; + /** Deterministic fingerprint of the value, or undefined when the provider is write-only. */ + fingerprint?: string; + lastModifiedAt?: string; +} + +/** Result of `inspect()` — a redacted snapshot of an endpoint. */ +export interface CredentialSnapshot { + provider: CredentialProviderId; + endpoint: CredentialEndpoint; + /** True when fingerprints reflect real values (provider can read values). */ + valuesReadable: boolean; + keys: CredentialKey[]; + inspectedAt: string; +} + +export type CredentialDiffOp = "add" | "update" | "remove" | "unchanged" | "unknown"; + +export interface CredentialDiffEntry { + key: string; + op: CredentialDiffOp; + sourceFingerprint?: string; + targetFingerprint?: string; + /** True for ops that overwrite or delete an existing target value. */ + destructive: boolean; +} + +export interface CredentialDiff { + from: CredentialEndpoint; + to: CredentialEndpoint; + redacted: true; + entries: CredentialDiffEntry[]; + createdAt: string; +} + +export interface CredentialPolicy { + /** Secret values are always redacted in output; here for spec completeness. */ + redactValues: true; + /** Destructive changes require an approval before a write runs. */ + requireApprovalForDestructive: boolean; + /** Keys matching these (glob-ish) names are never written. */ + denyKeys?: string[]; +} + +export interface CredentialSyncPlan { + type: "logicsrc.credential_sync_plan"; + id: string; + from: CredentialEndpoint; + to: CredentialEndpoint; + policy: CredentialPolicy; + changes: CredentialDiffEntry[]; + requiresApproval: boolean; + /** A rollback plan references the run it reverses. */ + rollbackOfRunId?: string; + createdAt: string; +} + +export interface CredentialApproval { + type: "logicsrc.credential_approval"; + id: string; + planId: string; + approver: LogicSrcPrincipal; + /** Keys explicitly approved; empty means "all changes in the plan". */ + approvedKeys: string[]; + approvedAt: string; +} + +export type CredentialSyncRunStatus = "planned" | "dry_run" | "applied" | "partial" | "failed" | "rolled_back"; + +export interface CredentialKeyResult { + key: string; + op: CredentialDiffOp; + applied: boolean; + dryRun: boolean; + targetFingerprint?: string; + error?: string; +} + +export interface CredentialSyncRun { + type: "logicsrc.credential_sync_run"; + id: string; + planId: string; + status: CredentialSyncRunStatus; + dryRun: boolean; + results: CredentialKeyResult[]; + auditEventIds: string[]; + /** True when a rollback pre-image was captured for this run. */ + reversible: boolean; + startedAt: string; + finishedAt: string; +} + +export interface CredentialAuditEvent { + type: "logicsrc.credential_audit_event"; + id: string; + runId?: string; + planId?: string; + provider: CredentialProviderId; + action: string; + key: string; + target: string; + /** Fingerprint of the value written/observed — never the value itself. */ + fingerprint?: string; + principal: LogicSrcPrincipal; + decision: LogicSrcPolicyDecision; + dryRun: boolean; + createdAt: string; +} + +/** Raw key/value bag — used only internally between adapters and the engine, never displayed. */ +export type CredentialValueBag = Record; + +export interface CredentialWriteResult { + key: string; + applied: boolean; + error?: string; +} + +export interface CredentialProviderManifest { + id: CredentialProviderId; + name: string; + description: string; + capabilities: CredentialProviderCapabilities; + /** Environment variables / fields the adapter needs to authenticate. */ + authRequirements: string[]; + status: "available" | "planned"; +} + +/** + * Provider adapter contract — the LogicSRC credential provider boundary. + * Adapters are pure I/O: they read/write a backend and never decide policy. + */ +export interface CredentialProvider extends CredentialProviderManifest { + /** Enumerate keys (names always; values only when capabilities.readValues). */ + inspect(endpoint: CredentialEndpoint): Promise; + /** Read raw values for the given keys (internal use only; gated by readValues). */ + readValues?(endpoint: CredentialEndpoint, keys: string[]): Promise; + /** Apply a set of key writes/deletes. `dryRun` must short-circuit all mutation. */ + write(input: { + endpoint: CredentialEndpoint; + upserts: CredentialValueBag; + deletes: string[]; + dryRun: boolean; + }): Promise; + /** Restore a previously captured pre-image (optional; gated by rollback). */ + rollback?(input: { endpoint: CredentialEndpoint; preImage: CredentialValueBag; dryRun: boolean }): Promise; + /** Native audit trail (optional; gated by audit). */ + audit?(endpoint: CredentialEndpoint): Promise; +} diff --git a/plugins/credential-sharing/tsconfig.json b/plugins/credential-sharing/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/plugins/credential-sharing/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}