@tulipes/cli 0.1.1 → 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 +10 -7
  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 +11 -6422
  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 +84 -43
  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 +68 -31
  47. package/templates/CLAUDE.md +5 -4
  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,409 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import { z } from "zod/v4";
4
+ import { afterAll, beforeAll, expect, test, vi } from "vitest";
5
+ import { boot } from "@tulipes/core/boot";
6
+ import { useTestApp } from "@tulipes/testing";
7
+
8
+ import { testEnv, testSettings } from "../../../tests/support.ts";
9
+
10
+ const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
+ const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
12
+
13
+
14
+ const tokens = z.object({
15
+ data: z.object({ access_token: z.string(), refresh_token: z.string() }),
16
+ });
17
+
18
+ async function post(path: string, body: unknown, token?: string) {
19
+ return fetch(app.base + path, {
20
+ method: "POST",
21
+ headers: {
22
+ "content-type": "application/json",
23
+ ...(token ? { authorization: "Bearer " + token } : {}),
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ }
28
+
29
+ async function account() {
30
+ const credentials = {
31
+ email: randomBytes(12).toString("hex") + "@example.test",
32
+ password: randomBytes(24).toString("hex"),
33
+ };
34
+
35
+ const response = await post("/api/v1/auth/register", credentials);
36
+
37
+ expect(response.status).toBe(201);
38
+
39
+ const body = await response.json();
40
+
41
+ const { user } = z
42
+ .object({ data: z.object({ user: z.object({ id: z.string() }) }) })
43
+ .parse(body).data;
44
+
45
+ return { ...tokens.parse(body).data, ...credentials, userId: user.id };
46
+ }
47
+
48
+ async function get(path: string, token: string) {
49
+ return fetch(app.base + path, { headers: { authorization: "Bearer " + token } });
50
+ }
51
+
52
+ async function sysadmin() {
53
+ const user = await account();
54
+
55
+ await app.handle.ctx
56
+ .models!.get("User")
57
+ .updateOne({ _id: user.userId }, { role: "sysadmin" });
58
+
59
+ return user;
60
+ }
61
+
62
+ async function logFiles(run: (dir: string) => Promise<void>) {
63
+ const { mkdtemp, rm } = await import("node:fs/promises");
64
+ const { tmpdir } = await import("node:os");
65
+ const { join } = await import("node:path");
66
+ const dir = await mkdtemp(join(tmpdir(), "tulipes-log-http-"));
67
+ const env = app.handle.ctx.Environment;
68
+
69
+ const previous = {
70
+ file: env.get("LOG_FILE"),
71
+ dir: env.get("LOG_DIR"),
72
+ };
73
+
74
+ env.store.set("LOG_FILE", true);
75
+
76
+ env.store.set("LOG_DIR", dir);
77
+
78
+ try {
79
+ await run(dir);
80
+ } finally {
81
+ env.store.set("LOG_FILE", previous.file);
82
+
83
+ env.store.set("LOG_DIR", previous.dir);
84
+
85
+ await rm(dir, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ test("log streaming enforces sysadmin access and never follows linked log targets", async () => {
90
+ const ordinary = await account();
91
+ const user = await sysadmin();
92
+
93
+ await logFiles(async (dir) => {
94
+ const { writeFile, symlink } = await import("node:fs/promises");
95
+ const { join } = await import("node:path");
96
+ const canary = randomBytes(24).toString("hex");
97
+
98
+ await writeFile(join(dir, "private"), canary);
99
+
100
+ await symlink(join(dir, "private"), join(dir, "app-backend.log"));
101
+
102
+ expect(
103
+ (await get("/api/v1/settings/logs/stream", ordinary.access_token)).status,
104
+ ).toBe(403);
105
+
106
+ const response = await get(
107
+ "/api/v1/settings/logs/stream",
108
+ user.access_token,
109
+ );
110
+
111
+ expect(response.status).toBe(409);
112
+ expect((await response.text()).includes(canary)).toBe(false);
113
+ });
114
+ });
115
+
116
+ test("log stream slots are capped per account and released on HTTP cancellation", async () => {
117
+ const user = await sysadmin();
118
+
119
+ await logFiles(async (dir) => {
120
+ const { writeFile } = await import("node:fs/promises");
121
+ const { join } = await import("node:path");
122
+
123
+ await writeFile(
124
+ join(dir, "app-backend.2026-09-10.1.log"),
125
+ JSON.stringify({ level: 30, msg: "retained line" }) + "\n",
126
+ );
127
+
128
+ const first = await get("/api/v1/settings/logs/stream", user.access_token);
129
+ const second = await get("/api/v1/settings/logs/stream", user.access_token);
130
+
131
+ try {
132
+ expect(first.status).toBe(200);
133
+ expect(first.headers.get("content-type")).toContain("text/event-stream");
134
+
135
+ const blocked = await get(
136
+ "/api/v1/settings/logs/stream",
137
+ user.access_token,
138
+ );
139
+
140
+ expect(blocked.status).toBe(429);
141
+
142
+ expect(await blocked.json()).toMatchObject({
143
+ errors: [{ code: "TOO_MANY_LOG_STREAMS" }],
144
+ });
145
+ } finally {
146
+ await first.body?.cancel();
147
+
148
+ await second.body?.cancel();
149
+ }
150
+
151
+ let reopened: Response | undefined;
152
+
153
+ await vi.waitFor(async () => {
154
+ const response = await get(
155
+ "/api/v1/settings/logs/stream",
156
+ user.access_token,
157
+ );
158
+
159
+ if (response.status === 200) reopened = response;
160
+
161
+ expect(response.status).toBe(200);
162
+ });
163
+
164
+ try {
165
+ const reader = reopened!.body!.getReader();
166
+ let received = "";
167
+
168
+ try {
169
+ while (!received.includes("retained line")) {
170
+ const part = await reader.read();
171
+
172
+ if (part.done) break;
173
+
174
+ received += new TextDecoder().decode(part.value);
175
+ }
176
+
177
+ expect(received).toContain("event: ready");
178
+ expect(received).toContain("retained line");
179
+ } finally {
180
+ await reader.cancel();
181
+ }
182
+ } finally {
183
+ if (!reopened!.body!.locked) await reopened!.body!.cancel();
184
+ }
185
+ });
186
+ });
187
+
188
+ test.each([
189
+ "role removal",
190
+ "user deletion",
191
+ "session revocation",
192
+ "session expiry",
193
+ "access revocation",
194
+ "logout",
195
+ "password replacement",
196
+ "refresh reuse",
197
+ ])("an open log stream closes after %s", async (change) => {
198
+ const user = await sysadmin();
199
+
200
+ const { verify, endSession, revokeAccess } = await import(
201
+ "../../auth/helpers/tokens.ts"
202
+ );
203
+
204
+ const claims = verify(app.handle.ctx, user.access_token, "access")!;
205
+
206
+ await logFiles(async (dir) => {
207
+ const response = await get(
208
+ "/api/v1/settings/logs/stream?tail=0",
209
+ user.access_token,
210
+ );
211
+
212
+ expect(response.status).toBe(200);
213
+
214
+ const reader = response.body!.getReader();
215
+ const decoder = new TextDecoder();
216
+ let body = "";
217
+
218
+ try {
219
+ // Observe actual SSE delivery before changing the authority behind it.
220
+ while (!body.includes("event: ready")) {
221
+ const part = await reader.read();
222
+
223
+ expect(part.done).toBe(false);
224
+
225
+ body += decoder.decode(part.value);
226
+ }
227
+
228
+ if (change === "role removal") {
229
+ await app.handle.ctx
230
+ .models!.get("User")
231
+ .updateOne({ _id: user.userId }, { role: "admin" });
232
+ } else if (change === "user deletion") {
233
+ await app.handle.ctx.models!.get("User").deleteOne({ _id: user.userId });
234
+ } else if (change === "session revocation") {
235
+ await endSession(app.handle.ctx, claims.sid);
236
+ } else if (change === "session expiry") {
237
+ await app.handle.ctx
238
+ .models!.get("Session")
239
+ .updateOne({ sid: claims.sid }, { expires_at: new Date(0) });
240
+ } else if (change === "access revocation") {
241
+ await revokeAccess(app.handle.ctx, claims);
242
+ } else if (change === "logout") {
243
+ expect(
244
+ (await post("/api/v1/auth/logout", {}, user.access_token)).status,
245
+ ).toBe(200);
246
+ } else if (change === "password replacement") {
247
+ const { setPassword } = await import(
248
+ "../../auth/helpers/passwords.ts"
249
+ );
250
+
251
+ await setPassword(
252
+ app.handle.ctx,
253
+ user.userId,
254
+ randomBytes(24).toString("hex"),
255
+ );
256
+ } else {
257
+ expect(
258
+ (
259
+ await post("/api/v1/auth/refresh", {
260
+ refresh_token: user.refresh_token,
261
+ })
262
+ ).status,
263
+ ).toBe(200);
264
+
265
+ expect(
266
+ (
267
+ await post("/api/v1/auth/refresh", {
268
+ refresh_token: user.refresh_token,
269
+ })
270
+ ).status,
271
+ ).toBe(401);
272
+ }
273
+
274
+ const started = performance.now();
275
+ let timeout: ReturnType<typeof setTimeout> | undefined;
276
+
277
+ try {
278
+ await Promise.race([
279
+ (async () => {
280
+ for (;;) {
281
+ const part = await reader.read();
282
+
283
+ if (part.done) break;
284
+
285
+ body += decoder.decode(part.value);
286
+ }
287
+ })(),
288
+ new Promise<never>((_resolve, reject) => {
289
+ timeout = setTimeout(
290
+ () => reject(new Error("Revoked stream stayed open")),
291
+ 3000,
292
+ );
293
+ }),
294
+ ]);
295
+ } finally {
296
+ clearTimeout(timeout);
297
+ }
298
+
299
+ // Two-second authorization lease plus scheduling tolerance on this runner.
300
+ expect(performance.now() - started).toBeLessThan(3000);
301
+
302
+ const { writeFile } = await import("node:fs/promises");
303
+ const { join } = await import("node:path");
304
+
305
+ await writeFile(join(dir, "app-backend.log"), "after revocation\n");
306
+
307
+ expect((await reader.read()).done).toBe(true);
308
+ expect(body).not.toContain("after revocation");
309
+
310
+ // A different administrator can still use the controller after closure.
311
+ const other = await sysadmin();
312
+
313
+ const replacement = await get(
314
+ "/api/v1/settings/logs/stream?tail=0",
315
+ other.access_token,
316
+ );
317
+
318
+ expect(replacement.status).toBe(200);
319
+
320
+ await replacement.body?.cancel();
321
+ } finally {
322
+ await reader.cancel();
323
+ }
324
+ });
325
+ });
326
+
327
+ test("shutdown drains settings streams and a later boot owns fresh stream admission", async () => {
328
+ const user = await sysadmin();
329
+
330
+ await logFiles(async (dir) => {
331
+ const { writeFile } = await import("node:fs/promises");
332
+ const { join } = await import("node:path");
333
+
334
+ await writeFile(
335
+ join(dir, "app-backend.log"),
336
+ JSON.stringify({ msg: "before drain" }) + "\n",
337
+ );
338
+
339
+ const active = await get("/api/v1/settings/logs/stream", user.access_token);
340
+
341
+ const idle = await get(
342
+ "/api/v1/settings/logs/stream?tail=0",
343
+ user.access_token,
344
+ );
345
+
346
+ expect(active.status).toBe(200);
347
+ expect(idle.status).toBe(200);
348
+
349
+ const readers = [active.body!.getReader(), idle.body!.getReader()];
350
+
351
+ try {
352
+ for (const [index, reader] of readers.entries()) {
353
+ let body = "";
354
+
355
+ do {
356
+ const part = await reader.read();
357
+
358
+ expect(part.done).toBe(false);
359
+
360
+ body += new TextDecoder().decode(part.value);
361
+ } while (!body.includes(index ? "event: ready" : "before drain"));
362
+ }
363
+
364
+ const stopping = app.handle.shutdown();
365
+
366
+ expect(app.handle.shutdown()).toBe(stopping);
367
+
368
+ await expect(stopping).resolves.toBeUndefined();
369
+
370
+ for (const reader of readers)
371
+ expect((await reader.read()).done).toBe(true);
372
+
373
+ // The module is imported once, but stream admission belongs to each boot.
374
+ const next = await boot({
375
+ rootDir,
376
+ appEnv: "test",
377
+ handleSignals: false,
378
+ });
379
+
380
+ try {
381
+ next.env.store.set("LOG_FILE", true);
382
+
383
+ next.env.store.set("LOG_DIR", dir);
384
+
385
+ const address = next.server!.address();
386
+
387
+ if (!address || typeof address === "string")
388
+ throw new Error("No listener after restart");
389
+
390
+ const response = await fetch(
391
+ "http://127.0.0.1:" +
392
+ address.port +
393
+ "/api/v1/settings/logs/stream?tail=0",
394
+ {
395
+ headers: { authorization: "Bearer " + user.access_token },
396
+ },
397
+ );
398
+
399
+ expect(response.status).toBe(200);
400
+
401
+ await response.body?.cancel();
402
+ } finally {
403
+ await next.shutdown();
404
+ }
405
+ } finally {
406
+ await Promise.all(readers.map((reader) => reader.cancel()));
407
+ }
408
+ });
409
+ });
@@ -0,0 +1,113 @@
1
+ import { forgetUser } from "@app/auth";
2
+ import type { Request, RequestHandler, Response } from "express";
3
+ import type { Ctx } from "@tulipes/core/boot";
4
+ import { HttpError } from "@tulipes/core/http";
5
+
6
+ /**
7
+ * One class per controller, holding the context. Each method returns a
8
+ * handler, so the routes file stays a table of contents.
9
+ *
10
+ * Every response goes through `res.respond`, which wraps the payload in
11
+ * the app's envelope. Failures are thrown rather than responded — the
12
+ * framework's terminal handler builds the same envelope from them — so a
13
+ * handler only ever writes the happy path.
14
+ *
15
+ * Thrown messages are i18n KEYS: the handler translates them in this
16
+ * module's namespace and derives the error code from the key, so
17
+ * "notFound" becomes NOT_FOUND with a translated message.
18
+ */
19
+ export class UsersController {
20
+ readonly #ctx: Ctx;
21
+
22
+ constructor(ctx: Ctx) {
23
+ this.#ctx = ctx;
24
+ }
25
+
26
+ list(): RequestHandler {
27
+ return async (req: Request, res: Response) => {
28
+ // ?page and ?per_page, already clamped by config.pagination.
29
+ const { per_page, skip } = req.page;
30
+ const User = this.#ctx.models!.get("User");
31
+
32
+ const [users, total_items] = await Promise.all([
33
+ User.find().select("email role -_id").skip(skip).limit(per_page).lean(),
34
+ User.countDocuments(),
35
+ ]);
36
+
37
+ // Passing total_items is what marks this a page: respond fills in
38
+ // page, per_page and total_pages from the request and the count.
39
+ res.respond({ data: users, meta: { total_items } });
40
+ };
41
+ }
42
+
43
+ /** Soft delete: the row stays in mongo but leaves every ordinary query. */
44
+ softDelete(): RequestHandler {
45
+ return async (req: Request, res: Response) => {
46
+ const user = await this.#ctx.models!.get("User").findOne({ email: req.params.email });
47
+ if (!user) throw new HttpError(404, "notFound");
48
+
49
+ // The account leaves every query, so its sessions resolve to nobody on
50
+ // the cold path; the cached identities would not know, so they go too.
51
+ await forgetUser(this.#ctx, String(user._id));
52
+ await (user as unknown as { softDelete(): Promise<unknown> }).softDelete();
53
+ res.respond({ data: { email: req.params.email, soft_deleted: true } });
54
+ };
55
+ }
56
+
57
+ listDeleted(): RequestHandler {
58
+ return async (_req: Request, res: Response) => {
59
+ const deleted = await this.#ctx.models!
60
+ .get("User")
61
+ .find({ is_deleted: true })
62
+ .setOptions({ withDeleted: true } as Record<string, unknown>)
63
+ .select("email role -_id")
64
+ .lean();
65
+
66
+ res.respond({ data: deleted });
67
+ };
68
+ }
69
+
70
+ create(): RequestHandler {
71
+ return async (req: Request, res: Response) => {
72
+ const { email } = (req.body ?? {}) as { email?: string };
73
+ // `field` is what puts this against the input in the envelope.
74
+ if (!email) throw new HttpError(422, "emailRequired", { field: "email" });
75
+
76
+ await this.#ctx.models!.get("User").create({ email });
77
+ // The locale travels with the job: the worker has no request to
78
+ // negotiate from, so whoever queues the work owns that decision.
79
+ await this.#ctx.queues!.add("users.welcome", "welcome", {
80
+ email,
81
+ locale: req.locale,
82
+ });
83
+
84
+ res.respond({ status: 201, data: { email, welcome_queued: true } });
85
+ };
86
+ }
87
+
88
+ ping(): RequestHandler {
89
+ return (req: Request, res: Response) => {
90
+ res.respond({
91
+ data: {
92
+ module: "users",
93
+ locale: req.locale,
94
+ max_sessions: this.#ctx.config.users?.maxSessions,
95
+ },
96
+ });
97
+ };
98
+ }
99
+
100
+ /** Demonstrates HttpError — the framework renders it into the envelope. */
101
+ demoMissing(): RequestHandler {
102
+ return async () => {
103
+ throw new HttpError(404, "notFound", { details: { hint: "demo endpoint" } });
104
+ };
105
+ }
106
+
107
+ /** Demonstrates an unexpected error: anonymous 500 outside development. */
108
+ demoBoom(): RequestHandler {
109
+ return async () => {
110
+ throw new Error("secret internals that must not leak in production");
111
+ };
112
+ }
113
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "created": "User {{email}} created",
3
+ "deleted": "User {{email}} deleted",
4
+ "notFound": "No such user",
5
+ "emailRequired": "An email address is required",
6
+ "list": {
7
+ "count_one": "{{count}} user",
8
+ "count_other": "{{count}} users"
9
+ },
10
+ "welcome": {
11
+ "subject": "Welcome, {{email}}"
12
+ },
13
+ "emailInvalid": "Must be a valid email address"
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "created": "Utilisateur {{email}} créé",
3
+ "deleted": "Utilisateur {{email}} supprimé",
4
+ "notFound": "Utilisateur introuvable",
5
+ "emailRequired": "Une adresse email est requise",
6
+ "list": {
7
+ "count_one": "{{count}} utilisateur",
8
+ "count_other": "{{count}} utilisateurs"
9
+ },
10
+ "welcome": {
11
+ "subject": "Bienvenue, {{email}}"
12
+ },
13
+ "emailInvalid": "Doit être une adresse email valide"
14
+ }
@@ -0,0 +1,18 @@
1
+ import { Schema } from "mongoose";
2
+ import type { ModelDef } from "@tulipes/mongoose";
3
+ // Cross-module import by package name, never a relative path out of the
4
+ // module — the dependency is declared in package.json, so it is visible.
5
+ import { baseSchemaOptions } from "@app/core";
6
+
7
+ const userSchema = new Schema(
8
+ {
9
+ email: { type: String, required: true, unique: true, lowercase: true },
10
+ role: { type: String, required: true, default: "user" },
11
+ // Retained for existing data/custom policies. The starter now authorizes
12
+ // sessions against Credential.version, which changes atomically with its hash.
13
+ tokens_valid_from: { type: Date },
14
+ },
15
+ baseSchemaOptions,
16
+ );
17
+
18
+ export default { name: "User", schema: userSchema } satisfies ModelDef;
@@ -0,0 +1,10 @@
1
+ import type { AclBuilder } from "@tulipes/core/acl";
2
+
3
+ export default function usersAcl(acl: AclBuilder): void {
4
+ acl.allow("user", "users:read", "users:ping");
5
+ // The demo endpoints are open so the error-handling examples stay
6
+ // curl-able without a token.
7
+ acl.allow("guest", "users:demo-missing", "users:demo-boom", "users:ping");
8
+ // Everything else — create, delete, read-deleted — stays with admin,
9
+ // which already holds "*".
10
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "@tulipes/core/config";
2
+ import { z } from "zod/v4";
3
+
4
+ export default defineConfig({
5
+ schema: z.object({
6
+ maxSessions: z.number().int().positive().default(5)
7
+ .describe("Max concurrent sessions per user; the oldest is evicted past it"),
8
+ signupMode: z.enum(["open", "invite", "closed"]).default("open")
9
+ .describe("Who can register: open to anyone, invite only, or nobody"),
10
+ }),
11
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@app/users",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "tulipes": {
7
+ "tier": "app",
8
+ "priority": 10,
9
+ "dependsOn": [
10
+ "auth",
11
+ "core"
12
+ ]
13
+ },
14
+ "dependencies": {
15
+ "@app/auth": "workspace:*",
16
+ "@app/core": "workspace:*",
17
+ "@tulipes/bullmq": "workspace:*",
18
+ "@tulipes/core": "workspace:*",
19
+ "@tulipes/mongoose": "workspace:*",
20
+ "@tulipes/socket.io": "workspace:*",
21
+ "express": "^5",
22
+ "mongoose": "^8"
23
+ }
24
+ }
@@ -0,0 +1,31 @@
1
+ import type { Ctx } from "@tulipes/core/boot";
2
+ import type { JobSchemas, QueueRegistry } from "@tulipes/bullmq";
3
+ import { z } from "zod/v4";
4
+
5
+ import { moduleLogger } from "@app/core";
6
+
7
+ /** The declaration: defines the queue, validates producers, types both sides. */
8
+ export const jobs = {
9
+ "users.welcome": z.object({ email: z.email(), locale: z.string() }),
10
+ } satisfies JobSchemas;
11
+
12
+ /**
13
+ * Runs in BOTH process modes: backend registers the queue and produces into it,
14
+ * the worker process turns the processor into a live consumer.
15
+ */
16
+ export default function usersQueues(ctx: Ctx, queues: QueueRegistry): void {
17
+ // The same logger the backend uses. In the worker process it writes
18
+ // to app-worker.log, so the two processes never share a file.
19
+ const log = moduleLogger(ctx.Environment, "users", ctx.mode);
20
+
21
+ queues.process("users.welcome", async (job) => {
22
+ // No request here to negotiate a locale from, so the one the caller
23
+ // was using travelled with the job. `ctx.t` binds this module's
24
+ // namespace explicitly — the part `req.t` normally does for free.
25
+ const t = ctx.t("users", job.data.locale);
26
+
27
+ log.info({ email: job.data.email, subject: t("welcome.subject", { email: job.data.email }) },
28
+ "sending welcome email");
29
+ return { sent: true };
30
+ });
31
+ }