@tulipes/cli 0.1.1 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +10 -7
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js +29 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/app-core.d.ts +8 -6
  6. package/dist/app-core.js +23 -14
  7. package/dist/app-core.js.map +1 -1
  8. package/dist/bin.js +3 -3
  9. package/dist/bin.js.map +1 -1
  10. package/dist/config-check.d.ts +8 -0
  11. package/dist/config-check.js +43 -0
  12. package/dist/config-check.js.map +1 -0
  13. package/dist/config-import.d.ts +20 -0
  14. package/dist/config-import.js +133 -0
  15. package/dist/config-import.js.map +1 -0
  16. package/dist/dev.d.ts +4 -4
  17. package/dist/dev.js +7 -10
  18. package/dist/dev.js.map +1 -1
  19. package/dist/init.d.ts +6 -11
  20. package/dist/init.js +11 -6422
  21. package/dist/init.js.map +1 -1
  22. package/dist/inspection.d.ts +9 -8
  23. package/dist/inspection.js +10 -11
  24. package/dist/inspection.js.map +1 -1
  25. package/dist/main.js +82 -11
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrate.d.ts +30 -0
  28. package/dist/migrate.js +772 -0
  29. package/dist/migrate.js.map +1 -0
  30. package/dist/new-module.js +84 -43
  31. package/dist/new-module.js.map +1 -1
  32. package/dist/routes.d.ts +3 -5
  33. package/dist/routes.js +4 -6
  34. package/dist/routes.js.map +1 -1
  35. package/dist/spec.d.ts +2 -5
  36. package/dist/spec.js +13 -6
  37. package/dist/spec.js.map +1 -1
  38. package/dist/sync.d.ts +3 -11
  39. package/dist/sync.js +81 -20
  40. package/dist/sync.js.map +1 -1
  41. package/dist/template.d.ts +58 -0
  42. package/dist/template.js +244 -0
  43. package/dist/template.js.map +1 -0
  44. package/dist/update.js +4 -4
  45. package/package.json +17 -16
  46. package/templates/AGENTS.md +68 -31
  47. package/templates/CLAUDE.md +5 -4
  48. package/templates/app/.envs/.env.development +18 -0
  49. package/templates/app/.envs/.env.production +19 -0
  50. package/templates/app/.envs/.env.staging +19 -0
  51. package/templates/app/README.md +327 -0
  52. package/templates/app/app.ts +3 -0
  53. package/templates/app/config/app.config.ts +73 -0
  54. package/templates/app/docs/agent-manifest.json +724 -0
  55. package/templates/app/ecosystem.config.cjs +60 -0
  56. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  57. package/templates/app/modules/agent/i18n/en.json +5 -0
  58. package/templates/app/modules/agent/i18n/fr.json +5 -0
  59. package/templates/app/modules/agent/module.acl.ts +14 -0
  60. package/templates/app/modules/agent/package.json +21 -0
  61. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  62. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  63. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  64. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  65. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  66. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  67. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  68. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  69. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  70. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  71. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  72. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  73. package/templates/app/modules/auth/i18n/en.json +19 -0
  74. package/templates/app/modules/auth/i18n/fr.json +19 -0
  75. package/templates/app/modules/auth/lib/index.ts +12 -0
  76. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  77. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  78. package/templates/app/modules/auth/models/session.model.ts +42 -0
  79. package/templates/app/modules/auth/module.acl.ts +41 -0
  80. package/templates/app/modules/auth/module.config.ts +30 -0
  81. package/templates/app/modules/auth/package.json +61 -0
  82. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  83. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  84. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  85. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  86. package/templates/app/modules/config/helpers/audit.ts +41 -0
  87. package/templates/app/modules/config/helpers/describe.ts +72 -0
  88. package/templates/app/modules/config/i18n/en.json +6 -0
  89. package/templates/app/modules/config/i18n/fr.json +6 -0
  90. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  91. package/templates/app/modules/config/module.acl.ts +12 -0
  92. package/templates/app/modules/config/package.json +23 -0
  93. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  94. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  95. package/templates/app/modules/config/tests/config.test.ts +126 -0
  96. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  97. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  98. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  99. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  100. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  101. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  102. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  103. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  104. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  105. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  108. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  109. package/templates/app/modules/core/lib/index.ts +34 -0
  110. package/templates/app/modules/core/module.config.ts +24 -0
  111. package/templates/app/modules/core/package.json +80 -0
  112. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  113. package/templates/app/modules/core/tests/core.test.ts +19 -0
  114. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  115. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  116. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  117. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  118. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  119. package/templates/app/modules/hello/i18n/en.json +8 -0
  120. package/templates/app/modules/hello/i18n/fr.json +8 -0
  121. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  122. package/templates/app/modules/hello/module.acl.ts +16 -0
  123. package/templates/app/modules/hello/module.config.ts +14 -0
  124. package/templates/app/modules/hello/package.json +24 -0
  125. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  126. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  127. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  128. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  129. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  130. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  131. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  132. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  133. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  134. package/templates/app/modules/security/module.config.ts +59 -0
  135. package/templates/app/modules/security/package.json +26 -0
  136. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  137. package/templates/app/modules/security/tests/security.test.ts +174 -0
  138. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  139. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  140. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  141. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  142. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  143. package/templates/app/modules/settings/i18n/en.json +3 -0
  144. package/templates/app/modules/settings/i18n/fr.json +3 -0
  145. package/templates/app/modules/settings/module.acl.ts +12 -0
  146. package/templates/app/modules/settings/package.json +21 -0
  147. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  148. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  149. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  150. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  151. package/templates/app/modules/users/i18n/en.json +14 -0
  152. package/templates/app/modules/users/i18n/fr.json +14 -0
  153. package/templates/app/modules/users/models/user.model.ts +18 -0
  154. package/templates/app/modules/users/module.acl.ts +10 -0
  155. package/templates/app/modules/users/module.config.ts +11 -0
  156. package/templates/app/modules/users/package.json +24 -0
  157. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  158. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  159. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  160. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  161. package/templates/app/nginx/development.conf +44 -0
  162. package/templates/app/nginx/production.conf +75 -0
  163. package/templates/app/nginx/staging.conf +51 -0
  164. package/templates/app/package.json +58 -0
  165. package/templates/app/scripts/count-greetings.ts +29 -0
  166. package/templates/app/scripts/deploy-nginx.ts +143 -0
  167. package/templates/app/scripts/list-users.ts +34 -0
  168. package/templates/app/tests/app.test.ts +28 -0
  169. package/templates/app/tests/support.ts +36 -0
  170. package/templates/app/tsconfig.json +28 -0
  171. package/templates/app/vitest.config.ts +6 -0
  172. package/templates/app/worker.ts +6 -0
  173. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
  174. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  175. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  176. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  177. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  178. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  179. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  180. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  181. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  182. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  183. package/templates/minimal/README.md +21 -0
  184. package/templates/minimal/app.ts +3 -0
  185. package/templates/minimal/config/app.config.ts +16 -0
  186. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  187. package/templates/minimal/modules/agent/package.json +14 -0
  188. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  189. package/templates/minimal/modules/core/module.acl.ts +4 -0
  190. package/templates/minimal/modules/core/package.json +21 -0
  191. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  192. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  193. package/templates/minimal/modules/hello/package.json +11 -0
  194. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  195. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  196. package/templates/minimal/modules/security/package.json +15 -0
  197. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  198. package/templates/minimal/package.json +38 -0
  199. package/templates/minimal/tests/app.test.ts +52 -0
  200. package/templates/minimal/tsconfig.json +22 -0
  201. package/templates/minimal/vitest.config.ts +5 -0
  202. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  203. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  204. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  205. package/templates/versions.json +11 -0
  206. package/dist/minimal.d.ts +0 -2
  207. package/dist/minimal.js +0 -2200
  208. package/dist/minimal.js.map +0 -1
  209. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -0,0 +1,43 @@
1
+ import type { Request, RequestHandler, Response } from "express";
2
+ import { requireModels, requireQueues, type Ctx } from "@tulipes/core/boot";
3
+ import { HttpError } from "@tulipes/core/http";
4
+
5
+ export class GreetingsController {
6
+ readonly #ctx: Ctx;
7
+
8
+ constructor(ctx: Ctx) {
9
+ this.#ctx = ctx;
10
+ }
11
+
12
+ list(): RequestHandler {
13
+ return async (req: Request, res: Response) => {
14
+ // ?page and ?per_page, already clamped by config.pagination — so
15
+ // every list endpoint in every module paginates the same way.
16
+ 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
+ ]);
24
+
25
+ // Passing total_items is what marks this a page: respond fills in
26
+ // page, per_page and total_pages from the request and the count.
27
+ res.respond({ data: greetings, meta: { total_items } });
28
+ };
29
+ }
30
+
31
+ use(): RequestHandler {
32
+ return async (req: Request, res: Response) => {
33
+ const name = String(req.params.name);
34
+ const greeting = await requireModels(this.#ctx).get("Greeting").findOne({ name }).lean();
35
+ if (!greeting) throw new HttpError(404, "noSuchGreeting", { vars: { name } });
36
+
37
+ // Slow or retryable work belongs in a job, not in the request. Run
38
+ // `pnpm worker` in another terminal to see it processed.
39
+ await requireQueues(this.#ctx).add("hello.count-greeting", "count", { name });
40
+ res.respond({ status: 202, data: { name, queued: true } });
41
+ };
42
+ }
43
+ }
@@ -0,0 +1,50 @@
1
+ import type { Request, RequestHandler, Response } from "express";
2
+ import type { Ctx } from "@tulipes/core/boot";
3
+
4
+ import { buildGreeting } from "../helpers/greeting.ts";
5
+
6
+ /**
7
+ * Controllers hold the work so routes stay a table of contents. The
8
+ * framework does not scan this folder — routes import the class directly,
9
+ * which keeps the wiring explicit.
10
+ *
11
+ * Taking ctx in the constructor is what makes it testable: construct one
12
+ * with a stub context and call the method.
13
+ */
14
+ export class HelloController {
15
+ readonly #ctx: Ctx;
16
+
17
+ constructor(ctx: Ctx) {
18
+ this.#ctx = ctx;
19
+ }
20
+
21
+ greet(): RequestHandler {
22
+ return (req: Request, res: Response): void => {
23
+ const name = String(req.query.name ?? "world");
24
+ // Every response goes through res.respond, which wraps the payload
25
+ // in the app's envelope: { success, data, errors, meta }.
26
+ res.respond({
27
+ data: {
28
+ message: buildGreeting(String(this.#ctx.config.hello?.greeting), name),
29
+ app: this.#ctx.config.app?.name,
30
+ },
31
+ });
32
+ };
33
+ }
34
+
35
+ secret(): RequestHandler {
36
+ return (req: Request, res: Response): void => {
37
+ // req.t is bound to this module's namespace by rai(), so the key
38
+ // resolves in modules/hello/i18n/<locale>.json. Try ?lang=fr.
39
+ res.respond({ data: { secret: req.t("secret"), locale: req.locale } });
40
+ };
41
+ }
42
+
43
+ byName(): RequestHandler {
44
+ return (req: Request, res: Response): void => {
45
+ res.respond({
46
+ data: { message: `${this.#ctx.config.hello?.greeting}, ${req.params.name}!` },
47
+ });
48
+ };
49
+ }
50
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Pure logic: no Express, no database, no framework. Trivial to unit
3
+ * test, and the framework never scans this folder — helpers are plain
4
+ * imports. Anything two modules need moves up to lib/.
5
+ */
6
+ export function buildGreeting(greeting: string, name: string): string {
7
+ return `${greeting}, ${name}!`;
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "secret": "Only roles granted hello:read see this",
3
+ "noSuchGreeting": "No greeting named \"{{name}}\"",
4
+ "list": {
5
+ "count_one": "{{count}} greeting",
6
+ "count_other": "{{count}} greetings"
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "secret": "Seuls les rôles disposant de hello:read voient ceci",
3
+ "noSuchGreeting": "Aucune salutation nommée « {{name}} »",
4
+ "list": {
5
+ "count_one": "{{count}} salutation",
6
+ "count_other": "{{count}} salutations"
7
+ }
8
+ }
@@ -0,0 +1,24 @@
1
+ import { Schema } from "mongoose";
2
+ import type { ModelDef } from "@tulipes/mongoose";
3
+
4
+ // Cross-module import by package name, never a relative path out
5
+ // of the module — the dependency is declared in package.json.
6
+ import { baseSchemaOptions } from "@app/core";
7
+
8
+ /**
9
+ * A model file is a DECLARATION, never a registration: the framework
10
+ * compiles the schema on its own connection and puts it in the model
11
+ * store. Never call mongoose.model() yourself.
12
+ *
13
+ * Read it anywhere the context reaches: models!.get("Greeting").
14
+ */
15
+ const greetingSchema = new Schema(
16
+ {
17
+ name: { type: String, required: true, unique: true, lowercase: true, trim: true },
18
+ message: { type: String, required: true },
19
+ times_used: { type: Number, default: 0, min: 0 },
20
+ },
21
+ baseSchemaOptions,
22
+ );
23
+
24
+ export default { name: "Greeting", schema: greetingSchema } satisfies ModelDef;
@@ -0,0 +1,16 @@
1
+ import type { AclBuilder } from "@tulipes/core/acl";
2
+
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.
8
+ */
9
+ export default function helloAcl(acl: AclBuilder): void {
10
+ // Public: the greeting endpoints are granted to guest, which is
11
+ // how an endpoint is made public — there is no "skip auth" flag.
12
+ acl.allow("guest", "hello:greet", "hello:greet-name", "hello:demo-missing");
13
+
14
+ // Signed in: the secret needs a real role.
15
+ acl.allow("user", "hello:read", "hello:list-greetings", "hello:use-greeting");
16
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "@tulipes/core/config";
2
+ import { z } from "zod/v4";
3
+
4
+ /**
5
+ * Whatever this declares is stored at `config.hello`, edited through the
6
+ * console, typed by `pnpm sync`, and read live by every other module — a
7
+ * change lands without a restart.
8
+ */
9
+ export default defineConfig({
10
+ schema: z.object({
11
+ greeting: z.string().min(1).default("Hello")
12
+ .describe("Word this module greets callers with"),
13
+ }),
14
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@app/hello",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "tulipes": {
7
+ "tier": "app",
8
+ "priority": 100,
9
+ "dependsOn": [
10
+ "auth",
11
+ "core"
12
+ ]
13
+ },
14
+ "dependencies": {
15
+ "@app/auth": "workspace:*",
16
+ "@app/core": "workspace:*",
17
+ "@tulipes/bullmq": "workspace:*",
18
+ "@tulipes/core": "workspace:*",
19
+ "@tulipes/mongoose": "workspace:*",
20
+ "@tulipes/socket.io": "workspace:*",
21
+ "express": "^5",
22
+ "mongoose": "^8"
23
+ }
24
+ }
@@ -0,0 +1,38 @@
1
+ import type { Ctx } from "@tulipes/core/boot";
2
+ import type { JobSchemas, QueueRegistry } from "@tulipes/bullmq";
3
+ import { z } from "zod/v4";
4
+
5
+ import { moduleLogger } from "@app/core";
6
+
7
+ /**
8
+ * The job data each queue accepts. This export is the declaration: it
9
+ * defines the queue, validates every `queues.add()` payload, and
10
+ * `pnpm sync` types `job.data` here and the payload at the producer from
11
+ * it — a wrong queue name or shape no longer compiles.
12
+ */
13
+ export const jobs = {
14
+ "hello.count-greeting": z.object({ name: z.string().min(1) }),
15
+ } satisfies JobSchemas;
16
+
17
+ /**
18
+ * ONE file describes both sides. The backend process registers the queue
19
+ * so routes can produce into it; `pnpm worker` turns the processor
20
+ * into a live consumer. Never duplicate the definition.
21
+ *
22
+ * Queue names use "." — BullMQ reserves ":" as its redis separator.
23
+ */
24
+ export default function helloQueues(ctx: Ctx, queues: QueueRegistry): void {
25
+ const { models } = ctx;
26
+ // The same logger the backend uses. In the worker process it writes
27
+ // to app-worker.log, so the two processes never share a file.
28
+ const log = moduleLogger(ctx.Environment, "hello", ctx.mode);
29
+
30
+ queues.process("hello.count-greeting", async (job) => {
31
+ // Jobs retry, so processors must be idempotent-safe. Pass ids in
32
+ // job.data and re-read state here rather than shipping documents.
33
+ const { name } = job.data;
34
+ await models!.get("Greeting").updateOne({ name }, { $inc: { times_used: 1 } });
35
+ log.info({ name }, "counted greeting");
36
+ return { counted: name };
37
+ });
38
+ }
@@ -0,0 +1,25 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+
4
+ import { GreetingsController } from "../controllers/greetings.controllers.ts";
5
+
6
+ export default function greetingsRoutes(ctx: BackendCtx): Router {
7
+ const { rai } = ctx;
8
+ const router = Router();
9
+ const greetings = new GreetingsController(ctx);
10
+
11
+ router.get("/greetings", rai({
12
+ id: "hello:list-greetings",
13
+ name: "List greetings",
14
+ description: "Reads the seeded rows, clamped by the shared bounds",
15
+ }), greetings.list());
16
+
17
+ router.post("/greetings/:name/use", rai({
18
+ id: "hello:use-greeting",
19
+ status: 202,
20
+ name: "Use a greeting",
21
+ description: "Queues a job that counts the use",
22
+ }), greetings.use());
23
+
24
+ return router;
25
+ }
@@ -0,0 +1,44 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+ import { HttpError } from "@tulipes/core/http";
4
+
5
+ import { HelloController } from "../controllers/hello.controllers.ts";
6
+
7
+ /**
8
+ * A routes file is a plain function of the context. It returns a Router,
9
+ * which core mounts under `config.api.prefix`, and declares a RAI on every
10
+ * 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.
13
+ */
14
+ export default function helloRoutes(ctx: BackendCtx): Router {
15
+ const { rai } = ctx;
16
+ const router = Router();
17
+ const hello = new HelloController(ctx);
18
+
19
+ router.get("/hello", rai({
20
+ id: "hello:greet",
21
+ name: "Greet",
22
+ description: "Public greeting, granted to guest",
23
+ }), hello.greet());
24
+
25
+ router.get("/hello/secret", rai({
26
+ id: "hello:read",
27
+ name: "Read the secret",
28
+ description: "Requires a role granted hello:read",
29
+ }), hello.secret());
30
+
31
+ router.get("/hello/:name", rai({
32
+ id: "hello:greet-name",
33
+ name: "Greet by name",
34
+ }), hello.byName());
35
+
36
+ router.get("/hello/demo/missing", rai({
37
+ id: "hello:demo-missing",
38
+ name: "HttpError demo",
39
+ }), async () => {
40
+ throw new HttpError(404, "nothing here", { details: { hint: "demo endpoint" } });
41
+ });
42
+
43
+ return router;
44
+ }
@@ -0,0 +1,21 @@
1
+ import type { Ctx } from "@tulipes/core/boot";
2
+ import type { SocketRegistry } from "@tulipes/socket.io";
3
+
4
+ /**
5
+ * Backend mode only. A namespace is claimed by exactly one module; emit
6
+ * from anywhere with `ctx.sockets!.of("/hello")`.
7
+ *
8
+ * Try it: wscat -c ws://localhost:3000/hello
9
+ */
10
+ export default function helloSockets({ config }: Ctx, sockets: SocketRegistry): void {
11
+ sockets.namespace("/hello", (nsp) => {
12
+ nsp.on("connection", (socket) => {
13
+ socket.emit("greeting", {
14
+ message: config.hello?.greeting,
15
+ online: nsp.sockets.size,
16
+ });
17
+
18
+ socket.on("ping", (ack: (reply: string) => void) => ack("pong"));
19
+ });
20
+ });
21
+ }
@@ -0,0 +1,19 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import { z } from "zod/v4";
4
+ import { afterAll, beforeAll, expect, test, vi } from "vitest";
5
+ import { boot } from "@tulipes/core/boot";
6
+ import { useTestApp } from "@tulipes/testing";
7
+
8
+ import { testEnv, testSettings } from "../../../tests/support.ts";
9
+
10
+ const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
+ const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
12
+
13
+
14
+ test("hello responds through the declared public endpoint", async () => {
15
+ const response = await fetch(app.base + "/api/v1/hello");
16
+
17
+ expect(response.status).toBe(200);
18
+ expect(await response.json()).toMatchObject({ success: true, errors: [] });
19
+ });
@@ -0,0 +1,8 @@
1
+ import type { BootstrapCleanup, RuntimeCtx } from "@tulipes/core/boot";
2
+
3
+ import { closeRateLimits } from "../helpers/rate-limit.ts";
4
+
5
+ /** The rate limiters open their Redis client on first use; this returns the close. */
6
+ export default function rateLimits(ctx: RuntimeCtx): BootstrapCleanup {
7
+ return () => closeRateLimits(ctx);
8
+ }
@@ -0,0 +1,65 @@
1
+ import type { CorsOptions } from "cors";
2
+ import type { HelmetOptions } from "helmet";
3
+
4
+ /** The settings the hardening middleware is built from — `ctx.config.security`. */
5
+ export interface HardeningSettings {
6
+ hstsMaxAge: number;
7
+ corsOrigins: string;
8
+ corsCredentials: boolean;
9
+ }
10
+
11
+ /**
12
+ * Helmet tuned for an API rather than a web page. Defaults assume a site
13
+ * that serves HTML and its own assets; this one serves JSON and nothing
14
+ * else, so the policy can be far stricter.
15
+ */
16
+ export function helmetOptions(settings: Pick<HardeningSettings, "hstsMaxAge">): HelmetOptions {
17
+ const maxAge = settings.hstsMaxAge;
18
+
19
+ return {
20
+ // An API serves no active content: deny every resource type outright,
21
+ // which defuses any HTML-ish response a bug might produce.
22
+ contentSecurityPolicy: {
23
+ useDefaults: false,
24
+ directives: { "default-src": ["'none'"], "frame-ancestors": ["'none'"] },
25
+ },
26
+ // Browsers only honour HSTS over HTTPS, so it is inert in local dev —
27
+ // but a max-age of 0 lets a deployment switch it off deliberately.
28
+ hsts: maxAge > 0 ? { maxAge, includeSubDomains: true } : false,
29
+ // Helmet defaults to SAMEORIGIN; an API is never framed at all.
30
+ frameguard: { action: "deny" },
31
+ // Irrelevant without a browsing context, and it costs a header.
32
+ originAgentCluster: false,
33
+ crossOriginEmbedderPolicy: false,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * CORS controls which browser origins can read responses. An empty list
39
+ * grants no cross-origin access; it does not reject incoming HTTP requests.
40
+ * Authentication and any future cookie/CSRF checks remain separate controls.
41
+ */
42
+ export function corsOptions(settings: Pick<HardeningSettings, "corsOrigins" | "corsCredentials">): CorsOptions {
43
+ const raw = settings.corsOrigins.trim();
44
+ // This controls Access-Control-Allow-Credentials, not token extraction.
45
+ // Explicit Bearer headers do not require cookie authentication to be enabled.
46
+ const credentials = settings.corsCredentials;
47
+
48
+ if (raw === "*") {
49
+ // "*" and credentials are mutually exclusive per the CORS spec — the
50
+ // browser rejects the response, so the schema refuses the combination
51
+ // rather than storing a policy that silently fails in production.
52
+ if (credentials) {
53
+ throw new Error('corsOrigins "*" cannot be combined with corsCredentials — list the origins explicitly');
54
+ }
55
+
56
+ return { origin: true, credentials: false };
57
+ }
58
+
59
+ const origins = raw
60
+ .split(",")
61
+ .map((origin) => origin.trim())
62
+ .filter(Boolean);
63
+
64
+ return { origin: origins, credentials };
65
+ }
@@ -0,0 +1,16 @@
1
+ import { isIP } from "node:net";
2
+
3
+ /** Explicit proxy peers only: no blanket trust, hop counts or named subnet aliases. */
4
+ export function trustedProxies(raw: string): false | string[] {
5
+ if (!raw.trim()) return false;
6
+ const entries = raw.split(",").map(entry => entry.trim());
7
+ for (const entry of entries) {
8
+ const [address = "", bits, ...extra] = entry.split("/");
9
+ const family = isIP(address);
10
+ if (!family || address.includes("%") || extra.length ||
11
+ (bits !== undefined && (!/^[1-9][0-9]{0,2}$/.test(bits) || Number(bits) > (family === 4 ? 32 : 128)))) {
12
+ throw new Error("TRUST_PROXY_CIDRS must be empty or a comma-separated list of IP addresses/CIDRs with nonzero prefix lengths");
13
+ }
14
+ }
15
+ return [...new Set(entries)];
16
+ }
@@ -0,0 +1,150 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import { Router, type RequestHandler } from "express";
4
+ import { Redis } from "ioredis";
5
+ import ipaddr from "ipaddr.js";
6
+ import { HttpError } from "@tulipes/core/http";
7
+ import type { Ctx } from "@tulipes/core/boot";
8
+
9
+ // One atomic, expiring counter per client and policy, shared by every backend.
10
+ // Rejected requests neither increase the counter nor extend its expiration.
11
+ const consume = `
12
+ local count = tonumber(redis.call('GET', KEYS[1]) or '0')
13
+ local ttl = redis.call('PTTL', KEYS[1])
14
+ if ttl <= 0 then
15
+ redis.call('SET', KEYS[1], 1, 'PX', ARGV[2])
16
+ return {1, tonumber(ARGV[2])}
17
+ end
18
+ if count >= tonumber(ARGV[1]) then return {0, ttl} end
19
+ redis.call('INCR', KEYS[1])
20
+ return {1, ttl}
21
+ `;
22
+
23
+ export interface RateLimitOptions {
24
+ redisUrl: string;
25
+ namespace: string;
26
+ windowMs: number;
27
+ authLimit: number;
28
+ sessionLimit: number;
29
+ configLimit: number;
30
+ }
31
+
32
+ /** The `rateLimits` setting, as `ctx.config.security.rateLimits`. */
33
+ export interface RateLimitSettings {
34
+ namespace: string;
35
+ windowMs: number;
36
+ auth: number;
37
+ session: number;
38
+ config: number;
39
+ }
40
+
41
+ type RateLimits = ReturnType<typeof createRateLimits>;
42
+
43
+ /**
44
+ * One limiter set per boot, built from the live settings on first use and
45
+ * rebuilt by module.config's onChange when they change. The routes file
46
+ * mounts a handler that always delegates to the current set, so a rebuild
47
+ * never re-mounts anything; the previous set's client is closed once the
48
+ * new one exists.
49
+ */
50
+ const instances = new WeakMap<Ctx, { limits: RateLimits; settings: RateLimitSettings }>();
51
+
52
+ export function configuredRateLimits(ctx: Ctx): RateLimits {
53
+ const current = instances.get(ctx);
54
+ if (current) return current.limits;
55
+ return rebuildRateLimits(ctx, settingsOf(ctx)).limits;
56
+ }
57
+
58
+ /** @internal module.config onChange and the first request. */
59
+ export function rebuildRateLimits(ctx: Ctx, settings: RateLimitSettings): { limits: RateLimits; settings: RateLimitSettings } {
60
+ const previous = instances.get(ctx);
61
+ if (previous && JSON.stringify(previous.settings) === JSON.stringify(settings)) return previous;
62
+ const next = {
63
+ settings: { ...settings },
64
+ limits: createRateLimits({
65
+ redisUrl: String(ctx.Environment.get("REDIS_URL")),
66
+ namespace: settings.namespace + ":" + ctx.Environment.appEnv,
67
+ windowMs: settings.windowMs,
68
+ authLimit: settings.auth,
69
+ sessionLimit: settings.session,
70
+ configLimit: settings.config,
71
+ }),
72
+ };
73
+ instances.set(ctx, next);
74
+ previous?.limits.close();
75
+ return next;
76
+ }
77
+
78
+ function settingsOf(ctx: Ctx): RateLimitSettings {
79
+ const settings = (ctx.config.security as { rateLimits?: RateLimitSettings } | undefined)?.rateLimits;
80
+ if (!settings) throw new Error("security settings are not loaded — the rate limiter is built after boot phase 6");
81
+ return settings;
82
+ }
83
+
84
+ export function createRateLimits(options: RateLimitOptions) {
85
+ for (const [name, value] of Object.entries({ windowMs: options.windowMs,
86
+ auth: options.authLimit, session: options.sessionLimit, config: options.configLimit })) {
87
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647) {
88
+ throw new Error("rateLimits." + name + " must be a positive integer no greater than 2147483647");
89
+ }
90
+ }
91
+ if (!/^[a-zA-Z0-9:_-]{1,128}$/.test(options.namespace)) {
92
+ throw new Error("rateLimits.namespace must use letters, digits, colon, underscore or hyphen (max 128 including app environment)");
93
+ }
94
+ const redis = new Redis(options.redisUrl, {
95
+ lazyConnect: true, enableOfflineQueue: false, maxRetriesPerRequest: 0,
96
+ connectTimeout: 1000, commandTimeout: 1000,
97
+ });
98
+ // Never log connection URLs or driver errors; HTTP failures use a stable code.
99
+ redis.on("error", () => {});
100
+ // Driver handshakes can involve several timed commands. Bound the initial
101
+ // wait separately; reconnection may continue while requests fail closed.
102
+ const ready = new Promise<void>(resolve => {
103
+ const timer = setTimeout(resolve, 1000);
104
+ timer.unref();
105
+ void redis.connect().catch(() => {}).finally(() => { clearTimeout(timer); resolve(); });
106
+ });
107
+ const limit = (policy: string, maximum: number): RequestHandler => async (req, res, next) => {
108
+ // Use only Express's resolved address. Invalid trusted-proxy input shares one
109
+ // bucket, so arbitrary header strings cannot mint arbitrary client identities.
110
+ let identity = "unknown";
111
+ if (req.ip && isIP(req.ip)) {
112
+ const address = ipaddr.process(req.ip);
113
+ const bytes = address.toByteArray();
114
+ if (address.kind() === "ipv6") bytes.fill(0, 7); // Aggregate IPv6 /56.
115
+ identity = ipaddr.fromByteArray(bytes).toString();
116
+ }
117
+ const key = "tulipes:rate:" + options.namespace + ":" + policy + ":"
118
+ + createHash("sha256").update(identity).digest("hex");
119
+ let result: unknown;
120
+ try {
121
+ await ready;
122
+ if (redis.status !== "ready") throw new Error("unavailable");
123
+ result = await redis.eval(consume, 1, key, maximum, options.windowMs);
124
+ if (!Array.isArray(result) || result.length !== 2 || ![0, 1].includes(result[0])
125
+ || !Number.isSafeInteger(result[1]) || result[1] < 1) throw new Error("invalid counter");
126
+ } catch {
127
+ throw new HttpError(503, "rateLimitUnavailable", { code: "RATE_LIMIT_UNAVAILABLE",
128
+ defaultMessage: "Request limits are unavailable; try again later" });
129
+ }
130
+ if (Array.isArray(result) && result[0] === 0) {
131
+ res.setHeader("Retry-After", Math.max(1, Math.ceil(Number(result[1]) / 1000)));
132
+ throw new HttpError(429, "tooManyRequests", { code: "TOO_MANY_REQUESTS",
133
+ defaultMessage: "Too many requests; try again later" });
134
+ }
135
+ next();
136
+ };
137
+ const auth = Router();
138
+ // The broad quota bounds all auth requests, including refresh, reads and misses.
139
+ auth.use(limit("sessions", options.sessionLimit));
140
+ auth.post(["/login", "/register", "/password"], limit("credentials", options.authLimit));
141
+ return { auth, config: limit("config", options.configLimit), close: () => {
142
+ redis.disconnect();
143
+ } };
144
+ }
145
+
146
+ /** Security owns its connections, including partially initialized boot cleanup. */
147
+ export function closeRateLimits(ctx: Ctx): void {
148
+ instances.get(ctx)?.limits.close();
149
+ instances.delete(ctx);
150
+ }
@@ -0,0 +1,59 @@
1
+ import { defineConfig } from "@tulipes/core/config";
2
+ import { z } from "zod/v4";
3
+
4
+ import { corsOptions } from "./helpers/hardening.ts";
5
+ import { rebuildRateLimits } from "./helpers/rate-limit.ts";
6
+ import { trustedProxies } from "./helpers/proxy.ts";
7
+
8
+ /**
9
+ * Everything the request pipeline is configured with. None of it is a
10
+ * secret and none of it is needed before the settings store answers, so
11
+ * none of it is an environment variable: an operator changes it through
12
+ * the console and every replica rebuilds its middleware from `onChange`.
13
+ * The schema refuses a value the middleware could not be built from, so a
14
+ * bad write never lands in the store.
15
+ */
16
+ export default defineConfig({
17
+ schema: z.object({
18
+ passwordRounds: z.number().int().min(4).max(31).default(12)
19
+ .describe("bcrypt cost factor for new password hashes; existing hashes are upgraded on login"),
20
+ bodyLimit: z.string().regex(/^\d+(\.\d+)?\s*(b|kb|mb|gb)?$/i).default("1mb")
21
+ .describe("Largest JSON request body express parses, e.g. \"1mb\"; nginx client_max_body_size must be at least this"),
22
+ trustProxyCidrs: z.string().default("")
23
+ .refine((raw) => { try { trustedProxies(raw); return true; } catch { return false; } },
24
+ "Empty, or a comma-separated list of IP addresses/CIDRs with nonzero prefix lengths")
25
+ .describe("Comma-separated trusted proxy addresses or CIDRs; empty ignores forwarded headers. Configure only actual proxy peers"),
26
+ corsOrigins: z.string().default("")
27
+ .describe("Comma-separated browser origins allowed to read API responses; \"*\" allows any, empty allows none (same-origin only)"),
28
+ corsCredentials: z.boolean().default(false)
29
+ .describe("Emit Access-Control-Allow-Credentials for browser credential mode; does not enable cookie authentication or CSRF protection (cannot be combined with corsOrigins \"*\")"),
30
+ hstsMaxAge: z.number().int().min(0).default(15_552_000)
31
+ .describe("Strict-Transport-Security max-age in seconds; 0 disables the header (browsers only honour it over HTTPS)"),
32
+ rateLimits: z.object({
33
+ namespace: z.string().regex(/^[a-zA-Z0-9:_-]{1,100}$/).default("tulipes")
34
+ .describe("Stable identifier shared by replicas; use a different value for unrelated apps on the same Redis database"),
35
+ windowMs: z.number().int().min(1).max(2_147_483_647).default(60_000)
36
+ .describe("Fixed request window in milliseconds"),
37
+ auth: z.number().int().min(1).max(2_147_483_647).default(20)
38
+ .describe("Combined login, register and password POST attempts per client per window"),
39
+ session: z.number().int().min(1).max(2_147_483_647).default(120)
40
+ .describe("All auth requests per client per window, including refresh and credential attempts"),
41
+ config: z.number().int().min(1).max(2_147_483_647).default(60)
42
+ .describe("All console (config and settings) requests per client per window"),
43
+ }).default({ namespace: "tulipes", windowMs: 60_000, auth: 20, session: 120, config: 60 })
44
+ .describe("Shared request quotas, counted in Redis so replicas agree"),
45
+ }).refine((values) => { try { corsOptions(values); return true; } catch { return false; } }, {
46
+ path: ["corsCredentials"],
47
+ message: "corsOrigins \"*\" cannot be combined with corsCredentials — list the origins explicitly",
48
+ }),
49
+
50
+ /**
51
+ * Helmet, CORS and the body parser read the live values on every
52
+ * request; the rate limiter holds a Redis client and the proxy policy is
53
+ * an app setting, so those two are rebuilt here.
54
+ */
55
+ onChange(next, ctx) {
56
+ ctx.app?.set("trust proxy", trustedProxies(next.trustProxyCidrs));
57
+ rebuildRateLimits(ctx, next.rateLimits);
58
+ },
59
+ });