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,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-side pairing service (spec 03 §4.1/§7, plan P2-11).
|
|
3
|
+
*
|
|
4
|
+
* The backend is the SINGLE writer of device-keypair.json / pairing.json; the
|
|
5
|
+
* TUI only drives this service over the authenticated local API. Every file
|
|
6
|
+
* mutation is serialized through one active operation ID — late async
|
|
7
|
+
* responses re-check it before committing so a stale turn can never
|
|
8
|
+
* overwrite a newer pairing.
|
|
9
|
+
*
|
|
10
|
+
* Origin resolution order (03 §4.1): pair URL → --hub → DPI_HUB_URL → saved
|
|
11
|
+
* origin. Multiple EXPLICIT differing origins → error before any network.
|
|
12
|
+
* HTTPS-only canonical origin (protocol/pairing.ts); redirects refused.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign as edSign } from "node:crypto";
|
|
15
|
+
import type { Store } from "./store.js";
|
|
16
|
+
import {
|
|
17
|
+
ensureFileMode0600,
|
|
18
|
+
ensurePrivateDir,
|
|
19
|
+
keypairPath,
|
|
20
|
+
loadJson,
|
|
21
|
+
pairingPath,
|
|
22
|
+
readKeypairFile,
|
|
23
|
+
removeStaleTemps,
|
|
24
|
+
writeAtomic,
|
|
25
|
+
type DeviceKeypair,
|
|
26
|
+
type PairingFile,
|
|
27
|
+
} from "./pairing-files.js";
|
|
28
|
+
import { PAIR_HTTP_MAX_RETRIES, PAIR_HTTP_TIMEOUT_MS, PAIR_POLL_MS } from "../../dpi-sdk/src/protocol/limits.js";
|
|
29
|
+
import { pairingSigningPayload, parseHubOrigin } from "../../dpi-sdk/src/protocol/pairing.js";
|
|
30
|
+
import type { TokenIssue } from "../../dpi-sdk/src/protocol/pairing.js";
|
|
31
|
+
|
|
32
|
+
const CLOCK_SKEW_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
export interface PairingStartInput {
|
|
35
|
+
code: string;
|
|
36
|
+
hub?: string;
|
|
37
|
+
pairUrl?: string;
|
|
38
|
+
force?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PairingStatus = {
|
|
42
|
+
state: "unpaired" | "pairing" | "paired" | "revoked";
|
|
43
|
+
deviceUid: string;
|
|
44
|
+
deviceName: string;
|
|
45
|
+
fingerprint: string; // from the LOCAL key — never echoed from the hub
|
|
46
|
+
hubOrigin?: string;
|
|
47
|
+
deviceId?: string;
|
|
48
|
+
pairingEpoch?: string;
|
|
49
|
+
generation?: number;
|
|
50
|
+
pending?: { hubOrigin: string; startedAt: number; note?: string };
|
|
51
|
+
lastError?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
interface PendingOp {
|
|
55
|
+
operationId: string;
|
|
56
|
+
hubOrigin: string;
|
|
57
|
+
code: string;
|
|
58
|
+
requestId: string;
|
|
59
|
+
deviceUid: string;
|
|
60
|
+
deviceName: string;
|
|
61
|
+
keyFingerprint: string;
|
|
62
|
+
proof: { type: "pair.response"; v: 1; enrollmentId: string; nonce: string; signature: string };
|
|
63
|
+
hubId: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
startedAt: number;
|
|
66
|
+
abort: AbortController;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code, status: 400 });
|
|
70
|
+
|
|
71
|
+
const isLoopbackHost = (hostname: string) => hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
72
|
+
const nonEmpty = (v: unknown, max: number): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
73
|
+
|
|
74
|
+
/** Why a hub-issued credential must not be saved, or undefined when it may be. The token leaves this device for hubUrl, so that is judged hardest. */
|
|
75
|
+
function refuseIssue(issue: TokenIssue, expect: { origin: string; hubId: string; enrollmentId: string; deviceUid: string; now: number }): string | undefined {
|
|
76
|
+
const raw = issue as unknown as Record<string, unknown>;
|
|
77
|
+
if (raw.enrollmentId !== expect.enrollmentId) return "enrollmentId differs from the enrollment this device started";
|
|
78
|
+
if (raw.deviceUid !== expect.deviceUid) return "deviceUid is not this device's";
|
|
79
|
+
if (raw.hubId !== expect.hubId) return "hubId differs from the hubId the challenge announced";
|
|
80
|
+
if (raw.hubOrigin !== expect.origin) return `hubOrigin differs from the origin paired against (${expect.origin})`;
|
|
81
|
+
const urlProblem = refuseTunnelUrl(raw.hubUrl, expect.origin);
|
|
82
|
+
if (urlProblem) return urlProblem;
|
|
83
|
+
if (typeof raw.issuedAt !== "number" || !Number.isFinite(raw.issuedAt)) return "issuedAt is not a finite number";
|
|
84
|
+
if (Math.abs(expect.now - raw.issuedAt) > CLOCK_SKEW_MS) return `issuedAt is more than ${CLOCK_SKEW_MS / 1000}s from this device's clock`;
|
|
85
|
+
if (!nonEmpty(raw.deviceId, 64)) return "deviceId is empty or not a string";
|
|
86
|
+
if (!nonEmpty(raw.pairingEpoch, 128)) return "pairingEpoch is empty or not a string";
|
|
87
|
+
if (!nonEmpty(raw.credentialId, 128)) return "credentialId is empty or not a string";
|
|
88
|
+
if (typeof raw.generation !== "number" || !Number.isInteger(raw.generation) || raw.generation < 1) return "generation is not a positive integer";
|
|
89
|
+
if (typeof raw.tunnelToken !== "string" || !/^dpt_[A-Za-z0-9_-]{43}$/.test(raw.tunnelToken)) return "tunnelToken is malformed";
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The tunnel is the paired origin's own /api/tunnel, over TLS (https is what the hub issues; ws clients upgrade it to wss). Plaintext only to this machine. */
|
|
94
|
+
function refuseTunnelUrl(raw: unknown, origin: string): string | undefined {
|
|
95
|
+
if (typeof raw !== "string") return "hubUrl is not a string";
|
|
96
|
+
let url: URL;
|
|
97
|
+
try {
|
|
98
|
+
url = new URL(raw);
|
|
99
|
+
} catch {
|
|
100
|
+
return "hubUrl is not a valid URL";
|
|
101
|
+
}
|
|
102
|
+
const tls = url.protocol === "wss:" || url.protocol === "https:";
|
|
103
|
+
const plain = url.protocol === "ws:" || url.protocol === "http:";
|
|
104
|
+
if (!tls && !(plain && isLoopbackHost(url.hostname))) return `hubUrl scheme ${url.protocol} is not allowed (wss or https; ws or http only on a loopback host)`;
|
|
105
|
+
if (url.username || url.password) return "hubUrl must not carry credentials";
|
|
106
|
+
if (url.host !== new URL(origin).host) return `hubUrl host differs from the origin paired against (${origin})`;
|
|
107
|
+
if (url.pathname !== "/api/tunnel" || url.search || url.hash) return "hubUrl must be exactly the origin's /api/tunnel";
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class DevicePairing {
|
|
112
|
+
private pending: PendingOp | undefined;
|
|
113
|
+
private keypair: DeviceKeypair | undefined;
|
|
114
|
+
private paired: PairingFile | undefined;
|
|
115
|
+
private revokedNotice = false;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly store: Store,
|
|
119
|
+
private readonly home: string,
|
|
120
|
+
private readonly deviceName: string,
|
|
121
|
+
private readonly envHubUrl: string | undefined,
|
|
122
|
+
private readonly hooks: { now?: () => number; fetchImpl?: typeof fetch; stopOldConnector?: (origin: string) => void; onPaired?: () => void; pollMs?: number } = {},
|
|
123
|
+
) {
|
|
124
|
+
ensurePrivateDir(home);
|
|
125
|
+
removeStaleTemps(home);
|
|
126
|
+
this.loadKeypair();
|
|
127
|
+
this.paired = loadJson<PairingFile>(pairingPath(home));
|
|
128
|
+
if (this.paired) ensureFileMode0600(pairingPath(home));
|
|
129
|
+
if (this.paired) ensurePrivateDir(home);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private get now() {
|
|
133
|
+
return this.hooks.now ?? Date.now;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private loadKeypair(): void {
|
|
137
|
+
const path = keypairPath(this.home);
|
|
138
|
+
const loaded = readKeypairFile(path);
|
|
139
|
+
if (loaded) {
|
|
140
|
+
this.keypair = loaded;
|
|
141
|
+
ensureFileMode0600(path);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// First boot only: a file that exists but cannot be used throws above instead of being replaced.
|
|
145
|
+
const kp = generateKeyPairSync("ed25519");
|
|
146
|
+
const der = kp.publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
147
|
+
this.keypair = {
|
|
148
|
+
fingerprint: `sha256:${createHash("sha256").update(der).digest("hex")}`,
|
|
149
|
+
publicKeyPem: kp.publicKey.export({ type: "spki", format: "pem" }) as string,
|
|
150
|
+
privateKeyPem: kp.privateKey.export({ type: "pkcs8", format: "pem" }) as string,
|
|
151
|
+
};
|
|
152
|
+
writeAtomic(path, JSON.stringify(this.keypair, null, 2));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
status(): PairingStatus {
|
|
156
|
+
const base: PairingStatus = {
|
|
157
|
+
state: this.pending ? "pairing" : this.revokedNotice ? "revoked" : this.paired ? "paired" : "unpaired",
|
|
158
|
+
deviceUid: this.store.uid,
|
|
159
|
+
deviceName: this.deviceName,
|
|
160
|
+
fingerprint: this.keypair!.fingerprint,
|
|
161
|
+
...(this.paired
|
|
162
|
+
? {
|
|
163
|
+
hubOrigin: this.paired.hubOrigin,
|
|
164
|
+
deviceId: this.paired.deviceId,
|
|
165
|
+
pairingEpoch: this.paired.pairingEpoch,
|
|
166
|
+
generation: this.paired.generation,
|
|
167
|
+
}
|
|
168
|
+
: {}),
|
|
169
|
+
...(this.pending
|
|
170
|
+
? { pending: { hubOrigin: this.pending.hubOrigin, startedAt: this.pending.startedAt, note: "polling for approval; cancel stops the local operation only" } }
|
|
171
|
+
: {}),
|
|
172
|
+
...(this.lastFailure ? { lastError: this.lastFailure } : {}),
|
|
173
|
+
};
|
|
174
|
+
return base;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Resolve the target origin per 03 §4.1 — BEFORE any network call. */
|
|
178
|
+
private resolveOrigin(input: PairingStartInput): { origin: string; code: string } {
|
|
179
|
+
let pairUrlOrigin: string | undefined;
|
|
180
|
+
let code = input.code ?? "";
|
|
181
|
+
if (input.pairUrl) {
|
|
182
|
+
const parsed = this.codeFromPairUrl(input.pairUrl);
|
|
183
|
+
if (!parsed) throw err("invalid", "pairUrl must look like https://<hub>/pair?code=<CODE>&v=1");
|
|
184
|
+
code = parsed;
|
|
185
|
+
const o = this.originOf(input.pairUrl);
|
|
186
|
+
if (!o.ok) throw err("invalid", o.error);
|
|
187
|
+
pairUrlOrigin = o.origin;
|
|
188
|
+
}
|
|
189
|
+
// Precedence: pair URL → explicit --hub → DPI_HUB_URL → saved origin. Every
|
|
190
|
+
// EXPLICIT source must be a valid HTTPS origin (fail on the bad one), then
|
|
191
|
+
// differing explicit origins are a conflict.
|
|
192
|
+
const explicitSources = [pairUrlOrigin, input.hub, this.envHubUrl].filter((x): x is string => !!x);
|
|
193
|
+
const explicitOrigins = explicitSources.map((raw) => {
|
|
194
|
+
const o = this.originOf(raw);
|
|
195
|
+
if (!o.ok) throw err("invalid", o.error);
|
|
196
|
+
return o.origin;
|
|
197
|
+
});
|
|
198
|
+
if (new Set(explicitOrigins).size > 1)
|
|
199
|
+
throw err("invalid", `Multiple explicit hub origins conflict: ${[...new Set(explicitOrigins)].join(", ")}; pass exactly one`);
|
|
200
|
+
const origin = explicitOrigins[0] ?? this.paired?.hubOrigin;
|
|
201
|
+
if (!origin)
|
|
202
|
+
throw err("hub_required", "A pairing code alone is not enough: provide --hub, DPI_HUB_URL, a pair URL, or a prior saved hub");
|
|
203
|
+
const o = this.originOf(origin);
|
|
204
|
+
if (!o.ok) throw err("invalid", o.error);
|
|
205
|
+
if (!/^[A-Z2-7]{8}$/.test(code)) throw err("invalid", "Pairing code must be 8 base32 characters (A-Z2-7)");
|
|
206
|
+
return { origin: o.origin, code };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private codeFromPairUrl(url: string): string | undefined {
|
|
210
|
+
try {
|
|
211
|
+
const u = new URL(url);
|
|
212
|
+
const code = u.searchParams.get("code");
|
|
213
|
+
if (!/^\/pair\/?$/.test(u.pathname) || u.username || u.password || u.hash
|
|
214
|
+
|| u.searchParams.getAll("code").length !== 1
|
|
215
|
+
|| u.searchParams.getAll("v").length !== 1 || u.searchParams.get("v") !== "1") return undefined;
|
|
216
|
+
if (u.searchParams.has("origin")) {
|
|
217
|
+
const origin = new URL(u.searchParams.get("origin")!);
|
|
218
|
+
if (u.searchParams.getAll("origin").length !== 1 || origin.origin !== u.origin || origin.pathname !== "/"
|
|
219
|
+
|| origin.username || origin.password || origin.search || origin.hash) return undefined;
|
|
220
|
+
}
|
|
221
|
+
return code && /^[A-Z2-7]{8}$/.test(code) ? code : undefined;
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private originOf(raw: string): { ok: true; origin: string } | { ok: false; error: string } {
|
|
228
|
+
// Accept bare origins and full pair URLs; canonicalize via the shared validator.
|
|
229
|
+
const candidate = raw.includes("/pair?") || raw.includes("/pair/") ? new URL(raw).origin : raw;
|
|
230
|
+
const parsed = parseHubOrigin(candidate);
|
|
231
|
+
return parsed.ok ? { ok: true, origin: parsed.origin } : { ok: false, error: parsed.error };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** POST /v1/pairing/start — kick off (or restart) pairing. */
|
|
235
|
+
async start(input: PairingStartInput): Promise<PairingStatus> {
|
|
236
|
+
const { origin, code } = this.resolveOrigin(input);
|
|
237
|
+
|
|
238
|
+
if (this.pending) throw err("busy", "A pairing operation is already running; cancel it first");
|
|
239
|
+
// A revoked pairing no longer binds the device to its hub.
|
|
240
|
+
const incumbent = this.revokedNotice ? undefined : this.paired;
|
|
241
|
+
if (incumbent && incumbent.hubOrigin !== origin && !input.force)
|
|
242
|
+
throw err(
|
|
243
|
+
"invalid",
|
|
244
|
+
`Already paired with ${incumbent.hubOrigin}; re-pairing another hub needs force (and revokes this pairing only after the new one succeeds)`,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const operationId = randomUUID();
|
|
248
|
+
this.lastFailure = undefined;
|
|
249
|
+
const keypair = this.keypair!;
|
|
250
|
+
const requestId = randomUUID();
|
|
251
|
+
const deviceUid = this.store.uid;
|
|
252
|
+
|
|
253
|
+
const challenge = (await this.hubFetch(origin, "/api/pair/start", {
|
|
254
|
+
type: "pair.request",
|
|
255
|
+
v: 1,
|
|
256
|
+
requestId,
|
|
257
|
+
code,
|
|
258
|
+
deviceUid,
|
|
259
|
+
deviceName: this.deviceName,
|
|
260
|
+
publicKey: keypair.publicKeyPem,
|
|
261
|
+
keyFingerprint: keypair.fingerprint,
|
|
262
|
+
clientTime: this.now(),
|
|
263
|
+
})) as { type: string; enrollmentId: string; nonce: string; issuedAt: number; hubId: string };
|
|
264
|
+
if (challenge.type !== "pair.challenge") throw err("invalid", `Unexpected hub response: ${JSON.stringify(challenge).slice(0, 200)}`);
|
|
265
|
+
if (typeof challenge.issuedAt !== "number" || !Number.isFinite(challenge.issuedAt)) throw err("invalid", "Challenge issuedAt is not a finite number");
|
|
266
|
+
if (!nonEmpty(challenge.hubId, 64) || !nonEmpty(challenge.enrollmentId, 64)) throw err("invalid", "Challenge is missing hubId or enrollmentId");
|
|
267
|
+
// P-4: device rejects a challenge skewed more than ±60s from its clock.
|
|
268
|
+
if (Math.abs(this.now() - challenge.issuedAt) > CLOCK_SKEW_MS)
|
|
269
|
+
throw err("clock_skew", `Hub clock differs by more than ${CLOCK_SKEW_MS / 1000}s; fix NTP and retry`);
|
|
270
|
+
|
|
271
|
+
const payload = pairingSigningPayload({
|
|
272
|
+
hubOrigin: origin,
|
|
273
|
+
hubId: challenge.hubId,
|
|
274
|
+
enrollmentId: challenge.enrollmentId,
|
|
275
|
+
nonce: challenge.nonce,
|
|
276
|
+
issuedAt: challenge.issuedAt,
|
|
277
|
+
deviceUid,
|
|
278
|
+
keyFingerprint: keypair.fingerprint,
|
|
279
|
+
});
|
|
280
|
+
const signature = edSign(null, Buffer.from(payload), createPrivateKey(keypair.privateKeyPem)).toString("base64url");
|
|
281
|
+
const proof = { type: "pair.response" as const, v: 1 as const, enrollmentId: challenge.enrollmentId, nonce: challenge.nonce, signature };
|
|
282
|
+
|
|
283
|
+
const pending: PendingOp = {
|
|
284
|
+
operationId,
|
|
285
|
+
hubOrigin: origin,
|
|
286
|
+
code,
|
|
287
|
+
requestId,
|
|
288
|
+
deviceUid,
|
|
289
|
+
deviceName: this.deviceName,
|
|
290
|
+
keyFingerprint: keypair.fingerprint,
|
|
291
|
+
proof,
|
|
292
|
+
hubId: challenge.hubId,
|
|
293
|
+
expiresAt: this.now() + 300_000,
|
|
294
|
+
startedAt: this.now(),
|
|
295
|
+
abort: new AbortController(),
|
|
296
|
+
};
|
|
297
|
+
this.pending = pending;
|
|
298
|
+
void this.pollLoop(pending).catch(() => undefined);
|
|
299
|
+
return this.status();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Poll the same signed proof until approval issues a TokenIssue (or expiry). */
|
|
303
|
+
private async pollLoop(pending: PendingOp): Promise<void> {
|
|
304
|
+
const pollMs = this.hooks.pollMs ?? PAIR_POLL_MS;
|
|
305
|
+
while (!pending.abort.signal.aborted && this.now() < pending.expiresAt && this.pending?.operationId === pending.operationId) {
|
|
306
|
+
const outcome = await this.hubFetchRaw(pending.hubOrigin, "/api/pair/verify", pending.proof, pending.abort.signal).catch(() => null);
|
|
307
|
+
if (this.pending?.operationId !== pending.operationId) return; // superseded
|
|
308
|
+
if (!outcome) {
|
|
309
|
+
await sleep(pollMs * 2, pending.abort.signal);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (outcome.status === 200 && outcome.body && (outcome.body as TokenIssue).type === "pair.token") {
|
|
313
|
+
const issue = outcome.body as TokenIssue;
|
|
314
|
+
const refusal = refuseIssue(issue, { origin: pending.hubOrigin, hubId: pending.hubId, enrollmentId: pending.proof.enrollmentId, deviceUid: pending.deviceUid, now: this.now() });
|
|
315
|
+
if (refusal) {
|
|
316
|
+
this.pending = undefined;
|
|
317
|
+
this.lastFailure = `invalid_token_issue: ${refusal}; the credential was not saved`;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
await this.commitTokenIssue(pending, issue);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (outcome.status === 202) {
|
|
324
|
+
await sleep(pollMs, pending.abort.signal);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const code = (outcome.body as { error?: { code?: string; message?: string } })?.error?.code ?? "invalid";
|
|
328
|
+
const message = (outcome.body as { error?: { message?: string } })?.error?.message ?? "pairing failed";
|
|
329
|
+
if (["code_expired", "enrollment_cancelled", "enrollment_lost", "challenge_expired"].includes(code)) {
|
|
330
|
+
this.pending = undefined;
|
|
331
|
+
this.lastFailure = `${code}: ${message}`;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
await sleep(pollMs * 2, pending.abort.signal);
|
|
335
|
+
}
|
|
336
|
+
if (this.pending?.operationId === pending.operationId) {
|
|
337
|
+
this.pending = undefined;
|
|
338
|
+
this.lastFailure = "code_expired: Pairing timed out; mint a fresh code and retry";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private lastFailure: string | undefined;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Persist pairing.json atomically, then flip to paired. The credential is
|
|
346
|
+
* never used or ACKed before the fsync chain completes, and a late response
|
|
347
|
+
* re-checks the operation ID + generation right before committing.
|
|
348
|
+
*/
|
|
349
|
+
private async commitTokenIssue(pending: PendingOp, issue: TokenIssue): Promise<void> {
|
|
350
|
+
const file: PairingFile = {
|
|
351
|
+
version: 1,
|
|
352
|
+
deviceUid: issue.deviceUid,
|
|
353
|
+
deviceId: issue.deviceId,
|
|
354
|
+
pairingEpoch: issue.pairingEpoch,
|
|
355
|
+
credentialId: issue.credentialId,
|
|
356
|
+
generation: issue.generation,
|
|
357
|
+
hubOrigin: issue.hubOrigin,
|
|
358
|
+
hubUrl: issue.hubUrl,
|
|
359
|
+
hubId: issue.hubId,
|
|
360
|
+
tunnelToken: issue.tunnelToken,
|
|
361
|
+
updatedAt: this.now(),
|
|
362
|
+
operationId: pending.operationId,
|
|
363
|
+
};
|
|
364
|
+
writeAtomic(pairingPath(this.home), JSON.stringify(file, null, 2));
|
|
365
|
+
// Re-check: a newer operation/pairing must not be clobbered by this commit.
|
|
366
|
+
if (this.pending?.operationId !== pending.operationId) return;
|
|
367
|
+
if (this.paired && this.paired.updatedAt > file.updatedAt) return; // never happens with sync writes, belt-and-braces
|
|
368
|
+
const previous = this.revokedNotice ? undefined : this.paired;
|
|
369
|
+
this.paired = file;
|
|
370
|
+
this.pending = undefined;
|
|
371
|
+
this.lastFailure = undefined;
|
|
372
|
+
this.revokedNotice = false;
|
|
373
|
+
// The old hub keeps its tunnel and its pairing right up to here: only a persisted replacement ends them.
|
|
374
|
+
if (previous && previous.hubOrigin !== file.hubOrigin) {
|
|
375
|
+
try {
|
|
376
|
+
this.hooks.stopOldConnector?.(previous.hubOrigin);
|
|
377
|
+
} catch {
|
|
378
|
+
/* hook errors never fail the pairing commit */
|
|
379
|
+
}
|
|
380
|
+
void this.bestEffortUnpair(previous).catch(() => undefined);
|
|
381
|
+
}
|
|
382
|
+
// Credential file is durable: the same-daemon connector may start now.
|
|
383
|
+
try {
|
|
384
|
+
this.hooks.onPaired?.();
|
|
385
|
+
} catch {
|
|
386
|
+
/* hook errors never fail the pairing commit */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** POST /v1/pairing/cancel — local abort only; never claims a remote cancel. */
|
|
391
|
+
cancel(): PairingStatus {
|
|
392
|
+
if (!this.pending) throw err("invalid", "No pairing operation running");
|
|
393
|
+
const target = this.pending.hubOrigin;
|
|
394
|
+
this.pending.abort.abort();
|
|
395
|
+
this.pending = undefined;
|
|
396
|
+
this.lastFailure = `cancelled locally against ${target}; the code may still be live on the hub until it expires — if issuance may have happened, check/revoke on the hub`;
|
|
397
|
+
return this.status();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The connector learned the hub revoked this pairing (and removed the file). A notice for another epoch is about a pairing we no longer hold. */
|
|
401
|
+
markRevoked(reason: string, pairingEpoch?: string): void {
|
|
402
|
+
if (pairingEpoch !== undefined && this.paired && this.paired.pairingEpoch !== pairingEpoch) return;
|
|
403
|
+
this.revokedNotice = true;
|
|
404
|
+
this.lastFailure = reason;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The connector persisted a rotated credential for this pairing; only a strictly newer generation of the same epoch counts. */
|
|
408
|
+
markRotated(file: PairingFile): void {
|
|
409
|
+
if (!this.paired || this.revokedNotice) return;
|
|
410
|
+
if (file.pairingEpoch !== this.paired.pairingEpoch || file.generation <= this.paired.generation) return;
|
|
411
|
+
this.paired = file;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private async bestEffortUnpair(paired: PairingFile): Promise<void> {
|
|
415
|
+
const ctl = new AbortController();
|
|
416
|
+
setTimeout(() => ctl.abort(), 2_000).unref?.();
|
|
417
|
+
await this.hubFetchRaw(paired.hubOrigin, "/api/tunnel", { type: "control", action: "unpair", pairingEpoch: paired.pairingEpoch, reason: "hub_migrated" }, ctl.signal).catch(() => undefined);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async hubFetch<T = unknown>(origin: string, path: string, body: unknown): Promise<T> {
|
|
421
|
+
for (let attempt = 0; attempt < PAIR_HTTP_MAX_RETRIES; attempt++) {
|
|
422
|
+
const outcome = await this.hubFetchRaw(origin, path, body).catch(() => null);
|
|
423
|
+
if (outcome && outcome.status < 500 && outcome.status !== 429) {
|
|
424
|
+
if (outcome.status >= 400) throw err("invalid", `Hub rejected ${path}: ${JSON.stringify(outcome.body).slice(0, 200)}`);
|
|
425
|
+
return outcome.body as T;
|
|
426
|
+
}
|
|
427
|
+
await sleep(500 * (attempt + 1));
|
|
428
|
+
}
|
|
429
|
+
throw err("offline", `Hub unreachable after ${PAIR_HTTP_MAX_RETRIES} attempts`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async hubFetchRaw(origin: string, path: string, body: unknown, signal?: AbortSignal): Promise<{ status: number; body: unknown }> {
|
|
433
|
+
const fetchImpl = this.hooks.fetchImpl ?? fetch;
|
|
434
|
+
const timeout = AbortSignal.timeout(PAIR_HTTP_TIMEOUT_MS);
|
|
435
|
+
const effective = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
436
|
+
const res = await fetchImpl(origin + path, {
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers: { "content-type": "application/json" },
|
|
439
|
+
body: JSON.stringify(body ?? {}),
|
|
440
|
+
signal: effective,
|
|
441
|
+
redirect: "error", // cross-origin redirects are never followed (03 §4.1)
|
|
442
|
+
});
|
|
443
|
+
let parsed: unknown;
|
|
444
|
+
try {
|
|
445
|
+
parsed = await res.json();
|
|
446
|
+
} catch {
|
|
447
|
+
parsed = undefined;
|
|
448
|
+
}
|
|
449
|
+
return { status: res.status, body: parsed };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
454
|
+
return new Promise((resolve) => {
|
|
455
|
+
if (signal?.aborted) return resolve();
|
|
456
|
+
const t = setTimeout(resolve, ms);
|
|
457
|
+
signal?.addEventListener("abort", () => (clearTimeout(t), resolve()), { once: true });
|
|
458
|
+
});
|
|
459
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// REST v1. Every route requires the device token (see auth.ts). Session refs are a UUID or a
|
|
2
|
+
// unique name; agent refs are a numeric id, `main`, or a unique name.
|
|
3
|
+
import { Router, type Request, type Response, type NextFunction } from "express";
|
|
4
|
+
import type { Conversation, Harness } from "@earendil-works/pi-durable";
|
|
5
|
+
import { DEVICE_DOC_KINDS, type SessionRow, type Store } from "./store.js";
|
|
6
|
+
import { agentStream, deviceStream, sessionStream } from "./streams.js";
|
|
7
|
+
import { idempotent } from "./idempotency.js";
|
|
8
|
+
import { invalid } from "./errors.js";
|
|
9
|
+
import type { DevicePairing } from "./pairing.js";
|
|
10
|
+
|
|
11
|
+
type Async = (req: Request, res: Response) => Promise<unknown>;
|
|
12
|
+
const route = (fn: Async) => (req: Request, res: Response, next: NextFunction) => fn(req, res).catch(next);
|
|
13
|
+
|
|
14
|
+
interface Locals {
|
|
15
|
+
row: SessionRow;
|
|
16
|
+
h: Harness;
|
|
17
|
+
c: Conversation;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const param = (req: Request, key: string) => String(req.params[key]);
|
|
21
|
+
const int = (value: unknown, name: string) => {
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (!Number.isSafeInteger(n)) throw invalid(`${name} must be an integer`);
|
|
24
|
+
return n;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function v1(store: Store, pairing?: DevicePairing, connector?: { status(): unknown }) {
|
|
28
|
+
const r = Router();
|
|
29
|
+
const create = idempotent(store);
|
|
30
|
+
|
|
31
|
+
// Local pairing control point (03 §7): the TUI never touches crypto or the
|
|
32
|
+
// pairing files directly — everything flows through these token-authed routes.
|
|
33
|
+
// Tunnel connector status (no secrets) for the TUI (P2-13).
|
|
34
|
+
if (connector) r.get("/tunnel", (_req, res) => res.json(connector.status()));
|
|
35
|
+
if (pairing) {
|
|
36
|
+
// Pairing errors carry {code, status}; map them without going through the
|
|
37
|
+
// generic middleware so codes like hub_required/busy survive to the TUI.
|
|
38
|
+
const fail = (e: unknown, res: Response) =>
|
|
39
|
+
res.status((e as { status?: number }).status ?? 400).json({
|
|
40
|
+
error: { code: (e as { code?: string }).code ?? "invalid", message: (e as Error).message },
|
|
41
|
+
});
|
|
42
|
+
r.get("/pairing", (_req, res) => res.json(pairing.status()));
|
|
43
|
+
r.post("/pairing/start", (req, res) => {
|
|
44
|
+
void (async () => {
|
|
45
|
+
try {
|
|
46
|
+
const input = req.body as { code?: string; hub?: string; pairUrl?: string; force?: boolean };
|
|
47
|
+
if (!input || typeof input.code !== "string" || (input.hub !== undefined && typeof input.hub !== "string") || (input.pairUrl !== undefined && typeof input.pairUrl !== "string"))
|
|
48
|
+
throw invalid("body: { code: string, hub?: string, pairUrl?: string, force?: boolean }");
|
|
49
|
+
await pairing.start({ code: input.code, hub: input.hub, pairUrl: input.pairUrl, force: input.force });
|
|
50
|
+
res.json(pairing.status());
|
|
51
|
+
} catch (e) {
|
|
52
|
+
fail(e, res);
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
});
|
|
56
|
+
r.post("/pairing/cancel", (_req, res) => {
|
|
57
|
+
try {
|
|
58
|
+
res.json(pairing.cancel());
|
|
59
|
+
} catch (e) {
|
|
60
|
+
fail(e, res);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve :s and :a once per request.
|
|
66
|
+
r.param("s", (req, res, next, value: string) => {
|
|
67
|
+
try {
|
|
68
|
+
const row = store.resolveSession(value);
|
|
69
|
+
res.locals.row = row;
|
|
70
|
+
next();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
next(e);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
r.param("a", (req, res, next, value: string) => {
|
|
76
|
+
store
|
|
77
|
+
.resolveAgent(param(req, "s"), value)
|
|
78
|
+
.then((resolved) => {
|
|
79
|
+
Object.assign(res.locals, resolved);
|
|
80
|
+
next();
|
|
81
|
+
})
|
|
82
|
+
.catch(next);
|
|
83
|
+
});
|
|
84
|
+
const L = (res: Response) => res.locals as Locals;
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------- device
|
|
87
|
+
r.get("/device", (_req, res) => res.json(store.deviceInfo()));
|
|
88
|
+
r.get("/tree", route(async (_req, res) => res.json(await store.tree())));
|
|
89
|
+
r.get("/events", (req, res, next) => {
|
|
90
|
+
try {
|
|
91
|
+
deviceStream(store, req, res);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
next(e);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
r.get("/fs/dirs", route(async (req, res) => res.json(await store.browse(String(req.query.path ?? "")))));
|
|
97
|
+
r.get(
|
|
98
|
+
"/docs/:kind",
|
|
99
|
+
route(async (req, res) => {
|
|
100
|
+
const kind = param(req, "kind");
|
|
101
|
+
if (!DEVICE_DOC_KINDS.includes(kind)) throw invalid(`Unknown device document ${kind}`);
|
|
102
|
+
res.json({ kind, ...(await store.deviceDoc(kind)) });
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------- sessions
|
|
107
|
+
r.get("/sessions", route(async (_req, res) => res.json((await store.tree()).sessions)));
|
|
108
|
+
r.post("/sessions", create, route(async (req, res) => res.status(201).json(await store.create(req.body ?? {}))));
|
|
109
|
+
r.get("/sessions/:s", route(async (_req, res) => res.json(await store.sessionSummary(L(res).row))));
|
|
110
|
+
r.patch("/sessions/:s", route(async (req, res) => res.json(await store.rename(L(res).row, req.body?.name))));
|
|
111
|
+
r.get("/sessions/:s/compaction", route(async (_req, res) => res.json(store.compactionOf(L(res).row.id))));
|
|
112
|
+
r.patch("/sessions/:s/compaction", route(async (req, res) => res.json(store.setCompaction(L(res).row, req.body ?? {}))));
|
|
113
|
+
r.delete("/sessions/:s", route(async (_req, res) => res.json(await store.archive(L(res).row))));
|
|
114
|
+
r.get("/sessions/:s/events", route(async (_req, res) => sessionStream(store, L(res).row, res)));
|
|
115
|
+
r.get(
|
|
116
|
+
"/sessions/:s/tasks",
|
|
117
|
+
route(async (req, res) => {
|
|
118
|
+
const status = String(req.query.status ?? "terminal");
|
|
119
|
+
if (!["live", "terminal"].includes(status)) throw invalid("status must be live or terminal");
|
|
120
|
+
const h = store.harness(L(res).row);
|
|
121
|
+
if (status === "live") return res.json(await store.liveTasks(h));
|
|
122
|
+
res.json(await store.tasks(h, status, Number(req.query.limit ?? 20)));
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
r.post(
|
|
126
|
+
"/sessions/:s/tasks/:taskId/abort",
|
|
127
|
+
route(async (req, res) => res.json(await store.abortTask(store.harness(L(res).row), int(req.params.taskId, "taskId")))),
|
|
128
|
+
);
|
|
129
|
+
r.get("/sessions/:s/inspect", route(async (_req, res) => res.json(await store.inspect(store.harness(L(res).row)))));
|
|
130
|
+
r.get(
|
|
131
|
+
"/sessions/:s/submissions/:id",
|
|
132
|
+
route(async (req, res) => {
|
|
133
|
+
const closed = new AbortController();
|
|
134
|
+
res.on("close", () => closed.abort());
|
|
135
|
+
res.json(
|
|
136
|
+
await store.settled(
|
|
137
|
+
store.harness(L(res).row),
|
|
138
|
+
int(req.params.id, "submission id"),
|
|
139
|
+
Math.min(Number(req.query.waitMs ?? 0) || 0, 300_000),
|
|
140
|
+
closed.signal,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------- agents
|
|
147
|
+
r.get("/sessions/:s/agents", route(async (_req, res) => res.json(await store.agents(L(res).row))));
|
|
148
|
+
r.post(
|
|
149
|
+
"/sessions/:s/agents",
|
|
150
|
+
create,
|
|
151
|
+
route(async (req, res) => res.status(201).json(await store.createAgent(L(res).row, req.body ?? {}))),
|
|
152
|
+
);
|
|
153
|
+
const a = "/sessions/:s/agents/:a";
|
|
154
|
+
r.get(a, route(async (_req, res) => res.json(await store.agentDetail(L(res).row, L(res).c))));
|
|
155
|
+
r.patch(
|
|
156
|
+
a,
|
|
157
|
+
route(async (req, res) => {
|
|
158
|
+
store.setAgentName(L(res).row, L(res).c.id as number, req.body?.name);
|
|
159
|
+
res.json(await store.agentSummary(L(res).row, L(res).c));
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
r.get(`${a}/events`, route(async (req, res) => agentStream(store, L(res), req, res)));
|
|
163
|
+
r.get(
|
|
164
|
+
`${a}/entries`,
|
|
165
|
+
route(async (req, res) => {
|
|
166
|
+
const scope = String(req.query.scope ?? "all");
|
|
167
|
+
if (scope === "active") return res.json({ items: await store.activeEntries(L(res).c) });
|
|
168
|
+
res.json(await store.entries(L(res).c, req.query.cursor as string | undefined, Number(req.query.limit ?? 500)));
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
r.get(`${a}/transcript`, route(async (req, res) => res.json(await store.transcript(L(res).c, String(req.query.format ?? "json")))));
|
|
172
|
+
r.post(
|
|
173
|
+
`${a}/messages`,
|
|
174
|
+
route(async (req, res) => {
|
|
175
|
+
const { h, c } = L(res);
|
|
176
|
+
const submission = await store.send(c, req.body ?? {});
|
|
177
|
+
const waitMs = Number(req.body?.waitMs ?? 0) || 0;
|
|
178
|
+
if (!waitMs) return res.status(202).json({ submissionId: submission.id, status: "queued" });
|
|
179
|
+
const closed = new AbortController();
|
|
180
|
+
res.on("close", () => closed.abort());
|
|
181
|
+
const result = await store.settled(h, submission.id as number, waitMs, closed.signal);
|
|
182
|
+
res.status(result.status === "done" || result.status === "unanswered" ? 200 : 202).json(result);
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
r.delete(
|
|
186
|
+
`${a}/inbox/:submissionId`,
|
|
187
|
+
route(async (req, res) => res.json(await store.withdraw(L(res).h, L(res).c, int(req.params.submissionId, "submissionId")))),
|
|
188
|
+
);
|
|
189
|
+
r.post(`${a}/abort`, route(async (req, res) => res.json(await store.abort(L(res).c, req.body?.background === true))));
|
|
190
|
+
r.post(`${a}/fork`, create, route(async (req, res) => res.status(201).json(await store.fork(L(res).row, L(res).c, req.body ?? {}))));
|
|
191
|
+
r.get(`${a}/config`, route(async (_req, res) => res.json((await store.agentDetail(L(res).row, L(res).c)).config)));
|
|
192
|
+
r.patch(`${a}/config`, route(async (req, res) => res.json(await store.configure(L(res).row, L(res).c, req.body ?? {}))));
|
|
193
|
+
r.get(`${a}/extensions`, route(async (_req, res) => res.json(await store.extensionsOf(L(res).row, L(res).c))));
|
|
194
|
+
r.patch(`${a}/extensions`, route(async (req, res) => res.json(await store.setExtension(L(res).row, L(res).c, req.body ?? {}))));
|
|
195
|
+
r.post(`${a}/compact`, route(async (req, res) => res.json(await store.compact(L(res).c, req.body?.instructions))));
|
|
196
|
+
r.post(`${a}/reset`, route(async (req, res) => res.json(await store.reset(L(res).c, req.body?.note))));
|
|
197
|
+
r.get(
|
|
198
|
+
`${a}/docs/:kind`,
|
|
199
|
+
route(async (req, res) => {
|
|
200
|
+
const kind = param(req, "kind");
|
|
201
|
+
res.json({ kind, value: await store.agentDoc(L(res).h, L(res).c, kind) });
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------- demo extension
|
|
206
|
+
r.post(`${a}/demo/state`, route(async (req, res) => res.json(await store.demoState(L(res).c, req.body ?? {}))));
|
|
207
|
+
r.post(
|
|
208
|
+
`${a}/demo/jobs`,
|
|
209
|
+
route(async (req, res) => res.status(201).json(await store.startJob(L(res).row, L(res).h, L(res).c, req.body ?? {}))),
|
|
210
|
+
);
|
|
211
|
+
r.post(
|
|
212
|
+
`${a}/demo/jobs/:taskId/approval`,
|
|
213
|
+
route(async (req, res) => res.json(await store.approveJob(L(res).c, int(req.params.taskId, "taskId"), req.body?.approved))),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return r;
|
|
217
|
+
}
|