@tulipes/cli 0.2.0 → 0.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/config-check.js +1 -1
  2. package/dist/env-check.d.ts +5 -3
  3. package/dist/env-check.js +26 -19
  4. package/dist/env-check.js.map +1 -1
  5. package/dist/main.js +83 -19
  6. package/dist/main.js.map +1 -1
  7. package/dist/migrate-0.12-env.d.ts +36 -0
  8. package/dist/migrate-0.12-env.js +250 -0
  9. package/dist/migrate-0.12-env.js.map +1 -0
  10. package/dist/migrate-0.12-scaffold.d.ts +1 -0
  11. package/dist/migrate-0.12-scaffold.js +197 -0
  12. package/dist/migrate-0.12-scaffold.js.map +1 -0
  13. package/dist/migrate-0.12.d.ts +47 -0
  14. package/dist/migrate-0.12.js +210 -0
  15. package/dist/migrate-0.12.js.map +1 -0
  16. package/dist/migrate.d.ts +3 -1
  17. package/dist/migrate.js +11 -5
  18. package/dist/migrate.js.map +1 -1
  19. package/dist/new-module.d.ts +7 -0
  20. package/dist/new-module.js +35 -16
  21. package/dist/new-module.js.map +1 -1
  22. package/dist/roles.d.ts +15 -0
  23. package/dist/roles.js +54 -0
  24. package/dist/roles.js.map +1 -0
  25. package/dist/routes.d.ts +7 -2
  26. package/dist/routes.js +19 -10
  27. package/dist/routes.js.map +1 -1
  28. package/dist/spec.d.ts +2 -0
  29. package/dist/spec.js +13 -11
  30. package/dist/spec.js.map +1 -1
  31. package/dist/sync.js +66 -53
  32. package/dist/sync.js.map +1 -1
  33. package/package.json +4 -4
  34. package/templates/AGENTS.md +35 -23
  35. package/templates/app/.envs/.env.development +2 -1
  36. package/templates/app/.envs/.env.production +2 -2
  37. package/templates/app/.envs/.env.staging +2 -2
  38. package/templates/app/README.md +44 -29
  39. package/templates/app/config/app.config.ts +60 -7
  40. package/templates/app/docs/agent-manifest.json +8 -681
  41. package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +1 -1
  42. package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -4
  43. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
  44. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
  45. package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
  46. package/templates/app/modules/auth/helpers/passport.ts +1 -1
  47. package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
  48. package/templates/app/modules/auth/helpers/tokens.ts +3 -5
  49. package/templates/app/modules/auth/module.acl.ts +31 -9
  50. package/templates/app/modules/auth/module.config.ts +5 -3
  51. package/templates/app/modules/auth/package.json +10 -10
  52. package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
  53. package/templates/app/modules/auth/tests/auth.test.ts +1 -1
  54. package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
  55. package/templates/app/modules/config/helpers/audit.ts +1 -1
  56. package/templates/app/modules/config/helpers/describe.ts +35 -26
  57. package/templates/app/modules/config/module.acl.ts +1 -1
  58. package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
  59. package/templates/app/modules/config/tests/config.test.ts +14 -6
  60. package/templates/app/modules/core/helpers/cache/index.ts +8 -7
  61. package/templates/app/modules/core/helpers/lazy.ts +3 -3
  62. package/templates/app/modules/core/helpers/logger/index.ts +24 -16
  63. package/templates/app/modules/core/module.config.ts +5 -3
  64. package/templates/app/modules/core/package.json +46 -19
  65. package/templates/app/modules/core/routes/core.routes.ts +4 -4
  66. package/templates/app/modules/hello/agent/hello.agent.ts +2 -2
  67. package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
  68. package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +2 -2
  69. package/templates/app/modules/hello/module.acl.ts +12 -7
  70. package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
  71. package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
  72. package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
  73. package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
  74. package/templates/app/modules/security/routes/security.routes.ts +4 -4
  75. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
  76. package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
  77. package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
  78. package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -3
  79. package/templates/app/modules/settings/tests/settings.test.ts +21 -25
  80. package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
  81. package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
  82. package/templates/app/modules/users/module.acl.ts +4 -4
  83. package/templates/app/modules/users/queues/users.queues.ts +1 -1
  84. package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
  85. package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
  86. package/templates/app/nginx/production.conf +1 -1
  87. package/templates/app/scripts/deploy-nginx.ts +3 -2
  88. package/templates/app/tests/support.ts +4 -3
  89. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +6 -4
  90. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
  91. package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
  92. package/templates/claude/skills/tulipes-endpoint/SKILL.md +34 -15
  93. package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
  94. package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
  95. package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
  96. package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
  97. package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
  98. package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
  99. package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
  100. package/templates/minimal/config/app.config.ts +13 -3
  101. package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
  102. package/templates/minimal/modules/core/module.acl.ts +7 -1
  103. package/templates/minimal/modules/core/package.json +4 -4
  104. package/templates/minimal/modules/hello/module.acl.ts +1 -1
  105. package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
  106. package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
  107. package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
  108. package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
  109. package/templates/versions.json +8 -8
  110. package/templates/app/modules/users/routes/users.routes.ts +0 -60
@@ -4,42 +4,18 @@ import type { Ctx } from "@tulipes/core/boot";
4
4
  import { HttpError } from "@tulipes/core/http";
5
5
 
6
6
  /**
7
- * One class per controller, holding the context. Each method returns a
8
- * handler, so the routes file stays a table of contents.
9
- *
10
- * Every response goes through `res.respond`, which wraps the payload in
11
- * the app's envelope. Failures are thrown rather than responded — the
12
- * framework's terminal handler builds the same envelope from them — so a
13
- * handler only ever writes the happy path.
14
- *
15
- * Thrown messages are i18n KEYS: the handler translates them in this
16
- * module's namespace and derives the error code from the key, so
17
- * "notFound" becomes NOT_FOUND with a translated message.
7
+ * The users back office — the users:bo:* endpoints, granted to admin
8
+ * through its users:* grant. Same shape as the web controller beside it:
9
+ * one class holding the context, one handler per method, failures thrown
10
+ * as i18n keys.
18
11
  */
19
- export class UsersController {
12
+ export class BoUsersController {
20
13
  readonly #ctx: Ctx;
21
14
 
22
15
  constructor(ctx: Ctx) {
23
16
  this.#ctx = ctx;
24
17
  }
25
18
 
26
- list(): RequestHandler {
27
- return async (req: Request, res: Response) => {
28
- // ?page and ?per_page, already clamped by config.pagination.
29
- const { per_page, skip } = req.page;
30
- const User = this.#ctx.models!.get("User");
31
-
32
- const [users, total_items] = await Promise.all([
33
- User.find().select("email role -_id").skip(skip).limit(per_page).lean(),
34
- User.countDocuments(),
35
- ]);
36
-
37
- // Passing total_items is what marks this a page: respond fills in
38
- // page, per_page and total_pages from the request and the count.
39
- res.respond({ data: users, meta: { total_items } });
40
- };
41
- }
42
-
43
19
  /** Soft delete: the row stays in mongo but leaves every ordinary query. */
44
20
  softDelete(): RequestHandler {
45
21
  return async (req: Request, res: Response) => {
@@ -84,30 +60,4 @@ export class UsersController {
84
60
  res.respond({ status: 201, data: { email, welcome_queued: true } });
85
61
  };
86
62
  }
87
-
88
- ping(): RequestHandler {
89
- return (req: Request, res: Response) => {
90
- res.respond({
91
- data: {
92
- module: "users",
93
- locale: req.locale,
94
- max_sessions: this.#ctx.config.users?.maxSessions,
95
- },
96
- });
97
- };
98
- }
99
-
100
- /** Demonstrates HttpError — the framework renders it into the envelope. */
101
- demoMissing(): RequestHandler {
102
- return async () => {
103
- throw new HttpError(404, "notFound", { details: { hint: "demo endpoint" } });
104
- };
105
- }
106
-
107
- /** Demonstrates an unexpected error: anonymous 500 outside development. */
108
- demoBoom(): RequestHandler {
109
- return async () => {
110
- throw new Error("secret internals that must not leak in production");
111
- };
112
- }
113
63
  }
@@ -1,11 +1,12 @@
1
- import { forgetUser } from "@app/auth";
2
1
  import type { Request, RequestHandler, Response } from "express";
3
2
  import type { Ctx } from "@tulipes/core/boot";
4
3
  import { HttpError } from "@tulipes/core/http";
5
4
 
6
5
  /**
7
6
  * One class per controller, holding the context. Each method returns a
8
- * handler, so the routes file stays a table of contents.
7
+ * handler, so the routes file stays a table of contents. The class is
8
+ * named after its file — web.users.controllers.ts, WebUsersController —
9
+ * and serves the users:web:* endpoints; the back office has its own.
9
10
  *
10
11
  * Every response goes through `res.respond`, which wraps the payload in
11
12
  * the app's envelope. Failures are thrown rather than responded — the
@@ -16,7 +17,7 @@ import { HttpError } from "@tulipes/core/http";
16
17
  * module's namespace and derives the error code from the key, so
17
18
  * "notFound" becomes NOT_FOUND with a translated message.
18
19
  */
19
- export class UsersController {
20
+ export class WebUsersController {
20
21
  readonly #ctx: Ctx;
21
22
 
22
23
  constructor(ctx: Ctx) {
@@ -40,45 +41,6 @@ export class UsersController {
40
41
  };
41
42
  }
42
43
 
43
- /** Soft delete: the row stays in mongo but leaves every ordinary query. */
44
- softDelete(): RequestHandler {
45
- return async (req: Request, res: Response) => {
46
- const user = await this.#ctx.models!.get("User").findOne({ email: req.params.email });
47
- if (!user) throw new HttpError(404, "notFound");
48
-
49
- // The account leaves every query, so its sessions resolve to nobody on
50
- // the cold path; the cached identities would not know, so they go too.
51
- await forgetUser(this.#ctx, String(user._id));
52
- await (user as unknown as { softDelete(): Promise<unknown> }).softDelete();
53
- res.respond({ data: { email: req.params.email, soft_deleted: true } });
54
- };
55
- }
56
-
57
- listDeleted(): RequestHandler {
58
- return async (_req: Request, res: Response) => {
59
- const deleted = await this.#ctx.models!
60
- .get("User")
61
- .find({ is_deleted: true })
62
- .setOptions({ withDeleted: true } as Record<string, unknown>)
63
- .select("email role -_id")
64
- .lean();
65
-
66
- res.respond({ data: deleted });
67
- };
68
- }
69
-
70
- create(): RequestHandler {
71
- return async (req: Request, res: Response) => {
72
- const { email } = (req.body ?? {}) as { email?: string };
73
- // `field` is what puts this against the input in the envelope.
74
- if (!email) throw new HttpError(422, "emailRequired", { field: "email" });
75
-
76
- await this.#ctx.models!.get("User").create({ email });
77
-
78
- res.respond({ status: 201, data: { email } });
79
- };
80
- }
81
-
82
44
  ping(): RequestHandler {
83
45
  return (req: Request, res: Response) => {
84
46
  res.respond({
@@ -1,10 +1,10 @@
1
1
  import type { AclBuilder } from "@tulipes/core/acl";
2
2
 
3
3
  export default function usersAcl(acl: AclBuilder): void {
4
- acl.allow("user", "users:read", "users:ping");
4
+ acl.allow("user", "users:web:list", "users:web:ping");
5
5
  // The demo endpoints are open so the error-handling examples stay
6
6
  // curl-able without a token.
7
- acl.allow("guest", "users:demo-missing", "users:demo-boom", "users:ping");
8
- // Everything else — create, delete, read-deleted — stays with admin,
9
- // which already holds "*".
7
+ acl.allow("guest", "users:web:demo:*", "users:web:ping");
8
+ // The back office — users:bo:create, :delete, :list:deleted — stays with
9
+ // admin and sysadmin, whose users:* grant in the auth module covers it.
10
10
  }
@@ -16,7 +16,7 @@ export const jobs = {
16
16
  export default function usersQueues(ctx: Ctx, queues: QueueRegistry): void {
17
17
  // The same logger the backend uses. In the worker process it writes
18
18
  // to app-worker.log, so the two processes never share a file.
19
- const log = moduleLogger(ctx.Environment, "users", ctx.mode);
19
+ const log = moduleLogger(ctx, "users");
20
20
 
21
21
  queues.process("users.welcome", async (job) => {
22
22
  // No request here to negotiate a locale from, so the one the caller
@@ -0,0 +1,39 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+
4
+ import { BoUsersController } from "../controllers/bo.users.controllers.ts";
5
+ import { createBody, emailParam } from "../schemas/users.schemas.ts";
6
+
7
+ /**
8
+ * The users back office: every id is users:bo:…, so `users:bo:*` grants
9
+ * all of it and `tulipes routes --scope bo` lists it. Mounted under /users
10
+ * like the web routes beside it.
11
+ */
12
+ export default function boUsersRoutes(ctx: BackendCtx): void {
13
+ const { rai, routes } = ctx;
14
+ const router = Router();
15
+ const users = new BoUsersController(ctx);
16
+
17
+ router.post("/", rai({
18
+ id: "users:bo:create",
19
+ status: 201,
20
+ name: "Create a user",
21
+ description: "Adds the account and queues its welcome email",
22
+ body: createBody,
23
+ }), users.create());
24
+
25
+ router.get("/deleted", rai({
26
+ id: "users:bo:list:deleted",
27
+ name: "List soft-deleted users",
28
+ }), users.listDeleted());
29
+
30
+ router.delete("/:email", rai({
31
+ id: "users:bo:delete",
32
+ name: "Soft-delete a user",
33
+ description: "Flags the account; ordinary queries stop returning it",
34
+ params: emailParam,
35
+ }), users.softDelete());
36
+
37
+ // Deliberately outside the API prefix, to show a mount of its own.
38
+ routes.mount(router, "/users");
39
+ }
@@ -0,0 +1,45 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+ import { z } from "zod/v4";
4
+
5
+ import { WebUsersController } from "../controllers/web.users.controllers.ts";
6
+ import { listQuery, userShape } from "../schemas/users.schemas.ts";
7
+
8
+ /**
9
+ * The users endpoints a signed-in client calls. The back office's sit in
10
+ * bo.users.routes.ts beside this file, under the same mount: one file per
11
+ * scope, named scope-first.
12
+ */
13
+ export default function webUsersRoutes(ctx: BackendCtx): void {
14
+ const { rai, routes } = ctx;
15
+ const router = Router();
16
+ const users = new WebUsersController(ctx);
17
+
18
+ router.get("/", rai({
19
+ id: "users:web:list",
20
+ name: "List users",
21
+ description: "Page through accounts, clamped by the shared bounds",
22
+ // Validated on the way in, and described on the way out: one
23
+ // declaration, so the spec cannot drift from what is enforced.
24
+ query: listQuery,
25
+ returns: z.array(userShape),
26
+ }), users.list());
27
+
28
+ router.get("/ping", rai({
29
+ id: "users:web:ping",
30
+ name: "Users module status",
31
+ }), users.ping());
32
+
33
+ router.get("/missing", rai({
34
+ id: "users:web:demo:missing",
35
+ name: "HttpError demo",
36
+ }), users.demoMissing());
37
+
38
+ router.get("/boom", rai({
39
+ id: "users:web:demo:boom",
40
+ name: "Unhandled error demo",
41
+ }), users.demoBoom());
42
+
43
+ // Deliberately outside the API prefix, to show a mount of its own.
44
+ routes.mount(router, "/users");
45
+ }
@@ -18,7 +18,7 @@ server {
18
18
  root /var/www/html;
19
19
  }
20
20
 
21
- # Keep in step with the BODY_LIMIT variable. If nginx is stricter,
21
+ # Keep in step with the security.bodyLimit setting. If nginx is stricter,
22
22
  # oversized requests die here as an nginx HTML page instead of the
23
23
  # API's JSON 413.
24
24
  client_max_body_size 1m;
@@ -40,9 +40,10 @@ try {
40
40
  }
41
41
 
42
42
  async function deploy(): Promise<void> {
43
- const { appEnv } = handle.env;
43
+ const { appEnv } = handle.ctx;
44
44
  const domain = String(handle.ctx.config.core?.publicDomain ?? "localhost").trim();
45
- const port = Number(handle.env.get("PORT"));
45
+ // What app.config wired from PORT — the same port the backend listens on.
46
+ const port = Number(handle.ctx.config.server?.port ?? 3000);
46
47
 
47
48
  const templatePath = join(rootDir, "nginx", `${appEnv}.conf`);
48
49
  if (!existsSync(templatePath)) {
@@ -2,9 +2,10 @@ import { randomBytes } from "node:crypto";
2
2
 
3
3
  /**
4
4
  * What every module test boots the app with. The services are dedicated:
5
- * the suite writes users, sessions and settings. The variables are the
6
- * eleven the app declares; everything else the request pipeline needs is a
7
- * setting, written after the boot the way an operator writes it.
5
+ * the suite writes users, sessions and settings. The variables are the ones
6
+ * the core and auth modules declare, by their env names; everything else the
7
+ * request pipeline needs is a setting, written after the boot the way an
8
+ * operator writes it.
8
9
  */
9
10
  export function testEnv(): Record<string, string> {
10
11
  if (!process.env.TEST_MONGO_URI || !process.env.TEST_REDIS_URL) {
@@ -27,13 +27,13 @@ export default defineAgent({
27
27
  summary: "Greets callers and counts how often each greeting is used.",
28
28
  actions: [
29
29
  action({
30
- id: "hello:list-greetings",
30
+ id: "hello:web:greetings",
31
31
  description: "The stored greetings, with how often each was used",
32
32
  readOnly: true,
33
33
  handler: async (ctx) => (await findGreetings(ctx))[0],
34
34
  }),
35
35
  action({
36
- id: "hello:reset-counts",
36
+ id: "hello:bo:greetings:reset",
37
37
  description: "Reset the usage counter of every greeting to zero",
38
38
  input: z.object({ confirm: z.literal(true) }),
39
39
  handler: async (ctx) => {
@@ -50,7 +50,9 @@ no service or `.env` needed; `--role admin` shows what one role gets.
50
50
 
51
51
  ## Rules
52
52
 
53
- - **An action's id is a permission like any route's.** The ACL decides on
53
+ - **An action's id is a RAI like any route's**, `module:scope:resource[:extra]`:
54
+ the scope says who it is for (`hello:bo:greetings:reset` is a back-office
55
+ action). The ACL decides on
54
56
  it first (401 for a guest, 403 for a role without it), then the `input`
55
57
  schema validates (422 with field issues), then the handler runs with the
56
58
  runtime context, the parsed input and the actor (`req.auth` plus `role`).
@@ -65,7 +67,7 @@ no service or `.env` needed; `--role admin` shows what one role gets.
65
67
  text the model sees before choosing the tool. Say what the tool does, what
66
68
  it changes and what it returns; keep `readOnly: true` honest.
67
69
  - **One `agent/*.agent.ts` per module.** Tool names come from the id
68
- (`hello:reset-counts` → `hello_reset-counts`); a name two modules claim
70
+ (`hello:bo:greetings:reset` → `hello_bo_greetings_reset`); a name two modules claim
69
71
  is a boot error.
70
72
 
71
73
  ## An agent inside the app
@@ -26,13 +26,16 @@ problems the report has not reached yet.
26
26
  |---|---|---|
27
27
  | `environment` | `required variable "X" has no value` | Declared `required` but nothing supplies it. Add it to `.envs/.env.<APP_ENV>`, or give the spec a `default`. |
28
28
  | `environment` | `"abc" is not a number` / `not one of: …` | Value fails its declared `type`/`enum`. Fix the value or the spec. |
29
- | `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same name. Delete the duplicate; read the other module's variable instead. |
29
+ | `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same `key`. Delete the duplicate; read the other module's config instead. |
30
+ | `environment` | `config.x.name is both a setting and an environment value` | A `tulipes.env` name is also a field of that module's settings schema. Rename one; they share the namespace. |
31
+ | `environment` | `tulipes.env is an object keyed by config name since 0.12` | The 0.11 array form. Run `tulipes migrate 0.12`, which rewrites it and the reads. |
30
32
  | `module graph` | `dependsOn "x" — no such module exists` | Typo, or you used the package name. `dependsOn` takes short names (`"users"`). |
31
33
  | `module graph` | `dependency cycle: a → b → a` | Mutual dependency. Move the shared part into the core module or a third module. |
32
34
  | `module graph` | `sys module cannot depend on app module` | A sys module needs something app-tier. Either it is not really sys, or the dependency belongs in `lib/`. |
33
35
  | `modules` | `x does not belong at the module root` | Only `package.json`, `module.config.ts` and `module.acl.ts` live there; the message names the folder the file belongs in. |
34
- | `modules` | `tulipes.offline is gone` / `meta.variables.json moved to package.json` | The 0.10 forms. Delete the flag; move the variables under `tulipes.env`. |
35
- | `routes` | `routes function threw: ctx.models is not available during inspection` | A routes file or a controller constructor touched infrastructure while building. Move the read into the handler, or build the middleware with `lazy()`. |
36
+ | `modules` | `tulipes.offline is gone` / `meta.variables.json … moved to package.json` | The 0.10 forms. Delete the flag; run `tulipes migrate 0.11` then `0.12` for the variables. |
37
+ | `routes` | `routes function threw: ctx.models is not available during inspection` / `config.x.name is an environment value, not available during inspection` | A routes file or a controller constructor touched infrastructure or an environment value while building. Move the read into the handler, or build the middleware with `lazy()`. |
38
+ | `config` | `app.config returns "x", which is the config namespace of module "x"` | `config/app.config.ts` seeded a key named after a module; that namespace holds the module's settings and environment values. Rename the key. |
36
39
  | `config` | `module.config must default-export defineConfig({ schema })` | Missing/misspelled `export default`, or the 0.10 factory form: values now live in the settings store. |
37
40
  | `config` | `module.config exports "onReady"` | The 0.10 lifecycle exports. Initialization is a `bootstrap/*.bootstrap.ts` file, which may return its cleanup; long-lived work ends on `ctx.signal`. |
38
41
  | `config` | `field "x" has no default — every setting needs one` | Give the field a `.default()`; an operator is never asked for a value the process needs to boot. |
@@ -43,13 +46,13 @@ problems the report has not reached yet.
43
46
  | `providers` | `provider "…" cannot be resolved from …` | The declared specifier is not installed in the app. Install it (or fix the path). |
44
47
  | `providers` | `"@tulipes/core/db" no longer contains the Mongoose provider` | The app still declares the pre-extraction specifier. Install `@tulipes/mongoose`, declare it, and import `ModelDef`/`BootstrapFn` from it. |
45
48
  | `providers` | `[x] requires capability "queues" … declares no provider for it` | A module has a `queues/` file (or `requires: ["queues"]`) but the app's `package.json` has no `"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. Add that line. |
46
- | `providers` | `provider "bullmq" is selected but "REDIS_URL" is not declared/set` | Add the variable to the sys `core` module's meta file and set it. |
49
+ | `providers` | `provider "bullmq" is selected but config.bullmq.redisUrl is not set` | Wire it in `config/app.config.ts` — `bullmq: { redisUrl: env.core.redisUrl }` — from a variable the sys `core` module declares, and set that variable. |
47
50
  | `providers` | `"@tulipes/core/queues" no longer contains the BullMQ provider` | Install `@tulipes/bullmq`, declare it, and import `QueueRegistry`/`QueuesFn` from it. |
48
51
  | `providers` | `[x] requires capability "sockets" … declares no provider for it` | A module has a `sockets/` file (or `requires: ["sockets"]`) but the app's `package.json` has no `"tulipes": { "providers": { "sockets": "@tulipes/socket.io" } }`. Add that line. |
49
52
  | `providers` | `"@tulipes/core/sockets" no longer contains the Socket.IO provider` | Install `@tulipes/socket.io`, declare it, and import `SocketRegistry`/`SocketsFn` from it. |
50
53
  | `providers` | `provider "socket.io" failed to attach to the HTTP server` | The transport could not bind to the server core created; the message carries the driver's reason. |
51
- | `providers` | `provider "mongoose" is selected but "MONGO_URI" is not declared/set` | Add the variable to the sys `core` module's meta file and set it. |
52
- | `providers` | `database connection failed` | Mongo unreachable or `MONGO_URI` wrong. Check the service is running. |
54
+ | `providers` | `provider "mongoose" is selected but config.mongoose.uri is not set` | Wire it in `config/app.config.ts` — `mongoose: { uri: env.core.mongodbUri }` (or the host, user, pass and dbName parts) — from variables the sys `core` module declares, and set them. |
55
+ | `providers` | `database connection failed (config.mongoose.uri)` | Mongo unreachable or the URI wrong. Check the service is running and what `pnpm check` resolved. |
53
56
  | `providers` | `package.json "tulipes" key: providers — Unrecognized key` | Typo in the capability name; only `models` exists. |
54
57
  | `providers` | `queue name … is invalid` | Queue names use `.`, not `:`. |
55
58
  | `routes` | `must default-export a routes function` | Missing `export default` in a `*.routes.ts`. |
@@ -72,7 +75,10 @@ runs, check in this order:
72
75
 
73
76
  ## Other symptoms
74
77
 
75
- - **Types are `unknown` or a new variable is not typed** → run `pnpm sync`.
78
+ - **Types are `unknown` or a new environment value is not typed** → run `pnpm sync`.
79
+ - **`console.log(ctx.config.core)` does not show an environment value** →
80
+ by design; they are non-enumerable so a secret never reaches a log or a
81
+ response. `pnpm check` shows them.
76
82
  - **Worker exits immediately** → no processors registered; it refuses to
77
83
  idle. Add a `*.queues.ts` with `process()`.
78
84
  - **Env change has no effect** → `process.env` beats the file. Check for a
@@ -38,7 +38,9 @@ object is replaced in place when a write lands.
38
38
  defaults; the first boot writes them. There is no "required setting",
39
39
  because an operator cannot be asked for a value the process needs to boot.
40
40
  - **No secrets.** `defineConfig` refuses a field whose metadata says
41
- `secret`; a secret is an environment variable. → skill `tulipes-env-variable`
41
+ `secret`; a secret is an environment value, declared in the module's
42
+ package.json and read beside the settings at `ctx.config.<module>`, never
43
+ listed. → skill `tulipes-env-variable`
42
44
  - **Describe every field.** `.describe()` is what the console form, the
43
45
  generated types and an agent reading the manifest see.
44
46
  - **Validate in the schema, not in `onChange`.** The store persists before
@@ -57,12 +59,12 @@ object is replaced in place when a write lands.
57
59
 
58
60
  | | |
59
61
  |---|---|
60
- | `GET /config` | every module in load order: manifest, declared variables (names, types, sources — a secret's value never), settings with schema, live values and stored version, endpoints with the roles that reach them |
62
+ | `GET /config` | every module in load order: manifest, environment values (config name, variable, type, source — a secret's value never), settings with schema, live values and stored version, endpoints with the roles that reach them |
61
63
  | `GET /config/:module` | the same for one module |
62
64
  | `PATCH /config/:module` | `{ values: { … }, version? }` — a patch over the stored values, validated by the module's schema (422 with the field), refused when `version` is not the stored one (409 `STALE_VERSION`), audited before it lands, applied at once in every replica |
63
65
 
64
- `config:read` and `config:describe` are granted to `admin` and `sysadmin`;
65
- `config:write` to `sysadmin` only. The audit (`config_audit_events`) holds
66
+ `config:bo:read` and `config:bo:describe` are granted to `admin` and `sysadmin`;
67
+ `config:bo:write` to `sysadmin` only. The audit (`config_audit_events`) holds
66
68
  the namespace, the field names, the version the caller read, the actor and
67
69
  the client IP — never a value — and is acknowledged by a majority before
68
70
  the write happens: a change nobody can account for is worse than one that
@@ -9,14 +9,25 @@ Two files: a controller class holding the work, and a shared declaration file wi
9
9
  it up. Every route declares a RAI — a Resource Access Identifier — and the
10
10
  app refuses to boot if one is missing.
11
11
 
12
- `modules/billing/controllers/billing.controllers.ts`:
12
+ Name both files **scope-first**: the scope is the surface the endpoint
13
+ serves (`web`, `bo`, `mobile`), and it is also the second segment of the
14
+ endpoint's RAI. A convention, not a rule core checks, but it is what lets
15
+ anyone find the back office of a module by looking at its folder.
16
+
17
+ ```
18
+ routes/{scope}.{module}[.{extra}].routes.ts web.billing.routes.ts, bo.billing.refunds.routes.ts
19
+ controllers/{scope}.{module}[.{extra}].controllers.ts web.billing.controllers.ts → WebBillingController
20
+ routes/{scope}/{module}[.{extra}].routes.ts several files for one scope go in a folder
21
+ ```
22
+
23
+ `modules/billing/controllers/web.billing.controllers.ts`:
13
24
 
14
25
  ```ts
15
26
  import type { Request, RequestHandler, Response } from "express";
16
27
  import type { Ctx } from "@tulipes/core/boot";
17
28
  import { HttpError } from "@tulipes/core/http";
18
29
 
19
- export class BillingController {
30
+ export class WebBillingController {
20
31
  constructor(private readonly ctx: Ctx) {}
21
32
 
22
33
  show(): RequestHandler {
@@ -33,18 +44,18 @@ Each method **returns** a handler rather than being one, so the routes file
33
44
  reads as a table of contents and the class can keep private helpers beside
34
45
  the handlers that use them without threading ctx through every signature.
35
46
 
36
- `modules/billing/routes/billing.routes.ts`:
47
+ `modules/billing/routes/web.billing.routes.ts`:
37
48
 
38
49
  ```ts
39
50
  import { Router } from "express";
40
51
  import type { BackendCtx } from "@tulipes/core/boot";
41
52
 
42
- import { BillingController } from "../controllers/billing.controllers.js";
53
+ import { WebBillingController } from "../controllers/web.billing.controllers.ts";
43
54
 
44
55
  export default function billingRoutes(ctx: BackendCtx): Router {
45
56
  const router = Router();
46
- const billing = new BillingController(ctx);
47
- router.get("/:id", ctx.rai({ id: "billing:read", name: "Read an invoice" }), billing.show());
57
+ const billing = new WebBillingController(ctx);
58
+ router.get("/:id", ctx.rai({ id: "billing:web:invoice", name: "Read an invoice" }), billing.show());
48
59
  return router; // mounted under config.api.prefix
49
60
  }
50
61
  ```
@@ -57,15 +68,19 @@ child for inherited parameters). A file that only mounts global middleware
57
68
  on `ctx.app` returns nothing.
58
69
 
59
70
  **Build with the context, use infrastructure in handlers.** `tulipes routes`
60
- and `tulipes spec` build every router with nothing acquired: the environment
61
- is declared but unresolved and `ctx.models`, `ctx.queues` and `ctx.sockets`
62
- are stubs that throw. So a controller keeps `ctx` and reads `ctx.models`
63
- inside its handlers (as every controller in this app does), and a middleware
64
- that needs a variable or a client is built on its first request:
71
+ and `tulipes spec` build every router with nothing acquired: an environment
72
+ value (`ctx.config.<module>.<name>`) throws when read, and `ctx.models`,
73
+ `ctx.queues` and `ctx.sockets` are stubs that throw. So a controller keeps
74
+ `ctx` and reads `ctx.models` inside its handlers (as every controller in
75
+ this app does), and a middleware that needs an environment value or a
76
+ client is built on its first request:
65
77
 
66
78
  ```ts
67
- import { lazy } from "@app/core";
68
- app.use(lazy(() => express.json({ limit: String(Environment.get("BODY_LIMIT")) })));
79
+ import { appCache, lazy } from "@app/core";
80
+ app.use(lazy(() => {
81
+ const cache = appCache(ctx); // opens Redis from config.core.redisUrl
82
+ return (req, res, next) => { /* … */ next(); };
83
+ }));
69
84
  ```
70
85
 
71
86
  Use router.use for shared middleware, router.param for parameter callbacks, and
@@ -76,7 +91,10 @@ changes run the app tests, `tulipes routes` and `pnpm spec`; inspect generated d
76
91
 
77
92
  ## The RAI is not optional
78
93
 
79
- `id` names a resource and an access to it, and **is** the ACL permission.
94
+ `id` is `module:scope:resource[:extra]` — `billing:web:invoice`,
95
+ `billing:bo:invoice:refund` — and **is** the ACL permission. Leaving the
96
+ scope out (`billing:read`) is a type error and a boot error. → skill
97
+ `tulipes-permissions` for the format and the grants.
80
98
  One declaration identifies the endpoint, gates it, marks the route as
81
99
  declared, and binds `req.t` to the declaring module's i18n namespace —
82
100
  which is why the message above needs no namespace of its own. → skill
@@ -94,7 +112,8 @@ undeclared route is not merely undocumented, it is reachable by `guest`.
94
112
  Grant the id in `module.acl.ts` — to `guest` if the endpoint is public.
95
113
 
96
114
  `tulipes routes` lists every declared endpoint with the roles that reach it,
97
- without a service, a secret or a `.env`.
115
+ without a service, a secret or a `.env`; `tulipes routes --scope bo` lists one
116
+ surface.
98
117
 
99
118
  A route is never a tool: what the module offers to agents is declared apart,
100
119
  in `agent/<module>.agent.ts`, and shares the route's logic through `helpers/`.