@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
@@ -3,9 +3,9 @@ import type { RequestHandler } from "express";
3
3
  /**
4
4
  * Middleware built on its first request rather than while the routes
5
5
  * file runs. Inspection (`tulipes routes`, `tulipes spec`) builds every
6
- * router without resolving the environment or acquiring a service, so a
7
- * middleware that reads a variable or opens a client is built here, once,
8
- * when a real request arrives.
6
+ * router without acquiring a service, and an environment value read while
7
+ * building throws there — so a middleware that reads one or opens a client
8
+ * is built here, once, when a real request arrives.
9
9
  */
10
10
  export function lazy(build: () => RequestHandler): RequestHandler {
11
11
  let handler: RequestHandler | undefined;
@@ -3,8 +3,8 @@ import { join } from "node:path";
3
3
  // namespace that only the default export carries.
4
4
  import pino from "pino";
5
5
  import type { Logger, TransportTargetOptions } from "pino";
6
- import type { ProcessMode } from "@tulipes/core/boot";
7
- import type { Environment } from "@tulipes/core/env";
6
+ import type { Ctx } from "@tulipes/core/boot";
7
+ import { toZonedISO } from "@tulipes/core/time";
8
8
 
9
9
  /**
10
10
  * The app's logger, shared by every module.
@@ -24,7 +24,7 @@ let instance: Logger | undefined;
24
24
 
25
25
  /**
26
26
  * How the files are named and rotated. These never needed to differ per
27
- * deployment, so they are constants rather than environment variables;
27
+ * deployment, so they are constants rather than environment values;
28
28
  * the settings module's log stream reads the same names.
29
29
  */
30
30
  export const LOG_FILES = Object.freeze({
@@ -40,13 +40,16 @@ export const LOG_FILES = Object.freeze({
40
40
  retentionFiles: 14,
41
41
  });
42
42
 
43
- export function logger(environment: Environment, mode: ProcessMode = "backend"): Logger {
43
+ export function logger({ config, mode }: Pick<Ctx, "config" | "mode">): Logger {
44
44
  if (instance) return instance;
45
45
 
46
- const level = String(environment.get("LOG_LEVEL"));
46
+ // The destinations are the core module's environment values, bound at
47
+ // boot from LOG_LEVEL, LOG_CONSOLE, LOG_FILE and LOG_DIR — each with a
48
+ // default, so they are there whenever the app runs.
49
+ const { logLevel: level, logConsole, logFile, logDir } = config.core!;
47
50
  const targets: TransportTargetOptions[] = [];
48
51
 
49
- if (Boolean(environment.get("LOG_CONSOLE"))) {
52
+ if (logConsole) {
50
53
  targets.push({
51
54
  target: "pino-pretty",
52
55
  level,
@@ -61,8 +64,8 @@ export function logger(environment: Environment, mode: ProcessMode = "backend"):
61
64
  });
62
65
  }
63
66
 
64
- if (Boolean(environment.get("LOG_FILE"))) {
65
- const dir = String(environment.get("LOG_DIR"));
67
+ if (logFile) {
68
+ const dir = logDir;
66
69
  const rotation = {
67
70
  // Both limits apply: whichever trips first rotates the file.
68
71
  frequency: LOG_FILES.rotationFrequency,
@@ -92,11 +95,20 @@ export function logger(environment: Environment, mode: ProcessMode = "backend"):
92
95
  });
93
96
  }
94
97
 
98
+ // Every line's time is written in the process zone with its offset —
99
+ // "2026-10-13T15:00:00.000+01:00" — so a file reads in the app's local
100
+ // time and still sorts and parses as one exact instant. Core moves the
101
+ // process onto config.timezone.default before bootstrap creates this
102
+ // logger, which is also what the console's SYS: times and the daily
103
+ // rotation above follow.
104
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
105
+ const options = { level, base: { mode }, timestamp: () => `,"time":"${toZonedISO(Date.now(), zone)}"` };
106
+
95
107
  instance = targets.length
96
- ? pino({ level, base: { mode } }, pino.transport({ targets }))
108
+ ? pino(options, pino.transport({ targets }))
97
109
  : // Every destination disabled: still return a working logger rather
98
110
  // than null-checking at a hundred call sites.
99
- pino({ level, base: { mode } });
111
+ pino(options);
100
112
 
101
113
  return instance;
102
114
  }
@@ -106,10 +118,6 @@ export function logger(environment: Environment, mode: ProcessMode = "backend"):
106
118
  * visible in both destinations: `[users] ready` on the console, and a
107
119
  * queryable `"module":"users"` field in the file.
108
120
  */
109
- export function moduleLogger(
110
- environment: Environment,
111
- name: string,
112
- mode: ProcessMode = "backend",
113
- ): Logger {
114
- return logger(environment, mode).child({ module: name });
121
+ export function moduleLogger(ctx: Pick<Ctx, "config" | "mode">, name: string): Logger {
122
+ return logger(ctx).child({ module: name });
115
123
  }
@@ -12,9 +12,11 @@ import { registerBasePlugins } from "./helpers/mongoose/index.ts";
12
12
  registerBasePlugins();
13
13
 
14
14
  /**
15
- * The sys core module's own settings. The environment keeps only what boot
16
- * needs before the store answers (the port, the connection strings, the log
17
- * destinations) and the secrets; everything else is a setting like this one.
15
+ * The sys core module's own settings. Its environment values (package.json
16
+ * `tulipes.env`) keep only what boot needs before the store answers — the
17
+ * port, the connection strings, the log destinations — and the secrets, and
18
+ * land beside these as `config.core.port`, `config.core.redisUrl`…;
19
+ * everything else is a setting like this one.
18
20
  */
19
21
  export default defineConfig({
20
22
  schema: z.object({
@@ -6,30 +6,57 @@
6
6
  "tulipes": {
7
7
  "tier": "sys",
8
8
  "priority": 0,
9
- "env": [
10
- {
11
- "name": "PORT",
9
+ "env": {
10
+ "port": {
11
+ "key": "PORT",
12
12
  "type": "number",
13
13
  "group": "http",
14
14
  "description": "HTTP port the backend process listens on",
15
15
  "default": 3000
16
16
  },
17
- {
18
- "name": "MONGO_URI",
17
+ "mongodbUri": {
18
+ "key": "MONGO_URI",
19
19
  "type": "url",
20
20
  "group": "database",
21
- "required": true,
22
- "description": "MongoDB connection string"
21
+ "description": "Full MongoDB connection string; when set it wins over the parts below (the only way to name a replica set, an Atlas mongodb+srv:// cluster or driver options)"
22
+ },
23
+ "mongodbHost": {
24
+ "key": "MONGO_HOST",
25
+ "type": "url",
26
+ "group": "database",
27
+ "description": "MongoDB host as a URI without credentials or database: mongodb://host:port",
28
+ "default": "mongodb://localhost:27017"
29
+ },
30
+ "mongodbUsername": {
31
+ "key": "MONGO_USERNAME",
32
+ "type": "string",
33
+ "group": "database",
34
+ "description": "MongoDB username; empty for an unauthenticated server",
35
+ "default": ""
36
+ },
37
+ "mongodbPassword": {
38
+ "key": "MONGO_PASSWORD",
39
+ "type": "secret",
40
+ "group": "database",
41
+ "description": "MongoDB password",
42
+ "default": ""
43
+ },
44
+ "mongodbDbName": {
45
+ "key": "MONGO_DBNAME",
46
+ "type": "string",
47
+ "group": "database",
48
+ "description": "MongoDB database name",
49
+ "default": "tulipes_example"
23
50
  },
24
- {
25
- "name": "REDIS_URL",
51
+ "redisUrl": {
52
+ "key": "REDIS_URL",
26
53
  "type": "url",
27
54
  "group": "redis",
28
55
  "required": true,
29
- "description": "Redis connection string (queues, cache)"
56
+ "description": "Redis connection string (queues, cache, rate limits)"
30
57
  },
31
- {
32
- "name": "LOG_LEVEL",
58
+ "logLevel": {
59
+ "key": "LOG_LEVEL",
33
60
  "type": "enum",
34
61
  "enum": [
35
62
  "fatal",
@@ -43,28 +70,28 @@
43
70
  "description": "Lowest level that reaches any destination",
44
71
  "default": "info"
45
72
  },
46
- {
47
- "name": "LOG_CONSOLE",
73
+ "logConsole": {
74
+ "key": "LOG_CONSOLE",
48
75
  "type": "boolean",
49
76
  "group": "logging",
50
77
  "description": "Pretty coloured logs on stdout; turn off where a collector reads files only",
51
78
  "default": true
52
79
  },
53
- {
54
- "name": "LOG_FILE",
80
+ "logFile": {
81
+ "key": "LOG_FILE",
55
82
  "type": "boolean",
56
83
  "group": "logging",
57
84
  "description": "Write JSON-line log files under LOG_DIR",
58
85
  "default": true
59
86
  },
60
- {
61
- "name": "LOG_DIR",
87
+ "logDir": {
88
+ "key": "LOG_DIR",
62
89
  "type": "string",
63
90
  "group": "logging",
64
91
  "description": "Directory for log files, relative to the app root or absolute",
65
92
  "default": "logs"
66
93
  }
67
- ]
94
+ }
68
95
  },
69
96
  "dependencies": {
70
97
  "@tulipes/core": "workspace:*",
@@ -9,11 +9,11 @@ import { lazy } from "../helpers/lazy.ts";
9
9
  * The sys core module loads first, so what it mounts on `ctx.app` runs on
10
10
  * every request of every later module: this is the global middleware slot.
11
11
  */
12
- export default function coreRoutes({ app, Environment }: BackendCtx): void {
12
+ export default function coreRoutes(ctx: BackendCtx): void {
13
13
  // The cache client opens on first use; built on the first request so
14
- // inspection never touches Redis.
15
- app.use(lazy(() => {
16
- const cache = appCache(Environment);
14
+ // inspection never touches Redis, nor reads the URL it would need.
15
+ ctx.app.use(lazy(() => {
16
+ const cache = appCache(ctx);
17
17
  return (async (_req, res, next) => {
18
18
  res.setHeader("X-Request-Id", randomUUID());
19
19
  const served = await cache.wrap("stats:served-sample", 10, async () => Date.now());
@@ -16,7 +16,7 @@ export default defineAgent({
16
16
  summary: "Greets callers and counts how often each greeting is used.",
17
17
  actions: [
18
18
  action({
19
- id: "hello:list-greetings",
19
+ id: "hello:web:greetings",
20
20
  description: "The stored greetings, with how often each was used",
21
21
  readOnly: true,
22
22
  handler: async (ctx) => (await findGreetings(ctx))[0],
@@ -25,7 +25,7 @@ export default defineAgent({
25
25
  // The ACL decides on the id (admin holds hello:* in the auth module's
26
26
  // ACL), then the schema validates, then this runs.
27
27
  action({
28
- id: "hello:reset-counts",
28
+ id: "hello:bo:greetings:reset",
29
29
  description: "Reset the usage counter of every greeting to zero",
30
30
  input: z.object({ confirm: z.literal(true).describe("Set to true to confirm the reset") }),
31
31
  handler: async (ctx) => {
@@ -1,7 +1,7 @@
1
1
  import type { Request, RequestHandler, Response } from "express";
2
2
  import type { Ctx } from "@tulipes/core/boot";
3
3
 
4
- import { buildGreeting } from "../helpers/greeting.ts";
4
+ import { buildGreeting } from "../../helpers/greeting.ts";
5
5
 
6
6
  /**
7
7
  * Controllers hold the work so routes stay a table of contents. The
@@ -2,9 +2,9 @@ import type { Request, RequestHandler, Response } from "express";
2
2
  import { requireModels, requireQueues, type Ctx } from "@tulipes/core/boot";
3
3
  import { HttpError } from "@tulipes/core/http";
4
4
 
5
- import { findGreetings } from "../helpers/greetings.ts";
5
+ import { findGreetings } from "../../helpers/greetings.ts";
6
6
 
7
- export class GreetingsController {
7
+ export class HelloGreetingsController {
8
8
  readonly #ctx: Ctx;
9
9
 
10
10
  constructor(ctx: Ctx) {
@@ -1,16 +1,21 @@
1
1
  import type { AclBuilder } from "@tulipes/core/acl";
2
2
 
3
3
  /**
4
- * Roles are global and defined once by the sys core module; a feature
5
- * module only attaches grants, and always on resources namespaced by
6
- * its own name ("hello:*"). Granting the same role+resource from two
7
- * modules crashes the boot.
4
+ * Roles are global and defined once by the auth module; a feature module
5
+ * only attaches grants, and always on its own RAIs ("hello:…"). Granting
6
+ * the same role+resource from two modules crashes the boot.
7
+ *
8
+ * A grant ending in `:*` covers everything under it, the bare resource
9
+ * included: `hello:web:greet:*` reaches `hello:web:greet` and
10
+ * `hello:web:greet:by-name`.
8
11
  */
9
12
  export default function helloAcl(acl: AclBuilder): void {
10
13
  // Public: the greeting endpoints are granted to guest, which is
11
14
  // how an endpoint is made public — there is no "skip auth" flag.
12
- acl.allow("guest", "hello:greet", "hello:greet-name", "hello:demo-missing");
15
+ acl.allow("guest", "hello:web:greet:*", "hello:web:demo:*");
13
16
 
14
- // Signed in: the secret needs a real role.
15
- acl.allow("user", "hello:read", "hello:list-greetings", "hello:use-greeting");
17
+ // Signed in: the secret, and listing and using greetings. The reset
18
+ // action is `hello:bo:greetings:reset`, which only admin's `hello:*`
19
+ // reaches.
20
+ acl.allow("user", "hello:web:secret", "hello:web:greetings:*");
16
21
  }
@@ -25,7 +25,7 @@ export default function helloQueues(ctx: Ctx, queues: QueueRegistry): void {
25
25
  const { models } = ctx;
26
26
  // The same logger the backend uses. In the worker process it writes
27
27
  // to app-worker.log, so the two processes never share a file.
28
- const log = moduleLogger(ctx.Environment, "hello", ctx.mode);
28
+ const log = moduleLogger(ctx, "hello");
29
29
 
30
30
  queues.process("hello.count-greeting", async (job) => {
31
31
  // Jobs retry, so processors must be idempotent-safe. Pass ids in
@@ -1,21 +1,21 @@
1
1
  import { Router } from "express";
2
2
  import type { BackendCtx } from "@tulipes/core/boot";
3
3
 
4
- import { GreetingsController } from "../controllers/greetings.controllers.ts";
4
+ import { HelloGreetingsController } from "../../controllers/web/hello.greetings.controllers.ts";
5
5
 
6
6
  export default function greetingsRoutes(ctx: BackendCtx): Router {
7
7
  const { rai } = ctx;
8
8
  const router = Router();
9
- const greetings = new GreetingsController(ctx);
9
+ const greetings = new HelloGreetingsController(ctx);
10
10
 
11
11
  router.get("/greetings", rai({
12
- id: "hello:list-greetings",
12
+ id: "hello:web:greetings",
13
13
  name: "List greetings",
14
14
  description: "Reads the seeded rows, clamped by the shared bounds",
15
15
  }), greetings.list());
16
16
 
17
17
  router.post("/greetings/:name/use", rai({
18
- id: "hello:use-greeting",
18
+ id: "hello:web:greetings:use",
19
19
  status: 202,
20
20
  name: "Use a greeting",
21
21
  description: "Queues a job that counts the use",
@@ -2,14 +2,20 @@ import { Router } from "express";
2
2
  import type { BackendCtx } from "@tulipes/core/boot";
3
3
  import { HttpError } from "@tulipes/core/http";
4
4
 
5
- import { HelloController } from "../controllers/hello.controllers.ts";
5
+ import { HelloController } from "../../controllers/web/hello.controllers.ts";
6
6
 
7
7
  /**
8
+ * This module has two routes files for one scope, so they sit in a folder
9
+ * named after it: routes/web/hello.routes.ts and
10
+ * routes/web/hello.greetings.routes.ts. One file per scope would instead be
11
+ * named scope-first at the top of routes/ (web.hello.routes.ts), as the
12
+ * users module does. Neither is enforced; core reads every *.routes.ts.
13
+ *
8
14
  * A routes file is a plain function of the context. It returns a Router,
9
15
  * which core mounts under `config.api.prefix`, and declares a RAI on every
10
16
  * endpoint: the id is the permission, and a route without one refuses the
11
- * boot. Build the router from the context; read models, settings and the
12
- * environment inside handlers, where they are live.
17
+ * boot. Build the router from the context; read models and config inside
18
+ * handlers, where they are live.
13
19
  */
14
20
  export default function helloRoutes(ctx: BackendCtx): Router {
15
21
  const { rai } = ctx;
@@ -17,24 +23,24 @@ export default function helloRoutes(ctx: BackendCtx): Router {
17
23
  const hello = new HelloController(ctx);
18
24
 
19
25
  router.get("/hello", rai({
20
- id: "hello:greet",
26
+ id: "hello:web:greet",
21
27
  name: "Greet",
22
28
  description: "Public greeting, granted to guest",
23
29
  }), hello.greet());
24
30
 
25
31
  router.get("/hello/secret", rai({
26
- id: "hello:read",
32
+ id: "hello:web:secret",
27
33
  name: "Read the secret",
28
- description: "Requires a role granted hello:read",
34
+ description: "Requires a role granted hello:web:secret",
29
35
  }), hello.secret());
30
36
 
31
37
  router.get("/hello/:name", rai({
32
- id: "hello:greet-name",
38
+ id: "hello:web:greet:by-name",
33
39
  name: "Greet by name",
34
40
  }), hello.byName());
35
41
 
36
42
  router.get("/hello/demo/missing", rai({
37
- id: "hello:demo-missing",
43
+ id: "hello:web:demo:missing",
38
44
  name: "HttpError demo",
39
45
  }), async () => {
40
46
  throw new HttpError(404, "nothing here", { details: { hint: "demo endpoint" } });
@@ -62,8 +62,9 @@ export function rebuildRateLimits(ctx: Ctx, settings: RateLimitSettings): { limi
62
62
  const next = {
63
63
  settings: { ...settings },
64
64
  limits: createRateLimits({
65
- redisUrl: String(ctx.Environment.get("REDIS_URL")),
66
- namespace: settings.namespace + ":" + ctx.Environment.appEnv,
65
+ // The core module's environment value; the app refuses to boot without it.
66
+ redisUrl: ctx.config.core!.redisUrl,
67
+ namespace: settings.namespace + ":" + ctx.appEnv,
67
68
  windowMs: settings.windowMs,
68
69
  authLimit: settings.auth,
69
70
  sessionLimit: settings.session,
@@ -15,11 +15,11 @@ import { configuredRateLimits } from "../helpers/rate-limit.ts";
15
15
  * current values (cheap, and a write lands at once), the proxy policy is
16
16
  * set here and again by module.config's onChange, and the rate limiter is
17
17
  * rebuilt there, so the handlers below always delegate to the current set.
18
- * The logger reads the environment, which inspection does not resolve, so
19
- * it is built on its first request.
18
+ * The logger reads the core module's environment values, which inspection
19
+ * refuses, so it is built on its first request.
20
20
  */
21
21
  export default function securityRoutes(ctx: BackendCtx): void {
22
- const { app, Environment, mode, config } = ctx;
22
+ const { app, config } = ctx;
23
23
  const prefix = String(config.api?.prefix ?? "");
24
24
  const security = () => config.security!;
25
25
 
@@ -27,7 +27,7 @@ export default function securityRoutes(ctx: BackendCtx): void {
27
27
  app.use((req, res, next) => helmet(helmetOptions(security()))(req, res, next));
28
28
  app.use((req, res, next) => cors(corsOptions(security()))(req, res, next));
29
29
  app.use(lazy(() => pinoHttp({
30
- logger: logger(Environment, mode),
30
+ logger: logger(ctx),
31
31
  customLogLevel: (_req, res, err) =>
32
32
  err || res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info",
33
33
  genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),
@@ -7,6 +7,6 @@ import { configuredLogStreams } from "../helpers/log-stream.ts";
7
7
  * aborts the moment shutdown begins, before HTTP stops accepting, so the
8
8
  * streams end while the server is still draining ordinary requests.
9
9
  */
10
- export default function logStreams({ Environment, signal }: RuntimeCtx): void {
11
- signal.addEventListener("abort", () => configuredLogStreams(Environment).drain(), { once: true });
10
+ export default function logStreams(ctx: RuntimeCtx): void {
11
+ ctx.signal.addEventListener("abort", () => configuredLogStreams(ctx).drain(), { once: true });
12
12
  }
@@ -13,19 +13,20 @@ import { configuredLogStreams, type LogStreams } from "../helpers/log-stream.ts"
13
13
  * while the stream is open, since a stream outlives the request that
14
14
  * opened it.
15
15
  */
16
- export class SettingsController {
16
+ export class BoSettingsController {
17
17
  readonly #logStreams: LogStreams;
18
18
  readonly #ctx: Ctx;
19
19
 
20
20
  constructor(ctx: Ctx) {
21
-
22
21
  this.#ctx = ctx;
23
- this.#logStreams = configuredLogStreams(ctx.Environment);
22
+ this.#logStreams = configuredLogStreams(ctx);
24
23
  }
25
24
 
26
25
  logStream(): RequestHandler {
27
26
  return async (req: Request, res: Response) => {
28
- if (!Boolean(this.#ctx.Environment.get("LOG_FILE"))) {
27
+ // Where the core module's logger writes, from its environment values.
28
+ const { logFile, logDir } = this.#ctx.config.core!;
29
+ if (!logFile) {
29
30
  throw new HttpError(409, "fileLoggingDisabled", {
30
31
  code: "FILE_LOGGING_DISABLED",
31
32
  defaultMessage: "LOG_FILE is off — there are no log files to stream",
@@ -39,7 +40,7 @@ export class SettingsController {
39
40
 
40
41
  const session = req.auth.session;
41
42
  await this.#logStreams.open(res, {
42
- authorize: createAccessCheck(this.#ctx, req.auth, "settings:logs"),
43
+ authorize: createAccessCheck(this.#ctx, req.auth, "settings:bo:logs"),
43
44
  userId: req.auth.user ? String(req.auth.user._id) : "",
44
45
  expiresAt: session
45
46
  ? Math.min(
@@ -47,7 +48,7 @@ export class SettingsController {
47
48
  session.expires_at.getTime(),
48
49
  )
49
50
  : 0,
50
- dir: String(this.#ctx.Environment.get("LOG_DIR")),
51
+ dir: logDir,
51
52
  prefix: `${LOG_FILES.basename}-${which}`,
52
53
  process: which,
53
54
  tail,
@@ -1,5 +1,5 @@
1
1
  import type { Response } from "express";
2
- import type { Environment } from "@tulipes/core/env";
2
+ import type { Ctx } from "@tulipes/core/boot";
3
3
  import { HttpError } from "@tulipes/core/http";
4
4
  import { AccessLease } from "./access-lease.ts";
5
5
  import { tailLogs, type Tail } from "./log-tail.ts";
@@ -37,14 +37,15 @@ const LEVELS: Record<string, number> = {
37
37
  fatal: 60,
38
38
  };
39
39
 
40
- const instances = new WeakMap<Environment, LogStreams>();
40
+ /** One manager per boot: the context object is what a boot has exactly one of. */
41
+ const instances = new WeakMap<Ctx, LogStreams>();
41
42
 
42
43
  /** Keep the stopped instance until the boot is collected; late requests cannot reopen it. */
43
- export function configuredLogStreams(environment: Environment): LogStreams {
44
- let streams = instances.get(environment);
44
+ export function configuredLogStreams(ctx: Ctx): LogStreams {
45
+ let streams = instances.get(ctx);
45
46
  if (!streams) {
46
47
  streams = new LogStreams();
47
- instances.set(environment, streams);
48
+ instances.set(ctx, streams);
48
49
  }
49
50
  return streams;
50
51
  }
@@ -1,7 +1,7 @@
1
1
  import { Router } from "express";
2
2
  import type { BackendCtx } from "@tulipes/core/boot";
3
3
 
4
- import { SettingsController } from "../controllers/settings.controllers.ts";
4
+ import { BoSettingsController } from "../controllers/bo.settings.controllers.ts";
5
5
  import { logQuery } from "../schemas/settings.schemas.ts";
6
6
 
7
7
  /**
@@ -13,12 +13,12 @@ import { logQuery } from "../schemas/settings.schemas.ts";
13
13
  export default function settingsRoutes(ctx: BackendCtx): void {
14
14
  const { rai, routes, config } = ctx;
15
15
  const router = Router();
16
- const settings = new SettingsController(ctx);
16
+ const settings = new BoSettingsController(ctx);
17
17
 
18
18
  router.get(
19
19
  "/logs/stream",
20
20
  rai({
21
- id: "settings:logs",
21
+ id: "settings:bo:logs",
22
22
  name: "Stream logs",
23
23
  description: "Server-Sent Events over the log files — backend or worker",
24
24
  folder: "settings",
@@ -1,4 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { readdir, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
2
6
  import { fileURLToPath } from "node:url";
3
7
  import { z } from "zod/v4";
4
8
  import { afterAll, beforeAll, expect, test, vi } from "vitest";
@@ -8,7 +12,15 @@ import { useTestApp } from "@tulipes/testing";
8
12
  import { testEnv, testSettings } from "../../../tests/support.ts";
9
13
 
10
14
  const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
- const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
15
+
16
+ /**
17
+ * Where this boot's logger writes and the stream reads: environment values
18
+ * are bound once at boot, so the file logging the stream needs is on for
19
+ * the whole file, into a directory the suite owns and empties per test.
20
+ */
21
+ const logDir = mkdtempSync(join(tmpdir(), "tulipes-log-http-"));
22
+ const app = useTestApp({ rootDir, env: { ...testEnv(), LOG_FILE: "true", LOG_DIR: logDir }, settings: testSettings() }, { beforeAll, afterAll });
23
+ afterAll(() => rm(logDir, { recursive: true, force: true }));
12
24
 
13
25
 
14
26
  const tokens = z.object({
@@ -59,30 +71,16 @@ async function sysadmin() {
59
71
  return user;
60
72
  }
61
73
 
74
+ /** `run` sees the log directory empty: one test's files never serve another's stream. */
62
75
  async function logFiles(run: (dir: string) => Promise<void>) {
63
- const { mkdtemp, rm } = await import("node:fs/promises");
64
- const { tmpdir } = await import("node:os");
65
- const { join } = await import("node:path");
66
- const dir = await mkdtemp(join(tmpdir(), "tulipes-log-http-"));
67
- const env = app.handle.ctx.Environment;
68
-
69
- const previous = {
70
- file: env.get("LOG_FILE"),
71
- dir: env.get("LOG_DIR"),
76
+ const empty = async () => {
77
+ for (const entry of await readdir(logDir)) await rm(join(logDir, entry), { recursive: true, force: true });
72
78
  };
73
-
74
- env.store.set("LOG_FILE", true);
75
-
76
- env.store.set("LOG_DIR", dir);
77
-
79
+ await empty();
78
80
  try {
79
- await run(dir);
81
+ await run(logDir);
80
82
  } finally {
81
- env.store.set("LOG_FILE", previous.file);
82
-
83
- env.store.set("LOG_DIR", previous.dir);
84
-
85
- await rm(dir, { recursive: true, force: true });
83
+ await empty();
86
84
  }
87
85
  }
88
86
 
@@ -378,10 +376,8 @@ test("shutdown drains settings streams and a later boot owns fresh stream admiss
378
376
  });
379
377
 
380
378
  try {
381
- next.env.store.set("LOG_FILE", true);
382
-
383
- next.env.store.set("LOG_DIR", dir);
384
-
379
+ // LOG_FILE and LOG_DIR are still set on the process, so this boot
380
+ // reads the same directory.
385
381
  const address = next.server!.address();
386
382
 
387
383
  if (!address || typeof address === "string")