@tulipes/cli 0.2.0 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config-check.js +1 -1
- package/dist/env-check.d.ts +5 -3
- package/dist/env-check.js +26 -19
- package/dist/env-check.js.map +1 -1
- package/dist/main.js +83 -19
- package/dist/main.js.map +1 -1
- package/dist/migrate-0.12-env.d.ts +36 -0
- package/dist/migrate-0.12-env.js +250 -0
- package/dist/migrate-0.12-env.js.map +1 -0
- package/dist/migrate-0.12-scaffold.d.ts +1 -0
- package/dist/migrate-0.12-scaffold.js +197 -0
- package/dist/migrate-0.12-scaffold.js.map +1 -0
- package/dist/migrate-0.12.d.ts +47 -0
- package/dist/migrate-0.12.js +210 -0
- package/dist/migrate-0.12.js.map +1 -0
- package/dist/migrate.d.ts +3 -1
- package/dist/migrate.js +11 -5
- package/dist/migrate.js.map +1 -1
- package/dist/new-module.d.ts +7 -0
- package/dist/new-module.js +35 -16
- package/dist/new-module.js.map +1 -1
- package/dist/roles.d.ts +15 -0
- package/dist/roles.js +54 -0
- package/dist/roles.js.map +1 -0
- package/dist/routes.d.ts +7 -2
- package/dist/routes.js +19 -10
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -0
- package/dist/spec.js +13 -11
- package/dist/spec.js.map +1 -1
- package/dist/sync.js +66 -53
- package/dist/sync.js.map +1 -1
- package/package.json +4 -4
- package/templates/AGENTS.md +35 -23
- package/templates/app/.envs/.env.development +2 -1
- package/templates/app/.envs/.env.production +2 -2
- package/templates/app/.envs/.env.staging +2 -2
- package/templates/app/README.md +44 -29
- package/templates/app/config/app.config.ts +60 -7
- package/templates/app/docs/agent-manifest.json +8 -681
- package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +1 -1
- package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -4
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
- package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
- package/templates/app/modules/auth/helpers/passport.ts +1 -1
- package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
- package/templates/app/modules/auth/helpers/tokens.ts +3 -5
- package/templates/app/modules/auth/module.acl.ts +31 -9
- package/templates/app/modules/auth/module.config.ts +5 -3
- package/templates/app/modules/auth/package.json +10 -10
- package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
- package/templates/app/modules/auth/tests/auth.test.ts +1 -1
- package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
- package/templates/app/modules/config/helpers/audit.ts +1 -1
- package/templates/app/modules/config/helpers/describe.ts +35 -26
- package/templates/app/modules/config/module.acl.ts +1 -1
- package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
- package/templates/app/modules/config/tests/config.test.ts +14 -6
- package/templates/app/modules/core/helpers/cache/index.ts +8 -7
- package/templates/app/modules/core/helpers/lazy.ts +3 -3
- package/templates/app/modules/core/helpers/logger/index.ts +24 -16
- package/templates/app/modules/core/module.config.ts +5 -3
- package/templates/app/modules/core/package.json +46 -19
- package/templates/app/modules/core/routes/core.routes.ts +4 -4
- package/templates/app/modules/hello/agent/hello.agent.ts +2 -2
- package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
- package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +2 -2
- package/templates/app/modules/hello/module.acl.ts +12 -7
- package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
- package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
- package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
- package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
- package/templates/app/modules/security/routes/security.routes.ts +4 -4
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
- package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
- package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
- package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -3
- package/templates/app/modules/settings/tests/settings.test.ts +21 -25
- package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
- package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
- package/templates/app/modules/users/module.acl.ts +4 -4
- package/templates/app/modules/users/queues/users.queues.ts +1 -1
- package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
- package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
- package/templates/app/nginx/production.conf +1 -1
- package/templates/app/scripts/deploy-nginx.ts +3 -2
- package/templates/app/tests/support.ts +4 -3
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +6 -4
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
- package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +34 -15
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
- package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
- package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
- package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
- package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
- package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
- package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
- package/templates/minimal/config/app.config.ts +13 -3
- package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
- package/templates/minimal/modules/core/module.acl.ts +7 -1
- package/templates/minimal/modules/core/package.json +4 -4
- package/templates/minimal/modules/hello/module.acl.ts +1 -1
- package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
- package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
- package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
- package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
- package/templates/versions.json +8 -8
- package/templates/app/modules/users/routes/users.routes.ts +0 -60
|
@@ -3,9 +3,9 @@ import type { RequestHandler } from "express";
|
|
|
3
3
|
/**
|
|
4
4
|
* Middleware built on its first request rather than while the routes
|
|
5
5
|
* file runs. Inspection (`tulipes routes`, `tulipes spec`) builds every
|
|
6
|
-
* router without
|
|
7
|
-
* middleware that reads
|
|
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 {
|
|
7
|
-
import
|
|
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
|
|
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(
|
|
43
|
+
export function logger({ config, mode }: Pick<Ctx, "config" | "mode">): Logger {
|
|
44
44
|
if (instance) return instance;
|
|
45
45
|
|
|
46
|
-
|
|
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 (
|
|
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 (
|
|
65
|
-
const 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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
16
|
-
* needs before the store answers
|
|
17
|
-
*
|
|
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
|
-
"
|
|
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
|
-
"
|
|
17
|
+
"mongodbUri": {
|
|
18
|
+
"key": "MONGO_URI",
|
|
19
19
|
"type": "url",
|
|
20
20
|
"group": "database",
|
|
21
|
-
"
|
|
22
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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(
|
|
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(
|
|
14
|
+
// inspection never touches Redis, nor reads the URL it would need.
|
|
15
|
+
ctx.app.use(lazy(() => {
|
|
16
|
+
const cache = appCache(ctx);
|
|
17
17
|
return (async (_req, res, next) => {
|
|
18
18
|
res.setHeader("X-Request-Id", randomUUID());
|
|
19
19
|
const served = await cache.wrap("stats:served-sample", 10, async () => Date.now());
|
|
@@ -16,7 +16,7 @@ export default defineAgent({
|
|
|
16
16
|
summary: "Greets callers and counts how often each greeting is used.",
|
|
17
17
|
actions: [
|
|
18
18
|
action({
|
|
19
|
-
id: "hello:
|
|
19
|
+
id: "hello:web:greetings",
|
|
20
20
|
description: "The stored greetings, with how often each was used",
|
|
21
21
|
readOnly: true,
|
|
22
22
|
handler: async (ctx) => (await findGreetings(ctx))[0],
|
|
@@ -25,7 +25,7 @@ export default defineAgent({
|
|
|
25
25
|
// The ACL decides on the id (admin holds hello:* in the auth module's
|
|
26
26
|
// ACL), then the schema validates, then this runs.
|
|
27
27
|
action({
|
|
28
|
-
id: "hello:reset
|
|
28
|
+
id: "hello:bo:greetings:reset",
|
|
29
29
|
description: "Reset the usage counter of every greeting to zero",
|
|
30
30
|
input: z.object({ confirm: z.literal(true).describe("Set to true to confirm the reset") }),
|
|
31
31
|
handler: async (ctx) => {
|
package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts}
RENAMED
|
@@ -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 "
|
|
4
|
+
import { buildGreeting } from "../../helpers/greeting.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Controllers hold the work so routes stay a table of contents. The
|
|
@@ -2,9 +2,9 @@ import type { Request, RequestHandler, Response } from "express";
|
|
|
2
2
|
import { requireModels, requireQueues, type Ctx } from "@tulipes/core/boot";
|
|
3
3
|
import { HttpError } from "@tulipes/core/http";
|
|
4
4
|
|
|
5
|
-
import { findGreetings } from "
|
|
5
|
+
import { findGreetings } from "../../helpers/greetings.ts";
|
|
6
6
|
|
|
7
|
-
export class
|
|
7
|
+
export class HelloGreetingsController {
|
|
8
8
|
readonly #ctx: Ctx;
|
|
9
9
|
|
|
10
10
|
constructor(ctx: Ctx) {
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import type { AclBuilder } from "@tulipes/core/acl";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Roles are global and defined once by the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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:
|
|
15
|
+
acl.allow("guest", "hello:web:greet:*", "hello:web:demo:*");
|
|
13
16
|
|
|
14
|
-
// Signed in: the secret
|
|
15
|
-
|
|
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
|
|
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
|
package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts}
RENAMED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import type { BackendCtx } from "@tulipes/core/boot";
|
|
3
3
|
|
|
4
|
-
import {
|
|
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
|
|
9
|
+
const greetings = new HelloGreetingsController(ctx);
|
|
10
10
|
|
|
11
11
|
router.get("/greetings", rai({
|
|
12
|
-
id: "hello:
|
|
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
|
|
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 "
|
|
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
|
|
12
|
-
*
|
|
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:
|
|
32
|
+
id: "hello:web:secret",
|
|
27
33
|
name: "Read the secret",
|
|
28
|
-
description: "Requires a role granted hello:
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
11
|
-
signal.addEventListener("abort", () => configuredLogStreams(
|
|
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
|
|
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
|
|
22
|
+
this.#logStreams = configuredLogStreams(ctx);
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
logStream(): RequestHandler {
|
|
27
26
|
return async (req: Request, res: Response) => {
|
|
28
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
44
|
-
let streams = instances.get(
|
|
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(
|
|
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 {
|
|
4
|
+
import { BoSettingsController } from "../controllers/bo.settings.controllers.ts";
|
|
5
5
|
import { logQuery } from "../schemas/settings.schemas.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -13,12 +13,12 @@ import { logQuery } from "../schemas/settings.schemas.ts";
|
|
|
13
13
|
export default function settingsRoutes(ctx: BackendCtx): void {
|
|
14
14
|
const { rai, routes, config } = ctx;
|
|
15
15
|
const router = Router();
|
|
16
|
-
const settings = new
|
|
16
|
+
const settings = new BoSettingsController(ctx);
|
|
17
17
|
|
|
18
18
|
router.get(
|
|
19
19
|
"/logs/stream",
|
|
20
20
|
rai({
|
|
21
|
-
id: "settings:logs",
|
|
21
|
+
id: "settings:bo:logs",
|
|
22
22
|
name: "Stream logs",
|
|
23
23
|
description: "Server-Sent Events over the log files — backend or worker",
|
|
24
24
|
folder: "settings",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { readdir, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
2
6
|
import { fileURLToPath } from "node:url";
|
|
3
7
|
import { z } from "zod/v4";
|
|
4
8
|
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
|
@@ -8,7 +12,15 @@ import { useTestApp } from "@tulipes/testing";
|
|
|
8
12
|
import { testEnv, testSettings } from "../../../tests/support.ts";
|
|
9
13
|
|
|
10
14
|
const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
|
|
11
|
-
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where this boot's logger writes and the stream reads: environment values
|
|
18
|
+
* are bound once at boot, so the file logging the stream needs is on for
|
|
19
|
+
* the whole file, into a directory the suite owns and empties per test.
|
|
20
|
+
*/
|
|
21
|
+
const logDir = mkdtempSync(join(tmpdir(), "tulipes-log-http-"));
|
|
22
|
+
const app = useTestApp({ rootDir, env: { ...testEnv(), LOG_FILE: "true", LOG_DIR: logDir }, settings: testSettings() }, { beforeAll, afterAll });
|
|
23
|
+
afterAll(() => rm(logDir, { recursive: true, force: true }));
|
|
12
24
|
|
|
13
25
|
|
|
14
26
|
const tokens = z.object({
|
|
@@ -59,30 +71,16 @@ async function sysadmin() {
|
|
|
59
71
|
return user;
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/** `run` sees the log directory empty: one test's files never serve another's stream. */
|
|
62
75
|
async function logFiles(run: (dir: string) => Promise<void>) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const { join } = await import("node:path");
|
|
66
|
-
const dir = await mkdtemp(join(tmpdir(), "tulipes-log-http-"));
|
|
67
|
-
const env = app.handle.ctx.Environment;
|
|
68
|
-
|
|
69
|
-
const previous = {
|
|
70
|
-
file: env.get("LOG_FILE"),
|
|
71
|
-
dir: env.get("LOG_DIR"),
|
|
76
|
+
const empty = async () => {
|
|
77
|
+
for (const entry of await readdir(logDir)) await rm(join(logDir, entry), { recursive: true, force: true });
|
|
72
78
|
};
|
|
73
|
-
|
|
74
|
-
env.store.set("LOG_FILE", true);
|
|
75
|
-
|
|
76
|
-
env.store.set("LOG_DIR", dir);
|
|
77
|
-
|
|
79
|
+
await empty();
|
|
78
80
|
try {
|
|
79
|
-
await run(
|
|
81
|
+
await run(logDir);
|
|
80
82
|
} finally {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
env.store.set("LOG_DIR", previous.dir);
|
|
84
|
-
|
|
85
|
-
await rm(dir, { recursive: true, force: true });
|
|
83
|
+
await empty();
|
|
86
84
|
}
|
|
87
85
|
}
|
|
88
86
|
|
|
@@ -378,10 +376,8 @@ test("shutdown drains settings streams and a later boot owns fresh stream admiss
|
|
|
378
376
|
});
|
|
379
377
|
|
|
380
378
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
next.env.store.set("LOG_DIR", dir);
|
|
384
|
-
|
|
379
|
+
// LOG_FILE and LOG_DIR are still set on the process, so this boot
|
|
380
|
+
// reads the same directory.
|
|
385
381
|
const address = next.server!.address();
|
|
386
382
|
|
|
387
383
|
if (!address || typeof address === "string")
|