create-flowdular 0.5.1 → 0.6.1
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/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- 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/agents/sandbox/business-manager.md +2 -2
- 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 +10 -8
- 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/spec-approval/SKILL.md +6 -2
- package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
- 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/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- 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/0003-module-settings.md +2 -0
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +3 -3
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +40 -3
- package/agent-template/docs/configuration.md +64 -9
- package/agent-template/docs/database-adapters.md +30 -22
- package/agent-template/docs/design-system.md +7 -3
- package/agent-template/docs/getting-started.md +29 -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 +6 -2
- package/agent-template/docs/sandbox.md +23 -4
- 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 +8 -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 +71 -0
- package/template/default/infra/docker/Dockerfile +29 -11
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +109 -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/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +223 -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 -3
- package/template/default/platform/octane.config.ts +290 -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 +101 -18
- 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/lifecycle.ts +325 -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 +119 -0
- package/template/default/platform/src/server/setup/page.ts +548 -0
- package/template/default/platform/src/server/setup/routes.ts +788 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +192 -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,788 @@
|
|
|
1
|
+
import { validateApplicationPath } from '@flowdular/sdk/server';
|
|
2
|
+
import {
|
|
3
|
+
assertPasswordPolicy,
|
|
4
|
+
AuthServiceError,
|
|
5
|
+
validateDisplayName,
|
|
6
|
+
validateEmailAddress,
|
|
7
|
+
validateWorkspaceName,
|
|
8
|
+
validateWorkspaceSlug,
|
|
9
|
+
} from '@flowdular/sdk/modules/auth/server';
|
|
10
|
+
import { AUTH_MODULE_SETTINGS } from '@flowdular/sdk/modules/auth';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { ServerRoute, type Context } from '@octanejs/app-core';
|
|
13
|
+
import {
|
|
14
|
+
validateDatabaseSelection,
|
|
15
|
+
type DatabaseAdapterConnectionInput,
|
|
16
|
+
type DatabaseAdapterProbeResult,
|
|
17
|
+
type DatabaseSafeConfiguration,
|
|
18
|
+
type ModuleDatabaseRequirements,
|
|
19
|
+
} from '@flowdular/sdk/database';
|
|
20
|
+
import {
|
|
21
|
+
readSetupSessionCookie,
|
|
22
|
+
SETUP_CSRF_FIELD,
|
|
23
|
+
setupSessionCookie,
|
|
24
|
+
type SetupAccess,
|
|
25
|
+
type SetupSession,
|
|
26
|
+
} from './access.ts';
|
|
27
|
+
import {
|
|
28
|
+
POSTGRESQL_ADAPTER_ID,
|
|
29
|
+
setupSecretValues,
|
|
30
|
+
type SetupAdapters,
|
|
31
|
+
} from './adapters.ts';
|
|
32
|
+
import {
|
|
33
|
+
createPlatformDatabaseProvider,
|
|
34
|
+
databaseProviderConfigFromEnvironment,
|
|
35
|
+
} from '../database.ts';
|
|
36
|
+
import {
|
|
37
|
+
writeEnvironmentFile,
|
|
38
|
+
type EnvironmentWriteResult,
|
|
39
|
+
} from './environment.ts';
|
|
40
|
+
import {
|
|
41
|
+
renderSetupPage,
|
|
42
|
+
type ModuleCompatibility,
|
|
43
|
+
type SetupPageView,
|
|
44
|
+
} from './page.ts';
|
|
45
|
+
import { classifySetupFailure } from './sanitize.ts';
|
|
46
|
+
import {
|
|
47
|
+
claimFirstRun,
|
|
48
|
+
seedFirstRun,
|
|
49
|
+
SetupSeedError,
|
|
50
|
+
type FirstRunOwner,
|
|
51
|
+
type FirstRunSeed,
|
|
52
|
+
} from './seed.ts';
|
|
53
|
+
|
|
54
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
55
|
+
const FIELD_PREFIX = 'field:';
|
|
56
|
+
|
|
57
|
+
export interface SetupRoutesOptions {
|
|
58
|
+
readonly environment: NodeJS.ProcessEnv;
|
|
59
|
+
readonly databasePreconfigured: boolean;
|
|
60
|
+
readonly defaultApplicationPath?: string;
|
|
61
|
+
readonly webMountPaths?: readonly string[];
|
|
62
|
+
readonly workspaceRoot: string;
|
|
63
|
+
readonly adapters: SetupAdapters;
|
|
64
|
+
readonly access: SetupAccess;
|
|
65
|
+
readonly modules: readonly ModuleDatabaseRequirements[];
|
|
66
|
+
/** True when the module list came from the manifest fallback, not from disk. */
|
|
67
|
+
readonly modulesApproximated: boolean;
|
|
68
|
+
readonly tokenFile: string | null;
|
|
69
|
+
readonly secureCookies: boolean;
|
|
70
|
+
/** Supplied by tests; production exits after the completed page requests restart. */
|
|
71
|
+
readonly restartApplication?: (exitCode: number) => void;
|
|
72
|
+
/** Set when setup runs inside the composed application, which serves itself
|
|
73
|
+
* once the first workspace exists instead of restarting. */
|
|
74
|
+
readonly inPlace?: { readonly onClaimed: () => void } | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ConnectionState {
|
|
78
|
+
readonly adapterId: string;
|
|
79
|
+
readonly input: DatabaseAdapterConnectionInput | null;
|
|
80
|
+
readonly values: Readonly<Record<string, string>>;
|
|
81
|
+
readonly probe: DatabaseAdapterProbeResult | null;
|
|
82
|
+
readonly modules: readonly ModuleCompatibility[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface DatabaseState extends ConnectionState {
|
|
86
|
+
readonly kind: 'database';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ReviewState extends ConnectionState {
|
|
90
|
+
readonly kind: 'review';
|
|
91
|
+
readonly owner: FirstRunOwner;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface DoneState {
|
|
95
|
+
readonly kind: 'done';
|
|
96
|
+
readonly applicationPath: string;
|
|
97
|
+
readonly seed: FirstRunSeed;
|
|
98
|
+
readonly environment: EnvironmentWriteResult | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type FlowState = DoneState | ReviewState | DatabaseState;
|
|
102
|
+
|
|
103
|
+
function flowState(session: SetupSession | null): FlowState | null {
|
|
104
|
+
const pending = session?.pending;
|
|
105
|
+
if (!pending || typeof pending !== 'object') return null;
|
|
106
|
+
const kind = (pending as { kind?: unknown }).kind;
|
|
107
|
+
return kind === 'database' || kind === 'review' || kind === 'done'
|
|
108
|
+
? (pending as FlowState)
|
|
109
|
+
: null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function canAutoRestart(
|
|
113
|
+
options: SetupRoutesOptions,
|
|
114
|
+
state: DoneState,
|
|
115
|
+
): boolean {
|
|
116
|
+
return (
|
|
117
|
+
!options.inPlace &&
|
|
118
|
+
options.environment.FD_SETUP_AUTO_RESTART === 'true' &&
|
|
119
|
+
(options.databasePreconfigured ||
|
|
120
|
+
state.environment?.status === 'written' ||
|
|
121
|
+
state.environment?.status === 'unchanged')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* auth.core's rule for this variable: blank means the default, and a value
|
|
126
|
+
outside the setting's bounds stops startup, as auth.core does at boot. */
|
|
127
|
+
function passwordMinLength(environment: NodeJS.ProcessEnv): number {
|
|
128
|
+
const { defaultValue, min, max } =
|
|
129
|
+
AUTH_MODULE_SETTINGS.settings.passwordMinLength!;
|
|
130
|
+
const value = environment.FD_AUTH_PASSWORD_MIN_LENGTH;
|
|
131
|
+
if (value === undefined || value.trim() === '') return Number(defaultValue);
|
|
132
|
+
const parsed = Number(value);
|
|
133
|
+
if (!Number.isSafeInteger(parsed) || parsed < min! || parsed > max!) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`FD_AUTH_PASSWORD_MIN_LENGTH must be an integer between ${min} and ${max}.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function emptyView(options: SetupRoutesOptions): SetupPageView {
|
|
142
|
+
return {
|
|
143
|
+
step: 'Unlock',
|
|
144
|
+
databasePreconfigured: options.databasePreconfigured,
|
|
145
|
+
inPlace: options.inPlace !== undefined,
|
|
146
|
+
autoRestart:
|
|
147
|
+
!options.inPlace &&
|
|
148
|
+
options.databasePreconfigured &&
|
|
149
|
+
options.environment.FD_SETUP_AUTO_RESTART === 'true',
|
|
150
|
+
csrfToken: null,
|
|
151
|
+
error: null,
|
|
152
|
+
notice: null,
|
|
153
|
+
adapters: options.adapters.registry.list(),
|
|
154
|
+
selectedAdapterId: null,
|
|
155
|
+
fieldErrors: {},
|
|
156
|
+
values: {
|
|
157
|
+
applicationPath:
|
|
158
|
+
options.environment.FD_APPLICATION_PATH ??
|
|
159
|
+
options.defaultApplicationPath ??
|
|
160
|
+
'/app',
|
|
161
|
+
},
|
|
162
|
+
probe: null,
|
|
163
|
+
modules: [],
|
|
164
|
+
environment: null,
|
|
165
|
+
seed: null,
|
|
166
|
+
modulesApproximated: options.modulesApproximated,
|
|
167
|
+
tokenFile: options.tokenFile,
|
|
168
|
+
/* auth.core creates the owner with this minimum, so the workspace step
|
|
169
|
+
has to refuse what the final step would. */
|
|
170
|
+
passwordMinLength: passwordMinLength(options.environment),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function page(
|
|
175
|
+
view: SetupPageView,
|
|
176
|
+
status = 200,
|
|
177
|
+
headers: Readonly<Record<string, string>> = {},
|
|
178
|
+
): Response {
|
|
179
|
+
const nonce = randomBytes(16).toString('base64');
|
|
180
|
+
return new Response(renderSetupPage(view, nonce), {
|
|
181
|
+
status,
|
|
182
|
+
headers: {
|
|
183
|
+
'content-type': 'text/html; charset=utf-8',
|
|
184
|
+
'x-flowdular-setup': 'first-run',
|
|
185
|
+
'cache-control': 'no-store',
|
|
186
|
+
'x-content-type-options': 'nosniff',
|
|
187
|
+
'referrer-policy': 'no-referrer',
|
|
188
|
+
'x-frame-options': 'DENY',
|
|
189
|
+
'content-security-policy': [
|
|
190
|
+
"default-src 'none'",
|
|
191
|
+
`style-src 'nonce-${nonce}'`,
|
|
192
|
+
`script-src 'nonce-${nonce}'`,
|
|
193
|
+
"img-src 'self' data:",
|
|
194
|
+
"connect-src 'self'",
|
|
195
|
+
"form-action 'self'",
|
|
196
|
+
"base-uri 'none'",
|
|
197
|
+
"frame-ancestors 'none'",
|
|
198
|
+
].join('; '),
|
|
199
|
+
...headers,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function redirect(
|
|
205
|
+
location: string,
|
|
206
|
+
headers: Record<string, string> = {},
|
|
207
|
+
): Response {
|
|
208
|
+
return new Response(null, {
|
|
209
|
+
status: 303,
|
|
210
|
+
headers: { location, 'cache-control': 'no-store', ...headers },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readForm(request: Request): Promise<FormData | null> {
|
|
215
|
+
const declared = Number(request.headers.get('content-length') ?? '0');
|
|
216
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null;
|
|
217
|
+
try {
|
|
218
|
+
if (!request.body) return null;
|
|
219
|
+
const reader = request.body.getReader();
|
|
220
|
+
const chunks: Uint8Array[] = [];
|
|
221
|
+
let length = 0;
|
|
222
|
+
for (;;) {
|
|
223
|
+
const { done, value } = await reader.read();
|
|
224
|
+
if (done) break;
|
|
225
|
+
length += value.byteLength;
|
|
226
|
+
if (length > MAX_BODY_BYTES) {
|
|
227
|
+
await reader.cancel();
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
chunks.push(value);
|
|
231
|
+
}
|
|
232
|
+
const contentType = request.headers.get('content-type');
|
|
233
|
+
if (!contentType) return null;
|
|
234
|
+
return await new Response(Buffer.concat(chunks), {
|
|
235
|
+
headers: { 'content-type': contentType },
|
|
236
|
+
}).formData();
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formString(form: FormData, key: string): string {
|
|
243
|
+
const value = form.get(key);
|
|
244
|
+
return typeof value === 'string' ? value : '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* Form values arrive as `field:<adapterId>:<key>` so every adapter's inputs
|
|
248
|
+
can be present at once and a submission still names exactly one adapter. */
|
|
249
|
+
function connectionInput(
|
|
250
|
+
form: FormData,
|
|
251
|
+
adapterId: string,
|
|
252
|
+
adapters: SetupAdapters,
|
|
253
|
+
): {
|
|
254
|
+
readonly input: DatabaseAdapterConnectionInput;
|
|
255
|
+
readonly values: Record<string, string>;
|
|
256
|
+
} {
|
|
257
|
+
const adapter = adapters.get(adapterId);
|
|
258
|
+
const config: Record<string, string> = {};
|
|
259
|
+
const secrets: Record<string, string> = {};
|
|
260
|
+
const values: Record<string, string> = {};
|
|
261
|
+
for (const definition of adapter?.descriptor.configurationSchema.fields ??
|
|
262
|
+
[]) {
|
|
263
|
+
const name = `${FIELD_PREFIX}${adapterId}:${definition.key}`;
|
|
264
|
+
const value = formString(form, name).trim();
|
|
265
|
+
if (definition.secret) secrets[definition.key] = value;
|
|
266
|
+
else {
|
|
267
|
+
config[definition.key] = value;
|
|
268
|
+
values[name] = value;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
input: {
|
|
273
|
+
config: config as DatabaseSafeConfiguration,
|
|
274
|
+
secrets,
|
|
275
|
+
},
|
|
276
|
+
values,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function compatibility(
|
|
281
|
+
adapters: SetupAdapters,
|
|
282
|
+
adapterId: string,
|
|
283
|
+
modules: readonly ModuleDatabaseRequirements[],
|
|
284
|
+
): readonly ModuleCompatibility[] {
|
|
285
|
+
const issues = validateDatabaseSelection(
|
|
286
|
+
adapters.registry,
|
|
287
|
+
{ version: 1, defaultAdapterId: adapterId },
|
|
288
|
+
modules,
|
|
289
|
+
);
|
|
290
|
+
return modules.map((module) => ({
|
|
291
|
+
moduleId: module.moduleId,
|
|
292
|
+
tenantOwned: module.tenantOwned,
|
|
293
|
+
issues: issues
|
|
294
|
+
.filter((issue) => issue.moduleId === module.moduleId)
|
|
295
|
+
.map((issue) => issue.message),
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Serves GET and POST /setup. */
|
|
300
|
+
export function createSetupHandler(
|
|
301
|
+
options: SetupRoutesOptions,
|
|
302
|
+
): (context: Context) => Promise<Response> {
|
|
303
|
+
const base = emptyView(options);
|
|
304
|
+
let restartScheduled = false;
|
|
305
|
+
let applying = false;
|
|
306
|
+
const preconfiguredState = (): DatabaseState => ({
|
|
307
|
+
kind: 'database',
|
|
308
|
+
adapterId: POSTGRESQL_ADAPTER_ID,
|
|
309
|
+
input: null,
|
|
310
|
+
values: base.values,
|
|
311
|
+
probe: null,
|
|
312
|
+
modules: compatibility(
|
|
313
|
+
options.adapters,
|
|
314
|
+
POSTGRESQL_ADAPTER_ID,
|
|
315
|
+
options.modules,
|
|
316
|
+
),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const unlockView = (
|
|
320
|
+
error: string | null,
|
|
321
|
+
status: number,
|
|
322
|
+
headers: Record<string, string> = {},
|
|
323
|
+
): Response => page({ ...base, error }, status, headers);
|
|
324
|
+
|
|
325
|
+
const configureView = (
|
|
326
|
+
session: SetupSession,
|
|
327
|
+
overrides: Partial<SetupPageView> = {},
|
|
328
|
+
): Response =>
|
|
329
|
+
page({
|
|
330
|
+
...base,
|
|
331
|
+
step: 'Database',
|
|
332
|
+
csrfToken: session.csrfToken,
|
|
333
|
+
...overrides,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const workspaceView = (
|
|
337
|
+
session: SetupSession,
|
|
338
|
+
state: ConnectionState,
|
|
339
|
+
overrides: Partial<SetupPageView> = {},
|
|
340
|
+
): Response =>
|
|
341
|
+
page({
|
|
342
|
+
...base,
|
|
343
|
+
step: 'Workspace',
|
|
344
|
+
csrfToken: session.csrfToken,
|
|
345
|
+
selectedAdapterId: state.adapterId,
|
|
346
|
+
values: state.values,
|
|
347
|
+
probe: state.probe,
|
|
348
|
+
modules: state.modules,
|
|
349
|
+
...overrides,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const reviewView = (
|
|
353
|
+
session: SetupSession,
|
|
354
|
+
state: ReviewState,
|
|
355
|
+
error: string | null = null,
|
|
356
|
+
): Response =>
|
|
357
|
+
page({
|
|
358
|
+
...base,
|
|
359
|
+
step: 'Review',
|
|
360
|
+
csrfToken: session.csrfToken,
|
|
361
|
+
selectedAdapterId: state.adapterId,
|
|
362
|
+
values: state.values,
|
|
363
|
+
probe: state.probe,
|
|
364
|
+
modules: state.modules,
|
|
365
|
+
error,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const doneView = (session: SetupSession, state: DoneState): Response =>
|
|
369
|
+
page({
|
|
370
|
+
...base,
|
|
371
|
+
step: 'Sign in',
|
|
372
|
+
autoRestart: canAutoRestart(options, state),
|
|
373
|
+
csrfToken: session.csrfToken,
|
|
374
|
+
seed: state.seed,
|
|
375
|
+
values: { applicationPath: state.applicationPath },
|
|
376
|
+
environment: state.environment,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const render = (context: Context): Response => {
|
|
380
|
+
const session = options.access.resume(
|
|
381
|
+
readSetupSessionCookie(context.request.headers.get('cookie')),
|
|
382
|
+
);
|
|
383
|
+
if (!session) return unlockView(null, 200);
|
|
384
|
+
const state = flowState(session);
|
|
385
|
+
if (state?.kind === 'done') return doneView(session, state);
|
|
386
|
+
if (state?.kind === 'review') return reviewView(session, state);
|
|
387
|
+
if (state?.kind === 'database') return workspaceView(session, state);
|
|
388
|
+
if (options.databasePreconfigured)
|
|
389
|
+
return workspaceView(session, preconfiguredState());
|
|
390
|
+
return configureView(session);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const unlock = async (
|
|
394
|
+
context: Context,
|
|
395
|
+
form: FormData,
|
|
396
|
+
): Promise<Response> => {
|
|
397
|
+
const result = options.access.open(formString(form, 'token').trim());
|
|
398
|
+
if (result.verdict === 'locked') {
|
|
399
|
+
return unlockView(
|
|
400
|
+
`Too many incorrect tokens. Setup is locked for a few minutes; ${options.inPlace ? 'running the deploy command again' : 'restarting this deployment'} issues a new token.`,
|
|
401
|
+
429,
|
|
402
|
+
{ 'retry-after': String(Math.ceil(result.retryAfterMs / 1000)) },
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (result.verdict === 'denied' || !result.session) {
|
|
406
|
+
return unlockView('That is not the setup token for this run.', 401);
|
|
407
|
+
}
|
|
408
|
+
/* A forwarded protocol header can only add the Secure flag, never remove
|
|
409
|
+
it, so trusting one here cannot widen where the cookie travels. */
|
|
410
|
+
const secure =
|
|
411
|
+
options.secureCookies ||
|
|
412
|
+
new URL(context.request.url).protocol === 'https:' ||
|
|
413
|
+
context.request.headers.get('x-forwarded-proto') === 'https';
|
|
414
|
+
return redirect('/setup', {
|
|
415
|
+
'set-cookie': setupSessionCookie(result.session.id, secure),
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const configure = async (
|
|
420
|
+
session: SetupSession,
|
|
421
|
+
form: FormData,
|
|
422
|
+
): Promise<Response> => {
|
|
423
|
+
if (options.databasePreconfigured)
|
|
424
|
+
return workspaceView(session, preconfiguredState());
|
|
425
|
+
const adapterId = formString(form, 'adapter').trim();
|
|
426
|
+
const adapter = options.adapters.get(adapterId);
|
|
427
|
+
if (!adapter) {
|
|
428
|
+
return configureView(session, {
|
|
429
|
+
error: 'Choose one of the databases listed above.',
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const { input, values } = connectionInput(
|
|
433
|
+
form,
|
|
434
|
+
adapterId,
|
|
435
|
+
options.adapters,
|
|
436
|
+
);
|
|
437
|
+
const fieldErrors: Record<string, string> = {};
|
|
438
|
+
for (const issue of adapter.descriptor.validate(input)) {
|
|
439
|
+
fieldErrors[issue.field] ??= issue.message;
|
|
440
|
+
}
|
|
441
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
442
|
+
return configureView(session, {
|
|
443
|
+
selectedAdapterId: adapterId,
|
|
444
|
+
values,
|
|
445
|
+
fieldErrors,
|
|
446
|
+
error:
|
|
447
|
+
'Some values need attention before the connection can be tested.',
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const probe = await adapter.descriptor.probe(input);
|
|
451
|
+
if (probe.status !== 'ready') {
|
|
452
|
+
return configureView(session, {
|
|
453
|
+
selectedAdapterId: adapterId,
|
|
454
|
+
values,
|
|
455
|
+
error: probe.message ?? 'The connection could not be tested.',
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const modules = compatibility(options.adapters, adapterId, options.modules);
|
|
459
|
+
const state: DatabaseState = {
|
|
460
|
+
kind: 'database',
|
|
461
|
+
adapterId,
|
|
462
|
+
input,
|
|
463
|
+
values: { ...values, applicationPath: base.values.applicationPath! },
|
|
464
|
+
probe,
|
|
465
|
+
modules,
|
|
466
|
+
};
|
|
467
|
+
session.pending = state;
|
|
468
|
+
return workspaceView(session, state);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const workspace = (session: SetupSession, form: FormData): Response => {
|
|
472
|
+
const pending = flowState(session);
|
|
473
|
+
const database =
|
|
474
|
+
pending?.kind === 'database' || pending?.kind === 'review'
|
|
475
|
+
? pending
|
|
476
|
+
: options.databasePreconfigured
|
|
477
|
+
? preconfiguredState()
|
|
478
|
+
: null;
|
|
479
|
+
if (!database) return configureView(session);
|
|
480
|
+
const values: Record<string, string> = {
|
|
481
|
+
...database.values,
|
|
482
|
+
workspaceName: formString(form, 'workspaceName').trim(),
|
|
483
|
+
workspaceSlug: formString(form, 'workspaceSlug').trim(),
|
|
484
|
+
ownerName: formString(form, 'ownerName').trim(),
|
|
485
|
+
ownerEmail: formString(form, 'ownerEmail').trim(),
|
|
486
|
+
applicationPath:
|
|
487
|
+
formString(form, 'applicationPath').trim() ||
|
|
488
|
+
base.values.applicationPath!,
|
|
489
|
+
};
|
|
490
|
+
const password = formString(form, 'ownerPassword');
|
|
491
|
+
const passwordConfirm = formString(form, 'ownerPasswordConfirm');
|
|
492
|
+
const fieldErrors: Record<string, string> = {};
|
|
493
|
+
const validate = (key: string, action: () => string): string => {
|
|
494
|
+
try {
|
|
495
|
+
return action();
|
|
496
|
+
} catch (error) {
|
|
497
|
+
fieldErrors[key] =
|
|
498
|
+
error instanceof AuthServiceError
|
|
499
|
+
? error.message
|
|
500
|
+
: 'This value is invalid.';
|
|
501
|
+
return values[key] ?? '';
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
values.workspaceName = validate('workspaceName', () =>
|
|
505
|
+
validateWorkspaceName(values.workspaceName!),
|
|
506
|
+
);
|
|
507
|
+
values.workspaceSlug = validate('workspaceSlug', () =>
|
|
508
|
+
validateWorkspaceSlug(values.workspaceSlug!),
|
|
509
|
+
);
|
|
510
|
+
values.ownerName = validate('ownerName', () =>
|
|
511
|
+
validateDisplayName(values.ownerName!),
|
|
512
|
+
);
|
|
513
|
+
values.ownerEmail = validate('ownerEmail', () =>
|
|
514
|
+
validateEmailAddress(values.ownerEmail!),
|
|
515
|
+
);
|
|
516
|
+
try {
|
|
517
|
+
assertPasswordPolicy(password, base.passwordMinLength, values.ownerEmail);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
fieldErrors.ownerPassword =
|
|
520
|
+
error instanceof AuthServiceError
|
|
521
|
+
? error.message
|
|
522
|
+
: 'Choose a valid owner password.';
|
|
523
|
+
}
|
|
524
|
+
if (password !== passwordConfirm) {
|
|
525
|
+
fieldErrors.ownerPasswordConfirm = 'Passwords do not match.';
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
validateApplicationPath(values.applicationPath!);
|
|
529
|
+
if (
|
|
530
|
+
options.webMountPaths?.some(
|
|
531
|
+
(path) =>
|
|
532
|
+
path === values.applicationPath ||
|
|
533
|
+
path.startsWith(values.applicationPath + '/'),
|
|
534
|
+
)
|
|
535
|
+
) {
|
|
536
|
+
fieldErrors.applicationPath =
|
|
537
|
+
'This address overlaps a configured public module. Choose another backoffice address.';
|
|
538
|
+
}
|
|
539
|
+
if (
|
|
540
|
+
(options.databasePreconfigured ||
|
|
541
|
+
options.environment.FD_APPLICATION_PATH) &&
|
|
542
|
+
values.applicationPath !== base.values.applicationPath
|
|
543
|
+
) {
|
|
544
|
+
fieldErrors.applicationPath =
|
|
545
|
+
'This address is controlled by the deployment. Change FD_APPLICATION_PATH in its environment.';
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
fieldErrors.applicationPath =
|
|
549
|
+
'Use one path such as /app or /backoffice (up to 64 characters, lowercase letters, digits and hyphens). System addresses are reserved.';
|
|
550
|
+
}
|
|
551
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
552
|
+
return workspaceView(session, database, {
|
|
553
|
+
values,
|
|
554
|
+
fieldErrors,
|
|
555
|
+
error: 'Some values need attention before you can continue.',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
const state: ReviewState = {
|
|
559
|
+
...database,
|
|
560
|
+
kind: 'review',
|
|
561
|
+
values,
|
|
562
|
+
owner: {
|
|
563
|
+
workspaceName: values.workspaceName!,
|
|
564
|
+
workspaceSlug: values.workspaceSlug!,
|
|
565
|
+
ownerEmail: values.ownerEmail!,
|
|
566
|
+
ownerName: values.ownerName!,
|
|
567
|
+
ownerPassword: password,
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
session.pending = state;
|
|
571
|
+
return reviewView(session, state);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const apply = async (
|
|
575
|
+
session: SetupSession,
|
|
576
|
+
state: ReviewState,
|
|
577
|
+
): Promise<Response> => {
|
|
578
|
+
const adapter = options.adapters.get(state.adapterId);
|
|
579
|
+
if (!options.databasePreconfigured && (!adapter || !state.input)) {
|
|
580
|
+
return reviewView(session, state, 'That database is no longer offered.');
|
|
581
|
+
}
|
|
582
|
+
if (!options.databasePreconfigured && state.probe?.status !== 'ready') {
|
|
583
|
+
return reviewView(
|
|
584
|
+
session,
|
|
585
|
+
state,
|
|
586
|
+
'The connection test did not succeed, so nothing was applied.',
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
if (state.modules.some((module) => module.issues.length > 0)) {
|
|
590
|
+
return reviewView(
|
|
591
|
+
session,
|
|
592
|
+
state,
|
|
593
|
+
'One or more enabled modules cannot run on this database, so nothing was applied.',
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (applying) {
|
|
597
|
+
return reviewView(
|
|
598
|
+
session,
|
|
599
|
+
state,
|
|
600
|
+
'Setup is already being applied. Wait a moment, then reload this page.',
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const secrets = [
|
|
604
|
+
...(state.input ? setupSecretValues(state.input) : []),
|
|
605
|
+
state.owner.ownerPassword,
|
|
606
|
+
];
|
|
607
|
+
let seed: FirstRunSeed;
|
|
608
|
+
applying = true;
|
|
609
|
+
try {
|
|
610
|
+
if (state.input) {
|
|
611
|
+
await adapter!.descriptor.provision(state.input, {
|
|
612
|
+
intent: 'confirmed-first-run',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
const provider = state.input
|
|
616
|
+
? adapter!.openProvider(state.input)
|
|
617
|
+
: createPlatformDatabaseProvider(
|
|
618
|
+
databaseProviderConfigFromEnvironment(
|
|
619
|
+
options.environment,
|
|
620
|
+
options.workspaceRoot,
|
|
621
|
+
),
|
|
622
|
+
);
|
|
623
|
+
try {
|
|
624
|
+
await provider.check();
|
|
625
|
+
const provision = () =>
|
|
626
|
+
seedFirstRun(
|
|
627
|
+
provider,
|
|
628
|
+
options.environment,
|
|
629
|
+
options.workspaceRoot,
|
|
630
|
+
state.owner,
|
|
631
|
+
);
|
|
632
|
+
/* An embedded database is one connection inside this process, which
|
|
633
|
+
the in-process guard above already serializes. */
|
|
634
|
+
seed =
|
|
635
|
+
provider.adapter === 'postgresql'
|
|
636
|
+
? await claimFirstRun(provider, provision)
|
|
637
|
+
: await provision();
|
|
638
|
+
} finally {
|
|
639
|
+
await provider.dispose();
|
|
640
|
+
}
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (
|
|
643
|
+
error instanceof SetupSeedError ||
|
|
644
|
+
error instanceof AuthServiceError
|
|
645
|
+
) {
|
|
646
|
+
return reviewView(session, state, error.message);
|
|
647
|
+
}
|
|
648
|
+
return reviewView(
|
|
649
|
+
session,
|
|
650
|
+
state,
|
|
651
|
+
classifySetupFailure(error, secrets).message,
|
|
652
|
+
);
|
|
653
|
+
} finally {
|
|
654
|
+
applying = false;
|
|
655
|
+
}
|
|
656
|
+
options.inPlace?.onClaimed();
|
|
657
|
+
/* Configuration is stored last: an earlier failure leaves this deployment
|
|
658
|
+
exactly as it was, still unconfigured, still on this screen. */
|
|
659
|
+
const done: DoneState = {
|
|
660
|
+
kind: 'done',
|
|
661
|
+
applicationPath: state.values.applicationPath!,
|
|
662
|
+
seed,
|
|
663
|
+
environment: state.input
|
|
664
|
+
? writeEnvironmentFile(options.workspaceRoot, {
|
|
665
|
+
...adapter!.environment(state.input),
|
|
666
|
+
FD_APPLICATION_PATH: state.values.applicationPath!,
|
|
667
|
+
})
|
|
668
|
+
: null,
|
|
669
|
+
};
|
|
670
|
+
session.pending = done;
|
|
671
|
+
return doneView(session, done);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const submit = async (context: Context): Promise<Response> => {
|
|
675
|
+
const form = await readForm(context.request);
|
|
676
|
+
if (!form) return unlockView('That request could not be read.', 400);
|
|
677
|
+
const step = formString(form, 'step');
|
|
678
|
+
if (step === 'unlock') return unlock(context, form);
|
|
679
|
+
const session = options.access.resume(
|
|
680
|
+
readSetupSessionCookie(context.request.headers.get('cookie')),
|
|
681
|
+
);
|
|
682
|
+
if (!session) {
|
|
683
|
+
return unlockView('The setup session expired. Unlock setup again.', 401);
|
|
684
|
+
}
|
|
685
|
+
if (
|
|
686
|
+
!options.access.verifyCsrf(session, formString(form, SETUP_CSRF_FIELD))
|
|
687
|
+
) {
|
|
688
|
+
/* The form did not come from this setup session. Dropping the session
|
|
689
|
+
makes the next step start from the token again. */
|
|
690
|
+
options.access.close();
|
|
691
|
+
return unlockView('That form was not accepted. Unlock setup again.', 403);
|
|
692
|
+
}
|
|
693
|
+
const state = flowState(session);
|
|
694
|
+
if (state?.kind === 'done') {
|
|
695
|
+
if (step === 'restart' && canAutoRestart(options, state)) {
|
|
696
|
+
if (!restartScheduled) {
|
|
697
|
+
restartScheduled = true;
|
|
698
|
+
const exitCode =
|
|
699
|
+
options.environment.FD_SETUP_RESTART_EXIT_CODE === '75' ? 75 : 0;
|
|
700
|
+
if (options.restartApplication) options.restartApplication(exitCode);
|
|
701
|
+
else setTimeout(() => process.exit(exitCode), 250).unref();
|
|
702
|
+
}
|
|
703
|
+
return new Response(null, {
|
|
704
|
+
status: 204,
|
|
705
|
+
headers: { 'cache-control': 'no-store' },
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return doneView(session, state);
|
|
709
|
+
}
|
|
710
|
+
if (step === 'back') {
|
|
711
|
+
if (state?.kind === 'review') {
|
|
712
|
+
const database: DatabaseState = {
|
|
713
|
+
kind: 'database',
|
|
714
|
+
adapterId: state.adapterId,
|
|
715
|
+
input: state.input,
|
|
716
|
+
values: state.values,
|
|
717
|
+
probe: state.probe,
|
|
718
|
+
modules: state.modules,
|
|
719
|
+
};
|
|
720
|
+
session.pending = database;
|
|
721
|
+
return workspaceView(session, database);
|
|
722
|
+
}
|
|
723
|
+
if (state?.kind === 'database' && !options.databasePreconfigured) {
|
|
724
|
+
session.pending = null;
|
|
725
|
+
return configureView(session, {
|
|
726
|
+
values: state.values,
|
|
727
|
+
selectedAdapterId: state.adapterId,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return render(context);
|
|
731
|
+
}
|
|
732
|
+
if (step === 'configure') return configure(session, form);
|
|
733
|
+
if (step === 'workspace') return workspace(session, form);
|
|
734
|
+
if (step === 'apply' && state?.kind === 'review') {
|
|
735
|
+
return apply(session, state);
|
|
736
|
+
}
|
|
737
|
+
return render(context);
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
return async (context) =>
|
|
741
|
+
context.request.method === 'POST' ? submit(context) : render(context);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function createSetupRoutes(
|
|
745
|
+
options: SetupRoutesOptions,
|
|
746
|
+
): readonly ServerRoute[] {
|
|
747
|
+
/* While installation has no workspace, only setup and process health are
|
|
748
|
+
served. A configured database does not expose the application until its
|
|
749
|
+
first owner has completed the wizard. */
|
|
750
|
+
return [
|
|
751
|
+
new ServerRoute({
|
|
752
|
+
path: '/setup',
|
|
753
|
+
methods: ['GET', 'POST'],
|
|
754
|
+
handler: createSetupHandler(options),
|
|
755
|
+
}),
|
|
756
|
+
new ServerRoute({
|
|
757
|
+
path: '/',
|
|
758
|
+
methods: ['GET'],
|
|
759
|
+
handler: () => redirect('/setup'),
|
|
760
|
+
}),
|
|
761
|
+
new ServerRoute({
|
|
762
|
+
path: '/api/*path',
|
|
763
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
764
|
+
handler: () =>
|
|
765
|
+
new Response(
|
|
766
|
+
JSON.stringify({
|
|
767
|
+
error: {
|
|
768
|
+
code: 'PLATFORM_NOT_CONFIGURED',
|
|
769
|
+
message:
|
|
770
|
+
'Complete the first-run setup at /setup before using the platform.',
|
|
771
|
+
},
|
|
772
|
+
}),
|
|
773
|
+
{
|
|
774
|
+
status: 503,
|
|
775
|
+
headers: {
|
|
776
|
+
'content-type': 'application/json; charset=utf-8',
|
|
777
|
+
'cache-control': 'no-store',
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
),
|
|
781
|
+
}),
|
|
782
|
+
new ServerRoute({
|
|
783
|
+
path: '/*path',
|
|
784
|
+
methods: ['GET'],
|
|
785
|
+
handler: () => redirect('/setup'),
|
|
786
|
+
}),
|
|
787
|
+
];
|
|
788
|
+
}
|