create-strata 1.0.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 (52) hide show
  1. package/README.md +54 -0
  2. package/dist/cli.js +4222 -0
  3. package/dist/templates/.env.example +22 -0
  4. package/dist/templates/docker-compose.yml +14 -0
  5. package/dist/templates/overlays/api/docs/API.md +49 -0
  6. package/dist/templates/overlays/server-htmx/public/assets/app.css +89 -0
  7. package/dist/templates/overlays/server-htmx/resources/views/errors/error.eta +14 -0
  8. package/dist/templates/overlays/server-htmx/resources/views/errors/forbidden.eta +4 -0
  9. package/dist/templates/overlays/server-htmx/resources/views/errors/not-found.eta +4 -0
  10. package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +34 -0
  11. package/dist/templates/overlays/server-htmx/resources/views/organizations/_table.eta +23 -0
  12. package/dist/templates/overlays/server-htmx/resources/views/organizations/index.eta +37 -0
  13. package/dist/templates/overlays/server-htmx/resources/views/pages/home.eta +4 -0
  14. package/dist/templates/overlays/server-htmx/resources/views/partials/_flash.eta +3 -0
  15. package/dist/templates/overlays/spa-react/frontend/build.ts +17 -0
  16. package/dist/templates/overlays/spa-react/frontend/bun-env.d.ts +4 -0
  17. package/dist/templates/overlays/spa-react/frontend/bun.lock +51 -0
  18. package/dist/templates/overlays/spa-react/frontend/dev-server.ts +66 -0
  19. package/dist/templates/overlays/spa-react/frontend/index.html +12 -0
  20. package/dist/templates/overlays/spa-react/frontend/package.json +21 -0
  21. package/dist/templates/overlays/spa-react/frontend/src/App.tsx +59 -0
  22. package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +86 -0
  23. package/dist/templates/overlays/spa-react/frontend/src/app.css +98 -0
  24. package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +103 -0
  25. package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +15 -0
  26. package/dist/templates/overlays/spa-react/frontend/src/main.tsx +22 -0
  27. package/dist/templates/overlays/spa-react/frontend/src/pages/HomePage.tsx +72 -0
  28. package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +70 -0
  29. package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +12 -0
  30. package/dist/templates/overlays/spa-react/frontend/tsconfig.json +17 -0
  31. package/dist/templates/package.json +22 -0
  32. package/dist/templates/public/assets/site.css +29 -0
  33. package/dist/templates/src/bootstrap/config.ts +29 -0
  34. package/dist/templates/src/bootstrap/createApp.ts +94 -0
  35. package/dist/templates/src/bootstrap/database.ts +30 -0
  36. package/dist/templates/src/bootstrap/preload.ts +5 -0
  37. package/dist/templates/src/bootstrap/providers/auth.ts +41 -0
  38. package/dist/templates/src/bootstrap/providers/cache.ts +28 -0
  39. package/dist/templates/src/bootstrap/providers/config.ts +27 -0
  40. package/dist/templates/src/bootstrap/providers/index.ts +14 -0
  41. package/dist/templates/src/bootstrap/providers/storage.ts +11 -0
  42. package/dist/templates/src/bootstrap/server.ts +25 -0
  43. package/dist/templates/src/db/fresh.ts +19 -0
  44. package/dist/templates/src/db/migrate.ts +35 -0
  45. package/dist/templates/src/lib/view.ts +21 -0
  46. package/dist/templates/src/modules/site/index.ts +29 -0
  47. package/dist/templates/src/routes.ts +6 -0
  48. package/dist/templates/strata.config.ts +7 -0
  49. package/dist/templates/tsconfig.json +14 -0
  50. package/dist/templates/views/home.eta +5 -0
  51. package/dist/templates/views/layouts/app.eta +18 -0
  52. package/package.json +29 -0
package/dist/cli.js ADDED
@@ -0,0 +1,4222 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // ../strata-starter/src/generate.ts
5
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
6
+ import { basename, join as join2, resolve } from "path";
7
+
8
+ // ../strata-starter/src/copy.ts
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync
17
+ } from "fs";
18
+ import { join } from "path";
19
+ var PLACEHOLDER = /\{\{PROJECT_NAME\}\}/g;
20
+ function copyTree(source, target, projectName, skipNames = new Set) {
21
+ mkdirSync(target, { recursive: true });
22
+ for (const entry of readdirSync(source)) {
23
+ if (skipNames.has(entry) || entry === ".DS_Store") {
24
+ continue;
25
+ }
26
+ const from = join(source, entry);
27
+ const to = join(target, entry.replace(PLACEHOLDER, projectName));
28
+ const info = statSync(from);
29
+ if (info.isDirectory()) {
30
+ copyTree(from, to, projectName, skipNames);
31
+ continue;
32
+ }
33
+ let contents = readFileSync(from, "utf8");
34
+ if (contents.includes("{{PROJECT_NAME}}")) {
35
+ contents = contents.replace(PLACEHOLDER, projectName);
36
+ }
37
+ writeFileSync(to, contents, { mode: info.mode & 511 });
38
+ }
39
+ }
40
+ function copyOverlayTree(sourceRoot, targetRoot) {
41
+ if (!existsSync(sourceRoot)) {
42
+ return;
43
+ }
44
+ for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
45
+ const sourcePath = join(sourceRoot, entry.name);
46
+ const targetPath = join(targetRoot, entry.name);
47
+ if (entry.isDirectory()) {
48
+ mkdirSync(targetPath, { recursive: true });
49
+ copyOverlayTree(sourcePath, targetPath);
50
+ continue;
51
+ }
52
+ if (existsSync(targetPath)) {
53
+ continue;
54
+ }
55
+ mkdirSync(join(targetPath, ".."), { recursive: true });
56
+ const contents = readFileSync(sourcePath);
57
+ writeFileSync(targetPath, contents);
58
+ }
59
+ }
60
+ function writeText(target, contents) {
61
+ mkdirSync(join(target, ".."), { recursive: true });
62
+ writeFileSync(target, contents.endsWith(`
63
+ `) ? contents : `${contents}
64
+ `);
65
+ }
66
+ function removeIfExists(path) {
67
+ if (existsSync(path)) {
68
+ rmSync(path, { recursive: true, force: true });
69
+ }
70
+ }
71
+
72
+ // ../strata-starter/src/types.ts
73
+ var FRONTENDS = ["api", "server-htmx", "spa-react", "hybrid"];
74
+ var DATABASES = ["sqlite", "postgres", "mysql"];
75
+ var AUTH_STACKS = [
76
+ "headers",
77
+ "cookie",
78
+ "token",
79
+ "jwt",
80
+ "cookie-token",
81
+ "cookie-token-jwt"
82
+ ];
83
+ var TENANCY_DRIVERS = ["none", "column", "rls"];
84
+ var CACHE_DRIVERS = ["array", "redis"];
85
+ var QUEUE_DRIVERS = ["sync", "redis"];
86
+ var MAIL_DRIVERS = ["log", "smtp"];
87
+ var DOCKER_SERVICE_NAMES = ["postgres", "mysql", "redis", "mailpit", "adminer"];
88
+ var DOCKER_SERVICE_LABELS = {
89
+ postgres: "Postgres",
90
+ mysql: "MySQL",
91
+ redis: "Redis",
92
+ mailpit: "SMTP (Mailpit)",
93
+ adminer: "Adminer (database UI)"
94
+ };
95
+ function authUsesCookie(auth) {
96
+ return auth === "cookie" || auth.startsWith("cookie-");
97
+ }
98
+ function authUsesToken(auth) {
99
+ return auth === "token" || auth.includes("token");
100
+ }
101
+ function authUsesJwt(auth) {
102
+ return auth === "jwt" || auth.endsWith("jwt");
103
+ }
104
+ function authNeedsUsers(auth) {
105
+ return auth !== "headers";
106
+ }
107
+ function usesTenantTable(tenancy) {
108
+ return tenancy === "rls" || tenancy === "column";
109
+ }
110
+ function htmlAuthKit(auth) {
111
+ return authUsesCookie(auth);
112
+ }
113
+ function needsFrontendBuild(frontend) {
114
+ return frontend === "spa-react" || frontend === "hybrid";
115
+ }
116
+ function nowTimestampLiteral(database) {
117
+ return database === "mysql" ? `new Date().toISOString().slice(0, 19).replace("T", " ")` : "new Date().toISOString()";
118
+ }
119
+ function extraApplies(extra, auth) {
120
+ if (extra === "metrics") {
121
+ return true;
122
+ }
123
+ if (extra === "mfa") {
124
+ return htmlAuthKit(auth);
125
+ }
126
+ return authNeedsUsers(auth);
127
+ }
128
+ function needsRedis(layers) {
129
+ return layers.cache === "redis" || layers.queue === "redis";
130
+ }
131
+ function emptyDockerServices() {
132
+ return { postgres: false, mysql: false, redis: false, mailpit: false, adminer: false };
133
+ }
134
+ function dockerDatabaseService(database) {
135
+ if (database === "postgres" || database === "mysql") {
136
+ return database;
137
+ }
138
+ return null;
139
+ }
140
+ function enableDockerServices(names) {
141
+ const services = emptyDockerServices();
142
+ for (const name of names) {
143
+ services[name] = true;
144
+ }
145
+ return services;
146
+ }
147
+ function neededDockerServices(layers) {
148
+ const needed = [];
149
+ if (layers.database === "postgres") {
150
+ needed.push("postgres");
151
+ }
152
+ if (layers.database === "mysql") {
153
+ needed.push("mysql");
154
+ }
155
+ if (needsRedis(layers)) {
156
+ needed.push("redis");
157
+ }
158
+ if (layers.mail === "smtp") {
159
+ needed.push("mailpit");
160
+ }
161
+ return needed;
162
+ }
163
+ function selectedDockerServices(layers) {
164
+ if (!layers.docker.enabled) {
165
+ return [];
166
+ }
167
+ const selected = neededDockerServices(layers).filter((name) => layers.docker.services[name]);
168
+ const databaseService = dockerDatabaseService(layers.database);
169
+ if (databaseService && selected.includes(databaseService) && layers.docker.services.adminer) {
170
+ selected.push("adminer");
171
+ }
172
+ return selected;
173
+ }
174
+ function reconcileDocker(layers) {
175
+ const selected = selectedDockerServices(layers);
176
+ return {
177
+ ...layers,
178
+ docker: {
179
+ enabled: selected.length > 0,
180
+ services: enableDockerServices(selected)
181
+ }
182
+ };
183
+ }
184
+ function dockerLayerForNeeded(layers, enabled) {
185
+ const needed = neededDockerServices(layers);
186
+ if (!enabled || needed.length === 0) {
187
+ return { enabled: false, services: emptyDockerServices() };
188
+ }
189
+ const names = [...needed];
190
+ if (dockerDatabaseService(layers.database)) {
191
+ names.push("adminer");
192
+ }
193
+ return { enabled: true, services: enableDockerServices(names) };
194
+ }
195
+
196
+ // ../strata-starter/src/presets.ts
197
+ var noneExtras = {
198
+ mfa: false,
199
+ emailVerification: false,
200
+ scim: false,
201
+ metrics: false
202
+ };
203
+ var enterpriseExtras = {
204
+ mfa: true,
205
+ emailVerification: true,
206
+ scim: true,
207
+ metrics: true
208
+ };
209
+ function withDocker(layers, composeForNeededTools) {
210
+ return {
211
+ ...layers,
212
+ docker: dockerLayerForNeeded(layers, composeForNeededTools)
213
+ };
214
+ }
215
+ function defaultLayers() {
216
+ return withDocker({
217
+ frontend: "api",
218
+ database: "sqlite",
219
+ auth: "headers",
220
+ tenancy: "none",
221
+ cache: "array",
222
+ queue: "sync",
223
+ mail: "log",
224
+ spaPrefix: "/app",
225
+ extras: { ...noneExtras }
226
+ }, false);
227
+ }
228
+ var EXAMPLE_APPS = {
229
+ "hiroapp-hobby": defaultLayers(),
230
+ "hiroapp-team": withDocker({
231
+ frontend: "server-htmx",
232
+ database: "postgres",
233
+ auth: "cookie",
234
+ tenancy: "none",
235
+ cache: "redis",
236
+ queue: "redis",
237
+ mail: "log",
238
+ spaPrefix: "/app",
239
+ extras: { ...noneExtras, metrics: true }
240
+ }, true),
241
+ hiroapp: withDocker({
242
+ frontend: "server-htmx",
243
+ database: "postgres",
244
+ auth: "cookie-token-jwt",
245
+ tenancy: "rls",
246
+ cache: "redis",
247
+ queue: "redis",
248
+ mail: "smtp",
249
+ spaPrefix: "/app",
250
+ extras: { ...enterpriseExtras }
251
+ }, true)
252
+ };
253
+
254
+ // ../strata-starter/src/parseArgs.ts
255
+ function usage() {
256
+ return `Usage: create-strata [project-name] [options]
257
+
258
+ Scaffold a runnable Strata app. The wizard always asks each layer. For CI, pass --yes
259
+ and the layer flags you want (defaults are SQLite, JSON API, header auth).
260
+
261
+ Options:
262
+ --frontend api | server-htmx | spa-react | hybrid
263
+ --database sqlite | postgres | mysql (one database; not mixed)
264
+ --auth headers | cookie | token | jwt | cookie-token | cookie-token-jwt
265
+ --tenancy none | column | rls (rls is Postgres SET LOCAL; sqlite/mysql coerce rls to column)
266
+ --cache array | redis
267
+ --queue sync | redis
268
+ --mail log | smtp
269
+ --spa-prefix SPA URL prefix (default /app)
270
+ --mfa / --no-mfa
271
+ --email-verification / --no-email-verification
272
+ --scim / --no-scim
273
+ --metrics / --no-metrics
274
+ --extras Interactive extras list (MFA, email verification, SCIM, metrics)
275
+ --docker Write Docker Compose for every selected tool that needs a service
276
+ --no-docker Skip docker-compose.yml; use installs already on this machine
277
+ --docker-services Subset: postgres, mysql, redis, mailpit, adminer (comma-separated)
278
+ --force Replace an existing directory
279
+ --yes, --no-interactive
280
+ -h, --help
281
+
282
+ Examples:
283
+ bunx create-strata my-app
284
+ bunx create-strata my-app --yes
285
+ bunx create-strata html --frontend server-htmx --database postgres --auth cookie --cache redis --queue redis --docker --yes
286
+ bunx create-strata html --frontend server-htmx --database postgres --no-docker --yes
287
+ `;
288
+ }
289
+ function takeValue(arg, prefix) {
290
+ if (arg.startsWith(`${prefix}=`)) {
291
+ return arg.slice(prefix.length + 1);
292
+ }
293
+ return;
294
+ }
295
+ function parseEnum(value, allowed, label) {
296
+ if (allowed.includes(value)) {
297
+ return value;
298
+ }
299
+ throw new Error(`Unknown ${label} "${value}". Expected ${allowed.join(", ")}.`);
300
+ }
301
+ function parseDockerServiceList(raw) {
302
+ const names = raw.split(",").map((part) => part.trim().toLowerCase()).filter((part) => part.length > 0);
303
+ const unknown = names.filter((name) => !DOCKER_SERVICE_NAMES.includes(name));
304
+ if (unknown.length > 0) {
305
+ throw new Error(`Unknown docker service "${unknown.join(", ")}". Expected ${DOCKER_SERVICE_NAMES.join(", ")}.`);
306
+ }
307
+ return names;
308
+ }
309
+ function dockerFlagsProvided(flags) {
310
+ return flags.docker !== undefined || flags.dockerServices !== undefined;
311
+ }
312
+ function parseCreateStrataArgs(argv) {
313
+ const flags = {
314
+ help: false,
315
+ yes: false,
316
+ noInteractive: false,
317
+ force: false,
318
+ extrasPrompt: false,
319
+ extras: {}
320
+ };
321
+ const positional = [];
322
+ for (let index = 0;index < argv.length; index += 1) {
323
+ const arg = argv[index];
324
+ if (!arg) {
325
+ continue;
326
+ }
327
+ if (arg === "-h" || arg === "--help") {
328
+ flags.help = true;
329
+ continue;
330
+ }
331
+ if (arg === "--yes" || arg === "-y") {
332
+ flags.yes = true;
333
+ continue;
334
+ }
335
+ if (arg === "--no-interactive") {
336
+ flags.noInteractive = true;
337
+ continue;
338
+ }
339
+ if (arg === "--force") {
340
+ flags.force = true;
341
+ continue;
342
+ }
343
+ if (arg === "--extras" || arg === "--corporate") {
344
+ flags.extrasPrompt = true;
345
+ continue;
346
+ }
347
+ if (arg === "--docker") {
348
+ flags.docker = true;
349
+ flags.dockerServices = undefined;
350
+ continue;
351
+ }
352
+ if (arg === "--no-docker") {
353
+ flags.docker = false;
354
+ flags.dockerServices = undefined;
355
+ continue;
356
+ }
357
+ const dockerServicesInline = takeValue(arg, "--docker-services");
358
+ if (dockerServicesInline !== undefined) {
359
+ flags.docker = true;
360
+ flags.dockerServices = parseDockerServiceList(dockerServicesInline);
361
+ continue;
362
+ }
363
+ if (arg === "--docker-services") {
364
+ flags.docker = true;
365
+ flags.dockerServices = parseDockerServiceList(argv[index + 1] ?? "");
366
+ index += 1;
367
+ continue;
368
+ }
369
+ const boolFlags = [
370
+ ["--mfa", "mfa", true],
371
+ ["--no-mfa", "mfa", false],
372
+ ["--email-verification", "emailVerification", true],
373
+ ["--no-email-verification", "emailVerification", false],
374
+ ["--scim", "scim", true],
375
+ ["--no-scim", "scim", false],
376
+ ["--metrics", "metrics", true],
377
+ ["--no-metrics", "metrics", false]
378
+ ];
379
+ const boolMatch = boolFlags.find(([name]) => name === arg);
380
+ if (boolMatch) {
381
+ flags.extras[boolMatch[1]] = boolMatch[2];
382
+ continue;
383
+ }
384
+ const pairs = [
385
+ [
386
+ "--frontend",
387
+ (value) => {
388
+ flags.frontend = parseEnum(value, FRONTENDS, "frontend");
389
+ }
390
+ ],
391
+ [
392
+ "--database",
393
+ (value) => {
394
+ flags.database = parseEnum(value, DATABASES, "database");
395
+ }
396
+ ],
397
+ [
398
+ "--auth",
399
+ (value) => {
400
+ flags.auth = parseEnum(value, AUTH_STACKS, "auth");
401
+ }
402
+ ],
403
+ [
404
+ "--tenancy",
405
+ (value) => {
406
+ flags.tenancy = parseEnum(value, TENANCY_DRIVERS, "tenancy");
407
+ }
408
+ ],
409
+ [
410
+ "--cache",
411
+ (value) => {
412
+ flags.cache = parseEnum(value, CACHE_DRIVERS, "cache");
413
+ }
414
+ ],
415
+ [
416
+ "--queue",
417
+ (value) => {
418
+ flags.queue = parseEnum(value, QUEUE_DRIVERS, "queue");
419
+ }
420
+ ],
421
+ [
422
+ "--mail",
423
+ (value) => {
424
+ flags.mail = parseEnum(value, MAIL_DRIVERS, "mail");
425
+ }
426
+ ],
427
+ [
428
+ "--spa-prefix",
429
+ (value) => {
430
+ flags.spaPrefix = value.startsWith("/") ? value : `/${value}`;
431
+ }
432
+ ]
433
+ ];
434
+ let matched = false;
435
+ for (const [name, apply] of pairs) {
436
+ const inline = takeValue(arg, name);
437
+ if (inline !== undefined) {
438
+ apply(inline);
439
+ matched = true;
440
+ break;
441
+ }
442
+ if (arg === name) {
443
+ apply(argv[index + 1] ?? "");
444
+ index += 1;
445
+ matched = true;
446
+ break;
447
+ }
448
+ }
449
+ if (matched) {
450
+ continue;
451
+ }
452
+ if (arg.startsWith("-")) {
453
+ throw new Error(`Unknown option ${arg}. Pass --help to list flags.`);
454
+ }
455
+ positional.push(arg);
456
+ }
457
+ if (positional[0]) {
458
+ flags.projectName = positional[0];
459
+ }
460
+ return flags;
461
+ }
462
+ function applyDockerFlags(layers, flags) {
463
+ const needed = neededDockerServices(layers);
464
+ if (flags.docker === false) {
465
+ return {
466
+ ...layers,
467
+ docker: dockerLayerForNeeded(layers, false)
468
+ };
469
+ }
470
+ if (flags.dockerServices) {
471
+ const databaseService = dockerDatabaseService(layers.database);
472
+ const selected = flags.dockerServices.filter((name) => {
473
+ if (needed.includes(name)) {
474
+ return true;
475
+ }
476
+ return name === "adminer" && databaseService !== null && flags.dockerServices?.includes(databaseService) === true;
477
+ });
478
+ return {
479
+ ...layers,
480
+ docker: {
481
+ enabled: selected.length > 0,
482
+ services: enableDockerServices(selected)
483
+ }
484
+ };
485
+ }
486
+ if (flags.docker === true) {
487
+ return {
488
+ ...layers,
489
+ docker: dockerLayerForNeeded(layers, true)
490
+ };
491
+ }
492
+ return layers;
493
+ }
494
+ function extraFlagName(extra) {
495
+ return extra === "emailVerification" ? "email-verification" : extra;
496
+ }
497
+ function dropInapplicableExtras(layers, flags) {
498
+ for (const key of Object.keys(layers.extras)) {
499
+ if (extraApplies(key, layers.auth)) {
500
+ continue;
501
+ }
502
+ if (flags.extras[key] === true) {
503
+ console.warn(`Ignoring --${extraFlagName(key)}: not available with --auth ${layers.auth}.`);
504
+ }
505
+ layers.extras[key] = false;
506
+ }
507
+ }
508
+ function applyFlagOverrides(base, flags) {
509
+ const next = {
510
+ ...base,
511
+ frontend: flags.frontend ?? base.frontend,
512
+ database: flags.database ?? base.database,
513
+ auth: flags.auth ?? base.auth,
514
+ tenancy: flags.tenancy ?? base.tenancy,
515
+ cache: flags.cache ?? base.cache,
516
+ queue: flags.queue ?? base.queue,
517
+ mail: flags.mail ?? base.mail,
518
+ spaPrefix: flags.spaPrefix ?? base.spaPrefix,
519
+ extras: { ...base.extras, ...flags.extras }
520
+ };
521
+ if (next.database !== "postgres" && next.tenancy === "rls") {
522
+ console.warn(`Using --tenancy column: rls is Postgres-only (SET LOCAL app.tenant_id) and ${next.database} has no equivalent.`);
523
+ next.tenancy = "column";
524
+ }
525
+ dropInapplicableExtras(next, flags);
526
+ return reconcileDocker(applyDockerFlags(next, flags));
527
+ }
528
+ function layersFromFlags(flags) {
529
+ return applyFlagOverrides(defaultLayers(), flags);
530
+ }
531
+
532
+ // ../strata-starter/src/prompt.ts
533
+ import { createInterface } from "readline/promises";
534
+ import tty2 from "tty";
535
+
536
+ // ../strata-starter/src/selectPrompt.ts
537
+ import tty from "tty";
538
+ function hasRawMode(input) {
539
+ return Boolean(input.isTTY && typeof input.setRawMode === "function");
540
+ }
541
+ var cachedFdIo;
542
+ function resolveSelectIo(preferred) {
543
+ if (preferred) {
544
+ return preferred;
545
+ }
546
+ const live = { input: process.stdin, output: process.stdout };
547
+ if (hasRawMode(live.input)) {
548
+ return live;
549
+ }
550
+ if (cachedFdIo && hasRawMode(cachedFdIo.input)) {
551
+ return cachedFdIo;
552
+ }
553
+ if (tty.isatty(0)) {
554
+ cachedFdIo = {
555
+ input: new tty.ReadStream(0),
556
+ output: tty.isatty(1) ? new tty.WriteStream(1) : process.stdout
557
+ };
558
+ return cachedFdIo;
559
+ }
560
+ return live;
561
+ }
562
+
563
+ class PromptCancelledError extends Error {
564
+ constructor() {
565
+ super("Cancelled");
566
+ this.name = "PromptCancelledError";
567
+ }
568
+ }
569
+ function consumeSelectKeys(buffer) {
570
+ const events = [];
571
+ let rest = buffer;
572
+ while (rest.length > 0) {
573
+ if (rest[0] === "\x1B") {
574
+ if (rest.length === 1) {
575
+ break;
576
+ }
577
+ if (rest.startsWith("\x1B[")) {
578
+ const end = rest.search(/[A-Za-z]/);
579
+ if (end < 2) {
580
+ break;
581
+ }
582
+ const command = rest[end];
583
+ rest = rest.slice(end + 1);
584
+ if (command === "A") {
585
+ events.push({ type: "up" });
586
+ } else if (command === "B") {
587
+ events.push({ type: "down" });
588
+ } else if (command === "C") {
589
+ events.push({ type: "right" });
590
+ } else if (command === "D") {
591
+ events.push({ type: "left" });
592
+ }
593
+ continue;
594
+ }
595
+ if (rest.startsWith("\x1BO")) {
596
+ if (rest.length < 3) {
597
+ break;
598
+ }
599
+ const command = rest[2];
600
+ rest = rest.slice(3);
601
+ if (command === "A") {
602
+ events.push({ type: "up" });
603
+ } else if (command === "B") {
604
+ events.push({ type: "down" });
605
+ } else if (command === "C") {
606
+ events.push({ type: "right" });
607
+ } else if (command === "D") {
608
+ events.push({ type: "left" });
609
+ }
610
+ continue;
611
+ }
612
+ rest = rest.slice(1);
613
+ events.push({ type: "abort" });
614
+ continue;
615
+ }
616
+ const next = rest[0] ?? "";
617
+ rest = rest.slice(1);
618
+ if (next === "\x03") {
619
+ events.push({ type: "abort" });
620
+ continue;
621
+ }
622
+ if (next === "\r" || next === `
623
+ `) {
624
+ events.push({ type: "submit" });
625
+ continue;
626
+ }
627
+ if (next === " ") {
628
+ events.push({ type: "toggle" });
629
+ continue;
630
+ }
631
+ if (next === "y" || next === "Y") {
632
+ events.push({ type: "yes" });
633
+ continue;
634
+ }
635
+ if (next === "n" || next === "N") {
636
+ events.push({ type: "no" });
637
+ continue;
638
+ }
639
+ if (next >= "1" && next <= "9") {
640
+ events.push({ type: "digit", value: Number(next) });
641
+ }
642
+ }
643
+ return { events, rest };
644
+ }
645
+ function moveSelectIndex(index, delta, length) {
646
+ if (length <= 0) {
647
+ return 0;
648
+ }
649
+ return (index + delta + length) % length;
650
+ }
651
+ function highlight(line, on) {
652
+ return on ? `\x1B[7m${line}\x1B[0m` : line;
653
+ }
654
+ function renderSelectLines(message, choices, index) {
655
+ return [
656
+ message,
657
+ ...choices.map((choice, choiceIndex) => {
658
+ const selected = choiceIndex === index;
659
+ const marker = selected ? ">" : " ";
660
+ return highlight(` ${marker} ${choiceIndex + 1}) ${choice.label}`, selected);
661
+ }),
662
+ ` \u2191/\u2193 and Enter, or 1-${choices.length}`
663
+ ];
664
+ }
665
+ function renderConfirmLines(message, yes) {
666
+ return [
667
+ message,
668
+ highlight(` ${yes ? ">" : " "} yes`, yes),
669
+ highlight(` ${yes ? " " : ">"} no`, !yes),
670
+ " \u2191/\u2193 and Enter, or y / n"
671
+ ];
672
+ }
673
+ function renderMultiSelectLines(message, choices, index) {
674
+ return [
675
+ message,
676
+ ...choices.map((choice, choiceIndex) => {
677
+ const focused = choiceIndex === index;
678
+ const box = choice.enabled ? "[x]" : "[ ]";
679
+ const marker = focused ? ">" : " ";
680
+ return highlight(` ${marker} ${box} ${choiceIndex + 1}) ${choice.label}`, focused);
681
+ }),
682
+ " \u2191/\u2193 move, Space or 1-9 toggle, Enter to continue"
683
+ ];
684
+ }
685
+ function writeLines(output, lines) {
686
+ for (const line of lines) {
687
+ output.write(`\x1B[2K${line}
688
+ `);
689
+ }
690
+ }
691
+ function clearDrawnLines(output, lineCount) {
692
+ if (lineCount <= 0) {
693
+ return;
694
+ }
695
+ output.write(`\x1B[${lineCount}F`);
696
+ for (let i = 0;i < lineCount; i += 1) {
697
+ output.write(`\x1B[2K
698
+ `);
699
+ }
700
+ output.write(`\x1B[${lineCount}F`);
701
+ }
702
+ function runRawPrompt(io, render, onEvent, summary) {
703
+ const { input, output } = io;
704
+ const wasRaw = Boolean(input.isTTY && typeof input.setRawMode === "function");
705
+ let buffer = "";
706
+ let lineCount = 0;
707
+ let settled = false;
708
+ const restore = () => {
709
+ output.write("\x1B[?25h");
710
+ if (wasRaw) {
711
+ input.setRawMode?.(false);
712
+ }
713
+ };
714
+ const paint = () => {
715
+ const lines = render();
716
+ if (lineCount > 0) {
717
+ output.write(`\x1B[${lineCount}F`);
718
+ }
719
+ writeLines(output, lines);
720
+ lineCount = lines.length;
721
+ };
722
+ input.setEncoding("utf8");
723
+ if (wasRaw) {
724
+ input.setRawMode?.(true);
725
+ }
726
+ if (typeof input.resume === "function") {
727
+ input.resume();
728
+ }
729
+ output.write("\x1B[?25l");
730
+ paint();
731
+ return new Promise((resolve, reject) => {
732
+ const cleanup = () => {
733
+ input.off("data", onData);
734
+ restore();
735
+ };
736
+ const succeed = (result) => {
737
+ if (settled) {
738
+ return;
739
+ }
740
+ settled = true;
741
+ cleanup();
742
+ clearDrawnLines(output, lineCount);
743
+ output.write(`${summary(result)}
744
+ `);
745
+ resolve(result);
746
+ };
747
+ const fail = (error) => {
748
+ if (settled) {
749
+ return;
750
+ }
751
+ settled = true;
752
+ cleanup();
753
+ clearDrawnLines(output, lineCount);
754
+ reject(error);
755
+ };
756
+ function onData(chunk) {
757
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
758
+ const consumed = consumeSelectKeys(buffer);
759
+ buffer = consumed.rest;
760
+ for (const event of consumed.events) {
761
+ const outcome = onEvent(event);
762
+ if (outcome === "abort") {
763
+ fail(new PromptCancelledError);
764
+ return;
765
+ }
766
+ if (outcome === "continue") {
767
+ paint();
768
+ continue;
769
+ }
770
+ succeed(outcome.done);
771
+ return;
772
+ }
773
+ }
774
+ input.on("data", onData);
775
+ });
776
+ }
777
+ async function promptSelect(message, choices, defaultValue, io) {
778
+ if (choices.length === 0) {
779
+ throw new Error(`No choices for "${message}".`);
780
+ }
781
+ const found = choices.findIndex((choice) => choice.value === defaultValue);
782
+ let index = found >= 0 ? found : 0;
783
+ return runRawPrompt(io, () => renderSelectLines(message, choices, index), (event) => {
784
+ if (event.type === "abort") {
785
+ return "abort";
786
+ }
787
+ if (event.type === "up") {
788
+ index = moveSelectIndex(index, -1, choices.length);
789
+ return "continue";
790
+ }
791
+ if (event.type === "down") {
792
+ index = moveSelectIndex(index, 1, choices.length);
793
+ return "continue";
794
+ }
795
+ if (event.type === "digit") {
796
+ if (event.value >= 1 && event.value <= choices.length) {
797
+ const choice = choices[event.value - 1];
798
+ if (choice) {
799
+ return { done: choice.value };
800
+ }
801
+ }
802
+ return "continue";
803
+ }
804
+ if (event.type === "submit") {
805
+ const choice = choices[index];
806
+ if (choice) {
807
+ return { done: choice.value };
808
+ }
809
+ }
810
+ return "continue";
811
+ }, (value) => `${message} ${choices.find((choice) => choice.value === value)?.label ?? value}`);
812
+ }
813
+ async function promptConfirm(message, defaultValue, io) {
814
+ let yes = defaultValue;
815
+ return runRawPrompt(io, () => renderConfirmLines(message, yes), (event) => {
816
+ if (event.type === "abort") {
817
+ return "abort";
818
+ }
819
+ if (event.type === "up" || event.type === "right" || event.type === "yes") {
820
+ if (event.type === "yes") {
821
+ return { done: true };
822
+ }
823
+ yes = true;
824
+ return "continue";
825
+ }
826
+ if (event.type === "down" || event.type === "left" || event.type === "no") {
827
+ if (event.type === "no") {
828
+ return { done: false };
829
+ }
830
+ yes = false;
831
+ return "continue";
832
+ }
833
+ if (event.type === "digit") {
834
+ if (event.value === 1) {
835
+ return { done: true };
836
+ }
837
+ if (event.value === 2) {
838
+ return { done: false };
839
+ }
840
+ }
841
+ if (event.type === "submit") {
842
+ return { done: yes };
843
+ }
844
+ return "continue";
845
+ }, (value) => `${message} ${value ? "yes" : "no"}`);
846
+ }
847
+ async function promptMultiSelect(message, choices, io) {
848
+ if (choices.length === 0) {
849
+ return [];
850
+ }
851
+ const selected = choices.map((choice) => choice.enabled);
852
+ let index = 0;
853
+ const enabledValues = () => choices.filter((_, choiceIndex) => selected[choiceIndex]).map((choice) => choice.value);
854
+ return runRawPrompt(io, () => renderMultiSelectLines(message, choices.map((choice, choiceIndex) => ({
855
+ ...choice,
856
+ enabled: Boolean(selected[choiceIndex])
857
+ })), index), (event) => {
858
+ if (event.type === "abort") {
859
+ return "abort";
860
+ }
861
+ if (event.type === "up") {
862
+ index = moveSelectIndex(index, -1, choices.length);
863
+ return "continue";
864
+ }
865
+ if (event.type === "down") {
866
+ index = moveSelectIndex(index, 1, choices.length);
867
+ return "continue";
868
+ }
869
+ if (event.type === "toggle") {
870
+ selected[index] = !selected[index];
871
+ return "continue";
872
+ }
873
+ if (event.type === "digit") {
874
+ if (event.value >= 1 && event.value <= choices.length) {
875
+ const target = event.value - 1;
876
+ selected[target] = !selected[target];
877
+ index = target;
878
+ }
879
+ return "continue";
880
+ }
881
+ if (event.type === "submit") {
882
+ return { done: enabledValues() };
883
+ }
884
+ return "continue";
885
+ }, (values) => {
886
+ const labels = choices.filter((choice) => values.includes(choice.value)).map((choice) => choice.value);
887
+ return `${message} ${labels.length > 0 ? labels.join(", ") : "none"}`;
888
+ });
889
+ }
890
+
891
+ // ../strata-starter/src/prompt.ts
892
+ var EXTRA_CHOICES = [
893
+ { value: "mfa", label: "mfa: authenticator challenge + setup pages" },
894
+ { value: "emailVerification", label: "email-verification: signed links + /email/verify" },
895
+ { value: "scim", label: "scim: /Users adapter" },
896
+ { value: "metrics", label: "metrics: Prometheus token" }
897
+ ];
898
+ function isInteractive(flags) {
899
+ if (flags.yes || flags.noInteractive) {
900
+ return false;
901
+ }
902
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY || tty2.isatty(0) && tty2.isatty(1));
903
+ }
904
+ function createReadlinePrompter(io) {
905
+ const stdio = () => resolveSelectIo(io);
906
+ async function askLine(message) {
907
+ const current = stdio();
908
+ const rl = createInterface({ input: current.input, output: current.output });
909
+ try {
910
+ return (await rl.question(message)).trim();
911
+ } finally {
912
+ rl.close();
913
+ }
914
+ }
915
+ return {
916
+ async question(message, defaultValue) {
917
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
918
+ const answer = await askLine(`${message}${suffix}: `);
919
+ return answer || defaultValue || "";
920
+ },
921
+ async confirm(message, defaultValue = false) {
922
+ const current = stdio();
923
+ if (hasRawMode(current.input)) {
924
+ return promptConfirm(message, defaultValue, current);
925
+ }
926
+ const hint = defaultValue ? "Y/n" : "y/N";
927
+ const answer = (await askLine(`${message} (${hint}): `)).toLowerCase();
928
+ if (!answer) {
929
+ return defaultValue;
930
+ }
931
+ return answer === "y" || answer === "yes";
932
+ },
933
+ async select(message, choices, defaultValue) {
934
+ const current = stdio();
935
+ if (hasRawMode(current.input)) {
936
+ return promptSelect(message, choices, defaultValue, current);
937
+ }
938
+ current.output.write(`${message}
939
+ `);
940
+ for (const [index, choice] of choices.entries()) {
941
+ const marker = choice.value === defaultValue ? "*" : " ";
942
+ current.output.write(` ${index + 1}) ${marker} ${choice.label}
943
+ `);
944
+ }
945
+ const defaultIndex = choices.findIndex((choice) => choice.value === defaultValue) + 1;
946
+ const answer = await askLine(`Choose [${defaultIndex}]: `);
947
+ if (!answer) {
948
+ return defaultValue;
949
+ }
950
+ const asNumber = Number.parseInt(answer, 10);
951
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= choices.length) {
952
+ const selected = choices[asNumber - 1];
953
+ return selected?.value ?? defaultValue;
954
+ }
955
+ const match = choices.find((choice) => choice.value === answer || choice.label === answer);
956
+ return match?.value ?? defaultValue;
957
+ },
958
+ async multiSelect(message, choices) {
959
+ const current = stdio();
960
+ if (hasRawMode(current.input)) {
961
+ return promptMultiSelect(message, choices, current);
962
+ }
963
+ const enabled = new Set(choices.filter((choice) => choice.enabled).map((choice) => choice.value));
964
+ current.output.write(`${message} (yes/no each)
965
+ `);
966
+ for (const choice of choices) {
967
+ const hint = enabled.has(choice.value) ? "Y/n" : "y/N";
968
+ const answer = (await askLine(` ${choice.label} (${hint}): `)).toLowerCase();
969
+ if (!answer) {
970
+ continue;
971
+ }
972
+ if (answer === "y" || answer === "yes") {
973
+ enabled.add(choice.value);
974
+ } else if (answer === "n" || answer === "no") {
975
+ enabled.delete(choice.value);
976
+ }
977
+ }
978
+ return [...enabled];
979
+ },
980
+ close() {
981
+ return;
982
+ }
983
+ };
984
+ }
985
+ function extrasStillToAsk(layers, flags) {
986
+ return EXTRA_CHOICES.filter((choice) => {
987
+ if (flags.extras[choice.value] !== undefined) {
988
+ return false;
989
+ }
990
+ return extraApplies(choice.value, layers.auth);
991
+ });
992
+ }
993
+ async function promptExtras(prompter, extras, layers, flags) {
994
+ const choices = extrasStillToAsk(layers, flags);
995
+ if (choices.length === 0) {
996
+ return extras;
997
+ }
998
+ const picked = new Set(await prompter.multiSelect("Extras", choices.map((choice) => ({
999
+ value: choice.value,
1000
+ label: choice.label,
1001
+ enabled: extras[choice.value]
1002
+ }))));
1003
+ const next = { ...extras };
1004
+ for (const choice of choices) {
1005
+ next[choice.value] = picked.has(choice.value);
1006
+ }
1007
+ for (const choice of EXTRA_CHOICES) {
1008
+ if (!extraApplies(choice.value, layers.auth) && flags.extras[choice.value] === undefined) {
1009
+ next[choice.value] = false;
1010
+ }
1011
+ }
1012
+ return next;
1013
+ }
1014
+ async function promptLayers(flags, prompter) {
1015
+ const layers = applyFlagOverrides(defaultLayers(), flags);
1016
+ layers.frontend = await prompter.select("Frontend", [
1017
+ { value: "api", label: "api: JSON only" },
1018
+ { value: "server-htmx", label: "server-htmx: Eta HTML + HTMX" },
1019
+ { value: "spa-react", label: "spa-react: JSON + React under SPA_PREFIX" },
1020
+ { value: "hybrid", label: "hybrid: HTML at / plus SPA prefix" }
1021
+ ], layers.frontend);
1022
+ layers.database = await prompter.select("Database (one engine)", [
1023
+ { value: "sqlite", label: "sqlite: file database" },
1024
+ { value: "postgres", label: "postgres" },
1025
+ { value: "mysql", label: "mysql" }
1026
+ ], layers.database);
1027
+ layers.auth = await prompter.select("Auth", [
1028
+ { value: "headers", label: "headers: x-authenticated-user-id (local/tests)" },
1029
+ { value: "cookie", label: "cookie: sessions table + CSRF" },
1030
+ { value: "token", label: "token: opaque hashed Bearer" },
1031
+ { value: "jwt", label: "jwt: short-lived HS256" },
1032
+ { value: "cookie-token", label: "cookie-token: HTML cookies + API tokens" },
1033
+ { value: "cookie-token-jwt", label: "cookie-token-jwt: cookies, tokens, and JWT" }
1034
+ ], layers.auth);
1035
+ layers.tenancy = await prompter.select("Tenancy", layers.database === "postgres" ? [
1036
+ { value: "none", label: "none: no tenant table" },
1037
+ {
1038
+ value: "column",
1039
+ label: "column: tenant table + users.tenant_id (no Postgres SET LOCAL)"
1040
+ },
1041
+ { value: "rls", label: "rls: Postgres row-level security plus a tenant table" }
1042
+ ] : [
1043
+ { value: "none", label: "none: no tenant table" },
1044
+ {
1045
+ value: "column",
1046
+ label: "column: tenant table + users.tenant_id (SQLite/MySQL cannot run Postgres RLS)"
1047
+ }
1048
+ ], layers.database === "postgres" ? layers.tenancy : layers.tenancy === "rls" ? "column" : layers.tenancy);
1049
+ layers.cache = await prompter.select("Cache", [
1050
+ { value: "array", label: "array: in-process" },
1051
+ { value: "redis", label: "redis" }
1052
+ ], layers.cache);
1053
+ layers.queue = await prompter.select("Queue", [
1054
+ { value: "sync", label: "sync: run jobs inline" },
1055
+ { value: "redis", label: "redis: background worker" }
1056
+ ], layers.queue);
1057
+ layers.mail = await prompter.select("Mail", [
1058
+ { value: "log", label: "log: print messages" },
1059
+ { value: "smtp", label: "smtp" }
1060
+ ], layers.mail);
1061
+ if (flags.spaPrefix === undefined && (layers.frontend === "spa-react" || layers.frontend === "hybrid")) {
1062
+ layers.spaPrefix = await prompter.question("SPA prefix", layers.spaPrefix);
1063
+ }
1064
+ layers.extras = await promptExtras(prompter, layers.extras, layers, flags);
1065
+ if (!dockerFlagsProvided(flags)) {
1066
+ layers.docker = await promptDockerLayer(prompter, layers);
1067
+ }
1068
+ return applyFlagOverrides(layers, flags);
1069
+ }
1070
+ async function promptDockerLayer(prompter, layers) {
1071
+ const needed = neededDockerServices(layers);
1072
+ if (needed.length === 0) {
1073
+ return dockerLayerForNeeded(layers, false);
1074
+ }
1075
+ const labels = needed.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
1076
+ const mode = await prompter.select(`How should supporting tools run (${labels})?`, [
1077
+ { value: "local", label: "local: installs already on this machine" },
1078
+ { value: "docker", label: "docker: Compose for all of them" },
1079
+ { value: "mix", label: "mix: pick Docker Compose vs local per tool" }
1080
+ ], "docker");
1081
+ if (mode === "local") {
1082
+ return dockerLayerForNeeded(layers, false);
1083
+ }
1084
+ if (mode === "docker") {
1085
+ return dockerLayerForNeeded(layers, true);
1086
+ }
1087
+ const services = emptyDockerServices();
1088
+ for (const name of needed) {
1089
+ services[name] = await prompter.confirm(`Docker Compose for ${DOCKER_SERVICE_LABELS[name]}?`, true);
1090
+ }
1091
+ const databaseService = dockerDatabaseService(layers.database);
1092
+ if (databaseService && services[databaseService]) {
1093
+ services.adminer = await prompter.confirm(`Docker Compose for ${DOCKER_SERVICE_LABELS.adminer}?`, true);
1094
+ }
1095
+ const selected = needed.filter((name) => services[name]);
1096
+ if (services.adminer) {
1097
+ selected.push("adminer");
1098
+ }
1099
+ return {
1100
+ enabled: selected.length > 0,
1101
+ services
1102
+ };
1103
+ }
1104
+ async function resolveStarterPlan(flags, injected) {
1105
+ if (!isInteractive(flags)) {
1106
+ return {
1107
+ projectName: flags.projectName ?? "strata-app",
1108
+ layers: layersFromFlags(flags)
1109
+ };
1110
+ }
1111
+ const prompter = injected ?? createReadlinePrompter();
1112
+ try {
1113
+ const projectName = flags.projectName || await prompter.question("Project name", "strata-app") || "strata-app";
1114
+ const layers = await promptLayers({ ...flags, projectName }, prompter);
1115
+ return { projectName, layers };
1116
+ } finally {
1117
+ if (!injected) {
1118
+ prompter.close();
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ // ../strata-starter/src/renderAuthFlows.ts
1124
+ function ph(layers, count, start = 1) {
1125
+ if (layers.database === "postgres") {
1126
+ return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
1127
+ }
1128
+ return Array.from({ length: count }, () => "?").join(", ");
1129
+ }
1130
+ function sqlFalse(layers) {
1131
+ return layers.database === "postgres" ? "false" : "0";
1132
+ }
1133
+ function sqlTrue(layers) {
1134
+ return layers.database === "postgres" ? "true" : "1";
1135
+ }
1136
+ function renderPendingMfaTs() {
1137
+ return `import { createHmac, timingSafeEqual } from "node:crypto";
1138
+ import { sessionSecret } from "./config.ts";
1139
+
1140
+ const COOKIE = "strata_mfa_pending";
1141
+
1142
+ function sign(userId: number, issuedAt: number): string {
1143
+ const payload = \`\${userId}.\${issuedAt}\`;
1144
+ const signature = createHmac("sha256", sessionSecret()).update(payload).digest("hex");
1145
+ return \`\${payload}.\${signature}\`;
1146
+ }
1147
+
1148
+ export function pendingMfaSetCookie(userId: number): string {
1149
+ const issuedAt = Date.now();
1150
+ return \`\${COOKIE}=\${sign(userId, issuedAt)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600\`;
1151
+ }
1152
+
1153
+ export function pendingMfaClearCookie(): string {
1154
+ return \`\${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0\`;
1155
+ }
1156
+
1157
+ export function readPendingMfaUserId(request: Request): number | null {
1158
+ const header = request.headers.get("cookie") ?? "";
1159
+ for (const part of header.split(";")) {
1160
+ const [name, ...rest] = part.trim().split("=");
1161
+ if (name !== COOKIE) {
1162
+ continue;
1163
+ }
1164
+ const value = rest.join("=");
1165
+ const pieces = value.split(".");
1166
+ if (pieces.length !== 3) {
1167
+ return null;
1168
+ }
1169
+ const userId = Number.parseInt(pieces[0] ?? "", 10);
1170
+ const issuedAt = Number.parseInt(pieces[1] ?? "", 10);
1171
+ const signature = pieces[2] ?? "";
1172
+ if (!Number.isInteger(userId) || userId <= 0 || Date.now() - issuedAt > 10 * 60 * 1000) {
1173
+ return null;
1174
+ }
1175
+ const expected = sign(userId, issuedAt).split(".").pop() ?? "";
1176
+ const left = Buffer.from(signature);
1177
+ const right = Buffer.from(expected);
1178
+ if (left.length !== right.length || !timingSafeEqual(left, right)) {
1179
+ return null;
1180
+ }
1181
+ return userId;
1182
+ }
1183
+ return null;
1184
+ }
1185
+ `;
1186
+ }
1187
+ function renderAuthModule(layers) {
1188
+ if (!authNeedsUsers(layers.auth)) {
1189
+ return null;
1190
+ }
1191
+ const cookie = htmlAuthKit(layers.auth);
1192
+ const mfa = Boolean(layers.extras.mfa && cookie);
1193
+ const verify = Boolean(layers.extras.emailVerification);
1194
+ const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
1195
+ const tenantInsert = usesTenantTable(layers.tenancy);
1196
+ const insertCols = tenantInsert ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
1197
+ const insertPh = tenantInsert ? ph(layers, 5) : ph(layers, 4);
1198
+ const insertTail = tenantInsert ? `, ${sqlFalse(layers)}, 1` : `, ${sqlFalse(layers)}`;
1199
+ const passwordPh = `${ph(layers, 1)}`;
1200
+ const emailPh = `${ph(layers, 1, 2)}`;
1201
+ const idPh = `${ph(layers, 1, 2)}`;
1202
+ const verifiedPh = `${ph(layers, 1)}`;
1203
+ const mfaUpdatePh = `${ph(layers, 1)}, ${ph(layers, 1, 2)}, ${ph(layers, 1, 3)}`;
1204
+ const mfaIdPh = `${ph(layers, 1, 4)}`;
1205
+ const imports = [];
1206
+ if (authUsesToken(layers.auth)) {
1207
+ imports.push(`import { randomBytes } from "node:crypto";`);
1208
+ }
1209
+ imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
1210
+ imports.push(`import type { AppModule } from "@getstrata/bootstrap/contracts";`);
1211
+ if (cookie) {
1212
+ imports.push(`import { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
1213
+ imports.push(`import { wrapWebLogin, wrapWebRegister } from "@getstrata/bootstrap/web/routing";`);
1214
+ imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
1215
+ }
1216
+ if (jsonApi) {
1217
+ imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
1218
+ }
1219
+ if (authUsesJwt(layers.auth)) {
1220
+ imports.push(`import { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
1221
+ }
1222
+ imports.push(`import { hashPassword, verifyPassword } from "@getstrata/core/auth/password";`);
1223
+ if (authUsesToken(layers.auth)) {
1224
+ imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
1225
+ imports.push(`import { sqlTimestamp } from "@getstrata/core/database/dialect";`);
1226
+ imports.push(`import { resolveDefaultTokenExpiryDays } from "@getstrata/core/security/tokenExpiry";`);
1227
+ }
1228
+ if (mfa) {
1229
+ imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
1230
+ }
1231
+ if (cookie) {
1232
+ imports.push(`import { flashResponse } from "@getstrata/core/http/flashSession";`);
1233
+ }
1234
+ if (jsonApi) {
1235
+ imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
1236
+ }
1237
+ imports.push(`import { absoluteTemporarySignedUrl, assertValidSignature } from "@getstrata/core/http/signedUrl";`);
1238
+ imports.push(`import { mailer } from "@getstrata/core/mail/mailer";`);
1239
+ if (mfa) {
1240
+ imports.push(`import { generateRecoveryCodes, hashRecoveryCode, recoveryCodeMatches } from "@getstrata/core/security/recoveryCodes";`);
1241
+ imports.push(`import { buildOtpauthUrl, generateTotpSecret, verifyTotp } from "@getstrata/core/security/totp";`);
1242
+ }
1243
+ imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
1244
+ imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
1245
+ if (cookie) {
1246
+ imports.push(`import { renderPage } from "../../lib/view.ts";`);
1247
+ }
1248
+ if (mfa) {
1249
+ imports.push(`import { pendingMfaClearCookie, pendingMfaSetCookie, readPendingMfaUserId } from "../../bootstrap/pendingMfa.ts";`);
1250
+ }
1251
+ const helpers = `
1252
+ async function sendSignedMail(to: string, subject: string, path: string, query: Record<string, string>) {
1253
+ const link = absoluteTemporarySignedUrl(path, 3600, query);
1254
+ await mailer().send({
1255
+ to,
1256
+ subject,
1257
+ body: \`\${subject}\\n\\n\${link}\\n\`,
1258
+ });
1259
+ }
1260
+ ${cookie ? `
1261
+ function redirectTo(path: string, status = 302): Response {
1262
+ return new Response(null, { status, headers: { location: path } });
1263
+ }
1264
+
1265
+ function sessionUser(user: { id: number; name?: string | null; email?: string | null; role: string }) {
1266
+ return {
1267
+ id: user.id,
1268
+ name: user.name ?? user.email ?? "",
1269
+ email: user.email ?? "",
1270
+ is_admin: user.role === "admin",
1271
+ };
1272
+ }
1273
+ ` : ""}`;
1274
+ const tokenLogin = authUsesToken(layers.auth) ? `
1275
+ "/api/v1/auth/login": {
1276
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1277
+ const body = (await request.json()) as { email?: string; password?: string };
1278
+ const email = (body.email ?? "").trim().toLowerCase();
1279
+ const password = body.password ?? "";
1280
+ const user = await starterAuthDirectory.verifyCredentials?.(email, password);
1281
+ if (!user) {
1282
+ return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
1283
+ }
1284
+ const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
1285
+ // API_TOKEN_DEFAULT_EXPIRY_DAYS bounds every minted token; unset means no expiry.
1286
+ const expiryDays = resolveDefaultTokenExpiryDays();
1287
+ const expiresAt = expiryDays
1288
+ ? new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000)
1289
+ : null;
1290
+ await getSql().unsafe(
1291
+ "INSERT INTO api_tokens (user_id, name, token_hash, abilities, expires_at) VALUES (${ph(layers, 5)})",
1292
+ [
1293
+ user.id,
1294
+ "spa",
1295
+ hashApiToken(plain),
1296
+ JSON.stringify(["profile:read"]),
1297
+ expiresAt ? sqlTimestamp(expiresAt) : null,
1298
+ ],
1299
+ );
1300
+ return jsonResponse({ token: plain, expires_at: expiresAt?.toISOString() ?? null });
1301
+ })),
1302
+ },
1303
+ "/api/v1/auth/me": {
1304
+ GET: kernel.wrapApi(async (request) => {
1305
+ const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
1306
+ const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
1307
+ return jsonResponse({
1308
+ id: record.id,
1309
+ name: record.name ?? record.email,
1310
+ email: record.email,
1311
+ role: record.role,
1312
+ });
1313
+ }),
1314
+ },` : "";
1315
+ const jsonRegister = jsonApi ? `
1316
+ "/api/v1/auth/register": {
1317
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1318
+ const body = (await request.json()) as { name?: string; email?: string; password?: string };
1319
+ const name = (body.name ?? "").trim();
1320
+ const email = (body.email ?? "").trim().toLowerCase();
1321
+ const password = body.password ?? "";
1322
+ if (!name || !email || password.length < 8) {
1323
+ return jsonResponse({ error: "Name, email, and a password of 8+ characters are required." }, { status: 422 });
1324
+ }
1325
+ if (await starterAuthDirectory.findByEmail?.(email)) {
1326
+ return jsonResponse({ error: "Email is already registered." }, { status: 422 });
1327
+ }
1328
+ const hashed = await hashPassword(password);
1329
+ await getSql().unsafe(
1330
+ "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
1331
+ [name, email, hashed${insertTail}],
1332
+ );
1333
+ const created = await starterAuthDirectory.findByEmail?.(email);
1334
+ ${verify ? `if (created) {
1335
+ await sendSignedMail(email, "Verify your email", "/api/v1/auth/verify-email", { id: String(created.id) });
1336
+ }` : ""}
1337
+ return jsonResponse({ ok: true }, { status: 201 });
1338
+ })),
1339
+ },` : "";
1340
+ const jwtLogin = authUsesJwt(layers.auth) ? `
1341
+ "/api/auth/token": {
1342
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1343
+ const body = (await request.json()) as { email?: string; password?: string };
1344
+ const email = (body.email ?? "").trim().toLowerCase();
1345
+ const password = body.password ?? "";
1346
+ const user = await starterAuthDirectory.verifyCredentials?.(email, password);
1347
+ if (!user) {
1348
+ return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
1349
+ }
1350
+ const token = signJwt({
1351
+ sub: user.id,
1352
+ role: user.role,
1353
+ abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"],${verify ? `
1354
+ emailVerifiedAt: user.emailVerifiedAt ?? null,` : ""}
1355
+ });
1356
+ return jsonResponse({
1357
+ token,
1358
+ token_type: "bearer",
1359
+ expires_in: jwtTtlSeconds(),
1360
+ });
1361
+ })),
1362
+ },` : "";
1363
+ const apiUser = jsonApi ? `
1364
+ "/api/user": {
1365
+ GET: kernel.wrapApi(async (request) => {
1366
+ const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
1367
+ return jsonResponse({ id: user.id, role: user.role ?? "member" });
1368
+ }),
1369
+ },` : "";
1370
+ const jsonPassword = jsonApi ? `
1371
+ "/api/v1/auth/forgot-password": {
1372
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1373
+ const body = (await request.json()) as { email?: string };
1374
+ const email = (body.email ?? "").trim().toLowerCase();
1375
+ const user = await starterAuthDirectory.findByEmail?.(email);
1376
+ if (user) {
1377
+ await sendSignedMail(email, "Reset your password", "/api/v1/auth/reset-password", { email });
1378
+ }
1379
+ return jsonResponse({ ok: true });
1380
+ })),
1381
+ },
1382
+ "/api/v1/auth/reset-password": {
1383
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1384
+ assertValidSignature(request);
1385
+ const body = (await request.json()) as { password?: string };
1386
+ const email = new URL(request.url).searchParams.get("email") ?? "";
1387
+ if (!email || !(body.password && body.password.length >= 8)) {
1388
+ return jsonResponse({ error: "Invalid reset payload." }, { status: 422 });
1389
+ }
1390
+ await getSql().unsafe(
1391
+ "UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
1392
+ [await hashPassword(body.password), email],
1393
+ );
1394
+ return jsonResponse({ ok: true });
1395
+ })),
1396
+ },` : "";
1397
+ const jsonVerify = jsonApi && verify ? `
1398
+ "/api/v1/auth/verify-email": {
1399
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1400
+ assertValidSignature(request);
1401
+ const id = Number.parseInt(new URL(request.url).searchParams.get("id") ?? "", 10);
1402
+ if (!Number.isInteger(id) || id <= 0) {
1403
+ return jsonResponse({ error: "Invalid verification link." }, { status: 422 });
1404
+ }
1405
+ await getSql().unsafe(
1406
+ "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1407
+ [${nowTimestampLiteral(layers.database)}, id],
1408
+ );
1409
+ return jsonResponse({ ok: true });
1410
+ })),
1411
+ },` : "";
1412
+ const apiRoutes = jsonApi ? `
1413
+ routes({ kernel, dependencies }) {
1414
+ return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1415
+ };
1416
+ },` : "";
1417
+ const mfaLoginBranch = mfa ? `if (user.mfa_enabled) {
1418
+ const pending = redirectTo("/login/mfa");
1419
+ pending.headers.append("set-cookie", pendingMfaSetCookie(user.id));
1420
+ return pending;
1421
+ }` : "";
1422
+ const verifyRegisterBranch = verify ? `await sendSignedMail(email, "Verify your email", "/email/verify", { id: String(insertedId) });
1423
+ return flashResponse(
1424
+ await auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/email/verify"),
1425
+ { level: "info", message: "Check your email for a verification link." },
1426
+ );` : `return auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/");`;
1427
+ const cookieRoutes = cookie ? `
1428
+ webRoutes({ kernel, dependencies }) {
1429
+ const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1430
+ return {
1431
+ "/login": {
1432
+ GET: kernel.wrapWebGuest(async (request) =>
1433
+ renderPage("auth/login.eta", { layout: { title: "Sign in" }, errors: {}, email: "", password: "" }, request),
1434
+ ),
1435
+ POST: wrapWebLogin(
1436
+ kernel,
1437
+ async (request) => {
1438
+ const { fields } = await parseFormBody(request);
1439
+ const email = (fields.email ?? "").trim().toLowerCase();
1440
+ const password = fields.password ?? "";
1441
+ const user = await starterAuthDirectory.findByEmail?.(email);
1442
+ if (!user?.password || !(await verifyPassword(password, user.password))) {
1443
+ return renderPage(
1444
+ "auth/login.eta",
1445
+ { layout: { title: "Sign in" }, errors: { email: "These credentials do not match our records." }, email, password: "" },
1446
+ request,
1447
+ );
1448
+ }
1449
+ ${mfaLoginBranch}
1450
+ return auth.signInRedirect(sessionUser(user), "/");
1451
+ },
1452
+ async (request) =>
1453
+ renderPage(
1454
+ "auth/login.eta",
1455
+ { layout: { title: "Sign in" }, errors: { email: "Too many login attempts. Try again shortly." }, email: "", password: "" },
1456
+ request,
1457
+ 429,
1458
+ ),
1459
+ ),
1460
+ },
1461
+ "/register": {
1462
+ GET: kernel.wrapWebGuest(async (request) =>
1463
+ renderPage("auth/register.eta", { layout: { title: "Create account" }, errors: {}, name: "", email: "", password: "" }, request),
1464
+ ),
1465
+ POST: wrapWebRegister(
1466
+ kernel,
1467
+ async (request) => {
1468
+ const { fields } = await parseFormBody(request);
1469
+ const name = (fields.name ?? "").trim();
1470
+ const email = (fields.email ?? "").trim().toLowerCase();
1471
+ const password = fields.password ?? "";
1472
+ const errors: Record<string, string> = {};
1473
+ if (!name) {
1474
+ errors.name = "Name is required.";
1475
+ }
1476
+ if (!email) {
1477
+ errors.email = "Email is required.";
1478
+ }
1479
+ if (password.length < 8) {
1480
+ errors.password = "Use at least 8 characters.";
1481
+ }
1482
+ if (email && (await starterAuthDirectory.findByEmail?.(email))) {
1483
+ errors.email = "Email is already registered.";
1484
+ }
1485
+ if (Object.keys(errors).length > 0) {
1486
+ return renderPage(
1487
+ "auth/register.eta",
1488
+ { layout: { title: "Create account" }, errors, name, email, password: "" },
1489
+ request,
1490
+ );
1491
+ }
1492
+ const hashed = await hashPassword(password);
1493
+ await getSql().unsafe(
1494
+ "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
1495
+ [name, email, hashed${insertTail}],
1496
+ );
1497
+ const created = await starterAuthDirectory.findByEmail?.(email);
1498
+ const insertedId = created?.id ?? 0;
1499
+ ${verifyRegisterBranch}
1500
+ },
1501
+ async (request) =>
1502
+ renderPage(
1503
+ "auth/register.eta",
1504
+ { layout: { title: "Create account" }, errors: { form: "Too many registration attempts. Try again shortly." }, name: "", email: "", password: "" },
1505
+ request,
1506
+ 429,
1507
+ ),
1508
+ ),
1509
+ },
1510
+ "/forgot-password": {
1511
+ GET: kernel.wrapWebGuest(async (request) =>
1512
+ renderPage("auth/forgot-password.eta", { layout: { title: "Forgot password" }, errors: {}, email: "" }, request),
1513
+ ),
1514
+ POST: kernel.wrapWeb(async (request) => {
1515
+ const { fields } = await parseFormBody(request);
1516
+ const email = (fields.email ?? "").trim().toLowerCase();
1517
+ const user = await starterAuthDirectory.findByEmail?.(email);
1518
+ if (user) {
1519
+ await sendSignedMail(email, "Reset your password", "/reset-password", { email });
1520
+ }
1521
+ return flashResponse(redirectTo("/forgot-password"), {
1522
+ level: "success",
1523
+ message: "If that account exists, a reset link is on its way.",
1524
+ });
1525
+ }),
1526
+ },
1527
+ "/reset-password": {
1528
+ GET: kernel.wrapWebGuest(async (request) => {
1529
+ assertValidSignature(request);
1530
+ const email = new URL(request.url).searchParams.get("email") ?? "";
1531
+ return renderPage(
1532
+ "auth/reset-password.eta",
1533
+ { layout: { title: "Reset password" }, errors: {}, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1534
+ request,
1535
+ );
1536
+ }),
1537
+ POST: kernel.wrapWeb(async (request) => {
1538
+ assertValidSignature(request);
1539
+ const { fields } = await parseFormBody(request);
1540
+ const email = new URL(request.url).searchParams.get("email") ?? fields.email ?? "";
1541
+ const password = fields.password ?? "";
1542
+ if (!email || password.length < 8) {
1543
+ return renderPage(
1544
+ "auth/reset-password.eta",
1545
+ { layout: { title: "Reset password" }, errors: { password: "Use at least 8 characters." }, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1546
+ request,
1547
+ );
1548
+ }
1549
+ await getSql().unsafe(
1550
+ "UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
1551
+ [await hashPassword(password), email],
1552
+ );
1553
+ return flashResponse(redirectTo("/login"), { level: "success", message: "Password updated. Sign in." });
1554
+ }),
1555
+ },
1556
+ "/logout": {
1557
+ POST: kernel.wrapWebAuthenticatedAllowUnverified((request) => auth.signOutRedirect(request, "/")),
1558
+ },${verify ? `
1559
+ "/email/verify": {
1560
+ GET: kernel.wrapWeb(async (request) => {
1561
+ const url = new URL(request.url);
1562
+ if (url.searchParams.get("signature")) {
1563
+ assertValidSignature(request);
1564
+ const id = Number.parseInt(url.searchParams.get("id") ?? "", 10);
1565
+ if (Number.isInteger(id) && id > 0) {
1566
+ await getSql().unsafe(
1567
+ "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1568
+ [${nowTimestampLiteral(layers.database)}, id],
1569
+ );
1570
+ const record = await starterAuthDirectory.findByIdOrThrow(id);
1571
+ return flashResponse(
1572
+ await auth.signInRedirect(sessionUser(record), "/"),
1573
+ { level: "success", message: "Email verified." },
1574
+ );
1575
+ }
1576
+ }
1577
+ return renderPage("auth/verify-email.eta", { layout: { title: "Verify email" } }, request);
1578
+ }),
1579
+ },
1580
+ "/email/verification-notification": {
1581
+ POST: kernel.wrapWebAuthenticatedAllowUnverified(async (request) => {
1582
+ const user = await auth.user(request);
1583
+ if (user) {
1584
+ const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
1585
+ await sendSignedMail(record.email ?? "", "Verify your email", "/email/verify", { id: String(record.id) });
1586
+ }
1587
+ return flashResponse(redirectTo("/email/verify"), { level: "info", message: "Verification link sent." });
1588
+ }),
1589
+ },` : ""}${mfa ? `
1590
+ "/login/mfa": {
1591
+ GET: kernel.wrapWebGuest(async (request) => {
1592
+ if (!readPendingMfaUserId(request)) {
1593
+ return redirectTo("/login");
1594
+ }
1595
+ return renderPage("auth/mfa-challenge.eta", { layout: { title: "MFA" }, errors: {}, code: "" }, request);
1596
+ }),
1597
+ POST: kernel.wrapWeb(async (request) => {
1598
+ const pendingId = readPendingMfaUserId(request);
1599
+ if (!pendingId) {
1600
+ return redirectTo("/login");
1601
+ }
1602
+ const { fields } = await parseFormBody(request);
1603
+ const submitted = (fields.code ?? "").trim();
1604
+ const record = await starterAuthDirectory.findByIdOrThrow(pendingId);
1605
+ const secret = revealMfaSecret(record.mfa_secret ?? null);
1606
+ const hashedCodes: string[] = record.mfa_recovery_codes
1607
+ ? (JSON.parse(record.mfa_recovery_codes) as string[])
1608
+ : [];
1609
+ const totpOk = secret ? verifyTotp(secret, submitted) : false;
1610
+ const recoveryOk = hashedCodes.some((hash) => recoveryCodeMatches(submitted, hash));
1611
+ if (!totpOk && !recoveryOk) {
1612
+ return renderPage(
1613
+ "auth/mfa-challenge.eta",
1614
+ { layout: { title: "MFA" }, errors: { code: "That code is not valid." }, code: "" },
1615
+ request,
1616
+ );
1617
+ }
1618
+ if (recoveryOk) {
1619
+ const remaining = hashedCodes.filter((hash) => !recoveryCodeMatches(submitted, hash));
1620
+ await getSql().unsafe(
1621
+ "UPDATE users SET mfa_recovery_codes = ${passwordPh} WHERE id = ${idPh}",
1622
+ [JSON.stringify(remaining), pendingId],
1623
+ );
1624
+ }
1625
+ const signed = await auth.signInRedirect(sessionUser(record), "/");
1626
+ signed.headers.append("set-cookie", pendingMfaClearCookie());
1627
+ return signed;
1628
+ }),
1629
+ },
1630
+ "/account/mfa": {
1631
+ GET: kernel.wrapWebAuthenticated(async (request) => {
1632
+ const secret = generateTotpSecret();
1633
+ const user = await auth.user(request);
1634
+ const record = user ? await starterAuthDirectory.findByIdOrThrow(Number(user.id)) : null;
1635
+ const otpauth = buildOtpauthUrl({
1636
+ secret,
1637
+ account: record?.email ?? "user",
1638
+ issuer: process.env.APP_NAME ?? "Strata",
1639
+ });
1640
+ return renderPage(
1641
+ "auth/mfa-setup.eta",
1642
+ { layout: { title: "MFA" }, errors: {}, code: "", secret, otpauth },
1643
+ request,
1644
+ );
1645
+ }),
1646
+ POST: kernel.wrapWebAuthenticated(async (request) => {
1647
+ const user = await auth.user(request);
1648
+ if (!user) {
1649
+ return redirectTo("/login");
1650
+ }
1651
+ const { fields } = await parseFormBody(request);
1652
+ const secret = (fields.secret ?? "").trim();
1653
+ const submitted = (fields.code ?? "").trim();
1654
+ if (!secret || !verifyTotp(secret, submitted)) {
1655
+ return renderPage(
1656
+ "auth/mfa-setup.eta",
1657
+ {
1658
+ layout: { title: "MFA" },
1659
+ errors: { code: "Could not confirm that code." },
1660
+ code: "",
1661
+ secret,
1662
+ otpauth: buildOtpauthUrl({ secret, account: "user", issuer: process.env.APP_NAME ?? "Strata" }),
1663
+ },
1664
+ request,
1665
+ );
1666
+ }
1667
+ const recoveryCodes = generateRecoveryCodes();
1668
+ const stored = protectMfaSecret(secret);
1669
+ await getSql().unsafe(
1670
+ "UPDATE users SET mfa_secret = ${mfaUpdatePh.split(", ")[0]}, mfa_enabled = ${mfaUpdatePh.split(", ")[1]}, mfa_recovery_codes = ${mfaUpdatePh.split(", ")[2]} WHERE id = ${mfaIdPh}",
1671
+ [stored, ${sqlTrue(layers)}, JSON.stringify(recoveryCodes.map((item) => hashRecoveryCode(item))), Number(user.id)],
1672
+ );
1673
+ return renderPage(
1674
+ "auth/mfa-setup.eta",
1675
+ {
1676
+ layout: { title: "MFA" },
1677
+ errors: {},
1678
+ code: "",
1679
+ secret,
1680
+ otpauth: buildOtpauthUrl({ secret, account: "user", issuer: process.env.APP_NAME ?? "Strata" }),
1681
+ recoveryCodes,
1682
+ },
1683
+ request,
1684
+ );
1685
+ }),
1686
+ },` : ""}
1687
+ };
1688
+ },` : "";
1689
+ return `${imports.join(`
1690
+ `)}
1691
+ ${helpers}
1692
+ const authModule: AppModule = {
1693
+ name: "auth",
1694
+ order: 2,${apiRoutes}${cookieRoutes}
1695
+ };
1696
+
1697
+ export default authModule;
1698
+ `;
1699
+ }
1700
+ function renderSiteModule(_layers) {
1701
+ return `import type { AppModule } from "@getstrata/bootstrap/contracts";
1702
+ import { withErrorHandling } from "@getstrata/core/http/response";
1703
+ import { getSql, pingDatabase } from "../../bootstrap/database.ts";
1704
+ import { plainText, renderPage } from "../../lib/view.ts";
1705
+
1706
+ // Proves the database answers and the schema is migrated. Point it at a table your app owns.
1707
+ async function schemaReady(): Promise<boolean> {
1708
+ try {
1709
+ await getSql().unsafe("SELECT 1 FROM notes LIMIT 1");
1710
+ return true;
1711
+ } catch {
1712
+ return false;
1713
+ }
1714
+ }
1715
+
1716
+ const siteModule: AppModule = {
1717
+ name: "site",
1718
+ order: 1,
1719
+ routes({ kernel }) {
1720
+ return {
1721
+ "/health": kernel.wrap("api", withErrorHandling(async () => {
1722
+ const ok = (await pingDatabase()) && (await schemaReady());
1723
+ return plainText(ok ? "ok" : "degraded", ok ? 200 : 503);
1724
+ })),
1725
+ };
1726
+ },
1727
+ webRoutes({ kernel }) {
1728
+ return {
1729
+ "/": kernel.wrapWeb(async (request) =>
1730
+ renderPage(
1731
+ "home.eta",
1732
+ {
1733
+ layout: {
1734
+ title: "Welcome",
1735
+ description: "Welcome to your Strata app. Restyle views/home.eta and public/assets/site.css.",
1736
+ },
1737
+ },
1738
+ request,
1739
+ ),
1740
+ ),
1741
+ };
1742
+ },
1743
+ };
1744
+
1745
+ export default siteModule;
1746
+ `;
1747
+ }
1748
+ // ../strata-starter/src/renderAuthViews.ts
1749
+ function renderSiteCss() {
1750
+ return `:root {
1751
+ color-scheme: light;
1752
+ --bg: #f4f1ea;
1753
+ --ink: #1c1917;
1754
+ --muted: #57534e;
1755
+ --card: #fffdf8;
1756
+ --line: #e7e0d4;
1757
+ --accent: #1d4e4f;
1758
+ --accent-ink: #f8faf8;
1759
+ --danger: #9f1239;
1760
+ --ok: #166534;
1761
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, serif;
1762
+ line-height: 1.5;
1763
+ }
1764
+
1765
+ * { box-sizing: border-box; }
1766
+
1767
+ body {
1768
+ margin: 0;
1769
+ min-height: 100vh;
1770
+ background: var(--bg);
1771
+ color: var(--ink);
1772
+ }
1773
+
1774
+ .site-header {
1775
+ display: flex;
1776
+ align-items: center;
1777
+ justify-content: space-between;
1778
+ gap: 1rem;
1779
+ padding: 1rem 1.5rem;
1780
+ border-bottom: 1px solid var(--line);
1781
+ background: var(--card);
1782
+ }
1783
+
1784
+ .brand {
1785
+ font-weight: 700;
1786
+ text-decoration: none;
1787
+ color: inherit;
1788
+ letter-spacing: 0.02em;
1789
+ }
1790
+
1791
+ .site-header nav {
1792
+ display: flex;
1793
+ gap: 0.75rem;
1794
+ align-items: center;
1795
+ font-size: 0.95rem;
1796
+ }
1797
+
1798
+ .site-header a { color: inherit; }
1799
+
1800
+ .site-header form { display: inline; }
1801
+
1802
+ main {
1803
+ padding: 2rem 1.5rem 3rem;
1804
+ }
1805
+
1806
+ .section, .auth-card {
1807
+ max-width: 36rem;
1808
+ margin: 0 auto;
1809
+ background: var(--card);
1810
+ border: 1px solid var(--line);
1811
+ border-radius: 1rem;
1812
+ padding: 1.5rem 1.5rem 1.75rem;
1813
+ }
1814
+
1815
+ .hero {
1816
+ max-width: 40rem;
1817
+ }
1818
+
1819
+ .section h1, .auth-card h1, .hero h1 {
1820
+ margin: 0 0 0.5rem;
1821
+ font-size: 1.8rem;
1822
+ }
1823
+
1824
+ .lede, .muted { color: var(--muted); }
1825
+
1826
+ .actions {
1827
+ display: flex;
1828
+ flex-wrap: wrap;
1829
+ gap: 0.75rem;
1830
+ margin-top: 1.25rem;
1831
+ }
1832
+
1833
+ label {
1834
+ display: block;
1835
+ margin: 0.85rem 0;
1836
+ font-size: 0.95rem;
1837
+ }
1838
+
1839
+ input[type="email"],
1840
+ input[type="password"],
1841
+ input[type="text"] {
1842
+ display: block;
1843
+ width: 100%;
1844
+ margin-top: 0.35rem;
1845
+ padding: 0.55rem 0.7rem;
1846
+ border: 1px solid var(--line);
1847
+ border-radius: 0.5rem;
1848
+ background: #fff;
1849
+ font: inherit;
1850
+ }
1851
+
1852
+ button, .button {
1853
+ display: inline-block;
1854
+ border: 0;
1855
+ border-radius: 999px;
1856
+ padding: 0.55rem 1rem;
1857
+ background: var(--accent);
1858
+ color: var(--accent-ink);
1859
+ font: inherit;
1860
+ text-decoration: none;
1861
+ cursor: pointer;
1862
+ }
1863
+
1864
+ .button-secondary {
1865
+ background: transparent;
1866
+ color: var(--ink);
1867
+ border: 1px solid var(--line);
1868
+ }
1869
+
1870
+ .error { color: var(--danger); }
1871
+ .ok, .flash-success { color: var(--ok); }
1872
+ .flash-error { color: var(--danger); }
1873
+ .flash {
1874
+ margin: 0 0 1rem;
1875
+ padding: 0.6rem 0.8rem;
1876
+ border-radius: 0.5rem;
1877
+ border: 1px solid var(--line);
1878
+ }
1879
+
1880
+ .auth-links {
1881
+ margin-top: 1rem;
1882
+ display: flex;
1883
+ flex-wrap: wrap;
1884
+ gap: 0.75rem 1rem;
1885
+ }
1886
+
1887
+ code { font-size: 0.9em; }
1888
+ `;
1889
+ }
1890
+ function renderLayout(layers, projectName) {
1891
+ const kit = htmlAuthKit(layers.auth);
1892
+ const guestNav = kit ? `<a href="/login">Sign in</a>
1893
+ <a href="/register">Create account</a>` : "";
1894
+ const userNav = kit ? `<% if (it.currentUser) { %>
1895
+ <span class="muted"><%= it.currentUser.email %></span>
1896
+ ${layers.extras.mfa ? '<a href="/account/mfa">MFA</a>' : ""}
1897
+ <form method="post" action="/logout">
1898
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1899
+ <button class="button-secondary" type="submit">Sign out</button>
1900
+ </form>
1901
+ <% } else { %>
1902
+ ${guestNav}
1903
+ <% } %>` : "";
1904
+ return `<!DOCTYPE html>
1905
+ <html lang="en">
1906
+ <head>
1907
+ <meta charset="utf-8" />
1908
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1909
+ <title><%= it.layout.title %> \xB7 ${projectName}</title>
1910
+ <% if (it.layout.description) { %>
1911
+ <meta name="description" content="<%= it.layout.description %>" />
1912
+ <% } %>
1913
+ <link rel="stylesheet" href="/assets/site.css" />
1914
+ </head>
1915
+ <body>
1916
+ <header class="site-header">
1917
+ <a class="brand" href="/">${projectName}</a>
1918
+ <nav>
1919
+ ${userNav}
1920
+ </nav>
1921
+ </header>
1922
+ <main>
1923
+ <% if (it.flash && it.flash.message) { %>
1924
+ <p class="flash flash-<%= it.flash.level %>"><%= it.flash.message %></p>
1925
+ <% } %>
1926
+ <%~ it.body %>
1927
+ </main>
1928
+ </body>
1929
+ </html>
1930
+ `;
1931
+ }
1932
+ function renderHomeView(projectName, layers) {
1933
+ const kit = htmlAuthKit(layers.auth);
1934
+ const tokenHint = authUsesToken(layers.auth) ? '<p class="muted">API token: <code>POST /api/v1/auth/login</code> with email and password.</p>' : "";
1935
+ const jwtHint = authUsesJwt(layers.auth) ? '<p class="muted">JWT: <code>POST /api/auth/token</code> with email and password.</p>' : "";
1936
+ const guest = kit ? `<% if (!it.currentUser) { %>
1937
+ <p class="lede">Sign in or create an account. Edit <code>views/home.eta</code> and <code>public/assets/site.css</code> to restyle this page.</p>
1938
+ <div class="actions">
1939
+ <a class="button" href="/register">Create account</a>
1940
+ <a class="button button-secondary" href="/login">Sign in</a>
1941
+ </div>
1942
+ <p class="muted">Seeded demo: <code>demo@example.com</code> / <code>password</code>.</p>
1943
+ <% } else { %>
1944
+ <p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
1945
+ <p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
1946
+ <% } %>` : `<p class="lede">Edit <code>views/home.eta</code> and <code>public/assets/site.css</code> to restyle this page.</p>
1947
+ <p class="muted">Auth stack: <code>${layers.auth}</code>.</p>`;
1948
+ const extras = [tokenHint, jwtHint].filter(Boolean).join(`
1949
+ `);
1950
+ return `<section class="section hero">
1951
+ <h1>Welcome to ${projectName}</h1>
1952
+ ${guest}
1953
+ <p>Health check: <a href="/health"><code>/health</code></a>.</p>${extras ? `
1954
+ ${extras}` : ""}
1955
+ </section>
1956
+ `;
1957
+ }
1958
+ function renderFormView(title, fields, submit, links) {
1959
+ return `<section class="auth-card">
1960
+ <h1>${title}</h1>
1961
+ <% if (it.status) { %>
1962
+ <p class="ok"><%= it.status %></p>
1963
+ <% } %>
1964
+ <% if (it.errors && it.errors.form) { %>
1965
+ <p class="error"><%= it.errors.form %></p>
1966
+ <% } %>
1967
+ <form method="post" action="<%= it.action || "" %>">
1968
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1969
+ ${fields}
1970
+ <div class="actions">
1971
+ <button type="submit">${submit}</button>
1972
+ </div>
1973
+ </form>
1974
+ <div class="auth-links">
1975
+ ${links}
1976
+ </div>
1977
+ </section>
1978
+ `;
1979
+ }
1980
+ function textField(name, label, type, extra = "") {
1981
+ return `<label>
1982
+ ${label}
1983
+ <% if (it.errors && it.errors.${name}) { %><span class="error"><%= it.errors.${name} %></span><% } %>
1984
+ <input type="${type}" name="${name}" value="<%= it.${name} || "" %>" ${extra} />
1985
+ </label>`;
1986
+ }
1987
+ function renderLoginView() {
1988
+ return renderFormView("Sign in", `${textField("email", "Email", "email", 'required autocomplete="username"')}
1989
+ ${textField("password", "Password", "password", 'required autocomplete="current-password"')}`, "Sign in", `<a href="/register">Create account</a>
1990
+ <a href="/forgot-password">Forgot password</a>`).replace('action="<%= it.action || "" %>"', 'action="/login"');
1991
+ }
1992
+ function renderRegisterView() {
1993
+ return renderFormView("Create account", `${textField("name", "Name", "text", "required")}
1994
+ ${textField("email", "Email", "email", 'required autocomplete="email"')}
1995
+ ${textField("password", "Password", "password", 'required minlength="8" autocomplete="new-password"')}`, "Create account", `<a href="/login">Already have an account</a>`).replace('action="<%= it.action || "" %>"', 'action="/register"');
1996
+ }
1997
+ function renderForgotPasswordView() {
1998
+ return renderFormView("Forgot password", textField("email", "Email", "email", "required"), "Send reset link", `<a href="/login">Back to sign in</a>`).replace('action="<%= it.action || "" %>"', 'action="/forgot-password"');
1999
+ }
2000
+ function renderResetPasswordView() {
2001
+ return renderFormView("Set a new password", `${textField("password", "New password", "password", 'required minlength="8" autocomplete="new-password"')}
2002
+ <input type="hidden" name="email" value="<%= it.email || "" %>" />`, "Update password", `<a href="/login">Back to sign in</a>`).replace('action="<%= it.action || "" %>"', 'action="<%= it.action %>"');
2003
+ }
2004
+ function renderVerifyEmailView() {
2005
+ return `<section class="auth-card">
2006
+ <h1>Verify your email</h1>
2007
+ <p>We sent a signed link to your inbox (or the mail log when <code>MAIL_DRIVER=log</code>).</p>
2008
+ <form method="post" action="/email/verification-notification">
2009
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
2010
+ <button type="submit">Resend link</button>
2011
+ </form>
2012
+ </section>
2013
+ `;
2014
+ }
2015
+ function renderMfaChallengeView() {
2016
+ return renderFormView("Two-factor code", `${textField("code", "Authenticator or recovery code", "text", 'required autocomplete="one-time-code"')}`, "Continue", `<a href="/login">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action="/login/mfa"');
2017
+ }
2018
+ function renderMfaSetupView() {
2019
+ return `<section class="auth-card">
2020
+ <h1>Authenticator app</h1>
2021
+ <p class="muted">Scan this otpauth URL in your authenticator, then confirm a code. Restyle this page in <code>views/auth/mfa-setup.eta</code>.</p>
2022
+ <p><code><%= it.otpauth %></code></p>
2023
+ <form method="post" action="/account/mfa">
2024
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
2025
+ <input type="hidden" name="secret" value="<%= it.secret %>" />
2026
+ ${textField("code", "Confirmation code", "text", "required")}
2027
+ <div class="actions"><button type="submit">Enable MFA</button></div>
2028
+ </form>
2029
+ <% if (it.recoveryCodes) { %>
2030
+ <h2>Recovery codes</h2>
2031
+ <p>Store these once. They will not be shown again.</p>
2032
+ <ul>
2033
+ <% for (const code of it.recoveryCodes) { %>
2034
+ <li><code><%= code %></code></li>
2035
+ <% } %>
2036
+ </ul>
2037
+ <% } %>
2038
+ </section>
2039
+ `;
2040
+ }
2041
+
2042
+ // ../strata-starter/src/renderAuth.ts
2043
+ function renderAuthDirectory(layers) {
2044
+ if (!authNeedsUsers(layers.auth)) {
2045
+ return null;
2046
+ }
2047
+ const tokenLookup = authUsesToken(layers.auth) ? `
2048
+ async resolveUserFromToken(token: string) {
2049
+ if (!token || token.split(".").length === 3) {
2050
+ return null;
2051
+ }
2052
+ const hashed = hashApiToken(token);
2053
+ const rows = await getSql().unsafe<
2054
+ {
2055
+ id: number;
2056
+ user_id: number;
2057
+ abilities: string;
2058
+ expires_at: Date | string | null;
2059
+ role?: string;
2060
+ is_admin?: number | boolean;
2061
+ email_verified_at?: Date | string | null;
2062
+ }
2063
+ >(
2064
+ \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
2065
+ FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
2066
+ WHERE t.token_hash = ?\`,
2067
+ [hashed],
2068
+ );
2069
+ const row = rows[0];
2070
+ if (!row) {
2071
+ return null;
2072
+ }
2073
+ if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
2074
+ return null;
2075
+ }
2076
+ let abilities: string[] = [];
2077
+ try {
2078
+ abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
2079
+ } catch {
2080
+ abilities = ["profile:read"];
2081
+ }
2082
+ return {
2083
+ id: Number(row.user_id),
2084
+ role: row.is_admin ? "admin" : "member",
2085
+ abilities,
2086
+ tokenId: Number(row.id),
2087
+ emailVerifiedAt: row.email_verified_at ?? null,
2088
+ };
2089
+ },` : `
2090
+ async resolveUserFromToken() {
2091
+ return null;
2092
+ },`;
2093
+ const placeholder = layers.database === "postgres" ? "$1" : "?";
2094
+ const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
2095
+ ` : "";
2096
+ const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
2097
+ const mfaColumns = layers.extras.mfa ? `
2098
+ mfa_enabled?: number | boolean;
2099
+ mfa_secret?: string | null;
2100
+ mfa_recovery_codes?: string | null;` : "";
2101
+ const mfaReturn = layers.extras.mfa ? `
2102
+ mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
2103
+ mfa_secret: row.mfa_secret ?? null,
2104
+ mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
2105
+ const userColumns = `id, name, email, is_admin, email_verified_at, password${mfaSelect}`;
2106
+ return `import type { AuthUser } from "@getstrata/core/auth/authContext";
2107
+ import { verifyPassword } from "@getstrata/core/auth/password";
2108
+ ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
2109
+ import { getSql } from "./database.ts";
2110
+
2111
+ type UserRow = {
2112
+ id: number;
2113
+ name: string;
2114
+ email: string;
2115
+ is_admin: number | boolean;
2116
+ email_verified_at: Date | string | null;
2117
+ password: string;${mfaColumns}
2118
+ };
2119
+
2120
+ function mapRole(isAdmin: unknown): string {
2121
+ return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
2122
+ }
2123
+
2124
+ function mapUserRow(row: UserRow) {
2125
+ return {
2126
+ id: Number(row.id),
2127
+ name: row.name,
2128
+ email: row.email,
2129
+ role: mapRole(row.is_admin),
2130
+ email_verified_at: row.email_verified_at ?? null,
2131
+ password: row.password,${mfaReturn}
2132
+ };
2133
+ }
2134
+
2135
+ async function findUserById(id: number) {
2136
+ const rows = await getSql().unsafe<UserRow>(
2137
+ "SELECT ${userColumns} FROM users WHERE id = ${placeholder}",
2138
+ [id],
2139
+ );
2140
+ const row = rows[0];
2141
+ if (!row) {
2142
+ throw new Error(\`User \${id} not found.\`);
2143
+ }
2144
+ return mapUserRow(row);
2145
+ }
2146
+
2147
+ async function findUserByEmail(email: string) {
2148
+ const rows = await getSql().unsafe<UserRow>(
2149
+ "SELECT ${userColumns} FROM users WHERE email = ${placeholder}",
2150
+ [email.trim().toLowerCase()],
2151
+ );
2152
+ const row = rows[0];
2153
+ return row ? mapUserRow(row) : null;
2154
+ }
2155
+
2156
+ export const starterAuthDirectory: AuthUserDirectory = {
2157
+ ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
2158
+
2159
+ findByIdOrThrow: findUserById,
2160
+
2161
+ findByEmail: findUserByEmail,
2162
+
2163
+ async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
2164
+ const user = await findUserByEmail(email);
2165
+ if (!user?.password || !(await verifyPassword(password, user.password))) {
2166
+ return null;
2167
+ }
2168
+ return {
2169
+ id: user.id,
2170
+ role: user.role,
2171
+ emailVerifiedAt: user.email_verified_at ?? null,
2172
+ };
2173
+ },
2174
+ };
2175
+ `;
2176
+ }
2177
+ function renderAuthProvider(layers) {
2178
+ if (layers.auth === "headers") {
2179
+ return `import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
2180
+ import type { AuthUser } from "@getstrata/core/auth/authContext";
2181
+ import { currentAuthUser } from "@getstrata/core/auth/authContext";
2182
+ import type { ServiceProvider } from "@getstrata/core/contracts/di";
2183
+
2184
+ class StarterAuthManager {
2185
+ async resolve(request?: Request): Promise<AuthUser | null> {
2186
+ if (process.env.AUTH_DEV_HEADERS === "false") {
2187
+ return request ? null : currentAuthUser();
2188
+ }
2189
+ if (request) {
2190
+ const userId = request.headers.get("x-authenticated-user-id");
2191
+ if (!userId) {
2192
+ return null;
2193
+ }
2194
+ const role = request.headers.get("x-authenticated-user-role");
2195
+ return {
2196
+ id: userId,
2197
+ ...(role ? { role } : {}),
2198
+ };
2199
+ }
2200
+ return currentAuthUser();
2201
+ }
2202
+
2203
+ user(request?: Request) {
2204
+ return this.resolve(request);
2205
+ }
2206
+
2207
+ async check(request: Request) {
2208
+ return (await this.user(request)) !== null;
2209
+ }
2210
+ }
2211
+
2212
+ const authProvider: ServiceProvider = {
2213
+ name: "starter.auth",
2214
+ register({ container }) {
2215
+ container.set(CORE_AUTH_TOKEN, new StarterAuthManager());
2216
+ },
2217
+ };
2218
+
2219
+ export default authProvider;
2220
+ `;
2221
+ }
2222
+ const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
2223
+ secret: sessionSecret(),
2224
+ cookieName: "strata_session",
2225
+ mapUser: (user) => ({
2226
+ id: user.id,
2227
+ role: user.is_admin ? "admin" : "member",
2228
+ ...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
2229
+ }),
2230
+ });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
2231
+ const auth = new AuthManager(fallback);`;
2232
+ const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
2233
+ auth.registerGuard("api", apiGuard);
2234
+ auth.registerGuard("access_token", apiGuard);
2235
+ auth.registerGuard("token", apiGuard);` : "";
2236
+ const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
2237
+ const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
2238
+ const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
2239
+ const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
2240
+ if (authUsesCookie(layers.auth)) {
2241
+ imports.push(`import { createCookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
2242
+ }
2243
+ if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
2244
+ imports.push(`import { BasicAuthGuard } from "@getstrata/core/auth/basicAuthGuard";`);
2245
+ }
2246
+ if (!authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
2247
+ imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
2248
+ } else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
2249
+ imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
2250
+ } else if (authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
2251
+ imports.push(`import { DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
2252
+ }
2253
+ if (authUsesJwt(layers.auth)) {
2254
+ imports.push(`import { JwtGuard } from "@getstrata/core/auth/jwtGuard";`);
2255
+ }
2256
+ if (authUsesToken(layers.auth)) {
2257
+ imports.push(`import { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
2258
+ }
2259
+ imports.push(`import type { ServiceProvider } from "@getstrata/core/contracts/di";`);
2260
+ const tokenImports = ["CORE_AUTH_USER_DIRECTORY_TOKEN"];
2261
+ if (authUsesToken(layers.auth)) {
2262
+ tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
2263
+ }
2264
+ imports.push(`import {
2265
+ ${tokenImports.join(`,
2266
+ `)},
2267
+ } from "@getstrata/core/contracts/serviceTokens";`);
2268
+ imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
2269
+ if (authUsesCookie(layers.auth)) {
2270
+ imports.push(`import { sessionSecret } from "../config.ts";`);
2271
+ }
2272
+ return `${imports.join(`
2273
+ `)}
2274
+
2275
+ const authProvider: ServiceProvider = {
2276
+ name: "starter.auth",
2277
+ register({ container }) {
2278
+ container.set(CORE_AUTH_USER_DIRECTORY_TOKEN, starterAuthDirectory);
2279
+ ${cookieBlock}
2280
+ ${tokenRegs}
2281
+ ${jwtReg}
2282
+ ${basicReg}
2283
+ ${ability}
2284
+ container.set(CORE_AUTH_TOKEN, auth);
2285
+ },
2286
+ };
2287
+
2288
+ export default authProvider;
2289
+ `;
2290
+ }
2291
+
2292
+ // ../strata-starter/src/renderEnv.ts
2293
+ function envFlag(value) {
2294
+ return value ? "true" : "false";
2295
+ }
2296
+ function appDatabaseName(projectName) {
2297
+ return projectName.replace(/[^A-Za-z0-9_]/g, "_");
2298
+ }
2299
+ function defaultDatabaseUrl(layers, projectName) {
2300
+ if (layers.database === "sqlite") {
2301
+ return "sqlite:./storage/app.sqlite";
2302
+ }
2303
+ const database = appDatabaseName(projectName);
2304
+ if (layers.database === "mysql") {
2305
+ return `mysql://root:root@localhost:3306/${database}`;
2306
+ }
2307
+ return `postgresql://postgres:postgres@localhost:5432/${database}`;
2308
+ }
2309
+ function renderEnvExample(projectName, layers) {
2310
+ const lines = [
2311
+ `APP_NAME=${projectName}`,
2312
+ `APP_KEY_PREFIX=${projectName}`,
2313
+ "APP_ENV=local",
2314
+ "PORT=3000",
2315
+ "APP_URL=http://localhost:3000",
2316
+ `DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
2317
+ ...layers.database === "sqlite" ? [] : [
2318
+ "# Optional. Migrate and boot against a different database than DATABASE_URL.",
2319
+ "# APP_DATABASE_URL="
2320
+ ],
2321
+ `DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
2322
+ `FRONTEND_MODE=${layers.frontend}`,
2323
+ `SPA_PREFIX=${layers.spaPrefix}`,
2324
+ `TENANCY_DRIVER=${layers.tenancy}`,
2325
+ `CACHE_DRIVER=${layers.cache}`,
2326
+ `QUEUE_DRIVER=${layers.queue}`,
2327
+ `MAIL_DRIVER=${layers.mail}`,
2328
+ `AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
2329
+ ];
2330
+ if (layers.frontend === "api") {
2331
+ lines.push("FEATURE_PUBLIC_READS=false");
2332
+ } else {
2333
+ lines.push("# Local convenience so the welcome page reads without a login.", "# Production boot is blocked unless this is false.", "FEATURE_PUBLIC_READS=true");
2334
+ }
2335
+ if (needsRedis(layers)) {
2336
+ lines.push("REDIS_URL=redis://127.0.0.1:6379");
2337
+ } else {
2338
+ lines.push("# REDIS_URL=redis://127.0.0.1:6379");
2339
+ }
2340
+ if (authUsesCookie(layers.auth) || layers.frontend === "server-htmx" || layers.frontend === "hybrid") {
2341
+ lines.push("SESSION_SECRET=dev-session-secret-change-me-please-32ch");
2342
+ } else {
2343
+ lines.push("# SESSION_SECRET=");
2344
+ }
2345
+ if (authUsesJwt(layers.auth)) {
2346
+ lines.push("JWT_SECRET=dev-jwt-secret-change-me-please-32chars");
2347
+ lines.push("JWT_TTL_SECONDS=3600");
2348
+ } else {
2349
+ lines.push("# JWT_SECRET=");
2350
+ }
2351
+ if (authUsesToken(layers.auth)) {
2352
+ lines.push("FEATURE_API_TOKENS=true");
2353
+ lines.push("TOKEN_HASH_PEPPER=dev-token-pepper-change-me");
2354
+ lines.push("API_TOKEN_DEFAULT_EXPIRY_DAYS=30");
2355
+ } else {
2356
+ lines.push("# FEATURE_API_TOKENS=false");
2357
+ }
2358
+ lines.push(`FEATURE_MFA=${envFlag(layers.extras.mfa)}`);
2359
+ lines.push(`FEATURE_EMAIL_VERIFICATION=${envFlag(layers.extras.emailVerification)}`);
2360
+ if (layers.extras.metrics) {
2361
+ lines.push("METRICS_TOKEN=dev-metrics-token-change-me");
2362
+ } else {
2363
+ lines.push("# METRICS_TOKEN=");
2364
+ }
2365
+ if (layers.extras.scim) {
2366
+ lines.push("FEATURE_SCIM=true");
2367
+ lines.push("SCIM_BEARER_TOKEN=dev-scim-token-change-me");
2368
+ lines.push("# SCIM_TENANT_TOKENS=1:token-a");
2369
+ } else {
2370
+ lines.push("# FEATURE_SCIM=false");
2371
+ lines.push("# SCIM_BEARER_TOKEN=");
2372
+ }
2373
+ if (layers.database === "mysql") {
2374
+ lines.push(`MYSQL_URL=${defaultDatabaseUrl(layers, projectName)}`);
2375
+ }
2376
+ if (layers.mail === "smtp") {
2377
+ lines.push("MAIL_HOST=localhost");
2378
+ lines.push("MAIL_PORT=1025");
2379
+ lines.push(`MAIL_FROM=noreply@${projectName}.local`);
2380
+ lines.push("MAIL_SECURE=false");
2381
+ } else {
2382
+ lines.push("# MAIL_HOST=");
2383
+ lines.push("# MAIL_FROM=");
2384
+ }
2385
+ lines.push("# Behind a reverse proxy, trust X-Forwarded-For (rightmost public hop) for throttles and session IPs.");
2386
+ lines.push("# TRUST_FORWARDED_FOR=true");
2387
+ return `${lines.join(`
2388
+ `)}
2389
+ `;
2390
+ }
2391
+ function renderDockerCompose(projectName, layers) {
2392
+ const selected = selectedDockerServices(layers);
2393
+ if (selected.length === 0) {
2394
+ return null;
2395
+ }
2396
+ const selectedSet = new Set(selected);
2397
+ const services = [];
2398
+ if (selectedSet.has("postgres")) {
2399
+ const database = appDatabaseName(projectName);
2400
+ services.push(` postgres:
2401
+ image: postgres:16-alpine
2402
+ environment:
2403
+ POSTGRES_USER: postgres
2404
+ POSTGRES_PASSWORD: postgres
2405
+ POSTGRES_DB: ${database}
2406
+ ports:
2407
+ - "5432:5432"
2408
+ volumes:
2409
+ - pgdata:/var/lib/postgresql/data`);
2410
+ }
2411
+ if (selectedSet.has("mysql")) {
2412
+ const database = appDatabaseName(projectName);
2413
+ services.push(` mysql:
2414
+ image: mysql:8.4
2415
+ environment:
2416
+ MYSQL_ROOT_PASSWORD: root
2417
+ MYSQL_DATABASE: ${database}
2418
+ ports:
2419
+ - "3306:3306"
2420
+ volumes:
2421
+ - mysqldata:/var/lib/mysql`);
2422
+ }
2423
+ if (selectedSet.has("adminer")) {
2424
+ const server = selectedSet.has("mysql") ? "mysql" : "postgres";
2425
+ services.push(` adminer:
2426
+ image: adminer:5.4.2
2427
+ environment:
2428
+ ADMINER_DEFAULT_SERVER: ${server}
2429
+ depends_on:
2430
+ - ${server}
2431
+ ports:
2432
+ - "8080:8080"`);
2433
+ }
2434
+ if (selectedSet.has("redis")) {
2435
+ services.push(` redis:
2436
+ image: redis:7-alpine
2437
+ ports:
2438
+ - "6379:6379"`);
2439
+ }
2440
+ if (selectedSet.has("mailpit")) {
2441
+ services.push(` mailpit:
2442
+ image: axllent/mailpit:latest
2443
+ ports:
2444
+ - "1025:1025"
2445
+ - "8025:8025"`);
2446
+ }
2447
+ const volumes = [];
2448
+ if (selectedSet.has("postgres")) {
2449
+ volumes.push(" pgdata:");
2450
+ }
2451
+ if (selectedSet.has("mysql")) {
2452
+ volumes.push(" mysqldata:");
2453
+ }
2454
+ return `services:
2455
+ ${services.join(`
2456
+
2457
+ `)}
2458
+ ${volumes.length > 0 ? `
2459
+ volumes:
2460
+ ${volumes.join(`
2461
+ `)}
2462
+ ` : ""}`;
2463
+ }
2464
+ function renderGitignore() {
2465
+ return `node_modules
2466
+ .env
2467
+ .env.local
2468
+ dist
2469
+ frontend/dist
2470
+ storage/*.sqlite
2471
+ storage/*.sqlite-journal
2472
+ storage/*.sqlite-wal
2473
+ storage/*.sqlite-shm
2474
+ coverage
2475
+ *.tsbuildinfo
2476
+ `;
2477
+ }
2478
+ function renderDockerfile(layers) {
2479
+ const frontend = needsFrontendBuild(layers.frontend);
2480
+ const lines = [
2481
+ '# Production image. Build once, run with env from your platform; see README "Deploy".',
2482
+ "FROM oven/bun:1.4 AS deps",
2483
+ "WORKDIR /app",
2484
+ "COPY package.json bun.lock ./",
2485
+ "RUN bun install --frozen-lockfile --production",
2486
+ ""
2487
+ ];
2488
+ if (frontend) {
2489
+ lines.push("FROM oven/bun:1.4 AS frontend", "WORKDIR /app/frontend", "COPY frontend/package.json frontend/bun.lock ./", "RUN bun install --frozen-lockfile", "COPY frontend/ ./", "RUN bun run build", "");
2490
+ }
2491
+ lines.push("FROM oven/bun:1.4-slim AS runtime", "WORKDIR /app", "ENV APP_ENV=production", "ENV AUTH_DEV_HEADERS=false", "ENV PORT=3000", "COPY --from=deps /app/node_modules ./node_modules", "COPY . .");
2492
+ if (frontend) {
2493
+ lines.push("COPY --from=frontend /app/frontend/dist ./frontend/dist");
2494
+ }
2495
+ lines.push("RUN mkdir -p storage && chown -R bun:bun /app", "USER bun", "EXPOSE 3000");
2496
+ if (layers.database === "sqlite") {
2497
+ lines.push("# SQLite lives in storage/; mount a volume there or the data dies with the container.", 'VOLUME ["/app/storage"]');
2498
+ }
2499
+ lines.push(`HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD ["bun", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]`, 'CMD ["bun", "run", "start"]');
2500
+ return `${lines.join(`
2501
+ `)}
2502
+ `;
2503
+ }
2504
+ function renderDockerignore(layers) {
2505
+ const lines = [
2506
+ ".git",
2507
+ "node_modules",
2508
+ ".env",
2509
+ ".env.*",
2510
+ "!.env.example",
2511
+ "storage/*.sqlite",
2512
+ "storage/*.sqlite-journal",
2513
+ "storage/*.sqlite-wal",
2514
+ "storage/*.sqlite-shm",
2515
+ "coverage",
2516
+ "docker-compose.yml"
2517
+ ];
2518
+ if (needsFrontendBuild(layers.frontend)) {
2519
+ lines.push("frontend/node_modules", "frontend/dist");
2520
+ }
2521
+ return `${lines.join(`
2522
+ `)}
2523
+ `;
2524
+ }
2525
+ function renderPackageJson(projectName, options = {}) {
2526
+ const coreDeps = options.workspaceDependencies ? {
2527
+ "@getstrata/bootstrap": "workspace:*",
2528
+ "@getstrata/cli": "workspace:*",
2529
+ "@getstrata/core": "workspace:*"
2530
+ } : {
2531
+ "@getstrata/bootstrap": "^1.0.1",
2532
+ "@getstrata/cli": "^1.0.1",
2533
+ "@getstrata/core": "^1.0.1"
2534
+ };
2535
+ if (options.layers?.database === "mysql") {
2536
+ coreDeps.mysql2 = "^3.24.3";
2537
+ }
2538
+ const scripts = {
2539
+ dev: "strata dev",
2540
+ start: "strata start",
2541
+ "db:migrate": "strata migrate",
2542
+ "db:fresh": "strata migrate:fresh",
2543
+ check: "tsc --noEmit"
2544
+ };
2545
+ if (options.layers && needsFrontendBuild(options.layers.frontend)) {
2546
+ scripts["frontend:install"] = "cd frontend && bun install";
2547
+ scripts["frontend:build"] = "cd frontend && bun run build";
2548
+ scripts["frontend:dev"] = "cd frontend && bun run dev";
2549
+ }
2550
+ return `${JSON.stringify({
2551
+ name: projectName,
2552
+ version: "0.1.0",
2553
+ private: true,
2554
+ type: "module",
2555
+ scripts,
2556
+ dependencies: coreDeps,
2557
+ devDependencies: {
2558
+ "@types/bun": "^1.4.0",
2559
+ typescript: "^5.9.2"
2560
+ }
2561
+ }, null, 2)}
2562
+ `;
2563
+ }
2564
+ function renderLayersManifest(projectName, layers) {
2565
+ return `${JSON.stringify({
2566
+ name: projectName,
2567
+ generatedBy: "create-strata",
2568
+ layers: {
2569
+ frontend: layers.frontend,
2570
+ database: layers.database,
2571
+ auth: layers.auth,
2572
+ tenancy: layers.tenancy,
2573
+ cache: layers.cache,
2574
+ queue: layers.queue,
2575
+ mail: layers.mail,
2576
+ spaPrefix: layers.spaPrefix,
2577
+ extras: layers.extras,
2578
+ docker: layers.docker
2579
+ },
2580
+ notes: "Generated by create-strata. Change layers later with flags or by editing env and bootstrap files."
2581
+ }, null, 2)}
2582
+ `;
2583
+ }
2584
+ function localEnvVars(services) {
2585
+ const vars = [];
2586
+ for (const name of services) {
2587
+ if (name === "postgres" || name === "mysql") {
2588
+ vars.push("DATABASE_URL");
2589
+ } else if (name === "redis") {
2590
+ vars.push("REDIS_URL");
2591
+ } else if (name === "mailpit") {
2592
+ vars.push("MAIL_HOST");
2593
+ }
2594
+ }
2595
+ return [...new Set(vars)];
2596
+ }
2597
+ function renderSupportingToolsReadme(layers) {
2598
+ const needed = neededDockerServices(layers);
2599
+ if (needed.length === 0) {
2600
+ return "";
2601
+ }
2602
+ const dockerOn = selectedDockerServices(layers);
2603
+ const dockerSet = new Set(dockerOn);
2604
+ const localOn = needed.filter((name) => !dockerSet.has(name));
2605
+ const lines = ["## Supporting tools", ""];
2606
+ if (dockerOn.length > 0) {
2607
+ const names = dockerOn.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
2608
+ lines.push(`Docker Compose includes ${names}.`, "", "```bash", "docker compose up -d", "```", "");
2609
+ if (dockerSet.has("adminer")) {
2610
+ const mysql = layers.database === "mysql";
2611
+ const system = mysql ? "MySQL" : "PostgreSQL";
2612
+ const server = mysql ? "mysql" : "postgres";
2613
+ const username = mysql ? "root" : "postgres";
2614
+ const password = mysql ? "root" : "postgres";
2615
+ lines.push(`Adminer: http://localhost:8080 (${system}, server \`${server}\`, username \`${username}\`, password \`${password}\`).`, "");
2616
+ }
2617
+ }
2618
+ if (localOn.length > 0) {
2619
+ const names = localOn.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
2620
+ const envVars = localEnvVars(localOn).map((name) => `\`${name}\``).join(", ");
2621
+ lines.push(`Use local installs for ${names}. Point ${envVars} in \`.env\` at services on this machine.`, "");
2622
+ }
2623
+ return `${lines.join(`
2624
+ `)}
2625
+ `;
2626
+ }
2627
+ function renderApiDocs(projectName, layers) {
2628
+ const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
2629
+ rows.push("| `GET` | `/health` | Plain text `ok` (200), or `degraded` (503) when the database or the migrated schema is unavailable. `/ready` returns the same checks as JSON. |");
2630
+ rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
2631
+ if (authUsesToken(layers.auth)) {
2632
+ rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
2633
+ }
2634
+ if (authUsesJwt(layers.auth)) {
2635
+ rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. |");
2636
+ }
2637
+ if (authNeedsUsers(layers.auth)) {
2638
+ rows.push("| `POST` | `/api/v1/auth/forgot-password` | Sends a signed reset link through the mail driver. |", "| `POST` | `/api/v1/auth/reset-password` | Consumes the signed link. |", "| `GET` | `/api/user` | Current user for the active guard. |");
2639
+ }
2640
+ if (layers.extras.metrics) {
2641
+ rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
2642
+ }
2643
+ const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`POST /api/v1/auth/login\`, then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
2644
+ return `# ${projectName} API
2645
+
2646
+ \`FRONTEND_MODE=${layers.frontend}\`. ${layers.frontend === "api" ? "No server-rendered views beyond the welcome page and no SPA assets." : `HTML is served alongside this API. The SPA is mounted at \`${layers.spaPrefix}\`.`}
2647
+
2648
+ ## Routes this app serves today
2649
+
2650
+ ${rows.join(`
2651
+ `)}
2652
+
2653
+ There is no CRUD endpoint for the seeded \`notes\` table. Adding your own routes is the first thing you do.
2654
+
2655
+ ## Auth
2656
+
2657
+ ${authNote}
2658
+
2659
+ ## Adding a route
2660
+
2661
+ Create a module under \`src/modules/\` and return a route map. Modules are discovered on boot.
2662
+
2663
+ \`\`\`typescript
2664
+ // src/modules/notes/index.ts
2665
+ import type { AppModule } from "@getstrata/bootstrap/contracts";
2666
+ import { jsonResponse } from "@getstrata/core/http/response";
2667
+ import { getSql } from "../../bootstrap/database.ts";
2668
+
2669
+ const notesModule: AppModule = {
2670
+ name: "notes",
2671
+ order: 2,
2672
+ routes({ kernel }) {
2673
+ return {
2674
+ "/api/v1/notes": kernel.wrap("api", async () => {
2675
+ const rows = await getSql().unsafe<{ id: number; body: string }>(
2676
+ "SELECT id, body FROM notes ORDER BY id DESC",
2677
+ );
2678
+ return jsonResponse({ data: rows });
2679
+ }),
2680
+ };
2681
+ },
2682
+ };
2683
+
2684
+ export default notesModule;
2685
+ \`\`\`
2686
+
2687
+ Import from \`@getstrata/core/...\` subpaths rather than the package root, so singleton state such as the database pool stays shared.
2688
+
2689
+ ## Docs
2690
+
2691
+ - [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md)
2692
+ - [Auth choices](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md)
2693
+ - [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
2694
+ `;
2695
+ }
2696
+ function renderReadme(projectName, layers) {
2697
+ const docker = renderDockerCompose(projectName, layers);
2698
+ const next = [`cd ${projectName}`, "cp .env.example .env"];
2699
+ if (docker) {
2700
+ next.push("docker compose up -d");
2701
+ }
2702
+ next.push("bun install");
2703
+ if (needsFrontendBuild(layers.frontend)) {
2704
+ next.push("bun run frontend:install", "bun run frontend:build");
2705
+ }
2706
+ next.push("bun run db:migrate", "bun run dev");
2707
+ const extras = Object.entries(layers.extras).filter(([, on]) => on).map(([key]) => key);
2708
+ const dockerServices = selectedDockerServices(layers);
2709
+ const neededTools = neededDockerServices(layers);
2710
+ const dockerLabel = neededTools.length === 0 ? "not needed" : dockerServices.length > 0 ? dockerServices.join(", ") : "off (local installs)";
2711
+ return `# ${projectName}
2712
+
2713
+ Strata app generated by \`create-strata\`.
2714
+
2715
+ ## Layers
2716
+
2717
+ | Layer | Choice |
2718
+ |-------|--------|
2719
+ | Frontend | \`${layers.frontend}\` |
2720
+ | Database | \`${layers.database}\` |
2721
+ | Auth | \`${layers.auth}\` |
2722
+ | Tenancy | \`${layers.tenancy}\` |
2723
+ | Cache | \`${layers.cache}\` |
2724
+ | Queue | \`${layers.queue}\` |
2725
+ | Mail | \`${layers.mail}\` |
2726
+ | SPA prefix | \`${layers.spaPrefix}\` |
2727
+ | Docker Compose | ${dockerLabel} |
2728
+ ${extras.length > 0 ? `| Extras | ${extras.join(", ")} |
2729
+ ` : ""}
2730
+ This file is the map for this app. Framework guides: [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md), [Auth](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md), [Starter](https://github.com/EyK-26/strata/blob/main/docs/STARTER.md).
2731
+
2732
+ ## Run it
2733
+
2734
+ \`\`\`bash
2735
+ ${next.join(`
2736
+ `)}
2737
+ \`\`\`
2738
+
2739
+ Open http://localhost:3000. Health check: \`GET /health\`.
2740
+
2741
+ ${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
2742
+ Seeded login (password \`password\`):
2743
+
2744
+ - \`demo@example.com\` (member)
2745
+ - \`admin@example.test\` (admin)
2746
+ ` : `
2747
+ Header auth is on for local use. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`). Production must set \`AUTH_DEV_HEADERS=false\`.
2748
+ `}${authUsesCookie(layers.auth) ? `
2749
+ HTML auth kit (restyle \`views/\` and \`public/assets/site.css\`):
2750
+
2751
+ - Welcome: \`/\`
2752
+ - Sign in: \`/login\`
2753
+ - Register: \`/register\`
2754
+ - Forgot password: \`/forgot-password\`
2755
+ - Reset password: signed \`/reset-password\` (mail log when \`MAIL_DRIVER=log\`)
2756
+ ${layers.extras.emailVerification ? "- Verify email: `/email/verify`\n" : ""}${layers.extras.mfa ? "- MFA challenge: `/login/mfa` and setup: `/account/mfa`\n" : ""}
2757
+ Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
2758
+ ` : ""}${authUsesToken(layers.auth) ? `
2759
+ Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Register: \`POST /api/v1/auth/register\`. Forgot/reset: \`POST /api/v1/auth/forgot-password\` and signed \`POST /api/v1/auth/reset-password\`. Send \`Authorization: Bearer\` after login.
2760
+ ` : ""}${authUsesJwt(layers.auth) ? `
2761
+ JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Do not use JWT as an HTML cookie session.
2762
+ ` : ""}${layers.extras.metrics ? `
2763
+ Prometheus scrape: \`GET /metrics\`. Production requires \`Authorization: Bearer <METRICS_TOKEN>\`.
2764
+ ` : ""}${needsFrontendBuild(layers.frontend) ? `
2765
+ ## Frontend
2766
+
2767
+ The React app lives in \`frontend/\` with its own \`package.json\`. It is not built by \`bun install\` at the root.
2768
+
2769
+ \`\`\`bash
2770
+ bun run frontend:install
2771
+ bun run frontend:build
2772
+ \`\`\`
2773
+
2774
+ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun run frontend:dev\` for the Vite-style dev server with hot reload.${layers.frontend === "hybrid" ? ` HTML stays at \`/\` and the SPA is served under \`${layers.spaPrefix}/*\`.` : ""}
2775
+ ` : ""}${layers.database === "sqlite" ? "" : `
2776
+ ## Database
2777
+
2778
+ The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.
2779
+ `}
2780
+ ## Deploy
2781
+
2782
+ \`Dockerfile\` builds a production image from the committed \`bun.lock\` (run \`bun install\` once and commit the lockfile).${needsFrontendBuild(layers.frontend) ? " The React frontend is built inside the image." : ""}
2783
+
2784
+ \`\`\`bash
2785
+ docker build -t ${projectName} .
2786
+ docker run --rm -p 3000:3000 --env-file .env.production ${projectName}
2787
+ \`\`\`
2788
+
2789
+ Migrations are a deploy step, not a boot step: run \`docker run --rm --env-file .env.production ${projectName} bun run db:migrate\` before the new version takes traffic.${layers.database === "sqlite" ? " SQLite stores its file in `/app/storage`; mount a volume there (`-v strata_data:/app/storage`) or the data is lost with the container." : ""}
2790
+ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything else in the Production list below comes from your environment (the \`.env.production\` file above is one way).
2791
+
2792
+ ## Production
2793
+
2794
+ \`createApp\` calls \`assertProductionSecrets()\` when \`APP_ENV=production\`. That check fails closed, so read this before your first production boot.
2795
+
2796
+ - Replace every \`change-me\` placeholder in \`.env\`. The guard rejects the values this generator wrote, not just empty ones.
2797
+ - Set \`APP_URL\` to the public origin (for example \`https://app.example.com\`). Signed links and redirects are built from it; localhost is rejected.
2798
+ - Set \`AUTH_DEV_HEADERS=false\`.
2799
+ - Set \`FEATURE_PUBLIC_READS=false\`. ${layers.frontend === "api" ? "This app already ships `false`." : "This app ships `true` so the local welcome page reads without a login. Production requires `false`."}
2800
+ - Cross-origin browser calls are off in production until you set \`CORS_ALLOWED_ORIGINS\` to explicit origins. A \`*\` entry is rejected. Non-browser clients are unaffected.
2801
+ - Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
2802
+ ${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}
2803
+ \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` answers 503 until the schema exists, so a fresh deploy stays out of rotation until it is migrated.
2804
+ `;
2805
+ }
2806
+
2807
+ // ../strata-starter/src/renderRuntime.ts
2808
+ function needsEnsure(layers) {
2809
+ return layers.database !== "sqlite";
2810
+ }
2811
+ function ensureImport(layers) {
2812
+ return needsEnsure(layers) ? `import { ensureAppDatabase } from "../bootstrap/ensureDatabase.ts";
2813
+ ` : "";
2814
+ }
2815
+ function ensureCall(layers) {
2816
+ return needsEnsure(layers) ? ` await ensureAppDatabase();
2817
+ ` : "";
2818
+ }
2819
+ function dialectFragments(database) {
2820
+ if (database === "sqlite") {
2821
+ return {
2822
+ id: "INTEGER PRIMARY KEY AUTOINCREMENT",
2823
+ text: "TEXT",
2824
+ keyText: "TEXT",
2825
+ defaultText: "TEXT",
2826
+ timestamp: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
2827
+ timestampNull: "TEXT",
2828
+ bool: "INTEGER NOT NULL DEFAULT 0"
2829
+ };
2830
+ }
2831
+ if (database === "mysql") {
2832
+ return {
2833
+ id: "INT AUTO_INCREMENT PRIMARY KEY",
2834
+ text: "TEXT",
2835
+ keyText: "VARCHAR(255)",
2836
+ defaultText: "VARCHAR(1024)",
2837
+ timestamp: "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
2838
+ timestampNull: "DATETIME NULL",
2839
+ bool: "TINYINT(1) NOT NULL DEFAULT 0"
2840
+ };
2841
+ }
2842
+ return {
2843
+ id: "SERIAL PRIMARY KEY",
2844
+ text: "TEXT",
2845
+ keyText: "TEXT",
2846
+ defaultText: "TEXT",
2847
+ timestamp: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
2848
+ timestampNull: "TIMESTAMPTZ",
2849
+ bool: "BOOLEAN NOT NULL DEFAULT FALSE"
2850
+ };
2851
+ }
2852
+ function driverName(database) {
2853
+ return database === "postgres" ? "pgsql" : database;
2854
+ }
2855
+ function renderDatabaseTs(layers) {
2856
+ const driver = driverName(layers.database);
2857
+ if (layers.database === "sqlite") {
2858
+ return `import { mkdirSync } from "node:fs";
2859
+ import { dirname } from "node:path";
2860
+ import type { SqlDatabaseConnection } from "@getstrata/core/database/baseRepository";
2861
+ import { bindDatabaseConnection } from "@getstrata/core/database/boundConnection";
2862
+ import { registerDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
2863
+ import { createSqliteConnection } from "@getstrata/core/database/sqliteConnection";
2864
+ import { useSqlDialect } from "@getstrata/core/database/dialect";
2865
+
2866
+ export type SqlClient = {
2867
+ unsafe<T>(query: string, params?: readonly unknown[]): Promise<T[]>;
2868
+ close?: () => Promise<void> | void;
2869
+ };
2870
+
2871
+ let sql: SqlClient | null = null;
2872
+
2873
+ function sqliteFilename(url: string): string {
2874
+ const trimmed = url.trim();
2875
+ if (trimmed === ":memory:" || trimmed === "sqlite::memory:") {
2876
+ return ":memory:";
2877
+ }
2878
+ if (trimmed.startsWith("sqlite:")) {
2879
+ return trimmed.slice("sqlite:".length).replace(/^\\/\\//, "") || "./storage/app.sqlite";
2880
+ }
2881
+ return trimmed || "./storage/app.sqlite";
2882
+ }
2883
+
2884
+ function asSqlPool(client: SqlClient): SqlDatabaseConnection {
2885
+ const tagged = async () => {
2886
+ throw new Error(
2887
+ "SQLite starter connections do not run tagged SQL. Keep TENANCY_DRIVER=none or use Postgres.",
2888
+ );
2889
+ };
2890
+ return Object.assign(tagged, client) as SqlDatabaseConnection;
2891
+ }
2892
+
2893
+ export function getSql(): SqlClient {
2894
+ if (sql) {
2895
+ return sql;
2896
+ }
2897
+
2898
+ const url = process.env.DATABASE_URL;
2899
+ if (!url) {
2900
+ throw new Error("DATABASE_URL is required");
2901
+ }
2902
+
2903
+ useSqlDialect("${driver}");
2904
+ const filename = sqliteFilename(url);
2905
+ if (filename !== ":memory:") {
2906
+ mkdirSync(dirname(filename), { recursive: true });
2907
+ }
2908
+ sql = createSqliteConnection(filename);
2909
+ bindDatabaseConnection(sql);
2910
+ registerDefaultDatabasePool(asSqlPool(sql));
2911
+ return sql;
2912
+ }
2913
+
2914
+ export async function pingDatabase(): Promise<boolean> {
2915
+ try {
2916
+ await getSql().unsafe("SELECT 1");
2917
+ return true;
2918
+ } catch {
2919
+ return false;
2920
+ }
2921
+ }
2922
+
2923
+ export async function closeDatabase() {
2924
+ if (sql?.close) {
2925
+ await sql.close();
2926
+ }
2927
+ sql = null;
2928
+ }
2929
+ `;
2930
+ }
2931
+ if (layers.database === "mysql") {
2932
+ return `import { bindDatabaseConnection } from "@getstrata/core/database/boundConnection";
2933
+ import type { SqlDatabaseConnection } from "@getstrata/core/database/baseRepository";
2934
+ import { registerDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
2935
+ import { createMysqlConnection } from "@getstrata/core/database/mysqlConnection";
2936
+ import { useSqlDialect } from "@getstrata/core/database/dialect";
2937
+
2938
+ export type SqlClient = {
2939
+ unsafe<T>(query: string, params?: readonly unknown[]): Promise<T[]>;
2940
+ close?: () => Promise<void> | void;
2941
+ };
2942
+
2943
+ let sql: SqlClient | null = null;
2944
+
2945
+ export function getSql(): SqlClient {
2946
+ if (sql) {
2947
+ return sql;
2948
+ }
2949
+
2950
+ const url = process.env.DATABASE_URL;
2951
+ if (!url) {
2952
+ throw new Error("DATABASE_URL is required");
2953
+ }
2954
+
2955
+ useSqlDialect("${driver}");
2956
+ sql = createMysqlConnection(url);
2957
+ bindDatabaseConnection(sql);
2958
+ registerDefaultDatabasePool(sql as SqlDatabaseConnection);
2959
+ return sql;
2960
+ }
2961
+
2962
+ export async function pingDatabase(): Promise<boolean> {
2963
+ try {
2964
+ await getSql().unsafe("SELECT 1");
2965
+ return true;
2966
+ } catch {
2967
+ return false;
2968
+ }
2969
+ }
2970
+
2971
+ export async function closeDatabase() {
2972
+ if (sql?.close) {
2973
+ await sql.close();
2974
+ }
2975
+ sql = null;
2976
+ }
2977
+ `;
2978
+ }
2979
+ return `import { createBunSqlPool } from "@getstrata/core/database/bunSql";
2980
+ import type { SqlDatabaseConnection } from "@getstrata/core/database/baseRepository";
2981
+ import { bindDatabaseConnection } from "@getstrata/core/database/boundConnection";
2982
+ import {
2983
+ getDefaultDatabaseQuery,
2984
+ registerDefaultDatabasePool,
2985
+ } from "@getstrata/core/database/defaultConnection";
2986
+ import { useSqlDialect } from "@getstrata/core/database/dialect";
2987
+
2988
+ export type SqlClient = {
2989
+ unsafe<T>(query: string, params?: readonly unknown[]): Promise<T[]>;
2990
+ close?: () => Promise<void> | void;
2991
+ };
2992
+
2993
+ let sql: SqlClient | null = null;
2994
+
2995
+ export function getSql(): SqlClient {
2996
+ if (sql) {
2997
+ return sql;
2998
+ }
2999
+
3000
+ const url = process.env.DATABASE_URL;
3001
+ if (!url) {
3002
+ throw new Error("DATABASE_URL is required");
3003
+ }
3004
+
3005
+ useSqlDialect("${driver}");
3006
+ const pool = createBunSqlPool({ url, max: 5 }) as SqlDatabaseConnection;
3007
+ registerDefaultDatabasePool(pool);
3008
+ bindDatabaseConnection(getDefaultDatabaseQuery());
3009
+ sql = getDefaultDatabaseQuery() as SqlClient;
3010
+ return sql;
3011
+ }
3012
+
3013
+ export async function pingDatabase(): Promise<boolean> {
3014
+ try {
3015
+ await getSql().unsafe("SELECT 1");
3016
+ return true;
3017
+ } catch {
3018
+ return false;
3019
+ }
3020
+ }
3021
+
3022
+ export async function closeDatabase() {
3023
+ if (sql?.close) {
3024
+ await sql.close();
3025
+ }
3026
+ sql = null;
3027
+ }
3028
+ `;
3029
+ }
3030
+ function renderMigrateTs(layers) {
3031
+ const d = dialectFragments(layers.database);
3032
+ const statements = [];
3033
+ const tenancyOn = usesTenantTable(layers.tenancy);
3034
+ const mfaOn = layers.extras.mfa && authNeedsUsers(layers.auth);
3035
+ if (tenancyOn) {
3036
+ statements.push(`CREATE TABLE IF NOT EXISTS tenant (
3037
+ id ${d.id},
3038
+ slug ${d.keyText} NOT NULL UNIQUE,
3039
+ plan ${d.defaultText} NOT NULL DEFAULT 'enterprise',
3040
+ region ${d.defaultText} NOT NULL DEFAULT 'eu'
3041
+ )`);
3042
+ }
3043
+ statements.push(`CREATE TABLE IF NOT EXISTS notes (
3044
+ id ${d.id},
3045
+ body ${d.text} NOT NULL,
3046
+ created_at ${d.timestamp}
3047
+ )`);
3048
+ if (authNeedsUsers(layers.auth)) {
3049
+ const tenantColumn = tenancyOn ? `
3050
+ tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
3051
+ const mfaColumns = mfaOn ? `
3052
+ mfa_secret ${d.text},
3053
+ mfa_enabled ${d.bool},
3054
+ mfa_recovery_codes ${d.text},` : "";
3055
+ statements.push(`CREATE TABLE IF NOT EXISTS users (
3056
+ id ${d.id},
3057
+ name ${d.text} NOT NULL,
3058
+ email ${d.keyText} NOT NULL UNIQUE,
3059
+ password ${d.text} NOT NULL,
3060
+ is_admin ${d.bool},${tenantColumn}${mfaColumns}
3061
+ email_verified_at ${d.timestampNull},
3062
+ created_at ${d.timestamp}
3063
+ )`);
3064
+ }
3065
+ if (authUsesCookie(layers.auth)) {
3066
+ statements.push(`CREATE TABLE IF NOT EXISTS sessions (
3067
+ id ${d.keyText} PRIMARY KEY,
3068
+ user_id INTEGER NOT NULL,
3069
+ expires_at ${d.timestamp},
3070
+ user_agent ${d.text},
3071
+ ip_address ${d.text},
3072
+ last_active_at ${d.timestamp}
3073
+ )`);
3074
+ }
3075
+ if (authUsesToken(layers.auth)) {
3076
+ statements.push(`CREATE TABLE IF NOT EXISTS api_tokens (
3077
+ id ${d.id},
3078
+ user_id INTEGER NOT NULL,
3079
+ name ${d.text} NOT NULL,
3080
+ token_hash ${d.keyText} NOT NULL UNIQUE,
3081
+ abilities ${d.defaultText} NOT NULL DEFAULT '[]',
3082
+ expires_at ${d.timestampNull},
3083
+ last_used_at ${d.timestampNull},
3084
+ created_at ${d.timestamp}
3085
+ )`);
3086
+ }
3087
+ const list = statements.map((sql) => ` \`${sql}\`,`).join(`
3088
+ `);
3089
+ const ph2 = layers.database === "postgres";
3090
+ const notePlaceholder = ph2 ? "$1" : "?";
3091
+ const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
3092
+ const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
3093
+ const userPlaceholders = verifyOn ? ph2 ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph2 ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
3094
+ const adminFlag = ph2 ? "false" : "0";
3095
+ const adminTrue = ph2 ? "true" : "1";
3096
+ const verifiedNow = nowTimestampLiteral(layers.database);
3097
+ const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
3098
+ const seedTenant = tenancyOn ? `
3099
+ const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
3100
+ "SELECT COUNT(*) AS count FROM tenant",
3101
+ );
3102
+ if (Number(tenantCount) === 0) {
3103
+ await sql.unsafe(
3104
+ "INSERT INTO tenant (slug, plan, region) VALUES (${ph2 ? "$1, $2, $3" : "?, ?, ?"})",
3105
+ ["default", "enterprise", "eu"],
3106
+ );
3107
+ }` : "";
3108
+ const seedUsers = authNeedsUsers(layers.auth) ? `
3109
+ const [{ count: userCount }] = await sql.unsafe<{ count: string | number }>(
3110
+ "SELECT COUNT(*) AS count FROM users",
3111
+ );
3112
+ if (Number(userCount) === 0) {
3113
+ const password = await hashPassword("password");
3114
+ await sql.unsafe(
3115
+ "INSERT INTO users (${userColumns}) VALUES (${userPlaceholders})",
3116
+ ${userValues},
3117
+ );
3118
+ }` : "";
3119
+ const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
3120
+ ` : "";
3121
+ const seedBlock = `${seedTenant}${seedUsers}`;
3122
+ return `${hashImport}${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3123
+
3124
+ const migrations = [
3125
+ ${list}
3126
+ ];
3127
+
3128
+ export async function seed() {
3129
+ ${ensureCall(layers)} const sql = getSql();
3130
+ const [{ count }] = await sql.unsafe<{ count: string | number }>(
3131
+ "SELECT COUNT(*) AS count FROM notes",
3132
+ );
3133
+ if (Number(count) === 0) {
3134
+ await sql.unsafe("INSERT INTO notes (body) VALUES (${notePlaceholder})", [
3135
+ "Welcome to Strata!",
3136
+ ]);
3137
+ }${seedBlock}
3138
+ }
3139
+
3140
+ export async function migrate() {
3141
+ ${ensureCall(layers)} const sql = getSql();
3142
+ for (const statement of migrations) {
3143
+ await sql.unsafe(statement);
3144
+ }
3145
+ await seed();
3146
+ }
3147
+
3148
+ /** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
3149
+ export async function close() {
3150
+ await closeDatabase();
3151
+ }
3152
+
3153
+ if (import.meta.main) {
3154
+ await migrate();
3155
+ console.log("Database migrated and seeded.");
3156
+ await close();
3157
+ process.exit(0);
3158
+ }
3159
+ `;
3160
+ }
3161
+ function dropTables(layers) {
3162
+ const ordered = [];
3163
+ if (authUsesToken(layers.auth)) {
3164
+ ordered.push("api_tokens");
3165
+ }
3166
+ if (authUsesCookie(layers.auth)) {
3167
+ ordered.push("sessions");
3168
+ }
3169
+ if (authNeedsUsers(layers.auth)) {
3170
+ ordered.push("users");
3171
+ }
3172
+ ordered.push("notes");
3173
+ if (usesTenantTable(layers.tenancy)) {
3174
+ ordered.push("tenant");
3175
+ }
3176
+ return ordered;
3177
+ }
3178
+ function renderFreshTs(layers) {
3179
+ const tables = dropTables(layers);
3180
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3181
+ return `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3182
+ import { migrate } from "./migrate.ts";
3183
+
3184
+ const tables = ${JSON.stringify(tables)};
3185
+
3186
+ export async function fresh() {
3187
+ ${ensureCall(layers)} const sql = getSql();
3188
+ for (const table of tables) {
3189
+ await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3190
+ }
3191
+ await migrate();
3192
+ }
3193
+
3194
+ /** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
3195
+ export async function close() {
3196
+ await closeDatabase();
3197
+ }
3198
+
3199
+ if (import.meta.main) {
3200
+ await fresh();
3201
+ console.log("Database reset, migrated, and seeded.");
3202
+ await close();
3203
+ process.exit(0);
3204
+ }
3205
+ `;
3206
+ }
3207
+ function renderSeedTs() {
3208
+ return `import { seed } from "./migrate.ts";
3209
+
3210
+ export { seed };
3211
+
3212
+ if (import.meta.main) {
3213
+ await seed();
3214
+ console.log("Database seeded.");
3215
+ process.exit(0);
3216
+ }
3217
+ `;
3218
+ }
3219
+ function renderStatusTs(layers) {
3220
+ const tables = dropTables(layers);
3221
+ return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
3222
+
3223
+ const tables = ${JSON.stringify(tables)};
3224
+
3225
+ export async function status() {
3226
+ ${ensureCall(layers)} const sql = getSql();
3227
+ console.log("Starter schema (inline SQL, not a migration runner):");
3228
+ for (const table of tables) {
3229
+ try {
3230
+ const rows = await sql.unsafe<{ count: string | number }>(
3231
+ \`SELECT COUNT(*) AS count FROM \${table}\`,
3232
+ );
3233
+ console.log(\`- [present] \${table} (rows: \${rows[0]?.count ?? 0})\`);
3234
+ } catch {
3235
+ console.log(\`- [missing] \${table}\`);
3236
+ }
3237
+ }
3238
+ }
3239
+
3240
+ if (import.meta.main) {
3241
+ await status();
3242
+ process.exit(0);
3243
+ }
3244
+ `;
3245
+ }
3246
+ function renderRollbackTs(layers) {
3247
+ const tables = dropTables(layers);
3248
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3249
+ return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
3250
+
3251
+ const tables = ${JSON.stringify(tables)};
3252
+
3253
+ export async function rollback() {
3254
+ ${ensureCall(layers)} const sql = getSql();
3255
+ for (const table of tables) {
3256
+ await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3257
+ console.log(\`dropped \${table}\`);
3258
+ }
3259
+ }
3260
+
3261
+ if (import.meta.main) {
3262
+ await rollback();
3263
+ console.log("Rolled back starter tables.");
3264
+ process.exit(0);
3265
+ }
3266
+ `;
3267
+ }
3268
+ function renderPreloadTs(layers, projectName) {
3269
+ const database = appDatabaseName(projectName);
3270
+ const fallback = layers.database === "sqlite" ? "sqlite:./storage/app.sqlite" : layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
3271
+ return `import { join } from "node:path";
3272
+ import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
3273
+
3274
+ process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
3275
+ process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
3276
+ process.env.SPA_PREFIX ??= ${JSON.stringify(layers.spaPrefix)};
3277
+ process.env.CACHE_DRIVER ??= ${JSON.stringify(layers.cache)};
3278
+ process.env.QUEUE_DRIVER ??= ${JSON.stringify(layers.queue)};
3279
+ process.env.MAIL_DRIVER ??= ${JSON.stringify(layers.mail)};
3280
+ process.env.TENANCY_DRIVER ??= ${JSON.stringify(layers.tenancy)};
3281
+ configureModulesDirectory(join(import.meta.dir, "../modules"));
3282
+ `;
3283
+ }
3284
+ function renderConfigTs() {
3285
+ return `export interface AppConfig {
3286
+ port: number;
3287
+ appUrl: string;
3288
+ databaseUrl: string;
3289
+ }
3290
+
3291
+ export function loadConfig(): AppConfig {
3292
+ const databaseUrl = process.env.DATABASE_URL;
3293
+ if (!databaseUrl) {
3294
+ throw new Error("DATABASE_URL is required");
3295
+ }
3296
+
3297
+ return {
3298
+ port: Number(process.env.PORT ?? 3000),
3299
+ appUrl: process.env.APP_URL ?? "http://localhost:3000",
3300
+ databaseUrl,
3301
+ };
3302
+ }
3303
+
3304
+ /** Cookie sessions and signed cookies are keyed by this; there is no default. */
3305
+ export function sessionSecret(): string {
3306
+ const secret = process.env.SESSION_SECRET?.trim();
3307
+ if (!secret) {
3308
+ throw new Error(
3309
+ "SESSION_SECRET is required. Copy .env.example to .env and set it (32+ characters).",
3310
+ );
3311
+ }
3312
+ return secret;
3313
+ }
3314
+ `;
3315
+ }
3316
+ function renderConfigProvider(layers) {
3317
+ return `import {
3318
+ APP_PORT_CONFIG_KEY,
3319
+ CORE_CONFIG_TOKEN,
3320
+ DATABASE_URL_CONFIG_KEY,
3321
+ REDIS_URL_CONFIG_KEY,
3322
+ } from "@getstrata/bootstrap/config";
3323
+ import type { ServiceProvider } from "@getstrata/core/contracts/di";
3324
+ import { loadConfig } from "../config.ts";
3325
+
3326
+ const configProvider: ServiceProvider = {
3327
+ name: "starter.config",
3328
+ register({ container, config }) {
3329
+ const appConfig = loadConfig();
3330
+
3331
+ container.set(CORE_CONFIG_TOKEN, config);
3332
+ config.set(DATABASE_URL_CONFIG_KEY, appConfig.databaseUrl);
3333
+ config.set(APP_PORT_CONFIG_KEY, appConfig.port);
3334
+ config.set(REDIS_URL_CONFIG_KEY, process.env.REDIS_URL ?? "");
3335
+ config.set("app.url", appConfig.appUrl);
3336
+ config.set("cache.driver", process.env.CACHE_DRIVER ?? "${layers.cache}");
3337
+ config.set("cache.ttlMs", 3_600_000);
3338
+ config.set("cache.maxEntries", 100);
3339
+ config.set("queue.driver", process.env.QUEUE_DRIVER ?? "${layers.queue}");
3340
+ },
3341
+ };
3342
+
3343
+ export default configProvider;
3344
+ `;
3345
+ }
3346
+ function renderQueueProvider() {
3347
+ return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
3348
+ import { CORE_QUEUE_TOKEN } from "@getstrata/core/contracts/serviceTokens";
3349
+ import {
3350
+ createAppQueue,
3351
+ createFailedJobService,
3352
+ FAILED_JOB_SERVICE_TOKEN,
3353
+ } from "@getstrata/core/queue/createAppQueue";
3354
+
3355
+ const queueProvider: ServiceProvider = {
3356
+ name: "starter.queue",
3357
+ register({ container }) {
3358
+ const driver = (process.env.QUEUE_DRIVER ?? "sync") as "sync" | "async" | "redis";
3359
+ const failedJobs = createFailedJobService();
3360
+ container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
3361
+ container.set(CORE_QUEUE_TOKEN, createAppQueue(driver, process.env.REDIS_URL, failedJobs));
3362
+ },
3363
+ };
3364
+
3365
+ export default queueProvider;
3366
+ `;
3367
+ }
3368
+ function renderEnsureDatabaseTs(layers, projectName) {
3369
+ if (layers.database === "sqlite") {
3370
+ return null;
3371
+ }
3372
+ const database = appDatabaseName(projectName);
3373
+ const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
3374
+ const resolveUrl = `/**
3375
+ * The database name comes from DATABASE_URL. Set APP_DATABASE_URL to point
3376
+ * migrations and the app at a different database than DATABASE_URL.
3377
+ */
3378
+ function resolveAppDatabaseUrl(): string {
3379
+ const explicit = process.env.APP_DATABASE_URL?.trim();
3380
+ if (explicit) {
3381
+ return explicit;
3382
+ }
3383
+ return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
3384
+ }
3385
+
3386
+ /** Reject anything we would have to quote before interpolating into DDL. */
3387
+ function safeDatabaseName(url: string): string {
3388
+ let name = "";
3389
+ try {
3390
+ name = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
3391
+ } catch {
3392
+ throw new Error(\`DATABASE_URL is not a valid URL: \${url}\`);
3393
+ }
3394
+ if (!name) {
3395
+ throw new Error("DATABASE_URL is missing a database name.");
3396
+ }
3397
+ if (name.replace(/[^A-Za-z0-9_]/g, "") !== name) {
3398
+ throw new Error(\`Refusing to create a database with an unsafe name: \${name}\`);
3399
+ }
3400
+ return name;
3401
+ }
3402
+ `;
3403
+ if (layers.database === "mysql") {
3404
+ return `import { createConnection } from "mysql2/promise";
3405
+
3406
+ const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3407
+
3408
+ ${resolveUrl}
3409
+ export async function ensureAppDatabase(): Promise<string> {
3410
+ const url = resolveAppDatabaseUrl();
3411
+ const name = safeDatabaseName(url);
3412
+
3413
+ const admin = new URL(url);
3414
+ admin.pathname = "/";
3415
+ const connection = await createConnection(admin.toString());
3416
+ try {
3417
+ await connection.query(\`CREATE DATABASE IF NOT EXISTS \${name}\`);
3418
+ } finally {
3419
+ await connection.end();
3420
+ }
3421
+
3422
+ process.env.DATABASE_URL = url;
3423
+ return url;
3424
+ }
3425
+ `;
3426
+ }
3427
+ return `const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3428
+
3429
+ ${resolveUrl}
3430
+ function adminCandidateUrls(url: string): string[] {
3431
+ const names = ["postgres", "template1"];
3432
+ try {
3433
+ const current = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
3434
+ if (current && !names.includes(current)) {
3435
+ names.push(current);
3436
+ }
3437
+ } catch {
3438
+ }
3439
+ return names.map((name) => {
3440
+ const admin = new URL(url);
3441
+ admin.pathname = \`/\${name}\`;
3442
+ return admin.toString();
3443
+ });
3444
+ }
3445
+
3446
+ async function openAdminConnection(url: string): Promise<Bun.SQL> {
3447
+ let lastError: unknown;
3448
+ for (const candidate of adminCandidateUrls(url)) {
3449
+ const adminSql = new Bun.SQL(candidate);
3450
+ try {
3451
+ await adminSql\`SELECT 1\`;
3452
+ return adminSql;
3453
+ } catch (error) {
3454
+ lastError = error;
3455
+ await adminSql.close().catch(() => undefined);
3456
+ }
3457
+ }
3458
+ throw lastError instanceof Error
3459
+ ? lastError
3460
+ : new Error("Could not open an admin connection to create the app database.");
3461
+ }
3462
+
3463
+ export async function ensureAppDatabase(): Promise<string> {
3464
+ const url = resolveAppDatabaseUrl();
3465
+ const name = safeDatabaseName(url);
3466
+
3467
+ const adminSql = await openAdminConnection(url);
3468
+ try {
3469
+ const rows = await adminSql\`
3470
+ SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
3471
+ \`;
3472
+ if (rows.length === 0) {
3473
+ await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
3474
+ }
3475
+ } finally {
3476
+ await adminSql.close();
3477
+ }
3478
+
3479
+ process.env.DATABASE_URL = url;
3480
+ return url;
3481
+ }
3482
+ `;
3483
+ }
3484
+ function renderSidecarsTs(_layers) {
3485
+ return null;
3486
+ }
3487
+ function renderProvidersIndex() {
3488
+ return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
3489
+ import authProvider from "./auth.ts";
3490
+ import cacheProvider from "./cache.ts";
3491
+ import configProvider from "./config.ts";
3492
+ import queueProvider from "./queue.ts";
3493
+ import storageProvider from "./storage.ts";
3494
+
3495
+ const starterProviders: ServiceProvider[] = [
3496
+ configProvider,
3497
+ cacheProvider,
3498
+ storageProvider,
3499
+ queueProvider,
3500
+ authProvider,
3501
+ ];
3502
+
3503
+ export { starterProviders };
3504
+ `;
3505
+ }
3506
+ function renderCreateAppTs(layers) {
3507
+ const ensureLine = needsEnsure(layers) ? `import { ensureAppDatabase } from "./ensureDatabase.ts";
3508
+ ` : "";
3509
+ const metricsImport = layers.extras.metrics ? `import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
3510
+ ` : "";
3511
+ const metricsSpread = layers.extras.metrics ? `
3512
+ ...createMetricsRoutes(),` : "";
3513
+ return `import { join } from "node:path";
3514
+ import "./preload.ts";
3515
+ import { runProviderPhase } from "@getstrata/bootstrap/context";
3516
+ import {
3517
+ type AppContext,
3518
+ type AppDependencies,
3519
+ type AppRouteMap,
3520
+ assertAppDependenciesComplete,
3521
+ type ConfigStore,
3522
+ type MutableAppDependencies,
3523
+ type ProviderContext,
3524
+ ServiceContainer,
3525
+ } from "@getstrata/bootstrap/contracts";
3526
+ import { mergeSpaRoutes } from "@getstrata/bootstrap/createSpaRoutes";
3527
+ import {
3528
+ configureModulesDirectory,
3529
+ ensureModulesLoaded,
3530
+ } from "@getstrata/bootstrap/discoverModules";
3531
+ import { createHealthRoutes } from "@getstrata/bootstrap/health";
3532
+ ${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
3533
+ import { createWebServer } from "@getstrata/bootstrap/web/server";
3534
+ import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
3535
+ import { migrate } from "../db/migrate.ts";
3536
+ import { buildRoutes } from "../routes.ts";
3537
+ import { loadConfig } from "./config.ts";
3538
+ import { getSql } from "./database.ts";
3539
+ ${ensureLine}import { starterProviders } from "./providers/index.ts";
3540
+
3541
+ export interface BootstrapOptions {
3542
+ migrate?: boolean;
3543
+ }
3544
+
3545
+ export interface BootstrappedApp {
3546
+ context: AppContext;
3547
+ routes: AppRouteMap;
3548
+ config: ReturnType<typeof loadConfig>;
3549
+ }
3550
+
3551
+ class AppConfigStore {
3552
+ private readonly values = new Map<string, unknown>();
3553
+
3554
+ set<T>(key: string, value: T): T {
3555
+ this.values.set(key, value);
3556
+ return value;
3557
+ }
3558
+
3559
+ get<T>(key: string): T | undefined {
3560
+ return this.values.get(key) as T | undefined;
3561
+ }
3562
+
3563
+ require<T>(key: string): T {
3564
+ const value = this.get<T>(key);
3565
+ if (value === undefined) {
3566
+ throw new Error(\`Missing required config value "\${key}".\`);
3567
+ }
3568
+ return value;
3569
+ }
3570
+
3571
+ has(key: string): boolean {
3572
+ return this.values.has(key);
3573
+ }
3574
+ }
3575
+
3576
+ function createAppContext(): AppContext {
3577
+ const container = new ServiceContainer();
3578
+ const config = new AppConfigStore() as unknown as ConfigStore;
3579
+ const dependencies: MutableAppDependencies = { container };
3580
+ const context: ProviderContext = { container, config, dependencies };
3581
+
3582
+ runProviderPhase(starterProviders, "register", context);
3583
+ runProviderPhase(starterProviders, "boot", context);
3584
+
3585
+ assertAppDependenciesComplete(dependencies);
3586
+
3587
+ const appContext = { container, config, dependencies: dependencies as AppDependencies };
3588
+ setActiveApplicationContext(appContext as never);
3589
+ return appContext;
3590
+ }
3591
+
3592
+ export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
3593
+ const isProduction = process.env.APP_ENV === "production";
3594
+ // Dev boots migrate for convenience. Production must not mutate schema on
3595
+ // start, so run \`strata migrate\` as an explicit deploy step instead.
3596
+ const { migrate: runMigrate = !isProduction } = options;
3597
+
3598
+ if (isProduction) {
3599
+ assertProductionSecrets();
3600
+ }
3601
+
3602
+ ${needsEnsure(layers) ? ` await ensureAppDatabase();
3603
+ ` : ""} const appConfig = loadConfig();
3604
+ getSql();
3605
+ configureModulesDirectory(join(import.meta.dir, "../modules"));
3606
+ await ensureModulesLoaded();
3607
+ const context = createAppContext();
3608
+
3609
+ if (runMigrate) {
3610
+ await migrate();
3611
+ }
3612
+
3613
+ const routes = mergeSpaRoutes(context.dependencies, {
3614
+ ...createHealthRoutes(context.dependencies),
3615
+ ...buildRoutes(context.dependencies),${metricsSpread}
3616
+ }, {
3617
+ distDirectory: join(import.meta.dir, "../../frontend/dist"),
3618
+ });
3619
+
3620
+ return { context, routes, config: appConfig };
3621
+ }
3622
+
3623
+ export async function createApp(options: BootstrapOptions = {}) {
3624
+ return bootstrapApp({ migrate: false, ...options });
3625
+ }
3626
+
3627
+ export function createAppServer(routes: AppRouteMap, port = 0) {
3628
+ return createWebServer({
3629
+ port,
3630
+ publicDir: "./public",
3631
+ routes,
3632
+ });
3633
+ }
3634
+
3635
+ export { createAppContext };
3636
+ `;
3637
+ }
3638
+ function renderRoutesTs() {
3639
+ return `import { buildModuleRoutes } from "@getstrata/bootstrap/buildModuleRoutes";
3640
+ import { buildWebModuleRoutes } from "@getstrata/bootstrap/buildWebModuleRoutes";
3641
+ import type { AppDependencies, AppRouteMap } from "@getstrata/bootstrap/contracts";
3642
+
3643
+ export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
3644
+ const api = buildModuleRoutes(dependencies);
3645
+ return {
3646
+ ...api,
3647
+ ...buildWebModuleRoutes(dependencies, { clearRegistry: false }),
3648
+ };
3649
+ }
3650
+ `;
3651
+ }
3652
+ function renderViewTs(layers) {
3653
+ const authImport = authNeedsUsers(layers.auth) ? `import { currentAuthUser } from "@getstrata/core/auth/authContext";
3654
+ import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
3655
+ import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
3656
+ import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
3657
+ import { starterAuthDirectory } from "../bootstrap/authDirectory.ts";
3658
+ ` : `import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
3659
+ import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
3660
+ import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
3661
+ `;
3662
+ const userBlock = authNeedsUsers(layers.auth) ? ` let currentUser: { id: number; email: string; name: string | null } | null = null;
3663
+ const authUser = currentAuthUser();
3664
+ if (authUser) {
3665
+ try {
3666
+ const row = await starterAuthDirectory.findByIdOrThrow(Number(authUser.id));
3667
+ currentUser = { id: row.id, email: row.email ?? "", name: row.name ?? null };
3668
+ } catch {
3669
+ currentUser = null;
3670
+ }
3671
+ }` : ` const currentUser = null;`;
3672
+ return `import { join } from "node:path";
3673
+ ${authImport}
3674
+ const engine = new EtaViewEngine(join(import.meta.dir, "../../views"));
3675
+
3676
+ export interface LayoutData {
3677
+ title: string;
3678
+ description?: string;
3679
+ }
3680
+
3681
+ export async function renderPage(
3682
+ template: string,
3683
+ data: Record<string, unknown> & { layout: LayoutData },
3684
+ request?: Request,
3685
+ status = 200,
3686
+ ): Promise<Response> {
3687
+ const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
3688
+ const flash = currentRequestMeta().flash ?? null;
3689
+ ${userBlock}
3690
+ const html = await engine.render(
3691
+ template,
3692
+ { ...data, csrfToken, flash, currentUser },
3693
+ { request },
3694
+ );
3695
+ return htmlResponse(html, { status });
3696
+ }
3697
+
3698
+ export function plainText(body: string, status = 200): Response {
3699
+ return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
3700
+ }
3701
+ `;
3702
+ }
3703
+
3704
+ // ../strata-starter/src/renderScim.ts
3705
+ function ph2(layers, count, start = 1) {
3706
+ if (layers.database === "postgres") {
3707
+ return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
3708
+ }
3709
+ return Array.from({ length: count }, () => "?").join(", ");
3710
+ }
3711
+ function sqlFalse2(layers) {
3712
+ return layers.database === "postgres" ? "false" : "0";
3713
+ }
3714
+ function renderScimModule(layers) {
3715
+ if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
3716
+ return null;
3717
+ }
3718
+ const tenantOn = usesTenantTable(layers.tenancy);
3719
+ const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
3720
+ const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
3721
+ const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
3722
+ const emailPh = ph2(layers, 1);
3723
+ const idPh = ph2(layers, 1);
3724
+ const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
3725
+ return `import { randomBytes } from "node:crypto";
3726
+ import type { AppModule } from "@getstrata/bootstrap/contracts";
3727
+ import { routeParams } from "@getstrata/bootstrap/web/routing";
3728
+ import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
3729
+ import { hashPassword } from "@getstrata/core/auth/password";
3730
+ import { withErrorHandling } from "@getstrata/core/http/response";
3731
+ import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
3732
+ import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
3733
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
3734
+ import { getSql } from "../../bootstrap/database.ts";
3735
+
3736
+ const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
3737
+ const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
3738
+ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
3739
+ const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
3740
+ const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
3741
+
3742
+ type UserRow = { id: number; name: string; email: string };
3743
+
3744
+ function scimEnabled(): boolean {
3745
+ return (process.env.FEATURE_SCIM ?? "false") === "true";
3746
+ }
3747
+
3748
+ function scimJson(body: unknown, status = 200): Response {
3749
+ return Response.json(body, {
3750
+ status,
3751
+ headers: { "content-type": "application/scim+json" },
3752
+ });
3753
+ }
3754
+
3755
+ function scimError(detail: string, status: number): Response {
3756
+ return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
3757
+ }
3758
+
3759
+ function toScimUser(row: UserRow) {
3760
+ return {
3761
+ schemas: [USER_SCHEMA],
3762
+ id: String(row.id),
3763
+ userName: row.email,
3764
+ name: { formatted: row.name },
3765
+ emails: [{ value: row.email, primary: true }],
3766
+ active: true,
3767
+ meta: { resourceType: "User" },
3768
+ };
3769
+ }
3770
+
3771
+ function readUserName(body: Record<string, unknown>): string {
3772
+ const emails = body.emails;
3773
+ if (Array.isArray(emails) && emails[0] && typeof emails[0] === "object") {
3774
+ const value = (emails[0] as { value?: unknown }).value;
3775
+ if (typeof value === "string" && value.trim()) {
3776
+ return value.trim().toLowerCase();
3777
+ }
3778
+ }
3779
+ return typeof body.userName === "string" ? body.userName.trim().toLowerCase() : "";
3780
+ }
3781
+
3782
+ function readName(body: Record<string, unknown>, fallback: string): string {
3783
+ const name = body.name;
3784
+ if (name && typeof name === "object") {
3785
+ const formatted = (name as { formatted?: unknown; givenName?: unknown; familyName?: unknown }).formatted;
3786
+ if (typeof formatted === "string" && formatted.trim()) {
3787
+ return formatted.trim();
3788
+ }
3789
+ const given = (name as { givenName?: unknown }).givenName;
3790
+ const family = (name as { familyName?: unknown }).familyName;
3791
+ const combined = [given, family].filter((part) => typeof part === "string").join(" ").trim();
3792
+ if (combined) {
3793
+ return combined;
3794
+ }
3795
+ }
3796
+ if (typeof body.displayName === "string" && body.displayName.trim()) {
3797
+ return body.displayName.trim();
3798
+ }
3799
+ return fallback;
3800
+ }
3801
+
3802
+ function wrapScim(handler: (request: Request) => Promise<Response>) {
3803
+ const throttle = createScimThrottleMiddleware({
3804
+ redisUrl: process.env.REDIS_URL,
3805
+ maxAttempts: 120,
3806
+ decaySeconds: 60,
3807
+ });
3808
+ return withMiddleware(throttle, createScimAuthMiddleware())(
3809
+ withErrorHandling(async (request) => {
3810
+ if (!scimEnabled()) {
3811
+ return scimError("SCIM is off.", 404);
3812
+ }
3813
+ return handler(request);
3814
+ }),
3815
+ );
3816
+ }
3817
+
3818
+ const scimModule: AppModule = {
3819
+ name: "scim",
3820
+ order: 8,
3821
+ routes({ kernel }) {
3822
+ return {
3823
+ "/scim/v2/ServiceProviderConfig": {
3824
+ GET: kernel.wrap(
3825
+ "api",
3826
+ wrapScim(async () =>
3827
+ scimJson({
3828
+ schemas: [CONFIG_SCHEMA],
3829
+ patch: { supported: true },
3830
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
3831
+ filter: { supported: true, maxResults: 200 },
3832
+ changePassword: { supported: false },
3833
+ sort: { supported: false },
3834
+ etag: { supported: false },
3835
+ authenticationSchemes: [
3836
+ {
3837
+ type: "oauthbearertoken",
3838
+ name: "OAuth Bearer Token",
3839
+ description: "Bearer token in the Authorization header.",
3840
+ specUri: "https://www.rfc-editor.org/rfc/rfc6750",
3841
+ primary: true,
3842
+ },
3843
+ ],
3844
+ }),
3845
+ ),
3846
+ ),
3847
+ },
3848
+ "/scim/v2/Users": {
3849
+ GET: kernel.wrap(
3850
+ "api",
3851
+ wrapScim(async (request) => {
3852
+ const url = new URL(request.url);
3853
+ const filter = url.searchParams.get("filter") ?? "";
3854
+ const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
3855
+ let rows: UserRow[];
3856
+ if (match?.[1]) {
3857
+ rows = await getSql().unsafe<UserRow>(
3858
+ "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3859
+ [match[1].trim().toLowerCase()],
3860
+ );
3861
+ } else {
3862
+ rows = await getSql().unsafe<UserRow>("SELECT id, name, email FROM users");
3863
+ }
3864
+ const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
3865
+ const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
3866
+ const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
3867
+ return scimJson({
3868
+ schemas: [LIST_SCHEMA],
3869
+ totalResults: rows.length,
3870
+ startIndex,
3871
+ itemsPerPage: slice.length,
3872
+ Resources: slice.map(toScimUser),
3873
+ });
3874
+ }),
3875
+ ),
3876
+ POST: kernel.wrap(
3877
+ "api",
3878
+ wrapScim(async (request) => {
3879
+ const body = (await request.json()) as Record<string, unknown>;
3880
+ const email = readUserName(body);
3881
+ const name = readName(body, email.split("@")[0] ?? "User");
3882
+ if (!email) {
3883
+ return scimError("userName is required.", 400);
3884
+ }
3885
+ const existing = await getSql().unsafe<UserRow>(
3886
+ "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3887
+ [email],
3888
+ );
3889
+ if (existing[0]) {
3890
+ return scimError("User already exists.", 409);
3891
+ }
3892
+ const hashed = await hashPassword(randomBytes(18).toString("hex"));
3893
+ const tenantId = currentTenant()?.id ?? 1;
3894
+ await getSql().unsafe(
3895
+ "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
3896
+ [name, email, hashed${insertTail}],
3897
+ );
3898
+ const created = await getSql().unsafe<UserRow>(
3899
+ "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3900
+ [email],
3901
+ );
3902
+ const row = created[0];
3903
+ if (!row) {
3904
+ return scimError("Could not create user.", 500);
3905
+ }
3906
+ return scimJson(toScimUser(row), 201);
3907
+ }),
3908
+ ),
3909
+ },
3910
+ "/scim/v2/Users/:id": {
3911
+ GET: kernel.wrap(
3912
+ "api",
3913
+ wrapScim(async (request) => {
3914
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3915
+ const rows = await getSql().unsafe<UserRow>(
3916
+ "SELECT id, name, email FROM users WHERE id = ${idPh}",
3917
+ [id],
3918
+ );
3919
+ const row = rows[0];
3920
+ if (!row) {
3921
+ return scimError("User not found.", 404);
3922
+ }
3923
+ return scimJson(toScimUser(row));
3924
+ }),
3925
+ ),
3926
+ PUT: kernel.wrap(
3927
+ "api",
3928
+ wrapScim(async (request) => {
3929
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3930
+ const body = (await request.json()) as Record<string, unknown>;
3931
+ const email = readUserName(body);
3932
+ const name = readName(body, email);
3933
+ if (!email || !name) {
3934
+ return scimError("userName and name are required.", 400);
3935
+ }
3936
+ await getSql().unsafe(
3937
+ "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3938
+ [name, email, id],
3939
+ );
3940
+ const rows = await getSql().unsafe<UserRow>(
3941
+ "SELECT id, name, email FROM users WHERE id = ${idPh}",
3942
+ [id],
3943
+ );
3944
+ const row = rows[0];
3945
+ if (!row) {
3946
+ return scimError("User not found.", 404);
3947
+ }
3948
+ return scimJson(toScimUser(row));
3949
+ }),
3950
+ ),
3951
+ PATCH: kernel.wrap(
3952
+ "api",
3953
+ wrapScim(async (request) => {
3954
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3955
+ const existing = await getSql().unsafe<UserRow>(
3956
+ "SELECT id, name, email FROM users WHERE id = ${idPh}",
3957
+ [id],
3958
+ );
3959
+ const row = existing[0];
3960
+ if (!row) {
3961
+ return scimError("User not found.", 404);
3962
+ }
3963
+ const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
3964
+ if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
3965
+ return scimError("Unsupported patch schema.", 400);
3966
+ }
3967
+ let name = row.name;
3968
+ let email = row.email;
3969
+ for (const operation of body.Operations ?? []) {
3970
+ const op = (operation.op ?? "replace").toLowerCase();
3971
+ if (op !== "replace" && op !== "add") {
3972
+ continue;
3973
+ }
3974
+ const path = (operation.path ?? "").toLowerCase();
3975
+ if (path === "username" || path === "emails") {
3976
+ email = String(operation.value ?? email).trim().toLowerCase();
3977
+ } else if (path === "name.formatted" || path === "displayname" || path === "name") {
3978
+ if (typeof operation.value === "string") {
3979
+ name = operation.value.trim() || name;
3980
+ } else if (operation.value && typeof operation.value === "object") {
3981
+ name = readName({ name: operation.value as Record<string, unknown> }, name);
3982
+ }
3983
+ } else if (!path && operation.value && typeof operation.value === "object") {
3984
+ const value = operation.value as Record<string, unknown>;
3985
+ email = readUserName(value) || email;
3986
+ name = readName(value, name);
3987
+ }
3988
+ }
3989
+ await getSql().unsafe(
3990
+ "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3991
+ [name, email, id],
3992
+ );
3993
+ return scimJson(toScimUser({ id: row.id, name, email }));
3994
+ }),
3995
+ ),
3996
+ DELETE: kernel.wrap(
3997
+ "api",
3998
+ wrapScim(async (request) => {
3999
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
4000
+ await getSql().unsafe("DELETE FROM users WHERE id = ${idPh}", [id]);
4001
+ return new Response(null, { status: 204 });
4002
+ }),
4003
+ ),
4004
+ },
4005
+ };
4006
+ },
4007
+ };
4008
+
4009
+ export default scimModule;
4010
+ `;
4011
+ }
4012
+
4013
+ // ../strata-starter/src/generate.ts
4014
+ var PROJECT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/i;
4015
+ function starterPackageRoot() {
4016
+ return join2(import.meta.dir, "..");
4017
+ }
4018
+ function resolveTemplateRoot(root = starterPackageRoot()) {
4019
+ const fromDist = join2(root, "templates");
4020
+ if (existsSync2(join2(fromDist, "src"))) {
4021
+ return fromDist;
4022
+ }
4023
+ const nested = join2(root, "dist/templates");
4024
+ if (existsSync2(join2(nested, "src"))) {
4025
+ return nested;
4026
+ }
4027
+ return fromDist;
4028
+ }
4029
+ function resolveOverlayRoot(root = starterPackageRoot()) {
4030
+ const candidates = [
4031
+ join2(root, "templates/overlays"),
4032
+ join2(root, "dist/templates/overlays"),
4033
+ join2(root, "../../templates/scaffold")
4034
+ ];
4035
+ for (const candidate of candidates) {
4036
+ if (existsSync2(join2(candidate, "server-htmx")) || existsSync2(join2(candidate, "spa-react")) || existsSync2(join2(candidate, "api"))) {
4037
+ return candidate;
4038
+ }
4039
+ }
4040
+ return candidates[0] ?? join2(root, "templates/overlays");
4041
+ }
4042
+ function assertProjectName(projectName) {
4043
+ if (!PROJECT_NAME_PATTERN.test(projectName)) {
4044
+ throw new Error("Project name must contain only letters, numbers, hyphens, and underscores.");
4045
+ }
4046
+ }
4047
+ function resolveProjectTarget(rawTarget, cwd) {
4048
+ const targetDir = resolve(cwd, rawTarget.trim());
4049
+ const projectName = basename(targetDir);
4050
+ assertProjectName(projectName);
4051
+ return { projectName, targetDir };
4052
+ }
4053
+ function applyFrontendOverlays(overlayRoot, targetDir, layers) {
4054
+ if (layers.frontend === "hybrid" || layers.frontend === "spa-react") {
4055
+ copyOverlayTree(join2(overlayRoot, "spa-react"), targetDir);
4056
+ }
4057
+ }
4058
+ function writeGeneratedFiles(options) {
4059
+ const { targetDir, projectName, layers } = options;
4060
+ const src = join2(targetDir, "src");
4061
+ writeText(join2(targetDir, ".env.example"), renderEnvExample(projectName, layers));
4062
+ writeText(join2(targetDir, ".gitignore"), renderGitignore());
4063
+ writeText(join2(targetDir, "package.json"), renderPackageJson(projectName, { ...options, layers }));
4064
+ writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers));
4065
+ if (layers.frontend === "api") {
4066
+ writeText(join2(targetDir, "docs/API.md"), renderApiDocs(projectName, layers));
4067
+ } else {
4068
+ removeIfExists(join2(targetDir, "docs/API.md"));
4069
+ }
4070
+ writeText(join2(targetDir, "strata.layers.json"), renderLayersManifest(projectName, layers));
4071
+ writeText(join2(targetDir, "Dockerfile"), renderDockerfile(layers));
4072
+ writeText(join2(targetDir, ".dockerignore"), renderDockerignore(layers));
4073
+ const compose = renderDockerCompose(projectName, layers);
4074
+ if (compose) {
4075
+ writeText(join2(targetDir, "docker-compose.yml"), compose);
4076
+ } else {
4077
+ removeIfExists(join2(targetDir, "docker-compose.yml"));
4078
+ }
4079
+ writeText(join2(src, "routes.ts"), renderRoutesTs());
4080
+ writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
4081
+ writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
4082
+ writeText(join2(src, "bootstrap/preload.ts"), renderPreloadTs(layers, projectName));
4083
+ writeText(join2(src, "bootstrap/database.ts"), renderDatabaseTs(layers));
4084
+ const ensureDatabase = renderEnsureDatabaseTs(layers, projectName);
4085
+ if (ensureDatabase) {
4086
+ writeText(join2(src, "bootstrap/ensureDatabase.ts"), ensureDatabase);
4087
+ } else {
4088
+ removeIfExists(join2(src, "bootstrap/ensureDatabase.ts"));
4089
+ }
4090
+ writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
4091
+ writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
4092
+ writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
4093
+ writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
4094
+ writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
4095
+ writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
4096
+ writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
4097
+ writeText(join2(src, "db/seed.ts"), renderSeedTs());
4098
+ writeText(join2(src, "db/status.ts"), renderStatusTs(layers));
4099
+ writeText(join2(src, "db/rollback.ts"), renderRollbackTs(layers));
4100
+ writeText(join2(src, "modules/site/index.ts"), renderSiteModule(layers));
4101
+ const directory = renderAuthDirectory(layers);
4102
+ if (directory) {
4103
+ writeText(join2(src, "bootstrap/authDirectory.ts"), directory);
4104
+ }
4105
+ const sidecars = renderSidecarsTs(layers);
4106
+ if (sidecars) {
4107
+ writeText(join2(src, "bootstrap/sidecars.ts"), sidecars);
4108
+ }
4109
+ const authModule = renderAuthModule(layers);
4110
+ if (authModule) {
4111
+ writeText(join2(src, "modules/auth/index.ts"), authModule);
4112
+ }
4113
+ const scimModule = renderScimModule(layers);
4114
+ if (scimModule) {
4115
+ writeText(join2(src, "modules/scim/index.ts"), scimModule);
4116
+ } else {
4117
+ removeIfExists(join2(src, "modules/scim/index.ts"));
4118
+ }
4119
+ if (layers.extras.mfa && htmlAuthKit(layers.auth)) {
4120
+ writeText(join2(src, "bootstrap/pendingMfa.ts"), renderPendingMfaTs());
4121
+ } else {
4122
+ removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
4123
+ }
4124
+ writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
4125
+ writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
4126
+ writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
4127
+ if (htmlAuthKit(layers.auth)) {
4128
+ writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView());
4129
+ writeText(join2(targetDir, "views/auth/register.eta"), renderRegisterView());
4130
+ writeText(join2(targetDir, "views/auth/forgot-password.eta"), renderForgotPasswordView());
4131
+ writeText(join2(targetDir, "views/auth/reset-password.eta"), renderResetPasswordView());
4132
+ if (layers.extras.emailVerification) {
4133
+ writeText(join2(targetDir, "views/auth/verify-email.eta"), renderVerifyEmailView());
4134
+ }
4135
+ if (layers.extras.mfa) {
4136
+ writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
4137
+ writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
4138
+ }
4139
+ }
4140
+ mkdirSync2(join2(targetDir, "storage"), { recursive: true });
4141
+ writeText(join2(targetDir, "storage/.gitkeep"), "");
4142
+ }
4143
+ function printNextSteps(projectName, layers, compose, cdTarget = projectName) {
4144
+ const dockerOn = selectedDockerServices(layers);
4145
+ const neededTools = neededDockerServices(layers);
4146
+ const localOn = neededTools.filter((name) => !dockerOn.includes(name));
4147
+ const dockerSummary = dockerOn.length > 0 ? dockerOn.join("+") : neededTools.length === 0 ? "none" : "local";
4148
+ console.log(`
4149
+ Created Strata app in ${projectName}/
4150
+ `);
4151
+ console.log(`frontend=${layers.frontend} db=${layers.database} auth=${layers.auth} docker=${dockerSummary}`);
4152
+ console.log(`
4153
+ Next steps:`);
4154
+ console.log(` cd ${cdTarget}`);
4155
+ console.log(" cp .env.example .env");
4156
+ if (compose) {
4157
+ console.log(" docker compose up -d");
4158
+ }
4159
+ if (dockerOn.includes("adminer")) {
4160
+ console.log(" Adminer: http://localhost:8080");
4161
+ }
4162
+ if (localOn.length > 0) {
4163
+ console.log(` Point env at local ${localOn.join(", ")} (see README).`);
4164
+ }
4165
+ console.log(" bun install");
4166
+ if (needsFrontendBuild(layers.frontend)) {
4167
+ console.log(" bun run frontend:install");
4168
+ console.log(" bun run frontend:build");
4169
+ }
4170
+ console.log(" bun run db:migrate");
4171
+ console.log(` bun run dev
4172
+ `);
4173
+ console.log("The strata binary is local to the app, so use the bun run scripts above.");
4174
+ console.log(`Run it directly with bunx strata <command> from inside the app directory.
4175
+ `);
4176
+ }
4177
+ function generateProject(options) {
4178
+ assertProjectName(options.projectName);
4179
+ if (existsSync2(options.targetDir)) {
4180
+ if (!options.force) {
4181
+ throw new Error(`Directory already exists: ${options.targetDir}`);
4182
+ }
4183
+ rmSync2(options.targetDir, { recursive: true, force: true });
4184
+ }
4185
+ copyTree(options.templateRoot, options.targetDir, options.projectName, new Set(["overlays"]));
4186
+ applyFrontendOverlays(options.overlayRoot, options.targetDir, options.layers);
4187
+ writeGeneratedFiles(options);
4188
+ }
4189
+ async function runCreateStrata(argv, cwd = process.cwd()) {
4190
+ let flags;
4191
+ try {
4192
+ flags = parseCreateStrataArgs(argv);
4193
+ } catch (error) {
4194
+ console.error(error instanceof Error ? error.message : error);
4195
+ return 1;
4196
+ }
4197
+ if (flags.help) {
4198
+ console.log(usage());
4199
+ return 0;
4200
+ }
4201
+ try {
4202
+ const plan = await resolveStarterPlan(flags);
4203
+ const { projectName, targetDir } = resolveProjectTarget(plan.projectName, cwd);
4204
+ generateProject({
4205
+ projectName,
4206
+ targetDir,
4207
+ layers: plan.layers,
4208
+ templateRoot: resolveTemplateRoot(),
4209
+ overlayRoot: resolveOverlayRoot(),
4210
+ force: flags.force
4211
+ });
4212
+ printNextSteps(projectName, plan.layers, renderDockerCompose(projectName, plan.layers) !== null, plan.projectName);
4213
+ return 0;
4214
+ } catch (error) {
4215
+ console.error(error instanceof Error ? error.message : error);
4216
+ return 1;
4217
+ }
4218
+ }
4219
+
4220
+ // ../strata-starter/cli.ts
4221
+ var code = await runCreateStrata(process.argv.slice(2));
4222
+ process.exit(code);