mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
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:
parent
6e7f44612a
commit
cf73fe5af2
30 changed files with 1849 additions and 38 deletions
88
plugins/credential-sharing/src/providers/railway.ts
Normal file
88
plugins/credential-sharing/src/providers/railway.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue