@tulipes/cli 0.2.0-rc.2 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +5 -5
- 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 +44 -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/dist/update.js +1 -1
- package/dist/update.js.map +1 -1
- package/package.json +4 -4
- package/templates/AGENTS.md +41 -29
- 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} +4 -5
- package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -6
- 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 +18 -14
- 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} +4 -8
- package/templates/app/modules/hello/helpers/greetings.ts +14 -0
- 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 -4
- 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 +49 -34
- 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 +36 -19
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Redis } from "ioredis";
|
|
2
|
-
import type {
|
|
2
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
3
3
|
|
|
4
4
|
import { MemoryCache } from "./memory-cache.ts";
|
|
5
5
|
import { RedisCache } from "./redis-cache.ts";
|
|
@@ -13,18 +13,19 @@ export { RedisCache } from "./redis-cache.ts";
|
|
|
13
13
|
let active: { cache: Cache; redis?: Redis } | undefined;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* The app's one cache, picked from the
|
|
17
|
-
*
|
|
18
|
-
* created, process-wide singleton.
|
|
16
|
+
* The app's one cache, picked from the core module's environment values:
|
|
17
|
+
* redis when `config.core.redisUrl` is set, in-process memory otherwise
|
|
18
|
+
* (tests, minimal setups). Lazily created, process-wide singleton.
|
|
19
19
|
*/
|
|
20
|
-
export function appCache(
|
|
20
|
+
export function appCache({ config }: Pick<Ctx, "config">): Cache {
|
|
21
21
|
if (active) return active.cache;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
const redisUrl = config.core?.redisUrl;
|
|
24
|
+
if (redisUrl) {
|
|
24
25
|
// Fail a command fast instead of queueing it and retrying for seconds: the
|
|
25
26
|
// cache is consulted on every request, so a redis outage must cost a miss,
|
|
26
27
|
// not a stalled request. RedisCache turns the rejection into that miss.
|
|
27
|
-
const redis = new Redis(
|
|
28
|
+
const redis = new Redis(redisUrl, {
|
|
28
29
|
maxRetriesPerRequest: 1,
|
|
29
30
|
enableOfflineQueue: false,
|
|
30
31
|
});
|
|
@@ -3,9 +3,9 @@ import type { RequestHandler } from "express";
|
|
|
3
3
|
/**
|
|
4
4
|
* Middleware built on its first request rather than while the routes
|
|
5
5
|
* file runs. Inspection (`tulipes routes`, `tulipes spec`) builds every
|
|
6
|
-
* router without
|
|
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());
|
|
@@ -2,26 +2,30 @@ import { action, defineAgent } from "@tulipes/core/agent";
|
|
|
2
2
|
import { requireModels } from "@tulipes/core/boot";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
|
|
5
|
+
import { findGreetings } from "../helpers/greetings.ts";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
* What
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* What this module offers to agents — an in-app agent module reads it from
|
|
9
|
+
* `ctx.agent.manifest()`, Claude Code through `tulipes-mcp`. Routes are the
|
|
10
|
+
* API and are never tools: an action is declared here, with its own id (a
|
|
11
|
+
* permission granted in module.acl.ts), input schema and handler, and
|
|
12
|
+
* shares its logic with the routes through helpers/. `pnpm tulipes agent
|
|
13
|
+
* manifest` prints the result.
|
|
11
14
|
*/
|
|
12
15
|
export default defineAgent({
|
|
13
16
|
summary: "Greets callers and counts how often each greeting is used.",
|
|
14
|
-
|
|
15
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
actions: [
|
|
18
|
+
action({
|
|
19
|
+
id: "hello:web:greetings",
|
|
20
|
+
description: "The stored greetings, with how often each was used",
|
|
21
|
+
readOnly: true,
|
|
22
|
+
handler: async (ctx) => (await findGreetings(ctx))[0],
|
|
23
|
+
}),
|
|
19
24
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// then the schema validates, then this runs.
|
|
25
|
+
// The ACL decides on the id (admin holds hello:* in the auth module's
|
|
26
|
+
// ACL), then the schema validates, then this runs.
|
|
23
27
|
action({
|
|
24
|
-
id: "hello:reset
|
|
28
|
+
id: "hello:bo:greetings:reset",
|
|
25
29
|
description: "Reset the usage counter of every greeting to zero",
|
|
26
30
|
input: z.object({ confirm: z.literal(true).describe("Set to true to confirm the reset") }),
|
|
27
31
|
handler: async (ctx) => {
|
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,7 +2,9 @@ import type { Request, RequestHandler, Response } from "express";
|
|
|
2
2
|
import { requireModels, requireQueues, type Ctx } from "@tulipes/core/boot";
|
|
3
3
|
import { HttpError } from "@tulipes/core/http";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { findGreetings } from "../../helpers/greetings.ts";
|
|
6
|
+
|
|
7
|
+
export class HelloGreetingsController {
|
|
6
8
|
readonly #ctx: Ctx;
|
|
7
9
|
|
|
8
10
|
constructor(ctx: Ctx) {
|
|
@@ -14,13 +16,7 @@ export class GreetingsController {
|
|
|
14
16
|
// ?page and ?per_page, already clamped by config.pagination — so
|
|
15
17
|
// every list endpoint in every module paginates the same way.
|
|
16
18
|
const { per_page, skip } = req.page;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const [greetings, total_items] = await Promise.all([
|
|
20
|
-
Greeting.find().select("name message times_used -_id")
|
|
21
|
-
.skip(skip).limit(per_page).lean(),
|
|
22
|
-
Greeting.countDocuments(),
|
|
23
|
-
]);
|
|
19
|
+
const [greetings, total_items] = await findGreetings(this.#ctx, { skip, limit: per_page });
|
|
24
20
|
|
|
25
21
|
// Passing total_items is what marks this a page: respond fills in
|
|
26
22
|
// page, per_page and total_pages from the request and the count.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { requireModels, type Ctx } from "@tulipes/core/boot";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One query, two callers: the list route pages it, the agent action reads
|
|
5
|
+
* the first page. Logic a route and an action share lives here, so neither
|
|
6
|
+
* calls the other.
|
|
7
|
+
*/
|
|
8
|
+
export function findGreetings(ctx: Ctx, { skip = 0, limit = 20 } = {}) {
|
|
9
|
+
const Greeting = requireModels(ctx).get("Greeting");
|
|
10
|
+
return Promise.all([
|
|
11
|
+
Greeting.find().select("name message times_used -_id").skip(skip).limit(limit).lean(),
|
|
12
|
+
Greeting.countDocuments(),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import type { AclBuilder } from "@tulipes/core/acl";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Roles are global and defined once by the
|
|
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,18 +13,17 @@ import { logQuery } from "../schemas/settings.schemas.ts";
|
|
|
13
13
|
export default function settingsRoutes(ctx: BackendCtx): void {
|
|
14
14
|
const { rai, routes, config } = ctx;
|
|
15
15
|
const router = Router();
|
|
16
|
-
const settings = new
|
|
16
|
+
const settings = new BoSettingsController(ctx);
|
|
17
17
|
|
|
18
18
|
router.get(
|
|
19
19
|
"/logs/stream",
|
|
20
20
|
rai({
|
|
21
|
-
id: "settings:logs",
|
|
21
|
+
id: "settings:bo:logs",
|
|
22
22
|
name: "Stream logs",
|
|
23
23
|
description: "Server-Sent Events over the log files — backend or worker",
|
|
24
24
|
folder: "settings",
|
|
25
25
|
query: logQuery,
|
|
26
26
|
// A stream is not a tool: it never ends on its own.
|
|
27
|
-
agent: false,
|
|
28
27
|
}),
|
|
29
28
|
settings.logStream(),
|
|
30
29
|
);
|