@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,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-model
|
|
3
|
+
description: Use when adding or changing a database model in a Tulipes app, querying from a route or job, seeding data, or ensuring indexes. Covers the ModelDef contract, the model store and bootstrap tasks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding a model
|
|
7
|
+
|
|
8
|
+
Model files are **declarations**, not registrations. Never call
|
|
9
|
+
`mongoose.model()` — the framework compiles the schema on its own
|
|
10
|
+
connection and puts it in the model store.
|
|
11
|
+
|
|
12
|
+
`modules/billing/models/invoice.model.ts`:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Schema } from "mongoose";
|
|
16
|
+
import type { ModelDef } from "@tulipes/mongoose";
|
|
17
|
+
import { baseSchemaOptions } from "@app/core";
|
|
18
|
+
|
|
19
|
+
const invoiceSchema = new Schema(
|
|
20
|
+
{
|
|
21
|
+
customer: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
|
22
|
+
amount_cents: { type: Number, required: true, min: 0 },
|
|
23
|
+
status: { type: String, enum: ["draft", "paid"], default: "draft", index: true },
|
|
24
|
+
},
|
|
25
|
+
baseSchemaOptions,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export default { name: "Invoice", schema: invoiceSchema } satisfies ModelDef;
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Field names are snake_case
|
|
32
|
+
|
|
33
|
+
Every persisted field — and therefore every field that reaches a response
|
|
34
|
+
body — is `snake_case`: `amount_cents`, `created_at`, `last_seen_at`,
|
|
35
|
+
`password_hash`. camelCase is for TypeScript-only names: locals, function
|
|
36
|
+
parameters, helper return shapes that never reach mongo or the wire.
|
|
37
|
+
|
|
38
|
+
**Never declare `createdAt` / `updatedAt` yourself.** `baseSchemaOptions`
|
|
39
|
+
already provides them as `created_at` and `updated_at`, and a hand-declared
|
|
40
|
+
camelCase field silently shadows the plugin, leaving you with both.
|
|
41
|
+
|
|
42
|
+
Renaming a persisted field is a data migration, not a rename: existing
|
|
43
|
+
documents keep the old key and stop matching queries. Write a bootstrap
|
|
44
|
+
task with `$rename` when you change one on live data.
|
|
45
|
+
|
|
46
|
+
Read it anywhere the context reaches:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const Invoice = models!.get("Invoice");
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`models.get()` throws on an unregistered name (listing what does exist),
|
|
53
|
+
so a typo fails at the call site instead of returning `undefined`.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- The app selects its database once, in its root `package.json`:
|
|
58
|
+
`"tulipes": { "providers": { "models": "@tulipes/mongoose" } }`. A module
|
|
59
|
+
with `models/` or `bootstrap/` files in an app without that line is a boot
|
|
60
|
+
refusal before any model is imported. A module that reads models but owns
|
|
61
|
+
none declares `"tulipes": { "requires": ["models"] }` in its own manifest.
|
|
62
|
+
- The provider needs `MONGO_URI` declared (sys `core` module) and set;
|
|
63
|
+
selected without a value is a boot refusal, not a warning.
|
|
64
|
+
- Model names are globally unique; two modules registering `Invoice`
|
|
65
|
+
crashes the boot naming both.
|
|
66
|
+
- Reference other modules' models by name (`ref: "User"`) — no import,
|
|
67
|
+
therefore no coupling.
|
|
68
|
+
|
|
69
|
+
## Seeding and indexes
|
|
70
|
+
|
|
71
|
+
Put anything that must run after models exist but before the server
|
|
72
|
+
accepts traffic in `modules/<name>/bootstrap/*.bootstrap.ts`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
76
|
+
|
|
77
|
+
export default async function seedPlans({ models }: Ctx): Promise<void> {
|
|
78
|
+
const Plan = models!.get("Plan");
|
|
79
|
+
await Plan.updateOne(
|
|
80
|
+
{ code: "free" },
|
|
81
|
+
{ $setOnInsert: { code: "free", price_cents: 0 } },
|
|
82
|
+
{ upsert: true },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Make bootstrap tasks idempotent** — they run on every boot, in every
|
|
88
|
+
process, including each worker. Upsert; never blind-insert.
|
|
89
|
+
|
|
90
|
+
**And tolerate concurrency.** Backend and worker start together, so two
|
|
91
|
+
processes run the same seed at the same time. Concurrent upserts on a
|
|
92
|
+
unique index race: one inserts, the other gets a duplicate-key error
|
|
93
|
+
(`code: 11000`). Catch that one code and carry on — the row exists, which
|
|
94
|
+
is all the task wanted.
|
|
95
|
+
|
|
96
|
+
## Gotchas
|
|
97
|
+
|
|
98
|
+
- Global plugins (timestamps, soft delete) only apply to schemas compiled
|
|
99
|
+
*after* registration, which is why `registerBasePlugins()` is called from
|
|
100
|
+
the sys core module's `module.config.ts` — phase 6, before models compile
|
|
101
|
+
in phase 8. That covers every mode, scripts included; calling it from
|
|
102
|
+
`app.ts` instead would leave scripts compiling models without the
|
|
103
|
+
soft-delete guard.
|
|
104
|
+
- Mongoose queries are lazy: `await` them, and prefer `.lean()` for reads
|
|
105
|
+
you only serialize.
|
|
106
|
+
- If the app soft-deletes, ordinary queries already exclude deleted rows;
|
|
107
|
+
use `.withDeleted()` / `.onlyDeleted()` to opt out deliberately.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-module
|
|
3
|
+
description: Use when adding a new feature to a Tulipes app — a new module under modules/ — or when deciding whether something belongs in an existing module or a new one. Covers the tulipes manifest key, tier, priority, dependsOn and workspace wiring.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding a module
|
|
7
|
+
|
|
8
|
+
A module is one feature, one folder, one workspace package. It owns its
|
|
9
|
+
routes, models, config, permissions and jobs, and can be deleted without
|
|
10
|
+
touching anything else.
|
|
11
|
+
|
|
12
|
+
## Decide first
|
|
13
|
+
|
|
14
|
+
- **Extend an existing module** when the work is part of that feature
|
|
15
|
+
(another users endpoint → `modules/users`).
|
|
16
|
+
- **New module** when it is a separate feature with its own vocabulary.
|
|
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` and import it
|
|
19
|
+
as `@app/core`. There is no top-level `lib/`: a helper two modules need
|
|
20
|
+
is owned by a module like everything else.
|
|
21
|
+
|
|
22
|
+
## Steps
|
|
23
|
+
|
|
24
|
+
1. Scaffold: `yarn tulipes new module billing`. It creates every
|
|
25
|
+
contract — package.json (scope detected from siblings), meta.variables,
|
|
26
|
+
config with lifecycle hooks, ACL, helper, a controller class, routes
|
|
27
|
+
declaring a RAI, model, bootstrap, queue and socket. Delete the ones this feature does not
|
|
28
|
+
need. For offline inspection, keep tulipes.offline: true and wrap route
|
|
29
|
+
factories in defineRoutes(); modules without routes need no placeholder file.
|
|
30
|
+
2. Set ordering in `modules/billing/package.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"name": "@app/billing",
|
|
35
|
+
"tulipes": { "tier": "app", "priority": 100, "dependsOn": ["users"] },
|
|
36
|
+
"dependencies": { "@tulipes/core": "^0.7.0", "express": "^5" }
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
3. `yarn install` — the module is a workspace, so the package manager must
|
|
41
|
+
link it before imports resolve.
|
|
42
|
+
4. Put endpoints and shared middleware in native `routes/*.routes.ts` factories.
|
|
43
|
+
Add optional files as needed:
|
|
44
|
+
`models/`, `queues/`, `sockets/`, `bootstrap/`, `module.acl.ts`,
|
|
45
|
+
`i18n/` — the module is its own i18n namespace. → skill `tulipes-i18n`
|
|
46
|
+
5. `yarn sync && yarn dev` — the banner must list the module.
|
|
47
|
+
|
|
48
|
+
## The manifest key
|
|
49
|
+
|
|
50
|
+
| Field | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `tier` | `sys` loads before every `app` module. Use `sys` **only** for framework-level concerns: global middleware, roles, infra variables. A business feature is always `app`. |
|
|
53
|
+
| `priority` | Lower loads earlier within a tier. Default 100. Use it for coarse ordering, not for dependencies. |
|
|
54
|
+
| `dependsOn` | Short names (`"users"`, not `"@app/users"`) of modules that must load first. Declare this whenever you import from another module or read its config. |
|
|
55
|
+
|
|
56
|
+
## Gotchas
|
|
57
|
+
|
|
58
|
+
- **`dependsOn` and the npm dependency are two different things.** To
|
|
59
|
+
import `@app/users` you need it in `dependencies` (resolution) *and* in
|
|
60
|
+
`dependsOn` (load order). Missing the first fails at import; missing the
|
|
61
|
+
second gives you a module whose config namespace may not exist yet.
|
|
62
|
+
- A `sys` module may not `dependsOn` an `app` module — that inverts the
|
|
63
|
+
tier guarantee and crashes the boot.
|
|
64
|
+
- Cycles crash the boot with the cycle printed. If two modules need each
|
|
65
|
+
other, the shared part belongs in the core module or a third module.
|
|
66
|
+
- The folder name is cosmetic; the **short name** (last segment of the
|
|
67
|
+
package name) is what `dependsOn` and the config namespace use.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-permissions
|
|
3
|
+
description: Use when adding roles or permissions to a Tulipes app, guarding an endpoint by role, making a route public, or deciding who may do what. Covers module.acl.ts, the guest role, and module-namespaced resources.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding permissions
|
|
7
|
+
|
|
8
|
+
Roles are **global** and defined once by the sys `auth` module. Every other
|
|
9
|
+
module **grants** permissions on its own resources to those roles.
|
|
10
|
+
|
|
11
|
+
`modules/auth/module.acl.ts` — the vocabulary:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export default function authAcl(acl: AclBuilder): void {
|
|
15
|
+
acl.defineRole("guest").defineRole("user").defineRole("admin");
|
|
16
|
+
acl.allow("guest", "auth:login", "auth:register", "auth:refresh");
|
|
17
|
+
acl.allow("admin", "*");
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`modules/billing/module.acl.ts` — grants for this feature:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
export default function billingAcl(acl: AclBuilder): void {
|
|
25
|
+
acl.allow("user", "invoices:read");
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Guarding is the route's RAI — its `id` **is** the permission, so there is
|
|
30
|
+
no second call to forget:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
router.get(
|
|
34
|
+
`${base}/invoices`,
|
|
35
|
+
rai({ id: "invoices:read", name: "List invoices" }),
|
|
36
|
+
billing.list(),
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Public routes are grants, not exceptions
|
|
41
|
+
|
|
42
|
+
A request with no valid token is not rejected — it becomes the **`guest`**
|
|
43
|
+
caller. So "public" means guest holds the permission:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
acl.allow("guest", "docs:read"); // now anyone may read the docs
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
There is no allowlist, no `skipAuth` flag, no second mechanism. Access is
|
|
50
|
+
decided in one place, and the whole policy is readable by reading the
|
|
51
|
+
`module.acl.ts` files.
|
|
52
|
+
|
|
53
|
+
**The corollary matters: a route with no permission check is public.**
|
|
54
|
+
Because the pipeline authenticates rather than rejects, forgetting a guard
|
|
55
|
+
does not fail closed — it silently exposes the route. Name a permission on
|
|
56
|
+
every route, even ones you expect everybody to reach.
|
|
57
|
+
|
|
58
|
+
## Rules
|
|
59
|
+
|
|
60
|
+
- **Resources are namespaced by module**: `invoices:read`, `users:write`.
|
|
61
|
+
Match the module's short name so ownership is obvious.
|
|
62
|
+
- **Grant, don't define.** A feature module calling `defineRole()` usually
|
|
63
|
+
means the role belongs in `auth`. `allow()` on an unknown role is a boot
|
|
64
|
+
error, never an implicit creation.
|
|
65
|
+
- The same role+resource granted twice crashes the boot.
|
|
66
|
+
- Wildcards: `*` (everything), `invoices:*` (a namespace). Give `*` to
|
|
67
|
+
`admin` only.
|
|
68
|
+
- Unknown roles always deny at runtime.
|
|
69
|
+
|
|
70
|
+
## Who is calling
|
|
71
|
+
|
|
72
|
+
`req.auth` is resolved before any feature route runs, and is never
|
|
73
|
+
undefined inside a declared route:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const { isAuthenticated, user, session } = req.auth;
|
|
77
|
+
user?.email // the hydrated user document, or null for a guest
|
|
78
|
+
session?.sid // this device's session row, or null
|
|
79
|
+
session?.accessJti // the token in hand, for revoking exactly it
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Never re-parse the Authorization header or trust a claim out of a token —
|
|
83
|
+
the pipeline already resolved both, and reloads the user on every request
|
|
84
|
+
so a demotion takes effect immediately.
|
|
85
|
+
|
|
86
|
+
A route that only cares that somebody is signed in still declares a RAI
|
|
87
|
+
and grants it to `user`; there is no separate "just needs auth" guard.
|
|
88
|
+
|
|
89
|
+
## Denials
|
|
90
|
+
|
|
91
|
+
The RAI answers 401 when the caller is a guest (signing in would help) and
|
|
92
|
+
403 when they are authenticated but lack the grant (it would not).
|
|
93
|
+
|
|
94
|
+
## Gotchas
|
|
95
|
+
|
|
96
|
+
- Load order: roles must exist before grants, which is why `auth` is
|
|
97
|
+
`tier: "sys", priority: 20`. A module granting on those roles is app-tier
|
|
98
|
+
and therefore loads later; if you make one sys-tier, give it a higher
|
|
99
|
+
priority than auth or it will crash with "unknown role".
|
|
100
|
+
- `acl.can()` answers *what a role may do*, not *who the caller is*.
|
|
101
|
+
Resolving the caller is the auth module's job — it sets `req.auth`.
|
|
102
|
+
- The role core checks is `req.auth.user.role`. An app whose roles live
|
|
103
|
+
elsewhere replaces the decision with `ctx.routes.setAccessChecker()`
|
|
104
|
+
rather than inventing a fake user.
|
|
105
|
+
- Roles are lowercase kebab-case; resources are `name:action`.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-queue
|
|
3
|
+
description: Use when adding background work to a Tulipes app — emails, webhooks, exports, scheduled retries — or when a request handler is doing something slow. Covers the queues contract, producing jobs, and the backend/worker process split.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding a background job
|
|
7
|
+
|
|
8
|
+
One file declares both sides of a queue, and the process mode decides what
|
|
9
|
+
happens with it: the **backend** process registers the queue so routes can
|
|
10
|
+
produce into it; the **worker** process turns the processor into a live
|
|
11
|
+
consumer. Never duplicate the definition.
|
|
12
|
+
|
|
13
|
+
`modules/billing/queues/invoices.queues.ts`:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
17
|
+
import type { QueueRegistry } from "@tulipes/bullmq";
|
|
18
|
+
|
|
19
|
+
export default function invoiceQueues({ models }: Ctx, queues: QueueRegistry): void {
|
|
20
|
+
queues.define("billing.send-invoice");
|
|
21
|
+
|
|
22
|
+
queues.process("billing.send-invoice", async (job) => {
|
|
23
|
+
const invoice = await models!.get("Invoice").findById(job.data.invoiceId);
|
|
24
|
+
// …do the slow thing here…
|
|
25
|
+
return { sent: true };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Produce from anywhere with the context:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
await queues!.add("billing.send-invoice", "send", { invoiceId: invoice.id });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Run a consumer with `yarn worker`. Without it, jobs pile up in Redis and
|
|
37
|
+
nothing processes them — that is the correct behaviour, not a bug.
|
|
38
|
+
|
|
39
|
+
## Rules
|
|
40
|
+
|
|
41
|
+
- **Queue names use `.`, never `:`** (`billing.send-invoice`). BullMQ
|
|
42
|
+
reserves `:` as its Redis key separator; a `:` name crashes the boot.
|
|
43
|
+
- Names are globally unique, and a queue may have only one processor —
|
|
44
|
+
duplicates crash the boot naming both modules.
|
|
45
|
+
- A processor registered for a queue nobody defined is a boot error; call
|
|
46
|
+
`define()` in the same file.
|
|
47
|
+
- The app selects its queue provider once, in its root `package.json`:
|
|
48
|
+
`"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. A module with a
|
|
49
|
+
`queues/` file in an app without that line is a boot refusal before any
|
|
50
|
+
module is imported; a module that only produces declares
|
|
51
|
+
`"tulipes": { "requires": ["queues"] }` in its own manifest.
|
|
52
|
+
- The provider needs `REDIS_URL` declared (sys `core` module) and set;
|
|
53
|
+
selected without a value is a boot refusal. The app's own Redis users
|
|
54
|
+
(cache, rate limiter) keep their own connections and their own `ioredis`.
|
|
55
|
+
- Worker mode with zero processors refuses to start: a consumer with
|
|
56
|
+
nothing to consume is a misconfigured deployment.
|
|
57
|
+
|
|
58
|
+
## Writing a processor
|
|
59
|
+
|
|
60
|
+
- **Jobs retry.** Make the handler idempotent — the same job may run twice
|
|
61
|
+
after a crash or timeout.
|
|
62
|
+
- Pass identifiers in `job.data`, not whole documents; re-read from the
|
|
63
|
+
database inside the processor so the job acts on current state.
|
|
64
|
+
- Keep payloads small and JSON-serializable.
|
|
65
|
+
- Throwing marks the job failed and schedules a retry per its options; let
|
|
66
|
+
it throw rather than swallowing errors.
|
|
67
|
+
- Both processes run the full boot pipeline, so `models`, `config` and
|
|
68
|
+
`Environment` are all available inside a processor.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-response
|
|
3
|
+
description: Use when writing any endpoint response in a Tulipes app — returning data, paginating a list, reporting a validation failure, or setting meta.action. Covers res.respond, the response envelope, error codes and req.page.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Responding
|
|
7
|
+
|
|
8
|
+
Every endpoint answers with one shape, so a client writes its unwrapping
|
|
9
|
+
once:
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
{
|
|
13
|
+
"success": true,
|
|
14
|
+
"data": { }, // or [ ], or null on failure
|
|
15
|
+
"errors": [ ], // [{ field, message, code }] on failure
|
|
16
|
+
"meta": { } // action, pagination
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`res.respond` builds it. Express is untouched — `res.json`, `res.send` and
|
|
21
|
+
`res.status` behave exactly as they always did — so `respond` is a method
|
|
22
|
+
you call, never a wrapper you fight.
|
|
23
|
+
|
|
24
|
+
## Success
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
res.respond({ data: user }); // 200
|
|
28
|
+
res.respond({ data: user, status: 201 }); // created
|
|
29
|
+
res.respond({ data: null, meta: { action: "complete_profile" } });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Lists and pagination
|
|
33
|
+
|
|
34
|
+
`req.page` is already parsed from `?page` and `?per_page` and clamped by
|
|
35
|
+
`config.pagination`, so no endpoint re-derives it and none can be talked
|
|
36
|
+
into ten thousand rows:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const { per_page, skip } = req.page;
|
|
40
|
+
const [items, total_items] = await Promise.all([
|
|
41
|
+
Model.find().skip(skip).limit(per_page).lean(),
|
|
42
|
+
Model.countDocuments(),
|
|
43
|
+
]);
|
|
44
|
+
res.respond({ data: items, meta: { total_items } });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Passing `total_items` is what marks a response as a page.** `respond`
|
|
48
|
+
fills in `page`, `per_page` and `total_pages` from the request and the
|
|
49
|
+
count. Without it, `meta` stays clean — a single record does not carry
|
|
50
|
+
four null pagination keys.
|
|
51
|
+
|
|
52
|
+
## Failures are thrown, never responded
|
|
53
|
+
|
|
54
|
+
The terminal handler builds the same envelope from a thrown error, so a
|
|
55
|
+
handler only ever writes the happy path.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
throw new HttpError(404, "notFound");
|
|
59
|
+
throw new HttpError(422, "emailTaken", { field: "email" });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The message is an **i18n key**. The handler translates it in the module's
|
|
63
|
+
namespace — the one the route's RAI recorded — and derives the code from
|
|
64
|
+
the key, so `notFound` becomes `NOT_FOUND` and `emailTaken` becomes
|
|
65
|
+
`EMAIL_TAKEN`. One string, no second thing to keep in step.
|
|
66
|
+
|
|
67
|
+
An already-translated message also works, because a string that resolves
|
|
68
|
+
to no key is used as written:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
throw new HttpError(403, req.t("registrationClosed", { mode }), {
|
|
72
|
+
code: "REGISTRATION_CLOSED", // no key to derive from, so name it
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Prefer the key form. Pass `vars` when the key interpolates
|
|
77
|
+
(`{ vars: { name } }`), and `code` only to override what the key derives.
|
|
78
|
+
|
|
79
|
+
### Several fields at once
|
|
80
|
+
|
|
81
|
+
A form wrong in three places should say so once:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const issues: FieldIssue[] = [];
|
|
85
|
+
if (!email) issues.push({ field: "email", message: "emailRequired" });
|
|
86
|
+
if (!password) issues.push({ field: "password", message: "passwordTooShort" });
|
|
87
|
+
if (issues.length > 0) throw new ValidationError(issues); // 422
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## meta.action
|
|
91
|
+
|
|
92
|
+
`action` tells the client what to do next — `complete_profile`,
|
|
93
|
+
`redirect_to_signin`. Declare the set and every call site is checked:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
declare module "@tulipes/core/http" {
|
|
97
|
+
interface MetaActions {
|
|
98
|
+
complete_profile: true;
|
|
99
|
+
redirect_to_signin: true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Until you declare any, `action` accepts any string.
|
|
105
|
+
|
|
106
|
+
## Rules
|
|
107
|
+
|
|
108
|
+
- **Never `res.json` in a handler.** It ships a raw, unenveloped body.
|
|
109
|
+
Development logs a warning naming the route when this happens; nothing
|
|
110
|
+
catches it in production.
|
|
111
|
+
- **Never build an error body by hand.** `throw` and let the terminal
|
|
112
|
+
handler shape it — a hand-rolled 500 leaks what the framework hides.
|
|
113
|
+
- `data` and `errors` are mutually exclusive: a failure carries `data:
|
|
114
|
+
null`, whatever was passed.
|
|
115
|
+
- **Field names on the wire are `snake_case`**, envelope and payload
|
|
116
|
+
alike. → skill `tulipes-model`
|
|
117
|
+
- Codes are stable and never translated; messages are translated and
|
|
118
|
+
never parsed. A client branches on `code`.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-settings
|
|
3
|
+
description: Use when working with the settings module — the sysadmin console that describes every module, edits environment variables and translations, restarts the app, or streams logs. Covers the sysadmin role, where a written variable actually lands, and why some writes cannot take effect.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# The settings module
|
|
7
|
+
|
|
8
|
+
An infrastructure console: what every module declares, what its variables
|
|
9
|
+
resolve to and from where, its translations, and a live view of the logs.
|
|
10
|
+
|
|
11
|
+
Everything it reports comes from the **booted context** — `ctx.modules`,
|
|
12
|
+
the variable store, the ACL, the RAI registry — so it cannot describe an
|
|
13
|
+
app different from the one running.
|
|
14
|
+
|
|
15
|
+
## Access is `sysadmin`, and that is a real boundary
|
|
16
|
+
|
|
17
|
+
`settings:*` is granted only to `sysadmin`. This works because the auth
|
|
18
|
+
module grants `admin` its namespaces **explicitly** rather than `*`:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
acl.allow("admin", "auth:*", "users:*", "hello:*");
|
|
22
|
+
acl.allow("sysadmin", "auth:*", "users:*", "hello:*"); // + settings:* from settings
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**A wildcard would reach `settings:*` like any other id**, and "sysadmin
|
|
26
|
+
only" would be a label rather than a rule. When you add a feature module,
|
|
27
|
+
add its namespace to *both* grants — a new module is invisible to admin
|
|
28
|
+
otherwise.
|
|
29
|
+
|
|
30
|
+
A fresh app seeds an `admin`, so the console is inert until someone is
|
|
31
|
+
deliberately promoted to `sysadmin`.
|
|
32
|
+
|
|
33
|
+
## Endpoints
|
|
34
|
+
|
|
35
|
+
| | |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `GET /settings/modules` | every module: manifest, variables with full spec + resolved source, config, i18n, routes |
|
|
38
|
+
| `GET /settings/variables/:name` | one variable; `?reveal=true` returns a secret's value, audited |
|
|
39
|
+
| `PUT /settings/variables/:name` | set a value — validated against the variable's own spec |
|
|
40
|
+
| `GET`/`PUT /settings/i18n/:module/:locale` | read or replace a module's translations |
|
|
41
|
+
| `POST /settings/reboot` | restart via PM2 so written values take effect |
|
|
42
|
+
| `GET /settings/logs/stream` | SSE; `?process=backend\|worker&level=warn&tail=200` |
|
|
43
|
+
|
|
44
|
+
## Where a written variable actually lands
|
|
45
|
+
|
|
46
|
+
**Always `.envs/.env.<APP_ENV>` — never `meta.variables.json`.** That file
|
|
47
|
+
is the module's committed contract: rewriting a default there would change
|
|
48
|
+
it for every environment and be lost on the next deploy. The env file is
|
|
49
|
+
what exists to hold per-deployment values.
|
|
50
|
+
|
|
51
|
+
The file is edited **surgically** — one line replaced, comments and
|
|
52
|
+
ordering untouched — because these files are half explanation and a
|
|
53
|
+
`stringify(parse(file))` round trip would erase all of it.
|
|
54
|
+
|
|
55
|
+
### The case that catches people
|
|
56
|
+
|
|
57
|
+
Precedence is `process.env` → env file → default. A variable injected by
|
|
58
|
+
the **container** outranks both, so writing the file changes nothing, and
|
|
59
|
+
a restart re-reads the same container environment and it wins again.
|
|
60
|
+
|
|
61
|
+
When that happens the write still lands in the file *and* sets
|
|
62
|
+
`process.env` live, and the response says so:
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
{ "meta": { "action": "restart_required_shadowed" } }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
That makes the change real for **this process only**. Other replicas keep
|
|
69
|
+
the old value until the container's environment itself is changed.
|
|
70
|
+
|
|
71
|
+
## Nothing takes effect until a restart
|
|
72
|
+
|
|
73
|
+
Variables resolve once at boot, and `config.<module>` is derived from them
|
|
74
|
+
by factories that also run once. A write persists; it does not move the
|
|
75
|
+
running app. Every write response carries `requires_reboot: true`.
|
|
76
|
+
|
|
77
|
+
`POST /settings/reboot` restarts through PM2, ordering the process serving
|
|
78
|
+
the request **last** so the caller still gets its response. Under `yarn
|
|
79
|
+
dev` there is no PM2, and it answers `503 NOT_SUPERVISED` rather than
|
|
80
|
+
exiting a process nothing would restart.
|
|
81
|
+
|
|
82
|
+
## Logs are read from files, not hooked in-process
|
|
83
|
+
|
|
84
|
+
The stream tails `LOG_DIR`, which is what lets it show the **worker** —
|
|
85
|
+
backend and worker are separate processes writing separate files, and an
|
|
86
|
+
in-process stream could only ever show its own. The tailer is
|
|
87
|
+
rotation-aware: pino-roll renames and starts a new file, and a naive watch
|
|
88
|
+
on one path goes silent the moment it rolls.
|
|
89
|
+
|
|
90
|
+
`LOG_FILE=false` means there are no files; the endpoint says so rather
|
|
91
|
+
than streaming nothing.
|
|
92
|
+
|
|
93
|
+
## Audit
|
|
94
|
+
|
|
95
|
+
Every reveal, write and reboot logs at `warn` with actor, role, IP and
|
|
96
|
+
old→new. **A secret's value is never recorded in either direction** — an
|
|
97
|
+
audit entry that reconstructs a rotated key defeats rotating it; those
|
|
98
|
+
entries carry `redacted: true` and no values.
|
|
99
|
+
|
|
100
|
+
`?level=warn` on the log stream shows the audit trail and nothing else.
|
|
101
|
+
|
|
102
|
+
## Rules
|
|
103
|
+
|
|
104
|
+
- Never add a `settings:*` grant to another role. If someone needs part of
|
|
105
|
+
it, give them `sysadmin`, or split the ids.
|
|
106
|
+
- Never widen a write to `meta.variables.json`. It is a contract, not
|
|
107
|
+
storage.
|
|
108
|
+
- A new feature module needs its namespace in **both** the `admin` and
|
|
109
|
+
`sysadmin` grants.
|
|
110
|
+
- Treat a `restart_required_shadowed` response as a warning to fix the
|
|
111
|
+
deployment's environment, not as a successful change.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tulipes-socket
|
|
3
|
+
description: Use when adding realtime features to a Tulipes app — websockets, live updates, presence, notifications — or when emitting events from a route or background job. Covers the sockets contract and namespace ownership.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding realtime
|
|
7
|
+
|
|
8
|
+
Socket.IO namespaces are claimed by modules, one owner each, in
|
|
9
|
+
`modules/<name>/sockets/*.sockets.ts`. Websockets are opt-in: an app with
|
|
10
|
+
no socket files starts no Socket.IO server at all.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
14
|
+
import type { SocketRegistry } from "@tulipes/socket.io";
|
|
15
|
+
|
|
16
|
+
export default function billingSockets({ config }: Ctx, sockets: SocketRegistry): void {
|
|
17
|
+
sockets.namespace("/billing", (nsp) => {
|
|
18
|
+
nsp.on("connection", (socket) => {
|
|
19
|
+
socket.emit("ready", { plan: config.billing?.plan });
|
|
20
|
+
|
|
21
|
+
socket.on("subscribe", (invoiceId: string) => {
|
|
22
|
+
socket.join(`invoice:${invoiceId}`);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Emit from anywhere with the context — a route, a bootstrap task, a queue
|
|
30
|
+
processor in the backend process:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
ctx.sockets!.of("/billing").to(`invoice:${id}`).emit("invoice:paid", payload);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Rules
|
|
37
|
+
|
|
38
|
+
- The app selects its socket provider once, in its root `package.json`:
|
|
39
|
+
`"tulipes": { "providers": { "sockets": "@tulipes/socket.io" } }`. A module
|
|
40
|
+
with a `sockets/` file in an app without that line is a boot refusal before
|
|
41
|
+
any module is imported; a module that only emits declares
|
|
42
|
+
`"tulipes": { "requires": ["sockets"] }`.
|
|
43
|
+
- **Namespaces are claimed once.** A second module claiming `/billing`
|
|
44
|
+
crashes the boot naming both.
|
|
45
|
+
- `sockets.of()` throws on a namespace nobody claimed. Socket.IO's raw
|
|
46
|
+
`io.of()` would silently create it and emit into the void — use the
|
|
47
|
+
framework accessor so typos surface.
|
|
48
|
+
- Sockets exist in **backend mode only**. `ctx.sockets` is undefined in the
|
|
49
|
+
worker process, so a queue processor cannot emit directly — publish to
|
|
50
|
+
Redis, or have the backend process subscribe.
|
|
51
|
+
- Namespace names are lowercase kebab-case with a leading slash.
|
|
52
|
+
|
|
53
|
+
## Gotchas
|
|
54
|
+
|
|
55
|
+
- Authenticate in namespace middleware (`nsp.use(...)`), not per event —
|
|
56
|
+
the framework's HTTP pipeline does not run for socket connections.
|
|
57
|
+
- Scaling past one backend process needs the Socket.IO Redis adapter;
|
|
58
|
+
otherwise an emit only reaches clients connected to that instance.
|
|
59
|
+
- Socket handlers run outside the request pipeline: no `HttpError`, no
|
|
60
|
+
request logging. Handle failures explicitly and emit an error event.
|