@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
package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts}
RENAMED
|
@@ -4,42 +4,18 @@ import type { Ctx } from "@tulipes/core/boot";
|
|
|
4
4
|
import { HttpError } from "@tulipes/core/http";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the app's envelope. Failures are thrown rather than responded — the
|
|
12
|
-
* framework's terminal handler builds the same envelope from them — so a
|
|
13
|
-
* handler only ever writes the happy path.
|
|
14
|
-
*
|
|
15
|
-
* Thrown messages are i18n KEYS: the handler translates them in this
|
|
16
|
-
* module's namespace and derives the error code from the key, so
|
|
17
|
-
* "notFound" becomes NOT_FOUND with a translated message.
|
|
7
|
+
* The users back office — the users:bo:* endpoints, granted to admin
|
|
8
|
+
* through its users:* grant. Same shape as the web controller beside it:
|
|
9
|
+
* one class holding the context, one handler per method, failures thrown
|
|
10
|
+
* as i18n keys.
|
|
18
11
|
*/
|
|
19
|
-
export class
|
|
12
|
+
export class BoUsersController {
|
|
20
13
|
readonly #ctx: Ctx;
|
|
21
14
|
|
|
22
15
|
constructor(ctx: Ctx) {
|
|
23
16
|
this.#ctx = ctx;
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
list(): RequestHandler {
|
|
27
|
-
return async (req: Request, res: Response) => {
|
|
28
|
-
// ?page and ?per_page, already clamped by config.pagination.
|
|
29
|
-
const { per_page, skip } = req.page;
|
|
30
|
-
const User = this.#ctx.models!.get("User");
|
|
31
|
-
|
|
32
|
-
const [users, total_items] = await Promise.all([
|
|
33
|
-
User.find().select("email role -_id").skip(skip).limit(per_page).lean(),
|
|
34
|
-
User.countDocuments(),
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
// Passing total_items is what marks this a page: respond fills in
|
|
38
|
-
// page, per_page and total_pages from the request and the count.
|
|
39
|
-
res.respond({ data: users, meta: { total_items } });
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
19
|
/** Soft delete: the row stays in mongo but leaves every ordinary query. */
|
|
44
20
|
softDelete(): RequestHandler {
|
|
45
21
|
return async (req: Request, res: Response) => {
|
|
@@ -84,30 +60,4 @@ export class UsersController {
|
|
|
84
60
|
res.respond({ status: 201, data: { email, welcome_queued: true } });
|
|
85
61
|
};
|
|
86
62
|
}
|
|
87
|
-
|
|
88
|
-
ping(): RequestHandler {
|
|
89
|
-
return (req: Request, res: Response) => {
|
|
90
|
-
res.respond({
|
|
91
|
-
data: {
|
|
92
|
-
module: "users",
|
|
93
|
-
locale: req.locale,
|
|
94
|
-
max_sessions: this.#ctx.config.users?.maxSessions,
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Demonstrates HttpError — the framework renders it into the envelope. */
|
|
101
|
-
demoMissing(): RequestHandler {
|
|
102
|
-
return async () => {
|
|
103
|
-
throw new HttpError(404, "notFound", { details: { hint: "demo endpoint" } });
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Demonstrates an unexpected error: anonymous 500 outside development. */
|
|
108
|
-
demoBoom(): RequestHandler {
|
|
109
|
-
return async () => {
|
|
110
|
-
throw new Error("secret internals that must not leak in production");
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
63
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { forgetUser } from "@app/auth";
|
|
2
1
|
import type { Request, RequestHandler, Response } from "express";
|
|
3
2
|
import type { Ctx } from "@tulipes/core/boot";
|
|
4
3
|
import { HttpError } from "@tulipes/core/http";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* One class per controller, holding the context. Each method returns a
|
|
8
|
-
* handler, so the routes file stays a table of contents.
|
|
7
|
+
* handler, so the routes file stays a table of contents. The class is
|
|
8
|
+
* named after its file — web.users.controllers.ts, WebUsersController —
|
|
9
|
+
* and serves the users:web:* endpoints; the back office has its own.
|
|
9
10
|
*
|
|
10
11
|
* Every response goes through `res.respond`, which wraps the payload in
|
|
11
12
|
* the app's envelope. Failures are thrown rather than responded — the
|
|
@@ -16,7 +17,7 @@ import { HttpError } from "@tulipes/core/http";
|
|
|
16
17
|
* module's namespace and derives the error code from the key, so
|
|
17
18
|
* "notFound" becomes NOT_FOUND with a translated message.
|
|
18
19
|
*/
|
|
19
|
-
export class
|
|
20
|
+
export class WebUsersController {
|
|
20
21
|
readonly #ctx: Ctx;
|
|
21
22
|
|
|
22
23
|
constructor(ctx: Ctx) {
|
|
@@ -40,45 +41,6 @@ export class UsersController {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Soft delete: the row stays in mongo but leaves every ordinary query. */
|
|
44
|
-
softDelete(): RequestHandler {
|
|
45
|
-
return async (req: Request, res: Response) => {
|
|
46
|
-
const user = await this.#ctx.models!.get("User").findOne({ email: req.params.email });
|
|
47
|
-
if (!user) throw new HttpError(404, "notFound");
|
|
48
|
-
|
|
49
|
-
// The account leaves every query, so its sessions resolve to nobody on
|
|
50
|
-
// the cold path; the cached identities would not know, so they go too.
|
|
51
|
-
await forgetUser(this.#ctx, String(user._id));
|
|
52
|
-
await (user as unknown as { softDelete(): Promise<unknown> }).softDelete();
|
|
53
|
-
res.respond({ data: { email: req.params.email, soft_deleted: true } });
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
listDeleted(): RequestHandler {
|
|
58
|
-
return async (_req: Request, res: Response) => {
|
|
59
|
-
const deleted = await this.#ctx.models!
|
|
60
|
-
.get("User")
|
|
61
|
-
.find({ is_deleted: true })
|
|
62
|
-
.setOptions({ withDeleted: true } as Record<string, unknown>)
|
|
63
|
-
.select("email role -_id")
|
|
64
|
-
.lean();
|
|
65
|
-
|
|
66
|
-
res.respond({ data: deleted });
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
create(): RequestHandler {
|
|
71
|
-
return async (req: Request, res: Response) => {
|
|
72
|
-
const { email } = (req.body ?? {}) as { email?: string };
|
|
73
|
-
// `field` is what puts this against the input in the envelope.
|
|
74
|
-
if (!email) throw new HttpError(422, "emailRequired", { field: "email" });
|
|
75
|
-
|
|
76
|
-
await this.#ctx.models!.get("User").create({ email });
|
|
77
|
-
|
|
78
|
-
res.respond({ status: 201, data: { email } });
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
44
|
ping(): RequestHandler {
|
|
83
45
|
return (req: Request, res: Response) => {
|
|
84
46
|
res.respond({
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { AclBuilder } from "@tulipes/core/acl";
|
|
2
2
|
|
|
3
3
|
export default function usersAcl(acl: AclBuilder): void {
|
|
4
|
-
acl.allow("user", "users:
|
|
4
|
+
acl.allow("user", "users:web:list", "users:web:ping");
|
|
5
5
|
// The demo endpoints are open so the error-handling examples stay
|
|
6
6
|
// curl-able without a token.
|
|
7
|
-
acl.allow("guest", "users:
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
acl.allow("guest", "users:web:demo:*", "users:web:ping");
|
|
8
|
+
// The back office — users:bo:create, :delete, :list:deleted — stays with
|
|
9
|
+
// admin and sysadmin, whose users:* grant in the auth module covers it.
|
|
10
10
|
}
|
|
@@ -16,7 +16,7 @@ export const jobs = {
|
|
|
16
16
|
export default function usersQueues(ctx: Ctx, queues: QueueRegistry): void {
|
|
17
17
|
// The same logger the backend uses. In the worker process it writes
|
|
18
18
|
// to app-worker.log, so the two processes never share a file.
|
|
19
|
-
const log = moduleLogger(ctx
|
|
19
|
+
const log = moduleLogger(ctx, "users");
|
|
20
20
|
|
|
21
21
|
queues.process("users.welcome", async (job) => {
|
|
22
22
|
// No request here to negotiate a locale from, so the one the caller
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { BackendCtx } from "@tulipes/core/boot";
|
|
3
|
+
|
|
4
|
+
import { BoUsersController } from "../controllers/bo.users.controllers.ts";
|
|
5
|
+
import { createBody, emailParam } from "../schemas/users.schemas.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The users back office: every id is users:bo:…, so `users:bo:*` grants
|
|
9
|
+
* all of it and `tulipes routes --scope bo` lists it. Mounted under /users
|
|
10
|
+
* like the web routes beside it.
|
|
11
|
+
*/
|
|
12
|
+
export default function boUsersRoutes(ctx: BackendCtx): void {
|
|
13
|
+
const { rai, routes } = ctx;
|
|
14
|
+
const router = Router();
|
|
15
|
+
const users = new BoUsersController(ctx);
|
|
16
|
+
|
|
17
|
+
router.post("/", rai({
|
|
18
|
+
id: "users:bo:create",
|
|
19
|
+
status: 201,
|
|
20
|
+
name: "Create a user",
|
|
21
|
+
description: "Adds the account and queues its welcome email",
|
|
22
|
+
body: createBody,
|
|
23
|
+
}), users.create());
|
|
24
|
+
|
|
25
|
+
router.get("/deleted", rai({
|
|
26
|
+
id: "users:bo:list:deleted",
|
|
27
|
+
name: "List soft-deleted users",
|
|
28
|
+
}), users.listDeleted());
|
|
29
|
+
|
|
30
|
+
router.delete("/:email", rai({
|
|
31
|
+
id: "users:bo:delete",
|
|
32
|
+
name: "Soft-delete a user",
|
|
33
|
+
description: "Flags the account; ordinary queries stop returning it",
|
|
34
|
+
params: emailParam,
|
|
35
|
+
}), users.softDelete());
|
|
36
|
+
|
|
37
|
+
// Deliberately outside the API prefix, to show a mount of its own.
|
|
38
|
+
routes.mount(router, "/users");
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { BackendCtx } from "@tulipes/core/boot";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
|
|
5
|
+
import { WebUsersController } from "../controllers/web.users.controllers.ts";
|
|
6
|
+
import { listQuery, userShape } from "../schemas/users.schemas.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The users endpoints a signed-in client calls. The back office's sit in
|
|
10
|
+
* bo.users.routes.ts beside this file, under the same mount: one file per
|
|
11
|
+
* scope, named scope-first.
|
|
12
|
+
*/
|
|
13
|
+
export default function webUsersRoutes(ctx: BackendCtx): void {
|
|
14
|
+
const { rai, routes } = ctx;
|
|
15
|
+
const router = Router();
|
|
16
|
+
const users = new WebUsersController(ctx);
|
|
17
|
+
|
|
18
|
+
router.get("/", rai({
|
|
19
|
+
id: "users:web:list",
|
|
20
|
+
name: "List users",
|
|
21
|
+
description: "Page through accounts, clamped by the shared bounds",
|
|
22
|
+
// Validated on the way in, and described on the way out: one
|
|
23
|
+
// declaration, so the spec cannot drift from what is enforced.
|
|
24
|
+
query: listQuery,
|
|
25
|
+
returns: z.array(userShape),
|
|
26
|
+
}), users.list());
|
|
27
|
+
|
|
28
|
+
router.get("/ping", rai({
|
|
29
|
+
id: "users:web:ping",
|
|
30
|
+
name: "Users module status",
|
|
31
|
+
}), users.ping());
|
|
32
|
+
|
|
33
|
+
router.get("/missing", rai({
|
|
34
|
+
id: "users:web:demo:missing",
|
|
35
|
+
name: "HttpError demo",
|
|
36
|
+
}), users.demoMissing());
|
|
37
|
+
|
|
38
|
+
router.get("/boom", rai({
|
|
39
|
+
id: "users:web:demo:boom",
|
|
40
|
+
name: "Unhandled error demo",
|
|
41
|
+
}), users.demoBoom());
|
|
42
|
+
|
|
43
|
+
// Deliberately outside the API prefix, to show a mount of its own.
|
|
44
|
+
routes.mount(router, "/users");
|
|
45
|
+
}
|
|
@@ -18,7 +18,7 @@ server {
|
|
|
18
18
|
root /var/www/html;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
# Keep in step with the
|
|
21
|
+
# Keep in step with the security.bodyLimit setting. If nginx is stricter,
|
|
22
22
|
# oversized requests die here as an nginx HTML page instead of the
|
|
23
23
|
# API's JSON 413.
|
|
24
24
|
client_max_body_size 1m;
|
|
@@ -40,9 +40,10 @@ try {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
async function deploy(): Promise<void> {
|
|
43
|
-
const { appEnv } = handle.
|
|
43
|
+
const { appEnv } = handle.ctx;
|
|
44
44
|
const domain = String(handle.ctx.config.core?.publicDomain ?? "localhost").trim();
|
|
45
|
-
|
|
45
|
+
// What app.config wired from PORT — the same port the backend listens on.
|
|
46
|
+
const port = Number(handle.ctx.config.server?.port ?? 3000);
|
|
46
47
|
|
|
47
48
|
const templatePath = join(rootDir, "nginx", `${appEnv}.conf`);
|
|
48
49
|
if (!existsSync(templatePath)) {
|
|
@@ -2,9 +2,10 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What every module test boots the app with. The services are dedicated:
|
|
5
|
-
* the suite writes users, sessions and settings. The variables are the
|
|
6
|
-
*
|
|
7
|
-
* setting, written after the boot the way an
|
|
5
|
+
* the suite writes users, sessions and settings. The variables are the ones
|
|
6
|
+
* the core and auth modules declare, by their env names; everything else the
|
|
7
|
+
* request pipeline needs is a setting, written after the boot the way an
|
|
8
|
+
* operator writes it.
|
|
8
9
|
*/
|
|
9
10
|
export function testEnv(): Record<string, string> {
|
|
10
11
|
if (!process.env.TEST_MONGO_URI || !process.env.TEST_REDIS_URL) {
|
|
@@ -27,13 +27,13 @@ export default defineAgent({
|
|
|
27
27
|
summary: "Greets callers and counts how often each greeting is used.",
|
|
28
28
|
actions: [
|
|
29
29
|
action({
|
|
30
|
-
id: "hello:
|
|
30
|
+
id: "hello:web:greetings",
|
|
31
31
|
description: "The stored greetings, with how often each was used",
|
|
32
32
|
readOnly: true,
|
|
33
33
|
handler: async (ctx) => (await findGreetings(ctx))[0],
|
|
34
34
|
}),
|
|
35
35
|
action({
|
|
36
|
-
id: "hello:reset
|
|
36
|
+
id: "hello:bo:greetings:reset",
|
|
37
37
|
description: "Reset the usage counter of every greeting to zero",
|
|
38
38
|
input: z.object({ confirm: z.literal(true) }),
|
|
39
39
|
handler: async (ctx) => {
|
|
@@ -50,7 +50,9 @@ no service or `.env` needed; `--role admin` shows what one role gets.
|
|
|
50
50
|
|
|
51
51
|
## Rules
|
|
52
52
|
|
|
53
|
-
- **An action's id is a
|
|
53
|
+
- **An action's id is a RAI like any route's**, `module:scope:resource[:extra]`:
|
|
54
|
+
the scope says who it is for (`hello:bo:greetings:reset` is a back-office
|
|
55
|
+
action). The ACL decides on
|
|
54
56
|
it first (401 for a guest, 403 for a role without it), then the `input`
|
|
55
57
|
schema validates (422 with field issues), then the handler runs with the
|
|
56
58
|
runtime context, the parsed input and the actor (`req.auth` plus `role`).
|
|
@@ -65,7 +67,7 @@ no service or `.env` needed; `--role admin` shows what one role gets.
|
|
|
65
67
|
text the model sees before choosing the tool. Say what the tool does, what
|
|
66
68
|
it changes and what it returns; keep `readOnly: true` honest.
|
|
67
69
|
- **One `agent/*.agent.ts` per module.** Tool names come from the id
|
|
68
|
-
(`hello:reset
|
|
70
|
+
(`hello:bo:greetings:reset` → `hello_bo_greetings_reset`); a name two modules claim
|
|
69
71
|
is a boot error.
|
|
70
72
|
|
|
71
73
|
## An agent inside the app
|
|
@@ -26,13 +26,16 @@ problems the report has not reached yet.
|
|
|
26
26
|
|---|---|---|
|
|
27
27
|
| `environment` | `required variable "X" has no value` | Declared `required` but nothing supplies it. Add it to `.envs/.env.<APP_ENV>`, or give the spec a `default`. |
|
|
28
28
|
| `environment` | `"abc" is not a number` / `not one of: …` | Value fails its declared `type`/`enum`. Fix the value or the spec. |
|
|
29
|
-
| `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same
|
|
29
|
+
| `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same `key`. Delete the duplicate; read the other module's config instead. |
|
|
30
|
+
| `environment` | `config.x.name is both a setting and an environment value` | A `tulipes.env` name is also a field of that module's settings schema. Rename one; they share the namespace. |
|
|
31
|
+
| `environment` | `tulipes.env is an object keyed by config name since 0.12` | The 0.11 array form. Run `tulipes migrate 0.12`, which rewrites it and the reads. |
|
|
30
32
|
| `module graph` | `dependsOn "x" — no such module exists` | Typo, or you used the package name. `dependsOn` takes short names (`"users"`). |
|
|
31
33
|
| `module graph` | `dependency cycle: a → b → a` | Mutual dependency. Move the shared part into the core module or a third module. |
|
|
32
34
|
| `module graph` | `sys module cannot depend on app module` | A sys module needs something app-tier. Either it is not really sys, or the dependency belongs in `lib/`. |
|
|
33
35
|
| `modules` | `x does not belong at the module root` | Only `package.json`, `module.config.ts` and `module.acl.ts` live there; the message names the folder the file belongs in. |
|
|
34
|
-
| `modules` | `tulipes.offline is gone` / `meta.variables.json moved to package.json` | The 0.10 forms. Delete the flag;
|
|
35
|
-
| `routes` | `routes function threw: ctx.models is not available during inspection` | A routes file or a controller constructor touched infrastructure while building. Move the read into the handler, or build the middleware with `lazy()`. |
|
|
36
|
+
| `modules` | `tulipes.offline is gone` / `meta.variables.json … moved to package.json` | The 0.10 forms. Delete the flag; run `tulipes migrate 0.11` then `0.12` for the variables. |
|
|
37
|
+
| `routes` | `routes function threw: ctx.models is not available during inspection` / `config.x.name is an environment value, not available during inspection` | A routes file or a controller constructor touched infrastructure or an environment value while building. Move the read into the handler, or build the middleware with `lazy()`. |
|
|
38
|
+
| `config` | `app.config returns "x", which is the config namespace of module "x"` | `config/app.config.ts` seeded a key named after a module; that namespace holds the module's settings and environment values. Rename the key. |
|
|
36
39
|
| `config` | `module.config must default-export defineConfig({ schema })` | Missing/misspelled `export default`, or the 0.10 factory form: values now live in the settings store. |
|
|
37
40
|
| `config` | `module.config exports "onReady"` | The 0.10 lifecycle exports. Initialization is a `bootstrap/*.bootstrap.ts` file, which may return its cleanup; long-lived work ends on `ctx.signal`. |
|
|
38
41
|
| `config` | `field "x" has no default — every setting needs one` | Give the field a `.default()`; an operator is never asked for a value the process needs to boot. |
|
|
@@ -43,13 +46,13 @@ problems the report has not reached yet.
|
|
|
43
46
|
| `providers` | `provider "…" cannot be resolved from …` | The declared specifier is not installed in the app. Install it (or fix the path). |
|
|
44
47
|
| `providers` | `"@tulipes/core/db" no longer contains the Mongoose provider` | The app still declares the pre-extraction specifier. Install `@tulipes/mongoose`, declare it, and import `ModelDef`/`BootstrapFn` from it. |
|
|
45
48
|
| `providers` | `[x] requires capability "queues" … declares no provider for it` | A module has a `queues/` file (or `requires: ["queues"]`) but the app's `package.json` has no `"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. Add that line. |
|
|
46
|
-
| `providers` | `provider "bullmq" is selected but
|
|
49
|
+
| `providers` | `provider "bullmq" is selected but config.bullmq.redisUrl is not set` | Wire it in `config/app.config.ts` — `bullmq: { redisUrl: env.core.redisUrl }` — from a variable the sys `core` module declares, and set that variable. |
|
|
47
50
|
| `providers` | `"@tulipes/core/queues" no longer contains the BullMQ provider` | Install `@tulipes/bullmq`, declare it, and import `QueueRegistry`/`QueuesFn` from it. |
|
|
48
51
|
| `providers` | `[x] requires capability "sockets" … declares no provider for it` | A module has a `sockets/` file (or `requires: ["sockets"]`) but the app's `package.json` has no `"tulipes": { "providers": { "sockets": "@tulipes/socket.io" } }`. Add that line. |
|
|
49
52
|
| `providers` | `"@tulipes/core/sockets" no longer contains the Socket.IO provider` | Install `@tulipes/socket.io`, declare it, and import `SocketRegistry`/`SocketsFn` from it. |
|
|
50
53
|
| `providers` | `provider "socket.io" failed to attach to the HTTP server` | The transport could not bind to the server core created; the message carries the driver's reason. |
|
|
51
|
-
| `providers` | `provider "mongoose" is selected but
|
|
52
|
-
| `providers` | `database connection failed` | Mongo unreachable or
|
|
54
|
+
| `providers` | `provider "mongoose" is selected but config.mongoose.uri is not set` | Wire it in `config/app.config.ts` — `mongoose: { uri: env.core.mongodbUri }` (or the host, user, pass and dbName parts) — from variables the sys `core` module declares, and set them. |
|
|
55
|
+
| `providers` | `database connection failed (config.mongoose.uri)` | Mongo unreachable or the URI wrong. Check the service is running and what `pnpm check` resolved. |
|
|
53
56
|
| `providers` | `package.json "tulipes" key: providers — Unrecognized key` | Typo in the capability name; only `models` exists. |
|
|
54
57
|
| `providers` | `queue name … is invalid` | Queue names use `.`, not `:`. |
|
|
55
58
|
| `routes` | `must default-export a routes function` | Missing `export default` in a `*.routes.ts`. |
|
|
@@ -72,7 +75,10 @@ runs, check in this order:
|
|
|
72
75
|
|
|
73
76
|
## Other symptoms
|
|
74
77
|
|
|
75
|
-
- **Types are `unknown` or a new
|
|
78
|
+
- **Types are `unknown` or a new environment value is not typed** → run `pnpm sync`.
|
|
79
|
+
- **`console.log(ctx.config.core)` does not show an environment value** →
|
|
80
|
+
by design; they are non-enumerable so a secret never reaches a log or a
|
|
81
|
+
response. `pnpm check` shows them.
|
|
76
82
|
- **Worker exits immediately** → no processors registered; it refuses to
|
|
77
83
|
idle. Add a `*.queues.ts` with `process()`.
|
|
78
84
|
- **Env change has no effect** → `process.env` beats the file. Check for a
|
|
@@ -38,7 +38,9 @@ object is replaced in place when a write lands.
|
|
|
38
38
|
defaults; the first boot writes them. There is no "required setting",
|
|
39
39
|
because an operator cannot be asked for a value the process needs to boot.
|
|
40
40
|
- **No secrets.** `defineConfig` refuses a field whose metadata says
|
|
41
|
-
`secret`; a secret is an environment
|
|
41
|
+
`secret`; a secret is an environment value, declared in the module's
|
|
42
|
+
package.json and read beside the settings at `ctx.config.<module>`, never
|
|
43
|
+
listed. → skill `tulipes-env-variable`
|
|
42
44
|
- **Describe every field.** `.describe()` is what the console form, the
|
|
43
45
|
generated types and an agent reading the manifest see.
|
|
44
46
|
- **Validate in the schema, not in `onChange`.** The store persists before
|
|
@@ -57,12 +59,12 @@ object is replaced in place when a write lands.
|
|
|
57
59
|
|
|
58
60
|
| | |
|
|
59
61
|
|---|---|
|
|
60
|
-
| `GET /config` | every module in load order: manifest,
|
|
62
|
+
| `GET /config` | every module in load order: manifest, environment values (config name, variable, type, source — a secret's value never), settings with schema, live values and stored version, endpoints with the roles that reach them |
|
|
61
63
|
| `GET /config/:module` | the same for one module |
|
|
62
64
|
| `PATCH /config/:module` | `{ values: { … }, version? }` — a patch over the stored values, validated by the module's schema (422 with the field), refused when `version` is not the stored one (409 `STALE_VERSION`), audited before it lands, applied at once in every replica |
|
|
63
65
|
|
|
64
|
-
`config:read` and `config:describe` are granted to `admin` and `sysadmin`;
|
|
65
|
-
`config:write` to `sysadmin` only. The audit (`config_audit_events`) holds
|
|
66
|
+
`config:bo:read` and `config:bo:describe` are granted to `admin` and `sysadmin`;
|
|
67
|
+
`config:bo:write` to `sysadmin` only. The audit (`config_audit_events`) holds
|
|
66
68
|
the namespace, the field names, the version the caller read, the actor and
|
|
67
69
|
the client IP — never a value — and is acknowledged by a majority before
|
|
68
70
|
the write happens: a change nobody can account for is worse than one that
|
|
@@ -9,14 +9,25 @@ Two files: a controller class holding the work, and a shared declaration file wi
|
|
|
9
9
|
it up. Every route declares a RAI — a Resource Access Identifier — and the
|
|
10
10
|
app refuses to boot if one is missing.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Name both files **scope-first**: the scope is the surface the endpoint
|
|
13
|
+
serves (`web`, `bo`, `mobile`), and it is also the second segment of the
|
|
14
|
+
endpoint's RAI. A convention, not a rule core checks, but it is what lets
|
|
15
|
+
anyone find the back office of a module by looking at its folder.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
routes/{scope}.{module}[.{extra}].routes.ts web.billing.routes.ts, bo.billing.refunds.routes.ts
|
|
19
|
+
controllers/{scope}.{module}[.{extra}].controllers.ts web.billing.controllers.ts → WebBillingController
|
|
20
|
+
routes/{scope}/{module}[.{extra}].routes.ts several files for one scope go in a folder
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`modules/billing/controllers/web.billing.controllers.ts`:
|
|
13
24
|
|
|
14
25
|
```ts
|
|
15
26
|
import type { Request, RequestHandler, Response } from "express";
|
|
16
27
|
import type { Ctx } from "@tulipes/core/boot";
|
|
17
28
|
import { HttpError } from "@tulipes/core/http";
|
|
18
29
|
|
|
19
|
-
export class
|
|
30
|
+
export class WebBillingController {
|
|
20
31
|
constructor(private readonly ctx: Ctx) {}
|
|
21
32
|
|
|
22
33
|
show(): RequestHandler {
|
|
@@ -33,18 +44,18 @@ Each method **returns** a handler rather than being one, so the routes file
|
|
|
33
44
|
reads as a table of contents and the class can keep private helpers beside
|
|
34
45
|
the handlers that use them without threading ctx through every signature.
|
|
35
46
|
|
|
36
|
-
`modules/billing/routes/billing.routes.ts`:
|
|
47
|
+
`modules/billing/routes/web.billing.routes.ts`:
|
|
37
48
|
|
|
38
49
|
```ts
|
|
39
50
|
import { Router } from "express";
|
|
40
51
|
import type { BackendCtx } from "@tulipes/core/boot";
|
|
41
52
|
|
|
42
|
-
import {
|
|
53
|
+
import { WebBillingController } from "../controllers/web.billing.controllers.ts";
|
|
43
54
|
|
|
44
55
|
export default function billingRoutes(ctx: BackendCtx): Router {
|
|
45
56
|
const router = Router();
|
|
46
|
-
const billing = new
|
|
47
|
-
router.get("/:id", ctx.rai({ id: "billing:
|
|
57
|
+
const billing = new WebBillingController(ctx);
|
|
58
|
+
router.get("/:id", ctx.rai({ id: "billing:web:invoice", name: "Read an invoice" }), billing.show());
|
|
48
59
|
return router; // mounted under config.api.prefix
|
|
49
60
|
}
|
|
50
61
|
```
|
|
@@ -57,15 +68,19 @@ child for inherited parameters). A file that only mounts global middleware
|
|
|
57
68
|
on `ctx.app` returns nothing.
|
|
58
69
|
|
|
59
70
|
**Build with the context, use infrastructure in handlers.** `tulipes routes`
|
|
60
|
-
and `tulipes spec` build every router with nothing acquired:
|
|
61
|
-
|
|
62
|
-
are stubs that throw. So a controller keeps
|
|
63
|
-
inside its handlers (as every controller in
|
|
64
|
-
|
|
71
|
+
and `tulipes spec` build every router with nothing acquired: an environment
|
|
72
|
+
value (`ctx.config.<module>.<name>`) throws when read, and `ctx.models`,
|
|
73
|
+
`ctx.queues` and `ctx.sockets` are stubs that throw. So a controller keeps
|
|
74
|
+
`ctx` and reads `ctx.models` inside its handlers (as every controller in
|
|
75
|
+
this app does), and a middleware that needs an environment value or a
|
|
76
|
+
client is built on its first request:
|
|
65
77
|
|
|
66
78
|
```ts
|
|
67
|
-
import { lazy } from "@app/core";
|
|
68
|
-
app.use(lazy(() =>
|
|
79
|
+
import { appCache, lazy } from "@app/core";
|
|
80
|
+
app.use(lazy(() => {
|
|
81
|
+
const cache = appCache(ctx); // opens Redis from config.core.redisUrl
|
|
82
|
+
return (req, res, next) => { /* … */ next(); };
|
|
83
|
+
}));
|
|
69
84
|
```
|
|
70
85
|
|
|
71
86
|
Use router.use for shared middleware, router.param for parameter callbacks, and
|
|
@@ -76,7 +91,10 @@ changes run the app tests, `tulipes routes` and `pnpm spec`; inspect generated d
|
|
|
76
91
|
|
|
77
92
|
## The RAI is not optional
|
|
78
93
|
|
|
79
|
-
`id`
|
|
94
|
+
`id` is `module:scope:resource[:extra]` — `billing:web:invoice`,
|
|
95
|
+
`billing:bo:invoice:refund` — and **is** the ACL permission. Leaving the
|
|
96
|
+
scope out (`billing:read`) is a type error and a boot error. → skill
|
|
97
|
+
`tulipes-permissions` for the format and the grants.
|
|
80
98
|
One declaration identifies the endpoint, gates it, marks the route as
|
|
81
99
|
declared, and binds `req.t` to the declaring module's i18n namespace —
|
|
82
100
|
which is why the message above needs no namespace of its own. → skill
|
|
@@ -94,7 +112,8 @@ undeclared route is not merely undocumented, it is reachable by `guest`.
|
|
|
94
112
|
Grant the id in `module.acl.ts` — to `guest` if the endpoint is public.
|
|
95
113
|
|
|
96
114
|
`tulipes routes` lists every declared endpoint with the roles that reach it,
|
|
97
|
-
without a service, a secret or a `.env
|
|
115
|
+
without a service, a secret or a `.env`; `tulipes routes --scope bo` lists one
|
|
116
|
+
surface.
|
|
98
117
|
|
|
99
118
|
A route is never a tool: what the module offers to agents is declared apart,
|
|
100
119
|
in `agent/<module>.agent.ts`, and shares the route's logic through `helpers/`.
|