|
| 1 | +# shortlink |
| 2 | + |
| 3 | +A link shortener for pod resources. Long pod URLs |
| 4 | +(`/alice/notes/2026/07/a-very-long-resource-name….html`) become short, |
| 5 | +shareable redirect links (`/s/x7Kp2Qa`) — with custom slugs, per-creator |
| 6 | +ownership, hit counts, and a preview mode. **Local targets only**: this is a |
| 7 | +pod-resource shortener, not an open redirector. |
| 8 | + |
| 9 | +## Usage |
| 10 | + |
| 11 | +```js |
| 12 | +import { createServer } from 'javascript-solid-server/src/server.js'; |
| 13 | + |
| 14 | +const server = createServer({ |
| 15 | + plugins: [{ |
| 16 | + id: 'shortlink', |
| 17 | + module: './plugins/shortlink/plugin.js', |
| 18 | + prefix: '/s', |
| 19 | + config: { baseUrl: 'https://pod.example' }, // required |
| 20 | + }], |
| 21 | +}); |
| 22 | +``` |
| 23 | + |
| 24 | +`baseUrl` is required (the plugin throws at activate without it — no |
| 25 | +`api.serverInfo`, the usual finding): it is both the locality boundary for |
| 26 | +targets and the origin the minted `short` URLs carry. |
| 27 | + |
| 28 | +| Route | Auth | Does | |
| 29 | +|---|---|---| |
| 30 | +| `POST {prefix}` | bearer | `{ url, slug? }` → 201 `{ short, slug, url, created }` | |
| 31 | +| `GET {prefix}` | bearer | list the caller's own links (with hit counts) | |
| 32 | +| `GET {prefix}/<slug>` | none | 302 → target, `Cache-Control: no-store`; `?preview=1` → the record as JSON | |
| 33 | +| `DELETE {prefix}/<slug>` | bearer, creator only | 204 | |
| 34 | + |
| 35 | +Slugs: omit `slug` for a minted 7-char base62 one; a custom slug is |
| 36 | +lowercased and must match `[a-z0-9-]{1,64}`. A slug held by another agent → |
| 37 | +409; the same agent re-posting the same `url`+`slug` is idempotent (200, |
| 38 | +nothing rewritten). |
| 39 | + |
| 40 | +## A curl session |
| 41 | + |
| 42 | +```sh |
| 43 | +# mint a pod bearer |
| 44 | +TOKEN=$(curl -s -X POST https://pod.example/idp/credentials \ |
| 45 | + -H 'content-type: application/json' \ |
| 46 | + -d '{"username":"alice","password":"…"}' | jq -r .access_token) |
| 47 | + |
| 48 | +# shorten a long pod URL |
| 49 | +curl -s -X POST https://pod.example/s -H "authorization: Bearer $TOKEN" \ |
| 50 | + -H 'content-type: application/json' \ |
| 51 | + -d '{"url":"https://pod.example/alice/notes/2026/07/quarterly-numbers.html"}' |
| 52 | +# → 201 {"short":"https://pod.example/s/x7Kp2Qa","slug":"x7Kp2Qa","url":"…","created":"…"} |
| 53 | + |
| 54 | +# …or claim a custom slug |
| 55 | +curl -s -X POST https://pod.example/s -H "authorization: Bearer $TOKEN" \ |
| 56 | + -H 'content-type: application/json' \ |
| 57 | + -d '{"url":"https://pod.example/alice/notes/2026/07/quarterly-numbers.html","slug":"q2"}' |
| 58 | + |
| 59 | +# follow it (anyone, no auth — the TARGET still enforces its own WAC) |
| 60 | +curl -si https://pod.example/s/q2 | grep -iE 'HTTP|location|cache-control' |
| 61 | +# HTTP/1.1 302 Found |
| 62 | +# location: https://pod.example/alice/notes/2026/07/quarterly-numbers.html |
| 63 | +# cache-control: no-store |
| 64 | + |
| 65 | +# see where it goes without going (safety affordance; not counted as a hit) |
| 66 | +curl -s https://pod.example/s/q2?preview=1 |
| 67 | +# {"short":"…","slug":"q2","url":"…","created":"…","agent":"https://pod.example/alice/profile/card#me","hits":3} |
| 68 | + |
| 69 | +# an external target is refused — not an open redirector |
| 70 | +curl -s -X POST https://pod.example/s -H "authorization: Bearer $TOKEN" \ |
| 71 | + -H 'content-type: application/json' -d '{"url":"https://evil.example/phish"}' |
| 72 | +# → 400 |
| 73 | + |
| 74 | +# my links, and cleanup (creator only) |
| 75 | +curl -s https://pod.example/s -H "authorization: Bearer $TOKEN" |
| 76 | +curl -s -X DELETE https://pod.example/s/q2 -H "authorization: Bearer $TOKEN" # 204; others get 403 |
| 77 | +``` |
| 78 | + |
| 79 | +## What maps / what doesn't |
| 80 | + |
| 81 | +**Maps cleanly:** authed minting via `api.auth.getAgent` (any credential |
| 82 | +scheme); per-creator ownership from the same agent id; a JSON table in |
| 83 | +`api.storage.pluginDir()`; one exact route (`{prefix}` for POST/list) |
| 84 | +coexisting with a wildcard (`{prefix}/*` for slug GET/DELETE) — Fastify |
| 85 | +prefers the more specific match, no tricks needed. |
| 86 | + |
| 87 | +**Doesn't:** the plugin can't learn its own origin (`config.baseUrl` |
| 88 | +required — `api.serverInfo` seam); it can't raise `maxParamLength`, so slug |
| 89 | +routes are wildcards after capability/'s finding; and there is no guidance |
| 90 | +on *where plugin state should live* — see finding 4. |
| 91 | + |
| 92 | +## Findings |
| 93 | + |
| 94 | +1. **No open redirector, by decision.** Targets must live under |
| 95 | + `config.baseUrl` — anything else is 400. An unauthenticated 302 to |
| 96 | + arbitrary URLs is phishing infrastructure: the pod's domain reputation |
| 97 | + would launder any destination, and every mail filter that trusts the pod |
| 98 | + host would trust the phish. Locality is enforced by **parsed-origin |
| 99 | + comparison, never `url.startsWith(baseUrl)`** — the string check accepts |
| 100 | + `https://pod.example.evil.test` when baseUrl is `https://pod.example` |
| 101 | + (the classic open-redirect-filter bypass; the test suite drives it). |
| 102 | + The corollary is the nice part: because every target is local, |
| 103 | + redirecting to a **WAC-protected** resource is perfectly safe — the 302 |
| 104 | + grants nothing, the pod runs its own auth when the client arrives, and |
| 105 | + an anonymous follower of a short link to a private note gets the pod's |
| 106 | + 401, not the note. A loopback-free demonstration that **route ownership |
| 107 | + ≠ authority**: the plugin names resources, it never confers access to |
| 108 | + them. (Same boundary capability/ documents from the other side.) Bonus |
| 109 | + hygiene for free: local-only targets mean the redirect can never leak a |
| 110 | + Referer to a third-party host, so no `Referrer-Policy` gymnastics needed. |
| 111 | +2. **`pluginDir` JSON-table persistence — the 11th witness.** One file |
| 112 | + (`links.json`, slug → `{ url, agent, created, hits }`), loaded into a |
| 113 | + `Map` at activate, written back on mutation — otp/'s shape verbatim. |
| 114 | + Eleven of the repo's plugins now persist through `pluginDir()` (relay, |
| 115 | + capability, otp, activitypub, gitscratch, mastodon, oembed, search, |
| 116 | + terminal, didweb, shortlink); the pattern costs ~10 lines every time and |
| 117 | + has never needed anything from core. It is easily the most settled seam |
| 118 | + in the api. |
| 119 | +3. **Hit-counter durability: fire-and-forget, on purpose.** Creates and |
| 120 | + deletes are written synchronously (a link must never be lost), but the |
| 121 | + redirect path increments in memory and flushes asynchronously on a |
| 122 | + serialized promise chain — the 302 must not wait on disk. A crash loses |
| 123 | + recent *counts*, never *links*. A real deployment that cared about the |
| 124 | + numbers would want an append-only hit log (WAL) replayed into the table |
| 125 | + at boot, or batched interval flushes with the loss window made explicit |
| 126 | + — and past one process, a real datastore, because a rewritten JSON |
| 127 | + snapshot has no cross-process story at all. Counts here are advisory; |
| 128 | + that's the honest label. |
| 129 | +4. **Where should plugin state live? `pluginDir` vs pod resources — a |
| 130 | + design-note seam.** Short links on a *pod* server would more naturally |
| 131 | + be POD RESOURCES: a `/alice/shortlinks/` container the owner controls, |
| 132 | + WAC-governed, portable, visible to other apps. But walk it through: the |
| 133 | + CREATE path becomes a micropub-style loopback write into the pod, and — |
| 134 | + the killer — every REDIRECT becomes a loopback read per hit, turning the |
| 135 | + hottest, cheapest route (a Map lookup and a 302) into an internal HTTP |
| 136 | + round-trip; the hit counter would be a read-modify-write per hit against |
| 137 | + WAC. So this plugin chose `pluginDir` for latency, and the cost is real: |
| 138 | + the links are invisible to the pod's own tooling, don't back up with the |
| 139 | + pod (backup/ won't see them), don't move if the pod moves, and deletion |
| 140 | + rights ride the plugin's own `agent ===` check instead of WAC. Neither |
| 141 | + answer is wrong; the point is **the api gives no guidance** — `storage` |
| 142 | + offers exactly one primitive (`pluginDir()`) and no doctrine for when |
| 143 | + state belongs in the pod instead. A `storage` design note (rule of |
| 144 | + thumb: *pod for user-owned durable data an owner should see and control; |
| 145 | + pluginDir for indexes, secrets, and hot operational state — and say |
| 146 | + which one your plugin picked*) would do more for consistency across |
| 147 | + plugins than any new primitive. shortlink's records are arguably |
| 148 | + user-owned durable data serving as hot operational state — precisely the |
| 149 | + case the missing note needs to adjudicate. |
| 150 | +5. **`maxParamLength` avoided pre-emptively (capability/'s finding pays |
| 151 | + off).** Slugs are ≤ 64 chars, under Fastify's 100-char named-param |
| 152 | + limit, so `:slug` would *work* — but any request with a longer garbage |
| 153 | + slug would 404 at the *router* with Fastify's default body instead of |
| 154 | + this plugin's JSON error. Since a plugin can't set server options, the |
| 155 | + slug routes are wildcards (`{prefix}/*`, reject anything containing |
| 156 | + `/`), which handles arbitrary-length input uniformly; the suite drives a |
| 157 | + 150-char slug and asserts *our* 404 body answered. Second consumer for |
| 158 | + the "can't set Fastify server options" note, this time as dodge rather |
| 159 | + than bite. |
| 160 | +6. **`Cache-Control: no-store` on the 302 is what makes DELETE real.** A |
| 161 | + cacheable redirect outlives its record — an intermediary that cached the |
| 162 | + 302 would keep resolving a deleted (or worse, a *reassigned*) slug. |
| 163 | + Every slug-route response carries `no-store`, and the deletion test |
| 164 | + passes because of it. Redirect hygiene is two lines, but only if you |
| 165 | + remember them. |
0 commit comments