create-flowdular 0.6.0 → 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/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
- package/agent-template/.ai/platform-capabilities.md +4 -4
- 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/.claude/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/docs/adr/0003-module-settings.md +2 -0
- package/agent-template/docs/agent-contract.md +1 -1
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +16 -0
- package/agent-template/docs/configuration.md +32 -4
- package/agent-template/docs/database-adapters.md +10 -2
- package/agent-template/docs/design-system.md +6 -2
- package/agent-template/docs/getting-started.md +5 -1
- package/agent-template/docs/modules.md +3 -1
- package/agent-template/docs/sandbox.md +23 -4
- package/package.json +1 -1
- package/template/default/.env.example +5 -0
- package/template/default/infra/docker/.env.example +5 -0
- package/template/default/infra/docker/Dockerfile +5 -1
- package/template/default/infra/docker/compose.yaml +4 -0
- package/template/default/infra/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/build.mjs +9 -0
- package/template/default/modules/example/package.json +1 -1
- package/template/default/package.json +2 -3
- package/template/default/platform/octane.config.ts +53 -15
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/scripts/dev.mjs +66 -21
- package/template/default/platform/src/server/lifecycle.ts +325 -0
- package/template/default/platform/src/server/setup/modules.ts +28 -32
- package/template/default/platform/src/server/setup/page.ts +54 -3
- package/template/default/platform/src/server/setup/routes.ts +1 -0
- package/template/default/platform/src/server/setup/seed.ts +51 -4
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import type { EventEmitter } from 'node:events';
|
|
4
|
+
import { BroadcastChannel } from 'node:worker_threads';
|
|
5
|
+
import type { Middleware } from '@octanejs/app-core';
|
|
6
|
+
import { serverLogger, trackResponseBody } from '@flowdular/sdk/server';
|
|
7
|
+
|
|
8
|
+
export const PLATFORM_LIFECYCLE_SYMBOL = Symbol.for(
|
|
9
|
+
'flowdular.platform.runtime-lifecycle',
|
|
10
|
+
);
|
|
11
|
+
export const PLATFORM_LIFECYCLE_ACTIVATE_EVENT =
|
|
12
|
+
'flowdular:platform-runtime-activate';
|
|
13
|
+
export const PLATFORM_LIFECYCLE_RETIRE_EVENT =
|
|
14
|
+
'flowdular:platform-runtime-retire';
|
|
15
|
+
const PLATFORM_LIFECYCLE_CHANNEL = 'flowdular.platform.runtime-lifecycle';
|
|
16
|
+
|
|
17
|
+
type Dispose = () => void | Promise<void>;
|
|
18
|
+
|
|
19
|
+
export interface PlatformRuntimeLifecycle {
|
|
20
|
+
readonly middleware: Middleware;
|
|
21
|
+
/** Runs as soon as retirement begins, before the requests in flight drain:
|
|
22
|
+
for a producer that holds a request open until it is told to stop. */
|
|
23
|
+
addInterrupt(interrupt: Dispose): void;
|
|
24
|
+
addQuiesce(quiesce: Dispose): void;
|
|
25
|
+
add(dispose: Dispose): void;
|
|
26
|
+
retire(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ProcessWithLifecycle = NodeJS.Process & {
|
|
30
|
+
[PLATFORM_LIFECYCLE_SYMBOL]?: PlatformRuntimeLifecycle;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/* An event stream never finishes on its own. Its protocol has the client
|
|
34
|
+
reconnect and resume from Last-Event-ID, which reaches the next generation,
|
|
35
|
+
so retirement ends it instead of waiting for it. Any other body drains. */
|
|
36
|
+
function isEventStream(response: Response): boolean {
|
|
37
|
+
const type = response.headers.get('content-type')?.split(';', 1)[0];
|
|
38
|
+
return type?.trim().toLowerCase() === 'text/event-stream';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* EventSource stops reconnecting for good on any answer but a 200 event
|
|
42
|
+
stream, and a production server keeps listening while it retires. A stream
|
|
43
|
+
request to a retired generation gets an empty stream that ends at once, so
|
|
44
|
+
the client keeps reconnecting until the next process answers. */
|
|
45
|
+
function retiredResponse(request: Request | undefined): Response {
|
|
46
|
+
if (
|
|
47
|
+
request?.method === 'GET' &&
|
|
48
|
+
request.headers.get('accept')?.includes('text/event-stream')
|
|
49
|
+
) {
|
|
50
|
+
return new Response('retry: 1000\n\n', {
|
|
51
|
+
headers: {
|
|
52
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return new Response(null, {
|
|
58
|
+
status: 503,
|
|
59
|
+
headers: { 'retry-after': '1' },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Vite evaluates octane.config.ts again when one of its SSR dependencies is
|
|
64
|
+
invalidated. A generation owns every resource created by that evaluation.
|
|
65
|
+
Retirement interrupts what would hold a request open, waits for requests
|
|
66
|
+
already using the old route closures, then disposes its resources in
|
|
67
|
+
reverse composition order. */
|
|
68
|
+
export function createPlatformRuntimeLifecycle(): PlatformRuntimeLifecycle {
|
|
69
|
+
const disposers: Dispose[] = [];
|
|
70
|
+
const quiescers: Dispose[] = [];
|
|
71
|
+
const interrupts = new Set<Dispose>();
|
|
72
|
+
let interrupted: Promise<unknown[]> = Promise.resolve([]);
|
|
73
|
+
let activeRequests = 0;
|
|
74
|
+
let retired = false;
|
|
75
|
+
let finishing = false;
|
|
76
|
+
let disposal: Promise<void> | undefined;
|
|
77
|
+
let resolveDisposal: (() => void) | undefined;
|
|
78
|
+
let rejectDisposal: ((error: unknown) => void) | undefined;
|
|
79
|
+
|
|
80
|
+
const finish = () => {
|
|
81
|
+
if (!retired || activeRequests !== 0 || disposal === undefined || finishing)
|
|
82
|
+
return;
|
|
83
|
+
finishing = true;
|
|
84
|
+
const currentQuiescers = quiescers.splice(0).reverse();
|
|
85
|
+
const current = disposers.splice(0).reverse();
|
|
86
|
+
void (async () => {
|
|
87
|
+
const failures = await interrupted;
|
|
88
|
+
/* Every background producer stops before any module repository closes. */
|
|
89
|
+
for (const quiesce of currentQuiescers) {
|
|
90
|
+
try {
|
|
91
|
+
await quiesce();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
failures.push(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const dispose of current) {
|
|
97
|
+
try {
|
|
98
|
+
await dispose();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failures.push(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (failures.length > 0) {
|
|
104
|
+
/* A logger reads the message, not the errors array, so each cause
|
|
105
|
+
is named there rather than left for a debugger. */
|
|
106
|
+
rejectDisposal?.(
|
|
107
|
+
new AggregateError(
|
|
108
|
+
failures,
|
|
109
|
+
'Platform runtime teardown did not release every resource: ' +
|
|
110
|
+
failures
|
|
111
|
+
.map((failure) =>
|
|
112
|
+
failure instanceof Error ? failure.message : String(failure),
|
|
113
|
+
)
|
|
114
|
+
.join('; '),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
resolveDisposal?.();
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const interrupt = async (): Promise<unknown[]> => {
|
|
124
|
+
const failures: unknown[] = [];
|
|
125
|
+
await Promise.all(
|
|
126
|
+
[...interrupts].map(async (run) => {
|
|
127
|
+
try {
|
|
128
|
+
await run();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
failures.push(error);
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
return failures;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const lifecycle: PlatformRuntimeLifecycle = {
|
|
138
|
+
middleware: async (context, next) => {
|
|
139
|
+
if (retired) return retiredResponse(context.request);
|
|
140
|
+
activeRequests += 1;
|
|
141
|
+
let endStream: (() => void) | undefined;
|
|
142
|
+
const release = () => {
|
|
143
|
+
if (endStream) interrupts.delete(endStream);
|
|
144
|
+
activeRequests -= 1;
|
|
145
|
+
finish();
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const response = await next();
|
|
149
|
+
const signal = context.request?.signal;
|
|
150
|
+
if (!isEventStream(response)) {
|
|
151
|
+
return trackResponseBody(response, release, signal);
|
|
152
|
+
}
|
|
153
|
+
const end = new AbortController();
|
|
154
|
+
endStream = () => end.abort(new Error('The platform runtime retired.'));
|
|
155
|
+
if (retired) endStream();
|
|
156
|
+
else interrupts.add(endStream);
|
|
157
|
+
return trackResponseBody(
|
|
158
|
+
response,
|
|
159
|
+
release,
|
|
160
|
+
signal ? AbortSignal.any([signal, end.signal]) : end.signal,
|
|
161
|
+
);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
release();
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
addInterrupt(run) {
|
|
168
|
+
if (retired) {
|
|
169
|
+
void Promise.resolve()
|
|
170
|
+
.then(run)
|
|
171
|
+
.catch((error: unknown) => {
|
|
172
|
+
serverLogger().error('late platform interrupt failed', {
|
|
173
|
+
module: 'platform',
|
|
174
|
+
err: error,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
interrupts.add(run);
|
|
180
|
+
},
|
|
181
|
+
addQuiesce(quiesce) {
|
|
182
|
+
if (retired) {
|
|
183
|
+
void Promise.resolve()
|
|
184
|
+
.then(quiesce)
|
|
185
|
+
.catch((error: unknown) => {
|
|
186
|
+
serverLogger().error('late platform quiesce failed', {
|
|
187
|
+
module: 'platform',
|
|
188
|
+
err: error,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
quiescers.push(quiesce);
|
|
194
|
+
},
|
|
195
|
+
add(dispose) {
|
|
196
|
+
if (retired) {
|
|
197
|
+
void Promise.resolve()
|
|
198
|
+
.then(dispose)
|
|
199
|
+
.catch((error: unknown) => {
|
|
200
|
+
serverLogger().error('late platform teardown failed', {
|
|
201
|
+
module: 'platform',
|
|
202
|
+
err: error,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
disposers.push(dispose);
|
|
208
|
+
},
|
|
209
|
+
retire() {
|
|
210
|
+
if (!disposal) {
|
|
211
|
+
disposal = new Promise<void>((resolve, reject) => {
|
|
212
|
+
resolveDisposal = resolve;
|
|
213
|
+
rejectDisposal = reject;
|
|
214
|
+
});
|
|
215
|
+
retired = true;
|
|
216
|
+
interrupted = interrupt();
|
|
217
|
+
finish();
|
|
218
|
+
}
|
|
219
|
+
return disposal;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return lifecycle;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* Activation happens only after the new configuration composed successfully.
|
|
226
|
+
A failed HMR evaluation therefore tears down only its partial generation and
|
|
227
|
+
leaves the previous, still-routable generation alive. */
|
|
228
|
+
export function activatePlatformRuntimeLifecycle(
|
|
229
|
+
lifecycle: PlatformRuntimeLifecycle,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const owner = process as ProcessWithLifecycle;
|
|
232
|
+
const events = process as unknown as EventEmitter;
|
|
233
|
+
const generationId = `${process.pid}:${randomUUID()}`;
|
|
234
|
+
const channel = new BroadcastChannel(PLATFORM_LIFECYCLE_CHANNEL);
|
|
235
|
+
channel.unref();
|
|
236
|
+
const previous = owner[PLATFORM_LIFECYCLE_SYMBOL];
|
|
237
|
+
owner[PLATFORM_LIFECYCLE_SYMBOL] = lifecycle;
|
|
238
|
+
/* Octane loads its config once through the Vite config loader and again
|
|
239
|
+
through the SSR module runner. Their process wrappers do not share custom
|
|
240
|
+
properties, but both delegate EventEmitter operations to the real process.
|
|
241
|
+
The event is therefore the cross-runner ownership handoff. */
|
|
242
|
+
const onActivation = (
|
|
243
|
+
next: PlatformRuntimeLifecycle,
|
|
244
|
+
report?: (retirement: Promise<void>) => void,
|
|
245
|
+
) => {
|
|
246
|
+
if (next === lifecycle) return;
|
|
247
|
+
const retirement = lifecycle.retire();
|
|
248
|
+
report?.(retirement);
|
|
249
|
+
void retirement.catch((error: unknown) => {
|
|
250
|
+
serverLogger().error('stale platform teardown failed', {
|
|
251
|
+
module: 'platform',
|
|
252
|
+
err: error,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
const onRetire = (report: (retirement: Promise<void>) => void) => {
|
|
257
|
+
report(lifecycle.retire());
|
|
258
|
+
};
|
|
259
|
+
channel.onmessage = (event) => {
|
|
260
|
+
if (!event.data || typeof event.data !== 'object') return;
|
|
261
|
+
const message = event.data as { type?: unknown; generationId?: unknown };
|
|
262
|
+
if (
|
|
263
|
+
message.type !== 'retire-all' &&
|
|
264
|
+
(message.type !== 'activate' || message.generationId === generationId)
|
|
265
|
+
) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
/* Closing a BroadcastChannel from inside its own callback can wait for the
|
|
269
|
+
callback to return. Start retirement in the next microtask so channel
|
|
270
|
+
teardown cannot deadlock the generation it is releasing. */
|
|
271
|
+
queueMicrotask(() => {
|
|
272
|
+
void lifecycle.retire().catch((error: unknown) => {
|
|
273
|
+
serverLogger().error('cross-runner platform teardown failed', {
|
|
274
|
+
module: 'platform',
|
|
275
|
+
err: error,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
events.on(PLATFORM_LIFECYCLE_ACTIVATE_EVENT, onActivation);
|
|
281
|
+
events.on(PLATFORM_LIFECYCLE_RETIRE_EVENT, onRetire);
|
|
282
|
+
lifecycle.add(() => {
|
|
283
|
+
channel.close();
|
|
284
|
+
events.off(PLATFORM_LIFECYCLE_ACTIVATE_EVENT, onActivation);
|
|
285
|
+
events.off(PLATFORM_LIFECYCLE_RETIRE_EVENT, onRetire);
|
|
286
|
+
if (owner[PLATFORM_LIFECYCLE_SYMBOL] === lifecycle) {
|
|
287
|
+
delete owner[PLATFORM_LIFECYCLE_SYMBOL];
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
const retirements = new Set<Promise<void>>();
|
|
291
|
+
events.emit(
|
|
292
|
+
PLATFORM_LIFECYCLE_ACTIVATE_EVENT,
|
|
293
|
+
lifecycle,
|
|
294
|
+
(retirement: Promise<void>) => retirements.add(retirement),
|
|
295
|
+
);
|
|
296
|
+
channel.postMessage({ type: 'activate', generationId });
|
|
297
|
+
if (previous && previous !== lifecycle) {
|
|
298
|
+
const retirement = previous.retire();
|
|
299
|
+
retirements.add(retirement);
|
|
300
|
+
void retirement.catch((error: unknown) => {
|
|
301
|
+
serverLogger().error('stale platform teardown failed', {
|
|
302
|
+
module: 'platform',
|
|
303
|
+
err: error,
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return Promise.all(retirements).then(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* Preparation may inspect durable state but must not create write handles,
|
|
311
|
+
workers or timers. Only a fully prepared generation may retire the one that
|
|
312
|
+
is currently serving requests. */
|
|
313
|
+
export async function prepareAndActivatePlatformRuntimeLifecycle(
|
|
314
|
+
lifecycle: PlatformRuntimeLifecycle,
|
|
315
|
+
preparations: readonly (() => void | Promise<void>)[],
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
for (const prepare of preparations) await prepare();
|
|
318
|
+
await activatePlatformRuntimeLifecycle(lifecycle);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function activePlatformRuntimeLifecycle():
|
|
322
|
+
| PlatformRuntimeLifecycle
|
|
323
|
+
| undefined {
|
|
324
|
+
return (process as ProcessWithLifecycle)[PLATFORM_LIFECYCLE_SYMBOL];
|
|
325
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
DATABASE_CAPABILITY_IDS,
|
|
5
5
|
DATABASE_DIALECT_IDS,
|
|
6
6
|
type ModuleDatabaseRequirements,
|
|
7
7
|
} from '@flowdular/sdk/database';
|
|
8
|
+
import { findModuleManifests } from '@flowdular/sdk/kernel/module-manifests';
|
|
8
9
|
import projectManifest from '../../../../flowdular.json';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -27,9 +28,10 @@ export const PLATFORM_MODULE_DATABASE_REQUIREMENTS = Object.freeze({
|
|
|
27
28
|
export interface EnabledDatabaseModules {
|
|
28
29
|
readonly modules: readonly ModuleDatabaseRequirements[];
|
|
29
30
|
/**
|
|
30
|
-
* True when
|
|
31
|
-
* is treated as owning tenant-scoped tables. Over-approximating
|
|
32
|
-
* check strict; the review screen says the list came from this
|
|
31
|
+
* True when a module manifest could not be read, so the enabled module it
|
|
32
|
+
* belongs to is treated as owning tenant-scoped tables. Over-approximating
|
|
33
|
+
* keeps the check strict; the review screen says the list came from this
|
|
34
|
+
* fallback.
|
|
33
35
|
*/
|
|
34
36
|
readonly approximated: boolean;
|
|
35
37
|
}
|
|
@@ -75,9 +77,10 @@ function requirements(
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* The enabled modules that own database tables, in `flowdular.json` order.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
80
|
+
* Manifests are found where the CLI finds them, @flowdular/sdk included. A
|
|
81
|
+
* container image ships only `platform/dist`, so the enabled list falls back
|
|
82
|
+
* to the manifest bundled at build time and an enabled module whose manifest
|
|
83
|
+
* is not beside it is assumed to own tenant-scoped tables.
|
|
81
84
|
*/
|
|
82
85
|
export function enabledDatabaseModules(
|
|
83
86
|
workspaceRoot: string,
|
|
@@ -87,37 +90,30 @@ export function enabledDatabaseModules(
|
|
|
87
90
|
(projectManifest as ProjectManifest);
|
|
88
91
|
const enabled = stringList(project.modules?.enabled);
|
|
89
92
|
if (enabled.length === 0) return { modules: [], approximated: false };
|
|
90
|
-
|
|
93
|
+
let paths: readonly string[] = [];
|
|
94
|
+
try {
|
|
95
|
+
paths = findModuleManifests(workspaceRoot, project.modules?.roots);
|
|
96
|
+
} catch {
|
|
97
|
+
/* Unreadable roots leave every module to the strict fallback below. */
|
|
98
|
+
}
|
|
91
99
|
const manifests = new Map<string, ModuleManifest>();
|
|
92
|
-
for (const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
entries = readdirSync(directory);
|
|
97
|
-
} catch {
|
|
98
|
-
continue;
|
|
100
|
+
for (const path of paths) {
|
|
101
|
+
const manifest = readJson<ModuleManifest>(path);
|
|
102
|
+
if (typeof manifest?.id === 'string') {
|
|
103
|
+
manifests.set(manifest.id, manifest);
|
|
99
104
|
}
|
|
100
|
-
for (const entry of entries) {
|
|
101
|
-
const manifest = readJson<ModuleManifest>(
|
|
102
|
-
join(directory, entry, 'module.json'),
|
|
103
|
-
);
|
|
104
|
-
if (typeof manifest?.id === 'string') {
|
|
105
|
-
manifests.set(manifest.id, manifest);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
if (manifests.size === 0) {
|
|
110
|
-
return {
|
|
111
|
-
modules: enabled.map((moduleId) => requirements(moduleId, true)),
|
|
112
|
-
approximated: true,
|
|
113
|
-
};
|
|
114
105
|
}
|
|
115
106
|
const modules: ModuleDatabaseRequirements[] = [];
|
|
107
|
+
let approximated = false;
|
|
116
108
|
for (const moduleId of enabled) {
|
|
117
109
|
const manifest = manifests.get(moduleId);
|
|
118
|
-
if (!manifest)
|
|
110
|
+
if (!manifest) {
|
|
111
|
+
modules.push(requirements(moduleId, true));
|
|
112
|
+
approximated = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
119
115
|
if (!stringList(manifest.capabilities).includes('database')) continue;
|
|
120
116
|
modules.push(requirements(moduleId, manifest.tenancy === 'required'));
|
|
121
117
|
}
|
|
122
|
-
return { modules, approximated
|
|
118
|
+
return { modules, approximated };
|
|
123
119
|
}
|
|
@@ -304,6 +304,42 @@ ${steps(view.step, view.databasePreconfigured)}${alerts(view)}
|
|
|
304
304
|
const WORKSPACE_SLUG_INPUT_PATTERN =
|
|
305
305
|
'(?!.*--)[a-z0-9][a-z0-9\\-]{1,46}[a-z0-9]';
|
|
306
306
|
|
|
307
|
+
/* What the address field keeps of typed or pasted text. Accented letters fold
|
|
308
|
+
to their base letter: NFD splits the accent off and the drop below removes
|
|
309
|
+
it, and the map covers letters that do not decompose. A short value and a
|
|
310
|
+
trailing hyphen pass, because typing goes through both; the pattern and the
|
|
311
|
+
server refuse them on submit. Every step reads left to right, one character
|
|
312
|
+
at a time, so filtering the text up to the end of an edit tells what that
|
|
313
|
+
edit may insert. The page ships this source and tests evaluate it. */
|
|
314
|
+
export const WORKSPACE_SLUG_TYPING_FILTER = String.raw`(function(){var fold={'ł':'l','Ł':'l','ø':'o','Ø':'o','đ':'d','Đ':'d','ð':'d','Ð':'d','þ':'th','Þ':'th','æ':'ae','Æ':'ae','œ':'oe','Œ':'oe','ß':'ss','ẞ':'ss','ı':'i'};
|
|
315
|
+
return function(value){return value.normalize('NFD').replace(/[^\x00-\x7f]/g,function(c){return fold[c]||c;}).toLowerCase().replace(/\s/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-/,'').slice(0,48);};})()`;
|
|
316
|
+
|
|
317
|
+
/* An insertion at the selection that the page may cancel is filtered before
|
|
318
|
+
the browser applies it and replayed as an editing command, so undo, redo,
|
|
319
|
+
the caret and maxlength only ever see filtered text; assigning the value
|
|
320
|
+
instead corrupts Chrome's undo history. A failed replay lets the raw edit
|
|
321
|
+
through for the pattern to refuse. Deletions pass untouched: they add no
|
|
322
|
+
character, and rewriting them would erase separators mid-edit. Composed
|
|
323
|
+
text is filtered once committed, in every engine. */
|
|
324
|
+
const WORKSPACE_SLUG_TYPING = `
|
|
325
|
+
(function(filter){var input=document.getElementById('setup-workspaceSlug');if(!input)return;
|
|
326
|
+
function kept(start,end,text){var value=input.value,head=value.slice(0,start);
|
|
327
|
+
var next=filter(head+text).slice(filter(head).length);
|
|
328
|
+
return next.slice(-1)==='-'&&value.charAt(end)==='-'?next.slice(0,-1):next;}
|
|
329
|
+
function insert(text){return document.execCommand(text?'insertText':'delete',false,text);}
|
|
330
|
+
input.addEventListener('beforeinput',function(event){
|
|
331
|
+
if(!event.cancelable||event.isComposing||!/^insert(Text|FromPaste|FromDrop)$/.test(event.inputType))return;
|
|
332
|
+
var text=event.data!=null?event.data:event.dataTransfer?event.dataTransfer.getData('text/plain'):'';
|
|
333
|
+
var next=kept(input.selectionStart,input.selectionEnd,text);
|
|
334
|
+
if(next!==text&&(!next||insert(next)))event.preventDefault();});
|
|
335
|
+
input.addEventListener('compositionend',function(event){
|
|
336
|
+
var text=event.data||'',end=input.selectionStart,start=end-text.length;
|
|
337
|
+
if(!text||start<0||input.value.slice(start,end)!==text)return;
|
|
338
|
+
var next=kept(start,end,text);
|
|
339
|
+
if(next!==text){input.setSelectionRange(start,end);insert(next);}});
|
|
340
|
+
})(${WORKSPACE_SLUG_TYPING_FILTER});
|
|
341
|
+
`;
|
|
342
|
+
|
|
307
343
|
function workspaceField(
|
|
308
344
|
view: SetupPageView,
|
|
309
345
|
name: string,
|
|
@@ -394,7 +430,7 @@ ${
|
|
|
394
430
|
</ul></div>
|
|
395
431
|
<div><p class="setup-label setup-sublabel">Enabled modules that own tables</p><ul class="setup-list">${modules || '<li><span class="setup-help">No enabled module owns database tables.</span></li>'}</ul>${
|
|
396
432
|
view.modulesApproximated
|
|
397
|
-
? '<p class="setup-note setup-note--gap">This build ships without
|
|
433
|
+
? '<p class="setup-note setup-note--gap">This build ships without some module manifests, so each enabled module without one is checked against the strictest requirements any module can ask for.</p>'
|
|
398
434
|
: ''
|
|
399
435
|
}</div>
|
|
400
436
|
<form class="setup-form" method="post" action="/setup">${csrf(view)}
|
|
@@ -430,6 +466,17 @@ function environmentSection(result: EnvironmentWriteResult): string {
|
|
|
430
466
|
}.</p>`;
|
|
431
467
|
}
|
|
432
468
|
|
|
469
|
+
function ungrantedNotice(modules: readonly string[]): string {
|
|
470
|
+
if (modules.length === 0) return '';
|
|
471
|
+
const commands = modules
|
|
472
|
+
.map(
|
|
473
|
+
(id) =>
|
|
474
|
+
`<br><code>pnpm flowdular auth sync-scopes --module ${escapeHtml(id)} --apply</code>`,
|
|
475
|
+
)
|
|
476
|
+
.join('');
|
|
477
|
+
return `<p class="setup-alert setup-alert--warning" role="alert">The owner was not granted the permissions of ${escapeHtml(modules.join(', '))}. Run ${modules.length === 1 ? 'this command' : 'these commands'} with this deployment's database settings (stop the app first if it uses the embedded database), then sign in:${commands}</p>`;
|
|
478
|
+
}
|
|
479
|
+
|
|
433
480
|
function doneStep(view: SetupPageView): string {
|
|
434
481
|
const seed = view.seed;
|
|
435
482
|
const owner = seed?.accounts[0];
|
|
@@ -445,7 +492,7 @@ function doneStep(view: SetupPageView): string {
|
|
|
445
492
|
: 'Restart this deployment to leave setup and open the sign-in screen.';
|
|
446
493
|
return `<header><span class="setup-kicker">Sign in</span><h2>Flowdular is ready</h2>
|
|
447
494
|
<p class="setup-card__sub">The workspace ${escapeHtml(seed?.workspace.name ?? '')} is ready. ${nextStep}</p></header>
|
|
448
|
-
${steps(view.step, view.databasePreconfigured)}${alerts(view)}
|
|
495
|
+
${steps(view.step, view.databasePreconfigured)}${alerts(view)}${ungrantedNotice(seed?.ungrantedModules ?? [])}
|
|
449
496
|
<div><p class="setup-label setup-sublabel">Sign in as the workspace owner</p><div class="setup-credentials"><div><b>Owner account</b><code>${escapeHtml(owner?.email ?? view.values.ownerEmail ?? '')}</code></div></div>
|
|
450
497
|
<p class="setup-note setup-note--gap">After signing in, you can connect an AI model in Providers. This is optional and can be done later.</p></div>
|
|
451
498
|
${view.environment ? environmentSection(view.environment) : ''}
|
|
@@ -459,6 +506,10 @@ export function renderSetupPage(view: SetupPageView, nonce: string): string {
|
|
|
459
506
|
view.step === 'Sign in' && view.autoRestart && view.csrfToken
|
|
460
507
|
? `<script nonce="${nonce}">fetch('/setup',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({step:'restart',setupCsrf:'${view.csrfToken}'}),keepalive:true}).catch(()=>{});</script>`
|
|
461
508
|
: '';
|
|
509
|
+
const slugScript =
|
|
510
|
+
view.step === 'Workspace'
|
|
511
|
+
? `<script nonce="${nonce}">${WORKSPACE_SLUG_TYPING}</script>`
|
|
512
|
+
: '';
|
|
462
513
|
const body =
|
|
463
514
|
view.step === 'Unlock'
|
|
464
515
|
? unlockStep(view)
|
|
@@ -492,6 +543,6 @@ function sync(){for(var i=0;i<f.length;i++){var s=document.querySelector('fields
|
|
|
492
543
|
if(s)s.hidden=!f[i].checked;}}
|
|
493
544
|
for(var i=0;i<f.length;i++)f[i].addEventListener('change',sync);sync();})();
|
|
494
545
|
</script>
|
|
495
|
-
${restartScript}
|
|
546
|
+
${slugScript}${restartScript}
|
|
496
547
|
</body></html>`;
|
|
497
548
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
authRuntimeOptionsFromEnvironment,
|
|
4
4
|
createAuthRuntime,
|
|
5
5
|
} from '@flowdular/sdk/modules/auth/server';
|
|
6
|
+
import { readModuleCatalog } from '@flowdular/sdk/modules/system/server';
|
|
6
7
|
/* First-run provisioning uses the same audited auth service as the operator
|
|
7
8
|
CLI. The operator chooses the first owner; no demo credentials are shipped. */
|
|
8
9
|
|
|
@@ -26,6 +27,8 @@ export interface FirstRunOwner {
|
|
|
26
27
|
export interface FirstRunSeed {
|
|
27
28
|
readonly workspace: { readonly name: string; readonly slug: string };
|
|
28
29
|
readonly accounts: readonly SeededAccount[];
|
|
30
|
+
/** Enabled modules whose permissions setup could not grant to the owner. */
|
|
31
|
+
readonly ungrantedModules: readonly string[];
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export class SetupSeedError extends Error {
|
|
@@ -38,10 +41,19 @@ export class SetupSeedError extends Error {
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/* A driver message can carry a host or a connection string, so only a code
|
|
45
|
+
from a fixed alphabet is logged, such as a SQLSTATE or an errno name. */
|
|
46
|
+
function stableCode(error: unknown): string {
|
|
47
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
48
|
+
if (typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code)) return code;
|
|
49
|
+
return error instanceof Error ? error.name : 'unknown';
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
/**
|
|
42
|
-
* Migrates auth.core, then creates the chosen workspace and its owner
|
|
43
|
-
*
|
|
44
|
-
*
|
|
53
|
+
* Migrates auth.core, then creates the chosen workspace and its owner, and
|
|
54
|
+
* grants that owner the permissions the enabled modules declare. The database
|
|
55
|
+
* must still have no workspace, so a stale session cannot re-provision an
|
|
56
|
+
* existing installation.
|
|
45
57
|
*/
|
|
46
58
|
export async function seedFirstRun(
|
|
47
59
|
databases: DatabaseProvider,
|
|
@@ -49,6 +61,9 @@ export async function seedFirstRun(
|
|
|
49
61
|
workspaceRoot: string,
|
|
50
62
|
owner: FirstRunOwner,
|
|
51
63
|
): Promise<FirstRunSeed> {
|
|
64
|
+
const enabled = readModuleCatalog(workspaceRoot).filter(
|
|
65
|
+
(entry) => entry.enabled,
|
|
66
|
+
);
|
|
52
67
|
const runtime = createAuthRuntime({
|
|
53
68
|
...authRuntimeOptionsFromEnvironment(environment, workspaceRoot),
|
|
54
69
|
databases,
|
|
@@ -72,6 +87,37 @@ export async function seedFirstRun(
|
|
|
72
87
|
password: owner.ownerPassword,
|
|
73
88
|
operator: FIRST_RUN_OPERATOR,
|
|
74
89
|
});
|
|
90
|
+
/* An application's own modules declare permissions the owner defaults
|
|
91
|
+
cannot carry, and the grant module enable runs found no workspace
|
|
92
|
+
before this one. The workspace is committed and first run is over, so
|
|
93
|
+
a failed grant is reported with its repair command rather than failing
|
|
94
|
+
a setup that cannot run again. */
|
|
95
|
+
const held = new Set(provisioned.owner.scopes);
|
|
96
|
+
const pending = enabled.filter((entry) =>
|
|
97
|
+
entry.permissions.some((permission) => !held.has(permission.id)),
|
|
98
|
+
);
|
|
99
|
+
let scopes = provisioned.owner.scopes;
|
|
100
|
+
let ungrantedModules: readonly string[] = [];
|
|
101
|
+
if (pending.length > 0) {
|
|
102
|
+
try {
|
|
103
|
+
const grants = await service.grantModuleScopes(
|
|
104
|
+
pending.flatMap((entry) =>
|
|
105
|
+
entry.permissions.map((permission) => permission.id),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
scopes = [
|
|
109
|
+
...scopes,
|
|
110
|
+
...(grants.find(
|
|
111
|
+
(grant) => grant.accountId === provisioned.owner.accountId,
|
|
112
|
+
)?.granted ?? []),
|
|
113
|
+
];
|
|
114
|
+
} catch (error) {
|
|
115
|
+
ungrantedModules = pending.map((entry) => entry.id);
|
|
116
|
+
console.warn(
|
|
117
|
+
`First-run setup could not grant the owner the permissions of ${ungrantedModules.join(', ')} (${stableCode(error)}).`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
75
121
|
return {
|
|
76
122
|
workspace: {
|
|
77
123
|
name: provisioned.workspace.name,
|
|
@@ -82,9 +128,10 @@ export async function seedFirstRun(
|
|
|
82
128
|
email: provisioned.owner.email,
|
|
83
129
|
displayName: provisioned.owner.displayName,
|
|
84
130
|
role: provisioned.owner.role,
|
|
85
|
-
scopes
|
|
131
|
+
scopes,
|
|
86
132
|
},
|
|
87
133
|
],
|
|
134
|
+
ungrantedModules,
|
|
88
135
|
};
|
|
89
136
|
} finally {
|
|
90
137
|
await runtime.dispose();
|