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.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a `human_signal` is from, decided by the token that sent it, never by
|
|
3
|
+
* the request. Both ways in — `POST /api/narratives/:id/signal` and the
|
|
4
|
+
* control socket's `signal` message — used to take `source` from the body,
|
|
5
|
+
* so any token holder could write the operator's kind of event under any
|
|
6
|
+
* label, and an agent's token could post "web" signals into any narrative.
|
|
7
|
+
*
|
|
8
|
+
* A signal is the operator speaking. A device token is the operator's
|
|
9
|
+
* delegate (the app), and this account over `daemon.sock` is the operator's
|
|
10
|
+
* shell; an agent token is neither. An agent that wants to speak into
|
|
11
|
+
* another narrative narrates into it, which the daemon records as an
|
|
12
|
+
* `agent_signal` stamped with the agent's own narrative.
|
|
13
|
+
*/
|
|
14
|
+
/** The `source` stamped on a signal sent with each kind of token. "web" is
|
|
15
|
+
* what the app always sent, so existing bubbles and new ones read alike. */
|
|
16
|
+
export const SIGNAL_SOURCES = { device: "web", local: "local" };
|
|
17
|
+
export const AGENT_SIGNAL_REFUSAL = "An agent's token cannot send a human_signal; narrate into the narrative instead (it arrives as an agent_signal)";
|
|
18
|
+
/** The source to stamp, or the refusal to send. */
|
|
19
|
+
export function signalStanding(record) {
|
|
20
|
+
if (record.kind === "agent")
|
|
21
|
+
return { refused: AGENT_SIGNAL_REFUSAL };
|
|
22
|
+
return { source: SIGNAL_SOURCES[record.kind] };
|
|
23
|
+
}
|
|
24
|
+
/** The fields a `human_signal` is appended with, given the token's standing
|
|
25
|
+
* and the request (an HTTP body, or a control-socket message). `content`
|
|
26
|
+
* and `re` come from the request; `source` never does, whatever it says. */
|
|
27
|
+
export function signalEventFields(standing, request, payloads) {
|
|
28
|
+
return {
|
|
29
|
+
content: request.content,
|
|
30
|
+
source: standing.source,
|
|
31
|
+
re: request.re,
|
|
32
|
+
...(payloads ? { payloads } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=signal-source.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Tailscale re-probe policy for the daemon's listeners.
|
|
2
|
+
//
|
|
3
|
+
// `startServer` asks Tailscale for its addresses exactly once, at startup, and
|
|
4
|
+
// binds to whatever comes back. That is the right first move and the wrong
|
|
5
|
+
// last one: a `tailscale status` that times out — the extension wedged, the
|
|
6
|
+
// app still launching after a reboot — leaves the daemon bound to loopback
|
|
7
|
+
// only, and it stays that way for as long as it runs. Observed on 2026-08-30:
|
|
8
|
+
// three consecutive daemon starts logged "No Tailscale detected" while the
|
|
9
|
+
// Tailscale extension was hung, and the phone stayed locked out through the
|
|
10
|
+
// recovery until the operator happened to restart the daemon again
|
|
11
|
+
// (claude-9ya34xr8:13, :23).
|
|
12
|
+
//
|
|
13
|
+
// The policy here is window-free and pure so `src/test/tailscale-rebind.test.ts`
|
|
14
|
+
// can pin it: how long to wait before asking again, and which of the addresses
|
|
15
|
+
// an answer carries still need a listener.
|
|
16
|
+
/** First retry after a probe that found nothing. */
|
|
17
|
+
export const TAILSCALE_PROBE_INITIAL_MS = 5_000;
|
|
18
|
+
/** Ceiling for the not-found backoff. */
|
|
19
|
+
export const TAILSCALE_PROBE_MAX_MS = 60_000;
|
|
20
|
+
/** Cadence once Tailscale has been seen — catches address changes and a
|
|
21
|
+
* restart that came back with new IPs. */
|
|
22
|
+
export const TAILSCALE_PROBE_STEADY_MS = 60_000;
|
|
23
|
+
/**
|
|
24
|
+
* Delay before the next probe.
|
|
25
|
+
*
|
|
26
|
+
* Not found: exponential backoff from `TAILSCALE_PROBE_INITIAL_MS`, doubling
|
|
27
|
+
* up to `TAILSCALE_PROBE_MAX_MS`. A Tailscale that is starting up is usually
|
|
28
|
+
* ready within seconds; one that is wedged is not, and there is no point
|
|
29
|
+
* hammering a hung CLI.
|
|
30
|
+
*
|
|
31
|
+
* Found: `TAILSCALE_PROBE_STEADY_MS`, regardless of what came before.
|
|
32
|
+
*/
|
|
33
|
+
export function nextProbeDelay(previous, found) {
|
|
34
|
+
if (found)
|
|
35
|
+
return TAILSCALE_PROBE_STEADY_MS;
|
|
36
|
+
if (previous === null || previous <= 0)
|
|
37
|
+
return TAILSCALE_PROBE_INITIAL_MS;
|
|
38
|
+
return Math.min(previous * 2, TAILSCALE_PROBE_MAX_MS);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Addresses a probe returned that the daemon has not bound yet, in the order
|
|
42
|
+
* Tailscale reported them, without duplicates or blanks. An address that is
|
|
43
|
+
* already bound is never returned — rebinding it would throw EADDRINUSE
|
|
44
|
+
* against our own listener.
|
|
45
|
+
*/
|
|
46
|
+
export function addressesToBind(bound, probed) {
|
|
47
|
+
const have = new Set(bound);
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const raw of probed) {
|
|
50
|
+
const addr = raw.trim();
|
|
51
|
+
if (!addr || have.has(addr))
|
|
52
|
+
continue;
|
|
53
|
+
have.add(addr);
|
|
54
|
+
out.push(addr);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=tailscale-rebind.js.map
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's bearer tokens, and how they change hands.
|
|
3
|
+
*
|
|
4
|
+
* **Only hashes are stored.** Every token is a random UUID the daemon hands
|
|
5
|
+
* out exactly once — to the device that asked, or to the agent session it
|
|
6
|
+
* launches — and keeps nothing but its SHA-256 in the `tokens` table
|
|
7
|
+
* (`src/db/tokens.ts`). That table therefore
|
|
8
|
+
* authenticates nothing by itself, and the daemon cannot print a token back:
|
|
9
|
+
* a device that needs one gets a freshly minted one (`POST /api/tokens`,
|
|
10
|
+
* which is what `brane meld` calls). This is a claim about the table, not
|
|
11
|
+
* about `brane.db`: events and captured tool calls are stored verbatim
|
|
12
|
+
* (`prepareCaptureText`, src/db/tool-uses.ts), so a token an agent printed or
|
|
13
|
+
* quoted is in the database in the clear until a rotation retires it. The tokens are 122 random bits, so a plain
|
|
14
|
+
* SHA-256 is the right hash; a slow one only helps a guessable secret.
|
|
15
|
+
*
|
|
16
|
+
* A `device` token is held by a web client as a durable credential — a
|
|
17
|
+
* registry entry written from a meld QR. This machine's own CLI and MCP
|
|
18
|
+
* bridges hold none: they reach the daemon over `daemon.sock` and present
|
|
19
|
+
* nothing, and act as the one live `local` row, the account row, which no
|
|
20
|
+
* token opens (`bootstrapTokens`, src/db/tokens.ts).
|
|
21
|
+
*
|
|
22
|
+
* Rotation is what "sign out other devices" means here. The hard part is not
|
|
23
|
+
* the rotation but not stranding the device that asked for it. The obvious
|
|
24
|
+
* shape — rotate returns the new token and the old dies on the spot — has one
|
|
25
|
+
* failure the request layer's own history makes real: on a satellite link, if
|
|
26
|
+
* the *response* is lost, the daemon has rotated and nobody holds the new
|
|
27
|
+
* token, the requester included. And a `POST` is rightly never retried, so
|
|
28
|
+
* the client cannot paper over it.
|
|
29
|
+
*
|
|
30
|
+
* So the swap happens **on first use**. `beginRotation` mints a *pending*
|
|
31
|
+
* token for the requester and changes nothing durable; every live token still
|
|
32
|
+
* authenticates. The first request that presents the pending token is the
|
|
33
|
+
* proof it arrived, and only then is it written down: it becomes a row of the
|
|
34
|
+
* requester's kind, and the requester's old row and every other device row
|
|
35
|
+
* are revoked — `authenticate` reports which, and the daemon closes the
|
|
36
|
+
* sockets they opened. The account row is never among them, so the CLI and
|
|
37
|
+
* MCP bridges ride through a phone's rotation the way they always have. A
|
|
38
|
+
* pending token nobody ever uses lapses after
|
|
39
|
+
* `ROTATION_GRACE_MS` and nothing has changed: a lost response costs nothing,
|
|
40
|
+
* and a restart mid-handshake (this state is in-memory) lands on the safe
|
|
41
|
+
* side.
|
|
42
|
+
*
|
|
43
|
+
* What rotation guarantees is only this: once it completes, holders of the
|
|
44
|
+
* requester's old token, and of every other device token, are out. It cannot
|
|
45
|
+
* stop a token holder from calling the route first — a token already grants
|
|
46
|
+
* keystrokes into every tmux session, so that adds nothing — but it must
|
|
47
|
+
* never *help* them, which is why a pending token is issued exactly once
|
|
48
|
+
* (see `beginRotation`).
|
|
49
|
+
*
|
|
50
|
+
* The shell's "sign out every device" (`brane token rotate`) needs none of
|
|
51
|
+
* that: it holds no token to swap, so nothing of its own can be stranded by
|
|
52
|
+
* a lost response, and `signOutDevices` revokes on the spot.
|
|
53
|
+
*
|
|
54
|
+
* Rows are revoked, never deleted.
|
|
55
|
+
*/
|
|
56
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
57
|
+
/** How long a minted-but-unused token stays valid before the rotation is
|
|
58
|
+
* abandoned. Long enough for a slow link's round trip plus a reconnect;
|
|
59
|
+
* short enough that a token stolen at the worst moment is not good for long
|
|
60
|
+
* if the requester vanishes. */
|
|
61
|
+
export const ROTATION_GRACE_MS = 60_000;
|
|
62
|
+
/** How long a minted device token may sit unused before it lapses. A meld QR
|
|
63
|
+
* is scanned on the spot; one that never was — every opening of the app's
|
|
64
|
+
* Meld sheet mints one — would otherwise stay a full-power credential for
|
|
65
|
+
* good, wherever the QR was photographed, and a phantom row in any devices
|
|
66
|
+
* list. First use makes a token permanent, as before. Only minted device
|
|
67
|
+
* tokens lapse: the account row and adopted ones are already in use. */
|
|
68
|
+
/** Label for the token brane used before hashing, adopted as a device row so
|
|
69
|
+
* every device that already holds it keeps working. Only a label: what marks
|
|
70
|
+
* an adopted row is its `origin`, which no rename can change. */
|
|
71
|
+
export const LEGACY_TOKEN_LABEL = "melded before hashing";
|
|
72
|
+
/** What a rotation's replacement row is labelled when the requester's own
|
|
73
|
+
* label was `LEGACY_TOKEN_LABEL`: the new token was minted, not melded
|
|
74
|
+
* before hashing, and a label that says otherwise is wrong. */
|
|
75
|
+
export const ROTATED_LEGACY_LABEL = "kept when other devices were signed out";
|
|
76
|
+
export const UNUSED_TOKEN_LAPSE_MS = 10 * 60_000;
|
|
77
|
+
export { WS_CLOSE_TOKEN_ROTATED } from "../shared/token-rotation.js";
|
|
78
|
+
export function hashToken(token) {
|
|
79
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
80
|
+
}
|
|
81
|
+
/** A device token for a daemon on another machine (`brane meld --remote`),
|
|
82
|
+
* and its hash. Nothing here stores either: only the hash goes to that
|
|
83
|
+
* machine (`BRANE_DEVICE_TOKEN_SHA256`), which its daemon adopts at
|
|
84
|
+
* startup, and the token goes no further than the QR. */
|
|
85
|
+
export function mintRemoteToken() {
|
|
86
|
+
const token = randomBytes(32).toString("base64url");
|
|
87
|
+
return { token, hash: hashToken(token) };
|
|
88
|
+
}
|
|
89
|
+
export class TokenRing {
|
|
90
|
+
store;
|
|
91
|
+
mint;
|
|
92
|
+
pending = null;
|
|
93
|
+
constructor(store, mint) {
|
|
94
|
+
this.store = store;
|
|
95
|
+
this.mint = mint;
|
|
96
|
+
}
|
|
97
|
+
/** The unexpired pending rotation, if any; expiry is checked lazily here
|
|
98
|
+
* rather than by a timer so the ring has no lifecycle of its own. */
|
|
99
|
+
pendingRotation(now = Date.now()) {
|
|
100
|
+
if (this.pending && this.pending.expiresAt <= now)
|
|
101
|
+
this.pending = null;
|
|
102
|
+
return this.pending;
|
|
103
|
+
}
|
|
104
|
+
/** Mint a new token and record only its hash — the caller hands the
|
|
105
|
+
* plaintext to whoever asked and keeps no copy. */
|
|
106
|
+
issue(kind, label, now = Date.now(), opts = {}) {
|
|
107
|
+
const token = this.mint();
|
|
108
|
+
return { token, record: this.store.insert(hashToken(token), kind, label, now, opts) };
|
|
109
|
+
}
|
|
110
|
+
/** Mint a pending token for `requester`. A rotation already pending is
|
|
111
|
+
* *replaced*, never re-issued: the only copy of a pending token must be
|
|
112
|
+
* the response that minted it. Handing the same token to a second caller
|
|
113
|
+
* would give it to exactly the party rotation exists to evict — anyone
|
|
114
|
+
* holding a token can call this route too, and during the window they
|
|
115
|
+
* would be handed the new one to use first. Replacing instead means a
|
|
116
|
+
* racing rotate invalidates the requester's token, whose confirming
|
|
117
|
+
* request then fails visibly (and `replaced` is logged) rather than
|
|
118
|
+
* succeeding with a guest aboard. Nothing is lost for the honest retry: a
|
|
119
|
+
* client that timed out has discarded the first response, so a fresh
|
|
120
|
+
* token is as good.
|
|
121
|
+
*
|
|
122
|
+
* Throws for a `local` requester. Completing a rotation writes a row of
|
|
123
|
+
* the requester's kind under the new token, so one begun for this account
|
|
124
|
+
* would put back a token that opens the `local` row — the copyable local
|
|
125
|
+
* token `bootstrapTokens` retires. That "no token opens this machine's
|
|
126
|
+
* row" must hold here, not only in the one route that calls this
|
|
127
|
+
* (`gatedRotateRoute`, which sends this account to `signOutDevices`). */
|
|
128
|
+
beginRotation(requester, now = Date.now()) {
|
|
129
|
+
if (requester.kind === "local") {
|
|
130
|
+
throw new Error("This account holds no token to rotate; signOutDevices is what signs devices out for it");
|
|
131
|
+
}
|
|
132
|
+
const replaced = this.pendingRotation(now) !== null;
|
|
133
|
+
const token = this.mint();
|
|
134
|
+
this.pending = { hash: hashToken(token), requester, expiresAt: now + ROTATION_GRACE_MS };
|
|
135
|
+
return { token, expiresAt: this.pending.expiresAt, replaced };
|
|
136
|
+
}
|
|
137
|
+
/** Abandon a pending rotation begun by `tokenId`. Revoking a device must
|
|
138
|
+
* also void a rotation it started: otherwise its pending token would
|
|
139
|
+
* confirm afterwards and write the revoked device back in as a fresh row,
|
|
140
|
+
* signing out everyone else on the way. Returns whether one was dropped. */
|
|
141
|
+
abandonRotationBy(tokenId) {
|
|
142
|
+
if (this.pending?.requester.id !== tokenId)
|
|
143
|
+
return false;
|
|
144
|
+
this.pending = null;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
/** Sign out every device at once, for a requester that holds no token:
|
|
148
|
+
* this account, over the daemon's socket. Revokes every live device row
|
|
149
|
+
* and the externals they dialed in, and voids any pending rotation — its
|
|
150
|
+
* requester is a device, which has just been signed out, and its pending
|
|
151
|
+
* token must not write it back in. Returns the revoked ids, whose sockets
|
|
152
|
+
* the caller closes, and how many of them were devices someone was signed
|
|
153
|
+
* in on: an unscanned QR is revoked too, but it was nobody's device. */
|
|
154
|
+
signOutDevices(requester, now = Date.now()) {
|
|
155
|
+
const replaced = this.pendingRotation(now) !== null;
|
|
156
|
+
const result = this.store.transaction(() => {
|
|
157
|
+
const live = this.store.live("device");
|
|
158
|
+
const devices = live.map((d) => d.id);
|
|
159
|
+
this.store.revoke(devices, now);
|
|
160
|
+
const dependents = this.store.retireDependents?.(devices, requester.id, requester.id, now) ?? [];
|
|
161
|
+
return { ids: [...new Set([...devices, ...dependents])], devices: live.filter((d) => !d.unused).length };
|
|
162
|
+
});
|
|
163
|
+
this.pending = null;
|
|
164
|
+
return { ...result, replaced };
|
|
165
|
+
}
|
|
166
|
+
/** Authenticate a request that presented no token but arrived where only
|
|
167
|
+
* this Unix account can reach (the daemon's socket, its directory
|
|
168
|
+
* checked): it acts as the live `local` row. The caller decides the
|
|
169
|
+
* "where"; this only says whether there is a row to act as. */
|
|
170
|
+
authenticateAccount(now = Date.now(), seen) {
|
|
171
|
+
const record = this.store.account?.(now, seen) ?? null;
|
|
172
|
+
return record ? { ok: true, record } : { ok: false };
|
|
173
|
+
}
|
|
174
|
+
/** Check a presented token. A live row always authenticates; the pending
|
|
175
|
+
* token authenticates and, in doing so, is written down and retires the
|
|
176
|
+
* requester's old row and every other device row — the caller gets
|
|
177
|
+
* `retired` exactly once and is expected to act on it (close those rows'
|
|
178
|
+
* sockets) before answering the request. */
|
|
179
|
+
authenticate(presented, now = Date.now(), seen) {
|
|
180
|
+
if (presented == null || presented === "")
|
|
181
|
+
return { ok: false };
|
|
182
|
+
const hash = hashToken(presented);
|
|
183
|
+
const record = this.store.find(hash, now, seen);
|
|
184
|
+
if (record)
|
|
185
|
+
return { ok: true, record };
|
|
186
|
+
const live = this.pendingRotation(now);
|
|
187
|
+
if (live && hash === live.hash) {
|
|
188
|
+
const { requester } = live;
|
|
189
|
+
// One transaction, so a rotation either completes or never happened: a
|
|
190
|
+
// new token that works while nobody was signed out is the failure this
|
|
191
|
+
// must not have. If it throws, the pending token survives for a retry.
|
|
192
|
+
const result = this.store.transaction(() => {
|
|
193
|
+
// Written as used: this very request is its first use, and a device
|
|
194
|
+
// that rotates and then only holds a socket open must not lapse. Its
|
|
195
|
+
// sighting is this request's — the device that asked to rotate.
|
|
196
|
+
const label = requester.label === LEGACY_TOKEN_LABEL ? ROTATED_LEGACY_LABEL : requester.label;
|
|
197
|
+
const replacement = this.store.insert(hash, requester.kind, label, now, { used: true, seen });
|
|
198
|
+
const ids = new Set([requester.id]);
|
|
199
|
+
for (const d of this.store.live("device"))
|
|
200
|
+
if (d.id !== replacement.id)
|
|
201
|
+
ids.add(d.id);
|
|
202
|
+
this.store.revoke([...ids], now);
|
|
203
|
+
for (const x of this.store.retireDependents?.([...ids], requester.id, replacement.id, now) ?? [])
|
|
204
|
+
ids.add(x);
|
|
205
|
+
return { replacement, ids: [...ids] };
|
|
206
|
+
});
|
|
207
|
+
this.pending = null;
|
|
208
|
+
return { ok: true, record: result.replacement, retired: { ids: result.ids } };
|
|
209
|
+
}
|
|
210
|
+
return { ok: false };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** A `TokenStore` in memory, for the suite and for anything that needs a
|
|
214
|
+
* ring without a database. */
|
|
215
|
+
export class MemoryTokenStore {
|
|
216
|
+
rows = new Map();
|
|
217
|
+
seq = 0;
|
|
218
|
+
/** Seed with plaintext tokens — only their hashes are kept. */
|
|
219
|
+
constructor(seed = []) {
|
|
220
|
+
for (const s of seed)
|
|
221
|
+
this.insert(hashToken(s.token), s.kind, s.label ?? s.kind);
|
|
222
|
+
}
|
|
223
|
+
find(hash) {
|
|
224
|
+
for (const r of this.rows.values()) {
|
|
225
|
+
if (r.hash === hash && r.revokedAt === null)
|
|
226
|
+
return { id: r.id, kind: r.kind, label: r.label };
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
insert(hash, kind, label) {
|
|
231
|
+
const id = `t${++this.seq}`;
|
|
232
|
+
this.rows.set(id, { id, kind, label, hash, revokedAt: null });
|
|
233
|
+
return { id, kind, label };
|
|
234
|
+
}
|
|
235
|
+
revoke(ids, now) {
|
|
236
|
+
for (const id of ids) {
|
|
237
|
+
const r = this.rows.get(id);
|
|
238
|
+
if (r && r.revokedAt === null)
|
|
239
|
+
r.revokedAt = now;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
live(kind) {
|
|
243
|
+
return [...this.rows.values()]
|
|
244
|
+
.filter((r) => r.kind === kind && r.revokedAt === null)
|
|
245
|
+
.map(({ id, kind, label }) => ({ id, kind, label }));
|
|
246
|
+
}
|
|
247
|
+
account() {
|
|
248
|
+
return this.live("local")[0] ?? null;
|
|
249
|
+
}
|
|
250
|
+
transaction(fn) {
|
|
251
|
+
const snapshot = new Map([...this.rows].map(([id, r]) => [id, { ...r }]));
|
|
252
|
+
const seq = this.seq;
|
|
253
|
+
try {
|
|
254
|
+
return fn();
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
this.rows = snapshot;
|
|
258
|
+
this.seq = seq;
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
//# sourceMappingURL=token-ring.js.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pieces of the token routes that hold their own logic, kept out of the
|
|
3
|
+
* server's closure so the suite can drive them (`src/test/token-routes.test.ts`,
|
|
4
|
+
* `src/test/token-devices.test.ts`): minting a device token, finishing a
|
|
5
|
+
* rotation, and the Devices list with its per-device revoke.
|
|
6
|
+
*/
|
|
7
|
+
import { describeUserAgent } from "../user-agent.js";
|
|
8
|
+
import { UNUSED_TOKEN_LAPSE_MS, WS_CLOSE_TOKEN_ROTATED } from "./token-ring.js";
|
|
9
|
+
const MAX_LABEL_LENGTH = 200;
|
|
10
|
+
/** The label a `POST /api/tokens` body asks for, or why the body is refused.
|
|
11
|
+
* Separate from minting so the route can refuse a bad body before it asks
|
|
12
|
+
* the operator to approve anything (src/daemon/presence-gate.ts). */
|
|
13
|
+
export function parseMintBody(rawBody, now = Date.now()) {
|
|
14
|
+
let parsed = {};
|
|
15
|
+
if (rawBody && rawBody.trim()) {
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(rawBody);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { error: "Body must be a JSON object" };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
24
|
+
return { error: "Body must be a JSON object" };
|
|
25
|
+
}
|
|
26
|
+
const { label } = parsed;
|
|
27
|
+
// Control characters become spaces: a label is logged and will be shown in
|
|
28
|
+
// a devices list, and a newline in either is a forged line.
|
|
29
|
+
const clean = typeof label === "string" ? label.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim() : "";
|
|
30
|
+
return { label: clean ? clean.slice(0, MAX_LABEL_LENGTH) : `melded ${new Date(now).toISOString().slice(0, 10)}` };
|
|
31
|
+
}
|
|
32
|
+
/** `POST /api/tokens`: mint a device token from a request body. The daemon
|
|
33
|
+
* keeps only the hash, so the `token` in a 200 is the one and only copy, and
|
|
34
|
+
* `expiresAt` is when it lapses if nothing has used it by then. An
|
|
35
|
+
* empty body is fine (`brane meld` sends `{}` or nothing); a body that is not
|
|
36
|
+
* a JSON object is a 400. */
|
|
37
|
+
export function mintDeviceTokenRoute(ring, rawBody, now = Date.now(),
|
|
38
|
+
/** The requester's row: recorded on the new one, so the list can say which
|
|
39
|
+
* device added which, and nothing later has to guess. */
|
|
40
|
+
mintedBy) {
|
|
41
|
+
const parsed = parseMintBody(rawBody, now);
|
|
42
|
+
if ("error" in parsed)
|
|
43
|
+
return { status: 400, body: parsed };
|
|
44
|
+
const { token, record } = ring.issue("device", parsed.label, now, { mintedBy: mintedBy?.id });
|
|
45
|
+
return {
|
|
46
|
+
status: 200,
|
|
47
|
+
body: {
|
|
48
|
+
token,
|
|
49
|
+
id: record.id,
|
|
50
|
+
label: record.label,
|
|
51
|
+
expiresAt: now + UNUSED_TOKEN_LAPSE_MS,
|
|
52
|
+
// Relative too, for a client whose clock is not the daemon's.
|
|
53
|
+
validForMs: UNUSED_TOKEN_LAPSE_MS,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Finish a rotation, inside the request that first used the new token and
|
|
58
|
+
* before it is answered: close every viewer socket opened under a revoked
|
|
59
|
+
* row with 4401, and only those. */
|
|
60
|
+
export function retireTokens(opts) {
|
|
61
|
+
return { closed: closeSocketsOf(opts.viewerSockets, opts.retired.ids, "token rotated") };
|
|
62
|
+
}
|
|
63
|
+
/** Whether a socket opened under `record` on `channel` must be closed when
|
|
64
|
+
* that token is revoked or rotated away. Control and terminal sockets
|
|
65
|
+
* always. An MCP socket only under a device token: a device token can open
|
|
66
|
+
* `/ws/mcp`, which reaches every MCP tool, so a revoke that left it open
|
|
67
|
+
* would leave the revoked token working for as long as it stayed connected;
|
|
68
|
+
* an agent token's MCP socket is that agent's bridge, and closing it is what
|
|
69
|
+
* revoking an agent means.
|
|
70
|
+
* A local bridge is exempt — it dialed as this account, which is not what
|
|
71
|
+
* a device revoke or rotation retires. */
|
|
72
|
+
export function closesOnRevoke(channel, record) {
|
|
73
|
+
return channel !== "mcp" || record.kind !== "local";
|
|
74
|
+
}
|
|
75
|
+
/** Which rows own a socket. Usually one, the token that opened it. An MCP
|
|
76
|
+
* socket bound to a narrative also belongs to that narrative's agent token,
|
|
77
|
+
* whoever dialed it: a device's socket must close when the *device* is
|
|
78
|
+
* revoked (the dialer), and an external's socket dialed as this account
|
|
79
|
+
* must close when its *agent* is revoked (the narrative). Tagging only one
|
|
80
|
+
* of the two is how a revoked device kept its MCP socket open
|
|
81
|
+
* (claude-jrjh1e8w:13 #1). */
|
|
82
|
+
export function socketOwners(channel, record, narrativeAgentToken) {
|
|
83
|
+
const ids = closesOnRevoke(channel, record) ? [record.id] : [];
|
|
84
|
+
if (channel === "mcp" && narrativeAgentToken && !ids.includes(narrativeAgentToken))
|
|
85
|
+
ids.push(narrativeAgentToken);
|
|
86
|
+
return ids;
|
|
87
|
+
}
|
|
88
|
+
/** Close every viewer socket opened under one of `ids` with 4401, the code
|
|
89
|
+
* the app reads as "signed out" rather than a network blip, and forget it.
|
|
90
|
+
* Returns how many it closed. */
|
|
91
|
+
export function closeSocketsOf(viewerSockets, ids, reason = "token revoked") {
|
|
92
|
+
const revoked = new Set(ids);
|
|
93
|
+
let closed = 0;
|
|
94
|
+
for (const [ws, owners] of viewerSockets) {
|
|
95
|
+
const list = typeof owners === "string" ? [owners] : owners;
|
|
96
|
+
if (!list.some((id) => revoked.has(id)))
|
|
97
|
+
continue;
|
|
98
|
+
viewerSockets.delete(ws);
|
|
99
|
+
try {
|
|
100
|
+
ws.close(WS_CLOSE_TOKEN_ROTATED, reason);
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
closed++;
|
|
104
|
+
}
|
|
105
|
+
return closed;
|
|
106
|
+
}
|
|
107
|
+
function withoutExternals(store, rootId, ids) {
|
|
108
|
+
const externals = new Set(store.externalsOf([rootId, ...ids]));
|
|
109
|
+
return ids.filter((id) => !externals.has(id));
|
|
110
|
+
}
|
|
111
|
+
/** `GET /api/tokens`: every live token, the local one included (shown, never
|
|
112
|
+
* revocable from here). */
|
|
113
|
+
export function listTokensRoute(store, requester, now = Date.now(), connected = new Set()) {
|
|
114
|
+
const launched = store.launched();
|
|
115
|
+
const tokens = store.list(now).map((t) => ({
|
|
116
|
+
...t,
|
|
117
|
+
device: describeUserAgent(t.user_agent),
|
|
118
|
+
last_device: describeUserAgent(t.last_user_agent),
|
|
119
|
+
minted_by_device: t.minted_by === null
|
|
120
|
+
? null
|
|
121
|
+
: t.minted_by_kind === "local"
|
|
122
|
+
? "this machine"
|
|
123
|
+
: describeUserAgent(t.minted_by_user_agent) ?? "an unknown device",
|
|
124
|
+
current: t.id === requester.id,
|
|
125
|
+
connected: connected.has(t.id),
|
|
126
|
+
expires_at: t.unused ? t.created_at + UNUSED_TOKEN_LAPSE_MS : null,
|
|
127
|
+
// Externals are left out: they go with their dialer regardless, so
|
|
128
|
+
// offering them as a choice would be a checkbox that does nothing.
|
|
129
|
+
descendants: t.kind !== "local" && t.id !== requester.id ? withoutExternals(store, t.id, store.descendants(t.id, requester.id)) : [],
|
|
130
|
+
agents: launched.get(t.id) ?? [],
|
|
131
|
+
}));
|
|
132
|
+
return { status: 200, body: { tokens } };
|
|
133
|
+
}
|
|
134
|
+
/** What a revoke would do, or why it is refused, without doing it: the
|
|
135
|
+
* checks of `revokeTokenRoute`, which the route runs before asking the
|
|
136
|
+
* operator to approve a device's sign-out, so a request that would fail
|
|
137
|
+
* never raises a prompt. The revoke itself runs them again, since the rows
|
|
138
|
+
* can change while the prompt is up. */
|
|
139
|
+
export function planRevoke(store, requester, id, also = []) {
|
|
140
|
+
const row = store.get(id);
|
|
141
|
+
if (!row)
|
|
142
|
+
return { status: 404, body: { error: "No live token with that id" } };
|
|
143
|
+
if (row.kind === "local") {
|
|
144
|
+
return { status: 400, body: { error: "That row is this machine's own account, which holds no token and cannot be revoked" } };
|
|
145
|
+
}
|
|
146
|
+
if (row.id === requester.id) {
|
|
147
|
+
return { status: 409, body: { error: "That is this device; use \"Sign out other devices\" to keep it and sign out the rest" } };
|
|
148
|
+
}
|
|
149
|
+
const allowed = new Set(store.descendants(row.id, requester.id));
|
|
150
|
+
const extra = [...new Set(also)];
|
|
151
|
+
const stray = extra.filter((x) => !allowed.has(x));
|
|
152
|
+
if (stray.length) {
|
|
153
|
+
return { status: 400, body: { error: `Not a device this one added: ${stray.join(", ")}` } };
|
|
154
|
+
}
|
|
155
|
+
const chosen = [row.id, ...extra];
|
|
156
|
+
// Plus the externals any of them dialed in, always: they are those
|
|
157
|
+
// tokens' credentials in another form, not devices of their own.
|
|
158
|
+
const ids = [...chosen, ...store.externalsOf(chosen).filter((x) => !chosen.includes(x))];
|
|
159
|
+
return { row, extra, ids };
|
|
160
|
+
}
|
|
161
|
+
/** `POST /api/tokens/:id/revoke`: revoke one device token. Bodiless, so the
|
|
162
|
+
* app sends it without a preflight. Three refusals, each naming the route
|
|
163
|
+
* that does what the caller probably meant:
|
|
164
|
+
*
|
|
165
|
+
* - the account row (`local`): it is this machine's CLI and MCP bridges on
|
|
166
|
+
* `daemon.sock`, which hold no token, so there is nothing to revoke;
|
|
167
|
+
* - the requester's own token: a device revoking itself strands itself, and
|
|
168
|
+
* "Sign out other devices" is the version that keeps it in;
|
|
169
|
+
* - an id that is not live: 404, so a double-tap reads as already done.
|
|
170
|
+
*
|
|
171
|
+
* On success the row is revoked (never deleted) and any rotation that device
|
|
172
|
+
* had begun is abandoned — otherwise its pending token could confirm later
|
|
173
|
+
* and write the device back in. The caller closes the row's sockets. */
|
|
174
|
+
export function revokeTokenRoute(store, ring, requester, id, now = Date.now(),
|
|
175
|
+
/** Devices to revoke with it: each must be one this row added, directly or
|
|
176
|
+
* down a chain, and not the requester or anything it added. Checked here,
|
|
177
|
+
* so the list can never be used to revoke an unrelated device. */
|
|
178
|
+
also = []) {
|
|
179
|
+
const plan = planRevoke(store, requester, id, also);
|
|
180
|
+
if ("status" in plan)
|
|
181
|
+
return plan;
|
|
182
|
+
const { row, extra, ids } = plan;
|
|
183
|
+
// One write: the device and the ones it added go together, or none do.
|
|
184
|
+
store.revoke(ids, now);
|
|
185
|
+
let abandonedRotation = false;
|
|
186
|
+
for (const x of ids)
|
|
187
|
+
abandonedRotation = ring.abandonRotationBy(x) || abandonedRotation;
|
|
188
|
+
return { status: 200, body: { revoked: row.id, kind: row.kind, label: row.label, also: extra, ids, abandonedRotation } };
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=token-routes.js.map
|