create-flowdular 0.5.1 → 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 +8 -5
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.agents/skills/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/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- 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/platform-capabilities.md +7 -5
- package/agent-template/.ai/policies/capabilities.yaml +28 -12
- 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 +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/test-hardening/SKILL.md +2 -2
- package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.claude/skills/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/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- 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 +32 -5
- package/agent-template/docs/database-adapters.md +20 -20
- package/agent-template/docs/design-system.md +1 -1
- 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 +3 -1
- package/agent-template/platform/scripts/build.mjs +7 -0
- package/dist/bin.js +3 -6
- package/package.json +1 -1
- package/template/default/.env.example +3 -3
- package/template/default/.vercelignore +8 -0
- package/template/default/README.md +20 -11
- 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 +2 -2
- package/template/default/platform/octane.config.ts +252 -156
- package/template/default/platform/package.json +5 -5
- package/template/default/platform/scripts/build.mjs +56 -0
- package/template/default/platform/scripts/dev.mjs +38 -0
- package/template/default/platform/src/generated/modules.server.ts +1 -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,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
|