my-durable 0.0.0-stage → 0.0.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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// The playground hub: the local registry of devices (~/.dpi/network.sqlite), their live status,
|
|
2
|
+
// a REST proxy that injects device tokens, one multiplexed SSE stream per browser tab, and the
|
|
3
|
+
// Vue client. It runs no agents; every agent lives in some device's dpi-backend.
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { extname, resolve } from "node:path";
|
|
8
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
9
|
+
import { NetworkRegistry, UI_STATE_MAX_CHARS } from "../../../../dpi-sdk/src/node/registry.js";
|
|
10
|
+
import { ByteBudget } from "../../../../dpi-sdk/src/node/chunk.js";
|
|
11
|
+
import { DeviceMonitor } from "./monitor.js";
|
|
12
|
+
import { StreamMux } from "./mux.js";
|
|
13
|
+
import { proxy } from "./proxy.js";
|
|
14
|
+
import { guard, requireTls } from "./guard.js";
|
|
15
|
+
import { ipKey, isTrustedProxy } from "./client-ip.js";
|
|
16
|
+
import { Audit, denialLogging } from "./audit.js";
|
|
17
|
+
import { body as failureBody, describeFailure, errorMiddleware, safe } from "./http-errors.js";
|
|
18
|
+
import { TokenBuckets } from "./limiter.js";
|
|
19
|
+
import { PairingService } from "./pairing.js";
|
|
20
|
+
import { CredentialService } from "./credentials.js";
|
|
21
|
+
import { TunnelServer } from "./tunnel.js";
|
|
22
|
+
import { TunnelDispatcher, TunnelTransport } from "./tunnel-transport.js";
|
|
23
|
+
import { RotationService } from "./rotation.js";
|
|
24
|
+
import { seedFromConfig } from "./seed.js";
|
|
25
|
+
import {
|
|
26
|
+
assertLimitsConfig,
|
|
27
|
+
API_RATE_LIMITS,
|
|
28
|
+
HUB_BUFFER_BUDGET_BYTES,
|
|
29
|
+
} from "../../../../dpi-sdk/src/protocol/limits.js";
|
|
30
|
+
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
const option = (key: string, fallback: string) => {
|
|
33
|
+
const i = args.indexOf(key);
|
|
34
|
+
if (i < 0) return fallback;
|
|
35
|
+
if (!args[i + 1] || args[i + 1].startsWith("--")) throw Error(`${key} needs a value`);
|
|
36
|
+
return args[i + 1];
|
|
37
|
+
};
|
|
38
|
+
const appDir = fileURLToPath(new URL("../../../", import.meta.url));
|
|
39
|
+
const port = Number(option("--port", process.env.DPI_HUB_PORT ?? "4781"));
|
|
40
|
+
const host = option("--host", process.env.DPI_HUB_HOST ?? "127.0.0.1");
|
|
41
|
+
// When running behind a TLS reverse proxy (reference profile: Caddy), the
|
|
42
|
+
// browser-facing host:port differs from this process's own port. The proxy
|
|
43
|
+
// forwards the original Host header, so it must be allow-listed explicitly.
|
|
44
|
+
const publicHost = option("--public-host", process.env.DPI_HUB_PUBLIC_HOST ?? "");
|
|
45
|
+
// A public host means the hub sits behind the TLS proxy, so it then refuses anything the proxy did not
|
|
46
|
+
// vouch for (spec 05 §4.2). DPI_HUB_REQUIRE_TLS=1/0 overrides either way; a typo stops the start instead of
|
|
47
|
+
// silently picking a side.
|
|
48
|
+
function tlsRequired(): boolean {
|
|
49
|
+
const raw = process.env.DPI_HUB_REQUIRE_TLS;
|
|
50
|
+
if (raw === undefined || raw === "") return publicHost !== "";
|
|
51
|
+
if (/^(1|true|yes|on)$/i.test(raw)) return true;
|
|
52
|
+
if (/^(0|false|no|off)$/i.test(raw)) return false;
|
|
53
|
+
throw Error("DPI_HUB_REQUIRE_TLS must be 1 or 0");
|
|
54
|
+
}
|
|
55
|
+
const requireTlsEnabled = tlsRequired();
|
|
56
|
+
|
|
57
|
+
const registry = new NetworkRegistry();
|
|
58
|
+
registry.clearTunnelState(); // live tunnel state is RAM-only; never resurrect from DB
|
|
59
|
+
const configFile = seedFromConfig(registry, appDir);
|
|
60
|
+
const monitor = new DeviceMonitor(registry);
|
|
61
|
+
monitor.start();
|
|
62
|
+
const bufferBudget = new ByteBudget("hub", HUB_BUFFER_BUDGET_BYTES);
|
|
63
|
+
const mux = new StreamMux(registry, monitor, { budget: bufferBudget });
|
|
64
|
+
|
|
65
|
+
const allowedHosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
|
|
66
|
+
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
67
|
+
allowedHosts.add(`${hostname().toLowerCase()}:${port}`);
|
|
68
|
+
allowedHosts.add(
|
|
69
|
+
`${hostname()
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.replace(/\.local$/, "")}.local:${port}`,
|
|
72
|
+
);
|
|
73
|
+
for (const addresses of Object.values(networkInterfaces()))
|
|
74
|
+
for (const a of addresses ?? [])
|
|
75
|
+
if (a.family === "IPv4") allowedHosts.add(`${a.address}:${port}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const err = (code: string, message: string) => ({ error: { code, message } });
|
|
79
|
+
// Unauthenticated liveness probe for process supervisors and the test
|
|
80
|
+
// harness. No registry data, no tokens — safe behind the loopback bind and
|
|
81
|
+
// the TLS reverse proxy.
|
|
82
|
+
if (publicHost) allowedHosts.add(publicHost.toLowerCase());
|
|
83
|
+
|
|
84
|
+
const app = express();
|
|
85
|
+
// F10: behind the reference TLS proxy (same-host Caddy) the socket address
|
|
86
|
+
// is always loopback — trust ONLY loopback proxies so req.ip reflects the
|
|
87
|
+
// real client via the proxy-overwritten X-Forwarded-For. Forged XFF from a
|
|
88
|
+
// non-loopback direct connection is ignored (proxy-addr semantics).
|
|
89
|
+
app.set("trust proxy", isTrustedProxy);
|
|
90
|
+
app.disable("x-powered-by");
|
|
91
|
+
// Every answer is non-sniffable; the admin API is never cacheable (it carries pairing codes and, on the
|
|
92
|
+
// pairing routes, a credential). Registered first so that refusals made further down carry them too.
|
|
93
|
+
app.use((req, res, next) => {
|
|
94
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
95
|
+
if (req.path === "/api" || req.path.startsWith("/api/")) res.set("Cache-Control", "no-store");
|
|
96
|
+
next();
|
|
97
|
+
});
|
|
98
|
+
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
|
99
|
+
// All CANON limits must be sane or the hub refuses to start (P2-22: fail startup, not per-request).
|
|
100
|
+
assertLimitsConfig();
|
|
101
|
+
/** Operator floor for free space on the registry volume (bytes); below it audited changes are refused with 503. */
|
|
102
|
+
function auditFloorFromEnv(): { minFreeBytes?: number } {
|
|
103
|
+
const raw = process.env.DPI_AUDIT_MIN_FREE_BYTES;
|
|
104
|
+
if (raw === undefined || raw === "") return {};
|
|
105
|
+
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw)))
|
|
106
|
+
throw Error("DPI_AUDIT_MIN_FREE_BYTES must be a non-negative integer");
|
|
107
|
+
return { minFreeBytes: Number(raw) };
|
|
108
|
+
}
|
|
109
|
+
const audit = new Audit(registry, auditFloorFromEnv());
|
|
110
|
+
audit.startSweeper();
|
|
111
|
+
// Everything registered below needs proof of TLS; /healthz (above) stays reachable for supervisors.
|
|
112
|
+
if (requireTlsEnabled) app.use(denialLogging(audit), requireTls());
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-IP admin-REST token bucket (API_RATE_LIMITS policy; never request-overridable). Bounded to
|
|
116
|
+
* 10.000 addresses with a 15 minute idle TTL; a full table refuses new addresses instead of
|
|
117
|
+
* forgetting the ones that are being limited.
|
|
118
|
+
*/
|
|
119
|
+
const apiBuckets = new TokenBuckets({
|
|
120
|
+
capacity: API_RATE_LIMITS.capacity,
|
|
121
|
+
perMinute: API_RATE_LIMITS.perMinute,
|
|
122
|
+
maxKeys: 10_000,
|
|
123
|
+
idleTtlMs: 15 * 60_000,
|
|
124
|
+
});
|
|
125
|
+
setInterval(() => apiBuckets.sweep(), 60_000).unref();
|
|
126
|
+
const apiRate: express.RequestHandler = (req, res, next) => {
|
|
127
|
+
const taken = apiBuckets.take(ipKey(req.ip ?? req.socket.remoteAddress ?? "?"));
|
|
128
|
+
if (taken.ok) return void next();
|
|
129
|
+
res.set("Retry-After", String(Math.max(1, Math.ceil(taken.retryAfterMs / 1000))));
|
|
130
|
+
res
|
|
131
|
+
.status(429)
|
|
132
|
+
.json(
|
|
133
|
+
err(
|
|
134
|
+
"rate_limited",
|
|
135
|
+
taken.reason === "full" ? "Too many clients; retry later" : "Too many requests; slow down",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
// /healthz exempt (uptime probes must survive floods); one SSE open = one request;
|
|
140
|
+
// stream frames are responses and never hit this middleware.
|
|
141
|
+
|
|
142
|
+
const hubId = (
|
|
143
|
+
registry.db.prepare("SELECT value FROM hub_meta WHERE key='hub_id'").get() as { value: string }
|
|
144
|
+
).value;
|
|
145
|
+
const hubOrigin = process.env.DPI_HUB_ORIGIN ?? `https://${hostname().toLowerCase()}`;
|
|
146
|
+
const credentials = new CredentialService(registry, audit, hubOrigin, hubId);
|
|
147
|
+
const pairing = new PairingService({
|
|
148
|
+
registry,
|
|
149
|
+
audit,
|
|
150
|
+
hubOrigin,
|
|
151
|
+
hubId,
|
|
152
|
+
issue: async (e) => {
|
|
153
|
+
const ticket = await credentials.issue(e);
|
|
154
|
+
tunnel.closeConnection(ticket.deviceId, 1000, "pairing replaced");
|
|
155
|
+
monitor.watch(ticket.deviceId);
|
|
156
|
+
return ticket;
|
|
157
|
+
},
|
|
158
|
+
receiptValidator: (t) =>
|
|
159
|
+
credentials.receiptStillValid(t) ? true : { code: credentials.receiptFailure(t) },
|
|
160
|
+
});
|
|
161
|
+
// F15: housekeeping — past expiry+grace the entry goes, issued receipts
|
|
162
|
+
// included (they hold a plaintext token); the RAM maps never grow unbounded.
|
|
163
|
+
const pairSweeper = setInterval(() => pairing.sweep(), 60_000);
|
|
164
|
+
pairSweeper.unref();
|
|
165
|
+
const pairErr = (
|
|
166
|
+
r: { error: { status: number; code: string; message: string; retryAfterMs?: number } },
|
|
167
|
+
res: express.Response,
|
|
168
|
+
) =>
|
|
169
|
+
res.status(r.error.status).json({
|
|
170
|
+
error: {
|
|
171
|
+
code: r.error.code,
|
|
172
|
+
message: r.error.message,
|
|
173
|
+
...(r.error.retryAfterMs ? { retryAfterMs: r.error.retryAfterMs } : {}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Public pairing ingress (03 §4) — registered BEFORE the admin /api chain so
|
|
178
|
+
// that (a) the pairing 16 KiB body parsers are the FIRST parsers to touch
|
|
179
|
+
// these bodies (F20: the global 256 KiB parser must not pre-read them), and
|
|
180
|
+
// (b) the admin token bucket never meters the public pairing surface (F10):
|
|
181
|
+
// PairingService enforces its own per-IP code/verify buckets + fail-window
|
|
182
|
+
// here. Host-allowlist + denial logging still apply.
|
|
183
|
+
const pairGuard = [denialLogging(audit), guard(allowedHosts)] as const;
|
|
184
|
+
app.post("/api/pair/code", ...pairGuard, express.json({ limit: "16kb" }), (req, res) => {
|
|
185
|
+
const r = pairing.createCode(req.ip ?? "unknown");
|
|
186
|
+
return r.ok ? res.status(201).json(r.value) : pairErr(r, res);
|
|
187
|
+
});
|
|
188
|
+
app.post("/api/pair/start", ...pairGuard, express.json({ limit: "16kb" }), (req, res) => {
|
|
189
|
+
const r = pairing.start(req.body, req.ip ?? "unknown");
|
|
190
|
+
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
191
|
+
});
|
|
192
|
+
app.post("/api/pair/verify", ...pairGuard, express.json({ limit: "16kb" }), async (req, res) => {
|
|
193
|
+
const r = await pairing.verify(req.body, req.ip ?? "unknown");
|
|
194
|
+
if (r.ok) return res.status(r.status).json(r.value);
|
|
195
|
+
pairErr(r, res);
|
|
196
|
+
});
|
|
197
|
+
app.get("/api/pair/codes/:codeId", ...pairGuard, (req, res) => {
|
|
198
|
+
const r = pairing.status(String(req.params.codeId));
|
|
199
|
+
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
200
|
+
});
|
|
201
|
+
app.post(
|
|
202
|
+
"/api/pair/codes/:codeId/approve",
|
|
203
|
+
...pairGuard,
|
|
204
|
+
express.json({ limit: "16kb" }),
|
|
205
|
+
(req, res) => {
|
|
206
|
+
const r = pairing.approve(String(req.params.codeId), req.body);
|
|
207
|
+
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
app.post("/api/pair/codes/:codeId/cancel", ...pairGuard, (req, res) => {
|
|
211
|
+
const r = pairing.cancel(String(req.params.codeId));
|
|
212
|
+
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Admin /api surface: guarded, rate-bucketed per real client IP, 256 KiB JSON.
|
|
216
|
+
app.use(
|
|
217
|
+
"/api",
|
|
218
|
+
denialLogging(audit),
|
|
219
|
+
guard(allowedHosts),
|
|
220
|
+
apiRate,
|
|
221
|
+
express.json({ limit: "256kb" }),
|
|
222
|
+
);
|
|
223
|
+
app.post("/api/pair/resolve", (req, res) => {
|
|
224
|
+
const r = pairing.resolveCode(req.body);
|
|
225
|
+
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
226
|
+
});
|
|
227
|
+
app.get("/api/pairings", (_req, res) =>
|
|
228
|
+
res.json(
|
|
229
|
+
monitor.network().flatMap((device) =>
|
|
230
|
+
device.pairing && device.deviceUid && device.tunnel
|
|
231
|
+
? [
|
|
232
|
+
{
|
|
233
|
+
...device.pairing,
|
|
234
|
+
deviceId: device.id,
|
|
235
|
+
deviceUid: device.deviceUid,
|
|
236
|
+
deviceName: device.name,
|
|
237
|
+
tunnel: device.tunnel,
|
|
238
|
+
},
|
|
239
|
+
]
|
|
240
|
+
: [],
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
// Keep the original device routes as aliases for existing clients.
|
|
245
|
+
app.post(["/api/pairings/:id/rotate", "/api/devices/:id/rotate"], (req, res) => {
|
|
246
|
+
const r = rotation.startRotation(String(req.params.id), `admin:${req.ip ?? "unknown"}`);
|
|
247
|
+
if (r.ok) {
|
|
248
|
+
monitor.refresh();
|
|
249
|
+
return res.status(202).json(r);
|
|
250
|
+
}
|
|
251
|
+
// Honest statuses: an unknown device or a device with no active pairing is a 404,
|
|
252
|
+
// a second rotate while one is in flight is a 409, and only a request that names
|
|
253
|
+
// something that can never rotate (a non-tunnel device) is a 400.
|
|
254
|
+
const status =
|
|
255
|
+
r.code === "unknown_device" || r.code === "no_pairing"
|
|
256
|
+
? 404
|
|
257
|
+
: r.code === "in_flight"
|
|
258
|
+
? 409
|
|
259
|
+
: 400;
|
|
260
|
+
const code = status === 404 ? "not_found" : status === 409 ? "conflict" : "invalid";
|
|
261
|
+
return res.status(status).json({ error: { code, message: r.error } });
|
|
262
|
+
});
|
|
263
|
+
app.post(
|
|
264
|
+
["/api/pairings/:id/revoke", "/api/devices/:id/revoke"],
|
|
265
|
+
express.json({ limit: "4kb" }),
|
|
266
|
+
(req, res) => {
|
|
267
|
+
const id = String(req.params.id);
|
|
268
|
+
const pairingRow = registry.db
|
|
269
|
+
.prepare("SELECT pairing_epoch FROM pairings WHERE device_id=? AND status='paired'")
|
|
270
|
+
.get(id) as { pairing_epoch: string } | undefined;
|
|
271
|
+
try {
|
|
272
|
+
credentials.revoke(
|
|
273
|
+
id,
|
|
274
|
+
req.body?.reason === "compromise" ? "compromise" : "user",
|
|
275
|
+
`admin:${req.ip ?? "unknown"}`,
|
|
276
|
+
);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
const failure = describeFailure(e);
|
|
279
|
+
return res.status(failure.status).json(failureBody(failure));
|
|
280
|
+
}
|
|
281
|
+
// Durable revoke committed: notice the live transport for ITS epoch and cut
|
|
282
|
+
// it immediately (<1 s). Authorization never waits for a notice ACK.
|
|
283
|
+
if (pairingRow)
|
|
284
|
+
tunnel.send(id, {
|
|
285
|
+
type: "control",
|
|
286
|
+
action: "revoked",
|
|
287
|
+
notice: {
|
|
288
|
+
type: "pair.revoke",
|
|
289
|
+
v: 1,
|
|
290
|
+
deviceId: id,
|
|
291
|
+
pairingEpoch: pairingRow.pairing_epoch,
|
|
292
|
+
reason: req.body?.reason === "compromise" ? "compromise" : "user",
|
|
293
|
+
revokedAt: Date.now(),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
tunnel.closeConnection(id, 1000, "pairing revoked");
|
|
297
|
+
monitor.check(id);
|
|
298
|
+
res.status(202).json({ ok: true });
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
app.get("/api/network", (_req, res) => res.json({ configFile, devices: monitor.network() }));
|
|
303
|
+
// Devices are configured in dpi-network.yaml and `just net register`; the hub only reads them.
|
|
304
|
+
app.post(
|
|
305
|
+
"/api/devices/:id/check",
|
|
306
|
+
safe(async (req, res) => {
|
|
307
|
+
const id = String(req.params.id);
|
|
308
|
+
if (!registry.get(id)) throw Object.assign(Error(`No device ${id}`), { status: 404 });
|
|
309
|
+
monitor.check(id);
|
|
310
|
+
res.json(await monitor.settled(id));
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
app.get("/api/ui-state/:key", (req, res) =>
|
|
314
|
+
res.json({ value: registry.uiGet(String(req.params.key)) ?? null }),
|
|
315
|
+
);
|
|
316
|
+
app.put(
|
|
317
|
+
"/api/ui-state/:key",
|
|
318
|
+
safe((req, res) => {
|
|
319
|
+
const value = req.body?.value ?? null;
|
|
320
|
+
if (JSON.stringify(value).length > UI_STATE_MAX_CHARS)
|
|
321
|
+
throw Object.assign(Error("UI state value too large"), { status: 413 });
|
|
322
|
+
registry.uiSet(String(req.params.key), value);
|
|
323
|
+
res.json({ ok: true });
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
app.get("/api/stream", (req, res) => mux.open(req, res));
|
|
327
|
+
app.put(
|
|
328
|
+
"/api/stream/:streamId",
|
|
329
|
+
safe((req, res) => res.json(mux.subscribe(String(req.params.streamId), req.body?.subs))),
|
|
330
|
+
);
|
|
331
|
+
app.all(
|
|
332
|
+
"/api/devices/:id/v1/{*rest}",
|
|
333
|
+
proxy(registry, monitor, {
|
|
334
|
+
dispatcherFor: (row) => new TunnelTransport(row, dispatcher),
|
|
335
|
+
hasLiveConnection: (id) => tunnel.hasConnection(id),
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
app.use("/api", (_req, res) => res.status(404).json(err("not_found", "API route not found")));
|
|
339
|
+
|
|
340
|
+
const server = createServer(app);
|
|
341
|
+
let vite: Awaited<ReturnType<(typeof import("vite"))["createServer"]>> | undefined;
|
|
342
|
+
if (args.includes("--dev")) {
|
|
343
|
+
const { createServer: createVite } = await import("vite");
|
|
344
|
+
vite = await createVite({
|
|
345
|
+
configFile: resolve(appDir, "apps/dpi-playground-fe/vite.config.ts"),
|
|
346
|
+
server: { middlewareMode: true, hmr: { server } },
|
|
347
|
+
});
|
|
348
|
+
app.use(vite.middlewares);
|
|
349
|
+
} else {
|
|
350
|
+
// The shell is the page that holds the operator's credentials. It loads only its own script and
|
|
351
|
+
// styles (plus the stylesheet host of the brand fonts), talks only to this hub and cannot be framed.
|
|
352
|
+
const shellPolicy = [
|
|
353
|
+
"default-src 'none'",
|
|
354
|
+
"script-src 'self'",
|
|
355
|
+
"style-src 'self' https://fonts.googleapis.com",
|
|
356
|
+
"font-src https://fonts.gstatic.com",
|
|
357
|
+
"img-src 'self' data:",
|
|
358
|
+
"connect-src 'self'",
|
|
359
|
+
"base-uri 'none'",
|
|
360
|
+
"form-action 'self'",
|
|
361
|
+
"frame-ancestors 'none'",
|
|
362
|
+
].join("; ");
|
|
363
|
+
app.use((_req, res, next) => {
|
|
364
|
+
res.set({
|
|
365
|
+
"Content-Security-Policy": shellPolicy,
|
|
366
|
+
"X-Frame-Options": "DENY",
|
|
367
|
+
"Referrer-Policy": "no-referrer",
|
|
368
|
+
});
|
|
369
|
+
next();
|
|
370
|
+
});
|
|
371
|
+
app.use(express.static(resolve(appDir, "apps/dpi-playground-fe/dist")));
|
|
372
|
+
app.get("/{*path}", (req, res, next) => {
|
|
373
|
+
// A path that names a file is an asset that does not exist, not a page of the app.
|
|
374
|
+
if (extname(req.path)) return void next();
|
|
375
|
+
res.sendFile(resolve(appDir, "apps/dpi-playground-fe/dist/index.html"));
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
app.use((_req, res) => res.status(404).json(err("not_found", "Not found")));
|
|
379
|
+
app.use(errorMiddleware);
|
|
380
|
+
|
|
381
|
+
// Tunnel endpoint (04 §4): /api/tunnel upgrades after hash-first credential
|
|
382
|
+
// auth. In --dev mode Vite owns its own HMR upgrade path, so only the exact
|
|
383
|
+
// tunnel path is claimed here and other upgrades are left alone.
|
|
384
|
+
const tunnel = new TunnelServer(registry, audit, hubId, {}, { requireTls: requireTlsEnabled });
|
|
385
|
+
const dispatcher = new TunnelDispatcher(tunnel, { audit, budget: bufferBudget });
|
|
386
|
+
const rotation = new RotationService(registry, audit, tunnel, {
|
|
387
|
+
onChange: () => monitor.refresh(),
|
|
388
|
+
});
|
|
389
|
+
mux.setTunnelSource({
|
|
390
|
+
dispatcherFor: (row) => new TunnelTransport(row, dispatcher),
|
|
391
|
+
hasLiveConnection: (id) => tunnel.hasConnection(id),
|
|
392
|
+
});
|
|
393
|
+
monitor.setTunnelSource({
|
|
394
|
+
dispatcherFor: (row) => new TunnelTransport(row, dispatcher),
|
|
395
|
+
hasLiveConnection: (id) => tunnel.hasConnection(id),
|
|
396
|
+
diagnostics: (id) => ({
|
|
397
|
+
...tunnel.diagnostics(id),
|
|
398
|
+
readerCount: dispatcher.subscriptionCount(id),
|
|
399
|
+
}),
|
|
400
|
+
});
|
|
401
|
+
server.on("upgrade", (req, socket, head) => {
|
|
402
|
+
let path = "/";
|
|
403
|
+
try {
|
|
404
|
+
path = new URL(req.url ?? "/", "http://hub.local").pathname;
|
|
405
|
+
} catch {
|
|
406
|
+
/* reject below */
|
|
407
|
+
}
|
|
408
|
+
if (path !== "/api/tunnel") {
|
|
409
|
+
if (!vite) socket.destroy();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// handleUpgrade fails closed on its own; this guard keeps a bug there from ever becoming an uncaught exception.
|
|
413
|
+
try {
|
|
414
|
+
tunnel.handleUpgrade(req, socket, head);
|
|
415
|
+
} catch {
|
|
416
|
+
socket.destroy();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
server.listen(port, host, () => {
|
|
421
|
+
assertLimitsConfig();
|
|
422
|
+
console.log(`Durable Pi hub: http://${host === "0.0.0.0" ? "localhost" : host}:${port}`);
|
|
423
|
+
console.log(
|
|
424
|
+
`registry: ${registry.path}${configFile ? ` config: ${configFile}` : " (no dpi-network.yaml)"}`,
|
|
425
|
+
);
|
|
426
|
+
console.log(
|
|
427
|
+
`devices: ${
|
|
428
|
+
registry
|
|
429
|
+
.list()
|
|
430
|
+
.map((d) => d.id)
|
|
431
|
+
.join(", ") || "none"
|
|
432
|
+
}`,
|
|
433
|
+
);
|
|
434
|
+
if (requireTlsEnabled)
|
|
435
|
+
console.log(
|
|
436
|
+
"TLS required: only requests the loopback TLS proxy marks X-Forwarded-Proto: https are served",
|
|
437
|
+
);
|
|
438
|
+
if (host !== "127.0.0.1" && host !== "localhost")
|
|
439
|
+
console.warn(
|
|
440
|
+
`WARNING: the hub is listening on ${host}. Anyone who can reach it can drive every device it holds tokens for.`,
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
let closing = false;
|
|
445
|
+
const shutdown = async () => {
|
|
446
|
+
if (closing) return;
|
|
447
|
+
closing = true;
|
|
448
|
+
dispatcher.close();
|
|
449
|
+
tunnel.close();
|
|
450
|
+
rotation.close();
|
|
451
|
+
monitor.stop();
|
|
452
|
+
server.close();
|
|
453
|
+
server.closeAllConnections();
|
|
454
|
+
await vite?.close();
|
|
455
|
+
audit.flushDenials();
|
|
456
|
+
registry.close();
|
|
457
|
+
process.exit(0);
|
|
458
|
+
};
|
|
459
|
+
process.on("SIGTERM", () => void shutdown());
|
|
460
|
+
process.on("SIGINT", () => void shutdown());
|