@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/README.md +2 -2
- package/dist/cli.js +1918 -455
- package/dist/templates/overlays/api/docs/API.md +1 -1
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
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
|
|
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 = "
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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/
|
|
625
|
-
function
|
|
626
|
-
if (
|
|
627
|
-
return
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
);
|
|
726
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
754
|
-
if (layers.auth
|
|
755
|
-
return
|
|
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
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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 (
|
|
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 {
|
|
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 {
|
|
1157
|
+
imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
|
|
833
1158
|
}
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
`)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
864
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 {${
|
|
1334
|
+
return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
|
|
979
1335
|
};
|
|
980
1336
|
},` : "";
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
986
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1018
|
-
name: "auth",
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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.
|
|
2378
|
+
"@getstrata/bootstrap": "^0.4.3",
|
|
1292
2379
|
"@getstrata/cli": "^0.2.0",
|
|
1293
|
-
"@getstrata/core": "^0.7.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1705
|
-
const notePlaceholder =
|
|
1706
|
-
const
|
|
1707
|
-
const
|
|
1708
|
-
const
|
|
1709
|
-
const
|
|
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 (${
|
|
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 (
|
|
1727
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
2254
|
-
|
|
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 (
|
|
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"), "");
|