alchemy-sops
v0.2.0
Published
Alchemy Effect resource for decrypting SOPS files into redacted secret outputs.
Readme
alchemy-sops
Alchemy Effect resource for decrypting SOPS files into redacted secret outputs.
alchemy-sops decrypts SOPS files, parses the decrypted document, and returns
Alchemy outputs whose scalar leaves are Redacted<string>. It prefers the
native sops-age backend for age-encrypted JSON/YAML/dotenv files and keeps the
sops CLI backend for binary files, custom SOPS flags, and non-age backends.
Contents
Install
bun add alchemy-sopsThe native backend does not require a sops binary. Install sops only when
you need backend: "cli" or automatic fallback for SOPS features not supported
by sops-age.
Usage
import * as Alchemy from "alchemy";
import * as Output from "alchemy/Output";
import { SopsFile, SopsFileProvider } from "alchemy-sops";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"App",
{
providers: SopsFileProvider(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const secrets = yield* SopsFile("Secrets", {
path: "./secrets.enc.yaml",
format: "yaml",
ageKey: Config.redacted("SOPS_AGE_KEY"),
secrets: {
DATABASE_URL: "database.url",
API_TOKEN: "api.token",
},
});
return {
sourceHash: secrets.sourceHash,
databaseUrl: Output.map(secrets.secrets, (s) => s.DATABASE_URL),
};
}),
);For local files, backend: "auto" is the default. It tries sops-age first for
structured age-encrypted files, then falls back to the CLI when a local path
source is available. Use backend: "sops-age" to require the native backend or
backend: "cli" to force the binary.
Cloudflare Secrets Store Action
Use CloudflareSopsSecrets when Cloudflare Workers should receive secrets from
Cloudflare Secrets Store instead of Alchemy state. It is the high-level wrapper
around the exported CloudflareSopsSecretsAction.
The wrapper reads a local encrypted SOPS file before registering the Action, passes ciphertext into Action state, decrypts during deploy, and imports selected values into the target store. Plaintext is sent to Cloudflare Secrets Store but is not persisted as Action input.
A stack using the Action needs:
- Cloudflare providers and state configured in the Alchemy stack
- A
Cloudflare.SecretsStoreresource or{ accountId, storeId }reference - A deploy-time SOPS identity, preferably passed as
Redacted<string> - A
secretsmap from Cloudflare secret names to decrypted dot-path selectors, or nosecretsmap when all scalar leaves should be imported
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import {
CloudflareSopsSecrets,
cloudflareSopsWorkerBindings,
} from "alchemy-sops";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
export default Alchemy.Stack(
"Worker",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const store = yield* Cloudflare.SecretsStore("Secrets");
const imported = yield* CloudflareSopsSecrets("WorkerSecrets", {
path: "./secrets.enc.yaml",
format: "yaml",
backend: "sops-age",
store,
ageKey: Redacted.make(process.env.SOPS_AGE_KEY!),
scopes: ["workers"],
comment: "imported by alchemy-sops",
secrets: {
API_TOKEN: "api.token",
DATABASE_URL: "database.url",
},
});
const worker = yield* Cloudflare.Worker("Api", {
main: "./src/worker.ts",
});
yield* worker.bind(
"sops-secrets",
cloudflareSopsWorkerBindings(imported, [
"API_TOKEN",
"DATABASE_URL",
]),
);
return {
url: worker.url,
};
}),
);secrets maps Cloudflare secret names to paths in the decrypted document. Omit
it to import every scalar leaf; generated names are derived from dot paths, and
namePrefix can add a prefix to every generated name.
cloudflareSopsWorkerBindings(imported, ["API_TOKEN"]) binds a Worker variable
to the Secrets Store secret with the same name. Pass an object when the Worker
binding name should differ from the stored secret name:
yield* worker.bind(
"sops-secrets",
cloudflareSopsWorkerBindings(imported, {
API_TOKEN: "WORKER_API_TOKEN",
}),
);Run the stack with your normal Alchemy deploy command. The Action runs when its input changes, including the encrypted file content, backend options, selected secret paths, scopes, comments, and target store.
Existing Secrets Store entries are replaced by default because Cloudflare does
not allow patching a secret value. Set replaceExisting: false when you only
want to converge scopes and comments for an existing secret name. The Cloudflare
credentials used by the stack must be allowed to manage the target Secrets
Store, and Worker deploy permissions are also needed when the same stack binds
those secrets to a Worker.
Most stacks should call CloudflareSopsSecrets. Use
CloudflareSopsSecretsAction directly only when the encrypted content is
already available and you want to pass the Action input yourself:
import { CloudflareSopsSecretsAction } from "alchemy-sops";
import * as Redacted from "effect/Redacted";
const imported = yield* CloudflareSopsSecretsAction("WorkerSecrets", {
path: "secrets.enc.yaml",
content: encryptedSopsYaml,
format: "yaml",
backend: "sops-age",
store: {
accountId: "account-id",
storeId: "store-id",
},
ageKey: Redacted.make(process.env.SOPS_AGE_KEY!),
secrets: {
API_TOKEN: "api.token",
},
});Edge usage
Alchemy programs can avoid local filesystem and process APIs by using inline encrypted content or a URL source with the native backend:
import { SopsFile } from "alchemy-sops";
const secrets = yield* SopsFile("WorkerSecrets", {
content: encryptedSopsJson,
format: "json",
backend: "sops-age",
ageKey: workerEnv.SOPS_AGE_KEY,
});The Alchemy resource entrypoint still imports Alchemy. For code that is bundled
directly into an edge runtime, use the low-level alchemy-sops/edge subpath:
import { runSopsAge } from "alchemy-sops/edge";Inputs
Every string-like option accepts the same shapes as Alchemy SecretInput:
stringRedacted<string>Effect<string | Redacted<string>>Config<string | Redacted<string>>
Supported options:
path,content, orurl: exactly one encrypted source is requiredcwd,sopsBinarybackend:auto,sops-age, orcliformat:auto,json,yaml,dotenv,text, orbinaryinputType,outputType: input/output format hintsextract: passed tosops --extractfor CLI and as a key path forsops-agesopsArgs: extra CLI args; requiresbackend: "cli"or CLI fallbackenv,ageKey,ageKeyFile: SOPS environment inputs;sops-ageuses directageKey/SOPS_AGE_KEYsecrets: output-name to dot-path selectorscache,timeoutMs,retry
CloudflareSopsSecrets shares the decrypt options above and adds:
store: Cloudflare Secrets Store resource or{ accountId, storeId }namePrefix: prefix for generated Cloudflare secret names whensecretsis omittedscopes: Cloudflare Secrets Store scopes; defaults to["workers"]comment: free-form Cloudflare Secrets Store commentreplaceExisting: delete and recreate matching existing secrets so values converge; defaults totrue
CloudflareSopsSecretsAction also accepts content, the encrypted SOPS
ciphertext to use as Action input. The CloudflareSopsSecrets wrapper fills
that field by reading path.
Outputs
The resource returns:
data: nested document with scalar leaves redactedflat: dot-path map of all redacted leavessecrets: selected redacted leaves, or all leaves whensecretsis omittedsourceHash: SHA-256 digest of the encrypted source plus non-secret optionspath,format,version
cache defaults to true. If the encrypted source digest and resource version
are unchanged, the provider returns the previous redacted output without
decrypting again. Set cache: false to force decryption on every deploy.
The Cloudflare Action returns accountId, storeId, path, and an imported
array containing each Cloudflare secret name, source dot path, secret id, and
status.
Security note
Redacted<string> prevents accidental printing and logging, but Alchemy state
stores still persist values so they can be revived later. Use a state store you
trust for decrypted secrets.
