@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,4 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { readdir, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
2
6
|
import { fileURLToPath } from "node:url";
|
|
3
7
|
import { z } from "zod/v4";
|
|
4
8
|
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
|
@@ -8,7 +12,15 @@ import { useTestApp } from "@tulipes/testing";
|
|
|
8
12
|
import { testEnv, testSettings } from "../../../tests/support.ts";
|
|
9
13
|
|
|
10
14
|
const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
|
|
11
|
-
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where this boot's logger writes and the stream reads: environment values
|
|
18
|
+
* are bound once at boot, so the file logging the stream needs is on for
|
|
19
|
+
* the whole file, into a directory the suite owns and empties per test.
|
|
20
|
+
*/
|
|
21
|
+
const logDir = mkdtempSync(join(tmpdir(), "tulipes-log-http-"));
|
|
22
|
+
const app = useTestApp({ rootDir, env: { ...testEnv(), LOG_FILE: "true", LOG_DIR: logDir }, settings: testSettings() }, { beforeAll, afterAll });
|
|
23
|
+
afterAll(() => rm(logDir, { recursive: true, force: true }));
|
|
12
24
|
|
|
13
25
|
|
|
14
26
|
const tokens = z.object({
|
|
@@ -59,30 +71,16 @@ async function sysadmin() {
|
|
|
59
71
|
return user;
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/** `run` sees the log directory empty: one test's files never serve another's stream. */
|
|
62
75
|
async function logFiles(run: (dir: string) => Promise<void>) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const { join } = await import("node:path");
|
|
66
|
-
const dir = await mkdtemp(join(tmpdir(), "tulipes-log-http-"));
|
|
67
|
-
const env = app.handle.ctx.Environment;
|
|
68
|
-
|
|
69
|
-
const previous = {
|
|
70
|
-
file: env.get("LOG_FILE"),
|
|
71
|
-
dir: env.get("LOG_DIR"),
|
|
76
|
+
const empty = async () => {
|
|
77
|
+
for (const entry of await readdir(logDir)) await rm(join(logDir, entry), { recursive: true, force: true });
|
|
72
78
|
};
|
|
73
|
-
|
|
74
|
-
env.store.set("LOG_FILE", true);
|
|
75
|
-
|
|
76
|
-
env.store.set("LOG_DIR", dir);
|
|
77
|
-
|
|
79
|
+
await empty();
|
|
78
80
|
try {
|
|
79
|
-
await run(
|
|
81
|
+
await run(logDir);
|
|
80
82
|
} finally {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
env.store.set("LOG_DIR", previous.dir);
|
|
84
|
-
|
|
85
|
-
await rm(dir, { recursive: true, force: true });
|
|
83
|
+
await empty();
|
|
86
84
|
}
|
|
87
85
|
}
|
|
88
86
|
|
|
@@ -378,10 +376,8 @@ test("shutdown drains settings streams and a later boot owns fresh stream admiss
|
|
|
378
376
|
});
|
|
379
377
|
|
|
380
378
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
next.env.store.set("LOG_DIR", dir);
|
|
384
|
-
|
|
379
|
+
// LOG_FILE and LOG_DIR are still set on the process, so this boot
|
|
380
|
+
// reads the same directory.
|
|
385
381
|
const address = next.server!.address();
|
|
386
382
|
|
|
387
383
|
if (!address || typeof address === "string")
|
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) {
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tulipes-agent-tool
|
|
3
|
-
description: Use when
|
|
3
|
+
description: Use when a Tulipes module should offer something to AI agents — declaring an action, describing the module, building an agent inside the app from the other modules' actions, or wiring the app to Claude Code through MCP. Covers agent/*.agent.ts, ctx.agent, the manifest, the agent door and tulipes-mcp.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Offering a module's actions to agents
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
no `agent/` folder exposes every route it declares. The folder adds what
|
|
13
|
-
cannot be derived.
|
|
8
|
+
Routes are the API. What a module offers to agents — and to an agent
|
|
9
|
+
module inside the same app — is a separate, explicit list: its **actions**,
|
|
10
|
+
declared in `agent/<module>.agent.ts`. Nothing is derived from the routes,
|
|
11
|
+
and a module with no `agent/` file offers nothing.
|
|
14
12
|
|
|
15
13
|
The agent surface and `@tulipes/mcp` are **experimental** in 0.11: they may
|
|
16
14
|
change in any release without a deprecation period. Check `MIGRATING.md`
|
|
@@ -23,19 +21,22 @@ import { action, defineAgent } from "@tulipes/core/agent";
|
|
|
23
21
|
import { requireModels } from "@tulipes/core/boot";
|
|
24
22
|
import { z } from "zod/v4";
|
|
25
23
|
|
|
24
|
+
import { findGreetings } from "../helpers/greetings.ts";
|
|
25
|
+
|
|
26
26
|
export default defineAgent({
|
|
27
27
|
summary: "Greets callers and counts how often each greeting is used.",
|
|
28
|
-
|
|
29
|
-
// Derived from the route; the description overrides the RAI's for agents only.
|
|
30
|
-
{ route: "hello:list-greetings", description: "The stored greetings, most used first" },
|
|
31
|
-
{ route: "hello:use-greeting" },
|
|
32
|
-
|
|
33
|
-
// Declared: not an endpoint. The id is a permission, granted in module.acl.ts.
|
|
28
|
+
actions: [
|
|
34
29
|
action({
|
|
35
|
-
id: "hello:
|
|
30
|
+
id: "hello:web:greetings",
|
|
31
|
+
description: "The stored greetings, with how often each was used",
|
|
32
|
+
readOnly: true,
|
|
33
|
+
handler: async (ctx) => (await findGreetings(ctx))[0],
|
|
34
|
+
}),
|
|
35
|
+
action({
|
|
36
|
+
id: "hello:bo:greetings:reset",
|
|
36
37
|
description: "Reset the usage counter of every greeting to zero",
|
|
37
38
|
input: z.object({ confirm: z.literal(true) }),
|
|
38
|
-
handler: async (ctx
|
|
39
|
+
handler: async (ctx) => {
|
|
39
40
|
const { modifiedCount } = await requireModels(ctx).get("Greeting").updateMany({}, { times_used: 0 });
|
|
40
41
|
return { reset: modifiedCount };
|
|
41
42
|
},
|
|
@@ -49,33 +50,47 @@ no service or `.env` needed; `--role admin` shows what one role gets.
|
|
|
49
50
|
|
|
50
51
|
## Rules
|
|
51
52
|
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
a boot error. One `agent/*.agent.ts` per module.
|
|
56
|
-
- **An endpoint that must stay out of the manifest says `agent: false`** on
|
|
57
|
-
its `rai()` — the agent door's own routes do.
|
|
58
|
-
- **An action's id is a permission like any route's.** The ACL decides on
|
|
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
|
|
59
56
|
it first (401 for a guest, 403 for a role without it), then the `input`
|
|
60
57
|
schema validates (422 with field issues), then the handler runs with the
|
|
61
58
|
runtime context, the parsed input and the actor (`req.auth` plus `role`).
|
|
62
|
-
Grant it in `module.acl.ts`; a wildcard such as `hello:*` already covers
|
|
59
|
+
Grant it in `module.acl.ts`; a wildcard such as `hello:*` already covers
|
|
60
|
+
it. An action that does what a route does may share the route's id, so
|
|
61
|
+
one grant covers both.
|
|
62
|
+
- **Logic lives in `helpers/`, called from both.** A route controller and
|
|
63
|
+
an action call the same helper; a route never calls an action and an
|
|
64
|
+
action never calls a route. An action that grows a second step is a
|
|
65
|
+
controller in disguise.
|
|
63
66
|
- **Write the description for the agent, not the operator.** It is the only
|
|
64
67
|
text the model sees before choosing the tool. Say what the tool does, what
|
|
65
68
|
it changes and what it returns; keep `readOnly: true` honest.
|
|
66
|
-
- **
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
- **One `agent/*.agent.ts` per module.** Tool names come from the id
|
|
70
|
+
(`hello:bo:greetings:reset` → `hello_bo_greetings_reset`); a name two modules claim
|
|
71
|
+
is a boot error.
|
|
72
|
+
|
|
73
|
+
## An agent inside the app
|
|
74
|
+
|
|
75
|
+
`ctx.agent` exists in every process — backend, worker, script — once the
|
|
76
|
+
ACL is loaded. An agent module runs a model against the other modules'
|
|
77
|
+
actions without any HTTP:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const { tools } = ctx.agent.manifest(actor.role); // name, description, input_schema: a tool definition each
|
|
81
|
+
const result = await ctx.agent.invoke(name, input, actor); // ACL, then schema, then the handler
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A long run belongs in a queue job (`queues/`), where `ctx.agent` is the
|
|
85
|
+
same registry.
|
|
72
86
|
|
|
73
87
|
## The door and MCP
|
|
74
88
|
|
|
75
|
-
|
|
76
|
-
filtered to the caller's role, and
|
|
77
|
-
`POST <prefix>/agent/tools/<name>`. Any client with a
|
|
78
|
-
module accepts can use it. For Claude Code or Claude
|
|
89
|
+
For agents outside the process, the app's `agent` module serves the
|
|
90
|
+
manifest at `GET <prefix>/agent/tools`, filtered to the caller's role, and
|
|
91
|
+
runs actions at `POST <prefix>/agent/tools/<name>`. Any client with a
|
|
92
|
+
bearer token the auth module accepts can use it. For Claude Code or Claude
|
|
93
|
+
Desktop:
|
|
79
94
|
|
|
80
95
|
```sh
|
|
81
96
|
pnpm add -D @tulipes/mcp
|
|
@@ -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
|