eleven / messenger Public

server: rename the glue service to fetch (#1248)

'Glue' was the name for the whole supporting stack (router, relay,
provisioner…), not one service — this one is the public-web FETCH service
(Paul, 2026-07-22). Mechanical rename, no behaviour change: lchat fetch
run, lchat-fetch.service, LCHAT_FETCH_URL / LCHAT_FETCH_DB,
window.__fetchURL, fetch_previews, docs/fetch.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6595a2ad8aaa7db56d2a877cdfa38de012d7a044 parent 21b8c73
10 files changed, +152 −152
  • CLAUDE.md +2 −2
  • bootstrap.go +5 −5
  • deploy/systemd/{lchat-glue.service => lchat-fetch.service} +4 −4
  • docs/{glue.md => fetch.md} +21 −21
  • glue.go => fetch.go +62 −62
  • glue_test.go => fetch_test.go +35 −35
  • main.go +13 −13
  • subcommands.go +1 −1
  • web/pages/index.html +3 −3
  • web/static/shell.js +6 −6
diff --git a/CLAUDE.md b/CLAUDE.md
index 93c5186..6ecf410 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -165,13 +165,13 @@ in, so deploy is one file (no cgo).
Stood up per instance by `lchat add-hooks`. Design + trust model:
`docs/webhooks.md`; management UI `web/static/apps/webhooks.js` (admin-only).
- `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache).
-- `glue.go` — `lchat glue run`, the **public-web fetch glue**: a shared,
+- `fetch.go` — `lchat fetch run`, the **public-web fetch service**: a shared,
identity-free service (unfurl + camo-style HMAC-signed image proxy) that
fronts public web content for clients, so eventually the *instance* never
sees URLs and card-image hosts never see member IPs. Reuses unfurl.go's
fetch/SSRF/sealed-cache machinery; sees URLs attached to nothing; member
content categorically out of scope. Design + trust model + roadmap
- (client flip, resize, Giphy-with-our-key): `docs/glue.md`.
+ (client flip, resize, Giphy-with-our-key): `docs/fetch.md`.
- `linkscmd.go` — `lchat links run`, the `11ch.at` short-link redirector for
message permalinks (issue #905): a bare apex request bounces to
`elevenmessenger.com`; `/l/<host>` live-checks the target (SSRF-guarded fetch
diff --git a/bootstrap.go b/bootstrap.go
index 49b53c4..acf9136 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -78,12 +78,12 @@ var unitCloud string
//go:embed deploy/systemd/lchat-tunnel.service
var unitTunnel string
-// The public-web fetch glue (`lchat glue run` — see docs/glue.md). Installs
+// The public-web fetch service (`lchat fetch run` — see docs/fetch.md). Installs
// everywhere, stays inert until an operator enables it and fronts it with
-// `add-service`; instances opt in by pointing LCHAT_GLUE_URL at that route.
+// `add-service`; instances opt in by pointing LCHAT_FETCH_URL at that route.
//
-//go:embed deploy/systemd/lchat-glue.service
-var unitGlue string
+//go:embed deploy/systemd/lchat-fetch.service
+var unitFetch string
// The auto-updater (updater.go): a root oneshot fired hourly by its timer.
// Enabled by bootstrap — a pass with no reachable release feed is a no-op, so
@@ -503,7 +503,7 @@ func runBootstrap(args []string) {
write(filepath.Join(*unitDir, "lchat-control.service"), unitControl)
write(filepath.Join(*unitDir, "lchat-cloud.service"), unitCloud)
write(filepath.Join(*unitDir, "lchat-tunnel.service"), unitTunnel)
- write(filepath.Join(*unitDir, "lchat-glue.service"), unitGlue)
+ write(filepath.Join(*unitDir, "lchat-fetch.service"), unitFetch)
write(filepath.Join(*unitDir, "lchat-update.service"), unitUpdate)
write(filepath.Join(*unitDir, "lchat-update.timer"), unitUpdateTimer)
write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf)
diff --git a/deploy/systemd/lchat-glue.service b/deploy/systemd/lchat-fetch.service
similarity index 71%
rename from deploy/systemd/lchat-glue.service
rename to deploy/systemd/lchat-fetch.service
index 7f066a1..c2496d9 100644
--- a/deploy/systemd/lchat-glue.service
+++ b/deploy/systemd/lchat-fetch.service
@@ -1,12 +1,12 @@
[Unit]
-Description=lchat public-web fetch glue (unfurl + signed image proxy)
+Description=lchat public-web fetch service (unfurl + signed image proxy)
After=network-online.target
Wants=network-online.target
[Service]
-# No env file: the glue's secrets (preview salt + image-signing key) mint
-# themselves into its DB on first boot (glue.go); nothing else to configure.
-ExecStart=/opt/lchat/lchat glue run --socket /run/lchat/glue.sock --db /data/glue.db
+# No env file: the service's secrets (preview salt + image-signing key) mint
+# themselves into its DB on first boot (fetch.go); nothing else to configure.
+ExecStart=/opt/lchat/lchat fetch run --socket /run/lchat/fetch.sock --db /data/fetch.db
Restart=always
RestartSec=1
diff --git a/docs/glue.md b/docs/fetch.md
similarity index 72%
rename from docs/glue.md
rename to docs/fetch.md
index d250a34..d280265 100644
--- a/docs/glue.md
+++ b/docs/fetch.md
@@ -1,8 +1,8 @@
-# 🤖 The public-web fetch glue (`lchat glue run`)
+# 🤖 The public-web fetch service (`lchat fetch run`)
A shared, identity-free service that fronts **public web content** for
clients: OpenGraph unfurls and a camo-style signed image proxy for the cards'
-images. One binary subcommand (`glue.go`), one sqlite file, fronted by the
+images. One binary subcommand (`fetch.go`), one sqlite file, fronted by the
instance router like links/push/home. Born in the unfurl-through-Finland
discussion (PRs #1237/#1240 → this): if a service is going to see posted
URLs at all, it should be a service that knows *nothing else*.
@@ -11,22 +11,22 @@ URLs at all, it should be a service that knows *nothing else*.
Unfurling is the messenger's one documented E2E exception: to build a link
card, *some* server must see the URL. Today that server is the member's own
-instance. Moving the fetch to shared glue buys, in rough order of value:
+instance. Moving the fetch to the shared service buys, in rough order of value:
-1. **The instance can stop seeing URLs entirely** (once clients call the glue
+1. **The instance can stop seeing URLs entirely** (once clients call the service
directly — the phase-2 flip below). The instance server then stores and
relays only ciphertext, full stop; the unfurl exception moves to a service
that holds no members, threads, tokens, or instance list.
2. **Viewers stop leaking their IPs to card-image hosts.** The web client
sets `img.src` to the raw `og:image` URL today, so every *viewer* of a
card fetches from a third-party host; `http:`-only images die silently as
- mixed content. The glue's `/img` proxy fixes both.
+ mixed content. Its `/img` proxy fixes both.
3. **Self-hosters stop exposing their egress IP.** A member can post a link
to a server they control and learn the instance's IP — for a home
- self-hoster, their home IP. Pointing unfurl at the glue hides it; running
- their own glue keeps full autonomy.
+ self-hoster, their home IP. Pointing unfurl at the shared service hides it; running
+ their own keeps full autonomy.
4. **A shared warm cache.** Popular links unfurl once per language across
- every space that uses the glue, and the poster/recipient in-flight window
+ every space that uses the service, and the poster/recipient in-flight window
mostly disappears because the cache is usually already warm.
## Trust model
@@ -37,14 +37,14 @@ instance. Moving the fetch to shared glue buys, in rough order of value:
- **Stores:** the sealed preview cache — same convergent-encryption scheme as
the instance's (`unfurl.go`): row key = salted hash of language+URL, blob
opens only with a key derived from language+URL, service-wide salt (minted
- on first boot, lives only in the glue's DB) defeats precomputed
+ on first boot, lives only in the service's DB) defeats precomputed
dictionaries. The cache at rest reveals nothing a reader doesn't already
know the URL for. No request logs; the only log line is startup.
- **Never:** member content. Member photos/files are encrypted client-side
(`processImage` + `encryptBytes`) and are categorically out of scope — the
- glue's remit is content already on the public web. Any future endpoint that
+ its remit is content already on the public web. Any future endpoint that
would touch member bytes is a design error; say no.
-- **Not an open proxy:** `/img` serves only URLs the glue itself HMAC-signed
+- **Not an open proxy:** `/img` serves only URLs the service itself HMAC-signed
while building a card (GitHub-camo shape), so third parties can't drive it
as a generic fetcher. Signing is at response time; the cache stores raw
URLs, so the key could rotate without invalidating cached cards.
@@ -56,33 +56,33 @@ instance. Moving the fetch to shared glue buys, in rough order of value:
`/api/unfurl` and `/img` carry per-IP token buckets, which behind the router
collapse into global buckets (no forwarded IPs — a privacy feature with a
-rate-limiting cost). That is acceptable while nothing points at the glue by
+rate-limiting cost). That is acceptable while nothing points at it by
default. Before the client flip makes it load-bearing, add **hashcash** (the
cloud provisioner already ships the machinery) so anonymous callers pay in
CPU instead of identity.
## Deployment
-Standard sibling-service shape: `lchat glue run` behind the router on a unix
-socket (`-socket /run/lchat/glue.sock`), DB at `/data/glue.db`
-(`LCHAT_GLUE_DB`), service route + `/etc/hosts` pin like cloud/home. Not
+Standard sibling-service shape: `lchat fetch run` behind the router on a unix
+socket (`-socket /run/lchat/fetch.sock`), DB at `/data/fetch.db`
+(`LCHAT_FETCH_DB`), service route + `/etc/hosts` pin like cloud/home. Not
deployed anywhere yet; when it ships, the planet-scale guide gains its row.
No secrets in env — the salt and signing key mint themselves into the DB on
first boot.
## Roadmap
-- **Phase 2 — the client flip.** Instance serves the glue URL to clients
+- **Phase 2 — the client flip.** Instance serves the fetch URL to clients
(config), CSP grows `connect-src`/`img-src` for it, `host.unfurl` prefers
- the glue with instance-unfurl fallback, and hashcash lands with it.
+ the fetch service with instance-unfurl fallback, and hashcash lands with it.
Self-host default stays *local fetch* — a self-hosted instance silently
- shipping its members' URLs to our glue would betray the self-host story;
- pointing at shared glue (or their own) is opt-in config.
+ shipping its members' URLs to our shared service would betray the self-host story;
+ pointing at the shared service (or their own) is opt-in config.
- **Resize.** Public card images only (og:image can be multi-MB); a flag on
the `/img` path, never a new trust decision. Member photos keep their
client-side pipeline.
- **Giphy.** Same service family, authenticated upstream: search rides the
- glue with *our* API key (kills the get-an-API-key wall for spaces), the
+ the fetch service with *our* API key (kills the get-an-API-key wall for spaces), the
space-supplied key remains as override (docs/app-credentials.md). To be
honest about the trade: today Giphy search is client→Giphy (we see
- nothing); via glue we see search terms, identity-free.
+ nothing); via the service we see search terms, identity-free.
diff --git a/glue.go b/fetch.go
similarity index 73%
rename from glue.go
rename to fetch.go
index b0c731b..a91291a 100644
--- a/glue.go
+++ b/fetch.go
@@ -1,15 +1,15 @@
-// `lchat glue run` — the public-web fetch glue (docs/glue.md). A shared,
+// `lchat fetch run` — the public-web fetch service (docs/fetch.md). A shared,
// identity-free service that fronts *public web content* for clients: OpenGraph
// unfurls and an HMAC-signed image proxy for the cards' images. Like links
// (linkscmd.go) it's a subcommand of this binary, not a separate module —
// it deliberately reuses the instance's own unfurl machinery (unfurl.go:
// fetchOG, the SSRF dial guard, the sealed convergent cache, previewFlight).
//
-// Trust model, in one breath: the glue sees URLs (the documented unfurl
+// Trust model, in one breath: the fetch service sees URLs (the documented unfurl
// exception) attached to NOTHING — no token, no instance, no member. Behind
// the router it doesn't even see client IPs (nothing forwards them — that's a
// feature, with the honest cost that per-IP rate buckets collapse to global
-// ones there; hashcash is the planned real gate, see docs/glue.md). It must
+// ones there; hashcash is the planned real gate, see docs/fetch.md). It must
// never grow an endpoint that touches member content: member photos/files are
// encrypted client-side and are categorically out of scope here.
//
@@ -41,22 +41,22 @@ import (
)
const (
- glueImgMaxBytes = 8 * 1024 * 1024 // largest card image we'll proxy
- glueImgTimeout = 15 * time.Second
- glueImgCache = "public, max-age=86400"
+ fetchImgMaxBytes = 8 * 1024 * 1024 // largest card image we'll proxy
+ fetchImgTimeout = 15 * time.Second
+ fetchImgCache = "public, max-age=86400"
)
-// glueImgClient fetches card images. Same SSRF posture as ogClient — every
+// fetchImgClient fetches card images. Same SSRF posture as ogClient — every
// dial (and every redirect hop) re-validates the resolved IP.
-var glueImgClient = &http.Client{
- Timeout: glueImgTimeout,
+var fetchImgClient = &http.Client{
+ Timeout: fetchImgTimeout,
Transport: &http.Transport{
DialContext: (&net.Dialer{
- Timeout: glueImgTimeout,
+ Timeout: fetchImgTimeout,
Control: guardDialControl,
}).DialContext,
- TLSHandshakeTimeout: glueImgTimeout,
- ResponseHeaderTimeout: glueImgTimeout,
+ TLSHandshakeTimeout: fetchImgTimeout,
+ ResponseHeaderTimeout: fetchImgTimeout,
DisableKeepAlives: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -70,7 +70,7 @@ var glueImgClient = &http.Client{
},
}
-type glueServer struct {
+type fetchServer struct {
db *sql.DB
salt string // service-wide preview salt (convergent-encryption pepper)
signKey []byte // HMAC key for /img URLs; minted once, lives only in the DB
@@ -83,7 +83,7 @@ type glueServer struct {
imgLimiter *modLimiter
}
-func newGlueServer(dbPath string) (*glueServer, error) {
+func newFetchServer(dbPath string) (*fetchServer, error) {
d, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
@@ -104,7 +104,7 @@ func newGlueServer(dbPath string) (*glueServer, error) {
// is a salted hash and the blob opens only with a key derived from the
// language+URL, so the shared cache at rest reveals nothing a reader
// doesn't already know the URL for.
- `CREATE TABLE IF NOT EXISTS glue_previews(
+ `CREATE TABLE IF NOT EXISTS fetch_previews(
url_hash TEXT PRIMARY KEY,
blob TEXT NOT NULL,
fetched_at INTEGER NOT NULL)`,
@@ -113,15 +113,15 @@ func newGlueServer(dbPath string) (*glueServer, error) {
return nil, err
}
}
- g := &glueServer{
+ g := &fetchServer{
db: d,
unfurlLimiter: newModLimiter(30, 30.0/60),
imgLimiter: newModLimiter(240, 120.0/60),
}
- if g.salt, err = glueConfigSecret(d, "preview_salt"); err != nil {
+ if g.salt, err = fetchConfigSecret(d, "preview_salt"); err != nil {
return nil, err
}
- keyHex, err := glueConfigSecret(d, "img_sign_key")
+ keyHex, err := fetchConfigSecret(d, "img_sign_key")
if err != nil {
return nil, err
}
@@ -131,10 +131,10 @@ func newGlueServer(dbPath string) (*glueServer, error) {
return g, nil
}
-// glueConfigSecret returns the named config value, minting a random one on
+// fetchConfigSecret returns the named config value, minting a random one on
// first boot — the same insert-if-absent pattern as the instance's
// previewSalt, so restarts and concurrent starts converge on one value.
-func glueConfigSecret(d *sql.DB, key string) (string, error) {
+func fetchConfigSecret(d *sql.DB, key string) (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
@@ -148,7 +148,7 @@ func glueConfigSecret(d *sql.DB, key string) (string, error) {
return v, err
}
-func (g *glueServer) handler() http.Handler {
+func (g *fetchServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/unfurl", g.serveUnfurl)
mux.HandleFunc("GET /img", g.serveImage)
@@ -156,19 +156,19 @@ func (g *glueServer) handler() http.Handler {
fmt.Fprintln(w, "ok")
})
mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]any{"service": "glue", "build": buildVersion})
+ writeJSON(w, http.StatusOK, map[string]any{"service": "fetch", "build": buildVersion})
})
- // The glue has no pages of its own; a stray visit goes to the real site.
+ // The fetch service has no pages of its own; a stray visit goes to the real site.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://elevenmessenger.com", http.StatusFound)
})
return mux
}
-// glueCallerKey is the rate-limit bucket key. Mirrors linkscmd.go — behind
+// fetchCallerKey is the rate-limit bucket key. Mirrors linkscmd.go — behind
// the router (unix socket, no forwarded IPs) this degrades to one shared
// bucket, which is the accepted posture for now (package doc).
-func glueCallerKey(r *http.Request) string {
+func fetchCallerKey(r *http.Request) string {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
@@ -179,8 +179,8 @@ func glueCallerKey(r *http.Request) string {
// serveUnfurl is the instance handler's anonymous twin: no token, same URL
// validation, same language handling, same stale-serve semantics — but the
// card's image URL is rewritten to the signed /img proxy before it leaves.
-func (g *glueServer) serveUnfurl(w http.ResponseWriter, r *http.Request) {
- if !g.unfurlLimiter.allow(glueCallerKey(r)) {
+func (g *fetchServer) serveUnfurl(w http.ResponseWriter, r *http.Request) {
+ if !g.unfurlLimiter.allow(fetchCallerKey(r)) {
httpError(w, http.StatusTooManyRequests, "slow down")
return
}
@@ -208,8 +208,8 @@ func (g *glueServer) serveUnfurl(w http.ResponseWriter, r *http.Request) {
}
// fetchAndCache mirrors the instance's fetchAndCachePreview (main.go),
-// including the mid-fetch waiters fix — against the glue's own store.
-func (g *glueServer) fetchAndCache(norm, lang string) LinkPreview {
+// including the mid-fetch waiters fix — against the fetch service's own store.
+func (g *fetchServer) fetchAndCache(norm, lang string) LinkPreview {
flightKey := previewCacheKey(lang, norm)
f := &previewFlight{done: make(chan struct{})}
if cur, busy := g.inflight.LoadOrStore(flightKey, f); busy {
@@ -233,12 +233,12 @@ func (g *glueServer) fetchAndCache(norm, lang string) LinkPreview {
return pv
}
-func (g *glueServer) getPreview(u, lang string) (LinkPreview, bool) {
+func (g *fetchServer) getPreview(u, lang string) (LinkPreview, bool) {
key := previewCacheKey(lang, u)
var blob string
var fetchedAt int64
err := g.db.QueryRow(
- "SELECT blob, fetched_at FROM glue_previews WHERE url_hash = ?", previewURLHash(g.salt, key),
+ "SELECT blob, fetched_at FROM fetch_previews WHERE url_hash = ?", previewURLHash(g.salt, key),
).Scan(&blob, &fetchedAt)
if err != nil {
return LinkPreview{}, false
@@ -252,35 +252,35 @@ func (g *glueServer) getPreview(u, lang string) (LinkPreview, bool) {
return pv, true
}
-func (g *glueServer) putPreview(pv LinkPreview, lang string) {
+func (g *fetchServer) putPreview(pv LinkPreview, lang string) {
key := previewCacheKey(lang, pv.URL)
blob, err := sealPreview(g.salt, key, pv)
if err != nil {
return
}
// Same put-time TTL sweep as the instance cache: prunes with activity.
- g.db.Exec("DELETE FROM glue_previews WHERE fetched_at < ?", time.Now().Unix()-previewMaxAge)
+ g.db.Exec("DELETE FROM fetch_previews WHERE fetched_at < ?", time.Now().Unix()-previewMaxAge)
g.db.Exec(
- `INSERT INTO glue_previews(url_hash, blob, fetched_at) VALUES(?, ?, ?)
+ `INSERT INTO fetch_previews(url_hash, blob, fetched_at) VALUES(?, ?, ?)
ON CONFLICT(url_hash) DO UPDATE SET blob=excluded.blob, fetched_at=excluded.fetched_at`,
previewURLHash(g.salt, key), blob, pv.FetchedAt)
}
// signedCard swaps a card's raw og:image URL for the signed /img proxy URL,
-// so the viewer's browser talks to the glue, not the image's host (no member
+// so the viewer's browser talks to the fetch service, not the image's host (no member
// IP reaches it, and http-only images stop dying as mixed content).
-func (g *glueServer) signedCard(r *http.Request, pv LinkPreview) LinkPreview {
+func (g *fetchServer) signedCard(r *http.Request, pv LinkPreview) LinkPreview {
if pv.Image != "" {
pv.Image = g.proxyImageURL(r, pv.Image)
}
return pv
}
-func (g *glueServer) proxyImageURL(r *http.Request, img string) string {
+func (g *fetchServer) proxyImageURL(r *http.Request, img string) string {
mac := hmac.New(sha256.New, g.signKey)
mac.Write([]byte(img))
scheme := "https"
- if r.TLS == nil && glueLoopbackHost(r.Host) {
+ if r.TLS == nil && fetchLoopbackHost(r.Host) {
scheme = "http" // tests and bare local runs; behind the router TLS is the router's
}
return scheme + "://" + r.Host + "/img?u=" +
@@ -288,7 +288,7 @@ func (g *glueServer) proxyImageURL(r *http.Request, img string) string {
"&s=" + hex.EncodeToString(mac.Sum(nil))
}
-func glueLoopbackHost(hostport string) bool {
+func fetchLoopbackHost(hostport string) bool {
host, _, err := net.SplitHostPort(hostport)
if err != nil {
host = hostport
@@ -300,13 +300,13 @@ func glueLoopbackHost(hostport string) bool {
return ip != nil && ip.IsLoopback()
}
-// serveImage is the camo half: verify the signature (only URLs this glue
+// serveImage is the camo half: verify the signature (only URLs this service
// itself put in a card are proxyable — never an open proxy), then fetch and
// re-serve the image over our origin. Buffered rather than streamed so an
// oversize image is rejected cleanly instead of truncated mid-body; the size
// cap bounds the memory.
-func (g *glueServer) serveImage(w http.ResponseWriter, r *http.Request) {
- if !g.imgLimiter.allow(glueCallerKey(r)) {
+func (g *fetchServer) serveImage(w http.ResponseWriter, r *http.Request) {
+ if !g.imgLimiter.allow(fetchCallerKey(r)) {
httpError(w, http.StatusTooManyRequests, "slow down")
return
}
@@ -337,9 +337,9 @@ func (g *glueServer) serveImage(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadGateway, "fetch failed")
return
}
- req.Header.Set("User-Agent", "lchat-glue/1.0 (+https://elevenmessenger.com)")
+ req.Header.Set("User-Agent", "lchat-fetch/1.0 (+https://elevenmessenger.com)")
req.Header.Set("Accept", "image/*")
- resp, err := glueImgClient.Do(req)
+ resp, err := fetchImgClient.Do(req)
if err != nil {
httpError(w, http.StatusBadGateway, "fetch failed")
return
@@ -350,52 +350,52 @@ func (g *glueServer) serveImage(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadGateway, "not an image")
return
}
- body, err := io.ReadAll(io.LimitReader(resp.Body, glueImgMaxBytes+1))
- if err != nil || len(body) > glueImgMaxBytes {
+ body, err := io.ReadAll(io.LimitReader(resp.Body, fetchImgMaxBytes+1))
+ if err != nil || len(body) > fetchImgMaxBytes {
httpError(w, http.StatusBadGateway, "image too large")
return
}
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
- w.Header().Set("Cache-Control", glueImgCache)
+ w.Header().Set("Cache-Control", fetchImgCache)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(body)
}
-// runGlueCmd dispatches `lchat glue <command>`, mirroring runLinksCmd.
-func runGlueCmd(args []string) {
+// runFetchCmd dispatches `lchat fetch <command>`, mirroring runLinksCmd.
+func runFetchCmd(args []string) {
if len(args) < 1 {
- fmt.Fprintln(os.Stderr, `usage: lchat glue <command>
+ fmt.Fprintln(os.Stderr, `usage: lchat fetch <command>
commands:
- run serve the public-web fetch glue (unfurl + signed image proxy; docs/glue.md)`)
+ run serve the public-web fetch service (unfurl + signed image proxy; docs/fetch.md)`)
os.Exit(2)
}
switch args[0] {
case "run":
- runGlueRun(args[1:])
+ runFetchRun(args[1:])
default:
- die("glue: unknown command %q", args[0])
+ die("fetch: unknown command %q", args[0])
}
}
-// runGlueRun is `lchat glue run`.
-func runGlueRun(args []string) {
- fs := flag.NewFlagSet("glue", flag.ExitOnError)
+// runFetchRun is `lchat fetch run`.
+func runFetchRun(args []string) {
+ fs := flag.NewFlagSet("fetch", flag.ExitOnError)
addr := fs.String("addr", envOr("ADDR", ":1749"), "listen address (env: ADDR)")
socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET) — how the router fronts this service")
- dbPath := fs.String("db", envOr("LCHAT_GLUE_DB", "glue.db"), "sqlite path for the sealed preview cache + service secrets (env: LCHAT_GLUE_DB)")
+ dbPath := fs.String("db", envOr("LCHAT_FETCH_DB", "fetch.db"), "sqlite path for the sealed preview cache + service secrets (env: LCHAT_FETCH_DB)")
_ = fs.Parse(args)
- g, err := newGlueServer(*dbPath)
+ g, err := newFetchServer(*dbPath)
if err != nil {
- die("glue: %v", err)
+ die("fetch: %v", err)
}
ln, addrDesc, err := listen(*addr, *socket)
if err != nil {
- die("glue: %v", err)
+ die("fetch: %v", err)
}
- // Deliberately the only startup log line — the glue never logs URLs.
- log.Printf("glue: serving on %s", addrDesc)
+ // Deliberately the only startup log line — the fetch service never logs URLs.
+ log.Printf("fetch: serving on %s", addrDesc)
srv := &http.Server{Handler: g.handler(), ReadHeaderTimeout: 10 * time.Second}
if err := srv.Serve(ln); err != nil {
log.Fatal(err)
diff --git a/glue_test.go b/fetch_test.go
similarity index 71%
rename from glue_test.go
rename to fetch_test.go
index f8724c7..5c92f75 100644
--- a/glue_test.go
+++ b/fetch_test.go
@@ -10,11 +10,11 @@ import (
"testing"
)
-func newTestGlue(t *testing.T) (*httptest.Server, *glueServer) {
+func newTestFetch(t *testing.T) (*httptest.Server, *fetchServer) {
t.Helper()
- g, err := newGlueServer(t.TempDir() + "/glue.db")
+ g, err := newFetchServer(t.TempDir() + "/fetch.db")
if err != nil {
- t.Fatalf("newGlueServer: %v", err)
+ t.Fatalf("newFetchServer: %v", err)
}
ts := httptest.NewServer(g.handler())
t.Cleanup(ts.Close)
@@ -22,11 +22,11 @@ func newTestGlue(t *testing.T) (*httptest.Server, *glueServer) {
return ts, g
}
-// The glue unfurls anonymously (no token — it has no members to know about),
+// The fetch service unfurls anonymously (no token — it has no members to know about),
// rewrites the card's image to its signed /img proxy, and that proxy URL
-// actually serves the image bytes back over the glue's own origin.
-func TestGlueUnfurlAndImageProxy(t *testing.T) {
- ts, g := newTestGlue(t)
+// actually serves the image bytes back over the fetch service's own origin.
+func TestFetchUnfurlAndImageProxy(t *testing.T) {
+ ts, g := newTestFetch(t)
allowPrivatePreviewHosts = true
t.Cleanup(func() { allowPrivatePreviewHosts = false })
@@ -39,7 +39,7 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) {
return
}
w.Header().Set("Content-Type", "text/html")
- w.Write([]byte(`<head><meta property="og:title" content="Glued">` +
+ w.Write([]byte(`<head><meta property="og:title" content="Fetched">` +
`<meta property="og:image" content="/pic.png"></head>`))
}))
t.Cleanup(site.Close)
@@ -51,10 +51,10 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) {
var pv LinkPreview
json.NewDecoder(r.Body).Decode(&pv)
r.Body.Close()
- if !pv.OK || pv.Title != "Glued" {
+ if !pv.OK || pv.Title != "Fetched" {
t.Fatalf("preview = %+v", pv)
}
- // The image is no longer the site's URL — it's the glue's signed proxy.
+ // The image is no longer the site's URL — it's the fetch service's signed proxy.
if !strings.HasPrefix(pv.Image, ts.URL+"/img?") {
t.Fatalf("image not proxied: %q", pv.Image)
}
@@ -74,10 +74,10 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) {
// Cached: a second unfurl works with the cache row (and stays sealed at
// rest — no plaintext in the stored row).
var urlHash, blob string
- if err := g.db.QueryRow("SELECT url_hash, blob FROM glue_previews").Scan(&urlHash, &blob); err != nil {
+ if err := g.db.QueryRow("SELECT url_hash, blob FROM fetch_previews").Scan(&urlHash, &blob); err != nil {
t.Fatalf("raw row: %v", err)
}
- for _, secret := range []string{site.URL, "Glued", "pic.png"} {
+ for _, secret := range []string{site.URL, "Fetched", "pic.png"} {
if strings.Contains(urlHash+blob, secret) {
t.Fatalf("plaintext %q found in the stored row", secret)
}
@@ -85,10 +85,10 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) {
}
// /img is not an open proxy: an unsigned or tampered URL never fetches.
-func TestGlueImageProxyRejectsUnsigned(t *testing.T) {
- ts, g := newTestGlue(t)
+func TestFetchImageProxyRejectsUnsigned(t *testing.T) {
+ ts, g := newTestFetch(t)
- // A URL the glue never signed: correct encoding, wrong signature.
+ // A URL the fetch service never signed: correct encoding, wrong signature.
u := "aHR0cDovL2V4YW1wbGUuY29tL3guanBn" // base64url("http://example.com/x.jpg")
for _, q := range []string{
"u=" + u + "&s=" + strings.Repeat("00", 32),
@@ -125,10 +125,10 @@ func TestGlueImageProxyRejectsUnsigned(t *testing.T) {
}
}
-// Language variants cache as separate rows on the glue too — same contract
+// Language variants cache as separate rows on the fetch service too — same contract
// as the instance (TestUnfurlLanguage), shared-cache edition.
-func TestGlueUnfurlLanguageVariants(t *testing.T) {
- ts, g := newTestGlue(t)
+func TestFetchUnfurlLanguageVariants(t *testing.T) {
+ ts, g := newTestFetch(t)
allowPrivatePreviewHosts = true
t.Cleanup(func() { allowPrivatePreviewHosts = false })
@@ -163,16 +163,16 @@ func TestGlueUnfurlLanguageVariants(t *testing.T) {
t.Fatalf("en-IE = %q", got)
}
var rows int
- g.db.QueryRow("SELECT COUNT(*) FROM glue_previews").Scan(&rows)
+ g.db.QueryRow("SELECT COUNT(*) FROM fetch_previews").Scan(&rows)
if rows != 2 {
t.Fatalf("cache rows = %d, want 2", rows)
}
}
-// The client flip is env-gated: with LCHAT_GLUE_URL set, the chat page names
-// the glue in connect-src and hands the origin to shell.js; unset, the page
+// The client flip is env-gated: with LCHAT_FETCH_URL set, the chat page names
+// the fetch service in connect-src and hands the origin to shell.js; unset, the page
// is byte-identical to today's (self-host default = local unfurl).
-func TestGlueClientFlipInjection(t *testing.T) {
+func TestFetchClientFlipInjection(t *testing.T) {
ts, srv := newTestServer(t)
get := func() (csp, body string) {
@@ -187,41 +187,41 @@ func TestGlueClientFlipInjection(t *testing.T) {
}
csp, body := get()
- if strings.Contains(csp, "glue") {
- t.Fatalf("CSP names a glue with none configured: %s", csp)
+ if strings.Contains(csp, "fetch.example") {
+ t.Fatalf("CSP names a fetch service with none configured: %s", csp)
}
- if !strings.Contains(body, `window.__glueURL = ""`) {
- t.Fatal("page should inject an empty glue URL when unconfigured")
+ if !strings.Contains(body, `window.__fetchURL = ""`) {
+ t.Fatal("page should inject an empty fetch URL when unconfigured")
}
- srv.glueURL = "https://glue.example"
+ srv.fetchURL = "https://fetch.example"
csp, body = get()
if !strings.Contains(csp, "connect-src 'self' https://*.giphy.com") ||
- !strings.Contains(csp, " https://glue.example; ") {
- t.Fatalf("CSP missing the glue origin: %s", csp)
+ !strings.Contains(csp, " https://fetch.example; ") {
+ t.Fatalf("CSP missing the fetch service origin: %s", csp)
}
- if !strings.Contains(body, `window.__glueURL = "https://glue.example"`) {
- t.Fatal("page missing the injected glue URL")
+ if !strings.Contains(body, `window.__fetchURL = "https://fetch.example"`) {
+ t.Fatal("page missing the injected fetch URL")
}
}
// Service secrets survive a restart: the same DB yields the same salt and
// signing key, so cached rows still open and signed URLs stay valid.
-func TestGlueSecretsPersist(t *testing.T) {
+func TestFetchSecretsPersist(t *testing.T) {
dir := t.TempDir()
- g1, err := newGlueServer(dir + "/glue.db")
+ g1, err := newFetchServer(dir + "/fetch.db")
if err != nil {
t.Fatalf("first open: %v", err)
}
salt, key := g1.salt, g1.signKey
g1.db.Close()
- g2, err := newGlueServer(dir + "/glue.db")
+ g2, err := newFetchServer(dir + "/fetch.db")
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer g2.db.Close()
if g2.salt != salt || string(g2.signKey) != string(key) {
- t.Fatal("glue secrets changed across restart")
+ t.Fatal("fetch-service secrets changed across restart")
}
if len(key) != 32 || salt == "" {
t.Fatalf("weak secrets: key %d bytes, salt %q", len(key), salt)
diff --git a/main.go b/main.go
index 72056ee..f91f924 100644
--- a/main.go
+++ b/main.go
@@ -83,11 +83,11 @@ type Server struct {
// <homeOrigin>/enroll as a user-initiated popup to back this instance up (see
// the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment.
homeOrigin string
- // The public-web fetch glue this instance's clients should prefer for
- // unfurls (docs/glue.md) — an origin like https://glue.example, set via
- // LCHAT_GLUE_URL. Empty (the default, and the self-host default on
+ // The public-web fetch service this instance's clients should prefer for
+ // unfurls (docs/fetch.md) — an origin like https://fetch.example, set via
+ // LCHAT_FETCH_URL. Empty (the default, and the self-host default on
// purpose) keeps unfurling on this instance's own /api/unfurl.
- glueURL string
+ fetchURL string
// The cloud ledger's plan flag, mirrored into this instance's env by the
// provisioner (LCHAT_ENTITLEMENT, "free"/"paid"/"demo"). Gates the space
// menu's Upgrade entry (free) and the demo countdown band (demo); empty on
@@ -199,8 +199,8 @@ func main() {
case "links":
runLinksCmd(os.Args[2:])
return
- case "glue":
- runGlueCmd(os.Args[2:])
+ case "fetch":
+ runFetchCmd(os.Args[2:])
return
case "remove":
runRemove(os.Args[2:])
@@ -327,7 +327,7 @@ func main() {
}
s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(),
appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin,
- glueURL: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_GLUE_URL")), "/"),
+ fetchURL: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_FETCH_URL")), "/"),
entitlement: strings.TrimSpace(os.Getenv("LCHAT_ENTITLEMENT")),
maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes),
blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes),
@@ -756,12 +756,12 @@ func (s *Server) servePage(name string) http.HandlerFunc {
// image/frame sources are limited to what the app actually loads (preview
// images over https, YouTube's no-cookie embed); everything else is 'self'.
nonce := cspNonce()
- // The glue (docs/glue.md) is one more named connect-src host, present
+ // The fetch service (docs/fetch.md) is one more named connect-src host, present
// only when this instance opts in — img-src's blanket https: already
// admits its proxied card images.
- glueSrc := ""
- if s.glueURL != "" {
- glueSrc = " " + s.glueURL
+ fetchSrc := ""
+ if s.fetchURL != "" {
+ fetchSrc = " " + s.fetchURL
}
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
@@ -778,7 +778,7 @@ func (s *Server) servePage(name string) http.HandlerFunc {
// of the unfurl trade-off. The home origin admits exactly one call: the
// enrolment completion poll (backup.js startEnrolPoll), which sends only
// the client-random nonce. Keep connect-src pinned to named hosts.
- "connect-src 'self' https://*.giphy.com "+s.homeOrigin+glueSrc+"; "+
+ "connect-src 'self' https://*.giphy.com "+s.homeOrigin+fetchSrc+"; "+
// 'self' admits exactly one same-origin frame: /app-sandbox, the
// opaque-origin cage member apps run in (memberapps.go).
"frame-src 'self' https://www.youtube-nocookie.com; "+
@@ -803,7 +803,7 @@ func (s *Server) servePage(name string) http.HandlerFunc {
page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL))
// The home server the chat page enrols into for passkey backup.
page = strings.ReplaceAll(page, "__HOME_ORIGIN__", html.EscapeString(s.homeOrigin))
- page = strings.ReplaceAll(page, "__GLUE_URL__", html.EscapeString(s.glueURL))
+ page = strings.ReplaceAll(page, "__FETCH_URL__", html.EscapeString(s.fetchURL))
// The provisioner-pushed plan flag ("free"/"paid"; empty when self-hosted)
// — gates the space menu's Upgrade entry, enforced by nothing here: the
// checkout itself lives on the home server.
diff --git a/subcommands.go b/subcommands.go
index c4df995..214865a 100644
--- a/subcommands.go
+++ b/subcommands.go
@@ -40,7 +40,7 @@ var subcommands = []subcommand{
{"cloud", "run the public self-serve space provisioner"},
{"tunnel", "run the trial-tunnel gateway that fronts `lchat try`"},
{"links", "run the short-link redirector (`links run`)"},
- {"glue", "run the public-web fetch glue: unfurl + signed image proxy (`glue run`)"},
+ {"fetch", "run the public-web fetch service: unfurl + signed image proxy (`fetch run`)"},
{"hooks", "run the webhook-ingest sidecar (`hooks run`)"},
{"bot", "run the bot sidecar (`bot run`)"},
diff --git a/web/pages/index.html b/web/pages/index.html
index 3009e6b..983e565 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -31,9 +31,9 @@
// demo countdown band (demo) from it — display only, nothing here enforces
// billing or the demo TTL.
window.__entitlement = "__ENTITLEMENT__";
- // The public-web fetch glue this instance prefers for unfurls (docs/glue.md;
- // server-configured via LCHAT_GLUE_URL, empty = unfurl via this instance).
- window.__glueURL = "__GLUE_URL__";
+ // The public-web fetch service this instance prefers for unfurls (docs/fetch.md;
+ // server-configured via LCHAT_FETCH_URL, empty = unfurl via this instance).
+ window.__fetchURL = "__FETCH_URL__";
</script>
<!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in
page. This must be client-side — the session lives in localStorage, which
diff --git a/web/static/shell.js b/web/static/shell.js
index c333123..a969cee 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -4924,15 +4924,15 @@ setChatAppHost({
loadBlob: (threadId, att, onProgress) => loadBlob(att.id, threadId, att.type, onProgress),
upload: (threadId, file, onProgress) => chatAppUpload(threadId, file, onProgress),
unfurl: async (url) => {
- // Prefer the shared glue when this instance points at one (docs/glue.md):
+ // Prefer the shared fetch service when this instance points at one (docs/fetch.md):
// identity-free — no token rides along — and the card's image comes back
- // as the glue's signed proxy URL. Any transport failure falls through to
- // the instance's own unfurler, so a glue outage degrades to today's path
- // (an ok:false glue answer is a real answer — the page had nothing — not
+ // as the service's signed proxy URL. Any transport failure falls through to
+ // the instance's own unfurler, so a fetch-service outage degrades to today's path
+ // (an ok:false answer is a real answer — the page had nothing — not
// a reason to re-ask the instance).
- if (window.__glueURL) {
+ if (window.__fetchURL) {
try {
- const r = await fetch(`${window.__glueURL}/api/unfurl?url=${encodeURIComponent(url)}`);
+ const r = await fetch(`${window.__fetchURL}/api/unfurl?url=${encodeURIComponent(url)}`);
if (r.ok) return await r.json();
} catch {}
}