@@ -1,3 +1,5 @@
1+<img src="desk/web/shelf-icon.svg" width="96" height="96" alt="Shelf logo">
2+
13# %shelf
24
35A 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
79any desks you republish from other ships. Each app has an Install button that
810opens it in the visitor's own Landscape, and the Dojo `|install` command as a
911fallback. 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
13+JSON.
1114
1215%shelf answers on exactly one path, `/shelf`. Run it behind a reverse proxy
1316that 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
15−page's links point at `/`.
17+[Serving it on its own domain](#serving-it-on-its-own-domain)). The page's
18+links and its install form all point at `/`.
1619
1720## Requirements
1821
1922- 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.
2125
2226## Install
2327
24−Build the desk with Nix. `result` is then the desk folder:
25−
2628```sh
2729nix build github:iko-soy/shelf
2830```
2931
30−From a checkout, `nix build` does the same, or you can use `desk/` directly.
31−
32−Then, with your ship running:
32+`result` is now the desk folder (see [Building with Nix](#building-with-nix)).
33+Without Nix, use `desk/` from a checkout in its place. Then, with your ship
34+running:
3335
3436```
3537~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:
5153on the ship's address the page renders, but its links lead to `/`, which
5254belongs to Landscape.
5355
56+## Building with Nix
57+
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.
61+
62+| Command | Builds |
63+|---|---|
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 |
67+
68+Each one leaves a `result` symlink to the desk in the store:
69+
70+```
71+result/
72+├── desk.bill desk.docket-0 sys.kelvin
73+├── app/ gen/ lib/ mar/ sur/ tests/
74+└── web/
75+```
76+
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
82+ Intel Macs.
83+- nixpkgs is only used for `runCommandLocal`. `nix flake update` moves its
84+ pin in `flake.lock`.
85+- From another flake, for example a NixOS config that sets up a ship, the desk
86+ is:
87+
88+ ```nix
89+ inputs.shelf.url = "github:iko-soy/shelf";
90+ # …
91+ shelf.packages.${system}.default
92+ ```
93+
5494## Update
5595
5696Build and copy the desk into the mount again, as above, and run
@@ -76,7 +116,9 @@ before you can publish it.
76116 `:treaty|unally ~other-ship`.
77117
78118%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.
80122
81123A republished app appears once your ship has received its docket from the
82124publisher, which happens a few seconds after you ally them. Apps are sorted by
@@ -103,7 +145,7 @@ apps.example.com {
103145- `redir` sends every path except `/` back to `/`, so nothing else on your
104146 ship can be reached through this domain.
105147- `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.
107149- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
108150 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
109151 that also serves its own TLS redirects every proxied request to its HTTPS
@@ -113,6 +155,10 @@ apps.example.com {
113155 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
114156 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
115157- 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.
116162
117163This config was tested with Caddy 2.11 in front of a fake ship.
118164
@@ -155,12 +201,15 @@ curl -H 'Accept: application/json' https://apps.example.com/
155201- `image` is `null` when the docket has none, and `website` is `""`.
156202- `image` and `website` are the docket's values as they are. Dockets can come
157203 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
205+ `data:image/…` URL.
206+- Text that isn't valid UTF-8 comes out with U+FFFD in its place, so the
207+ listing is always valid JSON.
159208- Entries are sorted like the page. Key order within an object is not fixed.
160209- JSON wins when the header ranks `application/json` above `text/html`. When
161210 the two rank equally, the more specific match wins, so
162211 `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.
164213- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
165214 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
166215 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/
172221 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
1732222. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
174223 and port. It remembers that origin in a cookie for a year, then redirects
175− to
176− `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
224+ to `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
177225 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.
1792283. On later visits, Install links straight there. The button reads
180229 `Install on ~sampel-palnet` when the URL's first label is a planet name
181230 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
@@ -191,8 +240,9 @@ Guards:
191240 docket image URL pointing at your domain, can plant a URL in a visitor's
192241 cookie or bounce them elsewhere through your domain.
193242- Only apps actually on the shelf can be installed through the form.
194−- On the page, image and website URLs from dockets are used only if they are
195− `http(s)`. Dockets can come from other ships.
243+- On the page, docket images are used only if they are `http(s)` or
244+ `data:image/…` URLs, and website links only if they are `http(s)`.
245+ Dockets can come from other ships.
196246
197247## Traffic
198248
@@ -235,22 +285,115 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
235285 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
236286 it loads, so it also restores the binding if someone removed it by hand.
237287
288+## Changes from the design handoffs
289+
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,
294+and why.
295+
296+**Serving**
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
306+ with 404.
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.
314+
315+**Installing**
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.
334+
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
343+ all visitors.
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
347+ renders.
348+
349+**Page**
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.
362+
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
373+ JSON.
374+
375+**Other**
376+- **Kernel `[%zuse 408]`**, the current release, replacing the handoff's
377+ placeholder 409.
378+
238379## Development
239380
240381The desk is self-contained. `desk/` holds everything Clay needs:
241382
242383| Path | What |
243384|---|---|
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 |
245386| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
246387| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
247388| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
248389| `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=` |
249391| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
250392| `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`) |
252394
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)).
254397
255398Run the tests on a ship with the desk installed:
256399