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,554 @@
|
|
|
1
|
+
import { flowdularStateDirectory } from '@flowdular/sdk/kernel/runtime-config';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
createDatabaseAdapterRegistry,
|
|
6
|
+
databaseProviderConfigFromEnvironment,
|
|
7
|
+
DATABASE_ADAPTER_IDS,
|
|
8
|
+
DATABASE_CAPABILITY_IDS,
|
|
9
|
+
DATABASE_DIALECT_IDS,
|
|
10
|
+
type DatabaseAdapter,
|
|
11
|
+
type DatabaseAdapterConnectionInput,
|
|
12
|
+
type DatabaseAdapterDescriptor,
|
|
13
|
+
type DatabaseAdapterId,
|
|
14
|
+
type DatabaseAdapterLease,
|
|
15
|
+
type DatabaseAdapterProbeResult,
|
|
16
|
+
type DatabaseAdapterRegistry,
|
|
17
|
+
type DatabaseAdapterState,
|
|
18
|
+
type DatabaseAdapterValidationIssue,
|
|
19
|
+
type DatabaseRow,
|
|
20
|
+
type DatabaseTransaction,
|
|
21
|
+
} from '@flowdular/sdk/database';
|
|
22
|
+
import {
|
|
23
|
+
createPlatformDatabaseProvider,
|
|
24
|
+
type PlatformDatabaseProvider,
|
|
25
|
+
} from '../database.ts';
|
|
26
|
+
import { classifySetupFailure, SetupProbeError } from './sanitize.ts';
|
|
27
|
+
|
|
28
|
+
export const PGLITE_ADAPTER_ID = 'flowdular.pglite';
|
|
29
|
+
export const POSTGRESQL_ADAPTER_ID = DATABASE_ADAPTER_IDS.postgresql;
|
|
30
|
+
|
|
31
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
32
|
+
const SETUP_NAMESPACE = 'flowdular.setup';
|
|
33
|
+
|
|
34
|
+
/* Mirrors what PostgresDatabaseAdapter advertises. Both first-run options run
|
|
35
|
+
that adapter, so the embedded database enforces the same forced row-level
|
|
36
|
+
security a server does; the readiness check proves it at runtime. */
|
|
37
|
+
const POSTGRES_PROFILE = Object.freeze({
|
|
38
|
+
features: Object.freeze([
|
|
39
|
+
DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
|
|
40
|
+
DATABASE_CAPABILITY_IDS.RETURNING,
|
|
41
|
+
DATABASE_CAPABILITY_IDS.ROW_LEVEL_SECURITY,
|
|
42
|
+
DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
|
|
43
|
+
DATABASE_CAPABILITY_IDS.TENANT_CONTEXT,
|
|
44
|
+
DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
|
|
45
|
+
DATABASE_CAPABILITY_IDS.TRANSACTIONS,
|
|
46
|
+
]),
|
|
47
|
+
isolationLevels: Object.freeze([
|
|
48
|
+
'read-committed',
|
|
49
|
+
'repeatable-read',
|
|
50
|
+
'serializable',
|
|
51
|
+
] as const),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface SetupAdapterOptions {
|
|
55
|
+
readonly workspaceRoot: string;
|
|
56
|
+
readonly production: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A registered adapter plus the two things the platform, not the contract,
|
|
61
|
+
* owns: the environment a deployment needs so this adapter becomes its
|
|
62
|
+
* database, and a provider built from that same environment.
|
|
63
|
+
*/
|
|
64
|
+
export interface PlatformSetupAdapter {
|
|
65
|
+
readonly descriptor: DatabaseAdapterDescriptor;
|
|
66
|
+
environment(input: DatabaseAdapterConnectionInput): Record<string, string>;
|
|
67
|
+
openProvider(input: DatabaseAdapterConnectionInput): PlatformDatabaseProvider;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SetupAdapters {
|
|
71
|
+
readonly registry: DatabaseAdapterRegistry;
|
|
72
|
+
get(adapterId: string): PlatformSetupAdapter | undefined;
|
|
73
|
+
list(): readonly PlatformSetupAdapter[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function text(
|
|
77
|
+
input: DatabaseAdapterConnectionInput,
|
|
78
|
+
key: string,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
const value = input.config[key];
|
|
81
|
+
if (typeof value === 'string') return value.trim() || undefined;
|
|
82
|
+
if (typeof value === 'number') return String(value);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function secret(
|
|
87
|
+
input: DatabaseAdapterConnectionInput,
|
|
88
|
+
key: string,
|
|
89
|
+
): string | undefined {
|
|
90
|
+
return input.secrets[key] || undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setupSecretValues(
|
|
94
|
+
input: DatabaseAdapterConnectionInput,
|
|
95
|
+
): readonly string[] {
|
|
96
|
+
return Object.values(input.secrets).filter((value) => value.length > 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withTimeout<T>(work: Promise<T>, timeoutMs: number): Promise<T> {
|
|
100
|
+
let timer: NodeJS.Timeout | undefined;
|
|
101
|
+
return Promise.race([
|
|
102
|
+
work,
|
|
103
|
+
new Promise<never>((_resolve, reject) => {
|
|
104
|
+
timer = setTimeout(
|
|
105
|
+
() => reject(new SetupProbeError('TIMED_OUT')),
|
|
106
|
+
timeoutMs,
|
|
107
|
+
);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
}),
|
|
110
|
+
]).finally(() => clearTimeout(timer));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* A leased runtime handle presented as the adapter the descriptor contract
|
|
114
|
+
returns. Disposing it releases the lease and closes every pool the
|
|
115
|
+
configuration opened, so a probe leaves no connection behind. */
|
|
116
|
+
function leasedAdapter(
|
|
117
|
+
provider: PlatformDatabaseProvider,
|
|
118
|
+
lease: DatabaseAdapterLease,
|
|
119
|
+
): DatabaseAdapter {
|
|
120
|
+
const handle = lease.database;
|
|
121
|
+
let state: DatabaseAdapterState = 'ready';
|
|
122
|
+
return {
|
|
123
|
+
get state() {
|
|
124
|
+
return state;
|
|
125
|
+
},
|
|
126
|
+
adapterId: handle.adapterId,
|
|
127
|
+
dialectId: handle.dialectId,
|
|
128
|
+
capabilities: handle.capabilities,
|
|
129
|
+
schema: handle.schema,
|
|
130
|
+
query<Row extends DatabaseRow = DatabaseRow>(
|
|
131
|
+
statement: Parameters<typeof handle.query>[0],
|
|
132
|
+
options?: Parameters<typeof handle.query>[1],
|
|
133
|
+
) {
|
|
134
|
+
return handle.query<Row>(statement, options);
|
|
135
|
+
},
|
|
136
|
+
execute: (statement, options) => handle.execute(statement, options),
|
|
137
|
+
executeScript: (script, options) => handle.executeScript(script, options),
|
|
138
|
+
transaction<T>(
|
|
139
|
+
operation: (transaction: DatabaseTransaction) => Promise<T>,
|
|
140
|
+
options?: Parameters<typeof handle.transaction>[1],
|
|
141
|
+
) {
|
|
142
|
+
return handle.transaction(operation, options);
|
|
143
|
+
},
|
|
144
|
+
async dispose() {
|
|
145
|
+
if (state !== 'ready') return;
|
|
146
|
+
state = 'disposing';
|
|
147
|
+
try {
|
|
148
|
+
await lease.release();
|
|
149
|
+
await provider.dispose();
|
|
150
|
+
} finally {
|
|
151
|
+
state = 'disposed';
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Reachability is answered by the provider's own readiness check, which
|
|
158
|
+
already refuses a runtime role holding SUPERUSER or BYPASSRLS, plus a
|
|
159
|
+
background lease because auth.core takes one on its first request. A
|
|
160
|
+
deployment that cannot serve one would boot and then fail. */
|
|
161
|
+
async function probeProvider(
|
|
162
|
+
adapter: PlatformSetupAdapter,
|
|
163
|
+
input: DatabaseAdapterConnectionInput,
|
|
164
|
+
): Promise<DatabaseAdapterProbeResult> {
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
let provider: PlatformDatabaseProvider | undefined;
|
|
167
|
+
try {
|
|
168
|
+
provider = adapter.openProvider(input);
|
|
169
|
+
const opened = provider;
|
|
170
|
+
await withTimeout(
|
|
171
|
+
(async () => {
|
|
172
|
+
await opened.check();
|
|
173
|
+
const background = await opened.acquire({
|
|
174
|
+
namespace: SETUP_NAMESPACE,
|
|
175
|
+
purpose: 'background',
|
|
176
|
+
});
|
|
177
|
+
await background.release();
|
|
178
|
+
})(),
|
|
179
|
+
PROBE_TIMEOUT_MS,
|
|
180
|
+
);
|
|
181
|
+
return { status: 'ready', latencyMs: Date.now() - started };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
status: 'unavailable',
|
|
185
|
+
latencyMs: Date.now() - started,
|
|
186
|
+
message: classifySetupFailure(error, setupSecretValues(input)).message,
|
|
187
|
+
};
|
|
188
|
+
} finally {
|
|
189
|
+
await provider?.dispose().catch(() => undefined);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function connectProvider(
|
|
194
|
+
adapter: PlatformSetupAdapter,
|
|
195
|
+
input: DatabaseAdapterConnectionInput,
|
|
196
|
+
): Promise<DatabaseAdapter> {
|
|
197
|
+
const provider = adapter.openProvider(input);
|
|
198
|
+
try {
|
|
199
|
+
const lease = await provider.acquire({
|
|
200
|
+
namespace: SETUP_NAMESPACE,
|
|
201
|
+
purpose: 'runtime',
|
|
202
|
+
});
|
|
203
|
+
return leasedAdapter(provider, lease);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
await provider.dispose().catch(() => undefined);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function issue(
|
|
211
|
+
field: string,
|
|
212
|
+
code: string,
|
|
213
|
+
message: string,
|
|
214
|
+
): DatabaseAdapterValidationIssue {
|
|
215
|
+
return { field, code, message };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const HOST = /^[A-Za-z0-9._:[\]-]{1,255}$/;
|
|
219
|
+
const IDENTIFIER = /^[A-Za-z0-9_$-]{1,63}$/;
|
|
220
|
+
const TLS_MODES = ['verify-full', 'require', 'disable'] as const;
|
|
221
|
+
|
|
222
|
+
function dsn(
|
|
223
|
+
input: DatabaseAdapterConnectionInput,
|
|
224
|
+
user: string,
|
|
225
|
+
password: string,
|
|
226
|
+
): string {
|
|
227
|
+
const host = text(input, 'host') ?? '';
|
|
228
|
+
const port = text(input, 'port') ?? '5432';
|
|
229
|
+
const database = text(input, 'database') ?? '';
|
|
230
|
+
return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(
|
|
231
|
+
password,
|
|
232
|
+
)}@${host}:${port}/${encodeURIComponent(database)}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createPostgresqlAdapter(
|
|
236
|
+
options: SetupAdapterOptions,
|
|
237
|
+
): PlatformSetupAdapter {
|
|
238
|
+
const adapter: PlatformSetupAdapter = {
|
|
239
|
+
descriptor: {
|
|
240
|
+
adapterId: POSTGRESQL_ADAPTER_ID,
|
|
241
|
+
dialectId: DATABASE_DIALECT_IDS.postgresql,
|
|
242
|
+
label: 'PostgreSQL server',
|
|
243
|
+
description:
|
|
244
|
+
'A PostgreSQL server you already run. The deployment connects with three roles: one that owns the schema, one tenant-scoped role that serves requests, and one read-only role for the few cross-tenant lookups.',
|
|
245
|
+
capabilities: POSTGRES_PROFILE,
|
|
246
|
+
configurationSchema: {
|
|
247
|
+
version: 1,
|
|
248
|
+
fields: [
|
|
249
|
+
{
|
|
250
|
+
key: 'host',
|
|
251
|
+
label: 'Host',
|
|
252
|
+
description: 'Host name or address of the PostgreSQL server.',
|
|
253
|
+
kind: 'text',
|
|
254
|
+
required: true,
|
|
255
|
+
secret: false,
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
key: 'port',
|
|
259
|
+
label: 'Port',
|
|
260
|
+
description: 'Port the server listens on.',
|
|
261
|
+
kind: 'integer',
|
|
262
|
+
required: true,
|
|
263
|
+
secret: false,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
key: 'database',
|
|
267
|
+
label: 'Database',
|
|
268
|
+
description: 'An existing, empty database this deployment owns.',
|
|
269
|
+
kind: 'text',
|
|
270
|
+
required: true,
|
|
271
|
+
secret: false,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
key: 'migrator-user',
|
|
275
|
+
label: 'Migration role',
|
|
276
|
+
description:
|
|
277
|
+
'Owns the schema. Used only while migrations run, never to serve a request.',
|
|
278
|
+
kind: 'text',
|
|
279
|
+
required: true,
|
|
280
|
+
secret: false,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
key: 'migrator-password',
|
|
284
|
+
label: 'Migration role password',
|
|
285
|
+
description: 'Password for the migration role.',
|
|
286
|
+
kind: 'text',
|
|
287
|
+
required: true,
|
|
288
|
+
secret: true,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
key: 'runtime-user',
|
|
292
|
+
label: 'Runtime role',
|
|
293
|
+
description:
|
|
294
|
+
'Serves every request. It must hold neither SUPERUSER nor BYPASSRLS, because tenant isolation depends on that.',
|
|
295
|
+
kind: 'text',
|
|
296
|
+
required: true,
|
|
297
|
+
secret: false,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
key: 'runtime-password',
|
|
301
|
+
label: 'Runtime role password',
|
|
302
|
+
description: 'Password for the runtime role.',
|
|
303
|
+
kind: 'text',
|
|
304
|
+
required: true,
|
|
305
|
+
secret: true,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: 'background-user',
|
|
309
|
+
label: 'Background role',
|
|
310
|
+
description:
|
|
311
|
+
'Reads the routing columns a scheduler poll needs across tenants. It writes nothing.',
|
|
312
|
+
kind: 'text',
|
|
313
|
+
required: true,
|
|
314
|
+
secret: false,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
key: 'background-password',
|
|
318
|
+
label: 'Background role password',
|
|
319
|
+
description: 'Password for the background role.',
|
|
320
|
+
kind: 'text',
|
|
321
|
+
required: true,
|
|
322
|
+
secret: true,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
key: 'tls',
|
|
326
|
+
label: 'TLS',
|
|
327
|
+
description: options.production
|
|
328
|
+
? 'A production deployment verifies the server certificate in full.'
|
|
329
|
+
: 'How this deployment verifies the server certificate.',
|
|
330
|
+
kind: 'select',
|
|
331
|
+
required: true,
|
|
332
|
+
secret: false,
|
|
333
|
+
options: (options.production
|
|
334
|
+
? (['verify-full'] as const)
|
|
335
|
+
: TLS_MODES
|
|
336
|
+
).map((mode) => ({ label: mode, value: mode })),
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
key: 'tls-authority-file',
|
|
340
|
+
label: 'Certificate authority file',
|
|
341
|
+
description:
|
|
342
|
+
'Path to a PEM certificate authority, when the server presents a certificate this machine does not already trust.',
|
|
343
|
+
kind: 'text',
|
|
344
|
+
required: false,
|
|
345
|
+
secret: false,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
validate(input) {
|
|
350
|
+
const issues: DatabaseAdapterValidationIssue[] = [];
|
|
351
|
+
const host = text(input, 'host');
|
|
352
|
+
if (!host || !HOST.test(host)) {
|
|
353
|
+
issues.push(
|
|
354
|
+
issue('host', 'INVALID', 'Enter a host name or an address.'),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const port = Number(text(input, 'port'));
|
|
358
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
359
|
+
issues.push(
|
|
360
|
+
issue('port', 'INVALID', 'Enter a port between 1 and 65535.'),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const database = text(input, 'database');
|
|
364
|
+
if (!database || !IDENTIFIER.test(database)) {
|
|
365
|
+
issues.push(
|
|
366
|
+
issue('database', 'INVALID', 'Enter an existing database name.'),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
for (const role of ['migrator', 'runtime', 'background'] as const) {
|
|
370
|
+
const user = text(input, `${role}-user`);
|
|
371
|
+
if (!user || !IDENTIFIER.test(user)) {
|
|
372
|
+
issues.push(
|
|
373
|
+
issue(`${role}-user`, 'INVALID', 'Enter the role name.'),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (!secret(input, `${role}-password`)) {
|
|
377
|
+
issues.push(
|
|
378
|
+
issue(
|
|
379
|
+
`${role}-password`,
|
|
380
|
+
'REQUIRED',
|
|
381
|
+
'Enter the password for this role.',
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const migrator = text(input, 'migrator-user');
|
|
387
|
+
const runtime = text(input, 'runtime-user');
|
|
388
|
+
if (migrator && runtime && migrator === runtime) {
|
|
389
|
+
issues.push(
|
|
390
|
+
issue(
|
|
391
|
+
'runtime-user',
|
|
392
|
+
'INVALID',
|
|
393
|
+
'The runtime role must differ from the migration role, so a request can never run schema operations.',
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const tls = text(input, 'tls');
|
|
398
|
+
if (!tls || !TLS_MODES.includes(tls as (typeof TLS_MODES)[number])) {
|
|
399
|
+
issues.push(issue('tls', 'INVALID', 'Choose how TLS is verified.'));
|
|
400
|
+
} else if (options.production && tls !== 'verify-full') {
|
|
401
|
+
issues.push(
|
|
402
|
+
issue(
|
|
403
|
+
'tls',
|
|
404
|
+
'INVALID',
|
|
405
|
+
'A production deployment requires full certificate verification.',
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return issues;
|
|
410
|
+
},
|
|
411
|
+
probe: (input) => probeProvider(adapter, input),
|
|
412
|
+
async provision(_input, authorization) {
|
|
413
|
+
if (authorization.intent !== 'confirmed-first-run') {
|
|
414
|
+
throw new Error('Provisioning requires a confirmed first run.');
|
|
415
|
+
}
|
|
416
|
+
/* The server, the database, and the three roles belong to the
|
|
417
|
+
operator. Creating them would need a superuser credential this
|
|
418
|
+
screen deliberately never asks for. */
|
|
419
|
+
},
|
|
420
|
+
connect: (input) => connectProvider(adapter, input),
|
|
421
|
+
},
|
|
422
|
+
environment(input) {
|
|
423
|
+
const authority = text(input, 'tls-authority-file');
|
|
424
|
+
return {
|
|
425
|
+
FD_DATABASE_ADAPTER: 'postgresql',
|
|
426
|
+
FD_DATABASE_URL: dsn(
|
|
427
|
+
input,
|
|
428
|
+
text(input, 'runtime-user') ?? '',
|
|
429
|
+
secret(input, 'runtime-password') ?? '',
|
|
430
|
+
),
|
|
431
|
+
FD_DATABASE_MIGRATOR_URL: dsn(
|
|
432
|
+
input,
|
|
433
|
+
text(input, 'migrator-user') ?? '',
|
|
434
|
+
secret(input, 'migrator-password') ?? '',
|
|
435
|
+
),
|
|
436
|
+
FD_DATABASE_BACKGROUND_URL: dsn(
|
|
437
|
+
input,
|
|
438
|
+
text(input, 'background-user') ?? '',
|
|
439
|
+
secret(input, 'background-password') ?? '',
|
|
440
|
+
),
|
|
441
|
+
FD_DATABASE_TLS: text(input, 'tls') ?? 'verify-full',
|
|
442
|
+
...(authority ? { FD_DATABASE_TLS_CA_FILE: authority } : {}),
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
openProvider(input) {
|
|
446
|
+
return createPlatformDatabaseProvider(
|
|
447
|
+
databaseProviderConfigFromEnvironment(
|
|
448
|
+
{
|
|
449
|
+
NODE_ENV: options.production ? 'production' : 'development',
|
|
450
|
+
FD_DATABASE_CONNECT_TIMEOUT_MS: String(PROBE_TIMEOUT_MS),
|
|
451
|
+
...adapter.environment(input),
|
|
452
|
+
},
|
|
453
|
+
options.workspaceRoot,
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
return adapter;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function createPgliteAdapter(
|
|
462
|
+
options: SetupAdapterOptions,
|
|
463
|
+
): PlatformSetupAdapter {
|
|
464
|
+
const defaultDirectory = resolve(
|
|
465
|
+
flowdularStateDirectory(options.workspaceRoot),
|
|
466
|
+
'data',
|
|
467
|
+
'pglite',
|
|
468
|
+
);
|
|
469
|
+
const directoryOf = (input: DatabaseAdapterConnectionInput): string =>
|
|
470
|
+
resolve(
|
|
471
|
+
options.workspaceRoot,
|
|
472
|
+
text(input, 'data-directory') ?? defaultDirectory,
|
|
473
|
+
);
|
|
474
|
+
const adapter: PlatformSetupAdapter = {
|
|
475
|
+
descriptor: {
|
|
476
|
+
adapterId: PGLITE_ADAPTER_ID,
|
|
477
|
+
dialectId: DATABASE_DIALECT_IDS.postgresql,
|
|
478
|
+
label: 'Embedded PostgreSQL',
|
|
479
|
+
description:
|
|
480
|
+
'PostgreSQL running inside this process, storing its data in a directory on this machine. Nothing to install and no credentials to manage; it enforces the same forced row-level security a server does.',
|
|
481
|
+
capabilities: POSTGRES_PROFILE,
|
|
482
|
+
configurationSchema: {
|
|
483
|
+
version: 1,
|
|
484
|
+
fields: [
|
|
485
|
+
{
|
|
486
|
+
key: 'data-directory',
|
|
487
|
+
label: 'Data directory',
|
|
488
|
+
description:
|
|
489
|
+
'Where the database files live. Leave it empty to use the default below.',
|
|
490
|
+
kind: 'text',
|
|
491
|
+
required: false,
|
|
492
|
+
secret: false,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
validate(input) {
|
|
497
|
+
const directory = text(input, 'data-directory');
|
|
498
|
+
if (directory && /[\0]/.test(directory)) {
|
|
499
|
+
return [
|
|
500
|
+
issue('data-directory', 'INVALID', 'Enter a directory path.'),
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
return [];
|
|
504
|
+
},
|
|
505
|
+
probe: (input) => probeProvider(adapter, input),
|
|
506
|
+
async provision(input, authorization) {
|
|
507
|
+
if (authorization.intent !== 'confirmed-first-run') {
|
|
508
|
+
throw new Error('Provisioning requires a confirmed first run.');
|
|
509
|
+
}
|
|
510
|
+
/* The only external state this adapter owns is its data directory,
|
|
511
|
+
and the database files inside it must not be world readable. */
|
|
512
|
+
mkdirSync(directoryOf(input), { recursive: true, mode: 0o700 });
|
|
513
|
+
},
|
|
514
|
+
connect: (input) => connectProvider(adapter, input),
|
|
515
|
+
},
|
|
516
|
+
environment: (input) => ({
|
|
517
|
+
FD_DATABASE_ADAPTER: 'pglite',
|
|
518
|
+
FD_DATABASE_PGLITE_DIRECTORY: directoryOf(input),
|
|
519
|
+
}),
|
|
520
|
+
openProvider(input) {
|
|
521
|
+
return createPlatformDatabaseProvider(
|
|
522
|
+
databaseProviderConfigFromEnvironment(
|
|
523
|
+
{ NODE_ENV: 'development', ...adapter.environment(input) },
|
|
524
|
+
options.workspaceRoot,
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
return adapter;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The adapters this deployment can be pointed at on a first run. The embedded
|
|
534
|
+
* database is absent in production: a deployment states its real database
|
|
535
|
+
* instead of shipping one inside the process.
|
|
536
|
+
*/
|
|
537
|
+
export function createSetupAdapters(
|
|
538
|
+
options: SetupAdapterOptions,
|
|
539
|
+
): SetupAdapters {
|
|
540
|
+
const registry = createDatabaseAdapterRegistry();
|
|
541
|
+
const adapters = new Map<DatabaseAdapterId, PlatformSetupAdapter>();
|
|
542
|
+
const register = (adapter: PlatformSetupAdapter): void => {
|
|
543
|
+
registry.register(adapter.descriptor);
|
|
544
|
+
adapters.set(adapter.descriptor.adapterId, adapter);
|
|
545
|
+
};
|
|
546
|
+
if (!options.production) register(createPgliteAdapter(options));
|
|
547
|
+
register(createPostgresqlAdapter(options));
|
|
548
|
+
registry.seal();
|
|
549
|
+
return {
|
|
550
|
+
registry,
|
|
551
|
+
get: (adapterId) => adapters.get(adapterId),
|
|
552
|
+
list: () => [...adapters.values()],
|
|
553
|
+
};
|
|
554
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/* Writing configuration is a real capability and a deployment may withhold it
|
|
12
|
+
on purpose: infra/docker/compose.yaml sets read_only: true. A refused write
|
|
13
|
+
is reported as a refused write, and the operator gets the exact block to
|
|
14
|
+
paste into their orchestrator instead of a success that did not happen. */
|
|
15
|
+
|
|
16
|
+
export type EnvironmentWriteStatus =
|
|
17
|
+
| 'failed'
|
|
18
|
+
| 'read-only'
|
|
19
|
+
| 'unchanged'
|
|
20
|
+
| 'written';
|
|
21
|
+
|
|
22
|
+
export interface EnvironmentWriteResult {
|
|
23
|
+
readonly status: EnvironmentWriteStatus;
|
|
24
|
+
readonly path: string;
|
|
25
|
+
/** Keys this run added to the file. */
|
|
26
|
+
readonly added: readonly string[];
|
|
27
|
+
/** Keys the file already set. An existing value is never replaced. */
|
|
28
|
+
readonly kept: readonly string[];
|
|
29
|
+
/** The block to paste when this deployment cannot persist it itself. */
|
|
30
|
+
readonly block: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BARE_VALUE = /^[A-Za-z0-9_@%:/.,+~=?&[\]{}!*()$^-]+$/;
|
|
34
|
+
const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
35
|
+
|
|
36
|
+
function quote(value: string): string {
|
|
37
|
+
if (BARE_VALUE.test(value)) return value;
|
|
38
|
+
if (value.includes('"') || value.includes('\\')) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'A value containing a quote or a backslash cannot be stored in a .env file.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return `"${value}"`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function existingKeys(contents: string): ReadonlySet<string> {
|
|
47
|
+
const keys = new Set<string>();
|
|
48
|
+
for (const line of contents.split('\n')) {
|
|
49
|
+
const match = KEY_LINE.exec(line);
|
|
50
|
+
if (match?.[1]) keys.add(match[1]);
|
|
51
|
+
}
|
|
52
|
+
return keys;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderEnvironmentBlock(
|
|
56
|
+
values: Readonly<Record<string, string>>,
|
|
57
|
+
): string {
|
|
58
|
+
return Object.entries(values)
|
|
59
|
+
.map(([key, value]) => `${key}=${quote(value)}`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isReadOnly(error: unknown): boolean {
|
|
64
|
+
const code =
|
|
65
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
66
|
+
? String((error as { code: unknown }).code)
|
|
67
|
+
: '';
|
|
68
|
+
return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Adds the keys this deployment is missing to `<workspaceRoot>/.env`, owner
|
|
73
|
+
* readable only. A key the file already sets is left exactly as it is, so a
|
|
74
|
+
* second run can never quietly re-point a database that is already configured.
|
|
75
|
+
*/
|
|
76
|
+
export function writeEnvironmentFile(
|
|
77
|
+
workspaceRoot: string,
|
|
78
|
+
values: Readonly<Record<string, string>>,
|
|
79
|
+
): EnvironmentWriteResult {
|
|
80
|
+
const path = resolve(workspaceRoot, '.env');
|
|
81
|
+
let block: string;
|
|
82
|
+
try {
|
|
83
|
+
block = renderEnvironmentBlock(values);
|
|
84
|
+
} catch {
|
|
85
|
+
/* A value a .env file cannot represent unambiguously. Reporting the keys
|
|
86
|
+
is the whole answer; writing a file that parses back differently is
|
|
87
|
+
the one outcome worse than not writing at all. */
|
|
88
|
+
return {
|
|
89
|
+
status: 'failed',
|
|
90
|
+
path,
|
|
91
|
+
added: [],
|
|
92
|
+
kept: [],
|
|
93
|
+
block: Object.keys(values).join('\n'),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let contents = '';
|
|
97
|
+
try {
|
|
98
|
+
const stat = lstatSync(path);
|
|
99
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
100
|
+
return { status: 'failed', path, added: [], kept: [], block };
|
|
101
|
+
}
|
|
102
|
+
contents = readFileSync(path, 'utf8');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
/* Only a missing file may be created. An unreadable file must not be
|
|
105
|
+
treated as empty and replaced by the atomic rename below. */
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
107
|
+
return {
|
|
108
|
+
status: isReadOnly(error) ? 'read-only' : 'failed',
|
|
109
|
+
path,
|
|
110
|
+
added: [],
|
|
111
|
+
kept: [],
|
|
112
|
+
block,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const present = existingKeys(contents);
|
|
117
|
+
const added: string[] = [];
|
|
118
|
+
const kept: string[] = [];
|
|
119
|
+
for (const key of Object.keys(values)) {
|
|
120
|
+
if (present.has(key)) kept.push(key);
|
|
121
|
+
else added.push(key);
|
|
122
|
+
}
|
|
123
|
+
if (added.length === 0) {
|
|
124
|
+
return { status: 'unchanged', path, added, kept, block };
|
|
125
|
+
}
|
|
126
|
+
const appended = added.map((key) => `${key}=${quote(values[key]!)}`);
|
|
127
|
+
const next = [
|
|
128
|
+
...(contents.length > 0 ? [contents.replace(/\n*$/, '\n')] : []),
|
|
129
|
+
'# Written by the Flowdular first-run setup.\n',
|
|
130
|
+
`${appended.join('\n')}\n`,
|
|
131
|
+
].join('');
|
|
132
|
+
/* A partial .env is worse than none, and a read-only mount must leave no
|
|
133
|
+
trace at all, so the file appears only once it is complete. */
|
|
134
|
+
const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`;
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(temporary, next, { encoding: 'utf8', mode: 0o600 });
|
|
137
|
+
renameSync(temporary, path);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
try {
|
|
140
|
+
rmSync(temporary, { force: true });
|
|
141
|
+
} catch {
|
|
142
|
+
/* The temporary file was never created on a filesystem that refused
|
|
143
|
+
the write, so there is nothing to clean up. */
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
status: isReadOnly(error) ? 'read-only' : 'failed',
|
|
147
|
+
path,
|
|
148
|
+
added: [],
|
|
149
|
+
kept,
|
|
150
|
+
block,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { status: 'written', path, added, kept, block };
|
|
154
|
+
}
|