brane 0.1.1 → 0.3.1

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 (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,624 @@
1
+ /**
2
+ * The `tokens` table: the daemon's bearer tokens, by hash only. The ring that
3
+ * reads it — authentication, rotation, and why only hashes — is
4
+ * `src/daemon/token-ring.ts`; this module is the storage and the one-time
5
+ * migration from the plaintext token brane kept before.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { hashToken, LEGACY_TOKEN_LABEL, UNUSED_TOKEN_LAPSE_MS, } from "../daemon/token-ring.js";
10
+ /** A user agent is shown in a devices list and nowhere else; past this it is
11
+ * noise, and an unbounded header is not something to keep forever. */
12
+ const MAX_USER_AGENT_LENGTH = 512;
13
+ /** An `Origin` is `scheme://host[:port]`; anything longer than this is not
14
+ * one a browser sent. */
15
+ const MAX_ORIGIN_LENGTH = 256;
16
+ /** An `Origin` header as it may be stored. The literal `null` is what a
17
+ * browser sends for an opaque origin (a sandboxed iframe, a `file://` page,
18
+ * some cross-origin redirects): it names nothing, and stored it would read
19
+ * "via null" for good, since the first origin is never overwritten. So it
20
+ * counts as no origin at all. */
21
+ function cleanOrigin(value) {
22
+ const clean = cleanSighting(value, MAX_ORIGIN_LENGTH);
23
+ return clean === "null" ? null : clean;
24
+ }
25
+ /** A header or address as it may be stored and shown: control characters
26
+ * become spaces (a newline in a list is a forged row), capped, or null. */
27
+ function cleanSighting(value, max) {
28
+ if (typeof value !== "string")
29
+ return null;
30
+ const clean = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, max);
31
+ return clean || null;
32
+ }
33
+ /** An IPv4 peer arrives on a dual-stack socket as `::ffff:100.64.0.1`. */
34
+ export function normalizeRemoteAddr(addr) {
35
+ const clean = cleanSighting(addr, 64);
36
+ return clean?.startsWith("::ffff:") && clean.includes(".") ? clean.slice("::ffff:".length) : clean;
37
+ }
38
+ /** The sighting recorded for a connection on the daemon's Unix socket,
39
+ * which has no address of its own. */
40
+ export const UNIX_SOCKET_SIGHTING = "daemon.sock";
41
+ /** Whether two sightings are the same place. Loopback is one place however
42
+ * it is spelled: the MCP bridge dials `localhost` (often `::1`), the CLI
43
+ * dials `127.0.0.1` or `daemon.sock`, and a token used by both is not a
44
+ * copy. */
45
+ export function samePlace(a, b) {
46
+ const loopback = (x) => x === "::1" || x === UNIX_SOCKET_SIGHTING || /^127\.\d+\.\d+\.\d+$/.test(x);
47
+ return a === b || (loopback(a) && loopback(b));
48
+ }
49
+ /** Record which token's request launched (spawned, ran or revived) a
50
+ * narrative's process, on the narrative and on the agent token that launch
51
+ * minted — which is what lets revoking a device cascade to the agents it
52
+ * started. Best-effort: it labels, and must never fail the launch it
53
+ * follows. */
54
+ export function recordLauncher(db, nid, tokenId) {
55
+ try {
56
+ db.prepare("UPDATE narratives SET launched_by = ? WHERE id = ?").run(tokenId, nid);
57
+ db.prepare("UPDATE tokens SET minted_by = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL AND minted_by IS NULL").run(tokenId, nid);
58
+ }
59
+ catch { }
60
+ }
61
+ /** Mint a narrative's agent token: the credential its session uses for
62
+ * everything (the MCP bridge, the `brane` CLI in its shell, hooks), handed
63
+ * to it in the tmux env as `BRANE_NARRATIVE_SECRET`. The daemon keeps only
64
+ * the hash. Every launch mints a fresh one and revokes the narrative's
65
+ * previous ones in the same write, so exactly one is live per narrative and
66
+ * a revoked agent comes back only by being revived. Returns the plaintext,
67
+ * which the caller puts in the session's env and nowhere else. */
68
+ export function mintAgentToken(db, nid, now = Date.now(), mint = () => randomUUID(),
69
+ /** false: leave the previous token live, for a launch that may still fail —
70
+ * the caller then calls `settleAgentLaunch` with the outcome. */
71
+ revokePrevious = true) {
72
+ const token = mint();
73
+ db.transaction(() => {
74
+ if (revokePrevious) {
75
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL").run(now, nid);
76
+ }
77
+ db.prepare(`INSERT INTO tokens (id, hash, kind, label, origin, created_at, last_used_at, owner_nid)
78
+ VALUES (?, ?, 'agent', ?, 'minted', ?, ?, ?)`).run(randomUUID(), hashToken(token), nid, now, now, nid);
79
+ })();
80
+ return token;
81
+ }
82
+ /** Finish a launch that minted with `revokePrevious = false`. On success the
83
+ * narrative's other agent tokens are revoked, so one is live; on failure the
84
+ * new token is, and the previous one stands. Without this split, a revive
85
+ * that raced another (a double tap) would revoke the running session's
86
+ * token and then fail to launch — leaving the live agent cut off by a revive
87
+ * that never happened. */
88
+ export function settleAgentLaunch(db, nid, token, launched, now = Date.now()) {
89
+ const hash = hashToken(token);
90
+ if (launched) {
91
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL AND hash != ?").run(now, nid, hash);
92
+ }
93
+ else {
94
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE hash = ? AND revoked_at IS NULL").run(now, hash);
95
+ }
96
+ }
97
+ /** The id of `nid`'s live agent token row, if it has one. */
98
+ export function liveAgentTokenId(db, nid) {
99
+ const row = db
100
+ .prepare("SELECT id FROM tokens WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1")
101
+ .get(nid);
102
+ return row?.id ?? null;
103
+ }
104
+ /** Whether `secret` is `nid`'s live agent token. What `/ws/mcp` and the hook
105
+ * route check a narrative claim against, in place of comparing the plaintext
106
+ * `narratives.bind_token` brane used to keep. */
107
+ export function verifyAgentToken(db, nid, secret) {
108
+ return (db
109
+ .prepare("SELECT 1 FROM tokens WHERE hash = ? AND kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL")
110
+ .get(hashToken(secret), nid) !== undefined);
111
+ }
112
+ /** Adopt the plaintext bind secret of each narrative in `only` as an agent
113
+ * token, so sessions launched before agent tokens and still running keep
114
+ * working: their tmux env holds that secret, and it is now checked by hash.
115
+ * Only running sessions: a dead or archived narrative's stored secret would
116
+ * otherwise become a permanent bearer token for a session nobody is running
117
+ * (367 of them on the first machine this ran on, claude-jrjh1e8w:13 #3).
118
+ * Omit `only` to adopt every stored secret (tests). Idempotent, and a hash
119
+ * already known — revoked or not — is never adopted again. The plaintext
120
+ * column is left as it is; a secret not adopted authenticates nothing. */
121
+ export function adoptBindTokens(db, now = Date.now(), only) {
122
+ const rows = db
123
+ .prepare("SELECT id, bind_token FROM narratives WHERE bind_token IS NOT NULL AND bind_token != ''")
124
+ .all().filter((r) => !only || only.has(r.id));
125
+ const known = db.prepare("SELECT 1 FROM tokens WHERE hash = ?");
126
+ const insert = db.prepare(`INSERT INTO tokens (id, hash, kind, label, origin, created_at, last_used_at, owner_nid)
127
+ VALUES (?, ?, 'agent', ?, 'adopted', ?, NULL, ?)`);
128
+ let adopted = 0;
129
+ db.transaction(() => {
130
+ for (const r of rows) {
131
+ const hash = hashToken(r.bind_token);
132
+ if (known.get(hash))
133
+ continue;
134
+ insert.run(randomUUID(), hash, r.id, now, r.id);
135
+ adopted++;
136
+ }
137
+ })();
138
+ return adopted;
139
+ }
140
+ /** `last_used_at` is a "last seen" for a devices list, not an audit log, so a
141
+ * busy token writes it at most this often rather than on every request. */
142
+ const TOUCH_INTERVAL_MS = 60_000;
143
+ export { LEGACY_TOKEN_LABEL };
144
+ const ROW_COLUMNS = "id, kind, label, origin, created_at, last_used_at, user_agent, remote_addr, " +
145
+ "last_user_agent, last_remote_addr, app_origin, last_app_origin, elsewhere_at, minted_by, owner_nid";
146
+ /** `ROW_COLUMNS` qualified as `t.`, plus what the minting token is, for the list. */
147
+ const LISTING_COLUMNS = ROW_COLUMNS.split(", ").map((c) => `t.${c}`).join(", ") +
148
+ ", m.kind AS minted_by_kind, m.user_agent AS minted_by_user_agent";
149
+ /** The rows `UNUSED_TOKEN_LAPSE_MS` applies to, as SQL: minted device tokens
150
+ * never presented. */
151
+ const UNUSED_DEVICE = "kind = 'device' AND origin = 'minted' AND last_used_at IS NULL";
152
+ export class DbTokenStore {
153
+ db;
154
+ touched = new Map();
155
+ /** Rows presented since this daemon started. A token's first use is what
156
+ * saves it from lapsing, and the `last_used_at` stamp that records it is
157
+ * best-effort; this is the in-memory record that cannot fail. */
158
+ used = new Set();
159
+ /** Rows whose sighting is on disk, so a busy token checks it once per
160
+ * daemon rather than on every request. */
161
+ sighted = new Set();
162
+ /** When this daemon last stamped `elsewhere_at`, per row, so a copy in
163
+ * steady use writes at most once a minute. */
164
+ elsewhereStamped = new Map();
165
+ constructor(db) {
166
+ this.db = db;
167
+ }
168
+ find(hash, now, seen) {
169
+ const row = this.db
170
+ .prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE hash = ? AND revoked_at IS NULL`)
171
+ .get(hash);
172
+ return row ? this.admit(row, now, seen) : null;
173
+ }
174
+ account(now, seen) {
175
+ const row = this.db
176
+ .prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE kind = 'local' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1`)
177
+ .get();
178
+ return row ? this.admit(row, now, seen) : null;
179
+ }
180
+ /** A live row being used: refuse it if it has lapsed, else stamp its
181
+ * sighting and last use, and return it. */
182
+ admit(row, now, seen) {
183
+ if (this.lapsed(row, now)) {
184
+ // Refused whether or not the revoke lands; the sweep retries it.
185
+ try {
186
+ this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(now, row.id);
187
+ }
188
+ catch { }
189
+ return null;
190
+ }
191
+ this.used.add(row.id);
192
+ if (seen && !this.sighted.has(row.id)) {
193
+ if (row.user_agent !== null || row.remote_addr !== null)
194
+ this.sighted.add(row.id);
195
+ // Best-effort, like the stamp below; the next request retries it.
196
+ else
197
+ try {
198
+ if (this.recordSighting(row.id, seen))
199
+ this.sighted.add(row.id);
200
+ }
201
+ catch { }
202
+ }
203
+ // The origin is sighted on its own, not with the user agent and address:
204
+ // the first request to present a token is often a same-origin GET, which
205
+ // carries no Origin, so it is the first request that *has* one — in
206
+ // practice the control socket's upgrade — that names the app. Written
207
+ // once, like the sighting, and best-effort, like everything here.
208
+ const origin = seen ? cleanOrigin(seen.origin) : null;
209
+ if (origin !== null && row.app_origin === null) {
210
+ try {
211
+ this.recordOrigin(row.id, origin);
212
+ }
213
+ catch { }
214
+ }
215
+ // The first sighting names the row; the latest, and whether it came from
216
+ // somewhere else, is what shows a copied token. An address is the one
217
+ // thing about a request its sender cannot choose, so it is what's compared.
218
+ const userAgent = seen ? cleanSighting(seen.userAgent, MAX_USER_AGENT_LENGTH) : null;
219
+ const remoteAddr = seen ? normalizeRemoteAddr(seen.remoteAddr) : null;
220
+ const elsewhere = remoteAddr !== null && row.remote_addr !== null && !samePlace(remoteAddr, row.remote_addr);
221
+ const last = this.touched.get(row.id) ?? row.last_used_at ?? 0;
222
+ const lastElsewhere = this.elsewhereStamped.get(row.id) ?? row.elsewhere_at ?? 0;
223
+ if (now - last >= TOUCH_INTERVAL_MS || (elsewhere && now - lastElsewhere >= TOUCH_INTERVAL_MS)) {
224
+ // Best-effort: a "last seen" stamp must never fail the request it rides
225
+ // on (a busy or read-only database, say). The next request retries it.
226
+ try {
227
+ this.db
228
+ .prepare(`UPDATE tokens SET last_used_at = ?,
229
+ last_user_agent = COALESCE(?, last_user_agent),
230
+ last_remote_addr = COALESCE(?, last_remote_addr),
231
+ last_app_origin = COALESCE(?, last_app_origin),
232
+ elsewhere_at = CASE WHEN ? THEN ? ELSE elsewhere_at END
233
+ WHERE id = ?`)
234
+ .run(now, userAgent, remoteAddr, origin, elsewhere ? 1 : 0, now, row.id);
235
+ this.touched.set(row.id, now);
236
+ if (elsewhere)
237
+ this.elsewhereStamped.set(row.id, now);
238
+ }
239
+ catch { }
240
+ }
241
+ return { id: row.id, kind: row.kind, label: row.label, ...(row.owner_nid ? { ownerNid: row.owner_nid } : {}) };
242
+ }
243
+ /** Whether this hash has *ever* been a token, revoked or not — so a
244
+ * revoked token presented again at startup is never re-adopted. */
245
+ known(hash) {
246
+ return this.db.prepare("SELECT 1 FROM tokens WHERE hash = ?").get(hash) !== undefined;
247
+ }
248
+ insert(hash, kind, label, now, opts = {}) {
249
+ const record = this.write(hash, kind, label, now, "minted");
250
+ if (opts.mintedBy)
251
+ this.db.prepare("UPDATE tokens SET minted_by = ? WHERE id = ?").run(opts.mintedBy, record.id);
252
+ if (opts.used) {
253
+ this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").run(now, record.id);
254
+ this.used.add(record.id);
255
+ this.touched.set(record.id, now);
256
+ }
257
+ // Best-effort: a rotation's replacement is inserted inside the rotation's
258
+ // transaction, and a label for a list must never be what aborts it.
259
+ if (opts.seen)
260
+ try {
261
+ if (this.recordSighting(record.id, opts.seen))
262
+ this.sighted.add(record.id);
263
+ }
264
+ catch { }
265
+ if (opts.seen) {
266
+ const origin = cleanOrigin(opts.seen.origin);
267
+ if (origin !== null)
268
+ try {
269
+ this.recordOrigin(record.id, origin);
270
+ }
271
+ catch { }
272
+ }
273
+ return record;
274
+ }
275
+ /** Write a row's first origin, if it has none; the latest rides with
276
+ * `last_used_at`. Never overwrites one, for the same reason as the
277
+ * sighting: the row keeps naming the app that claimed it. */
278
+ recordOrigin(id, origin) {
279
+ this.db
280
+ .prepare("UPDATE tokens SET app_origin = ?, last_app_origin = COALESCE(last_app_origin, ?) WHERE id = ? AND app_origin IS NULL")
281
+ .run(origin, origin, id);
282
+ }
283
+ /** Write a row's first sighting, if it has none. Never overwrites one: the
284
+ * row should keep naming the device that first presented it, not whoever
285
+ * presents it next. Returns whether anything was written. */
286
+ recordSighting(id, seen) {
287
+ const userAgent = cleanSighting(seen.userAgent, MAX_USER_AGENT_LENGTH);
288
+ const remoteAddr = normalizeRemoteAddr(seen.remoteAddr);
289
+ if (userAgent === null && remoteAddr === null)
290
+ return false;
291
+ this.db
292
+ .prepare("UPDATE tokens SET user_agent = ?, remote_addr = ? WHERE id = ? AND user_agent IS NULL AND remote_addr IS NULL")
293
+ .run(userAgent, remoteAddr, id);
294
+ return true;
295
+ }
296
+ /** Every live row, oldest first, for the Devices list. A minted token that
297
+ * has already lapsed but not yet been swept is left out: it can no longer
298
+ * authenticate, so it is not a device. */
299
+ list(now) {
300
+ const rows = this.db
301
+ .prepare(`SELECT ${LISTING_COLUMNS} FROM tokens t LEFT JOIN tokens m ON m.id = t.minted_by
302
+ WHERE t.revoked_at IS NULL ORDER BY t.created_at, t.id`)
303
+ .all();
304
+ return rows
305
+ .filter((r) => !this.lapsed(r, now))
306
+ .map((r) => ({ ...r, unused: this.unused(r) }));
307
+ }
308
+ retireDependents(_retired, requesterId, replacementId, now) {
309
+ this.db.prepare("UPDATE tokens SET minted_by = ? WHERE minted_by = ?").run(replacementId, requesterId);
310
+ // Every external whose dialer is no longer live — the rows this rotation
311
+ // retired, and any revoked earlier without its externals (by a store
312
+ // from before that rule, say) — so no rotation leaves one standing.
313
+ const ids = this.db
314
+ .prepare(`SELECT t.id FROM tokens t
315
+ JOIN narratives n ON n.id = t.owner_nid
316
+ LEFT JOIN tokens d ON d.id = t.minted_by
317
+ WHERE t.kind = 'agent' AND t.revoked_at IS NULL AND n.agent_type = 'external'
318
+ AND t.minted_by IS NOT NULL AND (d.id IS NULL OR d.revoked_at IS NOT NULL)`)
319
+ .all().map((r) => r.id);
320
+ this.revoke(ids, now);
321
+ return ids;
322
+ }
323
+ /** The live agent tokens of external narratives that any of `dialers`
324
+ * dialed in. An external's token is its dialer's credential in another
325
+ * form — one `/ws/mcp` dial mints it — so it goes wherever its dialer
326
+ * goes, without asking (claude-jrjh1e8w:22). */
327
+ externalsOf(dialers) {
328
+ if (dialers.length === 0)
329
+ return [];
330
+ return this.db
331
+ .prepare(`SELECT t.id FROM tokens t JOIN narratives n ON n.id = t.owner_nid
332
+ WHERE t.kind = 'agent' AND t.revoked_at IS NULL AND n.agent_type = 'external'
333
+ AND t.minted_by IN (${dialers.map(() => "?").join(", ")})`)
334
+ .all(...dialers).map((r) => r.id);
335
+ }
336
+ /** Every live device row minted by `rootId`, directly or through a chain of
337
+ * devices, revoked links included (a laptop revoked last week still
338
+ * connects the phone to the tablet it added) — what a cascading revoke may
339
+ * take. Never the `keep` row or anything it added by a path that does not
340
+ * run through `rootId`: that is the requester, and a cascade that reached
341
+ * it would strand the device doing the revoking. (Not through the root,
342
+ * because the requester is often an *ancestor*: `brane meld` from this
343
+ * machine added the phone, and revoking the phone from here must still
344
+ * take the laptop the phone added.) */
345
+ descendants(rootId, keep) {
346
+ const rows = this.db.prepare("SELECT id, kind, minted_by, revoked_at FROM tokens").all();
347
+ const children = new Map();
348
+ for (const r of rows) {
349
+ if (r.minted_by === null)
350
+ continue;
351
+ const list = children.get(r.minted_by) ?? [];
352
+ list.push(r);
353
+ children.set(r.minted_by, list);
354
+ }
355
+ const subtree = (from, stopAt) => {
356
+ const seen = new Set([from]);
357
+ const queue = [from];
358
+ while (queue.length) {
359
+ for (const c of children.get(queue.shift()) ?? []) {
360
+ if (c.id === stopAt || seen.has(c.id))
361
+ continue;
362
+ seen.add(c.id);
363
+ queue.push(c.id);
364
+ }
365
+ }
366
+ return seen;
367
+ };
368
+ const kept = keep ? subtree(keep, rootId) : new Set();
369
+ const out = [];
370
+ for (const id of subtree(rootId)) {
371
+ if (id === rootId || kept.has(id))
372
+ continue;
373
+ const row = rows.find((r) => r.id === id);
374
+ if (row.kind !== "local" && row.revoked_at === null)
375
+ out.push(id);
376
+ }
377
+ return out;
378
+ }
379
+ /** The narratives each token last launched (spawn, run or revive), not
380
+ * archived, keyed by token id. */
381
+ launched() {
382
+ const rows = this.db
383
+ .prepare(`SELECT id, title, status, agent_type, launched_by FROM narratives
384
+ WHERE launched_by IS NOT NULL AND (archived = 0 OR archived IS NULL)
385
+ ORDER BY created_at`)
386
+ .all();
387
+ const out = new Map();
388
+ for (const { launched_by, ...agent } of rows) {
389
+ const list = out.get(launched_by) ?? [];
390
+ list.push(agent);
391
+ out.set(launched_by, list);
392
+ }
393
+ return out;
394
+ }
395
+ /** One live row by id, or null. */
396
+ get(id) {
397
+ const row = this.db
398
+ .prepare(`SELECT ${LISTING_COLUMNS} FROM tokens t LEFT JOIN tokens m ON m.id = t.minted_by
399
+ WHERE t.id = ? AND t.revoked_at IS NULL`)
400
+ .get(id);
401
+ return row ? { ...row, unused: this.unused(row) } : null;
402
+ }
403
+ unused(row) {
404
+ return row.kind === "device" && row.origin === "minted" && row.last_used_at === null && !this.used.has(row.id);
405
+ }
406
+ lapsed(row, now) {
407
+ return this.unused(row) && now - row.created_at >= UNUSED_TOKEN_LAPSE_MS;
408
+ }
409
+ /** Revoke every minted device token that lapsed unused — the sweep behind
410
+ * `find`'s own check, so a QR nobody scanned does not linger as a live
411
+ * row. Returns how many it revoked. */
412
+ lapseUnused(now) {
413
+ const rows = this.db
414
+ .prepare(`SELECT id FROM tokens WHERE ${UNUSED_DEVICE} AND revoked_at IS NULL AND created_at <= ?`)
415
+ .all(now - UNUSED_TOKEN_LAPSE_MS);
416
+ const ids = rows.map((r) => r.id).filter((id) => !this.used.has(id));
417
+ this.revoke(ids, now);
418
+ return ids.length;
419
+ }
420
+ /** Record a token that existed as plaintext before this daemon hashed it —
421
+ * one that may have been written down somewhere (older daemons logged it). */
422
+ adopt(hash, kind, label, now) {
423
+ return this.write(hash, kind, label, now, "adopted");
424
+ }
425
+ write(hash, kind, label, now, origin) {
426
+ const id = randomUUID();
427
+ this.db
428
+ .prepare("INSERT INTO tokens (id, hash, kind, label, origin, created_at) VALUES (?, ?, ?, ?, ?, ?)")
429
+ .run(id, hash, kind, label, origin, now);
430
+ return { id, kind, label };
431
+ }
432
+ revoke(ids, now) {
433
+ const stmt = this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL");
434
+ this.transaction(() => {
435
+ for (const id of ids)
436
+ stmt.run(now, id);
437
+ });
438
+ }
439
+ transaction(fn) {
440
+ return this.db.transaction(fn)();
441
+ }
442
+ live(kind) {
443
+ return this.db
444
+ .prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE kind = ? AND revoked_at IS NULL ORDER BY created_at`)
445
+ .all(kind).map((row) => ({ id: row.id, kind: row.kind, label: row.label, ...(this.unused(row) ? { unused: true } : {}) }));
446
+ }
447
+ }
448
+ /** Advice while an adopted token is still live, or null. Daemons before
449
+ * hashing printed their token into `daemon.log` at every start, and a
450
+ * `--token` sits in argv and its config, so an adopted token should be
451
+ * retired: one rotation, from the device in hand or the shell, then re-meld
452
+ * the others. (Re-melding first would be undone: a rotation revokes every
453
+ * other device row.) Keyed on `origin`, not the label, so renaming the
454
+ * device does not silence it. */
455
+ export function legacyTokenWarning(db) {
456
+ const live = db
457
+ // Not agent rows: an adopted bind secret dies at its narrative's next
458
+ // revive, and was never something a person could have written down.
459
+ .prepare("SELECT 1 FROM tokens WHERE origin = 'adopted' AND kind != 'agent' AND revoked_at IS NULL")
460
+ .get();
461
+ if (!live)
462
+ return null;
463
+ return ("a token that existed as plaintext before it was hashed is still valid " +
464
+ "(older brane versions printed theirs in daemon.log, and a --token sits in argv and its config). " +
465
+ 'Retire it with "Sign out other devices" in the app, or `brane token rotate`, then re-meld your devices.');
466
+ }
467
+ /** Read `.daemon_token`, the file older branes kept this machine's local
468
+ * token in, or null if it is absent or empty. Nothing presents it any more;
469
+ * it is read at daemon start (to retire what it holds, after which the
470
+ * daemon empties it) and by `brane doctor`. */
471
+ export function readTokenFile(path) {
472
+ try {
473
+ const token = readFileSync(path, "utf-8").trim();
474
+ return token || null;
475
+ }
476
+ catch {
477
+ return null;
478
+ }
479
+ }
480
+ /** What the account row's `hash` column starts with. The rest is a UUID,
481
+ * only to satisfy the column's UNIQUE. */
482
+ export const ACCOUNT_HASH_PREFIX = "no-token:";
483
+ /** Whether a `hash` column holds a token's SHA-256, which some presented
484
+ * token could match — as opposed to the account row's placeholder, which
485
+ * none can: `hashToken` only ever yields 64 hex digits. */
486
+ export function isTokenHash(hash) {
487
+ return /^[0-9a-f]{64}$/.test(hash);
488
+ }
489
+ /** How a token still sitting in `.daemon_token` stands, for `brane doctor`:
490
+ * `live` when it authenticates (a pre-hashing token adopted as a device
491
+ * row, until a rotation retires it), `dead` when it does not. The daemon
492
+ * empties the file at start, so this only arises where it could not. */
493
+ export function tokenFileStanding(db, token) {
494
+ const row = db.prepare("SELECT 1 FROM tokens WHERE hash = ? AND revoked_at IS NULL").get(hashToken(token));
495
+ return row ? "live" : "dead";
496
+ }
497
+ /**
498
+ * Bring the token state to "hashes only, and none for this machine" at
499
+ * daemon start. Idempotent: on an already-migrated install it finds the
500
+ * account row and changes nothing.
501
+ *
502
+ * **This machine holds no token.** Its CLI and MCP bridges reach the daemon
503
+ * over `daemon.sock` and present nothing (`authenticateRequest`); what they
504
+ * act as is the one live `local` row, the account row, whose `hash` is a
505
+ * placeholder no token can match (`ACCOUNT_HASH_PREFIX`). Older branes kept
506
+ * a local token in `.daemon_token`, which worked from any address the IP
507
+ * filter admits, so a copy of the file was this machine's shell from
508
+ * elsewhere. Every `local` row a token can open is revoked here, and the
509
+ * file is then emptied, so no token, live or dead, stays on disk. Its hash
510
+ * stays a revoked row, so an older brane started later cannot re-adopt it.
511
+ * The file itself is not removed; `brane doctor` reports the empty leftover
512
+ * for the operator to remove. What any earlier `local` row had minted,
513
+ * dialed in or launched moves to the account row, so a device `brane meld`
514
+ * added last week is still "added by this machine", and the agents `brane
515
+ * spawn` started are still listed under it.
516
+ *
517
+ * Before hashing, one plaintext token lived in both `meta.daemon_token` and
518
+ * `.daemon_token`, and every melded device held it. That token is adopted as
519
+ * a *device* row, so those devices keep working, and the `meta` row is
520
+ * dropped. Its plaintext leaves the file with everything else, so from then
521
+ * on it exists only on the devices that hold it, and dies with the first
522
+ * rotation like any other device token. A `--token` passed on the command line is adopted
523
+ * the same way, labelled as configured. A token whose hash is already known
524
+ * — live or revoked — is never adopted again, so a revoked token cannot come
525
+ * back through a stale file or a stale `--token`.
526
+ */
527
+ /** The label of a device row `brane meld --remote` sent as a hash. */
528
+ export const REMOTE_MELD_LABEL = "melded remotely";
529
+ /** The device-token hashes in `BRANE_DEVICE_TOKEN_SHA256`: comma-separated
530
+ * lowercase hex SHA-256s. Anything else is dropped, so a malformed secret
531
+ * adopts nothing rather than an unusable row. */
532
+ export function parseTokenHashes(value) {
533
+ return [...new Set((value ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => /^[0-9a-f]{64}$/.test(s)))];
534
+ }
535
+ export function bootstrapTokens(db, opts) {
536
+ const store = new DbTokenStore(db);
537
+ const mint = opts.mint ?? (() => randomUUID());
538
+ const now = opts.now ?? Date.now();
539
+ const adopted = [];
540
+ const adopt = (plain, label) => {
541
+ if (!plain)
542
+ return;
543
+ const hash = hashToken(plain);
544
+ if (store.known(hash))
545
+ return;
546
+ store.adopt(hash, "device", label, now);
547
+ adopted.push(label);
548
+ };
549
+ const fileToken = readTokenFile(opts.tokenFile);
550
+ const metaRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_token'").get();
551
+ // A plain DELETE leaves the old value in the freed page and in WAL frames
552
+ // until something overwrites them. secure_delete zeroes the page, and the
553
+ // checkpoint below folds the WAL back in and truncates it.
554
+ const secureDelete = db.pragma("secure_delete", { simple: true });
555
+ if (metaRow)
556
+ db.pragma("secure_delete = ON");
557
+ db.transaction(() => {
558
+ // A file token that is a `local` row is known, so it is not adopted
559
+ // here; it is retired below.
560
+ adopt(fileToken, LEGACY_TOKEN_LABEL);
561
+ adopt(metaRow?.value, LEGACY_TOKEN_LABEL);
562
+ adopt(opts.configured, "configured with --token");
563
+ for (const hash of opts.configuredHashes ?? []) {
564
+ if (store.known(hash))
565
+ continue;
566
+ // Minted, not adopted: like any QR, it lapses if nobody scans it.
567
+ store.insert(hash, "device", REMOTE_MELD_LABEL, now);
568
+ adopted.push(REMOTE_MELD_LABEL);
569
+ }
570
+ // The plaintext this replaces. Its hash is now a row, so nothing is lost
571
+ // that any device needs.
572
+ db.prepare("DELETE FROM meta WHERE key = 'daemon_token'").run();
573
+ })();
574
+ if (metaRow) {
575
+ db.pragma(`secure_delete = ${secureDelete ? "ON" : "OFF"}`);
576
+ try {
577
+ db.pragma("wal_checkpoint(TRUNCATE)");
578
+ }
579
+ catch { }
580
+ }
581
+ // One live `local` row, and no token opens it.
582
+ const retiredLocal = db.transaction(() => {
583
+ const locals = db
584
+ .prepare("SELECT id, hash FROM tokens WHERE kind = 'local' AND revoked_at IS NULL ORDER BY created_at DESC")
585
+ .all();
586
+ const withToken = locals.filter((r) => isTokenHash(r.hash)).map((r) => r.id);
587
+ const account = locals.find((r) => !isTokenHash(r.hash))?.id ??
588
+ store.insert(`${ACCOUNT_HASH_PREFIX}${mint()}`, "local", "this machine", now).id;
589
+ store.revoke(withToken, now);
590
+ // What any earlier `local` row minted, dialed in or launched is the
591
+ // account's: the rows just retired, and ones revoked before — by a shell
592
+ // rotation on an older brane, which never moved what it had launched, or
593
+ // by an older daemon started in between, which replaces the account row
594
+ // with a token-opened one of its own.
595
+ const earlier = db.prepare("SELECT id FROM tokens WHERE kind = 'local' AND id != ?").all(account);
596
+ const minted = db.prepare("UPDATE tokens SET minted_by = ? WHERE minted_by = ?");
597
+ const launched = db.prepare("UPDATE narratives SET launched_by = ? WHERE launched_by = ?");
598
+ for (const { id } of earlier) {
599
+ minted.run(account, id);
600
+ launched.run(account, id);
601
+ }
602
+ return withToken.length;
603
+ })();
604
+ // Whatever the file held is a row now, so nothing is lost with it: a
605
+ // revoked local token, or a pre-hashing token its devices still hold.
606
+ // Emptied only after the rows are written, and never fatal: a file that
607
+ // cannot be emptied is reported, by the caller and by `brane doctor`.
608
+ let tokenFile = "absent";
609
+ if (fileToken) {
610
+ try {
611
+ writeFileSync(opts.tokenFile, "", { mode: 0o600 });
612
+ chmodSync(opts.tokenFile, 0o600);
613
+ tokenFile = "emptied";
614
+ }
615
+ catch (err) {
616
+ tokenFile = { error: err.message };
617
+ }
618
+ }
619
+ // Sessions launched before agent tokens hold a plaintext bind secret in
620
+ // their env; adopting each as an agent row keeps them working.
621
+ adoptBindTokens(db, now, opts.liveSessions ?? new Set());
622
+ return { adopted, retiredLocal, tokenFile };
623
+ }
624
+ //# sourceMappingURL=tokens.js.map