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.
Files changed (259) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/ai_docs/durable_pi/announcement.md +860 -0
  5. package/ai_docs/durable_pi/changelog.md +51 -0
  6. package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
  7. package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
  8. package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
  9. package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
  10. package/ai_docs/durable_pi/readme.md +617 -0
  11. package/ai_docs/durable_pi/sources.md +22 -0
  12. package/ai_docs/durable_pi/spec.md +4696 -0
  13. package/ai_docs/durable_pi/vacation-planner.md +57 -0
  14. package/ai_docs/pi/chord.md +268 -0
  15. package/ai_docs/pi/cli-integration.md +110 -0
  16. package/ai_docs/pi/cli.md +325 -0
  17. package/ai_docs/pi/codemode.md +211 -0
  18. package/ai_docs/pi/compaction.md +467 -0
  19. package/ai_docs/pi/configuration.md +51 -0
  20. package/ai_docs/pi/containerization.md +187 -0
  21. package/ai_docs/pi/custom-provider.md +177 -0
  22. package/ai_docs/pi/environment-variables.md +102 -0
  23. package/ai_docs/pi/extensions.md +278 -0
  24. package/ai_docs/pi/how-pi-works.md +53 -0
  25. package/ai_docs/pi/index.md +43 -0
  26. package/ai_docs/pi/json.md +230 -0
  27. package/ai_docs/pi/keybindings.md +198 -0
  28. package/ai_docs/pi/llama-cpp.md +118 -0
  29. package/ai_docs/pi/mcp.md +274 -0
  30. package/ai_docs/pi/message-types.md +265 -0
  31. package/ai_docs/pi/models.md +209 -0
  32. package/ai_docs/pi/overview.md +166 -0
  33. package/ai_docs/pi/packages.md +133 -0
  34. package/ai_docs/pi/pi-agent-core.md +568 -0
  35. package/ai_docs/pi/pi-ai.md +1942 -0
  36. package/ai_docs/pi/prompt-templates.md +63 -0
  37. package/ai_docs/pi/providers.md +204 -0
  38. package/ai_docs/pi/quickstart.md +140 -0
  39. package/ai_docs/pi/readme.md +99 -0
  40. package/ai_docs/pi/rpc-commands.md +864 -0
  41. package/ai_docs/pi/rpc-extension-ui.md +204 -0
  42. package/ai_docs/pi/rpc.md +197 -0
  43. package/ai_docs/pi/sdk.md +151 -0
  44. package/ai_docs/pi/security.md +103 -0
  45. package/ai_docs/pi/session-format.md +297 -0
  46. package/ai_docs/pi/sessions.md +70 -0
  47. package/ai_docs/pi/settings.md +173 -0
  48. package/ai_docs/pi/shell-aliases.md +97 -0
  49. package/ai_docs/pi/skills.md +97 -0
  50. package/ai_docs/pi/slash-commands.md +64 -0
  51. package/ai_docs/pi/sources.md +57 -0
  52. package/ai_docs/pi/terminal-setup.md +223 -0
  53. package/ai_docs/pi/termux.md +121 -0
  54. package/ai_docs/pi/themes.md +140 -0
  55. package/ai_docs/pi/tmux.md +59 -0
  56. package/ai_docs/pi/toolkit.md +168 -0
  57. package/ai_docs/pi/tui.md +129 -0
  58. package/ai_docs/pi/usage.md +98 -0
  59. package/ai_docs/pi/virtual-models.md +118 -0
  60. package/ai_docs/pi/windows.md +71 -0
  61. package/apps/dpi-backend/.nvmrc +1 -0
  62. package/apps/dpi-backend/API.md +99 -0
  63. package/apps/dpi-backend/README.md +119 -0
  64. package/apps/dpi-backend/deploy/backend.env.example +9 -0
  65. package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
  66. package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
  67. package/apps/dpi-backend/package.json +33 -0
  68. package/apps/dpi-backend/prompts/one_sentence.md +3 -0
  69. package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
  70. package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
  71. package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
  72. package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
  73. package/apps/dpi-backend/scripts/token.ts +57 -0
  74. package/apps/dpi-backend/src/addons.ts +139 -0
  75. package/apps/dpi-backend/src/auth.ts +20 -0
  76. package/apps/dpi-backend/src/config.ts +54 -0
  77. package/apps/dpi-backend/src/contract-check.ts +118 -0
  78. package/apps/dpi-backend/src/errors.ts +50 -0
  79. package/apps/dpi-backend/src/extensions.ts +510 -0
  80. package/apps/dpi-backend/src/faux.ts +80 -0
  81. package/apps/dpi-backend/src/history.ts +165 -0
  82. package/apps/dpi-backend/src/idempotency.ts +20 -0
  83. package/apps/dpi-backend/src/jev.ts +43 -0
  84. package/apps/dpi-backend/src/jobs.ts +790 -0
  85. package/apps/dpi-backend/src/main.ts +76 -0
  86. package/apps/dpi-backend/src/model-catalog.ts +180 -0
  87. package/apps/dpi-backend/src/pairing-files.ts +142 -0
  88. package/apps/dpi-backend/src/pairing.ts +459 -0
  89. package/apps/dpi-backend/src/routes.ts +217 -0
  90. package/apps/dpi-backend/src/shared-state.ts +85 -0
  91. package/apps/dpi-backend/src/store.ts +1240 -0
  92. package/apps/dpi-backend/src/streams.ts +256 -0
  93. package/apps/dpi-backend/src/tunnel.ts +955 -0
  94. package/apps/dpi-backend/tsconfig.json +13 -0
  95. package/apps/dpi-playground/README.md +83 -0
  96. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
  97. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
  98. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
  99. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
  100. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
  101. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
  102. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
  103. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
  104. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
  105. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
  106. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
  107. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
  108. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
  109. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
  110. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
  111. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
  112. package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
  113. package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
  114. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
  115. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
  116. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
  117. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
  118. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
  119. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
  120. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
  121. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
  122. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
  123. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
  124. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
  125. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
  126. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
  127. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
  128. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
  129. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
  130. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
  131. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
  132. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
  133. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
  134. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
  135. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
  136. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
  137. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
  138. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
  139. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
  140. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
  141. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
  142. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
  143. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
  144. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
  145. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
  146. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
  147. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
  148. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
  149. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
  150. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
  151. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
  152. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
  153. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
  154. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
  155. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
  156. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
  157. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
  158. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
  159. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
  160. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
  161. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
  162. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
  163. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
  164. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
  165. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
  166. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
  167. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
  168. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
  169. package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
  170. package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
  171. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
  172. package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
  173. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
  174. package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
  175. package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
  176. package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
  177. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
  178. package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
  179. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
  180. package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
  181. package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
  182. package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
  183. package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
  184. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
  185. package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
  186. package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
  187. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
  188. package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
  189. package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
  190. package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
  191. package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
  192. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
  193. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
  194. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
  195. package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
  196. package/apps/dpi-playground/package.json +37 -0
  197. package/apps/dpi-sdk/README.md +74 -0
  198. package/apps/dpi-sdk/package.json +20 -0
  199. package/apps/dpi-sdk/scripts/net.ts +73 -0
  200. package/apps/dpi-sdk/src/api.ts +631 -0
  201. package/apps/dpi-sdk/src/app-state.ts +111 -0
  202. package/apps/dpi-sdk/src/client.ts +330 -0
  203. package/apps/dpi-sdk/src/errors.ts +16 -0
  204. package/apps/dpi-sdk/src/jobs.ts +99 -0
  205. package/apps/dpi-sdk/src/models.ts +472 -0
  206. package/apps/dpi-sdk/src/node/chunk.ts +488 -0
  207. package/apps/dpi-sdk/src/node/config.ts +175 -0
  208. package/apps/dpi-sdk/src/node/drivers.ts +402 -0
  209. package/apps/dpi-sdk/src/node/ops.ts +114 -0
  210. package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
  211. package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
  212. package/apps/dpi-sdk/src/node/registry.ts +390 -0
  213. package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
  214. package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
  215. package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
  216. package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
  217. package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
  218. package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
  219. package/apps/dpi-sdk/src/sse.ts +117 -0
  220. package/apps/dpi-sdk/src/transcript.ts +291 -0
  221. package/apps/dpi-sdk/tsconfig.json +13 -0
  222. package/apps/dpi-sdk/w +20 -0
  223. package/apps/dpi-tui/README.md +54 -0
  224. package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
  225. package/apps/dpi-tui/package.json +20 -0
  226. package/apps/dpi-tui/src/app.ts +433 -0
  227. package/apps/dpi-tui/src/commands.ts +151 -0
  228. package/apps/dpi-tui/src/main.ts +122 -0
  229. package/apps/dpi-tui/src/pairing.ts +287 -0
  230. package/apps/dpi-tui/src/pickers.ts +238 -0
  231. package/apps/dpi-tui/src/render.ts +164 -0
  232. package/apps/dpi-tui/src/theme.ts +57 -0
  233. package/apps/dpi-tui/tsconfig.json +14 -0
  234. package/bin/device.mjs +5 -0
  235. package/bin/hub.mjs +5 -0
  236. package/bin/tui.mjs +5 -0
  237. package/deploy/Caddyfile +120 -0
  238. package/docs/verification.md +102 -0
  239. package/dpi-network.example.yaml +62 -0
  240. package/images/01_durable_agent_network.svg +185 -0
  241. package/images/02_pi_made_durable.svg +204 -0
  242. package/images/03_restart_mid_tool.svg +168 -0
  243. package/images/07_one_api_every_client.svg +123 -0
  244. package/images/08_device_session_agent.svg +123 -0
  245. package/images/09_compact_keep_history.svg +143 -0
  246. package/images/10_shared_app_state.svg +177 -0
  247. package/images/11_durable_jobs_wait.svg +141 -0
  248. package/images/12_tools_change_mid_run.svg +231 -0
  249. package/images/13_hook_blocks_tool.svg +122 -0
  250. package/images/14_fork_at_any_entry.svg +119 -0
  251. package/images/15_web_client.png +0 -0
  252. package/images/16_terminal_client.svg +43 -0
  253. package/images/17_devices_are_data.svg +182 -0
  254. package/images/18_should_every_agent_be_durable.svg +118 -0
  255. package/images/19_sqlite_on_every_device.svg +155 -0
  256. package/images/20_admission_and_effects.svg +134 -0
  257. package/justfile +33 -0
  258. package/package.json +41 -4
  259. package/scripts/verify.mjs +210 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
9
+ "types": ["vite/client"],
10
+ "allowImportingTsExtensions": true,
11
+ "noEmit": true,
12
+ "baseUrl": ".",
13
+ "paths": { "@sdk/*": ["../../../dpi-sdk/src/*"] }
14
+ },
15
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
16
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "vite";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { fileURLToPath } from "node:url";
4
+ const sdk = fileURLToPath(new URL("../../../dpi-sdk/src", import.meta.url));
5
+ export default defineConfig({
6
+ root: fileURLToPath(new URL(".", import.meta.url)),
7
+ plugins: [vue()],
8
+ resolve: { alias: { "@sdk": sdk } },
9
+ server: {
10
+ host: "127.0.0.1",
11
+ fs: { allow: [fileURLToPath(new URL("../../../..", import.meta.url))] },
12
+ },
13
+ build: { outDir: "dist", emptyOutDir: true },
14
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dpi-playground-hub",
3
+ "private": true,
4
+ "type": "module"
5
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Transactional audit (spec 07 §5.3) — append-only `events` rows committed in
3
+ * the SAME transaction as the credential/registry mutation they describe.
4
+ *
5
+ * - Audit failure fails the mutation closed (rollback), never the reverse.
6
+ * - detail_json is capped at 4096 UTF-8 BYTES (SQLite sees a BLOB; JS checks
7
+ * bytes, not characters) and is scrubbed of every secret shape — tokens,
8
+ * pairing codes, private keys, Authorization values, pair-URL queries and
9
+ * transcript fragments — including substrings.
10
+ * - Denials are aggregated per (action, source, kind, time window): a flood of bad
11
+ * packets produces O(windows) rows, not O(packets); the RAM bucket table is
12
+ * itself bounded. A source is an IPv4 address or an IPv6 /64. The first denial
13
+ * of a window writes an opening row; when the window has closed (sweep or
14
+ * shutdown) a second row carries the window's total. Rows are append-only, so
15
+ * the total cannot be written into the opening row. Denial accounting is
16
+ * best-effort and never throws: a
17
+ * storage fault loses the denial row (counted in `droppedDenials`), it must
18
+ * not take a request path or the process down.
19
+ * - Disk-space guard: audited work (every append, every transaction) is
20
+ * refused with AuditUnavailableError (503) below a free-space floor on the
21
+ * registry volume, before any state is touched.
22
+ * - The app never disables the append-only triggers; prune is a separate
23
+ * maintenance path (P3) that recreates them transactionally.
24
+ */
25
+ import { chmodSync, statSync, statfsSync } from "node:fs";
26
+ import { dirname } from "node:path";
27
+ import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
28
+ import { ipKey } from "./client-ip.js";
29
+
30
+ export const AUDIT_ACTIONS = [
31
+ "pair.code_created",
32
+ "pair.approved",
33
+ "pair.cancelled",
34
+ "pair.success",
35
+ "pair.revoke",
36
+ "token.rotate_started",
37
+ "token.rotate_completed",
38
+ "token.rotate_regenerated",
39
+ "tunnel.connect",
40
+ "tunnel.disconnect",
41
+ "auth.denied",
42
+ "rate_limited",
43
+ "tunnel.listener_error",
44
+ "rotation.stale_cleared",
45
+ ] as const;
46
+ export type AuditAction = (typeof AUDIT_ACTIONS)[number];
47
+
48
+ const DETAIL_MAX_BYTES = 4096;
49
+ const FIELD_MAX = 128;
50
+ /** A denial sample is a hint for the operator, not a copy of the request. */
51
+ const SAMPLE_FIELD_MAX = 200;
52
+ const SAMPLE_FIELDS_MAX = 16;
53
+ const DENIAL_WINDOW_MS = 60_000;
54
+ const DENIAL_BUCKETS_MAX = 512;
55
+ /** Free space that must remain on the registry volume for audited work (05 §4.4 disk-space guard). */
56
+ const MIN_FREE_BYTES = 32 * 1024 * 1024;
57
+ const DISK_CHECK_TTL_MS = 1_000;
58
+ const DROP_WARN_INTERVAL_MS = 60_000;
59
+
60
+ export interface AuditEvent {
61
+ actor: string;
62
+ action: AuditAction;
63
+ outcome: "ok" | "denied";
64
+ requestId?: string;
65
+ connectionId?: string;
66
+ detail?: Record<string, unknown>;
67
+ }
68
+
69
+ export interface AuditOptions {
70
+ /** Refuse audited work below this many free bytes on the registry volume (default 32 MiB). */
71
+ minFreeBytes?: number;
72
+ /** Free bytes on the registry volume (default: statfs of its directory, cached for 1 s). Test seam. */
73
+ freeBytes?: () => number;
74
+ /** When true, every append throws (fault injection for fail-closed tests). */
75
+ failAppend?: boolean;
76
+ }
77
+
78
+ /** Audit storage cannot take a write right now; the mutation must not proceed. */
79
+ export class AuditUnavailableError extends Error {
80
+ readonly status = 503;
81
+ readonly code = "audit_unavailable";
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = "AuditUnavailableError";
85
+ }
86
+ }
87
+
88
+ /** Redact every secret shape, including substrings, from arbitrary strings. */
89
+ export function scrubSecrets(value: string): string {
90
+ let out = value;
91
+ out = out.replace(/dpt_[A-Za-z0-9_-]{1,64}/g, "dpt_<redacted>");
92
+ out = out.replace(/dpi_[A-Za-z0-9_-]{1,64}/g, "dpi_<redacted>");
93
+ out = out.replace(
94
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
95
+ "<private-key-redacted>",
96
+ );
97
+ out = out.replace(/(authorization\s*[:=]\s*)(\S+)/gi, "$1<redacted>");
98
+ out = out.replace(/(bearer\s+)([A-Za-z0-9._-]{8,})/gi, "$1<redacted>");
99
+ out = out.replace(/([?&]code=)[A-Za-z0-9_-]+/g, "$1<redacted>");
100
+ // Credential material without its prefix (slices, echo): 43-char base64url runs.
101
+ out = out.replace(/(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, "<redacted>");
102
+ // Pairing codes as standalone values ("code":"AB2C7DEF") but not ordinary words.
103
+ out = out.replace(/(["'?=:,\s])([A-Z2-7]{8})(?=["'?\s:,}]|$)/g, "$1<code-redacted>");
104
+ return out;
105
+ }
106
+
107
+ function scrubValue(value: unknown, depth = 0): unknown {
108
+ if (depth > 8) return "<depth-limit>";
109
+ if (typeof value === "string")
110
+ return scrubSecrets(value.length > 8192 ? value.slice(0, 8192) : value);
111
+ if (Array.isArray(value)) return value.slice(0, 32).map((v) => scrubValue(v, depth + 1));
112
+ if (value && typeof value === "object") {
113
+ const out: Record<string, unknown> = {};
114
+ for (const [k, v] of Object.entries(value).slice(0, 64))
115
+ out[scrubSecrets(k)] = scrubValue(v, depth + 1);
116
+ return out;
117
+ }
118
+ return value;
119
+ }
120
+
121
+ interface DenialBucket {
122
+ action: AuditAction;
123
+ source: string;
124
+ window: number;
125
+ count: number;
126
+ sample: Record<string, unknown>;
127
+ /** The opening row reached storage; only such a window may get a closing row. */
128
+ persisted: boolean;
129
+ closed: boolean;
130
+ }
131
+
132
+ /** Bounded copy of a denial sample: at most 16 fields, strings cut at 200 characters. */
133
+ function clipSample(sample: Record<string, unknown>): Record<string, unknown> {
134
+ const out: Record<string, unknown> = {};
135
+ for (const [key, value] of Object.entries(sample).slice(0, SAMPLE_FIELDS_MAX))
136
+ out[key.slice(0, SAMPLE_FIELD_MAX)] =
137
+ typeof value === "string" ? value.slice(0, SAMPLE_FIELD_MAX) : value;
138
+ return out;
139
+ }
140
+
141
+ export class Audit {
142
+ private buckets = new Map<string, DenialBucket>();
143
+ private windowRows = new Map<number, number>();
144
+ private overflowed = new Set<number>();
145
+ private static readonly WINDOW_ROW_CAP = 256;
146
+ private diskCheckedAt = 0;
147
+ private diskFree = Number.POSITIVE_INFINITY;
148
+ private lastDropWarning = 0;
149
+ /** Denial rows that could not be persisted (storage fault or disk guard); counts stay in RAM only. */
150
+ droppedDenials = 0;
151
+
152
+ constructor(
153
+ private readonly registry: NetworkRegistry,
154
+ private readonly hooks: AuditOptions = {},
155
+ ) {
156
+ this.ensureStoragePerms();
157
+ }
158
+
159
+ /**
160
+ * Disk-space guard (05 §4.4): refuse audited work when the registry volume
161
+ * is nearly full, before any state changes. An unreadable or unknown volume
162
+ * never blocks (a false refusal would lock the operator out of revoke).
163
+ */
164
+ assertWritable(): void {
165
+ const floor = this.hooks.minFreeBytes ?? MIN_FREE_BYTES;
166
+ const free = this.hooks.freeBytes ? this.hooks.freeBytes() : this.volumeFreeBytes();
167
+ if (free < floor)
168
+ throw new AuditUnavailableError(
169
+ `audit: ${free} bytes free on the registry volume (floor ${floor}); audited changes are refused`,
170
+ );
171
+ }
172
+
173
+ private volumeFreeBytes(): number {
174
+ const now = Date.now();
175
+ if (now - this.diskCheckedAt < DISK_CHECK_TTL_MS) return this.diskFree;
176
+ this.diskCheckedAt = now;
177
+ try {
178
+ const fs = statfsSync(dirname(this.registry.path));
179
+ this.diskFree = fs.blocks > 0 ? fs.bavail * fs.bsize : Number.POSITIVE_INFINITY;
180
+ } catch {
181
+ this.diskFree = Number.POSITIVE_INFINITY;
182
+ }
183
+ return this.diskFree;
184
+ }
185
+
186
+ /** The registry file must stay 0600 — tighten quietly if a copy restored looser bits. */
187
+ private ensureStoragePerms() {
188
+ try {
189
+ const mode = statSync(this.registry.path).mode & 0o777;
190
+ if (mode !== 0o600) chmodSync(this.registry.path, 0o600);
191
+ } catch {
192
+ /* best effort; SQLite still works */
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Run `fn` inside one IMMEDIATE transaction on the registry connection.
198
+ * Any throw (including audit-append failures) rolls everything back —
199
+ * a mutation is never committed without its audit row.
200
+ */
201
+ withTransaction<T>(fn: () => T): T {
202
+ this.assertWritable();
203
+ const db = this.registry.db;
204
+ db.exec("BEGIN IMMEDIATE");
205
+ try {
206
+ const result = fn();
207
+ db.exec("COMMIT");
208
+ return result;
209
+ } catch (e) {
210
+ try {
211
+ db.exec("ROLLBACK");
212
+ } catch {
213
+ /* connection-level failure already rolled back */
214
+ }
215
+ throw e;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Append one audit row. Callers that mutate credentials must do it INSIDE
221
+ * withTransaction so the row commits (or rolls back) with the mutation.
222
+ * Throws (fail closed) on: oversize detail bytes, DB write errors, injected
223
+ * faults. Never throws "soft".
224
+ */
225
+ append(event: AuditEvent): void {
226
+ if (this.hooks.failAppend) throw new Error("audit: injected append failure");
227
+ this.assertWritable();
228
+ // Reject oversize details BEFORE any scrubbing/truncation could mask them.
229
+ const rawBytes = Buffer.byteLength(JSON.stringify(event.detail ?? {}), "utf8");
230
+ if (rawBytes > DETAIL_MAX_BYTES)
231
+ throw new Error(
232
+ `audit: detail_json is ${rawBytes} bytes (max ${DETAIL_MAX_BYTES} UTF-8 bytes)`,
233
+ );
234
+ const actor = scrubSecrets(String(event.actor)).slice(0, FIELD_MAX);
235
+ const action = String(event.action).slice(0, FIELD_MAX);
236
+ const requestId = event.requestId
237
+ ? scrubSecrets(String(event.requestId)).slice(0, FIELD_MAX)
238
+ : null;
239
+ const connectionId = event.connectionId
240
+ ? scrubSecrets(String(event.connectionId)).slice(0, FIELD_MAX)
241
+ : null;
242
+ const detail = event.detail ? (scrubValue(event.detail) as Record<string, unknown>) : {};
243
+ const detailJson = JSON.stringify(detail);
244
+ const bytes = Buffer.byteLength(detailJson, "utf8");
245
+ if (bytes > DETAIL_MAX_BYTES)
246
+ throw new Error(`audit: detail_json is ${bytes} bytes (max ${DETAIL_MAX_BYTES} UTF-8 bytes)`);
247
+ this.registry.db
248
+ .prepare(
249
+ "INSERT INTO events(ts,actor,action,request_id,connection_id,outcome,detail_json) VALUES(?,?,?,?,?,?,?)",
250
+ )
251
+ .run(Date.now(), actor, action, requestId, connectionId, event.outcome, detailJson);
252
+ }
253
+
254
+ /**
255
+ * Denial/rate-limit accounting with bounded aggregation: one row per
256
+ * (action, ip, kind, window) records the window and a redacted sample; the
257
+ * RAM bucket map is capped so a flood cannot grow it without bound. `kind`
258
+ * separates denials that must stay distinguishable (hub-chosen, bounded
259
+ * values only — never request data).
260
+ *
261
+ * Best-effort by design: a storage fault or the disk guard drops the row
262
+ * (see `droppedDenials`) but never throws into the request path.
263
+ */
264
+ denied(action: AuditAction, ip: string, sample: Record<string, unknown> = {}, kind = ""): void {
265
+ try {
266
+ this.recordDenial(action, ip, sample, kind);
267
+ } catch (e) {
268
+ this.noteDropped(e);
269
+ }
270
+ }
271
+
272
+ private recordDenial(
273
+ action: AuditAction,
274
+ address: string,
275
+ sample: Record<string, unknown>,
276
+ kind: string,
277
+ ): void {
278
+ const now = Date.now();
279
+ const window = Math.floor(now / DENIAL_WINDOW_MS) * DENIAL_WINDOW_MS;
280
+ const source = ipKey(address);
281
+ const key = `${action}|${source}|${kind}|${window}`;
282
+ const known = this.buckets.get(key);
283
+ if (known) {
284
+ known.count += 1; // counts live in RAM; the row records the window + sample
285
+ return;
286
+ }
287
+ if (this.buckets.size >= DENIAL_BUCKETS_MAX) {
288
+ const oldest = this.buckets.keys().next().value;
289
+ if (oldest !== undefined) this.buckets.delete(oldest);
290
+ }
291
+ const bucket: DenialBucket = {
292
+ action,
293
+ source,
294
+ window,
295
+ count: 1,
296
+ persisted: false,
297
+ closed: false,
298
+ sample: scrubValue(clipSample(sample)) as Record<string, unknown>,
299
+ };
300
+ this.buckets.set(key, bucket);
301
+ // Global row budget per window: distinct-IP floods land in one overflow row.
302
+ // A row slot is spent BEFORE the write so a failing disk is not retried per request.
303
+ const written = this.windowRows.get(window) ?? 0;
304
+ if (written >= Audit.WINDOW_ROW_CAP) {
305
+ if (this.overflowed.has(window)) return;
306
+ this.overflowed.add(window);
307
+ this.windowRows.set(window, written + 1);
308
+ this.append({
309
+ actor: "ip:*",
310
+ action: "rate_limited",
311
+ outcome: "denied",
312
+ detail: {
313
+ aggregated: true,
314
+ window,
315
+ overflow: true,
316
+ note: "denial row budget exceeded; counts in RAM only",
317
+ },
318
+ });
319
+ return;
320
+ }
321
+ this.windowRows.set(window, written + 1);
322
+ this.append({
323
+ actor: `ip:${source}`,
324
+ action,
325
+ outcome: "denied",
326
+ detail: { aggregated: true, window, sample: bucket.sample },
327
+ });
328
+ bucket.persisted = true;
329
+ }
330
+
331
+ /** The window's total, as a second row (the opening row cannot be updated). Best-effort like every denial record. */
332
+ private closeBucket(bucket: DenialBucket): void {
333
+ bucket.closed = true;
334
+ if (!bucket.persisted || bucket.count < 2) return;
335
+ try {
336
+ this.append({
337
+ actor: `ip:${bucket.source}`,
338
+ action: bucket.action,
339
+ outcome: "denied",
340
+ detail: { aggregated: true, window: bucket.window, closing: true, count: bucket.count },
341
+ });
342
+ } catch (e) {
343
+ this.noteDropped(e);
344
+ }
345
+ }
346
+
347
+ private noteDropped(e: unknown): void {
348
+ this.droppedDenials++;
349
+ const now = Date.now();
350
+ if (now - this.lastDropWarning < DROP_WARN_INTERVAL_MS) return;
351
+ this.lastDropWarning = now;
352
+ console.error(
353
+ `audit: denial record dropped (${scrubSecrets(String((e as Error)?.message ?? e)).slice(0, 160)}); ${this.droppedDenials} dropped so far`,
354
+ );
355
+ }
356
+
357
+ /** Close the windows that have ended, then drop buckets older than two windows (housekeeping for long-lived hubs). */
358
+ sweepDenials(): void {
359
+ const now = Date.now();
360
+ const cutoff = now - DENIAL_WINDOW_MS * 2;
361
+ for (const [key, bucket] of this.buckets) {
362
+ if (!bucket.closed && bucket.window + DENIAL_WINDOW_MS <= now) this.closeBucket(bucket);
363
+ if (bucket.window < cutoff) this.buckets.delete(key);
364
+ }
365
+ for (const window of this.windowRows.keys())
366
+ if (window < cutoff) this.windowRows.delete(window);
367
+ for (const window of this.overflowed) if (window < cutoff) this.overflowed.delete(window);
368
+ }
369
+
370
+ /** Shutdown: give every window that is still open its total, so a stop does not lose the counts held in RAM. */
371
+ flushDenials(): void {
372
+ for (const bucket of this.buckets.values()) if (!bucket.closed) this.closeBucket(bucket);
373
+ }
374
+
375
+ /** Run `sweepDenials` on a timer that never keeps the process alive. Returns the stop function. */
376
+ startSweeper(intervalMs = DENIAL_WINDOW_MS): () => void {
377
+ const timer = setInterval(() => this.sweepDenials(), intervalMs);
378
+ timer.unref();
379
+ return () => clearInterval(timer);
380
+ }
381
+
382
+ /** Read-only counters for tests and diagnostics. */
383
+ stats(): { denialBuckets: number; droppedDenials: number } {
384
+ return { denialBuckets: this.buckets.size, droppedDenials: this.droppedDenials };
385
+ }
386
+ }
387
+
388
+ /** Express middleware: log 4xx/429 security denials through bounded aggregation. */
389
+ export function denialLogging(audit: Audit): import("express").RequestHandler {
390
+ return (req, res, next) => {
391
+ res.on("finish", () => {
392
+ if ([401, 403, 421, 429, 415].includes(res.statusCode)) {
393
+ try {
394
+ audit.denied("auth.denied", req.ip ?? "unknown", {
395
+ method: req.method,
396
+ path: req.path,
397
+ status: res.statusCode,
398
+ });
399
+ } catch {
400
+ /* audit must never take the request path down */
401
+ }
402
+ }
403
+ });
404
+ next();
405
+ };
406
+ }
@@ -0,0 +1,72 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import { BlockList, isIP } from "node:net";
3
+
4
+ const loopback = new BlockList();
5
+ loopback.addSubnet("127.0.0.0", 8, "ipv4");
6
+ loopback.addAddress("::1", "ipv6");
7
+
8
+ /** The reference proxy runs on the same host. No other peers may assert XFF. */
9
+ export function isTrustedProxy(address: string): boolean {
10
+ const family = isIP(address);
11
+ return family !== 0 && loopback.check(address, family === 6 ? "ipv6" : "ipv4");
12
+ }
13
+
14
+ /**
15
+ * Did this request reach the TLS proxy over TLS? Only the proxy can say so, and only a loopback peer
16
+ * is the proxy. The reference proxy overwrites X-Forwarded-Proto from the real connection, so the
17
+ * answer is exactly one `https`; a comma list means a hop appended to a value it did not own.
18
+ */
19
+ export function viaTls(req: Pick<IncomingMessage, "socket" | "headers">): boolean {
20
+ if (!isTrustedProxy(req.socket.remoteAddress ?? "")) return false;
21
+ const proto = req.headers["x-forwarded-proto"];
22
+ return typeof proto === "string" && proto.trim().toLowerCase() === "https";
23
+ }
24
+
25
+ /** The eight 16-bit groups of a valid IPv6 literal (zone already removed). */
26
+ function ipv6Groups(address: string): number[] {
27
+ let text = address;
28
+ const tail = /\d+\.\d+\.\d+\.\d+$/.exec(text)?.[0];
29
+ if (tail) {
30
+ const [a, b, c, d] = tail.split(".").map(Number);
31
+ text = `${text.slice(0, -tail.length)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
32
+ }
33
+ const [head, rest] = text.split("::");
34
+ const front = head ? head.split(":") : [];
35
+ const back = rest ? rest.split(":") : [];
36
+ const gap = text.includes("::")
37
+ ? new Array<string>(8 - front.length - back.length).fill("0")
38
+ : [];
39
+ return [...front, ...gap, ...back].map((group) => parseInt(group, 16));
40
+ }
41
+
42
+ /**
43
+ * The identity a per-address limit counts. A /64 is the smallest block an IPv6 host can be handed, so
44
+ * every address in it is one source; an IPv4 client of a dual-stack listener (`::ffff:a.b.c.d`) is
45
+ * the IPv4 address. Anything that is not an IP literal is returned unchanged. Idempotent.
46
+ */
47
+ export function ipKey(address: string): string {
48
+ const literal = address.split("%", 1)[0];
49
+ const family = isIP(literal);
50
+ if (family === 4) return literal;
51
+ if (family !== 6) return address;
52
+ const g = ipv6Groups(literal);
53
+ if (g.slice(0, 5).every((group) => group === 0) && g[5] === 0xffff)
54
+ return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
55
+ return `${g
56
+ .slice(0, 4)
57
+ .map((group) => group.toString(16))
58
+ .join(":")}::/64`;
59
+ }
60
+
61
+ /** Also used for upgrades, which never pass through Express's req.ip getter. */
62
+ export function clientIp(req: IncomingMessage): string {
63
+ const peer = req.socket.remoteAddress ?? "unknown";
64
+ if (!isTrustedProxy(peer)) return peer;
65
+ const forwarded = req.headers["x-forwarded-for"];
66
+ if (typeof forwarded !== "string") return peer;
67
+ const chain = forwarded.split(",").map((address) => address.trim());
68
+ if (chain.some((address) => !isIP(address))) return peer;
69
+ let ip = peer;
70
+ for (let i = chain.length - 1; i >= 0 && isTrustedProxy(ip); i--) ip = chain[i];
71
+ return ip;
72
+ }