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.
Files changed (36) hide show
  1. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  2. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  3. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
  4. package/agent-template/.ai/platform-capabilities.md +4 -4
  5. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  6. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  7. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  8. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  9. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  10. package/agent-template/docs/agent-contract.md +1 -1
  11. package/agent-template/docs/cli-extensions.md +1 -0
  12. package/agent-template/docs/cli.md +16 -0
  13. package/agent-template/docs/configuration.md +32 -4
  14. package/agent-template/docs/database-adapters.md +10 -2
  15. package/agent-template/docs/design-system.md +6 -2
  16. package/agent-template/docs/getting-started.md +5 -1
  17. package/agent-template/docs/modules.md +3 -1
  18. package/agent-template/docs/sandbox.md +23 -4
  19. package/package.json +1 -1
  20. package/template/default/.env.example +5 -0
  21. package/template/default/infra/docker/.env.example +5 -0
  22. package/template/default/infra/docker/Dockerfile +5 -1
  23. package/template/default/infra/docker/compose.yaml +4 -0
  24. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  25. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  26. package/template/default/infra/vercel/build.mjs +9 -0
  27. package/template/default/modules/example/package.json +1 -1
  28. package/template/default/package.json +2 -3
  29. package/template/default/platform/octane.config.ts +53 -15
  30. package/template/default/platform/package.json +1 -1
  31. package/template/default/platform/scripts/dev.mjs +66 -21
  32. package/template/default/platform/src/server/lifecycle.ts +325 -0
  33. package/template/default/platform/src/server/setup/modules.ts +28 -32
  34. package/template/default/platform/src/server/setup/page.ts +54 -3
  35. package/template/default/platform/src/server/setup/routes.ts +1 -0
  36. 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 { readdirSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
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 the module manifests could not be read, so every enabled module
31
- * is treated as owning tenant-scoped tables. Over-approximating keeps the
32
- * check strict; the review screen says the list came from this fallback.
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
- * A container image ships only `platform/dist`, so the enabled list falls back
79
- * to the manifest bundled at build time and, without the module manifests
80
- * beside it, every enabled module is assumed to own tenant-scoped tables.
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
- const roots = stringList(project.modules?.roots);
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 root of roots.length > 0 ? roots : ['modules']) {
93
- const directory = resolve(workspaceRoot, root);
94
- let entries: readonly string[];
95
- try {
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) continue;
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: false };
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 the module manifests, so every enabled module is checked against the strictest requirements any of them can ask for.</p>'
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
  }
@@ -191,6 +191,7 @@ function page(
191
191
  `style-src 'nonce-${nonce}'`,
192
192
  `script-src 'nonce-${nonce}'`,
193
193
  "img-src 'self' data:",
194
+ "connect-src 'self'",
194
195
  "form-action 'self'",
195
196
  "base-uri 'none'",
196
197
  "frame-ancestors 'none'",
@@ -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. The
43
- * database must still have no workspace, so a stale session cannot re-provision
44
- * an existing installation.
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: provisioned.owner.scopes,
131
+ scopes,
86
132
  },
87
133
  ],
134
+ ungrantedModules,
88
135
  };
89
136
  } finally {
90
137
  await runtime.dispose();