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 gcloud login (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 reaktor CLI 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 primitivePurposeCurrent use
SecretRefStable GCP resource reference to a secret version, never the value.projects//secrets/bestbuds-google-service-account-json/versions/latest
SecretValue / SecretVersionPayload result and version metadata returned by store reads/writes.Carry provider version names without exposing values in higher-level ledgers.
SecretStoreCommon read/write boundary with getString, requireString, and addVersion.Shared API for JVM, JS, Cloudflare, and tests.
SecretStoreAdapterReaktor adapter base for runtime-specific stores.Used by JVM, JS, and Cloudflare implementations.
Feature.SecretsGlobal feature slot for code that wants the configured store.Lets shared code resolve a store without hard-coding a provider.
InMemorySecretStoreTest/local fake with versioned addVersion behavior.Common tests and lightweight fixtures.
Implemented storeRuntimeAuth path
JvmGcpSecretManagerStoreJVM server, desktop tools, k3s jobs, CI helpersGoogle ADC or explicitly supplied GoogleCredentials.
WebGcpSecretManagerStoreJS tools that already hold a scoped bearer tokenSecretAccessTokenProvider; never service-account JSON in browser/client bundles.
CloudflareSecretStoreCloudflare WorkersReads already-synced Worker secret bindings through CloudflareContext.secretOrNull.
BestBudsGoogleCredentialsBestBuds JVM server Pub/Sub and Manna clientsPrefers 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 primitivePurposeCommon use
SecretManifestDeclarative set of refs required by a component.reaktorServer, companyos-bestbuds, notification-service
SecretSnapshotNon-secret cache ledger: ref, provider version, etag, target, synced time.Decide whether to skip or refresh a materialized cache.
SecretMaterializerWrites a target cache atomically.Kubernetes Secret, Cloudflare Worker secret, local .dev.vars.
SecretGraphGraph 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:

ComponentUses GCP Secret Manager how?Runtime cache
reaktorServer on k3sSupabase, auth, service-account, Cloudflare tunnel, Pub/Sub refs.Kubernetes Secret + in-process snapshot.
reaktor-authJWT keys, service-account signing material, PAT bootstrap secrets.Server memory cache backed by k3s Secret.
BestBuds workersWorker-specific API tokens and service-auth secrets.Cloudflare Worker Secrets / Secrets Store.
CompanyOSGoogle OAuth client secret, test service auth, portal credential references.Cloudflare Worker secret; portal values stay as references.
CI and deploy toolingCloudflare API token, GitHub package token, deploy credentials.Dagger session cache; no checked-in file.
Local devStage-only secrets for emulators and worker dev.Gitignored .env/.dev.vars with a lockfile snapshot.
Desktop/mobileOnly receives user-local tokens or non-secret refs; no direct prod secret fetch.OS keychain for user tokens.
SurfaceAuth to Secret ManagerNotes
Local devgcloud auth application-default login (user ADC)Devs get stage-* secrets only
Cloudflare syncRuns where gcloud/WIF is authed (CI or operator laptop)Workers never call GCP; secrets are pushed at deploy
k3s serverGCE VM metadata service account → ADCESO uses this; no key file. Grant the node SA secretAccessor
CIGitHub OIDC → Workload Identity FederationNo 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

  1. Load SecretManifest for the target.
  2. For each SecretRef, check the target cache and local SecretSnapshot.
  3. If the target secret is missing, access the provider payload once and write the cache atomically.
  4. If the target exists, read provider metadata only: latest enabled version name, state, update time, and etag.
  5. If metadata matches the snapshot, do nothing.
  6. If metadata changed, access that exact version payload once, replace the target cache, write the new snapshot, and emit a secret.cache.updated graph event.
  7. If access fails, keep the last known-good target cache and mark the graph node stale rather 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:

FunctionPurposeWraps
secretsMetadata(env, manifest)Read provider metadata for refs without reading payloads.gcloud secrets versions list/describe
secretsPull(gcpCredentials, env) → DirectoryMaterialize .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.

  • 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.