brane 0.1.1 → 0.3.0
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 +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -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 +116 -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 +332 -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 +1094 -0
- package/dist/commands/token.js +153 -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 +6349 -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 +633 -0
- package/dist/db/tokens.js +583 -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/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 +82 -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 +197 -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/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 +49 -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 +262 -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 +85 -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 +137 -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 +115 -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/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -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 +581 -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/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -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 +53 -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 +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -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 +48 -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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four routes the presence gate holds, and its cancel route, as
|
|
3
|
+
* functions of the gate, the token ring and store, and who is asking — out
|
|
4
|
+
* of the server's closure so the suite can drive them
|
|
5
|
+
* (`src/test/presence-gate.test.ts`). The server keeps what is about its
|
|
6
|
+
* sockets: logging, and closing what a revoke signed out.
|
|
7
|
+
*
|
|
8
|
+
* Each one checks what it would refuse anyway, then asks, then acts — and
|
|
9
|
+
* the act re-checks, since rows can change while a prompt is up.
|
|
10
|
+
*/
|
|
11
|
+
import { gateStanding, revokeSignsOutDevice } from "./presence-gate.js";
|
|
12
|
+
import { mintRemoteToken } from "./token-ring.js";
|
|
13
|
+
import { mintDeviceTokenRoute, parseMintBody, planRevoke, revokeTokenRoute } from "./token-routes.js";
|
|
14
|
+
/** What an agent is told for each gated act: it is the operator's to do. */
|
|
15
|
+
const AGENT_REFUSALS = {
|
|
16
|
+
meld: "An agent cannot add a device (spawn without --meld). The operator melds from a shell outside an agent session, or from the app's Meld sheet",
|
|
17
|
+
"meld-remote": "An agent cannot add a device to a remote daemon; the operator runs `brane meld --remote` from a shell outside an agent session",
|
|
18
|
+
"revoke-device": "An agent cannot sign out a device; the operator does, from a shell outside an agent session or from the app",
|
|
19
|
+
rotate: "An agent's token is replaced by reviving its narrative, not rotated",
|
|
20
|
+
};
|
|
21
|
+
/** Null when `caller` may go on — a device, or a prompt approved — else the
|
|
22
|
+
* refusal to send: an agent's at once, the shell's once the prompt says no. */
|
|
23
|
+
export async function askPresence(gate, act, caller, signal) {
|
|
24
|
+
const standing = gateStanding(caller.record);
|
|
25
|
+
if (standing === "pass")
|
|
26
|
+
return null;
|
|
27
|
+
if (standing === "refuse") {
|
|
28
|
+
gate.refuse(act, caller.by, "an agent's token");
|
|
29
|
+
return { status: 403, body: { error: AGENT_REFUSALS[act], presence: "agent" } };
|
|
30
|
+
}
|
|
31
|
+
const outcome = await gate.check(act, caller.by, signal);
|
|
32
|
+
if (outcome.ok)
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
status: outcome.status,
|
|
36
|
+
body: { error: outcome.error, presence: outcome.reason },
|
|
37
|
+
...(outcome.retryAfterMs !== undefined ? { retryAfterMs: outcome.retryAfterMs } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** `POST /api/tokens`, gated. A body the mint would refuse is refused before
|
|
41
|
+
* anyone is asked. */
|
|
42
|
+
export async function gatedMintRoute(deps, caller, rawBody, signal, now = Date.now) {
|
|
43
|
+
const parsed = parseMintBody(rawBody, now());
|
|
44
|
+
if ("error" in parsed)
|
|
45
|
+
return { status: 400, body: parsed };
|
|
46
|
+
const refused = await askPresence(deps.gate, "meld", caller, signal);
|
|
47
|
+
if (refused)
|
|
48
|
+
return refused;
|
|
49
|
+
return mintDeviceTokenRoute(deps.ring, rawBody, now(), caller.record);
|
|
50
|
+
}
|
|
51
|
+
/** `POST /api/tokens/remote`, gated: a device token for a daemon on another
|
|
52
|
+
* machine, and its hash, for `brane meld --remote`. Nothing is stored here,
|
|
53
|
+
* so the 200 is the only copy of either. */
|
|
54
|
+
export async function gatedRemoteMintRoute(deps, caller, signal) {
|
|
55
|
+
const refused = await askPresence(deps.gate, "meld-remote", caller, signal);
|
|
56
|
+
if (refused)
|
|
57
|
+
return refused;
|
|
58
|
+
return { status: 200, body: (deps.mint ?? mintRemoteToken)() };
|
|
59
|
+
}
|
|
60
|
+
/** `POST /api/tokens/:id/revoke`, gated when it signs out a device; stopping
|
|
61
|
+
* an agent is not. Refusals the revoke would give come back unasked, and
|
|
62
|
+
* the revoke runs its own checks again after the prompt. */
|
|
63
|
+
export async function gatedRevokeRoute(deps, caller, id, also, signal, now = Date.now) {
|
|
64
|
+
const plan = planRevoke(deps.store, caller.record, id, also);
|
|
65
|
+
if ("status" in plan)
|
|
66
|
+
return plan;
|
|
67
|
+
if (revokeSignsOutDevice(deps.store, plan.ids)) {
|
|
68
|
+
const refused = await askPresence(deps.gate, "revoke-device", caller, signal);
|
|
69
|
+
if (refused)
|
|
70
|
+
return refused;
|
|
71
|
+
}
|
|
72
|
+
const revoked = revokeTokenRoute(deps.store, deps.ring, caller.record, id, now(), also);
|
|
73
|
+
return { status: revoked.status, body: revoked.body, revoked };
|
|
74
|
+
}
|
|
75
|
+
/** `POST /api/token/rotate`, gated. An agent's token is bound to its
|
|
76
|
+
* narrative and replaced by reviving it, so agents get 403 unasked, as on
|
|
77
|
+
* every gated act. The
|
|
78
|
+
* prompt comes before `beginRotation`: a second begin replaces a pending
|
|
79
|
+
* rotation, so a refused request must never reach it, or a loop could void
|
|
80
|
+
* the operator's own rotation without passing a prompt.
|
|
81
|
+
*
|
|
82
|
+
* A device begins a rotation and completes it by presenting the token this
|
|
83
|
+
* returns. This account (the shell, over `daemon.sock`) holds no token to
|
|
84
|
+
* swap, so for it the devices are signed out here, in this one request,
|
|
85
|
+
* and the answer is `{ signed_out }` with no token in it. */
|
|
86
|
+
export async function gatedRotateRoute(deps, caller, signal) {
|
|
87
|
+
const refused = await askPresence(deps.gate, "rotate", caller, signal);
|
|
88
|
+
if (refused)
|
|
89
|
+
return refused;
|
|
90
|
+
if (caller.record.kind === "local") {
|
|
91
|
+
const signedOut = deps.ring.signOutDevices(caller.record);
|
|
92
|
+
return { status: 200, body: { signed_out: signedOut.devices, replaced: signedOut.replaced }, signedOut };
|
|
93
|
+
}
|
|
94
|
+
const rotation = deps.ring.beginRotation(caller.record);
|
|
95
|
+
return {
|
|
96
|
+
status: 200,
|
|
97
|
+
body: { token: rotation.token, expires_at: rotation.expiresAt, replaced: rotation.replaced },
|
|
98
|
+
rotation,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** `POST /api/presence/cancel?id=`: close the prompt that is up. Only a
|
|
102
|
+
* device may: it is how the operator, away from the machine, says "that one
|
|
103
|
+
* is not mine". Anything on this machine that could cancel could cancel
|
|
104
|
+
* every prompt as it appeared, and with the cooldown keep `brane token
|
|
105
|
+
* rotate` from ever completing; the asker withdraws by disconnecting. */
|
|
106
|
+
export function cancelPresenceRoute(gate, record, id) {
|
|
107
|
+
if (record.kind !== "device") {
|
|
108
|
+
return { status: 403, body: { error: "Only a signed-in device can cancel a presence prompt" } };
|
|
109
|
+
}
|
|
110
|
+
return { status: 200, body: { canceled: gate.cancel(id) } };
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=gated-routes.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// KeepAwake — holds an idle-sleep power assertion while the daemon runs.
|
|
2
|
+
//
|
|
3
|
+
// On macOS the daemon spawns a single hidden `caffeinate -i` child whose
|
|
4
|
+
// lifetime mirrors the daemon's. The child holds a
|
|
5
|
+
// `PreventUserIdleSystemSleep` IOKit assertion, equivalent to running
|
|
6
|
+
// `caffeinate -i` in a terminal, but without occupying a tmux session.
|
|
7
|
+
// Display sleep is unaffected — the screen still dims normally.
|
|
8
|
+
//
|
|
9
|
+
// On other platforms this is a no-op. The status surface lets callers tell
|
|
10
|
+
// "we are deliberately not holding an assertion" (unsupported) apart from
|
|
11
|
+
// "we tried and failed" (unavailable).
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
const RESPAWN_BACKOFF_MS = 5000;
|
|
14
|
+
export class KeepAwake {
|
|
15
|
+
log;
|
|
16
|
+
child = null;
|
|
17
|
+
wantRunning = false;
|
|
18
|
+
respawnTimer = null;
|
|
19
|
+
lastError;
|
|
20
|
+
supported;
|
|
21
|
+
constructor(log = () => { }) {
|
|
22
|
+
this.log = log;
|
|
23
|
+
this.supported = process.platform === "darwin";
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
if (!this.supported)
|
|
27
|
+
return;
|
|
28
|
+
if (this.wantRunning)
|
|
29
|
+
return;
|
|
30
|
+
this.wantRunning = true;
|
|
31
|
+
this.spawnChild();
|
|
32
|
+
}
|
|
33
|
+
stop() {
|
|
34
|
+
this.wantRunning = false;
|
|
35
|
+
if (this.respawnTimer) {
|
|
36
|
+
clearTimeout(this.respawnTimer);
|
|
37
|
+
this.respawnTimer = null;
|
|
38
|
+
}
|
|
39
|
+
if (this.child && this.child.exitCode === null) {
|
|
40
|
+
try {
|
|
41
|
+
this.child.kill("SIGTERM");
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
this.child = null;
|
|
46
|
+
}
|
|
47
|
+
getStatus() {
|
|
48
|
+
if (!this.supported)
|
|
49
|
+
return { state: "unsupported", pid: null };
|
|
50
|
+
if (this.child && this.child.exitCode === null && this.child.pid) {
|
|
51
|
+
return { state: "held", pid: this.child.pid };
|
|
52
|
+
}
|
|
53
|
+
const status = { state: this.wantRunning ? "unavailable" : "released", pid: null };
|
|
54
|
+
if (this.lastError)
|
|
55
|
+
status.last_error = this.lastError;
|
|
56
|
+
return status;
|
|
57
|
+
}
|
|
58
|
+
spawnChild() {
|
|
59
|
+
try {
|
|
60
|
+
const child = spawn("caffeinate", ["-i"], {
|
|
61
|
+
stdio: "ignore",
|
|
62
|
+
detached: false,
|
|
63
|
+
});
|
|
64
|
+
this.child = child;
|
|
65
|
+
child.once("error", (err) => {
|
|
66
|
+
this.lastError = err.message;
|
|
67
|
+
this.log(`caffeinate failed to spawn: ${err.message}`);
|
|
68
|
+
this.child = null;
|
|
69
|
+
this.scheduleRespawn();
|
|
70
|
+
});
|
|
71
|
+
child.once("exit", (code, signal) => {
|
|
72
|
+
if (this.child === child)
|
|
73
|
+
this.child = null;
|
|
74
|
+
if (this.wantRunning) {
|
|
75
|
+
this.lastError = `caffeinate exited (code=${code} signal=${signal})`;
|
|
76
|
+
this.log(this.lastError);
|
|
77
|
+
this.scheduleRespawn();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
this.lastError = undefined;
|
|
81
|
+
this.log(`keep-awake: caffeinate pid=${child.pid} (PreventUserIdleSystemSleep)`);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
this.lastError = err.message;
|
|
85
|
+
this.log(`caffeinate spawn threw: ${this.lastError}`);
|
|
86
|
+
this.scheduleRespawn();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
scheduleRespawn() {
|
|
90
|
+
if (!this.wantRunning)
|
|
91
|
+
return;
|
|
92
|
+
if (this.respawnTimer)
|
|
93
|
+
return;
|
|
94
|
+
this.respawnTimer = setTimeout(() => {
|
|
95
|
+
this.respawnTimer = null;
|
|
96
|
+
if (this.wantRunning)
|
|
97
|
+
this.spawnChild();
|
|
98
|
+
}, RESPAWN_BACKOFF_MS);
|
|
99
|
+
if (typeof this.respawnTimer.unref === "function")
|
|
100
|
+
this.respawnTimer.unref();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=keep-awake.js.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's Unix-domain socket, `daemon.sock` beside the database.
|
|
3
|
+
*
|
|
4
|
+
* It is how a process on this machine reaches the daemon with the kernel
|
|
5
|
+
* vouching for who it is: connecting means resolving the path, which takes
|
|
6
|
+
* search permission on `~/.brane`, and that directory is 0700 — so only this
|
|
7
|
+
* Unix account (and root) gets in. Loopback TCP cannot say as much: any
|
|
8
|
+
* account on the machine can dial 127.0.0.1. See SECURITY.md, "Who the
|
|
9
|
+
* operator is".
|
|
10
|
+
*
|
|
11
|
+
* A connection over the socket gains loopback standing (`isLoopbackRequest`),
|
|
12
|
+
* and a request on it that presents no token acts as this account — the
|
|
13
|
+
* live `local` row — for as long as `accountDirTrust` holds.
|
|
14
|
+
*/
|
|
15
|
+
import { connect } from "node:net";
|
|
16
|
+
import { chmodSync, lstatSync, statSync, unlinkSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { UNIX_SOCKET_SIGHTING } from "../db/tokens.js";
|
|
19
|
+
/** `daemon.sock` beside a database. */
|
|
20
|
+
export function getSocketPath(db) {
|
|
21
|
+
return join(dirname(db.name), "daemon.sock");
|
|
22
|
+
}
|
|
23
|
+
/** Whether the directory holding the socket still makes "reached the
|
|
24
|
+
* socket" mean "is this Unix account": owned by the daemon's own uid, with
|
|
25
|
+
* no group or other permission bits. Both chmods that set this up swallow
|
|
26
|
+
* their errors, and anyone may loosen it later, so it is checked when it is
|
|
27
|
+
* relied on, not once at startup. Follows a symlinked directory, as
|
|
28
|
+
* resolving the socket's path does. */
|
|
29
|
+
export function accountDirTrust(dir) {
|
|
30
|
+
const uid = process.getuid?.();
|
|
31
|
+
if (uid === undefined)
|
|
32
|
+
return { ok: false, reason: "this platform has no Unix uids" };
|
|
33
|
+
let st;
|
|
34
|
+
try {
|
|
35
|
+
st = statSync(dir);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { ok: false, reason: `${dir}: ${err.code ?? String(err)}` };
|
|
39
|
+
}
|
|
40
|
+
if (st.uid !== uid)
|
|
41
|
+
return { ok: false, reason: `${dir} is owned by uid ${st.uid}, not this daemon's ${uid}` };
|
|
42
|
+
if ((st.mode & 0o077) !== 0) {
|
|
43
|
+
return { ok: false, reason: `${dir} is mode ${(st.mode & 0o777).toString(8)}; other accounts could reach the socket (want 700)` };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true };
|
|
46
|
+
}
|
|
47
|
+
/** The longest path `bind(2)` takes for a Unix socket: `sun_path` is 104
|
|
48
|
+
* bytes on macOS (108 on Linux), terminator included. */
|
|
49
|
+
export const MAX_SOCKET_PATH = 103;
|
|
50
|
+
/** Connections accepted on the Unix socket. `isLoopbackRequest` consults it,
|
|
51
|
+
* since such a socket has no remote address to judge by. */
|
|
52
|
+
const unixConnections = new WeakSet();
|
|
53
|
+
export function markUnixConnection(socket) {
|
|
54
|
+
unixConnections.add(socket);
|
|
55
|
+
}
|
|
56
|
+
export function isUnixConnection(socket) {
|
|
57
|
+
return unixConnections.has(socket);
|
|
58
|
+
}
|
|
59
|
+
/** Where a connection came from, for token sightings and log lines:
|
|
60
|
+
* `daemon.sock` for the Unix socket, which has no address of its own, else
|
|
61
|
+
* the remote address, or null when even that is gone. */
|
|
62
|
+
export function connectionLabel(socket) {
|
|
63
|
+
return isUnixConnection(socket) ? UNIX_SOCKET_SIGHTING : (socket.remoteAddress ?? null);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Make `path` free to listen on. A clean shutdown leaves nothing behind
|
|
67
|
+
* (closing the server unlinks it); a crash leaves the socket file, which
|
|
68
|
+
* `listen()` would refuse. Only a socket whose connect is refused
|
|
69
|
+
* (ECONNREFUSED) is removed: one that accepts a connection belongs to a live
|
|
70
|
+
* daemon, and any other error is no proof either way, so both are left alone.
|
|
71
|
+
* Callers hold the daemon claim, so that would be another brane dir's
|
|
72
|
+
* daemon, or not a daemon at all.
|
|
73
|
+
*
|
|
74
|
+
* Anything at `path` that is not a socket is never touched.
|
|
75
|
+
*
|
|
76
|
+
* Resolves `true` when `path` is free, `false` when it is not and stays so.
|
|
77
|
+
*/
|
|
78
|
+
export async function clearStaleSocket(path) {
|
|
79
|
+
let isSocket;
|
|
80
|
+
try {
|
|
81
|
+
isSocket = lstatSync(path).isSocket();
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return err.code === "ENOENT"; // nothing there
|
|
85
|
+
}
|
|
86
|
+
if (!isSocket)
|
|
87
|
+
return false;
|
|
88
|
+
// Only a refusal proves nothing is listening. Any other failure — no
|
|
89
|
+
// permission, no file descriptors, a live listener's full backlog — proves
|
|
90
|
+
// nothing, and the path is left alone.
|
|
91
|
+
const refused = await new Promise((resolve) => {
|
|
92
|
+
const probe = connect(path);
|
|
93
|
+
probe.once("connect", () => {
|
|
94
|
+
probe.destroy();
|
|
95
|
+
resolve(false);
|
|
96
|
+
});
|
|
97
|
+
probe.once("error", (err) => resolve(err.code === "ECONNREFUSED"));
|
|
98
|
+
});
|
|
99
|
+
if (!refused)
|
|
100
|
+
return false;
|
|
101
|
+
unlinkSync(path);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Start `server` on the socket beside `db` and advertise it as
|
|
106
|
+
* `meta.daemon_socket`. Never throws: a path too long for `bind(2)`, a path
|
|
107
|
+
* that is in use or not a socket, or a failed `listen()` is logged and
|
|
108
|
+
* answered `false`. Agents and devices still reach the daemon over TCP with
|
|
109
|
+
* their tokens; this account's shell, which holds none, cannot. Marks each accepted
|
|
110
|
+
* connection, so `isLoopbackRequest` and `connectionLabel` know it.
|
|
111
|
+
*/
|
|
112
|
+
export async function listenOnLocalSocket(server, db, log = (line) => console.warn(line)) {
|
|
113
|
+
const path = getSocketPath(db);
|
|
114
|
+
if (Buffer.byteLength(path) > MAX_SOCKET_PATH) {
|
|
115
|
+
log(`[socket] ${path} is too long for a Unix socket (${Buffer.byteLength(path)} bytes, limit ${MAX_SOCKET_PATH})`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
server.on("connection", (socket) => markUnixConnection(socket));
|
|
119
|
+
try {
|
|
120
|
+
if (!(await clearStaleSocket(path))) {
|
|
121
|
+
log(`[socket] ${path} is in use or not a socket; left alone`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
server.once("error", reject);
|
|
126
|
+
server.listen(path, () => {
|
|
127
|
+
server.off("error", reject);
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
log(`[socket] could not listen on ${path}: ${err.message}`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
// The directory is what keeps other accounts out; on Linux the socket's
|
|
137
|
+
// own mode is checked too.
|
|
138
|
+
try {
|
|
139
|
+
chmodSync(path, 0o600);
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_socket', ?)").run(path);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=local-socket.js.map
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presence gate: the few acts a process on this machine may not do on
|
|
3
|
+
* its own say-so wait here for the operator to pass the OS's own prompt
|
|
4
|
+
* (`src/presence.ts`). SECURITY.md, "Who the operator is": a process running
|
|
5
|
+
* as this account is not the operator, and acts that reach off this machine
|
|
6
|
+
* or cut the operator off are gated by the presence check.
|
|
7
|
+
*
|
|
8
|
+
* Gated acts, for this machine's shell (no token, over `daemon.sock`):
|
|
9
|
+
*
|
|
10
|
+
* - `meld`: minting a device token (`POST /api/tokens`), which extends reach
|
|
11
|
+
* off this machine;
|
|
12
|
+
* - `meld-remote`: minting a device token for a daemon on another machine
|
|
13
|
+
* (`POST /api/tokens/remote`, `brane meld --remote`). This daemon stores
|
|
14
|
+
* nothing and could not stop a process that made its own token; what the
|
|
15
|
+
* gate holds is the sanctioned path, which then asks what `brane meld`
|
|
16
|
+
* asks;
|
|
17
|
+
* - `revoke-device`: revoking a device token, which cuts the operator off a
|
|
18
|
+
* device — an agent loop could otherwise sign out their phone;
|
|
19
|
+
* - `rotate`: signing out every other device (`POST /api/token/rotate`).
|
|
20
|
+
*
|
|
21
|
+
* A device token is the operator's delegate, minted by an operator act, and
|
|
22
|
+
* is never prompted: the app's Meld sheet, Revoke and "Sign out other
|
|
23
|
+
* devices" work as they always have. An agent's token is refused all three,
|
|
24
|
+
* unasked (`gateStanding`). Who is asking is read from the token's kind,
|
|
25
|
+
* never from the address — a device on this machine's browser is still a
|
|
26
|
+
* device, and an agent is an agent over any transport.
|
|
27
|
+
*
|
|
28
|
+
* Less obvious than it looks:
|
|
29
|
+
*
|
|
30
|
+
* - **The prompt's words are fixed per act.** Nothing from a request reaches
|
|
31
|
+
* them, not even a device's label or user agent, which whoever minted it
|
|
32
|
+
* chose: an agent that could write the prompt could make it read like the
|
|
33
|
+
* one the operator expects ("Unlock 1Password").
|
|
34
|
+
* - **One prompt at a time, and no queue.** A second gated request while one
|
|
35
|
+
* is up is refused at once (409). After a prompt that was not approved,
|
|
36
|
+
* further requests are refused for `cooldownMs` (429), so a loop cannot
|
|
37
|
+
* keep a dialog on screen until it is approved out of habit. A withdrawn
|
|
38
|
+
* prompt counts: give up and ask again is exactly that loop.
|
|
39
|
+
* - **The asker can withdraw.** The route aborts the prompt when its client
|
|
40
|
+
* goes away (Ctrl-C on `brane meld`), so no dialog outlives its request.
|
|
41
|
+
* - **A device can cancel; nothing can approve.** A remote approve would be
|
|
42
|
+
* a device minting a token, which the Meld sheet already does in the open.
|
|
43
|
+
* Only a device may cancel: anything on this machine that could cancel
|
|
44
|
+
* could cancel every prompt as it appeared, and with the cooldown keep
|
|
45
|
+
* `brane token rotate` from ever completing. The asker withdraws by
|
|
46
|
+
* disconnecting instead.
|
|
47
|
+
*/
|
|
48
|
+
/** What the OS prompt says. macOS shows it as "osascript is trying to <it>",
|
|
49
|
+
* hence the lowercase verb phrase. */
|
|
50
|
+
export const PRESENCE_PROMPTS = {
|
|
51
|
+
meld: "add a device to brane",
|
|
52
|
+
"meld-remote": "add a device to brane on another machine",
|
|
53
|
+
"revoke-device": "sign out a device from brane",
|
|
54
|
+
rotate: "sign out every other device from brane",
|
|
55
|
+
};
|
|
56
|
+
/** How a caller holding `record` stands before a gated act: a device goes
|
|
57
|
+
* on, an agent is refused unasked, and anything else (this machine's shell)
|
|
58
|
+
* is asked. An agent that follows the rules therefore never puts a dialog
|
|
59
|
+
* in front of the operator — one that read like the operator's own
|
|
60
|
+
* `brane meld` would be approved out of habit. */
|
|
61
|
+
export function gateStanding(record) {
|
|
62
|
+
return record.kind === "device" ? "pass" : record.kind === "agent" ? "refuse" : "ask";
|
|
63
|
+
}
|
|
64
|
+
/** Whether a revoke of `id` (plus `also`) signs out a device, which is the
|
|
65
|
+
* gated part of revoking; stopping an agent is not. Unknown ids are not
|
|
66
|
+
* gated, so a request the route will refuse (404) never raises a prompt. */
|
|
67
|
+
export function revokeSignsOutDevice(store, ids) {
|
|
68
|
+
return ids.some((id) => store.get(id)?.kind === "device");
|
|
69
|
+
}
|
|
70
|
+
export const PRESENCE_TIMEOUT_MS = 45_000;
|
|
71
|
+
export const PRESENCE_COOLDOWN_MS = 15_000;
|
|
72
|
+
export class PresenceGate {
|
|
73
|
+
opts;
|
|
74
|
+
pending = null;
|
|
75
|
+
cooldownUntil = 0;
|
|
76
|
+
timeoutMs;
|
|
77
|
+
cooldownMs;
|
|
78
|
+
now;
|
|
79
|
+
newId;
|
|
80
|
+
constructor(opts) {
|
|
81
|
+
this.opts = opts;
|
|
82
|
+
this.timeoutMs = opts.timeoutMs ?? PRESENCE_TIMEOUT_MS;
|
|
83
|
+
this.cooldownMs = opts.cooldownMs ?? PRESENCE_COOLDOWN_MS;
|
|
84
|
+
this.now = opts.now ?? Date.now;
|
|
85
|
+
let n = 0;
|
|
86
|
+
this.newId = opts.newId ?? (() => `presence-${++n}`);
|
|
87
|
+
}
|
|
88
|
+
/** The prompt that is up, if any, without its handle. */
|
|
89
|
+
current() {
|
|
90
|
+
if (!this.pending)
|
|
91
|
+
return null;
|
|
92
|
+
const { abort: _a, remoteCancel: _r, ...pending } = this.pending;
|
|
93
|
+
return pending;
|
|
94
|
+
}
|
|
95
|
+
/** Ask the operator to approve `act` for `by`. Resolves once they answer,
|
|
96
|
+
* the prompt times out, it is canceled, or `signal` (the request's own
|
|
97
|
+
* lifetime) aborts. Never throws. */
|
|
98
|
+
async check(act, by, signal) {
|
|
99
|
+
const prompt = PRESENCE_PROMPTS[act];
|
|
100
|
+
const now = this.now();
|
|
101
|
+
if (this.pending) {
|
|
102
|
+
const up = this.pending;
|
|
103
|
+
this.log(act, by, "refused: another prompt is up");
|
|
104
|
+
// Name the prompt that is up and who raised it: its dialog may read
|
|
105
|
+
// exactly like the one this caller expected, and approving it would
|
|
106
|
+
// approve someone else's act.
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
status: 409,
|
|
110
|
+
reason: "busy",
|
|
111
|
+
error: `Another presence prompt is up ("${up.prompt}", asked by ${up.by}). ` +
|
|
112
|
+
"If you did not start it, decline it at the machine or cancel it from a signed-in device, then try again",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (now < this.cooldownUntil) {
|
|
116
|
+
const retryAfterMs = this.cooldownUntil - now;
|
|
117
|
+
this.log(act, by, "refused: cooling down after a prompt that was not approved");
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
status: 429,
|
|
121
|
+
reason: "cooldown",
|
|
122
|
+
retryAfterMs,
|
|
123
|
+
error: `A presence prompt was just declined, withdrawn or left unanswered; try again in ${Math.ceil(retryAfterMs / 1000)} s`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (signal?.aborted)
|
|
127
|
+
return { ok: false, status: 403, reason: "aborted", error: "The request was withdrawn" };
|
|
128
|
+
const abort = new AbortController();
|
|
129
|
+
const onAbort = () => abort.abort();
|
|
130
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
131
|
+
const pending = {
|
|
132
|
+
id: this.newId(),
|
|
133
|
+
act,
|
|
134
|
+
prompt,
|
|
135
|
+
by,
|
|
136
|
+
startedAt: now,
|
|
137
|
+
expiresAt: now + this.timeoutMs,
|
|
138
|
+
abort,
|
|
139
|
+
remoteCancel: false,
|
|
140
|
+
};
|
|
141
|
+
this.pending = pending;
|
|
142
|
+
this.opts.onChange?.(this.current());
|
|
143
|
+
let result;
|
|
144
|
+
try {
|
|
145
|
+
result = await this.opts.confirm(prompt, { timeoutMs: this.timeoutMs, signal: abort.signal });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
result = { ok: false, method: null, reason: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
signal?.removeEventListener("abort", onAbort);
|
|
152
|
+
this.pending = null;
|
|
153
|
+
this.opts.onChange?.(null);
|
|
154
|
+
}
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
this.log(act, by, `approved (${result.method})`);
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
const reason = pending.remoteCancel ? "remote-cancel" : result.reason;
|
|
160
|
+
// After every prompt that was shown and not approved — a withdrawn one
|
|
161
|
+
// too, or a loop could give up and ask again with no pause between
|
|
162
|
+
// dialogs. Only `unavailable` showed nothing.
|
|
163
|
+
if (reason !== "unavailable")
|
|
164
|
+
this.cooldownUntil = this.now() + this.cooldownMs;
|
|
165
|
+
this.log(act, by, `${reason}${result.detail ? `: ${result.detail}` : ""}`);
|
|
166
|
+
return { ok: false, status: 403, reason, error: refusalMessage(reason, result.detail) };
|
|
167
|
+
}
|
|
168
|
+
/** Record an attempt refused without a prompt — an agent's token asking
|
|
169
|
+
* for an operator's act — so it leaves the same trace as one that was
|
|
170
|
+
* asked. An agent trying to add a device or sign out the operator's phone
|
|
171
|
+
* is the attempt most worth seeing. */
|
|
172
|
+
refuse(act, by, why) {
|
|
173
|
+
this.log(act, by, `refused: ${why}`);
|
|
174
|
+
}
|
|
175
|
+
/** Close the prompt that is up. With `id`, only that prompt — so a stale
|
|
176
|
+
* Cancel tap cannot close a newer one. Returns whether one was closed.
|
|
177
|
+
* Who may call it is the route's to decide (`cancelPresenceRoute`). */
|
|
178
|
+
cancel(id) {
|
|
179
|
+
const p = this.pending;
|
|
180
|
+
if (!p || (id !== undefined && id !== p.id))
|
|
181
|
+
return false;
|
|
182
|
+
p.remoteCancel = true;
|
|
183
|
+
p.abort.abort();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
log(act, by, outcome) {
|
|
187
|
+
this.opts.log?.(`[presence] ${act} for ${by}: ${outcome}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function refusalMessage(reason, detail) {
|
|
191
|
+
switch (reason) {
|
|
192
|
+
case "canceled":
|
|
193
|
+
return "The presence prompt was declined at this machine";
|
|
194
|
+
case "remote-cancel":
|
|
195
|
+
return "The presence prompt was canceled from a signed-in device";
|
|
196
|
+
case "timeout":
|
|
197
|
+
return "Nobody answered the presence prompt in time";
|
|
198
|
+
case "aborted":
|
|
199
|
+
return "The request was withdrawn";
|
|
200
|
+
case "unavailable":
|
|
201
|
+
return `This machine cannot show a presence prompt${detail ? ` (${detail})` : ""}; this needs the operator at a machine that can, or a signed-in device`;
|
|
202
|
+
default:
|
|
203
|
+
return `The presence check failed${detail ? `: ${detail}` : ""}`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=presence-gate.js.map
|