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,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized protocol limits and constants — Durable Agent Network P2.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for every CANON number referenced by
|
|
5
|
+
* specs 03 (pairing) and 04 (tunnel transport). Runtime code MUST NOT accept
|
|
6
|
+
* TTL/counter/limit overrides from requests: these constants are server-side
|
|
7
|
+
* policy. Config that derives from them is validated by `assertLimitsConfig`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** WebSocket maxPayload for one fully-assembled RFC message (both sides). 1 MiB. */
|
|
11
|
+
export const WS_MAX_MESSAGE_BYTES = 1_048_576;
|
|
12
|
+
/** Application-layer chunk raw size (before base64). 64 KiB. */
|
|
13
|
+
export const CHUNK_RAW_BYTES = 65_536;
|
|
14
|
+
/** Maximum chunks per logical message. 256. */
|
|
15
|
+
export const MAX_CHUNKS_PER_MESSAGE = 256;
|
|
16
|
+
/** Maximum logical message size including envelope. 16 MiB. */
|
|
17
|
+
export const LOGICAL_MESSAGE_MAX_BYTES = 16_777_216;
|
|
18
|
+
/** Handshake/control/fault frames must fit without chunking. 4 KiB. */
|
|
19
|
+
export const CONTROL_FRAME_MAX_BYTES = 4_096;
|
|
20
|
+
/** Per-message reassembly timeout. 30 s. */
|
|
21
|
+
export const REASSEMBLY_TIMEOUT_MS = 30_000;
|
|
22
|
+
/** Concurrent messages being reassembled per connection. */
|
|
23
|
+
export const REASSEMBLY_CONCURRENT_PER_CONNECTION = 4;
|
|
24
|
+
/** Total reassembly + outbound queue budget per device connection. 32 MiB. */
|
|
25
|
+
export const CONNECTION_BUFFER_BUDGET_BYTES = 33_554_432;
|
|
26
|
+
/** Total hub-wide budget (reassembly + outbound queues + pending REST). 64 MiB. */
|
|
27
|
+
export const HUB_BUFFER_BUDGET_BYTES = 67_108_864;
|
|
28
|
+
|
|
29
|
+
/** Hub → browser SSE buffered-output cap before the viewer is ended. 4 MB. */
|
|
30
|
+
export const SSE_HUB_VIEWER_BYTES = 4_000_000;
|
|
31
|
+
/** Hub ↔ device WS buffered-output cap per direction before close(1011). 4 MB. */
|
|
32
|
+
export const WS_BACKPRESSURE_BYTES = 4_000_000;
|
|
33
|
+
/**
|
|
34
|
+
* Pacing mark for the connector's outbound WS queue: a sender holds back while more than this is queued,
|
|
35
|
+
* so a valid message up to the 16 MiB logical cap flows through without ever reaching the close above. 1 MiB.
|
|
36
|
+
*/
|
|
37
|
+
export const WS_PACE_LOW_WATER_BYTES = 1_048_576;
|
|
38
|
+
/** Backend → SSE viewer cap (LAN or connector). 2 MB. */
|
|
39
|
+
export const BACKEND_SSE_VIEWER_BYTES = 2_000_000;
|
|
40
|
+
/** Paced write segment for SSE/WS writers. 64 KiB. */
|
|
41
|
+
export const PACE_WRITE_BYTES = 65_536;
|
|
42
|
+
|
|
43
|
+
/** Subscription keys per browser SSE connection. */
|
|
44
|
+
export const SUBSCRIPTION_KEYS_PER_BROWSER_CONNECTION = 20;
|
|
45
|
+
/** Active readers per device, monitor included. */
|
|
46
|
+
export const READERS_PER_DEVICE = 128;
|
|
47
|
+
/** Active readers hub-wide. */
|
|
48
|
+
export const READERS_PER_HUB = 1_024;
|
|
49
|
+
|
|
50
|
+
/** REST request body cap (checked before and after reassembly). 256 KiB. */
|
|
51
|
+
export const REST_BODY_MAX_BYTES = 262_144;
|
|
52
|
+
/** REST end-to-end timeout (send + chunk + reassembly). 330 s. */
|
|
53
|
+
export const REST_TIMEOUT_MS = 330_000;
|
|
54
|
+
/** Pending REST requests per device. */
|
|
55
|
+
export const REST_PENDING_PER_DEVICE = 32;
|
|
56
|
+
/** Pending REST requests hub-wide. */
|
|
57
|
+
export const REST_PENDING_PER_HUB = 256;
|
|
58
|
+
|
|
59
|
+
/** Tunnel identity handshake deadline after 101. 5 s. */
|
|
60
|
+
export const HANDSHAKE_DEADLINE_MS = 5_000;
|
|
61
|
+
/** WS ping interval, both directions. 15 s. */
|
|
62
|
+
export const PING_INTERVAL_MS = 15_000;
|
|
63
|
+
/** Missed pings before close. */
|
|
64
|
+
export const PING_MISS_LIMIT = 2;
|
|
65
|
+
/** No-byte idle safety net. 45 s. */
|
|
66
|
+
export const IDLE_TIMEOUT_MS = 45_000;
|
|
67
|
+
/**
|
|
68
|
+
* Reconnect backoff shared by the device connector, the hub's mux reader and the SDK stream follower:
|
|
69
|
+
* 1, 2, 4, 8, 15, 15 s, back to 1 s on hello (see `Backoff`).
|
|
70
|
+
*/
|
|
71
|
+
export const RECONNECT_BACKOFF_S = { initS: 1, capS: 15 } as const;
|
|
72
|
+
/** Monitor probe backoff: the same curve with a 30 s cap. */
|
|
73
|
+
export const MONITOR_BACKOFF_S = { initS: 1, capS: 30 } as const;
|
|
74
|
+
|
|
75
|
+
/** Pairing code TTL by hub clock. 5 min. */
|
|
76
|
+
export const PAIR_CODE_TTL_MS = 300_000;
|
|
77
|
+
/** Challenge nonce TTL. 60 s. */
|
|
78
|
+
export const PAIR_NONCE_TTL_MS = 60_000;
|
|
79
|
+
/** Grace after code expiry for retrying an ALREADY issued TokenIssue. 120 s. */
|
|
80
|
+
export const PAIR_RECEIPT_GRACE_MS = 120_000;
|
|
81
|
+
/** Wrong signatures before an enrollment is cancelled. */
|
|
82
|
+
export const PAIR_SIGNATURE_FAIL_LIMIT = 3;
|
|
83
|
+
/** Clock-skew tolerance for challenges accepted by the device. ±60 s. */
|
|
84
|
+
export const PAIR_CLOCK_SKEW_MS = 60_000;
|
|
85
|
+
|
|
86
|
+
/** Rate limits (per real-IP, hub-side token buckets). */
|
|
87
|
+
export const PAIR_RATE_LIMITS = {
|
|
88
|
+
codeCreate: { perMinute: 5, capacity: 5 },
|
|
89
|
+
start: { perMinute: 20, capacity: 20 },
|
|
90
|
+
verifyFails: { perWindow: 100, windowMinutes: 15 },
|
|
91
|
+
verifyTotal: { perMinute: 300, capacity: 300 },
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
/** RAM caps for pending pairing state. */
|
|
95
|
+
export const PAIR_RAM_CAPS = { codes: 1_000, enrollments: 1_000 } as const;
|
|
96
|
+
/** Pairing endpoint body cap. 16 KiB. */
|
|
97
|
+
export const PAIRING_BODY_MAX_BYTES = 16_384;
|
|
98
|
+
/** HTTP pairing operation timeout / retry budget. */
|
|
99
|
+
export const PAIR_HTTP_TIMEOUT_MS = 10_000;
|
|
100
|
+
export const PAIR_HTTP_MAX_RETRIES = 3;
|
|
101
|
+
/** Approved-code polling cadence. 1 s. */
|
|
102
|
+
export const PAIR_POLL_MS = 1_000;
|
|
103
|
+
|
|
104
|
+
/** Hub admin REST rate limit (per real IP): refill 60/min, burst capacity 120. */
|
|
105
|
+
export const API_RATE_LIMITS = { perMinute: 60, capacity: 120 } as const;
|
|
106
|
+
|
|
107
|
+
/** Per-address limiter tables (pairing, admin API, tunnel upgrade): key cap and idle TTL. A full table refuses new keys. */
|
|
108
|
+
export const IP_BUCKET_MAX_KEYS = 10_000;
|
|
109
|
+
export const IP_BUCKET_IDLE_TTL_MS = 900_000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Tunnel upgrade allocation (05 §4.3 / S-10): attempts per real IP (refill and burst), pending+live
|
|
113
|
+
* connections per real IP, and the hub-wide pending-handshake and authenticated-connection caps.
|
|
114
|
+
*/
|
|
115
|
+
export const WS_UPGRADE_LIMITS = {
|
|
116
|
+
perMinutePerIp: 30,
|
|
117
|
+
burstPerIp: 10,
|
|
118
|
+
connectionsPerIp: 5,
|
|
119
|
+
pendingPerHub: 100,
|
|
120
|
+
livePerHub: 1_000,
|
|
121
|
+
} as const;
|
|
122
|
+
|
|
123
|
+
/** Routine credential rotation interval. 90 days. */
|
|
124
|
+
export const ROTATION_INTERVAL_DAYS = 90;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Tunneled REST paths live under /v1 and must match the route allowlist (rest-routes.ts). SSE endpoints (streams
|
|
128
|
+
* are subscription-based) and the device-LOCAL control surface (pairing/tunnel status) are not on it: they belong
|
|
129
|
+
* to the device operator, not to remote hub callers.
|
|
130
|
+
*/
|
|
131
|
+
export const REST_PATH_PREFIX = "/v1/";
|
|
132
|
+
|
|
133
|
+
/** Canonical snapshot of every startup-validated number (testability: callers may pass mutated copies to validateLimits). */
|
|
134
|
+
export interface LimitsSnapshot {
|
|
135
|
+
wsMaxMessageBytes: number;
|
|
136
|
+
chunkRawBytes: number;
|
|
137
|
+
maxChunksPerMessage: number;
|
|
138
|
+
logicalMessageMaxBytes: number;
|
|
139
|
+
connectionBufferBudgetBytes: number;
|
|
140
|
+
hubBufferBudgetBytes: number;
|
|
141
|
+
readersPerDevice: number;
|
|
142
|
+
readersPerHub: number;
|
|
143
|
+
restPendingPerDevice: number;
|
|
144
|
+
restPendingPerHub: number;
|
|
145
|
+
connectorBackoffFirstS: number;
|
|
146
|
+
pairVerifyFailsPerWindow: number;
|
|
147
|
+
pairVerifyFailWindowMinutes: number;
|
|
148
|
+
apiPerMinute: number;
|
|
149
|
+
apiCapacity: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** CANON snapshot — the single source validated at startup. */
|
|
153
|
+
const CANON_SNAPSHOT: LimitsSnapshot = {
|
|
154
|
+
wsMaxMessageBytes: WS_MAX_MESSAGE_BYTES,
|
|
155
|
+
chunkRawBytes: CHUNK_RAW_BYTES,
|
|
156
|
+
maxChunksPerMessage: MAX_CHUNKS_PER_MESSAGE,
|
|
157
|
+
logicalMessageMaxBytes: LOGICAL_MESSAGE_MAX_BYTES,
|
|
158
|
+
connectionBufferBudgetBytes: CONNECTION_BUFFER_BUDGET_BYTES,
|
|
159
|
+
hubBufferBudgetBytes: HUB_BUFFER_BUDGET_BYTES,
|
|
160
|
+
readersPerDevice: READERS_PER_DEVICE,
|
|
161
|
+
readersPerHub: READERS_PER_HUB,
|
|
162
|
+
restPendingPerDevice: REST_PENDING_PER_DEVICE,
|
|
163
|
+
restPendingPerHub: REST_PENDING_PER_HUB,
|
|
164
|
+
connectorBackoffFirstS: RECONNECT_BACKOFF_S.initS,
|
|
165
|
+
pairVerifyFailsPerWindow: PAIR_RATE_LIMITS.verifyFails.perWindow,
|
|
166
|
+
pairVerifyFailWindowMinutes: PAIR_RATE_LIMITS.verifyFails.windowMinutes,
|
|
167
|
+
apiPerMinute: API_RATE_LIMITS.perMinute,
|
|
168
|
+
apiCapacity: API_RATE_LIMITS.capacity,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pure validation over a snapshot: every number must be a positive safe
|
|
173
|
+
* integer within sane bounds, and structural relations must hold. Exported
|
|
174
|
+
* for compile-negative tests; production calls assertLimitsConfig() (CANON).
|
|
175
|
+
*/
|
|
176
|
+
export function validateLimits(l: LimitsSnapshot): void {
|
|
177
|
+
const fields = Object.entries(l) as Array<[keyof LimitsSnapshot, number]>;
|
|
178
|
+
for (const [name, v] of fields) {
|
|
179
|
+
if (!Number.isSafeInteger(v) || v <= 0) throw new Error(`limits: ${name} must be a positive safe integer (got ${v})`);
|
|
180
|
+
}
|
|
181
|
+
if (l.maxChunksPerMessage > 65_536 || l.wsMaxMessageBytes > 64 * 1_048_576 || l.hubBufferBudgetBytes > (1 << 30))
|
|
182
|
+
throw new Error("limits: value out of sane range");
|
|
183
|
+
if (l.maxChunksPerMessage * l.chunkRawBytes < l.logicalMessageMaxBytes)
|
|
184
|
+
throw new Error("limits: chunk count cannot cover logical message budget");
|
|
185
|
+
if (l.chunkRawBytes * 2 > l.wsMaxMessageBytes)
|
|
186
|
+
throw new Error("limits: single chunk + envelope must fit one WS message");
|
|
187
|
+
if (l.connectionBufferBudgetBytes >= l.hubBufferBudgetBytes)
|
|
188
|
+
throw new Error("limits: connection budget must be smaller than hub budget");
|
|
189
|
+
if (l.readersPerDevice > l.readersPerHub)
|
|
190
|
+
throw new Error("limits: per-device reader cap exceeds hub cap");
|
|
191
|
+
if (l.restPendingPerDevice > l.restPendingPerHub)
|
|
192
|
+
throw new Error("limits: per-device pending cap exceeds hub cap");
|
|
193
|
+
if (l.apiCapacity < l.apiPerMinute)
|
|
194
|
+
throw new Error("limits: API burst capacity must be >= per-minute refill");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function assertLimitsConfig(): void {
|
|
198
|
+
validateLimits(CANON_SNAPSHOT);
|
|
199
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing protocol DTOs — Durable Agent Network P2 (spec 03 §5).
|
|
3
|
+
*
|
|
4
|
+
* Strict TypeBox schemas: unknown fields are rejected everywhere. Schema
|
|
5
|
+
* checks are necessary but NOT sufficient: cross-field/state invariants and
|
|
6
|
+
* origin normalization live in the exported helpers below and in the hub
|
|
7
|
+
* state machine (03 §4).
|
|
8
|
+
*
|
|
9
|
+
* This module must stay browser-safe: no Node builtin imports.
|
|
10
|
+
*/
|
|
11
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
12
|
+
import { Value } from "@sinclair/typebox/value";
|
|
13
|
+
|
|
14
|
+
const Strict = { additionalProperties: false } as const;
|
|
15
|
+
|
|
16
|
+
const Uuid = Type.String({
|
|
17
|
+
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
18
|
+
minLength: 36,
|
|
19
|
+
maxLength: 36,
|
|
20
|
+
});
|
|
21
|
+
// Strict end anchor: JS `$` alone can still match before a final newline, so
|
|
22
|
+
// DeviceId uses an explicit lookahead. All other patterns are length-clamped
|
|
23
|
+
// (min===max), which also rejects trailing-newline inputs.
|
|
24
|
+
const DeviceId = Type.String({
|
|
25
|
+
pattern: "^[a-z0-9][a-z0-9-]{0,31}(?![\\s\\S])",
|
|
26
|
+
minLength: 1,
|
|
27
|
+
maxLength: 32,
|
|
28
|
+
});
|
|
29
|
+
const Fingerprint = Type.String({ pattern: "^sha256:[0-9a-f]{64}$", minLength: 71, maxLength: 71 });
|
|
30
|
+
const Nonce = Type.String({ pattern: "^[A-Za-z0-9_-]{43}$", minLength: 43, maxLength: 43 });
|
|
31
|
+
const TunnelToken = Type.String({ pattern: "^dpt_[A-Za-z0-9_-]{43}$", minLength: 47, maxLength: 47 });
|
|
32
|
+
const PairCode = Type.String({ pattern: "^[A-Z2-7]{8}$", minLength: 8, maxLength: 8 });
|
|
33
|
+
|
|
34
|
+
export const PairRequest = Type.Object(
|
|
35
|
+
{
|
|
36
|
+
type: Type.Literal("pair.request"),
|
|
37
|
+
v: Type.Literal(1),
|
|
38
|
+
requestId: Uuid,
|
|
39
|
+
code: PairCode,
|
|
40
|
+
deviceUid: Uuid,
|
|
41
|
+
deviceName: Type.String({ minLength: 1, maxLength: 64 }),
|
|
42
|
+
publicKey: Type.String({ minLength: 1, maxLength: 256 }), // Ed25519 SPKI PEM
|
|
43
|
+
keyFingerprint: Fingerprint,
|
|
44
|
+
clientTime: Type.Integer(),
|
|
45
|
+
},
|
|
46
|
+
Strict,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export const PairChallenge = Type.Object(
|
|
50
|
+
{
|
|
51
|
+
type: Type.Literal("pair.challenge"),
|
|
52
|
+
v: Type.Literal(1),
|
|
53
|
+
enrollmentId: Uuid,
|
|
54
|
+
nonce: Nonce,
|
|
55
|
+
issuedAt: Type.Integer(),
|
|
56
|
+
hubId: Uuid, // deployment ID, not a cryptographic fingerprint
|
|
57
|
+
},
|
|
58
|
+
Strict,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export const PairResponse = Type.Object(
|
|
62
|
+
{
|
|
63
|
+
type: Type.Literal("pair.response"),
|
|
64
|
+
v: Type.Literal(1),
|
|
65
|
+
enrollmentId: Uuid,
|
|
66
|
+
nonce: Nonce,
|
|
67
|
+
signature: Type.String({ pattern: "^[A-Za-z0-9_-]{86}$", minLength: 86, maxLength: 86 }),
|
|
68
|
+
},
|
|
69
|
+
Strict,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export const PairPending = Type.Object(
|
|
73
|
+
{
|
|
74
|
+
type: Type.Literal("pair.pending"),
|
|
75
|
+
v: Type.Literal(1),
|
|
76
|
+
enrollmentId: Uuid,
|
|
77
|
+
state: Type.Literal("awaiting_approval"),
|
|
78
|
+
expiresAt: Type.Integer(),
|
|
79
|
+
pollAfterMs: Type.Integer({ minimum: 1 }),
|
|
80
|
+
},
|
|
81
|
+
Strict,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
export const TokenIssue = Type.Object(
|
|
85
|
+
{
|
|
86
|
+
type: Type.Literal("pair.token"),
|
|
87
|
+
v: Type.Literal(1),
|
|
88
|
+
enrollmentId: Uuid,
|
|
89
|
+
deviceUid: Uuid,
|
|
90
|
+
deviceId: DeviceId,
|
|
91
|
+
pairingEpoch: Uuid,
|
|
92
|
+
credentialId: Uuid,
|
|
93
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
94
|
+
hubId: Uuid,
|
|
95
|
+
hubOrigin: Type.String(),
|
|
96
|
+
hubUrl: Type.String(),
|
|
97
|
+
tunnelToken: TunnelToken,
|
|
98
|
+
issuedAt: Type.Integer(),
|
|
99
|
+
expiresAt: Type.Null(),
|
|
100
|
+
},
|
|
101
|
+
Strict,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
export const PairApproval = Type.Object(
|
|
105
|
+
{
|
|
106
|
+
enrollmentId: Uuid,
|
|
107
|
+
deviceUid: Uuid,
|
|
108
|
+
keyFingerprint: Fingerprint,
|
|
109
|
+
replaceKey: Type.Optional(Type.Boolean()),
|
|
110
|
+
},
|
|
111
|
+
Strict,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
export const RevokeNotice = Type.Object(
|
|
115
|
+
{
|
|
116
|
+
type: Type.Literal("pair.revoke"),
|
|
117
|
+
v: Type.Literal(1),
|
|
118
|
+
deviceId: DeviceId,
|
|
119
|
+
pairingEpoch: Uuid,
|
|
120
|
+
reason: Type.Union([
|
|
121
|
+
Type.Literal("user"),
|
|
122
|
+
Type.Literal("compromise"),
|
|
123
|
+
Type.Literal("hub_migrated"),
|
|
124
|
+
]),
|
|
125
|
+
revokedAt: Type.Integer(),
|
|
126
|
+
},
|
|
127
|
+
Strict,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
export const PairError = Type.Object(
|
|
131
|
+
{
|
|
132
|
+
type: Type.Literal("pair.error"),
|
|
133
|
+
v: Type.Literal(1),
|
|
134
|
+
code: Type.Union([
|
|
135
|
+
Type.Literal("code_invalid"),
|
|
136
|
+
Type.Literal("code_expired"),
|
|
137
|
+
Type.Literal("code_used"),
|
|
138
|
+
Type.Literal("code_in_use"),
|
|
139
|
+
Type.Literal("rate_limited"),
|
|
140
|
+
Type.Literal("signature_invalid"),
|
|
141
|
+
Type.Literal("clock_skew"),
|
|
142
|
+
Type.Literal("paired_elsewhere"),
|
|
143
|
+
Type.Literal("hub_required"),
|
|
144
|
+
Type.Literal("fingerprint_mismatch"),
|
|
145
|
+
Type.Literal("identity_conflict"),
|
|
146
|
+
Type.Literal("enrollment_lost"),
|
|
147
|
+
Type.Literal("enrollment_cancelled"),
|
|
148
|
+
Type.Literal("already_issued"),
|
|
149
|
+
Type.Literal("challenge_expired"),
|
|
150
|
+
]),
|
|
151
|
+
message: Type.String({ maxLength: 512 }),
|
|
152
|
+
retryAfterMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
153
|
+
},
|
|
154
|
+
Strict,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
export const PairCodeIssue = Type.Object(
|
|
158
|
+
{
|
|
159
|
+
type: Type.Literal("pair.code"),
|
|
160
|
+
v: Type.Literal(1),
|
|
161
|
+
codeId: Uuid,
|
|
162
|
+
code: PairCode,
|
|
163
|
+
expiresAt: Type.Integer(),
|
|
164
|
+
hubOrigin: Type.String({ maxLength: 2048 }),
|
|
165
|
+
pairUrl: Type.String({ maxLength: 4096 }),
|
|
166
|
+
},
|
|
167
|
+
Strict,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const PairCodeStatePhase = Type.Union([
|
|
171
|
+
Type.Literal("open"),
|
|
172
|
+
Type.Literal("awaiting_proof"),
|
|
173
|
+
Type.Literal("awaiting_approval"),
|
|
174
|
+
Type.Literal("approved"),
|
|
175
|
+
Type.Literal("issued"),
|
|
176
|
+
Type.Literal("connected"),
|
|
177
|
+
Type.Literal("cancelled"),
|
|
178
|
+
Type.Literal("expired"),
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
export const PairCodeState = Type.Object(
|
|
182
|
+
{
|
|
183
|
+
type: Type.Literal("pair.code-state"),
|
|
184
|
+
v: Type.Literal(1),
|
|
185
|
+
codeId: Uuid,
|
|
186
|
+
expiresAt: Type.Integer(),
|
|
187
|
+
phase: PairCodeStatePhase,
|
|
188
|
+
enrollment: Type.Optional(
|
|
189
|
+
Type.Object(
|
|
190
|
+
{
|
|
191
|
+
enrollmentId: Uuid,
|
|
192
|
+
deviceUid: Uuid,
|
|
193
|
+
deviceName: Type.String({ minLength: 1, maxLength: 64 }),
|
|
194
|
+
keyFingerprint: Fingerprint,
|
|
195
|
+
proofVerified: Type.Boolean(),
|
|
196
|
+
keyChanged: Type.Boolean(),
|
|
197
|
+
},
|
|
198
|
+
Strict,
|
|
199
|
+
),
|
|
200
|
+
),
|
|
201
|
+
deviceId: Type.Optional(DeviceId),
|
|
202
|
+
error: Type.Optional(PairError),
|
|
203
|
+
},
|
|
204
|
+
Strict,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
export type PairRequest = Static<typeof PairRequest>;
|
|
208
|
+
export type PairChallenge = Static<typeof PairChallenge>;
|
|
209
|
+
export type PairResponse = Static<typeof PairResponse>;
|
|
210
|
+
export type PairPending = Static<typeof PairPending>;
|
|
211
|
+
export type TokenIssue = Static<typeof TokenIssue>;
|
|
212
|
+
export type PairApproval = Static<typeof PairApproval>;
|
|
213
|
+
export type RevokeNotice = Static<typeof RevokeNotice>;
|
|
214
|
+
export type PairError = Static<typeof PairError>;
|
|
215
|
+
export type PairCodeIssue = Static<typeof PairCodeIssue>;
|
|
216
|
+
export type PairCodeState = Static<typeof PairCodeState>;
|
|
217
|
+
|
|
218
|
+
/** The ten wire DTOs — used by schema-coverage tests (W-3). */
|
|
219
|
+
export const PAIRING_DTOS = [
|
|
220
|
+
PairRequest,
|
|
221
|
+
PairChallenge,
|
|
222
|
+
PairResponse,
|
|
223
|
+
PairPending,
|
|
224
|
+
TokenIssue,
|
|
225
|
+
PairApproval,
|
|
226
|
+
RevokeNotice,
|
|
227
|
+
PairError,
|
|
228
|
+
PairCodeIssue,
|
|
229
|
+
PairCodeState,
|
|
230
|
+
] as const;
|
|
231
|
+
|
|
232
|
+
export function checkPairingDto<T extends (typeof PAIRING_DTOS)[number]>(
|
|
233
|
+
schema: T,
|
|
234
|
+
value: unknown,
|
|
235
|
+
): value is Static<T> {
|
|
236
|
+
return Value.Check(schema, value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Canonical hub origin: HTTPS only, no userinfo/query/fragment/base path.
|
|
241
|
+
* The tunnel WSS URL is always derived from this same origin + /api/tunnel.
|
|
242
|
+
*/
|
|
243
|
+
export function parseHubOrigin(
|
|
244
|
+
input: string,
|
|
245
|
+
): { ok: true; origin: string; tunnelUrl: string } | { ok: false; error: string } {
|
|
246
|
+
if (typeof input !== "string" || input.length === 0 || input.length > 2048) {
|
|
247
|
+
return { ok: false, error: "origin: empty or too long" };
|
|
248
|
+
}
|
|
249
|
+
if (/\s|\n|\r|\t/.test(input)) {
|
|
250
|
+
return { ok: false, error: "origin: whitespace not allowed" };
|
|
251
|
+
}
|
|
252
|
+
let url: URL;
|
|
253
|
+
try {
|
|
254
|
+
url = new URL(input);
|
|
255
|
+
} catch {
|
|
256
|
+
return { ok: false, error: "origin: not a valid URL" };
|
|
257
|
+
}
|
|
258
|
+
if (url.protocol !== "https:") {
|
|
259
|
+
return { ok: false, error: "origin: HTTPS origin required" };
|
|
260
|
+
}
|
|
261
|
+
if (url.username || url.password) {
|
|
262
|
+
return { ok: false, error: "origin: userinfo not allowed" };
|
|
263
|
+
}
|
|
264
|
+
if (url.search || url.hash) {
|
|
265
|
+
return { ok: false, error: "origin: query/fragment not allowed" };
|
|
266
|
+
}
|
|
267
|
+
if (url.pathname !== "/" && url.pathname !== "") {
|
|
268
|
+
return { ok: false, error: "origin: base path not allowed" };
|
|
269
|
+
}
|
|
270
|
+
return { ok: true, origin: url.origin, tunnelUrl: `${url.origin}/api/tunnel` };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Exact Ed25519 signing payload — element order is normative (03 §4.2). */
|
|
274
|
+
export function pairingSigningPayload(parts: {
|
|
275
|
+
hubOrigin: string;
|
|
276
|
+
hubId: string;
|
|
277
|
+
enrollmentId: string;
|
|
278
|
+
nonce: string;
|
|
279
|
+
issuedAt: number;
|
|
280
|
+
deviceUid: string;
|
|
281
|
+
keyFingerprint: string;
|
|
282
|
+
}): string {
|
|
283
|
+
return JSON.stringify([
|
|
284
|
+
"dpi-pair-v1",
|
|
285
|
+
parts.hubOrigin,
|
|
286
|
+
parts.hubId,
|
|
287
|
+
parts.enrollmentId,
|
|
288
|
+
parts.nonce,
|
|
289
|
+
parts.issuedAt,
|
|
290
|
+
parts.deviceUid,
|
|
291
|
+
parts.keyFingerprint,
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Base64url byte-length validation for credential-bearing strings read from
|
|
297
|
+
* files/env (which may carry a trailing newline). Protocol contexts use the
|
|
298
|
+
* strict patterns above; this guards the persistence/compare layer.
|
|
299
|
+
*/
|
|
300
|
+
export function strictTokenBytes(input: string, expectedBytes: number): boolean {
|
|
301
|
+
if (input.includes("=") || input.includes("+") || input.includes("/")) return false;
|
|
302
|
+
if (!/^[A-Za-z0-9_-]+$/.test(input)) return false; // trailing newline/space/charset
|
|
303
|
+
const chars = input.length;
|
|
304
|
+
if (chars !== Math.ceil((expectedBytes * 8) / 6)) return false;
|
|
305
|
+
try {
|
|
306
|
+
const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
307
|
+
return globalThis.atob(b64).length === expectedBytes;
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The device REST routes a hub may call through the tunnel (04 §9, P2-16), as an allowlist of method + route.
|
|
2
|
+
// Browser-safe: no Node builtins.
|
|
3
|
+
//
|
|
4
|
+
// Everything else under /v1 is refused. That keeps the device-local control surface (/v1/pairing/*, /v1/tunnel)
|
|
5
|
+
// and the three SSE streams (/v1/events, .../events) off the REST channel in every spelling, instead of trying
|
|
6
|
+
// to name each bad spelling. A route the device adds is remote-callable only once it is listed here; the
|
|
7
|
+
// backend's route-parity test fails until a new route is classified as served or as local/stream.
|
|
8
|
+
//
|
|
9
|
+
// `:name` stands for one parameter segment. Every other segment is a literal that must match exactly (lowercase):
|
|
10
|
+
// Express matches routes case-insensitively, so a looser comparison here would let `/v1/Pairing` through.
|
|
11
|
+
|
|
12
|
+
const ROUTES: readonly (readonly [method: string, pattern: string])[] = [
|
|
13
|
+
["GET", "/device"],
|
|
14
|
+
["GET", "/tree"],
|
|
15
|
+
["GET", "/fs/dirs"],
|
|
16
|
+
["GET", "/docs/:kind"],
|
|
17
|
+
|
|
18
|
+
["GET", "/sessions"],
|
|
19
|
+
["POST", "/sessions"],
|
|
20
|
+
["GET", "/sessions/:s"],
|
|
21
|
+
["PATCH", "/sessions/:s"],
|
|
22
|
+
["DELETE", "/sessions/:s"],
|
|
23
|
+
["GET", "/sessions/:s/compaction"],
|
|
24
|
+
["PATCH", "/sessions/:s/compaction"],
|
|
25
|
+
["GET", "/sessions/:s/tasks"],
|
|
26
|
+
["POST", "/sessions/:s/tasks/:taskId/abort"],
|
|
27
|
+
["GET", "/sessions/:s/inspect"],
|
|
28
|
+
["GET", "/sessions/:s/submissions/:id"],
|
|
29
|
+
|
|
30
|
+
["GET", "/sessions/:s/agents"],
|
|
31
|
+
["POST", "/sessions/:s/agents"],
|
|
32
|
+
["GET", "/sessions/:s/agents/:a"],
|
|
33
|
+
["PATCH", "/sessions/:s/agents/:a"],
|
|
34
|
+
["GET", "/sessions/:s/agents/:a/entries"],
|
|
35
|
+
["GET", "/sessions/:s/agents/:a/transcript"],
|
|
36
|
+
["POST", "/sessions/:s/agents/:a/messages"],
|
|
37
|
+
["DELETE", "/sessions/:s/agents/:a/inbox/:submissionId"],
|
|
38
|
+
["POST", "/sessions/:s/agents/:a/abort"],
|
|
39
|
+
["POST", "/sessions/:s/agents/:a/fork"],
|
|
40
|
+
["GET", "/sessions/:s/agents/:a/config"],
|
|
41
|
+
["PATCH", "/sessions/:s/agents/:a/config"],
|
|
42
|
+
["GET", "/sessions/:s/agents/:a/extensions"],
|
|
43
|
+
["PATCH", "/sessions/:s/agents/:a/extensions"],
|
|
44
|
+
["POST", "/sessions/:s/agents/:a/compact"],
|
|
45
|
+
["POST", "/sessions/:s/agents/:a/reset"],
|
|
46
|
+
["GET", "/sessions/:s/agents/:a/docs/:kind"],
|
|
47
|
+
["POST", "/sessions/:s/agents/:a/demo/state"],
|
|
48
|
+
["POST", "/sessions/:s/agents/:a/demo/jobs"],
|
|
49
|
+
["POST", "/sessions/:s/agents/:a/demo/jobs/:taskId/approval"],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** The allowlist as data: `route` is relative to /v1. */
|
|
53
|
+
export const REST_ROUTE_ALLOWLIST: readonly { method: string; route: string }[] = ROUTES.map(([method, route]) => ({
|
|
54
|
+
method,
|
|
55
|
+
route,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
/** Longest decoded parameter (session and agent names stop at 100 characters). */
|
|
59
|
+
export const REST_PARAM_MAX_CHARS = 256;
|
|
60
|
+
|
|
61
|
+
const COMPILED = ROUTES.map(([method, pattern]) => ({ method, parts: pattern.slice(1).split("/") }));
|
|
62
|
+
|
|
63
|
+
const decodeOr = (segment: string): string | undefined => {
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(segment);
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** One percent-encoded name: not empty, not a dot segment, no separators or control characters once decoded. */
|
|
72
|
+
function isParam(segment: string): boolean {
|
|
73
|
+
const name = decodeOr(segment);
|
|
74
|
+
return (
|
|
75
|
+
name !== undefined &&
|
|
76
|
+
name.length > 0 &&
|
|
77
|
+
name.length <= REST_PARAM_MAX_CHARS &&
|
|
78
|
+
name !== "." &&
|
|
79
|
+
name !== ".." &&
|
|
80
|
+
// eslint-disable-next-line no-control-regex
|
|
81
|
+
!/[\u0000-\u001f\u007f/\\]/.test(name)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `segments` are the raw (still percent-encoded) path segments after /v1. */
|
|
86
|
+
export function matchesRestRoute(method: string, segments: readonly string[]): boolean {
|
|
87
|
+
return COMPILED.some(
|
|
88
|
+
(route) =>
|
|
89
|
+
route.method === method &&
|
|
90
|
+
route.parts.length === segments.length &&
|
|
91
|
+
route.parts.every((part, i) => (part.startsWith(":") ? isParam(segments[i]) : part === segments[i])),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Why a path that matched no route is out of bounds, for the refusal message only (the allowlist alone decides).
|
|
97
|
+
* Case-insensitive on purpose: `/v1/Events` is still an event stream as far as Express is concerned.
|
|
98
|
+
*/
|
|
99
|
+
export function restRouteKind(segments: readonly string[]): "stream" | "local" | undefined {
|
|
100
|
+
const s = segments.map((segment) => (decodeOr(segment) ?? segment).toLowerCase());
|
|
101
|
+
if (s[0] === "pairing" || s[0] === "tunnel") return "local";
|
|
102
|
+
if (s[0] === "events") return "stream";
|
|
103
|
+
if (s[0] === "sessions" && (s[2] === "events" || (s[2] === "agents" && s[4] === "events"))) return "stream";
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|