@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/app-core.d.ts +33 -0
  4. package/dist/app-core.js +58 -0
  5. package/dist/app-core.js.map +1 -0
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +7 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/dev.d.ts +8 -0
  10. package/dist/dev.js +22 -0
  11. package/dist/dev.js.map +1 -0
  12. package/dist/env-check.d.ts +6 -0
  13. package/dist/env-check.js +28 -0
  14. package/dist/env-check.js.map +1 -0
  15. package/dist/init.d.ts +25 -0
  16. package/dist/init.js +6416 -0
  17. package/dist/init.js.map +1 -0
  18. package/dist/inspection.d.ts +8 -0
  19. package/dist/inspection.js +15 -0
  20. package/dist/inspection.js.map +1 -0
  21. package/dist/main.d.ts +1 -0
  22. package/dist/main.js +166 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/minimal.d.ts +2 -0
  25. package/dist/minimal.js +2200 -0
  26. package/dist/minimal.js.map +1 -0
  27. package/dist/new-module.d.ts +19 -0
  28. package/dist/new-module.js +348 -0
  29. package/dist/new-module.js.map +1 -0
  30. package/dist/package-info.d.ts +18 -0
  31. package/dist/package-info.js +53 -0
  32. package/dist/package-info.js.map +1 -0
  33. package/dist/routes.d.ts +8 -0
  34. package/dist/routes.js +54 -0
  35. package/dist/routes.js.map +1 -0
  36. package/dist/spec.d.ts +19 -0
  37. package/dist/spec.js +101 -0
  38. package/dist/spec.js.map +1 -0
  39. package/dist/sync.d.ts +12 -0
  40. package/dist/sync.js +117 -0
  41. package/dist/sync.js.map +1 -0
  42. package/dist/update.d.ts +20 -0
  43. package/dist/update.js +240 -0
  44. package/dist/update.js.map +1 -0
  45. package/package.json +59 -0
  46. package/templates/CLAUDE.md +120 -0
  47. package/templates/browser-auth.md +120 -0
  48. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +74 -0
  49. package/templates/claude/skills/tulipes-endpoint/SKILL.md +132 -0
  50. package/templates/claude/skills/tulipes-env-variable/SKILL.md +78 -0
  51. package/templates/claude/skills/tulipes-i18n/SKILL.md +98 -0
  52. package/templates/claude/skills/tulipes-model/SKILL.md +107 -0
  53. package/templates/claude/skills/tulipes-module/SKILL.md +67 -0
  54. package/templates/claude/skills/tulipes-permissions/SKILL.md +105 -0
  55. package/templates/claude/skills/tulipes-queue/SKILL.md +68 -0
  56. package/templates/claude/skills/tulipes-response/SKILL.md +118 -0
  57. package/templates/claude/skills/tulipes-settings/SKILL.md +111 -0
  58. package/templates/claude/skills/tulipes-socket/SKILL.md +60 -0
  59. 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.