@tulipes/cli 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/app-core.d.ts +33 -0
  4. package/dist/app-core.js +58 -0
  5. package/dist/app-core.js.map +1 -0
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +7 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/dev.d.ts +8 -0
  10. package/dist/dev.js +22 -0
  11. package/dist/dev.js.map +1 -0
  12. package/dist/env-check.d.ts +6 -0
  13. package/dist/env-check.js +28 -0
  14. package/dist/env-check.js.map +1 -0
  15. package/dist/init.d.ts +25 -0
  16. package/dist/init.js +6416 -0
  17. package/dist/init.js.map +1 -0
  18. package/dist/inspection.d.ts +8 -0
  19. package/dist/inspection.js +15 -0
  20. package/dist/inspection.js.map +1 -0
  21. package/dist/main.d.ts +1 -0
  22. package/dist/main.js +166 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/minimal.d.ts +2 -0
  25. package/dist/minimal.js +2200 -0
  26. package/dist/minimal.js.map +1 -0
  27. package/dist/new-module.d.ts +19 -0
  28. package/dist/new-module.js +348 -0
  29. package/dist/new-module.js.map +1 -0
  30. package/dist/package-info.d.ts +18 -0
  31. package/dist/package-info.js +53 -0
  32. package/dist/package-info.js.map +1 -0
  33. package/dist/routes.d.ts +8 -0
  34. package/dist/routes.js +54 -0
  35. package/dist/routes.js.map +1 -0
  36. package/dist/spec.d.ts +19 -0
  37. package/dist/spec.js +101 -0
  38. package/dist/spec.js.map +1 -0
  39. package/dist/sync.d.ts +12 -0
  40. package/dist/sync.js +117 -0
  41. package/dist/sync.js.map +1 -0
  42. package/dist/update.d.ts +20 -0
  43. package/dist/update.js +240 -0
  44. package/dist/update.js.map +1 -0
  45. package/package.json +59 -0
  46. package/templates/CLAUDE.md +120 -0
  47. package/templates/browser-auth.md +120 -0
  48. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +74 -0
  49. package/templates/claude/skills/tulipes-endpoint/SKILL.md +132 -0
  50. package/templates/claude/skills/tulipes-env-variable/SKILL.md +78 -0
  51. package/templates/claude/skills/tulipes-i18n/SKILL.md +98 -0
  52. package/templates/claude/skills/tulipes-model/SKILL.md +107 -0
  53. package/templates/claude/skills/tulipes-module/SKILL.md +67 -0
  54. package/templates/claude/skills/tulipes-permissions/SKILL.md +105 -0
  55. package/templates/claude/skills/tulipes-queue/SKILL.md +68 -0
  56. package/templates/claude/skills/tulipes-response/SKILL.md +118 -0
  57. package/templates/claude/skills/tulipes-settings/SKILL.md +111 -0
  58. package/templates/claude/skills/tulipes-socket/SKILL.md +60 -0
  59. package/templates/claude/skills/tulipes-spec/SKILL.md +101 -0
@@ -0,0 +1,120 @@
1
+ # Working in this project
2
+
3
+ This is a **Tulipes** application (`@tulipes/core`) — a module-based Express
4
+ framework. It is convention-driven and deliberately fail-closed: almost
5
+ everything that can be validated at boot is, and a violation crashes the
6
+ process with a report rather than degrading quietly.
7
+
8
+ Writing plain Express here will fight the framework. Read the rule that
9
+ applies before you write code.
10
+
11
+ ## Preset scope
12
+
13
+ Read the installed modules and package scripts before applying integration examples.
14
+ The minimal preset has core, security and hello only. Auth, users, settings,
15
+ MongoDB, Redis, queues, sockets, shared logger/cache helpers and PM2 examples
16
+ below apply when those modules are installed (the production preset). Minimal
17
+ requests use the framework guest identity. Grant permissions to roles the app
18
+ actually defines. Use the generated Vitest suite for changes; production tests
19
+ require dedicated TEST_MONGO_URI and TEST_REDIS_URL services.
20
+
21
+ ## Non-negotiable rules
22
+
23
+ 1. **Never read `process.env` directly.** Every configuration value is
24
+ declared in a module's `meta.variables.json` and read with
25
+ `ctx.Environment.get("NAME")`. Reading an undeclared name throws on
26
+ purpose. → skill `tulipes-env-variable`
27
+ 2. **A feature is a module, not a layer.** Everything a feature needs —
28
+ routes, models, config, permissions, jobs — lives in one folder under
29
+ `modules/`. Never add a top-level `services/` or `middlewares/` folder.
30
+ → skill `tulipes-module`
31
+ 3. **Never call `mongoose.model()` yourself.** Models are declarations
32
+ picked up by the framework and read from `ctx.models`. Persisted and
33
+ wire field names are `snake_case` (`created_at`, never `createdAt`);
34
+ camelCase is for TypeScript-only names. → skill `tulipes-model`
35
+ 4. **Global middleware belongs to a sys-tier module**, not to the
36
+ entrypoint. `app.ts` stays three lines. → skill `tulipes-endpoint`
37
+ 5. **Run `yarn sync` after touching any `meta.variables.json` or
38
+ `module.config.ts`.** It regenerates `types/config.d.ts` and
39
+ `.env.example`; stale generated types are a lie.
40
+ 6. **Every response is `res.respond({ data, meta })`** — never `res.json`,
41
+ which ships a raw body outside the envelope. Failures are **thrown**:
42
+ `throw new HttpError(404, "notFound")`, where the message is an i18n
43
+ key and the code derives from it. Never hand-roll an error shape.
44
+ → skill `tulipes-response`
45
+ 7. **Every route declares a RAI**: put `rai({ id: "x:read" })` first in
46
+ each endpoint handler chain, including routes inside `defineRoutes()`.
47
+ The id is also the ACL permission, and core refuses to boot if a route
48
+ is missing one. Public is `acl.allow("guest", "x:read")`, never a
49
+ missing check. → skill `tulipes-endpoint`
50
+ 8. **Validate input with a zod schema on the RAI** (`body`, `query`,
51
+ `params`, from `zod/v4`) rather than hand-rolled `if` checks. The same
52
+ declaration generates the OpenAPI and Postman documents, so they cannot
53
+ describe a shape the endpoint rejects. → skill `tulipes-spec`
54
+ 9. **User-facing strings are keys, never literals.** `req.t("notFound")`
55
+ inside a route reads that module's `i18n/<locale>.json` — the module is
56
+ the namespace and `rai()` binds it. Never concatenate a translated
57
+ string with a variable. → skill `tulipes-i18n`
58
+ 10. **`req.auth` is who is calling** — `{ isAuthenticated, user, session }`,
59
+ resolved before any feature route and never undefined inside one. Never
60
+ re-parse the Authorization header or trust a claim from a token.
61
+ → skill `tulipes-permissions`
62
+ 11. **When auth is installed, secrets never go on the User model.** Passwords and keys belong in
63
+ the auth module's `Credential` model, which is `select: false`.
64
+ 12. **When auth is installed, a new feature module's namespace goes in BOTH the `admin` and
65
+ `sysadmin` grants** in `modules/auth/module.acl.ts`. Neither role holds
66
+ `*` — a wildcard would reach `settings:*` and make the sysadmin
67
+ boundary a label rather than a rule. → skill `tulipes-settings`
68
+ 13. **Cross-module imports use the package name** (`@app/users`,
69
+ `@app/core`), never a relative path that climbs out of the module. The
70
+ workspace boundary is what keeps modules independent, and a package
71
+ name makes the dependency visible in package.json — declare it there
72
+ and in `dependsOn`.
73
+
74
+ For endpoint changes, read `tulipes-endpoint`: native routers live in
75
+ `routes/*.routes.ts`. Use `defineRoutes()` and `runtime()` for offline inspection;
76
+ keep shared middleware and param callbacks beside their endpoints.
77
+
78
+ ## Where things go
79
+
80
+ | Decision | Location |
81
+ |---|---|
82
+ | Value differs per deployment (URL, secret, toggle) | that module's `meta.variables.json` |
83
+ | Policy shared by ≥2 modules (pagination, body limit) | `config/app.config.ts` |
84
+ | Config for one feature | that module's `module.config.ts` |
85
+ | A string a user will read | that module's `i18n/<locale>.json`, reached with `req.t` |
86
+ | The shape of a request body/query | a zod schema on the route's `rai()` |
87
+ | Middleware for every request | a sys-tier module's routes file |
88
+ | Middleware for one feature's routes | that module's own `Router` |
89
+ | Helper used by ≥2 modules | the sys core module, exported from its `index.ts` and imported as `@app/core` |
90
+ | Helper used by one module | that module's `helpers/` |
91
+ | One-off task (backfill, export, admin chore) | `scripts/`, booted with `mode: "script"` |
92
+ | Anything you would `console.log` | `moduleLogger(ctx.Environment, "<module>", ctx.mode)` from `@app/core` |
93
+
94
+ ## Boot order (why load order matters)
95
+
96
+ `sys` modules load before `app` modules; within a tier, lower `priority`
97
+ first; then `dependsOn` topological order. Config → translations → ACL →
98
+ database → queues → routes. If code needs something another module
99
+ provides, declare `dependsOn` — do not rely on luck.
100
+
101
+ ## Commands
102
+
103
+ | Command | Use |
104
+ |---|---|
105
+ | `yarn dev` | run the backend process (regenerates types first) |
106
+ | `yarn worker` | run the queue-consumer process |
107
+ | `yarn sync` | regenerate `types/config.d.ts` + `.env.example` |
108
+ | `yarn check` | validate the environment without booting |
109
+ | `yarn typecheck` | typecheck the whole app |
110
+ | `yarn tulipes new module <name>` | scaffold a module |
111
+ | `tulipes update` | upgrade the global cli and the framework in every manifest |
112
+ | `yarn script scripts/x.ts` | run a one-off script against the real app |
113
+
114
+ ## Verifying your work
115
+
116
+ A change is not done until the app boots. Run `yarn typecheck`, then
117
+ `yarn dev` and confirm the startup banner lists what you added (modules,
118
+ queues, sockets, roles). Boot failures print an aggregate report naming
119
+ every problem at once — read all of it before fixing anything. → skill
120
+ `tulipes-boot-errors`
@@ -0,0 +1,120 @@
1
+ # Browser authentication and cookies
2
+
3
+ This production starter returns access and refresh tokens in JSON. It does not
4
+ issue authentication cookies, read credentials from cookies, or install CSRF
5
+ middleware. Its current transport contract is:
6
+
7
+ | Operation | Credential source |
8
+ |---|---|
9
+ | Protected HTTP routes | `Authorization: Bearer <access_token>` |
10
+ | Login and registration | Email and password in an `application/json` body |
11
+ | Refresh | `refresh_token` in an `application/json` body |
12
+ | Logout, password change and device revocation | Access-token header |
13
+
14
+ The access-token extractor ignores cookies and URL parameters. Keep tokens out of
15
+ URLs: URLs can reach history, logs and referrers. The built-in auth routes do not
16
+ parse form or text bodies as JSON. Preserve these boundaries when extending them.
17
+
18
+ ## Connect a browser using the current contract
19
+
20
+ Set `CORS_ORIGINS` to the exact browser origins, including scheme and port, such as
21
+ `https://console.example.com`. An empty value grants no cross-origin response
22
+ access. `*` allows every origin; reserve it for intentionally public APIs.
23
+ `CORS_CREDENTIALS=false` is the default. A caller can supply a Bearer header
24
+ explicitly without enabling credential mode for cookies; that header requires a
25
+ successful CORS preflight. [Fetch CORS protocol](https://fetch.spec.whatwg.org/#cors-protocol-and-credentials).
26
+
27
+ ```ts
28
+ // The caller holds this token in memory after login; do not put it in the URL.
29
+ const response = await fetch(`${apiBase}/api/v1/auth/me`, {
30
+ credentials: "omit",
31
+ headers: { Authorization: `Bearer ${accessToken}` },
32
+ });
33
+
34
+ if (!response.ok) {
35
+ throw new Error("The authenticated request failed");
36
+ }
37
+
38
+ const profile = await response.json();
39
+ ```
40
+
41
+ CORS supplies browser response-access headers. A disallowed origin can still send
42
+ some requests, and command-line clients do not enforce CORS. Authentication and
43
+ ACLs remain necessary. `CORS_CREDENTIALS=true` only emits a CORS response header;
44
+ it neither reads cookies nor adds CSRF checks. The starter rejects combining it
45
+ with `CORS_ORIGINS=*`. [Express CORS middleware](https://expressjs.com/en/resources/middleware/cors/).
46
+
47
+ A browser frontend needs its own storage policy. Prefer memory for short-lived
48
+ Bearer credentials. Persistent JavaScript-readable storage exposes tokens to any
49
+ script running in that origin; do not treat localStorage as a secure session
50
+ vault. An XSS flaw can also steal in-memory tokens or make requests as the user.
51
+ A server-backed browser session can keep refresh credentials away from frontend
52
+ JavaScript, but requires the cookie controls below.
53
+ [OWASP session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
54
+
55
+ ## If the application adopts cookies
56
+
57
+ Treat cookie authentication as an application change with retained abuse tests.
58
+ Apply CSRF validation before actions on every affected route, including login,
59
+ refresh, logout, password changes and settings. Prefer an existing maintained
60
+ implementation using a session-bound synchronizer token, or a signed double-submit
61
+ token bound to the session. Reject absent or invalid tokens. Check the exact
62
+ trusted source origin, with a defined Referer fallback and missing-header policy.
63
+ Keep state-changing operations off GET. Restrict accepted content types and CORS
64
+ origins; do not trust arbitrary sibling subdomains. Same-site and same-origin are
65
+ different boundaries: sibling origins can be same-site. SameSite provides additional
66
+ protection but does not replace these checks. XSS can defeat CSRF defenses.
67
+ [OWASP CSRF prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html).
68
+
69
+ Use HTTPS with `Secure` and `HttpOnly` authentication cookies. Choose an explicit
70
+ `SameSite=Strict` or `Lax` policy that fits the login/navigation flow. Cross-site
71
+ cookies require `SameSite=None; Secure`, and browser third-party-cookie restrictions
72
+ may still block them. Prefer host-only cookies without a Domain attribute. A
73
+ `__Host-` cookie requires Secure, Path=/ and no Domain; a narrower Path cannot use
74
+ that prefix. Path is not an authorization boundary. Align cookie lifetime with
75
+ server session expiry, and clear cookies using the same name, path and domain when
76
+ revoking the server session. HttpOnly prevents script reads, but browsers still
77
+ attach the cookie to requests. [MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie).
78
+
79
+ For cross-origin cookie requests, configure the browser with
80
+ `credentials: "include"`, exact allowed origins and `CORS_CREDENTIALS=true`.
81
+ Browser cookie restrictions still apply. This setup does not supply CSRF
82
+ validation. [MDN CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS).
83
+
84
+ If HttpOnly cookies are intended to hide refresh credentials from JavaScript,
85
+ remove those credentials from response bodies as part of the adaptation too.
86
+
87
+ ## Evidence required for a cookie adaptation
88
+
89
+ Test rejected CSRF tokens and hostile origins without side effects, login CSRF,
90
+ refresh/logout and administrative operations, plus expiration and revocation.
91
+ Use real browsers to check same-site sibling origins, cross-site preflights,
92
+ credential mode, cookie flags and third-party-cookie behavior through the deployed
93
+ TLS/proxy topology. Include missing Origin/Referer cases in the chosen policy.
94
+
95
+ The starter's HTTP tests retain header/body-only authentication, absent Set-Cookie,
96
+ and rejection of cookie-only, query-token and non-JSON refresh attempts. They run
97
+ in Node and do not establish browser enforcement or validate a future cookie flow.
98
+
99
+ ## Refresh rotation and suspected theft
100
+
101
+ One login creates one token family, identified by the session's `sid`. Refresh
102
+ rotates within that family. Reusing any valid, unexpired superseded refresh token
103
+ expires the whole family, including its access tokens. Other devices stay active;
104
+ changing the account password invalidates all devices. This strict policy follows
105
+ the replay-detection rationale in [RFC 9700 section 4.14.2](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2);
106
+ the starter itself is not an OAuth authorization server.
107
+
108
+ Serialize refreshes per session, coordinating tabs that share credentials. A
109
+ concurrent duplicate causes one success and one rejection, but makes the success
110
+ pair unusable too. After a refresh rejection, lost response or
111
+ `REFRESH_UNAVAILABLE` (503), discard the local pair and sign in again. There is no
112
+ retry grace period: a request that committed before its response was lost may
113
+ already have consumed the token.
114
+
115
+ Reuse does not reveal which caller is legitimate. Someone with an old unexpired
116
+ token can force the device to sign in again; an expired token cannot trigger this
117
+ revocation. Theft without reuse is not detected. A database failure may prevent
118
+ revocation or obscure its outcome, and requests already authorized are not
119
+ cancelled. Use password change for suspected account-wide compromise and apply
120
+ your own incident-response and alerting policy.
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: tulipes-boot-errors
3
+ description: Use when a Tulipes app fails to boot, when a route/model/queue you added is not being picked up, or when reading a TulipesBootError report. Maps each boot phase's crash message to its cause and fix.
4
+ ---
5
+
6
+ # Diagnosing a boot failure
7
+
8
+ Boot crashes with a `TulipesBootError` naming the phase and listing
9
+ **every** problem found in that phase:
10
+
11
+ ```
12
+ TulipesBootError: Boot aborted during "environment" — 3 issue(s):
13
+ ✖ [core] required variable "MONGO_URI" has no value (…)
14
+ ✖ [users] variable "USERS_MAX_SESSIONS": "abc" is not a number
15
+ ✖ [users] required variable "USERS_ADMIN_EMAIL" has no value (…)
16
+ ```
17
+
18
+ **Read the whole list before changing anything.** The report is complete
19
+ for that phase — fixing one line at a time wastes restarts. Phases run in
20
+ order and stop at the first failing one, so a later phase may still hold
21
+ problems the report has not reached yet.
22
+
23
+ ## By phase
24
+
25
+ | Phase | Typical message | Cause and fix |
26
+ |---|---|---|
27
+ | `environment` | `required variable "X" has no value` | Declared `required` but nothing supplies it. Add it to `.envs/.env.<APP_ENV>`, or give the spec a `default`. |
28
+ | `environment` | `"abc" is not a number` / `not one of: …` | Value fails its declared `type`/`enum`. Fix the value or the spec. |
29
+ | `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same name. Delete the duplicate; read the other module's variable instead. |
30
+ | `module graph` | `dependsOn "x" — no such module exists` | Typo, or you used the package name. `dependsOn` takes short names (`"users"`). |
31
+ | `module graph` | `dependency cycle: a → b → a` | Mutual dependency. Move the shared part into the core module or a third module. |
32
+ | `module graph` | `sys module cannot depend on app module` | A sys module needs something app-tier. Either it is not really sys, or the dependency belongs in `lib/`. |
33
+ | `config` | `module.config must default-export a function` | Missing/misspelled `export default`. |
34
+ | `acl` | `allow() on unknown role "x"` | Role never defined. Roles live in the sys `auth` module; define it there and `dependsOn: ["auth"]`. |
35
+ | `acl` | `already granted … duplicate grant` | Two modules grant the same role+resource. Namespace the resource by module. |
36
+ | `providers` | `[x] requires capability "models" … declares no provider for it` | A module has `models/` or `bootstrap/` files (or `requires: ["models"]`) but the app's `package.json` has no `"tulipes": { "providers": { "models": "@tulipes/mongoose" } }`. Add that line; an installed package or a `MONGO_URI` alone selects nothing. |
37
+ | `providers` | `provider "…" cannot be resolved from …` | The declared specifier is not installed in the app. Install it (or fix the path). |
38
+ | `providers` | `"@tulipes/core/db" no longer contains the Mongoose provider` | The app still declares the pre-extraction specifier. Install `@tulipes/mongoose`, declare it, and import `ModelDef`/`BootstrapFn` from it. |
39
+ | `providers` | `[x] requires capability "queues" … declares no provider for it` | A module has a `queues/` file (or `requires: ["queues"]`) but the app's `package.json` has no `"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. Add that line. |
40
+ | `providers` | `provider "bullmq" is selected but "REDIS_URL" is not declared/set` | Add the variable to the sys `core` module's meta file and set it. |
41
+ | `providers` | `"@tulipes/core/queues" no longer contains the BullMQ provider` | Install `@tulipes/bullmq`, declare it, and import `QueueRegistry`/`QueuesFn` from it. |
42
+ | `providers` | `[x] requires capability "sockets" … declares no provider for it` | A module has a `sockets/` file (or `requires: ["sockets"]`) but the app's `package.json` has no `"tulipes": { "providers": { "sockets": "@tulipes/socket.io" } }`. Add that line. |
43
+ | `providers` | `"@tulipes/core/sockets" no longer contains the Socket.IO provider` | Install `@tulipes/socket.io`, declare it, and import `SocketRegistry`/`SocketsFn` from it. |
44
+ | `providers` | `provider "socket.io" failed to attach to the HTTP server` | The transport could not bind to the server core created; the message carries the driver's reason. |
45
+ | `providers` | `provider "mongoose" is selected but "MONGO_URI" is not declared/set` | Add the variable to the sys `core` module's meta file and set it. |
46
+ | `providers` | `database connection failed` | Mongo unreachable or `MONGO_URI` wrong. Check the service is running. |
47
+ | `providers` | `package.json "tulipes" key: providers — Unrecognized key` | Typo in the capability name; only `models` exists. |
48
+ | `providers` | `queue name … is invalid` | Queue names use `.`, not `:`. |
49
+ | `routes` | `must default-export a routes function` | Missing `export default` in a `*.routes.ts`. |
50
+
51
+ ## "My file is being ignored"
52
+
53
+ The framework discovers files by convention. If something you added never
54
+ runs, check in this order:
55
+
56
+ 1. **Filename suffix** — `*.routes.ts`, `*.model.ts`, `*.queues.ts`,
57
+ `*.sockets.ts`, `*.bootstrap.ts`, `module.config.ts`, `module.acl.ts`.
58
+ A file named `users.route.ts` (no `s`) is invisible.
59
+ 2. **Directory** — routes in `routes/`, models in `models/`, and so on.
60
+ Nested subfolders are not scanned.
61
+ 3. **The module is registered** — it needs a `package.json` and a
62
+ `yarn install` after creation. The startup banner lists every module it
63
+ found; if yours is missing, the problem is there.
64
+ 4. **`export default`** — every contract file except models exports a
65
+ function as its default.
66
+
67
+ ## Other symptoms
68
+
69
+ - **Types are `unknown` or a new variable is not typed** → run `yarn sync`.
70
+ - **Worker exits immediately** → no processors registered; it refuses to
71
+ idle. Add a `*.queues.ts` with `process()`.
72
+ - **Env change has no effect** → `process.env` beats the file. Check for a
73
+ shell export or container variable of the same name.
74
+ - To validate configuration without booting the whole app: `yarn check`.
@@ -0,0 +1,132 @@
1
+ ---
2
+ name: tulipes-endpoint
3
+ description: Use when adding or changing an HTTP endpoint in a Tulipes app, writing a controller, mounting middleware, or handling request errors. Covers the rai() declaration every route must carry, controller classes, and the request pipeline.
4
+ ---
5
+
6
+ # Adding an endpoint
7
+
8
+ Two files: a controller class holding the work, and a shared declaration file wiring
9
+ it up. Every route declares a RAI — a Resource Access Identifier — and the
10
+ app refuses to boot if one is missing.
11
+
12
+ `modules/billing/controllers/billing.controllers.ts`:
13
+
14
+ ```ts
15
+ import type { Request, RequestHandler, Response } from "express";
16
+ import type { Ctx } from "@tulipes/core/boot";
17
+ import { HttpError } from "@tulipes/core/http";
18
+
19
+ export class BillingController {
20
+ constructor(private readonly ctx: Ctx) {}
21
+
22
+ show(): RequestHandler {
23
+ return async (req: Request, res: Response) => {
24
+ const invoice = await this.ctx.models!.get("Invoice").findById(req.params.id).lean();
25
+ if (!invoice) throw new HttpError(404, "noSuchInvoice");
26
+ res.respond({ data: invoice });
27
+ };
28
+ }
29
+ }
30
+ ```
31
+
32
+ Each method **returns** a handler rather than being one, so the routes file
33
+ reads as a table of contents and the class can keep private helpers beside
34
+ the handlers that use them without threading ctx through every signature.
35
+
36
+ `modules/billing/routes/billing.routes.ts`:
37
+
38
+ ```ts
39
+ import { Router } from "express";
40
+ import { defineRoutes } from "@tulipes/core/http";
41
+
42
+ export default defineRoutes(({ app, rai, routes, runtime }) => {
43
+ const router = Router();
44
+ const billing = runtime(async ctx => {
45
+ const { BillingController } = await import("../controllers/billing.controllers.js");
46
+ return new BillingController(ctx);
47
+ });
48
+ router.get("/:id", rai({ id: "billing:read", name: "Read an invoice" }),
49
+ billing(value => value.show()));
50
+ routes.mount(app, "/invoices", router);
51
+ });
52
+ ```
53
+
54
+ Set tulipes.offline: true in the module manifest. runtime() acquires each controller
55
+ once at boot, and its binder selects callbacks before listening. Offline inspection
56
+ builds real routers without running acquisition functions or callback selectors.
57
+ Keep service-dependent imports inside runtime(). Pure callbacks need no binding.
58
+
59
+ Use router.use for shared middleware, router.param for parameter callbacks, and
60
+ router.route for chained method registrations. The same binder supports all
61
+ callback kinds, including error handlers. Preserve native registration order.
62
+ Parameter callbacks run before endpoint RAI; authorization-sensitive loading
63
+ belongs after rai() or behind an appropriate earlier authorization check.
64
+
65
+ Use routes.mount for nested/shared prefixes, including named parameters. Set
66
+ Router({mergeParams:true}) on children needing inherited parameter values.
67
+ Existing RoutesFn factories remain supported with --runtime. After changes run
68
+ the app tests, tulipes routes --offline and yarn spec; inspect generated diffs.
69
+
70
+ ## The RAI is not optional
71
+
72
+ `id` names a resource and an access to it, and **is** the ACL permission.
73
+ One declaration identifies the endpoint, gates it, marks the route as
74
+ declared, and binds `req.t` to the declaring module's i18n namespace —
75
+ which is why the message above needs no namespace of its own. → skill
76
+ `tulipes-i18n` Core walks the router stack after mounting; a route without a
77
+ RAI, or two routes sharing an id, abort the boot:
78
+
79
+ ```
80
+ TulipesBootError: Boot aborted during "routes" — 1 issue(s):
81
+ ✖ GET /api/v1/invoices has no rai() declaration — every route must
82
+ declare one, or it is reachable by anyone
83
+ ```
84
+
85
+ This exists because the pipeline authenticates rather than rejects: an
86
+ undeclared route is not merely undocumented, it is reachable by `guest`.
87
+ Grant the id in `module.acl.ts` — to `guest` if the endpoint is public.
88
+
89
+ `tulipes routes --offline` lists every declared endpoint with the roles that reach
90
+ it.
91
+
92
+ ## Responses and errors
93
+
94
+ Every response goes through `res.respond`, which builds the app's
95
+ envelope — `{ success, data, errors, meta }`. Failures are **thrown**, and
96
+ the terminal handler builds the same envelope from them, so a handler only
97
+ ever writes the happy path. → skill `tulipes-response`
98
+
99
+ ```ts
100
+ res.respond({ data: invoice });
101
+ res.respond({ data: invoices, meta: { total_items } }); // paginated
102
+ throw new HttpError(404, "noSuchInvoice"); // → NOT_FOUND
103
+ ```
104
+
105
+ Express 5 forwards rejected async handlers to the terminal handler, so
106
+ `throw` works inside `async` without a try/catch.
107
+
108
+ - Middleware errors carrying a 4xx pass through (oversized body → 413,
109
+ malformed JSON → 400).
110
+ - Anything else → anonymous `500`, real message only in development.
111
+ **Never** catch an error just to `res.status(500).json(...)` yourself;
112
+ that leaks what the framework hides.
113
+ - **Never `res.json`** — it ships a raw, unenveloped body. Development
114
+ logs a warning naming the route; production does not.
115
+
116
+ ## The request pipeline
117
+
118
+ ```
119
+ module routers, in load order:
120
+ sys modules request ids, helmet, cors, logging, body parsing,
121
+ then auth resolving req.auth (guest if no token)
122
+ app modules your feature routes, each gated by its RAI
123
+ notFoundHandler framework — 404 for anything unclaimed
124
+ errorHandler framework — terminal
125
+ ```
126
+
127
+ - **Middleware for every request** → a sys-tier module's routes file,
128
+ mounted on `ctx.app` (that file returns nothing).
129
+ - **Middleware for one router** → router.use before its endpoints.
130
+ - **Middleware for one endpoint** → put callbacks after its rai() handler.
131
+ - Never mount middleware in `app.ts`, and never reorder by moving
132
+ `app.use` calls — ordering is `tier` + `priority`.
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: tulipes-env-variable
3
+ description: Use when adding or changing any configuration value in a Tulipes app — API keys, connection strings, feature toggles, limits — or when tempted to read process.env. Covers meta.variables.json, the resolution precedence and typed reads.
4
+ ---
5
+
6
+ # Adding a configuration value
7
+
8
+ **Never read `process.env` directly.** Every value is declared by exactly
9
+ one module and read through the typed store. An undeclared read throws, so
10
+ a typo can never reach production as `undefined`.
11
+
12
+ ## Steps
13
+
14
+ 1. Choose the owning module. Infra shared by everything (`PORT`,
15
+ `MONGO_URI`, `REDIS_URL`) belongs to the sys `core` module; anything
16
+ feature-specific belongs to that feature's module.
17
+ 2. Declare it in that module's `meta.variables.json`:
18
+
19
+ ```json
20
+ {
21
+ "variables": [
22
+ {
23
+ "name": "STRIPE_API_KEY",
24
+ "type": "secret",
25
+ "group": "billing",
26
+ "required": true,
27
+ "description": "Server-side Stripe key"
28
+ },
29
+ {
30
+ "name": "BILLING_TRIAL_DAYS",
31
+ "type": "number",
32
+ "group": "billing",
33
+ "description": "Free trial length",
34
+ "default": 14
35
+ }
36
+ ]
37
+ }
38
+ ```
39
+
40
+ 3. Add a value to `.envs/.env.development` for anything `required`.
41
+ 4. `yarn sync` — regenerates `types/config.d.ts` (so the read is typed)
42
+ and `.env.example` (so deployments know what to set).
43
+ 5. Read it: `Environment.get("BILLING_TRIAL_DAYS")` → typed `number`.
44
+
45
+ ## Field rules
46
+
47
+ | Field | Rule |
48
+ |---|---|
49
+ | `name` | UPPER_SNAKE, and **globally unique** — two modules declaring the same name crash the boot. Do not re-declare a variable another module owns; just read it. |
50
+ | `type` | `string` · `number` · `boolean` · `enum` · `url` · `secret`. Values are coerced (`"14"` → `14`) and validated. |
51
+ | `enum` | Required with `type: "enum"`. Generates a literal union type. |
52
+ | `required` | No value anywhere → boot crash. Mutually exclusive with `default`. |
53
+ | `default` | Used when nothing supplies a value. Never put a real credential here. |
54
+ | `group` | Free-form label; groups the variable in the generated `.env.example`. |
55
+
56
+ Use `secret` for anything sensitive — those values are redacted in logs,
57
+ reports and the startup banner.
58
+
59
+ ## Precedence
60
+
61
+ `process.env` › `.envs/.env.<APP_ENV>` › `default`. Container-injected
62
+ values always win, which is why deployments need no file.
63
+
64
+ ## Where it does NOT go
65
+
66
+ - Value shared by several modules but the same everywhere (pagination
67
+ limits, body size) → `config/app.config.ts`, not a variable.
68
+ - Value used by one module and never changed per deployment → that
69
+ module's `module.config.ts`.
70
+
71
+ ## Gotchas
72
+
73
+ - `Environment.get()` on an undeclared name **throws**. If you added the
74
+ entry and still get the error, you did not run `yarn sync`, or you
75
+ edited a different module's meta file than the one you are reading from.
76
+ - `APP_ENV` (not `NODE_ENV`) selects the env file.
77
+ - A missing `required` variable is not a runtime surprise — the boot
78
+ aborts and lists every missing variable at once.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: tulipes-i18n
3
+ description: Use when writing any user-facing string in a Tulipes app — an error message, a response body, an email subject — or when adding a locale, translating a module, or working out why an endpoint answers in the wrong language. Covers req.t, ctx.t, module namespaces and the missing-key files.
4
+ ---
5
+
6
+ # Translating
7
+
8
+ Every user-facing string is a key. A module is a namespace, and it owns
9
+ its strings:
10
+
11
+ ```
12
+ modules/users/i18n/
13
+ en.json the default locale — must be complete
14
+ fr.json gaps fall back to en
15
+ fr.missing.json generated; never edit, never commit
16
+ ```
17
+
18
+ ## In an endpoint
19
+
20
+ `req.t` is already bound to the module that declared the route, because
21
+ `rai()` binds it. Nothing to import, nothing to pass:
22
+
23
+ ```ts
24
+ res.json({ message: req.t("created", { email }) });
25
+ throw new HttpError(404, req.t("notFound"));
26
+ ```
27
+
28
+ with `modules/users/i18n/en.json`:
29
+
30
+ ```json
31
+ {
32
+ "created": "User {{email}} created",
33
+ "notFound": "No such user",
34
+ "list": { "count_one": "{{count}} user", "count_other": "{{count}} users" }
35
+ }
36
+ ```
37
+
38
+ - **Nesting** is a `.` in the key: `req.t("list.count", { count })`.
39
+ - **Plurals** come from `count` — i18next picks `_one` / `_other` (and the
40
+ categories a language actually has) so no handler writes a ternary.
41
+ - **Another module's strings**: name the namespace, `req.t("auth:invalid")`.
42
+ Rare, and worth a second thought — a string two modules need usually
43
+ belongs to whichever one owns the concept.
44
+ - `req.locale` is the negotiated locale, if you need it in a payload.
45
+
46
+ ## Outside a request
47
+
48
+ A queue processor, script or bootstrap task has no request to negotiate
49
+ from, so it names both the namespace and the locale:
50
+
51
+ ```ts
52
+ const t = ctx.t("users", recipient.locale);
53
+ await sendMail(t("welcome.subject", { name }));
54
+ ```
55
+
56
+ Which means the locale has to travel with the work — put it in the job
57
+ payload when you enqueue, since the worker cannot recover it later:
58
+
59
+ ```ts
60
+ await ctx.queues!.add("users.welcome", "welcome", { email, locale: req.locale });
61
+ ```
62
+
63
+ `ctx.i18n` is the i18next instance underneath, for anything else.
64
+
65
+ ## How a locale is chosen
66
+
67
+ 1. `?lang=fr` — an explicit override, so a locale is testable with curl
68
+ and survives a shared link
69
+ 2. `Accept-Language`, q-values honoured
70
+ 3. `config.i18n.defaultLocale`
71
+
72
+ Every candidate is narrowed to `config.i18n.supportedLocales`, so an
73
+ unsupported `?lang=xx` falls through rather than serving raw keys. A
74
+ region the app does not ship resolves to its base language: `fr-CA` is
75
+ served by `fr` unless `fr-CA.json` exists.
76
+
77
+ ## Adding a locale
78
+
79
+ 1. Add it to `supportedLocales` in `config/app.config.ts`.
80
+ 2. Boot. Every module now has `<locale>.missing.json` listing everything
81
+ it needs, with the English text as the value.
82
+ 3. Translate into `<locale>.json`, delete nothing — the missing file
83
+ disappears on the next boot once the gaps are gone.
84
+
85
+ ## Rules
86
+
87
+ - **Never concatenate a translated string.** Put the variable in the key:
88
+ `"Welcome, {{name}}"`, not `t("welcome") + name`. Word order differs
89
+ between languages, and the concatenation is untranslatable.
90
+ - **Only `en.json` (the default locale) must be complete.** A missing key
91
+ in another locale falls back to it — a feature can ship before its
92
+ translations do.
93
+ - **`*.missing.json` is generated.** It is git-ignored, rewritten on every
94
+ non-production boot, and never written in production.
95
+ - Invalid JSON in a locale file, or an `i18n/` folder with no
96
+ default-locale file, aborts the boot — i18next would otherwise serve raw
97
+ keys with nothing in the log to explain it.
98
+ - A module with no `i18n/` folder is fine; it simply has nothing to say.