@tulipes/cli 0.1.1 → 0.2.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/README.md +10 -7
- package/dist/agent.d.ts +12 -0
- package/dist/agent.js +29 -0
- package/dist/agent.js.map +1 -0
- package/dist/app-core.d.ts +8 -6
- package/dist/app-core.js +23 -14
- package/dist/app-core.js.map +1 -1
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/config-check.d.ts +8 -0
- package/dist/config-check.js +43 -0
- package/dist/config-check.js.map +1 -0
- package/dist/config-import.d.ts +20 -0
- package/dist/config-import.js +133 -0
- package/dist/config-import.js.map +1 -0
- package/dist/dev.d.ts +4 -4
- package/dist/dev.js +7 -10
- package/dist/dev.js.map +1 -1
- package/dist/init.d.ts +6 -11
- package/dist/init.js +11 -6422
- package/dist/init.js.map +1 -1
- package/dist/inspection.d.ts +9 -8
- package/dist/inspection.js +10 -11
- package/dist/inspection.js.map +1 -1
- package/dist/main.js +82 -11
- package/dist/main.js.map +1 -1
- package/dist/migrate.d.ts +30 -0
- package/dist/migrate.js +772 -0
- package/dist/migrate.js.map +1 -0
- package/dist/new-module.js +84 -43
- package/dist/new-module.js.map +1 -1
- package/dist/routes.d.ts +3 -5
- package/dist/routes.js +4 -6
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -5
- package/dist/spec.js +13 -6
- package/dist/spec.js.map +1 -1
- package/dist/sync.d.ts +3 -11
- package/dist/sync.js +81 -20
- package/dist/sync.js.map +1 -1
- package/dist/template.d.ts +58 -0
- package/dist/template.js +244 -0
- package/dist/template.js.map +1 -0
- package/dist/update.d.ts +8 -3
- package/dist/update.js +111 -38
- package/dist/update.js.map +1 -1
- package/package.json +17 -16
- package/templates/AGENTS.md +68 -31
- package/templates/CLAUDE.md +5 -4
- package/templates/app/.envs/.env.development +18 -0
- package/templates/app/.envs/.env.production +19 -0
- package/templates/app/.envs/.env.staging +19 -0
- package/templates/app/README.md +327 -0
- package/templates/app/app.ts +3 -0
- package/templates/app/config/app.config.ts +73 -0
- package/templates/app/docs/agent-manifest.json +724 -0
- package/templates/app/ecosystem.config.cjs +60 -0
- package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
- package/templates/app/modules/agent/i18n/en.json +5 -0
- package/templates/app/modules/agent/i18n/fr.json +5 -0
- package/templates/app/modules/agent/module.acl.ts +14 -0
- package/templates/app/modules/agent/package.json +21 -0
- package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
- package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
- package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
- package/templates/app/modules/auth/helpers/access-check.ts +42 -0
- package/templates/app/modules/auth/helpers/auth.ts +48 -0
- package/templates/app/modules/auth/helpers/documents.ts +57 -0
- package/templates/app/modules/auth/helpers/passport.ts +180 -0
- package/templates/app/modules/auth/helpers/passwords.ts +58 -0
- package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
- package/templates/app/modules/auth/helpers/tokens.ts +329 -0
- package/templates/app/modules/auth/i18n/en.json +19 -0
- package/templates/app/modules/auth/i18n/fr.json +19 -0
- package/templates/app/modules/auth/lib/index.ts +12 -0
- package/templates/app/modules/auth/models/credential.model.ts +31 -0
- package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
- package/templates/app/modules/auth/models/session.model.ts +42 -0
- package/templates/app/modules/auth/module.acl.ts +41 -0
- package/templates/app/modules/auth/module.config.ts +30 -0
- package/templates/app/modules/auth/package.json +61 -0
- package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
- package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
- package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
- package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
- package/templates/app/modules/config/helpers/audit.ts +41 -0
- package/templates/app/modules/config/helpers/describe.ts +72 -0
- package/templates/app/modules/config/i18n/en.json +6 -0
- package/templates/app/modules/config/i18n/fr.json +6 -0
- package/templates/app/modules/config/models/config-audit.model.ts +26 -0
- package/templates/app/modules/config/module.acl.ts +12 -0
- package/templates/app/modules/config/package.json +23 -0
- package/templates/app/modules/config/routes/config.routes.ts +37 -0
- package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
- package/templates/app/modules/config/tests/config.test.ts +126 -0
- package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
- package/templates/app/modules/core/helpers/cache/index.ts +60 -0
- package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
- package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
- package/templates/app/modules/core/helpers/cache/types.ts +37 -0
- package/templates/app/modules/core/helpers/lazy.ts +13 -0
- package/templates/app/modules/core/helpers/logger/index.ts +115 -0
- package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
- package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
- package/templates/app/modules/core/lib/index.ts +34 -0
- package/templates/app/modules/core/module.config.ts +24 -0
- package/templates/app/modules/core/package.json +80 -0
- package/templates/app/modules/core/routes/core.routes.ts +24 -0
- package/templates/app/modules/core/tests/core.test.ts +19 -0
- package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
- package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
- package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
- package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
- package/templates/app/modules/hello/helpers/greeting.ts +8 -0
- package/templates/app/modules/hello/i18n/en.json +8 -0
- package/templates/app/modules/hello/i18n/fr.json +8 -0
- package/templates/app/modules/hello/models/greeting.model.ts +24 -0
- package/templates/app/modules/hello/module.acl.ts +16 -0
- package/templates/app/modules/hello/module.config.ts +14 -0
- package/templates/app/modules/hello/package.json +24 -0
- package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
- package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
- package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
- package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
- package/templates/app/modules/hello/tests/hello.test.ts +19 -0
- package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
- package/templates/app/modules/security/helpers/hardening.ts +65 -0
- package/templates/app/modules/security/helpers/proxy.ts +16 -0
- package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
- package/templates/app/modules/security/module.config.ts +59 -0
- package/templates/app/modules/security/package.json +26 -0
- package/templates/app/modules/security/routes/security.routes.ts +42 -0
- package/templates/app/modules/security/tests/security.test.ts +174 -0
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
- package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
- package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
- package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
- package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
- package/templates/app/modules/settings/i18n/en.json +3 -0
- package/templates/app/modules/settings/i18n/fr.json +3 -0
- package/templates/app/modules/settings/module.acl.ts +12 -0
- package/templates/app/modules/settings/package.json +21 -0
- package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
- package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
- package/templates/app/modules/settings/tests/settings.test.ts +409 -0
- package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
- package/templates/app/modules/users/i18n/en.json +14 -0
- package/templates/app/modules/users/i18n/fr.json +14 -0
- package/templates/app/modules/users/models/user.model.ts +18 -0
- package/templates/app/modules/users/module.acl.ts +10 -0
- package/templates/app/modules/users/module.config.ts +11 -0
- package/templates/app/modules/users/package.json +24 -0
- package/templates/app/modules/users/queues/users.queues.ts +31 -0
- package/templates/app/modules/users/routes/users.routes.ts +60 -0
- package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
- package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
- package/templates/app/nginx/development.conf +44 -0
- package/templates/app/nginx/production.conf +75 -0
- package/templates/app/nginx/staging.conf +51 -0
- package/templates/app/package.json +58 -0
- package/templates/app/scripts/count-greetings.ts +29 -0
- package/templates/app/scripts/deploy-nginx.ts +143 -0
- package/templates/app/scripts/list-users.ts +34 -0
- package/templates/app/tests/app.test.ts +28 -0
- package/templates/app/tests/support.ts +36 -0
- package/templates/app/tsconfig.json +28 -0
- package/templates/app/vitest.config.ts +6 -0
- package/templates/app/worker.ts +6 -0
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +87 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
- package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
- package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
- package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
- package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
- package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
- package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
- package/templates/minimal/README.md +21 -0
- package/templates/minimal/app.ts +3 -0
- package/templates/minimal/config/app.config.ts +16 -0
- package/templates/minimal/modules/agent/module.acl.ts +3 -0
- package/templates/minimal/modules/agent/package.json +14 -0
- package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
- package/templates/minimal/modules/core/module.acl.ts +4 -0
- package/templates/minimal/modules/core/package.json +21 -0
- package/templates/minimal/modules/hello/i18n/en.json +3 -0
- package/templates/minimal/modules/hello/module.acl.ts +2 -0
- package/templates/minimal/modules/hello/package.json +11 -0
- package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
- package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
- package/templates/minimal/modules/security/package.json +15 -0
- package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
- package/templates/minimal/package.json +38 -0
- package/templates/minimal/tests/app.test.ts +52 -0
- package/templates/minimal/tsconfig.json +22 -0
- package/templates/minimal/vitest.config.ts +5 -0
- package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
- package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
- package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
- package/templates/versions.json +11 -0
- package/dist/minimal.d.ts +0 -2
- package/dist/minimal.js +0 -2200
- package/dist/minimal.js.map +0 -1
- /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-agent-tool
|
|
3
|
+
description: Use when exposing a Tulipes module to AI agents — describing it, choosing which endpoints an agent sees, adding an action that is not an endpoint, or wiring the app to Claude Code through MCP. Covers agent/*.agent.ts, the manifest, the agent door and tulipes-mcp.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Exposing a module to agents
|
|
7
|
+
|
|
8
|
+
Every endpoint is already a tool: its `rai()` declares the name, the
|
|
9
|
+
description, the input schema (`params`, `query`, `body`) and the
|
|
10
|
+
permission, and the boot derives a tool from it — `hello:list-greetings`
|
|
11
|
+
becomes `hello_list-greetings`, a `GET` is marked read-only. A module with
|
|
12
|
+
no `agent/` folder exposes every route it declares. The folder adds what
|
|
13
|
+
cannot be derived.
|
|
14
|
+
|
|
15
|
+
The agent surface and `@tulipes/mcp` are **experimental** in 0.11: they may
|
|
16
|
+
change in any release without a deprecation period. Check `MIGRATING.md`
|
|
17
|
+
after each `@tulipes/core` upgrade before relying on a manifest shape.
|
|
18
|
+
|
|
19
|
+
`modules/hello/agent/hello.agent.ts`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { action, defineAgent } from "@tulipes/core/agent";
|
|
23
|
+
import { requireModels } from "@tulipes/core/boot";
|
|
24
|
+
import { z } from "zod/v4";
|
|
25
|
+
|
|
26
|
+
export default defineAgent({
|
|
27
|
+
summary: "Greets callers and counts how often each greeting is used.",
|
|
28
|
+
tools: [
|
|
29
|
+
// Derived from the route; the description overrides the RAI's for agents only.
|
|
30
|
+
{ route: "hello:list-greetings", description: "The stored greetings, most used first" },
|
|
31
|
+
{ route: "hello:use-greeting" },
|
|
32
|
+
|
|
33
|
+
// Declared: not an endpoint. The id is a permission, granted in module.acl.ts.
|
|
34
|
+
action({
|
|
35
|
+
id: "hello:reset-counts",
|
|
36
|
+
description: "Reset the usage counter of every greeting to zero",
|
|
37
|
+
input: z.object({ confirm: z.literal(true) }),
|
|
38
|
+
handler: async (ctx, input, actor) => {
|
|
39
|
+
const { modifiedCount } = await requireModels(ctx).get("Greeting").updateMany({}, { times_used: 0 });
|
|
40
|
+
return { reset: modifiedCount };
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`pnpm tulipes agent manifest` prints the result from an inspection boot —
|
|
48
|
+
no service or `.env` needed; `--role admin` shows what one role gets.
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
- **`tools` omitted exposes every route; given, it is the whole list.**
|
|
53
|
+
`{ route: "*" }` is every route of the module, `{ route: "hello:*" }` a
|
|
54
|
+
namespace, an exact id one route. A route id the module never declared is
|
|
55
|
+
a boot error. One `agent/*.agent.ts` per module.
|
|
56
|
+
- **An endpoint that must stay out of the manifest says `agent: false`** on
|
|
57
|
+
its `rai()` — the agent door's own routes do.
|
|
58
|
+
- **An action's id is a permission like any route's.** The ACL decides on
|
|
59
|
+
it first (401 for a guest, 403 for a role without it), then the `input`
|
|
60
|
+
schema validates (422 with field issues), then the handler runs with the
|
|
61
|
+
runtime context, the parsed input and the actor (`req.auth` plus `role`).
|
|
62
|
+
Grant it in `module.acl.ts`; a wildcard such as `hello:*` already covers it.
|
|
63
|
+
- **Write the description for the agent, not the operator.** It is the only
|
|
64
|
+
text the model sees before choosing the tool. Say what the tool does, what
|
|
65
|
+
it changes and what it returns; keep `readOnly: true` honest.
|
|
66
|
+
- **Route tools are called at their own path**, through the app's middleware
|
|
67
|
+
and auth, never around it. The manifest gives the method and path; the
|
|
68
|
+
input is `{ params, query, body }` as the RAI declares them. A path
|
|
69
|
+
parameter without a `params` schema is still listed as a required string.
|
|
70
|
+
- **Keep handlers small.** An action that grows a second step is a
|
|
71
|
+
controller in disguise: move the logic to `helpers/` and call it from both.
|
|
72
|
+
|
|
73
|
+
## The door and MCP
|
|
74
|
+
|
|
75
|
+
The app's `agent` module serves the manifest at `GET <prefix>/agent/tools`,
|
|
76
|
+
filtered to the caller's role, and runs actions at
|
|
77
|
+
`POST <prefix>/agent/tools/<name>`. Any client with a bearer token the auth
|
|
78
|
+
module accepts can use it. For Claude Code or Claude Desktop:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pnpm add -D @tulipes/mcp
|
|
82
|
+
claude mcp add my-app -- pnpm tulipes-mcp --url http://localhost:3000/api/v1/agent --token "$TOKEN"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`tulipes-mcp` lists the manifest as MCP tools and calls each through the
|
|
86
|
+
door with that token, so the app's own auth and ACL decide — it adds no
|
|
87
|
+
identity of its own and never touches the database.
|
|
@@ -30,10 +30,16 @@ problems the report has not reached yet.
|
|
|
30
30
|
| `module graph` | `dependsOn "x" — no such module exists` | Typo, or you used the package name. `dependsOn` takes short names (`"users"`). |
|
|
31
31
|
| `module graph` | `dependency cycle: a → b → a` | Mutual dependency. Move the shared part into the core module or a third module. |
|
|
32
32
|
| `module graph` | `sys module cannot depend on app module` | A sys module needs something app-tier. Either it is not really sys, or the dependency belongs in `lib/`. |
|
|
33
|
-
| `
|
|
33
|
+
| `modules` | `x does not belong at the module root` | Only `package.json`, `module.config.ts` and `module.acl.ts` live there; the message names the folder the file belongs in. |
|
|
34
|
+
| `modules` | `tulipes.offline is gone` / `meta.variables.json moved to package.json` | The 0.10 forms. Delete the flag; move the variables under `tulipes.env`. |
|
|
35
|
+
| `routes` | `routes function threw: ctx.models is not available during inspection` | A routes file or a controller constructor touched infrastructure while building. Move the read into the handler, or build the middleware with `lazy()`. |
|
|
36
|
+
| `config` | `module.config must default-export defineConfig({ schema })` | Missing/misspelled `export default`, or the 0.10 factory form: values now live in the settings store. |
|
|
37
|
+
| `config` | `module.config exports "onReady"` | The 0.10 lifecycle exports. Initialization is a `bootstrap/*.bootstrap.ts` file, which may return its cleanup; long-lived work ends on `ctx.signal`. |
|
|
38
|
+
| `config` | `field "x" has no default — every setting needs one` | Give the field a `.default()`; an operator is never asked for a value the process needs to boot. |
|
|
39
|
+
| `settings` | `stored setting "users.maxSessions": …` | The store holds a value the schema no longer accepts. Fix it in the store (or the file the message names). |
|
|
34
40
|
| `acl` | `allow() on unknown role "x"` | Role never defined. Roles live in the sys `auth` module; define it there and `dependsOn: ["auth"]`. |
|
|
35
41
|
| `acl` | `already granted … duplicate grant` | Two modules grant the same role+resource. Namespace the resource by module. |
|
|
36
|
-
| `providers` | `[x] requires capability "models" … declares no provider for it` | A module has `models/`
|
|
42
|
+
| `providers` | `[x] requires capability "models" … declares no provider for it` | A module has `models/` files (or `requires: ["models"]`) but the app's `package.json` has no `"tulipes": { "providers": { "models": "@tulipes/mongoose" } }`. Add that line; an installed package or a `MONGO_URI` alone selects nothing. |
|
|
37
43
|
| `providers` | `provider "…" cannot be resolved from …` | The declared specifier is not installed in the app. Install it (or fix the path). |
|
|
38
44
|
| `providers` | `"@tulipes/core/db" no longer contains the Mongoose provider` | The app still declares the pre-extraction specifier. Install `@tulipes/mongoose`, declare it, and import `ModelDef`/`BootstrapFn` from it. |
|
|
39
45
|
| `providers` | `[x] requires capability "queues" … declares no provider for it` | A module has a `queues/` file (or `requires: ["queues"]`) but the app's `package.json` has no `"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. Add that line. |
|
|
@@ -59,16 +65,16 @@ runs, check in this order:
|
|
|
59
65
|
2. **Directory** — routes in `routes/`, models in `models/`, and so on.
|
|
60
66
|
Nested subfolders are not scanned.
|
|
61
67
|
3. **The module is registered** — it needs a `package.json` and a
|
|
62
|
-
`
|
|
68
|
+
`pnpm install` after creation. The startup banner lists every module it
|
|
63
69
|
found; if yours is missing, the problem is there.
|
|
64
70
|
4. **`export default`** — every contract file except models exports a
|
|
65
71
|
function as its default.
|
|
66
72
|
|
|
67
73
|
## Other symptoms
|
|
68
74
|
|
|
69
|
-
- **Types are `unknown` or a new variable is not typed** → run `
|
|
75
|
+
- **Types are `unknown` or a new variable is not typed** → run `pnpm sync`.
|
|
70
76
|
- **Worker exits immediately** → no processors registered; it refuses to
|
|
71
77
|
idle. Add a `*.queues.ts` with `process()`.
|
|
72
78
|
- **Env change has no effect** → `process.env` beats the file. Check for a
|
|
73
79
|
shell export or container variable of the same name.
|
|
74
|
-
- To validate configuration without booting the whole app: `
|
|
80
|
+
- To validate configuration without booting the whole app: `pnpm check`.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-config
|
|
3
|
+
description: Use when adding or changing a setting — a value an operator should be able to edit at runtime — or when working on the config module, the console that describes every module and edits settings. Covers defineConfig, onChange, restart-tagged fields, the console endpoints, versions and the audit.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Settings and the config module
|
|
7
|
+
|
|
8
|
+
A setting is declared once, in the module's `module.config.ts`, the file's
|
|
9
|
+
only export:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { defineConfig } from "@tulipes/core/config";
|
|
13
|
+
import { z } from "zod/v4";
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
schema: z.object({
|
|
17
|
+
maxSessions: z.number().int().positive().default(5)
|
|
18
|
+
.describe("Max concurrent sessions per user; the oldest is evicted past it"),
|
|
19
|
+
signupMode: z.enum(["open", "invite", "closed"]).default("open")
|
|
20
|
+
.describe("Who can register"),
|
|
21
|
+
listenPort: z.number().int().default(8080).meta({ restart: true })
|
|
22
|
+
.describe("Applied on the next boot"),
|
|
23
|
+
}),
|
|
24
|
+
/** Optional: rebuild what was built from a value. Runs in every replica after a write lands. */
|
|
25
|
+
onChange(next, ctx) {
|
|
26
|
+
rebuildRateLimits(ctx, next.rateLimits);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`pnpm sync` types it: `ctx.config.users.maxSessions` is a `number` at
|
|
32
|
+
every call site. Read it where it is used, never capture it at boot — the
|
|
33
|
+
object is replaced in place when a write lands.
|
|
34
|
+
|
|
35
|
+
## Rules
|
|
36
|
+
|
|
37
|
+
- **Every field has a default.** A namespace never written is its
|
|
38
|
+
defaults; the first boot writes them. There is no "required setting",
|
|
39
|
+
because an operator cannot be asked for a value the process needs to boot.
|
|
40
|
+
- **No secrets.** `defineConfig` refuses a field whose metadata says
|
|
41
|
+
`secret`; a secret is an environment variable. → skill `tulipes-env-variable`
|
|
42
|
+
- **Describe every field.** `.describe()` is what the console form, the
|
|
43
|
+
generated types and an agent reading the manifest see.
|
|
44
|
+
- **Validate in the schema, not in `onChange`.** The store persists before
|
|
45
|
+
`onChange` runs, so a refinement that throws there would leave a value
|
|
46
|
+
stored that the module cannot use. `.refine()` keeps it out of the store.
|
|
47
|
+
- **Rebuild in `onChange` what you built from a value.** A middleware
|
|
48
|
+
constructed from a setting at boot does not see the write; either build
|
|
49
|
+
it per request from the live value (helmet, CORS, the body parser do) or
|
|
50
|
+
rebuild it in `onChange` (the rate limiter does, because it holds a Redis
|
|
51
|
+
client).
|
|
52
|
+
- **Tag what cannot be hot-applied** with `.meta({ restart: true })`. The
|
|
53
|
+
write persists, applies on the next boot, and the response carries
|
|
54
|
+
`requires_restart` and `meta.action: "restart_required"`.
|
|
55
|
+
|
|
56
|
+
## The console: the `config` module
|
|
57
|
+
|
|
58
|
+
| | |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `GET /config` | every module in load order: manifest, declared variables (names, types, sources — a secret's value never), settings with schema, live values and stored version, endpoints with the roles that reach them |
|
|
61
|
+
| `GET /config/:module` | the same for one module |
|
|
62
|
+
| `PATCH /config/:module` | `{ values: { … }, version? }` — a patch over the stored values, validated by the module's schema (422 with the field), refused when `version` is not the stored one (409 `STALE_VERSION`), audited before it lands, applied at once in every replica |
|
|
63
|
+
|
|
64
|
+
`config:read` and `config:describe` are granted to `admin` and `sysadmin`;
|
|
65
|
+
`config:write` to `sysadmin` only. The audit (`config_audit_events`) holds
|
|
66
|
+
the namespace, the field names, the version the caller read, the actor and
|
|
67
|
+
the client IP — never a value — and is acknowledged by a majority before
|
|
68
|
+
the write happens: a change nobody can account for is worse than one that
|
|
69
|
+
did not happen. Variables are read-only here: what boot needs before the
|
|
70
|
+
store answers, and secrets, are not edited at runtime.
|
|
71
|
+
|
|
72
|
+
The same three endpoints are tools in the agent manifest, so an agent with
|
|
73
|
+
`sysadmin` reconfigures the app through the door an operator uses, with
|
|
74
|
+
the same audit. → skill `tulipes-agent-tool`
|
|
@@ -37,35 +37,42 @@ the handlers that use them without threading ctx through every signature.
|
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
39
|
import { Router } from "express";
|
|
40
|
-
import {
|
|
40
|
+
import type { BackendCtx } from "@tulipes/core/boot";
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
import { BillingController } from "../controllers/billing.controllers.js";
|
|
43
|
+
|
|
44
|
+
export default function billingRoutes(ctx: BackendCtx): Router {
|
|
43
45
|
const router = Router();
|
|
44
|
-
const billing =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
router.get("/:id", rai({ id: "billing:read", name: "Read an invoice" }),
|
|
49
|
-
billing(value => value.show()));
|
|
50
|
-
routes.mount(app, "/invoices", router);
|
|
51
|
-
});
|
|
46
|
+
const billing = new BillingController(ctx);
|
|
47
|
+
router.get("/:id", ctx.rai({ id: "billing:read", name: "Read an invoice" }), billing.show());
|
|
48
|
+
return router; // mounted under config.api.prefix
|
|
49
|
+
}
|
|
52
50
|
```
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
A routes file is a plain function of the context. A returned router is
|
|
53
|
+
mounted under `config.api.prefix`; `ctx.routes.mount(router, "/invoices")`
|
|
54
|
+
chooses another path, and `ctx.routes.mount(parent, "/tenants/:tenant",
|
|
55
|
+
child)` nests one router in another (`Router({ mergeParams: true })` on the
|
|
56
|
+
child for inherited parameters). A file that only mounts global middleware
|
|
57
|
+
on `ctx.app` returns nothing.
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
**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:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { lazy } from "@app/core";
|
|
68
|
+
app.use(lazy(() => express.json({ limit: String(Environment.get("BODY_LIMIT")) })));
|
|
69
|
+
```
|
|
64
70
|
|
|
65
|
-
Use
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
Use router.use for shared middleware, router.param for parameter callbacks, and
|
|
72
|
+
router.route for chained method registrations. Preserve native registration
|
|
73
|
+
order. Parameter callbacks run before the endpoint's RAI; authorization-sensitive
|
|
74
|
+
loading belongs after rai() or behind an earlier authorization check. After
|
|
75
|
+
changes run the app tests, `tulipes routes` and `pnpm spec`; inspect generated diffs.
|
|
69
76
|
|
|
70
77
|
## The RAI is not optional
|
|
71
78
|
|
|
@@ -86,8 +93,14 @@ This exists because the pipeline authenticates rather than rejects: an
|
|
|
86
93
|
undeclared route is not merely undocumented, it is reachable by `guest`.
|
|
87
94
|
Grant the id in `module.acl.ts` — to `guest` if the endpoint is public.
|
|
88
95
|
|
|
89
|
-
`tulipes routes
|
|
90
|
-
|
|
96
|
+
`tulipes routes` lists every declared endpoint with the roles that reach it,
|
|
97
|
+
without a service, a secret or a `.env`.
|
|
98
|
+
|
|
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.
|
|
103
|
+
→ skill `tulipes-agent-tool`
|
|
91
104
|
|
|
92
105
|
## Responses and errors
|
|
93
106
|
|
|
@@ -1,44 +1,72 @@
|
|
|
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
|
|
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.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Adding a configuration value
|
|
7
7
|
|
|
8
|
-
**
|
|
9
|
-
|
|
10
|
-
a
|
|
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>`:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// modules/billing/module.config.ts — the file's only export
|
|
17
|
+
import { defineConfig } from "@tulipes/core/config";
|
|
18
|
+
import { z } from "zod/v4";
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
schema: z.object({
|
|
22
|
+
trialDays: z.number().int().positive().default(14).describe("Free trial length"),
|
|
23
|
+
// a field the process cannot apply without a restart:
|
|
24
|
+
workers: z.number().int().positive().default(2).meta({ restart: true }),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every field needs a default, secrets are refused, and `pnpm sync` types
|
|
30
|
+
`ctx.config.billing.trialDays` as `number`. The rest of this guide is the
|
|
31
|
+
environment variable.
|
|
32
|
+
|
|
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`.
|
|
11
36
|
|
|
12
37
|
## Steps
|
|
13
38
|
|
|
14
39
|
1. Choose the owning module. Infra shared by everything (`PORT`,
|
|
15
40
|
`MONGO_URI`, `REDIS_URL`) belongs to the sys `core` module; anything
|
|
16
41
|
feature-specific belongs to that feature's module.
|
|
17
|
-
2. Declare it in that module's `
|
|
42
|
+
2. Declare it under `tulipes.env` in that module's `package.json`:
|
|
18
43
|
|
|
19
44
|
```json
|
|
20
45
|
{
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
"name": "@app/billing",
|
|
47
|
+
"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
|
+
]
|
|
64
|
+
}
|
|
37
65
|
}
|
|
38
66
|
```
|
|
39
67
|
|
|
40
68
|
3. Add a value to `.envs/.env.development` for anything `required`.
|
|
41
|
-
4. `
|
|
69
|
+
4. `pnpm sync` — regenerates `types/tulipes.d.ts` (so the read is typed)
|
|
42
70
|
and `.env.example` (so deployments know what to set).
|
|
43
71
|
5. Read it: `Environment.get("BILLING_TRIAL_DAYS")` → typed `number`.
|
|
44
72
|
|
|
@@ -65,14 +93,14 @@ values always win, which is why deployments need no file.
|
|
|
65
93
|
|
|
66
94
|
- Value shared by several modules but the same everywhere (pagination
|
|
67
95
|
limits, body size) → `config/app.config.ts`, not a variable.
|
|
68
|
-
-
|
|
69
|
-
module's `module.config.ts
|
|
96
|
+
- Any value an operator may want to change at runtime → a setting in that
|
|
97
|
+
module's `module.config.ts` schema (above), not a variable.
|
|
70
98
|
|
|
71
99
|
## Gotchas
|
|
72
100
|
|
|
73
101
|
- `Environment.get()` on an undeclared name **throws**. If you added the
|
|
74
|
-
entry and still get the error, you did not run `
|
|
75
|
-
edited a different module's
|
|
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.
|
|
76
104
|
- `APP_ENV` (not `NODE_ENV`) selects the env file.
|
|
77
105
|
- A missing `required` variable is not a runtime surprise — the boot
|
|
78
106
|
aborts and lists every missing variable at once.
|
|
@@ -49,8 +49,15 @@ Read it anywhere the context reaches:
|
|
|
49
49
|
const Invoice = models!.get("Invoice");
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
`
|
|
53
|
-
so
|
|
52
|
+
Run `pnpm sync` after adding a model: it records the model's name and file
|
|
53
|
+
in `types/tulipes.d.ts`, so `models.get("Invoice")` is typed from the
|
|
54
|
+
schema above (its fields, methods and statics) and a name that no model
|
|
55
|
+
file declares does not compile. The name must be a string literal in the
|
|
56
|
+
file for that; a computed name stays reachable only through
|
|
57
|
+
`models.get<T>(name)`, the caller-side generic, which also serves code that
|
|
58
|
+
builds names at runtime. `models.get()` throws on an unregistered name
|
|
59
|
+
(listing what does exist), so a typo in dynamic code still fails at the
|
|
60
|
+
call site instead of returning `undefined`.
|
|
54
61
|
|
|
55
62
|
## Requirements
|
|
56
63
|
|
|
@@ -97,8 +104,8 @@ is all the task wanted.
|
|
|
97
104
|
|
|
98
105
|
- Global plugins (timestamps, soft delete) only apply to schemas compiled
|
|
99
106
|
*after* registration, which is why `registerBasePlugins()` is called from
|
|
100
|
-
the sys core module's `module.config.ts` — phase
|
|
101
|
-
in phase
|
|
107
|
+
the sys core module's `module.config.ts` — declaration time (phase 4),
|
|
108
|
+
before models compile in phase 5. That covers every mode, scripts included; calling it from
|
|
102
109
|
`app.ts` instead would leave scripts compiling models without the
|
|
103
110
|
soft-delete guard.
|
|
104
111
|
- Mongoose queries are lazy: `await` them, and prefer `.lean()` for reads
|
|
@@ -15,18 +15,19 @@ touching anything else.
|
|
|
15
15
|
(another users endpoint → `modules/users`).
|
|
16
16
|
- **New module** when it is a separate feature with its own vocabulary.
|
|
17
17
|
- **The sys core module** when it is a shared helper with no routes or
|
|
18
|
-
models of its own — export it from `modules/core/index.ts`
|
|
19
|
-
as `@app/core`. There is no top-level `lib/`: a helper two modules need
|
|
18
|
+
models of its own — export it from `modules/core/lib/index.ts` (what its
|
|
19
|
+
`package.json` "exports" points at) and import it as `@app/core`. There is no top-level `lib/`: a helper two modules need
|
|
20
20
|
is owned by a module like everything else.
|
|
21
21
|
|
|
22
22
|
## Steps
|
|
23
23
|
|
|
24
|
-
1. Scaffold: `
|
|
25
|
-
contract — package.json (scope detected from siblings),
|
|
26
|
-
|
|
27
|
-
declaring a RAI, model, bootstrap
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
1. Scaffold: `pnpm tulipes new module billing`. It creates every
|
|
25
|
+
contract — package.json (scope detected from siblings), a settings
|
|
26
|
+
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
|
|
29
|
+
only `package.json`, `module.config.ts` and `module.acl.ts`; everything
|
|
30
|
+
else has a folder, and the boot refuses a stray root file.
|
|
30
31
|
2. Set ordering in `modules/billing/package.json`:
|
|
31
32
|
|
|
32
33
|
```json
|
|
@@ -37,13 +38,13 @@ touching anything else.
|
|
|
37
38
|
}
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
3. `
|
|
41
|
+
3. `pnpm install` — the module is a workspace, so the package manager must
|
|
41
42
|
link it before imports resolve.
|
|
42
43
|
4. Put endpoints and shared middleware in native `routes/*.routes.ts` factories.
|
|
43
44
|
Add optional files as needed:
|
|
44
45
|
`models/`, `queues/`, `sockets/`, `bootstrap/`, `module.acl.ts`,
|
|
45
46
|
`i18n/` — the module is its own i18n namespace. → skill `tulipes-i18n`
|
|
46
|
-
5. `
|
|
47
|
+
5. `pnpm sync && pnpm dev` — the banner must list the module.
|
|
47
48
|
|
|
48
49
|
## The manifest key
|
|
49
50
|
|
|
@@ -14,11 +14,15 @@ consumer. Never duplicate the definition.
|
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
16
|
import type { Ctx } from "@tulipes/core/boot";
|
|
17
|
-
import type { QueueRegistry } from "@tulipes/bullmq";
|
|
17
|
+
import type { JobSchemas, QueueRegistry } from "@tulipes/bullmq";
|
|
18
|
+
import { z } from "zod/v4";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// The declaration: defines the queue, validates every add(), types both sides.
|
|
21
|
+
export const jobs = {
|
|
22
|
+
"billing.send-invoice": z.object({ invoiceId: z.string() }),
|
|
23
|
+
} satisfies JobSchemas;
|
|
21
24
|
|
|
25
|
+
export default function invoiceQueues({ models }: Ctx, queues: QueueRegistry): void {
|
|
22
26
|
queues.process("billing.send-invoice", async (job) => {
|
|
23
27
|
const invoice = await models!.get("Invoice").findById(job.data.invoiceId);
|
|
24
28
|
// …do the slow thing here…
|
|
@@ -33,7 +37,16 @@ Produce from anywhere with the context:
|
|
|
33
37
|
await queues!.add("billing.send-invoice", "send", { invoiceId: invoice.id });
|
|
34
38
|
```
|
|
35
39
|
|
|
36
|
-
Run
|
|
40
|
+
Run `pnpm sync` after adding a queue: it records the `jobs` export in
|
|
41
|
+
`types/tulipes.d.ts`, so the payload above and `job.data` in the processor
|
|
42
|
+
are both typed from the schema, and a queue name no file declares does not
|
|
43
|
+
compile. At runtime `add()` parses the payload with the schema — defaults
|
|
44
|
+
applied, a bad payload refused at the producer rather than in the worker.
|
|
45
|
+
A queue that needs options (`defaultJobOptions`, a prefix) calls
|
|
46
|
+
`queues.define(name, options)` as well; a queue without a schema is defined
|
|
47
|
+
that way alone and takes any payload.
|
|
48
|
+
|
|
49
|
+
Run a consumer with `pnpm worker`. Without it, jobs pile up in Redis and
|
|
37
50
|
nothing processes them — that is the correct behaviour, not a bug.
|
|
38
51
|
|
|
39
52
|
## Rules
|
|
@@ -42,8 +55,9 @@ nothing processes them — that is the correct behaviour, not a bug.
|
|
|
42
55
|
reserves `:` as its Redis key separator; a `:` name crashes the boot.
|
|
43
56
|
- Names are globally unique, and a queue may have only one processor —
|
|
44
57
|
duplicates crash the boot naming both modules.
|
|
45
|
-
- A processor registered for a queue nobody defined is a boot error;
|
|
46
|
-
`define()` in the same file.
|
|
58
|
+
- A processor registered for a queue nobody defined is a boot error; list
|
|
59
|
+
it in `jobs` or call `define()` in the same file. One schema per queue:
|
|
60
|
+
a second module declaring one is a boot error naming the first.
|
|
47
61
|
- The app selects its queue provider once, in its root `package.json`:
|
|
48
62
|
`"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. A module with a
|
|
49
63
|
`queues/` file in an app without that line is a boot refusal before any
|