mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 12:54:03 +00:00
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>
145 lines
6.5 KiB
TypeScript
145 lines
6.5 KiB
TypeScript
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");
|
|
});
|
|
});
|