@tulipes/cli 0.1.0 → 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/dist/agent.d.ts +12 -0
- package/dist/agent.js +29 -0
- package/dist/agent.js.map +1 -0
- package/dist/app-core.d.ts +8 -6
- package/dist/app-core.js +23 -14
- package/dist/app-core.js.map +1 -1
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/config-check.d.ts +8 -0
- package/dist/config-check.js +43 -0
- package/dist/config-check.js.map +1 -0
- package/dist/config-import.d.ts +20 -0
- package/dist/config-import.js +133 -0
- package/dist/config-import.js.map +1 -0
- package/dist/dev.d.ts +4 -4
- package/dist/dev.js +7 -10
- package/dist/dev.js.map +1 -1
- package/dist/init.d.ts +6 -11
- package/dist/init.js +17 -6420
- package/dist/init.js.map +1 -1
- package/dist/inspection.d.ts +9 -8
- package/dist/inspection.js +10 -11
- package/dist/inspection.js.map +1 -1
- package/dist/main.js +82 -11
- package/dist/main.js.map +1 -1
- package/dist/migrate.d.ts +30 -0
- package/dist/migrate.js +772 -0
- package/dist/migrate.js.map +1 -0
- package/dist/new-module.js +87 -46
- package/dist/new-module.js.map +1 -1
- package/dist/routes.d.ts +3 -5
- package/dist/routes.js +4 -6
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -5
- package/dist/spec.js +13 -6
- package/dist/spec.js.map +1 -1
- package/dist/sync.d.ts +3 -11
- package/dist/sync.js +81 -20
- package/dist/sync.js.map +1 -1
- package/dist/template.d.ts +58 -0
- package/dist/template.js +244 -0
- package/dist/template.js.map +1 -0
- package/dist/update.js +4 -4
- package/package.json +17 -16
- package/templates/AGENTS.md +162 -0
- package/templates/CLAUDE.md +11 -118
- package/templates/app/.envs/.env.development +18 -0
- package/templates/app/.envs/.env.production +19 -0
- package/templates/app/.envs/.env.staging +19 -0
- package/templates/app/README.md +327 -0
- package/templates/app/app.ts +3 -0
- package/templates/app/config/app.config.ts +73 -0
- package/templates/app/docs/agent-manifest.json +724 -0
- package/templates/app/ecosystem.config.cjs +60 -0
- package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
- package/templates/app/modules/agent/i18n/en.json +5 -0
- package/templates/app/modules/agent/i18n/fr.json +5 -0
- package/templates/app/modules/agent/module.acl.ts +14 -0
- package/templates/app/modules/agent/package.json +21 -0
- package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
- package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
- package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
- package/templates/app/modules/auth/helpers/access-check.ts +42 -0
- package/templates/app/modules/auth/helpers/auth.ts +48 -0
- package/templates/app/modules/auth/helpers/documents.ts +57 -0
- package/templates/app/modules/auth/helpers/passport.ts +180 -0
- package/templates/app/modules/auth/helpers/passwords.ts +58 -0
- package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
- package/templates/app/modules/auth/helpers/tokens.ts +329 -0
- package/templates/app/modules/auth/i18n/en.json +19 -0
- package/templates/app/modules/auth/i18n/fr.json +19 -0
- package/templates/app/modules/auth/lib/index.ts +12 -0
- package/templates/app/modules/auth/models/credential.model.ts +31 -0
- package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
- package/templates/app/modules/auth/models/session.model.ts +42 -0
- package/templates/app/modules/auth/module.acl.ts +41 -0
- package/templates/app/modules/auth/module.config.ts +30 -0
- package/templates/app/modules/auth/package.json +61 -0
- package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
- package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
- package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
- package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
- package/templates/app/modules/config/helpers/audit.ts +41 -0
- package/templates/app/modules/config/helpers/describe.ts +72 -0
- package/templates/app/modules/config/i18n/en.json +6 -0
- package/templates/app/modules/config/i18n/fr.json +6 -0
- package/templates/app/modules/config/models/config-audit.model.ts +26 -0
- package/templates/app/modules/config/module.acl.ts +12 -0
- package/templates/app/modules/config/package.json +23 -0
- package/templates/app/modules/config/routes/config.routes.ts +37 -0
- package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
- package/templates/app/modules/config/tests/config.test.ts +126 -0
- package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
- package/templates/app/modules/core/helpers/cache/index.ts +60 -0
- package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
- package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
- package/templates/app/modules/core/helpers/cache/types.ts +37 -0
- package/templates/app/modules/core/helpers/lazy.ts +13 -0
- package/templates/app/modules/core/helpers/logger/index.ts +115 -0
- package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
- package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
- package/templates/app/modules/core/lib/index.ts +34 -0
- package/templates/app/modules/core/module.config.ts +24 -0
- package/templates/app/modules/core/package.json +80 -0
- package/templates/app/modules/core/routes/core.routes.ts +24 -0
- package/templates/app/modules/core/tests/core.test.ts +19 -0
- package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
- package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
- package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
- package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
- package/templates/app/modules/hello/helpers/greeting.ts +8 -0
- package/templates/app/modules/hello/i18n/en.json +8 -0
- package/templates/app/modules/hello/i18n/fr.json +8 -0
- package/templates/app/modules/hello/models/greeting.model.ts +24 -0
- package/templates/app/modules/hello/module.acl.ts +16 -0
- package/templates/app/modules/hello/module.config.ts +14 -0
- package/templates/app/modules/hello/package.json +24 -0
- package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
- package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
- package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
- package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
- package/templates/app/modules/hello/tests/hello.test.ts +19 -0
- package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
- package/templates/app/modules/security/helpers/hardening.ts +65 -0
- package/templates/app/modules/security/helpers/proxy.ts +16 -0
- package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
- package/templates/app/modules/security/module.config.ts +59 -0
- package/templates/app/modules/security/package.json +26 -0
- package/templates/app/modules/security/routes/security.routes.ts +42 -0
- package/templates/app/modules/security/tests/security.test.ts +174 -0
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
- package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
- package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
- package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
- package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
- package/templates/app/modules/settings/i18n/en.json +3 -0
- package/templates/app/modules/settings/i18n/fr.json +3 -0
- package/templates/app/modules/settings/module.acl.ts +12 -0
- package/templates/app/modules/settings/package.json +21 -0
- package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
- package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
- package/templates/app/modules/settings/tests/settings.test.ts +409 -0
- package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
- package/templates/app/modules/users/i18n/en.json +14 -0
- package/templates/app/modules/users/i18n/fr.json +14 -0
- package/templates/app/modules/users/models/user.model.ts +18 -0
- package/templates/app/modules/users/module.acl.ts +10 -0
- package/templates/app/modules/users/module.config.ts +11 -0
- package/templates/app/modules/users/package.json +24 -0
- package/templates/app/modules/users/queues/users.queues.ts +31 -0
- package/templates/app/modules/users/routes/users.routes.ts +60 -0
- package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
- package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
- package/templates/app/nginx/development.conf +44 -0
- package/templates/app/nginx/production.conf +75 -0
- package/templates/app/nginx/staging.conf +51 -0
- package/templates/app/package.json +58 -0
- package/templates/app/scripts/count-greetings.ts +29 -0
- package/templates/app/scripts/deploy-nginx.ts +143 -0
- package/templates/app/scripts/list-users.ts +34 -0
- package/templates/app/tests/app.test.ts +28 -0
- package/templates/app/tests/support.ts +36 -0
- package/templates/app/tsconfig.json +28 -0
- package/templates/app/vitest.config.ts +6 -0
- package/templates/app/worker.ts +6 -0
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
- package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
- package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
- package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
- package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
- package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
- package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
- package/templates/minimal/README.md +21 -0
- package/templates/minimal/app.ts +3 -0
- package/templates/minimal/config/app.config.ts +16 -0
- package/templates/minimal/modules/agent/module.acl.ts +3 -0
- package/templates/minimal/modules/agent/package.json +14 -0
- package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
- package/templates/minimal/modules/core/module.acl.ts +4 -0
- package/templates/minimal/modules/core/package.json +21 -0
- package/templates/minimal/modules/hello/i18n/en.json +3 -0
- package/templates/minimal/modules/hello/module.acl.ts +2 -0
- package/templates/minimal/modules/hello/package.json +11 -0
- package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
- package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
- package/templates/minimal/modules/security/package.json +15 -0
- package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
- package/templates/minimal/package.json +38 -0
- package/templates/minimal/tests/app.test.ts +52 -0
- package/templates/minimal/tsconfig.json +22 -0
- package/templates/minimal/vitest.config.ts +5 -0
- package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
- package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
- package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
- package/templates/versions.json +11 -0
- package/dist/minimal.d.ts +0 -2
- package/dist/minimal.js +0 -2200
- package/dist/minimal.js.map +0 -1
- /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// PM2 definitions for both Tulipes processes in all three modes.
|
|
2
|
+
//
|
|
3
|
+
// pnpm pm2:start development
|
|
4
|
+
// pnpm pm2:start:staging staging
|
|
5
|
+
// pnpm pm2:start:production production
|
|
6
|
+
// pnpm pm2:stop | pm2:restart | pm2:delete | pm2:logs | pm2:status
|
|
7
|
+
//
|
|
8
|
+
// CommonJS on purpose: this app is an ESM package, and PM2 reads its
|
|
9
|
+
// config with require().
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Each block repeats both variables rather than relying on PM2 merging
|
|
13
|
+
* env_<name> over env: a half-applied environment (right APP_ENV, wrong
|
|
14
|
+
* PROCESS_MODE) would start the wrong process against the wrong config,
|
|
15
|
+
* and the repetition is cheap insurance against that.
|
|
16
|
+
*/
|
|
17
|
+
const modes = (processMode) => ({
|
|
18
|
+
env: { APP_ENV: "development", PROCESS_MODE: processMode },
|
|
19
|
+
env_staging: { APP_ENV: "staging", PROCESS_MODE: processMode },
|
|
20
|
+
env_production: { APP_ENV: "production", PROCESS_MODE: processMode },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Shared by both apps. */
|
|
24
|
+
const common = {
|
|
25
|
+
cwd: __dirname,
|
|
26
|
+
// Node 24 strips types natively, so PM2 runs the TypeScript entrypoints
|
|
27
|
+
// with node itself: no loader, no build step, nothing to point elsewhere.
|
|
28
|
+
interpreter: "node",
|
|
29
|
+
// Fork, not cluster: Socket.IO needs sticky sessions or the Redis
|
|
30
|
+
// adapter before a second instance is safe, and bootstrap tasks would
|
|
31
|
+
// run once per instance. Scale out only after wiring the adapter.
|
|
32
|
+
exec_mode: "fork",
|
|
33
|
+
instances: 1,
|
|
34
|
+
autorestart: true,
|
|
35
|
+
max_memory_restart: "512M",
|
|
36
|
+
// Graceful shutdown drains queues and closes mongo and redis. PM2's
|
|
37
|
+
// default 1.6s SIGKILL would cut that short and lose in-flight jobs.
|
|
38
|
+
kill_timeout: 10000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
apps: [
|
|
43
|
+
{
|
|
44
|
+
...common,
|
|
45
|
+
name: "tulipes-example",
|
|
46
|
+
script: "app.ts",
|
|
47
|
+
error_file: "logs/backend-error.log",
|
|
48
|
+
out_file: "logs/backend-out.log",
|
|
49
|
+
...modes("backend"),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
...common,
|
|
53
|
+
name: "tulipes-example-worker",
|
|
54
|
+
script: "worker.ts",
|
|
55
|
+
error_file: "logs/worker-error.log",
|
|
56
|
+
out_file: "logs/worker-out.log",
|
|
57
|
+
...modes("worker"),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Request, RequestHandler, Response } from "express";
|
|
2
|
+
import type { BackendCtx } from "@tulipes/core/boot";
|
|
3
|
+
import { roleOf } from "@tulipes/core/http";
|
|
4
|
+
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
export class AgentController {
|
|
12
|
+
readonly #ctx: BackendCtx;
|
|
13
|
+
|
|
14
|
+
constructor(ctx: BackendCtx) {
|
|
15
|
+
this.#ctx = ctx;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
tools(): RequestHandler {
|
|
19
|
+
return (req: Request, res: Response) => {
|
|
20
|
+
res.respond({ data: this.#ctx.agent.manifest(roleOf(req)) });
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
invoke(): RequestHandler {
|
|
25
|
+
return async (req: Request, res: Response) => {
|
|
26
|
+
const name = String(req.params.name);
|
|
27
|
+
const actor = { ...req.auth, role: roleOf(req) };
|
|
28
|
+
res.respond({ data: await this.#ctx.agent.invoke(name, req.body, actor, req) });
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AclBuilder } from "@tulipes/core/acl";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The agent door is open to every role, including guest: the manifest is
|
|
5
|
+
* filtered to what the caller's role may call, and an action checks its
|
|
6
|
+
* own permission before running, so these grants reveal and allow nothing
|
|
7
|
+
* the ACL does not already.
|
|
8
|
+
*/
|
|
9
|
+
export default function agentAcl(acl: AclBuilder): void {
|
|
10
|
+
acl.allow("guest", "agent:*");
|
|
11
|
+
acl.allow("user", "agent:*");
|
|
12
|
+
acl.allow("admin", "agent:*");
|
|
13
|
+
acl.allow("sysadmin", "agent:*");
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/agent",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"tulipes": {
|
|
7
|
+
"tier": "app",
|
|
8
|
+
"priority": 950,
|
|
9
|
+
"dependsOn": [
|
|
10
|
+
"auth",
|
|
11
|
+
"core"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@app/auth": "workspace:*",
|
|
16
|
+
"@app/core": "workspace:*",
|
|
17
|
+
"@tulipes/core": "workspace:*",
|
|
18
|
+
"express": "^5",
|
|
19
|
+
"zod": "^3.25"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { BackendCtx } from "@tulipes/core/boot";
|
|
3
|
+
|
|
4
|
+
import { AgentController } from "../controllers/agent.controllers.ts";
|
|
5
|
+
import { toolInput, toolName } from "../schemas/agent.schemas.ts";
|
|
6
|
+
|
|
7
|
+
export default function agentRoutes(ctx: BackendCtx): void {
|
|
8
|
+
const { rai, routes, config } = ctx;
|
|
9
|
+
const router = Router();
|
|
10
|
+
const agent = new AgentController(ctx);
|
|
11
|
+
|
|
12
|
+
router.get("/tools", rai({
|
|
13
|
+
id: "agent:tools",
|
|
14
|
+
name: "List the tools an agent may call",
|
|
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
|
+
}), agent.tools());
|
|
18
|
+
|
|
19
|
+
router.post("/tools/:name", rai({
|
|
20
|
+
id: "agent:invoke",
|
|
21
|
+
name: "Run a declared action",
|
|
22
|
+
description: "Runs an action from a module's agent declaration; the action's own permission and input schema apply",
|
|
23
|
+
params: toolName,
|
|
24
|
+
body: toolInput,
|
|
25
|
+
agent: false,
|
|
26
|
+
}), agent.invoke());
|
|
27
|
+
|
|
28
|
+
routes.mount(router, `${config.api!.prefix}/agent`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/** The tool name as the manifest lists it. */
|
|
4
|
+
export const toolName = z.object({
|
|
5
|
+
name: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, "invalidToolName"),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/** The tool's input — the action's own schema validates it next. */
|
|
9
|
+
export const toolInput = z.looseObject({});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RuntimeCtx } from "@tulipes/core/boot";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Outside development the seeded credentials and signing keys must not be
|
|
5
|
+
* the scaffold's placeholders, and the two keys must differ so leaking one
|
|
6
|
+
* cannot mint the other. Sorted before seed-admin so the admin is never
|
|
7
|
+
* seeded with a refused password.
|
|
8
|
+
*/
|
|
9
|
+
export default function credentials({ Environment }: RuntimeCtx): void {
|
|
10
|
+
if (Environment.appEnv !== "production" && Environment.appEnv !== "staging") return;
|
|
11
|
+
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);
|
|
21
|
+
}
|
|
22
|
+
if (Environment.get("JWT_ACCESS_SECRET") === Environment.get("JWT_REFRESH_SECRET")) {
|
|
23
|
+
invalid.push("JWT_ACCESS_SECRET / JWT_REFRESH_SECRET (use distinct keys)");
|
|
24
|
+
}
|
|
25
|
+
if (invalid.length) throw new Error("Unsafe production credentials: configure " + invalid.join(", "));
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DatabaseCtx } from "@tulipes/core/boot";
|
|
2
|
+
|
|
3
|
+
import { setPassword } from "../helpers/passwords.ts";
|
|
4
|
+
import type { UserDoc } from "../helpers/documents.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Seeds the first admin: the account and its credential together.
|
|
8
|
+
*
|
|
9
|
+
* It lives in auth rather than users because only auth knows how to store
|
|
10
|
+
* a password — which is the whole reason credentials are a separate model.
|
|
11
|
+
* Auth reaches the User model through the model store by name, not by
|
|
12
|
+
* importing the users module: auth is sys-tier and users is app-tier, and
|
|
13
|
+
* a sys module may not depend on an app module.
|
|
14
|
+
*/
|
|
15
|
+
export default async function seedAdmin(ctx: DatabaseCtx): Promise<void> {
|
|
16
|
+
// Bootstrap runs in every mode; a one-off script has no business seeding.
|
|
17
|
+
if (ctx.mode === "script") return;
|
|
18
|
+
const email = String(ctx.Environment.get("AUTH_ADMIN_EMAIL")).toLowerCase();
|
|
19
|
+
const User = ctx.models.get<UserDoc>("User");
|
|
20
|
+
|
|
21
|
+
let user: Pick<UserDoc, "_id"> | null = await User.findOne({ email }).lean();
|
|
22
|
+
|
|
23
|
+
if (!user) {
|
|
24
|
+
try {
|
|
25
|
+
user = await User.create({ email, role: "admin" });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// Backend and worker boot together, so both may reach this at once.
|
|
28
|
+
// The loser of that race just reads the row the winner inserted.
|
|
29
|
+
if ((error as { code?: number }).code !== 11000) throw error;
|
|
30
|
+
user = await User.findOne({ email }).lean();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Only set the password when there is no credential yet, so a rotated
|
|
35
|
+
// admin password is not silently reset to the seed value on next boot.
|
|
36
|
+
const existing = await ctx.models
|
|
37
|
+
.get("Credential")
|
|
38
|
+
.countDocuments({ user: user!._id })
|
|
39
|
+
.limit(1);
|
|
40
|
+
|
|
41
|
+
if (existing === 0) {
|
|
42
|
+
await setPassword(ctx, String(user!._id), String(ctx.Environment.get("AUTH_ADMIN_PASSWORD")));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import passport from "passport";
|
|
2
|
+
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
|
3
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
4
|
+
import {
|
|
5
|
+
HttpError,
|
|
6
|
+
ValidationError,
|
|
7
|
+
type FieldIssue,
|
|
8
|
+
} from "@tulipes/core/http";
|
|
9
|
+
|
|
10
|
+
import { auth } from "../helpers/auth.ts";
|
|
11
|
+
import type { PasswordIdentity, UserDoc } from "../helpers/documents.ts";
|
|
12
|
+
import { setPassword } from "../helpers/passwords.ts";
|
|
13
|
+
import {
|
|
14
|
+
endSession,
|
|
15
|
+
listSessions,
|
|
16
|
+
originOf,
|
|
17
|
+
rotateSession,
|
|
18
|
+
startSession,
|
|
19
|
+
revokeAccess,
|
|
20
|
+
verify,
|
|
21
|
+
type TokenClaims,
|
|
22
|
+
} from "../helpers/tokens.ts";
|
|
23
|
+
|
|
24
|
+
interface Credentials {
|
|
25
|
+
email?: string;
|
|
26
|
+
password?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One class per controller, holding the context.
|
|
31
|
+
*
|
|
32
|
+
* Each method *returns* a handler rather than being one, so the routes
|
|
33
|
+
* file reads as a table of contents — `auth.login()` — and the class can
|
|
34
|
+
* keep private helpers beside the handlers that use them without
|
|
35
|
+
* threading ctx through every signature.
|
|
36
|
+
*
|
|
37
|
+
* Every response goes through `res.respond`; failures are thrown and the
|
|
38
|
+
* framework's terminal handler builds the same envelope from them.
|
|
39
|
+
*
|
|
40
|
+
* Thrown messages are i18n KEYS — the handler translates them in this
|
|
41
|
+
* module's namespace and derives the code from the key, so
|
|
42
|
+
* "invalidCredentials" becomes INVALID_CREDENTIALS with a translated
|
|
43
|
+
* message and there is no second string to keep in step.
|
|
44
|
+
*/
|
|
45
|
+
export class AuthController {
|
|
46
|
+
readonly #ctx: Ctx;
|
|
47
|
+
|
|
48
|
+
constructor(ctx: Ctx) {
|
|
49
|
+
this.#ctx = ctx;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Creates the account and its credential. */
|
|
53
|
+
register(): RequestHandler {
|
|
54
|
+
return async (req: Request, res: Response) => {
|
|
55
|
+
const { email, password } = (req.body ?? {}) as Credentials;
|
|
56
|
+
|
|
57
|
+
// Both fields are checked before answering: a form wrong in two
|
|
58
|
+
// places should say so once, not across two round trips.
|
|
59
|
+
const issues: FieldIssue[] = [];
|
|
60
|
+
if (!email) issues.push({ field: "email", message: "emailRequired" });
|
|
61
|
+
if (!password || password.length < 8) {
|
|
62
|
+
issues.push({ field: "password", message: "passwordTooShort" });
|
|
63
|
+
}
|
|
64
|
+
if (issues.length > 0) throw new ValidationError(issues);
|
|
65
|
+
// The guards above threw unless both are present — a narrowing
|
|
66
|
+
// TypeScript cannot make through the issues array.
|
|
67
|
+
|
|
68
|
+
if (this.#ctx.config.users?.signupMode !== "open") {
|
|
69
|
+
const mode = this.#ctx.config.users?.signupMode ?? "closed";
|
|
70
|
+
throw new HttpError(403, req.t("registrationClosed", { mode }), {
|
|
71
|
+
code: "REGISTRATION_CLOSED",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const User = this.#ctx.models!.get<UserDoc>("User");
|
|
76
|
+
const normalised = email!.toLowerCase();
|
|
77
|
+
if (await User.countDocuments({ email: normalised }).limit(1)) {
|
|
78
|
+
// Deliberately the same shape as any other validation failure: a
|
|
79
|
+
// distinct message would turn registration into an oracle for
|
|
80
|
+
// which addresses already have accounts.
|
|
81
|
+
throw new HttpError(422, "emailUnavailable", { field: "email" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const user = await User.create({ email: normalised, role: "user" });
|
|
85
|
+
const credentialVersion = await setPassword(
|
|
86
|
+
this.#ctx,
|
|
87
|
+
String(user._id),
|
|
88
|
+
password!,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const tokens = await startSession(
|
|
92
|
+
this.#ctx,
|
|
93
|
+
String(user._id),
|
|
94
|
+
credentialVersion,
|
|
95
|
+
originOf(req),
|
|
96
|
+
);
|
|
97
|
+
res.respond({
|
|
98
|
+
status: 201,
|
|
99
|
+
data: {
|
|
100
|
+
user: { id: String(user._id), email: normalised, role: "user" },
|
|
101
|
+
...tokens,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** passport-local checks the password; this mints the pair. */
|
|
108
|
+
login(): RequestHandler {
|
|
109
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
110
|
+
passport.authenticate(
|
|
111
|
+
"local",
|
|
112
|
+
{ session: false },
|
|
113
|
+
async (error: unknown, identity: PasswordIdentity | false) => {
|
|
114
|
+
if (error) return next(error);
|
|
115
|
+
// One message for "no such user" and "wrong password" alike.
|
|
116
|
+
if (!identity) return next(new HttpError(401, "invalidCredentials"));
|
|
117
|
+
const { user, credentialVersion } = identity;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const tokens = await startSession(
|
|
121
|
+
this.#ctx,
|
|
122
|
+
String(user._id),
|
|
123
|
+
credentialVersion,
|
|
124
|
+
originOf(req),
|
|
125
|
+
);
|
|
126
|
+
res.respond({
|
|
127
|
+
data: {
|
|
128
|
+
user: {
|
|
129
|
+
id: String(user._id),
|
|
130
|
+
email: user.email,
|
|
131
|
+
role: user.role,
|
|
132
|
+
},
|
|
133
|
+
...tokens,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
} catch (issueError) {
|
|
137
|
+
next(issueError);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
)(req, res, next);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Rotates the pair, consuming the refresh token presented. */
|
|
145
|
+
refresh(): RequestHandler {
|
|
146
|
+
return async (req: Request, res: Response) => {
|
|
147
|
+
const { refresh_token } = (req.body ?? {}) as { refresh_token?: string };
|
|
148
|
+
if (!refresh_token) {
|
|
149
|
+
throw new HttpError(422, "refreshRequired", { field: "refresh_token" });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const claims = verify(this.#ctx, refresh_token, "refresh");
|
|
153
|
+
if (!claims)
|
|
154
|
+
throw new HttpError(401, "refreshInvalid", { field: "refresh_token" });
|
|
155
|
+
|
|
156
|
+
const user = await this.#ctx
|
|
157
|
+
.models!.get<UserDoc>("User")
|
|
158
|
+
.findById(claims.sub)
|
|
159
|
+
.lean();
|
|
160
|
+
if (!user) throw new HttpError(401, "accountGone");
|
|
161
|
+
|
|
162
|
+
// Rotation also detects reuse and expires this device's token family.
|
|
163
|
+
// A duplicate client request has the same consequence as token theft.
|
|
164
|
+
const tokens = await rotateSession(this.#ctx, claims);
|
|
165
|
+
if (!tokens)
|
|
166
|
+
throw new HttpError(401, "refreshUsed", { field: "refresh_token" });
|
|
167
|
+
|
|
168
|
+
res.respond({ data: tokens });
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Ends this session immediately — both halves of it. */
|
|
173
|
+
logout(): RequestHandler {
|
|
174
|
+
return async (req: Request, res: Response) => {
|
|
175
|
+
const { user, session } = auth(req);
|
|
176
|
+
|
|
177
|
+
// Deleting the row kills the refresh half: no session, no rotation,
|
|
178
|
+
// and the next request carrying this access token resolves to guest.
|
|
179
|
+
if (session) await endSession(this.#ctx, session.sid);
|
|
180
|
+
|
|
181
|
+
// Blacklisting the access token is what makes logout take effect
|
|
182
|
+
// now, rather than whenever the token would have expired.
|
|
183
|
+
const access = this.#accessClaims(req);
|
|
184
|
+
if (access) await revokeAccess(this.#ctx, access);
|
|
185
|
+
|
|
186
|
+
res.respond({
|
|
187
|
+
data: {
|
|
188
|
+
message: req.t("loggedOut"),
|
|
189
|
+
logged_out: true,
|
|
190
|
+
user: user ? String(user._id) : null,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whoever the request pipeline resolved, and the session behind it. */
|
|
197
|
+
me(): RequestHandler {
|
|
198
|
+
return (req: Request, res: Response) => {
|
|
199
|
+
const { isAuthenticated, user, session } = req.auth;
|
|
200
|
+
|
|
201
|
+
res.respond({
|
|
202
|
+
data: {
|
|
203
|
+
is_authenticated: isAuthenticated,
|
|
204
|
+
locale: req.locale,
|
|
205
|
+
user: user && {
|
|
206
|
+
id: String(user._id),
|
|
207
|
+
email: user.email,
|
|
208
|
+
role: user.role,
|
|
209
|
+
},
|
|
210
|
+
session: session && {
|
|
211
|
+
sid: session.sid,
|
|
212
|
+
ip: session.ip,
|
|
213
|
+
user_agent: session.user_agent,
|
|
214
|
+
started_at: session.created_at,
|
|
215
|
+
last_seen_at: session.last_seen_at,
|
|
216
|
+
expires_at: session.expires_at,
|
|
217
|
+
access_expires_at: session.access_expires_at,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Every device this account is signed in on. */
|
|
225
|
+
sessions(): RequestHandler {
|
|
226
|
+
return async (req: Request, res: Response) => {
|
|
227
|
+
const { user, session } = req.auth;
|
|
228
|
+
const rows = await listSessions(this.#ctx, String(user!._id));
|
|
229
|
+
|
|
230
|
+
res.respond({
|
|
231
|
+
data: rows.map((row) => ({
|
|
232
|
+
sid: row.sid,
|
|
233
|
+
current: row.sid === session?.sid,
|
|
234
|
+
ip: row.ip,
|
|
235
|
+
user_agent: row.user_agent,
|
|
236
|
+
started_at: row.created_at,
|
|
237
|
+
last_seen_at: row.last_seen_at,
|
|
238
|
+
})),
|
|
239
|
+
meta: { total_items: rows.length },
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Sign one other device out, by the sid from `sessions()`. */
|
|
245
|
+
revokeSession(): RequestHandler {
|
|
246
|
+
return async (req: Request, res: Response) => {
|
|
247
|
+
const { user } = req.auth;
|
|
248
|
+
const { sid } = req.params;
|
|
249
|
+
|
|
250
|
+
// Scoped to the caller's own sessions: a sid is guessable enough
|
|
251
|
+
// that ending anyone else's must not be one request away.
|
|
252
|
+
const owned = await listSessions(this.#ctx, String(user!._id));
|
|
253
|
+
if (!owned.some((row) => row.sid === sid)) {
|
|
254
|
+
throw new HttpError(404, "noSuchSession", { field: "sid" });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await endSession(this.#ctx, String(sid));
|
|
258
|
+
res.respond({ data: { message: req.t("sessionEnded"), sid } });
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Changes own password, ending every other session. */
|
|
263
|
+
changePassword(): RequestHandler {
|
|
264
|
+
return async (req: Request, res: Response) => {
|
|
265
|
+
const userId = String(req.auth.user!._id);
|
|
266
|
+
const { password } = (req.body ?? {}) as { password?: string };
|
|
267
|
+
this.#assertPassword(password);
|
|
268
|
+
|
|
269
|
+
// Capture the version written by this change, even if another change races.
|
|
270
|
+
const expectedVersion = req.auth.session?.credential_version;
|
|
271
|
+
if (!expectedVersion) throw new HttpError(401, "invalidCredentials");
|
|
272
|
+
const credentialVersion = await setPassword(
|
|
273
|
+
this.#ctx,
|
|
274
|
+
userId,
|
|
275
|
+
password!,
|
|
276
|
+
expectedVersion,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Every earlier token is dead now, including the one that made this
|
|
280
|
+
// request — a fresh session keeps this client signed in while the
|
|
281
|
+
// other devices are signed out.
|
|
282
|
+
const tokens = await startSession(
|
|
283
|
+
this.#ctx,
|
|
284
|
+
userId,
|
|
285
|
+
credentialVersion,
|
|
286
|
+
originOf(req),
|
|
287
|
+
);
|
|
288
|
+
res.respond({
|
|
289
|
+
data: {
|
|
290
|
+
message: req.t("passwordChanged"),
|
|
291
|
+
changed: true,
|
|
292
|
+
sessions_revoked: true,
|
|
293
|
+
...tokens,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Takes the request only to reach `req.t`: a rule the user broke has to
|
|
300
|
+
// be explained in the language they asked for.
|
|
301
|
+
#assertPassword(password: string | undefined): asserts password is string {
|
|
302
|
+
if (!password || password.length < 8) {
|
|
303
|
+
throw new HttpError(422, "passwordTooShort", { field: "password" });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#accessClaims(req: Request): TokenClaims | undefined {
|
|
308
|
+
const header = req.header("authorization") ?? "";
|
|
309
|
+
if (!header.startsWith("Bearer ")) return undefined;
|
|
310
|
+
return verify(this.#ctx, header.slice(7), "access");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
2
|
+
import type { Auth } from "@tulipes/core/http";
|
|
3
|
+
import type { UserDoc } from "./documents.ts";
|
|
4
|
+
import { isRevoked, loadSession } from "./tokens.ts";
|
|
5
|
+
|
|
6
|
+
/** Capture the authenticated token identity, then reload its authority on demand. */
|
|
7
|
+
export function createAccessCheck(
|
|
8
|
+
ctx: Ctx,
|
|
9
|
+
auth: Auth,
|
|
10
|
+
permission: string,
|
|
11
|
+
): (signal: AbortSignal) => Promise<boolean> {
|
|
12
|
+
// Copy scalars; do not retain a mutable request/user document as authorization.
|
|
13
|
+
const userId = auth.user ? String(auth.user._id) : "";
|
|
14
|
+
const sid = auth.session?.sid;
|
|
15
|
+
const jti = auth.session?.access_jti;
|
|
16
|
+
const version = auth.session?.credential_version;
|
|
17
|
+
const expiresAt = auth.session?.access_expires_at.getTime() ?? 0;
|
|
18
|
+
|
|
19
|
+
return async (signal) => {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
if (!userId || !sid || !jti || !version || Date.now() >= expiresAt) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// loadSession checks owner, current credential version and live expiration.
|
|
26
|
+
// It also rejects a family expired by refresh-token reuse.
|
|
27
|
+
const session = await loadSession(ctx, sid, userId);
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
if (!session || session.credential_version !== version) return false;
|
|
30
|
+
|
|
31
|
+
const revoked = await isRevoked(ctx, jti);
|
|
32
|
+
signal.throwIfAborted();
|
|
33
|
+
if (revoked) return false;
|
|
34
|
+
|
|
35
|
+
const user = await ctx.models!.get<UserDoc>("User").findById(userId);
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
|
|
38
|
+
return Boolean(
|
|
39
|
+
user && Date.now() < expiresAt && ctx.acl?.can(user.role, permission),
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Request } from "express";
|
|
2
|
+
import { ANONYMOUS, type Auth } from "@tulipes/core/http";
|
|
3
|
+
|
|
4
|
+
import type { SessionDoc, UserDoc } from "./documents.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Core owns the `Auth` shape and reads `req.auth`; this module's only job
|
|
8
|
+
* is putting one there. That is the whole contract between an auth
|
|
9
|
+
* implementation and the framework — swap JWT for sessions or mTLS and
|
|
10
|
+
* nothing else changes.
|
|
11
|
+
*
|
|
12
|
+
* Core declares `user` and `session` as empty interfaces on purpose: it
|
|
13
|
+
* knows the shape of the contract, and the app says what fills it. These
|
|
14
|
+
* augmentations are what make `req.auth.user.email` and
|
|
15
|
+
* `req.auth.session.last_seen_at` typed at every call site.
|
|
16
|
+
*/
|
|
17
|
+
declare module "@tulipes/core/http" {
|
|
18
|
+
interface AuthUser extends UserDoc {}
|
|
19
|
+
|
|
20
|
+
interface AuthSession extends SessionDoc {
|
|
21
|
+
/**
|
|
22
|
+
* This request's ACCESS token, added to the session row rather than
|
|
23
|
+
* stored on it: the row outlives any single token, and these three
|
|
24
|
+
* are what let a handler revoke exactly the token in its hand.
|
|
25
|
+
*/
|
|
26
|
+
access_jti: string;
|
|
27
|
+
access_issued_at: Date;
|
|
28
|
+
access_expires_at: Date;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type { Auth };
|
|
33
|
+
export { ANONYMOUS as GUEST };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Never undefined inside a declared route: the auth middleware sets it,
|
|
37
|
+
* and `rai()` falls back to the guest value. This helper exists for code
|
|
38
|
+
* that runs BEFORE any rai() — middleware in another sys module.
|
|
39
|
+
*/
|
|
40
|
+
export function auth(req: Request): Auth {
|
|
41
|
+
if (!req.auth || req.auth === ANONYMOUS) req.auth = { ...ANONYMOUS };
|
|
42
|
+
return req.auth;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The signed-in user, or undefined for a guest. Saves an `if` at call sites. */
|
|
46
|
+
export function currentUser(req: Request) {
|
|
47
|
+
return auth(req).user ?? undefined;
|
|
48
|
+
}
|