@@ -1,3 +1,5 @@
1+<img src="desk/web/shelf-icon.svg" width="96" height="96" alt="Shelf logo">
3A public web page that lists the apps your Urbit ship publishes, so anyone can
5A public web page that lists the apps your Urbit ship publishes, so anyone can
@@ -7,29 +9,29 @@ The page shows your ship's `%treaty` alliance: your own published desks plus
7any desks you republish from other ships. Each app has an Install button that
9any desks you republish from other ships. Each app has an Install button that
8opens it in the visitor's own Landscape, and the Dojo `|install` command as a
10opens it in the visitor's own Landscape, and the Dojo `|install` command as a
9fallback. The page follows the visitor's light or dark preference. It has no
11fallback. The page follows the visitor's light or dark preference. It has no
10−JavaScript except for the Copy buttons.
12+JavaScript except for the Copy buttons. The same URL also returns the list as
12%shelf answers on exactly one path, `/shelf`. Run it behind a reverse proxy
15%shelf answers on exactly one path, `/shelf`. Run it behind a reverse proxy
13that serves it at the root of a dedicated domain (see
16that serves it at the root of a dedicated domain (see
14−[Serving it on its own domain](#serving-it-on-its-own-domain)). All of the
17+[Serving it on its own domain](#serving-it-on-its-own-domain)). The page's
15−page's links point at `/`.
18+links and its install form all point at `/`.
17## Requirements
20## Requirements
19- Kernel `[%zuse 408]`.
22- Kernel `[%zuse 408]`.
20−- `%landscape`, which provides `%treaty`. Fresh ships have it.
23+- `%landscape`, which provides `%treaty` and `%docket`. Fresh ships have it.
24+- Nix, for the one-command build. Without it, copy `desk/` from a checkout.
24−Build the desk with Nix. `result` is then the desk folder:
27nix build github:iko-soy/shelf
29nix build github:iko-soy/shelf
30−From a checkout, `nix build` does the same, or you can use `desk/` directly.
32+`result` is now the desk folder (see [Building with Nix](#building-with-nix)).
31−
33+Without Nix, use `desk/` from a checkout in its place. Then, with your ship
32−Then, with your ship running:
34+running:
35~sampel-palnet:dojo> |new-desk %shelf
37~sampel-palnet:dojo> |new-desk %shelf
@@ -51,6 +53,44 @@ tile that opens it. It is meant to be opened through your own domain, though:
51on the ship's address the page renders, but its links lead to `/`, which
53on the ship's address the page renders, but its links lead to `/`, which
52belongs to Landscape.
54belongs to Landscape.
58+`flake.nix` has one output: `packages.<system>.default`, the desk. Building it
59+copies `desk/` into the Nix store; nothing is compiled, because Clay compiles
60+the Hoon on your ship when you commit the desk.
64+| `nix build github:iko-soy/shelf` | the latest commit on the default branch |
65+| `nix build github:iko-soy/shelf/<commit>` | a specific commit |
66+| `nix build` | your checkout, from the repo root |
68+Each one leaves a `result` symlink to the desk in the store:
72+├── desk.bill desk.docket-0 sys.kelvin
73+├── app/ gen/ lib/ mar/ sur/ tests/
77+- Files in the store are read-only, so `chmod -R u+w` the mount after copying
78+ them in: the ship has to be able to write to its own mount.
79+- `nix flake check --all-systems` checks the flake on every system it
80+ supports: `x86_64-linux`, `aarch64-linux` and `aarch64-darwin`. Plain
81+ `nix flake check` checks only your own. Current nixpkgs no longer supports
83+- nixpkgs is only used for `runCommandLocal`. `nix flake update` moves its
85+- From another flake, for example a NixOS config that sets up a ship, the desk
89+ inputs.shelf.url = "github:iko-soy/shelf";
91+ shelf.packages.${system}.default
56Build and copy the desk into the mount again, as above, and run
96Build and copy the desk into the mount again, as above, and run
@@ -76,7 +116,9 @@ before you can publish it.
76 `:treaty|unally ~other-ship`.
116 `:treaty|unally ~other-ship`.
78%shelf has a docket of its own, so `:treaty|publish %shelf` lists it on its
118%shelf has a docket of its own, so `:treaty|publish %shelf` lists it on its
79−own page, where others can install it.
119+own page, where others can install it. The docket carries the app icon
120+inline, as a URL-encoded `data:` URL, so the icon shows in Landscape and on
121+other ships' pages with nothing to host.
81A republished app appears once your ship has received its docket from the
123A republished app appears once your ship has received its docket from the
82publisher, which happens a few seconds after you ally them. Apps are sorted by
124publisher, which happens a few seconds after you ally them. Apps are sorted by
@@ -103,7 +145,7 @@ apps.example.com {
103- `redir` sends every path except `/` back to `/`, so nothing else on your
145- `redir` sends every path except `/` back to `/`, so nothing else on your
104 ship can be reached through this domain.
146 ship can be reached through this domain.
105- `rewrite` replaces only the path, so the query string the page relies on
147- `rewrite` replaces only the path, so the query string the page relies on
106− (`/?install=…`) reaches the ship intact.
148+ (`/?install=…`, `/?asset=…`) reaches the ship intact.
107- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
149- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
108 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
150 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
109 that also serves its own TLS redirects every proxied request to its HTTPS
151 that also serves its own TLS redirects every proxied request to its HTTPS
@@ -113,6 +155,10 @@ apps.example.com {
113 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
155 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
114 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
156 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
115- Your ship's own domain is unaffected: `/` still goes to Landscape.
157- Your ship's own domain is unaffected: `/` still goes to Landscape.
158+- %shelf serves everything the page needs from the desk, at the same URL: the
159+ CSS and JavaScript inline, and the favicon at `/?asset=favicon.svg`. It
160+ also serves the app icon at `/?asset=shelf-icon.svg`, though the docket
161+ carries that inline. Nothing else needs to be reachable.
117This config was tested with Caddy 2.11 in front of a fake ship.
163This config was tested with Caddy 2.11 in front of a fake ship.
@@ -155,12 +201,15 @@ curl -H 'Accept: application/json' https://apps.example.com/
155- `image` is `null` when the docket has none, and `website` is `""`.
201- `image` is `null` when the docket has none, and `website` is `""`.
156- `image` and `website` are the docket's values as they are. Dockets can come
202- `image` and `website` are the docket's values as they are. Dockets can come
157 from other ships, and unlike the page, the JSON doesn't filter these, so
203 from other ships, and unlike the page, the JSON doesn't filter these, so
158− check for `http(s)` before using them.
204+ check them before using them. `image` may also be an inline
206+- Text that isn't valid UTF-8 comes out with U+FFFD in its place, so the
207+ listing is always valid JSON.
159- Entries are sorted like the page. Key order within an object is not fixed.
208- Entries are sorted like the page. Key order within an object is not fixed.
160- JSON wins when the header ranks `application/json` above `text/html`. When
209- JSON wins when the header ranks `application/json` above `text/html`. When
161 the two rank equally, the more specific match wins, so
210 the two rank equally, the more specific match wins, so
162 `application/json, */*` gets JSON. A full tie, a bare `*/*` or a missing
211 `application/json, */*` gets JSON. A full tie, a bare `*/*` or a missing
163− header gets HTML.
212+ header gets HTML. Repeated `Accept` lines count as one list.
164- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
213- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
165 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
214 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
166 its session cookie to every response, and on your own logged-in requests
215 its session cookie to every response, and on your own logged-in requests
@@ -172,10 +221,10 @@ curl -H 'Accept: application/json' https://apps.example.com/
172 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
221 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
1732. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
2222. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
174 and port. It remembers that origin in a cookie for a year, then redirects
223 and port. It remembers that origin in a cookie for a year, then redirects
176− `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
224+ to `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
177 Landscape's own "Get App" dialog. The visitor's ship fetches the app's
225 Landscape's own "Get App" dialog. The visitor's ship fetches the app's
178− details directly from the publisher.
226+ details directly from the publisher, and a visitor who isn't logged in
227+ lands there after their ship's login page.
1793. On later visits, Install links straight there. The button reads
2283. On later visits, Install links straight there. The button reads
180 `Install on ~sampel-palnet` when the URL's first label is a planet name
229 `Install on ~sampel-palnet` when the URL's first label is a planet name
181 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
230 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
@@ -191,8 +240,9 @@ Guards:
191 docket image URL pointing at your domain, can plant a URL in a visitor's
240 docket image URL pointing at your domain, can plant a URL in a visitor's
192 cookie or bounce them elsewhere through your domain.
241 cookie or bounce them elsewhere through your domain.
193- Only apps actually on the shelf can be installed through the form.
242- Only apps actually on the shelf can be installed through the form.
243+- On the page, docket images are used only if they are `http(s)` or
194−- On the page, image and website URLs from dockets are used only if they are
244+ `data:image/…` URLs, and website links only if they are `http(s)`.
195− `http(s)`. Dockets can come from other ships.
245+ Dockets can come from other ships.
197## Traffic
247## Traffic
@@ -235,22 +285,115 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
235 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
285 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
236 it loads, so it also restores the binding if someone removed it by hand.
286 it loads, so it also restores the binding if someone removed it by hand.
288+## Changes from the design handoffs
290+%shelf follows two design handoffs: the page itself (layout, copy, behaviour,
291+JSON, a skeleton agent) and a later icon update. Rendered with the mock's sample
292+data, the page matches the design mock pixel for pixel in light and dark mode,
293+apart from the icons and names. Everything below differs from the handoffs,
297+- **One path, `/shelf`, instead of `/`.** On most ships Landscape's `%docket`
298+ owns `/`, and Eyre doesn't refuse a second claim: it silently hands `/`
299+ over, which would take away the owner's login page. A dedicated domain
300+ mapped onto `/shelf` gives visitors a clean root URL instead.
301+- **One URL for everything.** Because only `/` reaches the ship through that
302+ domain, the CSS and JavaScript are inlined (not `/shelf.css`, `/copy.js`),
303+ the icons are at `/?asset=…` (not `/favicon.svg`, `/shelf-icon.svg`), the
304+ install form posts to `/` (not `/install`), and Change ship is `/?forget`
305+ (not `/forget`). Other paths are the proxy's job; the ship answers them
307+- **JSON by `Accept` header at the same URL, not `/apps.json`, and
308+ `private`.** The single URL rules out `/apps.json`, and `public` caching
309+ could hand an owner's login cookie to a shared cache.
310+- **HTML varies on `Cookie` as well as `Accept`.** Otherwise a browser shows
311+ its cached copy for up to a minute after Change ship or a save.
312+- **Bodies go out in 16 KiB pieces**, because a single large response gets
313+ quadratically slower on its way out of the ship.
316+- **The install form is a POST, accepted only from the page itself, and only
317+ for apps on the shelf.** A GET could be triggered by a link or by another
318+ publisher's docket image, planting a phishing URL in visitors' cookies.
319+- **Deep link:** `<ship>/apps/landscape/search/direct/apps/~pub/desk`,
320+ Landscape's own "show install" route, tested end to end. The handoff's
321+ `…/leap/search/…` also works, but only through a redirect.
322+- **Saved ship URLs keep only scheme, host and port**, so a pasted
323+ `…/apps/groups` doesn't break the deep link. `localhost` and `127.*`
324+ default to `http://`, and ports must be 1–65535.
325+- **The cookie holds the URL as is (not URL-encoded), `HttpOnly`, and gets
326+ `Secure` when the visitor came over HTTPS**, as `Forwarded` or
327+ `X-Forwarded-Proto` says behind a proxy. The normalized URL only contains
328+ cookie-safe characters.
329+- **"Install on ~ship" needs a planet or longer name, or a known ship host.**
330+ The handoff's rule (any first DNS label that parses as an `@p`) turned
331+ `dev.example.com` into "Install on ~dev".
332+- **Returning visitors see "Installs open on ~ship. Change ship"** in the
333+ masthead, a muted link that clears the cookie. The handoff left this open.
335+**Reading the alliance**
336+- **Own desks' treaties are built from Clay**, from each desk's
337+ `desk.docket-0`, as `%treaty` does. `%treaty`'s `/treaty/<ship>/<desk>`
338+ scry only covers other ships' desks and crashes for your own.
339+- **Only scries that can't fail.** The handoff wrapped scries in `mule`, but
340+ inside an agent `mule` can't catch a failed scry: the request just fails.
341+- **Visitors arrive under an Eyre guest identity**, so the owner check
342+ applies only to the settings pokes. Checking every request would 500 for
344+- **Docket text from other ships is handled defensively:** images only
345+ `http(s)` or `data:image/…`, links only `http(s)`, invalid UTF-8 replaced
346+ in the JSON, and a title that starts with a control character still
350+- **Header and rows share one grid (CSS subgrid)**, so columns stay aligned
351+ when Install labels get wider for a saved ship.
352+- **Narrow screens:** with a saved ship the stacked layout starts at 1040px
353+ instead of 860px, and the install button's text wraps; the URL field gets
354+ its own line; long words wrap instead of overflowing.
355+- **Keyboard focus inside a row tints it**, as the handoff's interaction table
356+ says (its CSS only did this on hover).
357+- **"1 app"**, not "1 apps".
358+- **The sigil is drawn in Hoon** by a vendored renderer, in `currentColor` on
359+ `var(--ink)` so it inverts with the theme, at sigil-js's 56px geometry.
360+ Moons and comets get the plain square, as in Landscape.
361+- **New: owner-set raw HTML in `<head>` and `<body>`**, for analytics.
363+**Icons and docket** (the icon update)
364+- **The docket's `image` is the icon inline, as a URL-encoded `data:` URL**,
365+ instead of an absolute URL on the ship, which would need filling in per
366+ ship and would break wherever that ship can't be reached.
367+- **The tile opens `/shelf`** (`site+/shelf`, not `site+/`), and the docket's
368+ website is this repo.
369+- **The docket's description drops "with one-click install"**: first-time
370+ visitors enter their ship's URL before the Install button takes them there.
371+- **`mar/svg` is the upstream mark from `%base`**, not the handoff's new one.
372+- **Icons are served `private`** for the same login-cookie reason as the
376+- **Kernel `[%zuse 408]`**, the current release, replacing the handoff's
238## Development
379## Development
240The desk is self-contained. `desk/` holds everything Clay needs:
381The desk is self-contained. `desk/` holds everything Clay needs:
242| Path | What |
383| Path | What |
244−| `desk.docket-0` | What Landscape shows: title, description, color, version, website, license |
385+| `desk.docket-0` | What Landscape shows: title, description, color, icon, version, website, license. Its `image` is `web/shelf-icon.svg` URL-encoded; a test checks they match |
245| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
386| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
246| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
387| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
247| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
388| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
248| `web/shelf.css`, `web/copy.js` | Inlined into the page at build time with `/*` |
389| `web/shelf.css`, `web/copy.js` | Inlined into the page at build time with `/*` |
390+| `web/shelf-icon.svg`, `web/favicon.svg` | The app icon (also inlined in the docket) and the favicon, served at `?asset=` |
249| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
391| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
250| `lib/sigil.hoon`, `lib/sigil/symbols.hoon` | Vendored [sigil renderer](https://github.com/Fang-/suite) (MIT, ~paldev; shapes from `@urbit/sigil-js`, MIT, Tlon) |
392| `lib/sigil.hoon`, `lib/sigil/symbols.hoon` | Vendored [sigil renderer](https://github.com/Fang-/suite) (MIT, ~paldev; shapes from `@urbit/sigil-js`, MIT, Tlon) |
251−| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`) and `%landscape` (`sur/treaty`, `sur/docket`, `lib/docket`, `mar/docket-0`; tloncorp/landscape `46c6ed8`) |
393+| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`, including `mar/svg`) and `%landscape` (`sur/treaty`, `sur/docket`, `lib/docket`, `mar/docket-0`; tloncorp/landscape `46c6ed8`) |
253−`flake.nix` makes `nix build` produce the desk as `result`.
395+Outside the desk, `flake.nix` and `flake.lock` define the Nix build (see
396+[Building with Nix](#building-with-nix)).
255Run the tests on a ship with the desk installed:
398Run the tests on a ship with the desk installed: