Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions bridge/auth-path-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
19 changes: 19 additions & 0 deletions bridge/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
guard,
historyParams,
isHostAllowed,
isReservedAuthPath,
keysPane,
normalizeTabLabel,
paneReadResponse,
Expand Down Expand Up @@ -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);
}
});
});
57 changes: 57 additions & 0 deletions bridge/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down Expand Up @@ -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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Nothing configured here — Collie</title>
</head>
<body>
<h1>Nothing is configured at this address</h1>
<p>Collie reserves <code>/auth/</code> 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.</p>
<p>If you are the operator: point this path at your proxy's sign-in flow. See <em>Serving Collie
behind your own reverse proxy</em> in the README.</p>
<p><a href="/">Back to Collie</a></p>
</body>
</html>
`;
return secure(
new Response(body, {
status: 404,
headers: {
"content-type": "text/html; charset=utf-8",
"content-security-policy": CSP,
"cache-control": "no-store",
},
}),
);
}

async function serveStatic(pathname: string): Promise<Response> {
const resolved = resolveStaticPath(pathname);
if (!resolved) return text("forbidden", 403);
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "herdr.collie"
name = "Collie"
version = "0.17.0"
version = "0.18.0"
min_herdr_version = "0.7.0"
description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale"
platforms = ["linux", "macos"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"license": "MIT",
"description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale",
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie-web",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/connection-banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ describe("ConnectionBanner — the single connection surface", () => {
expect(cfg.calls).toBe(0);
});

// The escape hatch for an installed PWA, which has no address bar: a real link to the one path the
// service worker always passes to the network. It must stay an <a> with a real href — a button
// with an onClick would be a same-document action the SW never sees as a navigation, which is the
// whole bug (#31). If this assertion is ever "fixed" by swapping in a Button, the PWA is bricked
// again behind a refused session and nothing else will fail.
it("offers a real link to the reserved proxy path, not a click handler", () => {
renderBanner({ authError: true });

const signIn = screen.getByRole("link", { name: "Sign in" });
expect(signIn).toHaveAttribute("href", "/auth/");
});

it("renders nothing while healthy — no bar at all", () => {
renderBanner({ bridge: "connected" });
expect(screen.queryByRole("status")).toBeNull();
Expand Down
42 changes: 35 additions & 7 deletions web/src/components/connection-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRevalidator } from "react-router";
import { CheckCircle2, Loader2, Plug, RefreshCw, RotateCw, TriangleAlert, WifiOff } from "lucide-react";
import {
CheckCircle2,
Loader2,
LogIn,
Plug,
RefreshCw,
RotateCw,
TriangleAlert,
WifiOff,
} from "lucide-react";

import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { PROXY_AUTH_PATH } from "@/lib/sw-routes";
import { useConnectionLost, useConnectionTrouble } from "@/hooks/use-connection-lost";
import { useLoadingStalled } from "@/hooks/use-loading-stalled";
import { useOnline } from "@/hooks/use-online";
Expand Down Expand Up @@ -52,8 +62,15 @@ export function ConnectionBanner({ bridge, error, authError }: ConnectionBannerP
// the cause. The flag covers 401 and 403 alike, and a 403 can equally mean "this device is not
// allowlisted", "host not allowed" or "cross-origin rejected", so naming any one of them would be
// wrong more often than right. What the operator needs here is the one fact the old behaviour hid:
// this is not the network. Reload is the useful action either way, because it is what lets a
// fronting proxy serve its sign-in page.
// this is not the network.
//
// Reload alone is NOT enough to reach a fronting proxy, which is what this banner used to claim. In
// an installed PWA the service worker answers every navigation it owns — a reload included — from
// the precached app shell, so a reload re-renders the same refused UI and never touches the proxy.
// "Sign in" is the escape: a real navigation to the one path the SW always passes to the network
// (lib/sw-routes). An <a>, not a button, so it is an ordinary navigation the SW sees as such — and
// so it still works if React is wedged. Reload stays alongside it, since a merely stale session on
// an already-signed-in device recovers without leaving the app.
function AuthErrorBanner() {
return (
<div className="grid shrink-0 grid-rows-[1fr] overflow-hidden opacity-100">
Expand All @@ -70,13 +87,24 @@ function AuthErrorBanner() {
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
Access refused. This is not a connection problem.
</span>
<a
href={PROXY_AUTH_PATH}
className={cn(
buttonVariants({ size: "sm" }),
"h-6 gap-1 px-2 text-xs no-underline",
)}
>
<LogIn className="size-3.5" />
Sign in
</a>
<Button
size="sm"
className="h-6 gap-1 px-2 text-xs"
size="icon"
variant="ghost"
aria-label="Reload"
className="size-6 text-muted-foreground"
onClick={() => window.location.reload()}
>
<RefreshCw className="size-3.5" />
Reload
</Button>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ function Button({
);
}

export { Button };
// `buttonVariants` is exported so a real <a> can wear the button's clothes where a navigation, not a
// click handler, is the point — see the access-refused banner's "Sign in".
export { Button, buttonVariants };
Loading
Loading