Secrets
ctx.secrets.get(KEY) reads from a per-(tenant, app) encrypted store.
Two tiers, with tenant-override beating dev-default.
Mental model
Section titled “Mental model” ┌─────────────────────────────┐ │ ctx.secrets.get('OPENAI_KEY') │ └──────────────┬──────────────────┘ │ Look up tenant-override (this tenant + this app) │ ┌───────────┴────────────┐ │ │ found? not found │ │ ▼ ▼ return decrypted Look up dev-default (this app) │ ┌───────────┴────────────┐ │ │ found? not found │ │ ▼ ▼ return decrypted return null- Dev-default: you (the dev) seed it once with
linkworld secrets set. Visible to every tenant install of the app. - Tenant-override: the tenant admin sets it via their app-settings UI. Wins over dev-default for that one tenant.
Both are encrypted at rest with AES-256-GCM, stored in
linkworld_app_secrets, and never displayed after creation.
When to use which
Section titled “When to use which”| Pattern | Tier | Example |
|---|---|---|
| You pay for the API, all tenants share | Dev-default | OPENAI_KEY for an LLM the app uses internally |
| Each tenant brings their own credential | Force tenant-override (no default) | STRIPE_SECRET_KEY for tenant’s own Stripe account |
| You ship a sane default, tenant can override | Dev-default + advertise override is supported | MAX_CONCURRENT_REQUESTS=10 |
Setting dev-defaults
Section titled “Setting dev-defaults”CLI:
linkworld secrets set OPENAI_KEY=sk-real-... \ --app tax-bot \ --description "LLM key — bills to dev's account"Dev console:
- Open
/dev/apps/<your-app>→ Secrets tab → “Add secret” - Key (must match
^[A-Z][A-Z0-9_]{0,63}$), value (write-only field), optional description (max 200 chars, no control chars) - Submit. The value is gone from the UI immediately — there’s no way to read it back.
Tenant overrides
Section titled “Tenant overrides”Tenant admins set these in their own UI (different surface, different auth). You don’t touch them — that’s the point of the tier separation.
If you want to require a tenant-override (no fallback), don’t seed
a dev-default. ctx.secrets.get(...) returns null and your code
should fail loudly:
key = await ctx.secrets.get("STRIPE_SECRET_KEY")if key is None: raise RuntimeError( "This tenant has not configured STRIPE_SECRET_KEY. " "Ask the admin to set it in the app's settings." )Read-time semantics
Section titled “Read-time semantics”val = await ctx.secrets.get("MY_KEY")| Outcome | Returns |
|---|---|
| Tenant-override exists | decrypted string |
| Only dev-default exists | decrypted string |
| Neither set | None |
| Network error reaching the platform | None (fail-closed) |
| 4xx from the platform (auth error) | None |
| Malformed key (doesn’t match the regex) | None |
The SDK never falls back to environment variables. A previous Phase-2 design did, and the security review (rightly) flagged it: a partner process can mutate its own env, so an env-fallback let partner code plant secret values that won on any platform hiccup. Today it’s fail-closed.
Rotation
Section titled “Rotation”Same CLI command, same UI button — just submit a new value. The old
value is overwritten in the same row; no “rotate window” or active/
deprecated split. If you need that pattern, store two distinct keys
(MY_KEY_PRIMARY, MY_KEY_BACKUP) and rotate them in sequence.
Every read is audit-logged:
- Visible to the dev in the Logs / Errors tabs (filtered to your app)
- Tenant identifier truncated to 8-char prefix (devs don’t need full tenant UUIDs)
- The value itself NEVER appears in any audit trail or log line — only the key name + outcome
The audit row is written before the value is returned. If the audit
bus is unreachable the platform still serves the read, but bumps a
Prometheus counter (linkworld_app_secret_audit_failures_total) so an
alarm fires — silent gaps are not acceptable.
Failure modes you should handle
Section titled “Failure modes you should handle”# 1. Key not configured (most common).api_key = await ctx.secrets.get("OPENAI_KEY")if api_key is None: return {"error": "tenant has not configured OPENAI_KEY"}
# 2. Key configured but the underlying API rejects it (rotated, revoked).try: response = await openai_client.complete(api_key=api_key, ...)except openai_client.AuthError: # Don't retry blindly — tell the tenant to update their key. return {"error": "OPENAI_KEY rejected by upstream; please rotate"}