@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
package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts}
RENAMED
|
@@ -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
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
[
|
|
15
|
-
|
|
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 (
|
|
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
|
-
|
|
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),
|
|
44
|
+
await setPassword(ctx, String(user!._id), adminPassword);
|
|
43
45
|
}
|
|
44
46
|
}
|
package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts}
RENAMED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
7
|
-
* environment
|
|
8
|
-
*
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 {
|
|
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
|
|
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:
|
|
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.
|
|
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);
|
package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts}
RENAMED
|
@@ -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
|
|
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.
|
|
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
|
|
6
|
-
* value never, any value only as what the
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 === "
|
|
88
|
-
expect(secret).toMatchObject({ is_secret: true, is_set: true, value: null, default: null });
|
|
89
|
-
expect(JSON.stringify(data)).not.toContain(
|
|
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);
|