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,111 @@
1
+ /* First-run failures reach the operator screen, the terminal scrollback, and
2
+ whatever support channel the operator pastes them into. Nothing derived from
3
+ a driver message is ever returned: the classification below writes the whole
4
+ sentence, and the secret pass is a second barrier for values that reached a
5
+ string some other way. */
6
+
7
+ export type SetupFailureCode =
8
+ | 'AUTHENTICATION_REJECTED'
9
+ | 'DATABASE_MISSING'
10
+ | 'HOST_UNREACHABLE'
11
+ | 'PERMISSION_DENIED'
12
+ | 'PROBE_FAILED'
13
+ | 'ROLE_TOO_PRIVILEGED'
14
+ | 'TIMED_OUT'
15
+ | 'TLS_REJECTED';
16
+
17
+ export interface SetupFailure {
18
+ readonly code: SetupFailureCode;
19
+ readonly message: string;
20
+ }
21
+
22
+ const MESSAGES: Readonly<Record<SetupFailureCode, string>> = Object.freeze({
23
+ AUTHENTICATION_REJECTED:
24
+ 'The server rejected the credentials for one of the roles. Check the user names and passwords you entered.',
25
+ DATABASE_MISSING:
26
+ 'The server answered but does not have a database with that name. Create it, then try again.',
27
+ HOST_UNREACHABLE:
28
+ 'Nothing answered at that host and port. Check the address, the port, and whether the server accepts connections from this machine.',
29
+ PERMISSION_DENIED:
30
+ 'The server accepted the connection and then refused the operation. Grant the role the rights the deployment needs.',
31
+ PROBE_FAILED:
32
+ 'The connection attempt failed. Check the values you entered and the server log for the matching entry.',
33
+ ROLE_TOO_PRIVILEGED:
34
+ 'The runtime role holds SUPERUSER or BYPASSRLS. Tenant isolation depends on that role being unable to bypass row-level security, so this connection is refused.',
35
+ TIMED_OUT:
36
+ 'The server did not answer within the connection timeout. Check the network path and whether the server is accepting connections.',
37
+ TLS_REJECTED:
38
+ 'The TLS certificate was not accepted. Supply the certificate authority, or pick a TLS mode that matches how the server is configured.',
39
+ });
40
+
41
+ /* Mirrors redactSecrets in packages/ai-provider/src/errors.ts: every value the
42
+ operator typed as a secret is replaced wherever it appears. Short values are
43
+ skipped because a two-character password would redact ordinary prose. */
44
+ export function redactSetupSecrets(
45
+ value: string,
46
+ secrets: Iterable<string>,
47
+ ): string {
48
+ let redacted = value;
49
+ for (const secret of secrets) {
50
+ if (secret.length < 4) continue;
51
+ redacted = redacted.split(secret).join('[redacted]');
52
+ }
53
+ return redacted;
54
+ }
55
+
56
+ function errorCode(error: unknown): string {
57
+ return typeof error === 'object' && error !== null && 'code' in error
58
+ ? String((error as { code: unknown }).code)
59
+ : '';
60
+ }
61
+
62
+ const SQLSTATE: Readonly<Record<string, SetupFailureCode>> = Object.freeze({
63
+ '28000': 'AUTHENTICATION_REJECTED',
64
+ '28P01': 'AUTHENTICATION_REJECTED',
65
+ '3D000': 'DATABASE_MISSING',
66
+ '42501': 'PERMISSION_DENIED',
67
+ '53300': 'HOST_UNREACHABLE',
68
+ '57P03': 'HOST_UNREACHABLE',
69
+ });
70
+
71
+ const ERRNO: Readonly<Record<string, SetupFailureCode>> = Object.freeze({
72
+ EAI_AGAIN: 'HOST_UNREACHABLE',
73
+ ECONNREFUSED: 'HOST_UNREACHABLE',
74
+ ECONNRESET: 'HOST_UNREACHABLE',
75
+ EHOSTUNREACH: 'HOST_UNREACHABLE',
76
+ ENETUNREACH: 'HOST_UNREACHABLE',
77
+ ENOTFOUND: 'HOST_UNREACHABLE',
78
+ ETIMEDOUT: 'TIMED_OUT',
79
+ CERT_HAS_EXPIRED: 'TLS_REJECTED',
80
+ DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS_REJECTED',
81
+ ERR_TLS_CERT_ALTNAME_INVALID: 'TLS_REJECTED',
82
+ SELF_SIGNED_CERT_IN_CHAIN: 'TLS_REJECTED',
83
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS_REJECTED',
84
+ });
85
+
86
+ export class SetupProbeError extends Error {
87
+ constructor(readonly failureCode: SetupFailureCode) {
88
+ super(MESSAGES[failureCode]);
89
+ this.name = 'SetupProbeError';
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Turns any failure into one of a fixed set of operator-actionable sentences.
95
+ * The driver message is read to classify and then discarded, so a DSN, a host,
96
+ * or a password inside it cannot reach the caller.
97
+ */
98
+ export function classifySetupFailure(
99
+ error: unknown,
100
+ secrets: Iterable<string> = [],
101
+ ): SetupFailure {
102
+ const code =
103
+ error instanceof SetupProbeError
104
+ ? error.failureCode
105
+ : (SQLSTATE[errorCode(error)] ??
106
+ ERRNO[errorCode(error)] ??
107
+ (error instanceof Error && error.name === 'AbortError'
108
+ ? 'TIMED_OUT'
109
+ : 'PROBE_FAILED'));
110
+ return { code, message: redactSetupSecrets(MESSAGES[code], secrets) };
111
+ }
@@ -0,0 +1,145 @@
1
+ import type { DatabaseProvider } from '@flowdular/sdk/database';
2
+ import {
3
+ authRuntimeOptionsFromEnvironment,
4
+ createAuthRuntime,
5
+ } from '@flowdular/sdk/modules/auth/server';
6
+ /* First-run provisioning uses the same audited auth service as the operator
7
+ CLI. The operator chooses the first owner; no demo credentials are shipped. */
8
+
9
+ export const FIRST_RUN_OPERATOR = 'setup:first-run';
10
+
11
+ export interface SeededAccount {
12
+ readonly email: string;
13
+ readonly displayName: string;
14
+ readonly role: string;
15
+ readonly scopes: readonly string[];
16
+ }
17
+
18
+ export interface FirstRunOwner {
19
+ readonly workspaceName: string;
20
+ readonly workspaceSlug: string;
21
+ readonly ownerEmail: string;
22
+ readonly ownerName: string;
23
+ readonly ownerPassword: string;
24
+ }
25
+
26
+ export interface FirstRunSeed {
27
+ readonly workspace: { readonly name: string; readonly slug: string };
28
+ readonly accounts: readonly SeededAccount[];
29
+ }
30
+
31
+ export class SetupSeedError extends Error {
32
+ constructor(
33
+ readonly code: 'DATABASE_NOT_EMPTY' | 'SETUP_IN_PROGRESS',
34
+ message: string,
35
+ ) {
36
+ super(message);
37
+ this.name = 'SetupSeedError';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Migrates auth.core, then creates the chosen workspace and its owner. The
43
+ * database must still have no workspace, so a stale session cannot re-provision
44
+ * an existing installation.
45
+ */
46
+ export async function seedFirstRun(
47
+ databases: DatabaseProvider,
48
+ environment: NodeJS.ProcessEnv,
49
+ workspaceRoot: string,
50
+ owner: FirstRunOwner,
51
+ ): Promise<FirstRunSeed> {
52
+ const runtime = createAuthRuntime({
53
+ ...authRuntimeOptionsFromEnvironment(environment, workspaceRoot),
54
+ databases,
55
+ });
56
+ try {
57
+ const service = await runtime.service();
58
+ const existing = await service.listTenants();
59
+ if (existing.length > 0) {
60
+ throw new SetupSeedError(
61
+ 'DATABASE_NOT_EMPTY',
62
+ `This database already holds ${existing.length} workspace${
63
+ existing.length === 1 ? '' : 's'
64
+ }. Setup does not reset an existing installation. Point this deployment at that database and sign in, or choose an empty one.`,
65
+ );
66
+ }
67
+ const provisioned = await service.provisionWorkspace({
68
+ name: owner.workspaceName,
69
+ slug: owner.workspaceSlug,
70
+ ownerEmail: owner.ownerEmail,
71
+ ownerDisplayName: owner.ownerName,
72
+ password: owner.ownerPassword,
73
+ operator: FIRST_RUN_OPERATOR,
74
+ });
75
+ return {
76
+ workspace: {
77
+ name: provisioned.workspace.name,
78
+ slug: provisioned.workspace.slug,
79
+ },
80
+ accounts: [
81
+ {
82
+ email: provisioned.owner.email,
83
+ displayName: provisioned.owner.displayName,
84
+ role: provisioned.owner.role,
85
+ scopes: provisioned.owner.scopes,
86
+ },
87
+ ],
88
+ };
89
+ } finally {
90
+ await runtime.dispose();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Runs a first-run seed under a PostgreSQL advisory lock, so two processes on
96
+ * one database cannot both pass the empty check above and each create a first
97
+ * workspace. A second claimant is refused, not queued. The lock is held by its
98
+ * own transaction, and the seed runs outside that transaction's async context
99
+ * because an adapter refuses nested use from inside one.
100
+ */
101
+ export async function claimFirstRun<T>(
102
+ databases: DatabaseProvider,
103
+ seed: () => Promise<T>,
104
+ ): Promise<T> {
105
+ const lease = await databases.acquire({
106
+ namespace: 'platform.setup',
107
+ purpose: 'migration',
108
+ });
109
+ try {
110
+ let release!: () => void;
111
+ const released = new Promise<void>((resolve) => {
112
+ release = resolve;
113
+ });
114
+ let report!: (claimed: boolean) => void;
115
+ const reported = new Promise<boolean>((resolve) => {
116
+ report = resolve;
117
+ });
118
+ const held = lease.database.transaction(async (transaction) => {
119
+ const result = await transaction.query<{ claimed: boolean }>({
120
+ text: 'SELECT pg_try_advisory_xact_lock(hashtext($1), hashtext($2)) AS claimed',
121
+ parameters: ['flowdular-first-run', 'platform.setup'],
122
+ });
123
+ report(result.rows[0]?.claimed === true);
124
+ await released;
125
+ });
126
+ if (!(await Promise.race([reported, held.then(() => false)]))) {
127
+ release();
128
+ await held;
129
+ throw new SetupSeedError(
130
+ 'SETUP_IN_PROGRESS',
131
+ 'Another setup is creating the first workspace right now. Wait a moment, then reload this page.',
132
+ );
133
+ }
134
+ try {
135
+ return await seed();
136
+ } finally {
137
+ release();
138
+ /* The lock transaction writes nothing, so once the seed has settled a
139
+ failure to end it cannot change the outcome. */
140
+ await held.catch(() => undefined);
141
+ }
142
+ } finally {
143
+ await lease.release();
144
+ }
145
+ }
@@ -0,0 +1,79 @@
1
+ import { flowdularStateDirectory } from '@flowdular/sdk/kernel/runtime-config';
2
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { generateSetupToken } from './access.ts';
5
+
6
+ export const SETUP_TOKEN_FILE = 'setup-token';
7
+
8
+ export interface IssuedSetupToken {
9
+ readonly token: string;
10
+ /** Where the token was written, or null when the filesystem refused it. */
11
+ readonly file: string | null;
12
+ readonly banner: string;
13
+ }
14
+
15
+ export interface IssueSetupTokenOptions {
16
+ readonly workspaceRoot: string;
17
+ readonly origin: string;
18
+ readonly token?: string;
19
+ }
20
+
21
+ function banner(token: string, origin: string, file: string | null): string {
22
+ const lines = [
23
+ '',
24
+ ' Flowdular is not configured yet and started its first-run setup.',
25
+ '',
26
+ ` Open ${origin}/setup`,
27
+ ` Token ${token}`,
28
+ '',
29
+ file
30
+ ? ` The same token is in ${file} (readable only by this user).`
31
+ : ' This filesystem is read only, so the token exists only in this output.',
32
+ ' It is required for every setup request and is not shown again after',
33
+ ' setup completes. Restarting the process issues a new one.',
34
+ '',
35
+ ];
36
+ return lines.join('\n');
37
+ }
38
+
39
+ /**
40
+ * Mints the token that binds every first-run request, prints it, and stores it
41
+ * with owner-only permissions. A filesystem that refuses the write is not an
42
+ * error: the token is still on stdout, which is where a container operator
43
+ * reads it anyway.
44
+ */
45
+ export function issueSetupToken(
46
+ options: IssueSetupTokenOptions,
47
+ ): IssuedSetupToken {
48
+ const token = options.token ?? generateSetupToken();
49
+ const directory = flowdularStateDirectory(options.workspaceRoot);
50
+ const path = resolve(directory, SETUP_TOKEN_FILE);
51
+ let file: string | null = null;
52
+ try {
53
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
54
+ writeFileSync(path, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
55
+ /* An existing file keeps its old mode through an overwrite, so the mode
56
+ is asserted rather than only requested. */
57
+ chmodSync(path, 0o600);
58
+ file = path;
59
+ } catch {
60
+ /* Read-only root filesystems are a deliberate deployment choice; the
61
+ reason is not reported because it cannot change the outcome. */
62
+ file = null;
63
+ }
64
+ return { token, file, banner: banner(token, options.origin, file) };
65
+ }
66
+
67
+ /**
68
+ * Removes the token a previous first run left behind. A configured deployment
69
+ * has no setup routes, so the file is a stale secret with nothing to unlock.
70
+ */
71
+ export function clearSetupToken(workspaceRoot: string): void {
72
+ try {
73
+ rmSync(resolve(flowdularStateDirectory(workspaceRoot), SETUP_TOKEN_FILE), {
74
+ force: true,
75
+ });
76
+ } catch {
77
+ /* A read-only state directory cannot hold a stale token either. */
78
+ }
79
+ }
@@ -0,0 +1,193 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import {
3
+ defineEndpoint,
4
+ HttpProblem,
5
+ jsonResponse,
6
+ problemResponse,
7
+ serverLogger,
8
+ type ModuleServerComposition,
9
+ } from '@flowdular/sdk/server';
10
+
11
+ export const WORKER_TICK_PATH = '/api/internal/worker/tick';
12
+ const DEFAULT_WINDOW_MS = 50_000;
13
+ const MIN_WINDOW_MS = 1_000;
14
+ const MAX_WINDOW_MS = 240_000;
15
+ const MIN_SECRET_LENGTH = 32;
16
+ /* A request that just queued work asks for a short window: long enough for the
17
+ first passes to claim it, short enough that a burst of requests is not held
18
+ behind one long drain. */
19
+ const KICK_WINDOW_MS = 15_000;
20
+
21
+ export interface WorkerTickConfig {
22
+ readonly secret: string;
23
+ readonly windowMs: number;
24
+ }
25
+
26
+ export function workerTickConfigFromEnvironment(
27
+ environment: NodeJS.ProcessEnv,
28
+ ): WorkerTickConfig {
29
+ const secret = environment.FD_WORKER_TICK_SECRET?.trim() ?? '';
30
+ if (secret.length < MIN_SECRET_LENGTH) {
31
+ throw new Error(
32
+ `FD_WORKER_TICK_SECRET must hold at least ${MIN_SECRET_LENGTH} characters in the tick role.`,
33
+ );
34
+ }
35
+ const configured = environment.FD_WORKER_TICK_WINDOW_MS?.trim();
36
+ const windowMs = configured ? Number(configured) : DEFAULT_WINDOW_MS;
37
+ if (
38
+ !Number.isSafeInteger(windowMs) ||
39
+ windowMs < MIN_WINDOW_MS ||
40
+ windowMs > MAX_WINDOW_MS
41
+ ) {
42
+ throw new Error(
43
+ `FD_WORKER_TICK_WINDOW_MS must be an integer between ${MIN_WINDOW_MS} and ${MAX_WINDOW_MS}.`,
44
+ );
45
+ }
46
+ return { secret, windowMs };
47
+ }
48
+
49
+ export interface WorkerTickReport {
50
+ readonly status: 'drained' | 'closed';
51
+ readonly joined: boolean;
52
+ readonly windowMs: number;
53
+ readonly durationMs: number;
54
+ }
55
+
56
+ export interface WorkerTicker {
57
+ /** Runs the module workers for one window, then drains them. A tick that
58
+ * arrives while a window is open joins it instead of starting another. */
59
+ tick(windowMs?: number): Promise<WorkerTickReport>;
60
+ /** Ends an open window early and waits for its drain; later ticks run nothing. */
61
+ close(): Promise<void>;
62
+ }
63
+
64
+ export function createWorkerTicker(
65
+ compositions: readonly ModuleServerComposition[],
66
+ options: { readonly windowMs: number; readonly now?: () => number },
67
+ ): WorkerTicker {
68
+ const now = options.now ?? Date.now;
69
+ let current: Promise<WorkerTickReport> | undefined;
70
+ let closed = false;
71
+ let endWindow: (() => void) | undefined;
72
+
73
+ const run = async (windowMs: number): Promise<WorkerTickReport> => {
74
+ const startedAt = now();
75
+ const started: ModuleServerComposition[] = [];
76
+ let failure: unknown;
77
+ try {
78
+ for (const composition of compositions) {
79
+ if (!composition.startWorker || closed) continue;
80
+ /* Recorded before the await: a worker that failed halfway through
81
+ its start may still hold a timer, and its stop releases it. */
82
+ started.push(composition);
83
+ await composition.startWorker();
84
+ }
85
+ if (!closed) {
86
+ await new Promise<void>((resolve) => {
87
+ const timer = setTimeout(resolve, windowMs);
88
+ endWindow = () => {
89
+ clearTimeout(timer);
90
+ resolve();
91
+ };
92
+ });
93
+ }
94
+ } catch (error) {
95
+ failure = error;
96
+ } finally {
97
+ endWindow = undefined;
98
+ }
99
+ /* Drained before the request answers: the host may freeze the instance
100
+ as soon as the response leaves, and a frozen claim waits out its lease. */
101
+ for (const composition of started.reverse()) {
102
+ try {
103
+ await composition.stop?.();
104
+ } catch (error) {
105
+ failure ??= error;
106
+ }
107
+ }
108
+ if (failure !== undefined) throw failure;
109
+ return {
110
+ status: closed ? 'closed' : 'drained',
111
+ joined: false,
112
+ windowMs,
113
+ durationMs: now() - startedAt,
114
+ };
115
+ };
116
+
117
+ return {
118
+ tick(windowMs = options.windowMs) {
119
+ if (current) {
120
+ return current.then((report) => ({ ...report, joined: true }));
121
+ }
122
+ if (closed) {
123
+ return Promise.resolve({
124
+ status: 'closed',
125
+ joined: false,
126
+ windowMs,
127
+ durationMs: 0,
128
+ });
129
+ }
130
+ const pending = run(windowMs).finally(() => {
131
+ if (current === pending) current = undefined;
132
+ });
133
+ current = pending;
134
+ return pending;
135
+ },
136
+ async close() {
137
+ closed = true;
138
+ endWindow?.();
139
+ await current?.catch(() => undefined);
140
+ },
141
+ };
142
+ }
143
+
144
+ function digest(value: string): Buffer {
145
+ return createHash('sha256').update(value).digest();
146
+ }
147
+
148
+ export function createWorkerTickEndpoint(
149
+ ticker: WorkerTicker,
150
+ config: WorkerTickConfig,
151
+ ) {
152
+ const expected = digest(config.secret);
153
+ const kickWindowMs = Math.min(KICK_WINDOW_MS, config.windowMs);
154
+ return defineEndpoint({
155
+ id: 'system.worker-tick',
156
+ path: WORKER_TICK_PATH,
157
+ /* GET is what a scheduler such as Vercel Cron sends; POST is a request
158
+ path asking for a pass after it queued work. */
159
+ methods: ['GET', 'POST'],
160
+ access: { kind: 'public' },
161
+ handler: async ({ octane }) => {
162
+ const header = octane.request.headers.get('authorization') ?? '';
163
+ const presented = /^Bearer (.+)$/i.exec(header)?.[1]?.trim() ?? '';
164
+ if (!timingSafeEqual(digest(presented), expected)) {
165
+ return problemResponse(
166
+ new HttpProblem(
167
+ 'UNAUTHENTICATED',
168
+ 'Authentication is required.',
169
+ 401,
170
+ ),
171
+ );
172
+ }
173
+ try {
174
+ const report = await ticker.tick(
175
+ octane.request.method === 'POST' ? kickWindowMs : config.windowMs,
176
+ );
177
+ return jsonResponse(report, report.status === 'closed' ? 503 : 200);
178
+ } catch (error) {
179
+ serverLogger().error('worker tick failed', {
180
+ module: 'platform',
181
+ err: error,
182
+ });
183
+ return problemResponse(
184
+ new HttpProblem(
185
+ 'WORKER_TICK_FAILED',
186
+ 'The worker tick did not complete.',
187
+ 500,
188
+ ),
189
+ );
190
+ }
191
+ },
192
+ });
193
+ }
@@ -0,0 +1,16 @@
1
+ import { basename, dirname } from 'node:path';
2
+
3
+ export function findWorkspaceRoot(start: string): string {
4
+ /* Development loads platform/octane.config.ts from platform/. The production
5
+ bundle loads it from platform/dist/server/, including in Docker. */
6
+ if (basename(start) === 'platform') return dirname(start);
7
+ const dist = dirname(start);
8
+ const platform = dirname(dist);
9
+ if (
10
+ basename(start) === 'server' &&
11
+ basename(dist) === 'dist' &&
12
+ basename(platform) === 'platform'
13
+ )
14
+ return dirname(platform);
15
+ throw new Error('Unexpected Flowdular platform entry layout.');
16
+ }
@@ -0,0 +1,70 @@
1
+ # Flowdular requires a persistent web process, external PostgreSQL and object storage.
2
+ # See infra/README.md before connecting this Blueprint.
3
+ services:
4
+ - type: web
5
+ runtime: docker
6
+ name: flowdular
7
+ plan: 1c-2g
8
+ dockerfilePath: ./infra/docker/Dockerfile
9
+ dockerContext: .
10
+ healthCheckPath: /api/health
11
+ autoDeployTrigger: 'off'
12
+ maxShutdownDelaySeconds: 120
13
+ envVars:
14
+ - key: NODE_ENV
15
+ value: production
16
+ - key: FD_DATABASE_ADAPTER
17
+ value: postgresql
18
+ - key: FD_DATABASE_TLS
19
+ value: verify-full
20
+ - key: FD_DATABASE_URL
21
+ sync: false
22
+ - key: FD_DATABASE_BACKGROUND_URL
23
+ sync: false
24
+ - key: FD_DATABASE_MIGRATOR_URL
25
+ sync: false
26
+ - key: FD_DATABASE_TLS_CA
27
+ sync: false
28
+ - key: FD_AUTH_PUBLIC_ORIGIN
29
+ fromService:
30
+ name: flowdular
31
+ type: web
32
+ envVarKey: RENDER_EXTERNAL_URL
33
+ - key: FD_AUTH_SECURE_COOKIE
34
+ value: 'true'
35
+ - key: FD_AUTH_ALLOW_SIGN_UP
36
+ value: 'false'
37
+ - key: FD_TRUST_PROXY
38
+ value: 'true'
39
+ - key: FD_STORAGE_ADAPTER
40
+ value: s3
41
+ - key: FD_STORAGE_S3_BUCKET
42
+ sync: false
43
+ - key: FD_STORAGE_S3_REGION
44
+ sync: false
45
+ - key: FD_STORAGE_S3_ACCESS_KEY_ID
46
+ sync: false
47
+ - key: FD_STORAGE_S3_SECRET_ACCESS_KEY
48
+ sync: false
49
+ - key: FD_AGENT_CREDENTIAL_KEY
50
+ generateValue: true
51
+ - key: FD_AGENT_RUN_GRANT_KEY
52
+ generateValue: true
53
+ - key: FD_AUTH_MFA_KEY
54
+ generateValue: true
55
+ - key: FD_APPROVAL_GRANT_KEY
56
+ generateValue: true
57
+ - key: FD_AUTOMATIONS_CREDENTIAL_KEY
58
+ generateValue: true
59
+ - key: FD_NOTIFICATIONS_SECRET_KEY
60
+ generateValue: true
61
+ - key: FD_WORKFLOWS_PAYLOAD_KEY
62
+ generateValue: true
63
+ - key: FD_WORKFLOWS_CURSOR_KEY
64
+ generateValue: true
65
+ - key: FD_STORAGE_ENCRYPTION_KEY
66
+ generateValue: true
67
+ - key: FD_CONNECTORS_SECRET_KEY
68
+ generateValue: true
69
+ - key: FD_AUDIT_ANCHOR_KEY
70
+ generateValue: true
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": null,
4
+ "buildCommand": "node infra/vercel/build.mjs"
5
+ }