@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.
Files changed (114) hide show
  1. package/dist/agent.d.ts +5 -5
  2. package/dist/config-check.js +1 -1
  3. package/dist/env-check.d.ts +5 -3
  4. package/dist/env-check.js +26 -19
  5. package/dist/env-check.js.map +1 -1
  6. package/dist/main.js +83 -19
  7. package/dist/main.js.map +1 -1
  8. package/dist/migrate-0.12-env.d.ts +36 -0
  9. package/dist/migrate-0.12-env.js +250 -0
  10. package/dist/migrate-0.12-env.js.map +1 -0
  11. package/dist/migrate-0.12-scaffold.d.ts +1 -0
  12. package/dist/migrate-0.12-scaffold.js +197 -0
  13. package/dist/migrate-0.12-scaffold.js.map +1 -0
  14. package/dist/migrate-0.12.d.ts +47 -0
  15. package/dist/migrate-0.12.js +210 -0
  16. package/dist/migrate-0.12.js.map +1 -0
  17. package/dist/migrate.d.ts +3 -1
  18. package/dist/migrate.js +44 -5
  19. package/dist/migrate.js.map +1 -1
  20. package/dist/new-module.d.ts +7 -0
  21. package/dist/new-module.js +35 -16
  22. package/dist/new-module.js.map +1 -1
  23. package/dist/roles.d.ts +15 -0
  24. package/dist/roles.js +54 -0
  25. package/dist/roles.js.map +1 -0
  26. package/dist/routes.d.ts +7 -2
  27. package/dist/routes.js +19 -10
  28. package/dist/routes.js.map +1 -1
  29. package/dist/spec.d.ts +2 -0
  30. package/dist/spec.js +13 -11
  31. package/dist/spec.js.map +1 -1
  32. package/dist/sync.js +66 -53
  33. package/dist/sync.js.map +1 -1
  34. package/dist/update.js +1 -1
  35. package/dist/update.js.map +1 -1
  36. package/package.json +4 -4
  37. package/templates/AGENTS.md +41 -29
  38. package/templates/app/.envs/.env.development +2 -1
  39. package/templates/app/.envs/.env.production +2 -2
  40. package/templates/app/.envs/.env.staging +2 -2
  41. package/templates/app/README.md +44 -29
  42. package/templates/app/config/app.config.ts +60 -7
  43. package/templates/app/docs/agent-manifest.json +8 -681
  44. package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +4 -5
  45. package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -6
  46. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
  47. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
  48. package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
  49. package/templates/app/modules/auth/helpers/passport.ts +1 -1
  50. package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
  51. package/templates/app/modules/auth/helpers/tokens.ts +3 -5
  52. package/templates/app/modules/auth/module.acl.ts +31 -9
  53. package/templates/app/modules/auth/module.config.ts +5 -3
  54. package/templates/app/modules/auth/package.json +10 -10
  55. package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
  56. package/templates/app/modules/auth/tests/auth.test.ts +1 -1
  57. package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
  58. package/templates/app/modules/config/helpers/audit.ts +1 -1
  59. package/templates/app/modules/config/helpers/describe.ts +35 -26
  60. package/templates/app/modules/config/module.acl.ts +1 -1
  61. package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
  62. package/templates/app/modules/config/tests/config.test.ts +14 -6
  63. package/templates/app/modules/core/helpers/cache/index.ts +8 -7
  64. package/templates/app/modules/core/helpers/lazy.ts +3 -3
  65. package/templates/app/modules/core/helpers/logger/index.ts +24 -16
  66. package/templates/app/modules/core/module.config.ts +5 -3
  67. package/templates/app/modules/core/package.json +46 -19
  68. package/templates/app/modules/core/routes/core.routes.ts +4 -4
  69. package/templates/app/modules/hello/agent/hello.agent.ts +18 -14
  70. package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
  71. package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +4 -8
  72. package/templates/app/modules/hello/helpers/greetings.ts +14 -0
  73. package/templates/app/modules/hello/module.acl.ts +12 -7
  74. package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
  75. package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
  76. package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
  77. package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
  78. package/templates/app/modules/security/routes/security.routes.ts +4 -4
  79. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
  80. package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
  81. package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
  82. package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -4
  83. package/templates/app/modules/settings/tests/settings.test.ts +21 -25
  84. package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
  85. package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
  86. package/templates/app/modules/users/module.acl.ts +4 -4
  87. package/templates/app/modules/users/queues/users.queues.ts +1 -1
  88. package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
  89. package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
  90. package/templates/app/nginx/production.conf +1 -1
  91. package/templates/app/scripts/deploy-nginx.ts +3 -2
  92. package/templates/app/tests/support.ts +4 -3
  93. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +49 -34
  94. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
  95. package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
  96. package/templates/claude/skills/tulipes-endpoint/SKILL.md +36 -19
  97. package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
  98. package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
  99. package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
  100. package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
  101. package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
  102. package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
  103. package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
  104. package/templates/minimal/config/app.config.ts +13 -3
  105. package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
  106. package/templates/minimal/modules/core/module.acl.ts +7 -1
  107. package/templates/minimal/modules/core/package.json +4 -4
  108. package/templates/minimal/modules/hello/module.acl.ts +1 -1
  109. package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
  110. package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
  111. package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
  112. package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
  113. package/templates/versions.json +8 -8
  114. package/templates/app/modules/users/routes/users.routes.ts +0 -60
@@ -3,12 +3,11 @@ import type { BackendCtx } from "@tulipes/core/boot";
3
3
  import { roleOf } from "@tulipes/core/http";
4
4
 
5
5
  /**
6
- * The HTTP transport over `ctx.agent`: the manifest a role may call, and
7
- * the action endpoint. Route tools are not invoked here — the manifest
8
- * gives their method and path, and an agent calls them like any client,
9
- * through the same middleware.
6
+ * The HTTP transport over `ctx.agent`, for agents outside the process:
7
+ * the manifest a role may call, and the action endpoint. An agent inside
8
+ * the app needs neither — it reads `ctx.agent` directly.
10
9
  */
11
- export class AgentController {
10
+ export class WebAgentController {
12
11
  readonly #ctx: BackendCtx;
13
12
 
14
13
  constructor(ctx: BackendCtx) {
@@ -1,28 +1,26 @@
1
1
  import { Router } from "express";
2
2
  import type { BackendCtx } from "@tulipes/core/boot";
3
3
 
4
- import { AgentController } from "../controllers/agent.controllers.ts";
4
+ import { WebAgentController } from "../controllers/web.agent.controllers.ts";
5
5
  import { toolInput, toolName } from "../schemas/agent.schemas.ts";
6
6
 
7
7
  export default function agentRoutes(ctx: BackendCtx): void {
8
8
  const { rai, routes, config } = ctx;
9
9
  const router = Router();
10
- const agent = new AgentController(ctx);
10
+ const agent = new WebAgentController(ctx);
11
11
 
12
12
  router.get("/tools", rai({
13
- id: "agent:tools",
13
+ id: "agent:web:tools",
14
14
  name: "List the tools an agent may call",
15
15
  description: "The manifest filtered to the caller's role: route tools with their method and path, actions with their input schema",
16
- agent: false,
17
16
  }), agent.tools());
18
17
 
19
18
  router.post("/tools/:name", rai({
20
- id: "agent:invoke",
19
+ id: "agent:web:invoke",
21
20
  name: "Run a declared action",
22
21
  description: "Runs an action from a module's agent declaration; the action's own permission and input schema apply",
23
22
  params: toolName,
24
23
  body: toolInput,
25
- agent: false,
26
24
  }), agent.invoke());
27
25
 
28
26
  routes.mount(router, `${config.api!.prefix}/agent`);
@@ -5,22 +5,30 @@ import type { RuntimeCtx } from "@tulipes/core/boot";
5
5
  * the scaffold's placeholders, and the two keys must differ so leaking one
6
6
  * cannot mint the other. Sorted before seed-admin so the admin is never
7
7
  * seeded with a refused password.
8
+ *
9
+ * The values are this module's environment values, `config.auth.*`; the
10
+ * message names the variables an operator sets, not the config names.
8
11
  */
9
- export default function credentials({ Environment }: RuntimeCtx): void {
10
- if (Environment.appEnv !== "production" && Environment.appEnv !== "staging") return;
12
+ const PLACEHOLDERS = [
13
+ ["jwtAccessSecret", "JWT_ACCESS_SECRET", "dev-only-access-secret-change-me"],
14
+ ["jwtRefreshSecret", "JWT_REFRESH_SECRET", "dev-only-refresh-secret-change-me"],
15
+ ["adminPassword", "AUTH_ADMIN_PASSWORD", "dev-admin-password"],
16
+ ["adminEmail", "AUTH_ADMIN_EMAIL", "admin@example.com"],
17
+ ] as const;
18
+
19
+ export default function credentials({ appEnv, config }: RuntimeCtx): void {
20
+ if (appEnv !== "production" && appEnv !== "staging") return;
21
+
22
+ const auth = config.auth!;
11
23
  const invalid: string[] = [];
12
- const defaults = [
13
- ["JWT_ACCESS_SECRET", "dev-only-access-secret-change-me"],
14
- ["JWT_REFRESH_SECRET", "dev-only-refresh-secret-change-me"],
15
- ["AUTH_ADMIN_PASSWORD", "dev-admin-password"],
16
- ["AUTH_ADMIN_EMAIL", "admin@example.com"],
17
- ] as const;
18
- for (const [name, developmentValue] of defaults) {
19
- const value = String(Environment.get(name)).trim();
20
- if (!value || value === developmentValue) invalid.push(name);
24
+
25
+ for (const [name, variable, developmentValue] of PLACEHOLDERS) {
26
+ const value = auth[name].trim();
27
+ if (!value || value === developmentValue) invalid.push(variable);
21
28
  }
22
- if (Environment.get("JWT_ACCESS_SECRET") === Environment.get("JWT_REFRESH_SECRET")) {
29
+ if (auth.jwtAccessSecret === auth.jwtRefreshSecret) {
23
30
  invalid.push("JWT_ACCESS_SECRET / JWT_REFRESH_SECRET (use distinct keys)");
24
31
  }
32
+
25
33
  if (invalid.length) throw new Error("Unsafe production credentials: configure " + invalid.join(", "));
26
34
  }
@@ -15,7 +15,9 @@ import type { UserDoc } from "../helpers/documents.ts";
15
15
  export default async function seedAdmin(ctx: DatabaseCtx): Promise<void> {
16
16
  // Bootstrap runs in every mode; a one-off script has no business seeding.
17
17
  if (ctx.mode === "script") return;
18
- const email = String(ctx.Environment.get("AUTH_ADMIN_EMAIL")).toLowerCase();
18
+ // The seeded credentials are this module's environment values.
19
+ const { adminEmail, adminPassword } = ctx.config.auth!;
20
+ const email = adminEmail.toLowerCase();
19
21
  const User = ctx.models.get<UserDoc>("User");
20
22
 
21
23
  let user: Pick<UserDoc, "_id"> | null = await User.findOne({ email }).lean();
@@ -39,6 +41,6 @@ export default async function seedAdmin(ctx: DatabaseCtx): Promise<void> {
39
41
  .limit(1);
40
42
 
41
43
  if (existing === 0) {
42
- await setPassword(ctx, String(user!._id), String(ctx.Environment.get("AUTH_ADMIN_PASSWORD")));
44
+ await setPassword(ctx, String(user!._id), adminPassword);
43
45
  }
44
46
  }
@@ -42,7 +42,7 @@ interface Credentials {
42
42
  * "invalidCredentials" becomes INVALID_CREDENTIALS with a translated
43
43
  * message and there is no second string to keep in step.
44
44
  */
45
- export class AuthController {
45
+ export class WebAuthController {
46
46
  readonly #ctx: Ctx;
47
47
 
48
48
  constructor(ctx: Ctx) {
@@ -92,7 +92,7 @@ export function configurePassport(ctx: Ctx): void {
92
92
  // Cookies and URL parameters never authenticate this starter. A cookie
93
93
  // extractor would also need the policy described in docs/browser-auth.md.
94
94
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
95
- secretOrKey: String(ctx.Environment.get("JWT_ACCESS_SECRET")),
95
+ secretOrKey: ctx.config.auth!.jwtAccessSecret,
96
96
  passReqToCallback: false,
97
97
  },
98
98
  async (claims: TokenClaims, done) => {
@@ -32,18 +32,18 @@ export function cacheTtlSeconds(ctx: Ctx): number {
32
32
 
33
33
  export async function readIdentity(ctx: Ctx, sid: string): Promise<CachedIdentity | null> {
34
34
  if (cacheTtlSeconds(ctx) === 0) return null;
35
- return appCache(ctx.Environment).get<CachedIdentity>(KEY + sid);
35
+ return appCache(ctx).get<CachedIdentity>(KEY + sid);
36
36
  }
37
37
 
38
38
  export async function writeIdentity(ctx: Ctx, sid: string, identity: CachedIdentity): Promise<void> {
39
39
  const ttl = cacheTtlSeconds(ctx);
40
40
  if (ttl === 0) return;
41
- await appCache(ctx.Environment).set(KEY + sid, identity, ttl);
41
+ await appCache(ctx).set(KEY + sid, identity, ttl);
42
42
  }
43
43
 
44
44
  /** Forget one session: the next request with its token takes the cold path. */
45
45
  export async function forgetSession(ctx: Ctx, sid: string): Promise<void> {
46
- await appCache(ctx.Environment).del(KEY + sid);
46
+ await appCache(ctx).del(KEY + sid);
47
47
  }
48
48
 
49
49
  /**
@@ -36,12 +36,10 @@ export interface SessionOrigin {
36
36
  user_agent?: string;
37
37
  }
38
38
 
39
+ // The signing keys are this module's environment values, read where they
40
+ // are used: inspection has no value, and nothing here runs under it.
39
41
  const secret = (ctx: Ctx, typ: "access" | "refresh"): string =>
40
- String(
41
- ctx.Environment.get(
42
- typ === "access" ? "JWT_ACCESS_SECRET" : "JWT_REFRESH_SECRET",
43
- ),
44
- );
42
+ typ === "access" ? ctx.config.auth!.jwtAccessSecret : ctx.config.auth!.jwtRefreshSecret;
45
43
 
46
44
  // Read at signing time, never cached: token policy is a setting an operator
47
45
  // changes without a restart.
@@ -1,31 +1,53 @@
1
1
  import type { AclBuilder } from "@tulipes/core/acl";
2
2
 
3
+ /**
4
+ * Roles carry whatever the app wants to read back about them. Declaring an
5
+ * extra here types it everywhere: `ctx.acl.role("admin")?.level` is a
6
+ * number. Anything undeclared is still accepted, just untyped.
7
+ */
8
+ declare module "@tulipes/core/acl" {
9
+ interface RoleMeta {
10
+ /** Higher reaches more. For display and ordering; the ACL decides access. */
11
+ level?: number;
12
+ }
13
+ }
14
+
3
15
  /**
4
16
  * The auth module owns the role vocabulary, because roles only mean
5
- * anything alongside the thing that assigns them.
17
+ * anything alongside the thing that assigns them. `pnpm tulipes roles`
18
+ * prints them with their descriptions and every grant they hold.
6
19
  *
7
20
  * `guest` is the fallback for a request with no valid token. That is what
8
21
  * makes "public endpoint" an ordinary ACL grant rather than a second
9
22
  * mechanism: login is public because guest may do it, not because a path
10
23
  * appears on an allowlist somewhere else.
24
+ *
25
+ * Every permission is a RAI id, `module:scope:resource[:extra]`. This app
26
+ * has two scopes: `web`, what a signed-in client calls, and `bo`, the back
27
+ * office. A grant names one id or a prefix: `users:bo:*` is the whole users
28
+ * back office, `users:*` every users surface, `*` everything.
11
29
  */
12
30
  export default function authAcl(acl: AclBuilder): void {
13
- acl.defineRole("guest").defineRole("user").defineRole("admin").defineRole("sysadmin");
31
+ acl
32
+ .defineRole("guest", { description: "Any request without a valid token", level: 0 })
33
+ .defineRole("user", { description: "A signed-in account", level: 1 })
34
+ .defineRole("admin", { description: "Runs the back office", level: 2 })
35
+ .defineRole("sysadmin", { description: "Administers the deployment and its settings", level: 3 });
14
36
 
15
37
  // Anyone may attempt to authenticate. Refresh is public because the
16
38
  // refresh token itself is the credential — an expired access token must
17
39
  // not stand in the way of renewing it.
18
- acl.allow("guest", "auth:login", "auth:register", "auth:refresh");
40
+ acl.allow("guest", "auth:web:login", "auth:web:register", "auth:web:refresh");
19
41
 
20
42
  // Signed in: end your session, read yourself, change your own password,
21
- // and manage the devices you are signed in on.
43
+ // and manage the devices you are signed in on (`auth:web:sessions:*`
44
+ // covers listing them and ending one).
22
45
  acl.allow(
23
46
  "user",
24
- "auth:logout",
25
- "auth:me",
26
- "auth:password",
27
- "auth:sessions",
28
- "auth:session-end",
47
+ "auth:web:logout",
48
+ "auth:web:me",
49
+ "auth:web:password",
50
+ "auth:web:sessions:*",
29
51
  );
30
52
  // `admin` is granted each feature namespace EXPLICITLY rather than "*".
31
53
  // A wildcard would also reach settings:*, and the infrastructure console
@@ -3,9 +3,11 @@ import { z } from "zod/v4";
3
3
 
4
4
  /**
5
5
  * Token policy is a setting: every deployment wants to tune it, none of it
6
- * is secret. The signing keys and the seeded admin credentials stay in the
7
- * environment, where secrets belong, and `bootstrap/credentials.bootstrap.ts`
8
- * refuses development values outside development.
6
+ * is secret. The signing keys and the seeded admin credentials are this
7
+ * module's environment values (package.json `tulipes.env`), where secrets
8
+ * belong — read as `config.auth.jwtAccessSecret` and the like, never listed
9
+ * — and `bootstrap/credentials.bootstrap.ts` refuses development values
10
+ * outside development.
9
11
  */
10
12
  export default defineConfig({
11
13
  schema: z.object({
@@ -9,36 +9,36 @@
9
9
  "tulipes": {
10
10
  "tier": "sys",
11
11
  "priority": 20,
12
- "env": [
13
- {
14
- "name": "JWT_ACCESS_SECRET",
12
+ "env": {
13
+ "jwtAccessSecret": {
14
+ "key": "JWT_ACCESS_SECRET",
15
15
  "type": "secret",
16
16
  "group": "auth",
17
17
  "required": true,
18
18
  "description": "Signing key for short-lived access tokens"
19
19
  },
20
- {
21
- "name": "JWT_REFRESH_SECRET",
20
+ "jwtRefreshSecret": {
21
+ "key": "JWT_REFRESH_SECRET",
22
22
  "type": "secret",
23
23
  "group": "auth",
24
24
  "required": true,
25
25
  "description": "Signing key for refresh tokens; separate from the access key so leaking one cannot mint the other"
26
26
  },
27
- {
28
- "name": "AUTH_ADMIN_EMAIL",
27
+ "adminEmail": {
28
+ "key": "AUTH_ADMIN_EMAIL",
29
29
  "type": "string",
30
30
  "group": "auth",
31
31
  "required": true,
32
32
  "description": "Email of the admin account seeded on first boot"
33
33
  },
34
- {
35
- "name": "AUTH_ADMIN_PASSWORD",
34
+ "adminPassword": {
35
+ "key": "AUTH_ADMIN_PASSWORD",
36
36
  "type": "secret",
37
37
  "group": "auth",
38
38
  "required": true,
39
39
  "description": "Password for the seeded admin account; change it after the first login"
40
40
  }
41
- ]
41
+ }
42
42
  },
43
43
  "dependencies": {
44
44
  "@app/core": "workspace:*",
@@ -3,7 +3,7 @@ import passport from "passport";
3
3
  import type { BackendCtx } from "@tulipes/core/boot";
4
4
  import { lazy } from "@app/core";
5
5
 
6
- import { AuthController } from "../controllers/auth.controllers.ts";
6
+ import { WebAuthController } from "../controllers/web.auth.controllers.ts";
7
7
  import { authenticate, configurePassport } from "../helpers/passport.ts";
8
8
  import {
9
9
  credentials,
@@ -14,9 +14,14 @@ import {
14
14
  tokenPair,
15
15
  } from "../schemas/auth.schemas.ts";
16
16
 
17
+ /**
18
+ * Every endpoint here serves the client-facing surface, so the file is
19
+ * named scope-first (`web.auth.routes.ts`) and every id is `auth:web:…`.
20
+ * A back-office surface would sit beside it as `bo.auth.routes.ts`.
21
+ */
17
22
  export default function authRoutes(ctx: BackendCtx): void {
18
23
  const { app, rai, routes, config } = ctx;
19
- const auth = new AuthController(ctx);
24
+ const auth = new WebAuthController(ctx);
20
25
 
21
26
  // Passport reads the signing keys when it is configured, so it is
22
27
  // configured on the first request, after which every request is identified
@@ -30,7 +35,7 @@ export default function authRoutes(ctx: BackendCtx): void {
30
35
  const router = Router();
31
36
 
32
37
  router.post("/register", rai({
33
- id: "auth:register",
38
+ id: "auth:web:register",
34
39
  status: 201,
35
40
  name: "Register",
36
41
  description: "Create an account and its credential",
@@ -38,7 +43,7 @@ export default function authRoutes(ctx: BackendCtx): void {
38
43
  }), auth.register());
39
44
 
40
45
  router.post("/login", rai({
41
- id: "auth:login",
46
+ id: "auth:web:login",
42
47
  name: "Log in",
43
48
  description: "Exchange email and password for a token pair",
44
49
  body: credentials,
@@ -46,7 +51,7 @@ export default function authRoutes(ctx: BackendCtx): void {
46
51
  }), auth.login());
47
52
 
48
53
  router.post("/refresh", rai({
49
- id: "auth:refresh",
54
+ id: "auth:web:refresh",
50
55
  name: "Refresh tokens",
51
56
  description: "Rotate a refresh token for a new pair",
52
57
  body: refreshBody,
@@ -54,19 +59,19 @@ export default function authRoutes(ctx: BackendCtx): void {
54
59
  }), auth.refresh());
55
60
 
56
61
  router.post("/logout", rai({
57
- id: "auth:logout",
62
+ id: "auth:web:logout",
58
63
  name: "Log out",
59
64
  description: "Revoke this session immediately",
60
65
  }), auth.logout());
61
66
 
62
67
  router.get("/me", rai({
63
- id: "auth:me",
68
+ id: "auth:web:me",
64
69
  name: "Who am I",
65
70
  description: "The caller and the session behind this request",
66
71
  }), auth.me());
67
72
 
68
73
  router.get("/sessions", rai({
69
- id: "auth:sessions",
74
+ id: "auth:web:sessions",
70
75
  name: "List sessions",
71
76
  description: "Every device this account is signed in on",
72
77
  // These two are auth's, but they answer a different question from
@@ -76,7 +81,7 @@ export default function authRoutes(ctx: BackendCtx): void {
76
81
  }), auth.sessions());
77
82
 
78
83
  router.delete("/sessions/:sid", rai({
79
- id: "auth:session-end",
84
+ id: "auth:web:sessions:end",
80
85
  name: "End a session",
81
86
  description: "Sign one other device out",
82
87
  folder: "sessions",
@@ -84,7 +89,7 @@ export default function authRoutes(ctx: BackendCtx): void {
84
89
  }), auth.revokeSession());
85
90
 
86
91
  router.post("/password", rai({
87
- id: "auth:password",
92
+ id: "auth:web:password",
88
93
  name: "Change password",
89
94
  description: "Set a new password and end every other session",
90
95
  body: newPassword,
@@ -255,7 +255,7 @@ test("invalid refresh credentials cannot revoke a live family", async () => {
255
255
  const initial = await account();
256
256
  const other = await account();
257
257
  const original = jwt.decode(initial.refresh_token) as Record<string, unknown>;
258
- const key = String(app.handle.ctx.Environment.get("JWT_REFRESH_SECRET"));
258
+ const key = String(app.handle.ctx.config.auth!.jwtRefreshSecret);
259
259
 
260
260
  const sign = (changes: Record<string, unknown>) =>
261
261
  jwt.sign({ ...original, ...changes }, key);
@@ -4,7 +4,7 @@ import { SettingsConflictError, SettingsValidationError } from "@tulipes/core/co
4
4
  import { HttpError, ValidationError } from "@tulipes/core/http";
5
5
 
6
6
  import { auditChange } from "../helpers/audit.ts";
7
- import { describeModule } from "../helpers/describe.ts";
7
+ import { describeModule, describeRoles } from "../helpers/describe.ts";
8
8
 
9
9
  /**
10
10
  * The console: what every module is configured with, and the one write —
@@ -14,7 +14,7 @@ import { describeModule } from "../helpers/describe.ts";
14
14
  * the environment: a variable is what boot needs before the store answers
15
15
  * or a secret, and neither is edited at runtime.
16
16
  */
17
- export class ConfigController {
17
+ export class BoConfigController {
18
18
  readonly #ctx: Ctx;
19
19
 
20
20
  constructor(ctx: Ctx) {
@@ -28,6 +28,7 @@ export class ConfigController {
28
28
  data: {
29
29
  app: this.#ctx.config.app ?? {},
30
30
  store: this.#ctx.settings.status(),
31
+ roles: describeRoles(this.#ctx),
31
32
  modules: this.#ctx.modules.map((module) => describeModule(this.#ctx, module.name)),
32
33
  },
33
34
  });
@@ -27,7 +27,7 @@ export async function auditChange(
27
27
  actor: String(user._id),
28
28
  actor_role: user.role,
29
29
  ip: req.ip,
30
- app_env: ctx.Environment.appEnv,
30
+ app_env: ctx.appEnv,
31
31
  mode: ctx.mode,
32
32
  recorded_at: new Date(),
33
33
  }).save({ w: "majority", j: true, wtimeout: 5000 });
@@ -2,8 +2,9 @@ import type { Ctx } from "@tulipes/core/boot";
2
2
 
3
3
  /**
4
4
  * Everything the framework knows about one module, for the console: the
5
- * manifest, the variables it declares (names, types, sources — a secret's
6
- * value never, any value only as what the process resolved), its settings
5
+ * manifest, the environment values it declares (config names, variables,
6
+ * types, sources — a secret's value never, any value only as what the
7
+ * process resolved), its settings
7
8
  * with their schema, live values and stored version, and its endpoints
8
9
  * with the roles that reach them. Nothing is invented: it all comes from
9
10
  * the booted context, so the console cannot describe an app different
@@ -13,25 +14,24 @@ export function describeModule(ctx: Ctx, name: string): Record<string, unknown>
13
14
  const manifest = ctx.modules.find((entry) => entry.name === name);
14
15
  if (!manifest) return undefined;
15
16
 
16
- const variables = ctx.Environment.store.describe()
17
- .filter((variable) => variable.owner === name)
18
- .map((variable) => ({
19
- name: variable.spec.name,
20
- type: variable.spec.type,
21
- description: variable.spec.description,
22
- group: variable.spec.group ?? null,
23
- required: variable.spec.required ?? false,
24
- enum: variable.spec.enum ?? null,
25
- default: variable.spec.type === "secret" ? null : (variable.spec.default ?? null),
26
- source: variable.source,
27
- is_secret: variable.isSecret,
28
- is_set: variable.isSet,
29
- // What this process resolved; a secret is never here.
30
- value: variable.isSecret ? null : (variable.value ?? null),
31
- }));
17
+ // The env half of the module's namespace, as the framework describes it:
18
+ // a secret's value and default are already withheld.
19
+ const variables = ctx.settings.describeEnv(name).map((variable) => ({
20
+ name: variable.name,
21
+ key: variable.key,
22
+ type: variable.type,
23
+ description: variable.description,
24
+ group: variable.group ?? null,
25
+ required: variable.required,
26
+ enum: variable.enum ?? null,
27
+ default: variable.default ?? null,
28
+ source: variable.source ?? null,
29
+ is_secret: variable.isSecret,
30
+ is_set: variable.isSet,
31
+ value: variable.value ?? null,
32
+ }));
32
33
 
33
34
  const settings = ctx.settings.namespaces().includes(name) ? ctx.settings.describe(name) : undefined;
34
- const grants = ctx.acl?.describe() ?? [];
35
35
  const routes = ctx.routes.all()
36
36
  .filter((record) => record.module === name)
37
37
  .map((record) => ({
@@ -39,7 +39,8 @@ export function describeModule(ctx: Ctx, name: string): Record<string, unknown>
39
39
  method: record.method.toUpperCase(),
40
40
  path: record.path,
41
41
  name: record.info.name ?? null,
42
- roles: ctx.routes.checker ? "dynamic" : rolesFor(grants, record.info.id),
42
+ // The ACL resolves who holds the id exactly as it gates the request.
43
+ roles: ctx.routes.checker ? "dynamic" : (ctx.acl?.rolesFor(record.info.id) ?? []),
43
44
  }));
44
45
 
45
46
  return {
@@ -63,10 +64,18 @@ export function describeModule(ctx: Ctx, name: string): Record<string, unknown>
63
64
  };
64
65
  }
65
66
 
66
- function rolesFor(grants: { role: string; resource: string }[], id: string): string[] {
67
- const namespace = `${id.split(":")[0]}:*`;
68
- const roles = grants
69
- .filter(({ resource }) => resource === id || resource === "*" || resource === namespace)
70
- .map(({ role }) => role);
71
- return [...new Set(roles)].sort();
67
+ /**
68
+ * Every role the app defines, with the description and extras it was
69
+ * defined with — so the console can say who "admin" is, not just that it
70
+ * exists — and the grants each one holds.
71
+ */
72
+ export function describeRoles(ctx: Ctx): Record<string, unknown>[] {
73
+ const grants = ctx.acl?.describe() ?? [];
74
+ return (ctx.acl?.describeRoles() ?? []).map(({ name, definedBy, description, ...extras }) => ({
75
+ name,
76
+ description: description ?? null,
77
+ defined_by: definedBy,
78
+ extras,
79
+ grants: grants.filter(({ role }) => role === name).map(({ resource }) => resource).sort(),
80
+ }));
72
81
  }
@@ -7,6 +7,6 @@ import type { AclBuilder } from "@tulipes/core/acl";
7
7
  * grants `admin` the `*` wildcard.
8
8
  */
9
9
  export default function configAcl(acl: AclBuilder): void {
10
- acl.allow("admin", "config:read", "config:describe");
10
+ acl.allow("admin", "config:bo:read", "config:bo:describe");
11
11
  acl.allow("sysadmin", "config:*");
12
12
  }
@@ -1,23 +1,23 @@
1
1
  import { Router } from "express";
2
2
  import type { BackendCtx } from "@tulipes/core/boot";
3
3
 
4
- import { ConfigController } from "../controllers/config.controllers.ts";
4
+ import { BoConfigController } from "../controllers/bo.config.controllers.ts";
5
5
  import { changeBody, moduleParam } from "../schemas/config.schemas.ts";
6
6
 
7
7
  export default function configRoutes(ctx: BackendCtx): void {
8
8
  const { rai, routes, config } = ctx;
9
9
  const router = Router();
10
- const console = new ConfigController(ctx);
10
+ const console = new BoConfigController(ctx);
11
11
 
12
12
  router.get("/", rai({
13
- id: "config:read",
13
+ id: "config:bo:read",
14
14
  name: "Describe the app",
15
15
  description: "Every module in load order: manifest, declared variables (names, types, sources; secrets never), settings with schema, live values and stored version, endpoints with the roles that reach them",
16
16
  folder: "config",
17
17
  }), console.app());
18
18
 
19
19
  router.get("/:module", rai({
20
- id: "config:describe",
20
+ id: "config:bo:describe",
21
21
  name: "Describe one module",
22
22
  description: "The same description for one module",
23
23
  folder: "config",
@@ -25,7 +25,7 @@ export default function configRoutes(ctx: BackendCtx): void {
25
25
  }), console.module());
26
26
 
27
27
  router.patch("/:module", rai({
28
- id: "config:write",
28
+ id: "config:bo:write",
29
29
  name: "Change a module's settings",
30
30
  description: "A patch over the stored values, validated by the module's schema; a stale version is refused; applied at once in every replica, except fields tagged restart, which apply on the next boot",
31
31
  folder: "config",
@@ -8,7 +8,8 @@ import { useTestApp } from "@tulipes/testing";
8
8
  import { testEnv, testSettings } from "../../../tests/support.ts";
9
9
 
10
10
  const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
- const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
11
+ const env = testEnv();
12
+ const app = useTestApp({ rootDir, env, settings: testSettings() }, { beforeAll, afterAll });
12
13
 
13
14
 
14
15
  const tokens = z.object({
@@ -79,16 +80,23 @@ test("the console describes every module without a secret's value and only admin
79
80
 
80
81
  const response = await get("/api/v1/config", admin.access_token);
81
82
  expect(response.status).toBe(200);
82
- const { data } = (await response.json()) as { data: { store: { ready: boolean }; modules: Record<string, unknown>[] } };
83
+ const { data } = (await response.json()) as { data: { store: { ready: boolean }; roles: Record<string, unknown>[]; modules: Record<string, unknown>[] } };
83
84
  expect(data.store.ready).toBe(true);
84
85
  expect(data.modules.map((module) => module.name)).toEqual(app.handle.ctx.modules.map((module) => module.name));
86
+ expect(data.roles).toContainEqual({
87
+ name: "admin",
88
+ description: "Runs the back office",
89
+ defined_by: "auth",
90
+ extras: { level: 2 },
91
+ grants: ["agent:*", "auth:*", "config:bo:describe", "config:bo:read", "hello:*", "users:*"],
92
+ });
85
93
 
86
94
  const auth = data.modules.find((module) => module.name === "auth") as { variables: Record<string, unknown>[]; settings: { values: Record<string, unknown>; version: number }; routes: Record<string, unknown>[] };
87
- const secret = auth.variables.find((variable) => variable.name === "JWT_ACCESS_SECRET")!;
88
- expect(secret).toMatchObject({ is_secret: true, is_set: true, value: null, default: null });
89
- expect(JSON.stringify(data)).not.toContain(process.env.JWT_ACCESS_SECRET);
95
+ const secret = auth.variables.find((variable) => variable.name === "jwtAccessSecret")!;
96
+ expect(secret).toMatchObject({ key: "JWT_ACCESS_SECRET", type: "secret", source: "process.env", is_secret: true, is_set: true, value: null, default: null });
97
+ expect(JSON.stringify(data)).not.toContain(env.JWT_ACCESS_SECRET);
90
98
  expect(auth.settings.values).toMatchObject({ accessTtl: "15m" });
91
- expect(auth.routes).toContainEqual(expect.objectContaining({ id: "auth:login", method: "POST", roles: expect.arrayContaining(["guest"]) }));
99
+ expect(auth.routes).toContainEqual(expect.objectContaining({ id: "auth:web:login", method: "POST", roles: expect.arrayContaining(["guest"]) }));
92
100
 
93
101
  // The administrator may read; writing is the deployment's job.
94
102
  expect((await patch("/api/v1/config/hello", { values: { greeting: "Nope" } }, admin.access_token)).status).toBe(403);