create-flowdular 0.2.6 → 0.3.0
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.
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +109 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +29 -0
- package/agent-template/.agents/skills/module-update/SKILL.md +9 -1
- package/agent-template/.agents/skills/spec-interview/SKILL.md +114 -0
- package/agent-template/.agents/skills/ux-design/SKILL.md +34 -3
- package/agent-template/.ai/README.md +2 -1
- package/agent-template/.ai/agents/sandbox/business-manager.md +5 -1
- package/agent-template/.ai/blueprints/author-spec/README.md +1 -1
- package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +44 -0
- package/agent-template/.ai/blueprints/author-spec/steps.yaml +5 -5
- package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +99 -3
- package/agent-template/.ai/blueprints/edit-module/gates.yaml +4 -0
- package/agent-template/.ai/blueprints/edit-module/required-files.yaml +9 -0
- package/agent-template/.ai/blueprints/new-module/gates.yaml +4 -0
- package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +2 -2
- package/agent-template/.ai/blueprints/release/gates.yaml +4 -0
- package/agent-template/.ai/platform-capabilities.md +128 -0
- package/agent-template/.ai/policies/capabilities.yaml +100 -0
- package/agent-template/.ai/policies/path-ownership.yaml +5 -2
- package/agent-template/.ai/policies/task-budgets.yaml +5 -3
- package/agent-template/.ai/references/catalog/module.json +4 -4
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +5 -3
- package/agent-template/.ai/references/catalog/src/platform.ts +2 -0
- package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +89 -1
- package/agent-template/.ai/references/catalog/src/services/data-classes.ts +47 -0
- package/agent-template/.ai/references/catalog/src/services/database-repository.ts +98 -1
- package/agent-template/.ai/references/catalog/src/services/repository.ts +22 -1
- package/agent-template/.ai/references/catalog/tests/data-classes.test.ts +157 -0
- package/agent-template/.ai/references/catalog.provenance.json +12 -10
- package/agent-template/.ai/rules/flowdular.md +4 -0
- package/agent-template/.ai/skills/README.md +10 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -2
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.ai/skills/business-agent-design/SKILL.md +0 -1
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +114 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +29 -3
- package/agent-template/.ai/skills/module-update/SKILL.md +9 -3
- package/agent-template/.ai/skills/perf-audit/SKILL.md +0 -1
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +0 -1
- package/agent-template/.ai/skills/spec-interview/SKILL.md +120 -0
- package/agent-template/.ai/skills/test-hardening/SKILL.md +1 -0
- package/agent-template/.ai/skills/ux-design/SKILL.md +34 -3
- package/agent-template/.ai/skills/variables/SKILL.md +0 -2
- package/agent-template/.ai/skills/workflow-development/SKILL.md +0 -1
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +109 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +29 -0
- package/agent-template/.claude/skills/module-update/SKILL.md +9 -1
- package/agent-template/.claude/skills/spec-interview/SKILL.md +114 -0
- package/agent-template/.claude/skills/ux-design/SKILL.md +34 -3
- package/agent-template/AGENTS.md +4 -0
- package/agent-template/CLAUDE.md +4 -0
- package/agent-template/docs/adr/0003-module-settings.md +1 -1
- package/agent-template/docs/adr/0006-agentic-workflows.md +24 -21
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli-extensions.md +82 -0
- package/agent-template/docs/cli.md +190 -0
- package/agent-template/docs/configuration.md +593 -36
- package/agent-template/docs/design-system.md +185 -31
- package/agent-template/docs/getting-started.md +118 -0
- package/agent-template/docs/module-distribution.md +96 -0
- package/agent-template/docs/module-web-surfaces.md +221 -0
- package/agent-template/docs/modules.md +216 -0
- package/agent-template/docs/operations.md +464 -0
- package/agent-template/docs/sandbox.md +212 -0
- package/agent-template/platform/scripts/build.mjs +10 -0
- package/dist/bin.js +28 -0
- package/package.json +1 -1
- package/template/default/.dockerignore +14 -0
- package/template/default/.env.example +94 -0
- package/template/default/README.md +37 -1
- package/template/default/flowdular.json +15 -4
- package/template/default/infra/README.md +116 -0
- package/template/default/infra/docker/Dockerfile +37 -0
- package/template/default/infra/docker/compose.yaml +158 -0
- package/template/default/infra/docker/postgres/10-roles.sh +31 -0
- package/template/default/infra/docker/postgres/tls-init.sh +28 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +15 -0
- package/template/default/infra/kubernetes/deployment.yaml +211 -0
- package/template/default/infra/kubernetes/kustomization.yaml +9 -0
- package/template/default/infra/kubernetes/secrets.example.yaml +52 -0
- package/template/default/infra/kubernetes/service.yaml +13 -0
- package/template/default/modules/example/module.json +2 -1
- package/template/default/modules/example/package.json +1 -1
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/database-repository.ts +2 -12
- package/template/default/package.json +3 -2
- package/template/default/platform/octane.config.ts +99 -9
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/src/generated/modules.client.ts +26 -2
- package/template/default/platform/src/generated/modules.server.ts +241 -10
- package/template/default/platform/src/server/health.ts +47 -0
- package/template/default/platform/src/server/metrics.ts +100 -0
- package/template/default/platform/src/server/storage.ts +172 -0
- package/template/default/platform/src/server/tracing.ts +85 -0
- 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
|
|
10
|
-
| -------------------- |
|
|
11
|
-
| `FD_ENV` | `NODE_ENV`, else `development`
|
|
12
|
-
| `FD_PORT` | `3000`
|
|
13
|
-
| `FD_TRUST_PROXY` | `false`
|
|
14
|
-
| `FD_CSP` | built-in policy
|
|
15
|
-
| `FD_CSP_REPORT_ONLY` | `true` outside production
|
|
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
|
|
50
|
-
|
|
|
51
|
-
| `FD_AUTH_ALLOW_SIGN_UP`
|
|
52
|
-
| `FD_AUTH_SECURE_COOKIE`
|
|
53
|
-
| `FD_AUTH_PUBLIC_ORIGIN`
|
|
54
|
-
| `FD_AUTH_SESSION_TTL_HOURS`
|
|
55
|
-
| `FD_AUTH_SESSION_IDLE_MINUTES`
|
|
56
|
-
| `FD_AUTH_PASSWORD_MIN_LENGTH`
|
|
57
|
-
| `FD_AUTH_EMAIL_CONFIRMATION`
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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`.
|
|
78
|
-
required in production: the module
|
|
79
|
-
|
|
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
|
|
84
|
-
|
|
|
85
|
-
| `FD_WORKFLOWS_PAYLOAD_KEY`
|
|
86
|
-
| `
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
122
|
-
|
|
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).
|