@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.
- package/dist/config-check.js +1 -1
- package/dist/env-check.d.ts +5 -3
- package/dist/env-check.js +26 -19
- package/dist/env-check.js.map +1 -1
- package/dist/main.js +83 -19
- package/dist/main.js.map +1 -1
- package/dist/migrate-0.12-env.d.ts +36 -0
- package/dist/migrate-0.12-env.js +250 -0
- package/dist/migrate-0.12-env.js.map +1 -0
- package/dist/migrate-0.12-scaffold.d.ts +1 -0
- package/dist/migrate-0.12-scaffold.js +197 -0
- package/dist/migrate-0.12-scaffold.js.map +1 -0
- package/dist/migrate-0.12.d.ts +47 -0
- package/dist/migrate-0.12.js +210 -0
- package/dist/migrate-0.12.js.map +1 -0
- package/dist/migrate.d.ts +3 -1
- package/dist/migrate.js +11 -5
- package/dist/migrate.js.map +1 -1
- package/dist/new-module.d.ts +7 -0
- package/dist/new-module.js +35 -16
- package/dist/new-module.js.map +1 -1
- package/dist/roles.d.ts +15 -0
- package/dist/roles.js +54 -0
- package/dist/roles.js.map +1 -0
- package/dist/routes.d.ts +7 -2
- package/dist/routes.js +19 -10
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -0
- package/dist/spec.js +13 -11
- package/dist/spec.js.map +1 -1
- package/dist/sync.js +66 -53
- package/dist/sync.js.map +1 -1
- package/package.json +4 -4
- package/templates/AGENTS.md +35 -23
- package/templates/app/.envs/.env.development +2 -1
- package/templates/app/.envs/.env.production +2 -2
- package/templates/app/.envs/.env.staging +2 -2
- package/templates/app/README.md +44 -29
- package/templates/app/config/app.config.ts +60 -7
- package/templates/app/docs/agent-manifest.json +8 -681
- package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +1 -1
- package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -4
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
- package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
- package/templates/app/modules/auth/helpers/passport.ts +1 -1
- package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
- package/templates/app/modules/auth/helpers/tokens.ts +3 -5
- package/templates/app/modules/auth/module.acl.ts +31 -9
- package/templates/app/modules/auth/module.config.ts +5 -3
- package/templates/app/modules/auth/package.json +10 -10
- package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
- package/templates/app/modules/auth/tests/auth.test.ts +1 -1
- package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
- package/templates/app/modules/config/helpers/audit.ts +1 -1
- package/templates/app/modules/config/helpers/describe.ts +35 -26
- package/templates/app/modules/config/module.acl.ts +1 -1
- package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
- package/templates/app/modules/config/tests/config.test.ts +14 -6
- package/templates/app/modules/core/helpers/cache/index.ts +8 -7
- package/templates/app/modules/core/helpers/lazy.ts +3 -3
- package/templates/app/modules/core/helpers/logger/index.ts +24 -16
- package/templates/app/modules/core/module.config.ts +5 -3
- package/templates/app/modules/core/package.json +46 -19
- package/templates/app/modules/core/routes/core.routes.ts +4 -4
- package/templates/app/modules/hello/agent/hello.agent.ts +2 -2
- package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
- package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +2 -2
- package/templates/app/modules/hello/module.acl.ts +12 -7
- package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
- package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
- package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
- package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
- package/templates/app/modules/security/routes/security.routes.ts +4 -4
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
- package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
- package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
- package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -3
- package/templates/app/modules/settings/tests/settings.test.ts +21 -25
- package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
- package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
- package/templates/app/modules/users/module.acl.ts +4 -4
- package/templates/app/modules/users/queues/users.queues.ts +1 -1
- package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
- package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
- package/templates/app/nginx/production.conf +1 -1
- package/templates/app/scripts/deploy-nginx.ts +3 -2
- package/templates/app/tests/support.ts +4 -3
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +6 -4
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
- package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +34 -15
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
- package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
- package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
- package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
- package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
- package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
- package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
- package/templates/minimal/config/app.config.ts +13 -3
- package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
- package/templates/minimal/modules/core/module.acl.ts +7 -1
- package/templates/minimal/modules/core/package.json +4 -4
- package/templates/minimal/modules/hello/module.acl.ts +1 -1
- package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
- package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
- package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
- package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
- package/templates/versions.json +8 -8
- 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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
30
|
+
environment value.
|
|
32
31
|
|
|
33
|
-
**
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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: `
|
|
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
|
-
|
|
|
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
|
|
85
|
-
|
|
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,
|
|
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
|
-
- `
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
9
|
-
module **grants** permissions on its own resources to those roles.
|
|
8
|
+
## The RAI format
|
|
10
9
|
|
|
11
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
`
|
|
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:
|
|
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
|
|
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
|
|
43
|
-
caller
|
|
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
|
|
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
|
-
##
|
|
122
|
+
## Files follow the scope (a convention)
|
|
59
123
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
101
|
-
|
|
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
|
-
-
|
|
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
|
|
82
|
-
`
|
|
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
|
|
9
|
-
before the settings store can answer — the
|
|
10
|
-
the log destinations — plus secrets:
|
|
11
|
-
module's `package.json`,
|
|
12
|
-
at
|
|
13
|
-
`module.config.ts`, stored per module,
|
|
14
|
-
hot-applied, read live at
|
|
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;
|
|
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"
|
|
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
|
-
"
|
|
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"); }
|
package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts}
RENAMED
|
@@ -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: "
|
|
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
|
|
5
|
+
export class HelloGreetingsController {
|
|
6
6
|
readonly #ctx: Ctx;
|
|
7
7
|
|
|
8
8
|
constructor(ctx: Ctx) {
|