@tulipes/core 0.1.14 → 0.2.0

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/init.js CHANGED
@@ -320,6 +320,12 @@ function renderProject(name, coreVersion) {
320
320
  `# script refuses until this is a real domain.`,
321
321
  `PUBLIC_DOMAIN=localhost`,
322
322
  ``,
323
+ `# Auth. Separate keys, so leaking one cannot mint the other.`,
324
+ `JWT_ACCESS_SECRET=dev-only-access-secret-change-me`,
325
+ `JWT_REFRESH_SECRET=dev-only-refresh-secret-change-me`,
326
+ `AUTH_ADMIN_EMAIL=admin@example.com`,
327
+ `AUTH_ADMIN_PASSWORD=dev-admin-password`,
328
+ ``,
323
329
  ].join("\n")],
324
330
  [".envs/.env.staging", [
325
331
  `# Staging. Committed like the development file: it holds structure and`,
@@ -341,6 +347,10 @@ function renderProject(name, coreVersion) {
341
347
  ``,
342
348
  `# MONGO_URI=`,
343
349
  `# REDIS_URL=`,
350
+ `# JWT_ACCESS_SECRET=`,
351
+ `# JWT_REFRESH_SECRET=`,
352
+ `# AUTH_ADMIN_EMAIL=`,
353
+ `# AUTH_ADMIN_PASSWORD=`,
344
354
  ``,
345
355
  ].join("\n")],
346
356
  [".envs/.env.production", [
@@ -363,6 +373,10 @@ function renderProject(name, coreVersion) {
363
373
  ``,
364
374
  `# MONGO_URI=`,
365
375
  `# REDIS_URL=`,
376
+ `# JWT_ACCESS_SECRET=`,
377
+ `# JWT_REFRESH_SECRET=`,
378
+ `# AUTH_ADMIN_EMAIL=`,
379
+ `# AUTH_ADMIN_PASSWORD=`,
366
380
  ``,
367
381
  ].join("\n")],
368
382
  ["nginx/development.conf", [
@@ -697,6 +711,901 @@ function renderProject(name, coreVersion) {
697
711
  `}`,
698
712
  ``,
699
713
  ].join("\n")],
714
+ ["modules/auth/meta.variables.json", [
715
+ `{`,
716
+ ` "variables": [`,
717
+ ` {`,
718
+ ` "name": "JWT_ACCESS_SECRET",`,
719
+ ` "type": "secret",`,
720
+ ` "group": "auth",`,
721
+ ` "required": true,`,
722
+ ` "description": "Signing key for short-lived access tokens"`,
723
+ ` },`,
724
+ ` {`,
725
+ ` "name": "JWT_REFRESH_SECRET",`,
726
+ ` "type": "secret",`,
727
+ ` "group": "auth",`,
728
+ ` "required": true,`,
729
+ ` "description": "Signing key for refresh tokens; separate from the access key so leaking one cannot mint the other"`,
730
+ ` },`,
731
+ ` {`,
732
+ ` "name": "JWT_ACCESS_TTL",`,
733
+ ` "type": "string",`,
734
+ ` "group": "auth",`,
735
+ ` "description": "Access token lifetime, as an ms/jsonwebtoken duration. Keep it short: a revoked user keeps their role until it expires",`,
736
+ ` "default": "15m"`,
737
+ ` },`,
738
+ ` {`,
739
+ ` "name": "JWT_REFRESH_TTL",`,
740
+ ` "type": "string",`,
741
+ ` "group": "auth",`,
742
+ ` "description": "Refresh token lifetime; also the TTL on its database record",`,
743
+ ` "default": "30d"`,
744
+ ` },`,
745
+ ` {`,
746
+ ` "name": "AUTH_ADMIN_EMAIL",`,
747
+ ` "type": "string",`,
748
+ ` "group": "auth",`,
749
+ ` "required": true,`,
750
+ ` "description": "Email of the admin account seeded on first boot"`,
751
+ ` },`,
752
+ ` {`,
753
+ ` "name": "AUTH_ADMIN_PASSWORD",`,
754
+ ` "type": "secret",`,
755
+ ` "group": "auth",`,
756
+ ` "required": true,`,
757
+ ` "description": "Password for the seeded admin account; change it after the first login"`,
758
+ ` }`,
759
+ ` ]`,
760
+ `}`,
761
+ ``,
762
+ ].join("\n")],
763
+ ["modules/auth/module.acl.ts", [
764
+ `import type { AclBuilder } from "@tulipes/core/acl";`,
765
+ ``,
766
+ `/**`,
767
+ ` * The auth module owns the role vocabulary, because roles only mean`,
768
+ ` * anything alongside the thing that assigns them.`,
769
+ ` *`,
770
+ ` * \`guest\` is the fallback for a request with no valid token. That is what`,
771
+ ` * makes "public endpoint" an ordinary ACL grant rather than a second`,
772
+ ` * mechanism: login is public because guest may do it, not because a path`,
773
+ ` * appears on an allowlist somewhere else.`,
774
+ ` */`,
775
+ `export default function authAcl(acl: AclBuilder): void {`,
776
+ ` acl.defineRole("guest").defineRole("user").defineRole("admin");`,
777
+ ``,
778
+ ` // Anyone may attempt to authenticate. Refresh is public because the`,
779
+ ` // refresh token itself is the credential — an expired access token must`,
780
+ ` // not stand in the way of renewing it.`,
781
+ ` acl.allow("guest", "auth:login", "auth:register", "auth:refresh");`,
782
+ ``,
783
+ ` // Signed in: end your session, read yourself, change your own password.`,
784
+ ` acl.allow("user", "auth:logout", "auth:me", "auth:password");`,
785
+ ` acl.allow("admin", "*");`,
786
+ `}`,
787
+ ``,
788
+ ].join("\n")],
789
+ ["modules/auth/module.config.ts", [
790
+ `import type { Ctx } from "@tulipes/core/boot";`,
791
+ ``,
792
+ `export default function authConfig({ Environment }: Ctx) {`,
793
+ ` return {`,
794
+ ` accessTtl: Environment.get("JWT_ACCESS_TTL"),`,
795
+ ` refreshTtl: Environment.get("JWT_REFRESH_TTL"),`,
796
+ ` };`,
797
+ `}`,
798
+ ``,
799
+ ].join("\n")],
800
+ ["modules/auth/index.ts", [
801
+ `/**`,
802
+ ` * What the auth module offers other modules. Imported by package name:`,
803
+ ` *`,
804
+ ` * import { requireAuth, requirePermission } from "@app/auth";`,
805
+ ` */`,
806
+ `export { requireAuth, requirePermission } from "./helpers/guards.js";`,
807
+ `export { principal, GUEST, type Principal } from "./helpers/principal.js";`,
808
+ `export { hashPassword, setPassword, verifyPassword } from "./helpers/passwords.js";`,
809
+ ``,
810
+ ].join("\n")],
811
+ ["modules/auth/helpers/documents.ts", [
812
+ `import type { Types } from "mongoose";`,
813
+ ``,
814
+ `/**`,
815
+ ` * The shapes auth reads through the model store.`,
816
+ ` *`,
817
+ ` * \`models.get()\` is generic precisely so a consumer can say what it`,
818
+ ` * expects — the store cannot know, since models are registered by name`,
819
+ ` * from other modules. Declaring them here keeps the casts in one file`,
820
+ ` * instead of scattered through the handlers.`,
821
+ ` */`,
822
+ `export interface UserDoc {`,
823
+ ` _id: Types.ObjectId;`,
824
+ ` email: string;`,
825
+ ` role: string;`,
826
+ ` tokensValidFrom?: Date;`,
827
+ `}`,
828
+ ``,
829
+ `export interface CredentialDoc {`,
830
+ ` _id: Types.ObjectId;`,
831
+ ` user: Types.ObjectId;`,
832
+ ` passwordHash: string;`,
833
+ `}`,
834
+ ``,
835
+ ].join("\n")],
836
+ ["modules/auth/helpers/principal.ts", [
837
+ `import type { Request } from "express";`,
838
+ ``,
839
+ `/**`,
840
+ ` * Who is making this request.`,
841
+ ` *`,
842
+ ` * There is always one — a request with no valid token is the \`guest\``,
843
+ ` * principal rather than an absent user. That is what lets a public`,
844
+ ` * endpoint be an ordinary ACL grant (\`allow("guest", "auth:login")\`)`,
845
+ ` * instead of a second, parallel allowlist mechanism.`,
846
+ ` */`,
847
+ `export interface Principal {`,
848
+ ` id?: string;`,
849
+ ` email?: string;`,
850
+ ` role: string;`,
851
+ ` /** jti of the access token, so logout can revoke this exact session. */`,
852
+ ` jti?: string;`,
853
+ ` authenticated: boolean;`,
854
+ `}`,
855
+ ``,
856
+ `export const GUEST: Principal = { role: "guest", authenticated: false };`,
857
+ ``,
858
+ `declare global {`,
859
+ ` // eslint-disable-next-line @typescript-eslint/no-namespace`,
860
+ ` namespace Express {`,
861
+ ` interface Request {`,
862
+ ` principal: Principal;`,
863
+ ` }`,
864
+ ` }`,
865
+ `}`,
866
+ ``,
867
+ `/** Never undefined: middleware assigns GUEST before any route runs. */`,
868
+ `export function principal(req: Request): Principal {`,
869
+ ` return req.principal ?? GUEST;`,
870
+ `}`,
871
+ ``,
872
+ ].join("\n")],
873
+ ["modules/auth/helpers/tokens.ts", [
874
+ `import { randomUUID } from "node:crypto";`,
875
+ `import jwt from "jsonwebtoken";`,
876
+ `import type { Ctx } from "@tulipes/core/boot";`,
877
+ ``,
878
+ `/** What a verified token tells us. Roles are never trusted from here. */`,
879
+ `export interface TokenClaims {`,
880
+ ` sub: string;`,
881
+ ` jti: string;`,
882
+ ` typ: "access" | "refresh";`,
883
+ ` iat: number;`,
884
+ ` exp: number;`,
885
+ `}`,
886
+ ``,
887
+ `export interface TokenPair {`,
888
+ ` accessToken: string;`,
889
+ ` refreshToken: string;`,
890
+ ` expiresIn: string;`,
891
+ `}`,
892
+ ``,
893
+ `const secret = (ctx: Ctx, typ: "access" | "refresh"): string =>`,
894
+ ` String(ctx.Environment.get(typ === "access" ? "JWT_ACCESS_SECRET" : "JWT_REFRESH_SECRET"));`,
895
+ ``,
896
+ `const ttl = (ctx: Ctx, typ: "access" | "refresh"): string =>`,
897
+ ` String(ctx.Environment.get(typ === "access" ? "JWT_ACCESS_TTL" : "JWT_REFRESH_TTL"));`,
898
+ ``,
899
+ `/**`,
900
+ ` * Issue a pair and record the refresh token so it can be revoked.`,
901
+ ` *`,
902
+ ` * The access token deliberately carries no role. Roles change, tokens do`,
903
+ ` * not, and a stale role in a signed token is a privilege bug waiting to`,
904
+ ` * happen — the request pipeline reloads the user instead.`,
905
+ ` */`,
906
+ `export async function issuePair(ctx: Ctx, userId: string): Promise<TokenPair> {`,
907
+ ` const accessJti = randomUUID();`,
908
+ ` const refreshJti = randomUUID();`,
909
+ ``,
910
+ ` const accessToken = jwt.sign({ typ: "access" }, secret(ctx, "access"), {`,
911
+ ` subject: userId,`,
912
+ ` jwtid: accessJti,`,
913
+ ` expiresIn: ttl(ctx, "access") as jwt.SignOptions["expiresIn"],`,
914
+ ` });`,
915
+ ``,
916
+ ` const refreshToken = jwt.sign({ typ: "refresh" }, secret(ctx, "refresh"), {`,
917
+ ` subject: userId,`,
918
+ ` jwtid: refreshJti,`,
919
+ ` expiresIn: ttl(ctx, "refresh") as jwt.SignOptions["expiresIn"],`,
920
+ ` });`,
921
+ ``,
922
+ ` const { exp } = jwt.decode(refreshToken) as { exp: number };`,
923
+ ` await ctx.models!.get("RefreshToken").create({`,
924
+ ` jti: refreshJti,`,
925
+ ` user: userId,`,
926
+ ` expiresAt: new Date(exp * 1000),`,
927
+ ` });`,
928
+ ``,
929
+ ` return { accessToken, refreshToken, expiresIn: ttl(ctx, "access") };`,
930
+ `}`,
931
+ ``,
932
+ `/** Verify signature and type. Returns undefined rather than throwing. */`,
933
+ `export function verify(`,
934
+ ` ctx: Ctx,`,
935
+ ` token: string,`,
936
+ ` typ: "access" | "refresh",`,
937
+ `): TokenClaims | undefined {`,
938
+ ` try {`,
939
+ ` const claims = jwt.verify(token, secret(ctx, typ)) as TokenClaims;`,
940
+ ` return claims.typ === typ ? claims : undefined;`,
941
+ ` } catch {`,
942
+ ` return undefined;`,
943
+ ` }`,
944
+ `}`,
945
+ ``,
946
+ `/** Consume a refresh token: valid only if its row still exists. */`,
947
+ `export async function consumeRefresh(ctx: Ctx, jti: string): Promise<boolean> {`,
948
+ ` const result = await ctx.models!.get("RefreshToken").deleteOne({ jti });`,
949
+ ` return result.deletedCount === 1;`,
950
+ `}`,
951
+ ``,
952
+ `/** Blacklist an access token for whatever is left of its lifetime. */`,
953
+ `export async function revokeAccess(ctx: Ctx, claims: TokenClaims): Promise<void> {`,
954
+ ` await ctx.models!.get("RevokedToken").updateOne(`,
955
+ ` { jti: claims.jti },`,
956
+ ` { $setOnInsert: { jti: claims.jti, expiresAt: new Date(claims.exp * 1000) } },`,
957
+ ` { upsert: true },`,
958
+ ` );`,
959
+ `}`,
960
+ ``,
961
+ `export async function isRevoked(ctx: Ctx, jti: string): Promise<boolean> {`,
962
+ ` return (await ctx.models!.get("RevokedToken").countDocuments({ jti }).limit(1)) > 0;`,
963
+ `}`,
964
+ ``,
965
+ `/** Drop every refresh token a user holds — used after a password change. */`,
966
+ `export async function revokeAllForUser(ctx: Ctx, userId: string): Promise<void> {`,
967
+ ` await ctx.models!.get("RefreshToken").deleteMany({ user: userId });`,
968
+ `}`,
969
+ ``,
970
+ ].join("\n")],
971
+ ["modules/auth/helpers/passwords.ts", [
972
+ `import bcrypt from "bcryptjs";`,
973
+ `import type { Ctx } from "@tulipes/core/boot";`,
974
+ ``,
975
+ `/**`,
976
+ ` * bcryptjs — pure JavaScript, so installs never need a native toolchain.`,
977
+ ` * The cost factor comes from config rather than a literal: raising it is`,
978
+ ` * how you keep pace with hardware, and it should not require a code change.`,
979
+ ` */`,
980
+ `const rounds = (ctx: Ctx): number => Number(ctx.config.security?.passwordRounds ?? 12);`,
981
+ ``,
982
+ `export async function hashPassword(ctx: Ctx, plain: string): Promise<string> {`,
983
+ ` return bcrypt.hash(plain, rounds(ctx));`,
984
+ `}`,
985
+ ``,
986
+ `export async function verifyPassword(plain: string, hash: string): Promise<boolean> {`,
987
+ ` return bcrypt.compare(plain, hash);`,
988
+ `}`,
989
+ ``,
990
+ `/**`,
991
+ ` * Set or replace a user's password and invalidate every session they have.`,
992
+ ` *`,
993
+ ` * Revoking here is the whole point of changing a password after a suspected`,
994
+ ` * compromise: leaving other devices signed in would defeat it.`,
995
+ ` */`,
996
+ `export async function setPassword(ctx: Ctx, userId: string, plain: string): Promise<void> {`,
997
+ ` const passwordHash = await hashPassword(ctx, plain);`,
998
+ ``,
999
+ ` await ctx.models!.get("Credential").updateOne(`,
1000
+ ` { user: userId },`,
1001
+ ` { $set: { passwordHash } },`,
1002
+ ` { upsert: true },`,
1003
+ ` );`,
1004
+ ``,
1005
+ ` // Bumps the epoch that the request pipeline compares every access token`,
1006
+ ` // against, so tokens issued before this moment stop being accepted.`,
1007
+ ` await ctx.models!.get("User").updateOne(`,
1008
+ ` { _id: userId },`,
1009
+ ` { $set: { tokensValidFrom: new Date() } },`,
1010
+ ` );`,
1011
+ ``,
1012
+ ` const { revokeAllForUser } = await import("./tokens.js");`,
1013
+ ` await revokeAllForUser(ctx, userId);`,
1014
+ `}`,
1015
+ ``,
1016
+ ].join("\n")],
1017
+ ["modules/auth/helpers/passport.ts", [
1018
+ `import passport from "passport";`,
1019
+ `import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";`,
1020
+ `import { Strategy as LocalStrategy } from "passport-local";`,
1021
+ `import type { Ctx } from "@tulipes/core/boot";`,
1022
+ ``,
1023
+ `import { verifyPassword } from "./passwords.js";`,
1024
+ `import { isRevoked, type TokenClaims } from "./tokens.js";`,
1025
+ `import { GUEST, type Principal } from "./principal.js";`,
1026
+ `import type { CredentialDoc, UserDoc } from "./documents.js";`,
1027
+ ``,
1028
+ `/**`,
1029
+ ` * Two strategies, both stateless (\`session: false\`):`,
1030
+ ` *`,
1031
+ ` * local runs once, at login, to check an email and password`,
1032
+ ` * jwt runs on every request, to turn a Bearer token into a principal`,
1033
+ ` *`,
1034
+ ` * The jwt strategy reloads the user on each request rather than trusting`,
1035
+ ` * the token's claims. It costs a query, and it is what makes a demotion or`,
1036
+ ` * a ban take effect immediately instead of whenever the token expires.`,
1037
+ ` */`,
1038
+ `export function configurePassport(ctx: Ctx): void {`,
1039
+ ` passport.use(`,
1040
+ ` new LocalStrategy(`,
1041
+ ` { usernameField: "email", passwordField: "password", session: false },`,
1042
+ ` async (email, password, done) => {`,
1043
+ ` try {`,
1044
+ ` const user = await ctx.models!`,
1045
+ ` .get<UserDoc>("User")`,
1046
+ ` .findOne({ email: String(email).toLowerCase() })`,
1047
+ ` .lean();`,
1048
+ ` if (!user) return done(null, false);`,
1049
+ ``,
1050
+ ` // The hash is select:false, so it has to be asked for by name.`,
1051
+ ` const credential = await ctx.models!`,
1052
+ ` .get<CredentialDoc>("Credential")`,
1053
+ ` .findOne({ user: user._id })`,
1054
+ ` .select("+passwordHash")`,
1055
+ ` .lean();`,
1056
+ ` if (!credential) return done(null, false);`,
1057
+ ``,
1058
+ ` const ok = await verifyPassword(password, credential.passwordHash);`,
1059
+ ` return done(null, ok ? (user as Express.User) : false);`,
1060
+ ` } catch (error) {`,
1061
+ ` return done(error as Error);`,
1062
+ ` }`,
1063
+ ` },`,
1064
+ ` ),`,
1065
+ ` );`,
1066
+ ``,
1067
+ ` passport.use(`,
1068
+ ` new JwtStrategy(`,
1069
+ ` {`,
1070
+ ` jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),`,
1071
+ ` secretOrKey: String(ctx.Environment.get("JWT_ACCESS_SECRET")),`,
1072
+ ` passReqToCallback: false,`,
1073
+ ` },`,
1074
+ ` async (claims: TokenClaims, done) => {`,
1075
+ ` try {`,
1076
+ ` if (claims.typ !== "access") return done(null, false);`,
1077
+ ``,
1078
+ ` // Logout blacklists the jti; without this check a "logged out"`,
1079
+ ` // token keeps working until it expires.`,
1080
+ ` if (await isRevoked(ctx, claims.jti)) return done(null, false);`,
1081
+ ``,
1082
+ ` const record = await ctx.models!.get<UserDoc>("User").findById(claims.sub).lean();`,
1083
+ ` if (!record) return done(null, false);`,
1084
+ ``,
1085
+ ` // A password change moves this epoch forward, which invalidates`,
1086
+ ` // every token issued before it — on every device at once.`,
1087
+ ` //`,
1088
+ ` // Compared at second precision, because \`iat\` only has seconds:`,
1089
+ ` // a token minted milliseconds *after* the epoch would otherwise`,
1090
+ ` // look older than it and reject itself the moment it was issued.`,
1091
+ ` if (record.tokensValidFrom) {`,
1092
+ ` const epochSeconds = Math.floor(record.tokensValidFrom.getTime() / 1000);`,
1093
+ ` if (claims.iat < epochSeconds) return done(null, false);`,
1094
+ ` }`,
1095
+ ``,
1096
+ ` const resolved: Principal = {`,
1097
+ ` id: String(record._id),`,
1098
+ ` email: record.email,`,
1099
+ ` role: record.role,`,
1100
+ ` jti: claims.jti,`,
1101
+ ` authenticated: true,`,
1102
+ ` };`,
1103
+ ` return done(null, resolved as Express.User);`,
1104
+ ` } catch (error) {`,
1105
+ ` return done(error as Error);`,
1106
+ ` }`,
1107
+ ` },`,
1108
+ ` ),`,
1109
+ ` );`,
1110
+ `}`,
1111
+ ``,
1112
+ `/**`,
1113
+ ` * Runs on every request. Populates \`req.principal\`, and never rejects:`,
1114
+ ` * an unauthenticated caller becomes GUEST, and the ACL decides from there.`,
1115
+ ` */`,
1116
+ `export function authenticate(): import("express").RequestHandler {`,
1117
+ ` return (req, res, next) => {`,
1118
+ ` passport.authenticate("jwt", { session: false }, (_err: unknown, user: Principal | false) => {`,
1119
+ ` req.principal = user || GUEST;`,
1120
+ ` next();`,
1121
+ ` })(req, res, next);`,
1122
+ ` };`,
1123
+ `}`,
1124
+ ``,
1125
+ ].join("\n")],
1126
+ ["modules/auth/helpers/guards.ts", [
1127
+ `import type { RequestHandler } from "express";`,
1128
+ `import type { Ctx } from "@tulipes/core/boot";`,
1129
+ `import { HttpError } from "@tulipes/core/http";`,
1130
+ ``,
1131
+ `import { principal } from "./principal.js";`,
1132
+ ``,
1133
+ `/**`,
1134
+ ` * Guards other modules import from \`@app/auth\`.`,
1135
+ ` *`,
1136
+ ` * Both distinguish "you are not signed in" (401 — logging in would help)`,
1137
+ ` * from "you are signed in and still may not" (403 — it would not). A`,
1138
+ ` * client can act on that difference; a blanket 403 makes it guess.`,
1139
+ ` */`,
1140
+ ``,
1141
+ `/** Requires any signed-in principal, without naming a permission. */`,
1142
+ `export function requireAuth(): RequestHandler {`,
1143
+ ` return (req, _res, next) => {`,
1144
+ ` if (!principal(req).authenticated) {`,
1145
+ ` throw new HttpError(401, "authentication required");`,
1146
+ ` }`,
1147
+ ` next();`,
1148
+ ` };`,
1149
+ `}`,
1150
+ ``,
1151
+ `/**`,
1152
+ ` * Requires a permission, checked against the module ACL.`,
1153
+ ` *`,
1154
+ ` * Guests are subject to the same check as anyone else, which is what makes`,
1155
+ ` * a public route just a grant to \`guest\` rather than a special case.`,
1156
+ ` */`,
1157
+ `export function requirePermission(ctx: Ctx, permission: string): RequestHandler {`,
1158
+ ` return (req, _res, next) => {`,
1159
+ ` const who = principal(req);`,
1160
+ ` if (ctx.acl!.can(who.role, permission)) return next();`,
1161
+ ``,
1162
+ ` throw who.authenticated`,
1163
+ ` ? new HttpError(403, \`role "\${who.role}" may not \${permission}\`)`,
1164
+ ` : new HttpError(401, "authentication required");`,
1165
+ ` };`,
1166
+ `}`,
1167
+ ``,
1168
+ ].join("\n")],
1169
+ ["modules/auth/controllers/auth.controllers.ts", [
1170
+ `import passport from "passport";`,
1171
+ `import type { Request, RequestHandler, Response } from "express";`,
1172
+ `import type { Ctx } from "@tulipes/core/boot";`,
1173
+ `import { HttpError } from "@tulipes/core/http";`,
1174
+ ``,
1175
+ `import { principal } from "../helpers/principal.js";`,
1176
+ `import type { UserDoc } from "../helpers/documents.js";`,
1177
+ `import { setPassword } from "../helpers/passwords.js";`,
1178
+ `import {`,
1179
+ ` consumeRefresh,`,
1180
+ ` issuePair,`,
1181
+ ` revokeAccess,`,
1182
+ ` verify,`,
1183
+ ` type TokenClaims,`,
1184
+ `} from "../helpers/tokens.js";`,
1185
+ ``,
1186
+ `interface Credentials {`,
1187
+ ` email?: string;`,
1188
+ ` password?: string;`,
1189
+ `}`,
1190
+ ``,
1191
+ `/** POST /auth/register — creates the account and its credential. */`,
1192
+ `export function register(ctx: Ctx): RequestHandler {`,
1193
+ ` return async (req: Request, res: Response) => {`,
1194
+ ` const { email, password } = (req.body ?? {}) as Credentials;`,
1195
+ ` if (!email || !password) {`,
1196
+ ` throw new HttpError(422, "email and password are required");`,
1197
+ ` }`,
1198
+ ` if (password.length < 8) {`,
1199
+ ` throw new HttpError(422, "password must be at least 8 characters");`,
1200
+ ` }`,
1201
+ ` if (ctx.config.users?.signupMode !== "open") {`,
1202
+ ` throw new HttpError(403, \`registration is \${ctx.config.users?.signupMode ?? "closed"}\`);`,
1203
+ ` }`,
1204
+ ``,
1205
+ ` const User = ctx.models!.get<UserDoc>("User");`,
1206
+ ` const normalised = email.toLowerCase();`,
1207
+ ` if (await User.countDocuments({ email: normalised }).limit(1)) {`,
1208
+ ` // Deliberately the same shape as any other validation failure: a`,
1209
+ ` // different message here would turn registration into an oracle for`,
1210
+ ` // which addresses have accounts.`,
1211
+ ` throw new HttpError(422, "that email cannot be registered");`,
1212
+ ` }`,
1213
+ ``,
1214
+ ` const user = await User.create({ email: normalised, role: "user" });`,
1215
+ ` await setPassword(ctx, String(user._id), password);`,
1216
+ ``,
1217
+ ` const tokens = await issuePair(ctx, String(user._id));`,
1218
+ ` res.status(201).json({ user: { id: String(user._id), email: normalised, role: "user" }, ...tokens });`,
1219
+ ` };`,
1220
+ `}`,
1221
+ ``,
1222
+ `/** POST /auth/login — passport-local checks the password, then we mint. */`,
1223
+ `export function login(ctx: Ctx): RequestHandler {`,
1224
+ ` return (req, res, next) => {`,
1225
+ ` passport.authenticate(`,
1226
+ ` "local",`,
1227
+ ` { session: false },`,
1228
+ ` async (error: unknown, user: { _id: unknown; email: string; role: string } | false) => {`,
1229
+ ` if (error) return next(error);`,
1230
+ ` // One message for "no such user" and "wrong password" alike.`,
1231
+ ` if (!user) return next(new HttpError(401, "invalid email or password"));`,
1232
+ ``,
1233
+ ` try {`,
1234
+ ` const tokens = await issuePair(ctx, String(user._id));`,
1235
+ ` res.json({`,
1236
+ ` user: { id: String(user._id), email: user.email, role: user.role },`,
1237
+ ` ...tokens,`,
1238
+ ` });`,
1239
+ ` } catch (issueError) {`,
1240
+ ` next(issueError);`,
1241
+ ` }`,
1242
+ ` },`,
1243
+ ` )(req, res, next);`,
1244
+ ` };`,
1245
+ `}`,
1246
+ ``,
1247
+ `/** POST /auth/refresh — rotates the pair. */`,
1248
+ `export function refresh(ctx: Ctx): RequestHandler {`,
1249
+ ` return async (req, res) => {`,
1250
+ ` const { refreshToken } = (req.body ?? {}) as { refreshToken?: string };`,
1251
+ ` if (!refreshToken) throw new HttpError(422, "refreshToken is required");`,
1252
+ ``,
1253
+ ` const claims = verify(ctx, refreshToken, "refresh");`,
1254
+ ` if (!claims) throw new HttpError(401, "invalid refresh token");`,
1255
+ ``,
1256
+ ` // Rotation: the old jti is consumed here, so the same refresh token`,
1257
+ ` // cannot be exchanged twice.`,
1258
+ ` if (!(await consumeRefresh(ctx, claims.jti))) {`,
1259
+ ` throw new HttpError(401, "refresh token already used or revoked");`,
1260
+ ` }`,
1261
+ ``,
1262
+ ` const user = await ctx.models!.get<UserDoc>("User").findById(claims.sub).lean();`,
1263
+ ` if (!user) throw new HttpError(401, "account no longer exists");`,
1264
+ ``,
1265
+ ` res.json(await issuePair(ctx, claims.sub));`,
1266
+ ` };`,
1267
+ `}`,
1268
+ ``,
1269
+ `/** POST /auth/logout — ends this session immediately. */`,
1270
+ `export function logout(ctx: Ctx): RequestHandler {`,
1271
+ ` return async (req, res) => {`,
1272
+ ` const who = principal(req);`,
1273
+ ` const { refreshToken } = (req.body ?? {}) as { refreshToken?: string };`,
1274
+ ``,
1275
+ ` // Drop the refresh token so it cannot be rotated…`,
1276
+ ` if (refreshToken) {`,
1277
+ ` const claims = verify(ctx, refreshToken, "refresh");`,
1278
+ ` if (claims) await consumeRefresh(ctx, claims.jti);`,
1279
+ ` }`,
1280
+ ``,
1281
+ ` // …and blacklist the access token, so it stops working now rather than`,
1282
+ ` // when it expires.`,
1283
+ ` const header = req.header("authorization") ?? "";`,
1284
+ ` const bearer = header.startsWith("Bearer ") ? header.slice(7) : undefined;`,
1285
+ ` const access = bearer ? (verify(ctx, bearer, "access") as TokenClaims | undefined) : undefined;`,
1286
+ ` if (access) await revokeAccess(ctx, access);`,
1287
+ ``,
1288
+ ` res.json({ loggedOut: true, user: who.id });`,
1289
+ ` };`,
1290
+ `}`,
1291
+ ``,
1292
+ `/** GET /auth/me — the principal the request pipeline resolved. */`,
1293
+ `export function me(): RequestHandler {`,
1294
+ ` return (req, res) => {`,
1295
+ ` const { id, email, role } = principal(req);`,
1296
+ ` res.json({ id, email, role });`,
1297
+ ` };`,
1298
+ `}`,
1299
+ ``,
1300
+ `/** POST /auth/password — change own password, ending every other session. */`,
1301
+ `export function changePassword(ctx: Ctx): RequestHandler {`,
1302
+ ` return async (req, res) => {`,
1303
+ ` const who = principal(req);`,
1304
+ ` const { password } = (req.body ?? {}) as { password?: string };`,
1305
+ ` if (!password || password.length < 8) {`,
1306
+ ` throw new HttpError(422, "password must be at least 8 characters");`,
1307
+ ` }`,
1308
+ ``,
1309
+ ` await setPassword(ctx, who.id!, password);`,
1310
+ ``,
1311
+ ` // Every previous token is now dead, including the one that made this`,
1312
+ ` // request. Handing back a fresh pair keeps the client that just`,
1313
+ ` // changed its password signed in, while every other device is out.`,
1314
+ ` const tokens = await issuePair(ctx, who.id!);`,
1315
+ ` res.json({ changed: true, sessionsRevoked: true, ...tokens });`,
1316
+ ` };`,
1317
+ `}`,
1318
+ ``,
1319
+ ].join("\n")],
1320
+ ["modules/auth/routes/auth.routes.ts", [
1321
+ `import { Router } from "express";`,
1322
+ `import passport from "passport";`,
1323
+ `import type { Ctx } from "@tulipes/core/boot";`,
1324
+ ``,
1325
+ `import {`,
1326
+ ` changePassword,`,
1327
+ ` login,`,
1328
+ ` logout,`,
1329
+ ` me,`,
1330
+ ` refresh,`,
1331
+ ` register,`,
1332
+ `} from "../controllers/auth.controllers.js";`,
1333
+ `import { authenticate, configurePassport } from "../helpers/passport.js";`,
1334
+ `import { requirePermission } from "../helpers/guards.js";`,
1335
+ ``,
1336
+ `/**`,
1337
+ ` * Auth is a sys-tier module, so this runs before every feature router.`,
1338
+ ` * It does two things:`,
1339
+ ` *`,
1340
+ ` * 1. mounts the global principal resolver on ctx.app — every request`,
1341
+ ` * downstream has a \`req.principal\`, \`guest\` when unauthenticated`,
1342
+ ` * 2. returns its own endpoints as a Router, like any other module`,
1343
+ ` *`,
1344
+ ` * Each endpoint names a permission rather than being implicitly public.`,
1345
+ ` * Login is reachable because \`guest\` is granted \`auth:login\` in`,
1346
+ ` * module.acl.ts — one mechanism, visible in one place.`,
1347
+ ` */`,
1348
+ `export default function authRoutes(ctx: Ctx): Router {`,
1349
+ ` configurePassport(ctx);`,
1350
+ ``,
1351
+ ` ctx.app!.use(passport.initialize());`,
1352
+ ` ctx.app!.use(authenticate());`,
1353
+ ``,
1354
+ ` const router = Router();`,
1355
+ ` const base = \`\${String(ctx.config.api?.prefix ?? "")}/auth\`;`,
1356
+ ` const may = (permission: string) => requirePermission(ctx, permission);`,
1357
+ ``,
1358
+ ` router.post(\`\${base}/register\`, may("auth:register"), register(ctx));`,
1359
+ ` router.post(\`\${base}/login\`, may("auth:login"), login(ctx));`,
1360
+ ` router.post(\`\${base}/refresh\`, may("auth:refresh"), refresh(ctx));`,
1361
+ ` router.post(\`\${base}/logout\`, may("auth:logout"), logout(ctx));`,
1362
+ ` router.get(\`\${base}/me\`, may("auth:me"), me());`,
1363
+ ` router.post(\`\${base}/password\`, may("auth:password"), changePassword(ctx));`,
1364
+ ``,
1365
+ ` return router;`,
1366
+ `}`,
1367
+ ``,
1368
+ ].join("\n")],
1369
+ ["modules/auth/models/credential.model.ts", [
1370
+ `import { Schema } from "mongoose";`,
1371
+ `import type { ModelDef } from "@tulipes/core/db";`,
1372
+ `import { baseSchemaOptions } from "@app/core";`,
1373
+ ``,
1374
+ `/**`,
1375
+ ` * Secrets live here, never on the User document.`,
1376
+ ` *`,
1377
+ ` * The separation is the point: a route that serialises a user cannot leak`,
1378
+ ` * a password hash by accident, because the hash is not in that document at`,
1379
+ ` * all. \`select: false\` is a second layer — even a query against this`,
1380
+ ` * collection has to ask for the hash explicitly.`,
1381
+ ` *`,
1382
+ ` * One row per user, password only. API keys and OAuth links would be their`,
1383
+ ` * own models rather than more columns here.`,
1384
+ ` */`,
1385
+ `const credentialSchema = new Schema(`,
1386
+ ` {`,
1387
+ ` user: {`,
1388
+ ` type: Schema.Types.ObjectId,`,
1389
+ ` ref: "User",`,
1390
+ ` required: true,`,
1391
+ ` unique: true,`,
1392
+ ` index: true,`,
1393
+ ` },`,
1394
+ ` passwordHash: { type: String, required: true, select: false },`,
1395
+ ` },`,
1396
+ ` baseSchemaOptions,`,
1397
+ `);`,
1398
+ ``,
1399
+ `export default { name: "Credential", schema: credentialSchema } satisfies ModelDef;`,
1400
+ ``,
1401
+ ].join("\n")],
1402
+ ["modules/auth/models/refresh-token.model.ts", [
1403
+ `import { Schema } from "mongoose";`,
1404
+ `import type { ModelDef } from "@tulipes/core/db";`,
1405
+ ``,
1406
+ `/**`,
1407
+ ` * One row per issued refresh token, keyed by its jti.`,
1408
+ ` *`,
1409
+ ` * Refresh tokens rotate: using one deletes its row and issues a new pair,`,
1410
+ ` * so a token that is not in this collection is not usable — which is what`,
1411
+ ` * makes logout and expiry mean something for an otherwise stateless JWT.`,
1412
+ ` *`,
1413
+ ` * \`expiresAt\` carries a TTL index, so mongo removes the rows itself and`,
1414
+ ` * the collection cannot grow without bound.`,
1415
+ ` */`,
1416
+ `const refreshTokenSchema = new Schema({`,
1417
+ ` jti: { type: String, required: true, unique: true, index: true },`,
1418
+ ` user: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },`,
1419
+ ` expiresAt: { type: Date, required: true, expires: 0 },`,
1420
+ ` createdAt: { type: Date, default: Date.now },`,
1421
+ `});`,
1422
+ ``,
1423
+ `export default { name: "RefreshToken", schema: refreshTokenSchema } satisfies ModelDef;`,
1424
+ ``,
1425
+ ].join("\n")],
1426
+ ["modules/auth/models/revoked-token.model.ts", [
1427
+ `import { Schema } from "mongoose";`,
1428
+ `import type { ModelDef } from "@tulipes/core/db";`,
1429
+ ``,
1430
+ `/**`,
1431
+ ` * Access tokens are stateless, so logging out cannot un-issue one. This is`,
1432
+ ` * the blacklist that makes logout immediate: the access token's jti is`,
1433
+ ` * recorded until its natural expiry, and every authenticated request`,
1434
+ ` * checks it.`,
1435
+ ` *`,
1436
+ ` * Rows carry a TTL index set to the token's own expiry — once the token`,
1437
+ ` * would have expired anyway, the row deletes itself and the list stays as`,
1438
+ ` * small as the access-token lifetime allows. That is the argument for`,
1439
+ ` * keeping JWT_ACCESS_TTL short.`,
1440
+ ` */`,
1441
+ `const revokedTokenSchema = new Schema({`,
1442
+ ` jti: { type: String, required: true, unique: true, index: true },`,
1443
+ ` expiresAt: { type: Date, required: true, expires: 0 },`,
1444
+ `});`,
1445
+ ``,
1446
+ `export default { name: "RevokedToken", schema: revokedTokenSchema } satisfies ModelDef;`,
1447
+ ``,
1448
+ ].join("\n")],
1449
+ ["modules/auth/bootstrap/seed-admin.bootstrap.ts", [
1450
+ `import type { Ctx } from "@tulipes/core/boot";`,
1451
+ ``,
1452
+ `import { setPassword } from "../helpers/passwords.js";`,
1453
+ `import type { UserDoc } from "../helpers/documents.js";`,
1454
+ ``,
1455
+ `/**`,
1456
+ ` * Seeds the first admin: the account and its credential together.`,
1457
+ ` *`,
1458
+ ` * It lives in auth rather than users because only auth knows how to store`,
1459
+ ` * a password — which is the whole reason credentials are a separate model.`,
1460
+ ` * Auth reaches the User model through the model store by name, not by`,
1461
+ ` * importing the users module: auth is sys-tier and users is app-tier, and`,
1462
+ ` * a sys module may not depend on an app module.`,
1463
+ ` */`,
1464
+ `export default async function seedAdmin(ctx: Ctx): Promise<void> {`,
1465
+ ` const email = String(ctx.Environment.get("AUTH_ADMIN_EMAIL")).toLowerCase();`,
1466
+ ` const User = ctx.models!.get<UserDoc>("User");`,
1467
+ ``,
1468
+ ` let user: Pick<UserDoc, "_id"> | null = await User.findOne({ email }).lean();`,
1469
+ ``,
1470
+ ` if (!user) {`,
1471
+ ` try {`,
1472
+ ` user = await User.create({ email, role: "admin" });`,
1473
+ ` } catch (error) {`,
1474
+ ` // Backend and worker boot together, so both may reach this at once.`,
1475
+ ` // The loser of that race just reads the row the winner inserted.`,
1476
+ ` if ((error as { code?: number }).code !== 11000) throw error;`,
1477
+ ` user = await User.findOne({ email }).lean();`,
1478
+ ` }`,
1479
+ ` }`,
1480
+ ``,
1481
+ ` // Only set the password when there is no credential yet, so a rotated`,
1482
+ ` // admin password is not silently reset to the seed value on next boot.`,
1483
+ ` const existing = await ctx.models!`,
1484
+ ` .get("Credential")`,
1485
+ ` .countDocuments({ user: user!._id })`,
1486
+ ` .limit(1);`,
1487
+ ``,
1488
+ ` if (existing === 0) {`,
1489
+ ` await setPassword(ctx, String(user!._id), String(ctx.Environment.get("AUTH_ADMIN_PASSWORD")));`,
1490
+ ` }`,
1491
+ `}`,
1492
+ ``,
1493
+ ].join("\n")],
1494
+ ["modules/auth/package.json", json({
1495
+ name: `@app/auth`,
1496
+ version: "0.0.0",
1497
+ private: true,
1498
+ type: "module",
1499
+ exports: { ".": "./index.ts" },
1500
+ // sys tier: the principal resolver must run before any feature
1501
+ // router, and the roles it defines must exist before modules grant.
1502
+ tulipes: { tier: "sys", priority: 20 },
1503
+ dependencies: {
1504
+ "@tulipes/core": core,
1505
+ "@app/core": "workspace:*",
1506
+ bcryptjs: "^3",
1507
+ express: "^5",
1508
+ jsonwebtoken: "^9",
1509
+ mongoose: "^8",
1510
+ passport: "^0.7",
1511
+ "passport-jwt": "^4",
1512
+ "passport-local": "^1",
1513
+ },
1514
+ devDependencies: {
1515
+ "@types/jsonwebtoken": "^9",
1516
+ "@types/passport": "^1",
1517
+ "@types/passport-jwt": "^4",
1518
+ "@types/passport-local": "^1",
1519
+ },
1520
+ })],
1521
+ ["modules/users/models/user.model.ts", [
1522
+ `import { Schema } from "mongoose";`,
1523
+ `import type { ModelDef } from "@tulipes/core/db";`,
1524
+ `import { baseSchemaOptions } from "@app/core";`,
1525
+ ``,
1526
+ `/**`,
1527
+ ` * The account record — and nothing secret.`,
1528
+ ` *`,
1529
+ ` * Passwords and keys live in the auth module's Credential model. Keeping`,
1530
+ ` * them out of this document means a route that serialises a user cannot`,
1531
+ ` * leak one by accident.`,
1532
+ ` */`,
1533
+ `const userSchema = new Schema(`,
1534
+ ` {`,
1535
+ ` email: { type: String, required: true, unique: true, lowercase: true, trim: true },`,
1536
+ ` role: { type: String, required: true, default: "user" },`,
1537
+ ` // Every access token issued before this instant is rejected. Auth`,
1538
+ ` // moves it forward on a password change, which is how one action signs`,
1539
+ ` // the account out everywhere. Not a secret.`,
1540
+ ` tokensValidFrom: { type: Date },`,
1541
+ ` },`,
1542
+ ` baseSchemaOptions,`,
1543
+ `);`,
1544
+ ``,
1545
+ `export default { name: "User", schema: userSchema } satisfies ModelDef;`,
1546
+ ``,
1547
+ ].join("\n")],
1548
+ ["modules/users/routes/users.routes.ts", [
1549
+ `import { Router } from "express";`,
1550
+ `import type { Ctx } from "@tulipes/core/boot";`,
1551
+ `import { requirePermission } from "@app/auth";`,
1552
+ ``,
1553
+ `export default function usersRoutes(ctx: Ctx): Router {`,
1554
+ ` const router = Router();`,
1555
+ ` const base = String(ctx.config.api?.prefix ?? "");`,
1556
+ ``,
1557
+ ` // Every route names a permission. A route without one is reachable by`,
1558
+ ` // \`guest\`, because the pipeline authenticates rather than rejects — so`,
1559
+ ` // "I forgot the guard" and "I meant this to be public" must never look`,
1560
+ ` // the same. Naming one always is what keeps that honest.`,
1561
+ ` const may = (permission: string) => requirePermission(ctx, permission);`,
1562
+ ``,
1563
+ ` router.get(\`\${base}/users\`, may("users:read"), async (req, res) => {`,
1564
+ ` const { defaultLimit = 20, maxLimit = 100 } = ctx.config.pagination ?? {};`,
1565
+ ` const asked = Number(req.query.limit ?? defaultLimit);`,
1566
+ ` const limit = Math.min(Number.isFinite(asked) ? asked : defaultLimit, maxLimit);`,
1567
+ ``,
1568
+ ` const users = await ctx.models!`,
1569
+ ` .get("User")`,
1570
+ ` .find()`,
1571
+ ` .select("email role -_id")`,
1572
+ ` .limit(limit)`,
1573
+ ` .lean();`,
1574
+ ``,
1575
+ ` res.json({ count: users.length, limit, users });`,
1576
+ ` });`,
1577
+ ``,
1578
+ ` return router;`,
1579
+ `}`,
1580
+ ``,
1581
+ ].join("\n")],
1582
+ ["modules/users/module.acl.ts", [
1583
+ `import type { AclBuilder } from "@tulipes/core/acl";`,
1584
+ ``,
1585
+ `/**`,
1586
+ ` * Roles are defined by the auth module; features only attach grants, on`,
1587
+ ` * resources namespaced by their own name.`,
1588
+ ` */`,
1589
+ `export default function usersAcl(acl: AclBuilder): void {`,
1590
+ ` acl.allow("user", "users:read");`,
1591
+ ` // writes stay with admin, which already holds "*"`,
1592
+ `}`,
1593
+ ``,
1594
+ ].join("\n")],
1595
+ ["modules/users/package.json", json({
1596
+ name: `@app/users`,
1597
+ version: "0.0.0",
1598
+ private: true,
1599
+ type: "module",
1600
+ tulipes: { tier: "app", priority: 10, dependsOn: ["auth", "core"] },
1601
+ dependencies: {
1602
+ "@tulipes/core": core,
1603
+ "@app/auth": "workspace:*",
1604
+ "@app/core": "workspace:*",
1605
+ express: "^5",
1606
+ mongoose: "^8",
1607
+ },
1608
+ })],
700
1609
  // ── modules/core — sys tier, priority 0: the very first router ─────────
701
1610
  ["modules/core/package.json", json({
702
1611
  name: `@app/core`,
@@ -1413,21 +2322,6 @@ function renderProject(name, coreVersion) {
1413
2322
  `}`,
1414
2323
  ``,
1415
2324
  ].join("\n")],
1416
- ["modules/core/module.acl.ts", [
1417
- `import type { AclBuilder } from "@tulipes/core/acl";`,
1418
- ``,
1419
- `/**`,
1420
- ` * The sys core module owns the global role vocabulary. Business modules`,
1421
- ` * never define roles — they attach grants to these.`,
1422
- ` */`,
1423
- `export default function coreAcl(acl: AclBuilder): void {`,
1424
- ` acl.defineRole("admin").defineRole("user");`,
1425
- ``,
1426
- ` // admin can do everything, per resource-namespace wildcard rules.`,
1427
- ` acl.allow("admin", "*");`,
1428
- `}`,
1429
- ``,
1430
- ].join("\n")],
1431
2325
  ["modules/core/routes/core.routes.ts", [
1432
2326
  `import { randomUUID } from "node:crypto";`,
1433
2327
  `import type { Ctx } from "@tulipes/core/boot";`,
@@ -1612,9 +2506,10 @@ function renderProject(name, coreVersion) {
1612
2506
  version: "0.0.0",
1613
2507
  private: true,
1614
2508
  type: "module",
1615
- tulipes: { tier: "app", priority: 100, dependsOn: ["core"] },
2509
+ tulipes: { tier: "app", priority: 100, dependsOn: ["auth", "core"] },
1616
2510
  dependencies: {
1617
2511
  "@tulipes/core": core,
2512
+ "@app/auth": "workspace:*",
1618
2513
  "@app/core": "workspace:*",
1619
2514
  express: "^5",
1620
2515
  mongoose: "^8",
@@ -1713,12 +2608,13 @@ function renderProject(name, coreVersion) {
1713
2608
  `import { Router } from "express";`,
1714
2609
  `import type { Ctx } from "@tulipes/core/boot";`,
1715
2610
  `import { HttpError } from "@tulipes/core/http";`,
2611
+ `import { requirePermission } from "@app/auth";`,
1716
2612
  ``,
1717
2613
  `import { greet } from "../controllers/hello.controllers.js";`,
1718
2614
  ``,
1719
2615
  `export default function helloRoutes(ctx: Ctx): Router {`,
1720
2616
  ` const router = Router();`,
1721
- ` const { acl, config } = ctx;`,
2617
+ ` const { config } = ctx;`,
1722
2618
  ``,
1723
2619
  ` // Built from the shared prefix, so versioning the whole API is a`,
1724
2620
  ` // one-line change in config/app.config.ts.`,
@@ -1728,13 +2624,10 @@ function renderProject(name, coreVersion) {
1728
2624
  ``,
1729
2625
  ` // Literal paths must be registered before parameterised ones, or`,
1730
2626
  ` // "/hello/:name" would swallow this.`,
1731
- ` router.get(\`\${base}/hello/secret\`, (req, res) => {`,
1732
- ` // A real app resolves the role in an auth module and puts it on the`,
1733
- ` // request; this reads a header purely so the demo is curl-able.`,
1734
- ` const role = String(req.header("x-demo-role") ?? "guest");`,
1735
- ` if (!acl!.can(role, "hello:read")) {`,
1736
- ` throw new HttpError(403, \`role "\${role}" may not read hello\`);`,
1737
- ` }`,
2627
+ ` // requirePermission checks the caller against module.acl.ts. An`,
2628
+ ` // unauthenticated request carries the "guest" role, so a stranger`,
2629
+ ` // gets 401 and someone signed in without the grant gets 403.`,
2630
+ ` router.get(\`\${base}/hello/secret\`, requirePermission(ctx, "hello:read"), (_req, res) => {`,
1738
2631
  ` res.json({ secret: "only roles granted hello:read see this" });`,
1739
2632
  ` });`,
1740
2633
  ``,
@@ -1953,6 +2846,47 @@ function renderProject(name, coreVersion) {
1953
2846
  `| \`yarn tulipes new module <name>\` | scaffold a module |`,
1954
2847
  `| \`yarn tulipes update\` | upgrade the framework everywhere it is declared |`,
1955
2848
  ``,
2849
+ `## Authentication`,
2850
+ ``,
2851
+ `\`modules/auth\` is sys tier, so it resolves the caller before any`,
2852
+ `feature route runs. A request with no valid token is not rejected \u2014 it`,
2853
+ `becomes the **guest** principal, which is what makes a public endpoint`,
2854
+ `an ordinary ACL grant instead of a second mechanism:`,
2855
+ ``,
2856
+ "```ts",
2857
+ `acl.allow("guest", "auth:login"); // that is why login is reachable`,
2858
+ "```",
2859
+ ``,
2860
+ `Guard a route with the permission it needs. 401 means "sign in"; 403`,
2861
+ `means "signed in, still not allowed":`,
2862
+ ``,
2863
+ "```ts",
2864
+ `import { requirePermission } from "@app/auth";`,
2865
+ ``,
2866
+ `router.get(\`\${base}/invoices\`, requirePermission(ctx, "invoices:read"), handler);`,
2867
+ "```",
2868
+ ``,
2869
+ `**A route with no permission check is public**, since the pipeline`,
2870
+ `authenticates rather than rejects. Name one on every route.`,
2871
+ ``,
2872
+ `Try it \u2014 the admin comes from AUTH_ADMIN_EMAIL / AUTH_ADMIN_PASSWORD:`,
2873
+ ``,
2874
+ "```sh",
2875
+ `curl -X POST localhost:3000/api/v1/auth/login \\`,
2876
+ ` -H 'content-type: application/json' \\`,
2877
+ ` -d '{"email":"admin@example.com","password":"dev-admin-password"}'`,
2878
+ ``,
2879
+ `curl localhost:3000/api/v1/auth/me -H "authorization: Bearer <accessToken>"`,
2880
+ "```",
2881
+ ``,
2882
+ `Passwords never touch the User document: the hash lives in the auth`,
2883
+ `module's \`Credential\` model, \`select: false\`, so a route that`,
2884
+ `serialises a user cannot leak one. Refresh tokens rotate on use;`,
2885
+ `logout deletes the refresh row and blacklists the access token's jti`,
2886
+ `until it expires; a password change moves a per-user epoch that`,
2887
+ `invalidates every token issued before it. Both token collections carry`,
2888
+ `TTL indexes.`,
2889
+ ``,
1956
2890
  `## Logging`,
1957
2891
  ``,
1958
2892
  `The logger lives in the core module and is shared by every other one:`,