diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a4ecaa..9534331 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ All notable changes to Collie are recorded here. The format follows
`version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by
`scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy.
+## [0.18.0] - 2026-07-28
+
+### Added
+- **Approvals are bound server-side to the prompt they were decided against.** `/keys` and `/reply` accept an optional `expected_prompt`; the bridge re-reads the pane immediately before writing and refuses with `409 prompt_changed` if the dialog moved. Shrinks the guard window from human latency to two local RPCs — a mitigation, not a guarantee, until herdr gains a conditional-input primitive (#29) — thanks @Optic00 (6afaf5b)
+- **`/auth/` is reserved for a fronting proxy's sign-in page**, and the service worker always passes it to the network. An installed PWA could not reach a proxy page at all before — the precache answered every navigation, reload included — so operators had to squat a page inside `/api/`. The refusal banner now links there (#31) — thanks @Optic00 (1a5972b)
+
## [0.17.0] - 2026-07-27
### Fixed
diff --git a/README.md b/README.md
index 4669f35..f7ebfd9 100644
--- a/README.md
+++ b/README.md
@@ -547,6 +547,48 @@ indefinitely — clients keep running old code with no way to notice. If your pr
honor origin headers (Caddy and stock Nginx `proxy_cache` do by default; CDNs often need it
enabled explicitly).
+**Serve your sign-in page under `/auth/`.** Collie reserves that path for you and routes nothing
+there. It matters because of how an installed PWA behaves: the service worker answers every
+navigation it owns from the precached app shell without touching the network, and there is no
+address bar to work around it. So a proxy page served anywhere Collie owns — including `/` — is
+invisible to the installed app, and a reload just re-renders the refused UI. `/auth/` (and anything
+beneath it) is the one path the service worker always passes through, so it is the only address that
+reaches you. When the bridge answers there itself, nothing claimed the path — that placeholder is
+your signal that the proxy rule is missing.
+
+```caddyfile
+collie.example.com {
+ handle /auth/* {
+ # your sign-in / device-enrolment flow, exempt from the auth check that guards the rest
+ reverse_proxy 127.0.0.1:9091
+ }
+ handle {
+ forward_auth 127.0.0.1:9091 { ... }
+ reverse_proxy 127.0.0.1:8787 { ... }
+ }
+}
+```
+
+Collie's refusal banner links to `/auth/` when the bridge or your proxy answers 401/403, so a
+signed-out phone has a tappable way back in. A `?rd=`/`?next=` return-to parameter on the redirect is
+fine — the passthrough matches the query string too. If your flow lives at a path you can't move,
+redirect `/auth/` to it; the redirect is followed on the network side, where the service worker isn't
+looking. Cloudflare Access is the exception that can't be redirected, since it owns `/cdn-cgi/access/`
+outright — that prefix is reserved as well, so its flow works untouched.
+
+> ⚠️ **Let the static bundle through even when the session has lapsed** — everything except `/api/`
+> and page navigations. It is public client code with no secrets in it, and it is the only way an
+> installed app can receive an update. If your proxy refuses `/sw.js` to a signed-out client, that
+> client's service worker can never be replaced: `registration.update()` fails outright, so a device
+> that lapsed while running an old build stays on that build forever. Measured, not theorised — with
+> the bundle refused, `update()` throws; with it allowed, the worker updates cleanly while `/api/` is
+> still answering 401.
+>
+> This bites hardest on the very fix described above: a device that was already locked out **before**
+> upgrading to 0.18.0 cannot pick up the new service worker, and its `/auth/` link won't exist. Those
+> devices need their site data cleared once (browser settings → the site → clear data), then a fresh
+> load. New installs and any device that updated while signed in are unaffected.
+
### Variant D — off-host identity proxy over the tailnet
Choose this when you already run a **central ingress node** for your tailnet — one forward-auth/SSO
diff --git a/bridge/auth-path-contract.test.ts b/bridge/auth-path-contract.test.ts
new file mode 100644
index 0000000..b104835
--- /dev/null
+++ b/bridge/auth-path-contract.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test";
+
+import { isReservedAuthPath } from "./server.ts";
+import { isNetworkOnlyNavigation } from "../web/src/lib/sw-routes.ts";
+
+// The `/auth/` reservation is implemented twice — once in the service worker's navigation denylist
+// (web/src/lib/sw-routes.ts) and once in the bridge's request dispatch (isReservedAuthPath). Each
+// side has its own tests, so either could be changed alone and stay green while the two drift, and
+// drift is silent in the way that matters: the SW hands the navigation to the network, the bridge
+// doesn't recognise it, the SPA fallback answers with the app shell, and the operator is back to
+// staring at the UI they were trying to escape. That is issue #31 all over again.
+//
+// So run one shared list through BOTH and require them to agree. This is the only test that fails
+// when someone edits one side and not the other. It imports across the bridge/web boundary on
+// purpose: sw-routes.ts is pure (no DOM, no workbox), which is why it was split out at all.
+//
+// The inputs carry query strings because workbox matches the denylist against `pathname + search`;
+// the bridge matches a parsed `pathname`, so it takes the same cases with the query stripped.
+const CASES: { url: string; reserved: boolean }[] = [
+ { url: "/auth", reserved: true },
+ { url: "/auth/", reserved: true },
+ { url: "/auth/sign-in", reserved: true },
+ { url: "/auth/oidc/callback", reserved: true },
+ { url: "/auth?rd=%2F", reserved: true },
+ { url: "/auth/?next=/settings", reserved: true },
+ { url: "/", reserved: false },
+ { url: "/settings", reserved: false },
+ { url: "/pane/w1:p1", reserved: false },
+ { url: "/pane/w1:p1?s=demo", reserved: false },
+ { url: "/space/w1", reserved: false },
+ { url: "/authors", reserved: false },
+ { url: "/authors?x=1", reserved: false },
+];
+
+describe("the /auth reservation agrees across the service worker and the bridge", () => {
+ for (const { url, reserved } of CASES) {
+ test(`${url} → ${reserved ? "reserved" : "Collie's"}`, () => {
+ const pathname = url.split("?")[0]!;
+ expect(isReservedAuthPath(pathname)).toBe(reserved);
+ // The API is network-only too, but it is not part of the reservation, so only /auth cases
+ // are expected to agree with the bridge — every case here is a navigation, not an API call.
+ expect(isNetworkOnlyNavigation(url)).toBe(reserved);
+ });
+ }
+});
diff --git a/bridge/server.test.ts b/bridge/server.test.ts
index fbf1e06..824a7ce 100644
--- a/bridge/server.test.ts
+++ b/bridge/server.test.ts
@@ -8,6 +8,7 @@ import {
guard,
historyParams,
isHostAllowed,
+ isReservedAuthPath,
keysPane,
normalizeTabLabel,
paneReadResponse,
@@ -845,3 +846,21 @@ describe("cacheControlFor", () => {
}
});
});
+
+// The other end of web/src/lib/sw-routes.ts. The service worker hands `/auth/` to the network; if
+// the bridge doesn't recognise the same set, the SPA fallback answers with the app shell and the
+// operator gets the very UI they were trying to escape. These two must agree exactly.
+describe("isReservedAuthPath — the namespace a fronting proxy owns", () => {
+ test("claims /auth with or without a trailing slash, and everything beneath it", () => {
+ expect(isReservedAuthPath("/auth")).toBe(true);
+ expect(isReservedAuthPath("/auth/")).toBe(true);
+ expect(isReservedAuthPath("/auth/sign-in")).toBe(true);
+ expect(isReservedAuthPath("/auth/oidc/callback")).toBe(true);
+ });
+
+ test("leaves Collie's own routes alone, including a mere prefix match", () => {
+ for (const path of ["/", "/settings", "/pane/w1:p1", "/authors", "/api/snapshot"]) {
+ expect(isReservedAuthPath(path)).toBe(false);
+ }
+ });
+});
diff --git a/bridge/server.ts b/bridge/server.ts
index 50ec01e..11b8a7d 100644
--- a/bridge/server.ts
+++ b/bridge/server.ts
@@ -310,6 +310,14 @@ export function startServer(opts: {
return json(updateMonitor.status(), req.headers.get("accept-encoding"));
}
+ // ── Reserved for a fronting proxy's sign-in page ─────────────────────
+ // `/auth/` is the one path the service worker always passes to the network (web/src/lib/
+ // sw-routes.ts), so it is the only address an installed PWA can reach when a proxy in front of
+ // the bridge refuses a stale session. Collie never routes it. If a request gets this far, no
+ // proxy claimed it — say so, instead of letting the SPA fallback answer with the app shell and
+ // leave the operator staring at the UI they were trying to escape.
+ if (isReservedAuthPath(pathname)) return reservedAuthPlaceholder();
+
// ── Static PWA (with SPA fallback) ───────────────────────────────────
return serveStatic(pathname);
},
@@ -1260,6 +1268,55 @@ export function resolveStaticPath(
return { rel, full };
}
+/**
+ * The namespace reserved for the operator's front door. Matches `/auth` with or without a trailing
+ * slash and anything beneath it — a proxy may serve one page or a whole flow. Kept in lockstep with
+ * the service worker's navigation denylist (`web/src/lib/sw-routes.ts`); if these two disagree, an
+ * installed PWA either can't reach the proxy or can't reach Collie. Pure + exported for tests.
+ */
+export function isReservedAuthPath(pathname: string): boolean {
+ return pathname === "/auth" || pathname.startsWith("/auth/");
+}
+
+/**
+ * What `/auth/` says when nothing is in front of the bridge. Deliberately a 404: the path is
+ * reserved, not implemented — Collie has no sign-in of its own and must not imply otherwise. Plain
+ * HTML with no inline style or script (the strict CSP forbids both) and a link home, because in an
+ * installed PWA this page may be the only thing on screen and there is no address bar to leave it.
+ * Unauthenticated by design: it sits outside every gate, since the reason to be here is that a gate
+ * refused you.
+ */
+function reservedAuthPlaceholder(): Response {
+ const body = `
+
+
+
+
+Nothing configured here — Collie
+
+
+
Nothing is configured at this address
+
Collie reserves /auth/ for a reverse proxy sitting in front of it, so that an
+installed app has somewhere to reach a sign-in or device-enrolment page. Collie itself serves
+nothing here and has no sign-in of its own.
+
If you are the operator: point this path at your proxy's sign-in flow. See Serving Collie
+behind your own reverse proxy in the README.