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