@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.
- package/README.md +10 -7
- package/dist/agent.d.ts +12 -0
- package/dist/agent.js +29 -0
- package/dist/agent.js.map +1 -0
- package/dist/app-core.d.ts +8 -6
- package/dist/app-core.js +23 -14
- package/dist/app-core.js.map +1 -1
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/config-check.d.ts +8 -0
- package/dist/config-check.js +43 -0
- package/dist/config-check.js.map +1 -0
- package/dist/config-import.d.ts +20 -0
- package/dist/config-import.js +133 -0
- package/dist/config-import.js.map +1 -0
- package/dist/dev.d.ts +4 -4
- package/dist/dev.js +7 -10
- package/dist/dev.js.map +1 -1
- package/dist/init.d.ts +6 -11
- package/dist/init.js +11 -6422
- package/dist/init.js.map +1 -1
- package/dist/inspection.d.ts +9 -8
- package/dist/inspection.js +10 -11
- package/dist/inspection.js.map +1 -1
- package/dist/main.js +82 -11
- package/dist/main.js.map +1 -1
- package/dist/migrate.d.ts +30 -0
- package/dist/migrate.js +772 -0
- package/dist/migrate.js.map +1 -0
- package/dist/new-module.js +84 -43
- package/dist/new-module.js.map +1 -1
- package/dist/routes.d.ts +3 -5
- package/dist/routes.js +4 -6
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -5
- package/dist/spec.js +13 -6
- package/dist/spec.js.map +1 -1
- package/dist/sync.d.ts +3 -11
- package/dist/sync.js +81 -20
- package/dist/sync.js.map +1 -1
- package/dist/template.d.ts +58 -0
- package/dist/template.js +244 -0
- package/dist/template.js.map +1 -0
- package/dist/update.js +4 -4
- package/package.json +17 -16
- package/templates/AGENTS.md +68 -31
- package/templates/CLAUDE.md +5 -4
- package/templates/app/.envs/.env.development +18 -0
- package/templates/app/.envs/.env.production +19 -0
- package/templates/app/.envs/.env.staging +19 -0
- package/templates/app/README.md +327 -0
- package/templates/app/app.ts +3 -0
- package/templates/app/config/app.config.ts +73 -0
- package/templates/app/docs/agent-manifest.json +724 -0
- package/templates/app/ecosystem.config.cjs +60 -0
- package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
- package/templates/app/modules/agent/i18n/en.json +5 -0
- package/templates/app/modules/agent/i18n/fr.json +5 -0
- package/templates/app/modules/agent/module.acl.ts +14 -0
- package/templates/app/modules/agent/package.json +21 -0
- package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
- package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
- package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
- package/templates/app/modules/auth/helpers/access-check.ts +42 -0
- package/templates/app/modules/auth/helpers/auth.ts +48 -0
- package/templates/app/modules/auth/helpers/documents.ts +57 -0
- package/templates/app/modules/auth/helpers/passport.ts +180 -0
- package/templates/app/modules/auth/helpers/passwords.ts +58 -0
- package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
- package/templates/app/modules/auth/helpers/tokens.ts +329 -0
- package/templates/app/modules/auth/i18n/en.json +19 -0
- package/templates/app/modules/auth/i18n/fr.json +19 -0
- package/templates/app/modules/auth/lib/index.ts +12 -0
- package/templates/app/modules/auth/models/credential.model.ts +31 -0
- package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
- package/templates/app/modules/auth/models/session.model.ts +42 -0
- package/templates/app/modules/auth/module.acl.ts +41 -0
- package/templates/app/modules/auth/module.config.ts +30 -0
- package/templates/app/modules/auth/package.json +61 -0
- package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
- package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
- package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
- package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
- package/templates/app/modules/config/helpers/audit.ts +41 -0
- package/templates/app/modules/config/helpers/describe.ts +72 -0
- package/templates/app/modules/config/i18n/en.json +6 -0
- package/templates/app/modules/config/i18n/fr.json +6 -0
- package/templates/app/modules/config/models/config-audit.model.ts +26 -0
- package/templates/app/modules/config/module.acl.ts +12 -0
- package/templates/app/modules/config/package.json +23 -0
- package/templates/app/modules/config/routes/config.routes.ts +37 -0
- package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
- package/templates/app/modules/config/tests/config.test.ts +126 -0
- package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
- package/templates/app/modules/core/helpers/cache/index.ts +60 -0
- package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
- package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
- package/templates/app/modules/core/helpers/cache/types.ts +37 -0
- package/templates/app/modules/core/helpers/lazy.ts +13 -0
- package/templates/app/modules/core/helpers/logger/index.ts +115 -0
- package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
- package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
- package/templates/app/modules/core/lib/index.ts +34 -0
- package/templates/app/modules/core/module.config.ts +24 -0
- package/templates/app/modules/core/package.json +80 -0
- package/templates/app/modules/core/routes/core.routes.ts +24 -0
- package/templates/app/modules/core/tests/core.test.ts +19 -0
- package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
- package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
- package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
- package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
- package/templates/app/modules/hello/helpers/greeting.ts +8 -0
- package/templates/app/modules/hello/i18n/en.json +8 -0
- package/templates/app/modules/hello/i18n/fr.json +8 -0
- package/templates/app/modules/hello/models/greeting.model.ts +24 -0
- package/templates/app/modules/hello/module.acl.ts +16 -0
- package/templates/app/modules/hello/module.config.ts +14 -0
- package/templates/app/modules/hello/package.json +24 -0
- package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
- package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
- package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
- package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
- package/templates/app/modules/hello/tests/hello.test.ts +19 -0
- package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
- package/templates/app/modules/security/helpers/hardening.ts +65 -0
- package/templates/app/modules/security/helpers/proxy.ts +16 -0
- package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
- package/templates/app/modules/security/module.config.ts +59 -0
- package/templates/app/modules/security/package.json +26 -0
- package/templates/app/modules/security/routes/security.routes.ts +42 -0
- package/templates/app/modules/security/tests/security.test.ts +174 -0
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
- package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
- package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
- package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
- package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
- package/templates/app/modules/settings/i18n/en.json +3 -0
- package/templates/app/modules/settings/i18n/fr.json +3 -0
- package/templates/app/modules/settings/module.acl.ts +12 -0
- package/templates/app/modules/settings/package.json +21 -0
- package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
- package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
- package/templates/app/modules/settings/tests/settings.test.ts +409 -0
- package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
- package/templates/app/modules/users/i18n/en.json +14 -0
- package/templates/app/modules/users/i18n/fr.json +14 -0
- package/templates/app/modules/users/models/user.model.ts +18 -0
- package/templates/app/modules/users/module.acl.ts +10 -0
- package/templates/app/modules/users/module.config.ts +11 -0
- package/templates/app/modules/users/package.json +24 -0
- package/templates/app/modules/users/queues/users.queues.ts +31 -0
- package/templates/app/modules/users/routes/users.routes.ts +60 -0
- package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
- package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
- package/templates/app/nginx/development.conf +44 -0
- package/templates/app/nginx/production.conf +75 -0
- package/templates/app/nginx/staging.conf +51 -0
- package/templates/app/package.json +58 -0
- package/templates/app/scripts/count-greetings.ts +29 -0
- package/templates/app/scripts/deploy-nginx.ts +143 -0
- package/templates/app/scripts/list-users.ts +34 -0
- package/templates/app/tests/app.test.ts +28 -0
- package/templates/app/tests/support.ts +36 -0
- package/templates/app/tsconfig.json +28 -0
- package/templates/app/vitest.config.ts +6 -0
- package/templates/app/worker.ts +6 -0
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
- package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
- package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
- package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
- package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
- package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
- package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
- package/templates/minimal/README.md +21 -0
- package/templates/minimal/app.ts +3 -0
- package/templates/minimal/config/app.config.ts +16 -0
- package/templates/minimal/modules/agent/module.acl.ts +3 -0
- package/templates/minimal/modules/agent/package.json +14 -0
- package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
- package/templates/minimal/modules/core/module.acl.ts +4 -0
- package/templates/minimal/modules/core/package.json +21 -0
- package/templates/minimal/modules/hello/i18n/en.json +3 -0
- package/templates/minimal/modules/hello/module.acl.ts +2 -0
- package/templates/minimal/modules/hello/package.json +11 -0
- package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
- package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
- package/templates/minimal/modules/security/package.json +15 -0
- package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
- package/templates/minimal/package.json +38 -0
- package/templates/minimal/tests/app.test.ts +52 -0
- package/templates/minimal/tsconfig.json +22 -0
- package/templates/minimal/vitest.config.ts +5 -0
- package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
- package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
- package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
- package/templates/versions.json +11 -0
- package/dist/minimal.d.ts +0 -2
- package/dist/minimal.js +0 -2200
- package/dist/minimal.js.map +0 -1
- /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,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
|
+
});
|