@getstrata/starter 0.1.8 → 0.1.10

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