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 <providers|inspect|diff|plan|approve|sync|
  rollback|audit|export>` (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 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-27 15:24:30 +00:00
parent 6e7f44612a
commit cf73fe5af2
30 changed files with 1849 additions and 38 deletions

View file

@ -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 <projectId> --to-config <environmentId>.');
}
return { projectId: endpoint.project, environmentId: endpoint.config, serviceId: endpoint.service };
}
async function gql<T>(query: string, variables: Record<string, unknown>, expect: string): Promise<T> {
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<CredentialValueBag> {
const { projectId, environmentId, serviceId } = scope(endpoint);
const data = await gql<{ variables: Record<string, string> }>(
`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;
}
};