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,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel wire protocol — Durable Agent Network P2 (spec 04 §3).
|
|
3
|
+
*
|
|
4
|
+
* Runtime schemas for TunnelFrame / TunnelChunk / TunnelAuthError with
|
|
5
|
+
* direction and phase guards. TypeScript unions are NOT sufficient: every
|
|
6
|
+
* inbound message must pass Value.Check plus the guards below.
|
|
7
|
+
*
|
|
8
|
+
* This module must stay browser-safe: no Node builtin imports.
|
|
9
|
+
*/
|
|
10
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
11
|
+
import { Value } from "@sinclair/typebox/value";
|
|
12
|
+
import type { Frame } from "../api.js";
|
|
13
|
+
import { CHUNK_RAW_BYTES, MAX_CHUNKS_PER_MESSAGE, REST_PATH_PREFIX } from "./limits.js";
|
|
14
|
+
import { matchesRestRoute, restRouteKind } from "./rest-routes.js";
|
|
15
|
+
|
|
16
|
+
const Strict = { additionalProperties: false } as const;
|
|
17
|
+
|
|
18
|
+
const Uuid = Type.String({
|
|
19
|
+
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
20
|
+
minLength: 36,
|
|
21
|
+
maxLength: 36,
|
|
22
|
+
});
|
|
23
|
+
const DeviceId = Type.String({
|
|
24
|
+
pattern: "^[a-z0-9][a-z0-9-]{0,31}(?![\\s\\S])",
|
|
25
|
+
minLength: 1,
|
|
26
|
+
maxLength: 32,
|
|
27
|
+
});
|
|
28
|
+
const Fingerprint = Type.String({ pattern: "^sha256:[0-9a-f]{64}$", minLength: 71, maxLength: 71 });
|
|
29
|
+
const TunnelToken = Type.String({ pattern: "^dpt_[A-Za-z0-9_-]{43}$", minLength: 47, maxLength: 47 });
|
|
30
|
+
const SubscriptionFields = {
|
|
31
|
+
subscriptionId: Uuid,
|
|
32
|
+
sub: Type.String({ minLength: 1, maxLength: 512 }),
|
|
33
|
+
} as const;
|
|
34
|
+
const RotationFields = {
|
|
35
|
+
pairingEpoch: Uuid,
|
|
36
|
+
rotationId: Uuid,
|
|
37
|
+
credentialId: Uuid,
|
|
38
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** Typed pre-upgrade auth error body (04 §4): JSON 401 before 101. */
|
|
42
|
+
export const TunnelAuthError = Type.Object(
|
|
43
|
+
{
|
|
44
|
+
type: Type.Literal("tunnel.auth-error"),
|
|
45
|
+
v: Type.Literal(1),
|
|
46
|
+
code: Type.Union([
|
|
47
|
+
Type.Literal("revoked"),
|
|
48
|
+
Type.Literal("token_unknown"),
|
|
49
|
+
Type.Literal("rate_limited"),
|
|
50
|
+
]),
|
|
51
|
+
message: Type.String({ maxLength: 512 }),
|
|
52
|
+
retryAfterMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
53
|
+
},
|
|
54
|
+
Strict,
|
|
55
|
+
);
|
|
56
|
+
export type TunnelAuthError = Static<typeof TunnelAuthError>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A backend SSE `Frame` ride-along. The Frame shape itself is owned by
|
|
60
|
+
* models/api; the tunnel validates it only as a JSON object with a known
|
|
61
|
+
* discriminator to avoid duplicating the full grammar here.
|
|
62
|
+
*/
|
|
63
|
+
const FramePayload = Type.Object({
|
|
64
|
+
type: Type.Union([
|
|
65
|
+
Type.Literal("hello"), Type.Literal("fault"), Type.Literal("tree"), Type.Literal("activity"),
|
|
66
|
+
Type.Literal("session"), Type.Literal("agents"), Type.Literal("tasks"), Type.Literal("usage"),
|
|
67
|
+
Type.Literal("snapshot"), Type.Literal("batch"), Type.Literal("inbox"), Type.Literal("doc"),
|
|
68
|
+
]),
|
|
69
|
+
}, { additionalProperties: true });
|
|
70
|
+
|
|
71
|
+
/** Hub mux control frames must never be accepted from a device, even after chunk reassembly. */
|
|
72
|
+
export function checkDeviceFrame(value: unknown): value is Exclude<Frame, { type: "network" | "disconnected" }> {
|
|
73
|
+
return Value.Check(FramePayload, value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const TunnelControl = Type.Union([
|
|
77
|
+
Type.Object({ type: Type.Literal("control"), action: Type.Literal("subscribe"), ...SubscriptionFields }, Strict),
|
|
78
|
+
Type.Object({ type: Type.Literal("control"), action: Type.Literal("unsubscribe"), ...SubscriptionFields }, Strict),
|
|
79
|
+
Type.Object({ type: Type.Literal("control"), action: Type.Literal("revoked"), notice: Type.Object({
|
|
80
|
+
type: Type.Literal("pair.revoke"), v: Type.Literal(1), deviceId: DeviceId, pairingEpoch: Uuid,
|
|
81
|
+
reason: Type.Union([Type.Literal("user"), Type.Literal("compromise"), Type.Literal("hub_migrated")]),
|
|
82
|
+
revokedAt: Type.Integer(),
|
|
83
|
+
}, Strict) }, Strict),
|
|
84
|
+
Type.Object(
|
|
85
|
+
{ type: Type.Literal("control"), action: Type.Literal("token-rotate"), tunnelToken: TunnelToken, ...RotationFields },
|
|
86
|
+
Strict,
|
|
87
|
+
),
|
|
88
|
+
Type.Object({ type: Type.Literal("control"), action: Type.Literal("token-rotate-ack"), ...RotationFields }, Strict),
|
|
89
|
+
Type.Object({
|
|
90
|
+
type: Type.Literal("control"), action: Type.Literal("unpair"),
|
|
91
|
+
pairingEpoch: Uuid, reason: Type.Literal("hub_migrated"),
|
|
92
|
+
}, Strict),
|
|
93
|
+
Type.Object({ type: Type.Literal("control"), action: Type.Literal("close"), reason: Type.String({ maxLength: 512 }) }, Strict),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
export const TunnelFrame = Type.Union([
|
|
97
|
+
Type.Object(
|
|
98
|
+
{
|
|
99
|
+
type: Type.Literal("pair-handshake"),
|
|
100
|
+
v: Type.Literal(1),
|
|
101
|
+
deviceUid: Uuid,
|
|
102
|
+
deviceId: DeviceId,
|
|
103
|
+
pairingEpoch: Uuid,
|
|
104
|
+
credentialId: Uuid,
|
|
105
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
106
|
+
apiVersion: Type.Literal(1),
|
|
107
|
+
tunnelProto: Type.Literal(1),
|
|
108
|
+
keyFingerprint: Fingerprint,
|
|
109
|
+
},
|
|
110
|
+
Strict,
|
|
111
|
+
),
|
|
112
|
+
Type.Object(
|
|
113
|
+
{
|
|
114
|
+
type: Type.Literal("hello"),
|
|
115
|
+
apiVersion: Type.Literal(1),
|
|
116
|
+
tunnelProto: Type.Literal(1),
|
|
117
|
+
hubId: Uuid,
|
|
118
|
+
connectionId: Uuid,
|
|
119
|
+
serverTime: Type.Integer(),
|
|
120
|
+
},
|
|
121
|
+
Strict,
|
|
122
|
+
),
|
|
123
|
+
Type.Object({ type: Type.Literal("tunnel-data"), frame: FramePayload, ...SubscriptionFields }, Strict),
|
|
124
|
+
Type.Object(
|
|
125
|
+
{
|
|
126
|
+
type: Type.Literal("rest-request"),
|
|
127
|
+
reqId: Uuid,
|
|
128
|
+
method: Type.Union([Type.Literal("GET"), Type.Literal("POST"), Type.Literal("PUT"), Type.Literal("PATCH"), Type.Literal("DELETE")]),
|
|
129
|
+
path: Type.String({ minLength: 1, maxLength: 2048 }),
|
|
130
|
+
idempotencyKey: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
131
|
+
body: Type.Optional(Type.Unknown()),
|
|
132
|
+
},
|
|
133
|
+
Strict,
|
|
134
|
+
),
|
|
135
|
+
Type.Object(
|
|
136
|
+
{
|
|
137
|
+
type: Type.Literal("rest-response"),
|
|
138
|
+
reqId: Uuid,
|
|
139
|
+
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
140
|
+
contentType: Type.Optional(Type.String({ maxLength: 256 })),
|
|
141
|
+
body: Type.Optional(Type.Unknown()),
|
|
142
|
+
},
|
|
143
|
+
Strict,
|
|
144
|
+
),
|
|
145
|
+
TunnelControl,
|
|
146
|
+
Type.Object(
|
|
147
|
+
{
|
|
148
|
+
type: Type.Literal("fault"),
|
|
149
|
+
error: Type.Object({ code: Type.String({ maxLength: 64 }), message: Type.String({ maxLength: 512 }) }, Strict),
|
|
150
|
+
subscriptionId: Type.Optional(Uuid),
|
|
151
|
+
reqId: Type.Optional(Uuid),
|
|
152
|
+
},
|
|
153
|
+
Strict,
|
|
154
|
+
),
|
|
155
|
+
]);
|
|
156
|
+
export type TunnelFrame = Static<typeof TunnelFrame>;
|
|
157
|
+
|
|
158
|
+
const ChunkBase = {
|
|
159
|
+
type: Type.Literal("tunnel-chunk"),
|
|
160
|
+
v: Type.Literal(1),
|
|
161
|
+
messageId: Uuid,
|
|
162
|
+
index: Type.Integer({ minimum: 0, maximum: MAX_CHUNKS_PER_MESSAGE - 1 }),
|
|
163
|
+
count: Type.Integer({ minimum: 1, maximum: MAX_CHUNKS_PER_MESSAGE }),
|
|
164
|
+
totalBytes: Type.Integer({ minimum: 1 }),
|
|
165
|
+
data: Type.String({ pattern: "^[A-Za-z0-9+/=]*$", minLength: 0 }),
|
|
166
|
+
} as const;
|
|
167
|
+
|
|
168
|
+
const TunnelChunk = Type.Union([
|
|
169
|
+
Type.Object({ ...ChunkBase, kind: Type.Literal("tunnel-data"), ...SubscriptionFields }, Strict),
|
|
170
|
+
Type.Object({ ...ChunkBase, kind: Type.Union([Type.Literal("rest-request"), Type.Literal("rest-response")]), reqId: Uuid }, Strict),
|
|
171
|
+
]);
|
|
172
|
+
export type TunnelChunk = Static<typeof TunnelChunk>;
|
|
173
|
+
|
|
174
|
+
export const TunnelWireMessage = Type.Union([TunnelFrame, TunnelChunk]);
|
|
175
|
+
export type TunnelWireMessage = Static<typeof TunnelWireMessage>;
|
|
176
|
+
|
|
177
|
+
export function checkTunnelMessage(value: unknown): value is TunnelWireMessage {
|
|
178
|
+
return Value.Check(TunnelWireMessage, value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Direction guards (04 §3): who may send what.
|
|
183
|
+
|
|
184
|
+
export type Direction = "hub->device" | "device->hub";
|
|
185
|
+
|
|
186
|
+
const HUB_TO_DEVICE = new Set([
|
|
187
|
+
"control:subscribe",
|
|
188
|
+
"control:unsubscribe",
|
|
189
|
+
"control:revoked",
|
|
190
|
+
"control:token-rotate",
|
|
191
|
+
"rest-request",
|
|
192
|
+
"hello",
|
|
193
|
+
"fault",
|
|
194
|
+
"control:close",
|
|
195
|
+
]);
|
|
196
|
+
const DEVICE_TO_HUB = new Set([
|
|
197
|
+
"pair-handshake",
|
|
198
|
+
"control:token-rotate-ack",
|
|
199
|
+
"control:unpair",
|
|
200
|
+
"tunnel-data",
|
|
201
|
+
"tunnel-chunk:tunnel-data",
|
|
202
|
+
"rest-response",
|
|
203
|
+
"tunnel-chunk:rest-response",
|
|
204
|
+
"tunnel-chunk:rest-request",
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
function messageKey(msg: { type: string; action?: string; kind?: string }): string {
|
|
208
|
+
if (msg.type === "control") return `control:${msg.action}`;
|
|
209
|
+
if (msg.type === "tunnel-chunk") return `tunnel-chunk:${msg.kind}`;
|
|
210
|
+
return msg.type;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Returns the allowed direction(s), or null if the message type is unknown. */
|
|
214
|
+
export function messageDirection(msg: { type: string; action?: string; kind?: string }): Direction[] {
|
|
215
|
+
const key = messageKey(msg);
|
|
216
|
+
const dirs: Direction[] = [];
|
|
217
|
+
if (HUB_TO_DEVICE.has(key)) dirs.push("hub->device");
|
|
218
|
+
if (DEVICE_TO_HUB.has(key)) dirs.push("device->hub");
|
|
219
|
+
return dirs;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function assertDirection(
|
|
223
|
+
msg: { type: string; action?: string; kind?: string },
|
|
224
|
+
dir: Direction,
|
|
225
|
+
): void {
|
|
226
|
+
const allowed = messageDirection(msg);
|
|
227
|
+
if (allowed.length === 0) {
|
|
228
|
+
throw new Error(`tunnel: unknown message type ${messageKey(msg)}`);
|
|
229
|
+
}
|
|
230
|
+
if (!allowed.includes(dir)) {
|
|
231
|
+
throw new Error(`tunnel: ${messageKey(msg)} is not allowed in direction ${dir}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Phase guards (04 §4): handshake must complete before data flows.
|
|
237
|
+
|
|
238
|
+
export type TunnelPhase = "awaiting-handshake" | "open" | "closed";
|
|
239
|
+
|
|
240
|
+
/** Only pair-handshake (device→hub) is valid before the handshake deadline. */
|
|
241
|
+
export function allowedInPhase(
|
|
242
|
+
msg: { type: string; action?: string; kind?: string },
|
|
243
|
+
phase: TunnelPhase,
|
|
244
|
+
): boolean {
|
|
245
|
+
if (phase === "closed") return false;
|
|
246
|
+
const key = messageKey(msg);
|
|
247
|
+
if (phase === "awaiting-handshake") return key === "pair-handshake";
|
|
248
|
+
return key !== "pair-handshake"; // hello is hub→hub-transition; data only after open
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// REST path/method validation for tunneled requests (04 §9).
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The connector passes `path` to fetch() unchanged, and Express then matches it case-insensitively. fetch()'s URL
|
|
256
|
+
* parser drops tab/CR/LF, trims edge spaces, reads "\" as "/", resolves "." and "%2e" segments and cuts a
|
|
257
|
+
* "#fragment". So a spelling that the parsers could read differently from this check is refused, not repaired:
|
|
258
|
+
* the decision is made on exactly the bytes that will be requested, and only a route the device serves to a
|
|
259
|
+
* remote caller (rest-routes.ts) with one of its methods passes.
|
|
260
|
+
*/
|
|
261
|
+
export function validateRestRequest(input: { method: string; path: string }): { ok: true } | { ok: false; error: string } {
|
|
262
|
+
const refuse = (error: string) => ({ ok: false as const, error });
|
|
263
|
+
if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(input.method)) return refuse("rest: method not allowed");
|
|
264
|
+
const raw = input.path;
|
|
265
|
+
if (raw.includes("..") || raw.includes("\\") || raw.includes("://") || raw.startsWith("//")) {
|
|
266
|
+
return refuse("rest: path traversal or absolute URL rejected");
|
|
267
|
+
}
|
|
268
|
+
let decoded: string;
|
|
269
|
+
try {
|
|
270
|
+
decoded = decodeURIComponent(raw);
|
|
271
|
+
} catch {
|
|
272
|
+
return refuse("rest: invalid percent-encoding");
|
|
273
|
+
}
|
|
274
|
+
if (decoded.includes("..") || decoded.includes("\\") || decoded.includes("://") || decoded.startsWith("//")) {
|
|
275
|
+
return refuse("rest: encoded traversal rejected");
|
|
276
|
+
}
|
|
277
|
+
if (!raw.startsWith(REST_PATH_PREFIX)) return refuse("rest: path outside /v1 rejected");
|
|
278
|
+
if (!/^[\x21-\x7e]+$/.test(raw) || raw.includes("#")) {
|
|
279
|
+
return refuse("rest: path must be printable ASCII without a fragment (percent-encode everything else)");
|
|
280
|
+
}
|
|
281
|
+
const query = raw.indexOf("?");
|
|
282
|
+
const pathname = query < 0 ? raw : raw.slice(0, query);
|
|
283
|
+
if (pathname.includes(";")) return refuse("rest: path parameters are not accepted");
|
|
284
|
+
const segments = pathname.slice(REST_PATH_PREFIX.length).split("/");
|
|
285
|
+
if (segments.some((segment) => segment === "" || segment.toLowerCase() === "%2e" || segment === ".")) {
|
|
286
|
+
return refuse("rest: path is not canonical (empty or dot segment)");
|
|
287
|
+
}
|
|
288
|
+
if (matchesRestRoute(input.method, segments)) return { ok: true };
|
|
289
|
+
const kind = restRouteKind(segments);
|
|
290
|
+
return refuse(
|
|
291
|
+
kind === "stream"
|
|
292
|
+
? "rest: SSE endpoint must not be tunneled"
|
|
293
|
+
: kind === "local"
|
|
294
|
+
? "rest: device-local control route must not be tunneled"
|
|
295
|
+
: "rest: route is not allowlisted",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Chunk envelope validation (04 §3.3).
|
|
301
|
+
|
|
302
|
+
export function validateChunkEnvelope(chunk: {
|
|
303
|
+
index: number;
|
|
304
|
+
count: number;
|
|
305
|
+
totalBytes: number;
|
|
306
|
+
data: string;
|
|
307
|
+
}): { ok: true; remainder: number } | { ok: false; error: string } {
|
|
308
|
+
const expectedCount = Math.ceil(chunk.totalBytes / CHUNK_RAW_BYTES);
|
|
309
|
+
if (chunk.count !== expectedCount) {
|
|
310
|
+
return { ok: false, error: "chunk: count/totalBytes mismatch" };
|
|
311
|
+
}
|
|
312
|
+
if (chunk.index >= chunk.count) {
|
|
313
|
+
return { ok: false, error: "chunk: index out of range" };
|
|
314
|
+
}
|
|
315
|
+
const decodedLength = base64DecodedLength(chunk.data);
|
|
316
|
+
if (decodedLength < 0) return { ok: false, error: "chunk: invalid base64" };
|
|
317
|
+
const expected = chunk.index === chunk.count - 1
|
|
318
|
+
? chunk.totalBytes - CHUNK_RAW_BYTES * (chunk.count - 1)
|
|
319
|
+
: CHUNK_RAW_BYTES;
|
|
320
|
+
if (decodedLength !== expected) {
|
|
321
|
+
return { ok: false, error: `chunk: size mismatch at index ${chunk.index}` };
|
|
322
|
+
}
|
|
323
|
+
return { ok: true, remainder: decodedLength };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function base64DecodedLength(data: string): number {
|
|
327
|
+
if (!/^[A-Za-z0-9+/=]*$/.test(data)) return -1;
|
|
328
|
+
if (data.length % 4 !== 0) return -1;
|
|
329
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
330
|
+
return (data.length / 4) * 3 - padding;
|
|
331
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { DpiError } from "./errors.js";
|
|
2
|
+
import { LOGICAL_MESSAGE_MAX_BYTES } from "./protocol/limits.js";
|
|
3
|
+
|
|
4
|
+
export interface SseMessage {
|
|
5
|
+
event: string;
|
|
6
|
+
data: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** UTF-8 size of a string without allocating its encoding (this module also runs in browsers). */
|
|
11
|
+
function utf8Length(text: string): number {
|
|
12
|
+
let bytes = 0;
|
|
13
|
+
for (let i = 0; i < text.length; i++) {
|
|
14
|
+
const unit = text.charCodeAt(i);
|
|
15
|
+
if (unit < 0x80) bytes += 1;
|
|
16
|
+
else if (unit < 0x800) bytes += 2;
|
|
17
|
+
else if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < text.length && (text.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
|
|
18
|
+
bytes += 4;
|
|
19
|
+
i++;
|
|
20
|
+
} else bytes += 3;
|
|
21
|
+
}
|
|
22
|
+
return bytes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a Server-Sent Events body from fetch. Servers send a `: ping` comment every 15s, so
|
|
27
|
+
* `idleMs` of silence means a half-open socket and ends the stream with an `offline` error.
|
|
28
|
+
*
|
|
29
|
+
* One event may not exceed `maxEventBytes` (default: the 16 MiB logical message cap, 04 §3.3). The
|
|
30
|
+
* check runs on the bytes read so far, before any framing or JSON parsing, so a peer that never
|
|
31
|
+
* terminates an event cannot make this reader buffer without bound; the source stream is cancelled
|
|
32
|
+
* and a non-retryable `invalid` error ends the reader.
|
|
33
|
+
*/
|
|
34
|
+
export async function* readSse(
|
|
35
|
+
body: ReadableStream<Uint8Array>,
|
|
36
|
+
idleMs = 45_000,
|
|
37
|
+
maxEventBytes = LOGICAL_MESSAGE_MAX_BYTES,
|
|
38
|
+
): AsyncGenerator<SseMessage> {
|
|
39
|
+
const reader = body.getReader();
|
|
40
|
+
const decoder = new TextDecoder();
|
|
41
|
+
const tooLarge = () => new DpiError("invalid", `SSE event exceeds ${maxEventBytes} bytes`);
|
|
42
|
+
// Text read since the last delimiter. Pieces are only joined once a delimiter shows up, so a
|
|
43
|
+
// long event costs one copy instead of one per read.
|
|
44
|
+
let pending: string[] = [];
|
|
45
|
+
let pendingBytes = 0;
|
|
46
|
+
let lastChar = "";
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
+
const idle = new Promise<never>((_, reject) => {
|
|
51
|
+
timer = setTimeout(
|
|
52
|
+
() => reject(new DpiError("offline", `No data for ${idleMs / 1000}s`)),
|
|
53
|
+
idleMs,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
let chunk: ReadableStreamReadResult<Uint8Array>;
|
|
57
|
+
try {
|
|
58
|
+
chunk = await Promise.race([reader.read(), idle]);
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
if (chunk.done) return;
|
|
63
|
+
const text = decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n");
|
|
64
|
+
if (!text) continue;
|
|
65
|
+
// A CRLF split across two reads: the previous read ended with the CR of a pair whose LF
|
|
66
|
+
// starts this one — coalesce that too, or the CR leaks into the last field value.
|
|
67
|
+
if (lastChar === "\r" && text.startsWith("\n")) {
|
|
68
|
+
const last = pending.pop();
|
|
69
|
+
if (last !== undefined) {
|
|
70
|
+
const trimmed = last.slice(0, -1); // drop the dangling CR; the LF below completes the pair
|
|
71
|
+
if (trimmed) pending.push(trimmed);
|
|
72
|
+
pendingBytes -= 1;
|
|
73
|
+
lastChar = pending.length > 0 ? pending[pending.length - 1].slice(-1) : "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
pendingBytes += utf8Length(text);
|
|
77
|
+
if (!(lastChar + text).includes("\n\n")) {
|
|
78
|
+
if (pendingBytes > maxEventBytes) throw tooLarge();
|
|
79
|
+
pending.push(text);
|
|
80
|
+
lastChar = text.slice(-1);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
let buffer = pending.join("") + text;
|
|
84
|
+
let cut: number;
|
|
85
|
+
while ((cut = buffer.indexOf("\n\n")) >= 0) {
|
|
86
|
+
const block = buffer.slice(0, cut);
|
|
87
|
+
const blockBytes = utf8Length(block);
|
|
88
|
+
if (blockBytes > maxEventBytes) throw tooLarge();
|
|
89
|
+
buffer = buffer.slice(cut + 2);
|
|
90
|
+
pendingBytes -= blockBytes + 2;
|
|
91
|
+
const message = parseBlock(block);
|
|
92
|
+
if (message) yield message;
|
|
93
|
+
}
|
|
94
|
+
if (pendingBytes > maxEventBytes) throw tooLarge();
|
|
95
|
+
pending = buffer ? [buffer] : [];
|
|
96
|
+
lastChar = buffer.slice(-1);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
reader.cancel().catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseBlock(block: string): SseMessage | undefined {
|
|
104
|
+
let event = "message";
|
|
105
|
+
let id: string | undefined;
|
|
106
|
+
const data: string[] = [];
|
|
107
|
+
for (const line of block.split("\n")) {
|
|
108
|
+
if (!line || line.startsWith(":")) continue;
|
|
109
|
+
const colon = line.indexOf(":");
|
|
110
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
111
|
+
const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
112
|
+
if (field === "event") event = value;
|
|
113
|
+
else if (field === "data") data.push(value);
|
|
114
|
+
else if (field === "id") id = value;
|
|
115
|
+
}
|
|
116
|
+
return data.length ? { event, data: data.join("\n"), id } : undefined;
|
|
117
|
+
}
|