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,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"
}
}

View file

@ -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");
});
});

View file

@ -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<string, CredentialProvider>;
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<string, CredentialProvider>;
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<CredentialSnapshot> {
return this.requireProvider(endpoint.provider).inspect(endpoint);
}
/** Compare a source against a target without moving any secret. */
async diffCredentialEndpoints(from: CredentialEndpoint, to: CredentialEndpoint): Promise<CredentialDiff> {
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<CredentialPolicy>;
}): Promise<CredentialSyncPlan> {
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<CredentialSyncRun> {
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<CredentialSyncPlan> {
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<CredentialValueBag> {
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(":");
}

Binary file not shown.

View file

@ -0,0 +1,66 @@
import type { PluginDefinition } from "@logicsrc/plugin-core";
import { credentialSharingManifest } from "./manifest.js";
import { CredentialEngine, type CredentialEngineOptions } from "./engine.js";
import { listCredentialProviderManifests } from "./providers/index.js";
export const credentialSharingPlugin: PluginDefinition = {
manifest: credentialSharingManifest,
configDefaults: {
enabled: true,
default_policy: "approval_required_for_destructive",
credential_home: "${LOGICSRC_CREDENTIAL_HOME}"
},
routes: [
{ method: "GET", path: "/api/credentials/providers", capability: "credentials.providers.list" },
{ method: "GET", path: "/api/credentials/inspect", capability: "credentials.inspect" },
{ method: "POST", path: "/api/credentials/diff", capability: "credentials.diff" },
{ method: "POST", path: "/api/credentials/plans", capability: "credentials.plan" },
{ method: "POST", path: "/api/credentials/plans/:id/approve", capability: "credentials.approve" },
{ method: "POST", path: "/api/credentials/plans/:id/sync", capability: "credentials.sync" },
{ method: "POST", path: "/api/credentials/runs/:id/rollback", capability: "credentials.rollback" },
{ method: "GET", path: "/api/credentials/runs/:id/audit", capability: "credentials.audit.read" }
],
permissions: [
"credentials:inspect",
"credentials:diff",
"credentials:plan",
"credentials:approve",
"credentials:sync",
"credentials:rollback",
"credentials:audit:read"
],
tuiPanels: [{ id: "credential-sharing", title: "Credential Sharing" }]
};
/** Factory mirroring the LogicSRC Credential Sharing SDK spec. */
export function createCredentialEngine(options: CredentialEngineOptions = {}): CredentialEngine {
return new CredentialEngine(options);
}
/** Provider listing without constructing an engine (used by the CLI `providers` command). */
export function listCredentialProviders() {
return listCredentialProviderManifests();
}
export { credentialSharingManifest };
export { CredentialEngine, DEFAULT_CREDENTIAL_POLICY, endpointLabel } from "./engine.js";
export type { CredentialEngineOptions } from "./engine.js";
export {
credentialProviders,
credentialProviderRegistry,
listCredentialProviderManifests,
envProvider,
dopplerProvider,
railwayProvider,
githubSecretsProvider,
parseEnv,
applyEnv
} from "./providers/index.js";
export {
createFileCredentialStore,
createMemoryCredentialStore,
defaultCredentialHome,
type CredentialStore
} from "./store.js";
export { fingerprintValue, fingerprintsEqual } from "./fingerprint.js";
export * from "./types.js";

View file

@ -0,0 +1,22 @@
import type { PluginManifest } from "@logicsrc/plugin-core";
export const credentialSharingManifest: PluginManifest = {
id: "credential-sharing",
name: "Credential Sharing",
version: "0.1.0",
type: ["credentials", "secrets", "sync"],
default: true,
capabilities: [
"credentials.providers.list",
"credentials.inspect",
"credentials.diff",
"credentials.plan",
"credentials.approve",
"credentials.sync",
"credentials.rollback",
"credentials.audit.read",
"credentials.export"
],
commands: ["credentials"],
env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME"]
};

View file

@ -0,0 +1,75 @@
import { keysFromValues } from "../fingerprint.js";
import { httpJson, requireEnv } from "./http.js";
import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js";
const DOPPLER_API = "https://api.doppler.com/v3";
function auth(): string {
const token = requireEnv("DOPPLER_TOKEN", "Create a Doppler service token and export it as DOPPLER_TOKEN.");
return `Bearer ${token}`;
}
function scopeQuery(endpoint: CredentialEndpoint): string {
const params = new URLSearchParams();
if (endpoint.project) params.set("project", endpoint.project);
if (endpoint.config) params.set("config", endpoint.config);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
interface DopplerSecretsResponse {
secrets: Record<string, { raw?: string; computed?: string }>;
}
async function fetchSecrets(endpoint: CredentialEndpoint): Promise<CredentialValueBag> {
const data = await httpJson<DopplerSecretsResponse>(`${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<string, string | null> = { ...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;
}
};

View file

@ -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');
});
});

View file

@ -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 });
}
};

View file

@ -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<string, string> {
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=<org>.');
return `/orgs/${endpoint.project}/actions/secrets`;
}
if (!endpoint.project || !endpoint.service) {
throw new Error('GitHub repo secrets need project=<owner> and service=<repo>.');
}
if (scope === "environment") {
if (!endpoint.config) throw new Error('GitHub environment secrets need config=<environment>.');
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<string> {
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<void>;
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<SecretsList>(`${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<PublicKey>(`${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;
}
};

View file

@ -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<T>(
url: string,
init: RequestInit & { expect?: string } = {}
): Promise<T> {
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;
}

View file

@ -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<string, CredentialProvider> = 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";

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;
}
};

View file

@ -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
* `<cwd>/.logicsrc/credentials`):
* plans/<id>.json redacted sync plans (fingerprints only)
* runs/<id>.json run records (fingerprints only)
* audit/<runId>.json audit events (fingerprints only)
* vault/<runId>.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<T>(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<CredentialSyncPlan>(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<CredentialSyncRun>(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<CredentialAuditEvent[]>(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<CredentialValueBag>(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<string, CredentialSyncPlan>();
const runs = new Map<string, CredentialSyncRun>();
const audit = new Map<string, CredentialAuditEvent[]>();
const vault = new Map<string, CredentialValueBag>();
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$/, ""));
}

View file

@ -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<string, unknown>;
}
/** 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<string, string>;
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<CredentialSnapshot>;
/** Read raw values for the given keys (internal use only; gated by readValues). */
readValues?(endpoint: CredentialEndpoint, keys: string[]): Promise<CredentialValueBag>;
/** Apply a set of key writes/deletes. `dryRun` must short-circuit all mutation. */
write(input: {
endpoint: CredentialEndpoint;
upserts: CredentialValueBag;
deletes: string[];
dryRun: boolean;
}): Promise<CredentialWriteResult[]>;
/** Restore a previously captured pre-image (optional; gated by rollback). */
rollback?(input: { endpoint: CredentialEndpoint; preImage: CredentialValueBag; dryRun: boolean }): Promise<CredentialWriteResult[]>;
/** Native audit trail (optional; gated by audit). */
audit?(endpoint: CredentialEndpoint): Promise<CredentialAuditEvent[]>;
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}