create-flowdular 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  2. package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/cli-extension/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/deploy-operate/SKILL.md +114 -0
  5. package/agent-template/.agents/skills/module-new/SKILL.md +29 -0
  6. package/agent-template/.agents/skills/module-update/SKILL.md +9 -1
  7. package/agent-template/.agents/skills/spec-interview/SKILL.md +114 -0
  8. package/agent-template/.agents/skills/ux-design/SKILL.md +34 -3
  9. package/agent-template/.ai/README.md +2 -1
  10. package/agent-template/.ai/agents/sandbox/business-manager.md +5 -1
  11. package/agent-template/.ai/blueprints/author-spec/README.md +1 -1
  12. package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +44 -0
  13. package/agent-template/.ai/blueprints/author-spec/steps.yaml +5 -5
  14. package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +99 -3
  15. package/agent-template/.ai/blueprints/edit-module/gates.yaml +4 -0
  16. package/agent-template/.ai/blueprints/edit-module/required-files.yaml +9 -0
  17. package/agent-template/.ai/blueprints/new-module/gates.yaml +4 -0
  18. package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +2 -2
  19. package/agent-template/.ai/blueprints/release/gates.yaml +4 -0
  20. package/agent-template/.ai/platform-capabilities.md +128 -0
  21. package/agent-template/.ai/policies/capabilities.yaml +130 -3
  22. package/agent-template/.ai/policies/path-ownership.yaml +5 -2
  23. package/agent-template/.ai/policies/task-budgets.yaml +5 -3
  24. package/agent-template/.ai/references/catalog/module.json +4 -4
  25. package/agent-template/.ai/references/catalog/package.json +2 -2
  26. package/agent-template/.ai/references/catalog/spec/module.yaml +5 -3
  27. package/agent-template/.ai/references/catalog/src/platform.ts +2 -0
  28. package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +89 -1
  29. package/agent-template/.ai/references/catalog/src/services/data-classes.ts +47 -0
  30. package/agent-template/.ai/references/catalog/src/services/database-repository.ts +98 -1
  31. package/agent-template/.ai/references/catalog/src/services/repository.ts +22 -1
  32. package/agent-template/.ai/references/catalog/tests/data-classes.test.ts +157 -0
  33. package/agent-template/.ai/references/catalog.provenance.json +12 -10
  34. package/agent-template/.ai/rules/flowdular.md +4 -0
  35. package/agent-template/.ai/skills/README.md +10 -0
  36. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -2
  37. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  38. package/agent-template/.ai/skills/business-agent-design/SKILL.md +0 -1
  39. package/agent-template/.ai/skills/cli-extension/SKILL.md +1 -1
  40. package/agent-template/.ai/skills/deploy-operate/SKILL.md +119 -0
  41. package/agent-template/.ai/skills/module-new/SKILL.md +29 -3
  42. package/agent-template/.ai/skills/module-update/SKILL.md +9 -3
  43. package/agent-template/.ai/skills/perf-audit/SKILL.md +0 -1
  44. package/agent-template/.ai/skills/release-eject-pr/SKILL.md +0 -1
  45. package/agent-template/.ai/skills/spec-interview/SKILL.md +120 -0
  46. package/agent-template/.ai/skills/test-hardening/SKILL.md +1 -0
  47. package/agent-template/.ai/skills/ux-design/SKILL.md +34 -3
  48. package/agent-template/.ai/skills/variables/SKILL.md +0 -2
  49. package/agent-template/.ai/skills/workflow-development/SKILL.md +0 -1
  50. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  51. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  52. package/agent-template/.claude/skills/cli-extension/SKILL.md +1 -1
  53. package/agent-template/.claude/skills/deploy-operate/SKILL.md +114 -0
  54. package/agent-template/.claude/skills/module-new/SKILL.md +29 -0
  55. package/agent-template/.claude/skills/module-update/SKILL.md +9 -1
  56. package/agent-template/.claude/skills/spec-interview/SKILL.md +114 -0
  57. package/agent-template/.claude/skills/ux-design/SKILL.md +34 -3
  58. package/agent-template/AGENTS.md +4 -0
  59. package/agent-template/CLAUDE.md +4 -0
  60. package/agent-template/docs/adr/0003-module-settings.md +1 -1
  61. package/agent-template/docs/adr/0006-agentic-workflows.md +24 -21
  62. package/agent-template/docs/agent-contract.md +2 -2
  63. package/agent-template/docs/cli-extensions.md +82 -0
  64. package/agent-template/docs/cli.md +195 -0
  65. package/agent-template/docs/configuration.md +593 -36
  66. package/agent-template/docs/design-system.md +185 -31
  67. package/agent-template/docs/getting-started.md +118 -0
  68. package/agent-template/docs/module-distribution.md +96 -0
  69. package/agent-template/docs/module-web-surfaces.md +221 -0
  70. package/agent-template/docs/modules.md +216 -0
  71. package/agent-template/docs/operations.md +545 -0
  72. package/agent-template/docs/sandbox.md +212 -0
  73. package/agent-template/platform/scripts/build.mjs +11 -0
  74. package/dist/bin.js +29 -0
  75. package/package.json +1 -1
  76. package/template/default/.dockerignore +14 -0
  77. package/template/default/.env.example +96 -0
  78. package/template/default/README.md +37 -1
  79. package/template/default/flowdular.json +15 -4
  80. package/template/default/infra/README.md +116 -0
  81. package/template/default/infra/docker/Dockerfile +37 -0
  82. package/template/default/infra/docker/compose.yaml +158 -0
  83. package/template/default/infra/docker/postgres/10-roles.sh +31 -0
  84. package/template/default/infra/docker/postgres/tls-init.sh +28 -0
  85. package/template/default/infra/kubernetes/database-secret.example.yaml +15 -0
  86. package/template/default/infra/kubernetes/deployment.yaml +211 -0
  87. package/template/default/infra/kubernetes/kustomization.yaml +9 -0
  88. package/template/default/infra/kubernetes/secrets.example.yaml +52 -0
  89. package/template/default/infra/kubernetes/service.yaml +13 -0
  90. package/template/default/modules/example/module.json +2 -1
  91. package/template/default/modules/example/package.json +1 -1
  92. package/template/default/modules/example/spec/module.yaml +1 -1
  93. package/template/default/modules/example/src/services/database-repository.ts +2 -12
  94. package/template/default/package.json +3 -2
  95. package/template/default/platform/octane.config.ts +99 -9
  96. package/template/default/platform/package.json +1 -1
  97. package/template/default/platform/src/generated/modules.client.ts +26 -2
  98. package/template/default/platform/src/generated/modules.server.ts +241 -10
  99. package/template/default/platform/src/server/health.ts +47 -0
  100. package/template/default/platform/src/server/metrics.ts +100 -0
  101. package/template/default/platform/src/server/storage.ts +172 -0
  102. package/template/default/platform/src/server/tracing.ts +85 -0
  103. package/template/default/specs/application.yaml +15 -0
@@ -6,13 +6,38 @@ deployments must set the secret keys.
6
6
 
7
7
  ## Platform
8
8
 
9
- | Variable | Default | Purpose |
10
- | -------------------- | ------------------------------ | ------------------------------------------------ |
11
- | `FD_ENV` | `NODE_ENV`, else `development` | Environment the CLI and destructive guards check |
12
- | `FD_PORT` | `3000` | Host port published by the container |
13
- | `FD_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` behind a reverse proxy |
14
- | `FD_CSP` | built-in policy | Override the Content Security Policy |
15
- | `FD_CSP_REPORT_ONLY` | `true` outside production | Report CSP violations instead of enforcing them |
9
+ | Variable | Default | Purpose |
10
+ | -------------------- | --------------------------------- | -------------------------------------------------------------------------- |
11
+ | `FD_ENV` | `NODE_ENV`, else `development` | Environment the CLI and destructive guards check |
12
+ | `FD_PORT` | `3000` | Host port published by the container |
13
+ | `FD_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` behind a reverse proxy |
14
+ | `FD_CSP` | built-in policy | Override the Content Security Policy |
15
+ | `FD_CSP_REPORT_ONLY` | `true` outside production | Report CSP violations instead of enforcing them |
16
+ | `FD_LOG_FORMAT` | `json` in production, else `text` | `json` (one object per line) or `text`; see [operations.md](operations.md) |
17
+ | `FD_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` |
18
+ | `FD_METRICS` | `false` | Expose `GET /api/metrics`; see [operations.md](operations.md) |
19
+ | `FD_METRICS_TOKEN` | none | Bearer token a metrics scrape must present |
20
+
21
+ ## Observability
22
+
23
+ Spans are always recorded into a bounded in-process buffer and the logger always
24
+ writes the trace id; only the two egresses below are optional, and a
25
+ misconfigured one fails the boot rather than silently sending nothing.
26
+
27
+ | Variable | Default | Purpose |
28
+ | ----------------------- | ------- | ------------------------------------------------------------------------- |
29
+ | `FD_TRACE_SAMPLE` | `1` | Ratio of new root traces recorded, 0 to 1 |
30
+ | `FD_TRACE_EXPORTER` | `none` | `none` or `otlp`; see [operations.md](operations.md) |
31
+ | `FD_TRACE_OTLP_URL` | none | OTLP/HTTP JSON traces endpoint; required with `otlp`, https in production |
32
+ | `FD_TRACE_OTLP_HEADERS` | none | `name=value,name2=value2` sent with every batch, at most 16 |
33
+ | `FD_ERROR_SINK` | `none` | `none` or `webhook`; where a logged error is reported |
34
+ | `FD_ERROR_SINK_URL` | none | Webhook endpoint; required with `webhook`, https in production |
35
+ | `FD_ERROR_SINK_TOKEN` | none | Bearer credential the webhook request presents |
36
+
37
+ A sample ratio outside 0 to 1, an unknown exporter or sink, a missing URL, a
38
+ plain `http` endpoint in production and a malformed header list are all refused
39
+ while the platform composes. An unsampled trace still propagates its
40
+ `traceparent`, so a downstream service keeps the correlation.
16
41
 
17
42
  ## Database
18
43
 
@@ -46,48 +71,579 @@ unreachable. Set the authority either inline or as a file, never both.
46
71
 
47
72
  ## Authentication (`auth.core`)
48
73
 
49
- | Variable | Default | Purpose |
50
- | ------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------- |
51
- | `FD_AUTH_ALLOW_SIGN_UP` | `true` outside production | Expose account creation |
52
- | `FD_AUTH_SECURE_COOKIE` | `true` in production | Secure flag and `__Host-` prefix on the session cookie |
53
- | `FD_AUTH_PUBLIC_ORIGIN` | request origin | Absolute public origin, HTTPS unless loopback |
54
- | `FD_AUTH_SESSION_TTL_HOURS` | `12` (1 to 168) | Absolute session lifetime |
55
- | `FD_AUTH_SESSION_IDLE_MINUTES` | `120` (5 to 1440) | Idle timeout |
56
- | `FD_AUTH_PASSWORD_MIN_LENGTH` | `12` (8 to 128) | Minimum password length |
57
- | `FD_AUTH_EMAIL_CONFIRMATION` | `false` | Hold the session after sign-up until the address is confirmed; requires a composed mail transport |
58
- | `FD_AUTH_DEVELOPMENT_MAIL` | `false` | In-memory mail delivery, refused in production |
59
- | `FD_AUTH_SIGN_IN_PROVIDERS` | empty | Comma list of external providers rendered on sign-in |
60
- | `FD_AUTH_OIDC_PROVIDERS` | empty | JSON array of at most eight OIDC provider configurations |
61
- | `FD_AUTH_MFA_KEY` | unset | Base64 32-byte key encrypting MFA secrets; MFA enrollment is unavailable without it |
62
-
63
- Listing a provider in `FD_AUTH_SIGN_IN_PROVIDERS` only surfaces the button; the
64
- matching `/api/auth/sso/{provider}/start` handler must be composed at the
65
- platform level.
74
+ | Variable | Default | Purpose |
75
+ | -------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
76
+ | `FD_AUTH_ALLOW_SIGN_UP` | `true` outside production | Expose account creation |
77
+ | `FD_AUTH_SECURE_COOKIE` | `true` in production | Secure flag and `__Host-` prefix on the session cookie |
78
+ | `FD_AUTH_PUBLIC_ORIGIN` | request origin | Absolute public origin, HTTPS unless loopback |
79
+ | `FD_AUTH_SESSION_TTL_HOURS` | `12` (1 to 168) | Absolute session lifetime |
80
+ | `FD_AUTH_SESSION_IDLE_MINUTES` | `120` (5 to 1440) | Idle timeout |
81
+ | `FD_AUTH_PASSWORD_MIN_LENGTH` | `12` (8 to 128) | Minimum password length |
82
+ | `FD_AUTH_EMAIL_CONFIRMATION` | `false` | Hold the session after sign-up; needs a mail transport. auth.core composes no confirmation message yet, so none is sent |
83
+ | `FD_AUTH_MAIL_TRANSPORT` | `none` | Deprecated spelling of `FD_MAIL_TRANSPORT`; see Mail |
84
+ | `FD_AUTH_SMTP_URL` | none | Deprecated spelling of `FD_MAIL_SMTP_URL`; see Mail |
85
+ | `FD_AUTH_MAIL_FROM` | none | Deprecated spelling of `FD_MAIL_FROM`; see Mail |
86
+ | `FD_AUTH_SMTP_TLS_REJECT_UNAUTHORIZED` | `true` | Deprecated spelling of `FD_MAIL_SMTP_TLS_REJECT_UNAUTHORIZED`; see Mail |
87
+ | `FD_AUTH_SMTP_REQUIRE_TLS` | `true` | Deprecated spelling of `FD_MAIL_SMTP_REQUIRE_TLS`; see Mail |
88
+ | `FD_AUTH_DEVELOPMENT_MAIL` | `false` | Deprecated switch for `FD_MAIL_TRANSPORT=development`; in-memory, refused in production |
89
+ | `FD_AUTH_SIGN_IN_PROVIDERS` | empty | Comma list of external providers rendered on sign-in |
90
+ | `FD_AUTH_OIDC_PROVIDERS` | empty | JSON array of at most eight OIDC provider configurations |
91
+ | `FD_AUTH_PROVIDER_HOST_ALLOWLIST` | empty | Comma list of hosts a provider URL may name; empty allows any public host |
92
+ | `FD_AUTH_MFA_KEY` | unset | Base64 32-byte key encrypting MFA secrets; MFA enrollment is unavailable without it |
93
+ | `FD_AUTH_MFA_KEY_PREVIOUS` | empty | Comma list of retired MFA keys, kept readable until `auth secrets-rotate --apply` re-seals them |
94
+
95
+ Listing a provider in `FD_AUTH_SIGN_IN_PROVIDERS` only surfaces the button. The
96
+ matching entry in `FD_AUTH_OIDC_PROVIDERS` is what makes
97
+ `/api/auth/oidc/{provider}/start` serve it; a listed id without one is dropped
98
+ from the sign-in screen. SAML is not implemented, so no provider id is served
99
+ over SAML.
100
+
101
+ Each entry of `FD_AUTH_OIDC_PROVIDERS` requires `id`, `issuer`,
102
+ `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, `clientId` and
103
+ `clientSecret`, all HTTPS URLs except the ids and the credentials:
104
+
105
+ ```json
106
+ [
107
+ {
108
+ "id": "example",
109
+ "issuer": "https://identity.example",
110
+ "authorizationEndpoint": "https://identity.example/authorize",
111
+ "tokenEndpoint": "https://identity.example/token",
112
+ "userInfoEndpoint": "https://identity.example/userinfo",
113
+ "clientId": "client-id",
114
+ "clientSecret": "client-secret"
115
+ }
116
+ ]
117
+ ```
118
+
119
+ `issuer` is required and is the exact `iss` value the provider puts in its ID
120
+ tokens. The callback reads `{issuer}/.well-known/openid-configuration` and the
121
+ `jwks_uri` it names, caches both for ten minutes, and verifies every ID token
122
+ against that key set: RS256 or ES256 signature, `iss`, `aud`, `exp` and `iat`
123
+ within five minutes, and the `nonce` bound to the signed state cookie. A
124
+ provider entry without `issuer` refuses the boot, and a token that fails any
125
+ check ends the flow with `OIDC_AUTHENTICATION_FAILED`. The verified `sub` is
126
+ stored on the account and identifies it on later sign-ins, so an address change
127
+ at the provider does not move the session to another account.
128
+
129
+ ### Platform and workspace identity providers
130
+
131
+ Every `FD_AUTH_OIDC_PROVIDERS` entry is a platform provider: it is read at boot,
132
+ offered to every workspace read-only, served from `/api/auth/oidc/{id}/start`,
133
+ and binds identities without a workspace exactly as before. A workspace adds
134
+ providers of its own in Administration, Identity providers, behind
135
+ `auth.providers.read` and `auth.providers.manage`, and they are stored in
136
+ `auth_identity_providers` under the workspace's forced row-level security. No
137
+ API changes a platform provider.
138
+
139
+ A workspace provider is saved with an HTTPS issuer that is verified through the
140
+ same discovery path (`{issuer}/.well-known/openid-configuration` naming the same
141
+ issuer back); the authorization, token and userinfo endpoints that document
142
+ publishes are stored with it. Its client secret is entered once, sealed with the
143
+ deployment key in `FD_AUTH_MFA_KEY` under a provider-specific context, and never
144
+ returned: administrators see a fingerprint. `pnpm flowdular auth secrets-rotate`
145
+ re-seals workspace provider secrets in bounded batches beside the enrolled TOTP
146
+ factors, and reports counts per table; the fingerprint does not change, because
147
+ the secret behind it did not. A platform provider shows no fingerprint at all:
148
+ its secret is the deployment's, shared by every workspace, so no workspace is
149
+ handed a value derived from it.
150
+
151
+ `POST /api/auth/providers/update` is a partial update. It needs the `id`, and
152
+ every field it leaves out keeps what the stored row has, so sending only
153
+ `{ id, label }` renames a provider and changes nothing about its issuer, client
154
+ id, scopes, provisioning, domains or role. The merged record is validated as a
155
+ whole, so turning `jitEnabled` on without sending `allowedDomains` is refused
156
+ when the stored list is empty. Sending `clientSecret` here replaces the secret,
157
+ exactly as `POST /api/auth/providers/rotate-secret` does; leaving it out keeps
158
+ the sealed one. A role a provider names in `jitRole` cannot be deleted while it
159
+ does: `POST /api/auth/roles/delete` answers `ROLE_NAMED_BY_PROVIDER` (409) until
160
+ the provider is pointed at another role.
161
+
162
+ Every URL auth.core fetches for a provider is host-guarded before the request
163
+ leaves: the issuer a save supplies, the endpoints its discovery document
164
+ publishes, and the stored token and userinfo endpoints a sign-in uses. The URL
165
+ must be HTTPS without credentials and must name a host, never `localhost`, a
166
+ `.local`, `.localhost` or `.internal` name, or a literal address, which is what
167
+ keeps a deployment from being pointed at 127.0.0.1, 10/8, 172.16/12,
168
+ 192.168/16, 169.254/16 or their IPv6 equivalents. `FD_AUTH_PROVIDER_HOST_ALLOWLIST`
169
+ narrows it further to an exact comma-separated list of hostnames, the way
170
+ `FD_AGENT_PROVIDER_HOST_ALLOWLIST` does for agent providers; empty allows any
171
+ public host. A refused save answers `PROVIDER_HOST_BLOCKED` or
172
+ `PROVIDER_HOST_NOT_ALLOWLISTED` (400) before anything is fetched, and a sign-in
173
+ against a refused stored endpoint ends with the generic
174
+ `OIDC_AUTHENTICATION_FAILED`. Set the allowlist on any deployment where
175
+ workspaces configure their own providers: without it a hostname that resolves
176
+ into a private range is still reachable, since the guard does not resolve names.
177
+
178
+ Sign-in is routed by workspace. `GET /api/auth/config?workspace={slug}` answers
179
+ with the password form, that workspace's enabled providers and the platform
180
+ providers; an unknown workspace answers exactly as none at all. A workspace
181
+ provider starts at `/api/auth/oidc/{workspace}/{key}/start` and returns to
182
+ `/api/auth/oidc/{workspace}/{key}/callback`, which is the redirect URI to
183
+ register with the provider. The signed state cookie binds the workspace and the
184
+ provider key, and a callback whose state names another workspace is refused
185
+ before the code is exchanged. A workspace provider opens a session in its own
186
+ workspace only.
187
+
188
+ The password form is routed the same way. `POST /api/auth/sign-in` takes an
189
+ optional `workspace` (the id or slug the screen resolved, at most 128
190
+ characters): the account has to hold an active membership there, and the session
191
+ opens in that workspace whether or not it is the account's oldest one. A
192
+ workspace that does not exist and a workspace the account is not a member of
193
+ both answer the same `INVALID_CREDENTIALS` (401) a wrong password does, so the
194
+ form reports nothing about which workspaces hold an address. Without the field
195
+ the oldest membership answers, exactly as before.
196
+
197
+ The workspace lookup behind `GET /api/auth/config?workspace={slug}` is bounded
198
+ per workspace reference, 120 per minute, and per client address plus workspace
199
+ when `FD_TRUST_PROXY` is on and a proxy reports one. Enumerating workspace ids
200
+ is what the bound is for; a visitor asking for another workspace never spends
201
+ the first one's allowance.
202
+
203
+ ### Just-in-time provisioning
204
+
205
+ Provisioning is off per provider. Turned on, it requires a non-empty list of
206
+ allowed e-mail domains and the role a new member receives (a role key of that
207
+ workspace; `member` by default). A verified address inside those domains creates
208
+ or reuses the account, creates an active membership with the role's scopes,
209
+ appends an `auth.member.provisioned` audit row and opens the session. With
210
+ provisioning off, an external sign-in needs an existing membership; an address
211
+ outside the allowed domains is refused before any account or membership is
212
+ written. Both refusals answer with the same stable external sign-in error.
213
+
214
+ ### Membership status
215
+
216
+ Membership status is per workspace. `POST /api/auth/memberships/status` with
217
+ `{ accountId, status }` (scope `users.members.manage`, browser session and
218
+ `x-csrf-token` required) disables or re-enables the membership of the acting
219
+ workspace: disabling revokes that workspace's sessions and API tokens for the
220
+ account and refuses its sign-in and tenant switch, while the person's other
221
+ workspaces keep working. Re-enabling restores sign-in, and tokens revoked by the
222
+ disable stay revoked. The account-level status stays the deployment operator's
223
+ platform-wide block and is unchanged by this route; the member drawer shows it
224
+ read-only and says so. Disabling a membership that is already disabled changes
225
+ nothing and answers with the same status, and an owner whose account the
226
+ operator has blocked is not one of the active owners the last-owner rule counts,
227
+ so removing its workspace access is not held hostage by that rule.
228
+
229
+ `FD_AUTH_MFA_KEY` is rotatable. Move the retiring key into
230
+ `FD_AUTH_MFA_KEY_PREVIOUS` (comma separated, at most eight entries), put the new
231
+ one in `FD_AUTH_MFA_KEY`, and boot: stored factors keep opening, every new
232
+ enrolment is sealed with the new key, and each row records which key sealed it.
233
+ `pnpm flowdular auth secrets-rotate` counts the rows per key and
234
+ `--apply` re-seals the stale ones in batches of 200; drop the retired key once
235
+ it reports no stale rows.
236
+
237
+ Without `FD_AUTH_MFA_KEY` a workspace cannot turn on the `auth.core`
238
+ `requireMfa` setting: enrolment has no key to seal a secret with, so the
239
+ requirement would close the workspace to everyone. The write is refused with
240
+ `MFA_KEY_REQUIRED` (409), the way email confirmation is refused without a mail
241
+ transport. With the requirement on, `GET /api/settings` and
242
+ `POST /api/settings/update` stay reachable so an owner can always reverse it,
243
+ and API tokens are exempt from the enrolment gate: a service account has no
244
+ browser to enrol with, and its authority is already the intersection of its
245
+ recorded scopes with the live membership. Browser sessions stay gated, on the
246
+ API and on module web pages alike.
247
+
248
+ auth.core no longer owns the transport: it composes the invitation, password
249
+ reset and confirmation messages and hands them to the platform mail port, which
250
+ the Mail section below configures. The `FD_AUTH_*` mail variables above are the
251
+ retired spelling of the `FD_MAIL_*` ones and still work. Without a transport
252
+ (`none`) an invitation is refused with `MAIL_NOT_CONFIGURED` and a password
253
+ reset still answers generically while the server logs one warning that the
254
+ message was not delivered.
66
255
 
67
256
  ## Agents (`agents.core`)
68
257
 
69
258
  | Variable | Default | Purpose |
70
259
  | ---------------------------------- | ----------------- | ---------------------------------------------------- |
71
260
  | `FD_AGENT_CREDENTIAL_KEY` | generated dev key | Base64 32-byte key for the provider credential vault |
261
+ | `FD_AGENT_CREDENTIAL_KEY_PREVIOUS` | empty | Retired credential keys, comma separated, read only |
72
262
  | `FD_AGENT_RUN_GRANT_KEY` | generated dev key | Base64 32-byte key signing run grants |
73
263
  | `FD_AGENT_WORKER_CONCURRENCY` | `2` (1 to 16) | Parallel run workers |
74
264
  | `FD_AGENT_WORKER_LEASE_MS` | `30000` | Run lease before recovery reclaims it |
75
265
  | `FD_AGENT_PROVIDER_HOST_ALLOWLIST` | empty | Hostnames an external provider may be called on |
76
266
 
77
- Outside production the keys are generated once under `.flowdular/data`. Both are
78
- required in production: the module refuses to boot without them.
79
- Rotating `FD_AGENT_CREDENTIAL_KEY` invalidates every stored provider credential.
267
+ Outside production the keys are generated once under `.flowdular/data`. The
268
+ credential key and the run grant key are required in production: the module
269
+ refuses to boot without them. `FD_AGENT_CREDENTIAL_KEY_PREVIOUS` holds the keys
270
+ a rotation has not finished retiring: stored credentials still open under them
271
+ and nothing is written with them. `pnpm flowdular agents secrets-rotate --apply`
272
+ re-seals the stored rows; see the key rotation section of `docs/operations.md`.
273
+
274
+ ## Automations (`automations.core`)
275
+
276
+ | Variable | Default | Purpose |
277
+ | ---------------------------------------- | ----------------- | ------------------------------------------------------ |
278
+ | `FD_AUTOMATIONS_CREDENTIAL_KEY` | generated dev key | Base64 32-byte key for the automation credential vault |
279
+ | `FD_AUTOMATIONS_CREDENTIAL_KEY_PREVIOUS` | empty | Retired secret keys, comma separated, read only |
280
+
281
+ Outside production the key is generated once under `.flowdular/data`. It is
282
+ required in production: the module refuses to boot without it.
283
+ `FD_AUTOMATIONS_CREDENTIAL_KEY_PREVIOUS` holds the keys a rotation has not
284
+ finished retiring: stored trigger secrets still open under them and nothing is
285
+ written with them. See the key rotation section of `docs/operations.md`.
286
+
287
+ ## Notifications (`notifications.core`)
288
+
289
+ | Variable | Default | Purpose |
290
+ | -------------------------------------- | ----------------- | -------------------------------------------------- |
291
+ | `FD_NOTIFICATIONS_SECRET_KEY` | generated dev key | Base64 32-byte key sealing webhook signing secrets |
292
+ | `FD_NOTIFICATIONS_SECRET_KEY_PREVIOUS` | empty | Retired secret keys, comma separated, read only |
293
+
294
+ Outside production the key is generated once under `.flowdular/data`. It is
295
+ required in production: the module refuses to boot without it.
296
+ `FD_NOTIFICATIONS_SECRET_KEY_PREVIOUS` holds the keys a rotation has not
297
+ finished retiring: stored webhook secrets still open under them and nothing is
298
+ written with them.
299
+
300
+ The delivery loop itself is configured through module settings, not the
301
+ environment: `retentionDays`, `retryMaxAttempts` and `retryMaxBackoffMinutes`
302
+ per workspace, `egressAllowlist` and `pollIntervalSeconds` for the platform.
303
+ Edit them in Administration, Modules.
304
+
305
+ E-mail delivery has no variable of its own. A member turns it on for their own
306
+ account under Notification preferences, and the message then leaves through the
307
+ platform mail port with the same queue, retry, backoff and dead letter a webhook
308
+ gets. With no transport composed the attempts are recorded as refused rather
309
+ than dropped, so the deliveries screen shows what was not sent.
310
+
311
+ ## Connectors (`connectors.core`)
312
+
313
+ | Variable | Default | Purpose |
314
+ | ----------------------------------- | ----------------- | --------------------------------------------------- |
315
+ | `FD_CONNECTORS_SECRET_KEY` | generated dev key | Base64 32-byte key sealing connector credentials |
316
+ | `FD_CONNECTORS_SECRET_KEY_PREVIOUS` | empty | Retired credential keys, comma separated, read only |
317
+
318
+ Outside production the key is generated once under `.flowdular/data`. It is
319
+ required in production: the module refuses to boot without it. Losing it makes
320
+ every stored connector credential unreadable, and each instance has to be given
321
+ its credential again. `FD_CONNECTORS_SECRET_KEY_PREVIOUS` holds the keys a
322
+ rotation has not finished retiring: stored credentials still open under them and
323
+ nothing is written with them.
324
+
325
+ The call bounds are module settings, not environment variables: `callTimeoutMs`
326
+ and `maxResponseBytes` for the platform. Edit them in Administration, Modules.
327
+ Which hosts an instance may reach is per instance, not per deployment.
328
+
329
+ ## Audit (`audit.core`)
330
+
331
+ | Variable | Default | Purpose |
332
+ | ------------------------------ | ------------- | ---------------------------------------------------------------- |
333
+ | `FD_AUDIT_BACKUP_MANIFEST` | empty | Path to the latest `backup.json`, or the directory holding it |
334
+ | `FD_AUDIT_EXPORT_DIRECTORY` | empty | Absolute directory outside the workspace tree operator files use |
335
+ | `FD_AUDIT_ANCHOR_KEY` | generated key | Base64 32-byte key signing audit chain anchors |
336
+ | `FD_AUDIT_ANCHOR_KEY_PREVIOUS` | empty | Retired anchor keys, comma separated, verify only, at most eight |
337
+
338
+ This is how a deployment records that a backup exists. `flowdular database
339
+ backup --output <dir> --apply` writes `<dir>/backup.json`; point the variable at
340
+ that file or at `<dir>` and the retention sweep and the export read it on every
341
+ pass. Without it both are refused and the sweep ledger records `refused` with
342
+ the reason `BACKUP_MANIFEST_MISSING`, so a retention policy can never destroy
343
+ data no backup holds. Update the value after every backup: the export manifest
344
+ records the `createdAt` and the key fingerprints it found, which is the evidence
345
+ that the archive was taken while a backup existed.
346
+
347
+ `FD_AUDIT_EXPORT_DIRECTORY` is where the deployment allows an archive to be
348
+ written. An archive carries every row of one workspace, so the operator picks a
349
+ directory inside this one and nowhere else: `flowdular audit export --workspace
350
+ <slug> --output <dir> --apply` is refused with `EXPORT_DIRECTORY_NOT_CONFIGURED`
351
+ while the variable is empty, with `EXPORT_OUTPUT_NOT_ALLOWED` for a directory
352
+ outside it, and with `EXPORT_OUTPUT_INSIDE_WORKSPACE` when it points inside the
353
+ application tree, where a deployment step could commit or serve the archive by
354
+ accident. The platform creates the directories it needs with owner-only modes
355
+ and writes each archive `0600`.
356
+
357
+ **The platform must be running for an export, with or without `--apply`.** The
358
+ command records the request in the workspace and waits; the platform process is
359
+ the only one holding the sealed data class registry with every module's export
360
+ operation, so it is the process that performs the export, writes the archive and
361
+ records the run with its digest. Without `--apply` the same request is answered
362
+ as a plan: the platform counts through every owner and writes no archive. If no
363
+ platform answers within ten minutes the command stops with `EXPORT_NOT_ANSWERED`
364
+ and the run stays in the workspace's export history.
365
+
366
+ `FD_AUDIT_EXPORT_DIRECTORY` is also where `audit seal`, `audit verify` and
367
+ `audit erase` read and write. A segment file carries every audit event of a
368
+ range and a certificate names a person, so the same rule applies: the operator
369
+ picks a directory inside the allowed one, never inside the application tree.
370
+
371
+ `FD_AUDIT_ANCHOR_KEY` signs the anchor of every sealed segment with
372
+ HMAC-SHA256, which is what proves a segment file was closed by this deployment
373
+ and not by whoever holds the file. It is required in production. Outside
374
+ production the platform generates a random 32-byte key on first use and keeps it
375
+ in the local data directory, so anchors sealed yesterday still verify today; it
376
+ is not derived from anything, and losing that file loses the ability to verify
377
+ the anchors it signed. `FD_AUDIT_ANCHOR_KEY_PREVIOUS` holds the keys a rotation
378
+ has not finished retiring, comma separated: they verify anchors already written
379
+ and sign nothing. At most eight of them are accepted, the same bound the shared
380
+ keyring enforces; a longer list is a rotation that was never finished and the
381
+ platform refuses to start with it. See Key rotation in `docs/operations.md`.
382
+
383
+ The sweep itself is configured through platform module settings, not the
384
+ environment: `sweepIntervalMinutes` (5 to 1440, default 60) and `sweepBatchSize`
385
+ (50 to 5000, default 500). Edit them in Administration, Modules. The batch size
386
+ is read on every pass; the interval is armed when the platform starts, so a new
387
+ cadence applies at the next start.
388
+
389
+ ### Sealing, legal holds and erasure
390
+
391
+ Three operator commands, all dry runs without `--apply`:
392
+
393
+ ```bash
394
+ flowdular audit seal --workspace <slug> --output <dir> [--apply]
395
+ flowdular audit verify --workspace <slug> --input <dir>
396
+ flowdular audit erase --workspace <slug> --account <id> --output <dir> \
397
+ [--apply --confirm erase-subject] [--destroy-key]
398
+ ```
399
+
400
+ `audit seal` closes a segment of the workspace's audit event chain: every event
401
+ after the last anchor, written as JSON Lines with the anchor as the first line
402
+ of the file and recorded in the workspace as well. The anchor carries the
403
+ sequence range, the row count, the time range, the hash chain over the segment
404
+ continuing from the previous anchor, and a signature. Sealing itself writes an
405
+ audit event, so each run leaves exactly one link for the next one.
406
+
407
+ `audit verify` reads the segment files a directory holds for that workspace,
408
+ recomputes every event hash and the segment chain, checks each anchor signature
409
+ and the links between anchors, and names the first line and anchor that do not
410
+ match. One directory may hold the segments of several workspaces: a verification
411
+ reads the files named after the one it was asked about and skips the rest. It
412
+ reports how many events in those segments carry no event format, which are the
413
+ ones written before audit.core sealed anything and therefore may carry an actor,
414
+ a subject and details in the clear; an event about nobody is not one of them.
415
+
416
+ Retention removes an audit event only once a segment file holds it. A sweep of
417
+ `audit.core.events` that finds older events above the newest anchor is recorded
418
+ `refused` with the reason `SEGMENT_NOT_SEALED` and removes none of them, so a
419
+ chain is never aged faster than it is archived. Archiving a segment to S3 is out
420
+ of scope; the deployment's own directory is the archive.
421
+
422
+ A legal hold is a per-workspace row an owner holding `audit.holds.manage` places,
423
+ lists and lifts, in Administration, Legal holds or through
424
+ `flowdular audit holds list|place|lift`. It names an account, the workspace, a
425
+ data class or a date range, optionally narrowed by several of them together, and
426
+ carries the reason it was placed and the reason it was lifted; both are written
427
+ to the audit trail. A hold names the account it covers and the matter behind it,
428
+ so `audit.registry.read` does not reach the list: all three operations need
429
+ `audit.holds.manage`. While a hold stands, the retention sweep and erasure refuse
430
+ for everything it covers with the stable code `HOLD_ACTIVE`, and the sweep
431
+ ledger records the refusal per class with the rows held back where audit.core
432
+ owns the class and can count them. A hold that names rows rather than a class
433
+ withholds the whole class of that workspace, because the module sweep contract
434
+ carries no row predicate; a hold never withholds less than it covers.
435
+
436
+ `audit erase` walks the sealed data class registry and removes one subject's
437
+ records from every class whose declaration carries an erase operation. It is
438
+ refused under a hold; without `--apply` it lists what would be erased per module
439
+ and class, and a class that cannot count says so rather than guessing. Every
440
+ class of the registry appears in the plan and on the certificate, and one that
441
+ declares no erase is named `not-erasable` rather than left out. With `--apply`
442
+ it needs `--confirm erase-subject`, runs each class inside the owning module's
443
+ own transaction, writes an audit event before and after, and writes a JSON
444
+ certificate naming the workspace, the subject, every class with its outcome and
445
+ count, the operator and the newest anchor. A class that reports rows left behind
446
+ leaves the certificate incomplete and the run recorded `partial`. **The platform
447
+ must be running**, as for an export: only the platform process holds the sealed
448
+ registry with every module's erase operation, so the command records the request
449
+ and waits for it; a request nothing answers within 24 hours is recorded failed
450
+ with `ERASURE_REQUEST_EXPIRED`.
451
+
452
+ A module offers a class for erasure by declaring it on the data class it already
453
+ declares, next to `sweep` and `export`:
454
+
455
+ ```ts
456
+ context.dataClasses.declare('agents.core', [
457
+ {
458
+ key: 'runs',
459
+ label: 'Agent runs',
460
+ defaultRetentionDays: 90,
461
+ exportable: true,
462
+ sweep: ({ tenantId, cutoff, limit }) => /* ... */,
463
+ export: ({ tenantId, sink }) => /* ... */,
464
+ erase: async ({ tenantId, subject, limit }) => {
465
+ const removed = await repository.deleteRunsOf(
466
+ tenantId,
467
+ subject.accountId,
468
+ limit,
469
+ );
470
+ return { removed, truncated: removed === limit };
471
+ },
472
+ count: ({ tenantId, subject }) =>
473
+ repository.countRunsOf(tenantId, subject.accountId),
474
+ },
475
+ ]);
476
+ ```
477
+
478
+ Both members are optional and both run inside the declaring module, on its own
479
+ leases and its own tenant transaction. `erase` removes at most `limit` rows and
480
+ answers how many it removed, with `truncated` when rows of the subject are left
481
+ for the next call; audit.core repeats the call until one answers fewer than the
482
+ batch or the batch cap is reached. `count` answers how many rows the subject
483
+ holds, or `null` when the module cannot say cheaply. The public capability
484
+ `audit.erasure.v1` carries the same two operations for a module that composes
485
+ after audit.core and prefers to register `{ moduleId, classId, erase, count? }`
486
+ instead; where a class has both, the declaration wins.
487
+
488
+ Audit events themselves are never erased: they are the evidence the data
489
+ lifecycle happened, and retention bounds them instead. From 0.2.0 an audit event
490
+ that names a person seals the actor, the subject and the details under that
491
+ person's data key in `audit_subject_keys`, and the event hash covers the sealed
492
+ bytes, so `audit erase --destroy-key` makes those fields unreadable for ever
493
+ while the chain still verifies. A destroyed key is never recreated: the key row
494
+ keeps a marker that outlives the account, so a later event about that subject is
495
+ written under the marker and a write that would create a second key is refused
496
+ with `SUBJECT_KEY_DESTROYED`. Events written before the event format marker stay
497
+ in the clear and `audit verify` reports them as such.
498
+
499
+ What a workspace can set a period for is what the composed modules declared. The
500
+ classes and the defaults they ship with:
501
+
502
+ | Class | Default retention | Swept | Exported | Erasable |
503
+ | ------------------------------------ | ----------------- | ----- | -------- | -------- |
504
+ | `agents.core.runs` | 90 days | yes | yes | yes |
505
+ | `agents.core.audit-events` | kept | no | yes | no |
506
+ | `agents.core.provider-credentials` | kept | no | no | no |
507
+ | `approvals.core.requests` | 400 days | yes | yes | yes |
508
+ | `audit.core.events` | 400 days | yes | yes | no |
509
+ | `audit.core.sweep-runs` | 400 days | yes | yes | no |
510
+ | `audit.core.export-runs` | 400 days | yes | yes | no |
511
+ | `audit.core.legal-holds` | kept | no | yes | no |
512
+ | `audit.core.erasure-runs` | kept | no | yes | no |
513
+ | `audit.core.anchors` | kept | no | no | no |
514
+ | `audit.core.subject-keys` | kept | no | no | no |
515
+ | `auth.core.sessions` | 30 days | yes | yes | yes |
516
+ | `auth.core.api-tokens` | 400 days | yes | yes | yes |
517
+ | `auth.core.audit-events` | 400 days | yes | yes | no |
518
+ | `automations.core.audit-events` | kept | no | yes | no |
519
+ | `connectors.core.calls` | 400 days | yes | yes | no |
520
+ | `connectors.core.audit` | kept | no | yes | no |
521
+ | `connectors.core.instances` | kept | no | yes | no |
522
+ | `directory.core.provisioning-events` | 365 days | yes | yes | no |
523
+ | `documents.core.documents` | kept | no | yes | no |
524
+ | `import.core.jobs` | 180 days | yes | yes | no |
525
+ | `metering.core.buckets` | 400 days | yes | yes | no |
526
+ | `notifications.core.inbox` | 365 days | yes | yes | no |
527
+ | `notifications.core.deliveries` | 30 days | yes | yes | no |
528
+ | `search.core.recent-queries` | 90 days | yes | yes | no |
529
+ | `workflows.core.runs` | 90 days | yes | yes | yes |
530
+ | `workflows.core.audit-events` | kept | no | yes | no |
531
+ | `workflows.core.definitions` | kept | no | yes | no |
532
+
533
+ "Kept" means the rows leave only when a person or the owning module deletes
534
+ them. "Erasable" means the class declares `erase` for a subject account; a class
535
+ without it is named as not erasable on every erasure certificate. A hash-chained trail is aged only once it is archived: removing links no
536
+ segment file holds would make the next verification indistinguishable from
537
+ tampering, so the sweep of `audit.core.events` stops at the newest anchor and
538
+ refuses the rest with `SEGMENT_NOT_SEALED`, while `automations.core` keeps its
539
+ chain until chain archival ships. A sweep never
540
+ removes a row that is still in use: a live session, a token that still
541
+ authenticates and a delivery attempt that has not completed stay whatever their
542
+ age. The periods above are defaults; Administration, Audit sets the workspace's
543
+ own.
80
544
 
81
545
  ## Workflows (`workflows.core`)
82
546
 
83
- | Variable | Default | Purpose |
84
- | -------------------------- | --------------- | -------------------------------------------- |
85
- | `FD_WORKFLOWS_PAYLOAD_KEY` | derived dev key | Base64 32-byte key encrypting run payloads |
86
- | `FD_WORKFLOWS_CURSOR_KEY` | derived dev key | Base64 32-byte key signing execution cursors |
547
+ | Variable | Default | Purpose |
548
+ | ----------------------------------- | --------------- | ------------------------------------------------- |
549
+ | `FD_WORKFLOWS_PAYLOAD_KEY` | derived dev key | Base64 32-byte key encrypting run payloads |
550
+ | `FD_WORKFLOWS_PAYLOAD_KEY_PREVIOUS` | empty | Retired payload keys, comma separated, read only |
551
+ | `FD_WORKFLOWS_CURSOR_KEY` | derived dev key | Base64 32-byte key signing execution cursors |
552
+ | `FD_WORKFLOWS_CURSOR_KEY_PREVIOUS` | empty | Retired cursor keys, comma separated, verify only |
553
+
554
+ Both current keys are required in production. The `_PREVIOUS` lists hold the
555
+ keys a rotation has not finished retiring: stored payloads still open and
556
+ outstanding cursors still verify under them, while every new payload is sealed
557
+ and every new cursor signed with the current key. Cursors are never stored, so a
558
+ cursor key needs no re-signing pass. See the key rotation section of
559
+ `docs/operations.md`.
560
+
561
+ ## Mail
562
+
563
+ One outbound transport serves the whole deployment. Modules never select one:
564
+ they receive the mail port on the server context (`context.mail`) and hand it a
565
+ bounded message. auth.core sends invitations, password resets and confirmations
566
+ through it, and notifications.core mails an inbox item to a member who asked
567
+ for it.
568
+
569
+ | Variable | Default | Purpose |
570
+ | -------------------------------------- | ------- | ------------------------------------------------------------------------------------- |
571
+ | `FD_MAIL_TRANSPORT` | `none` | `none`, `development` or `smtp`; `development` is refused in production |
572
+ | `FD_MAIL_SMTP_URL` | none | `smtp://` or `smtps://` relay URL with credentials; required by the `smtp` transport |
573
+ | `FD_MAIL_FROM` | none | Sender as `Name <address>` or `address`; required by the `smtp` transport |
574
+ | `FD_MAIL_SMTP_TLS_REJECT_UNAUTHORIZED` | `true` | Verify the relay certificate |
575
+ | `FD_MAIL_SMTP_REQUIRE_TLS` | `true` | Demand STARTTLS on the cleartext `smtp://` scheme; `false` allows a plaintext session |
576
+
577
+ Each variable falls back to the auth.core name it replaces
578
+ (`FD_AUTH_MAIL_TRANSPORT`, `FD_AUTH_SMTP_URL`, `FD_AUTH_MAIL_FROM`,
579
+ `FD_AUTH_SMTP_TLS_REJECT_UNAUTHORIZED`, `FD_AUTH_SMTP_REQUIRE_TLS`, and
580
+ `FD_AUTH_DEVELOPMENT_MAIL=true` for `FD_MAIL_TRANSPORT=development`). A
581
+ deployment on the old names keeps working and the server logs one
582
+ `deprecated mail variable` line per retired key it read, naming the
583
+ replacement. The platform name wins when both are set, and every refusal names
584
+ the variable the deployment actually set.
585
+
586
+ `none` refuses every message with `MAIL_NOT_CONFIGURED` and sends nothing.
587
+ `development` keeps the last 100 messages in memory for a local run and a test
588
+ and is refused at boot in production, where it would be silent data loss.
589
+ `smtp` connects with a 10 second connection and greeting timeout and sends the
590
+ plain-text and HTML parts together. `FD_MAIL_SMTP_URL` carries the relay
591
+ password, so it belongs in the same secret store as the encryption keys; it is
592
+ never logged. Its user and password are percent-decoded, so any reserved
593
+ character in them, `%` included, must be percent-encoded or the boot stops on
594
+ `FD_MAIL_SMTP_URL credentials must be percent-encoded.` The cleartext `smtp://`
595
+ scheme (port 587 by default) starts in the open and is upgraded by STARTTLS, so
596
+ `FD_MAIL_SMTP_REQUIRE_TLS` defaults to `true` and the relay has to offer the
597
+ upgrade; `smtps://` is already encrypted end to end and ignores the variable.
598
+
599
+ Every message is bounded before a transport sees it: at most 16 recipients, a
600
+ 200 character single-line subject, 64 KB of text, 256 KB of HTML, at most 16
601
+ extra headers whose names the envelope does not own, and no CR or LF anywhere a
602
+ header could be opened. A message that breaks a bound is refused with
603
+ `MAIL_MESSAGE_REJECTED` before the connection opens, and a relay failure
604
+ reaches the sender as `MAIL_DELIVERY_FAILED` carrying nothing the relay said.
605
+
606
+ ## Storage
607
+
608
+ Objects (attachments, documents, images) live outside PostgreSQL, so tenant
609
+ isolation there is the key layout rather than row-level security: every key is
610
+ `<tenantId>/<moduleId>/<objectId>` and the tenant id comes from the
611
+ authenticated principal, never from a request. Every object is encrypted with
612
+ AES-256-GCM before it is written, and its metadata is authenticated with it, so
613
+ an object moved into another tenant's prefix does not open.
614
+
615
+ | Variable | Default | Purpose |
616
+ | ------------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------- |
617
+ | `FD_STORAGE_ADAPTER` | `s3` in production, else `local` | `local` or `s3`; `local` is refused in production |
618
+ | `FD_STORAGE_LOCAL_DIRECTORY` | `.flowdular/data/storage` | Object directory of the local adapter |
619
+ | `FD_STORAGE_S3_BUCKET` | none | Bucket name; required by the S3 adapter |
620
+ | `FD_STORAGE_S3_REGION` | none | Signing region; required by the S3 adapter |
621
+ | `FD_STORAGE_S3_ENDPOINT` | `https://s3.<region>.amazonaws.com` | Endpoint for MinIO, R2 or another S3-compatible store |
622
+ | `FD_STORAGE_S3_ACCESS_KEY_ID` | none | Access key id; required by the S3 adapter |
623
+ | `FD_STORAGE_S3_SECRET_ACCESS_KEY` | none | Secret access key; required by the S3 adapter |
624
+ | `FD_STORAGE_S3_FORCE_PATH_STYLE` | `false` | `<endpoint>/<bucket>/<key>` instead of a bucket subdomain |
625
+ | `FD_STORAGE_MAX_OBJECT_BYTES` | `26214400` (25 MiB) | Per-object limit, 1024 to 268435456; a stream is cut off at it |
626
+ | `FD_STORAGE_ENCRYPTION_KEY` | derived dev key | Base64 32-byte key sealing every object and read URL; required in production |
627
+ | `FD_STORAGE_ENCRYPTION_KEY_PREVIOUS` | empty | Retired object keys, comma separated, read only |
628
+
629
+ A module writes through `context.storage` and never sees an adapter, a bucket or
630
+ a path. Only these content types are stored, and the bytes are verified against
631
+ the declared type before the write: PDF, PNG, JPEG, GIF, WebP, plain text, CSV,
632
+ the three OOXML documents (`.docx`, `.xlsx`, `.pptx`) and the two legacy Office
633
+ formats. Archives and executables are refused, and an archive renamed to
634
+ `.xlsx` is refused too, because an OOXML file has to carry
635
+ `[Content_Types].xml` as its first entry. A malware scanner is a seam rather
636
+ than a shipped feature: without one every object is stored with the verdict
637
+ `unscanned`, and a scanner that answers `infected` refuses the write.
87
638
 
88
- Both keys are required in production. Rotating the payload key makes retained
89
- execution payloads unreadable, so drain runs and let retention remove payloads
90
- first.
639
+ `storage.readUrl(...)` returns `/api/storage/objects/<token>`, a platform route
640
+ rather than a presigned object-store URL, because the stored bytes are
641
+ ciphertext. The token is the capability: it is sealed under the storage keyring,
642
+ carries the tenant, module, object and an expiry of at most one hour, and needs
643
+ no session. The route answers 404 for an expired, forged or unknown token
644
+ alike, sends `content-disposition: attachment` with
645
+ `cache-control: private, no-store`, and is limited to 600 reads a minute per
646
+ caller.
91
647
 
92
648
  ## Sandbox
93
649
 
@@ -118,5 +674,6 @@ openssl rand -base64 32
118
674
  ```
119
675
 
120
676
  For containers, copy `infra/docker/.env.example` to `infra/docker/.env` and fill
121
- in every empty value: the four encryption keys above and the four PostgreSQL
122
- role passwords. See [../infra/README.md](../infra/README.md).
677
+ in every empty value: every encryption key above, including the connectors
678
+ and audit anchor keys, the object store settings and the four PostgreSQL role
679
+ passwords. See [../infra/README.md](../infra/README.md).