Reaktor Secrets
One source of truth in GCP Secret Manager, exposed through reaktor-secrets, then materialized into the runtimes that cannot or should not call GCP directly. The implementation seed now covers common/JVM/JS stores, Cloudflare Worker bindings, and BestBuds JVM credential bootstrap; the next layer is cache-first materialization for local dev, Cloudflare Workers, k3s, and CI.
Context — why this exists
Without a single source of truth, secrets accumulate across a repository — build files, manifests, env files, worker configs — with no rotation path, so changing one value means hand-editing many files across four runtimes. The requirements are usable security: one place to define a secret, automatic delivery to every runtime, no long-lived key files on disk, and rotation as a single operation — without making local dev or deploys painful.
The system runs on four surfaces that each receive secrets differently today: local dev, Cloudflare Workers, the k3s JVM server (on GCE), and CI (GitHub Actions). This plan unifies them behind GCP Secret Manager and operates them through reaktor-secrets plus Dagger/CLI functions.
Goals & principles
- One source of truth: GCP Secret Manager. Fits the existing GCP palette — we don't add a platform for a single use case.
- Cache first: payload reads happen only when the local/k3s/Cloudflare cache is missing, or when provider metadata proves the secret has a newer enabled version.
- No long-lived keys anywhere: local uses your
gcloudlogin (ADC); k3s uses the VM's metadata service account; CI uses GitHub OIDC → Workload Identity Federation. - Pipeline-as-code: pull / sync / rotate / verify are Dagger functions and
reaktorCLI commands, identical on a laptop and in CI. No GUI, no manual copying. - Least privilege: each consumer reads only what it needs; most workers get zero secrets.
- Don't break running surfaces: the cutover is sequenced so prod stays up (the cutover sequence is in the private copy).
Reaktor Secrets module
reaktor-secrets is the slow-changing secret boundary. Apps and workers should not know whether a value came from GCP Secret Manager, a Kubernetes Secret, a Cloudflare Worker binding, or a local developer fallback. They should depend on a SecretStore.
| Implemented primitive | Purpose | Current use |
|---|---|---|
SecretRef | Stable GCP resource reference to a secret version, never the value. | projects/ |
SecretValue / SecretVersion | Payload result and version metadata returned by store reads/writes. | Carry provider version names without exposing values in higher-level ledgers. |
SecretStore | Common read/write boundary with getString, requireString, and addVersion. | Shared API for JVM, JS, Cloudflare, and tests. |
SecretStoreAdapter | Reaktor adapter base for runtime-specific stores. | Used by JVM, JS, and Cloudflare implementations. |
Feature.Secrets | Global feature slot for code that wants the configured store. | Lets shared code resolve a store without hard-coding a provider. |
InMemorySecretStore | Test/local fake with versioned addVersion behavior. | Common tests and lightweight fixtures. |
| Implemented store | Runtime | Auth path |
|---|---|---|
JvmGcpSecretManagerStore | JVM server, desktop tools, k3s jobs, CI helpers | Google ADC or explicitly supplied GoogleCredentials. |
WebGcpSecretManagerStore | JS tools that already hold a scoped bearer token | SecretAccessTokenProvider; never service-account JSON in browser/client bundles. |
CloudflareSecretStore | Cloudflare Workers | Reads already-synced Worker secret bindings through CloudflareContext.secretOrNull. |
BestBudsGoogleCredentials | BestBuds JVM server Pub/Sub and Manna clients | Prefers Secret Manager, then inline/local/classpath fallbacks, then ADC. |
Common API:
val ref = SecretRef.gcp("<gcp-project>", "bestbuds-google-service-account-json")
val credentialsJson = store.requireString(ref)
JVM:
val store = JvmGcpSecretManagerStore(defaultProjectId = "<gcp-project>")
val credentialsJson = store.requireString("bestbuds-google-service-account-json")
Cloudflare Workers:
val store = context.boundSecretStore(defaultProjectId = "<gcp-project>")
val clientSecret = store.requireString("BESTBUDS_WORKER_SERVICE_CLIENT_SECRET")
The next layer keeps the same public boundary but adds higher-order operations:
| Next primitive | Purpose | Common use |
|---|---|---|
SecretManifest | Declarative set of refs required by a component. | reaktorServer, companyos-bestbuds, notification-service |
SecretSnapshot | Non-secret cache ledger: ref, provider version, etag, target, synced time. | Decide whether to skip or refresh a materialized cache. |
SecretMaterializer | Writes a target cache atomically. | Kubernetes Secret, Cloudflare Worker secret, local .dev.vars. |
SecretGraph | Graph nodes for ownership, consumers, target caches, rotations, and audit. | Deploy pane, tests, blast radius, stale-cache warnings. |
That split follows the Reaktor API rule: today's provider/store layer is small and usable immediately; manifest → materialize → verify becomes the common deploy path once Dagger/CLI functions land.
Architecture
GCP Secret Manager (project: <gcp-project>)
names: {env}-{domain}-{name}
┌───────────────────┬──────────┴───────────┬────────────────────┐
▼ ▼ ▼ ▼
Local dev Cloudflare Workers k3s JVM server CI (GitHub Actions)
gcloud ADC Reaktor sync → Reaktor/ESO sync GitHub OIDC →
→ .env + Worker Secrets / → native k8s Workload Identity
per-worker Secrets Store Secrets Federation (no key)
.dev.vars (version cached) (version cached)
Cloudflare Workers can't reach GCP metadata (they run on Cloudflare), so their secrets are pushed to Cloudflare's encrypted per-worker store at deploy time and read at runtime via the existing requireSecret(name) accessor in reaktor-cloudflare. The database stays on Hyperdrive — its connection string lives in the Hyperdrive config server-side, not as a worker secret.
GCP Secret Manager is the canonical source for almost every Reaktor/BestBuds component:
| Component | Uses GCP Secret Manager how? | Runtime cache |
|---|---|---|
reaktorServer on k3s | Supabase, auth, service-account, Cloudflare tunnel, Pub/Sub refs. | Kubernetes Secret + in-process snapshot. |
reaktor-auth | JWT keys, service-account signing material, PAT bootstrap secrets. | Server memory cache backed by k3s Secret. |
| BestBuds workers | Worker-specific API tokens and service-auth secrets. | Cloudflare Worker Secrets / Secrets Store. |
| CompanyOS | Google OAuth client secret, test service auth, portal credential references. | Cloudflare Worker secret; portal values stay as references. |
| CI and deploy tooling | Cloudflare API token, GitHub package token, deploy credentials. | Dagger session cache; no checked-in file. |
| Local dev | Stage-only secrets for emulators and worker dev. | Gitignored .env/.dev.vars with a lockfile snapshot. |
| Desktop/mobile | Only receives user-local tokens or non-secret refs; no direct prod secret fetch. | OS keychain for user tokens. |
| Surface | Auth to Secret Manager | Notes |
|---|---|---|
| Local dev | gcloud auth application-default login (user ADC) | Devs get stage-* secrets only |
| Cloudflare sync | Runs where gcloud/WIF is authed (CI or operator laptop) | Workers never call GCP; secrets are pushed at deploy |
| k3s server | GCE VM metadata service account → ADC | ESO uses this; no key file. Grant the node SA secretAccessor |
| CI | GitHub OIDC → Workload Identity Federation | No stored SA key in GitHub |
Cache-first delivery model
Target inventory is small: assume about 50 secrets, usually one enabled version each. GCP Secret Manager remains cheap at this scale; the cache design is about blast-radius control, startup speed, and avoiding secret-value movement, not about saving pennies.
The invariant is strict:
A secret payload is read from GCP only when a target cache is missing, or when GCP metadata shows the enabled version changed.
Cache ledger
Every materialized target gets a non-secret SecretSnapshot ledger:
{
"target": "k3s://reaktor/app-server-secrets",
"ref": "secret://gcp/prod/reaktor/auth-jwt-secret",
"provider": "gcp-secret-manager",
"providerVersion": "projects/<gcp-project>/secrets/prod-reaktor-auth-jwt-secret/versions/7",
"etag": "1678f7...",
"state": "ENABLED",
"syncedAt": "2026-06-06T00:00:00Z"
}
The ledger never stores plaintext, ciphertext, or a raw hash that could be used as an offline oracle. It stores only provider metadata and target status.
Refresh algorithm
- Load
SecretManifestfor the target. - For each
SecretRef, check the target cache and localSecretSnapshot. - If the target secret is missing, access the provider payload once and write the cache atomically.
- If the target exists, read provider metadata only: latest enabled version name, state, update time, and etag.
- If metadata matches the snapshot, do nothing.
- If metadata changed, access that exact version payload once, replace the target cache, write the new snapshot, and emit a
secret.cache.updatedgraph event. - If access fails, keep the last known-good target cache and mark the graph node
stalerather than deleting the runtime secret.
This makes read volume predictable. With 50 secrets, a normal month is roughly: startup/sync metadata checks plus payload reads only for first materialization and rotations. A full redeploy of every target should not imply 50 GCP payload reads unless the target cache is new.
Rotation signal
The preferred rotation path is:
reaktor secrets put prod/reaktor/auth-jwt-secret
reaktor secrets refresh --target k3s://reaktor/app-server-secrets
reaktor secrets refresh --target cf://companyos-bestbuds
pushSecretVersion writes the GCP version and emits a Reaktor graph event. The caches refresh from that event. If a human rotates a secret directly in the GCP console, periodic metadata reconcile catches it; payload access still happens only when the version differs.
Secrets as a Dagger workflow
Steady-state secret operations are Dagger functions in the existing module (.dagger/src/index.ts, class Bestbuds), so they run identically locally and in CI:
| Function | Purpose | Wraps |
|---|---|---|
secretsMetadata(env, manifest) | Read provider metadata for refs without reading payloads. | gcloud secrets versions list/describe |
secretsPull(gcpCredentials, env) → Directory | Materialize .env + per-worker .dev.vars only if missing/stale. | metadata check, then gcloud secrets versions access only on miss/update |
syncWorkerSecrets(ctr, name, gcpCredentials, env) | Push a Worker's SM secrets to Cloudflare only if missing/stale. | wrangler secret list + wrangler secret bulk --name |
syncK3sSecrets(env, namespace, secretName) | Refresh Kubernetes Secret cache only if missing/stale. | kubectl get secret + GCP metadata/payload access |
deployWorker(...) / deployWorkers(...) | Existing deploys, now secret-aware (metadata compare → sync if needed → deploy). | extends current functions |
pushSecretVersion(name, value, env) | Set a new value into SM when you rotate; emits graph event. | gcloud secrets versions add |
verifySecrets(env) | Assert each surface resolves required secrets (presence/version, not values). | gcloud + wrangler secret list + kubectl get secret |
Rotation becomes: rotate the upstream value in the vendor console → dagger call push-secret-version → reaktor secrets refresh --all or targeted refreshes. Cloudflare and k3s update only the caches whose provider version changed. One-time bootstrap (WIF pool, helm install external-secrets, initial IAM, history purge) stays a runbook; the gcloud/helm steps can be wrapped as dagger call bootstrap-* later.
Related docs
- Security Plan - Use before enabling deploy actions, remote agents, or privileged commands.
- Auth Architecture - Use before changing reaktor-auth or request authorization boundaries.
- DevOps - Use before changing the build/deploy/ops pipeline, the Kotlin platform model, deployment partitioning, gated deploy operations, or the reaktorDesktop Deploy pane; pairs with reaktor-cloud-pane (operator view) and secrets-management.
- Cloud Pane - Use when building the operator view over product infrastructure and tool execution.
- Shadow Dev - Use before building production-like test and replay environments.
- Gemini DevOps Agent - Use before handing DevOps execution from Codex or Claude to Antigravity CLI, adding agent-run recipes, or wiring build/test/deploy operations into reaktorDesktop.