@tulipes/core 0.1.2 → 0.1.4
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 +17 -1
- package/dist/cli/init.d.ts +17 -8
- package/dist/cli/init.js +651 -70
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.js +6 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/http/error-handler.js +21 -0
- package/dist/http/error-handler.js.map +1 -1
- package/package.json +3 -2
- package/templates/CLAUDE.md +72 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +64 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +82 -0
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +78 -0
- package/templates/claude/skills/tulipes-model/SKILL.md +75 -0
- package/templates/claude/skills/tulipes-module/SKILL.md +60 -0
- package/templates/claude/skills/tulipes-permissions/SKILL.md +61 -0
- package/templates/claude/skills/tulipes-queue/SKILL.md +62 -0
- package/templates/claude/skills/tulipes-socket/SKILL.md +55 -0
package/dist/cli/init.js
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
/**
|
|
5
|
-
* `tulipes init [dir]` — scaffold a complete application
|
|
6
|
-
* ZERO infrastructure: the generated core module declares no MONGO_URI or
|
|
7
|
-
* REDIS_URL, and with no models and no queue files the pipeline skips both
|
|
8
|
-
* engines legitimately. `yarn install && yarn dev` must always succeed on a
|
|
9
|
-
* bare machine; databases arrive later as one meta.variables.json entry.
|
|
5
|
+
* `tulipes init [dir] [--full]` — scaffold a complete application.
|
|
10
6
|
*
|
|
11
7
|
* Three modules ship: core (sys 0 — env contract, roles, request ids),
|
|
12
|
-
* security (sys 10 —
|
|
13
|
-
*
|
|
8
|
+
* security (sys 10 — helmet, cors, logging, body parsing) and hello, a
|
|
9
|
+
* worked example of every module contract.
|
|
10
|
+
*
|
|
11
|
+
* By default the app boots with ZERO infrastructure: no MONGO_URI or
|
|
12
|
+
* REDIS_URL is declared, and with no models and no queue files the
|
|
13
|
+
* pipeline skips both engines legitimately, so `yarn install && yarn dev`
|
|
14
|
+
* always succeeds on a bare machine.
|
|
15
|
+
*
|
|
16
|
+
* `--full` adds the contracts that cannot work without infrastructure —
|
|
17
|
+
* models, bootstrap tasks and queues — along with the variables they
|
|
18
|
+
* require. That app needs mongo and redis running to boot.
|
|
14
19
|
*/
|
|
15
|
-
export async function runInit(rootDir, target) {
|
|
20
|
+
export async function runInit(rootDir, target, options = {}) {
|
|
16
21
|
const dir = resolve(rootDir, target ?? ".");
|
|
17
22
|
const name = sanitizeName(basename(dir));
|
|
23
|
+
const full = options.full ?? false;
|
|
18
24
|
if (existsSync(dir) && readdirSync(dir).some((entry) => entry !== ".git")) {
|
|
19
25
|
console.error(`refusing to init: ${dir} is not empty`);
|
|
20
26
|
process.exitCode = 1;
|
|
21
27
|
return;
|
|
22
28
|
}
|
|
23
29
|
const coreVersion = ownVersion();
|
|
24
|
-
const files = renderProject(name, coreVersion);
|
|
30
|
+
const files = renderProject(name, coreVersion, full);
|
|
25
31
|
for (const [relPath, content] of files) {
|
|
26
32
|
const filePath = join(dir, relPath);
|
|
27
33
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
28
34
|
writeFileSync(filePath, content);
|
|
29
35
|
console.log(` create ${relPath}`);
|
|
30
36
|
}
|
|
37
|
+
copyAgentGuides(dir);
|
|
31
38
|
console.log([
|
|
32
39
|
"",
|
|
33
40
|
`Project "${name}" is ready. Next:`,
|
|
@@ -39,8 +46,9 @@ export async function runInit(rootDir, target) {
|
|
|
39
46
|
"",
|
|
40
47
|
" → http://localhost:3000/hello",
|
|
41
48
|
"",
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
full
|
|
50
|
+
? "Needs mongo and redis running (MONGO_URI / REDIS_URL in .envs/.env.development).\nRun `yarn worker` in a second terminal to consume queued jobs."
|
|
51
|
+
: "No database or redis needed to boot — add MONGO_URI / REDIS_URL to\nmodules/core/meta.variables.json when your first model or queue lands,\nor scaffold them now with `tulipes init --full`.",
|
|
44
52
|
].filter((line) => line !== undefined).join("\n"));
|
|
45
53
|
}
|
|
46
54
|
/**
|
|
@@ -49,18 +57,47 @@ export async function runInit(rootDir, target) {
|
|
|
49
57
|
* the exports map deliberately doesn't expose package.json.
|
|
50
58
|
*/
|
|
51
59
|
function ownVersion() {
|
|
52
|
-
const
|
|
53
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
60
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
54
61
|
return pkg.version;
|
|
55
62
|
}
|
|
63
|
+
function packageRoot() {
|
|
64
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Instructions for AI coding agents working in the generated app: an
|
|
68
|
+
* always-loaded CLAUDE.md plus task-scoped skills. They ship as real
|
|
69
|
+
* markdown in the package (templates/) rather than string literals here,
|
|
70
|
+
* so they stay editable and reviewable as documents.
|
|
71
|
+
*
|
|
72
|
+
* The source tree uses `claude/` and is written out as `.claude/`: npm
|
|
73
|
+
* has a long history of mangling dot-prefixed paths inside published
|
|
74
|
+
* tarballs, and this sidesteps it entirely.
|
|
75
|
+
*/
|
|
76
|
+
function copyAgentGuides(dir) {
|
|
77
|
+
const templates = join(packageRoot(), "templates");
|
|
78
|
+
if (!existsSync(templates))
|
|
79
|
+
return; // tolerate a stripped install
|
|
80
|
+
const claudeMd = join(templates, "CLAUDE.md");
|
|
81
|
+
if (existsSync(claudeMd)) {
|
|
82
|
+
cpSync(claudeMd, join(dir, "CLAUDE.md"));
|
|
83
|
+
console.log(" create CLAUDE.md");
|
|
84
|
+
}
|
|
85
|
+
const skills = join(templates, "claude");
|
|
86
|
+
if (existsSync(skills)) {
|
|
87
|
+
cpSync(skills, join(dir, ".claude"), { recursive: true });
|
|
88
|
+
for (const skill of readdirSync(join(skills, "skills"))) {
|
|
89
|
+
console.log(` create .claude/skills/${skill}/SKILL.md`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
56
93
|
function sanitizeName(raw) {
|
|
57
94
|
const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
58
95
|
return name || "tulipes-app";
|
|
59
96
|
}
|
|
60
97
|
const json = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
61
|
-
function renderProject(name, coreVersion) {
|
|
98
|
+
function renderProject(name, coreVersion, full) {
|
|
62
99
|
const core = `^${coreVersion}`;
|
|
63
|
-
|
|
100
|
+
const files = [
|
|
64
101
|
["package.json", json({
|
|
65
102
|
name,
|
|
66
103
|
private: true,
|
|
@@ -76,8 +113,12 @@ function renderProject(name, coreVersion) {
|
|
|
76
113
|
check: "tulipes env:check",
|
|
77
114
|
typecheck: "tsc --noEmit",
|
|
78
115
|
},
|
|
79
|
-
dependencies: {
|
|
116
|
+
dependencies: {
|
|
117
|
+
"@tulipes/core": core,
|
|
118
|
+
express: "^5",
|
|
119
|
+
},
|
|
80
120
|
devDependencies: {
|
|
121
|
+
"@types/express": "^5",
|
|
81
122
|
"@types/node": "^24",
|
|
82
123
|
tsx: "^4",
|
|
83
124
|
typescript: "^7",
|
|
@@ -98,6 +139,20 @@ function renderProject(name, coreVersion) {
|
|
|
98
139
|
},
|
|
99
140
|
include: ["app.ts", "worker.ts", "types", "modules", "config"],
|
|
100
141
|
})],
|
|
142
|
+
[".yarnrc.yml", [
|
|
143
|
+
`nodeLinker: node-modules`,
|
|
144
|
+
``,
|
|
145
|
+
`# tsx (esbuild) and bullmq's native helpers need their install scripts;`,
|
|
146
|
+
`# stated explicitly so a restrictive global yarn config can't break dev.`,
|
|
147
|
+
`enableScripts: true`,
|
|
148
|
+
``,
|
|
149
|
+
`# Yarn's release-age gate quarantines freshly published packages, which`,
|
|
150
|
+
`# would block brand-new @tulipes releases from installing. Preapprove the`,
|
|
151
|
+
`# framework scope only — the gate stays active for everything else.`,
|
|
152
|
+
`npmPreapprovedPackages:`,
|
|
153
|
+
` - "@tulipes/*"`,
|
|
154
|
+
``,
|
|
155
|
+
].join("\n")],
|
|
101
156
|
[".gitignore", [
|
|
102
157
|
"node_modules/",
|
|
103
158
|
"dist/",
|
|
@@ -128,18 +183,156 @@ function renderProject(name, coreVersion) {
|
|
|
128
183
|
`import type { AppConfigFn } from "@tulipes/core/boot";`,
|
|
129
184
|
``,
|
|
130
185
|
`/**`,
|
|
131
|
-
` *
|
|
132
|
-
`
|
|
186
|
+
` * ── The app-wide config seed ──────────────────────────────────────────`,
|
|
187
|
+
` *`,
|
|
188
|
+
` * Runs FIRST in boot phase 6, before any module.config.ts factory, and`,
|
|
189
|
+
` * whatever it returns becomes the base of the global config. Every`,
|
|
190
|
+
` * module then reads it through \`ctx.config.<key>\`:`,
|
|
191
|
+
` *`,
|
|
192
|
+
` * export default function usersRoutes({ config }: Ctx) {`,
|
|
193
|
+
` * const router = Router();`,
|
|
194
|
+
` * router.get(\`\${config.api!.prefix}/users\`, handler);`,
|
|
195
|
+
` * }`,
|
|
196
|
+
` *`,
|
|
197
|
+
` * The framework itself reads exactly ONE field: \`app.name\`, for the`,
|
|
198
|
+
` * startup banner. Everything else below is convention — YOUR keys, read`,
|
|
199
|
+
` * by YOUR modules. Rename them, delete them, invent new ones freely;`,
|
|
200
|
+
` * nothing in core depends on them.`,
|
|
201
|
+
` *`,
|
|
202
|
+
` * Config that belongs to one feature does NOT go here — it goes in that`,
|
|
203
|
+
` * module's \`module.config.ts\`, where codegen types it automatically.`,
|
|
204
|
+
` * This file is for values two or more modules share.`,
|
|
205
|
+
` *`,
|
|
206
|
+
` * Values can be hardcoded (below) or driven by the environment. To make`,
|
|
207
|
+
` * any of them env-driven, declare the variable in a module's`,
|
|
208
|
+
` * meta.variables.json first, then read it here with`,
|
|
209
|
+
` * \`Environment.get("NAME")\` — reads of undeclared variables throw on`,
|
|
210
|
+
` * purpose, so a typo can never boot.`,
|
|
133
211
|
` */`,
|
|
134
|
-
|
|
212
|
+
`// \`satisfies\` (not a type annotation) so TypeScript keeps the exact`,
|
|
213
|
+
`// shape of this object for the augmentation at the bottom of the file.`,
|
|
214
|
+
`const appConfig = ((Environment) => ({`,
|
|
215
|
+
` /**`,
|
|
216
|
+
` * Identity. \`name\` is the one field core consumes — it titles the`,
|
|
217
|
+
` * startup banner ("▲ ${name}"). The rest is yours: stamp \`version\``,
|
|
218
|
+
` * into a /health payload, switch behaviour on \`env\`, etc.`,
|
|
219
|
+
` */`,
|
|
135
220
|
` app: {`,
|
|
136
221
|
` name: ${JSON.stringify(name)},`,
|
|
222
|
+
` version: "0.1.0",`,
|
|
223
|
+
` description: "A Tulipes API",`,
|
|
224
|
+
` // development | staging | production | test — resolved from APP_ENV`,
|
|
137
225
|
` env: Environment.appEnv,`,
|
|
226
|
+
` // Read a declared variable (PORT lives in modules/core/meta.variables.json)`,
|
|
227
|
+
` port: Environment.get("PORT"),`,
|
|
138
228
|
` },`,
|
|
139
|
-
|
|
229
|
+
``,
|
|
230
|
+
` /**`,
|
|
231
|
+
` * Route prefixing. Modules build their paths from this instead of`,
|
|
232
|
+
` * hardcoding "/api/v1", so versioning the whole API is a one-line`,
|
|
233
|
+
` * change here: \`router.get(\\\`\${config.api!.prefix}/users\\\`, …)\`.`,
|
|
234
|
+
` */`,
|
|
235
|
+
` api: {`,
|
|
236
|
+
` prefix: "/api/v1",`,
|
|
237
|
+
` // Where the docs/collection live, if you expose them`,
|
|
238
|
+
` docsPath: "/api/docs",`,
|
|
239
|
+
` },`,
|
|
240
|
+
``,
|
|
241
|
+
` /**`,
|
|
242
|
+
` * HTTP behaviour consumed by the sys security module`,
|
|
243
|
+
` * (modules/security/routes/security.routes.ts). Change the body limit`,
|
|
244
|
+
` * or CORS origins here rather than editing that file.`,
|
|
245
|
+
` */`,
|
|
246
|
+
` http: {`,
|
|
247
|
+
` // Passed to express.json({ limit }) — a parser without a cap is a`,
|
|
248
|
+
` // memory-exhaustion invitation.`,
|
|
249
|
+
` bodyLimit: "1mb",`,
|
|
250
|
+
` // Browser origins allowed to call this API. Add a cors() middleware`,
|
|
251
|
+
` // in the security module and feed it this list.`,
|
|
252
|
+
` corsOrigins: ["http://localhost:5173"],`,
|
|
253
|
+
` // Set true behind nginx/a load balancer so req.ip is the real client`,
|
|
254
|
+
` // (app.set("trust proxy", config.http!.trustProxy)).`,
|
|
255
|
+
` trustProxy: false,`,
|
|
256
|
+
` // Seconds a request may run before you abort it, if you add a timeout`,
|
|
257
|
+
` // middleware.`,
|
|
258
|
+
` requestTimeout: 30,`,
|
|
259
|
+
` },`,
|
|
260
|
+
``,
|
|
261
|
+
` /**`,
|
|
262
|
+
` * List-endpoint defaults, so every module paginates identically and`,
|
|
263
|
+
` * no caller can ask for 10 000 rows at once.`,
|
|
264
|
+
` */`,
|
|
265
|
+
` pagination: {`,
|
|
266
|
+
` defaultLimit: 20,`,
|
|
267
|
+
` maxLimit: 100,`,
|
|
268
|
+
` },`,
|
|
269
|
+
``,
|
|
270
|
+
` /**`,
|
|
271
|
+
` * Cross-cutting security knobs. Secrets themselves never live here —`,
|
|
272
|
+
` * declare them as \`"type": "secret"\` variables in meta.variables.json`,
|
|
273
|
+
` * (redacted in logs and the banner) and read them where needed.`,
|
|
274
|
+
` */`,
|
|
275
|
+
` security: {`,
|
|
276
|
+
` // Token lifetimes for an auth module, in seconds`,
|
|
277
|
+
` accessTokenTtl: 900, // 15 minutes`,
|
|
278
|
+
` refreshTokenTtl: 2_592_000, // 30 days`,
|
|
279
|
+
` // bcrypt/argon cost factor`,
|
|
280
|
+
` passwordRounds: 12,`,
|
|
281
|
+
` // Rate limiting, if you mount a limiter in the security module`,
|
|
282
|
+
` rateLimit: { windowSeconds: 60, max: 100 },`,
|
|
283
|
+
` },`,
|
|
284
|
+
``,
|
|
285
|
+
` /**`,
|
|
286
|
+
` * Public-facing URLs. Anything that builds a link a human will click —`,
|
|
287
|
+
` * password-reset emails, webhook callbacks, OAuth redirects — reads`,
|
|
288
|
+
` * these instead of guessing the host from a request.`,
|
|
289
|
+
` */`,
|
|
290
|
+
` urls: {`,
|
|
291
|
+
` api: \`http://localhost:\${Environment.get("PORT")}\`,`,
|
|
292
|
+
` frontend: "http://localhost:5173",`,
|
|
293
|
+
` },`,
|
|
294
|
+
``,
|
|
295
|
+
` /**`,
|
|
296
|
+
` * Upload constraints shared by every module that accepts files.`,
|
|
297
|
+
` */`,
|
|
298
|
+
` uploads: {`,
|
|
299
|
+
` maxSizeMb: 10,`,
|
|
300
|
+
` allowedMimeTypes: ["image/png", "image/jpeg", "application/pdf"],`,
|
|
301
|
+
` },`,
|
|
302
|
+
``,
|
|
303
|
+
` /**`,
|
|
304
|
+
` * Feature flags. Cheap way to ship dark code: a module checks`,
|
|
305
|
+
` * \`config.features!.signup\` before mounting a route. Flip per`,
|
|
306
|
+
` * environment by reading a declared boolean variable instead of a`,
|
|
307
|
+
` * literal.`,
|
|
308
|
+
` */`,
|
|
309
|
+
` features: {`,
|
|
310
|
+
` signup: true,`,
|
|
311
|
+
` maintenanceMode: false,`,
|
|
312
|
+
` },`,
|
|
313
|
+
``,
|
|
314
|
+
` /**`,
|
|
315
|
+
` * Localization defaults for modules that render text (emails, errors).`,
|
|
316
|
+
` */`,
|
|
317
|
+
` i18n: {`,
|
|
318
|
+
` defaultLocale: "en",`,
|
|
319
|
+
` supportedLocales: ["en", "fr"],`,
|
|
320
|
+
` },`,
|
|
321
|
+
`})) satisfies AppConfigFn;`,
|
|
140
322
|
``,
|
|
141
323
|
`export default appConfig;`,
|
|
142
324
|
``,
|
|
325
|
+
`/**`,
|
|
326
|
+
` * Types the keys above at every call site: \`ctx.config.api?.prefix\` is a`,
|
|
327
|
+
` * string, not \`unknown\`. \`tulipes sync\` does this automatically for`,
|
|
328
|
+
` * module.config.ts factories; the app-level seed types itself here.`,
|
|
329
|
+
` * Optional (Partial) because the config object is empty until this`,
|
|
330
|
+
` * factory has run.`,
|
|
331
|
+
` */`,
|
|
332
|
+
`declare module "@tulipes/core/config" {`,
|
|
333
|
+
` interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}`,
|
|
334
|
+
`}`,
|
|
335
|
+
``,
|
|
143
336
|
].join("\n")],
|
|
144
337
|
[".envs/.env.development", [
|
|
145
338
|
`# Development mode — committed on purpose; values here are dev defaults.`,
|
|
@@ -149,6 +342,17 @@ function renderProject(name, coreVersion) {
|
|
|
149
342
|
`PORT=3000`,
|
|
150
343
|
`LOG_LEVEL=debug`,
|
|
151
344
|
``,
|
|
345
|
+
`# Browser origins allowed to call this API. Empty (the default) allows`,
|
|
346
|
+
`# none; "*" allows any but forbids credentials.`,
|
|
347
|
+
`CORS_ORIGINS=http://localhost:5173`,
|
|
348
|
+
``,
|
|
349
|
+
...(full
|
|
350
|
+
? [
|
|
351
|
+
`MONGO_URI=mongodb://127.0.0.1:27017/${name}`,
|
|
352
|
+
`REDIS_URL=redis://127.0.0.1:6379`,
|
|
353
|
+
``,
|
|
354
|
+
]
|
|
355
|
+
: []),
|
|
152
356
|
].join("\n")],
|
|
153
357
|
// ── modules/core — sys tier, priority 0: the very first router ─────────
|
|
154
358
|
["modules/core/package.json", json({
|
|
@@ -168,6 +372,26 @@ function renderProject(name, coreVersion) {
|
|
|
168
372
|
description: "HTTP port the web process listens on",
|
|
169
373
|
default: 3000,
|
|
170
374
|
},
|
|
375
|
+
// Infra connection strings live in the sys core module because every
|
|
376
|
+
// feature shares them — one owner per variable, no duplicates.
|
|
377
|
+
...(full
|
|
378
|
+
? [
|
|
379
|
+
{
|
|
380
|
+
name: "MONGO_URI",
|
|
381
|
+
type: "url",
|
|
382
|
+
group: "database",
|
|
383
|
+
required: true,
|
|
384
|
+
description: "MongoDB connection string",
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "REDIS_URL",
|
|
388
|
+
type: "url",
|
|
389
|
+
group: "redis",
|
|
390
|
+
required: true,
|
|
391
|
+
description: "Redis connection string (queues, cache)",
|
|
392
|
+
},
|
|
393
|
+
]
|
|
394
|
+
: []),
|
|
171
395
|
],
|
|
172
396
|
})],
|
|
173
397
|
["modules/core/module.acl.ts", [
|
|
@@ -211,11 +435,14 @@ function renderProject(name, coreVersion) {
|
|
|
211
435
|
tulipes: { tier: "sys", priority: 10 },
|
|
212
436
|
dependencies: {
|
|
213
437
|
"@tulipes/core": core,
|
|
438
|
+
cors: "^2",
|
|
214
439
|
express: "^5",
|
|
440
|
+
helmet: "^8",
|
|
215
441
|
pino: "^9",
|
|
216
442
|
"pino-http": "^10",
|
|
217
443
|
"pino-pretty": "^13",
|
|
218
444
|
},
|
|
445
|
+
devDependencies: { "@types/cors": "^2" },
|
|
219
446
|
})],
|
|
220
447
|
["modules/security/meta.variables.json", json({
|
|
221
448
|
variables: [
|
|
@@ -227,28 +454,85 @@ function renderProject(name, coreVersion) {
|
|
|
227
454
|
description: "Minimum pino log level",
|
|
228
455
|
default: "info",
|
|
229
456
|
},
|
|
457
|
+
{
|
|
458
|
+
name: "CORS_ORIGINS",
|
|
459
|
+
type: "string",
|
|
460
|
+
group: "cors",
|
|
461
|
+
description: 'Comma-separated browser origins allowed to call this API; "*" allows any, empty allows none (same-origin only)',
|
|
462
|
+
default: "",
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "CORS_CREDENTIALS",
|
|
466
|
+
type: "boolean",
|
|
467
|
+
group: "cors",
|
|
468
|
+
description: "Allow cookies and Authorization headers on cross-origin requests (cannot be combined with CORS_ORIGINS=*)",
|
|
469
|
+
default: false,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "HSTS_MAX_AGE",
|
|
473
|
+
type: "number",
|
|
474
|
+
group: "hardening",
|
|
475
|
+
description: "Strict-Transport-Security max-age in seconds; 0 disables the header (browsers only honour it over HTTPS)",
|
|
476
|
+
default: 15_552_000,
|
|
477
|
+
},
|
|
230
478
|
],
|
|
231
479
|
})],
|
|
232
|
-
["modules/security/helpers/
|
|
233
|
-
`import type {
|
|
480
|
+
["modules/security/helpers/hardening.ts", [
|
|
481
|
+
`import type { CorsOptions } from "cors";`,
|
|
482
|
+
`import type { HelmetOptions } from "helmet";`,
|
|
483
|
+
`import type { Environment } from "@tulipes/core/env";`,
|
|
234
484
|
``,
|
|
235
485
|
`/**`,
|
|
236
|
-
` *
|
|
237
|
-
` *
|
|
238
|
-
` *
|
|
486
|
+
` * Helmet tuned for an API rather than a web page. Its defaults assume a`,
|
|
487
|
+
` * site serving HTML and its own assets; this one serves JSON and`,
|
|
488
|
+
` * nothing else, so the policy can be far stricter.`,
|
|
239
489
|
` */`,
|
|
240
|
-
`export function
|
|
241
|
-
`
|
|
242
|
-
|
|
243
|
-
`
|
|
244
|
-
`
|
|
245
|
-
` //
|
|
246
|
-
`
|
|
247
|
-
`
|
|
248
|
-
`
|
|
490
|
+
`export function helmetOptions(environment: Environment): HelmetOptions {`,
|
|
491
|
+
` const maxAge = Number(environment.get("HSTS_MAX_AGE"));`,
|
|
492
|
+
``,
|
|
493
|
+
` return {`,
|
|
494
|
+
` // An API serves no active content: deny every resource type`,
|
|
495
|
+
` // outright, which defuses any HTML-ish response a bug produces.`,
|
|
496
|
+
` contentSecurityPolicy: {`,
|
|
497
|
+
` useDefaults: false,`,
|
|
498
|
+
` directives: { "default-src": ["'none'"], "frame-ancestors": ["'none'"] },`,
|
|
499
|
+
` },`,
|
|
500
|
+
` // Browsers only honour HSTS over HTTPS, so it is inert in local dev`,
|
|
501
|
+
` // — but a max-age of 0 lets a deployment switch it off deliberately.`,
|
|
502
|
+
` hsts: maxAge > 0 ? { maxAge, includeSubDomains: true } : false,`,
|
|
503
|
+
` // Helmet defaults to SAMEORIGIN; an API is never framed at all.`,
|
|
504
|
+
` frameguard: { action: "deny" },`,
|
|
505
|
+
` // Irrelevant without a browsing context, and each costs a header.`,
|
|
506
|
+
` originAgentCluster: false,`,
|
|
507
|
+
` crossOriginEmbedderPolicy: false,`,
|
|
249
508
|
` };`,
|
|
250
509
|
`}`,
|
|
251
510
|
``,
|
|
511
|
+
`/**`,
|
|
512
|
+
` * CORS from the environment, because allowed origins differ per`,
|
|
513
|
+
` * deployment. Fail-closed: the default is an empty list, meaning no`,
|
|
514
|
+
` * cross-origin browser may call this API until one is named.`,
|
|
515
|
+
` */`,
|
|
516
|
+
`export function corsOptions(environment: Environment): CorsOptions {`,
|
|
517
|
+
` const raw = String(environment.get("CORS_ORIGINS")).trim();`,
|
|
518
|
+
` const credentials = Boolean(environment.get("CORS_CREDENTIALS"));`,
|
|
519
|
+
``,
|
|
520
|
+
` if (raw === "*") {`,
|
|
521
|
+
` // "*" and credentials are mutually exclusive per the CORS spec — the`,
|
|
522
|
+
` // browser rejects the response, so refuse the combination at boot`,
|
|
523
|
+
` // rather than shipping an API that silently fails in production.`,
|
|
524
|
+
` if (credentials) {`,
|
|
525
|
+
` throw new Error(`,
|
|
526
|
+
` 'CORS_ORIGINS="*" cannot be combined with CORS_CREDENTIALS=true — list the origins explicitly',`,
|
|
527
|
+
` );`,
|
|
528
|
+
` }`,
|
|
529
|
+
` return { origin: true, credentials: false };`,
|
|
530
|
+
` }`,
|
|
531
|
+
``,
|
|
532
|
+
` const origins = raw.split(",").map((origin) => origin.trim()).filter(Boolean);`,
|
|
533
|
+
` return { origin: origins, credentials };`,
|
|
534
|
+
`}`,
|
|
535
|
+
``,
|
|
252
536
|
].join("\n")],
|
|
253
537
|
["modules/security/helpers/logger.ts", [
|
|
254
538
|
`import { pino, type Logger } from "pino";`,
|
|
@@ -278,26 +562,35 @@ function renderProject(name, coreVersion) {
|
|
|
278
562
|
``,
|
|
279
563
|
].join("\n")],
|
|
280
564
|
["modules/security/routes/security.routes.ts", [
|
|
565
|
+
`import cors from "cors";`,
|
|
281
566
|
`import express from "express";`,
|
|
567
|
+
`import helmet from "helmet";`,
|
|
282
568
|
`import { pinoHttp } from "pino-http";`,
|
|
283
569
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
284
570
|
``,
|
|
285
|
-
`import {
|
|
571
|
+
`import { corsOptions, helmetOptions } from "../helpers/hardening.js";`,
|
|
286
572
|
`import { logger } from "../helpers/logger.js";`,
|
|
287
573
|
``,
|
|
288
574
|
`/**`,
|
|
289
575
|
` * The app's request-hardening stack. Sys tier, so this mounts ahead of`,
|
|
290
576
|
` * every app-tier router — the framework core mounts no middleware of`,
|
|
291
577
|
` * its own; this module IS the pipeline's head.`,
|
|
578
|
+
` *`,
|
|
579
|
+
` * Order matters:`,
|
|
580
|
+
` * helmet — headers on every response, including errors and preflights`,
|
|
581
|
+
` * cors — must answer OPTIONS preflights before anything parses a body`,
|
|
582
|
+
` * pino — logs the request once the two above have had their say`,
|
|
583
|
+
` * json — parsing last, so a rejected origin never reaches the parser`,
|
|
292
584
|
` */`,
|
|
293
|
-
`export default function securityRoutes({ app, Environment }: Ctx): void {`,
|
|
294
|
-
` app!.use(
|
|
585
|
+
`export default function securityRoutes({ app, Environment, config }: Ctx): void {`,
|
|
586
|
+
` app!.use(helmet(helmetOptions(Environment)));`,
|
|
587
|
+
` app!.use(cors(corsOptions(Environment)));`,
|
|
295
588
|
``,
|
|
296
589
|
` app!.use(`,
|
|
297
590
|
` pinoHttp({`,
|
|
298
591
|
` logger: logger(Environment),`,
|
|
299
|
-
` // The core module (priority 0) stamps X-Request-Id
|
|
300
|
-
` // it so
|
|
592
|
+
` // The core module (priority 0) stamps X-Request-Id before this`,
|
|
593
|
+
` // runs — reuse it so logs and response headers tell one story.`,
|
|
301
594
|
` genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),`,
|
|
302
595
|
` serializers: {`,
|
|
303
596
|
` req: (req: { method: string; url: string }) => ({`,
|
|
@@ -310,64 +603,352 @@ function renderProject(name, coreVersion) {
|
|
|
310
603
|
` );`,
|
|
311
604
|
``,
|
|
312
605
|
` // Body-size cap is a security control too — a parser without a limit`,
|
|
313
|
-
` // is a memory-exhaustion invitation.`,
|
|
314
|
-
` app!.use(express.json({ limit: "1mb" }));`,
|
|
606
|
+
` // is a memory-exhaustion invitation. Tuned from config/app.config.ts.`,
|
|
607
|
+
` app!.use(express.json({ limit: config.http?.bodyLimit ?? "1mb" }));`,
|
|
315
608
|
`}`,
|
|
316
609
|
``,
|
|
317
610
|
].join("\n")],
|
|
318
|
-
// ── modules/hello — app tier: the proof route ──────────────────────────
|
|
319
611
|
["modules/hello/package.json", json({
|
|
320
612
|
name: `@app/hello`,
|
|
321
613
|
version: "0.0.0",
|
|
322
614
|
private: true,
|
|
323
615
|
type: "module",
|
|
324
616
|
tulipes: { tier: "app", priority: 100, dependsOn: [] },
|
|
325
|
-
dependencies: {
|
|
617
|
+
dependencies: {
|
|
618
|
+
"@tulipes/core": core,
|
|
619
|
+
express: "^5",
|
|
620
|
+
...(full ? { mongoose: "^8" } : {}),
|
|
621
|
+
},
|
|
326
622
|
})],
|
|
623
|
+
["modules/hello/meta.variables.json", json({
|
|
624
|
+
variables: [
|
|
625
|
+
{
|
|
626
|
+
name: "HELLO_GREETING",
|
|
627
|
+
type: "string",
|
|
628
|
+
group: "hello",
|
|
629
|
+
description: "Word this module greets callers with",
|
|
630
|
+
default: "Hello",
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
})],
|
|
634
|
+
["modules/hello/module.config.ts", [
|
|
635
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
636
|
+
``,
|
|
637
|
+
`/**`,
|
|
638
|
+
` * Runs in boot phase 6. Whatever this returns is stored at`,
|
|
639
|
+
` * \`config.hello\` and typed by \`tulipes sync\`, so every other module`,
|
|
640
|
+
` * reads it without importing anything from here.`,
|
|
641
|
+
` */`,
|
|
642
|
+
`export default function helloConfig({ Environment }: Ctx) {`,
|
|
643
|
+
` return {`,
|
|
644
|
+
` greeting: Environment.get("HELLO_GREETING"),`,
|
|
645
|
+
` };`,
|
|
646
|
+
`}`,
|
|
647
|
+
``,
|
|
648
|
+
`/** After the process is fully up (server listening / worker consuming). */`,
|
|
649
|
+
`export async function onReady({ config }: Ctx): Promise<void> {`,
|
|
650
|
+
` console.log(\`[hello] ready — greeting with "\${config.hello?.greeting}"\`);`,
|
|
651
|
+
`}`,
|
|
652
|
+
``,
|
|
653
|
+
`/**`,
|
|
654
|
+
` * Graceful shutdown. Hooks run in REVERSE load order, so a module can`,
|
|
655
|
+
` * rely on its dependencies still being alive while it cleans up.`,
|
|
656
|
+
` */`,
|
|
657
|
+
`export async function onShutdown(): Promise<void> {`,
|
|
658
|
+
` // Close anything this module owns: timers, external clients, streams.`,
|
|
659
|
+
`}`,
|
|
660
|
+
``,
|
|
661
|
+
].join("\n")],
|
|
662
|
+
["modules/hello/module.acl.ts", [
|
|
663
|
+
`import type { AclBuilder } from "@tulipes/core/acl";`,
|
|
664
|
+
``,
|
|
665
|
+
`/**`,
|
|
666
|
+
` * Roles are global and defined once by the sys core module; a feature`,
|
|
667
|
+
` * module only attaches grants, and always on resources namespaced by`,
|
|
668
|
+
` * its own name ("hello:*"). Granting the same role+resource from two`,
|
|
669
|
+
` * modules crashes the boot.`,
|
|
670
|
+
` */`,
|
|
671
|
+
`export default function helloAcl(acl: AclBuilder): void {`,
|
|
672
|
+
` acl.allow("user", "hello:read");`,
|
|
673
|
+
`}`,
|
|
674
|
+
``,
|
|
675
|
+
].join("\n")],
|
|
676
|
+
["modules/hello/helpers/greeting.ts", [
|
|
677
|
+
`/**`,
|
|
678
|
+
` * Pure logic: no Express, no database, no framework. Trivial to unit`,
|
|
679
|
+
` * test, and the framework never scans this folder — helpers are plain`,
|
|
680
|
+
` * imports. Anything two modules need moves up to lib/.`,
|
|
681
|
+
` */`,
|
|
682
|
+
`export function buildGreeting(greeting: string, name: string): string {`,
|
|
683
|
+
` return \`\${greeting}, \${name}!\`;`,
|
|
684
|
+
`}`,
|
|
685
|
+
``,
|
|
686
|
+
].join("\n")],
|
|
687
|
+
["modules/hello/controllers/hello.controllers.ts", [
|
|
688
|
+
`import type { Request, Response } from "express";`,
|
|
689
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
690
|
+
``,
|
|
691
|
+
`import { buildGreeting } from "../helpers/greeting.js";`,
|
|
692
|
+
``,
|
|
693
|
+
`/**`,
|
|
694
|
+
` * Controllers hold the work so routes stay a table of contents. The`,
|
|
695
|
+
` * framework does not scan this folder — routes import these directly,`,
|
|
696
|
+
` * which keeps the wiring explicit.`,
|
|
697
|
+
` *`,
|
|
698
|
+
` * Each controller takes ctx and RETURNS the handler. That closure is`,
|
|
699
|
+
` * what makes it testable: pass a stub context, get a plain function.`,
|
|
700
|
+
` */`,
|
|
701
|
+
`export function greet({ config }: Ctx) {`,
|
|
702
|
+
` return (req: Request, res: Response): void => {`,
|
|
703
|
+
` const name = String(req.query.name ?? "world");`,
|
|
704
|
+
` res.json({`,
|
|
705
|
+
` message: buildGreeting(String(config.hello?.greeting), name),`,
|
|
706
|
+
` app: config.app?.name,`,
|
|
707
|
+
` });`,
|
|
708
|
+
` };`,
|
|
709
|
+
`}`,
|
|
710
|
+
``,
|
|
711
|
+
].join("\n")],
|
|
327
712
|
["modules/hello/routes/hello.routes.ts", [
|
|
328
713
|
`import { Router } from "express";`,
|
|
329
714
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
715
|
+
`import { HttpError } from "@tulipes/core/http";`,
|
|
330
716
|
``,
|
|
331
|
-
`
|
|
717
|
+
`import { greet } from "../controllers/hello.controllers.js";`,
|
|
718
|
+
``,
|
|
719
|
+
`export default function helloRoutes(ctx: Ctx): Router {`,
|
|
332
720
|
` const router = Router();`,
|
|
721
|
+
` const { acl, config } = ctx;`,
|
|
722
|
+
``,
|
|
723
|
+
` // Built from the shared prefix, so versioning the whole API is a`,
|
|
724
|
+
` // one-line change in config/app.config.ts.`,
|
|
725
|
+
` const base = String(config.api?.prefix ?? "");`,
|
|
726
|
+
``,
|
|
727
|
+
` router.get(\`\${base}/hello\`, greet(ctx));`,
|
|
728
|
+
``,
|
|
729
|
+
` // Literal paths must be registered before parameterised ones, or`,
|
|
730
|
+
` // "/hello/:name" would swallow this.`,
|
|
731
|
+
` router.get(\`\${base}/hello/secret\`, (req, res) => {`,
|
|
732
|
+
` // A real app resolves the role in an auth module and puts it on the`,
|
|
733
|
+
` // request; this reads a header purely so the demo is curl-able.`,
|
|
734
|
+
` const role = String(req.header("x-demo-role") ?? "guest");`,
|
|
735
|
+
` if (!acl!.can(role, "hello:read")) {`,
|
|
736
|
+
` throw new HttpError(403, \`role "\${role}" may not read hello\`);`,
|
|
737
|
+
` }`,
|
|
738
|
+
` res.json({ secret: "only roles granted hello:read see this" });`,
|
|
739
|
+
` });`,
|
|
333
740
|
``,
|
|
334
|
-
`
|
|
335
|
-
`
|
|
741
|
+
` // Throwing is the whole error story — the framework's terminal`,
|
|
742
|
+
` // handler renders it. Express 5 forwards async rejections there too.`,
|
|
743
|
+
` router.get(\`\${base}/hello/:name\`, async (req, res) => {`,
|
|
744
|
+
` if (req.params.name === "nobody") {`,
|
|
745
|
+
` throw new HttpError(404, "nobody is not a greetable name");`,
|
|
746
|
+
` }`,
|
|
747
|
+
` res.json({ message: \`\${config.hello?.greeting}, \${req.params.name}!\` });`,
|
|
336
748
|
` });`,
|
|
337
749
|
``,
|
|
338
750
|
` return router;`,
|
|
339
751
|
`}`,
|
|
340
752
|
``,
|
|
341
753
|
].join("\n")],
|
|
342
|
-
["
|
|
343
|
-
|
|
754
|
+
["modules/hello/sockets/hello.sockets.ts", [
|
|
755
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
756
|
+
`import type { SocketRegistry } from "@tulipes/core/sockets";`,
|
|
757
|
+
``,
|
|
758
|
+
`/**`,
|
|
759
|
+
` * Web mode only. A namespace is claimed by exactly one module; emit`,
|
|
760
|
+
` * from anywhere with \`ctx.sockets!.of("/hello")\`.`,
|
|
761
|
+
` *`,
|
|
762
|
+
` * Try it: wscat -c ws://localhost:3000/hello`,
|
|
763
|
+
` */`,
|
|
764
|
+
`export default function helloSockets({ config }: Ctx, sockets: SocketRegistry): void {`,
|
|
765
|
+
` sockets.namespace("/hello", (nsp) => {`,
|
|
766
|
+
` nsp.on("connection", (socket) => {`,
|
|
767
|
+
` socket.emit("greeting", {`,
|
|
768
|
+
` message: config.hello?.greeting,`,
|
|
769
|
+
` online: nsp.sockets.size,`,
|
|
770
|
+
` });`,
|
|
771
|
+
``,
|
|
772
|
+
` socket.on("ping", (ack: (reply: string) => void) => ack("pong"));`,
|
|
773
|
+
` });`,
|
|
774
|
+
` });`,
|
|
775
|
+
`}`,
|
|
776
|
+
``,
|
|
777
|
+
].join("\n")],
|
|
778
|
+
];
|
|
779
|
+
// ── --full only: the contracts that need a database or redis ───────────
|
|
780
|
+
if (full) {
|
|
781
|
+
files.push(["modules/hello/models/greeting.model.ts", [
|
|
782
|
+
`import { Schema } from "mongoose";`,
|
|
783
|
+
`import type { ModelDef } from "@tulipes/core/db";`,
|
|
344
784
|
``,
|
|
345
|
-
|
|
785
|
+
`/**`,
|
|
786
|
+
` * A model file is a DECLARATION, never a registration: the framework`,
|
|
787
|
+
` * compiles the schema on its own connection and puts it in the model`,
|
|
788
|
+
` * store. Never call mongoose.model() yourself.`,
|
|
789
|
+
` *`,
|
|
790
|
+
` * Read it anywhere the context reaches: models!.get("Greeting").`,
|
|
791
|
+
` */`,
|
|
792
|
+
`const greetingSchema = new Schema(`,
|
|
793
|
+
` {`,
|
|
794
|
+
` name: { type: String, required: true, unique: true, lowercase: true, trim: true },`,
|
|
795
|
+
` message: { type: String, required: true },`,
|
|
796
|
+
` timesUsed: { type: Number, default: 0, min: 0 },`,
|
|
797
|
+
` },`,
|
|
798
|
+
` { timestamps: true },`,
|
|
799
|
+
`);`,
|
|
346
800
|
``,
|
|
347
|
-
|
|
801
|
+
`export default { name: "Greeting", schema: greetingSchema } satisfies ModelDef;`,
|
|
348
802
|
``,
|
|
349
|
-
|
|
350
|
-
`
|
|
351
|
-
`yarn install`,
|
|
352
|
-
`yarn dev # boot the web process → http://localhost:3000/hello`,
|
|
353
|
-
"```",
|
|
803
|
+
].join("\n")], ["modules/hello/bootstrap/seed-greetings.bootstrap.ts", [
|
|
804
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
354
805
|
``,
|
|
355
|
-
|
|
356
|
-
`
|
|
357
|
-
`
|
|
358
|
-
`
|
|
806
|
+
`/**`,
|
|
807
|
+
` * Runs after models are registered and before the server accepts`,
|
|
808
|
+
` * traffic — the place for indexes, seed rows and defaults.`,
|
|
809
|
+
` *`,
|
|
810
|
+
` * MUST be idempotent: it runs on every boot of every process, web and`,
|
|
811
|
+
` * worker alike. Upsert; never blind-insert.`,
|
|
812
|
+
` */`,
|
|
813
|
+
`export default async function seedGreetings({ models }: Ctx): Promise<void> {`,
|
|
814
|
+
` const Greeting = models!.get("Greeting");`,
|
|
359
815
|
``,
|
|
360
|
-
|
|
816
|
+
` await Greeting.updateOne(`,
|
|
817
|
+
` { name: "world" },`,
|
|
818
|
+
` { $setOnInsert: { name: "world", message: "Hello, world!" } },`,
|
|
819
|
+
` { upsert: true },`,
|
|
820
|
+
` );`,
|
|
821
|
+
`}`,
|
|
361
822
|
``,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
`| \`yarn worker\` | run the queue-consumer process (needs a queue first) |`,
|
|
366
|
-
`| \`yarn sync\` | regenerate \`types/config.d.ts\` + \`.env.example\` |`,
|
|
367
|
-
`| \`yarn check\` | validate env against every module contract — CI gate |`,
|
|
368
|
-
`| \`yarn tulipes new module <name>\` | scaffold a module |`,
|
|
823
|
+
].join("\n")], ["modules/hello/queues/hello.queues.ts", [
|
|
824
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
825
|
+
`import type { QueueRegistry } from "@tulipes/core/queues";`,
|
|
369
826
|
``,
|
|
370
|
-
|
|
371
|
-
|
|
827
|
+
`/**`,
|
|
828
|
+
` * ONE file describes both sides. The web process registers the queue`,
|
|
829
|
+
` * so routes can produce into it; \`yarn worker\` turns the processor`,
|
|
830
|
+
` * into a live consumer. Never duplicate the definition.`,
|
|
831
|
+
` *`,
|
|
832
|
+
` * Queue names use "." — BullMQ reserves ":" as its redis separator.`,
|
|
833
|
+
` */`,
|
|
834
|
+
`export default function helloQueues({ models }: Ctx, queues: QueueRegistry): void {`,
|
|
835
|
+
` queues.define("hello.count-greeting");`,
|
|
836
|
+
``,
|
|
837
|
+
` queues.process("hello.count-greeting", async (job) => {`,
|
|
838
|
+
` // Jobs retry, so processors must be idempotent-safe. Pass ids in`,
|
|
839
|
+
` // job.data and re-read state here rather than shipping documents.`,
|
|
840
|
+
` const { name } = job.data as { name: string };`,
|
|
841
|
+
` await models!.get("Greeting").updateOne({ name }, { $inc: { timesUsed: 1 } });`,
|
|
842
|
+
` return { counted: name };`,
|
|
843
|
+
` });`,
|
|
844
|
+
`}`,
|
|
845
|
+
``,
|
|
846
|
+
].join("\n")], ["modules/hello/routes/greetings.routes.ts", [
|
|
847
|
+
`import { Router } from "express";`,
|
|
848
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
849
|
+
`import { HttpError } from "@tulipes/core/http";`,
|
|
850
|
+
``,
|
|
851
|
+
`/**`,
|
|
852
|
+
` * A module may ship several routes files; each is discovered and`,
|
|
853
|
+
` * mounted independently. This one shows the database and the queue.`,
|
|
854
|
+
` */`,
|
|
855
|
+
`export default function greetingsRoutes({ config, models, queues }: Ctx): Router {`,
|
|
856
|
+
` const router = Router();`,
|
|
857
|
+
` const base = String(config.api?.prefix ?? "");`,
|
|
858
|
+
``,
|
|
859
|
+
` router.get(\`\${base}/greetings\`, async (req, res) => {`,
|
|
860
|
+
` // Pagination bounds are app-wide, so every list endpoint clamps`,
|
|
861
|
+
` // the same way and no caller can ask for the whole table.`,
|
|
862
|
+
` const { defaultLimit = 20, maxLimit = 100 } = config.pagination ?? {};`,
|
|
863
|
+
` const asked = Number(req.query.limit ?? defaultLimit);`,
|
|
864
|
+
` const limit = Math.min(Number.isFinite(asked) ? asked : defaultLimit, maxLimit);`,
|
|
865
|
+
``,
|
|
866
|
+
` const greetings = await models!`,
|
|
867
|
+
` .get("Greeting")`,
|
|
868
|
+
` .find()`,
|
|
869
|
+
` .select("name message timesUsed -_id")`,
|
|
870
|
+
` .limit(limit)`,
|
|
871
|
+
` .lean();`,
|
|
872
|
+
``,
|
|
873
|
+
` res.json({ count: greetings.length, limit, greetings });`,
|
|
874
|
+
` });`,
|
|
875
|
+
``,
|
|
876
|
+
` router.post(\`\${base}/greetings/:name/use\`, async (req, res) => {`,
|
|
877
|
+
` const { name } = req.params;`,
|
|
878
|
+
` const greeting = await models!.get("Greeting").findOne({ name }).lean();`,
|
|
879
|
+
` if (!greeting) throw new HttpError(404, \`no greeting named "\${name}"\`);`,
|
|
880
|
+
``,
|
|
881
|
+
` // Slow or retryable work belongs in a job, not in the request.`,
|
|
882
|
+
` // Run \`yarn worker\` in another terminal to see it processed.`,
|
|
883
|
+
` await queues!.add("hello.count-greeting", "count", { name });`,
|
|
884
|
+
` res.status(202).json({ name, queued: true });`,
|
|
885
|
+
` });`,
|
|
886
|
+
``,
|
|
887
|
+
` return router;`,
|
|
888
|
+
`}`,
|
|
889
|
+
``,
|
|
890
|
+
].join("\n")]);
|
|
891
|
+
}
|
|
892
|
+
files.push(["README.md", [
|
|
893
|
+
`# ${name}`,
|
|
894
|
+
``,
|
|
895
|
+
`A [Tulipes](https://www.npmjs.com/package/@tulipes/core) application.`,
|
|
896
|
+
``,
|
|
897
|
+
`## Run`,
|
|
898
|
+
``,
|
|
899
|
+
"```sh",
|
|
900
|
+
`corepack enable # once per machine — activates the pinned yarn 4`,
|
|
901
|
+
`yarn install`,
|
|
902
|
+
`yarn dev # boot the web process → http://localhost:3000/hello`,
|
|
903
|
+
"```",
|
|
904
|
+
``,
|
|
905
|
+
...(full
|
|
906
|
+
? [
|
|
907
|
+
`Needs mongo and redis running — see \`MONGO_URI\` and \`REDIS_URL\``,
|
|
908
|
+
`in \`.envs/.env.development\`. Run \`yarn worker\` in a second`,
|
|
909
|
+
`terminal to consume queued jobs.`,
|
|
910
|
+
]
|
|
911
|
+
: [
|
|
912
|
+
`Boots with no database and no redis: the pipeline skips engines no`,
|
|
913
|
+
`module asks for. When your first model lands, declare \`MONGO_URI\``,
|
|
914
|
+
`(type \`url\`, required) in \`modules/core/meta.variables.json\` and`,
|
|
915
|
+
`set it in \`.envs/.env.development\`; same with \`REDIS_URL\` for`,
|
|
916
|
+
`queues. \`tulipes init --full\` scaffolds all of that up front.`,
|
|
917
|
+
]),
|
|
918
|
+
``,
|
|
919
|
+
`## The hello module`,
|
|
920
|
+
``,
|
|
921
|
+
`\`modules/hello\` is a worked example of every module contract — read it`,
|
|
922
|
+
`before writing your own:`,
|
|
923
|
+
``,
|
|
924
|
+
`| File | Contract |`,
|
|
925
|
+
`|---|---|`,
|
|
926
|
+
`| \`meta.variables.json\` | the module's environment contract |`,
|
|
927
|
+
`| \`module.config.ts\` | config factory + \`onReady\` / \`onShutdown\` hooks |`,
|
|
928
|
+
`| \`module.acl.ts\` | permission grants on \`hello:*\` |`,
|
|
929
|
+
`| \`routes/*.routes.ts\` | endpoints, \`HttpError\`, ACL checks |`,
|
|
930
|
+
`| \`controllers/\` | the work; imported by routes, never scanned |`,
|
|
931
|
+
`| \`helpers/\` | pure logic, no framework |`,
|
|
932
|
+
`| \`sockets/*.sockets.ts\` | a claimed Socket.IO namespace |`,
|
|
933
|
+
...(full
|
|
934
|
+
? [
|
|
935
|
+
`| \`models/*.model.ts\` | a schema declaration for the model store |`,
|
|
936
|
+
`| \`bootstrap/*.bootstrap.ts\` | idempotent seeding, before traffic |`,
|
|
937
|
+
`| \`queues/*.queues.ts\` | a job definition and its processor |`,
|
|
938
|
+
]
|
|
939
|
+
: []),
|
|
940
|
+
``,
|
|
941
|
+
`## Daily commands`,
|
|
942
|
+
``,
|
|
943
|
+
`| command | does |`,
|
|
944
|
+
`|---|---|`,
|
|
945
|
+
`| \`yarn dev\` | regenerate types, run web process under tsx watch |`,
|
|
946
|
+
`| \`yarn worker\` | run the queue-consumer process (needs a queue first) |`,
|
|
947
|
+
`| \`yarn sync\` | regenerate \`types/config.d.ts\` + \`.env.example\` |`,
|
|
948
|
+
`| \`yarn check\` | validate env against every module contract — CI gate |`,
|
|
949
|
+
`| \`yarn tulipes new module <name>\` | scaffold a module |`,
|
|
950
|
+
``,
|
|
951
|
+
].join("\n")]);
|
|
952
|
+
return files;
|
|
372
953
|
}
|
|
373
954
|
//# sourceMappingURL=init.js.map
|