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.
- package/README.md +16 -10
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.agents/skills/bug-hunt/SKILL.md +1 -1
- package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
- package/agent-template/.agents/skills/spec-interview/SKILL.md +20 -20
- package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.ai/README.md +5 -3
- package/agent-template/.ai/agents/README.md +1 -1
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -0
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -0
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -0
- package/agent-template/.ai/blueprints/add-migration/README.md +1 -1
- package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
- package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
- package/agent-template/.ai/examples/bad/client-imports-server/README.md +1 -1
- package/agent-template/.ai/examples/bad/missing-acl/README.md +1 -1
- package/agent-template/.ai/examples/bad/tenant-from-body/README.md +1 -1
- package/agent-template/.ai/guides/application-development.md +7 -5
- package/agent-template/.ai/platform-capabilities.md +9 -5
- package/agent-template/.ai/policies/capabilities.yaml +28 -12
- package/agent-template/.ai/policies/task-budgets.yaml +1 -1
- package/agent-template/.ai/rules/flowdular.md +3 -2
- package/agent-template/.ai/skills/README.md +1 -1
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.ai/skills/bug-hunt/SKILL.md +1 -1
- package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
- package/agent-template/.ai/skills/spec-interview/SKILL.md +20 -20
- package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.ai/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
- package/agent-template/.ai/subagents/module-executor.md +25 -0
- package/agent-template/.ai/subagents/reviewer.md +23 -0
- package/agent-template/.ai/subagents/spec-author.md +23 -0
- package/agent-template/.claude/agents/module-executor.md +22 -0
- package/agent-template/.claude/agents/reviewer.md +24 -0
- package/agent-template/.claude/agents/spec-author.md +20 -0
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.claude/skills/bug-hunt/SKILL.md +1 -1
- package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
- package/agent-template/.claude/skills/spec-interview/SKILL.md +20 -20
- package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.codex/agents/module-executor.toml +17 -0
- package/agent-template/.codex/agents/reviewer.toml +14 -0
- package/agent-template/.codex/agents/spec-author.toml +15 -0
- package/agent-template/AGENTS.md +3 -2
- package/agent-template/CLAUDE.md +3 -2
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli.md +24 -3
- package/agent-template/docs/configuration.md +59 -5
- package/agent-template/docs/database-adapters.md +20 -20
- package/agent-template/docs/design-system.md +3 -3
- package/agent-template/docs/getting-started.md +25 -32
- package/agent-template/docs/module-distribution.md +79 -86
- package/agent-template/docs/module-web-surfaces.md +9 -7
- package/agent-template/docs/modules.md +9 -1
- package/agent-template/docs/sandbox.md +117 -6
- package/agent-template/platform/scripts/build.mjs +7 -0
- package/agent-template/rulesync.jsonc +1 -1
- package/dist/bin.js +12 -6
- package/package.json +2 -2
- package/template/default/.env.example +10 -3
- package/template/default/.prettierignore +2 -0
- package/template/default/.vercelignore +8 -0
- package/template/default/README.md +26 -15
- package/template/default/_gitignore +3 -2
- package/template/default/infra/README.md +86 -65
- package/template/default/infra/docker/.env.example +66 -0
- package/template/default/infra/docker/Dockerfile +24 -10
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +105 -58
- package/template/default/infra/docker/database-urls.mjs +28 -0
- package/template/default/infra/docker/pitr.sh +177 -0
- package/template/default/infra/docker/postgres/10-roles.sh +16 -12
- package/template/default/infra/docker/start.mjs +402 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
- package/template/default/infra/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +214 -0
- package/template/default/infra/vercel/handler.mjs +100 -0
- package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
- package/template/default/modules/example/module.json +1 -1
- package/template/default/modules/example/package.json +3 -3
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/migration.ts +2 -2
- package/template/default/modules/example/tests/module.test.ts +1 -1
- package/template/default/package.json +3 -2
- package/template/default/platform/index.html +7 -19
- package/template/default/platform/octane.config.ts +252 -156
- package/template/default/platform/package.json +5 -5
- package/template/default/platform/public/favicon.svg +1 -1
- package/template/default/platform/scripts/build.mjs +56 -0
- package/template/default/platform/scripts/dev.mjs +38 -0
- package/template/default/platform/src/App.tsrx +25 -1
- package/template/default/platform/src/generated/modules.server.ts +3 -0
- package/template/default/platform/src/server/database.ts +24 -0
- package/template/default/platform/src/server/runtime-role.ts +33 -0
- package/template/default/platform/src/server/setup/access.ts +160 -0
- package/template/default/platform/src/server/setup/adapters.ts +554 -0
- package/template/default/platform/src/server/setup/environment.ts +154 -0
- package/template/default/platform/src/server/setup/gate.ts +84 -0
- package/template/default/platform/src/server/setup/index.ts +181 -0
- package/template/default/platform/src/server/setup/modules.ts +123 -0
- package/template/default/platform/src/server/setup/page.ts +497 -0
- package/template/default/platform/src/server/setup/routes.ts +787 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +145 -0
- package/template/default/platform/src/server/setup/token.ts +79 -0
- package/template/default/platform/src/server/worker-tick.ts +193 -0
- package/template/default/platform/src/server/workspace-root.ts +16 -0
- package/template/default/render.yaml +70 -0
- 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('
|
|
14
|
-
WITH CHECK (tenant_id = current_setting('
|
|
13
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
14
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"test": "vitest run"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"octane": "0.1
|
|
18
|
-
"segment-state": "0.
|
|
19
|
-
"@flowdular/sdk": "0.
|
|
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
|
|
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('
|
|
20
|
-
WITH CHECK (tenant_id = current_setting('
|
|
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('
|
|
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": "
|
|
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.
|
|
29
|
+
"flowdular": "0.6.0",
|
|
29
30
|
"rulesync": "16.21.0"
|
|
30
31
|
}
|
|
31
32
|
}
|