@tulipes/cli 0.1.1 → 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 +10 -7
- 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 +11 -6422
- 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 +84 -43
- 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 +68 -31
- package/templates/CLAUDE.md +5 -4
- 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,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/security",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"tulipes": {
|
|
7
|
+
"tier": "sys",
|
|
8
|
+
"priority": 10,
|
|
9
|
+
"dependsOn": [
|
|
10
|
+
"core"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@app/core": "workspace:*",
|
|
15
|
+
"@tulipes/core": "workspace:*",
|
|
16
|
+
"cors": "^2",
|
|
17
|
+
"express": "^5",
|
|
18
|
+
"helmet": "^8",
|
|
19
|
+
"ioredis": "^5",
|
|
20
|
+
"ipaddr.js": "^2.2.0",
|
|
21
|
+
"pino-http": "^10"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/cors": "^2"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import helmet from "helmet";
|
|
4
|
+
import { pinoHttp } from "pino-http";
|
|
5
|
+
import type { BackendCtx } from "@tulipes/core/boot";
|
|
6
|
+
import { lazy, logger } from "@app/core";
|
|
7
|
+
|
|
8
|
+
import { corsOptions, helmetOptions } from "../helpers/hardening.ts";
|
|
9
|
+
import { trustedProxies } from "../helpers/proxy.ts";
|
|
10
|
+
import { configuredRateLimits } from "../helpers/rate-limit.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every piece here is configured from `ctx.config.security`, which is live:
|
|
14
|
+
* helmet, CORS and the body parser are built on each request from the
|
|
15
|
+
* current values (cheap, and a write lands at once), the proxy policy is
|
|
16
|
+
* set here and again by module.config's onChange, and the rate limiter is
|
|
17
|
+
* rebuilt there, so the handlers below always delegate to the current set.
|
|
18
|
+
* The logger reads the environment, which inspection does not resolve, so
|
|
19
|
+
* it is built on its first request.
|
|
20
|
+
*/
|
|
21
|
+
export default function securityRoutes(ctx: BackendCtx): void {
|
|
22
|
+
const { app, Environment, mode, config } = ctx;
|
|
23
|
+
const prefix = String(config.api?.prefix ?? "");
|
|
24
|
+
const security = () => config.security!;
|
|
25
|
+
|
|
26
|
+
app.set("trust proxy", trustedProxies(security().trustProxyCidrs));
|
|
27
|
+
app.use((req, res, next) => helmet(helmetOptions(security()))(req, res, next));
|
|
28
|
+
app.use((req, res, next) => cors(corsOptions(security()))(req, res, next));
|
|
29
|
+
app.use(lazy(() => pinoHttp({
|
|
30
|
+
logger: logger(Environment, mode),
|
|
31
|
+
customLogLevel: (_req, res, err) =>
|
|
32
|
+
err || res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info",
|
|
33
|
+
genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),
|
|
34
|
+
serializers: {
|
|
35
|
+
req: (req: { method: string; url: string }) => ({ method: req.method, url: req.url }),
|
|
36
|
+
res: (res: { statusCode: number }) => ({ status: res.statusCode }),
|
|
37
|
+
},
|
|
38
|
+
})));
|
|
39
|
+
app.use(`${prefix}/auth`, (req, res, next) => configuredRateLimits(ctx).auth(req, res, next));
|
|
40
|
+
app.use([`${prefix}/config`, `${prefix}/settings`], (req, res, next) => configuredRateLimits(ctx).config(req, res, next));
|
|
41
|
+
app.use((req, res, next) => express.json({ limit: security().bodyLimit })(req, res, next));
|
|
42
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
|
5
|
+
import { boot } from "@tulipes/core/boot";
|
|
6
|
+
import { useTestApp } from "@tulipes/testing";
|
|
7
|
+
|
|
8
|
+
import { testEnv, testSettings } from "../../../tests/support.ts";
|
|
9
|
+
|
|
10
|
+
const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
|
|
11
|
+
const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const tokens = z.object({
|
|
15
|
+
data: z.object({ access_token: z.string(), refresh_token: z.string() }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function post(path: string, body: unknown, token?: string) {
|
|
19
|
+
return fetch(app.base + path, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
...(token ? { authorization: "Bearer " + token } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function account() {
|
|
30
|
+
const credentials = {
|
|
31
|
+
email: randomBytes(12).toString("hex") + "@example.test",
|
|
32
|
+
password: randomBytes(24).toString("hex"),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const response = await post("/api/v1/auth/register", credentials);
|
|
36
|
+
|
|
37
|
+
expect(response.status).toBe(201);
|
|
38
|
+
|
|
39
|
+
const body = await response.json();
|
|
40
|
+
|
|
41
|
+
const { user } = z
|
|
42
|
+
.object({ data: z.object({ user: z.object({ id: z.string() }) }) })
|
|
43
|
+
.parse(body).data;
|
|
44
|
+
|
|
45
|
+
return { ...tokens.parse(body).data, ...credentials, userId: user.id };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function get(path: string, token: string) {
|
|
49
|
+
return fetch(app.base + path, { headers: { authorization: "Bearer " + token } });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function sysadmin() {
|
|
53
|
+
const user = await account();
|
|
54
|
+
|
|
55
|
+
await app.handle.ctx
|
|
56
|
+
.models!.get("User")
|
|
57
|
+
.updateOne({ _id: user.userId }, { role: "sysadmin" });
|
|
58
|
+
|
|
59
|
+
return user;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("CORS permits explicit Bearer headers without enabling cookie credentials", async () => {
|
|
63
|
+
const origin = "https://console.example.test";
|
|
64
|
+
const endpoint = app.base + "/api/v1/auth/me";
|
|
65
|
+
|
|
66
|
+
const preflight = await fetch(endpoint, {
|
|
67
|
+
method: "OPTIONS",
|
|
68
|
+
headers: {
|
|
69
|
+
origin,
|
|
70
|
+
"access-control-request-method": "GET",
|
|
71
|
+
"access-control-request-headers": "authorization",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(preflight.status).toBe(204);
|
|
76
|
+
expect(preflight.headers.get("access-control-allow-origin")).toBe(origin);
|
|
77
|
+
|
|
78
|
+
expect(preflight.headers.get("access-control-allow-headers")).toBe(
|
|
79
|
+
"authorization",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(preflight.headers.has("access-control-allow-credentials")).toBe(false);
|
|
83
|
+
|
|
84
|
+
const initial = await account();
|
|
85
|
+
|
|
86
|
+
const allowed = await fetch(endpoint, {
|
|
87
|
+
headers: { origin, authorization: "Bearer " + initial.access_token },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(allowed.status).toBe(200);
|
|
91
|
+
expect(allowed.headers.get("access-control-allow-origin")).toBe(origin);
|
|
92
|
+
expect(allowed.headers.has("access-control-allow-credentials")).toBe(false);
|
|
93
|
+
|
|
94
|
+
// Node does not enforce CORS. This proves CORS is not server authorization:
|
|
95
|
+
// a valid Bearer token still works, while the origin gets no read permission.
|
|
96
|
+
const unlisted = await fetch(endpoint, {
|
|
97
|
+
headers: {
|
|
98
|
+
origin: "https://unlisted.example.test",
|
|
99
|
+
authorization: "Bearer " + initial.access_token,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(unlisted.status).toBe(200);
|
|
104
|
+
expect(unlisted.headers.has("access-control-allow-origin")).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("configured proxy trust supplies the same client IP to sessions and durable audits", async () => {
|
|
108
|
+
const user = await sysadmin();
|
|
109
|
+
const headers = { "X-Forwarded-For": "203.0.113.200, 198.51.100.19" };
|
|
110
|
+
|
|
111
|
+
const login = await fetch(app.base + "/api/v1/auth/login", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ email: user.email, password: user.password }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(login.status).toBe(200);
|
|
118
|
+
|
|
119
|
+
const pair = tokens.parse(await login.json()).data;
|
|
120
|
+
const identity = await get("/api/v1/auth/me", pair.access_token);
|
|
121
|
+
|
|
122
|
+
expect(await identity.json()).toMatchObject({
|
|
123
|
+
data: { session: { ip: "198.51.100.19" } },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const changed = await fetch(app.base + "/api/v1/config/hello", {
|
|
127
|
+
method: "PATCH",
|
|
128
|
+
headers: { ...headers, "content-type": "application/json", authorization: "Bearer " + pair.access_token },
|
|
129
|
+
body: JSON.stringify({ values: { greeting: "Hello" } }),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(changed.status).toBe(200);
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
await app.handle.ctx
|
|
136
|
+
.models!.get("ConfigAudit")
|
|
137
|
+
.findOne({ actor: user.userId })
|
|
138
|
+
.lean(),
|
|
139
|
+
).toMatchObject({ ip: "198.51.100.19", module: "hello", keys: ["greeting"] });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test.each(["auth/login", "config"])(
|
|
143
|
+
"%s limits attempts before parsing or authentication",
|
|
144
|
+
async (path) => {
|
|
145
|
+
const responses = await Promise.all(
|
|
146
|
+
Array.from({ length: 201 }, (_, index) =>
|
|
147
|
+
fetch(app.base + "/api/v1/" + path, {
|
|
148
|
+
method: path.startsWith("auth") ? "POST" : "GET",
|
|
149
|
+
headers: {
|
|
150
|
+
"X-Forwarded-For":
|
|
151
|
+
"203.0.113." + ((index % 250) + 1) + ", 198.51.100.88",
|
|
152
|
+
"content-type": "application/json",
|
|
153
|
+
},
|
|
154
|
+
...(path.startsWith("auth") ? { body: "{" } : {}),
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
responses.filter((response) => response.status === 429),
|
|
161
|
+
).toHaveLength(1);
|
|
162
|
+
|
|
163
|
+
const blocked = responses.find((response) => response.status === 429)!;
|
|
164
|
+
|
|
165
|
+
expect(Number(blocked.headers.get("retry-after"))).toBeGreaterThan(0);
|
|
166
|
+
|
|
167
|
+
expect(await blocked.json()).toMatchObject({
|
|
168
|
+
success: false,
|
|
169
|
+
errors: [{ code: "TOO_MANY_REQUESTS" }],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect((await fetch(app.base + "/api/v1/hello")).status).toBe(200);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RuntimeCtx } from "@tulipes/core/boot";
|
|
2
|
+
|
|
3
|
+
import { configuredLogStreams } from "../helpers/log-stream.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An open log stream is a response that never ends on its own. `ctx.signal`
|
|
7
|
+
* aborts the moment shutdown begins, before HTTP stops accepting, so the
|
|
8
|
+
* streams end while the server is still draining ordinary requests.
|
|
9
|
+
*/
|
|
10
|
+
export default function logStreams({ Environment, signal }: RuntimeCtx): void {
|
|
11
|
+
signal.addEventListener("abort", () => configuredLogStreams(Environment).drain(), { once: true });
|
|
12
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createAccessCheck } from "@app/auth";
|
|
2
|
+
import { LOG_FILES } from "@app/core";
|
|
3
|
+
import type { Request, RequestHandler, Response } from "express";
|
|
4
|
+
import type { Ctx } from "@tulipes/core/boot";
|
|
5
|
+
import { HttpError } from "@tulipes/core/http";
|
|
6
|
+
|
|
7
|
+
import { configuredLogStreams, type LogStreams } from "../helpers/log-stream.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A live view of the logs — the one thing an operator needs from the
|
|
11
|
+
* running process that the console (the `config` module) cannot give
|
|
12
|
+
* them. Reachable only by `sysadmin` — see module.acl.ts — and re-checked
|
|
13
|
+
* while the stream is open, since a stream outlives the request that
|
|
14
|
+
* opened it.
|
|
15
|
+
*/
|
|
16
|
+
export class SettingsController {
|
|
17
|
+
readonly #logStreams: LogStreams;
|
|
18
|
+
readonly #ctx: Ctx;
|
|
19
|
+
|
|
20
|
+
constructor(ctx: Ctx) {
|
|
21
|
+
|
|
22
|
+
this.#ctx = ctx;
|
|
23
|
+
this.#logStreams = configuredLogStreams(ctx.Environment);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
logStream(): RequestHandler {
|
|
27
|
+
return async (req: Request, res: Response) => {
|
|
28
|
+
if (!Boolean(this.#ctx.Environment.get("LOG_FILE"))) {
|
|
29
|
+
throw new HttpError(409, "fileLoggingDisabled", {
|
|
30
|
+
code: "FILE_LOGGING_DISABLED",
|
|
31
|
+
defaultMessage: "LOG_FILE is off — there are no log files to stream",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const which = req.query.process === "worker" ? "worker" : "backend";
|
|
36
|
+
const level =
|
|
37
|
+
typeof req.query.level === "string" ? req.query.level : undefined;
|
|
38
|
+
const tail = Math.min(Number(req.query.tail ?? 200) || 0, 1000);
|
|
39
|
+
|
|
40
|
+
const session = req.auth.session;
|
|
41
|
+
await this.#logStreams.open(res, {
|
|
42
|
+
authorize: createAccessCheck(this.#ctx, req.auth, "settings:logs"),
|
|
43
|
+
userId: req.auth.user ? String(req.auth.user._id) : "",
|
|
44
|
+
expiresAt: session
|
|
45
|
+
? Math.min(
|
|
46
|
+
session.access_expires_at.getTime(),
|
|
47
|
+
session.expires_at.getTime(),
|
|
48
|
+
)
|
|
49
|
+
: 0,
|
|
50
|
+
dir: String(this.#ctx.Environment.get("LOG_DIR")),
|
|
51
|
+
prefix: `${LOG_FILES.basename}-${which}`,
|
|
52
|
+
process: which,
|
|
53
|
+
tail,
|
|
54
|
+
...(level ? { level } : {}),
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { HttpError } from "@tulipes/core/http";
|
|
2
|
+
|
|
3
|
+
/** Recheck one authorization at a time and expire permission from its start time. */
|
|
4
|
+
export class AccessLease {
|
|
5
|
+
#closed = false;
|
|
6
|
+
#expiresAt = 0;
|
|
7
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
8
|
+
#pending: Promise<boolean> | undefined;
|
|
9
|
+
#abort: AbortController | undefined;
|
|
10
|
+
|
|
11
|
+
readonly #authorize: (signal: AbortSignal) => Promise<boolean>;
|
|
12
|
+
readonly #intervalMs: number;
|
|
13
|
+
readonly #timeoutMs: number;
|
|
14
|
+
readonly #onInvalid: () => void;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
authorize: (signal: AbortSignal) => Promise<boolean>,
|
|
18
|
+
intervalMs: number,
|
|
19
|
+
timeoutMs: number,
|
|
20
|
+
onInvalid: () => void,
|
|
21
|
+
) {
|
|
22
|
+
this.#authorize = authorize;
|
|
23
|
+
this.#intervalMs = intervalMs;
|
|
24
|
+
this.#timeoutMs = timeoutMs;
|
|
25
|
+
this.#onInvalid = onInvalid;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get valid(): boolean {
|
|
29
|
+
return !this.#closed && Date.now() < this.#expiresAt;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async start(): Promise<void> {
|
|
33
|
+
await this.#check();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Keep capacity reserved until even an uncooperative pending check settles. */
|
|
37
|
+
async close(): Promise<void> {
|
|
38
|
+
this.#closed = true;
|
|
39
|
+
clearTimeout(this.#timer);
|
|
40
|
+
this.#abort?.abort();
|
|
41
|
+
await this.#pending?.catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async #check(): Promise<void> {
|
|
45
|
+
if (this.#closed) return;
|
|
46
|
+
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
const deadline = Math.min(
|
|
49
|
+
this.#expiresAt || Infinity,
|
|
50
|
+
started + this.#timeoutMs,
|
|
51
|
+
);
|
|
52
|
+
const abort = new AbortController();
|
|
53
|
+
this.#abort = abort;
|
|
54
|
+
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
let cancelled: () => void = () => {};
|
|
57
|
+
|
|
58
|
+
// The race bounds the response wait; close() also tracks the underlying work.
|
|
59
|
+
// Abort stops cooperative callbacks but is not proof a driver query stopped.
|
|
60
|
+
this.#pending = Promise.resolve().then(() => this.#authorize(abort.signal));
|
|
61
|
+
const stopped = new Promise<never>((_resolve, reject) => {
|
|
62
|
+
cancelled = () => reject(new Error("Authorization check stopped"));
|
|
63
|
+
abort.signal.addEventListener("abort", cancelled, { once: true });
|
|
64
|
+
timer = setTimeout(() => abort.abort(), Math.max(0, deadline - Date.now()));
|
|
65
|
+
timer.unref();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const allowed = await Promise.race([this.#pending, stopped]);
|
|
70
|
+
if (this.#closed) return;
|
|
71
|
+
|
|
72
|
+
if (Date.now() >= deadline) throw new Error("Authorization check expired");
|
|
73
|
+
if (!allowed) throw new HttpError(403, "forbidden");
|
|
74
|
+
|
|
75
|
+
// A slow successful query must not extend a stale decision's lifetime.
|
|
76
|
+
this.#expiresAt = started + this.#intervalMs + this.#timeoutMs;
|
|
77
|
+
this.#timer = setTimeout(() => {
|
|
78
|
+
if (!this.valid) {
|
|
79
|
+
this.#onInvalid();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void this.#check().catch(() => this.#onInvalid());
|
|
84
|
+
}, Math.max(0, started + this.#intervalMs - Date.now()));
|
|
85
|
+
this.#timer.unref();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (this.#closed) return;
|
|
88
|
+
if (error instanceof HttpError) throw error;
|
|
89
|
+
|
|
90
|
+
throw new HttpError(503, "streamAuthorizationUnavailable", {
|
|
91
|
+
code: "STREAM_AUTHORIZATION_UNAVAILABLE",
|
|
92
|
+
defaultMessage: "Log stream authorization could not be verified",
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
abort.signal.removeEventListener("abort", cancelled);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { Response } from "express";
|
|
2
|
+
import type { Environment } from "@tulipes/core/env";
|
|
3
|
+
import { HttpError } from "@tulipes/core/http";
|
|
4
|
+
import { AccessLease } from "./access-lease.ts";
|
|
5
|
+
import { tailLogs, type Tail } from "./log-tail.ts";
|
|
6
|
+
|
|
7
|
+
export const LOG_STREAM_LIMITS = Object.freeze({
|
|
8
|
+
connections: 4,
|
|
9
|
+
perUser: 2,
|
|
10
|
+
durationMs: 60_000,
|
|
11
|
+
outputBytes: 1024 * 1024,
|
|
12
|
+
bufferBytes: 64 * 1024,
|
|
13
|
+
heartbeatMs: 15_000,
|
|
14
|
+
authorizationIntervalMs: 1000,
|
|
15
|
+
authorizationTimeoutMs: 1000,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type StreamLimits = { [K in keyof typeof LOG_STREAM_LIMITS]: number };
|
|
19
|
+
|
|
20
|
+
interface StreamOptions {
|
|
21
|
+
authorize: (signal: AbortSignal) => Promise<boolean>;
|
|
22
|
+
userId: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
dir: string;
|
|
25
|
+
prefix: string;
|
|
26
|
+
process: "backend" | "worker";
|
|
27
|
+
tail: number;
|
|
28
|
+
level?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LEVELS: Record<string, number> = {
|
|
32
|
+
trace: 10,
|
|
33
|
+
debug: 20,
|
|
34
|
+
info: 30,
|
|
35
|
+
warn: 40,
|
|
36
|
+
error: 50,
|
|
37
|
+
fatal: 60,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const instances = new WeakMap<Environment, LogStreams>();
|
|
41
|
+
|
|
42
|
+
/** Keep the stopped instance until the boot is collected; late requests cannot reopen it. */
|
|
43
|
+
export function configuredLogStreams(environment: Environment): LogStreams {
|
|
44
|
+
let streams = instances.get(environment);
|
|
45
|
+
if (!streams) {
|
|
46
|
+
streams = new LogStreams();
|
|
47
|
+
instances.set(environment, streams);
|
|
48
|
+
}
|
|
49
|
+
return streams;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One boot owns its slots. Replicas each enforce this local resource cap. */
|
|
53
|
+
export class LogStreams {
|
|
54
|
+
readonly #active = new Map<string, number>();
|
|
55
|
+
readonly #closeStreams = new Set<() => void>();
|
|
56
|
+
#draining = false;
|
|
57
|
+
#count = 0;
|
|
58
|
+
readonly #limits: StreamLimits;
|
|
59
|
+
|
|
60
|
+
constructor(limits: Partial<StreamLimits> = {}) {
|
|
61
|
+
this.#limits = { ...LOG_STREAM_LIMITS, ...limits };
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
Object.values(this.#limits).some(
|
|
65
|
+
(value) =>
|
|
66
|
+
!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647,
|
|
67
|
+
)
|
|
68
|
+
) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
"Log stream limits must be positive integers no greater than 2147483647",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stop admission and output now; never wait for an uncooperative authorization query. */
|
|
76
|
+
drain(): void {
|
|
77
|
+
this.#draining = true;
|
|
78
|
+
for (const close of this.#closeStreams) close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async open(res: Response, options: StreamOptions): Promise<void> {
|
|
82
|
+
const unavailable = () => new HttpError(503, "logStreamsDraining", {
|
|
83
|
+
code: "LOG_STREAMS_DRAINING",
|
|
84
|
+
defaultMessage: "Log streams are unavailable during shutdown",
|
|
85
|
+
});
|
|
86
|
+
if (this.#draining) throw unavailable();
|
|
87
|
+
// The credential's expiry is absolute; the stream's own duration starts
|
|
88
|
+
// once it is ready, so a slow authorization round trip or file open cannot
|
|
89
|
+
// eat into it and turn a valid credential into a refusal.
|
|
90
|
+
let expiresAt = options.expiresAt;
|
|
91
|
+
if (!options.userId || !Number.isFinite(expiresAt) || Date.now() >= expiresAt) {
|
|
92
|
+
throw new HttpError(401, "unauthorized");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const owned = this.#active.get(options.userId) ?? 0;
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
owned >= this.#limits.perUser ||
|
|
99
|
+
this.#count >= this.#limits.connections
|
|
100
|
+
) {
|
|
101
|
+
res.setHeader("Retry-After", "1");
|
|
102
|
+
throw new HttpError(429, "tooManyLogStreams", {
|
|
103
|
+
code: "TOO_MANY_LOG_STREAMS",
|
|
104
|
+
defaultMessage: "Too many open log streams",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Reserve capacity before opening files or committing the SSE response.
|
|
109
|
+
this.#count++;
|
|
110
|
+
this.#active.set(options.userId, owned + 1);
|
|
111
|
+
|
|
112
|
+
let closed = false;
|
|
113
|
+
let sent = 0;
|
|
114
|
+
let tail: Tail | undefined;
|
|
115
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
117
|
+
|
|
118
|
+
const authorization = new AccessLease(
|
|
119
|
+
options.authorize,
|
|
120
|
+
this.#limits.authorizationIntervalMs,
|
|
121
|
+
this.#limits.authorizationTimeoutMs,
|
|
122
|
+
() => close(),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const releaseSlot = () => {
|
|
126
|
+
this.#count--;
|
|
127
|
+
const remaining = (this.#active.get(options.userId) ?? 1) - 1;
|
|
128
|
+
if (remaining) this.#active.set(options.userId, remaining);
|
|
129
|
+
else this.#active.delete(options.userId);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// All termination paths converge here; repeated events cannot release twice.
|
|
133
|
+
const close = (destroy = false) => {
|
|
134
|
+
if (closed) return;
|
|
135
|
+
|
|
136
|
+
closed = true;
|
|
137
|
+
this.#closeStreams.delete(close);
|
|
138
|
+
clearTimeout(deadline);
|
|
139
|
+
clearInterval(heartbeat);
|
|
140
|
+
tail?.close();
|
|
141
|
+
|
|
142
|
+
// A timed-out query may still run. Hold its slot until it settles so
|
|
143
|
+
// reconnects cannot create an unbounded queue of authorization work.
|
|
144
|
+
void authorization.close().then(releaseSlot);
|
|
145
|
+
|
|
146
|
+
res.off("close", disconnected);
|
|
147
|
+
res.off("error", disconnected);
|
|
148
|
+
|
|
149
|
+
if (destroy) res.destroy();
|
|
150
|
+
else if (res.headersSent && !res.destroyed && !res.writableEnded) {
|
|
151
|
+
res.end();
|
|
152
|
+
|
|
153
|
+
// A peer may stop draining even below write()'s high-water mark.
|
|
154
|
+
const finish = setTimeout(() => {
|
|
155
|
+
if (!res.writableFinished) res.destroy();
|
|
156
|
+
}, 1000);
|
|
157
|
+
finish.unref();
|
|
158
|
+
res.once("finish", () => clearTimeout(finish));
|
|
159
|
+
res.once("close", () => clearTimeout(finish));
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const disconnected = () => close();
|
|
164
|
+
|
|
165
|
+
// Bound application output before handing another frame to Node's buffer.
|
|
166
|
+
const write = (frame: string): boolean => {
|
|
167
|
+
if (closed) return false;
|
|
168
|
+
|
|
169
|
+
if (Date.now() >= expiresAt || !authorization.valid) {
|
|
170
|
+
close();
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const bytes = Buffer.byteLength(frame);
|
|
175
|
+
|
|
176
|
+
if (sent + bytes > this.#limits.outputBytes) {
|
|
177
|
+
close();
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
res.destroyed ||
|
|
182
|
+
res.writableLength + bytes > this.#limits.bufferBytes
|
|
183
|
+
) {
|
|
184
|
+
close(true);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
sent += bytes;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
if (!res.write(frame)) {
|
|
192
|
+
close(true);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
close(true);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const send = (event: string, data: unknown) =>
|
|
203
|
+
write("event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n");
|
|
204
|
+
|
|
205
|
+
res.once("close", disconnected);
|
|
206
|
+
res.once("error", disconnected);
|
|
207
|
+
this.#closeStreams.add(close);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Recheck before file access or SSE headers, closing the opening-request gap.
|
|
211
|
+
await authorization.start();
|
|
212
|
+
if (this.#draining && !res.destroyed && !res.headersSent) throw unavailable();
|
|
213
|
+
if (closed) return;
|
|
214
|
+
|
|
215
|
+
tail = tailLogs({
|
|
216
|
+
dir: options.dir,
|
|
217
|
+
prefix: options.prefix,
|
|
218
|
+
tail: options.tail,
|
|
219
|
+
onLine: (line) => {
|
|
220
|
+
let entry: unknown;
|
|
221
|
+
try {
|
|
222
|
+
entry = JSON.parse(line);
|
|
223
|
+
} catch {
|
|
224
|
+
entry = { msg: line };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const level =
|
|
228
|
+
entry &&
|
|
229
|
+
typeof entry === "object" &&
|
|
230
|
+
"level" in entry &&
|
|
231
|
+
typeof entry.level === "number"
|
|
232
|
+
? entry.level
|
|
233
|
+
: 0;
|
|
234
|
+
if (level < (LEVELS[options.level ?? ""] ?? 0)) return true;
|
|
235
|
+
return send("log", entry);
|
|
236
|
+
},
|
|
237
|
+
onError: (code) => {
|
|
238
|
+
send("error", { code });
|
|
239
|
+
close();
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
if (Date.now() >= expiresAt) throw new HttpError(401, "unauthorized");
|
|
243
|
+
expiresAt = Math.min(expiresAt, Date.now() + this.#limits.durationMs);
|
|
244
|
+
|
|
245
|
+
res.writeHead(200, {
|
|
246
|
+
"Content-Type": "text/event-stream",
|
|
247
|
+
"Cache-Control": "no-cache, no-transform",
|
|
248
|
+
Connection: "keep-alive",
|
|
249
|
+
"X-Accel-Buffering": "no",
|
|
250
|
+
});
|
|
251
|
+
if (
|
|
252
|
+
!send("ready", {
|
|
253
|
+
process: options.process,
|
|
254
|
+
tail: options.tail,
|
|
255
|
+
level: options.level ?? "all",
|
|
256
|
+
})
|
|
257
|
+
)
|
|
258
|
+
return;
|
|
259
|
+
|
|
260
|
+
deadline = setTimeout(() => close(), Math.max(1, expiresAt - Date.now()));
|
|
261
|
+
heartbeat = setInterval(
|
|
262
|
+
() => write(": keep-alive\n\n"),
|
|
263
|
+
this.#limits.heartbeatMs,
|
|
264
|
+
);
|
|
265
|
+
deadline.unref();
|
|
266
|
+
heartbeat.unref();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
close();
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|