@tulipes/cli 0.1.0 → 0.2.0-rc.1

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.
Files changed (209) hide show
  1. package/README.md +11 -8
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js +29 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/app-core.d.ts +8 -6
  6. package/dist/app-core.js +23 -14
  7. package/dist/app-core.js.map +1 -1
  8. package/dist/bin.js +3 -3
  9. package/dist/bin.js.map +1 -1
  10. package/dist/config-check.d.ts +8 -0
  11. package/dist/config-check.js +43 -0
  12. package/dist/config-check.js.map +1 -0
  13. package/dist/config-import.d.ts +20 -0
  14. package/dist/config-import.js +133 -0
  15. package/dist/config-import.js.map +1 -0
  16. package/dist/dev.d.ts +4 -4
  17. package/dist/dev.js +7 -10
  18. package/dist/dev.js.map +1 -1
  19. package/dist/init.d.ts +6 -11
  20. package/dist/init.js +17 -6420
  21. package/dist/init.js.map +1 -1
  22. package/dist/inspection.d.ts +9 -8
  23. package/dist/inspection.js +10 -11
  24. package/dist/inspection.js.map +1 -1
  25. package/dist/main.js +82 -11
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrate.d.ts +30 -0
  28. package/dist/migrate.js +772 -0
  29. package/dist/migrate.js.map +1 -0
  30. package/dist/new-module.js +87 -46
  31. package/dist/new-module.js.map +1 -1
  32. package/dist/routes.d.ts +3 -5
  33. package/dist/routes.js +4 -6
  34. package/dist/routes.js.map +1 -1
  35. package/dist/spec.d.ts +2 -5
  36. package/dist/spec.js +13 -6
  37. package/dist/spec.js.map +1 -1
  38. package/dist/sync.d.ts +3 -11
  39. package/dist/sync.js +81 -20
  40. package/dist/sync.js.map +1 -1
  41. package/dist/template.d.ts +58 -0
  42. package/dist/template.js +244 -0
  43. package/dist/template.js.map +1 -0
  44. package/dist/update.js +4 -4
  45. package/package.json +17 -16
  46. package/templates/AGENTS.md +162 -0
  47. package/templates/CLAUDE.md +11 -118
  48. package/templates/app/.envs/.env.development +18 -0
  49. package/templates/app/.envs/.env.production +19 -0
  50. package/templates/app/.envs/.env.staging +19 -0
  51. package/templates/app/README.md +327 -0
  52. package/templates/app/app.ts +3 -0
  53. package/templates/app/config/app.config.ts +73 -0
  54. package/templates/app/docs/agent-manifest.json +724 -0
  55. package/templates/app/ecosystem.config.cjs +60 -0
  56. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  57. package/templates/app/modules/agent/i18n/en.json +5 -0
  58. package/templates/app/modules/agent/i18n/fr.json +5 -0
  59. package/templates/app/modules/agent/module.acl.ts +14 -0
  60. package/templates/app/modules/agent/package.json +21 -0
  61. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  62. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  63. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  64. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  65. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  66. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  67. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  68. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  69. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  70. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  71. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  72. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  73. package/templates/app/modules/auth/i18n/en.json +19 -0
  74. package/templates/app/modules/auth/i18n/fr.json +19 -0
  75. package/templates/app/modules/auth/lib/index.ts +12 -0
  76. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  77. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  78. package/templates/app/modules/auth/models/session.model.ts +42 -0
  79. package/templates/app/modules/auth/module.acl.ts +41 -0
  80. package/templates/app/modules/auth/module.config.ts +30 -0
  81. package/templates/app/modules/auth/package.json +61 -0
  82. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  83. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  84. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  85. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  86. package/templates/app/modules/config/helpers/audit.ts +41 -0
  87. package/templates/app/modules/config/helpers/describe.ts +72 -0
  88. package/templates/app/modules/config/i18n/en.json +6 -0
  89. package/templates/app/modules/config/i18n/fr.json +6 -0
  90. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  91. package/templates/app/modules/config/module.acl.ts +12 -0
  92. package/templates/app/modules/config/package.json +23 -0
  93. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  94. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  95. package/templates/app/modules/config/tests/config.test.ts +126 -0
  96. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  97. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  98. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  99. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  100. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  101. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  102. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  103. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  104. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  105. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  108. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  109. package/templates/app/modules/core/lib/index.ts +34 -0
  110. package/templates/app/modules/core/module.config.ts +24 -0
  111. package/templates/app/modules/core/package.json +80 -0
  112. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  113. package/templates/app/modules/core/tests/core.test.ts +19 -0
  114. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  115. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  116. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  117. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  118. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  119. package/templates/app/modules/hello/i18n/en.json +8 -0
  120. package/templates/app/modules/hello/i18n/fr.json +8 -0
  121. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  122. package/templates/app/modules/hello/module.acl.ts +16 -0
  123. package/templates/app/modules/hello/module.config.ts +14 -0
  124. package/templates/app/modules/hello/package.json +24 -0
  125. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  126. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  127. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  128. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  129. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  130. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  131. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  132. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  133. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  134. package/templates/app/modules/security/module.config.ts +59 -0
  135. package/templates/app/modules/security/package.json +26 -0
  136. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  137. package/templates/app/modules/security/tests/security.test.ts +174 -0
  138. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  139. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  140. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  141. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  142. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  143. package/templates/app/modules/settings/i18n/en.json +3 -0
  144. package/templates/app/modules/settings/i18n/fr.json +3 -0
  145. package/templates/app/modules/settings/module.acl.ts +12 -0
  146. package/templates/app/modules/settings/package.json +21 -0
  147. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  148. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  149. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  150. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  151. package/templates/app/modules/users/i18n/en.json +14 -0
  152. package/templates/app/modules/users/i18n/fr.json +14 -0
  153. package/templates/app/modules/users/models/user.model.ts +18 -0
  154. package/templates/app/modules/users/module.acl.ts +10 -0
  155. package/templates/app/modules/users/module.config.ts +11 -0
  156. package/templates/app/modules/users/package.json +24 -0
  157. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  158. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  159. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  160. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  161. package/templates/app/nginx/development.conf +44 -0
  162. package/templates/app/nginx/production.conf +75 -0
  163. package/templates/app/nginx/staging.conf +51 -0
  164. package/templates/app/package.json +58 -0
  165. package/templates/app/scripts/count-greetings.ts +29 -0
  166. package/templates/app/scripts/deploy-nginx.ts +143 -0
  167. package/templates/app/scripts/list-users.ts +34 -0
  168. package/templates/app/tests/app.test.ts +28 -0
  169. package/templates/app/tests/support.ts +36 -0
  170. package/templates/app/tsconfig.json +28 -0
  171. package/templates/app/vitest.config.ts +6 -0
  172. package/templates/app/worker.ts +6 -0
  173. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
  174. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  175. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  176. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  177. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  178. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  179. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  180. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  181. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  182. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  183. package/templates/minimal/README.md +21 -0
  184. package/templates/minimal/app.ts +3 -0
  185. package/templates/minimal/config/app.config.ts +16 -0
  186. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  187. package/templates/minimal/modules/agent/package.json +14 -0
  188. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  189. package/templates/minimal/modules/core/module.acl.ts +4 -0
  190. package/templates/minimal/modules/core/package.json +21 -0
  191. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  192. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  193. package/templates/minimal/modules/hello/package.json +11 -0
  194. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  195. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  196. package/templates/minimal/modules/security/package.json +15 -0
  197. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  198. package/templates/minimal/package.json +38 -0
  199. package/templates/minimal/tests/app.test.ts +52 -0
  200. package/templates/minimal/tsconfig.json +22 -0
  201. package/templates/minimal/vitest.config.ts +5 -0
  202. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  203. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  204. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  205. package/templates/versions.json +11 -0
  206. package/dist/minimal.d.ts +0 -2
  207. package/dist/minimal.js +0 -2200
  208. package/dist/minimal.js.map +0 -1
  209. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -0,0 +1,57 @@
1
+ import type { HydratedDocument, Types } from "mongoose";
2
+
3
+ /**
4
+ * The shapes auth reads through the model store.
5
+ *
6
+ * `models.get()` is generic precisely so a consumer can say what it
7
+ * expects — the store cannot know, since models are registered by name
8
+ * from other modules. Declaring them here keeps the casts in one file
9
+ * instead of scattered through the handlers.
10
+ */
11
+ export interface UserAttrs {
12
+ _id: Types.ObjectId;
13
+ email: string;
14
+ role: string;
15
+ tokens_valid_from?: Date;
16
+ created_at: Date;
17
+ }
18
+
19
+ export interface SessionAttrs {
20
+ _id: Types.ObjectId;
21
+ sid: string;
22
+ user: Types.ObjectId;
23
+ credential_version: string;
24
+ refresh_jti: string;
25
+ ip?: string;
26
+ user_agent?: string;
27
+ last_seen_at?: Date;
28
+ expires_at: Date;
29
+ /** From baseSchemaOptions, like every model's. */
30
+ created_at: Date;
31
+ }
32
+
33
+ /**
34
+ * `req.auth.user` and `req.auth.session` are hydrated documents, not lean
35
+ * objects: the plugins and instance methods are available on them, and a
36
+ * handler holding one can save it.
37
+ *
38
+ * The cost of that convenience is that the request object is now a write
39
+ * handle — mutating `req.auth.user` and saving it persists, wherever in a
40
+ * handler that happens.
41
+ */
42
+ export type UserDoc = HydratedDocument<UserAttrs>;
43
+ export type SessionDoc = HydratedDocument<SessionAttrs>;
44
+
45
+ export interface CredentialDoc {
46
+ _id: Types.ObjectId;
47
+ user: Types.ObjectId;
48
+ password_hash: string;
49
+ /** Missing only on credentials created before versioned sessions. */
50
+ version?: string;
51
+ }
52
+
53
+ /** A local-password verification result, kept separate from the public user. */
54
+ export interface PasswordIdentity {
55
+ user: UserDoc;
56
+ credentialVersion: string;
57
+ }
@@ -0,0 +1,180 @@
1
+ import passport from "passport";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
4
+ import { Strategy as LocalStrategy } from "passport-local";
5
+ import type { Ctx } from "@tulipes/core/boot";
6
+ import type { Auth } from "@tulipes/core/http";
7
+
8
+ import { verifyPassword } from "./passwords.ts";
9
+ import {
10
+ isRevoked,
11
+ loadSession,
12
+ touchSession,
13
+ type TokenClaims,
14
+ } from "./tokens.ts";
15
+ import { GUEST } from "./auth.ts";
16
+ import { readIdentity, writeIdentity } from "./session-cache.ts";
17
+ import type {
18
+ CredentialDoc,
19
+ PasswordIdentity,
20
+ SessionAttrs,
21
+ SessionDoc,
22
+ UserAttrs,
23
+ UserDoc,
24
+ } from "./documents.ts";
25
+
26
+ /**
27
+ * Two strategies, both stateless as far as passport is concerned
28
+ * (`session: false` — this module keeps its own session rows):
29
+ *
30
+ * local runs once, at login, to check an email and password
31
+ * jwt runs on every request, to turn a Bearer token into a req.auth
32
+ *
33
+ * The jwt strategy never trusts the token's claims for authority: it
34
+ * resolves the session and the user, from the app cache when a request
35
+ * resolved them recently and nothing revoked them since (one Redis read),
36
+ * from the database otherwise (three reads). Every revocation deletes the
37
+ * cached entry, which is what makes a demotion, a ban or a logout take
38
+ * effect on the next request instead of whenever the token expires.
39
+ */
40
+ export function configurePassport(ctx: Ctx): void {
41
+ passport.use(
42
+ new LocalStrategy(
43
+ { usernameField: "email", passwordField: "password", session: false },
44
+ async (email, password, done) => {
45
+ try {
46
+ const user = await ctx.models!
47
+ .get<UserDoc>("User")
48
+ .findOne({ email: String(email).toLowerCase() });
49
+ if (!user) return done(null, false);
50
+
51
+ // The hash is select:false, so it has to be asked for by name.
52
+ const Credential = ctx.models!.get<CredentialDoc>("Credential");
53
+ const credential = await Credential
54
+ .findOne({ user: user._id })
55
+ .select("+password_hash")
56
+ .lean();
57
+ if (!credential) return done(null, false);
58
+
59
+ const ok = await verifyPassword(password, credential.password_hash);
60
+ if (!ok) return done(null, false);
61
+
62
+ if (!credential.version) {
63
+ // Upgrade a legacy credential only if the verified hash is unchanged.
64
+ // A racing password change or another upgrade makes this login retry.
65
+ const version = randomUUID();
66
+ const upgraded = await Credential.updateOne(
67
+ {
68
+ user: user._id,
69
+ password_hash: credential.password_hash,
70
+ version: { $exists: false },
71
+ },
72
+ { $set: { version } },
73
+ );
74
+ if (!upgraded.matchedCount) return done(null, false);
75
+ credential.version = version;
76
+ }
77
+
78
+ return done(null, {
79
+ user,
80
+ credentialVersion: credential.version,
81
+ } satisfies PasswordIdentity);
82
+ } catch (error) {
83
+ return done(error as Error);
84
+ }
85
+ },
86
+ ),
87
+ );
88
+
89
+ passport.use(
90
+ new JwtStrategy(
91
+ {
92
+ // Cookies and URL parameters never authenticate this starter. A cookie
93
+ // extractor would also need the policy described in docs/browser-auth.md.
94
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
95
+ secretOrKey: String(ctx.Environment.get("JWT_ACCESS_SECRET")),
96
+ passReqToCallback: false,
97
+ },
98
+ async (claims: TokenClaims, done) => {
99
+ try {
100
+ if (claims.typ !== "access") return done(null, false);
101
+
102
+ // A hit is the identity a cold path resolved moments ago, for
103
+ // this sid and subject, and no revocation has run since — every
104
+ // revocation path deletes the entry before it answers. Documents
105
+ // are hydrated from the cached attributes, so handlers hold the
106
+ // same kind of value either way.
107
+ const cached = await readIdentity(ctx, claims.sid);
108
+ if (cached && String(cached.user._id) === claims.sub && cached.session.sid === claims.sid
109
+ && new Date(cached.session.expires_at).getTime() > Date.now()) {
110
+ const user = ctx.models!.get<UserDoc>("User").hydrate(cached.user) as UserDoc;
111
+ const session = ctx.models!.get<SessionDoc>("Session").hydrate(cached.session) as SessionDoc;
112
+ if (await touchSession(ctx, session, touchInterval(ctx))) {
113
+ await writeIdentity(ctx, claims.sid, { user: cached.user, session: session.toObject() as SessionAttrs });
114
+ }
115
+ return done(null, resolve(user, session, claims) as unknown as Express.User);
116
+ }
117
+
118
+ // Logout blacklists the jti; without this check a "logged out"
119
+ // token keeps working until it expires.
120
+ if (await isRevoked(ctx, claims.jti)) return done(null, false);
121
+
122
+ // The session row is the server-side half: deleting it signs the
123
+ // device out even though the access token is still signed and
124
+ // unexpired. A token whose session is gone is not a caller.
125
+ const session = await loadSession(ctx, claims.sid, claims.sub);
126
+ if (!session) return done(null, false);
127
+
128
+ const user = await ctx.models!.get<UserDoc>("User").findById(claims.sub);
129
+ if (!user) return done(null, false);
130
+
131
+ await touchSession(ctx, session, touchInterval(ctx));
132
+ await writeIdentity(ctx, claims.sid, { user: user.toObject() as UserAttrs, session: session.toObject() as SessionAttrs });
133
+
134
+ return done(null, resolve(user, session, claims) as unknown as Express.User);
135
+ } catch (error) {
136
+ return done(error as Error);
137
+ }
138
+ },
139
+ ),
140
+ );
141
+ }
142
+
143
+ /**
144
+ * The session row plus the token in hand. The three access-token fields
145
+ * are attached to the document rather than stored on it — they belong to
146
+ * one request, while the row outlives every token it ever issued.
147
+ */
148
+ function resolve(user: UserDoc, session: SessionDoc, claims: TokenClaims): Auth {
149
+ return {
150
+ isAuthenticated: true,
151
+ user,
152
+ session: Object.assign(session, {
153
+ access_jti: claims.jti,
154
+ access_issued_at: new Date(claims.iat * 1000),
155
+ access_expires_at: new Date(claims.exp * 1000),
156
+ }),
157
+ };
158
+ }
159
+
160
+ /** How stale `last_seen_at` may get before a request pays for a write. */
161
+ function touchInterval(ctx: Ctx): number {
162
+ return Number(ctx.config.auth?.sessionTouchSeconds ?? 60);
163
+ }
164
+
165
+ /**
166
+ * Runs on every request. Populates `req.auth`, and never rejects: an
167
+ * unauthenticated caller becomes the guest, and the ACL decides from there.
168
+ */
169
+ export function authenticate(): import("express").RequestHandler {
170
+ return (req, res, next) => {
171
+ passport.authenticate(
172
+ "jwt",
173
+ { session: false },
174
+ (_err: unknown, resolved: Auth | false) => {
175
+ req.auth = resolved || { ...GUEST };
176
+ next();
177
+ },
178
+ )(req, res, next);
179
+ };
180
+ }
@@ -0,0 +1,58 @@
1
+ import bcrypt from "bcryptjs";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { CredentialDoc } from "./documents.ts";
4
+ import type { Ctx } from "@tulipes/core/boot";
5
+ import { HttpError } from "@tulipes/core/http";
6
+ import { forgetUser } from "./session-cache.ts";
7
+
8
+ /**
9
+ * bcryptjs — pure JavaScript, so installs never need a native toolchain.
10
+ * The cost factor comes from config rather than a literal: raising it is
11
+ * how you keep pace with hardware, and it should not require a code change.
12
+ */
13
+ const rounds = (ctx: Ctx): number => Number(ctx.config.security?.passwordRounds ?? 12);
14
+
15
+ export async function hashPassword(ctx: Ctx, plain: string): Promise<string> {
16
+ return bcrypt.hash(plain, rounds(ctx));
17
+ }
18
+
19
+ export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
20
+ return bcrypt.compare(plain, hash);
21
+ }
22
+
23
+ /**
24
+ * Set or replace a user's password and invalidate every session they have.
25
+ *
26
+ * Revoking here is the whole point of changing a password after a suspected
27
+ * compromise: leaving other devices signed in would defeat it.
28
+ */
29
+ export async function setPassword(ctx: Ctx, userId: string, plain: string, expectedVersion?: string): Promise<string> {
30
+ const password_hash = await hashPassword(ctx, plain);
31
+ const version = randomUUID();
32
+
33
+ // The password and its session version change in one atomic document write.
34
+ // Returning the previous version lets delayed cleanup target only that version.
35
+ const previous = await ctx.models!.get<CredentialDoc>("Credential").findOneAndUpdate(
36
+ { user: userId, ...(expectedVersion === undefined ? {} : { version: expectedVersion }) },
37
+ { $set: { password_hash, version } },
38
+ { upsert: expectedVersion === undefined, returnDocument: "before" },
39
+ );
40
+ if (expectedVersion !== undefined && !previous) throw new HttpError(401, "invalidCredentials");
41
+
42
+ // Authorization already rejects the previous version even if cleanup fails.
43
+ // Never delete sessions issued under a newer, concurrent password change.
44
+ // Cached identities of this user go first: they carry the old version.
45
+ await forgetUser(ctx, userId);
46
+ await ctx.models!.get("Session").deleteMany({
47
+ user: userId,
48
+ credential_version: previous?.version ?? { $exists: false },
49
+ });
50
+ return version;
51
+ }
52
+
53
+ /** Read only the version, never the hash, during session authorization. */
54
+ export async function currentCredentialVersion(ctx: Ctx, userId: string): Promise<string | undefined> {
55
+ const credential = await ctx.models!.get<CredentialDoc>("Credential")
56
+ .findOne({ user: userId }).select("version").lean();
57
+ return credential?.version;
58
+ }
@@ -0,0 +1,67 @@
1
+ import { appCache } from "@app/core";
2
+ import type { Ctx } from "@tulipes/core/boot";
3
+
4
+ import type { SessionAttrs, UserAttrs } from "./documents.ts";
5
+
6
+ /**
7
+ * What a bearer token resolves to, kept in the app cache (Redis in
8
+ * production, shared by every replica) under the session id. A hit is one
9
+ * Redis read in place of the three Mongo reads the cold path performs
10
+ * (RevokedToken, Session, User); the entry lives no longer than
11
+ * `auth.cacheTtlSeconds`, and every path that revokes — logout, refresh
12
+ * reuse, rotation, session end, password change, user deletion — deletes
13
+ * it before answering, so a revoked session is refused on the very next
14
+ * request on any replica, as strictly as the cold path refuses it.
15
+ *
16
+ * The cache is best effort: a Redis outage is a miss, never an error, and
17
+ * the cold path is what every request takes then.
18
+ */
19
+ export interface CachedIdentity {
20
+ user: UserAttrs;
21
+ session: SessionAttrs;
22
+ }
23
+
24
+ const KEY = "auth:session:";
25
+
26
+ /** How long an entry may live: the setting, bounded by the access token's own lifetime. */
27
+ export function cacheTtlSeconds(ctx: Ctx): number {
28
+ const configured = Number(ctx.config.auth?.cacheTtlSeconds ?? 60);
29
+ const access = durationSeconds(String(ctx.config.auth?.accessTtl ?? "15m"));
30
+ return Math.max(0, Math.min(configured, access));
31
+ }
32
+
33
+ export async function readIdentity(ctx: Ctx, sid: string): Promise<CachedIdentity | null> {
34
+ if (cacheTtlSeconds(ctx) === 0) return null;
35
+ return appCache(ctx.Environment).get<CachedIdentity>(KEY + sid);
36
+ }
37
+
38
+ export async function writeIdentity(ctx: Ctx, sid: string, identity: CachedIdentity): Promise<void> {
39
+ const ttl = cacheTtlSeconds(ctx);
40
+ if (ttl === 0) return;
41
+ await appCache(ctx.Environment).set(KEY + sid, identity, ttl);
42
+ }
43
+
44
+ /** Forget one session: the next request with its token takes the cold path. */
45
+ export async function forgetSession(ctx: Ctx, sid: string): Promise<void> {
46
+ await appCache(ctx.Environment).del(KEY + sid);
47
+ }
48
+
49
+ /**
50
+ * Forget every session of a user — after a password change, a role
51
+ * change or a deletion. The sids come from the rows that exist now, so
52
+ * this runs BEFORE the rows are deleted when both happen.
53
+ */
54
+ export async function forgetUser(ctx: Ctx, userId: string): Promise<void> {
55
+ const rows = await ctx.models!.get<{ sid: string }>("Session").find({ user: userId }).select("sid -_id").lean();
56
+ await Promise.all(rows.map((row) => forgetSession(ctx, row.sid)));
57
+ }
58
+
59
+ /** `15m`, `1h`, `30d`, `900` (seconds) — the subset of ms/jsonwebtoken durations a TTL is written in. */
60
+ function durationSeconds(value: string): number {
61
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?\s*$/i.exec(value);
62
+ if (!match) return 15 * 60;
63
+ const amount = Number(match[1]);
64
+ const unit = (match[2] ?? "s").toLowerCase();
65
+ const factor = { ms: 0.001, s: 1, m: 60, h: 3600, d: 86_400, w: 604_800, y: 31_557_600 }[unit] ?? 1;
66
+ return Math.floor(amount * factor);
67
+ }
@@ -0,0 +1,329 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import jwt from "jsonwebtoken";
3
+ import type { Request } from "express";
4
+ import type { Ctx } from "@tulipes/core/boot";
5
+ import { HttpError } from "@tulipes/core/http";
6
+ import { currentCredentialVersion } from "./passwords.ts";
7
+ import { forgetSession, forgetUser } from "./session-cache.ts";
8
+
9
+ import type { SessionDoc } from "./documents.ts";
10
+
11
+ /**
12
+ * What a verified token tells us. Roles are never trusted from here.
13
+ *
14
+ * `sid` names the session both tokens of a pair belong to; `jti` names the
15
+ * individual token. The split is what lets an access token find its own
16
+ * session row, whose refresh jti has meanwhile rotated out from under it.
17
+ */
18
+ export interface TokenClaims {
19
+ sub: string;
20
+ sid: string;
21
+ jti: string;
22
+ typ: "access" | "refresh";
23
+ iat: number;
24
+ exp: number;
25
+ }
26
+
27
+ export interface TokenPair {
28
+ access_token: string;
29
+ refresh_token: string;
30
+ expires_in: string;
31
+ }
32
+
33
+ /** Where a session was started, for the row and for the user to review. */
34
+ export interface SessionOrigin {
35
+ ip?: string;
36
+ user_agent?: string;
37
+ }
38
+
39
+ const secret = (ctx: Ctx, typ: "access" | "refresh"): string =>
40
+ String(
41
+ ctx.Environment.get(
42
+ typ === "access" ? "JWT_ACCESS_SECRET" : "JWT_REFRESH_SECRET",
43
+ ),
44
+ );
45
+
46
+ // Read at signing time, never cached: token policy is a setting an operator
47
+ // changes without a restart.
48
+ const ttl = (ctx: Ctx, typ: "access" | "refresh"): string =>
49
+ String(typ === "access" ? ctx.config.auth?.accessTtl ?? "15m" : ctx.config.auth?.refreshTtl ?? "30d");
50
+
51
+ /**
52
+ * Mint a pair for an existing session.
53
+ *
54
+ * The access token deliberately carries no role. Roles change, tokens do
55
+ * not, and a stale role in a signed token is a privilege bug waiting to
56
+ * happen — the request pipeline reloads the user instead.
57
+ */
58
+ function mint(
59
+ ctx: Ctx,
60
+ userId: string,
61
+ sid: string,
62
+ ): TokenPair & { refreshJti: string; refreshExp: number } {
63
+ const refreshJti = randomUUID();
64
+
65
+ const access_token = jwt.sign({ typ: "access", sid }, secret(ctx, "access"), {
66
+ subject: userId,
67
+ jwtid: randomUUID(),
68
+ expiresIn: ttl(ctx, "access") as jwt.SignOptions["expiresIn"],
69
+ });
70
+
71
+ const refresh_token = jwt.sign(
72
+ { typ: "refresh", sid },
73
+ secret(ctx, "refresh"),
74
+ {
75
+ subject: userId,
76
+ jwtid: refreshJti,
77
+ expiresIn: ttl(ctx, "refresh") as jwt.SignOptions["expiresIn"],
78
+ },
79
+ );
80
+
81
+ const { exp } = jwt.decode(refresh_token) as { exp: number };
82
+ return {
83
+ access_token,
84
+ refresh_token,
85
+ expires_in: ttl(ctx, "access"),
86
+ refreshJti,
87
+ refreshExp: exp,
88
+ };
89
+ }
90
+
91
+ /** Open a session — a sign-in, from a device worth remembering. */
92
+ export async function startSession(
93
+ ctx: Ctx,
94
+ userId: string,
95
+ credentialVersion: string,
96
+ origin: SessionOrigin = {},
97
+ ): Promise<TokenPair> {
98
+ const sid = randomUUID();
99
+ const { refreshJti, refreshExp, ...pair } = mint(ctx, userId, sid);
100
+
101
+ await ctx.models!.get("Session").create({
102
+ sid,
103
+ user: userId,
104
+ credential_version: credentialVersion,
105
+ refresh_jti: refreshJti,
106
+ ip: origin.ip,
107
+ user_agent: origin.user_agent,
108
+ expires_at: new Date(refreshExp * 1000),
109
+ });
110
+
111
+ // A password change may have committed while this insert was in flight.
112
+ if (!(await loadSession(ctx, sid, userId))) {
113
+ await endSession(ctx, sid);
114
+ throw new HttpError(401, "invalidCredentials");
115
+ }
116
+
117
+ return pair;
118
+ }
119
+
120
+ /**
121
+ * Each sid is one device's token family. A valid current token rotates it;
122
+ * a valid superseded token expires it, including all previously issued access.
123
+ * Both decisions happen in one document write, so reuse cannot race a second
124
+ * revocation write or leave a newly rotated descendant alive afterward.
125
+ */
126
+ export async function rotateSession(
127
+ ctx: Ctx,
128
+ claims: TokenClaims,
129
+ ): Promise<TokenPair | undefined> {
130
+ // Only verified refresh claims belong here; retain shape/type checks for
131
+ // custom callers before constructing a query or an aggregation expression.
132
+ if (!validClaims(claims, "refresh")) return undefined;
133
+
134
+ try {
135
+ const version = await currentCredentialVersion(ctx, claims.sub);
136
+ if (!version) return undefined;
137
+
138
+ const now = new Date();
139
+ const { refreshJti, refreshExp, ...pair } = mint(
140
+ ctx,
141
+ claims.sub,
142
+ claims.sid,
143
+ );
144
+ const current = { $eq: ["$refresh_jti", { $literal: claims.jti }] };
145
+
146
+ const previous = await ctx
147
+ .models!.get<SessionDoc>("Session")
148
+ .findOneAndUpdate(
149
+ {
150
+ sid: claims.sid,
151
+ user: claims.sub,
152
+ credential_version: version,
153
+ expires_at: { $gt: now },
154
+ },
155
+ [
156
+ {
157
+ $set: {
158
+ refresh_jti: { $cond: [current, refreshJti, "$refresh_jti"] },
159
+ // Existing access/session checks already reject this expired family.
160
+ // Its TTL removes the row later; cleanup timing is not authorization.
161
+ expires_at: {
162
+ $cond: [current, new Date(refreshExp * 1000), new Date(0)],
163
+ },
164
+ last_seen_at: { $cond: [current, now, "$last_seen_at"] },
165
+ },
166
+ },
167
+ ],
168
+ {
169
+ returnDocument: "before",
170
+ writeConcern: { w: "majority", j: true, wtimeout: 5000 },
171
+ },
172
+ );
173
+
174
+ // Either branch changed the row — rotated, or expired by reuse — so a
175
+ // cached copy of it is stale either way; the next request re-reads.
176
+ if (previous) await forgetSession(ctx, claims.sid);
177
+
178
+ // Returning the pre-update row tells us which atomic branch actually ran.
179
+ // Another replay may revoke this pair before its HTTP response is delivered.
180
+ return previous?.refresh_jti === claims.jti ? pair : undefined;
181
+ } catch {
182
+ // A missing acknowledgment is not proof that MongoDB applied no change.
183
+ // Never return a pair or claim successful revocation on that uncertain path.
184
+ throw new HttpError(503, "refreshUnavailable", {
185
+ code: "REFRESH_UNAVAILABLE",
186
+ defaultMessage:
187
+ "The refresh operation could not be acknowledged; sign in again",
188
+ });
189
+ }
190
+ }
191
+
192
+ /** Verify signature, expiry, type and the shape required for scoped session writes. */
193
+ export function verify(
194
+ ctx: Ctx,
195
+ token: string,
196
+ typ: "access" | "refresh",
197
+ ): TokenClaims | undefined {
198
+ try {
199
+ const claims = jwt.verify(token, secret(ctx, typ));
200
+ return validClaims(claims, typ) ? claims : undefined;
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+
206
+ function validClaims(
207
+ value: unknown,
208
+ typ: "access" | "refresh",
209
+ ): value is TokenClaims {
210
+ if (!value || typeof value !== "object") return false;
211
+
212
+ const claims = value as Record<string, unknown>;
213
+ const identifier = (value: unknown): value is string =>
214
+ typeof value === "string" && value.length > 0 && value.length <= 128;
215
+
216
+ return (
217
+ claims.typ === typ &&
218
+ identifier(claims.sub) &&
219
+ /^[a-f0-9]{24}$/i.test(claims.sub) &&
220
+ identifier(claims.sid) &&
221
+ identifier(claims.jti) &&
222
+ typeof claims.iat === "number" &&
223
+ Number.isSafeInteger(claims.iat) &&
224
+ typeof claims.exp === "number" &&
225
+ Number.isSafeInteger(claims.exp) &&
226
+ claims.iat >= 0 &&
227
+ claims.exp > claims.iat
228
+ );
229
+ }
230
+
231
+ /** A live session owned by the token subject. TTL cleanup is not authorization. */
232
+ export async function loadSession(
233
+ ctx: Ctx,
234
+ sid: string,
235
+ userId: string,
236
+ ): Promise<SessionDoc | null> {
237
+ const version = await currentCredentialVersion(ctx, userId);
238
+ if (!version) return null;
239
+
240
+ return ctx.models!.get<SessionDoc>("Session").findOne({
241
+ sid,
242
+ user: userId,
243
+ credential_version: version,
244
+ expires_at: { $gt: new Date() },
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Record that a session is still in use — at most once per `afterSeconds`,
250
+ * because a write on every request would cost more than the field is
251
+ * worth. Returns whether it wrote, so a cached copy can follow.
252
+ */
253
+ export async function touchSession(
254
+ ctx: Ctx,
255
+ session: SessionDoc,
256
+ afterSeconds: number,
257
+ ): Promise<boolean> {
258
+ const age = Date.now() - (session.last_seen_at?.getTime() ?? 0);
259
+ if (age < afterSeconds * 1000) return false;
260
+
261
+ const now = new Date();
262
+ session.last_seen_at = now;
263
+ await ctx
264
+ .models!.get("Session")
265
+ .updateOne({ sid: session.sid }, { last_seen_at: now });
266
+ return true;
267
+ }
268
+
269
+ /** End one session: the refresh half dies with its row, and its cached identity with it. */
270
+ export async function endSession(ctx: Ctx, sid: string): Promise<boolean> {
271
+ await forgetSession(ctx, sid);
272
+ const result = await ctx.models!.get("Session").deleteOne({ sid });
273
+ return result.deletedCount === 1;
274
+ }
275
+
276
+ /** Blacklist an access token for whatever is left of its lifetime. */
277
+ export async function revokeAccess(
278
+ ctx: Ctx,
279
+ claims: TokenClaims,
280
+ ): Promise<void> {
281
+ await forgetSession(ctx, claims.sid);
282
+ await ctx
283
+ .models!.get("RevokedToken")
284
+ .updateOne(
285
+ { jti: claims.jti },
286
+ {
287
+ $setOnInsert: {
288
+ jti: claims.jti,
289
+ expires_at: new Date(claims.exp * 1000),
290
+ },
291
+ },
292
+ { upsert: true },
293
+ );
294
+ }
295
+
296
+ export async function isRevoked(ctx: Ctx, jti: string): Promise<boolean> {
297
+ return (
298
+ (await ctx.models!.get("RevokedToken").countDocuments({ jti }).limit(1)) > 0
299
+ );
300
+ }
301
+
302
+ /** Drop stored sessions for a user; this alone does not block concurrent sign-ins. */
303
+ export async function endAllSessions(ctx: Ctx, userId: string): Promise<void> {
304
+ await forgetUser(ctx, userId);
305
+ await ctx.models!.get("Session").deleteMany({ user: userId });
306
+ }
307
+
308
+ /** Active devices, newest first, including when expired rows await TTL cleanup. */
309
+ export async function listSessions(
310
+ ctx: Ctx,
311
+ userId: string,
312
+ ): Promise<SessionDoc[]> {
313
+ const version = await currentCredentialVersion(ctx, userId);
314
+ if (!version) return [];
315
+
316
+ return ctx
317
+ .models!.get<SessionDoc>("Session")
318
+ .find({
319
+ user: userId,
320
+ credential_version: version,
321
+ expires_at: { $gt: new Date() },
322
+ })
323
+ .sort({ created_at: -1 });
324
+ }
325
+
326
+ /** Where a request came from, for the session row. */
327
+ export function originOf(req: Request): SessionOrigin {
328
+ return { ip: req.ip, user_agent: req.header("user-agent") };
329
+ }