@tulipes/cli 0.2.0 → 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 (110) hide show
  1. package/dist/config-check.js +1 -1
  2. package/dist/env-check.d.ts +5 -3
  3. package/dist/env-check.js +26 -19
  4. package/dist/env-check.js.map +1 -1
  5. package/dist/main.js +83 -19
  6. package/dist/main.js.map +1 -1
  7. package/dist/migrate-0.12-env.d.ts +36 -0
  8. package/dist/migrate-0.12-env.js +250 -0
  9. package/dist/migrate-0.12-env.js.map +1 -0
  10. package/dist/migrate-0.12-scaffold.d.ts +1 -0
  11. package/dist/migrate-0.12-scaffold.js +197 -0
  12. package/dist/migrate-0.12-scaffold.js.map +1 -0
  13. package/dist/migrate-0.12.d.ts +47 -0
  14. package/dist/migrate-0.12.js +210 -0
  15. package/dist/migrate-0.12.js.map +1 -0
  16. package/dist/migrate.d.ts +3 -1
  17. package/dist/migrate.js +11 -5
  18. package/dist/migrate.js.map +1 -1
  19. package/dist/new-module.d.ts +7 -0
  20. package/dist/new-module.js +35 -16
  21. package/dist/new-module.js.map +1 -1
  22. package/dist/roles.d.ts +15 -0
  23. package/dist/roles.js +54 -0
  24. package/dist/roles.js.map +1 -0
  25. package/dist/routes.d.ts +7 -2
  26. package/dist/routes.js +19 -10
  27. package/dist/routes.js.map +1 -1
  28. package/dist/spec.d.ts +2 -0
  29. package/dist/spec.js +13 -11
  30. package/dist/spec.js.map +1 -1
  31. package/dist/sync.js +66 -53
  32. package/dist/sync.js.map +1 -1
  33. package/package.json +4 -4
  34. package/templates/AGENTS.md +35 -23
  35. package/templates/app/.envs/.env.development +2 -1
  36. package/templates/app/.envs/.env.production +2 -2
  37. package/templates/app/.envs/.env.staging +2 -2
  38. package/templates/app/README.md +44 -29
  39. package/templates/app/config/app.config.ts +60 -7
  40. package/templates/app/docs/agent-manifest.json +8 -681
  41. package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +1 -1
  42. package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -4
  43. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
  44. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
  45. package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
  46. package/templates/app/modules/auth/helpers/passport.ts +1 -1
  47. package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
  48. package/templates/app/modules/auth/helpers/tokens.ts +3 -5
  49. package/templates/app/modules/auth/module.acl.ts +31 -9
  50. package/templates/app/modules/auth/module.config.ts +5 -3
  51. package/templates/app/modules/auth/package.json +10 -10
  52. package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
  53. package/templates/app/modules/auth/tests/auth.test.ts +1 -1
  54. package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
  55. package/templates/app/modules/config/helpers/audit.ts +1 -1
  56. package/templates/app/modules/config/helpers/describe.ts +35 -26
  57. package/templates/app/modules/config/module.acl.ts +1 -1
  58. package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
  59. package/templates/app/modules/config/tests/config.test.ts +14 -6
  60. package/templates/app/modules/core/helpers/cache/index.ts +8 -7
  61. package/templates/app/modules/core/helpers/lazy.ts +3 -3
  62. package/templates/app/modules/core/helpers/logger/index.ts +24 -16
  63. package/templates/app/modules/core/module.config.ts +5 -3
  64. package/templates/app/modules/core/package.json +46 -19
  65. package/templates/app/modules/core/routes/core.routes.ts +4 -4
  66. package/templates/app/modules/hello/agent/hello.agent.ts +2 -2
  67. package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
  68. package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +2 -2
  69. package/templates/app/modules/hello/module.acl.ts +12 -7
  70. package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
  71. package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
  72. package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
  73. package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
  74. package/templates/app/modules/security/routes/security.routes.ts +4 -4
  75. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
  76. package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
  77. package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
  78. package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -3
  79. package/templates/app/modules/settings/tests/settings.test.ts +21 -25
  80. package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
  81. package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
  82. package/templates/app/modules/users/module.acl.ts +4 -4
  83. package/templates/app/modules/users/queues/users.queues.ts +1 -1
  84. package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
  85. package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
  86. package/templates/app/nginx/production.conf +1 -1
  87. package/templates/app/scripts/deploy-nginx.ts +3 -2
  88. package/templates/app/tests/support.ts +4 -3
  89. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +6 -4
  90. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
  91. package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
  92. package/templates/claude/skills/tulipes-endpoint/SKILL.md +34 -15
  93. package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
  94. package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
  95. package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
  96. package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
  97. package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
  98. package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
  99. package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
  100. package/templates/minimal/config/app.config.ts +13 -3
  101. package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
  102. package/templates/minimal/modules/core/module.acl.ts +7 -1
  103. package/templates/minimal/modules/core/package.json +4 -4
  104. package/templates/minimal/modules/hello/module.acl.ts +1 -1
  105. package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
  106. package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
  107. package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
  108. package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
  109. package/templates/versions.json +8 -8
  110. package/templates/app/modules/users/routes/users.routes.ts +0 -60
@@ -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
@@ -2,15 +2,25 @@ import type { AppConfigFn } from "@tulipes/core/boot";
2
2
 
3
3
  /**
4
4
  * App-wide values that belong to no module. `api.prefix` is where a router
5
- * a routes file returns is mounted; `app.name` titles the startup banner.
5
+ * a routes file returns is mounted; `app.name` titles the startup banner;
6
+ * `timezone.default` is the zone instants are read in (requests may ask for
7
+ * another with `?tz=` or X-Timezone) and, from boot on, the process zone.
8
+ *
9
+ * `env` is every module's environment values — here `env.core.port`, from
10
+ * the `PORT` the core module declares in its package.json — and this is
11
+ * where they are wired to what core reads: `server.port`. A module reads
12
+ * its own values as `ctx.config.<module>.<name>` without any wiring.
6
13
  */
7
- const appConfig = (() => ({
14
+ const appConfig = ((env) => ({
8
15
  app: { name: "tulipes-example" },
9
16
  api: { prefix: "/api/v1" },
17
+ server: { port: env.core.port },
18
+ timezone: { default: "UTC" },
10
19
  })) satisfies AppConfigFn;
11
20
 
12
21
  export default appConfig;
13
22
 
23
+ /** `server` is left out: core already types the key it reads. */
14
24
  declare module "@tulipes/core/config" {
15
- interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}
25
+ interface GlobalConfig extends Partial<Omit<ReturnType<typeof appConfig>, "server">> {}
16
26
  }
@@ -7,11 +7,11 @@ import { roleOf } from "@tulipes/core/http";
7
7
  export default function agentRoutes(ctx: BackendCtx): Router {
8
8
  const { rai } = ctx;
9
9
  const router = Router();
10
- router.get("/agent/tools", rai({ id: "agent:tools", name: "Tools an agent may call" }), (req, res) => {
10
+ router.get("/agent/tools", rai({ id: "agent:web:tools", name: "Tools an agent may call" }), (req, res) => {
11
11
  res.respond({ data: ctx.agent.manifest(roleOf(req)) });
12
12
  });
13
13
  router.post("/agent/tools/:name", rai({
14
- id: "agent:invoke", name: "Run a declared action",
14
+ id: "agent:web:invoke", name: "Run a declared action",
15
15
  params: z.object({ name: z.string() }), body: z.looseObject({}),
16
16
  }), async (req, res) => {
17
17
  res.respond({ data: await ctx.agent.invoke(String(req.params.name), req.body, { ...req.auth, role: roleOf(req) }, req) });
@@ -1,4 +1,10 @@
1
1
  import type { AclBuilder } from "@tulipes/core/acl";
2
+
3
+ /**
4
+ * The roles, defined once. A RAI id is "module:scope:resource[:extra]";
5
+ * a grant names one or a prefix ending in ":*". `pnpm tulipes roles`
6
+ * prints every role with its grants.
7
+ */
2
8
  export default function permissions(acl: AclBuilder) {
3
- acl.defineRole("guest").allow("guest", "core:health");
9
+ acl.defineRole("guest", { description: "Any request without a valid token" });
4
10
  }
@@ -6,14 +6,14 @@
6
6
  "tulipes": {
7
7
  "tier": "sys",
8
8
  "priority": 0,
9
- "env": [
10
- {
11
- "name": "PORT",
9
+ "env": {
10
+ "port": {
11
+ "key": "PORT",
12
12
  "type": "number",
13
13
  "default": 3000,
14
14
  "description": "HTTP listener port"
15
15
  }
16
- ]
16
+ }
17
17
  },
18
18
  "dependencies": {
19
19
  "@tulipes/core": "workspace:*"
@@ -1,2 +1,2 @@
1
1
  import type { AclBuilder } from "@tulipes/core/acl";
2
- export default function permissions(acl: AclBuilder) { acl.allow("guest", "hello:read"); }
2
+ export default function permissions(acl: AclBuilder) { acl.allow("guest", "hello:web:read", "hello:web:health"); }
@@ -2,7 +2,7 @@ import { Router } from "express";
2
2
  import type { BackendCtx } from "@tulipes/core/boot";
3
3
  export default function healthRoutes({ rai, routes }: BackendCtx): void {
4
4
  const router = Router();
5
- router.get("/health", rai({ id: "core:health", name: "Health" }), (_req, res) => {
5
+ router.get("/health", rai({ id: "hello:web:health", name: "Health" }), (_req, res) => {
6
6
  res.respond({ data: { status: "ok" } });
7
7
  });
8
8
  routes.mount(router, "");
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
3
3
  import type { BackendCtx } from "@tulipes/core/boot";
4
4
  export default function helloRoutes({ rai }: BackendCtx): Router {
5
5
  const router = Router();
6
- router.get("/hello", rai({ id: "hello:read", name: "Hello", returns: z.object({ message: z.string() }) }), (req, res) => {
6
+ router.get("/hello", rai({ id: "hello:web:read", name: "Hello", returns: z.object({ message: z.string() }) }), (req, res) => {
7
7
  res.respond({ data: { message: req.t("greeting") } });
8
8
  });
9
9
  return router;
@@ -2,7 +2,7 @@ import type { Request, RequestHandler, Response } from "express";
2
2
  import { requireModels, type Ctx } from "@tulipes/core/boot";
3
3
  import { HttpError } from "@tulipes/core/http";
4
4
 
5
- export class GreetingsController {
5
+ export class HelloGreetingsController {
6
6
  readonly #ctx: Ctx;
7
7
 
8
8
  constructor(ctx: Ctx) {