brane 0.3.3 → 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.
@@ -0,0 +1,125 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { checkMeldHost, hostnameOf, isFirstMeld, meldRefusalMessage, tailnetOf, trustDomainOf, } from "../shared/tailnet.js";
4
+ describe("tailnetOf", () => {
5
+ it("reads the tailnet off a MagicDNS name, port or not", () => {
6
+ assert.equal(tailnetOf("mac.tail1234.ts.net"), "tail1234.ts.net");
7
+ assert.equal(tailnetOf("mac.tail1234.ts.net:47443"), "tail1234.ts.net");
8
+ assert.equal(tailnetOf("Mac.Tail1234.TS.net."), "tail1234.ts.net");
9
+ assert.equal(tailnetOf("mac.cat-crocodile.ts.net"), "cat-crocodile.ts.net");
10
+ });
11
+ it("is null for anything that is not a machine under a tailnet", () => {
12
+ assert.equal(tailnetOf("tail1234.ts.net"), null);
13
+ assert.equal(tailnetOf("ts.net"), null);
14
+ assert.equal(tailnetOf("100.101.102.103:58353"), null);
15
+ assert.equal(tailnetOf("brane.organelle.dev"), null);
16
+ assert.equal(tailnetOf("mac.tail1234.ts.net.evil.com"), null);
17
+ assert.equal(tailnetOf("localhost:8081"), null);
18
+ });
19
+ });
20
+ describe("trustDomainOf", () => {
21
+ it("is the tailnet for a MagicDNS name", () => {
22
+ assert.equal(trustDomainOf("mac.tail1234.ts.net:47443"), "tail1234.ts.net");
23
+ });
24
+ it("is one domain for every loopback spelling", () => {
25
+ for (const h of ["localhost", "localhost:58353", "127.0.0.1:58353", "127.9.9.9", "[::1]:58353", "app.localhost"]) {
26
+ assert.equal(trustDomainOf(h), "localhost", h);
27
+ }
28
+ });
29
+ it("is the hostname itself for any other host", () => {
30
+ assert.equal(trustDomainOf("100.101.102.103:58353"), "100.101.102.103");
31
+ assert.equal(trustDomainOf("192.168.1.5:58353"), "192.168.1.5");
32
+ assert.equal(trustDomainOf("brane.example.com"), "brane.example.com");
33
+ });
34
+ it("is null for what is not a host", () => {
35
+ for (const h of ["", " ", "https://mac.tail1234.ts.net", "mac.tail1234.ts.net/x", "user@mac.tail1234.ts.net", "a b"]) {
36
+ assert.equal(trustDomainOf(h), null, h);
37
+ assert.equal(hostnameOf(h), null, h);
38
+ }
39
+ });
40
+ });
41
+ describe("checkMeldHost", () => {
42
+ const home = ["mac.tail1234.ts.net:47443", "studio.tail1234.ts.net:51000"];
43
+ it("lets the first meld pick the domain, and says it does", () => {
44
+ assert.deepEqual(checkMeldHost("evil.tail5678.ts.net", []), { ok: true, domain: "tail5678.ts.net", setsHome: true });
45
+ assert.deepEqual(checkMeldHost("192.168.1.5:58353", []), { ok: true, domain: "192.168.1.5", setsHome: true });
46
+ });
47
+ it("admits another machine on the same tailnet", () => {
48
+ assert.deepEqual(checkMeldHost("fly-box.tail1234.ts.net", home), { ok: true, domain: "tail1234.ts.net", setsHome: false });
49
+ });
50
+ it("refuses a daemon on another tailnet, which is what a Funnel host is", () => {
51
+ assert.deepEqual(checkMeldHost("ben-macbook.tail5678.ts.net", home), {
52
+ ok: false,
53
+ domain: "tail5678.ts.net",
54
+ home: ["tail1234.ts.net"],
55
+ });
56
+ });
57
+ it("refuses IPs and public names beside a tailnet, and the reverse", () => {
58
+ for (const h of ["100.101.102.103:58353", "192.168.1.5:58353", "brane.organelle.dev"]) {
59
+ assert.equal(checkMeldHost(h, home).ok, false, h);
60
+ }
61
+ assert.equal(checkMeldHost("mac.tail1234.ts.net", ["100.101.102.103:58353"]).ok, false);
62
+ });
63
+ it("admits loopback beside a tailnet, without it deciding anything", () => {
64
+ for (const h of ["127.0.0.1:58353", "localhost:58353", "[::1]:58353"]) {
65
+ assert.deepEqual(checkMeldHost(h, home), { ok: true, domain: "localhost", setsHome: false }, h);
66
+ assert.deepEqual(checkMeldHost(h, []), { ok: true, domain: "localhost", setsHome: false }, h);
67
+ }
68
+ });
69
+ it("treats a browser with only loopback daemons as holding no domain, so its first tailnet is screened", () => {
70
+ assert.deepEqual(checkMeldHost("mac.tail1234.ts.net", ["localhost:58353"]), { ok: true, domain: "tail1234.ts.net", setsHome: true });
71
+ // …and once that tailnet is held, loopback beside it decides nothing.
72
+ assert.deepEqual(checkMeldHost("other.tail5678.ts.net", ["localhost:58353", "mac.tail1234.ts.net"]), {
73
+ ok: false,
74
+ domain: "tail5678.ts.net",
75
+ home: ["tail1234.ts.net"],
76
+ });
77
+ });
78
+ it("lets a browser that already holds two domains add to either, and nothing else", () => {
79
+ const mixed = ["mac.tail1234.ts.net", "box.tail5678.ts.net"];
80
+ assert.equal(checkMeldHost("other.tail5678.ts.net", mixed).ok, true);
81
+ const third = checkMeldHost("x.tail9999.ts.net", mixed);
82
+ assert.deepEqual(third, { ok: false, domain: "tail9999.ts.net", home: ["tail1234.ts.net", "tail5678.ts.net"] });
83
+ });
84
+ it("refuses an unparseable candidate and ignores unparseable stored hosts", () => {
85
+ assert.deepEqual(checkMeldHost("https://mac.tail1234.ts.net", []), { ok: false, domain: null, home: [] });
86
+ assert.equal(checkMeldHost("mac.tail1234.ts.net", ["not a host"]).ok, true);
87
+ });
88
+ });
89
+ describe("meldRefusalMessage", () => {
90
+ it("names both tailnets and the ways out", () => {
91
+ const v = checkMeldHost("evil.tail5678.ts.net:443", ["mac.tail1234.ts.net"]);
92
+ assert.equal(v.ok, false);
93
+ if (v.ok)
94
+ return;
95
+ const msg = meldRefusalMessage("evil.tail5678.ts.net:443", v);
96
+ assert.match(msg, /^evil\.tail5678\.ts\.net is on tail5678\.ts\.net, but this browser's daemons are on tail1234\.ts\.net\./);
97
+ assert.match(msg, /another browser or profile/);
98
+ });
99
+ });
100
+ describe("isFirstMeld", () => {
101
+ const ok = (candidate, hosts) => {
102
+ const v = checkMeldHost(candidate, hosts);
103
+ assert.equal(v.ok, true);
104
+ return v;
105
+ };
106
+ const APP = "brane.organelle.dev";
107
+ it("warns in a browser with no daemons, loopback link included", () => {
108
+ assert.equal(isFirstMeld([], ok("mac.tail1234.ts.net", []), "mac.tail1234.ts.net", APP), true);
109
+ assert.equal(isFirstMeld([], ok("127.0.0.1:58353", []), "127.0.0.1:58353", APP), true);
110
+ });
111
+ it("warns when the meld picks the domain beside loopback daemons", () => {
112
+ const hosts = ["localhost:58353"];
113
+ assert.equal(isFirstMeld(hosts, ok("mac.tail1234.ts.net", hosts), "mac.tail1234.ts.net", APP), true);
114
+ });
115
+ it("doesn't warn once the domain is held", () => {
116
+ const hosts = ["mac.tail1234.ts.net"];
117
+ assert.equal(isFirstMeld(hosts, ok("box.tail1234.ts.net", hosts), "box.tail1234.ts.net", APP), false);
118
+ assert.equal(isFirstMeld(hosts, ok("localhost:58353", hosts), "localhost:58353", APP), false);
119
+ });
120
+ it("never warns on the daemon's own origin, where Isolate lands", () => {
121
+ const host = "mac.tail1234.ts.net:47443";
122
+ assert.equal(isFirstMeld([], ok(host, []), host, "MAC.tail1234.ts.net:47443"), false);
123
+ });
124
+ });
125
+ //# sourceMappingURL=tailnet.test.js.map
@@ -25,14 +25,15 @@ until someone lists it. An agent also may not revive or prompt a
25
25
  | Method | Path | Purpose |
26
26
  |--------|------|---------|
27
27
  | `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`, `app_domain`: the shared app's host, where a daemon's own bundle hops out to on Isolate — see [View URLs](view-urls.md)) |
28
- | `GET` | `/api/narratives?archived=1` | List narratives (excludes archived by default) |
28
+ | `GET` | `/api/narratives?archived=1&brane_ids=` | List narratives (excludes archived by default). Without `brane_ids`, an archived brane's ended narratives are left out too |
29
29
  | `GET` | `/api/narratives/recent?since=&search=&status=&fold=&limit=&archived=1&brane_ids=` | Recently active narratives — the browse half of the `narrative_info` MCP tool; every brane unless `brane_ids` scopes it |
30
30
  | `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative; archiving a running one is a 409 (`src/daemon/archive-gate.ts`) |
31
31
  | `GET` | `/api/narratives/:id/events?after=N&before=S&tail=T` | Fetch events with cursor-based query |
32
32
  | `POST` | `/api/narratives/:id/signal` | Send operator signal (supports payloads). `source` is stamped from the token (`web` for a device, `local` for this account); a `source` in the body is ignored, and an agent's token is refused (`src/daemon/signal-source.ts`) |
33
33
  | `GET` | `/api/narratives/:id/export?format=html[&palette=N][&seqs=A,B,C][&seqs_mode=except]` | Conversation as a standalone HTML document; `palette` is the device's 🎨 Recolor salt, so foreign speakers wear the hues the operator sees, `seqs` is the chat's message selection, and `seqs_mode=except` exports its complement — see [Export Scope](export-scope.md) |
34
34
  | `GET` | `/api/payloads/:nid/:filename` | Serve stored payload files (immutable, cached) |
35
- | `GET` | `/api/branes` | List branes with narrative counts, home flag, and `cost: [{ unit, amount, metered }]` — per-unit running totals over every narrative, archived included |
35
+ | `GET` | `/api/branes?archived=1` | List branes with narrative counts, home flag, `archived` flag, and `cost: [{ unit, amount, metered }]` — per-unit running totals over every narrative, archived included. Archived branes are left out unless `archived=1` — see [Multi-Brane](multi-brane.md#archived-branes) |
36
+ | `PATCH` | `/api/branes/archive` | Archive/unarchive a brane (`{ brane_id, archived }`). Refuses the home brane and the last brane not archived (400) and an unknown one (404); never deletes anything |
36
37
  | `GET` | `/api/folds?archived=1` | List registered fold directories (includes brane_id; archived hidden unless asked) |
37
38
  | `POST` | `/api/folds` | Register a fold (`{ path, brane_id? }`) — idempotent; see [Folds API](#folds-api) for who may register what |
38
39
  | `PATCH` | `/api/folds/archive` | Soft-hide/unhide a fold (`{ path, archived }`) — never deletes the row |
@@ -48,6 +49,7 @@ until someone lists it. An agent also may not revive or prompt a
48
49
  | `POST` | `/api/tokens/remote` | A device token for a daemon on another machine, and its hash (`{ token, hash }`), for `brane meld --remote`; nothing is stored, so this response is the one copy of either. Gated like `POST /api/tokens`: the shell waits for the [presence check](presence.md#the-gate), an agent's token gets 403 unasked |
49
50
  | `GET` | `/api/tokens` | The Devices list: every live token (`{ tokens }`), each with `device` ("Safari on iPhone", from the User-Agent it was first presented with), `remote_addr`, the latest sighting and `elsewhere_at` (presented from a second address), `minted_by_device`, `last_used_at`, `current` (the requester), `connected`, and `unused` / `expires_at` for a QR nobody has scanned — see [Token Rotation](token-rotation.md#the-devices-list) |
50
51
  | `POST` | `/api/tokens/:id/revoke` | Revoke one device token, bodiless, plus `?also=a,b` from the devices it added (each row's `descendants`); every socket it opened (control, terminal, MCP) closes with 4401. Refuses this machine's own `local` row (400), the requester's own (409), and an id that is not live (404), all before any prompt. Signing out a device waits for the [presence check](presence.md#the-gate) from the shell and is refused (403) to an agent's token; revoking an agent does neither |
52
+ | `POST` | `/api/token/forget` | A device signs itself out, bodiless: its own token is revoked and its sockets close. The app's "Delete permanently" on a daemon. Refused (400) to anything but a device token |
51
53
  | `POST` | `/api/token/rotate` | From a device: mint a pending replacement token, returned as `{ token, expires_at, replaced }`; the old one is retired on the new one's first use — see [Token Rotation](token-rotation.md). From this machine's shell (no token, over `daemon.sock`): wait for the [presence check](presence.md#the-gate), then sign out every device in this one request, answering `{ signed_out, replaced }`. An agent's token gets 403 |
52
54
  | — | `BRANE_DEVICE_TOKEN_SHA256` | Not a route: comma-separated SHA-256 hashes in the daemon's environment, each adopted at startup as a device row unless ever seen before, then dropped from the environment. How `brane meld --remote` hands a remote daemon (a Fly machine, say) a token minted elsewhere — see [Presence Check](presence.md#the-gate) |
53
55
  | `POST` | `/api/presence/cancel` | Close the presence prompt that is up (`?id=` from the control socket's `presence` message, so a stale tap closes nothing) → `{ canceled }`. Device tokens only (403 otherwise); there is no approve |
@@ -12,3 +12,16 @@ A single daemon can host multiple branes. The **home brane** is auto-generated o
12
12
  Brane_id resolution chain for new narratives: fold's `brane_id` → `config default.brane` → home brane (`getBraneId`).
13
13
 
14
14
  Existing folds are backfilled to the home brane on upgrade (idempotent, runs on every `openDatabase()`).
15
+
16
+ ## Archived branes
17
+
18
+ A brane has no row of its own: it is the `brane_id` its narratives share. Archiving one writes a row in `branes` (`brane_id`, `archived`, `archived_at`), lazily and never deleted; unarchiving clears the flag (`src/db/branes.ts`, `PATCH /api/branes/archive`). The switcher offers it on each brane row's ⋯ and lists archived branes, with **Unarchive**, behind **Show N archived**, under the daemon that holds them.
19
+
20
+ What an archived brane hides:
21
+
22
+ - **The brane itself** from `GET /api/branes`, unless `archived=1`. `list_branes` still returns it, flagged `archived`, so an agent can reach it on purpose.
23
+ - **Its ended narratives** from every listing that names no brane: the app's All branes and its cost total, `brane list`, `brane memory log`. Naming the brane (`brane_ids`, or selecting it in the switcher) still shows all of it.
24
+
25
+ What it never hides is a **running** narrative: one whose status is not `ended`, or whose agent is on the bridge. Archiving a single narrative is refused while it runs (`src/daemon/archive-gate.ts`) so that a rogue agent can't drop out of sight, and an agent may spawn into an archived brane, so the brane must not become a hiding place either.
26
+
27
+ Two branes can't be archived: the **home brane**, where every spawn without a brane lands, and **the last brane not already archived**, which would leave All branes with nothing. The daemon refuses both with the reason; the switcher doesn't offer them. Archiving is an operator act, closed to agents' tokens.
@@ -379,6 +379,16 @@ revoke), the **requester's own** token (409; "Sign
379
379
  out other devices" keeps this one and signs out the rest), and an id that
380
380
  is **not live** (404, which the app treats as done).
381
381
 
382
+ A device that does mean to sign itself out uses `POST /api/token/forget`
383
+ (`forgetSelfRoute`, `src/daemon/token-routes.ts`). It is what the app's
384
+ **Delete permanently** on a daemon sends before forgetting it, so deleting
385
+ a daemon from a device leaves no live token on the daemon that nobody
386
+ holds. It is bodiless, revokes the requester's token and the externals it
387
+ dialed in (the devices it added stay), closes its sockets, and is refused
388
+ to anything but a device token. If the daemon can't be reached, the app
389
+ says so and offers to forget it anyway; the token then waits in another
390
+ device's Devices list for a Revoke.
391
+
382
392
  **A revoke can take the devices it added** (`?also=a,b`, in the query
383
393
  string so the request stays bodiless; `brane token revoke --cascade`). The
384
394
  first thing a stolen token's holder does is mint a spare, and a spare's
package/docs/view-urls.md CHANGED
@@ -56,15 +56,12 @@ pins, Files path and line, the `?scrollToSeq=` selection) goes through
56
56
  since the stack did not grow. So the URL keeps that state and Back returns to
57
57
  it, but it never costs an extra Back: browsing ten files is one entry, not ten.
58
58
  A bare link to the narrative you are in, with no seq, goes nowhere. The list
59
- is a jump too: a swipe down on the narrative's header card dismisses it to
60
- a pushed list, so Back returns to the narrative, whether or not a list was
61
- already beneath. **All narratives** in the switcher, in a browser, does not
62
- navigate this tab at all: it is a link that opens the list in a new one, on
63
- the same daemon (see "A new tab names its daemon" below). On native, and in
64
- the web app launched from the home screen, it pushes like the swipe: there is
65
- no tab to open, and on iOS a `_blank` link from a home-screen app lands in a
66
- browser view that shares none of its storage, so it would open on the meld
67
- screen. So does
59
+ is a jump too: **All narratives** in the switcher pushes it, so Back returns
60
+ to the narrative, whether or not a list was already beneath. It is an anchor
61
+ like the prose links, so a ⌘-click opens the list in a new tab instead, on
62
+ the same daemon (see "A new tab names its daemon" below). (In 0.3.3 every
63
+ click opened a new tab; that was undone.) So does a swipe down on the
64
+ narrative's header card, which dismisses it. So does
68
65
  **Archive** in the narrative's 🦠 menu, once the daemon has confirmed it (the
69
66
  pushed list fetches on mount, and nothing is broadcast to correct a fetch that
70
67
  raced the archive); Back returns to the archived narrative. Archiving from the
@@ -151,8 +148,8 @@ starts with none: ⌘-click, middle-click, long-press "Open in New Tab" and
151
148
  this browser's *default* daemon, which on a shared app with several melded
152
149
  need not be the one the link came from. So every in-app anchor names it:
153
150
  `inAppLinkProps` (the `@nid`, `nid:seq` and file links in prose, the narrate
154
- rows in tool groups) and `newTabLinkProps` (**All narratives**) render their
155
- href with `#daemon=<home brane_id>` (`DAEMON_PARAM`, `withDaemonFragment`),
151
+ rows in tool groups, **All narratives**) renders its href with
152
+ `#daemon=<home brane_id>` (`DAEMON_PARAM`, `withDaemonFragment`),
156
153
  while a plain click still navigates in place by the clean path. It goes in the
157
154
  fragment because it says which daemon, not what you are looking at: it is a
158
155
  connection param (`CONNECTION_PARAMS`), and like the token it never reaches the
package/docs/web-app.md CHANGED
@@ -16,8 +16,8 @@ The Expo app runs as a web PWA served by the daemon (and optionally via Cloudfla
16
16
  - **Archive/unarchive** — from the 🦠 menu, on a list card's status badge or in the narrative's header. Archive is offered only once the session has ended (the same test that swaps End for Revive); Unarchive on any archived narrative. Archiving from the header goes to the list as a jump, so Back returns — see [View URLs](view-urls.md)
17
17
  - **Payload rendering** — inline display of image payloads in chat
18
18
  - **Export** — download a narrative as standalone HTML via `/api/narratives/:id/export?format=html`. Foreign rows carry their speaker's hue inline, from the same `speakerColor` / `speakerBgColor` the chat uses, at the 🎨 Recolor salt the device sends as `&palette=` — the one place the salt leaves the browser. With messages selected, the menu offers both sides of the selection — **those messages only**, or the conversation with **those messages left out** — see [Export Scope](export-scope.md)
19
- - **Brane switcher** — dropdown to filter narratives by brane, add/switch daemons. Each brane row (and "All branes") shows its running metered cost as `$2441.72 (138) + 100.2 AIC (1)` — one total per unit with the number of sessions it covers, so a per-session figure can be read off without the line asserting one; the list screen re-reads `/api/branes` (debounced) when a cost `meta` arrives, since the total includes archived narratives the screen never loads. **Devices** lists every token on the active daemon (this device, this machine's CLI and agents, each melded device by browser, platform, address and when it was last seen, and any meld QR not yet scanned) with a Revoke on each other device; **Sign out other devices**, at the foot of that list, rotates instead — see [Token Rotation](token-rotation.md#the-devices-list)
19
+ - **Brane switcher** — dropdown to filter narratives by brane, add/switch daemons. Each row has a ⋯: **Archive** on a brane (on the daemon, for every device — see [Multi-Brane](multi-brane.md#archived-branes)), on another daemon, and, as "Archive this daemon", on the All branes row, which is the active daemon's. An archived daemon stays on this device with its token but is never connected to; **Show N archived** lists archived branes (**Unarchive**) and daemons (**Reconnect**, or **Delete…**, which signs this device out of the daemon with `POST /api/token/forget` and then forgets it). Each brane row (and "All branes") shows its running metered cost as `$2441.72 (138) + 100.2 AIC (1)` — one total per unit with the number of sessions it covers, so a per-session figure can be read off without the line asserting one; the list screen re-reads `/api/branes` (debounced) when a cost `meta` arrives, since the total includes archived narratives the screen never loads. **Devices** lists every token on the active daemon (this device, this machine's CLI and agents, each melded device by browser, platform, address and when it was last seen, and any meld QR not yet scanned) with a Revoke on each other device; **Sign out other devices**, at the foot of that list, rotates instead — see [Token Rotation](token-rotation.md#the-devices-list)
20
20
 
21
21
  `expo/lib/api.ts` also exports `searchNarratives()`, `searchWiki()`, and `readWikiSection()` clients for `/api/search` and `/api/wiki/*`. These have no UI yet — the transport landed ahead of the views.
22
22
 
23
- Connection via the URL **fragment**, `#host=<host>&token=<token>` on the shared app and `#token=<token>` on a daemon's own bundle (the legacy `?token=` query form is still read). Tailscale hostname auto-detected by `brane meld`. See [View URLs](view-urls.md) for why the fragment, and for the Expo Router trap it has to dodge.
23
+ Connection via the URL **fragment**, `#host=<host>&token=<token>` on the shared app and `#token=<token>` on a daemon's own bundle (the legacy `?token=` query form is still read). Tailscale hostname auto-detected by `brane meld`. See [View URLs](view-urls.md) for why the fragment, and for the Expo Router trap it has to dodge. A browser holds daemons from one tailnet only: a link or **+ Add Daemon** entry for a host on another tailnet is refused before anything is sent to it (`src/shared/tailnet.ts`, SECURITY.md, "One tailnet per browser").