@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/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 = "none";
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
- if (layers.database === "postgres") {
544
- layers.tenancy = await prompter.select("Tenancy", [
545
- { value: "none", label: "none: no tenant table" },
546
- { value: "rls", label: "rls: Postgres row-level security plus a tenant table" }
547
- ], layers.tenancy);
548
- } else {
549
- layers.tenancy = "none";
550
- }
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("Staff MFA env flag?", layers.extras.mfa);
569
- layers.extras.emailVerification = await prompter.confirm("Email verification env flag?", layers.extras.emailVerification);
570
- layers.extras.scim = await prompter.confirm("SCIM env stubs?", layers.extras.scim);
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/renderAuth.ts
625
- function renderAuthDirectory(layers) {
626
- if (!authNeedsUsers(layers.auth)) {
627
- return null;
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
- const tokenLookup = authUsesToken(layers.auth) ? `
630
- async resolveUserFromToken(token: string) {
631
- if (!token || token.split(".").length === 3) {
632
- return null;
633
- }
634
- const hashed = hashApiToken(token);
635
- const rows = await getSql().unsafe<
636
- Array<{
637
- id: number;
638
- user_id: number;
639
- abilities: string;
640
- expires_at: Date | string | null;
641
- role?: string;
642
- is_admin?: number | boolean;
643
- email_verified_at?: Date | string | null;
644
- }>
645
- >(
646
- \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
647
- FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
648
- WHERE t.token_hash = ?\`,
649
- [hashed],
650
- );
651
- const row = rows[0];
652
- if (!row) {
653
- return null;
654
- }
655
- if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
656
- return null;
657
- }
658
- let abilities: string[] = [];
659
- try {
660
- abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
661
- } catch {
662
- abilities = ["profile:read"];
663
- }
664
- return {
665
- id: Number(row.user_id),
666
- role: row.is_admin ? "admin" : "member",
667
- abilities,
668
- tokenId: Number(row.id),
669
- emailVerifiedAt: row.email_verified_at ?? null,
670
- };
671
- },` : `
672
- async resolveUserFromToken() {
673
- return null;
674
- },`;
675
- const placeholder = layers.database === "postgres" ? "$1" : "?";
676
- const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
677
- ` : "";
678
- return `import type { AuthUser } from "@getstrata/core/auth/authContext";
679
- import { verifyPassword } from "@getstrata/core/auth/password";
680
- ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
681
- import { getSql } from "./database.ts";
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
- function mapRole(isAdmin: unknown): string {
684
- return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
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
- export const starterAuthDirectory: AuthUserDirectory = {
688
- ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
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
- async findByIdOrThrow(id: number) {
691
- const rows = await getSql().unsafe<
692
- Array<{
693
- id: number;
694
- email: string;
695
- is_admin: number | boolean;
696
- email_verified_at: Date | string | null;
697
- password: string;
698
- }>
699
- >(\`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE id = ${placeholder}\`, [id]);
700
- const row = rows[0];
701
- if (!row) {
702
- throw new Error(\`User \${id} not found.\`);
703
- }
704
- return {
705
- id: Number(row.id),
706
- email: row.email,
707
- role: mapRole(row.is_admin),
708
- email_verified_at: row.email_verified_at ?? null,
709
- password: row.password,
710
- };
711
- },
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
- async findByEmail(email: string) {
714
- const rows = await getSql().unsafe<
715
- Array<{
716
- id: number;
717
- email: string;
718
- is_admin: number | boolean;
719
- email_verified_at: Date | string | null;
720
- password: string;
721
- }>
722
- >(
723
- \`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE email = ${placeholder}\`,
724
- [email.trim().toLowerCase()],
725
- );
726
- const row = rows[0];
727
- if (!row) {
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
- async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
740
- const user = await this.findByEmail(email);
741
- if (!user?.password || !(await verifyPassword(password, user.password))) {
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
- return {
745
- id: user.id,
746
- role: user.role,
747
- emailVerifiedAt: user.email_verified_at ?? null,
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
- if (request) {
766
- const userId = request.headers.get("x-authenticated-user-id");
767
- if (!userId) {
768
- return null;
769
- }
770
- const role = request.headers.get("x-authenticated-user-role");
771
- return {
772
- id: userId,
773
- ...(role ? { role } : {}),
774
- };
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 currentAuthUser();
777
- }
778
-
779
- user(request?: Request) {
780
- return this.resolve(request);
781
- }
782
-
783
- async check(request: Request) {
784
- return (await this.user(request)) !== null;
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 cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
799
- secret: process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch",
800
- cookieName: "strata_session",
801
- mapUser: (user) => ({
802
- id: user.id,
803
- role: user.is_admin ? "admin" : "member",
804
- }),
805
- });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
806
- const auth = new AuthManager(fallback);`;
807
- const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
808
- auth.registerGuard("api", apiGuard);
809
- auth.registerGuard("access_token", apiGuard);
810
- auth.registerGuard("token", apiGuard);` : "";
811
- const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
812
- const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
813
- const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
814
- const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
815
- if (authUsesCookie(layers.auth)) {
816
- imports.push(`import { createCookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
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
- if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
819
- imports.push(`import { BasicAuthGuard } from "@getstrata/core/auth/basicAuthGuard";`);
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 (!authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
822
- imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
823
- } else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
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 { JwtGuard } from "@getstrata/core/auth/jwtGuard";`);
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 { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
740
+ imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
833
741
  }
834
- imports.push(`import type { ServiceProvider } from "@getstrata/core/contracts/di";`);
835
- const tokenImports = ["CORE_AUTH_USER_DIRECTORY_TOKEN"];
836
- if (authUsesToken(layers.auth)) {
837
- tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
742
+ if (mfa) {
743
+ imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
838
744
  }
839
- imports.push(`import {
840
- ${tokenImports.join(`,
841
- `)},
842
- } from "@getstrata/core/contracts/serviceTokens";`);
843
- imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
844
- return `${imports.join(`
845
- `)}
846
-
847
- const authProvider: ServiceProvider = {
848
- name: "starter.auth",
849
- register({ container }) {
850
- container.set(CORE_AUTH_USER_DIRECTORY_TOKEN, starterAuthDirectory);
851
- ${cookieBlock}
852
- ${tokenRegs}
853
- ${jwtReg}
854
- ${basicReg}
855
- ${ability}
856
- container.set(CORE_AUTH_TOKEN, auth);
857
- },
858
- };
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
- export default authProvider;
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
- function renderAuthModule(layers) {
864
- if (!authNeedsUsers(layers.auth)) {
865
- return null;
866
- }
867
- const cookieRoutes = authUsesCookie(layers.auth) ? `
868
- webRoutes({ kernel, dependencies }) {
869
- const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
870
- return {
871
- "/login": {
872
- GET: kernel.wrapWebGuest(async (request) =>
873
- renderPage(
874
- "auth/login.eta",
875
- { layout: { title: "Sign in" }, errors: {}, email: "" },
876
- request,
877
- ),
878
- ),
879
- POST: wrapWebLogin(
880
- kernel,
881
- async (request) => {
882
- const { fields } = await parseFormBody(request);
883
- const email = (fields.email ?? "").trim().toLowerCase();
884
- const password = fields.password ?? "";
885
- const user = await starterAuthDirectory.findByEmail?.(email);
886
- if (!user?.password || !(await verifyPassword(password, user.password))) {
887
- return renderPage(
888
- "auth/login.eta",
889
- {
890
- layout: { title: "Sign in" },
891
- errors: { email: "These credentials do not match our records." },
892
- email,
893
- },
894
- request,
895
- );
896
- }
897
- return auth.signInRedirect(
898
- {
899
- id: user.id,
900
- name: user.email ?? "",
901
- email: user.email ?? "",
902
- is_admin: user.role === "admin",
903
- },
904
- "/",
905
- );
906
- },
907
- async () => new Response("Too many login attempts", { status: 429 }),
908
- ),
909
- },
910
- "/logout": {
911
- POST: kernel.wrapWebAuthenticatedAllowUnverified((request) =>
912
- auth.signOutRedirect(request, "/login"),
913
- ),
914
- },
915
- };
916
- },` : "";
917
- const apiLogin = authUsesToken(layers.auth) ? `
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.database === "postgres" ? "$1, $2, $3, $4" : "?, ?, ?, ?"})",
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 = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? `
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 routesBlock = apiLogin || jwtLogin || apiUser ? `
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 {${apiLogin}${jwtLogin}${apiUser}
917
+ return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
979
918
  };
980
919
  },` : "";
981
- const imports = [];
982
- if (authUsesToken(layers.auth)) {
983
- imports.push(`import { randomBytes } from "node:crypto";`);
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
- imports.push(`import type { AppModule } from "@getstrata/bootstrap/contracts";`);
986
- imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
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 { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
989
- imports.push(`import { wrapWebLogin } from "@getstrata/bootstrap/web/routing";`);
990
- imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
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 { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
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 { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
1003
- }
1004
- if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
1005
- imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
1759
+ imports.push(`import { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
1006
1760
  }
1007
- imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
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
- imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
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 authModule: AppModule = {
1018
- name: "auth",
1019
- order: 2,${routesBlock}${cookieRoutes}
1020
- };
1021
-
1022
- export default authModule;
1023
- `;
1024
- }
1025
- function renderSiteModule(layers) {
1026
- const loginHint = authUsesCookie(layers.auth) && (layers.frontend === "server-htmx" || layers.frontend === "hybrid") ? " Sign in at /login." : "";
1027
- return `import type { AppModule } from "@getstrata/bootstrap/contracts";
1028
- import { withErrorHandling } from "@getstrata/core/http/response";
1029
- import { pingDatabase } from "../../bootstrap/database.ts";
1030
- import { plainText, renderPage } from "../../lib/view.ts";
1031
-
1032
- const siteModule: AppModule = {
1033
- name: "site",
1034
- order: 1,
1035
- routes({ kernel }) {
1036
- return {
1037
- "/health": kernel.wrap("api", withErrorHandling(async () => {
1038
- const dbOk = await pingDatabase();
1039
- return plainText(dbOk ? "ok" : "degraded");
1040
- })),
1041
- };
1042
- },
1043
- webRoutes({ kernel }) {
1044
- return {
1045
- "/": kernel.wrapWeb(async (request) =>
1046
- renderPage(
1047
- "home.eta",
1048
- {
1049
- layout: {
1050
- title: "Home",
1051
- description: "A new Strata application.${loginHint}",
1052
- },
1053
- },
1054
- request,
1055
- ),
1056
- ),
1057
- };
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 siteModule;
1062
- `;
1063
- }
1064
- function renderLoginView() {
1065
- return `<section class="section">
1066
- <h1>Sign in</h1>
1067
- <p>Seeded accounts use password <code>password</code>.</p>
1068
- <% if (it.errors && it.errors.email) { %>
1069
- <p class="error"><%= it.errors.email %></p>
1070
- <% } %>
1071
- <form method="post" action="/login">
1072
- <input type="hidden" name="_token" value="<%= it.csrfToken %>" />
1073
- <label>
1074
- Email
1075
- <input type="email" name="email" value="<%= it.email || "demo@example.com" %>" required />
1076
- </label>
1077
- <label>
1078
- Password
1079
- <input type="password" name="password" value="password" required />
1080
- </label>
1081
- <button type="submit">Sign in</button>
1082
- </form>
1083
- </section>
1084
- `;
1085
- }
1086
- function renderLayout(layers, projectName) {
1087
- const cookie = authUsesCookie(layers.auth);
1088
- return `<!DOCTYPE html>
1089
- <html lang="en">
1090
- <head>
1091
- <meta charset="utf-8" />
1092
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1093
- <title><%= it.layout.title %> \xB7 ${projectName}</title>
1094
- <% if (it.layout.description) { %>
1095
- <meta name="description" content="<%= it.layout.description %>" />
1096
- <% } %>
1097
- <link rel="stylesheet" href="/assets/site.css" />
1098
- </head>
1099
- <body>
1100
- <header class="site-header">
1101
- <a class="brand" href="/">${projectName}</a>
1102
- <nav>
1103
- ${cookie ? '<a href="/login">Sign in</a>' : ""}
1104
- </nav>
1105
- </header>
1106
- <main><%~ it.body %></main>
1107
- </body>
1108
- </html>
1109
- `;
1110
- }
1111
- function renderHomeView(projectName, layers) {
1112
- const loginLine = authUsesCookie(layers.auth) ? '<p>HTML sign-in: <a href="/login">/login</a> (demo@example.com / password).</p>' : "";
1113
- return `<section class="section">
1114
- <h1>Welcome to ${projectName}</h1>
1115
- <p>Frontend <code>${layers.frontend}</code>, database <code>${layers.database}</code>, auth <code>${layers.auth}</code>.</p>
1116
- <p>Health check: <a href="/health"><code>/health</code></a>.</p>
1117
- ${loginLine}
1118
- </section>
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.2",
1961
+ "@getstrata/bootstrap": "^0.4.3",
1292
1962
  "@getstrata/cli": "^0.2.0",
1293
- "@getstrata/core": "^0.7.4"
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 sign-in lives at \`/login\` (cookie session + CSRF when \`FRONTEND_MODE\` is \`server-htmx\` or \`hybrid\`).
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
- if (layers.tenancy === "rls") {
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 = layers.tenancy === "rls" ? `
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 ph = layers.database === "postgres";
1705
- const notePlaceholder = ph ? "$1" : "?";
1706
- const userPlaceholders = ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
1707
- const adminFlag = ph ? "false, " : "0, ";
1708
- const adminTrue = ph ? "true" : "1";
1709
- const seedTenant = layers.tenancy === "rls" ? `
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 (${ph ? "$1, $2, $3" : "?, ?, ?"})",
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 (name, email, password, is_admin) VALUES (${userPlaceholders})",
1727
- ["Demo User", "demo@example.com", password, ${adminFlag}"Admin User", "admin@example.test", password, ${adminTrue}],
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 === "rls") {
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, seed } from "./migrate.ts";
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
- return `import { join } from "node:path";
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 html = await engine.render(template, { ...data, csrfToken });
2254
- return htmlResponse(html);
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 (authUsesCookie(layers.auth)) {
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"), "");