@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,327 @@
1
+ # tulipes-example
2
+
3
+ A production-preset [Tulipes](https://www.npmjs.com/package/@tulipes/core) application. Requires Node 24.x.
4
+
5
+ ## Run
6
+
7
+ ```sh
8
+ npm install -g pnpm # once per machine
9
+ pnpm install
10
+ pnpm dev # boot the backend process → http://localhost:3000/api/v1/hello
11
+ ```
12
+
13
+ Needs mongo and redis running — see `MONGO_URI` and `REDIS_URL` in
14
+ `.envs/.env.development`. Run `pnpm worker` in a second terminal to
15
+ consume queued jobs.
16
+
17
+ `.envs/` is git-ignored: those files hold working credentials. On a
18
+ fresh clone there is nothing in it, so copy `.env.example` — which is
19
+ committed, and which `pnpm sync` regenerates from every module's
20
+ declared variables — and fill it in:
21
+
22
+ ```sh
23
+ pnpm sync # refresh .env.example and types
24
+ mkdir -p .envs # git does not track empty dirs
25
+ cp .env.example .envs/.env.development
26
+ pnpm check # validate it without booting
27
+ ```
28
+
29
+ ## Verify
30
+
31
+ Run `pnpm sync`, `pnpm typecheck` and `pnpm check`. Tests need dedicated
32
+ services: `TEST_MONGO_URI=mongodb://127.0.0.1:27017/tulipes_tests TEST_REDIS_URL=redis://127.0.0.1:6379/3 pnpm test`.
33
+ Use test-only data: the suite creates users and sessions. `pnpm spec` writes
34
+ OpenAPI/Postman documents offline, without services or runtime secrets.
35
+ Change the shared API prefix in `config/routes.ts`. Runtime middleware stays
36
+ with endpoints in `routes/*.routes.ts`, plain functions of the context.
37
+ Staging/production boot rejects development credentials and equal JWT keys.
38
+ Auth, settings and deployment examples are application policy you own.
39
+
40
+ ## The hello module
41
+
42
+ `modules/hello` is a worked example of every module contract — read it
43
+ before writing your own:
44
+
45
+ | File | Contract |
46
+ |---|---|
47
+ | `package.json` `tulipes.env` | the module's environment contract |
48
+ | `module.config.ts` | `defineConfig({ schema })`: this module's settings |
49
+ | `module.acl.ts` | permission grants on `hello:*` |
50
+ | `routes/*.routes.ts` | native routers, shared middleware and RAI schemas |
51
+ | `controllers/` | the work; constructed from the context while routes build |
52
+ | `helpers/` | pure logic, no framework |
53
+ | `i18n/<locale>.json` | this module's strings — the module is the namespace |
54
+ | `sockets/*.sockets.ts` | a claimed Socket.IO namespace | <!-- feature:sockets -->
55
+ | `models/*.model.ts` | a schema declaration for the model store |
56
+ | `bootstrap/*.bootstrap.ts` | idempotent seeding, before traffic |
57
+ | `queues/*.queues.ts` | a job definition and its processor | <!-- feature:queues -->
58
+
59
+ ## Daily commands
60
+
61
+ | command | does |
62
+ |---|---|
63
+ | `pnpm dev` | regenerate types, run the backend process with `node --watch` |
64
+ | `pnpm worker` | run the queue-consumer process (needs a queue first) | <!-- feature:queues -->
65
+ | `pnpm sync` | regenerate `types/tulipes.d.ts` + `.env.example` |
66
+ | `pnpm check` | validate env against every module contract — CI gate |
67
+ | `pnpm tulipes new module <name>` | scaffold a module |
68
+ | `pnpm tulipes update` | upgrade the framework everywhere it is declared |
69
+
70
+ ## Authentication
71
+
72
+ `modules/auth` is sys tier, so it resolves the caller before any
73
+ feature route runs. A request with no valid token is not rejected — it
74
+ becomes the **guest** caller, which is what makes a public endpoint
75
+ an ordinary ACL grant instead of a second mechanism:
76
+
77
+ ```ts
78
+ acl.allow("guest", "auth:login"); // that is why login is reachable
79
+ ```
80
+
81
+ Guarding is the RAI itself — its `id` IS the permission, so there is
82
+ no second call to forget. 401 means "sign in"; 403 means "signed in,
83
+ still not allowed":
84
+
85
+ ```ts
86
+ router.get(
87
+ `${base}/invoices`,
88
+ rai({ id: "invoices:read", name: "List invoices" }),
89
+ billing.list(),
90
+ );
91
+ ```
92
+
93
+ A route that declares no RAI would be reachable by guest, so core
94
+ scans the router stack after mounting and refuses to boot when it
95
+ finds one. Run `pnpm tulipes routes` to see every endpoint with the
96
+ roles that reach it.
97
+
98
+ Try it — the admin comes from AUTH_ADMIN_EMAIL / AUTH_ADMIN_PASSWORD:
99
+
100
+ ```sh
101
+ curl -X POST localhost:3000/api/v1/auth/login \
102
+ -H 'content-type: application/json' \
103
+ -d '{"email":"admin@example.com","password":"dev-admin-password"}'
104
+
105
+ curl localhost:3000/api/v1/auth/me -H "authorization: Bearer <accessToken>"
106
+ ```
107
+
108
+ Inside a handler, the caller is `req.auth` — resolved before any
109
+ feature route and never undefined inside a declared one:
110
+
111
+ ```ts
112
+ const { isAuthenticated, user, session } = req.auth;
113
+ user?.email // the hydrated user document, null for a guest
114
+ session?.sid // this device's session row, null for a guest
115
+ ```
116
+
117
+ Never re-parse the Authorization header: the pipeline already did. It
118
+ resolves the session and the user on every request — from the app cache
119
+ (Redis, shared by every replica) when a request resolved them recently,
120
+ from the database otherwise — and every path that revokes (logout,
121
+ refresh reuse, password change, a session ended, a deleted account)
122
+ forgets the cached entry first, so a demotion, a ban or a logout takes
123
+ effect on the very next request on any replica. Code that writes a
124
+ user's role calls `forgetUser()` from `@app/auth` for the same reason.
125
+
126
+ Sessions are rows, so a signed-in account can see and end its own
127
+ devices — `GET /auth/sessions`, `DELETE /auth/sessions/:sid`. Ending
128
+ one takes effect on the next request that device makes, even though
129
+ its access token is still signed and unexpired.
130
+
131
+ Passwords never touch the User document: the hash lives in the auth
132
+ module's `Credential` model, `select: false`, so a route that
133
+ serialises a user cannot leak one. Refresh tokens rotate on use;
134
+ logout deletes the refresh row and blacklists the access token's jti
135
+ until it expires; a password change moves a per-user epoch that
136
+ invalidates every token issued before it. Both token collections carry
137
+ TTL indexes.
138
+
139
+ ## The console
140
+
141
+ `modules/config` is the operator's console: `GET /api/v1/config`
142
+ describes every module — manifest, declared variables (names, types and
143
+ sources; a secret's value never), settings with their schema, live values
144
+ and stored version, endpoints with the roles that reach them — and
145
+ `PATCH /api/v1/config/:module` changes a module's settings: validated by
146
+ its schema, refused on a stale version, audited (names, never values)
147
+ before it lands, applied at once in every replica. Reading needs `admin`;
148
+ writing needs `sysadmin`. Settings live in MongoDB (`settings` and
149
+ `settings_history`) because the root `package.json` declares
150
+ `@tulipes/mongoose/settings`; the request pipeline — proxy trust, CORS,
151
+ HSTS, the body limit, the rate limits — is configured from
152
+ `config.security` this way, so none of it is an environment variable.
153
+
154
+ ## Logging
155
+
156
+ The logger lives in the core module and is shared by every other one:
157
+
158
+ ```ts
159
+ import { moduleLogger } from "@app/core";
160
+
161
+ const log = moduleLogger(ctx.Environment, "billing", ctx.mode);
162
+ log.info({ invoiceId }, "invoice sent");
163
+ ```
164
+
165
+ It writes to two destinations at once, because they answer different
166
+ questions: a **coloured, human-readable** stream on the console, and
167
+ **JSON lines** in `logs/` — one object per line, which is what makes
168
+ them greppable with jq or ingestible by a collector. Errors are
169
+ additionally copied to their own file, so the first thing to open when
170
+ something breaks is small.
171
+
172
+ Files carry the process mode — `app-backend.log`, `app-worker.log` —
173
+ because backend and worker run at the same time and two processes
174
+ rotating one file will race.
175
+
176
+ The destinations are environment, because the logger exists before the
177
+ settings store answers: `LOG_LEVEL`, `LOG_CONSOLE`, `LOG_FILE` and
178
+ `LOG_DIR`, declared in `modules/core/package.json`. The file names and
179
+ the rotation (daily or 20m, whichever trips first; fourteen rotated
180
+ files kept) never needed to differ per deployment, so they are the
181
+ `LOG_FILES` constants in `modules/core/helpers/logger/`. A `sysadmin`
182
+ can watch the files live: `GET /api/v1/settings/logs/stream` (SSE).
183
+
184
+ ## Responses
185
+
186
+ Every endpoint answers with one envelope, so a client unwraps once:
187
+
188
+ ```jsonc
189
+ { "success": true, "data": { }, "errors": [], "meta": {} }
190
+ ```
191
+
192
+ `res.respond` builds it. Express is untouched — `res.json` and
193
+ `res.status` behave exactly as they always did:
194
+
195
+ ```ts
196
+ res.respond({ data: user });
197
+ res.respond({ data: user, status: 201 });
198
+ res.respond({ data: items, meta: { total_items } }); // paginated
199
+ ```
200
+
201
+ Failures are **thrown**, never responded — the framework's terminal
202
+ handler builds the same envelope from them, so a handler only writes
203
+ the happy path. The message is an i18n key: it gets translated in the
204
+ module's namespace and the error code is derived from it.
205
+
206
+ ```ts
207
+ throw new HttpError(404, "notFound"); // → NOT_FOUND
208
+ throw new HttpError(422, "emailTaken", { field: "email" });
209
+ ```
210
+
211
+ `req.page` is already parsed from `?page` / `?per_page` and clamped
212
+ by `config.pagination`; passing `total_items` is what marks a
213
+ response as a page and fills in `total_pages`.
214
+
215
+ In development, a route answering without `res.respond` logs a
216
+ warning naming it.
217
+
218
+ ## Browser authentication
219
+
220
+ Read [Browser authentication and cookies](docs/browser-auth.md) before wiring
221
+ up a browser client. The starter uses Bearer headers and JSON refresh tokens;
222
+ CORS settings do not install cookie authentication or CSRF protection.
223
+
224
+ ## Translations
225
+
226
+ Each module owns its strings and IS its own i18n namespace:
227
+ `modules/hello/i18n/en.json`, `fr.json`. Inside an endpoint `req.t`
228
+ is already bound to the module that declared the route — `rai()` binds
229
+ it — so there is nothing to import and no namespace to name:
230
+
231
+ ```ts
232
+ res.json({ message: req.t("created", { email }) });
233
+ throw new HttpError(404, req.t("notFound"));
234
+ ```
235
+
236
+ The locale is `?lang=fr`, else Accept-Language, else
237
+ `config.i18n.defaultLocale` — narrowed to `supportedLocales`:
238
+
239
+ ```sh
240
+ curl 'localhost:3000/api/v1/hello/secret?lang=fr' -H "authorization: Bearer <token>"
241
+ ```
242
+
243
+ Code with no request names both: `ctx.t("users", recipient.locale)`,
244
+ which is how a queue processor translates against the recipient's
245
+ locale rather than the enqueuer's — so put the locale in the job.
246
+
247
+ Only the default locale must be complete. Anything another locale
248
+ lacks falls back to it and is listed in `<locale>.missing.json` beside
249
+ it on the next boot — git-ignored, and never written in production.
250
+
251
+ ## Scripts
252
+
253
+ `scripts/` holds one-off tasks — backfills, exports, admin chores.
254
+ They boot the app in **script mode**, which runs every phase the
255
+ backend runs and then starts no server, so a script gets exactly what
256
+ a route handler gets: config, ACL, models, queue producers.
257
+
258
+ ```sh
259
+ pnpm script scripts/count-greetings.ts
260
+ ```
261
+
262
+ Copy the try/finally from `scripts/count-greetings.ts`: mongo and
263
+ redis hold the event loop open, so a script that forgets
264
+ `handle.shutdown()` prints its output and then hangs.
265
+
266
+ ## Deploying nginx
267
+
268
+ `nginx/{development,staging,production}.conf` are templates with
269
+ `{{DOMAIN}}` and `{{PORT}}` placeholders. The deploy script renders
270
+ the one matching `APP_ENV`, using that same environment's
271
+ the `core.publicDomain` setting and `PORT`, so template, domain and port can never
272
+ disagree:
273
+
274
+ ```sh
275
+ APP_ENV=production pnpm script scripts/deploy-nginx.ts --dry-run
276
+ sudo -E APP_ENV=production pnpm script scripts/deploy-nginx.ts
277
+ ```
278
+
279
+ It writes `sites-available/<domain>`, links `sites-enabled`, runs
280
+ `nginx -t`, then reloads. It refuses when not root, while
281
+ `core.publicDomain` is still `localhost`, or when the conf already exists
282
+ (pass `--force` to overwrite one). Debian/Ubuntu layout.
283
+
284
+ The rendered conf is HTTP-only with the TLS block commented out —
285
+ `nginx -t` fails on certificate paths that do not exist yet, which
286
+ would make the first deploy impossible. Install the certificate,
287
+ uncomment the block, redeploy with `--force`.
288
+
289
+ ## Running under PM2
290
+
291
+ `ecosystem.config.cjs` defines both processes — the backend and the
292
+ queue worker — in all three modes:
293
+
294
+ ```sh
295
+ pnpm pm2:start # development
296
+ pnpm pm2:start:staging
297
+ pnpm pm2:start:production
298
+
299
+ pnpm pm2:status # what is running
300
+ pnpm pm2:logs # tail both processes
301
+ pnpm pm2:restart # reload code
302
+ pnpm pm2:stop # stop, keep them in the process list
303
+ pnpm pm2:delete # remove from the process list
304
+ ```
305
+
306
+ Switching environment requires a restart carrying the flag, because
307
+ PM2 remembers the environment a process was started with:
308
+ `pm2 restart ecosystem.config.cjs --env production`.
309
+
310
+ `.envs/` holds one file per mode and is **git-ignored** — those files
311
+ carry working credentials. What is committed is `.env.example`,
312
+ generated by `pnpm sync`, naming every variable any module declares:
313
+ a clone copies it into `.envs/.env.<mode>` and fills it in. Servers
314
+ usually skip the file entirely and inject variables through the
315
+ container environment, which always wins over it. `pnpm check`
316
+ validates the current environment without booting, so a deploy can
317
+ fail on configuration before it fails on traffic.
318
+
319
+ PM2 is a devDependency so the scripts work straight after install;
320
+ servers usually install it globally instead (`npm i -g pm2`), in
321
+ which case the same scripts still work.
322
+
323
+ Two defaults worth knowing: processes run in **fork** mode with a
324
+ single instance, because Socket.IO needs sticky sessions or the Redis
325
+ adapter before a second instance is safe; and `kill_timeout` is 10s so
326
+ graceful shutdown can drain queues and close mongo and redis before
327
+ PM2 sends SIGKILL.
@@ -0,0 +1,3 @@
1
+ import { boot } from "@tulipes/core/boot";
2
+
3
+ await boot({ rootDir: import.meta.dirname, mode: "backend" });
@@ -0,0 +1,73 @@
1
+ import type { AppConfigFn } from "@tulipes/core/boot";
2
+
3
+ /**
4
+ * ── The app-wide config seed ──────────────────────────────────────────
5
+ *
6
+ * Runs FIRST in boot phase 6, before any module.config.ts factory, and
7
+ * whatever it returns becomes the base of the global config. Modules read
8
+ * it through `ctx.config.<key>`.
9
+ *
10
+ * Add a key here when two or more modules share a value. Config belonging
11
+ * to ONE feature goes in that module's `module.config.ts` instead, where
12
+ * `pnpm sync` types it for you — that is where `users.maxSessions` and
13
+ * `users.signupMode` live. A value that differs per deployment belongs in
14
+ * neither: declare it in a module's `meta.variables.json` and read it with
15
+ * `Environment.get`, the way MONGO_URI, CORS_ORIGINS and BODY_LIMIT do.
16
+ *
17
+ * The framework reads exactly one field of this object: `app.name`, which
18
+ * titles the startup banner. Everything else is yours.
19
+ */
20
+
21
+ // `satisfies` (not a type annotation) so TypeScript keeps the exact shape
22
+ // of this object for the augmentation at the bottom of the file.
23
+ const appConfig = (() => ({
24
+ app: {
25
+ name: "tulipes-example",
26
+ },
27
+
28
+ /**
29
+ * Route prefixing. A router a routes file returns is mounted under this
30
+ * prefix, so versioning the whole API is a one-line change here; a module
31
+ * that wants another path says so with `ctx.routes.mount(router, "/x")`.
32
+ */
33
+ api: {
34
+ prefix: "/api/v1",
35
+ },
36
+
37
+ /**
38
+ * The languages this app speaks. Every module owns its own strings in
39
+ * `modules/<name>/i18n/<locale>.json` — the module is the namespace —
40
+ * and `req.t` inside an endpoint is already bound to the module that
41
+ * declared it. A request picks its locale with `?lang=fr` or an
42
+ * Accept-Language header, narrowed to this list.
43
+ *
44
+ * `defaultLocale` is also the fallback: a key missing from fr.json is
45
+ * served from en.json, and outside production the gap is recorded in
46
+ * `fr.missing.json` next to it.
47
+ */
48
+ i18n: {
49
+ defaultLocale: "en",
50
+ supportedLocales: ["en", "fr"],
51
+ },
52
+
53
+ /**
54
+ * List-endpoint defaults, so every module paginates identically and no
55
+ * caller can ask for ten thousand rows. → modules/users/routes
56
+ */
57
+ pagination: {
58
+ defaultLimit: 20,
59
+ maxLimit: 100,
60
+ },
61
+ })) satisfies AppConfigFn;
62
+
63
+ export default appConfig;
64
+
65
+ /**
66
+ * Types these keys at every call site: `config.pagination!.maxLimit` is a
67
+ * number, not `unknown`. `pnpm sync` does this automatically for
68
+ * module.config.ts factories; the app-level seed types itself here.
69
+ * Partial because the config object is empty until this factory runs.
70
+ */
71
+ declare module "@tulipes/core/config" {
72
+ interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}
73
+ }