@tulipes/cli 0.2.0-rc.2 → 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 (114) hide show
  1. package/dist/agent.d.ts +5 -5
  2. package/dist/config-check.js +1 -1
  3. package/dist/env-check.d.ts +5 -3
  4. package/dist/env-check.js +26 -19
  5. package/dist/env-check.js.map +1 -1
  6. package/dist/main.js +83 -19
  7. package/dist/main.js.map +1 -1
  8. package/dist/migrate-0.12-env.d.ts +36 -0
  9. package/dist/migrate-0.12-env.js +250 -0
  10. package/dist/migrate-0.12-env.js.map +1 -0
  11. package/dist/migrate-0.12-scaffold.d.ts +1 -0
  12. package/dist/migrate-0.12-scaffold.js +197 -0
  13. package/dist/migrate-0.12-scaffold.js.map +1 -0
  14. package/dist/migrate-0.12.d.ts +47 -0
  15. package/dist/migrate-0.12.js +210 -0
  16. package/dist/migrate-0.12.js.map +1 -0
  17. package/dist/migrate.d.ts +3 -1
  18. package/dist/migrate.js +44 -5
  19. package/dist/migrate.js.map +1 -1
  20. package/dist/new-module.d.ts +7 -0
  21. package/dist/new-module.js +35 -16
  22. package/dist/new-module.js.map +1 -1
  23. package/dist/roles.d.ts +15 -0
  24. package/dist/roles.js +54 -0
  25. package/dist/roles.js.map +1 -0
  26. package/dist/routes.d.ts +7 -2
  27. package/dist/routes.js +19 -10
  28. package/dist/routes.js.map +1 -1
  29. package/dist/spec.d.ts +2 -0
  30. package/dist/spec.js +13 -11
  31. package/dist/spec.js.map +1 -1
  32. package/dist/sync.js +66 -53
  33. package/dist/sync.js.map +1 -1
  34. package/dist/update.js +1 -1
  35. package/dist/update.js.map +1 -1
  36. package/package.json +4 -4
  37. package/templates/AGENTS.md +41 -29
  38. package/templates/app/.envs/.env.development +2 -1
  39. package/templates/app/.envs/.env.production +2 -2
  40. package/templates/app/.envs/.env.staging +2 -2
  41. package/templates/app/README.md +44 -29
  42. package/templates/app/config/app.config.ts +60 -7
  43. package/templates/app/docs/agent-manifest.json +8 -681
  44. package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +4 -5
  45. package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -6
  46. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
  47. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
  48. package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
  49. package/templates/app/modules/auth/helpers/passport.ts +1 -1
  50. package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
  51. package/templates/app/modules/auth/helpers/tokens.ts +3 -5
  52. package/templates/app/modules/auth/module.acl.ts +31 -9
  53. package/templates/app/modules/auth/module.config.ts +5 -3
  54. package/templates/app/modules/auth/package.json +10 -10
  55. package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
  56. package/templates/app/modules/auth/tests/auth.test.ts +1 -1
  57. package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
  58. package/templates/app/modules/config/helpers/audit.ts +1 -1
  59. package/templates/app/modules/config/helpers/describe.ts +35 -26
  60. package/templates/app/modules/config/module.acl.ts +1 -1
  61. package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
  62. package/templates/app/modules/config/tests/config.test.ts +14 -6
  63. package/templates/app/modules/core/helpers/cache/index.ts +8 -7
  64. package/templates/app/modules/core/helpers/lazy.ts +3 -3
  65. package/templates/app/modules/core/helpers/logger/index.ts +24 -16
  66. package/templates/app/modules/core/module.config.ts +5 -3
  67. package/templates/app/modules/core/package.json +46 -19
  68. package/templates/app/modules/core/routes/core.routes.ts +4 -4
  69. package/templates/app/modules/hello/agent/hello.agent.ts +18 -14
  70. package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
  71. package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +4 -8
  72. package/templates/app/modules/hello/helpers/greetings.ts +14 -0
  73. package/templates/app/modules/hello/module.acl.ts +12 -7
  74. package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
  75. package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
  76. package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
  77. package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
  78. package/templates/app/modules/security/routes/security.routes.ts +4 -4
  79. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
  80. package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
  81. package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
  82. package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -4
  83. package/templates/app/modules/settings/tests/settings.test.ts +21 -25
  84. package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
  85. package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
  86. package/templates/app/modules/users/module.acl.ts +4 -4
  87. package/templates/app/modules/users/queues/users.queues.ts +1 -1
  88. package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
  89. package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
  90. package/templates/app/nginx/production.conf +1 -1
  91. package/templates/app/scripts/deploy-nginx.ts +3 -2
  92. package/templates/app/tests/support.ts +4 -3
  93. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +49 -34
  94. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
  95. package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
  96. package/templates/claude/skills/tulipes-endpoint/SKILL.md +36 -19
  97. package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
  98. package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
  99. package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
  100. package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
  101. package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
  102. package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
  103. package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
  104. package/templates/minimal/config/app.config.ts +13 -3
  105. package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
  106. package/templates/minimal/modules/core/module.acl.ts +7 -1
  107. package/templates/minimal/modules/core/package.json +4 -4
  108. package/templates/minimal/modules/hello/module.acl.ts +1 -1
  109. package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
  110. package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
  111. package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
  112. package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
  113. package/templates/versions.json +8 -8
  114. package/templates/app/modules/users/routes/users.routes.ts +0 -60
@@ -1,5 +1,5 @@
1
1
  import { Redis } from "ioredis";
2
- import type { Environment } from "@tulipes/core/env";
2
+ import type { Ctx } from "@tulipes/core/boot";
3
3
 
4
4
  import { MemoryCache } from "./memory-cache.ts";
5
5
  import { RedisCache } from "./redis-cache.ts";
@@ -13,18 +13,19 @@ export { RedisCache } from "./redis-cache.ts";
13
13
  let active: { cache: Cache; redis?: Redis } | undefined;
14
14
 
15
15
  /**
16
- * The app's one cache, picked from the environment: redis when REDIS_URL is
17
- * declared, in-process memory otherwise (tests, minimal setups). Lazily
18
- * created, process-wide singleton.
16
+ * The app's one cache, picked from the core module's environment values:
17
+ * redis when `config.core.redisUrl` is set, in-process memory otherwise
18
+ * (tests, minimal setups). Lazily created, process-wide singleton.
19
19
  */
20
- export function appCache(environment: Environment): Cache {
20
+ export function appCache({ config }: Pick<Ctx, "config">): Cache {
21
21
  if (active) return active.cache;
22
22
 
23
- if (environment.store.has("REDIS_URL")) {
23
+ const redisUrl = config.core?.redisUrl;
24
+ if (redisUrl) {
24
25
  // Fail a command fast instead of queueing it and retrying for seconds: the
25
26
  // cache is consulted on every request, so a redis outage must cost a miss,
26
27
  // not a stalled request. RedisCache turns the rejection into that miss.
27
- const redis = new Redis(String(environment.get("REDIS_URL")), {
28
+ const redis = new Redis(redisUrl, {
28
29
  maxRetriesPerRequest: 1,
29
30
  enableOfflineQueue: false,
30
31
  });
@@ -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());
@@ -2,26 +2,30 @@ import { action, defineAgent } from "@tulipes/core/agent";
2
2
  import { requireModels } from "@tulipes/core/boot";
3
3
  import { z } from "zod/v4";
4
4
 
5
+ import { findGreetings } from "../helpers/greetings.ts";
6
+
5
7
  /**
6
- * What an agent sees of this module. Every route is a tool already — its
7
- * rai() gives the name, description, input schema and permission — so this
8
- * file adds what cannot be derived: the summary, which routes to expose,
9
- * and an action that is not an endpoint. A module without this file
10
- * exposes every route; `pnpm tulipes agent manifest` prints the result.
8
+ * What this module offers to agents — an in-app agent module reads it from
9
+ * `ctx.agent.manifest()`, Claude Code through `tulipes-mcp`. Routes are the
10
+ * API and are never tools: an action is declared here, with its own id (a
11
+ * permission granted in module.acl.ts), input schema and handler, and
12
+ * shares its logic with the routes through helpers/. `pnpm tulipes agent
13
+ * manifest` prints the result.
11
14
  */
12
15
  export default defineAgent({
13
16
  summary: "Greets callers and counts how often each greeting is used.",
14
- tools: [
15
- { route: "hello:greet", description: "A greeting in the caller's language" },
16
- { route: "hello:greet-name" },
17
- { route: "hello:list-greetings", description: "The stored greetings, with how often each was used" },
18
- { route: "hello:use-greeting" },
17
+ actions: [
18
+ action({
19
+ id: "hello:web:greetings",
20
+ description: "The stored greetings, with how often each was used",
21
+ readOnly: true,
22
+ handler: async (ctx) => (await findGreetings(ctx))[0],
23
+ }),
19
24
 
20
- // An action: the id is a permission like any route's, granted to
21
- // admin through hello:* in the auth module's ACL. The ACL decides,
22
- // then the schema validates, then this runs.
25
+ // The ACL decides on the id (admin holds hello:* in the auth module's
26
+ // ACL), then the schema validates, then this runs.
23
27
  action({
24
- id: "hello:reset-counts",
28
+ id: "hello:bo:greetings:reset",
25
29
  description: "Reset the usage counter of every greeting to zero",
26
30
  input: z.object({ confirm: z.literal(true).describe("Set to true to confirm the reset") }),
27
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,7 +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
- export class GreetingsController {
5
+ import { findGreetings } from "../../helpers/greetings.ts";
6
+
7
+ export class HelloGreetingsController {
6
8
  readonly #ctx: Ctx;
7
9
 
8
10
  constructor(ctx: Ctx) {
@@ -14,13 +16,7 @@ export class GreetingsController {
14
16
  // ?page and ?per_page, already clamped by config.pagination — so
15
17
  // every list endpoint in every module paginates the same way.
16
18
  const { per_page, skip } = req.page;
17
- const Greeting = requireModels(this.#ctx).get("Greeting");
18
-
19
- const [greetings, total_items] = await Promise.all([
20
- Greeting.find().select("name message times_used -_id")
21
- .skip(skip).limit(per_page).lean(),
22
- Greeting.countDocuments(),
23
- ]);
19
+ const [greetings, total_items] = await findGreetings(this.#ctx, { skip, limit: per_page });
24
20
 
25
21
  // Passing total_items is what marks this a page: respond fills in
26
22
  // page, per_page and total_pages from the request and the count.
@@ -0,0 +1,14 @@
1
+ import { requireModels, type Ctx } from "@tulipes/core/boot";
2
+
3
+ /**
4
+ * One query, two callers: the list route pages it, the agent action reads
5
+ * the first page. Logic a route and an action share lives here, so neither
6
+ * calls the other.
7
+ */
8
+ export function findGreetings(ctx: Ctx, { skip = 0, limit = 20 } = {}) {
9
+ const Greeting = requireModels(ctx).get("Greeting");
10
+ return Promise.all([
11
+ Greeting.find().select("name message times_used -_id").skip(skip).limit(limit).lean(),
12
+ Greeting.countDocuments(),
13
+ ]);
14
+ }
@@ -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,18 +13,17 @@ 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",
25
25
  query: logQuery,
26
26
  // A stream is not a tool: it never ends on its own.
27
- agent: false,
28
27
  }),
29
28
  settings.logStream(),
30
29
  );