Skip to content

Commit 97a2ed1

Browse files
shortlink plugin: link shortener for pod resources
POST mints 7-char base62 (or custom) slugs for URLs under baseUrl only — deliberately not an open redirector (locality checked by parsed origin, never startsWith; the pod.example.evil.test bypass is in the test suite). 302s carry Cache-Control: no-store so DELETE is real; creator-only deletion; per-agent listing; otp/-style pluginDir JSON table with fire-and-forget hit counts drained in deactivate. Findings: redirecting to WAC-protected resources is safe because a 302 grants nothing (route ownership ≠ authority); pluginDir is now the 11th witnessed persistence consumer — the most settled seam in the api; and pluginDir-vs-pod-resources is an undoctrined storage design choice the api gives no guidance on (latency vs WAC-governed portability).
1 parent f19ab08 commit 97a2ed1

3 files changed

Lines changed: 607 additions & 0 deletions

File tree

shortlink/README.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)