@tulipes/cli 0.1.0 → 0.2.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/README.md +11 -8
- 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 +17 -6420
- 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 +87 -46
- 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.js +4 -4
- package/package.json +17 -16
- package/templates/AGENTS.md +162 -0
- package/templates/CLAUDE.md +11 -118
- 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 +83 -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,327 @@
|
|
|
1
|
+
# tulipes-example
|
|
2
|
+
|
|
3
|
+
A production-preset [Tulipes](https://www.npmjs.com/package/@tulipes/core) application. Requires Node 24.x.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g pnpm # once per machine
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev # boot the backend process → http://localhost:3000/api/v1/hello
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Needs mongo and redis running — see `MONGO_URI` and `REDIS_URL` in
|
|
14
|
+
`.envs/.env.development`. Run `pnpm worker` in a second terminal to
|
|
15
|
+
consume queued jobs.
|
|
16
|
+
|
|
17
|
+
`.envs/` is git-ignored: those files hold working credentials. On a
|
|
18
|
+
fresh clone there is nothing in it, so copy `.env.example` — which is
|
|
19
|
+
committed, and which `pnpm sync` regenerates from every module's
|
|
20
|
+
declared variables — and fill it in:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm sync # refresh .env.example and types
|
|
24
|
+
mkdir -p .envs # git does not track empty dirs
|
|
25
|
+
cp .env.example .envs/.env.development
|
|
26
|
+
pnpm check # validate it without booting
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Verify
|
|
30
|
+
|
|
31
|
+
Run `pnpm sync`, `pnpm typecheck` and `pnpm check`. Tests need dedicated
|
|
32
|
+
services: `TEST_MONGO_URI=mongodb://127.0.0.1:27017/tulipes_tests TEST_REDIS_URL=redis://127.0.0.1:6379/3 pnpm test`.
|
|
33
|
+
Use test-only data: the suite creates users and sessions. `pnpm spec` writes
|
|
34
|
+
OpenAPI/Postman documents offline, without services or runtime secrets.
|
|
35
|
+
Change the shared API prefix in `config/routes.ts`. Runtime middleware stays
|
|
36
|
+
with endpoints in `routes/*.routes.ts`, plain functions of the context.
|
|
37
|
+
Staging/production boot rejects development credentials and equal JWT keys.
|
|
38
|
+
Auth, settings and deployment examples are application policy you own.
|
|
39
|
+
|
|
40
|
+
## The hello module
|
|
41
|
+
|
|
42
|
+
`modules/hello` is a worked example of every module contract — read it
|
|
43
|
+
before writing your own:
|
|
44
|
+
|
|
45
|
+
| File | Contract |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `package.json` `tulipes.env` | the module's environment contract |
|
|
48
|
+
| `module.config.ts` | `defineConfig({ schema })`: this module's settings |
|
|
49
|
+
| `module.acl.ts` | permission grants on `hello:*` |
|
|
50
|
+
| `routes/*.routes.ts` | native routers, shared middleware and RAI schemas |
|
|
51
|
+
| `controllers/` | the work; constructed from the context while routes build |
|
|
52
|
+
| `helpers/` | pure logic, no framework |
|
|
53
|
+
| `i18n/<locale>.json` | this module's strings — the module is the namespace |
|
|
54
|
+
| `sockets/*.sockets.ts` | a claimed Socket.IO namespace | <!-- feature:sockets -->
|
|
55
|
+
| `models/*.model.ts` | a schema declaration for the model store |
|
|
56
|
+
| `bootstrap/*.bootstrap.ts` | idempotent seeding, before traffic |
|
|
57
|
+
| `queues/*.queues.ts` | a job definition and its processor | <!-- feature:queues -->
|
|
58
|
+
|
|
59
|
+
## Daily commands
|
|
60
|
+
|
|
61
|
+
| command | does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `pnpm dev` | regenerate types, run the backend process with `node --watch` |
|
|
64
|
+
| `pnpm worker` | run the queue-consumer process (needs a queue first) | <!-- feature:queues -->
|
|
65
|
+
| `pnpm sync` | regenerate `types/tulipes.d.ts` + `.env.example` |
|
|
66
|
+
| `pnpm check` | validate env against every module contract — CI gate |
|
|
67
|
+
| `pnpm tulipes new module <name>` | scaffold a module |
|
|
68
|
+
| `pnpm tulipes update` | upgrade the framework everywhere it is declared |
|
|
69
|
+
|
|
70
|
+
## Authentication
|
|
71
|
+
|
|
72
|
+
`modules/auth` is sys tier, so it resolves the caller before any
|
|
73
|
+
feature route runs. A request with no valid token is not rejected — it
|
|
74
|
+
becomes the **guest** caller, which is what makes a public endpoint
|
|
75
|
+
an ordinary ACL grant instead of a second mechanism:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
acl.allow("guest", "auth:login"); // that is why login is reachable
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Guarding is the RAI itself — its `id` IS the permission, so there is
|
|
82
|
+
no second call to forget. 401 means "sign in"; 403 means "signed in,
|
|
83
|
+
still not allowed":
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
router.get(
|
|
87
|
+
`${base}/invoices`,
|
|
88
|
+
rai({ id: "invoices:read", name: "List invoices" }),
|
|
89
|
+
billing.list(),
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A route that declares no RAI would be reachable by guest, so core
|
|
94
|
+
scans the router stack after mounting and refuses to boot when it
|
|
95
|
+
finds one. Run `pnpm tulipes routes` to see every endpoint with the
|
|
96
|
+
roles that reach it.
|
|
97
|
+
|
|
98
|
+
Try it — the admin comes from AUTH_ADMIN_EMAIL / AUTH_ADMIN_PASSWORD:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
curl -X POST localhost:3000/api/v1/auth/login \
|
|
102
|
+
-H 'content-type: application/json' \
|
|
103
|
+
-d '{"email":"admin@example.com","password":"dev-admin-password"}'
|
|
104
|
+
|
|
105
|
+
curl localhost:3000/api/v1/auth/me -H "authorization: Bearer <accessToken>"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Inside a handler, the caller is `req.auth` — resolved before any
|
|
109
|
+
feature route and never undefined inside a declared one:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const { isAuthenticated, user, session } = req.auth;
|
|
113
|
+
user?.email // the hydrated user document, null for a guest
|
|
114
|
+
session?.sid // this device's session row, null for a guest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Never re-parse the Authorization header: the pipeline already did. It
|
|
118
|
+
resolves the session and the user on every request — from the app cache
|
|
119
|
+
(Redis, shared by every replica) when a request resolved them recently,
|
|
120
|
+
from the database otherwise — and every path that revokes (logout,
|
|
121
|
+
refresh reuse, password change, a session ended, a deleted account)
|
|
122
|
+
forgets the cached entry first, so a demotion, a ban or a logout takes
|
|
123
|
+
effect on the very next request on any replica. Code that writes a
|
|
124
|
+
user's role calls `forgetUser()` from `@app/auth` for the same reason.
|
|
125
|
+
|
|
126
|
+
Sessions are rows, so a signed-in account can see and end its own
|
|
127
|
+
devices — `GET /auth/sessions`, `DELETE /auth/sessions/:sid`. Ending
|
|
128
|
+
one takes effect on the next request that device makes, even though
|
|
129
|
+
its access token is still signed and unexpired.
|
|
130
|
+
|
|
131
|
+
Passwords never touch the User document: the hash lives in the auth
|
|
132
|
+
module's `Credential` model, `select: false`, so a route that
|
|
133
|
+
serialises a user cannot leak one. Refresh tokens rotate on use;
|
|
134
|
+
logout deletes the refresh row and blacklists the access token's jti
|
|
135
|
+
until it expires; a password change moves a per-user epoch that
|
|
136
|
+
invalidates every token issued before it. Both token collections carry
|
|
137
|
+
TTL indexes.
|
|
138
|
+
|
|
139
|
+
## The console
|
|
140
|
+
|
|
141
|
+
`modules/config` is the operator's console: `GET /api/v1/config`
|
|
142
|
+
describes every module — manifest, declared variables (names, types and
|
|
143
|
+
sources; a secret's value never), settings with their schema, live values
|
|
144
|
+
and stored version, endpoints with the roles that reach them — and
|
|
145
|
+
`PATCH /api/v1/config/:module` changes a module's settings: validated by
|
|
146
|
+
its schema, refused on a stale version, audited (names, never values)
|
|
147
|
+
before it lands, applied at once in every replica. Reading needs `admin`;
|
|
148
|
+
writing needs `sysadmin`. Settings live in MongoDB (`settings` and
|
|
149
|
+
`settings_history`) because the root `package.json` declares
|
|
150
|
+
`@tulipes/mongoose/settings`; the request pipeline — proxy trust, CORS,
|
|
151
|
+
HSTS, the body limit, the rate limits — is configured from
|
|
152
|
+
`config.security` this way, so none of it is an environment variable.
|
|
153
|
+
|
|
154
|
+
## Logging
|
|
155
|
+
|
|
156
|
+
The logger lives in the core module and is shared by every other one:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { moduleLogger } from "@app/core";
|
|
160
|
+
|
|
161
|
+
const log = moduleLogger(ctx.Environment, "billing", ctx.mode);
|
|
162
|
+
log.info({ invoiceId }, "invoice sent");
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
It writes to two destinations at once, because they answer different
|
|
166
|
+
questions: a **coloured, human-readable** stream on the console, and
|
|
167
|
+
**JSON lines** in `logs/` — one object per line, which is what makes
|
|
168
|
+
them greppable with jq or ingestible by a collector. Errors are
|
|
169
|
+
additionally copied to their own file, so the first thing to open when
|
|
170
|
+
something breaks is small.
|
|
171
|
+
|
|
172
|
+
Files carry the process mode — `app-backend.log`, `app-worker.log` —
|
|
173
|
+
because backend and worker run at the same time and two processes
|
|
174
|
+
rotating one file will race.
|
|
175
|
+
|
|
176
|
+
The destinations are environment, because the logger exists before the
|
|
177
|
+
settings store answers: `LOG_LEVEL`, `LOG_CONSOLE`, `LOG_FILE` and
|
|
178
|
+
`LOG_DIR`, declared in `modules/core/package.json`. The file names and
|
|
179
|
+
the rotation (daily or 20m, whichever trips first; fourteen rotated
|
|
180
|
+
files kept) never needed to differ per deployment, so they are the
|
|
181
|
+
`LOG_FILES` constants in `modules/core/helpers/logger/`. A `sysadmin`
|
|
182
|
+
can watch the files live: `GET /api/v1/settings/logs/stream` (SSE).
|
|
183
|
+
|
|
184
|
+
## Responses
|
|
185
|
+
|
|
186
|
+
Every endpoint answers with one envelope, so a client unwraps once:
|
|
187
|
+
|
|
188
|
+
```jsonc
|
|
189
|
+
{ "success": true, "data": { }, "errors": [], "meta": {} }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`res.respond` builds it. Express is untouched — `res.json` and
|
|
193
|
+
`res.status` behave exactly as they always did:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
res.respond({ data: user });
|
|
197
|
+
res.respond({ data: user, status: 201 });
|
|
198
|
+
res.respond({ data: items, meta: { total_items } }); // paginated
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Failures are **thrown**, never responded — the framework's terminal
|
|
202
|
+
handler builds the same envelope from them, so a handler only writes
|
|
203
|
+
the happy path. The message is an i18n key: it gets translated in the
|
|
204
|
+
module's namespace and the error code is derived from it.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
throw new HttpError(404, "notFound"); // → NOT_FOUND
|
|
208
|
+
throw new HttpError(422, "emailTaken", { field: "email" });
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`req.page` is already parsed from `?page` / `?per_page` and clamped
|
|
212
|
+
by `config.pagination`; passing `total_items` is what marks a
|
|
213
|
+
response as a page and fills in `total_pages`.
|
|
214
|
+
|
|
215
|
+
In development, a route answering without `res.respond` logs a
|
|
216
|
+
warning naming it.
|
|
217
|
+
|
|
218
|
+
## Browser authentication
|
|
219
|
+
|
|
220
|
+
Read [Browser authentication and cookies](docs/browser-auth.md) before wiring
|
|
221
|
+
up a browser client. The starter uses Bearer headers and JSON refresh tokens;
|
|
222
|
+
CORS settings do not install cookie authentication or CSRF protection.
|
|
223
|
+
|
|
224
|
+
## Translations
|
|
225
|
+
|
|
226
|
+
Each module owns its strings and IS its own i18n namespace:
|
|
227
|
+
`modules/hello/i18n/en.json`, `fr.json`. Inside an endpoint `req.t`
|
|
228
|
+
is already bound to the module that declared the route — `rai()` binds
|
|
229
|
+
it — so there is nothing to import and no namespace to name:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
res.json({ message: req.t("created", { email }) });
|
|
233
|
+
throw new HttpError(404, req.t("notFound"));
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The locale is `?lang=fr`, else Accept-Language, else
|
|
237
|
+
`config.i18n.defaultLocale` — narrowed to `supportedLocales`:
|
|
238
|
+
|
|
239
|
+
```sh
|
|
240
|
+
curl 'localhost:3000/api/v1/hello/secret?lang=fr' -H "authorization: Bearer <token>"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Code with no request names both: `ctx.t("users", recipient.locale)`,
|
|
244
|
+
which is how a queue processor translates against the recipient's
|
|
245
|
+
locale rather than the enqueuer's — so put the locale in the job.
|
|
246
|
+
|
|
247
|
+
Only the default locale must be complete. Anything another locale
|
|
248
|
+
lacks falls back to it and is listed in `<locale>.missing.json` beside
|
|
249
|
+
it on the next boot — git-ignored, and never written in production.
|
|
250
|
+
|
|
251
|
+
## Scripts
|
|
252
|
+
|
|
253
|
+
`scripts/` holds one-off tasks — backfills, exports, admin chores.
|
|
254
|
+
They boot the app in **script mode**, which runs every phase the
|
|
255
|
+
backend runs and then starts no server, so a script gets exactly what
|
|
256
|
+
a route handler gets: config, ACL, models, queue producers.
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
pnpm script scripts/count-greetings.ts
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Copy the try/finally from `scripts/count-greetings.ts`: mongo and
|
|
263
|
+
redis hold the event loop open, so a script that forgets
|
|
264
|
+
`handle.shutdown()` prints its output and then hangs.
|
|
265
|
+
|
|
266
|
+
## Deploying nginx
|
|
267
|
+
|
|
268
|
+
`nginx/{development,staging,production}.conf` are templates with
|
|
269
|
+
`{{DOMAIN}}` and `{{PORT}}` placeholders. The deploy script renders
|
|
270
|
+
the one matching `APP_ENV`, using that same environment's
|
|
271
|
+
the `core.publicDomain` setting and `PORT`, so template, domain and port can never
|
|
272
|
+
disagree:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
APP_ENV=production pnpm script scripts/deploy-nginx.ts --dry-run
|
|
276
|
+
sudo -E APP_ENV=production pnpm script scripts/deploy-nginx.ts
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
It writes `sites-available/<domain>`, links `sites-enabled`, runs
|
|
280
|
+
`nginx -t`, then reloads. It refuses when not root, while
|
|
281
|
+
`core.publicDomain` is still `localhost`, or when the conf already exists
|
|
282
|
+
(pass `--force` to overwrite one). Debian/Ubuntu layout.
|
|
283
|
+
|
|
284
|
+
The rendered conf is HTTP-only with the TLS block commented out —
|
|
285
|
+
`nginx -t` fails on certificate paths that do not exist yet, which
|
|
286
|
+
would make the first deploy impossible. Install the certificate,
|
|
287
|
+
uncomment the block, redeploy with `--force`.
|
|
288
|
+
|
|
289
|
+
## Running under PM2
|
|
290
|
+
|
|
291
|
+
`ecosystem.config.cjs` defines both processes — the backend and the
|
|
292
|
+
queue worker — in all three modes:
|
|
293
|
+
|
|
294
|
+
```sh
|
|
295
|
+
pnpm pm2:start # development
|
|
296
|
+
pnpm pm2:start:staging
|
|
297
|
+
pnpm pm2:start:production
|
|
298
|
+
|
|
299
|
+
pnpm pm2:status # what is running
|
|
300
|
+
pnpm pm2:logs # tail both processes
|
|
301
|
+
pnpm pm2:restart # reload code
|
|
302
|
+
pnpm pm2:stop # stop, keep them in the process list
|
|
303
|
+
pnpm pm2:delete # remove from the process list
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Switching environment requires a restart carrying the flag, because
|
|
307
|
+
PM2 remembers the environment a process was started with:
|
|
308
|
+
`pm2 restart ecosystem.config.cjs --env production`.
|
|
309
|
+
|
|
310
|
+
`.envs/` holds one file per mode and is **git-ignored** — those files
|
|
311
|
+
carry working credentials. What is committed is `.env.example`,
|
|
312
|
+
generated by `pnpm sync`, naming every variable any module declares:
|
|
313
|
+
a clone copies it into `.envs/.env.<mode>` and fills it in. Servers
|
|
314
|
+
usually skip the file entirely and inject variables through the
|
|
315
|
+
container environment, which always wins over it. `pnpm check`
|
|
316
|
+
validates the current environment without booting, so a deploy can
|
|
317
|
+
fail on configuration before it fails on traffic.
|
|
318
|
+
|
|
319
|
+
PM2 is a devDependency so the scripts work straight after install;
|
|
320
|
+
servers usually install it globally instead (`npm i -g pm2`), in
|
|
321
|
+
which case the same scripts still work.
|
|
322
|
+
|
|
323
|
+
Two defaults worth knowing: processes run in **fork** mode with a
|
|
324
|
+
single instance, because Socket.IO needs sticky sessions or the Redis
|
|
325
|
+
adapter before a second instance is safe; and `kill_timeout` is 10s so
|
|
326
|
+
graceful shutdown can drain queues and close mongo and redis before
|
|
327
|
+
PM2 sends SIGKILL.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AppConfigFn } from "@tulipes/core/boot";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ── The app-wide config seed ──────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Runs FIRST in boot phase 6, before any module.config.ts factory, and
|
|
7
|
+
* whatever it returns becomes the base of the global config. Modules read
|
|
8
|
+
* it through `ctx.config.<key>`.
|
|
9
|
+
*
|
|
10
|
+
* Add a key here when two or more modules share a value. Config belonging
|
|
11
|
+
* to ONE feature goes in that module's `module.config.ts` instead, where
|
|
12
|
+
* `pnpm sync` types it for you — that is where `users.maxSessions` and
|
|
13
|
+
* `users.signupMode` live. A value that differs per deployment belongs in
|
|
14
|
+
* neither: declare it in a module's `meta.variables.json` and read it with
|
|
15
|
+
* `Environment.get`, the way MONGO_URI, CORS_ORIGINS and BODY_LIMIT do.
|
|
16
|
+
*
|
|
17
|
+
* The framework reads exactly one field of this object: `app.name`, which
|
|
18
|
+
* titles the startup banner. Everything else is yours.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// `satisfies` (not a type annotation) so TypeScript keeps the exact shape
|
|
22
|
+
// of this object for the augmentation at the bottom of the file.
|
|
23
|
+
const appConfig = (() => ({
|
|
24
|
+
app: {
|
|
25
|
+
name: "tulipes-example",
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Route prefixing. A router a routes file returns is mounted under this
|
|
30
|
+
* prefix, so versioning the whole API is a one-line change here; a module
|
|
31
|
+
* that wants another path says so with `ctx.routes.mount(router, "/x")`.
|
|
32
|
+
*/
|
|
33
|
+
api: {
|
|
34
|
+
prefix: "/api/v1",
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The languages this app speaks. Every module owns its own strings in
|
|
39
|
+
* `modules/<name>/i18n/<locale>.json` — the module is the namespace —
|
|
40
|
+
* and `req.t` inside an endpoint is already bound to the module that
|
|
41
|
+
* declared it. A request picks its locale with `?lang=fr` or an
|
|
42
|
+
* Accept-Language header, narrowed to this list.
|
|
43
|
+
*
|
|
44
|
+
* `defaultLocale` is also the fallback: a key missing from fr.json is
|
|
45
|
+
* served from en.json, and outside production the gap is recorded in
|
|
46
|
+
* `fr.missing.json` next to it.
|
|
47
|
+
*/
|
|
48
|
+
i18n: {
|
|
49
|
+
defaultLocale: "en",
|
|
50
|
+
supportedLocales: ["en", "fr"],
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* List-endpoint defaults, so every module paginates identically and no
|
|
55
|
+
* caller can ask for ten thousand rows. → modules/users/routes
|
|
56
|
+
*/
|
|
57
|
+
pagination: {
|
|
58
|
+
defaultLimit: 20,
|
|
59
|
+
maxLimit: 100,
|
|
60
|
+
},
|
|
61
|
+
})) satisfies AppConfigFn;
|
|
62
|
+
|
|
63
|
+
export default appConfig;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Types these keys at every call site: `config.pagination!.maxLimit` is a
|
|
67
|
+
* number, not `unknown`. `pnpm sync` does this automatically for
|
|
68
|
+
* module.config.ts factories; the app-level seed types itself here.
|
|
69
|
+
* Partial because the config object is empty until this factory runs.
|
|
70
|
+
*/
|
|
71
|
+
declare module "@tulipes/core/config" {
|
|
72
|
+
interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}
|
|
73
|
+
}
|