@tulipes/cli 0.2.0-rc.2 → 0.3.0-rc.2

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 (114) hide show
  1. package/dist/agent.d.ts +5 -5
  2. package/dist/config-check.js +1 -1
  3. package/dist/env-check.d.ts +5 -3
  4. package/dist/env-check.js +26 -19
  5. package/dist/env-check.js.map +1 -1
  6. package/dist/main.js +83 -19
  7. package/dist/main.js.map +1 -1
  8. package/dist/migrate-0.12-env.d.ts +36 -0
  9. package/dist/migrate-0.12-env.js +250 -0
  10. package/dist/migrate-0.12-env.js.map +1 -0
  11. package/dist/migrate-0.12-scaffold.d.ts +1 -0
  12. package/dist/migrate-0.12-scaffold.js +197 -0
  13. package/dist/migrate-0.12-scaffold.js.map +1 -0
  14. package/dist/migrate-0.12.d.ts +47 -0
  15. package/dist/migrate-0.12.js +210 -0
  16. package/dist/migrate-0.12.js.map +1 -0
  17. package/dist/migrate.d.ts +3 -1
  18. package/dist/migrate.js +44 -5
  19. package/dist/migrate.js.map +1 -1
  20. package/dist/new-module.d.ts +7 -0
  21. package/dist/new-module.js +35 -16
  22. package/dist/new-module.js.map +1 -1
  23. package/dist/roles.d.ts +15 -0
  24. package/dist/roles.js +54 -0
  25. package/dist/roles.js.map +1 -0
  26. package/dist/routes.d.ts +7 -2
  27. package/dist/routes.js +19 -10
  28. package/dist/routes.js.map +1 -1
  29. package/dist/spec.d.ts +2 -0
  30. package/dist/spec.js +13 -11
  31. package/dist/spec.js.map +1 -1
  32. package/dist/sync.js +66 -53
  33. package/dist/sync.js.map +1 -1
  34. package/dist/update.js +1 -1
  35. package/dist/update.js.map +1 -1
  36. package/package.json +4 -4
  37. package/templates/AGENTS.md +41 -29
  38. package/templates/app/.envs/.env.development +2 -1
  39. package/templates/app/.envs/.env.production +2 -2
  40. package/templates/app/.envs/.env.staging +2 -2
  41. package/templates/app/README.md +44 -29
  42. package/templates/app/config/app.config.ts +60 -7
  43. package/templates/app/docs/agent-manifest.json +8 -681
  44. package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +4 -5
  45. package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -6
  46. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
  47. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
  48. package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
  49. package/templates/app/modules/auth/helpers/passport.ts +1 -1
  50. package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
  51. package/templates/app/modules/auth/helpers/tokens.ts +3 -5
  52. package/templates/app/modules/auth/module.acl.ts +31 -9
  53. package/templates/app/modules/auth/module.config.ts +5 -3
  54. package/templates/app/modules/auth/package.json +10 -10
  55. package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
  56. package/templates/app/modules/auth/tests/auth.test.ts +1 -1
  57. package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
  58. package/templates/app/modules/config/helpers/audit.ts +1 -1
  59. package/templates/app/modules/config/helpers/describe.ts +35 -26
  60. package/templates/app/modules/config/module.acl.ts +1 -1
  61. package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
  62. package/templates/app/modules/config/tests/config.test.ts +14 -6
  63. package/templates/app/modules/core/helpers/cache/index.ts +8 -7
  64. package/templates/app/modules/core/helpers/lazy.ts +3 -3
  65. package/templates/app/modules/core/helpers/logger/index.ts +24 -16
  66. package/templates/app/modules/core/module.config.ts +5 -3
  67. package/templates/app/modules/core/package.json +46 -19
  68. package/templates/app/modules/core/routes/core.routes.ts +4 -4
  69. package/templates/app/modules/hello/agent/hello.agent.ts +18 -14
  70. package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
  71. package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +4 -8
  72. package/templates/app/modules/hello/helpers/greetings.ts +14 -0
  73. package/templates/app/modules/hello/module.acl.ts +12 -7
  74. package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
  75. package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
  76. package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
  77. package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
  78. package/templates/app/modules/security/routes/security.routes.ts +4 -4
  79. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
  80. package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
  81. package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
  82. package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -4
  83. package/templates/app/modules/settings/tests/settings.test.ts +21 -25
  84. package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
  85. package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
  86. package/templates/app/modules/users/module.acl.ts +4 -4
  87. package/templates/app/modules/users/queues/users.queues.ts +1 -1
  88. package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
  89. package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
  90. package/templates/app/nginx/production.conf +1 -1
  91. package/templates/app/scripts/deploy-nginx.ts +3 -2
  92. package/templates/app/tests/support.ts +4 -3
  93. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +49 -34
  94. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
  95. package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
  96. package/templates/claude/skills/tulipes-endpoint/SKILL.md +36 -19
  97. package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
  98. package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
  99. package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
  100. package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
  101. package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
  102. package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
  103. package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
  104. package/templates/minimal/config/app.config.ts +13 -3
  105. package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
  106. package/templates/minimal/modules/core/module.acl.ts +7 -1
  107. package/templates/minimal/modules/core/package.json +4 -4
  108. package/templates/minimal/modules/hello/module.acl.ts +1 -1
  109. package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
  110. package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
  111. package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
  112. package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
  113. package/templates/versions.json +8 -8
  114. package/templates/app/modules/users/routes/users.routes.ts +0 -60
@@ -9,14 +9,25 @@ Two files: a controller class holding the work, and a shared declaration file wi
9
9
  it up. Every route declares a RAI — a Resource Access Identifier — and the
10
10
  app refuses to boot if one is missing.
11
11
 
12
- `modules/billing/controllers/billing.controllers.ts`:
12
+ Name both files **scope-first**: the scope is the surface the endpoint
13
+ serves (`web`, `bo`, `mobile`), and it is also the second segment of the
14
+ endpoint's RAI. A convention, not a rule core checks, but it is what lets
15
+ anyone find the back office of a module by looking at its folder.
16
+
17
+ ```
18
+ routes/{scope}.{module}[.{extra}].routes.ts web.billing.routes.ts, bo.billing.refunds.routes.ts
19
+ controllers/{scope}.{module}[.{extra}].controllers.ts web.billing.controllers.ts → WebBillingController
20
+ routes/{scope}/{module}[.{extra}].routes.ts several files for one scope go in a folder
21
+ ```
22
+
23
+ `modules/billing/controllers/web.billing.controllers.ts`:
13
24
 
14
25
  ```ts
15
26
  import type { Request, RequestHandler, Response } from "express";
16
27
  import type { Ctx } from "@tulipes/core/boot";
17
28
  import { HttpError } from "@tulipes/core/http";
18
29
 
19
- export class BillingController {
30
+ export class WebBillingController {
20
31
  constructor(private readonly ctx: Ctx) {}
21
32
 
22
33
  show(): RequestHandler {
@@ -33,18 +44,18 @@ Each method **returns** a handler rather than being one, so the routes file
33
44
  reads as a table of contents and the class can keep private helpers beside
34
45
  the handlers that use them without threading ctx through every signature.
35
46
 
36
- `modules/billing/routes/billing.routes.ts`:
47
+ `modules/billing/routes/web.billing.routes.ts`:
37
48
 
38
49
  ```ts
39
50
  import { Router } from "express";
40
51
  import type { BackendCtx } from "@tulipes/core/boot";
41
52
 
42
- import { BillingController } from "../controllers/billing.controllers.js";
53
+ import { WebBillingController } from "../controllers/web.billing.controllers.ts";
43
54
 
44
55
  export default function billingRoutes(ctx: BackendCtx): Router {
45
56
  const router = Router();
46
- const billing = new BillingController(ctx);
47
- router.get("/:id", ctx.rai({ id: "billing:read", name: "Read an invoice" }), billing.show());
57
+ const billing = new WebBillingController(ctx);
58
+ router.get("/:id", ctx.rai({ id: "billing:web:invoice", name: "Read an invoice" }), billing.show());
48
59
  return router; // mounted under config.api.prefix
49
60
  }
50
61
  ```
@@ -57,15 +68,19 @@ child for inherited parameters). A file that only mounts global middleware
57
68
  on `ctx.app` returns nothing.
58
69
 
59
70
  **Build with the context, use infrastructure in handlers.** `tulipes routes`
60
- and `tulipes spec` build every router with nothing acquired: the environment
61
- is declared but unresolved and `ctx.models`, `ctx.queues` and `ctx.sockets`
62
- are stubs that throw. So a controller keeps `ctx` and reads `ctx.models`
63
- inside its handlers (as every controller in this app does), and a middleware
64
- that needs a variable or a client is built on its first request:
71
+ and `tulipes spec` build every router with nothing acquired: an environment
72
+ value (`ctx.config.<module>.<name>`) throws when read, and `ctx.models`,
73
+ `ctx.queues` and `ctx.sockets` are stubs that throw. So a controller keeps
74
+ `ctx` and reads `ctx.models` inside its handlers (as every controller in
75
+ this app does), and a middleware that needs an environment value or a
76
+ client is built on its first request:
65
77
 
66
78
  ```ts
67
- import { lazy } from "@app/core";
68
- app.use(lazy(() => express.json({ limit: String(Environment.get("BODY_LIMIT")) })));
79
+ import { appCache, lazy } from "@app/core";
80
+ app.use(lazy(() => {
81
+ const cache = appCache(ctx); // opens Redis from config.core.redisUrl
82
+ return (req, res, next) => { /* … */ next(); };
83
+ }));
69
84
  ```
70
85
 
71
86
  Use router.use for shared middleware, router.param for parameter callbacks, and
@@ -76,7 +91,10 @@ changes run the app tests, `tulipes routes` and `pnpm spec`; inspect generated d
76
91
 
77
92
  ## The RAI is not optional
78
93
 
79
- `id` names a resource and an access to it, and **is** the ACL permission.
94
+ `id` is `module:scope:resource[:extra]` — `billing:web:invoice`,
95
+ `billing:bo:invoice:refund` — and **is** the ACL permission. Leaving the
96
+ scope out (`billing:read`) is a type error and a boot error. → skill
97
+ `tulipes-permissions` for the format and the grants.
80
98
  One declaration identifies the endpoint, gates it, marks the route as
81
99
  declared, and binds `req.t` to the declaring module's i18n namespace —
82
100
  which is why the message above needs no namespace of its own. → skill
@@ -94,12 +112,11 @@ undeclared route is not merely undocumented, it is reachable by `guest`.
94
112
  Grant the id in `module.acl.ts` — to `guest` if the endpoint is public.
95
113
 
96
114
  `tulipes routes` lists every declared endpoint with the roles that reach it,
97
- without a service, a secret or a `.env`.
115
+ without a service, a secret or a `.env`; `tulipes routes --scope bo` lists one
116
+ surface.
98
117
 
99
- The same declaration is the endpoint's **tool definition**: `tulipes agent
100
- manifest` lists every route as a tool an agent may call, with the `name`,
101
- `description` and schemas as written here. Write the description for a model
102
- choosing between tools; `agent: false` keeps a route out of the manifest.
118
+ A route is never a tool: what the module offers to agents is declared apart,
119
+ in `agent/<module>.agent.ts`, and shares the route's logic through `helpers/`.
103
120
  → skill `tulipes-agent-tool`
104
121
 
105
122
  ## Responses and errors
@@ -1,16 +1,15 @@
1
1
  ---
2
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 the split between environment variables (tulipes.env in package.json) and settings (the module.config.ts schema), the resolution precedence and typed reads.
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 the split between environment values (tulipes.env in package.json, read at ctx.config.<module>.<name>) and settings (the module.config.ts schema), the resolution precedence and what the console may show.
4
4
  ---
5
5
 
6
6
  # Adding a configuration value
7
7
 
8
- **First decide which of two things it is.** An *environment variable* is
9
- what the process needs before the database answers — a connection string,
10
- the port, where logs go — or a secret. Everything else is a *setting*:
11
- declared with a default in the module's `module.config.ts` schema, stored
12
- in the settings store, editable by an operator at runtime without a
13
- restart, read at `ctx.config.<module>`:
8
+ **First decide which of two things it is.** An *environment value* is what
9
+ the process needs before the database answers — a connection string, the
10
+ port, where logs go — or a secret. Everything else is a *setting*: declared
11
+ with a default in the module's `module.config.ts` schema, stored in the
12
+ settings store, editable by an operator at runtime without a restart:
14
13
 
15
14
  ```ts
16
15
  // modules/billing/module.config.ts — the file's only export
@@ -28,39 +27,44 @@ export default defineConfig({
28
27
 
29
28
  Every field needs a default, secrets are refused, and `pnpm sync` types
30
29
  `ctx.config.billing.trialDays` as `number`. The rest of this guide is the
31
- environment variable.
30
+ environment value.
32
31
 
33
- **Never read `process.env` directly.** Every variable is declared by
34
- exactly one module and read through the typed store. An undeclared read
35
- throws, so a typo can never reach production as `undefined`.
32
+ **Both land in the same place.** A module's settings and its environment
33
+ values share its namespace, `ctx.config.<module>`: `config.billing.trialDays`
34
+ is a setting an operator edits, `config.billing.stripeApiKey` is an
35
+ environment value the deployment sets. Code reads them the same way; the
36
+ console (`GET /config`) shows the first with its value and the second by
37
+ name, type and source only. **Never read `process.env` directly.**
36
38
 
37
39
  ## Steps
38
40
 
39
41
  1. Choose the owning module. Infra shared by everything (`PORT`,
40
42
  `MONGO_URI`, `REDIS_URL`) belongs to the sys `core` module; anything
41
43
  feature-specific belongs to that feature's module.
42
- 2. Declare it under `tulipes.env` in that module's `package.json`:
44
+ 2. Declare it under `tulipes.env` in that module's `package.json`, keyed by
45
+ the **config name** you will read it as, with `key` naming the variable
46
+ the deployment sets:
43
47
 
44
48
  ```json
45
49
  {
46
50
  "name": "@app/billing",
47
51
  "tulipes": {
48
- "env": [
49
- {
50
- "name": "STRIPE_API_KEY",
51
- "type": "secret",
52
- "group": "billing",
53
- "required": true,
54
- "description": "Server-side Stripe key"
55
- },
56
- {
57
- "name": "BILLING_TRIAL_DAYS",
58
- "type": "number",
59
- "group": "billing",
60
- "description": "Free trial length",
61
- "default": 14
62
- }
63
- ]
52
+ "env": {
53
+ "stripeApiKey": {
54
+ "key": "STRIPE_API_KEY",
55
+ "type": "secret",
56
+ "group": "billing",
57
+ "required": true,
58
+ "description": "Server-side Stripe key"
59
+ },
60
+ "webhookTolerance": {
61
+ "key": "BILLING_WEBHOOK_TOLERANCE",
62
+ "type": "number",
63
+ "group": "billing",
64
+ "description": "Seconds a webhook signature may be old",
65
+ "default": 300
66
+ }
67
+ }
64
68
  }
65
69
  }
66
70
  ```
@@ -68,21 +72,41 @@ throws, so a typo can never reach production as `undefined`.
68
72
  3. Add a value to `.envs/.env.development` for anything `required`.
69
73
  4. `pnpm sync` — regenerates `types/tulipes.d.ts` (so the read is typed)
70
74
  and `.env.example` (so deployments know what to set).
71
- 5. Read it: `Environment.get("BILLING_TRIAL_DAYS")` → typed `number`.
75
+ 5. Read it where it is used: `ctx.config.billing.stripeApiKey`, typed
76
+ `string`; a value with no default and not `required` is typed
77
+ `| undefined`.
78
+
79
+ The framework and the providers read fixed keys that `config/app.config.ts`
80
+ wires from these values — `server.port`, `mongoose.uri`, `bullmq.redisUrl`.
81
+ A module's own values need no wiring.
72
82
 
73
83
  ## Field rules
74
84
 
75
85
  | Field | Rule |
76
86
  |---|---|
77
- | `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. |
87
+ | name (the object key) | camelCase, like a settings field. Unique within the module, and not also a settings field — the boot refuses a name used by both. |
88
+ | `key` | UPPER_SNAKE, and **unique across the app** — two modules declaring the same key crash the boot. Do not re-declare a variable another module owns; read that module's config. |
78
89
  | `type` | `string` · `number` · `boolean` · `enum` · `url` · `secret`. Values are coerced (`"14"` → `14`) and validated. |
79
90
  | `enum` | Required with `type: "enum"`. Generates a literal union type. |
80
91
  | `required` | No value anywhere → boot crash. Mutually exclusive with `default`. |
81
92
  | `default` | Used when nothing supplies a value. Never put a real credential here. |
82
93
  | `group` | Free-form label; groups the variable in the generated `.env.example`. |
83
94
 
84
- Use `secret` for anything sensitive — those values are redacted in logs,
85
- reports and the startup banner.
95
+ Use `secret` for anything sensitive: its value is withheld from `GET
96
+ /config`, from `tulipes env:check` and from the startup banner.
97
+
98
+ ## How the values behave
99
+
100
+ - **Read-only.** Assigning to `ctx.config.billing.stripeApiKey` throws. A
101
+ deployment value is not something code changes.
102
+ - **Invisible to listings.** `{ ...ctx.config.billing }`, `JSON.stringify`,
103
+ `Object.keys` and `console.log` skip environment values — that is what
104
+ keeps a secret out of a response body, a log line and the settings
105
+ store. To see them, run `pnpm check` (`tulipes env:check`).
106
+ - **Refused under inspection.** `tulipes routes` and `tulipes spec` boot
107
+ with nothing acquired; reading an environment value while *building* a
108
+ router throws, naming it. Read it inside the handler, or build the
109
+ middleware with `lazy()` from `@app/core`.
86
110
 
87
111
  ## Precedence
88
112
 
@@ -92,15 +116,16 @@ values always win, which is why deployments need no file.
92
116
  ## Where it does NOT go
93
117
 
94
118
  - Value shared by several modules but the same everywhere (pagination
95
- limits, body size) → `config/app.config.ts`, not a variable.
119
+ limits, locales) → `config/app.config.ts`, not a variable.
96
120
  - Any value an operator may want to change at runtime → a setting in that
97
121
  module's `module.config.ts` schema (above), not a variable.
98
122
 
99
123
  ## Gotchas
100
124
 
101
- - `Environment.get()` on an undeclared name **throws**. If you added the
102
- entry and still get the error, you did not run `pnpm sync`, or you
103
- edited a different module's manifest than the one you are reading from.
104
- - `APP_ENV` (not `NODE_ENV`) selects the env file.
125
+ - A read is `undefined` and you expected a value: you did not run
126
+ `pnpm sync` (the type would have told you), or the name is spelled
127
+ differently from the manifest, or the value is optional and nothing
128
+ supplied it.
129
+ - `APP_ENV` (not `NODE_ENV`) selects the env file; read it as `ctx.appEnv`.
105
130
  - A missing `required` variable is not a runtime surprise — the boot
106
131
  aborts and lists every missing variable at once.
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
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, the missing-key files, and time zones (req.timezone, req.time, ctx.time).
4
4
  ---
5
5
 
6
6
  # Translating
@@ -96,3 +96,22 @@ served by `fr` unless `fr-CA.json` exists.
96
96
  default-locale file, aborts the boot — i18next would otherwise serve raw
97
97
  keys with nothing in the log to explain it.
98
98
  - A module with no `i18n/` folder is fine; it simply has nothing to say.
99
+
100
+ ## Time zones
101
+
102
+ Dates are stored and returned as UTC; never convert them in a response
103
+ body. A zone is how a date is *read*, and it is negotiated like the locale:
104
+ `?tz=Europe/Paris`, then an `X-Timezone` header, then
105
+ `config.timezone.default` (`config/app.config.ts`).
106
+
107
+ ```ts
108
+ const since = req.time.startOfDay(); // the caller's midnight, as a UTC Date
109
+ const label = req.time.format(order.created_at); // in req.timezone and req.locale
110
+ const at = req.time.toISO(new Date()); // "2026-10-13T15:00:00.000+01:00"
111
+ ```
112
+
113
+ Without a request — a queue processor, an email to a user — read in the
114
+ recipient's zone: `ctx.time.in(user.timezone, user.locale).format(date)`.
115
+ Never call `getHours()`/`toLocaleString()` without a zone, and never build
116
+ "today" from `new Date().setHours(0)`: both read the process zone, which is
117
+ the app's default, not the caller's.
@@ -21,11 +21,12 @@ touching anything else.
21
21
 
22
22
  ## Steps
23
23
 
24
- 1. Scaffold: `pnpm tulipes new module billing`. It creates every
24
+ 1. Scaffold: `pnpm tulipes new module billing` (add `--scope bo` for a
25
+ back-office module; the default is `web`). It creates every
25
26
  contract — package.json (scope detected from siblings), a settings
26
27
  declaration, ACL, helper, a controller class, a plain routes function
27
- declaring a RAI, model, a bootstrap that may return its cleanup, queue and
28
- socket. Delete the ones this feature does not need. The module root holds
28
+ declaring the RAI `billing:web:list`, model, a bootstrap that may return
29
+ its cleanup, queue and socket. Delete the ones this feature does not need. The module root holds
29
30
  only `package.json`, `module.config.ts` and `module.acl.ts`; everything
30
31
  else has a folder, and the boot refuses a stray root file.
31
32
  2. Set ordering in `modules/billing/package.json`:
@@ -40,7 +41,11 @@ touching anything else.
40
41
 
41
42
  3. `pnpm install` — the module is a workspace, so the package manager must
42
43
  link it before imports resolve.
43
- 4. Put endpoints and shared middleware in native `routes/*.routes.ts` factories.
44
+ 4. Put endpoints and shared middleware in native routes factories, named
45
+ scope-first: `routes/web.billing.routes.ts`, `routes/bo.billing.routes.ts`,
46
+ or `routes/web/billing.invoices.routes.ts` when one scope has several
47
+ files. Every id they declare is `billing:<scope>:<resource>[:<extra>]`.
48
+ → skill `tulipes-permissions`
44
49
  Add optional files as needed:
45
50
  `models/`, `queues/`, `sockets/`, `bootstrap/`, `module.acl.ts`,
46
51
  `i18n/` — the module is its own i18n namespace. → skill `tulipes-i18n`
@@ -50,7 +55,7 @@ touching anything else.
50
55
 
51
56
  | Field | Meaning |
52
57
  |---|---|
53
- | `tier` | `sys` loads before every `app` module. Use `sys` **only** for framework-level concerns: global middleware, roles, infra variables. A business feature is always `app`. |
58
+ | `tier` | `sys` loads before every `app` module. Use `sys` **only** for framework-level concerns: global middleware, roles, the infrastructure environment values. A business feature is always `app`. |
54
59
  | `priority` | Lower loads earlier within a tier. Default 100. Use it for coarse ordering, not for dependencies. |
55
60
  | `dependsOn` | Short names (`"users"`, not `"@app/users"`) of modules that must load first. Declare this whenever you import from another module or read its config. |
56
61
 
@@ -1,49 +1,113 @@
1
1
  ---
2
2
  name: tulipes-permissions
3
- description: Use when adding roles or permissions to a Tulipes app, guarding an endpoint by role, making a route public, or deciding who may do what. Covers module.acl.ts, the guest role, and module-namespaced resources.
3
+ description: Use when adding roles or permissions to a Tulipes app, naming a RAI id, guarding an endpoint by role, making a route public, choosing a scope (bo, web, mobile), or deciding who may do what. Covers the module:scope:resource[:extra] format, module.acl.ts, wildcards, role descriptions and extras, and the guest role.
4
4
  ---
5
5
 
6
6
  # Adding permissions
7
7
 
8
- Roles are **global** and defined once by the sys `auth` module. Every other
9
- module **grants** permissions on its own resources to those roles.
8
+ ## The RAI format
10
9
 
11
- `modules/auth/module.acl.ts` — the vocabulary:
10
+ Every route and every agent action declares a RAI id, and that id **is**
11
+ the permission. It always has this shape:
12
+
13
+ ```
14
+ {module}:{scope}:{resource}[:{extra}]
15
+ ```
16
+
17
+ | Segment | Required | What it is | Examples |
18
+ |---|---|---|---|
19
+ | module | yes | the declaring module's name, as its folder spells it | `users`, `user-profiles` |
20
+ | scope | yes | the surface the endpoint serves | `bo`, `web`, `mobile` |
21
+ | resource | yes | what is reached | `list`, `detail`, `profile` |
22
+ | extra | no | a variant or sub-action of the resource | `delete`, `deleted`, `by-name` |
23
+
24
+ ```
25
+ users:bo:list the back office lists users
26
+ users:mobile:profile the mobile app reads the caller's profile
27
+ users:bo:detail the back office reads one user
28
+ users:bo:detail:delete ...and deletes it
29
+ ```
30
+
31
+ Each segment is lowercase, starts with a letter, and may hold digits and
32
+ hyphens. `users:read` (no scope), `Users:bo:list` and a fifth segment are
33
+ refused: in the editor, because `id` is typed, and at boot with the route
34
+ and module named.
35
+
36
+ Scopes are the app's own vocabulary. This app uses `web` for what a
37
+ signed-in client calls and `bo` for the back office; add `mobile` the day
38
+ a mobile client needs endpoints of its own. Nothing declares a scope
39
+ first. Keep the list short and spell it the same everywhere, because
40
+ `tulipes routes --scope bo` and `tulipes spec --scope mobile` filter on it.
41
+
42
+ ## Roles are defined once, with what they mean
43
+
44
+ Roles are **global** and defined by the `auth` module. A definition can
45
+ carry a description and any other property the app wants to read back:
12
46
 
13
47
  ```ts
48
+ // modules/auth/module.acl.ts
49
+ declare module "@tulipes/core/acl" {
50
+ interface RoleMeta { level?: number } // types the extra everywhere
51
+ }
52
+
14
53
  export default function authAcl(acl: AclBuilder): void {
15
- acl.defineRole("guest").defineRole("user").defineRole("admin");
16
- acl.allow("guest", "auth:login", "auth:register", "auth:refresh");
17
- acl.allow("admin", "*");
54
+ acl
55
+ .defineRole("guest", { description: "Any request without a valid token", level: 0 })
56
+ .defineRole("user", { description: "A signed-in account", level: 1 })
57
+ .defineRole("admin", { description: "Runs the back office", level: 2 });
58
+
59
+ acl.allow("guest", "auth:web:login", "auth:web:register", "auth:web:refresh");
18
60
  }
19
61
  ```
20
62
 
21
- `modules/billing/module.acl.ts` — grants for this feature:
63
+ Read a role back anywhere with `ctx.acl.role("admin")`: it returns
64
+ `{ name, definedBy, description, level }`, frozen. `ctx.acl.describeRoles()`
65
+ lists them all. `pnpm tulipes roles` prints each role with its
66
+ description, extras and every grant it holds, and the OpenAPI document
67
+ lists them under `x-tulipes-roles`.
68
+
69
+ `RoleMeta` is optional. Undeclared extras are accepted and returned as
70
+ `unknown`; declaring them is what types them and catches
71
+ `level: "high"` in the editor.
72
+
73
+ ## Every other module grants on its own ids
22
74
 
23
75
  ```ts
76
+ // modules/billing/module.acl.ts
24
77
  export default function billingAcl(acl: AclBuilder): void {
25
- acl.allow("user", "invoices:read");
78
+ acl.allow("user", "billing:web:invoices", "billing:web:invoices:download");
79
+ acl.allow("admin", "billing:bo:*");
26
80
  }
27
81
  ```
28
82
 
29
- Guarding is the route's RAI — its `id` **is** the permission, so there is
30
- no second call to forget:
83
+ Guarding is the route's RAI, so there is no second call to forget:
31
84
 
32
85
  ```ts
33
- router.get(
34
- `${base}/invoices`,
35
- rai({ id: "invoices:read", name: "List invoices" }),
36
- billing.list(),
37
- );
86
+ router.get("/invoices", rai({ id: "billing:web:invoices", name: "List my invoices" }), billing.list());
38
87
  ```
39
88
 
89
+ ## Wildcards
90
+
91
+ A grant is an exact id, `*`, or a prefix ending in `:*`. A prefix covers
92
+ everything under it, **the bare resource included**:
93
+
94
+ | Grant | Covers |
95
+ |---|---|
96
+ | `billing:bo:invoices:*` | `billing:bo:invoices`, `billing:bo:invoices:refund`, … |
97
+ | `billing:bo:*` | every billing permission in the back office |
98
+ | `billing:*` | every billing permission, any scope |
99
+ | `*` | everything. Give it to nobody but a true superuser. |
100
+
101
+ A scope across modules (`*:bo:*`) is not a grant: each module grants its
102
+ own ids, so the whole policy is readable module by module.
103
+
40
104
  ## Public routes are grants, not exceptions
41
105
 
42
- A request with no valid token is not rejected — it becomes the **`guest`**
43
- caller. So "public" means guest holds the permission:
106
+ A request with no valid token is not rejected. It becomes the **`guest`**
107
+ caller, so "public" means guest holds the permission:
44
108
 
45
109
  ```ts
46
- acl.allow("guest", "docs:read"); // now anyone may read the docs
110
+ acl.allow("guest", "docs:web:read"); // now anyone may read the docs
47
111
  ```
48
112
 
49
113
  There is no allowlist, no `skipAuth` flag, no second mechanism. Access is
@@ -52,20 +116,30 @@ decided in one place, and the whole policy is readable by reading the
52
116
 
53
117
  **The corollary matters: a route with no permission check is public.**
54
118
  Because the pipeline authenticates rather than rejects, forgetting a guard
55
- does not fail closed — it silently exposes the route. Name a permission on
119
+ does not fail closed. It silently exposes the route. Name a permission on
56
120
  every route, even ones you expect everybody to reach.
57
121
 
58
- ## Rules
122
+ ## Files follow the scope (a convention)
59
123
 
60
- - **Resources are namespaced by module**: `invoices:read`, `users:write`.
61
- Match the module's short name so ownership is obvious.
62
- - **Grant, don't define.** A feature module calling `defineRole()` usually
63
- means the role belongs in `auth`. `allow()` on an unknown role is a boot
64
- error, never an implicit creation.
65
- - The same role+resource granted twice crashes the boot.
66
- - Wildcards: `*` (everything), `invoices:*` (a namespace). Give `*` to
67
- `admin` only.
68
- - Unknown roles always deny at runtime.
124
+ Name routes and controllers scope-first, so a module's surfaces read off
125
+ its folder. Core does not check this; it reads every `*.routes.ts` under
126
+ `routes/`, at any depth.
127
+
128
+ ```
129
+ modules/users/
130
+ routes/web.users.routes.ts users:web:*
131
+ routes/bo.users.routes.ts users:bo:*
132
+ routes/bo.users.passwords.routes.ts a second bo file: the extra slot
133
+ controllers/web.users.controllers.ts export class WebUsersController
134
+ controllers/bo.users.controllers.ts export class BoUsersController
135
+
136
+ modules/hello/ several files in one scope: a folder
137
+ routes/web/hello.routes.ts
138
+ routes/web/hello.greetings.routes.ts
139
+ controllers/web/hello.greetings.controllers.ts HelloGreetingsController
140
+ ```
141
+
142
+ `tulipes new module billing --scope bo` scaffolds the flat form.
69
143
 
70
144
  ## Who is calling
71
145
 
@@ -79,8 +153,15 @@ session?.sid // this device's session row, or null
79
153
  session?.accessJti // the token in hand, for revoking exactly it
80
154
  ```
81
155
 
82
- Never re-parse the Authorization header or trust a claim out of a token —
83
- the pipeline already resolved both, and reloads the user on every request
156
+ The route's own declaration is `req.rai`, and `parseRai` splits its id:
157
+
158
+ ```ts
159
+ import { parseRai } from "@tulipes/core/http";
160
+ const { scope } = parseRai(req.rai!.id); // "bo"
161
+ ```
162
+
163
+ Never re-parse the Authorization header or trust a claim out of a token.
164
+ The pipeline already resolved both, and reloads the user on every request
84
165
  so a demotion takes effect immediately.
85
166
 
86
167
  A route that only cares that somebody is signed in still declares a RAI
@@ -93,13 +174,20 @@ The RAI answers 401 when the caller is a guest (signing in would help) and
93
174
 
94
175
  ## Gotchas
95
176
 
177
+ - **Grant, don't define.** A feature module calling `defineRole()` usually
178
+ means the role belongs in `auth`. `allow()` on an unknown role is a boot
179
+ error, never an implicit creation.
180
+ - The same role+resource granted twice crashes the boot.
96
181
  - Load order: roles must exist before grants, which is why `auth` is
97
182
  `tier: "sys", priority: 20`. A module granting on those roles is app-tier
98
183
  and therefore loads later; if you make one sys-tier, give it a higher
99
184
  priority than auth or it will crash with "unknown role".
100
- - `acl.can()` answers *what a role may do*, not *who the caller is*.
101
- Resolving the caller is the auth module's job — it sets `req.auth`.
185
+ - `acl.can(role, id)` answers *what a role may do*; `acl.rolesFor(id)`
186
+ answers *who may do this*. Neither says who the caller is. Resolving the
187
+ caller is the auth module's job: it sets `req.auth`.
102
188
  - The role core checks is `req.auth.user.role`. An app whose roles live
103
189
  elsewhere replaces the decision with `ctx.routes.setAccessChecker()`
104
190
  rather than inventing a fake user.
105
- - Roles are lowercase kebab-case; resources are `name:action`.
191
+ - Unknown roles always deny at runtime.
192
+ - Roles are lowercase kebab-case. `name` and `definedBy` are set by the
193
+ ACL and cannot be part of a definition.
@@ -78,5 +78,6 @@ nothing processes them — that is the correct behaviour, not a bug.
78
78
  - Keep payloads small and JSON-serializable.
79
79
  - Throwing marks the job failed and schedules a retry per its options; let
80
80
  it throw rather than swallowing errors.
81
- - Both processes run the full boot pipeline, so `models`, `config` and
82
- `Environment` are all available inside a processor.
81
+ - Both processes run the full boot pipeline, so `ctx.models` and
82
+ `ctx.config` (settings and environment values alike) are available
83
+ inside a processor.
@@ -5,13 +5,14 @@ description: Use when streaming an app's logs, or when deciding whether a value
5
5
 
6
6
  # Settings and the settings module
7
7
 
8
- Two layers of configuration. **Environment** is what the process needs
9
- before the settings store can answer — the port, the connection strings,
10
- the log destinations — plus secrets: declared under `tulipes.env` in a
11
- module's `package.json`, read with `ctx.Environment.get()`, resolved once
12
- at boot. **Settings** are everything else: declared with a zod schema in
13
- `module.config.ts`, stored per module, validated on every write,
14
- hot-applied, read live at `ctx.config.<module>`. The test is mechanical:
8
+ Two layers of configuration, one place to read them. **Environment values**
9
+ are what the process needs before the settings store can answer — the
10
+ port, the connection strings, the log destinations — plus secrets:
11
+ declared under `tulipes.env` in a module's `package.json`, resolved once at
12
+ boot, read-only at `ctx.config.<module>.<name>`. **Settings** are everything
13
+ else: declared with a zod schema in `module.config.ts`, stored per module,
14
+ validated on every write, hot-applied, read live at the same
15
+ `ctx.config.<module>`. The test is mechanical:
15
16
  if boot does not need it before the database answers and it is not a
16
17
  secret, it is a setting. → skills `tulipes-env-variable`, `tulipes-config`
17
18
 
@@ -54,7 +55,7 @@ and refuses when `LOG_FILE` is off.
54
55
  | 0.10 endpoint | 0.11 |
55
56
  |---|---|
56
57
  | `GET /settings/modules` | `GET /config` — the `config` module |
57
- | `GET`/`PUT /settings/variables/:name` | gone: no editable secrets; variables are read-only in `GET /config` |
58
+ | `GET`/`PUT /settings/variables/:name` | gone: no editable secrets; environment values are described (never a secret's value) in `GET /config` |
58
59
  | `POST /settings/reboot` | gone: nothing needs a restart to take effect; a restart-tagged field says so in the write's response |
59
60
  | `GET`/`PUT /settings/i18n/:module/:locale` | gone: translations are code, edited in the repository |
60
61
  | the `.env` file editor, PM2 control, the audit of those | gone with them; the console's writes are audited by the `config` module |
@@ -14,7 +14,7 @@ endpoint would reject.
14
14
  import { z } from "zod/v4"; // v4, not the classic import
15
15
 
16
16
  router.post("/users", rai({
17
- id: "users:create",
17
+ id: "users:bo:create",
18
18
  name: "Create a user",
19
19
  body: z.object({ email: z.email("emailInvalid") }),
20
20
  returns: z.object({ id: z.string(), email: z.string() }),
@@ -64,7 +64,7 @@ split one module's routes apart, or to gather several modules under one
64
64
  heading:
65
65
 
66
66
  ```ts
67
- rai({ id: "auth:sessions", name: "List sessions", folder: "sessions" })
67
+ rai({ id: "auth:web:sessions", name: "List sessions", folder: "sessions" })
68
68
  ```
69
69
 
70
70
  ## Generating
@@ -73,6 +73,16 @@ rai({ id: "auth:sessions", name: "List sessions", folder: "sessions" })
73
73
  pnpm spec # tulipes spec --openapi docs/openapi.json --postman docs/collection.json
74
74
  ```
75
75
 
76
+ One document per client surface: the RAI's scope is a filter, so a mobile
77
+ team gets only what the mobile app calls. Every operation carries
78
+ `x-tulipes-scope`, and the document lists the app's roles (with their
79
+ descriptions and extras) under `x-tulipes-roles`.
80
+
81
+ ```sh
82
+ pnpm tulipes spec --openapi docs/mobile.json --scope mobile
83
+ pnpm tulipes spec --postman docs/backoffice.json --scope bo
84
+ ```
85
+
76
86
  Generation is an inspection boot: every routes file runs with nothing
77
87
  acquired, so no service, secret or `.env` is needed, and a routes file that
78
88
  touches infrastructure while building fails with its path. `--url` sets the