@getstrata/starter 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +1485 -443
- 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,12 @@ 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
|
+
}
|
|
106
112
|
function needsRedis(layers) {
|
|
107
113
|
return layers.cache === "redis" || layers.queue === "redis";
|
|
108
114
|
}
|
|
@@ -225,7 +231,7 @@ Options:
|
|
|
225
231
|
--frontend api | server-htmx | spa-react | hybrid
|
|
226
232
|
--database sqlite | postgres | mysql (one database; not mixed)
|
|
227
233
|
--auth headers | cookie | token | jwt | cookie-token | cookie-token-jwt
|
|
228
|
-
--tenancy none | rls
|
|
234
|
+
--tenancy none | column | rls (rls is Postgres SET LOCAL; sqlite/mysql coerce rls to column)
|
|
229
235
|
--cache array | redis
|
|
230
236
|
--queue sync | redis
|
|
231
237
|
--mail log | smtp
|
|
@@ -461,8 +467,8 @@ function applyFlagOverrides(base, flags) {
|
|
|
461
467
|
spaPrefix: flags.spaPrefix ?? base.spaPrefix,
|
|
462
468
|
extras: { ...base.extras, ...flags.extras }
|
|
463
469
|
};
|
|
464
|
-
if (next.database !== "postgres") {
|
|
465
|
-
next.tenancy = "
|
|
470
|
+
if (next.database !== "postgres" && next.tenancy === "rls") {
|
|
471
|
+
next.tenancy = "column";
|
|
466
472
|
}
|
|
467
473
|
return reconcileDocker(applyDockerFlags(next, flags));
|
|
468
474
|
}
|
|
@@ -540,14 +546,20 @@ async function promptLayers(flags, prompter) {
|
|
|
540
546
|
{ value: "cookie-token", label: "cookie-token: HTML cookies + API tokens" },
|
|
541
547
|
{ value: "cookie-token-jwt", label: "cookie-token-jwt: cookies, tokens, and JWT" }
|
|
542
548
|
], layers.auth);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
549
|
+
layers.tenancy = await prompter.select("Tenancy", layers.database === "postgres" ? [
|
|
550
|
+
{ value: "none", label: "none: no tenant table" },
|
|
551
|
+
{
|
|
552
|
+
value: "column",
|
|
553
|
+
label: "column: tenant table + users.tenant_id (no Postgres SET LOCAL)"
|
|
554
|
+
},
|
|
555
|
+
{ value: "rls", label: "rls: Postgres row-level security plus a tenant table" }
|
|
556
|
+
] : [
|
|
557
|
+
{ value: "none", label: "none: no tenant table" },
|
|
558
|
+
{
|
|
559
|
+
value: "column",
|
|
560
|
+
label: "column: tenant table + users.tenant_id (SQLite/MySQL cannot run Postgres RLS)"
|
|
561
|
+
}
|
|
562
|
+
], layers.database === "postgres" ? layers.tenancy : layers.tenancy === "rls" ? "column" : layers.tenancy);
|
|
551
563
|
layers.cache = await prompter.select("Cache", [
|
|
552
564
|
{ value: "array", label: "array: in-process" },
|
|
553
565
|
{ value: "redis", label: "redis" }
|
|
@@ -565,9 +577,9 @@ async function promptLayers(flags, prompter) {
|
|
|
565
577
|
}
|
|
566
578
|
const askExtras = flags.extrasPrompt || await prompter.confirm("Configure extras (MFA, SCIM, metrics)?", false);
|
|
567
579
|
if (askExtras) {
|
|
568
|
-
layers.extras.mfa = await prompter.confirm("
|
|
569
|
-
layers.extras.emailVerification = await prompter.confirm("Email verification
|
|
570
|
-
layers.extras.scim = await prompter.confirm("SCIM
|
|
580
|
+
layers.extras.mfa = await prompter.confirm("Authenticator MFA (cookie challenge + setup pages)?", layers.extras.mfa);
|
|
581
|
+
layers.extras.emailVerification = await prompter.confirm("Email verification (signed links + /email/verify)?", layers.extras.emailVerification);
|
|
582
|
+
layers.extras.scim = await prompter.confirm("SCIM /Users adapter?", layers.extras.scim);
|
|
571
583
|
layers.extras.metrics = await prompter.confirm("Metrics token?", layers.extras.metrics);
|
|
572
584
|
}
|
|
573
585
|
if (!dockerFlagsProvided(flags)) {
|
|
@@ -621,300 +633,159 @@ async function resolveStarterPlan(flags, injected) {
|
|
|
621
633
|
}
|
|
622
634
|
}
|
|
623
635
|
|
|
624
|
-
// src/
|
|
625
|
-
function
|
|
626
|
-
if (
|
|
627
|
-
return
|
|
636
|
+
// src/renderAuthFlows.ts
|
|
637
|
+
function ph(layers, count, start = 1) {
|
|
638
|
+
if (layers.database === "postgres") {
|
|
639
|
+
return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
|
|
628
640
|
}
|
|
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";
|
|
641
|
+
return Array.from({ length: count }, () => "?").join(", ");
|
|
642
|
+
}
|
|
643
|
+
function sqlFalse(layers) {
|
|
644
|
+
return layers.database === "postgres" ? "false" : "0";
|
|
645
|
+
}
|
|
646
|
+
function sqlTrue(layers) {
|
|
647
|
+
return layers.database === "postgres" ? "true" : "1";
|
|
648
|
+
}
|
|
649
|
+
function renderPendingMfaTs() {
|
|
650
|
+
return `import { createHmac, timingSafeEqual } from "node:crypto";
|
|
682
651
|
|
|
683
|
-
|
|
684
|
-
|
|
652
|
+
const COOKIE = "strata_mfa_pending";
|
|
653
|
+
|
|
654
|
+
function secret(): string {
|
|
655
|
+
return process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch";
|
|
685
656
|
}
|
|
686
657
|
|
|
687
|
-
|
|
688
|
-
|
|
658
|
+
function sign(userId: number, issuedAt: number): string {
|
|
659
|
+
const payload = \`\${userId}.\${issuedAt}\`;
|
|
660
|
+
const signature = createHmac("sha256", secret()).update(payload).digest("hex");
|
|
661
|
+
return \`\${payload}.\${signature}\`;
|
|
662
|
+
}
|
|
689
663
|
|
|
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
|
-
},
|
|
664
|
+
export function pendingMfaSetCookie(userId: number): string {
|
|
665
|
+
const issuedAt = Date.now();
|
|
666
|
+
return \`\${COOKIE}=\${sign(userId, issuedAt)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600\`;
|
|
667
|
+
}
|
|
712
668
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
id: number;
|
|
717
|
-
email: string;
|
|
718
|
-
is_admin: number | boolean;
|
|
719
|
-
email_verified_at: Date | string | null;
|
|
720
|
-
password: string;
|
|
721
|
-
}>
|
|
722
|
-
>(
|
|
723
|
-
\`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE email = ${placeholder}\`,
|
|
724
|
-
[email.trim().toLowerCase()],
|
|
725
|
-
);
|
|
726
|
-
const row = rows[0];
|
|
727
|
-
if (!row) {
|
|
728
|
-
return null;
|
|
729
|
-
}
|
|
730
|
-
return {
|
|
731
|
-
id: Number(row.id),
|
|
732
|
-
email: row.email,
|
|
733
|
-
role: mapRole(row.is_admin),
|
|
734
|
-
email_verified_at: row.email_verified_at ?? null,
|
|
735
|
-
password: row.password,
|
|
736
|
-
};
|
|
737
|
-
},
|
|
669
|
+
export function pendingMfaClearCookie(): string {
|
|
670
|
+
return \`\${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0\`;
|
|
671
|
+
}
|
|
738
672
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
673
|
+
export function readPendingMfaUserId(request: Request): number | null {
|
|
674
|
+
const header = request.headers.get("cookie") ?? "";
|
|
675
|
+
for (const part of header.split(";")) {
|
|
676
|
+
const [name, ...rest] = part.trim().split("=");
|
|
677
|
+
if (name !== COOKIE) {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const value = rest.join("=");
|
|
681
|
+
const pieces = value.split(".");
|
|
682
|
+
if (pieces.length !== 3) {
|
|
742
683
|
return null;
|
|
743
684
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
},
|
|
750
|
-
};
|
|
751
|
-
`;
|
|
752
|
-
}
|
|
753
|
-
function renderAuthProvider(layers) {
|
|
754
|
-
if (layers.auth === "headers") {
|
|
755
|
-
return `import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
|
|
756
|
-
import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
757
|
-
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
758
|
-
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
759
|
-
|
|
760
|
-
class StarterAuthManager {
|
|
761
|
-
async resolve(request?: Request): Promise<AuthUser | null> {
|
|
762
|
-
if (process.env.AUTH_DEV_HEADERS === "false") {
|
|
763
|
-
return request ? null : currentAuthUser();
|
|
685
|
+
const userId = Number.parseInt(pieces[0] ?? "", 10);
|
|
686
|
+
const issuedAt = Number.parseInt(pieces[1] ?? "", 10);
|
|
687
|
+
const signature = pieces[2] ?? "";
|
|
688
|
+
if (!Number.isInteger(userId) || userId <= 0 || Date.now() - issuedAt > 10 * 60 * 1000) {
|
|
689
|
+
return null;
|
|
764
690
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const role = request.headers.get("x-authenticated-user-role");
|
|
771
|
-
return {
|
|
772
|
-
id: userId,
|
|
773
|
-
...(role ? { role } : {}),
|
|
774
|
-
};
|
|
691
|
+
const expected = sign(userId, issuedAt).split(".").pop() ?? "";
|
|
692
|
+
const left = Buffer.from(signature);
|
|
693
|
+
const right = Buffer.from(expected);
|
|
694
|
+
if (left.length !== right.length || !timingSafeEqual(left, right)) {
|
|
695
|
+
return null;
|
|
775
696
|
}
|
|
776
|
-
return
|
|
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;
|
|
697
|
+
return userId;
|
|
785
698
|
}
|
|
699
|
+
return null;
|
|
786
700
|
}
|
|
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
701
|
`;
|
|
702
|
+
}
|
|
703
|
+
function renderAuthModule(layers) {
|
|
704
|
+
if (!authNeedsUsers(layers.auth)) {
|
|
705
|
+
return null;
|
|
797
706
|
}
|
|
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";`);
|
|
707
|
+
const cookie = htmlAuthKit(layers.auth);
|
|
708
|
+
const mfa = Boolean(layers.extras.mfa && cookie);
|
|
709
|
+
const verify = Boolean(layers.extras.emailVerification);
|
|
710
|
+
const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
|
|
711
|
+
const tenantInsert = usesTenantTable(layers.tenancy);
|
|
712
|
+
const insertCols = tenantInsert ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
|
|
713
|
+
const insertPh = tenantInsert ? ph(layers, 5) : ph(layers, 4);
|
|
714
|
+
const insertTail = tenantInsert ? `, ${sqlFalse(layers)}, 1` : `, ${sqlFalse(layers)}`;
|
|
715
|
+
const passwordPh = `${ph(layers, 1)}`;
|
|
716
|
+
const emailPh = `${ph(layers, 1, 2)}`;
|
|
717
|
+
const idPh = `${ph(layers, 1, 2)}`;
|
|
718
|
+
const verifiedPh = `${ph(layers, 1)}`;
|
|
719
|
+
const mfaUpdatePh = `${ph(layers, 1)}, ${ph(layers, 1, 2)}, ${ph(layers, 1, 3)}`;
|
|
720
|
+
const mfaIdPh = `${ph(layers, 1, 4)}`;
|
|
721
|
+
const imports = [];
|
|
722
|
+
if (authUsesToken(layers.auth)) {
|
|
723
|
+
imports.push(`import { randomBytes } from "node:crypto";`);
|
|
817
724
|
}
|
|
818
|
-
|
|
819
|
-
|
|
725
|
+
imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
|
|
726
|
+
imports.push(`import type { AppModule } from "@getstrata/bootstrap/contracts";`);
|
|
727
|
+
if (cookie) {
|
|
728
|
+
imports.push(`import { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
|
|
729
|
+
imports.push(`import { wrapWebLogin, wrapWebRegister } from "@getstrata/bootstrap/web/routing";`);
|
|
730
|
+
imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
|
|
820
731
|
}
|
|
821
|
-
if (
|
|
822
|
-
imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
|
|
823
|
-
} else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
|
|
732
|
+
if (jsonApi) {
|
|
824
733
|
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
734
|
}
|
|
828
735
|
if (authUsesJwt(layers.auth)) {
|
|
829
|
-
imports.push(`import {
|
|
736
|
+
imports.push(`import { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
|
|
830
737
|
}
|
|
738
|
+
imports.push(`import { hashPassword, verifyPassword } from "@getstrata/core/auth/password";`);
|
|
831
739
|
if (authUsesToken(layers.auth)) {
|
|
832
|
-
imports.push(`import {
|
|
740
|
+
imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
|
|
833
741
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (authUsesToken(layers.auth)) {
|
|
837
|
-
tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
|
|
742
|
+
if (mfa) {
|
|
743
|
+
imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
|
|
838
744
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
`)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
}
|
|
745
|
+
if (cookie) {
|
|
746
|
+
imports.push(`import { flashResponse } from "@getstrata/core/http/flashSession";`);
|
|
747
|
+
}
|
|
748
|
+
if (jsonApi) {
|
|
749
|
+
imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
|
|
750
|
+
}
|
|
751
|
+
imports.push(`import { absoluteTemporarySignedUrl, assertValidSignature } from "@getstrata/core/http/signedUrl";`);
|
|
752
|
+
imports.push(`import { mailer } from "@getstrata/core/mail/mailer";`);
|
|
753
|
+
if (mfa) {
|
|
754
|
+
imports.push(`import { generateRecoveryCodes, hashRecoveryCode, recoveryCodeMatches } from "@getstrata/core/security/recoveryCodes";`);
|
|
755
|
+
imports.push(`import { buildOtpauthUrl, generateTotpSecret, verifyTotp } from "@getstrata/core/security/totp";`);
|
|
756
|
+
}
|
|
757
|
+
imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
|
|
758
|
+
imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
|
|
759
|
+
if (cookie) {
|
|
760
|
+
imports.push(`import { renderPage } from "../../lib/view.ts";`);
|
|
761
|
+
}
|
|
762
|
+
if (mfa) {
|
|
763
|
+
imports.push(`import { pendingMfaClearCookie, pendingMfaSetCookie, readPendingMfaUserId } from "../../bootstrap/pendingMfa.ts";`);
|
|
764
|
+
}
|
|
765
|
+
const helpers = `
|
|
766
|
+
async function sendSignedMail(to: string, subject: string, path: string, query: Record<string, string>) {
|
|
767
|
+
const link = absoluteTemporarySignedUrl(path, 3600, query);
|
|
768
|
+
await mailer().send({
|
|
769
|
+
to,
|
|
770
|
+
subject,
|
|
771
|
+
body: \`\${subject}\\n\\n\${link}\\n\`,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
${cookie ? `
|
|
775
|
+
function redirectTo(path: string, status = 302): Response {
|
|
776
|
+
return new Response(null, { status, headers: { location: path } });
|
|
777
|
+
}
|
|
859
778
|
|
|
860
|
-
|
|
861
|
-
|
|
779
|
+
function sessionUser(user: { id: number; name?: string | null; email?: string | null; role: string }) {
|
|
780
|
+
return {
|
|
781
|
+
id: user.id,
|
|
782
|
+
name: user.name ?? user.email ?? "",
|
|
783
|
+
email: user.email ?? "",
|
|
784
|
+
is_admin: user.role === "admin",
|
|
785
|
+
};
|
|
862
786
|
}
|
|
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) ? `
|
|
787
|
+
` : ""}`;
|
|
788
|
+
const tokenLogin = authUsesToken(layers.auth) ? `
|
|
918
789
|
"/api/v1/auth/login": {
|
|
919
790
|
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
920
791
|
const body = (await request.json()) as { email?: string; password?: string };
|
|
@@ -926,7 +797,7 @@ function renderAuthModule(layers) {
|
|
|
926
797
|
}
|
|
927
798
|
const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
|
|
928
799
|
await getSql().unsafe(
|
|
929
|
-
"INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${layers
|
|
800
|
+
"INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${ph(layers, 4)})",
|
|
930
801
|
[user.id, "spa", hashApiToken(plain), JSON.stringify(["profile:read"])],
|
|
931
802
|
);
|
|
932
803
|
return jsonResponse({ token: plain });
|
|
@@ -938,12 +809,37 @@ function renderAuthModule(layers) {
|
|
|
938
809
|
const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
|
|
939
810
|
return jsonResponse({
|
|
940
811
|
id: record.id,
|
|
941
|
-
name: record.email,
|
|
812
|
+
name: record.name ?? record.email,
|
|
942
813
|
email: record.email,
|
|
943
814
|
role: record.role,
|
|
944
815
|
});
|
|
945
816
|
}),
|
|
946
817
|
},` : "";
|
|
818
|
+
const jsonRegister = jsonApi ? `
|
|
819
|
+
"/api/v1/auth/register": {
|
|
820
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
821
|
+
const body = (await request.json()) as { name?: string; email?: string; password?: string };
|
|
822
|
+
const name = (body.name ?? "").trim();
|
|
823
|
+
const email = (body.email ?? "").trim().toLowerCase();
|
|
824
|
+
const password = body.password ?? "";
|
|
825
|
+
if (!name || !email || password.length < 8) {
|
|
826
|
+
return jsonResponse({ error: "Name, email, and a password of 8+ characters are required." }, { status: 422 });
|
|
827
|
+
}
|
|
828
|
+
if (await starterAuthDirectory.findByEmail?.(email)) {
|
|
829
|
+
return jsonResponse({ error: "Email is already registered." }, { status: 422 });
|
|
830
|
+
}
|
|
831
|
+
const hashed = await hashPassword(password);
|
|
832
|
+
await getSql().unsafe(
|
|
833
|
+
"INSERT INTO users (${insertCols}) VALUES (${insertPh})",
|
|
834
|
+
[name, email, hashed${insertTail}],
|
|
835
|
+
);
|
|
836
|
+
const created = await starterAuthDirectory.findByEmail?.(email);
|
|
837
|
+
${verify ? `if (created) {
|
|
838
|
+
await sendSignedMail(email, "Verify your email", "/api/v1/auth/verify-email", { id: String(created.id) });
|
|
839
|
+
}` : ""}
|
|
840
|
+
return jsonResponse({ ok: true }, { status: 201 });
|
|
841
|
+
})),
|
|
842
|
+
},` : "";
|
|
947
843
|
const jwtLogin = authUsesJwt(layers.auth) ? `
|
|
948
844
|
"/api/auth/token": {
|
|
949
845
|
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
@@ -957,7 +853,8 @@ function renderAuthModule(layers) {
|
|
|
957
853
|
const token = signJwt({
|
|
958
854
|
sub: user.id,
|
|
959
855
|
role: user.role,
|
|
960
|
-
abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"]
|
|
856
|
+
abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"],${verify ? `
|
|
857
|
+
emailVerifiedAt: user.emailVerifiedAt ?? null,` : ""}
|
|
961
858
|
});
|
|
962
859
|
return jsonResponse({
|
|
963
860
|
token,
|
|
@@ -966,156 +863,928 @@ function renderAuthModule(layers) {
|
|
|
966
863
|
});
|
|
967
864
|
})),
|
|
968
865
|
},` : "";
|
|
969
|
-
const apiUser =
|
|
866
|
+
const apiUser = jsonApi ? `
|
|
970
867
|
"/api/user": {
|
|
971
868
|
GET: kernel.wrapApi(async (request) => {
|
|
972
869
|
const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
|
|
973
870
|
return jsonResponse({ id: user.id, role: user.role ?? "member" });
|
|
974
871
|
}),
|
|
975
872
|
},` : "";
|
|
976
|
-
const
|
|
873
|
+
const jsonPassword = jsonApi ? `
|
|
874
|
+
"/api/v1/auth/forgot-password": {
|
|
875
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
876
|
+
const body = (await request.json()) as { email?: string };
|
|
877
|
+
const email = (body.email ?? "").trim().toLowerCase();
|
|
878
|
+
const user = await starterAuthDirectory.findByEmail?.(email);
|
|
879
|
+
if (user) {
|
|
880
|
+
await sendSignedMail(email, "Reset your password", "/api/v1/auth/reset-password", { email });
|
|
881
|
+
}
|
|
882
|
+
return jsonResponse({ ok: true });
|
|
883
|
+
})),
|
|
884
|
+
},
|
|
885
|
+
"/api/v1/auth/reset-password": {
|
|
886
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
887
|
+
assertValidSignature(request);
|
|
888
|
+
const body = (await request.json()) as { password?: string };
|
|
889
|
+
const email = new URL(request.url).searchParams.get("email") ?? "";
|
|
890
|
+
if (!email || !(body.password && body.password.length >= 8)) {
|
|
891
|
+
return jsonResponse({ error: "Invalid reset payload." }, { status: 422 });
|
|
892
|
+
}
|
|
893
|
+
await getSql().unsafe(
|
|
894
|
+
"UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
|
|
895
|
+
[await hashPassword(body.password), email],
|
|
896
|
+
);
|
|
897
|
+
return jsonResponse({ ok: true });
|
|
898
|
+
})),
|
|
899
|
+
},` : "";
|
|
900
|
+
const jsonVerify = jsonApi && verify ? `
|
|
901
|
+
"/api/v1/auth/verify-email": {
|
|
902
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
903
|
+
assertValidSignature(request);
|
|
904
|
+
const id = Number.parseInt(new URL(request.url).searchParams.get("id") ?? "", 10);
|
|
905
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
906
|
+
return jsonResponse({ error: "Invalid verification link." }, { status: 422 });
|
|
907
|
+
}
|
|
908
|
+
await getSql().unsafe(
|
|
909
|
+
"UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
|
|
910
|
+
[new Date().toISOString(), id],
|
|
911
|
+
);
|
|
912
|
+
return jsonResponse({ ok: true });
|
|
913
|
+
})),
|
|
914
|
+
},` : "";
|
|
915
|
+
const apiRoutes = jsonApi ? `
|
|
977
916
|
routes({ kernel, dependencies }) {
|
|
978
|
-
return {${
|
|
917
|
+
return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
|
|
979
918
|
};
|
|
980
919
|
},` : "";
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
920
|
+
const mfaLoginBranch = mfa ? `if (user.mfa_enabled) {
|
|
921
|
+
const pending = redirectTo("/login/mfa");
|
|
922
|
+
pending.headers.append("set-cookie", pendingMfaSetCookie(user.id));
|
|
923
|
+
return pending;
|
|
924
|
+
}` : "";
|
|
925
|
+
const verifyRegisterBranch = verify ? `await sendSignedMail(email, "Verify your email", "/email/verify", { id: String(insertedId) });
|
|
926
|
+
return flashResponse(
|
|
927
|
+
await auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/email/verify"),
|
|
928
|
+
{ level: "info", message: "Check your email for a verification link." },
|
|
929
|
+
);` : `return auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/");`;
|
|
930
|
+
const cookieRoutes = cookie ? `
|
|
931
|
+
webRoutes({ kernel, dependencies }) {
|
|
932
|
+
const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
|
|
933
|
+
return {
|
|
934
|
+
"/login": {
|
|
935
|
+
GET: kernel.wrapWebGuest(async (request) =>
|
|
936
|
+
renderPage("auth/login.eta", { layout: { title: "Sign in" }, errors: {}, email: "", password: "" }, request),
|
|
937
|
+
),
|
|
938
|
+
POST: wrapWebLogin(
|
|
939
|
+
kernel,
|
|
940
|
+
async (request) => {
|
|
941
|
+
const { fields } = await parseFormBody(request);
|
|
942
|
+
const email = (fields.email ?? "").trim().toLowerCase();
|
|
943
|
+
const password = fields.password ?? "";
|
|
944
|
+
const user = await starterAuthDirectory.findByEmail?.(email);
|
|
945
|
+
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
946
|
+
return renderPage(
|
|
947
|
+
"auth/login.eta",
|
|
948
|
+
{ layout: { title: "Sign in" }, errors: { email: "These credentials do not match our records." }, email, password: "" },
|
|
949
|
+
request,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
${mfaLoginBranch}
|
|
953
|
+
return auth.signInRedirect(sessionUser(user), "/");
|
|
954
|
+
},
|
|
955
|
+
async (request) =>
|
|
956
|
+
renderPage(
|
|
957
|
+
"auth/login.eta",
|
|
958
|
+
{ layout: { title: "Sign in" }, errors: { email: "Too many login attempts. Try again shortly." }, email: "", password: "" },
|
|
959
|
+
request,
|
|
960
|
+
429,
|
|
961
|
+
),
|
|
962
|
+
),
|
|
963
|
+
},
|
|
964
|
+
"/register": {
|
|
965
|
+
GET: kernel.wrapWebGuest(async (request) =>
|
|
966
|
+
renderPage("auth/register.eta", { layout: { title: "Create account" }, errors: {}, name: "", email: "", password: "" }, request),
|
|
967
|
+
),
|
|
968
|
+
POST: wrapWebRegister(
|
|
969
|
+
kernel,
|
|
970
|
+
async (request) => {
|
|
971
|
+
const { fields } = await parseFormBody(request);
|
|
972
|
+
const name = (fields.name ?? "").trim();
|
|
973
|
+
const email = (fields.email ?? "").trim().toLowerCase();
|
|
974
|
+
const password = fields.password ?? "";
|
|
975
|
+
const errors: Record<string, string> = {};
|
|
976
|
+
if (!name) {
|
|
977
|
+
errors.name = "Name is required.";
|
|
978
|
+
}
|
|
979
|
+
if (!email) {
|
|
980
|
+
errors.email = "Email is required.";
|
|
981
|
+
}
|
|
982
|
+
if (password.length < 8) {
|
|
983
|
+
errors.password = "Use at least 8 characters.";
|
|
984
|
+
}
|
|
985
|
+
if (email && (await starterAuthDirectory.findByEmail?.(email))) {
|
|
986
|
+
errors.email = "Email is already registered.";
|
|
987
|
+
}
|
|
988
|
+
if (Object.keys(errors).length > 0) {
|
|
989
|
+
return renderPage(
|
|
990
|
+
"auth/register.eta",
|
|
991
|
+
{ layout: { title: "Create account" }, errors, name, email, password: "" },
|
|
992
|
+
request,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const hashed = await hashPassword(password);
|
|
996
|
+
await getSql().unsafe(
|
|
997
|
+
"INSERT INTO users (${insertCols}) VALUES (${insertPh})",
|
|
998
|
+
[name, email, hashed${insertTail}],
|
|
999
|
+
);
|
|
1000
|
+
const created = await starterAuthDirectory.findByEmail?.(email);
|
|
1001
|
+
const insertedId = created?.id ?? 0;
|
|
1002
|
+
${verifyRegisterBranch}
|
|
1003
|
+
},
|
|
1004
|
+
async (request) =>
|
|
1005
|
+
renderPage(
|
|
1006
|
+
"auth/register.eta",
|
|
1007
|
+
{ layout: { title: "Create account" }, errors: { form: "Too many registration attempts. Try again shortly." }, name: "", email: "", password: "" },
|
|
1008
|
+
request,
|
|
1009
|
+
429,
|
|
1010
|
+
),
|
|
1011
|
+
),
|
|
1012
|
+
},
|
|
1013
|
+
"/forgot-password": {
|
|
1014
|
+
GET: kernel.wrapWebGuest(async (request) =>
|
|
1015
|
+
renderPage("auth/forgot-password.eta", { layout: { title: "Forgot password" }, errors: {}, email: "" }, request),
|
|
1016
|
+
),
|
|
1017
|
+
POST: kernel.wrapWeb(async (request) => {
|
|
1018
|
+
const { fields } = await parseFormBody(request);
|
|
1019
|
+
const email = (fields.email ?? "").trim().toLowerCase();
|
|
1020
|
+
const user = await starterAuthDirectory.findByEmail?.(email);
|
|
1021
|
+
if (user) {
|
|
1022
|
+
await sendSignedMail(email, "Reset your password", "/reset-password", { email });
|
|
1023
|
+
}
|
|
1024
|
+
return flashResponse(redirectTo("/forgot-password"), {
|
|
1025
|
+
level: "success",
|
|
1026
|
+
message: "If that account exists, a reset link is on its way.",
|
|
1027
|
+
});
|
|
1028
|
+
}),
|
|
1029
|
+
},
|
|
1030
|
+
"/reset-password": {
|
|
1031
|
+
GET: kernel.wrapWebGuest(async (request) => {
|
|
1032
|
+
assertValidSignature(request);
|
|
1033
|
+
const email = new URL(request.url).searchParams.get("email") ?? "";
|
|
1034
|
+
return renderPage(
|
|
1035
|
+
"auth/reset-password.eta",
|
|
1036
|
+
{ layout: { title: "Reset password" }, errors: {}, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
|
|
1037
|
+
request,
|
|
1038
|
+
);
|
|
1039
|
+
}),
|
|
1040
|
+
POST: kernel.wrapWeb(async (request) => {
|
|
1041
|
+
assertValidSignature(request);
|
|
1042
|
+
const { fields } = await parseFormBody(request);
|
|
1043
|
+
const email = new URL(request.url).searchParams.get("email") ?? fields.email ?? "";
|
|
1044
|
+
const password = fields.password ?? "";
|
|
1045
|
+
if (!email || password.length < 8) {
|
|
1046
|
+
return renderPage(
|
|
1047
|
+
"auth/reset-password.eta",
|
|
1048
|
+
{ layout: { title: "Reset password" }, errors: { password: "Use at least 8 characters." }, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
|
|
1049
|
+
request,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
await getSql().unsafe(
|
|
1053
|
+
"UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
|
|
1054
|
+
[await hashPassword(password), email],
|
|
1055
|
+
);
|
|
1056
|
+
return flashResponse(redirectTo("/login"), { level: "success", message: "Password updated. Sign in." });
|
|
1057
|
+
}),
|
|
1058
|
+
},
|
|
1059
|
+
"/logout": {
|
|
1060
|
+
POST: kernel.wrapWebAuthenticatedAllowUnverified((request) => auth.signOutRedirect(request, "/")),
|
|
1061
|
+
},${verify ? `
|
|
1062
|
+
"/email/verify": {
|
|
1063
|
+
GET: kernel.wrapWeb(async (request) => {
|
|
1064
|
+
const url = new URL(request.url);
|
|
1065
|
+
if (url.searchParams.get("signature")) {
|
|
1066
|
+
assertValidSignature(request);
|
|
1067
|
+
const id = Number.parseInt(url.searchParams.get("id") ?? "", 10);
|
|
1068
|
+
if (Number.isInteger(id) && id > 0) {
|
|
1069
|
+
await getSql().unsafe(
|
|
1070
|
+
"UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
|
|
1071
|
+
[new Date().toISOString(), id],
|
|
1072
|
+
);
|
|
1073
|
+
const record = await starterAuthDirectory.findByIdOrThrow(id);
|
|
1074
|
+
return flashResponse(
|
|
1075
|
+
await auth.signInRedirect(sessionUser(record), "/"),
|
|
1076
|
+
{ level: "success", message: "Email verified." },
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return renderPage("auth/verify-email.eta", { layout: { title: "Verify email" } }, request);
|
|
1081
|
+
}),
|
|
1082
|
+
},
|
|
1083
|
+
"/email/verification-notification": {
|
|
1084
|
+
POST: kernel.wrapWebAuthenticatedAllowUnverified(async (request) => {
|
|
1085
|
+
const user = await auth.user(request);
|
|
1086
|
+
if (user) {
|
|
1087
|
+
const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
|
|
1088
|
+
await sendSignedMail(record.email ?? "", "Verify your email", "/email/verify", { id: String(record.id) });
|
|
1089
|
+
}
|
|
1090
|
+
return flashResponse(redirectTo("/email/verify"), { level: "info", message: "Verification link sent." });
|
|
1091
|
+
}),
|
|
1092
|
+
},` : ""}${mfa ? `
|
|
1093
|
+
"/login/mfa": {
|
|
1094
|
+
GET: kernel.wrapWebGuest(async (request) => {
|
|
1095
|
+
if (!readPendingMfaUserId(request)) {
|
|
1096
|
+
return redirectTo("/login");
|
|
1097
|
+
}
|
|
1098
|
+
return renderPage("auth/mfa-challenge.eta", { layout: { title: "MFA" }, errors: {}, code: "" }, request);
|
|
1099
|
+
}),
|
|
1100
|
+
POST: kernel.wrapWeb(async (request) => {
|
|
1101
|
+
const pendingId = readPendingMfaUserId(request);
|
|
1102
|
+
if (!pendingId) {
|
|
1103
|
+
return redirectTo("/login");
|
|
1104
|
+
}
|
|
1105
|
+
const { fields } = await parseFormBody(request);
|
|
1106
|
+
const submitted = (fields.code ?? "").trim();
|
|
1107
|
+
const record = await starterAuthDirectory.findByIdOrThrow(pendingId);
|
|
1108
|
+
const secret = revealMfaSecret(record.mfa_secret ?? null);
|
|
1109
|
+
const hashedCodes: string[] = record.mfa_recovery_codes
|
|
1110
|
+
? (JSON.parse(record.mfa_recovery_codes) as string[])
|
|
1111
|
+
: [];
|
|
1112
|
+
const totpOk = secret ? verifyTotp(secret, submitted) : false;
|
|
1113
|
+
const recoveryOk = hashedCodes.some((hash) => recoveryCodeMatches(submitted, hash));
|
|
1114
|
+
if (!totpOk && !recoveryOk) {
|
|
1115
|
+
return renderPage(
|
|
1116
|
+
"auth/mfa-challenge.eta",
|
|
1117
|
+
{ layout: { title: "MFA" }, errors: { code: "That code is not valid." }, code: "" },
|
|
1118
|
+
request,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
if (recoveryOk) {
|
|
1122
|
+
const remaining = hashedCodes.filter((hash) => !recoveryCodeMatches(submitted, hash));
|
|
1123
|
+
await getSql().unsafe(
|
|
1124
|
+
"UPDATE users SET mfa_recovery_codes = ${passwordPh} WHERE id = ${idPh}",
|
|
1125
|
+
[JSON.stringify(remaining), pendingId],
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
const signed = await auth.signInRedirect(sessionUser(record), "/");
|
|
1129
|
+
signed.headers.append("set-cookie", pendingMfaClearCookie());
|
|
1130
|
+
return signed;
|
|
1131
|
+
}),
|
|
1132
|
+
},
|
|
1133
|
+
"/account/mfa": {
|
|
1134
|
+
GET: kernel.wrapWebAuthenticated(async (request) => {
|
|
1135
|
+
const secret = generateTotpSecret();
|
|
1136
|
+
const user = await auth.user(request);
|
|
1137
|
+
const record = user ? await starterAuthDirectory.findByIdOrThrow(Number(user.id)) : null;
|
|
1138
|
+
const otpauth = buildOtpauthUrl({
|
|
1139
|
+
secret,
|
|
1140
|
+
account: record?.email ?? "user",
|
|
1141
|
+
issuer: process.env.APP_NAME ?? "Strata",
|
|
1142
|
+
});
|
|
1143
|
+
return renderPage(
|
|
1144
|
+
"auth/mfa-setup.eta",
|
|
1145
|
+
{ layout: { title: "MFA" }, errors: {}, code: "", secret, otpauth },
|
|
1146
|
+
request,
|
|
1147
|
+
);
|
|
1148
|
+
}),
|
|
1149
|
+
POST: kernel.wrapWebAuthenticated(async (request) => {
|
|
1150
|
+
const user = await auth.user(request);
|
|
1151
|
+
if (!user) {
|
|
1152
|
+
return redirectTo("/login");
|
|
1153
|
+
}
|
|
1154
|
+
const { fields } = await parseFormBody(request);
|
|
1155
|
+
const secret = (fields.secret ?? "").trim();
|
|
1156
|
+
const submitted = (fields.code ?? "").trim();
|
|
1157
|
+
if (!secret || !verifyTotp(secret, submitted)) {
|
|
1158
|
+
return renderPage(
|
|
1159
|
+
"auth/mfa-setup.eta",
|
|
1160
|
+
{
|
|
1161
|
+
layout: { title: "MFA" },
|
|
1162
|
+
errors: { code: "Could not confirm that code." },
|
|
1163
|
+
code: "",
|
|
1164
|
+
secret,
|
|
1165
|
+
otpauth: buildOtpauthUrl({ secret, account: "user", issuer: process.env.APP_NAME ?? "Strata" }),
|
|
1166
|
+
},
|
|
1167
|
+
request,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
const recoveryCodes = generateRecoveryCodes();
|
|
1171
|
+
const stored = protectMfaSecret(secret);
|
|
1172
|
+
await getSql().unsafe(
|
|
1173
|
+
"UPDATE users SET mfa_secret = ${mfaUpdatePh.split(", ")[0]}, mfa_enabled = ${mfaUpdatePh.split(", ")[1]}, mfa_recovery_codes = ${mfaUpdatePh.split(", ")[2]} WHERE id = ${mfaIdPh}",
|
|
1174
|
+
[stored, ${sqlTrue(layers)}, JSON.stringify(recoveryCodes.map((item) => hashRecoveryCode(item))), Number(user.id)],
|
|
1175
|
+
);
|
|
1176
|
+
return renderPage(
|
|
1177
|
+
"auth/mfa-setup.eta",
|
|
1178
|
+
{
|
|
1179
|
+
layout: { title: "MFA" },
|
|
1180
|
+
errors: {},
|
|
1181
|
+
code: "",
|
|
1182
|
+
secret,
|
|
1183
|
+
otpauth: buildOtpauthUrl({ secret, account: "user", issuer: process.env.APP_NAME ?? "Strata" }),
|
|
1184
|
+
recoveryCodes,
|
|
1185
|
+
},
|
|
1186
|
+
request,
|
|
1187
|
+
);
|
|
1188
|
+
}),
|
|
1189
|
+
},` : ""}
|
|
1190
|
+
};
|
|
1191
|
+
},` : "";
|
|
1192
|
+
return `${imports.join(`
|
|
1193
|
+
`)}
|
|
1194
|
+
${helpers}
|
|
1195
|
+
const authModule: AppModule = {
|
|
1196
|
+
name: "auth",
|
|
1197
|
+
order: 2,${apiRoutes}${cookieRoutes}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
export default authModule;
|
|
1201
|
+
`;
|
|
1202
|
+
}
|
|
1203
|
+
function renderSiteModule(_layers) {
|
|
1204
|
+
return `import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
1205
|
+
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
1206
|
+
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
1207
|
+
import { plainText, renderPage } from "../../lib/view.ts";
|
|
1208
|
+
|
|
1209
|
+
const siteModule: AppModule = {
|
|
1210
|
+
name: "site",
|
|
1211
|
+
order: 1,
|
|
1212
|
+
routes({ kernel }) {
|
|
1213
|
+
return {
|
|
1214
|
+
"/health": kernel.wrap("api", withErrorHandling(async () => {
|
|
1215
|
+
const dbOk = await pingDatabase();
|
|
1216
|
+
return plainText(dbOk ? "ok" : "degraded");
|
|
1217
|
+
})),
|
|
1218
|
+
};
|
|
1219
|
+
},
|
|
1220
|
+
webRoutes({ kernel }) {
|
|
1221
|
+
return {
|
|
1222
|
+
"/": kernel.wrapWeb(async (request) =>
|
|
1223
|
+
renderPage(
|
|
1224
|
+
"home.eta",
|
|
1225
|
+
{
|
|
1226
|
+
layout: {
|
|
1227
|
+
title: "Welcome",
|
|
1228
|
+
description: "Welcome to your Strata app. Restyle views/home.eta and public/assets/site.css.",
|
|
1229
|
+
},
|
|
1230
|
+
},
|
|
1231
|
+
request,
|
|
1232
|
+
),
|
|
1233
|
+
),
|
|
1234
|
+
};
|
|
1235
|
+
},
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
export default siteModule;
|
|
1239
|
+
`;
|
|
1240
|
+
}
|
|
1241
|
+
// src/renderAuthViews.ts
|
|
1242
|
+
function renderSiteCss() {
|
|
1243
|
+
return `:root {
|
|
1244
|
+
color-scheme: light;
|
|
1245
|
+
--bg: #f4f1ea;
|
|
1246
|
+
--ink: #1c1917;
|
|
1247
|
+
--muted: #57534e;
|
|
1248
|
+
--card: #fffdf8;
|
|
1249
|
+
--line: #e7e0d4;
|
|
1250
|
+
--accent: #1d4e4f;
|
|
1251
|
+
--accent-ink: #f8faf8;
|
|
1252
|
+
--danger: #9f1239;
|
|
1253
|
+
--ok: #166534;
|
|
1254
|
+
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, serif;
|
|
1255
|
+
line-height: 1.5;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
* { box-sizing: border-box; }
|
|
1259
|
+
|
|
1260
|
+
body {
|
|
1261
|
+
margin: 0;
|
|
1262
|
+
min-height: 100vh;
|
|
1263
|
+
background: var(--bg);
|
|
1264
|
+
color: var(--ink);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
.site-header {
|
|
1268
|
+
display: flex;
|
|
1269
|
+
align-items: center;
|
|
1270
|
+
justify-content: space-between;
|
|
1271
|
+
gap: 1rem;
|
|
1272
|
+
padding: 1rem 1.5rem;
|
|
1273
|
+
border-bottom: 1px solid var(--line);
|
|
1274
|
+
background: var(--card);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
.brand {
|
|
1278
|
+
font-weight: 700;
|
|
1279
|
+
text-decoration: none;
|
|
1280
|
+
color: inherit;
|
|
1281
|
+
letter-spacing: 0.02em;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
.site-header nav {
|
|
1285
|
+
display: flex;
|
|
1286
|
+
gap: 0.75rem;
|
|
1287
|
+
align-items: center;
|
|
1288
|
+
font-size: 0.95rem;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
.site-header a { color: inherit; }
|
|
1292
|
+
|
|
1293
|
+
.site-header form { display: inline; }
|
|
1294
|
+
|
|
1295
|
+
main {
|
|
1296
|
+
padding: 2rem 1.5rem 3rem;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
.section, .auth-card {
|
|
1300
|
+
max-width: 36rem;
|
|
1301
|
+
margin: 0 auto;
|
|
1302
|
+
background: var(--card);
|
|
1303
|
+
border: 1px solid var(--line);
|
|
1304
|
+
border-radius: 1rem;
|
|
1305
|
+
padding: 1.5rem 1.5rem 1.75rem;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
.hero {
|
|
1309
|
+
max-width: 40rem;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
.section h1, .auth-card h1, .hero h1 {
|
|
1313
|
+
margin: 0 0 0.5rem;
|
|
1314
|
+
font-size: 1.8rem;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
.lede, .muted { color: var(--muted); }
|
|
1318
|
+
|
|
1319
|
+
.actions {
|
|
1320
|
+
display: flex;
|
|
1321
|
+
flex-wrap: wrap;
|
|
1322
|
+
gap: 0.75rem;
|
|
1323
|
+
margin-top: 1.25rem;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
label {
|
|
1327
|
+
display: block;
|
|
1328
|
+
margin: 0.85rem 0;
|
|
1329
|
+
font-size: 0.95rem;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
input[type="email"],
|
|
1333
|
+
input[type="password"],
|
|
1334
|
+
input[type="text"] {
|
|
1335
|
+
display: block;
|
|
1336
|
+
width: 100%;
|
|
1337
|
+
margin-top: 0.35rem;
|
|
1338
|
+
padding: 0.55rem 0.7rem;
|
|
1339
|
+
border: 1px solid var(--line);
|
|
1340
|
+
border-radius: 0.5rem;
|
|
1341
|
+
background: #fff;
|
|
1342
|
+
font: inherit;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
button, .button {
|
|
1346
|
+
display: inline-block;
|
|
1347
|
+
border: 0;
|
|
1348
|
+
border-radius: 999px;
|
|
1349
|
+
padding: 0.55rem 1rem;
|
|
1350
|
+
background: var(--accent);
|
|
1351
|
+
color: var(--accent-ink);
|
|
1352
|
+
font: inherit;
|
|
1353
|
+
text-decoration: none;
|
|
1354
|
+
cursor: pointer;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
.button-secondary {
|
|
1358
|
+
background: transparent;
|
|
1359
|
+
color: var(--ink);
|
|
1360
|
+
border: 1px solid var(--line);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.error { color: var(--danger); }
|
|
1364
|
+
.ok, .flash-success { color: var(--ok); }
|
|
1365
|
+
.flash-error { color: var(--danger); }
|
|
1366
|
+
.flash {
|
|
1367
|
+
margin: 0 0 1rem;
|
|
1368
|
+
padding: 0.6rem 0.8rem;
|
|
1369
|
+
border-radius: 0.5rem;
|
|
1370
|
+
border: 1px solid var(--line);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
.auth-links {
|
|
1374
|
+
margin-top: 1rem;
|
|
1375
|
+
display: flex;
|
|
1376
|
+
flex-wrap: wrap;
|
|
1377
|
+
gap: 0.75rem 1rem;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
code { font-size: 0.9em; }
|
|
1381
|
+
`;
|
|
1382
|
+
}
|
|
1383
|
+
function renderLayout(layers, projectName) {
|
|
1384
|
+
const kit = htmlAuthKit(layers.auth);
|
|
1385
|
+
const guestNav = kit ? `<a href="/login">Sign in</a>
|
|
1386
|
+
<a href="/register">Create account</a>` : "";
|
|
1387
|
+
const userNav = kit ? `<% if (it.currentUser) { %>
|
|
1388
|
+
<span class="muted"><%= it.currentUser.email %></span>
|
|
1389
|
+
${layers.extras.mfa ? '<a href="/account/mfa">MFA</a>' : ""}
|
|
1390
|
+
<form method="post" action="/logout">
|
|
1391
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken %>" />
|
|
1392
|
+
<button class="button-secondary" type="submit">Sign out</button>
|
|
1393
|
+
</form>
|
|
1394
|
+
<% } else { %>
|
|
1395
|
+
${guestNav}
|
|
1396
|
+
<% } %>` : "";
|
|
1397
|
+
return `<!DOCTYPE html>
|
|
1398
|
+
<html lang="en">
|
|
1399
|
+
<head>
|
|
1400
|
+
<meta charset="utf-8" />
|
|
1401
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1402
|
+
<title><%= it.layout.title %> \xB7 ${projectName}</title>
|
|
1403
|
+
<% if (it.layout.description) { %>
|
|
1404
|
+
<meta name="description" content="<%= it.layout.description %>" />
|
|
1405
|
+
<% } %>
|
|
1406
|
+
<link rel="stylesheet" href="/assets/site.css" />
|
|
1407
|
+
</head>
|
|
1408
|
+
<body>
|
|
1409
|
+
<header class="site-header">
|
|
1410
|
+
<a class="brand" href="/">${projectName}</a>
|
|
1411
|
+
<nav>
|
|
1412
|
+
${userNav}
|
|
1413
|
+
</nav>
|
|
1414
|
+
</header>
|
|
1415
|
+
<main>
|
|
1416
|
+
<% if (it.flash && it.flash.message) { %>
|
|
1417
|
+
<p class="flash flash-<%= it.flash.level %>"><%= it.flash.message %></p>
|
|
1418
|
+
<% } %>
|
|
1419
|
+
<%~ it.body %>
|
|
1420
|
+
</main>
|
|
1421
|
+
</body>
|
|
1422
|
+
</html>
|
|
1423
|
+
`;
|
|
1424
|
+
}
|
|
1425
|
+
function renderHomeView(projectName, layers) {
|
|
1426
|
+
const kit = htmlAuthKit(layers.auth);
|
|
1427
|
+
const tokenHint = authUsesToken(layers.auth) ? '<p class="muted">API token: <code>POST /api/v1/auth/login</code> with email and password.</p>' : "";
|
|
1428
|
+
const jwtHint = authUsesJwt(layers.auth) ? '<p class="muted">JWT: <code>POST /api/auth/token</code> with email and password.</p>' : "";
|
|
1429
|
+
const guest = kit ? `<% if (!it.currentUser) { %>
|
|
1430
|
+
<p class="lede">Sign in or create an account. Edit <code>views/home.eta</code> and <code>public/assets/site.css</code> to restyle this page.</p>
|
|
1431
|
+
<div class="actions">
|
|
1432
|
+
<a class="button" href="/register">Create account</a>
|
|
1433
|
+
<a class="button button-secondary" href="/login">Sign in</a>
|
|
1434
|
+
</div>
|
|
1435
|
+
<p class="muted">Seeded demo: <code>demo@example.com</code> / <code>password</code>.</p>
|
|
1436
|
+
<% } else { %>
|
|
1437
|
+
<p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
|
|
1438
|
+
<p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
|
|
1439
|
+
<% } %>` : `<p class="lede">Edit <code>views/home.eta</code> and <code>public/assets/site.css</code> to restyle this page.</p>
|
|
1440
|
+
<p class="muted">Auth stack: <code>${layers.auth}</code>.</p>`;
|
|
1441
|
+
const extras = [tokenHint, jwtHint].filter(Boolean).join(`
|
|
1442
|
+
`);
|
|
1443
|
+
return `<section class="section hero">
|
|
1444
|
+
<h1>Welcome to ${projectName}</h1>
|
|
1445
|
+
${guest}
|
|
1446
|
+
<p>Health check: <a href="/health"><code>/health</code></a>.</p>${extras ? `
|
|
1447
|
+
${extras}` : ""}
|
|
1448
|
+
</section>
|
|
1449
|
+
`;
|
|
1450
|
+
}
|
|
1451
|
+
function renderFormView(title, fields, submit, links) {
|
|
1452
|
+
return `<section class="auth-card">
|
|
1453
|
+
<h1>${title}</h1>
|
|
1454
|
+
<% if (it.status) { %>
|
|
1455
|
+
<p class="ok"><%= it.status %></p>
|
|
1456
|
+
<% } %>
|
|
1457
|
+
<% if (it.errors && it.errors.form) { %>
|
|
1458
|
+
<p class="error"><%= it.errors.form %></p>
|
|
1459
|
+
<% } %>
|
|
1460
|
+
<form method="post" action="<%= it.action || "" %>">
|
|
1461
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken %>" />
|
|
1462
|
+
${fields}
|
|
1463
|
+
<div class="actions">
|
|
1464
|
+
<button type="submit">${submit}</button>
|
|
1465
|
+
</div>
|
|
1466
|
+
</form>
|
|
1467
|
+
<div class="auth-links">
|
|
1468
|
+
${links}
|
|
1469
|
+
</div>
|
|
1470
|
+
</section>
|
|
1471
|
+
`;
|
|
1472
|
+
}
|
|
1473
|
+
function textField(name, label, type, extra = "") {
|
|
1474
|
+
return `<label>
|
|
1475
|
+
${label}
|
|
1476
|
+
<% if (it.errors && it.errors.${name}) { %><span class="error"><%= it.errors.${name} %></span><% } %>
|
|
1477
|
+
<input type="${type}" name="${name}" value="<%= it.${name} || "" %>" ${extra} />
|
|
1478
|
+
</label>`;
|
|
1479
|
+
}
|
|
1480
|
+
function renderLoginView() {
|
|
1481
|
+
return renderFormView("Sign in", `${textField("email", "Email", "email", 'required autocomplete="username"')}
|
|
1482
|
+
${textField("password", "Password", "password", 'required autocomplete="current-password"')}`, "Sign in", `<a href="/register">Create account</a>
|
|
1483
|
+
<a href="/forgot-password">Forgot password</a>`).replace('action="<%= it.action || "" %>"', 'action="/login"');
|
|
1484
|
+
}
|
|
1485
|
+
function renderRegisterView() {
|
|
1486
|
+
return renderFormView("Create account", `${textField("name", "Name", "text", "required")}
|
|
1487
|
+
${textField("email", "Email", "email", 'required autocomplete="email"')}
|
|
1488
|
+
${textField("password", "Password", "password", 'required minlength="8" autocomplete="new-password"')}`, "Create account", `<a href="/login">Already have an account</a>`).replace('action="<%= it.action || "" %>"', 'action="/register"');
|
|
1489
|
+
}
|
|
1490
|
+
function renderForgotPasswordView() {
|
|
1491
|
+
return renderFormView("Forgot password", textField("email", "Email", "email", "required"), "Send reset link", `<a href="/login">Back to sign in</a>`).replace('action="<%= it.action || "" %>"', 'action="/forgot-password"');
|
|
1492
|
+
}
|
|
1493
|
+
function renderResetPasswordView() {
|
|
1494
|
+
return renderFormView("Set a new password", `${textField("password", "New password", "password", 'required minlength="8" autocomplete="new-password"')}
|
|
1495
|
+
<input type="hidden" name="email" value="<%= it.email || "" %>" />`, "Update password", `<a href="/login">Back to sign in</a>`).replace('action="<%= it.action || "" %>"', 'action="<%= it.action %>"');
|
|
1496
|
+
}
|
|
1497
|
+
function renderVerifyEmailView() {
|
|
1498
|
+
return `<section class="auth-card">
|
|
1499
|
+
<h1>Verify your email</h1>
|
|
1500
|
+
<p>We sent a signed link to your inbox (or the mail log when <code>MAIL_DRIVER=log</code>).</p>
|
|
1501
|
+
<form method="post" action="/email/verification-notification">
|
|
1502
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken %>" />
|
|
1503
|
+
<button type="submit">Resend link</button>
|
|
1504
|
+
</form>
|
|
1505
|
+
</section>
|
|
1506
|
+
`;
|
|
1507
|
+
}
|
|
1508
|
+
function renderMfaChallengeView() {
|
|
1509
|
+
return renderFormView("Two-factor code", `${textField("code", "Authenticator or recovery code", "text", 'required autocomplete="one-time-code"')}`, "Continue", `<a href="/login">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action="/login/mfa"');
|
|
1510
|
+
}
|
|
1511
|
+
function renderMfaSetupView() {
|
|
1512
|
+
return `<section class="auth-card">
|
|
1513
|
+
<h1>Authenticator app</h1>
|
|
1514
|
+
<p class="muted">Scan this otpauth URL in your authenticator, then confirm a code. Restyle this page in <code>views/auth/mfa-setup.eta</code>.</p>
|
|
1515
|
+
<p><code><%= it.otpauth %></code></p>
|
|
1516
|
+
<form method="post" action="/account/mfa">
|
|
1517
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken %>" />
|
|
1518
|
+
<input type="hidden" name="secret" value="<%= it.secret %>" />
|
|
1519
|
+
${textField("code", "Confirmation code", "text", "required")}
|
|
1520
|
+
<div class="actions"><button type="submit">Enable MFA</button></div>
|
|
1521
|
+
</form>
|
|
1522
|
+
<% if (it.recoveryCodes) { %>
|
|
1523
|
+
<h2>Recovery codes</h2>
|
|
1524
|
+
<p>Store these once. They will not be shown again.</p>
|
|
1525
|
+
<ul>
|
|
1526
|
+
<% for (const code of it.recoveryCodes) { %>
|
|
1527
|
+
<li><code><%= code %></code></li>
|
|
1528
|
+
<% } %>
|
|
1529
|
+
</ul>
|
|
1530
|
+
<% } %>
|
|
1531
|
+
</section>
|
|
1532
|
+
`;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/renderAuth.ts
|
|
1536
|
+
function renderAuthDirectory(layers) {
|
|
1537
|
+
if (!authNeedsUsers(layers.auth)) {
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
const tokenLookup = authUsesToken(layers.auth) ? `
|
|
1541
|
+
async resolveUserFromToken(token: string) {
|
|
1542
|
+
if (!token || token.split(".").length === 3) {
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
const hashed = hashApiToken(token);
|
|
1546
|
+
const rows = await getSql().unsafe<
|
|
1547
|
+
Array<{
|
|
1548
|
+
id: number;
|
|
1549
|
+
user_id: number;
|
|
1550
|
+
abilities: string;
|
|
1551
|
+
expires_at: Date | string | null;
|
|
1552
|
+
role?: string;
|
|
1553
|
+
is_admin?: number | boolean;
|
|
1554
|
+
email_verified_at?: Date | string | null;
|
|
1555
|
+
}>
|
|
1556
|
+
>(
|
|
1557
|
+
\`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
|
|
1558
|
+
FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
|
|
1559
|
+
WHERE t.token_hash = ?\`,
|
|
1560
|
+
[hashed],
|
|
1561
|
+
);
|
|
1562
|
+
const row = rows[0];
|
|
1563
|
+
if (!row) {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
let abilities: string[] = [];
|
|
1570
|
+
try {
|
|
1571
|
+
abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
|
|
1572
|
+
} catch {
|
|
1573
|
+
abilities = ["profile:read"];
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
id: Number(row.user_id),
|
|
1577
|
+
role: row.is_admin ? "admin" : "member",
|
|
1578
|
+
abilities,
|
|
1579
|
+
tokenId: Number(row.id),
|
|
1580
|
+
emailVerifiedAt: row.email_verified_at ?? null,
|
|
1581
|
+
};
|
|
1582
|
+
},` : `
|
|
1583
|
+
async resolveUserFromToken() {
|
|
1584
|
+
return null;
|
|
1585
|
+
},`;
|
|
1586
|
+
const placeholder = layers.database === "postgres" ? "$1" : "?";
|
|
1587
|
+
const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
1588
|
+
` : "";
|
|
1589
|
+
const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
|
|
1590
|
+
const mfaReturn = layers.extras.mfa ? `
|
|
1591
|
+
mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
|
|
1592
|
+
mfa_secret: row.mfa_secret ?? null,
|
|
1593
|
+
mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
|
|
1594
|
+
return `import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
1595
|
+
import { verifyPassword } from "@getstrata/core/auth/password";
|
|
1596
|
+
${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
|
|
1597
|
+
import { getSql } from "./database.ts";
|
|
1598
|
+
|
|
1599
|
+
function mapRole(isAdmin: unknown): string {
|
|
1600
|
+
return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
export const starterAuthDirectory: AuthUserDirectory = {
|
|
1604
|
+
${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
|
|
1605
|
+
|
|
1606
|
+
async findByIdOrThrow(id: number) {
|
|
1607
|
+
const rows = await getSql().unsafe<
|
|
1608
|
+
Array<{
|
|
1609
|
+
id: number;
|
|
1610
|
+
name: string;
|
|
1611
|
+
email: string;
|
|
1612
|
+
is_admin: number | boolean;
|
|
1613
|
+
email_verified_at: Date | string | null;
|
|
1614
|
+
password: string;
|
|
1615
|
+
mfa_enabled?: number | boolean;
|
|
1616
|
+
mfa_secret?: string | null;
|
|
1617
|
+
mfa_recovery_codes?: string | null;
|
|
1618
|
+
}>
|
|
1619
|
+
>(\`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE id = ${placeholder}\`, [id]);
|
|
1620
|
+
const row = rows[0];
|
|
1621
|
+
if (!row) {
|
|
1622
|
+
throw new Error(\`User \${id} not found.\`);
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
id: Number(row.id),
|
|
1626
|
+
name: row.name,
|
|
1627
|
+
email: row.email,
|
|
1628
|
+
role: mapRole(row.is_admin),
|
|
1629
|
+
email_verified_at: row.email_verified_at ?? null,
|
|
1630
|
+
password: row.password,${mfaReturn}
|
|
1631
|
+
};
|
|
1632
|
+
},
|
|
1633
|
+
|
|
1634
|
+
async findByEmail(email: string) {
|
|
1635
|
+
const rows = await getSql().unsafe<
|
|
1636
|
+
Array<{
|
|
1637
|
+
id: number;
|
|
1638
|
+
name: string;
|
|
1639
|
+
email: string;
|
|
1640
|
+
is_admin: number | boolean;
|
|
1641
|
+
email_verified_at: Date | string | null;
|
|
1642
|
+
password: string;
|
|
1643
|
+
mfa_enabled?: number | boolean;
|
|
1644
|
+
mfa_secret?: string | null;
|
|
1645
|
+
mfa_recovery_codes?: string | null;
|
|
1646
|
+
}>
|
|
1647
|
+
>(
|
|
1648
|
+
\`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE email = ${placeholder}\`,
|
|
1649
|
+
[email.trim().toLowerCase()],
|
|
1650
|
+
);
|
|
1651
|
+
const row = rows[0];
|
|
1652
|
+
if (!row) {
|
|
1653
|
+
return null;
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
id: Number(row.id),
|
|
1657
|
+
name: row.name,
|
|
1658
|
+
email: row.email,
|
|
1659
|
+
role: mapRole(row.is_admin),
|
|
1660
|
+
email_verified_at: row.email_verified_at ?? null,
|
|
1661
|
+
password: row.password,${mfaReturn}
|
|
1662
|
+
};
|
|
1663
|
+
},
|
|
1664
|
+
|
|
1665
|
+
async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
|
|
1666
|
+
const user = await this.findByEmail(email);
|
|
1667
|
+
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
return {
|
|
1671
|
+
id: user.id,
|
|
1672
|
+
role: user.role,
|
|
1673
|
+
emailVerifiedAt: user.email_verified_at ?? null,
|
|
1674
|
+
};
|
|
1675
|
+
},
|
|
1676
|
+
};
|
|
1677
|
+
`;
|
|
1678
|
+
}
|
|
1679
|
+
function renderAuthProvider(layers) {
|
|
1680
|
+
if (layers.auth === "headers") {
|
|
1681
|
+
return `import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
|
|
1682
|
+
import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
1683
|
+
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
1684
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
1685
|
+
|
|
1686
|
+
class StarterAuthManager {
|
|
1687
|
+
async resolve(request?: Request): Promise<AuthUser | null> {
|
|
1688
|
+
if (process.env.AUTH_DEV_HEADERS === "false") {
|
|
1689
|
+
return request ? null : currentAuthUser();
|
|
1690
|
+
}
|
|
1691
|
+
if (request) {
|
|
1692
|
+
const userId = request.headers.get("x-authenticated-user-id");
|
|
1693
|
+
if (!userId) {
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
const role = request.headers.get("x-authenticated-user-role");
|
|
1697
|
+
return {
|
|
1698
|
+
id: userId,
|
|
1699
|
+
...(role ? { role } : {}),
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
return currentAuthUser();
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
user(request?: Request) {
|
|
1706
|
+
return this.resolve(request);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
async check(request: Request) {
|
|
1710
|
+
return (await this.user(request)) !== null;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const authProvider: ServiceProvider = {
|
|
1715
|
+
name: "starter.auth",
|
|
1716
|
+
register({ container }) {
|
|
1717
|
+
container.set(CORE_AUTH_TOKEN, new StarterAuthManager());
|
|
1718
|
+
},
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
export default authProvider;
|
|
1722
|
+
`;
|
|
984
1723
|
}
|
|
985
|
-
|
|
986
|
-
|
|
1724
|
+
const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
|
|
1725
|
+
secret: process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch",
|
|
1726
|
+
cookieName: "strata_session",
|
|
1727
|
+
mapUser: (user) => ({
|
|
1728
|
+
id: user.id,
|
|
1729
|
+
role: user.is_admin ? "admin" : "member",
|
|
1730
|
+
...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
|
|
1731
|
+
}),
|
|
1732
|
+
});` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
|
|
1733
|
+
const auth = new AuthManager(fallback);`;
|
|
1734
|
+
const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
|
|
1735
|
+
auth.registerGuard("api", apiGuard);
|
|
1736
|
+
auth.registerGuard("access_token", apiGuard);
|
|
1737
|
+
auth.registerGuard("token", apiGuard);` : "";
|
|
1738
|
+
const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
|
|
1739
|
+
const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
|
|
1740
|
+
const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
|
|
1741
|
+
const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
|
|
987
1742
|
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";`);
|
|
1743
|
+
imports.push(`import { createCookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
|
|
991
1744
|
}
|
|
992
1745
|
if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
|
|
1746
|
+
imports.push(`import { BasicAuthGuard } from "@getstrata/core/auth/basicAuthGuard";`);
|
|
1747
|
+
}
|
|
1748
|
+
if (!authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
|
|
1749
|
+
imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
|
|
1750
|
+
} else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
|
|
993
1751
|
imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
|
|
1752
|
+
} else if (authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
|
|
1753
|
+
imports.push(`import { DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
|
|
994
1754
|
}
|
|
995
1755
|
if (authUsesJwt(layers.auth)) {
|
|
996
|
-
imports.push(`import {
|
|
997
|
-
}
|
|
998
|
-
if (authUsesCookie(layers.auth)) {
|
|
999
|
-
imports.push(`import { verifyPassword } from "@getstrata/core/auth/password";`);
|
|
1756
|
+
imports.push(`import { JwtGuard } from "@getstrata/core/auth/jwtGuard";`);
|
|
1000
1757
|
}
|
|
1001
1758
|
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";`);
|
|
1759
|
+
imports.push(`import { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
|
|
1006
1760
|
}
|
|
1007
|
-
imports.push(`import {
|
|
1761
|
+
imports.push(`import type { ServiceProvider } from "@getstrata/core/contracts/di";`);
|
|
1762
|
+
const tokenImports = ["CORE_AUTH_USER_DIRECTORY_TOKEN"];
|
|
1008
1763
|
if (authUsesToken(layers.auth)) {
|
|
1009
|
-
|
|
1010
|
-
}
|
|
1011
|
-
if (authUsesCookie(layers.auth)) {
|
|
1012
|
-
imports.push(`import { renderPage } from "../../lib/view.ts";`);
|
|
1764
|
+
tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
|
|
1013
1765
|
}
|
|
1766
|
+
imports.push(`import {
|
|
1767
|
+
${tokenImports.join(`,
|
|
1768
|
+
`)},
|
|
1769
|
+
} from "@getstrata/core/contracts/serviceTokens";`);
|
|
1770
|
+
imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
|
|
1014
1771
|
return `${imports.join(`
|
|
1015
1772
|
`)}
|
|
1016
1773
|
|
|
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
|
-
};
|
|
1774
|
+
const authProvider: ServiceProvider = {
|
|
1775
|
+
name: "starter.auth",
|
|
1776
|
+
register({ container }) {
|
|
1777
|
+
container.set(CORE_AUTH_USER_DIRECTORY_TOKEN, starterAuthDirectory);
|
|
1778
|
+
${cookieBlock}
|
|
1779
|
+
${tokenRegs}
|
|
1780
|
+
${jwtReg}
|
|
1781
|
+
${basicReg}
|
|
1782
|
+
${ability}
|
|
1783
|
+
container.set(CORE_AUTH_TOKEN, auth);
|
|
1058
1784
|
},
|
|
1059
1785
|
};
|
|
1060
1786
|
|
|
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>
|
|
1787
|
+
export default authProvider;
|
|
1119
1788
|
`;
|
|
1120
1789
|
}
|
|
1121
1790
|
|
|
@@ -1187,6 +1856,7 @@ function renderEnvExample(projectName, layers) {
|
|
|
1187
1856
|
if (layers.extras.scim) {
|
|
1188
1857
|
lines.push("FEATURE_SCIM=true");
|
|
1189
1858
|
lines.push("SCIM_BEARER_TOKEN=dev-scim-token-change-me");
|
|
1859
|
+
lines.push("# SCIM_TENANT_TOKENS=1:token-a");
|
|
1190
1860
|
} else {
|
|
1191
1861
|
lines.push("# FEATURE_SCIM=false");
|
|
1192
1862
|
lines.push("# SCIM_BEARER_TOKEN=");
|
|
@@ -1288,9 +1958,9 @@ function renderPackageJson(projectName, options = {}) {
|
|
|
1288
1958
|
"@getstrata/cli": "workspace:*",
|
|
1289
1959
|
"@getstrata/core": "workspace:*"
|
|
1290
1960
|
} : {
|
|
1291
|
-
"@getstrata/bootstrap": "^0.4.
|
|
1961
|
+
"@getstrata/bootstrap": "^0.4.3",
|
|
1292
1962
|
"@getstrata/cli": "^0.2.0",
|
|
1293
|
-
"@getstrata/core": "^0.7.
|
|
1963
|
+
"@getstrata/core": "^0.7.5"
|
|
1294
1964
|
};
|
|
1295
1965
|
if (options.layers?.database === "mysql") {
|
|
1296
1966
|
coreDeps.mysql2 = "^3.24.3";
|
|
@@ -1419,9 +2089,17 @@ Seeded login (password \`password\`):
|
|
|
1419
2089
|
` : `
|
|
1420
2090
|
Header auth is on for local use. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`). Production must set \`AUTH_DEV_HEADERS=false\`.
|
|
1421
2091
|
`}${authUsesCookie(layers.auth) ? `
|
|
1422
|
-
HTML
|
|
2092
|
+
HTML auth kit (restyle \`views/\` and \`public/assets/site.css\`):
|
|
2093
|
+
|
|
2094
|
+
- Welcome: \`/\`
|
|
2095
|
+
- Sign in: \`/login\`
|
|
2096
|
+
- Register: \`/register\`
|
|
2097
|
+
- Forgot password: \`/forgot-password\`
|
|
2098
|
+
- Reset password: signed \`/reset-password\` (mail log when \`MAIL_DRIVER=log\`)
|
|
2099
|
+
${layers.extras.emailVerification ? "- Verify email: `/email/verify`\n" : ""}${layers.extras.mfa ? "- MFA challenge: `/login/mfa` and setup: `/account/mfa`\n" : ""}
|
|
2100
|
+
Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
|
|
1423
2101
|
` : ""}${authUsesToken(layers.auth) ? `
|
|
1424
|
-
Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Send \`Authorization: Bearer
|
|
2102
|
+
Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Register: \`POST /api/v1/auth/register\`. Forgot/reset: \`POST /api/v1/auth/forgot-password\` and signed \`POST /api/v1/auth/reset-password\`. Send \`Authorization: Bearer\` after login.
|
|
1425
2103
|
` : ""}${authUsesJwt(layers.auth) ? `
|
|
1426
2104
|
JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Not a portal session.
|
|
1427
2105
|
` : ""}
|
|
@@ -1651,7 +2329,9 @@ export async function closeDatabase() {
|
|
|
1651
2329
|
function renderMigrateTs(layers) {
|
|
1652
2330
|
const d = dialectFragments(layers.database);
|
|
1653
2331
|
const statements = [];
|
|
1654
|
-
|
|
2332
|
+
const tenancyOn = usesTenantTable(layers.tenancy);
|
|
2333
|
+
const mfaOn = layers.extras.mfa && authNeedsUsers(layers.auth);
|
|
2334
|
+
if (tenancyOn) {
|
|
1655
2335
|
statements.push(`CREATE TABLE IF NOT EXISTS tenant (
|
|
1656
2336
|
id ${d.id},
|
|
1657
2337
|
slug ${d.text} NOT NULL UNIQUE,
|
|
@@ -1665,14 +2345,18 @@ function renderMigrateTs(layers) {
|
|
|
1665
2345
|
created_at ${d.timestamp}
|
|
1666
2346
|
)`);
|
|
1667
2347
|
if (authNeedsUsers(layers.auth)) {
|
|
1668
|
-
const tenantColumn =
|
|
2348
|
+
const tenantColumn = tenancyOn ? `
|
|
1669
2349
|
tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
|
|
2350
|
+
const mfaColumns = mfaOn ? `
|
|
2351
|
+
mfa_secret ${d.text},
|
|
2352
|
+
mfa_enabled ${d.bool},
|
|
2353
|
+
mfa_recovery_codes ${d.text},` : "";
|
|
1670
2354
|
statements.push(`CREATE TABLE IF NOT EXISTS users (
|
|
1671
2355
|
id ${d.id},
|
|
1672
2356
|
name ${d.text} NOT NULL,
|
|
1673
2357
|
email ${d.text} NOT NULL UNIQUE,
|
|
1674
2358
|
password ${d.text} NOT NULL,
|
|
1675
|
-
is_admin ${d.bool},${tenantColumn}
|
|
2359
|
+
is_admin ${d.bool},${tenantColumn}${mfaColumns}
|
|
1676
2360
|
email_verified_at ${d.timestampNull},
|
|
1677
2361
|
created_at ${d.timestamp}
|
|
1678
2362
|
)`);
|
|
@@ -1701,18 +2385,22 @@ function renderMigrateTs(layers) {
|
|
|
1701
2385
|
}
|
|
1702
2386
|
const list = statements.map((sql) => ` \`${sql}\`,`).join(`
|
|
1703
2387
|
`);
|
|
1704
|
-
const
|
|
1705
|
-
const notePlaceholder =
|
|
1706
|
-
const
|
|
1707
|
-
const
|
|
1708
|
-
const
|
|
1709
|
-
const
|
|
2388
|
+
const ph2 = layers.database === "postgres";
|
|
2389
|
+
const notePlaceholder = ph2 ? "$1" : "?";
|
|
2390
|
+
const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
|
|
2391
|
+
const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
|
|
2392
|
+
const userPlaceholders = verifyOn ? ph2 ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph2 ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
|
|
2393
|
+
const adminFlag = ph2 ? "false" : "0";
|
|
2394
|
+
const adminTrue = ph2 ? "true" : "1";
|
|
2395
|
+
const verifiedNow = "new Date().toISOString()";
|
|
2396
|
+
const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
|
|
2397
|
+
const seedTenant = tenancyOn ? `
|
|
1710
2398
|
const [{ count: tenantCount }] = await sql.unsafe<Array<{ count: string | number }>>(
|
|
1711
2399
|
"SELECT COUNT(*) AS count FROM tenant",
|
|
1712
2400
|
);
|
|
1713
2401
|
if (Number(tenantCount) === 0) {
|
|
1714
2402
|
await sql.unsafe(
|
|
1715
|
-
"INSERT INTO tenant (slug, plan, region) VALUES (${
|
|
2403
|
+
"INSERT INTO tenant (slug, plan, region) VALUES (${ph2 ? "$1, $2, $3" : "?, ?, ?"})",
|
|
1716
2404
|
["default", "enterprise", "eu"],
|
|
1717
2405
|
);
|
|
1718
2406
|
}` : "";
|
|
@@ -1723,8 +2411,8 @@ function renderMigrateTs(layers) {
|
|
|
1723
2411
|
if (Number(userCount) === 0) {
|
|
1724
2412
|
const password = await hashPassword("password");
|
|
1725
2413
|
await sql.unsafe(
|
|
1726
|
-
"INSERT INTO users (
|
|
1727
|
-
|
|
2414
|
+
"INSERT INTO users (${userColumns}) VALUES (${userPlaceholders})",
|
|
2415
|
+
${userValues},
|
|
1728
2416
|
);
|
|
1729
2417
|
}` : "";
|
|
1730
2418
|
const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
|
|
@@ -1736,13 +2424,6 @@ const migrations = [
|
|
|
1736
2424
|
${list}
|
|
1737
2425
|
];
|
|
1738
2426
|
|
|
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
2427
|
export async function seed() {
|
|
1747
2428
|
${ensureCall(layers)} const sql = getSql();
|
|
1748
2429
|
const [{ count }] = await sql.unsafe<Array<{ count: string | number }>>(
|
|
@@ -1755,9 +2436,16 @@ ${ensureCall(layers)} const sql = getSql();
|
|
|
1755
2436
|
}${seedBlock}
|
|
1756
2437
|
}
|
|
1757
2438
|
|
|
2439
|
+
export async function migrate() {
|
|
2440
|
+
${ensureCall(layers)} const sql = getSql();
|
|
2441
|
+
for (const statement of migrations) {
|
|
2442
|
+
await sql.unsafe(statement);
|
|
2443
|
+
}
|
|
2444
|
+
await seed();
|
|
2445
|
+
}
|
|
2446
|
+
|
|
1758
2447
|
if (import.meta.main) {
|
|
1759
2448
|
await migrate();
|
|
1760
|
-
await seed();
|
|
1761
2449
|
console.log("Database migrated and seeded.");
|
|
1762
2450
|
process.exit(0);
|
|
1763
2451
|
}
|
|
@@ -1775,7 +2463,7 @@ function dropTables(layers) {
|
|
|
1775
2463
|
ordered.push("users");
|
|
1776
2464
|
}
|
|
1777
2465
|
ordered.push("notes");
|
|
1778
|
-
if (layers.tenancy
|
|
2466
|
+
if (usesTenantTable(layers.tenancy)) {
|
|
1779
2467
|
ordered.push("tenant");
|
|
1780
2468
|
}
|
|
1781
2469
|
return ordered;
|
|
@@ -1784,7 +2472,7 @@ function renderFreshTs(layers) {
|
|
|
1784
2472
|
const tables = dropTables(layers);
|
|
1785
2473
|
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
1786
2474
|
return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
1787
|
-
import { migrate
|
|
2475
|
+
import { migrate } from "./migrate.ts";
|
|
1788
2476
|
|
|
1789
2477
|
const tables = ${JSON.stringify(tables)};
|
|
1790
2478
|
|
|
@@ -1794,7 +2482,6 @@ ${ensureCall(layers)} const sql = getSql();
|
|
|
1794
2482
|
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
1795
2483
|
}
|
|
1796
2484
|
await migrate();
|
|
1797
|
-
await seed();
|
|
1798
2485
|
}
|
|
1799
2486
|
|
|
1800
2487
|
if (import.meta.main) {
|
|
@@ -2232,11 +2919,28 @@ export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
|
|
|
2232
2919
|
}
|
|
2233
2920
|
`;
|
|
2234
2921
|
}
|
|
2235
|
-
function renderViewTs() {
|
|
2236
|
-
|
|
2922
|
+
function renderViewTs(layers) {
|
|
2923
|
+
const authImport = authNeedsUsers(layers.auth) ? `import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
2237
2924
|
import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
|
|
2925
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
2238
2926
|
import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
|
|
2239
|
-
|
|
2927
|
+
import { starterAuthDirectory } from "../bootstrap/authDirectory.ts";
|
|
2928
|
+
` : `import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
|
|
2929
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
2930
|
+
import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
|
|
2931
|
+
`;
|
|
2932
|
+
const userBlock = authNeedsUsers(layers.auth) ? ` let currentUser: { id: number; email: string; name: string | null } | null = null;
|
|
2933
|
+
const authUser = currentAuthUser();
|
|
2934
|
+
if (authUser) {
|
|
2935
|
+
try {
|
|
2936
|
+
const row = await starterAuthDirectory.findByIdOrThrow(Number(authUser.id));
|
|
2937
|
+
currentUser = { id: row.id, email: row.email ?? "", name: row.name ?? null };
|
|
2938
|
+
} catch {
|
|
2939
|
+
currentUser = null;
|
|
2940
|
+
}
|
|
2941
|
+
}` : ` const currentUser = null;`;
|
|
2942
|
+
return `import { join } from "node:path";
|
|
2943
|
+
${authImport}
|
|
2240
2944
|
const engine = new EtaViewEngine(join(import.meta.dir, "../../views"));
|
|
2241
2945
|
|
|
2242
2946
|
export interface LayoutData {
|
|
@@ -2248,10 +2952,17 @@ export async function renderPage(
|
|
|
2248
2952
|
template: string,
|
|
2249
2953
|
data: Record<string, unknown> & { layout: LayoutData },
|
|
2250
2954
|
request?: Request,
|
|
2955
|
+
status = 200,
|
|
2251
2956
|
): Promise<Response> {
|
|
2252
2957
|
const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
|
|
2253
|
-
const
|
|
2254
|
-
|
|
2958
|
+
const flash = currentRequestMeta().flash ?? null;
|
|
2959
|
+
${userBlock}
|
|
2960
|
+
const html = await engine.render(
|
|
2961
|
+
template,
|
|
2962
|
+
{ ...data, csrfToken, flash, currentUser },
|
|
2963
|
+
{ request },
|
|
2964
|
+
);
|
|
2965
|
+
return htmlResponse(html, { status });
|
|
2255
2966
|
}
|
|
2256
2967
|
|
|
2257
2968
|
export function plainText(body: string): Response {
|
|
@@ -2260,6 +2971,315 @@ export function plainText(body: string): Response {
|
|
|
2260
2971
|
`;
|
|
2261
2972
|
}
|
|
2262
2973
|
|
|
2974
|
+
// src/renderScim.ts
|
|
2975
|
+
function ph2(layers, count, start = 1) {
|
|
2976
|
+
if (layers.database === "postgres") {
|
|
2977
|
+
return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
|
|
2978
|
+
}
|
|
2979
|
+
return Array.from({ length: count }, () => "?").join(", ");
|
|
2980
|
+
}
|
|
2981
|
+
function sqlFalse2(layers) {
|
|
2982
|
+
return layers.database === "postgres" ? "false" : "0";
|
|
2983
|
+
}
|
|
2984
|
+
function renderScimModule(layers) {
|
|
2985
|
+
if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
|
|
2986
|
+
return null;
|
|
2987
|
+
}
|
|
2988
|
+
const tenantOn = usesTenantTable(layers.tenancy);
|
|
2989
|
+
const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
|
|
2990
|
+
const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
|
|
2991
|
+
const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
|
|
2992
|
+
const emailPh = ph2(layers, 1);
|
|
2993
|
+
const idPh = ph2(layers, 1);
|
|
2994
|
+
const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
|
|
2995
|
+
return `import { randomBytes } from "node:crypto";
|
|
2996
|
+
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
2997
|
+
import { routeParams } from "@getstrata/bootstrap/web/routing";
|
|
2998
|
+
import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
|
|
2999
|
+
import { hashPassword } from "@getstrata/core/auth/password";
|
|
3000
|
+
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
3001
|
+
import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
|
|
3002
|
+
import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
|
|
3003
|
+
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
3004
|
+
import { getSql } from "../../bootstrap/database.ts";
|
|
3005
|
+
|
|
3006
|
+
const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
3007
|
+
const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
3008
|
+
const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
3009
|
+
const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
|
3010
|
+
const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
|
|
3011
|
+
|
|
3012
|
+
type UserRow = { id: number; name: string; email: string };
|
|
3013
|
+
|
|
3014
|
+
function scimEnabled(): boolean {
|
|
3015
|
+
return (process.env.FEATURE_SCIM ?? "false") === "true";
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function scimJson(body: unknown, status = 200): Response {
|
|
3019
|
+
return Response.json(body, {
|
|
3020
|
+
status,
|
|
3021
|
+
headers: { "content-type": "application/scim+json" },
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
function scimError(detail: string, status: number): Response {
|
|
3026
|
+
return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function toScimUser(row: UserRow) {
|
|
3030
|
+
return {
|
|
3031
|
+
schemas: [USER_SCHEMA],
|
|
3032
|
+
id: String(row.id),
|
|
3033
|
+
userName: row.email,
|
|
3034
|
+
name: { formatted: row.name },
|
|
3035
|
+
emails: [{ value: row.email, primary: true }],
|
|
3036
|
+
active: true,
|
|
3037
|
+
meta: { resourceType: "User" },
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
function readUserName(body: Record<string, unknown>): string {
|
|
3042
|
+
const emails = body.emails;
|
|
3043
|
+
if (Array.isArray(emails) && emails[0] && typeof emails[0] === "object") {
|
|
3044
|
+
const value = (emails[0] as { value?: unknown }).value;
|
|
3045
|
+
if (typeof value === "string" && value.trim()) {
|
|
3046
|
+
return value.trim().toLowerCase();
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
return typeof body.userName === "string" ? body.userName.trim().toLowerCase() : "";
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
function readName(body: Record<string, unknown>, fallback: string): string {
|
|
3053
|
+
const name = body.name;
|
|
3054
|
+
if (name && typeof name === "object") {
|
|
3055
|
+
const formatted = (name as { formatted?: unknown; givenName?: unknown; familyName?: unknown }).formatted;
|
|
3056
|
+
if (typeof formatted === "string" && formatted.trim()) {
|
|
3057
|
+
return formatted.trim();
|
|
3058
|
+
}
|
|
3059
|
+
const given = (name as { givenName?: unknown }).givenName;
|
|
3060
|
+
const family = (name as { familyName?: unknown }).familyName;
|
|
3061
|
+
const combined = [given, family].filter((part) => typeof part === "string").join(" ").trim();
|
|
3062
|
+
if (combined) {
|
|
3063
|
+
return combined;
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
if (typeof body.displayName === "string" && body.displayName.trim()) {
|
|
3067
|
+
return body.displayName.trim();
|
|
3068
|
+
}
|
|
3069
|
+
return fallback;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
function wrapScim(handler: (request: Request) => Promise<Response>) {
|
|
3073
|
+
const throttle = createScimThrottleMiddleware({
|
|
3074
|
+
redisUrl: process.env.REDIS_URL,
|
|
3075
|
+
maxAttempts: 120,
|
|
3076
|
+
decaySeconds: 60,
|
|
3077
|
+
});
|
|
3078
|
+
return withMiddleware(throttle, createScimAuthMiddleware())(
|
|
3079
|
+
withErrorHandling(async (request) => {
|
|
3080
|
+
if (!scimEnabled()) {
|
|
3081
|
+
return scimError("SCIM is off.", 404);
|
|
3082
|
+
}
|
|
3083
|
+
return handler(request);
|
|
3084
|
+
}),
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
const scimModule: AppModule = {
|
|
3089
|
+
name: "scim",
|
|
3090
|
+
order: 8,
|
|
3091
|
+
routes({ kernel }) {
|
|
3092
|
+
return {
|
|
3093
|
+
"/scim/v2/ServiceProviderConfig": {
|
|
3094
|
+
GET: kernel.wrap(
|
|
3095
|
+
"api",
|
|
3096
|
+
wrapScim(async () =>
|
|
3097
|
+
scimJson({
|
|
3098
|
+
schemas: [CONFIG_SCHEMA],
|
|
3099
|
+
patch: { supported: true },
|
|
3100
|
+
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
|
3101
|
+
filter: { supported: true, maxResults: 200 },
|
|
3102
|
+
changePassword: { supported: false },
|
|
3103
|
+
sort: { supported: false },
|
|
3104
|
+
etag: { supported: false },
|
|
3105
|
+
authenticationSchemes: [
|
|
3106
|
+
{
|
|
3107
|
+
type: "oauthbearertoken",
|
|
3108
|
+
name: "OAuth Bearer Token",
|
|
3109
|
+
description: "Bearer token in the Authorization header.",
|
|
3110
|
+
specUri: "https://www.rfc-editor.org/rfc/rfc6750",
|
|
3111
|
+
primary: true,
|
|
3112
|
+
},
|
|
3113
|
+
],
|
|
3114
|
+
}),
|
|
3115
|
+
),
|
|
3116
|
+
),
|
|
3117
|
+
},
|
|
3118
|
+
"/scim/v2/Users": {
|
|
3119
|
+
GET: kernel.wrap(
|
|
3120
|
+
"api",
|
|
3121
|
+
wrapScim(async (request) => {
|
|
3122
|
+
const url = new URL(request.url);
|
|
3123
|
+
const filter = url.searchParams.get("filter") ?? "";
|
|
3124
|
+
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
3125
|
+
let rows: UserRow[];
|
|
3126
|
+
if (match?.[1]) {
|
|
3127
|
+
rows = await getSql().unsafe<UserRow[]>(
|
|
3128
|
+
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3129
|
+
[match[1].trim().toLowerCase()],
|
|
3130
|
+
);
|
|
3131
|
+
} else {
|
|
3132
|
+
rows = await getSql().unsafe<UserRow[]>("SELECT id, name, email FROM users");
|
|
3133
|
+
}
|
|
3134
|
+
const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
|
|
3135
|
+
const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
|
|
3136
|
+
const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
|
|
3137
|
+
return scimJson({
|
|
3138
|
+
schemas: [LIST_SCHEMA],
|
|
3139
|
+
totalResults: rows.length,
|
|
3140
|
+
startIndex,
|
|
3141
|
+
itemsPerPage: slice.length,
|
|
3142
|
+
Resources: slice.map(toScimUser),
|
|
3143
|
+
});
|
|
3144
|
+
}),
|
|
3145
|
+
),
|
|
3146
|
+
POST: kernel.wrap(
|
|
3147
|
+
"api",
|
|
3148
|
+
wrapScim(async (request) => {
|
|
3149
|
+
const body = (await request.json()) as Record<string, unknown>;
|
|
3150
|
+
const email = readUserName(body);
|
|
3151
|
+
const name = readName(body, email.split("@")[0] ?? "User");
|
|
3152
|
+
if (!email) {
|
|
3153
|
+
return scimError("userName is required.", 400);
|
|
3154
|
+
}
|
|
3155
|
+
const existing = await getSql().unsafe<UserRow[]>(
|
|
3156
|
+
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3157
|
+
[email],
|
|
3158
|
+
);
|
|
3159
|
+
if (existing[0]) {
|
|
3160
|
+
return scimError("User already exists.", 409);
|
|
3161
|
+
}
|
|
3162
|
+
const hashed = await hashPassword(randomBytes(18).toString("hex"));
|
|
3163
|
+
const tenantId = currentTenant()?.id ?? 1;
|
|
3164
|
+
await getSql().unsafe(
|
|
3165
|
+
"INSERT INTO users (${insertCols}) VALUES (${insertPh})",
|
|
3166
|
+
[name, email, hashed${insertTail}],
|
|
3167
|
+
);
|
|
3168
|
+
const created = await getSql().unsafe<UserRow[]>(
|
|
3169
|
+
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3170
|
+
[email],
|
|
3171
|
+
);
|
|
3172
|
+
const row = created[0];
|
|
3173
|
+
if (!row) {
|
|
3174
|
+
return scimError("Could not create user.", 500);
|
|
3175
|
+
}
|
|
3176
|
+
return scimJson(toScimUser(row), 201);
|
|
3177
|
+
}),
|
|
3178
|
+
),
|
|
3179
|
+
},
|
|
3180
|
+
"/scim/v2/Users/:id": {
|
|
3181
|
+
GET: kernel.wrap(
|
|
3182
|
+
"api",
|
|
3183
|
+
wrapScim(async (request) => {
|
|
3184
|
+
const id = Number.parseInt(routeParams(request).id ?? "", 10);
|
|
3185
|
+
const rows = await getSql().unsafe<UserRow[]>(
|
|
3186
|
+
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3187
|
+
[id],
|
|
3188
|
+
);
|
|
3189
|
+
const row = rows[0];
|
|
3190
|
+
if (!row) {
|
|
3191
|
+
return scimError("User not found.", 404);
|
|
3192
|
+
}
|
|
3193
|
+
return scimJson(toScimUser(row));
|
|
3194
|
+
}),
|
|
3195
|
+
),
|
|
3196
|
+
PUT: kernel.wrap(
|
|
3197
|
+
"api",
|
|
3198
|
+
wrapScim(async (request) => {
|
|
3199
|
+
const id = Number.parseInt(routeParams(request).id ?? "", 10);
|
|
3200
|
+
const body = (await request.json()) as Record<string, unknown>;
|
|
3201
|
+
const email = readUserName(body);
|
|
3202
|
+
const name = readName(body, email);
|
|
3203
|
+
if (!email || !name) {
|
|
3204
|
+
return scimError("userName and name are required.", 400);
|
|
3205
|
+
}
|
|
3206
|
+
await getSql().unsafe(
|
|
3207
|
+
"UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
|
|
3208
|
+
[name, email, id],
|
|
3209
|
+
);
|
|
3210
|
+
const rows = await getSql().unsafe<UserRow[]>(
|
|
3211
|
+
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3212
|
+
[id],
|
|
3213
|
+
);
|
|
3214
|
+
const row = rows[0];
|
|
3215
|
+
if (!row) {
|
|
3216
|
+
return scimError("User not found.", 404);
|
|
3217
|
+
}
|
|
3218
|
+
return scimJson(toScimUser(row));
|
|
3219
|
+
}),
|
|
3220
|
+
),
|
|
3221
|
+
PATCH: kernel.wrap(
|
|
3222
|
+
"api",
|
|
3223
|
+
wrapScim(async (request) => {
|
|
3224
|
+
const id = Number.parseInt(routeParams(request).id ?? "", 10);
|
|
3225
|
+
const existing = await getSql().unsafe<UserRow[]>(
|
|
3226
|
+
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3227
|
+
[id],
|
|
3228
|
+
);
|
|
3229
|
+
const row = existing[0];
|
|
3230
|
+
if (!row) {
|
|
3231
|
+
return scimError("User not found.", 404);
|
|
3232
|
+
}
|
|
3233
|
+
const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
|
|
3234
|
+
if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
|
|
3235
|
+
return scimError("Unsupported patch schema.", 400);
|
|
3236
|
+
}
|
|
3237
|
+
let name = row.name;
|
|
3238
|
+
let email = row.email;
|
|
3239
|
+
for (const operation of body.Operations ?? []) {
|
|
3240
|
+
const op = (operation.op ?? "replace").toLowerCase();
|
|
3241
|
+
if (op !== "replace" && op !== "add") {
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
const path = (operation.path ?? "").toLowerCase();
|
|
3245
|
+
if (path === "username" || path === "emails") {
|
|
3246
|
+
email = String(operation.value ?? email).trim().toLowerCase();
|
|
3247
|
+
} else if (path === "name.formatted" || path === "displayname" || path === "name") {
|
|
3248
|
+
if (typeof operation.value === "string") {
|
|
3249
|
+
name = operation.value.trim() || name;
|
|
3250
|
+
} else if (operation.value && typeof operation.value === "object") {
|
|
3251
|
+
name = readName({ name: operation.value as Record<string, unknown> }, name);
|
|
3252
|
+
}
|
|
3253
|
+
} else if (!path && operation.value && typeof operation.value === "object") {
|
|
3254
|
+
const value = operation.value as Record<string, unknown>;
|
|
3255
|
+
email = readUserName(value) || email;
|
|
3256
|
+
name = readName(value, name);
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
await getSql().unsafe(
|
|
3260
|
+
"UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
|
|
3261
|
+
[name, email, id],
|
|
3262
|
+
);
|
|
3263
|
+
return scimJson(toScimUser({ id: row.id, name, email }));
|
|
3264
|
+
}),
|
|
3265
|
+
),
|
|
3266
|
+
DELETE: kernel.wrap(
|
|
3267
|
+
"api",
|
|
3268
|
+
wrapScim(async (request) => {
|
|
3269
|
+
const id = Number.parseInt(routeParams(request).id ?? "", 10);
|
|
3270
|
+
await getSql().unsafe("DELETE FROM users WHERE id = ${idPh}", [id]);
|
|
3271
|
+
return new Response(null, { status: 204 });
|
|
3272
|
+
}),
|
|
3273
|
+
),
|
|
3274
|
+
},
|
|
3275
|
+
};
|
|
3276
|
+
},
|
|
3277
|
+
};
|
|
3278
|
+
|
|
3279
|
+
export default scimModule;
|
|
3280
|
+
`;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
2263
3283
|
// src/generate.ts
|
|
2264
3284
|
var PROJECT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/i;
|
|
2265
3285
|
function starterPackageRoot() {
|
|
@@ -2317,7 +3337,7 @@ function writeGeneratedFiles(options) {
|
|
|
2317
3337
|
removeIfExists(join2(targetDir, "docker-compose.yml"));
|
|
2318
3338
|
}
|
|
2319
3339
|
writeText(join2(src, "routes.ts"), renderRoutesTs());
|
|
2320
|
-
writeText(join2(src, "lib/view.ts"), renderViewTs());
|
|
3340
|
+
writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
|
|
2321
3341
|
writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
|
|
2322
3342
|
writeText(join2(src, "bootstrap/preload.ts"), renderPreloadTs(layers, projectName));
|
|
2323
3343
|
writeText(join2(src, "bootstrap/database.ts"), renderDatabaseTs(layers));
|
|
@@ -2350,10 +3370,32 @@ function writeGeneratedFiles(options) {
|
|
|
2350
3370
|
if (authModule) {
|
|
2351
3371
|
writeText(join2(src, "modules/auth/index.ts"), authModule);
|
|
2352
3372
|
}
|
|
3373
|
+
const scimModule = renderScimModule(layers);
|
|
3374
|
+
if (scimModule) {
|
|
3375
|
+
writeText(join2(src, "modules/scim/index.ts"), scimModule);
|
|
3376
|
+
} else {
|
|
3377
|
+
removeIfExists(join2(src, "modules/scim/index.ts"));
|
|
3378
|
+
}
|
|
3379
|
+
if (layers.extras.mfa && htmlAuthKit(layers.auth)) {
|
|
3380
|
+
writeText(join2(src, "bootstrap/pendingMfa.ts"), renderPendingMfaTs());
|
|
3381
|
+
} else {
|
|
3382
|
+
removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
|
|
3383
|
+
}
|
|
3384
|
+
writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
|
|
2353
3385
|
writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
|
|
2354
3386
|
writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
|
|
2355
|
-
if (
|
|
3387
|
+
if (htmlAuthKit(layers.auth)) {
|
|
2356
3388
|
writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView());
|
|
3389
|
+
writeText(join2(targetDir, "views/auth/register.eta"), renderRegisterView());
|
|
3390
|
+
writeText(join2(targetDir, "views/auth/forgot-password.eta"), renderForgotPasswordView());
|
|
3391
|
+
writeText(join2(targetDir, "views/auth/reset-password.eta"), renderResetPasswordView());
|
|
3392
|
+
if (layers.extras.emailVerification) {
|
|
3393
|
+
writeText(join2(targetDir, "views/auth/verify-email.eta"), renderVerifyEmailView());
|
|
3394
|
+
}
|
|
3395
|
+
if (layers.extras.mfa) {
|
|
3396
|
+
writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
|
|
3397
|
+
writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
|
|
3398
|
+
}
|
|
2357
3399
|
}
|
|
2358
3400
|
mkdirSync2(join2(targetDir, "storage"), { recursive: true });
|
|
2359
3401
|
writeText(join2(targetDir, "storage/.gitkeep"), "");
|