@getstrata/starter 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -80,7 +80,7 @@ var AUTH_STACKS = [
80
80
  "cookie-token",
81
81
  "cookie-token-jwt"
82
82
  ];
83
- var TENANCY_DRIVERS = ["none", "rls"];
83
+ var TENANCY_DRIVERS = ["none", "column", "rls"];
84
84
  var CACHE_DRIVERS = ["array", "redis"];
85
85
  var QUEUE_DRIVERS = ["sync", "redis"];
86
86
  var MAIL_DRIVERS = ["log", "smtp"];
@@ -103,6 +103,21 @@ function authUsesJwt(auth) {
103
103
  function authNeedsUsers(auth) {
104
104
  return auth !== "headers";
105
105
  }
106
+ function usesTenantTable(tenancy) {
107
+ return tenancy === "rls" || tenancy === "column";
108
+ }
109
+ function htmlAuthKit(auth) {
110
+ return authUsesCookie(auth);
111
+ }
112
+ function extraApplies(extra, auth) {
113
+ if (extra === "metrics") {
114
+ return true;
115
+ }
116
+ if (extra === "mfa") {
117
+ return htmlAuthKit(auth);
118
+ }
119
+ return authNeedsUsers(auth);
120
+ }
106
121
  function needsRedis(layers) {
107
122
  return layers.cache === "redis" || layers.queue === "redis";
108
123
  }
@@ -225,7 +240,7 @@ Options:
225
240
  --frontend api | server-htmx | spa-react | hybrid
226
241
  --database sqlite | postgres | mysql (one database; not mixed)
227
242
  --auth headers | cookie | token | jwt | cookie-token | cookie-token-jwt
228
- --tenancy none | rls
243
+ --tenancy none | column | rls (rls is Postgres SET LOCAL; sqlite/mysql coerce rls to column)
229
244
  --cache array | redis
230
245
  --queue sync | redis
231
246
  --mail log | smtp
@@ -234,7 +249,7 @@ Options:
234
249
  --email-verification / --no-email-verification
235
250
  --scim / --no-scim
236
251
  --metrics / --no-metrics
237
- --extras Prompt (or enable) MFA, email verification, SCIM, metrics
252
+ --extras Interactive extras list (MFA, email verification, SCIM, metrics)
238
253
  --docker Write Docker Compose for every selected tool that needs a service
239
254
  --no-docker Skip docker-compose.yml; use installs already on this machine
240
255
  --docker-services Subset: postgres, mysql, redis, mailpit (comma-separated)
@@ -461,8 +476,8 @@ function applyFlagOverrides(base, flags) {
461
476
  spaPrefix: flags.spaPrefix ?? base.spaPrefix,
462
477
  extras: { ...base.extras, ...flags.extras }
463
478
  };
464
- if (next.database !== "postgres") {
465
- next.tenancy = "none";
479
+ if (next.database !== "postgres" && next.tenancy === "rls") {
480
+ next.tenancy = "column";
466
481
  }
467
482
  return reconcileDocker(applyDockerFlags(next, flags));
468
483
  }
@@ -473,14 +488,355 @@ function layersFromFlags(flags) {
473
488
  // src/prompt.ts
474
489
  import { stdin as input, stdout as output } from "process";
475
490
  import { createInterface } from "readline/promises";
491
+
492
+ // src/selectPrompt.ts
493
+ class PromptCancelledError extends Error {
494
+ constructor() {
495
+ super("Cancelled");
496
+ this.name = "PromptCancelledError";
497
+ }
498
+ }
499
+ function consumeSelectKeys(buffer) {
500
+ const events = [];
501
+ let rest = buffer;
502
+ while (rest.length > 0) {
503
+ if (rest[0] === "\x1B") {
504
+ if (rest.length === 1) {
505
+ break;
506
+ }
507
+ if (rest.startsWith("\x1B[")) {
508
+ const end = rest.search(/[A-Za-z]/);
509
+ if (end < 2) {
510
+ break;
511
+ }
512
+ const command = rest[end];
513
+ rest = rest.slice(end + 1);
514
+ if (command === "A") {
515
+ events.push({ type: "up" });
516
+ } else if (command === "B") {
517
+ events.push({ type: "down" });
518
+ } else if (command === "C") {
519
+ events.push({ type: "right" });
520
+ } else if (command === "D") {
521
+ events.push({ type: "left" });
522
+ }
523
+ continue;
524
+ }
525
+ if (rest.startsWith("\x1BO")) {
526
+ if (rest.length < 3) {
527
+ break;
528
+ }
529
+ const command = rest[2];
530
+ rest = rest.slice(3);
531
+ if (command === "A") {
532
+ events.push({ type: "up" });
533
+ } else if (command === "B") {
534
+ events.push({ type: "down" });
535
+ } else if (command === "C") {
536
+ events.push({ type: "right" });
537
+ } else if (command === "D") {
538
+ events.push({ type: "left" });
539
+ }
540
+ continue;
541
+ }
542
+ rest = rest.slice(1);
543
+ events.push({ type: "abort" });
544
+ continue;
545
+ }
546
+ const next = rest[0] ?? "";
547
+ rest = rest.slice(1);
548
+ if (next === "\x03") {
549
+ events.push({ type: "abort" });
550
+ continue;
551
+ }
552
+ if (next === "\r" || next === `
553
+ `) {
554
+ events.push({ type: "submit" });
555
+ continue;
556
+ }
557
+ if (next === " ") {
558
+ events.push({ type: "toggle" });
559
+ continue;
560
+ }
561
+ if (next === "y" || next === "Y") {
562
+ events.push({ type: "yes" });
563
+ continue;
564
+ }
565
+ if (next === "n" || next === "N") {
566
+ events.push({ type: "no" });
567
+ continue;
568
+ }
569
+ if (next >= "1" && next <= "9") {
570
+ events.push({ type: "digit", value: Number(next) });
571
+ }
572
+ }
573
+ return { events, rest };
574
+ }
575
+ function moveSelectIndex(index, delta, length) {
576
+ if (length <= 0) {
577
+ return 0;
578
+ }
579
+ return (index + delta + length) % length;
580
+ }
581
+ function highlight(line, on) {
582
+ return on ? `\x1B[7m${line}\x1B[0m` : line;
583
+ }
584
+ function renderSelectLines(message, choices, index) {
585
+ return [
586
+ message,
587
+ ...choices.map((choice, choiceIndex) => {
588
+ const selected = choiceIndex === index;
589
+ const marker = selected ? ">" : " ";
590
+ return highlight(` ${marker} ${choiceIndex + 1}) ${choice.label}`, selected);
591
+ }),
592
+ ` \u2191/\u2193 and Enter, or 1-${choices.length}`
593
+ ];
594
+ }
595
+ function renderConfirmLines(message, yes) {
596
+ return [
597
+ message,
598
+ highlight(` ${yes ? ">" : " "} yes`, yes),
599
+ highlight(` ${yes ? " " : ">"} no`, !yes),
600
+ " \u2191/\u2193 and Enter, or y / n"
601
+ ];
602
+ }
603
+ function renderMultiSelectLines(message, choices, index) {
604
+ return [
605
+ message,
606
+ ...choices.map((choice, choiceIndex) => {
607
+ const focused = choiceIndex === index;
608
+ const box = choice.enabled ? "[x]" : "[ ]";
609
+ const marker = focused ? ">" : " ";
610
+ return highlight(` ${marker} ${box} ${choiceIndex + 1}) ${choice.label}`, focused);
611
+ }),
612
+ " \u2191/\u2193 move, Space or 1-9 toggle, Enter to continue"
613
+ ];
614
+ }
615
+ function writeLines(output, lines) {
616
+ for (const line of lines) {
617
+ output.write(`\x1B[2K${line}
618
+ `);
619
+ }
620
+ }
621
+ function clearDrawnLines(output, lineCount) {
622
+ if (lineCount <= 0) {
623
+ return;
624
+ }
625
+ output.write(`\x1B[${lineCount}F`);
626
+ for (let i = 0;i < lineCount; i += 1) {
627
+ output.write(`\x1B[2K
628
+ `);
629
+ }
630
+ output.write(`\x1B[${lineCount}F`);
631
+ }
632
+ function runRawPrompt(io, render, onEvent, summary) {
633
+ const { input, output } = io;
634
+ const wasRaw = Boolean(input.isTTY && typeof input.setRawMode === "function");
635
+ let buffer = "";
636
+ let lineCount = 0;
637
+ let settled = false;
638
+ const restore = () => {
639
+ output.write("\x1B[?25h");
640
+ if (wasRaw) {
641
+ input.setRawMode?.(false);
642
+ }
643
+ };
644
+ const paint = () => {
645
+ const lines = render();
646
+ if (lineCount > 0) {
647
+ output.write(`\x1B[${lineCount}F`);
648
+ }
649
+ writeLines(output, lines);
650
+ lineCount = lines.length;
651
+ };
652
+ input.setEncoding("utf8");
653
+ if (wasRaw) {
654
+ input.setRawMode?.(true);
655
+ }
656
+ if (typeof input.resume === "function") {
657
+ input.resume();
658
+ }
659
+ output.write("\x1B[?25l");
660
+ paint();
661
+ return new Promise((resolve, reject) => {
662
+ const cleanup = () => {
663
+ input.off("data", onData);
664
+ restore();
665
+ };
666
+ const succeed = (result) => {
667
+ if (settled) {
668
+ return;
669
+ }
670
+ settled = true;
671
+ cleanup();
672
+ clearDrawnLines(output, lineCount);
673
+ output.write(`${summary(result)}
674
+ `);
675
+ resolve(result);
676
+ };
677
+ const fail = (error) => {
678
+ if (settled) {
679
+ return;
680
+ }
681
+ settled = true;
682
+ cleanup();
683
+ clearDrawnLines(output, lineCount);
684
+ reject(error);
685
+ };
686
+ function onData(chunk) {
687
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
688
+ const consumed = consumeSelectKeys(buffer);
689
+ buffer = consumed.rest;
690
+ for (const event of consumed.events) {
691
+ const outcome = onEvent(event);
692
+ if (outcome === "abort") {
693
+ fail(new PromptCancelledError);
694
+ return;
695
+ }
696
+ if (outcome === "continue") {
697
+ paint();
698
+ continue;
699
+ }
700
+ succeed(outcome.done);
701
+ return;
702
+ }
703
+ }
704
+ input.on("data", onData);
705
+ });
706
+ }
707
+ async function promptSelect(message, choices, defaultValue, io) {
708
+ if (choices.length === 0) {
709
+ throw new Error(`No choices for "${message}".`);
710
+ }
711
+ const found = choices.findIndex((choice) => choice.value === defaultValue);
712
+ let index = found >= 0 ? found : 0;
713
+ return runRawPrompt(io, () => renderSelectLines(message, choices, index), (event) => {
714
+ if (event.type === "abort") {
715
+ return "abort";
716
+ }
717
+ if (event.type === "up") {
718
+ index = moveSelectIndex(index, -1, choices.length);
719
+ return "continue";
720
+ }
721
+ if (event.type === "down") {
722
+ index = moveSelectIndex(index, 1, choices.length);
723
+ return "continue";
724
+ }
725
+ if (event.type === "digit") {
726
+ if (event.value >= 1 && event.value <= choices.length) {
727
+ const choice = choices[event.value - 1];
728
+ if (choice) {
729
+ return { done: choice.value };
730
+ }
731
+ }
732
+ return "continue";
733
+ }
734
+ if (event.type === "submit") {
735
+ const choice = choices[index];
736
+ if (choice) {
737
+ return { done: choice.value };
738
+ }
739
+ }
740
+ return "continue";
741
+ }, (value) => `${message} ${choices.find((choice) => choice.value === value)?.label ?? value}`);
742
+ }
743
+ async function promptConfirm(message, defaultValue, io) {
744
+ let yes = defaultValue;
745
+ return runRawPrompt(io, () => renderConfirmLines(message, yes), (event) => {
746
+ if (event.type === "abort") {
747
+ return "abort";
748
+ }
749
+ if (event.type === "up" || event.type === "right" || event.type === "yes") {
750
+ if (event.type === "yes") {
751
+ return { done: true };
752
+ }
753
+ yes = true;
754
+ return "continue";
755
+ }
756
+ if (event.type === "down" || event.type === "left" || event.type === "no") {
757
+ if (event.type === "no") {
758
+ return { done: false };
759
+ }
760
+ yes = false;
761
+ return "continue";
762
+ }
763
+ if (event.type === "digit") {
764
+ if (event.value === 1) {
765
+ return { done: true };
766
+ }
767
+ if (event.value === 2) {
768
+ return { done: false };
769
+ }
770
+ }
771
+ if (event.type === "submit") {
772
+ return { done: yes };
773
+ }
774
+ return "continue";
775
+ }, (value) => `${message} ${value ? "yes" : "no"}`);
776
+ }
777
+ async function promptMultiSelect(message, choices, io) {
778
+ if (choices.length === 0) {
779
+ return [];
780
+ }
781
+ const selected = choices.map((choice) => choice.enabled);
782
+ let index = 0;
783
+ const enabledValues = () => choices.filter((_, choiceIndex) => selected[choiceIndex]).map((choice) => choice.value);
784
+ return runRawPrompt(io, () => renderMultiSelectLines(message, choices.map((choice, choiceIndex) => ({
785
+ ...choice,
786
+ enabled: Boolean(selected[choiceIndex])
787
+ })), index), (event) => {
788
+ if (event.type === "abort") {
789
+ return "abort";
790
+ }
791
+ if (event.type === "up") {
792
+ index = moveSelectIndex(index, -1, choices.length);
793
+ return "continue";
794
+ }
795
+ if (event.type === "down") {
796
+ index = moveSelectIndex(index, 1, choices.length);
797
+ return "continue";
798
+ }
799
+ if (event.type === "toggle") {
800
+ selected[index] = !selected[index];
801
+ return "continue";
802
+ }
803
+ if (event.type === "digit") {
804
+ if (event.value >= 1 && event.value <= choices.length) {
805
+ const target = event.value - 1;
806
+ selected[target] = !selected[target];
807
+ index = target;
808
+ }
809
+ return "continue";
810
+ }
811
+ if (event.type === "submit") {
812
+ return { done: enabledValues() };
813
+ }
814
+ return "continue";
815
+ }, (values) => {
816
+ const labels = choices.filter((choice) => values.includes(choice.value)).map((choice) => choice.value);
817
+ return `${message} ${labels.length > 0 ? labels.join(", ") : "none"}`;
818
+ });
819
+ }
820
+
821
+ // src/prompt.ts
822
+ var EXTRA_CHOICES = [
823
+ { value: "mfa", label: "mfa: authenticator challenge + setup pages" },
824
+ { value: "emailVerification", label: "email-verification: signed links + /email/verify" },
825
+ { value: "scim", label: "scim: /Users adapter" },
826
+ { value: "metrics", label: "metrics: Prometheus token" }
827
+ ];
476
828
  function isInteractive(flags) {
477
829
  if (flags.yes || flags.noInteractive) {
478
830
  return false;
479
831
  }
480
832
  return Boolean(input.isTTY && output.isTTY);
481
833
  }
834
+ function canUseRawKeys() {
835
+ return Boolean(input.isTTY && typeof input.setRawMode === "function");
836
+ }
482
837
  function createReadlinePrompter() {
483
838
  const rl = createInterface({ input, output });
839
+ const io = { input, output };
484
840
  return {
485
841
  async question(message, defaultValue) {
486
842
  const suffix = defaultValue ? ` [${defaultValue}]` : "";
@@ -488,6 +844,15 @@ function createReadlinePrompter() {
488
844
  return answer || defaultValue || "";
489
845
  },
490
846
  async confirm(message, defaultValue = false) {
847
+ if (canUseRawKeys()) {
848
+ rl.pause();
849
+ try {
850
+ return await promptConfirm(message, defaultValue, io);
851
+ } finally {
852
+ input.setRawMode?.(false);
853
+ rl.resume();
854
+ }
855
+ }
491
856
  const hint = defaultValue ? "Y/n" : "y/N";
492
857
  const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
493
858
  if (!answer) {
@@ -496,6 +861,15 @@ function createReadlinePrompter() {
496
861
  return answer === "y" || answer === "yes";
497
862
  },
498
863
  async select(message, choices, defaultValue) {
864
+ if (canUseRawKeys()) {
865
+ rl.pause();
866
+ try {
867
+ return await promptSelect(message, choices, defaultValue, io);
868
+ } finally {
869
+ input.setRawMode?.(false);
870
+ rl.resume();
871
+ }
872
+ }
499
873
  console.log(message);
500
874
  for (const [index, choice] of choices.entries()) {
501
875
  const marker = choice.value === defaultValue ? "*" : " ";
@@ -514,11 +888,66 @@ function createReadlinePrompter() {
514
888
  const match = choices.find((choice) => choice.value === answer || choice.label === answer);
515
889
  return match?.value ?? defaultValue;
516
890
  },
891
+ async multiSelect(message, choices) {
892
+ if (canUseRawKeys()) {
893
+ rl.pause();
894
+ try {
895
+ return await promptMultiSelect(message, choices, io);
896
+ } finally {
897
+ input.setRawMode?.(false);
898
+ rl.resume();
899
+ }
900
+ }
901
+ const enabled = new Set(choices.filter((choice) => choice.enabled).map((choice) => choice.value));
902
+ console.log(`${message} (yes/no each)`);
903
+ for (const choice of choices) {
904
+ const hint = enabled.has(choice.value) ? "Y/n" : "y/N";
905
+ const answer = (await rl.question(` ${choice.label} (${hint}): `)).trim().toLowerCase();
906
+ if (!answer) {
907
+ continue;
908
+ }
909
+ if (answer === "y" || answer === "yes") {
910
+ enabled.add(choice.value);
911
+ } else if (answer === "n" || answer === "no") {
912
+ enabled.delete(choice.value);
913
+ }
914
+ }
915
+ return [...enabled];
916
+ },
517
917
  close() {
518
918
  rl.close();
519
919
  }
520
920
  };
521
921
  }
922
+ function extrasStillToAsk(layers, flags) {
923
+ return EXTRA_CHOICES.filter((choice) => {
924
+ if (flags.extras[choice.value] !== undefined) {
925
+ return false;
926
+ }
927
+ return extraApplies(choice.value, layers.auth);
928
+ });
929
+ }
930
+ async function promptExtras(prompter, extras, layers, flags) {
931
+ const choices = extrasStillToAsk(layers, flags);
932
+ if (choices.length === 0) {
933
+ return extras;
934
+ }
935
+ const picked = new Set(await prompter.multiSelect("Extras", choices.map((choice) => ({
936
+ value: choice.value,
937
+ label: choice.label,
938
+ enabled: extras[choice.value]
939
+ }))));
940
+ const next = { ...extras };
941
+ for (const choice of choices) {
942
+ next[choice.value] = picked.has(choice.value);
943
+ }
944
+ for (const choice of EXTRA_CHOICES) {
945
+ if (!extraApplies(choice.value, layers.auth) && flags.extras[choice.value] === undefined) {
946
+ next[choice.value] = false;
947
+ }
948
+ }
949
+ return next;
950
+ }
522
951
  async function promptLayers(flags, prompter) {
523
952
  const layers = applyFlagOverrides(defaultLayers(), flags);
524
953
  layers.frontend = await prompter.select("Frontend", [
@@ -540,14 +969,20 @@ async function promptLayers(flags, prompter) {
540
969
  { value: "cookie-token", label: "cookie-token: HTML cookies + API tokens" },
541
970
  { value: "cookie-token-jwt", label: "cookie-token-jwt: cookies, tokens, and JWT" }
542
971
  ], layers.auth);
543
- if (layers.database === "postgres") {
544
- layers.tenancy = await prompter.select("Tenancy", [
545
- { value: "none", label: "none: no tenant table" },
546
- { value: "rls", label: "rls: Postgres row-level security plus a tenant table" }
547
- ], layers.tenancy);
548
- } else {
549
- layers.tenancy = "none";
550
- }
972
+ layers.tenancy = await prompter.select("Tenancy", layers.database === "postgres" ? [
973
+ { value: "none", label: "none: no tenant table" },
974
+ {
975
+ value: "column",
976
+ label: "column: tenant table + users.tenant_id (no Postgres SET LOCAL)"
977
+ },
978
+ { value: "rls", label: "rls: Postgres row-level security plus a tenant table" }
979
+ ] : [
980
+ { value: "none", label: "none: no tenant table" },
981
+ {
982
+ value: "column",
983
+ label: "column: tenant table + users.tenant_id (SQLite/MySQL cannot run Postgres RLS)"
984
+ }
985
+ ], layers.database === "postgres" ? layers.tenancy : layers.tenancy === "rls" ? "column" : layers.tenancy);
551
986
  layers.cache = await prompter.select("Cache", [
552
987
  { value: "array", label: "array: in-process" },
553
988
  { value: "redis", label: "redis" }
@@ -560,16 +995,10 @@ async function promptLayers(flags, prompter) {
560
995
  { value: "log", label: "log: print messages" },
561
996
  { value: "smtp", label: "smtp" }
562
997
  ], layers.mail);
563
- if (layers.frontend === "spa-react" || layers.frontend === "hybrid") {
998
+ if (flags.spaPrefix === undefined && (layers.frontend === "spa-react" || layers.frontend === "hybrid")) {
564
999
  layers.spaPrefix = await prompter.question("SPA prefix", layers.spaPrefix);
565
1000
  }
566
- const askExtras = flags.extrasPrompt || await prompter.confirm("Configure extras (MFA, SCIM, metrics)?", false);
567
- if (askExtras) {
568
- layers.extras.mfa = await prompter.confirm("Staff MFA env flag?", layers.extras.mfa);
569
- layers.extras.emailVerification = await prompter.confirm("Email verification env flag?", layers.extras.emailVerification);
570
- layers.extras.scim = await prompter.confirm("SCIM env stubs?", layers.extras.scim);
571
- layers.extras.metrics = await prompter.confirm("Metrics token?", layers.extras.metrics);
572
- }
1001
+ layers.extras = await promptExtras(prompter, layers.extras, layers, flags);
573
1002
  if (!dockerFlagsProvided(flags)) {
574
1003
  layers.docker = await promptDockerLayer(prompter, layers);
575
1004
  }
@@ -621,300 +1050,159 @@ async function resolveStarterPlan(flags, injected) {
621
1050
  }
622
1051
  }
623
1052
 
624
- // src/renderAuth.ts
625
- function renderAuthDirectory(layers) {
626
- if (!authNeedsUsers(layers.auth)) {
627
- return null;
1053
+ // src/renderAuthFlows.ts
1054
+ function ph(layers, count, start = 1) {
1055
+ if (layers.database === "postgres") {
1056
+ return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
628
1057
  }
629
- const tokenLookup = authUsesToken(layers.auth) ? `
630
- async resolveUserFromToken(token: string) {
631
- if (!token || token.split(".").length === 3) {
632
- return null;
633
- }
634
- const hashed = hashApiToken(token);
635
- const rows = await getSql().unsafe<
636
- Array<{
637
- id: number;
638
- user_id: number;
639
- abilities: string;
640
- expires_at: Date | string | null;
641
- role?: string;
642
- is_admin?: number | boolean;
643
- email_verified_at?: Date | string | null;
644
- }>
645
- >(
646
- \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
647
- FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
648
- WHERE t.token_hash = ?\`,
649
- [hashed],
650
- );
651
- const row = rows[0];
652
- if (!row) {
653
- return null;
654
- }
655
- if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
656
- return null;
657
- }
658
- let abilities: string[] = [];
659
- try {
660
- abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
661
- } catch {
662
- abilities = ["profile:read"];
663
- }
664
- return {
665
- id: Number(row.user_id),
666
- role: row.is_admin ? "admin" : "member",
667
- abilities,
668
- tokenId: Number(row.id),
669
- emailVerifiedAt: row.email_verified_at ?? null,
670
- };
671
- },` : `
672
- async resolveUserFromToken() {
673
- return null;
674
- },`;
675
- const placeholder = layers.database === "postgres" ? "$1" : "?";
676
- const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
677
- ` : "";
678
- return `import type { AuthUser } from "@getstrata/core/auth/authContext";
679
- import { verifyPassword } from "@getstrata/core/auth/password";
680
- ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
681
- import { getSql } from "./database.ts";
1058
+ return Array.from({ length: count }, () => "?").join(", ");
1059
+ }
1060
+ function sqlFalse(layers) {
1061
+ return layers.database === "postgres" ? "false" : "0";
1062
+ }
1063
+ function sqlTrue(layers) {
1064
+ return layers.database === "postgres" ? "true" : "1";
1065
+ }
1066
+ function renderPendingMfaTs() {
1067
+ return `import { createHmac, timingSafeEqual } from "node:crypto";
682
1068
 
683
- function mapRole(isAdmin: unknown): string {
684
- return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
1069
+ const COOKIE = "strata_mfa_pending";
1070
+
1071
+ function secret(): string {
1072
+ return process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch";
685
1073
  }
686
1074
 
687
- export const starterAuthDirectory: AuthUserDirectory = {
688
- ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
1075
+ function sign(userId: number, issuedAt: number): string {
1076
+ const payload = \`\${userId}.\${issuedAt}\`;
1077
+ const signature = createHmac("sha256", secret()).update(payload).digest("hex");
1078
+ return \`\${payload}.\${signature}\`;
1079
+ }
689
1080
 
690
- async findByIdOrThrow(id: number) {
691
- const rows = await getSql().unsafe<
692
- Array<{
693
- id: number;
694
- email: string;
695
- is_admin: number | boolean;
696
- email_verified_at: Date | string | null;
697
- password: string;
698
- }>
699
- >(\`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE id = ${placeholder}\`, [id]);
700
- const row = rows[0];
701
- if (!row) {
702
- throw new Error(\`User \${id} not found.\`);
703
- }
704
- return {
705
- id: Number(row.id),
706
- email: row.email,
707
- role: mapRole(row.is_admin),
708
- email_verified_at: row.email_verified_at ?? null,
709
- password: row.password,
710
- };
711
- },
1081
+ export function pendingMfaSetCookie(userId: number): string {
1082
+ const issuedAt = Date.now();
1083
+ return \`\${COOKIE}=\${sign(userId, issuedAt)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600\`;
1084
+ }
712
1085
 
713
- async findByEmail(email: string) {
714
- const rows = await getSql().unsafe<
715
- Array<{
716
- id: number;
717
- email: string;
718
- is_admin: number | boolean;
719
- email_verified_at: Date | string | null;
720
- password: string;
721
- }>
722
- >(
723
- \`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE email = ${placeholder}\`,
724
- [email.trim().toLowerCase()],
725
- );
726
- const row = rows[0];
727
- if (!row) {
1086
+ export function pendingMfaClearCookie(): string {
1087
+ return \`\${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0\`;
1088
+ }
1089
+
1090
+ export function readPendingMfaUserId(request: Request): number | null {
1091
+ const header = request.headers.get("cookie") ?? "";
1092
+ for (const part of header.split(";")) {
1093
+ const [name, ...rest] = part.trim().split("=");
1094
+ if (name !== COOKIE) {
1095
+ continue;
1096
+ }
1097
+ const value = rest.join("=");
1098
+ const pieces = value.split(".");
1099
+ if (pieces.length !== 3) {
728
1100
  return null;
729
1101
  }
730
- return {
731
- id: Number(row.id),
732
- email: row.email,
733
- role: mapRole(row.is_admin),
734
- email_verified_at: row.email_verified_at ?? null,
735
- password: row.password,
736
- };
737
- },
738
-
739
- async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
740
- const user = await this.findByEmail(email);
741
- if (!user?.password || !(await verifyPassword(password, user.password))) {
1102
+ const userId = Number.parseInt(pieces[0] ?? "", 10);
1103
+ const issuedAt = Number.parseInt(pieces[1] ?? "", 10);
1104
+ const signature = pieces[2] ?? "";
1105
+ if (!Number.isInteger(userId) || userId <= 0 || Date.now() - issuedAt > 10 * 60 * 1000) {
742
1106
  return null;
743
1107
  }
744
- return {
745
- id: user.id,
746
- role: user.role,
747
- emailVerifiedAt: user.email_verified_at ?? null,
748
- };
749
- },
750
- };
1108
+ const expected = sign(userId, issuedAt).split(".").pop() ?? "";
1109
+ const left = Buffer.from(signature);
1110
+ const right = Buffer.from(expected);
1111
+ if (left.length !== right.length || !timingSafeEqual(left, right)) {
1112
+ return null;
1113
+ }
1114
+ return userId;
1115
+ }
1116
+ return null;
1117
+ }
751
1118
  `;
752
1119
  }
753
- function renderAuthProvider(layers) {
754
- if (layers.auth === "headers") {
755
- return `import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
756
- import type { AuthUser } from "@getstrata/core/auth/authContext";
757
- import { currentAuthUser } from "@getstrata/core/auth/authContext";
758
- import type { ServiceProvider } from "@getstrata/core/contracts/di";
759
-
760
- class StarterAuthManager {
761
- async resolve(request?: Request): Promise<AuthUser | null> {
762
- if (process.env.AUTH_DEV_HEADERS === "false") {
763
- return request ? null : currentAuthUser();
764
- }
765
- if (request) {
766
- const userId = request.headers.get("x-authenticated-user-id");
767
- if (!userId) {
768
- return null;
769
- }
770
- const role = request.headers.get("x-authenticated-user-role");
771
- return {
772
- id: userId,
773
- ...(role ? { role } : {}),
774
- };
775
- }
776
- return currentAuthUser();
777
- }
778
-
779
- user(request?: Request) {
780
- return this.resolve(request);
781
- }
782
-
783
- async check(request: Request) {
784
- return (await this.user(request)) !== null;
785
- }
786
- }
787
-
788
- const authProvider: ServiceProvider = {
789
- name: "starter.auth",
790
- register({ container }) {
791
- container.set(CORE_AUTH_TOKEN, new StarterAuthManager());
792
- },
793
- };
794
-
795
- export default authProvider;
796
- `;
1120
+ function renderAuthModule(layers) {
1121
+ if (!authNeedsUsers(layers.auth)) {
1122
+ return null;
797
1123
  }
798
- const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
799
- secret: process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch",
800
- cookieName: "strata_session",
801
- mapUser: (user) => ({
802
- id: user.id,
803
- role: user.is_admin ? "admin" : "member",
804
- }),
805
- });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
806
- const auth = new AuthManager(fallback);`;
807
- const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
808
- auth.registerGuard("api", apiGuard);
809
- auth.registerGuard("access_token", apiGuard);
810
- auth.registerGuard("token", apiGuard);` : "";
811
- const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
812
- const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
813
- const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
814
- const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
815
- if (authUsesCookie(layers.auth)) {
816
- imports.push(`import { createCookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
1124
+ const cookie = htmlAuthKit(layers.auth);
1125
+ const mfa = Boolean(layers.extras.mfa && cookie);
1126
+ const verify = Boolean(layers.extras.emailVerification);
1127
+ const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
1128
+ const tenantInsert = usesTenantTable(layers.tenancy);
1129
+ const insertCols = tenantInsert ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
1130
+ const insertPh = tenantInsert ? ph(layers, 5) : ph(layers, 4);
1131
+ const insertTail = tenantInsert ? `, ${sqlFalse(layers)}, 1` : `, ${sqlFalse(layers)}`;
1132
+ const passwordPh = `${ph(layers, 1)}`;
1133
+ const emailPh = `${ph(layers, 1, 2)}`;
1134
+ const idPh = `${ph(layers, 1, 2)}`;
1135
+ const verifiedPh = `${ph(layers, 1)}`;
1136
+ const mfaUpdatePh = `${ph(layers, 1)}, ${ph(layers, 1, 2)}, ${ph(layers, 1, 3)}`;
1137
+ const mfaIdPh = `${ph(layers, 1, 4)}`;
1138
+ const imports = [];
1139
+ if (authUsesToken(layers.auth)) {
1140
+ imports.push(`import { randomBytes } from "node:crypto";`);
817
1141
  }
818
- if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
819
- imports.push(`import { BasicAuthGuard } from "@getstrata/core/auth/basicAuthGuard";`);
1142
+ imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
1143
+ imports.push(`import type { AppModule } from "@getstrata/bootstrap/contracts";`);
1144
+ if (cookie) {
1145
+ imports.push(`import { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
1146
+ imports.push(`import { wrapWebLogin, wrapWebRegister } from "@getstrata/bootstrap/web/routing";`);
1147
+ imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
820
1148
  }
821
- if (!authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
822
- imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
823
- } else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
1149
+ if (jsonApi) {
824
1150
  imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
825
- } else if (authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
826
- imports.push(`import { DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
827
1151
  }
828
1152
  if (authUsesJwt(layers.auth)) {
829
- imports.push(`import { JwtGuard } from "@getstrata/core/auth/jwtGuard";`);
1153
+ imports.push(`import { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
830
1154
  }
1155
+ imports.push(`import { hashPassword, verifyPassword } from "@getstrata/core/auth/password";`);
831
1156
  if (authUsesToken(layers.auth)) {
832
- imports.push(`import { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
1157
+ imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
833
1158
  }
834
- imports.push(`import type { ServiceProvider } from "@getstrata/core/contracts/di";`);
835
- const tokenImports = ["CORE_AUTH_USER_DIRECTORY_TOKEN"];
836
- if (authUsesToken(layers.auth)) {
837
- tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
1159
+ if (mfa) {
1160
+ imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
838
1161
  }
839
- imports.push(`import {
840
- ${tokenImports.join(`,
841
- `)},
842
- } from "@getstrata/core/contracts/serviceTokens";`);
843
- imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
844
- return `${imports.join(`
845
- `)}
846
-
847
- const authProvider: ServiceProvider = {
848
- name: "starter.auth",
849
- register({ container }) {
850
- container.set(CORE_AUTH_USER_DIRECTORY_TOKEN, starterAuthDirectory);
851
- ${cookieBlock}
852
- ${tokenRegs}
853
- ${jwtReg}
854
- ${basicReg}
855
- ${ability}
856
- container.set(CORE_AUTH_TOKEN, auth);
857
- },
858
- };
1162
+ if (cookie) {
1163
+ imports.push(`import { flashResponse } from "@getstrata/core/http/flashSession";`);
1164
+ }
1165
+ if (jsonApi) {
1166
+ imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
1167
+ }
1168
+ imports.push(`import { absoluteTemporarySignedUrl, assertValidSignature } from "@getstrata/core/http/signedUrl";`);
1169
+ imports.push(`import { mailer } from "@getstrata/core/mail/mailer";`);
1170
+ if (mfa) {
1171
+ imports.push(`import { generateRecoveryCodes, hashRecoveryCode, recoveryCodeMatches } from "@getstrata/core/security/recoveryCodes";`);
1172
+ imports.push(`import { buildOtpauthUrl, generateTotpSecret, verifyTotp } from "@getstrata/core/security/totp";`);
1173
+ }
1174
+ imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
1175
+ imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
1176
+ if (cookie) {
1177
+ imports.push(`import { renderPage } from "../../lib/view.ts";`);
1178
+ }
1179
+ if (mfa) {
1180
+ imports.push(`import { pendingMfaClearCookie, pendingMfaSetCookie, readPendingMfaUserId } from "../../bootstrap/pendingMfa.ts";`);
1181
+ }
1182
+ const helpers = `
1183
+ async function sendSignedMail(to: string, subject: string, path: string, query: Record<string, string>) {
1184
+ const link = absoluteTemporarySignedUrl(path, 3600, query);
1185
+ await mailer().send({
1186
+ to,
1187
+ subject,
1188
+ body: \`\${subject}\\n\\n\${link}\\n\`,
1189
+ });
1190
+ }
1191
+ ${cookie ? `
1192
+ function redirectTo(path: string, status = 302): Response {
1193
+ return new Response(null, { status, headers: { location: path } });
1194
+ }
859
1195
 
860
- export default authProvider;
861
- `;
1196
+ function sessionUser(user: { id: number; name?: string | null; email?: string | null; role: string }) {
1197
+ return {
1198
+ id: user.id,
1199
+ name: user.name ?? user.email ?? "",
1200
+ email: user.email ?? "",
1201
+ is_admin: user.role === "admin",
1202
+ };
862
1203
  }
863
- function renderAuthModule(layers) {
864
- if (!authNeedsUsers(layers.auth)) {
865
- return null;
866
- }
867
- const cookieRoutes = authUsesCookie(layers.auth) ? `
868
- webRoutes({ kernel, dependencies }) {
869
- const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
870
- return {
871
- "/login": {
872
- GET: kernel.wrapWebGuest(async (request) =>
873
- renderPage(
874
- "auth/login.eta",
875
- { layout: { title: "Sign in" }, errors: {}, email: "" },
876
- request,
877
- ),
878
- ),
879
- POST: wrapWebLogin(
880
- kernel,
881
- async (request) => {
882
- const { fields } = await parseFormBody(request);
883
- const email = (fields.email ?? "").trim().toLowerCase();
884
- const password = fields.password ?? "";
885
- const user = await starterAuthDirectory.findByEmail?.(email);
886
- if (!user?.password || !(await verifyPassword(password, user.password))) {
887
- return renderPage(
888
- "auth/login.eta",
889
- {
890
- layout: { title: "Sign in" },
891
- errors: { email: "These credentials do not match our records." },
892
- email,
893
- },
894
- request,
895
- );
896
- }
897
- return auth.signInRedirect(
898
- {
899
- id: user.id,
900
- name: user.email ?? "",
901
- email: user.email ?? "",
902
- is_admin: user.role === "admin",
903
- },
904
- "/",
905
- );
906
- },
907
- async () => new Response("Too many login attempts", { status: 429 }),
908
- ),
909
- },
910
- "/logout": {
911
- POST: kernel.wrapWebAuthenticatedAllowUnverified((request) =>
912
- auth.signOutRedirect(request, "/login"),
913
- ),
914
- },
915
- };
916
- },` : "";
917
- const apiLogin = authUsesToken(layers.auth) ? `
1204
+ ` : ""}`;
1205
+ const tokenLogin = authUsesToken(layers.auth) ? `
918
1206
  "/api/v1/auth/login": {
919
1207
  POST: kernel.wrap("api", withErrorHandling(async (request) => {
920
1208
  const body = (await request.json()) as { email?: string; password?: string };
@@ -926,7 +1214,7 @@ function renderAuthModule(layers) {
926
1214
  }
927
1215
  const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
928
1216
  await getSql().unsafe(
929
- "INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${layers.database === "postgres" ? "$1, $2, $3, $4" : "?, ?, ?, ?"})",
1217
+ "INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${ph(layers, 4)})",
930
1218
  [user.id, "spa", hashApiToken(plain), JSON.stringify(["profile:read"])],
931
1219
  );
932
1220
  return jsonResponse({ token: plain });
@@ -938,12 +1226,37 @@ function renderAuthModule(layers) {
938
1226
  const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
939
1227
  return jsonResponse({
940
1228
  id: record.id,
941
- name: record.email,
1229
+ name: record.name ?? record.email,
942
1230
  email: record.email,
943
1231
  role: record.role,
944
1232
  });
945
1233
  }),
946
1234
  },` : "";
1235
+ const jsonRegister = jsonApi ? `
1236
+ "/api/v1/auth/register": {
1237
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1238
+ const body = (await request.json()) as { name?: string; email?: string; password?: string };
1239
+ const name = (body.name ?? "").trim();
1240
+ const email = (body.email ?? "").trim().toLowerCase();
1241
+ const password = body.password ?? "";
1242
+ if (!name || !email || password.length < 8) {
1243
+ return jsonResponse({ error: "Name, email, and a password of 8+ characters are required." }, { status: 422 });
1244
+ }
1245
+ if (await starterAuthDirectory.findByEmail?.(email)) {
1246
+ return jsonResponse({ error: "Email is already registered." }, { status: 422 });
1247
+ }
1248
+ const hashed = await hashPassword(password);
1249
+ await getSql().unsafe(
1250
+ "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
1251
+ [name, email, hashed${insertTail}],
1252
+ );
1253
+ const created = await starterAuthDirectory.findByEmail?.(email);
1254
+ ${verify ? `if (created) {
1255
+ await sendSignedMail(email, "Verify your email", "/api/v1/auth/verify-email", { id: String(created.id) });
1256
+ }` : ""}
1257
+ return jsonResponse({ ok: true }, { status: 201 });
1258
+ })),
1259
+ },` : "";
947
1260
  const jwtLogin = authUsesJwt(layers.auth) ? `
948
1261
  "/api/auth/token": {
949
1262
  POST: kernel.wrap("api", withErrorHandling(async (request) => {
@@ -957,7 +1270,8 @@ function renderAuthModule(layers) {
957
1270
  const token = signJwt({
958
1271
  sub: user.id,
959
1272
  role: user.role,
960
- abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"],
1273
+ abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"],${verify ? `
1274
+ emailVerifiedAt: user.emailVerifiedAt ?? null,` : ""}
961
1275
  });
962
1276
  return jsonResponse({
963
1277
  token,
@@ -966,156 +1280,928 @@ function renderAuthModule(layers) {
966
1280
  });
967
1281
  })),
968
1282
  },` : "";
969
- const apiUser = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? `
1283
+ const apiUser = jsonApi ? `
970
1284
  "/api/user": {
971
1285
  GET: kernel.wrapApi(async (request) => {
972
1286
  const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
973
1287
  return jsonResponse({ id: user.id, role: user.role ?? "member" });
974
1288
  }),
975
1289
  },` : "";
976
- const routesBlock = apiLogin || jwtLogin || apiUser ? `
1290
+ const jsonPassword = jsonApi ? `
1291
+ "/api/v1/auth/forgot-password": {
1292
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1293
+ const body = (await request.json()) as { email?: string };
1294
+ const email = (body.email ?? "").trim().toLowerCase();
1295
+ const user = await starterAuthDirectory.findByEmail?.(email);
1296
+ if (user) {
1297
+ await sendSignedMail(email, "Reset your password", "/api/v1/auth/reset-password", { email });
1298
+ }
1299
+ return jsonResponse({ ok: true });
1300
+ })),
1301
+ },
1302
+ "/api/v1/auth/reset-password": {
1303
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1304
+ assertValidSignature(request);
1305
+ const body = (await request.json()) as { password?: string };
1306
+ const email = new URL(request.url).searchParams.get("email") ?? "";
1307
+ if (!email || !(body.password && body.password.length >= 8)) {
1308
+ return jsonResponse({ error: "Invalid reset payload." }, { status: 422 });
1309
+ }
1310
+ await getSql().unsafe(
1311
+ "UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
1312
+ [await hashPassword(body.password), email],
1313
+ );
1314
+ return jsonResponse({ ok: true });
1315
+ })),
1316
+ },` : "";
1317
+ const jsonVerify = jsonApi && verify ? `
1318
+ "/api/v1/auth/verify-email": {
1319
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1320
+ assertValidSignature(request);
1321
+ const id = Number.parseInt(new URL(request.url).searchParams.get("id") ?? "", 10);
1322
+ if (!Number.isInteger(id) || id <= 0) {
1323
+ return jsonResponse({ error: "Invalid verification link." }, { status: 422 });
1324
+ }
1325
+ await getSql().unsafe(
1326
+ "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1327
+ [new Date().toISOString(), id],
1328
+ );
1329
+ return jsonResponse({ ok: true });
1330
+ })),
1331
+ },` : "";
1332
+ const apiRoutes = jsonApi ? `
977
1333
  routes({ kernel, dependencies }) {
978
- return {${apiLogin}${jwtLogin}${apiUser}
1334
+ return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
979
1335
  };
980
1336
  },` : "";
981
- const imports = [];
982
- if (authUsesToken(layers.auth)) {
983
- imports.push(`import { randomBytes } from "node:crypto";`);
1337
+ const mfaLoginBranch = mfa ? `if (user.mfa_enabled) {
1338
+ const pending = redirectTo("/login/mfa");
1339
+ pending.headers.append("set-cookie", pendingMfaSetCookie(user.id));
1340
+ return pending;
1341
+ }` : "";
1342
+ const verifyRegisterBranch = verify ? `await sendSignedMail(email, "Verify your email", "/email/verify", { id: String(insertedId) });
1343
+ return flashResponse(
1344
+ await auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/email/verify"),
1345
+ { level: "info", message: "Check your email for a verification link." },
1346
+ );` : `return auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/");`;
1347
+ const cookieRoutes = cookie ? `
1348
+ webRoutes({ kernel, dependencies }) {
1349
+ const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1350
+ return {
1351
+ "/login": {
1352
+ GET: kernel.wrapWebGuest(async (request) =>
1353
+ renderPage("auth/login.eta", { layout: { title: "Sign in" }, errors: {}, email: "", password: "" }, request),
1354
+ ),
1355
+ POST: wrapWebLogin(
1356
+ kernel,
1357
+ async (request) => {
1358
+ const { fields } = await parseFormBody(request);
1359
+ const email = (fields.email ?? "").trim().toLowerCase();
1360
+ const password = fields.password ?? "";
1361
+ const user = await starterAuthDirectory.findByEmail?.(email);
1362
+ if (!user?.password || !(await verifyPassword(password, user.password))) {
1363
+ return renderPage(
1364
+ "auth/login.eta",
1365
+ { layout: { title: "Sign in" }, errors: { email: "These credentials do not match our records." }, email, password: "" },
1366
+ request,
1367
+ );
1368
+ }
1369
+ ${mfaLoginBranch}
1370
+ return auth.signInRedirect(sessionUser(user), "/");
1371
+ },
1372
+ async (request) =>
1373
+ renderPage(
1374
+ "auth/login.eta",
1375
+ { layout: { title: "Sign in" }, errors: { email: "Too many login attempts. Try again shortly." }, email: "", password: "" },
1376
+ request,
1377
+ 429,
1378
+ ),
1379
+ ),
1380
+ },
1381
+ "/register": {
1382
+ GET: kernel.wrapWebGuest(async (request) =>
1383
+ renderPage("auth/register.eta", { layout: { title: "Create account" }, errors: {}, name: "", email: "", password: "" }, request),
1384
+ ),
1385
+ POST: wrapWebRegister(
1386
+ kernel,
1387
+ async (request) => {
1388
+ const { fields } = await parseFormBody(request);
1389
+ const name = (fields.name ?? "").trim();
1390
+ const email = (fields.email ?? "").trim().toLowerCase();
1391
+ const password = fields.password ?? "";
1392
+ const errors: Record<string, string> = {};
1393
+ if (!name) {
1394
+ errors.name = "Name is required.";
1395
+ }
1396
+ if (!email) {
1397
+ errors.email = "Email is required.";
1398
+ }
1399
+ if (password.length < 8) {
1400
+ errors.password = "Use at least 8 characters.";
1401
+ }
1402
+ if (email && (await starterAuthDirectory.findByEmail?.(email))) {
1403
+ errors.email = "Email is already registered.";
1404
+ }
1405
+ if (Object.keys(errors).length > 0) {
1406
+ return renderPage(
1407
+ "auth/register.eta",
1408
+ { layout: { title: "Create account" }, errors, name, email, password: "" },
1409
+ request,
1410
+ );
1411
+ }
1412
+ const hashed = await hashPassword(password);
1413
+ await getSql().unsafe(
1414
+ "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
1415
+ [name, email, hashed${insertTail}],
1416
+ );
1417
+ const created = await starterAuthDirectory.findByEmail?.(email);
1418
+ const insertedId = created?.id ?? 0;
1419
+ ${verifyRegisterBranch}
1420
+ },
1421
+ async (request) =>
1422
+ renderPage(
1423
+ "auth/register.eta",
1424
+ { layout: { title: "Create account" }, errors: { form: "Too many registration attempts. Try again shortly." }, name: "", email: "", password: "" },
1425
+ request,
1426
+ 429,
1427
+ ),
1428
+ ),
1429
+ },
1430
+ "/forgot-password": {
1431
+ GET: kernel.wrapWebGuest(async (request) =>
1432
+ renderPage("auth/forgot-password.eta", { layout: { title: "Forgot password" }, errors: {}, email: "" }, request),
1433
+ ),
1434
+ POST: kernel.wrapWeb(async (request) => {
1435
+ const { fields } = await parseFormBody(request);
1436
+ const email = (fields.email ?? "").trim().toLowerCase();
1437
+ const user = await starterAuthDirectory.findByEmail?.(email);
1438
+ if (user) {
1439
+ await sendSignedMail(email, "Reset your password", "/reset-password", { email });
1440
+ }
1441
+ return flashResponse(redirectTo("/forgot-password"), {
1442
+ level: "success",
1443
+ message: "If that account exists, a reset link is on its way.",
1444
+ });
1445
+ }),
1446
+ },
1447
+ "/reset-password": {
1448
+ GET: kernel.wrapWebGuest(async (request) => {
1449
+ assertValidSignature(request);
1450
+ const email = new URL(request.url).searchParams.get("email") ?? "";
1451
+ return renderPage(
1452
+ "auth/reset-password.eta",
1453
+ { layout: { title: "Reset password" }, errors: {}, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1454
+ request,
1455
+ );
1456
+ }),
1457
+ POST: kernel.wrapWeb(async (request) => {
1458
+ assertValidSignature(request);
1459
+ const { fields } = await parseFormBody(request);
1460
+ const email = new URL(request.url).searchParams.get("email") ?? fields.email ?? "";
1461
+ const password = fields.password ?? "";
1462
+ if (!email || password.length < 8) {
1463
+ return renderPage(
1464
+ "auth/reset-password.eta",
1465
+ { layout: { title: "Reset password" }, errors: { password: "Use at least 8 characters." }, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1466
+ request,
1467
+ );
1468
+ }
1469
+ await getSql().unsafe(
1470
+ "UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
1471
+ [await hashPassword(password), email],
1472
+ );
1473
+ return flashResponse(redirectTo("/login"), { level: "success", message: "Password updated. Sign in." });
1474
+ }),
1475
+ },
1476
+ "/logout": {
1477
+ POST: kernel.wrapWebAuthenticatedAllowUnverified((request) => auth.signOutRedirect(request, "/")),
1478
+ },${verify ? `
1479
+ "/email/verify": {
1480
+ GET: kernel.wrapWeb(async (request) => {
1481
+ const url = new URL(request.url);
1482
+ if (url.searchParams.get("signature")) {
1483
+ assertValidSignature(request);
1484
+ const id = Number.parseInt(url.searchParams.get("id") ?? "", 10);
1485
+ if (Number.isInteger(id) && id > 0) {
1486
+ await getSql().unsafe(
1487
+ "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1488
+ [new Date().toISOString(), id],
1489
+ );
1490
+ const record = await starterAuthDirectory.findByIdOrThrow(id);
1491
+ return flashResponse(
1492
+ await auth.signInRedirect(sessionUser(record), "/"),
1493
+ { level: "success", message: "Email verified." },
1494
+ );
1495
+ }
1496
+ }
1497
+ return renderPage("auth/verify-email.eta", { layout: { title: "Verify email" } }, request);
1498
+ }),
1499
+ },
1500
+ "/email/verification-notification": {
1501
+ POST: kernel.wrapWebAuthenticatedAllowUnverified(async (request) => {
1502
+ const user = await auth.user(request);
1503
+ if (user) {
1504
+ const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
1505
+ await sendSignedMail(record.email ?? "", "Verify your email", "/email/verify", { id: String(record.id) });
1506
+ }
1507
+ return flashResponse(redirectTo("/email/verify"), { level: "info", message: "Verification link sent." });
1508
+ }),
1509
+ },` : ""}${mfa ? `
1510
+ "/login/mfa": {
1511
+ GET: kernel.wrapWebGuest(async (request) => {
1512
+ if (!readPendingMfaUserId(request)) {
1513
+ return redirectTo("/login");
1514
+ }
1515
+ return renderPage("auth/mfa-challenge.eta", { layout: { title: "MFA" }, errors: {}, code: "" }, request);
1516
+ }),
1517
+ POST: kernel.wrapWeb(async (request) => {
1518
+ const pendingId = readPendingMfaUserId(request);
1519
+ if (!pendingId) {
1520
+ return redirectTo("/login");
1521
+ }
1522
+ const { fields } = await parseFormBody(request);
1523
+ const submitted = (fields.code ?? "").trim();
1524
+ const record = await starterAuthDirectory.findByIdOrThrow(pendingId);
1525
+ const secret = revealMfaSecret(record.mfa_secret ?? null);
1526
+ const hashedCodes: string[] = record.mfa_recovery_codes
1527
+ ? (JSON.parse(record.mfa_recovery_codes) as string[])
1528
+ : [];
1529
+ const totpOk = secret ? verifyTotp(secret, submitted) : false;
1530
+ const recoveryOk = hashedCodes.some((hash) => recoveryCodeMatches(submitted, hash));
1531
+ if (!totpOk && !recoveryOk) {
1532
+ return renderPage(
1533
+ "auth/mfa-challenge.eta",
1534
+ { layout: { title: "MFA" }, errors: { code: "That code is not valid." }, code: "" },
1535
+ request,
1536
+ );
1537
+ }
1538
+ if (recoveryOk) {
1539
+ const remaining = hashedCodes.filter((hash) => !recoveryCodeMatches(submitted, hash));
1540
+ await getSql().unsafe(
1541
+ "UPDATE users SET mfa_recovery_codes = ${passwordPh} WHERE id = ${idPh}",
1542
+ [JSON.stringify(remaining), pendingId],
1543
+ );
1544
+ }
1545
+ const signed = await auth.signInRedirect(sessionUser(record), "/");
1546
+ signed.headers.append("set-cookie", pendingMfaClearCookie());
1547
+ return signed;
1548
+ }),
1549
+ },
1550
+ "/account/mfa": {
1551
+ GET: kernel.wrapWebAuthenticated(async (request) => {
1552
+ const secret = generateTotpSecret();
1553
+ const user = await auth.user(request);
1554
+ const record = user ? await starterAuthDirectory.findByIdOrThrow(Number(user.id)) : null;
1555
+ const otpauth = buildOtpauthUrl({
1556
+ secret,
1557
+ account: record?.email ?? "user",
1558
+ issuer: process.env.APP_NAME ?? "Strata",
1559
+ });
1560
+ return renderPage(
1561
+ "auth/mfa-setup.eta",
1562
+ { layout: { title: "MFA" }, errors: {}, code: "", secret, otpauth },
1563
+ request,
1564
+ );
1565
+ }),
1566
+ POST: kernel.wrapWebAuthenticated(async (request) => {
1567
+ const user = await auth.user(request);
1568
+ if (!user) {
1569
+ return redirectTo("/login");
1570
+ }
1571
+ const { fields } = await parseFormBody(request);
1572
+ const secret = (fields.secret ?? "").trim();
1573
+ const submitted = (fields.code ?? "").trim();
1574
+ if (!secret || !verifyTotp(secret, submitted)) {
1575
+ return renderPage(
1576
+ "auth/mfa-setup.eta",
1577
+ {
1578
+ layout: { title: "MFA" },
1579
+ errors: { code: "Could not confirm that code." },
1580
+ code: "",
1581
+ secret,
1582
+ otpauth: buildOtpauthUrl({ secret, account: "user", issuer: process.env.APP_NAME ?? "Strata" }),
1583
+ },
1584
+ request,
1585
+ );
1586
+ }
1587
+ const recoveryCodes = generateRecoveryCodes();
1588
+ const stored = protectMfaSecret(secret);
1589
+ await getSql().unsafe(
1590
+ "UPDATE users SET mfa_secret = ${mfaUpdatePh.split(", ")[0]}, mfa_enabled = ${mfaUpdatePh.split(", ")[1]}, mfa_recovery_codes = ${mfaUpdatePh.split(", ")[2]} WHERE id = ${mfaIdPh}",
1591
+ [stored, ${sqlTrue(layers)}, JSON.stringify(recoveryCodes.map((item) => hashRecoveryCode(item))), Number(user.id)],
1592
+ );
1593
+ return renderPage(
1594
+ "auth/mfa-setup.eta",
1595
+ {
1596
+ layout: { title: "MFA" },
1597
+ errors: {},
1598
+ code: "",
1599
+ secret,
1600
+ otpauth: buildOtpauthUrl({ secret, account: "user", issuer: process.env.APP_NAME ?? "Strata" }),
1601
+ recoveryCodes,
1602
+ },
1603
+ request,
1604
+ );
1605
+ }),
1606
+ },` : ""}
1607
+ };
1608
+ },` : "";
1609
+ return `${imports.join(`
1610
+ `)}
1611
+ ${helpers}
1612
+ const authModule: AppModule = {
1613
+ name: "auth",
1614
+ order: 2,${apiRoutes}${cookieRoutes}
1615
+ };
1616
+
1617
+ export default authModule;
1618
+ `;
1619
+ }
1620
+ function renderSiteModule(_layers) {
1621
+ return `import type { AppModule } from "@getstrata/bootstrap/contracts";
1622
+ import { withErrorHandling } from "@getstrata/core/http/response";
1623
+ import { pingDatabase } from "../../bootstrap/database.ts";
1624
+ import { plainText, renderPage } from "../../lib/view.ts";
1625
+
1626
+ const siteModule: AppModule = {
1627
+ name: "site",
1628
+ order: 1,
1629
+ routes({ kernel }) {
1630
+ return {
1631
+ "/health": kernel.wrap("api", withErrorHandling(async () => {
1632
+ const dbOk = await pingDatabase();
1633
+ return plainText(dbOk ? "ok" : "degraded");
1634
+ })),
1635
+ };
1636
+ },
1637
+ webRoutes({ kernel }) {
1638
+ return {
1639
+ "/": kernel.wrapWeb(async (request) =>
1640
+ renderPage(
1641
+ "home.eta",
1642
+ {
1643
+ layout: {
1644
+ title: "Welcome",
1645
+ description: "Welcome to your Strata app. Restyle views/home.eta and public/assets/site.css.",
1646
+ },
1647
+ },
1648
+ request,
1649
+ ),
1650
+ ),
1651
+ };
1652
+ },
1653
+ };
1654
+
1655
+ export default siteModule;
1656
+ `;
1657
+ }
1658
+ // src/renderAuthViews.ts
1659
+ function renderSiteCss() {
1660
+ return `:root {
1661
+ color-scheme: light;
1662
+ --bg: #f4f1ea;
1663
+ --ink: #1c1917;
1664
+ --muted: #57534e;
1665
+ --card: #fffdf8;
1666
+ --line: #e7e0d4;
1667
+ --accent: #1d4e4f;
1668
+ --accent-ink: #f8faf8;
1669
+ --danger: #9f1239;
1670
+ --ok: #166534;
1671
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, serif;
1672
+ line-height: 1.5;
1673
+ }
1674
+
1675
+ * { box-sizing: border-box; }
1676
+
1677
+ body {
1678
+ margin: 0;
1679
+ min-height: 100vh;
1680
+ background: var(--bg);
1681
+ color: var(--ink);
1682
+ }
1683
+
1684
+ .site-header {
1685
+ display: flex;
1686
+ align-items: center;
1687
+ justify-content: space-between;
1688
+ gap: 1rem;
1689
+ padding: 1rem 1.5rem;
1690
+ border-bottom: 1px solid var(--line);
1691
+ background: var(--card);
1692
+ }
1693
+
1694
+ .brand {
1695
+ font-weight: 700;
1696
+ text-decoration: none;
1697
+ color: inherit;
1698
+ letter-spacing: 0.02em;
1699
+ }
1700
+
1701
+ .site-header nav {
1702
+ display: flex;
1703
+ gap: 0.75rem;
1704
+ align-items: center;
1705
+ font-size: 0.95rem;
1706
+ }
1707
+
1708
+ .site-header a { color: inherit; }
1709
+
1710
+ .site-header form { display: inline; }
1711
+
1712
+ main {
1713
+ padding: 2rem 1.5rem 3rem;
1714
+ }
1715
+
1716
+ .section, .auth-card {
1717
+ max-width: 36rem;
1718
+ margin: 0 auto;
1719
+ background: var(--card);
1720
+ border: 1px solid var(--line);
1721
+ border-radius: 1rem;
1722
+ padding: 1.5rem 1.5rem 1.75rem;
1723
+ }
1724
+
1725
+ .hero {
1726
+ max-width: 40rem;
1727
+ }
1728
+
1729
+ .section h1, .auth-card h1, .hero h1 {
1730
+ margin: 0 0 0.5rem;
1731
+ font-size: 1.8rem;
1732
+ }
1733
+
1734
+ .lede, .muted { color: var(--muted); }
1735
+
1736
+ .actions {
1737
+ display: flex;
1738
+ flex-wrap: wrap;
1739
+ gap: 0.75rem;
1740
+ margin-top: 1.25rem;
1741
+ }
1742
+
1743
+ label {
1744
+ display: block;
1745
+ margin: 0.85rem 0;
1746
+ font-size: 0.95rem;
1747
+ }
1748
+
1749
+ input[type="email"],
1750
+ input[type="password"],
1751
+ input[type="text"] {
1752
+ display: block;
1753
+ width: 100%;
1754
+ margin-top: 0.35rem;
1755
+ padding: 0.55rem 0.7rem;
1756
+ border: 1px solid var(--line);
1757
+ border-radius: 0.5rem;
1758
+ background: #fff;
1759
+ font: inherit;
1760
+ }
1761
+
1762
+ button, .button {
1763
+ display: inline-block;
1764
+ border: 0;
1765
+ border-radius: 999px;
1766
+ padding: 0.55rem 1rem;
1767
+ background: var(--accent);
1768
+ color: var(--accent-ink);
1769
+ font: inherit;
1770
+ text-decoration: none;
1771
+ cursor: pointer;
1772
+ }
1773
+
1774
+ .button-secondary {
1775
+ background: transparent;
1776
+ color: var(--ink);
1777
+ border: 1px solid var(--line);
1778
+ }
1779
+
1780
+ .error { color: var(--danger); }
1781
+ .ok, .flash-success { color: var(--ok); }
1782
+ .flash-error { color: var(--danger); }
1783
+ .flash {
1784
+ margin: 0 0 1rem;
1785
+ padding: 0.6rem 0.8rem;
1786
+ border-radius: 0.5rem;
1787
+ border: 1px solid var(--line);
1788
+ }
1789
+
1790
+ .auth-links {
1791
+ margin-top: 1rem;
1792
+ display: flex;
1793
+ flex-wrap: wrap;
1794
+ gap: 0.75rem 1rem;
1795
+ }
1796
+
1797
+ code { font-size: 0.9em; }
1798
+ `;
1799
+ }
1800
+ function renderLayout(layers, projectName) {
1801
+ const kit = htmlAuthKit(layers.auth);
1802
+ const guestNav = kit ? `<a href="/login">Sign in</a>
1803
+ <a href="/register">Create account</a>` : "";
1804
+ const userNav = kit ? `<% if (it.currentUser) { %>
1805
+ <span class="muted"><%= it.currentUser.email %></span>
1806
+ ${layers.extras.mfa ? '<a href="/account/mfa">MFA</a>' : ""}
1807
+ <form method="post" action="/logout">
1808
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1809
+ <button class="button-secondary" type="submit">Sign out</button>
1810
+ </form>
1811
+ <% } else { %>
1812
+ ${guestNav}
1813
+ <% } %>` : "";
1814
+ return `<!DOCTYPE html>
1815
+ <html lang="en">
1816
+ <head>
1817
+ <meta charset="utf-8" />
1818
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1819
+ <title><%= it.layout.title %> \xB7 ${projectName}</title>
1820
+ <% if (it.layout.description) { %>
1821
+ <meta name="description" content="<%= it.layout.description %>" />
1822
+ <% } %>
1823
+ <link rel="stylesheet" href="/assets/site.css" />
1824
+ </head>
1825
+ <body>
1826
+ <header class="site-header">
1827
+ <a class="brand" href="/">${projectName}</a>
1828
+ <nav>
1829
+ ${userNav}
1830
+ </nav>
1831
+ </header>
1832
+ <main>
1833
+ <% if (it.flash && it.flash.message) { %>
1834
+ <p class="flash flash-<%= it.flash.level %>"><%= it.flash.message %></p>
1835
+ <% } %>
1836
+ <%~ it.body %>
1837
+ </main>
1838
+ </body>
1839
+ </html>
1840
+ `;
1841
+ }
1842
+ function renderHomeView(projectName, layers) {
1843
+ const kit = htmlAuthKit(layers.auth);
1844
+ const tokenHint = authUsesToken(layers.auth) ? '<p class="muted">API token: <code>POST /api/v1/auth/login</code> with email and password.</p>' : "";
1845
+ const jwtHint = authUsesJwt(layers.auth) ? '<p class="muted">JWT: <code>POST /api/auth/token</code> with email and password.</p>' : "";
1846
+ const guest = kit ? `<% if (!it.currentUser) { %>
1847
+ <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>
1848
+ <div class="actions">
1849
+ <a class="button" href="/register">Create account</a>
1850
+ <a class="button button-secondary" href="/login">Sign in</a>
1851
+ </div>
1852
+ <p class="muted">Seeded demo: <code>demo@example.com</code> / <code>password</code>.</p>
1853
+ <% } else { %>
1854
+ <p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
1855
+ <p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
1856
+ <% } %>` : `<p class="lede">Edit <code>views/home.eta</code> and <code>public/assets/site.css</code> to restyle this page.</p>
1857
+ <p class="muted">Auth stack: <code>${layers.auth}</code>.</p>`;
1858
+ const extras = [tokenHint, jwtHint].filter(Boolean).join(`
1859
+ `);
1860
+ return `<section class="section hero">
1861
+ <h1>Welcome to ${projectName}</h1>
1862
+ ${guest}
1863
+ <p>Health check: <a href="/health"><code>/health</code></a>.</p>${extras ? `
1864
+ ${extras}` : ""}
1865
+ </section>
1866
+ `;
1867
+ }
1868
+ function renderFormView(title, fields, submit, links) {
1869
+ return `<section class="auth-card">
1870
+ <h1>${title}</h1>
1871
+ <% if (it.status) { %>
1872
+ <p class="ok"><%= it.status %></p>
1873
+ <% } %>
1874
+ <% if (it.errors && it.errors.form) { %>
1875
+ <p class="error"><%= it.errors.form %></p>
1876
+ <% } %>
1877
+ <form method="post" action="<%= it.action || "" %>">
1878
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1879
+ ${fields}
1880
+ <div class="actions">
1881
+ <button type="submit">${submit}</button>
1882
+ </div>
1883
+ </form>
1884
+ <div class="auth-links">
1885
+ ${links}
1886
+ </div>
1887
+ </section>
1888
+ `;
1889
+ }
1890
+ function textField(name, label, type, extra = "") {
1891
+ return `<label>
1892
+ ${label}
1893
+ <% if (it.errors && it.errors.${name}) { %><span class="error"><%= it.errors.${name} %></span><% } %>
1894
+ <input type="${type}" name="${name}" value="<%= it.${name} || "" %>" ${extra} />
1895
+ </label>`;
1896
+ }
1897
+ function renderLoginView() {
1898
+ return renderFormView("Sign in", `${textField("email", "Email", "email", 'required autocomplete="username"')}
1899
+ ${textField("password", "Password", "password", 'required autocomplete="current-password"')}`, "Sign in", `<a href="/register">Create account</a>
1900
+ <a href="/forgot-password">Forgot password</a>`).replace('action="<%= it.action || "" %>"', 'action="/login"');
1901
+ }
1902
+ function renderRegisterView() {
1903
+ return renderFormView("Create account", `${textField("name", "Name", "text", "required")}
1904
+ ${textField("email", "Email", "email", 'required autocomplete="email"')}
1905
+ ${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"');
1906
+ }
1907
+ function renderForgotPasswordView() {
1908
+ 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"');
1909
+ }
1910
+ function renderResetPasswordView() {
1911
+ return renderFormView("Set a new password", `${textField("password", "New password", "password", 'required minlength="8" autocomplete="new-password"')}
1912
+ <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 %>"');
1913
+ }
1914
+ function renderVerifyEmailView() {
1915
+ return `<section class="auth-card">
1916
+ <h1>Verify your email</h1>
1917
+ <p>We sent a signed link to your inbox (or the mail log when <code>MAIL_DRIVER=log</code>).</p>
1918
+ <form method="post" action="/email/verification-notification">
1919
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1920
+ <button type="submit">Resend link</button>
1921
+ </form>
1922
+ </section>
1923
+ `;
1924
+ }
1925
+ function renderMfaChallengeView() {
1926
+ 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"');
1927
+ }
1928
+ function renderMfaSetupView() {
1929
+ return `<section class="auth-card">
1930
+ <h1>Authenticator app</h1>
1931
+ <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>
1932
+ <p><code><%= it.otpauth %></code></p>
1933
+ <form method="post" action="/account/mfa">
1934
+ <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1935
+ <input type="hidden" name="secret" value="<%= it.secret %>" />
1936
+ ${textField("code", "Confirmation code", "text", "required")}
1937
+ <div class="actions"><button type="submit">Enable MFA</button></div>
1938
+ </form>
1939
+ <% if (it.recoveryCodes) { %>
1940
+ <h2>Recovery codes</h2>
1941
+ <p>Store these once. They will not be shown again.</p>
1942
+ <ul>
1943
+ <% for (const code of it.recoveryCodes) { %>
1944
+ <li><code><%= code %></code></li>
1945
+ <% } %>
1946
+ </ul>
1947
+ <% } %>
1948
+ </section>
1949
+ `;
1950
+ }
1951
+
1952
+ // src/renderAuth.ts
1953
+ function renderAuthDirectory(layers) {
1954
+ if (!authNeedsUsers(layers.auth)) {
1955
+ return null;
1956
+ }
1957
+ const tokenLookup = authUsesToken(layers.auth) ? `
1958
+ async resolveUserFromToken(token: string) {
1959
+ if (!token || token.split(".").length === 3) {
1960
+ return null;
1961
+ }
1962
+ const hashed = hashApiToken(token);
1963
+ const rows = await getSql().unsafe<
1964
+ Array<{
1965
+ id: number;
1966
+ user_id: number;
1967
+ abilities: string;
1968
+ expires_at: Date | string | null;
1969
+ role?: string;
1970
+ is_admin?: number | boolean;
1971
+ email_verified_at?: Date | string | null;
1972
+ }>
1973
+ >(
1974
+ \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
1975
+ FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
1976
+ WHERE t.token_hash = ?\`,
1977
+ [hashed],
1978
+ );
1979
+ const row = rows[0];
1980
+ if (!row) {
1981
+ return null;
1982
+ }
1983
+ if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
1984
+ return null;
1985
+ }
1986
+ let abilities: string[] = [];
1987
+ try {
1988
+ abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
1989
+ } catch {
1990
+ abilities = ["profile:read"];
1991
+ }
1992
+ return {
1993
+ id: Number(row.user_id),
1994
+ role: row.is_admin ? "admin" : "member",
1995
+ abilities,
1996
+ tokenId: Number(row.id),
1997
+ emailVerifiedAt: row.email_verified_at ?? null,
1998
+ };
1999
+ },` : `
2000
+ async resolveUserFromToken() {
2001
+ return null;
2002
+ },`;
2003
+ const placeholder = layers.database === "postgres" ? "$1" : "?";
2004
+ const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
2005
+ ` : "";
2006
+ const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
2007
+ const mfaReturn = layers.extras.mfa ? `
2008
+ mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
2009
+ mfa_secret: row.mfa_secret ?? null,
2010
+ mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
2011
+ return `import type { AuthUser } from "@getstrata/core/auth/authContext";
2012
+ import { verifyPassword } from "@getstrata/core/auth/password";
2013
+ ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
2014
+ import { getSql } from "./database.ts";
2015
+
2016
+ function mapRole(isAdmin: unknown): string {
2017
+ return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
2018
+ }
2019
+
2020
+ export const starterAuthDirectory: AuthUserDirectory = {
2021
+ ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
2022
+
2023
+ async findByIdOrThrow(id: number) {
2024
+ const rows = await getSql().unsafe<
2025
+ Array<{
2026
+ id: number;
2027
+ name: string;
2028
+ email: string;
2029
+ is_admin: number | boolean;
2030
+ email_verified_at: Date | string | null;
2031
+ password: string;
2032
+ mfa_enabled?: number | boolean;
2033
+ mfa_secret?: string | null;
2034
+ mfa_recovery_codes?: string | null;
2035
+ }>
2036
+ >(\`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE id = ${placeholder}\`, [id]);
2037
+ const row = rows[0];
2038
+ if (!row) {
2039
+ throw new Error(\`User \${id} not found.\`);
2040
+ }
2041
+ return {
2042
+ id: Number(row.id),
2043
+ name: row.name,
2044
+ email: row.email,
2045
+ role: mapRole(row.is_admin),
2046
+ email_verified_at: row.email_verified_at ?? null,
2047
+ password: row.password,${mfaReturn}
2048
+ };
2049
+ },
2050
+
2051
+ async findByEmail(email: string) {
2052
+ const rows = await getSql().unsafe<
2053
+ Array<{
2054
+ id: number;
2055
+ name: string;
2056
+ email: string;
2057
+ is_admin: number | boolean;
2058
+ email_verified_at: Date | string | null;
2059
+ password: string;
2060
+ mfa_enabled?: number | boolean;
2061
+ mfa_secret?: string | null;
2062
+ mfa_recovery_codes?: string | null;
2063
+ }>
2064
+ >(
2065
+ \`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE email = ${placeholder}\`,
2066
+ [email.trim().toLowerCase()],
2067
+ );
2068
+ const row = rows[0];
2069
+ if (!row) {
2070
+ return null;
2071
+ }
2072
+ return {
2073
+ id: Number(row.id),
2074
+ name: row.name,
2075
+ email: row.email,
2076
+ role: mapRole(row.is_admin),
2077
+ email_verified_at: row.email_verified_at ?? null,
2078
+ password: row.password,${mfaReturn}
2079
+ };
2080
+ },
2081
+
2082
+ async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
2083
+ const user = await this.findByEmail(email);
2084
+ if (!user?.password || !(await verifyPassword(password, user.password))) {
2085
+ return null;
2086
+ }
2087
+ return {
2088
+ id: user.id,
2089
+ role: user.role,
2090
+ emailVerifiedAt: user.email_verified_at ?? null,
2091
+ };
2092
+ },
2093
+ };
2094
+ `;
2095
+ }
2096
+ function renderAuthProvider(layers) {
2097
+ if (layers.auth === "headers") {
2098
+ return `import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
2099
+ import type { AuthUser } from "@getstrata/core/auth/authContext";
2100
+ import { currentAuthUser } from "@getstrata/core/auth/authContext";
2101
+ import type { ServiceProvider } from "@getstrata/core/contracts/di";
2102
+
2103
+ class StarterAuthManager {
2104
+ async resolve(request?: Request): Promise<AuthUser | null> {
2105
+ if (process.env.AUTH_DEV_HEADERS === "false") {
2106
+ return request ? null : currentAuthUser();
2107
+ }
2108
+ if (request) {
2109
+ const userId = request.headers.get("x-authenticated-user-id");
2110
+ if (!userId) {
2111
+ return null;
2112
+ }
2113
+ const role = request.headers.get("x-authenticated-user-role");
2114
+ return {
2115
+ id: userId,
2116
+ ...(role ? { role } : {}),
2117
+ };
2118
+ }
2119
+ return currentAuthUser();
2120
+ }
2121
+
2122
+ user(request?: Request) {
2123
+ return this.resolve(request);
2124
+ }
2125
+
2126
+ async check(request: Request) {
2127
+ return (await this.user(request)) !== null;
2128
+ }
2129
+ }
2130
+
2131
+ const authProvider: ServiceProvider = {
2132
+ name: "starter.auth",
2133
+ register({ container }) {
2134
+ container.set(CORE_AUTH_TOKEN, new StarterAuthManager());
2135
+ },
2136
+ };
2137
+
2138
+ export default authProvider;
2139
+ `;
984
2140
  }
985
- imports.push(`import type { AppModule } from "@getstrata/bootstrap/contracts";`);
986
- imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
2141
+ const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
2142
+ secret: process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch",
2143
+ cookieName: "strata_session",
2144
+ mapUser: (user) => ({
2145
+ id: user.id,
2146
+ role: user.is_admin ? "admin" : "member",
2147
+ ...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
2148
+ }),
2149
+ });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
2150
+ const auth = new AuthManager(fallback);`;
2151
+ const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
2152
+ auth.registerGuard("api", apiGuard);
2153
+ auth.registerGuard("access_token", apiGuard);
2154
+ auth.registerGuard("token", apiGuard);` : "";
2155
+ const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
2156
+ const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
2157
+ const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
2158
+ const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
987
2159
  if (authUsesCookie(layers.auth)) {
988
- imports.push(`import { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
989
- imports.push(`import { wrapWebLogin } from "@getstrata/bootstrap/web/routing";`);
990
- imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
2160
+ imports.push(`import { createCookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
991
2161
  }
992
2162
  if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
2163
+ imports.push(`import { BasicAuthGuard } from "@getstrata/core/auth/basicAuthGuard";`);
2164
+ }
2165
+ if (!authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
2166
+ imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
2167
+ } else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
993
2168
  imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
2169
+ } else if (authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
2170
+ imports.push(`import { DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
994
2171
  }
995
2172
  if (authUsesJwt(layers.auth)) {
996
- imports.push(`import { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
997
- }
998
- if (authUsesCookie(layers.auth)) {
999
- imports.push(`import { verifyPassword } from "@getstrata/core/auth/password";`);
2173
+ imports.push(`import { JwtGuard } from "@getstrata/core/auth/jwtGuard";`);
1000
2174
  }
1001
2175
  if (authUsesToken(layers.auth)) {
1002
- imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
1003
- }
1004
- if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
1005
- imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
2176
+ imports.push(`import { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
1006
2177
  }
1007
- imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
2178
+ imports.push(`import type { ServiceProvider } from "@getstrata/core/contracts/di";`);
2179
+ const tokenImports = ["CORE_AUTH_USER_DIRECTORY_TOKEN"];
1008
2180
  if (authUsesToken(layers.auth)) {
1009
- imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
1010
- }
1011
- if (authUsesCookie(layers.auth)) {
1012
- imports.push(`import { renderPage } from "../../lib/view.ts";`);
2181
+ tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
1013
2182
  }
2183
+ imports.push(`import {
2184
+ ${tokenImports.join(`,
2185
+ `)},
2186
+ } from "@getstrata/core/contracts/serviceTokens";`);
2187
+ imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
1014
2188
  return `${imports.join(`
1015
2189
  `)}
1016
2190
 
1017
- const authModule: AppModule = {
1018
- name: "auth",
1019
- order: 2,${routesBlock}${cookieRoutes}
1020
- };
1021
-
1022
- export default authModule;
1023
- `;
1024
- }
1025
- function renderSiteModule(layers) {
1026
- const loginHint = authUsesCookie(layers.auth) && (layers.frontend === "server-htmx" || layers.frontend === "hybrid") ? " Sign in at /login." : "";
1027
- return `import type { AppModule } from "@getstrata/bootstrap/contracts";
1028
- import { withErrorHandling } from "@getstrata/core/http/response";
1029
- import { pingDatabase } from "../../bootstrap/database.ts";
1030
- import { plainText, renderPage } from "../../lib/view.ts";
1031
-
1032
- const siteModule: AppModule = {
1033
- name: "site",
1034
- order: 1,
1035
- routes({ kernel }) {
1036
- return {
1037
- "/health": kernel.wrap("api", withErrorHandling(async () => {
1038
- const dbOk = await pingDatabase();
1039
- return plainText(dbOk ? "ok" : "degraded");
1040
- })),
1041
- };
1042
- },
1043
- webRoutes({ kernel }) {
1044
- return {
1045
- "/": kernel.wrapWeb(async (request) =>
1046
- renderPage(
1047
- "home.eta",
1048
- {
1049
- layout: {
1050
- title: "Home",
1051
- description: "A new Strata application.${loginHint}",
1052
- },
1053
- },
1054
- request,
1055
- ),
1056
- ),
1057
- };
2191
+ const authProvider: ServiceProvider = {
2192
+ name: "starter.auth",
2193
+ register({ container }) {
2194
+ container.set(CORE_AUTH_USER_DIRECTORY_TOKEN, starterAuthDirectory);
2195
+ ${cookieBlock}
2196
+ ${tokenRegs}
2197
+ ${jwtReg}
2198
+ ${basicReg}
2199
+ ${ability}
2200
+ container.set(CORE_AUTH_TOKEN, auth);
1058
2201
  },
1059
2202
  };
1060
2203
 
1061
- export default siteModule;
1062
- `;
1063
- }
1064
- function renderLoginView() {
1065
- return `<section class="section">
1066
- <h1>Sign in</h1>
1067
- <p>Seeded accounts use password <code>password</code>.</p>
1068
- <% if (it.errors && it.errors.email) { %>
1069
- <p class="error"><%= it.errors.email %></p>
1070
- <% } %>
1071
- <form method="post" action="/login">
1072
- <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1073
- <label>
1074
- Email
1075
- <input type="email" name="email" value="<%= it.email || "demo@example.com" %>" required />
1076
- </label>
1077
- <label>
1078
- Password
1079
- <input type="password" name="password" value="password" required />
1080
- </label>
1081
- <button type="submit">Sign in</button>
1082
- </form>
1083
- </section>
1084
- `;
1085
- }
1086
- function renderLayout(layers, projectName) {
1087
- const cookie = authUsesCookie(layers.auth);
1088
- return `<!DOCTYPE html>
1089
- <html lang="en">
1090
- <head>
1091
- <meta charset="utf-8" />
1092
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1093
- <title><%= it.layout.title %> \xB7 ${projectName}</title>
1094
- <% if (it.layout.description) { %>
1095
- <meta name="description" content="<%= it.layout.description %>" />
1096
- <% } %>
1097
- <link rel="stylesheet" href="/assets/site.css" />
1098
- </head>
1099
- <body>
1100
- <header class="site-header">
1101
- <a class="brand" href="/">${projectName}</a>
1102
- <nav>
1103
- ${cookie ? '<a href="/login">Sign in</a>' : ""}
1104
- </nav>
1105
- </header>
1106
- <main><%~ it.body %></main>
1107
- </body>
1108
- </html>
1109
- `;
1110
- }
1111
- function renderHomeView(projectName, layers) {
1112
- const loginLine = authUsesCookie(layers.auth) ? '<p>HTML sign-in: <a href="/login">/login</a> (demo@example.com / password).</p>' : "";
1113
- return `<section class="section">
1114
- <h1>Welcome to ${projectName}</h1>
1115
- <p>Frontend <code>${layers.frontend}</code>, database <code>${layers.database}</code>, auth <code>${layers.auth}</code>.</p>
1116
- <p>Health check: <a href="/health"><code>/health</code></a>.</p>
1117
- ${loginLine}
1118
- </section>
2204
+ export default authProvider;
1119
2205
  `;
1120
2206
  }
1121
2207
 
@@ -1187,6 +2273,7 @@ function renderEnvExample(projectName, layers) {
1187
2273
  if (layers.extras.scim) {
1188
2274
  lines.push("FEATURE_SCIM=true");
1189
2275
  lines.push("SCIM_BEARER_TOKEN=dev-scim-token-change-me");
2276
+ lines.push("# SCIM_TENANT_TOKENS=1:token-a");
1190
2277
  } else {
1191
2278
  lines.push("# FEATURE_SCIM=false");
1192
2279
  lines.push("# SCIM_BEARER_TOKEN=");
@@ -1288,9 +2375,9 @@ function renderPackageJson(projectName, options = {}) {
1288
2375
  "@getstrata/cli": "workspace:*",
1289
2376
  "@getstrata/core": "workspace:*"
1290
2377
  } : {
1291
- "@getstrata/bootstrap": "^0.4.2",
2378
+ "@getstrata/bootstrap": "^0.4.3",
1292
2379
  "@getstrata/cli": "^0.2.0",
1293
- "@getstrata/core": "^0.7.4"
2380
+ "@getstrata/core": "^0.7.5"
1294
2381
  };
1295
2382
  if (options.layers?.database === "mysql") {
1296
2383
  coreDeps.mysql2 = "^3.24.3";
@@ -1419,11 +2506,21 @@ Seeded login (password \`password\`):
1419
2506
  ` : `
1420
2507
  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\`.
1421
2508
  `}${authUsesCookie(layers.auth) ? `
1422
- HTML sign-in lives at \`/login\` (cookie session + CSRF when \`FRONTEND_MODE\` is \`server-htmx\` or \`hybrid\`).
2509
+ HTML auth kit (restyle \`views/\` and \`public/assets/site.css\`):
2510
+
2511
+ - Welcome: \`/\`
2512
+ - Sign in: \`/login\`
2513
+ - Register: \`/register\`
2514
+ - Forgot password: \`/forgot-password\`
2515
+ - Reset password: signed \`/reset-password\` (mail log when \`MAIL_DRIVER=log\`)
2516
+ ${layers.extras.emailVerification ? "- Verify email: `/email/verify`\n" : ""}${layers.extras.mfa ? "- MFA challenge: `/login/mfa` and setup: `/account/mfa`\n" : ""}
2517
+ Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
1423
2518
  ` : ""}${authUsesToken(layers.auth) ? `
1424
- Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Send \`Authorization: Bearer\`.
2519
+ 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.
1425
2520
  ` : ""}${authUsesJwt(layers.auth) ? `
1426
2521
  JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Not a portal session.
2522
+ ` : ""}${layers.extras.metrics ? `
2523
+ Prometheus scrape: \`GET /metrics\`. Production requires \`Authorization: Bearer <METRICS_TOKEN>\`.
1427
2524
  ` : ""}
1428
2525
  ## Production
1429
2526
 
@@ -1651,7 +2748,9 @@ export async function closeDatabase() {
1651
2748
  function renderMigrateTs(layers) {
1652
2749
  const d = dialectFragments(layers.database);
1653
2750
  const statements = [];
1654
- if (layers.tenancy === "rls") {
2751
+ const tenancyOn = usesTenantTable(layers.tenancy);
2752
+ const mfaOn = layers.extras.mfa && authNeedsUsers(layers.auth);
2753
+ if (tenancyOn) {
1655
2754
  statements.push(`CREATE TABLE IF NOT EXISTS tenant (
1656
2755
  id ${d.id},
1657
2756
  slug ${d.text} NOT NULL UNIQUE,
@@ -1665,14 +2764,18 @@ function renderMigrateTs(layers) {
1665
2764
  created_at ${d.timestamp}
1666
2765
  )`);
1667
2766
  if (authNeedsUsers(layers.auth)) {
1668
- const tenantColumn = layers.tenancy === "rls" ? `
2767
+ const tenantColumn = tenancyOn ? `
1669
2768
  tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
2769
+ const mfaColumns = mfaOn ? `
2770
+ mfa_secret ${d.text},
2771
+ mfa_enabled ${d.bool},
2772
+ mfa_recovery_codes ${d.text},` : "";
1670
2773
  statements.push(`CREATE TABLE IF NOT EXISTS users (
1671
2774
  id ${d.id},
1672
2775
  name ${d.text} NOT NULL,
1673
2776
  email ${d.text} NOT NULL UNIQUE,
1674
2777
  password ${d.text} NOT NULL,
1675
- is_admin ${d.bool},${tenantColumn}
2778
+ is_admin ${d.bool},${tenantColumn}${mfaColumns}
1676
2779
  email_verified_at ${d.timestampNull},
1677
2780
  created_at ${d.timestamp}
1678
2781
  )`);
@@ -1701,18 +2804,22 @@ function renderMigrateTs(layers) {
1701
2804
  }
1702
2805
  const list = statements.map((sql) => ` \`${sql}\`,`).join(`
1703
2806
  `);
1704
- const ph = layers.database === "postgres";
1705
- const notePlaceholder = ph ? "$1" : "?";
1706
- const userPlaceholders = ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
1707
- const adminFlag = ph ? "false, " : "0, ";
1708
- const adminTrue = ph ? "true" : "1";
1709
- const seedTenant = layers.tenancy === "rls" ? `
2807
+ const ph2 = layers.database === "postgres";
2808
+ const notePlaceholder = ph2 ? "$1" : "?";
2809
+ const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
2810
+ const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
2811
+ const userPlaceholders = verifyOn ? ph2 ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph2 ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
2812
+ const adminFlag = ph2 ? "false" : "0";
2813
+ const adminTrue = ph2 ? "true" : "1";
2814
+ const verifiedNow = "new Date().toISOString()";
2815
+ 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}]`;
2816
+ const seedTenant = tenancyOn ? `
1710
2817
  const [{ count: tenantCount }] = await sql.unsafe<Array<{ count: string | number }>>(
1711
2818
  "SELECT COUNT(*) AS count FROM tenant",
1712
2819
  );
1713
2820
  if (Number(tenantCount) === 0) {
1714
2821
  await sql.unsafe(
1715
- "INSERT INTO tenant (slug, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
2822
+ "INSERT INTO tenant (slug, plan, region) VALUES (${ph2 ? "$1, $2, $3" : "?, ?, ?"})",
1716
2823
  ["default", "enterprise", "eu"],
1717
2824
  );
1718
2825
  }` : "";
@@ -1723,8 +2830,8 @@ function renderMigrateTs(layers) {
1723
2830
  if (Number(userCount) === 0) {
1724
2831
  const password = await hashPassword("password");
1725
2832
  await sql.unsafe(
1726
- "INSERT INTO users (name, email, password, is_admin) VALUES (${userPlaceholders})",
1727
- ["Demo User", "demo@example.com", password, ${adminFlag}"Admin User", "admin@example.test", password, ${adminTrue}],
2833
+ "INSERT INTO users (${userColumns}) VALUES (${userPlaceholders})",
2834
+ ${userValues},
1728
2835
  );
1729
2836
  }` : "";
1730
2837
  const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
@@ -1736,13 +2843,6 @@ const migrations = [
1736
2843
  ${list}
1737
2844
  ];
1738
2845
 
1739
- export async function migrate() {
1740
- ${ensureCall(layers)} const sql = getSql();
1741
- for (const statement of migrations) {
1742
- await sql.unsafe(statement);
1743
- }
1744
- }
1745
-
1746
2846
  export async function seed() {
1747
2847
  ${ensureCall(layers)} const sql = getSql();
1748
2848
  const [{ count }] = await sql.unsafe<Array<{ count: string | number }>>(
@@ -1755,9 +2855,16 @@ ${ensureCall(layers)} const sql = getSql();
1755
2855
  }${seedBlock}
1756
2856
  }
1757
2857
 
2858
+ export async function migrate() {
2859
+ ${ensureCall(layers)} const sql = getSql();
2860
+ for (const statement of migrations) {
2861
+ await sql.unsafe(statement);
2862
+ }
2863
+ await seed();
2864
+ }
2865
+
1758
2866
  if (import.meta.main) {
1759
2867
  await migrate();
1760
- await seed();
1761
2868
  console.log("Database migrated and seeded.");
1762
2869
  process.exit(0);
1763
2870
  }
@@ -1775,7 +2882,7 @@ function dropTables(layers) {
1775
2882
  ordered.push("users");
1776
2883
  }
1777
2884
  ordered.push("notes");
1778
- if (layers.tenancy === "rls") {
2885
+ if (usesTenantTable(layers.tenancy)) {
1779
2886
  ordered.push("tenant");
1780
2887
  }
1781
2888
  return ordered;
@@ -1784,7 +2891,7 @@ function renderFreshTs(layers) {
1784
2891
  const tables = dropTables(layers);
1785
2892
  const cascade = layers.database === "sqlite" ? "" : " CASCADE";
1786
2893
  return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
1787
- import { migrate, seed } from "./migrate.ts";
2894
+ import { migrate } from "./migrate.ts";
1788
2895
 
1789
2896
  const tables = ${JSON.stringify(tables)};
1790
2897
 
@@ -1794,7 +2901,6 @@ ${ensureCall(layers)} const sql = getSql();
1794
2901
  await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
1795
2902
  }
1796
2903
  await migrate();
1797
- await seed();
1798
2904
  }
1799
2905
 
1800
2906
  if (import.meta.main) {
@@ -2094,6 +3200,10 @@ export { starterProviders };
2094
3200
  function renderCreateAppTs(layers) {
2095
3201
  const ensureLine = needsEnsure(layers) ? `import { ensureAppDatabase } from "./ensureDatabase.ts";
2096
3202
  ` : "";
3203
+ const metricsImport = layers.extras.metrics ? `import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
3204
+ ` : "";
3205
+ const metricsSpread = layers.extras.metrics ? `
3206
+ ...createMetricsRoutes(),` : "";
2097
3207
  return `import { join } from "node:path";
2098
3208
  import "./preload.ts";
2099
3209
  import { runProviderPhase } from "@getstrata/bootstrap/context";
@@ -2113,8 +3223,7 @@ import {
2113
3223
  ensureModulesLoaded,
2114
3224
  } from "@getstrata/bootstrap/discoverModules";
2115
3225
  import { createHealthRoutes } from "@getstrata/bootstrap/health";
2116
- import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
2117
- import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
3226
+ ${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
2118
3227
  import { createWebServer } from "@getstrata/bootstrap/web/server";
2119
3228
  import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
2120
3229
  import { migrate } from "../db/migrate.ts";
@@ -2194,8 +3303,7 @@ ${needsEnsure(layers) ? ` await ensureAppDatabase();
2194
3303
 
2195
3304
  const routes = mergeSpaRoutes(context.dependencies, {
2196
3305
  ...createHealthRoutes(context.dependencies),
2197
- ...buildRoutes(context.dependencies),
2198
- ...createMetricsRoutes(),
3306
+ ...buildRoutes(context.dependencies),${metricsSpread}
2199
3307
  }, {
2200
3308
  distDirectory: join(import.meta.dir, "../../frontend/dist"),
2201
3309
  });
@@ -2232,11 +3340,28 @@ export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
2232
3340
  }
2233
3341
  `;
2234
3342
  }
2235
- function renderViewTs() {
2236
- return `import { join } from "node:path";
3343
+ function renderViewTs(layers) {
3344
+ const authImport = authNeedsUsers(layers.auth) ? `import { currentAuthUser } from "@getstrata/core/auth/authContext";
2237
3345
  import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
3346
+ import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
2238
3347
  import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
2239
-
3348
+ import { starterAuthDirectory } from "../bootstrap/authDirectory.ts";
3349
+ ` : `import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
3350
+ import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
3351
+ import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
3352
+ `;
3353
+ const userBlock = authNeedsUsers(layers.auth) ? ` let currentUser: { id: number; email: string; name: string | null } | null = null;
3354
+ const authUser = currentAuthUser();
3355
+ if (authUser) {
3356
+ try {
3357
+ const row = await starterAuthDirectory.findByIdOrThrow(Number(authUser.id));
3358
+ currentUser = { id: row.id, email: row.email ?? "", name: row.name ?? null };
3359
+ } catch {
3360
+ currentUser = null;
3361
+ }
3362
+ }` : ` const currentUser = null;`;
3363
+ return `import { join } from "node:path";
3364
+ ${authImport}
2240
3365
  const engine = new EtaViewEngine(join(import.meta.dir, "../../views"));
2241
3366
 
2242
3367
  export interface LayoutData {
@@ -2248,10 +3373,17 @@ export async function renderPage(
2248
3373
  template: string,
2249
3374
  data: Record<string, unknown> & { layout: LayoutData },
2250
3375
  request?: Request,
3376
+ status = 200,
2251
3377
  ): Promise<Response> {
2252
3378
  const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
2253
- const html = await engine.render(template, { ...data, csrfToken });
2254
- return htmlResponse(html);
3379
+ const flash = currentRequestMeta().flash ?? null;
3380
+ ${userBlock}
3381
+ const html = await engine.render(
3382
+ template,
3383
+ { ...data, csrfToken, flash, currentUser },
3384
+ { request },
3385
+ );
3386
+ return htmlResponse(html, { status });
2255
3387
  }
2256
3388
 
2257
3389
  export function plainText(body: string): Response {
@@ -2260,6 +3392,315 @@ export function plainText(body: string): Response {
2260
3392
  `;
2261
3393
  }
2262
3394
 
3395
+ // src/renderScim.ts
3396
+ function ph2(layers, count, start = 1) {
3397
+ if (layers.database === "postgres") {
3398
+ return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
3399
+ }
3400
+ return Array.from({ length: count }, () => "?").join(", ");
3401
+ }
3402
+ function sqlFalse2(layers) {
3403
+ return layers.database === "postgres" ? "false" : "0";
3404
+ }
3405
+ function renderScimModule(layers) {
3406
+ if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
3407
+ return null;
3408
+ }
3409
+ const tenantOn = usesTenantTable(layers.tenancy);
3410
+ const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
3411
+ const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
3412
+ const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
3413
+ const emailPh = ph2(layers, 1);
3414
+ const idPh = ph2(layers, 1);
3415
+ const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
3416
+ return `import { randomBytes } from "node:crypto";
3417
+ import type { AppModule } from "@getstrata/bootstrap/contracts";
3418
+ import { routeParams } from "@getstrata/bootstrap/web/routing";
3419
+ import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
3420
+ import { hashPassword } from "@getstrata/core/auth/password";
3421
+ import { withErrorHandling } from "@getstrata/core/http/response";
3422
+ import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
3423
+ import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
3424
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
3425
+ import { getSql } from "../../bootstrap/database.ts";
3426
+
3427
+ const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
3428
+ const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
3429
+ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
3430
+ const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
3431
+ const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
3432
+
3433
+ type UserRow = { id: number; name: string; email: string };
3434
+
3435
+ function scimEnabled(): boolean {
3436
+ return (process.env.FEATURE_SCIM ?? "false") === "true";
3437
+ }
3438
+
3439
+ function scimJson(body: unknown, status = 200): Response {
3440
+ return Response.json(body, {
3441
+ status,
3442
+ headers: { "content-type": "application/scim+json" },
3443
+ });
3444
+ }
3445
+
3446
+ function scimError(detail: string, status: number): Response {
3447
+ return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
3448
+ }
3449
+
3450
+ function toScimUser(row: UserRow) {
3451
+ return {
3452
+ schemas: [USER_SCHEMA],
3453
+ id: String(row.id),
3454
+ userName: row.email,
3455
+ name: { formatted: row.name },
3456
+ emails: [{ value: row.email, primary: true }],
3457
+ active: true,
3458
+ meta: { resourceType: "User" },
3459
+ };
3460
+ }
3461
+
3462
+ function readUserName(body: Record<string, unknown>): string {
3463
+ const emails = body.emails;
3464
+ if (Array.isArray(emails) && emails[0] && typeof emails[0] === "object") {
3465
+ const value = (emails[0] as { value?: unknown }).value;
3466
+ if (typeof value === "string" && value.trim()) {
3467
+ return value.trim().toLowerCase();
3468
+ }
3469
+ }
3470
+ return typeof body.userName === "string" ? body.userName.trim().toLowerCase() : "";
3471
+ }
3472
+
3473
+ function readName(body: Record<string, unknown>, fallback: string): string {
3474
+ const name = body.name;
3475
+ if (name && typeof name === "object") {
3476
+ const formatted = (name as { formatted?: unknown; givenName?: unknown; familyName?: unknown }).formatted;
3477
+ if (typeof formatted === "string" && formatted.trim()) {
3478
+ return formatted.trim();
3479
+ }
3480
+ const given = (name as { givenName?: unknown }).givenName;
3481
+ const family = (name as { familyName?: unknown }).familyName;
3482
+ const combined = [given, family].filter((part) => typeof part === "string").join(" ").trim();
3483
+ if (combined) {
3484
+ return combined;
3485
+ }
3486
+ }
3487
+ if (typeof body.displayName === "string" && body.displayName.trim()) {
3488
+ return body.displayName.trim();
3489
+ }
3490
+ return fallback;
3491
+ }
3492
+
3493
+ function wrapScim(handler: (request: Request) => Promise<Response>) {
3494
+ const throttle = createScimThrottleMiddleware({
3495
+ redisUrl: process.env.REDIS_URL,
3496
+ maxAttempts: 120,
3497
+ decaySeconds: 60,
3498
+ });
3499
+ return withMiddleware(throttle, createScimAuthMiddleware())(
3500
+ withErrorHandling(async (request) => {
3501
+ if (!scimEnabled()) {
3502
+ return scimError("SCIM is off.", 404);
3503
+ }
3504
+ return handler(request);
3505
+ }),
3506
+ );
3507
+ }
3508
+
3509
+ const scimModule: AppModule = {
3510
+ name: "scim",
3511
+ order: 8,
3512
+ routes({ kernel }) {
3513
+ return {
3514
+ "/scim/v2/ServiceProviderConfig": {
3515
+ GET: kernel.wrap(
3516
+ "api",
3517
+ wrapScim(async () =>
3518
+ scimJson({
3519
+ schemas: [CONFIG_SCHEMA],
3520
+ patch: { supported: true },
3521
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
3522
+ filter: { supported: true, maxResults: 200 },
3523
+ changePassword: { supported: false },
3524
+ sort: { supported: false },
3525
+ etag: { supported: false },
3526
+ authenticationSchemes: [
3527
+ {
3528
+ type: "oauthbearertoken",
3529
+ name: "OAuth Bearer Token",
3530
+ description: "Bearer token in the Authorization header.",
3531
+ specUri: "https://www.rfc-editor.org/rfc/rfc6750",
3532
+ primary: true,
3533
+ },
3534
+ ],
3535
+ }),
3536
+ ),
3537
+ ),
3538
+ },
3539
+ "/scim/v2/Users": {
3540
+ GET: kernel.wrap(
3541
+ "api",
3542
+ wrapScim(async (request) => {
3543
+ const url = new URL(request.url);
3544
+ const filter = url.searchParams.get("filter") ?? "";
3545
+ const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
3546
+ let rows: UserRow[];
3547
+ if (match?.[1]) {
3548
+ rows = await getSql().unsafe<UserRow[]>(
3549
+ "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3550
+ [match[1].trim().toLowerCase()],
3551
+ );
3552
+ } else {
3553
+ rows = await getSql().unsafe<UserRow[]>("SELECT id, name, email FROM users");
3554
+ }
3555
+ const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
3556
+ const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
3557
+ const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
3558
+ return scimJson({
3559
+ schemas: [LIST_SCHEMA],
3560
+ totalResults: rows.length,
3561
+ startIndex,
3562
+ itemsPerPage: slice.length,
3563
+ Resources: slice.map(toScimUser),
3564
+ });
3565
+ }),
3566
+ ),
3567
+ POST: kernel.wrap(
3568
+ "api",
3569
+ wrapScim(async (request) => {
3570
+ const body = (await request.json()) as Record<string, unknown>;
3571
+ const email = readUserName(body);
3572
+ const name = readName(body, email.split("@")[0] ?? "User");
3573
+ if (!email) {
3574
+ return scimError("userName is required.", 400);
3575
+ }
3576
+ const existing = await getSql().unsafe<UserRow[]>(
3577
+ "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3578
+ [email],
3579
+ );
3580
+ if (existing[0]) {
3581
+ return scimError("User already exists.", 409);
3582
+ }
3583
+ const hashed = await hashPassword(randomBytes(18).toString("hex"));
3584
+ const tenantId = currentTenant()?.id ?? 1;
3585
+ await getSql().unsafe(
3586
+ "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
3587
+ [name, email, hashed${insertTail}],
3588
+ );
3589
+ const created = await getSql().unsafe<UserRow[]>(
3590
+ "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3591
+ [email],
3592
+ );
3593
+ const row = created[0];
3594
+ if (!row) {
3595
+ return scimError("Could not create user.", 500);
3596
+ }
3597
+ return scimJson(toScimUser(row), 201);
3598
+ }),
3599
+ ),
3600
+ },
3601
+ "/scim/v2/Users/:id": {
3602
+ GET: kernel.wrap(
3603
+ "api",
3604
+ wrapScim(async (request) => {
3605
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3606
+ const rows = await getSql().unsafe<UserRow[]>(
3607
+ "SELECT id, name, email FROM users WHERE id = ${idPh}",
3608
+ [id],
3609
+ );
3610
+ const row = rows[0];
3611
+ if (!row) {
3612
+ return scimError("User not found.", 404);
3613
+ }
3614
+ return scimJson(toScimUser(row));
3615
+ }),
3616
+ ),
3617
+ PUT: kernel.wrap(
3618
+ "api",
3619
+ wrapScim(async (request) => {
3620
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3621
+ const body = (await request.json()) as Record<string, unknown>;
3622
+ const email = readUserName(body);
3623
+ const name = readName(body, email);
3624
+ if (!email || !name) {
3625
+ return scimError("userName and name are required.", 400);
3626
+ }
3627
+ await getSql().unsafe(
3628
+ "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3629
+ [name, email, id],
3630
+ );
3631
+ const rows = await getSql().unsafe<UserRow[]>(
3632
+ "SELECT id, name, email FROM users WHERE id = ${idPh}",
3633
+ [id],
3634
+ );
3635
+ const row = rows[0];
3636
+ if (!row) {
3637
+ return scimError("User not found.", 404);
3638
+ }
3639
+ return scimJson(toScimUser(row));
3640
+ }),
3641
+ ),
3642
+ PATCH: kernel.wrap(
3643
+ "api",
3644
+ wrapScim(async (request) => {
3645
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3646
+ const existing = await getSql().unsafe<UserRow[]>(
3647
+ "SELECT id, name, email FROM users WHERE id = ${idPh}",
3648
+ [id],
3649
+ );
3650
+ const row = existing[0];
3651
+ if (!row) {
3652
+ return scimError("User not found.", 404);
3653
+ }
3654
+ const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
3655
+ if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
3656
+ return scimError("Unsupported patch schema.", 400);
3657
+ }
3658
+ let name = row.name;
3659
+ let email = row.email;
3660
+ for (const operation of body.Operations ?? []) {
3661
+ const op = (operation.op ?? "replace").toLowerCase();
3662
+ if (op !== "replace" && op !== "add") {
3663
+ continue;
3664
+ }
3665
+ const path = (operation.path ?? "").toLowerCase();
3666
+ if (path === "username" || path === "emails") {
3667
+ email = String(operation.value ?? email).trim().toLowerCase();
3668
+ } else if (path === "name.formatted" || path === "displayname" || path === "name") {
3669
+ if (typeof operation.value === "string") {
3670
+ name = operation.value.trim() || name;
3671
+ } else if (operation.value && typeof operation.value === "object") {
3672
+ name = readName({ name: operation.value as Record<string, unknown> }, name);
3673
+ }
3674
+ } else if (!path && operation.value && typeof operation.value === "object") {
3675
+ const value = operation.value as Record<string, unknown>;
3676
+ email = readUserName(value) || email;
3677
+ name = readName(value, name);
3678
+ }
3679
+ }
3680
+ await getSql().unsafe(
3681
+ "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3682
+ [name, email, id],
3683
+ );
3684
+ return scimJson(toScimUser({ id: row.id, name, email }));
3685
+ }),
3686
+ ),
3687
+ DELETE: kernel.wrap(
3688
+ "api",
3689
+ wrapScim(async (request) => {
3690
+ const id = Number.parseInt(routeParams(request).id ?? "", 10);
3691
+ await getSql().unsafe("DELETE FROM users WHERE id = ${idPh}", [id]);
3692
+ return new Response(null, { status: 204 });
3693
+ }),
3694
+ ),
3695
+ },
3696
+ };
3697
+ },
3698
+ };
3699
+
3700
+ export default scimModule;
3701
+ `;
3702
+ }
3703
+
2263
3704
  // src/generate.ts
2264
3705
  var PROJECT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/i;
2265
3706
  function starterPackageRoot() {
@@ -2317,7 +3758,7 @@ function writeGeneratedFiles(options) {
2317
3758
  removeIfExists(join2(targetDir, "docker-compose.yml"));
2318
3759
  }
2319
3760
  writeText(join2(src, "routes.ts"), renderRoutesTs());
2320
- writeText(join2(src, "lib/view.ts"), renderViewTs());
3761
+ writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
2321
3762
  writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
2322
3763
  writeText(join2(src, "bootstrap/preload.ts"), renderPreloadTs(layers, projectName));
2323
3764
  writeText(join2(src, "bootstrap/database.ts"), renderDatabaseTs(layers));
@@ -2350,10 +3791,32 @@ function writeGeneratedFiles(options) {
2350
3791
  if (authModule) {
2351
3792
  writeText(join2(src, "modules/auth/index.ts"), authModule);
2352
3793
  }
3794
+ const scimModule = renderScimModule(layers);
3795
+ if (scimModule) {
3796
+ writeText(join2(src, "modules/scim/index.ts"), scimModule);
3797
+ } else {
3798
+ removeIfExists(join2(src, "modules/scim/index.ts"));
3799
+ }
3800
+ if (layers.extras.mfa && htmlAuthKit(layers.auth)) {
3801
+ writeText(join2(src, "bootstrap/pendingMfa.ts"), renderPendingMfaTs());
3802
+ } else {
3803
+ removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
3804
+ }
3805
+ writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
2353
3806
  writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
2354
3807
  writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
2355
- if (authUsesCookie(layers.auth)) {
3808
+ if (htmlAuthKit(layers.auth)) {
2356
3809
  writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView());
3810
+ writeText(join2(targetDir, "views/auth/register.eta"), renderRegisterView());
3811
+ writeText(join2(targetDir, "views/auth/forgot-password.eta"), renderForgotPasswordView());
3812
+ writeText(join2(targetDir, "views/auth/reset-password.eta"), renderResetPasswordView());
3813
+ if (layers.extras.emailVerification) {
3814
+ writeText(join2(targetDir, "views/auth/verify-email.eta"), renderVerifyEmailView());
3815
+ }
3816
+ if (layers.extras.mfa) {
3817
+ writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
3818
+ writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
3819
+ }
2357
3820
  }
2358
3821
  mkdirSync2(join2(targetDir, "storage"), { recursive: true });
2359
3822
  writeText(join2(targetDir, "storage/.gitkeep"), "");