brane 0.3.4 → 0.3.5

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 CHANGED
@@ -148,6 +148,7 @@ Single SQLite database at `~/.brane/brane.db` (WAL mode). Key tables:
148
148
  | `meta` | Instance ID, daemon port/pid/version/tls/http_port/socket, config |
149
149
  | `tokens` | Daemon bearer tokens as SHA-256 hashes only (`local`: one row for this machine's CLI, which no token opens; `device` per meld, `agent` per narrative session, handed to it as `BRANE_NARRATIVE_SECRET`), revoked never deleted — see [Token Rotation](docs/token-rotation.md) |
150
150
  | `folds` | Registered spawn directories (path, brane_id, created_at) |
151
+ | `branes` | Archived branes (brane_id, archived, archived_at), written lazily, never deleted — see [Multi-Brane](docs/multi-brane.md#archived-branes) |
151
152
  | `narratives` | Agent sessions (id, brane_id, agent_type, role, status, fold_path, title, summary, archived, agent_session_id) |
152
153
  | `events` | Append-only event log: (narrative_id, seq, time, type, content, content_type, source, re, payloads, meta) |
153
154
  | `memory_chunks` | Event chunks for embeddings |
package/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ page, which is also what triggers its publish (see
6
6
  [docs/releasing.md](docs/releasing.md)). This file is the summary that
7
7
  travels in the npm tarball.
8
8
 
9
+ ## 0.3.5 - 2026-10-14
10
+
11
+ - A browser holds daemons from one tailnet only. A meld link or "+ Add
12
+ Daemon" entry for a host on another tailnet is refused before anything
13
+ is sent to it, so a stranger's daemon (behind Tailscale Funnel, say)
14
+ can't be melded into a browser that holds yours. To use another
15
+ tailnet, use another browser, or remove this one's daemons first
16
+ ([#613](https://github.com/benjamn/brane/pull/613)).
17
+ - A browser's first meld gets a warning card: continue only if you
18
+ installed brane and ran `brane meld` yourself, and cancel a link
19
+ someone sent you. A loopback daemon is accepted beside a tailnet and
20
+ never decides one
21
+ ([#615](https://github.com/benjamn/brane/pull/615)).
22
+ - Every row of the brane switcher has a ⋯. A daemon can be archived on
23
+ this device, keeping its token so it is one tap from Reconnect, or
24
+ deleted, which first signs this device out of it
25
+ (`POST /api/token/forget`). A brane can be archived on the daemon, for
26
+ every device: its ended narratives leave All branes, and its running
27
+ ones never do. The home brane and the last brane showing can't be
28
+ archived. The long-press that silently removed a daemon is gone
29
+ ([#614](https://github.com/benjamn/brane/pull/614)).
30
+
31
+ Published while the repository is private, so like 0.3.0–0.3.4 it
32
+ carries no npm provenance attestation.
33
+
9
34
  ## 0.3.4 - 2026-10-14
10
35
 
11
36
  - "All narratives" in the narrative switcher pushes the list in this tab
package/SECURITY.md CHANGED
@@ -46,6 +46,17 @@ A stranger who photographs your QR from across the coffee shop gets a token they
46
46
 
47
47
  To opt in to LAN reach on a trusted home network, pass `brane meld --host <lan-ip>` paired with a matching `--bind`. That is an explicit choice — the over-the-shoulder threat reappears for the duration of that session, but only because the operator asked for it.
48
48
 
49
+ ## One tailnet per browser
50
+
51
+ The QR's safety is about the link *you* show. The reverse case is a link someone sends *you*: a meld link to a daemon they run. Anyone can stand one up behind Tailscale Funnel, which gives it a public `*.ts.net` name and a valid certificate, and the meld card's **Daemon** and **Brane** rows are whatever that daemon says about itself, so it can claim to be `you@your-mac`. A browser keeps every melded daemon's token in one origin's `localStorage`. Any script that runs in that origin, through a renderer bug fed by the hostile daemon, can read every one of them. Your browser is on your tailnet, so the script can then use those tokens against your own daemons directly. Tapping **Meld** on such a link must cost nothing, whatever bugs the renderer has.
52
+
53
+ So a browser origin holds daemons from **one trust domain** only (`src/shared/tailnet.ts`). The domain is read from the host the browser connects to, never from anything the daemon reports. For a MagicDNS name, which is what `brane meld` writes, it is the tailnet: `tail1234.ts.net` for `mac.tail1234.ts.net`. Only Tailscale issues names and certificates under a tailnet's domain. Any other host is a domain of just itself. Loopback is no domain at all: only this machine can serve a loopback daemon, so one is accepted beside any tailnet and never decides one. A browser holding only loopback daemons still has its domain to pick. The first meld of anything else sets it. After that, a meld link or an **+ Add Daemon** entry for a host in another domain is refused **before anything is sent to it**, not even the status probe that would tell its owner the link was opened. The card names both domains. To use another tailnet, open the link in another browser or profile, or remove this browser's daemons first.
54
+
55
+ What this does not cover:
56
+ - **A hostile member of your own tailnet** passes. On a tailnet shared with other people, that person is inside the trust boundary, as they are for every other tailnet service.
57
+ - **A browser with no domain yet** (no daemons, or only loopback ones) takes whatever it is sent first, which is why that meld gets its own card. It says to continue only if you installed brane and ran `brane meld` yourself, and to cancel if someone sent you the link. It also names the tailnet the browser will be held to. Connecting to a stranger's daemon is already the loss: it sees everything you do in the app. So the defense is at that tap, not after it. Your own daemon is refused afterwards, which is how you would find out.
58
+ - **A browser melded before this rule may already hold two domains.** It can add daemons to either, but not to a third.
59
+
49
60
  ## Authentication
50
61
 
51
62
  Every API and WebSocket request over TCP requires a 128-bit UUID bearer token, passed via `Authorization: Bearer` header or query parameter. Brute-forcing 128 bits of entropy is not feasible. The one exception is a request over the daemon's Unix socket, which needs no token and acts as this machine's account (below).
@@ -193,6 +204,7 @@ Surfacing mentions on the recipient's own timeline — so a transcript reader ca
193
204
  - An attacker with root on your machine (nothing can)
194
205
  - A process running as your Unix account, agents included: it can reach `daemon.sock` and act as this machine, or use tmux directly (see [Who the operator is](#who-the-operator-is)). An agent token's narrower scope stops an agent that follows the rules, not one that goes around it
195
206
  - A compromised Tailscale account (same as any tailnet service)
207
+ - A hostile member of your own tailnet: a browser accepts daemons from its tailnet, and only from its tailnet (see [One tailnet per browser](#one-tailnet-per-browser))
196
208
  - A compromised brane.organelle.dev deployment (use `--local` to eliminate this)
197
209
  - Independent auditing of the upstream `tmux` build (the pinned SHA-256 guarantees you receive exactly the bytes brane vetted, not that those bytes were audited beyond trust-on-first-use)
198
210
 
@@ -24,7 +24,7 @@ import { signalEventFields, signalStanding } from "./signal-source.js";
24
24
  import { AGENT_LOOPBACK_REFUSAL, AGENT_REVIVE_REFUSAL, agentRefusal, agentReviveRefusal } from "./agent-scope.js";
25
25
  import { archiveRefusal } from "./archive-gate.js";
26
26
  import { DbTokenStore, bootstrapTokens, legacyTokenWarning, parseTokenHashes, liveAgentTokenId, mintAgentToken, recordLauncher, settleAgentLaunch, verifyAgentToken } from "../db/tokens.js";
27
- import { closeSocketsOf, listTokensRoute, socketOwners, retireTokens } from "./token-routes.js";
27
+ import { closeSocketsOf, forgetSelfRoute, listTokensRoute, socketOwners, retireTokens } from "./token-routes.js";
28
28
  import { getConfig, resolveAppHost } from "../commands/config.js";
29
29
  import { doSpawn, buildGooseExtensionArg, reestablishMcpConfigArgs, braneTmuxEnv, ensureHookConfig, resolveAgentCommand, LIVE_LOOP_PROMPT } from "../commands/spawn.js";
30
30
  import { ensureSessionAffinityConfig } from "../session-affinity.js";
@@ -46,6 +46,7 @@ import { parseDuration, timeSince } from "../commands/memory.js";
46
46
  import { getTlsCertPath, getTlsKeyPath } from "../commands/certify.js";
47
47
  import { getTailscaleIps, getTailscaleIpsAsync } from "../tailscale.js";
48
48
  import { addressesToBind, nextProbeDelay, TAILSCALE_PROBE_MAX_MS } from "./tailscale-rebind.js";
49
+ import { archivedBraneIds, braneArchiveRefusal, setBraneArchived, visibleUnscopedClause } from "../db/branes.js";
49
50
  import { CertManager } from "./cert-manager.js";
50
51
  import { KeepAwake } from "./keep-awake.js";
51
52
  import { resolveTmux, ensureTmux, newTmuxSession, parseTerminalSize } from "../tmux.js";
@@ -1308,6 +1309,13 @@ export async function startServer(config) {
1308
1309
  where.push(`brane_id IN (${braneIds.map(() => "?").join(",")})`);
1309
1310
  params.push(...braneIds);
1310
1311
  }
1312
+ else {
1313
+ // All branes: an archived brane's ended narratives stay out of it
1314
+ // (src/db/branes.ts). Naming the brane still lists them.
1315
+ const visible = visibleUnscopedClause(connectedNids);
1316
+ where.push(visible.sql);
1317
+ params.push(...visible.params);
1318
+ }
1311
1319
  // last_activity = time of the most recent event, so the UI can show a
1312
1320
  // recency timestamp on each card (more meaningful than created_at for
1313
1321
  // long-running narratives). Events are append-ordered, so the newest
@@ -1849,8 +1857,12 @@ export async function startServer(config) {
1849
1857
  }).catch((err) => sendJson(res, 500, { error: String(err) }));
1850
1858
  return;
1851
1859
  }
1852
- // Route: GET /api/branes
1860
+ // Route: GET /api/branes?archived=1
1861
+ // Archived branes (src/db/branes.ts) are left out unless archived=1, which
1862
+ // the switcher's "Show N archived" asks for; each row says which it is.
1853
1863
  if (path === "/api/branes" && req.method === "GET") {
1864
+ const includeArchived = url.searchParams.get("archived") === "1";
1865
+ const hidden = archivedBraneIds(db);
1854
1866
  const rows = db.prepare(`SELECT brane_id,
1855
1867
  COUNT(*) as narrative_count,
1856
1868
  SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_count,
@@ -1875,14 +1887,51 @@ export async function startServer(config) {
1875
1887
  list.push({ unit: c.unit, amount: Math.round(c.amount * 100) / 100, metered: c.metered });
1876
1888
  costByBrane.set(c.brane_id, list);
1877
1889
  }
1878
- const branes = rows.map((r) => ({
1890
+ const branes = rows
1891
+ .filter((r) => includeArchived || !hidden.has(r.brane_id))
1892
+ .map((r) => ({
1879
1893
  ...r,
1880
1894
  is_home: r.brane_id === braneId,
1895
+ archived: hidden.has(r.brane_id),
1881
1896
  cost: costByBrane.get(r.brane_id) ?? [],
1882
1897
  }));
1883
1898
  sendJson(res, 200, { branes });
1884
1899
  return;
1885
1900
  }
1901
+ // Route: PATCH /api/branes/archive { brane_id, archived }
1902
+ // Hide a brane, or bring it back. Refused for the home brane and for the
1903
+ // last brane not already archived (src/db/branes.ts). The operator's act:
1904
+ // not in AGENT_ROUTES.
1905
+ if (path === "/api/branes/archive" && req.method === "PATCH") {
1906
+ parseBody(req).then((body) => {
1907
+ let target, archived;
1908
+ try {
1909
+ ({ brane_id: target, archived } = JSON.parse(body));
1910
+ }
1911
+ catch {
1912
+ sendJson(res, 400, { error: "invalid JSON body" });
1913
+ return;
1914
+ }
1915
+ if (typeof target !== "string" || target.trim() === "") {
1916
+ sendJson(res, 400, { error: "brane_id is required" });
1917
+ return;
1918
+ }
1919
+ if (typeof archived !== "boolean") {
1920
+ sendJson(res, 400, { error: "archived must be true or false" });
1921
+ return;
1922
+ }
1923
+ const refusal = braneArchiveRefusal(db, target, archived, braneId);
1924
+ if (refusal) {
1925
+ sendJson(res, refusal.status, { error: refusal.error });
1926
+ return;
1927
+ }
1928
+ setBraneArchived(db, target, archived);
1929
+ sendJson(res, 200, { brane_id: target, archived });
1930
+ }).catch((err) => {
1931
+ sendJson(res, 400, { error: String(err) });
1932
+ });
1933
+ return;
1934
+ }
1886
1935
  // Route: GET /api/folds?archived=1
1887
1936
  // Archived folds are hidden from the spawn dropdown by default; pass
1888
1937
  // archived=1 to include them (for the "show hidden" affordance).
@@ -2203,6 +2252,20 @@ export async function startServer(config) {
2203
2252
  });
2204
2253
  return;
2205
2254
  }
2255
+ // Route: POST /api/token/forget — this device signs itself out, for the
2256
+ // app's "Delete permanently" (src/daemon/token-routes.ts). Bodiless. Its
2257
+ // sockets close after the answer is on its way.
2258
+ if (path === "/api/token/forget" && req.method === "POST") {
2259
+ const answer = forgetSelfRoute(tokenStore, tokenRing, auth.record);
2260
+ sendJson(res, answer.status, answer.body);
2261
+ if (answer.status === 200) {
2262
+ const closed = closeSocketsOf(viewerSockets, answer.body.ids);
2263
+ console.log(`[token] device "${answer.body.label}" signed itself out from ${connectionLabel(req.socket) ?? "?"}; ` +
2264
+ `closed ${closed} viewer socket(s)` +
2265
+ (answer.body.abandonedRotation ? "; abandoned the rotation it had begun" : ""));
2266
+ }
2267
+ return;
2268
+ }
2206
2269
  // Route: POST /api/token/rotate — "sign out other devices". For a device
2207
2270
  // it mints a pending token and returns it; nothing else changes until a
2208
2271
  // request presents that token, which is when the requester's old token
@@ -5582,6 +5645,13 @@ function handleNarrativeInfo(db, args, connectedNids, braneIds) {
5582
5645
  sql += ` AND n.brane_id IN (${braneIds.map(() => "?").join(", ")})`;
5583
5646
  params.push(...braneIds);
5584
5647
  }
5648
+ else {
5649
+ // Every brane, as `brane memory log` asks: archived branes' ended
5650
+ // narratives stay out, as they do from All branes (src/db/branes.ts).
5651
+ const visible = visibleUnscopedClause(connectedNids, "n");
5652
+ sql += ` AND ${visible.sql}`;
5653
+ params.push(...visible.params);
5654
+ }
5585
5655
  if (!args.archived) {
5586
5656
  sql += ` AND COALESCE(n.archived, 0) = 0`;
5587
5657
  }
@@ -5681,6 +5751,10 @@ function handleListBranes(db, selfNarrativeId) {
5681
5751
  FROM narratives
5682
5752
  GROUP BY brane_id
5683
5753
  ORDER BY last_activity DESC`).all();
5754
+ // Archived branes are listed too, flagged, so an agent can still reach one
5755
+ // on purpose (src/db/branes.ts).
5756
+ const hidden = archivedBraneIds(db);
5757
+ const flag = (id) => (hidden.has(id) ? { archived: true } : {});
5684
5758
  // Insert caller's home brane entry first so iteration order surfaces it,
5685
5759
  // then everything else in last_activity DESC order.
5686
5760
  const result = {};
@@ -5689,14 +5763,14 @@ function handleListBranes(db, selfNarrativeId) {
5689
5763
  const homeRow = rows.find((r) => r.brane_id === homeBraneId);
5690
5764
  if (homeRow) {
5691
5765
  const { brane_id, ...rest } = homeRow;
5692
- result[brane_id] = rest;
5766
+ result[brane_id] = { ...rest, ...flag(brane_id) };
5693
5767
  }
5694
5768
  }
5695
5769
  for (const r of rows) {
5696
5770
  if (r.brane_id === homeBraneId)
5697
5771
  continue;
5698
5772
  const { brane_id, ...rest } = r;
5699
- result[brane_id] = rest;
5773
+ result[brane_id] = { ...rest, ...flag(brane_id) };
5700
5774
  }
5701
5775
  return result;
5702
5776
  }
@@ -187,4 +187,23 @@ also = []) {
187
187
  abandonedRotation = ring.abandonRotationBy(x) || abandonedRotation;
188
188
  return { status: 200, body: { revoked: row.id, kind: row.kind, label: row.label, also: extra, ids, abandonedRotation } };
189
189
  }
190
+ /** `POST /api/token/forget`: a device signs itself out — the app's "Delete
191
+ * permanently" on a daemon it no longer wants, so no live token is left
192
+ * behind on the daemon that nobody holds. Bodiless, like the revoke. The
193
+ * per-device revoke refuses the requester's own token because "Sign out
194
+ * other devices" is usually what was meant; here stranding itself is the
195
+ * point. Only a device token can ask: this machine's account holds no token,
196
+ * and an agent's is not offered the route. The externals it dialed in go
197
+ * with it, as they do with any revoke; devices it added stay. */
198
+ export function forgetSelfRoute(store, ring, requester, now = Date.now()) {
199
+ if (requester.kind !== "device") {
200
+ return { status: 400, body: { error: `Only a device's token can sign itself out; this is a ${requester.kind} token` } };
201
+ }
202
+ const ids = [requester.id, ...store.externalsOf([requester.id]).filter((x) => x !== requester.id)];
203
+ store.revoke(ids, now);
204
+ let abandonedRotation = false;
205
+ for (const x of ids)
206
+ abandonedRotation = ring.abandonRotationBy(x) || abandonedRotation;
207
+ return { status: 200, body: { revoked: requester.id, label: requester.label, ids, abandonedRotation } };
208
+ }
190
209
  //# sourceMappingURL=token-routes.js.map
@@ -0,0 +1,44 @@
1
+ /** The ids of every archived brane. */
2
+ export function archivedBraneIds(db) {
3
+ const rows = db.prepare("SELECT brane_id FROM branes WHERE archived = 1").all();
4
+ return new Set(rows.map((r) => r.brane_id));
5
+ }
6
+ /** A WHERE clause over `narratives` (unaliased columns) that drops ended
7
+ * narratives of archived branes, for a listing that names no brane. A
8
+ * narrative counts as running if its status is not `ended` or its agent is
9
+ * on the bridge (`connected`, bound as parameters). `alias` qualifies the
10
+ * columns when the query joins another table. */
11
+ export function visibleUnscopedClause(connected, alias = "") {
12
+ const ids = [...connected];
13
+ const a = alias ? `${alias}.` : "";
14
+ const live = ids.length ? ` OR ${a}id IN (${ids.map(() => "?").join(",")})` : "";
15
+ return {
16
+ sql: `(${a}brane_id NOT IN (SELECT brane_id FROM branes WHERE archived = 1) OR ${a}status IS NOT 'ended'${live})`,
17
+ params: ids,
18
+ };
19
+ }
20
+ /** Why `braneId` cannot be archived, as a status and message, or null when
21
+ * it can. Unarchiving is refused only for a brane that does not exist. */
22
+ export function braneArchiveRefusal(db, braneId, archived, homeBraneId) {
23
+ const exists = db.prepare("SELECT 1 FROM narratives WHERE brane_id = ? LIMIT 1").get(braneId);
24
+ if (!exists)
25
+ return { status: 404, error: `No brane ${braneId}` };
26
+ if (!archived)
27
+ return null;
28
+ if (braneId === homeBraneId) {
29
+ return { status: 400, error: `${braneId} is this daemon's home brane, where every spawn without a brane lands; it can't be archived` };
30
+ }
31
+ const hidden = archivedBraneIds(db);
32
+ const remaining = db.prepare("SELECT DISTINCT brane_id FROM narratives").all()
33
+ .filter((r) => r.brane_id !== braneId && !hidden.has(r.brane_id));
34
+ if (remaining.length === 0) {
35
+ return { status: 400, error: `${braneId} is the last brane not archived; archiving it would leave nothing to show` };
36
+ }
37
+ return null;
38
+ }
39
+ /** Archive or unarchive `braneId`, writing its row on first use. */
40
+ export function setBraneArchived(db, braneId, archived, now = Date.now()) {
41
+ db.prepare(`INSERT INTO branes (brane_id, archived, archived_at) VALUES (?, ?, ?)
42
+ ON CONFLICT(brane_id) DO UPDATE SET archived = excluded.archived, archived_at = excluded.archived_at`).run(braneId, archived ? 1 : 0, archived ? now : null);
43
+ }
44
+ //# sourceMappingURL=branes.js.map
package/dist/db/schema.js CHANGED
@@ -456,6 +456,17 @@ export function applyMigrations(db) {
456
456
  if (!launchCols.has("launched_by")) {
457
457
  db.exec("ALTER TABLE narratives ADD COLUMN launched_by TEXT");
458
458
  }
459
+ // Which branes the operator has hidden (src/db/branes.ts). A brane has no
460
+ // row of its own anywhere else — it is the brane_id its narratives share —
461
+ // so a row here is written lazily by the first archive and never deleted;
462
+ // unarchiving clears the flag.
463
+ db.exec(`
464
+ CREATE TABLE IF NOT EXISTS branes (
465
+ brane_id TEXT PRIMARY KEY,
466
+ archived INTEGER NOT NULL DEFAULT 0,
467
+ archived_at INTEGER
468
+ )
469
+ `);
459
470
  }
460
471
  /** Carry provider-only `absorb_messages` / `absorb_files` rows (the shape
461
472
  * #456 shipped) into the brane-scoped tables. Detected by the old table
@@ -0,0 +1,117 @@
1
+ /** One tailnet per browser origin.
2
+ *
3
+ * Every daemon a browser has melded keeps its token in that origin's
4
+ * `localStorage`, so anything that runs script in the origin — a renderer bug
5
+ * fed by a hostile daemon — can read all of them, and a page on your tailnet
6
+ * can use them against your daemons directly. A meld link is the cheap way to
7
+ * get a hostile daemon in: a stranger stands one up behind Tailscale Funnel,
8
+ * which gives it a real `*.ts.net` name and certificate, and sends the link.
9
+ *
10
+ * The rule that makes that harmless: an origin holds daemons from one trust
11
+ * domain only, and the domain is read off the host the browser itself
12
+ * connects to, never off anything the daemon says about itself. For a
13
+ * MagicDNS name (`mac.tail1234.ts.net`, what `brane meld` always writes) the
14
+ * domain is the tailnet (`tail1234.ts.net`): only Tailscale issues names and
15
+ * certificates under it, so a daemon cannot claim to be in a tailnet it is
16
+ * not in. Any other host (a LAN IP, a raw Tailscale IP, a public name) is a
17
+ * domain of exactly itself. Loopback is the exception that is no domain at
18
+ * all: only this machine can serve it, so it is accepted beside any tailnet,
19
+ * and it never decides one. So a browser that melded `tail1234.ts.net`
20
+ * refuses a link to `evil.tail5678.ts.net` before it contacts it, and using
21
+ * another tailnet takes another browser (or profile), or forgetting this
22
+ * one's daemons first. See SECURITY.md, "One tailnet per browser".
23
+ *
24
+ * Pure, so the test suite drives it without a DOM. */
25
+ /** The hostname of a `host[:port]` as the app stores it, lower-cased, without
26
+ * brackets or a trailing dot; `null` when it is not a host at all. */
27
+ export function hostnameOf(host) {
28
+ const trimmed = host.trim();
29
+ if (!trimmed || /[/?#@\s]/.test(trimmed))
30
+ return null;
31
+ try {
32
+ const name = new URL(`http://${trimmed}`).hostname.toLowerCase();
33
+ return name.replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "") || null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ function isLoopback(hostname) {
40
+ return (hostname === "localhost" ||
41
+ hostname.endsWith(".localhost") ||
42
+ hostname === "::1" ||
43
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname));
44
+ }
45
+ /** The tailnet a MagicDNS name belongs to — `tail1234.ts.net` for
46
+ * `mac.tail1234.ts.net` — or `null` for any other host. */
47
+ export function tailnetOf(host) {
48
+ const name = hostnameOf(host);
49
+ if (!name)
50
+ return null;
51
+ const labels = name.split(".");
52
+ if (labels.length < 4 || labels.at(-1) !== "net" || labels.at(-2) !== "ts")
53
+ return null;
54
+ if (labels.some((l) => l === ""))
55
+ return null;
56
+ return labels.slice(-3).join(".");
57
+ }
58
+ /** The domain every loopback spelling shares. */
59
+ export const LOOPBACK = "localhost";
60
+ /** The trust domain a daemon at `host` belongs to: its tailnet, `localhost`
61
+ * for every loopback spelling, or else the hostname itself. `null` for
62
+ * something that is not a host. */
63
+ export function trustDomainOf(host) {
64
+ const name = hostnameOf(host);
65
+ if (!name)
66
+ return null;
67
+ if (isLoopback(name))
68
+ return LOOPBACK;
69
+ return tailnetOf(name) ?? name;
70
+ }
71
+ /** May a browser that already holds daemons at `existingHosts` add one at
72
+ * `candidate`? A loopback daemon, always: only this machine can serve one,
73
+ * and its token beside a tailnet's is exposed to nobody that tailnet's
74
+ * aren't. Anything else, when the browser holds no domain yet (the meld
75
+ * picks it, and `setsHome` says so), or when the domain is one it already
76
+ * holds — a browser melded before this rule may hold two, and adding to
77
+ * either exposes nothing new. Loopback never counts as holding a domain: a
78
+ * browser with only loopback daemons still has its domain to pick, and that
79
+ * pick is screened like a first meld, since a stranger's daemon there could
80
+ * reach the loopback one from this very browser. Hosts that do not parse
81
+ * are ignored on the existing side and refused on the candidate side. */
82
+ export function checkMeldHost(candidate, existingHosts) {
83
+ const home = [
84
+ ...new Set(existingHosts.map(trustDomainOf).filter((d) => d !== null && d !== LOOPBACK)),
85
+ ].sort();
86
+ const domain = trustDomainOf(candidate);
87
+ if (domain === null)
88
+ return { ok: false, domain, home };
89
+ if (domain === LOOPBACK)
90
+ return { ok: true, domain, setsHome: false };
91
+ if (home.length === 0)
92
+ return { ok: true, domain, setsHome: true };
93
+ if (home.includes(domain))
94
+ return { ok: true, domain, setsHome: false };
95
+ return { ok: false, domain, home };
96
+ }
97
+ /** Whether a meld link the rule allowed gets the first-meld warning: the
98
+ * browser holds no daemon at all, or this meld picks its domain. Never on
99
+ * the daemon's own origin (`candidate` is the page's host): that is where
100
+ * Isolate lands, from the operator's own app, and if the daemon were
101
+ * hostile the card would be drawn by its own bundle, warning about nothing.
102
+ * Only a page some other origin serves — the shared app — can warn honestly. */
103
+ export function isFirstMeld(existingHosts, verdict, candidate, pageHost) {
104
+ if (candidate.toLowerCase() === pageHost.toLowerCase())
105
+ return false;
106
+ return existingHosts.length === 0 || verdict.setsHome;
107
+ }
108
+ /** The refusal in words, for the meld card and the add-daemon form. */
109
+ export function meldRefusalMessage(candidate, verdict) {
110
+ if (verdict.domain === null)
111
+ return `"${candidate}" is not a daemon address.`;
112
+ const home = verdict.home.join(" and ");
113
+ return (`${hostnameOf(candidate) ?? candidate} is on ${verdict.domain}, but this browser's daemons are on ${home}. ` +
114
+ `A browser holds daemons from one tailnet only, so that no daemon can reach another tailnet's tokens. ` +
115
+ `To use it, open the link in another browser or profile, or remove this browser's daemons first.`);
116
+ }
117
+ //# sourceMappingURL=tailnet.js.map
@@ -142,6 +142,7 @@ const serverSource = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "
142
142
  const CLOSED_TO_AGENTS = new Set([
143
143
  "GET /api/absorb/survey",
144
144
  "POST /api/absorb",
145
+ "PATCH /api/branes/archive",
145
146
  "POST /api/cost/backfill",
146
147
  "POST /api/cost/reprice",
147
148
  "POST /api/folds",
@@ -150,6 +151,7 @@ const CLOSED_TO_AGENTS = new Set([
150
151
  "POST /api/narratives/:/signal",
151
152
  "POST /api/presence/cancel",
152
153
  "POST /api/run",
154
+ "POST /api/token/forget",
153
155
  "POST /api/token/rotate",
154
156
  "GET /api/tokens",
155
157
  "POST /api/tokens",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Archived branes (src/db/branes.ts) and a device signing itself out
3
+ * (`forgetSelfRoute`), the two daemon halves of the switcher's archive menus.
4
+ */
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { createTestDb } from "./helpers.js";
8
+ import { archivedBraneIds, braneArchiveRefusal, setBraneArchived, visibleUnscopedClause } from "../db/branes.js";
9
+ import { DbTokenStore } from "../db/tokens.js";
10
+ import { TokenRing, hashToken } from "../daemon/token-ring.js";
11
+ import { forgetSelfRoute } from "../daemon/token-routes.js";
12
+ const HOME = "brane-home";
13
+ function addNarrative(db, id, braneId, status) {
14
+ db.prepare("INSERT INTO narratives (id, brane_id, created_at, status) VALUES (?, ?, ?, ?)").run(id, braneId, 1, status);
15
+ }
16
+ function setup() {
17
+ const db = createTestDb();
18
+ addNarrative(db, "claude-h1", HOME, "ended");
19
+ addNarrative(db, "claude-a1", "brane-absorbed", "ended");
20
+ addNarrative(db, "claude-a2", "brane-absorbed", "active");
21
+ addNarrative(db, "claude-a3", "brane-absorbed", "ended");
22
+ addNarrative(db, "claude-o1", "brane-other", "ended");
23
+ return db;
24
+ }
25
+ function unscoped(db, connected = []) {
26
+ const v = visibleUnscopedClause(connected);
27
+ return db.prepare(`SELECT id FROM narratives WHERE ${v.sql} ORDER BY id`).all(...v.params).map((r) => r.id);
28
+ }
29
+ describe("archiving a brane", () => {
30
+ it("is refused for the home brane", () => {
31
+ const db = setup();
32
+ assert.equal(braneArchiveRefusal(db, HOME, true, HOME)?.status, 400);
33
+ assert.match(braneArchiveRefusal(db, HOME, true, HOME).error, /home brane/);
34
+ });
35
+ it("is refused for the last brane not already archived, even when that is not the home brane", () => {
36
+ const db = createTestDb();
37
+ addNarrative(db, "claude-x", "brane-only", "ended");
38
+ const refusal = braneArchiveRefusal(db, "brane-only", true, HOME);
39
+ assert.equal(refusal?.status, 400);
40
+ assert.match(refusal.error, /last brane/);
41
+ const db2 = setup();
42
+ // Home can't be archived, so "last" is reached by archiving the others.
43
+ assert.equal(braneArchiveRefusal(db2, "brane-absorbed", true, HOME), null);
44
+ setBraneArchived(db2, "brane-absorbed", true);
45
+ setBraneArchived(db2, "brane-other", true);
46
+ assert.equal(braneArchiveRefusal(db2, HOME, true, "brane-elsewhere")?.status, 400);
47
+ assert.match(braneArchiveRefusal(db2, HOME, true, "brane-elsewhere").error, /last brane/);
48
+ });
49
+ it("is 404 for a brane with no narratives, either way", () => {
50
+ const db = setup();
51
+ assert.equal(braneArchiveRefusal(db, "brane-nope", true, HOME)?.status, 404);
52
+ assert.equal(braneArchiveRefusal(db, "brane-nope", false, HOME)?.status, 404);
53
+ });
54
+ it("is undone by unarchiving, which is never refused for a brane that exists", () => {
55
+ const db = setup();
56
+ setBraneArchived(db, "brane-other", true, 5);
57
+ assert.deepEqual([...archivedBraneIds(db)], ["brane-other"]);
58
+ assert.equal(braneArchiveRefusal(db, "brane-other", false, HOME), null);
59
+ setBraneArchived(db, "brane-other", false, 6);
60
+ assert.deepEqual([...archivedBraneIds(db)], []);
61
+ // The row is kept, flag cleared: never deleted.
62
+ assert.deepEqual(db.prepare("SELECT * FROM branes").all(), [{ brane_id: "brane-other", archived: 0, archived_at: null }]);
63
+ });
64
+ });
65
+ describe("listings that name no brane", () => {
66
+ it("leave out an archived brane's ended narratives and keep its running ones", () => {
67
+ const db = setup();
68
+ assert.deepEqual(unscoped(db), ["claude-a1", "claude-a2", "claude-a3", "claude-h1", "claude-o1"]);
69
+ setBraneArchived(db, "brane-absorbed", true);
70
+ assert.deepEqual(unscoped(db), ["claude-a2", "claude-h1", "claude-o1"]);
71
+ });
72
+ it("keep a narrative whose agent is on the bridge, whatever its row says", () => {
73
+ const db = setup();
74
+ setBraneArchived(db, "brane-absorbed", true);
75
+ assert.deepEqual(unscoped(db, ["claude-a3"]), ["claude-a2", "claude-a3", "claude-h1", "claude-o1"]);
76
+ });
77
+ it("qualify their columns when given an alias", () => {
78
+ const v = visibleUnscopedClause(["claude-a3"], "n");
79
+ assert.match(v.sql, /^\(n\.brane_id NOT IN .* OR n\.status IS NOT 'ended' OR n\.id IN \(\?\)\)$/);
80
+ });
81
+ });
82
+ describe("a device signing itself out", () => {
83
+ function tokens() {
84
+ const db = createTestDb();
85
+ const store = new DbTokenStore(db);
86
+ const ring = new TokenRing(store, () => "unexpected-mint");
87
+ const local = store.insert(hashToken("local"), "local", "this machine", 1);
88
+ const phone = store.insert(hashToken("phone"), "device", "phone", 2);
89
+ const laptop = store.insert(hashToken("laptop"), "device", "laptop", 3);
90
+ return { store, ring, local, phone, laptop };
91
+ }
92
+ it("revokes its own token and leaves the others", () => {
93
+ const { store, ring, phone, laptop } = tokens();
94
+ const answer = forgetSelfRoute(store, ring, phone, 10);
95
+ assert.equal(answer.status, 200);
96
+ assert.deepEqual(answer.status === 200 && answer.body.ids, [phone.id]);
97
+ assert.equal(store.get(phone.id), null);
98
+ assert.ok(store.get(laptop.id));
99
+ });
100
+ it("is refused for this machine's account, which holds no token", () => {
101
+ const { store, ring, local } = tokens();
102
+ const answer = forgetSelfRoute(store, ring, local, 10);
103
+ assert.equal(answer.status, 400);
104
+ assert.ok(store.get(local.id));
105
+ });
106
+ });
107
+ //# sourceMappingURL=brane-archive.test.js.map