create-flowdular 0.4.3 → 0.6.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.
Files changed (130) hide show
  1. package/README.md +16 -10
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/auth-security-review/SKILL.md +2 -2
  4. package/agent-template/.agents/skills/bug-hunt/SKILL.md +1 -1
  5. package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
  6. package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  7. package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
  8. package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
  9. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  10. package/agent-template/.agents/skills/spec-interview/SKILL.md +20 -20
  11. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  12. package/agent-template/.agents/skills/ux-design/SKILL.md +1 -1
  13. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  14. package/agent-template/.ai/README.md +5 -3
  15. package/agent-template/.ai/agents/README.md +1 -1
  16. package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -0
  17. package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -0
  18. package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -0
  19. package/agent-template/.ai/blueprints/add-migration/README.md +1 -1
  20. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  21. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  22. package/agent-template/.ai/examples/bad/client-imports-server/README.md +1 -1
  23. package/agent-template/.ai/examples/bad/missing-acl/README.md +1 -1
  24. package/agent-template/.ai/examples/bad/tenant-from-body/README.md +1 -1
  25. package/agent-template/.ai/guides/application-development.md +7 -5
  26. package/agent-template/.ai/platform-capabilities.md +9 -5
  27. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  28. package/agent-template/.ai/policies/task-budgets.yaml +1 -1
  29. package/agent-template/.ai/rules/flowdular.md +3 -2
  30. package/agent-template/.ai/skills/README.md +1 -1
  31. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  32. package/agent-template/.ai/skills/auth-security-review/SKILL.md +2 -2
  33. package/agent-template/.ai/skills/bug-hunt/SKILL.md +1 -1
  34. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  35. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  36. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  37. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  38. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  39. package/agent-template/.ai/skills/spec-interview/SKILL.md +20 -20
  40. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  41. package/agent-template/.ai/skills/ux-design/SKILL.md +1 -1
  42. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  43. package/agent-template/.ai/subagents/module-executor.md +25 -0
  44. package/agent-template/.ai/subagents/reviewer.md +23 -0
  45. package/agent-template/.ai/subagents/spec-author.md +23 -0
  46. package/agent-template/.claude/agents/module-executor.md +22 -0
  47. package/agent-template/.claude/agents/reviewer.md +24 -0
  48. package/agent-template/.claude/agents/spec-author.md +20 -0
  49. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  50. package/agent-template/.claude/skills/auth-security-review/SKILL.md +2 -2
  51. package/agent-template/.claude/skills/bug-hunt/SKILL.md +1 -1
  52. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  53. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  54. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  55. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  56. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  57. package/agent-template/.claude/skills/spec-interview/SKILL.md +20 -20
  58. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  59. package/agent-template/.claude/skills/ux-design/SKILL.md +1 -1
  60. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  61. package/agent-template/.codex/agents/module-executor.toml +17 -0
  62. package/agent-template/.codex/agents/reviewer.toml +14 -0
  63. package/agent-template/.codex/agents/spec-author.toml +15 -0
  64. package/agent-template/AGENTS.md +3 -2
  65. package/agent-template/CLAUDE.md +3 -2
  66. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  67. package/agent-template/docs/agent-contract.md +2 -2
  68. package/agent-template/docs/cli.md +24 -3
  69. package/agent-template/docs/configuration.md +59 -5
  70. package/agent-template/docs/database-adapters.md +20 -20
  71. package/agent-template/docs/design-system.md +3 -3
  72. package/agent-template/docs/getting-started.md +25 -32
  73. package/agent-template/docs/module-distribution.md +79 -86
  74. package/agent-template/docs/module-web-surfaces.md +9 -7
  75. package/agent-template/docs/modules.md +9 -1
  76. package/agent-template/docs/sandbox.md +117 -6
  77. package/agent-template/platform/scripts/build.mjs +7 -0
  78. package/agent-template/rulesync.jsonc +1 -1
  79. package/dist/bin.js +12 -6
  80. package/package.json +2 -2
  81. package/template/default/.env.example +10 -3
  82. package/template/default/.prettierignore +2 -0
  83. package/template/default/.vercelignore +8 -0
  84. package/template/default/README.md +26 -15
  85. package/template/default/_gitignore +3 -2
  86. package/template/default/infra/README.md +86 -65
  87. package/template/default/infra/docker/.env.example +66 -0
  88. package/template/default/infra/docker/Dockerfile +24 -10
  89. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  90. package/template/default/infra/docker/compose.yaml +105 -58
  91. package/template/default/infra/docker/database-urls.mjs +28 -0
  92. package/template/default/infra/docker/pitr.sh +177 -0
  93. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  94. package/template/default/infra/docker/start.mjs +402 -0
  95. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  96. package/template/default/infra/vercel/README.md +262 -0
  97. package/template/default/infra/vercel/build.mjs +214 -0
  98. package/template/default/infra/vercel/handler.mjs +100 -0
  99. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  100. package/template/default/modules/example/module.json +1 -1
  101. package/template/default/modules/example/package.json +3 -3
  102. package/template/default/modules/example/spec/module.yaml +1 -1
  103. package/template/default/modules/example/src/services/migration.ts +2 -2
  104. package/template/default/modules/example/tests/module.test.ts +1 -1
  105. package/template/default/package.json +3 -2
  106. package/template/default/platform/index.html +7 -19
  107. package/template/default/platform/octane.config.ts +252 -156
  108. package/template/default/platform/package.json +5 -5
  109. package/template/default/platform/public/favicon.svg +1 -1
  110. package/template/default/platform/scripts/build.mjs +56 -0
  111. package/template/default/platform/scripts/dev.mjs +38 -0
  112. package/template/default/platform/src/App.tsrx +25 -1
  113. package/template/default/platform/src/generated/modules.server.ts +3 -0
  114. package/template/default/platform/src/server/database.ts +24 -0
  115. package/template/default/platform/src/server/runtime-role.ts +33 -0
  116. package/template/default/platform/src/server/setup/access.ts +160 -0
  117. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  118. package/template/default/platform/src/server/setup/environment.ts +154 -0
  119. package/template/default/platform/src/server/setup/gate.ts +84 -0
  120. package/template/default/platform/src/server/setup/index.ts +181 -0
  121. package/template/default/platform/src/server/setup/modules.ts +123 -0
  122. package/template/default/platform/src/server/setup/page.ts +497 -0
  123. package/template/default/platform/src/server/setup/routes.ts +787 -0
  124. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  125. package/template/default/platform/src/server/setup/seed.ts +145 -0
  126. package/template/default/platform/src/server/setup/token.ts +79 -0
  127. package/template/default/platform/src/server/worker-tick.ts +193 -0
  128. package/template/default/platform/src/server/workspace-root.ts +16 -0
  129. package/template/default/render.yaml +70 -0
  130. package/template/default/vercel.json +5 -0
@@ -0,0 +1,262 @@
1
+ # Vercel deployment
2
+
3
+ One command provisions the database, storage and keys, deploys to Vercel
4
+ Production and prints a one-time token; the first workspace is then created in
5
+ the browser.
6
+
7
+ ## Hobby or Pro
8
+
9
+ | | Hobby | Pro |
10
+ | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------- |
11
+ | Work a request queues (agent runs, renders, notifications, imports, exports) | starts within seconds | starts within seconds |
12
+ | Time-driven work (scheduled automations, retries and recovery after a lost lease, approval expiries, retention sweeps, digests) | next request or the daily 03:00 UTC run | within about a minute |
13
+ | Longest agent run | about 4 minutes | about 12 minutes |
14
+ | Allowed use | personal, non-commercial | commercial |
15
+
16
+ On Hobby, an external scheduler can tick the worker every minute instead: have
17
+ it call `GET https://<domain>/api/internal/worker/tick` with the header
18
+ `Authorization: Bearer <CRON_SECRET>`. The secret then also lives at that third
19
+ party.
20
+
21
+ ## Prerequisites
22
+
23
+ 1. Node.js 24 and pnpm 11, then `pnpm install` in this repository.
24
+ 2. A Vercel account. The repository does not need to be pushed: the command
25
+ uploads this directory.
26
+ 3. The Vercel CLI 62 or newer (verified with 62.2.0): `npm i -g vercel`.
27
+ 4. `vercel login`.
28
+
29
+ ## Deploy with one command
30
+
31
+ 1. `pnpm flowdular deploy plan vercel` checks the Vercel CLI and `.vercelignore`
32
+ and lists every step. It changes nothing.
33
+ 2. `pnpm flowdular deploy start vercel --apply` runs those steps. Answer the
34
+ Vercel prompts it shows while linking the project and adding Neon.
35
+
36
+ Flags that matter:
37
+
38
+ - `--project <name>` names the Vercel project to create or link.
39
+ - `--scope <team>` picks the team when the account has several.
40
+ - `--plan hobby|pro` sets the plan. Without it the command reads the team's plan
41
+ from `vercel whoami --json` (Enterprise counts as Pro). When that gives no
42
+ answer it sizes for Pro, and if Vercel then rejects the per-minute cron it
43
+ switches to Hobby and deploys once more.
44
+ - `--origin https://erp.example.com` when production is served from your own
45
+ domain instead of `<project>.vercel.app`.
46
+ - `--database-url-env NAME` uses an existing PostgreSQL instead of Neon. Run
47
+ `read -rs FD_OWNER_URL && export FD_OWNER_URL`, paste the owner URL (it stays
48
+ out of shell history), then pass `--database-url-env FD_OWNER_URL`. A URL is
49
+ never accepted on the command line. A database created by Flowdular 0.5 or
50
+ earlier is refused before anything is created; use a new database.
51
+ - `--cron "<expression>"` overrides the worker schedule the plan sets.
52
+
53
+ A rerun resumes after any failure and never regenerates a key that the backup or
54
+ Vercel already holds. If the Vercel CLI fails after it created the deployment,
55
+ for example with `Error: fetch failed` while streaming build logs, the command
56
+ follows that deployment on Vercel for up to 15 minutes and carries on once it
57
+ is Ready.
58
+
59
+ ## What it prints and what to back up
60
+
61
+ 1. Progress lines and the Vercel build output, with every secret value replaced
62
+ by `[redacted]`.
63
+ 2. A summary: the URL, the key backup path, the plan, the worker schedule, the
64
+ setup address, the setup token and the file that holds it.
65
+ 3. Copy `.flowdular/deploy/vercel-<project id>.env` (mode 0600) to a password
66
+ manager or encrypted storage off this machine. Vercel cannot show a sensitive
67
+ value again, so this file is the only readable copy of the keys.
68
+ 4. Until the first workspace exists, the setup token is kept in
69
+ `.flowdular/deploy/vercel-<project id>.setup-token` (mode 0600), written
70
+ before its SHA-256 is uploaded as `FD_SETUP_TOKEN_SHA256`. The command names
71
+ that file before it deploys and in every failure, so a failed run never loses
72
+ the token. A rerun before setup reuses it; the first run after setup deletes
73
+ the file and removes `FD_SETUP_TOKEN_SHA256` from Vercel.
74
+
75
+ ## Create the first workspace
76
+
77
+ 1. Open the printed address, `https://<domain>/setup`, and paste the setup token.
78
+ 2. Enter the workspace name and address, then your name, email and password.
79
+ The database step is skipped because the deployment provides it.
80
+ 3. Review and choose **Migrate and create workspace**.
81
+ 4. Choose **Go to sign in** and sign in. The app serves normally from the next
82
+ request, with no redeploy.
83
+ 5. Optional: connect an AI model key under Providers.
84
+
85
+ ## Check that it works
86
+
87
+ 1. `curl -s https://<domain>/api/ready` answers HTTP 200 with
88
+ `"status":"ready"`.
89
+ 2. The project's Cron Jobs settings page lists `/api/internal/worker/tick` with
90
+ `* * * * *` on Pro or `0 3 * * *` on Hobby.
91
+ 3. The project's Logs, filtered to `/api/internal/worker/tick`, show a tick
92
+ after each cron run and after each state-changing request.
93
+ 4. In the agents playground, queue a run and watch it finish. The worker badge
94
+ there reads "worker offline" on Vercel, because it reports the web Function,
95
+ which never runs workers; the run finishing is the check.
96
+
97
+ ## Move from Hobby to Pro
98
+
99
+ 1. Upgrade the team in the Vercel dashboard.
100
+ 2. `pnpm flowdular deploy start vercel --apply` reads Pro, sets
101
+ `FD_VERCEL_PLAN=pro` and redeploys. Pass `--plan pro` if it cannot read the
102
+ plan.
103
+ 3. If you set `FD_VERCEL_CRON_SCHEDULE` yourself, remove it with
104
+ `vercel env rm FD_VERCEL_CRON_SCHEDULE production --yes` and run step 2
105
+ again.
106
+
107
+ ## Redeploy after a code change
108
+
109
+ 1. `pnpm flowdular deploy start vercel --apply` deploys the working directory
110
+ and reuses everything else, or `vercel deploy --prod` deploys the code alone.
111
+ 2. A project connected to Git also deploys on every push to its production
112
+ branch.
113
+
114
+ ## Rotate CRON_SECRET
115
+
116
+ 1. Delete the `CRON_SECRET=` line from the key backup file.
117
+ 2. `vercel env rm CRON_SECRET production --yes`
118
+ 3. `pnpm flowdular deploy start vercel --apply` generates a new secret into the
119
+ backup, uploads it and redeploys. Vercel Cron uses it from that deployment.
120
+ 4. Give the new value to any external scheduler.
121
+
122
+ ## Manual path
123
+
124
+ 1. `vercel link` at the repository root, or import the project from the URL
125
+ `pnpm flowdular deploy plan vercel` prints. Select Node.js 24.
126
+ 2. Provision PostgreSQL and use the direct host, not a PgBouncer pooler: the
127
+ platform sends statement and lock timeouts as startup parameters, which a
128
+ pooler refuses.
129
+ 3. As the database owner, create `flowdular_runtime` and `flowdular_background`
130
+ without `SUPERUSER` or `BYPASSRLS` and grant what
131
+ `infra/docker/postgres/10-roles.sh` grants. The owner role is the migrator.
132
+ 4. Generate each stable key with `openssl rand -base64 32`, except
133
+ `FD_AUTH_MFA_KEY`: `openssl rand -base64 32 | tr '+/' '-_' | tr -d '='`.
134
+ Keep them in a 0600 file off the machine.
135
+ 5. Generate `CRON_SECRET` with `openssl rand -hex 32`.
136
+ 6. Add every Production variable below through stdin, for example
137
+ `printf '%s' "$VALUE" | vercel env add NAME production --sensitive`:
138
+ - `FD_DATABASE_ADAPTER=postgresql`
139
+ - `FD_DATABASE_URL` (runtime role), `FD_DATABASE_BACKGROUND_URL` (background
140
+ role), `FD_DATABASE_MIGRATOR_URL` (owner)
141
+ - `FD_DATABASE_TLS=verify-full`, plus `FD_DATABASE_TLS_CA` or
142
+ `FD_DATABASE_TLS_CA_FILE` when the server certificate is not publicly
143
+ trusted
144
+ - `FD_STORAGE_ADAPTER=vercel-blob` and `FD_STORAGE_MAX_OBJECT_BYTES=4194304`,
145
+ or `FD_STORAGE_ADAPTER=s3` with `FD_STORAGE_S3_BUCKET`,
146
+ `FD_STORAGE_S3_REGION`, `FD_STORAGE_S3_ACCESS_KEY_ID` and
147
+ `FD_STORAGE_S3_SECRET_ACCESS_KEY`
148
+ - `FD_AGENT_CREDENTIAL_KEY`, `FD_AGENT_RUN_GRANT_KEY`, `FD_AUTH_MFA_KEY`,
149
+ `FD_APPROVAL_GRANT_KEY`, `FD_AUTOMATIONS_CREDENTIAL_KEY`,
150
+ `FD_NOTIFICATIONS_SECRET_KEY`, `FD_WORKFLOWS_PAYLOAD_KEY`,
151
+ `FD_WORKFLOWS_CURSOR_KEY`, `FD_STORAGE_ENCRYPTION_KEY`,
152
+ `FD_CONNECTORS_SECRET_KEY`, `FD_AUDIT_ANCHOR_KEY`
153
+ - `CRON_SECRET`
154
+ - `FD_AUTH_PUBLIC_ORIGIN=https://<domain>`
155
+ - `FD_VERCEL_PLAN=hobby` or `pro`; optionally `FD_VERCEL_CRON_SCHEDULE` and
156
+ `FD_WORKER_TICK_WINDOW_MS`
157
+ 7. For Blob storage, `vercel storage create <name> --type blob --access private`,
158
+ then `vercel storage connect <store id> --environment production --yes`.
159
+ 8. Make a setup token and store only its hash:
160
+ `TOKEN=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')`, then
161
+ `printf '%s' "$TOKEN" | shasum -a 256 | cut -d' ' -f1 | tr -d '\n' | vercel env add FD_SETUP_TOKEN_SHA256 production --sensitive`.
162
+ 9. `vercel deploy --prod`.
163
+ 10. Open `https://<domain>/setup` and enter `$TOKEN`.
164
+
165
+ Scope preview variables to a separate database, store, keys and setup token;
166
+ otherwise preview code can migrate or mutate production data.
167
+
168
+ ## Tear down
169
+
170
+ 1. Delete the Neon database and every row in it with
171
+ `vercel integration resource remove <resource> --disconnect-all --yes`; the
172
+ resource name is on the project's Storage tab.
173
+ 2. `vercel storage delete <project>-files --type blob` deletes the Blob store
174
+ after a confirmation.
175
+ 3. `vercel project rm <project>`.
176
+ 4. Remove `.vercel`, and destroy every copy of the key backup once the data is
177
+ gone.
178
+
179
+ ## Troubleshooting
180
+
181
+ - **"Hobby accounts are limited to daily cron jobs"**: the team is on Hobby and
182
+ the build asked for a tighter cron. Rerun with `--plan hobby`, or remove a
183
+ tighter `FD_VERCEL_CRON_SCHEDULE`.
184
+ - **`/api/ready` answers 503**: the database or its role check failed. Check the
185
+ web Function logs, the three database URLs and that the Neon compute is up. A
186
+ 500 on every path means boot failed, and the log line `platform boot failed`
187
+ names the cause, such as a missing `FD_SETUP_TOKEN_SHA256` while no workspace
188
+ exists.
189
+ - **The setup page refuses the token**: use the token in
190
+ `.flowdular/deploy/vercel-<project id>.setup-token`, which every run before
191
+ setup reuses. If that file was deleted, the next run made a new token and only
192
+ that one works. Five wrong tries lock that instance for five minutes. If the page asks for the token again after a step, the request
193
+ reached another Function instance, which holds its own setup session; enter the
194
+ token again and repeat that step.
195
+ - **`Failed to connect <owner>/<repo> to project` while linking**: harmless. The
196
+ command uploads this directory, so the deployment does not need Git; pushes
197
+ just do not deploy on their own. Connect the repository later in the
198
+ project's Settings, Git.
199
+ - **Scheduled automations do not fire on Hobby**: between requests they wait
200
+ for the next request or the daily run. Move to Pro or add an external
201
+ scheduler (see [Hobby or Pro](#hobby-or-pro)).
202
+ - **An agent run started over**: it outlived its tick, about 4 minutes on Hobby
203
+ or 12 on Pro, so Vercel stopped the Function and a later tick resumed the run
204
+ once its lease expired. Keep runs shorter or move to Pro.
205
+
206
+ ## How it runs
207
+
208
+ Flowdular runs on Vercel as two Node.js Functions built from one server bundle.
209
+ The `vercel.json` build command builds Octane, then writes a Build Output API
210
+ artifact:
211
+
212
+ - `functions/flowdular.func` serves HTTP with `FD_RUNTIME_ROLE=web`. It never
213
+ starts a module worker and never claims queued work.
214
+ - `functions/worker.func` runs with `FD_RUNTIME_ROLE=tick` and answers only
215
+ `/api/internal/worker/tick`. Each tick starts every module worker, keeps them
216
+ running for one window and drains them before it answers.
217
+ - `static` holds the public client assets. Pages stay behind Octane's
218
+ authentication.
219
+
220
+ Two things tick the worker Function:
221
+
222
+ - Vercel Cron calls the tick path with a 50 second window
223
+ (`FD_WORKER_TICK_WINDOW_MS`): every minute on Pro, once a day on Hobby.
224
+ Scheduled work and recovery of interrupted jobs run there.
225
+ - After a state-changing `/api` request answers with a success status, the web
226
+ Function asks for a 15 second tick, so work a member just queued starts within
227
+ seconds instead of waiting for the next cron run. Repeated requests on one
228
+ instance ask at most once every 5 seconds, and a tick arriving while a window
229
+ is open joins it.
230
+
231
+ Both callers present `CRON_SECRET` as a bearer token; the worker Function reads
232
+ it as `FD_WORKER_TICK_SECRET`. Without a secret of at least 32 characters the
233
+ worker Function refuses to boot.
234
+
235
+ `FD_VERCEL_PLAN` sizes the build: the cron schedule, the worker Function's
236
+ `maxDuration` (300 seconds on Hobby, 800 on Pro) and how long a tick waits for
237
+ agent runs to finish (180 and 690 seconds). Until a workspace exists, the
238
+ application answers only `/setup`, `/api/health`, `/api/ready` and the tick
239
+ path, and each instance opens to everything on its next request after setup
240
+ finishes.
241
+
242
+ Cron runs only on the production deployment. A preview deployment ticks only
243
+ from its own requests, and only when Deployment Protection lets the request
244
+ through: enable Protection Bypass for Automation so the web Function can send
245
+ `VERCEL_AUTOMATION_BYPASS_SECRET`.
246
+
247
+ ## Limits
248
+
249
+ - A request or response body is capped at 4.5 MB, so keep
250
+ `FD_STORAGE_MAX_OBJECT_BYTES` at or below 4194304 until uploads go directly to
251
+ storage.
252
+ - A tick drains its workers before it answers, within the worker Function's
253
+ `maxDuration`. An agent run claimed during the window keeps running for the
254
+ rest of the window and then up to 180 seconds on Hobby or 690 on Pro
255
+ (`FD_AGENT_WORKER_DRAIN_MS`). Work still running after that is stopped and
256
+ starts again from its input on a later tick once its lease expires, so a run
257
+ claimed at the end of a window is sure to finish only within 3 minutes on
258
+ Hobby and 11.5 on Pro.
259
+ - `/api/ready` checks the database roles. It does not prove that ticks are
260
+ arriving; watch the Cron Jobs page and the worker Function logs.
261
+ - The embedded sandbox and workspace file edits are local development features
262
+ and are not durable inside a Function.
@@ -0,0 +1,214 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ cp,
4
+ lstat,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ realpath,
9
+ rm,
10
+ writeFile,
11
+ } from 'node:fs/promises';
12
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const repositoryRoot = resolve(
16
+ dirname(fileURLToPath(import.meta.url)),
17
+ '../..',
18
+ );
19
+ const args = process.argv.slice(2);
20
+ const rootFlag = args.indexOf('--root');
21
+ const root = rootFlag < 0 ? repositoryRoot : resolve(args[rootFlag + 1] ?? '');
22
+ const packageOnly = args.includes('--package-only');
23
+
24
+ if (rootFlag >= 0 && !args[rootFlag + 1]) {
25
+ throw new Error('--root requires a directory.');
26
+ }
27
+
28
+ if (!packageOnly) {
29
+ const result = spawnSync('pnpm', ['build'], { cwd: root, stdio: 'inherit' });
30
+ if (result.error) throw result.error;
31
+ if (result.status !== 0) process.exit(result.status ?? 1);
32
+ }
33
+
34
+ const output = join(root, '.vercel/output');
35
+ const functionRoot = join(output, 'functions/flowdular.func');
36
+ const workerFunctionRoot = join(output, 'functions/worker.func');
37
+ const WORKER_TICK_PATH = '/api/internal/worker/tick';
38
+ /* Hobby runs cron at most once a day, fails a deployment with a tighter
39
+ schedule and caps a Function at 300 seconds; Pro and Enterprise run cron
40
+ every minute and allow 800. A tick's window plus the agent drain must end a
41
+ minute before maxDuration, or Vercel stops the Function mid-drain. */
42
+ const PLANS = {
43
+ hobby: { schedule: '0 3 * * *', maxDuration: 300, agentDrainMs: 180_000 },
44
+ pro: { schedule: '* * * * *', maxDuration: 800, agentDrainMs: 690_000 },
45
+ };
46
+ const DRAIN_MARGIN_MS = 60_000;
47
+ const planName = process.env.FD_VERCEL_PLAN?.trim() || 'pro';
48
+ if (!Object.hasOwn(PLANS, planName)) {
49
+ throw new Error('FD_VERCEL_PLAN must be "hobby" or "pro".');
50
+ }
51
+ const plan = PLANS[planName];
52
+ const cronSchedule =
53
+ process.env.FD_VERCEL_CRON_SCHEDULE?.trim() || plan.schedule;
54
+ if (!/^\S+( \S+){4}$/.test(cronSchedule)) {
55
+ throw new Error(
56
+ 'FD_VERCEL_CRON_SCHEDULE must be a five-field cron expression.',
57
+ );
58
+ }
59
+ const tickWindowMs = Number(
60
+ process.env.FD_WORKER_TICK_WINDOW_MS?.trim() || 50_000,
61
+ );
62
+ if (
63
+ tickWindowMs + plan.agentDrainMs + DRAIN_MARGIN_MS >
64
+ plan.maxDuration * 1000
65
+ ) {
66
+ throw new Error(
67
+ `FD_WORKER_TICK_WINDOW_MS leaves no time to drain agent runs within the ${plan.maxDuration} second ${planName} limit.`,
68
+ );
69
+ }
70
+ const staticRoot = join(output, 'static');
71
+ const client = join(root, 'platform/dist/client');
72
+ const server = join(root, 'platform/dist/server');
73
+
74
+ async function assertInside(source, owner) {
75
+ const fromOwner = relative(await realpath(owner), await realpath(source));
76
+ if (!fromOwner || fromOwner.startsWith('..') || isAbsolute(fromOwner)) {
77
+ throw new Error(`${source} must stay inside ${owner}.`);
78
+ }
79
+ }
80
+
81
+ async function copyRegular(source, destination, owner = root) {
82
+ const entry = await lstat(source);
83
+ if (!entry.isFile() || entry.isSymbolicLink()) {
84
+ throw new Error(`${source} must be a regular file.`);
85
+ }
86
+ await assertInside(source, owner);
87
+ await mkdir(dirname(destination), { recursive: true });
88
+ await cp(source, destination);
89
+ }
90
+
91
+ async function copyOptional(source, destination) {
92
+ try {
93
+ await copyRegular(source, destination);
94
+ } catch (error) {
95
+ if (error.code !== 'ENOENT') throw error;
96
+ }
97
+ }
98
+
99
+ async function copyTree(source, destination) {
100
+ const entry = await lstat(source);
101
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
102
+ throw new Error(`${source} must be a directory.`);
103
+ }
104
+ await assertInside(source, root);
105
+ const pending = [source];
106
+ while (pending.length > 0) {
107
+ const directory = pending.pop();
108
+ for (const child of await readdir(directory, { withFileTypes: true })) {
109
+ const path = join(directory, child.name);
110
+ if (child.isDirectory()) pending.push(path);
111
+ else if (!child.isFile())
112
+ throw new Error(`${path} must be a regular file.`);
113
+ }
114
+ }
115
+ await cp(source, destination, { recursive: true, dereference: false });
116
+ }
117
+
118
+ await lstat(join(server, 'entry.js'));
119
+ await lstat(join(client, 'assets'));
120
+ try {
121
+ const vercelDirectory = await lstat(join(root, '.vercel'));
122
+ if (!vercelDirectory.isDirectory() || vercelDirectory.isSymbolicLink()) {
123
+ throw new Error('.vercel must be a directory inside the workspace.');
124
+ }
125
+ } catch (error) {
126
+ if (error.code !== 'ENOENT') throw error;
127
+ }
128
+ await rm(output, { recursive: true, force: true });
129
+ await mkdir(staticRoot, { recursive: true });
130
+ await copyTree(join(client, 'assets'), join(staticRoot, 'assets'));
131
+ for (const name of ['favicon.svg', 'og.png']) {
132
+ await copyOptional(join(client, name), join(staticRoot, name));
133
+ }
134
+
135
+ async function writeFunction(directory, runtimeRole) {
136
+ const tick = runtimeRole === 'tick';
137
+ await mkdir(directory, { recursive: true });
138
+ await copyTree(server, join(directory, 'platform/dist/server'));
139
+ await copyRegular(
140
+ join(root, 'platform/package.json'),
141
+ join(directory, 'platform/package.json'),
142
+ );
143
+ await copyRegular(
144
+ join(root, 'flowdular.json'),
145
+ join(directory, 'flowdular.json'),
146
+ );
147
+ for (const name of [
148
+ 'flowdular.modules.lock.json',
149
+ 'flowdular.module-sources.json',
150
+ ]) {
151
+ await copyOptional(join(root, name), join(directory, name));
152
+ }
153
+ const modulesRoot = join(root, 'modules');
154
+ for (const entry of await readdir(modulesRoot, { withFileTypes: true })) {
155
+ if (!entry.isDirectory()) continue;
156
+ for (const name of ['module.json', 'spec/module.yaml']) {
157
+ await copyOptional(
158
+ join(modulesRoot, entry.name, name),
159
+ join(directory, 'modules', entry.name, name),
160
+ );
161
+ }
162
+ }
163
+ await copyRegular(
164
+ join(repositoryRoot, 'infra/vercel/handler.mjs'),
165
+ join(directory, 'handler.mjs'),
166
+ repositoryRoot,
167
+ );
168
+ await writeFile(
169
+ join(directory, '.vc-config.json'),
170
+ JSON.stringify({
171
+ runtime: 'nodejs24.x',
172
+ handler: 'handler.mjs',
173
+ launcherType: 'Nodejs',
174
+ maxDuration: tick ? plan.maxDuration : 300,
175
+ supportsResponseStreaming: true,
176
+ environment: {
177
+ NODE_ENV: 'production',
178
+ FD_DEPLOYMENT_TARGET: 'vercel',
179
+ FD_RUNTIME_ROLE: runtimeRole,
180
+ FD_TRUST_PROXY: 'true',
181
+ FD_AUTH_SECURE_COOKIE: 'true',
182
+ FD_DATABASE_POOL_MAX: '2',
183
+ ...(tick
184
+ ? { FD_AGENT_WORKER_DRAIN_MS: String(plan.agentDrainMs) }
185
+ : {}),
186
+ },
187
+ }) + '\n',
188
+ );
189
+ }
190
+
191
+ /* Two functions over one server build: the web function never runs a module
192
+ worker, and the worker function runs them only inside a tick. */
193
+ await writeFunction(functionRoot, 'web');
194
+ await writeFunction(workerFunctionRoot, 'tick');
195
+ await writeFile(
196
+ join(output, 'config.json'),
197
+ JSON.stringify({
198
+ version: 3,
199
+ routes: [
200
+ { src: `^${WORKER_TICK_PATH}$`, dest: '/worker' },
201
+ { handle: 'filesystem' },
202
+ { src: '/(.*)', dest: '/flowdular' },
203
+ ],
204
+ crons: [{ path: WORKER_TICK_PATH, schedule: cronSchedule }],
205
+ }) + '\n',
206
+ );
207
+
208
+ /* The Build Output API function is self-contained. Static HTML is deliberately
209
+ excluded so the authenticated Octane route always owns page responses. */
210
+ const manifest = JSON.parse(
211
+ await readFile(join(output, 'config.json'), 'utf8'),
212
+ );
213
+ if (manifest.version !== 3) throw new Error('Invalid Vercel build output.');
214
+ console.log('Flowdular Vercel web artifact: .vercel/output');
@@ -0,0 +1,100 @@
1
+ /* Vercel assigns a different origin to every preview. Read its own deployment
2
+ hostname before importing Octane, whose auth runtime captures the origin at boot. */
3
+ process.env.NODE_ENV = 'production';
4
+ process.env.FD_DEPLOYMENT_TARGET = 'vercel';
5
+ process.env.FD_TRUST_PROXY = 'true';
6
+ process.env.FD_AUTH_SECURE_COOKIE = 'true';
7
+ if (!process.env.FD_AUTH_PUBLIC_ORIGIN && process.env.VERCEL_URL) {
8
+ const host = process.env.VERCEL_URL;
9
+ if (
10
+ !/^[-a-z0-9.]+$/i.test(host) ||
11
+ host.startsWith('.') ||
12
+ host.endsWith('.') ||
13
+ host.includes('..') ||
14
+ !host.includes('.')
15
+ ) {
16
+ throw new Error('VERCEL_URL must be a hostname without a scheme or path.');
17
+ }
18
+ process.env.FD_AUTH_PUBLIC_ORIGIN = `https://${host}`;
19
+ }
20
+
21
+ /* Vercel Cron presents CRON_SECRET as its bearer token, so the tick secret is
22
+ that value unless the deployment names its own. */
23
+ if (!process.env.FD_WORKER_TICK_SECRET && process.env.CRON_SECRET) {
24
+ process.env.FD_WORKER_TICK_SECRET = process.env.CRON_SECRET;
25
+ }
26
+
27
+ const { nodeHandler } = await import('./platform/dist/server/entry.js');
28
+
29
+ const WORKER_TICK_PATH = '/api/internal/worker/tick';
30
+ const KICK_INTERVAL_MS = 5_000;
31
+ /* The worker function keeps running after this request hangs up: Vercel ends
32
+ an invocation on disconnect only for functions that opt into cancellation. */
33
+ const KICK_HANDOFF_MS = 2_000;
34
+ const REQUEST_CONTEXT = Symbol.for('@vercel/request-context');
35
+ let lastKick = 0;
36
+
37
+ function kickWorker(origin, secret) {
38
+ const now = Date.now();
39
+ if (now - lastKick < KICK_INTERVAL_MS) return Promise.resolve();
40
+ lastKick = now;
41
+ const headers = { authorization: `Bearer ${secret}` };
42
+ if (process.env.VERCEL_AUTOMATION_BYPASS_SECRET) {
43
+ headers['x-vercel-protection-bypass'] =
44
+ process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
45
+ }
46
+ return fetch(new URL(WORKER_TICK_PATH, origin), {
47
+ method: 'POST',
48
+ headers,
49
+ signal: AbortSignal.timeout(KICK_HANDOFF_MS),
50
+ }).then(
51
+ (response) => response.body?.cancel(),
52
+ () => undefined,
53
+ );
54
+ }
55
+
56
+ /* The web function claims no queued work, so a request that changed state asks
57
+ the worker function for a short tick instead of leaving it to the next cron. */
58
+ function withWorkerKick(handler) {
59
+ const origin = process.env.FD_AUTH_PUBLIC_ORIGIN;
60
+ const secret = process.env.FD_WORKER_TICK_SECRET;
61
+ if (process.env.FD_RUNTIME_ROLE !== 'web' || !origin || !secret) {
62
+ return handler;
63
+ }
64
+ return (request, response) => {
65
+ const waitUntil = globalThis[REQUEST_CONTEXT]?.get?.()?.waitUntil;
66
+ if (
67
+ waitUntil &&
68
+ !['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
69
+ request.url?.startsWith('/api/')
70
+ ) {
71
+ waitUntil(
72
+ new Promise((resolve) => {
73
+ response.once('close', () => {
74
+ resolve(
75
+ response.statusCode < 400
76
+ ? kickWorker(origin, secret)
77
+ : undefined,
78
+ );
79
+ });
80
+ }),
81
+ );
82
+ }
83
+ return handler(request, response);
84
+ };
85
+ }
86
+
87
+ /* Vercel terminates TLS before the function, and Octane's Node adapter builds
88
+ every request URL as http://<host>. An absolute https target keeps the
89
+ origin that same-origin checks compare against. */
90
+ function withHttpsOrigin(handler) {
91
+ return (request, response) => {
92
+ const host = request.headers?.host;
93
+ if (host && request.url?.startsWith('/')) {
94
+ request.url = `https://${host}${request.url}`;
95
+ }
96
+ return handler(request, response);
97
+ };
98
+ }
99
+
100
+ export default withWorkerKick(withHttpsOrigin(nodeHandler));
@@ -10,5 +10,5 @@ CREATE INDEX IF NOT EXISTS example_notes_tenant_created_idx ON example_notes (te
10
10
  ALTER TABLE example_notes ENABLE ROW LEVEL SECURITY;
11
11
  ALTER TABLE example_notes FORCE ROW LEVEL SECURITY;
12
12
  CREATE POLICY example_notes_tenant_policy ON example_notes
13
- USING (tenant_id = current_setting('coreloom.tenant_id', true))
14
- WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
13
+ USING (tenant_id = current_setting('flowdular.tenant_id', true))
14
+ WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
@@ -4,7 +4,7 @@
4
4
  "id": "example.core",
5
5
  "package": "@app/module-example",
6
6
  "version": "0.1.0",
7
- "platformApi": "^0.1.0",
7
+ "platformApi": "^0.2.0",
8
8
  "profile": "full",
9
9
  "capabilities": ["api", "database", "client", "translations"],
10
10
  "platform": {
@@ -14,9 +14,9 @@
14
14
  "test": "vitest run"
15
15
  },
16
16
  "dependencies": {
17
- "octane": "0.1.51",
18
- "segment-state": "0.2.1",
19
- "@flowdular/sdk": "0.4.3"
17
+ "octane": "0.9.1",
18
+ "segment-state": "0.4.0",
19
+ "@flowdular/sdk": "0.6.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsrx/typescript-plugin": "0.3.120",
@@ -19,7 +19,7 @@ locales:
19
19
  - pl
20
20
  invariants:
21
21
  - Persistence runs on the shared asynchronous database contract. The module receives a leased handle from platform composition, never a driver or a connection string, and provides explicit SQL for every dialect it declares.
22
- - The PostgreSQL notes table enables and forces row-level security with USING and WITH CHECK policies bound to transaction-local coreloom.tenant_id, and the runtime role holds neither SUPERUSER nor BYPASSRLS. Migrations use a separate lease.
22
+ - The PostgreSQL notes table enables and forces row-level security with USING and WITH CHECK policies bound to transaction-local flowdular.tenant_id, and the runtime role holds neither SUPERUSER nor BYPASSRLS. Migrations use a separate lease.
23
23
  - Every note is owned by exactly one tenant and every query uses the trusted tenant identifier.
24
24
  permissions:
25
25
  - id: example.notes.read
@@ -16,8 +16,8 @@ CREATE INDEX IF NOT EXISTS example_notes_tenant_created_idx ON example_notes (te
16
16
  ALTER TABLE example_notes ENABLE ROW LEVEL SECURITY;
17
17
  ALTER TABLE example_notes FORCE ROW LEVEL SECURITY;
18
18
  CREATE POLICY example_notes_tenant_policy ON example_notes
19
- USING (tenant_id = current_setting('coreloom.tenant_id', true))
20
- WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
19
+ USING (tenant_id = current_setting('flowdular.tenant_id', true))
20
+ WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
21
21
  `;
22
22
 
23
23
  export const databaseMigrations: readonly DatabaseMigration[] = [
@@ -61,7 +61,7 @@ describe('example.core migrations', () => {
61
61
  const sql = migration.sql.postgresql ?? '';
62
62
  expect(sql).toContain('ENABLE ROW LEVEL SECURITY');
63
63
  expect(sql).toContain('FORCE ROW LEVEL SECURITY');
64
- expect(sql).toContain("current_setting('coreloom.tenant_id', true)");
64
+ expect(sql).toContain("current_setting('flowdular.tenant_id', true)");
65
65
  expect(sql).toContain('WITH CHECK');
66
66
  }
67
67
  });
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "node --env-file-if-exists=.env platform/scripts/dev.mjs",
12
- "sandbox": "npx @flowdular/sandbox",
12
+ "sandbox": "flowdular-sandbox",
13
13
  "typecheck": "pnpm -r --if-present typecheck",
14
14
  "test": "pnpm -r --if-present test",
15
15
  "format": "prettier --write .",
@@ -23,9 +23,10 @@
23
23
  "build": "flowdular module sync --apply && pnpm --filter @app/platform build"
24
24
  },
25
25
  "devDependencies": {
26
+ "@flowdular/sandbox": "0.6.0",
26
27
  "@tsrx/prettier-plugin": "0.3.120",
27
28
  "prettier": "3.6.2",
28
- "flowdular": "0.4.3",
29
+ "flowdular": "0.6.0",
29
30
  "rulesync": "16.21.0"
30
31
  }
31
32
  }