- Status: done
- Date: 2026-07-08
- Specs touched:
docs/specs/ENVIRONMENT.md(# Public-by-default, auth-gated — Per-app owner-only access rewritten to "as built"; # Deferred bullet narrowed to multi-user),docs/specs/DECISIONS.md(2026-07-08 entry)
Second box-side slice of the hosted per-app access-restriction epic (#304), closing #306 and building directly on the brain forward-auth verify endpoint + domain-wide cookie of #305 (hosted-forward-auth-verify.md). #305 gave the brain a GET /_malmo/forward-auth/verify endpoint and a lower-privilege malmo_forward_auth cookie; this slice wires the box Caddy to use them: a per-app exposure state, the central route builder that gates a restricted app and strips the cookie, the toggle endpoint, and the flip of the hosted default to owner-only.
- The
Cookiestrip is the whole header, on every hosted app route (restricted and public), not just restricted. The forward-auth cookie isDomain=<box-id>.malmo.network, so the browser sends it to every<slug>.<box-id>subdomain; stripping only restricted routes would leak it to a public app that could replay it against the owner's restricted apps. Tradeoff: a hosted app never receives any browser cookie, so an app relying on its own cookie session/CSRF won't see it — the box identity (X-Malmo-Userheaders) is the session for owner-only apps. Per-cookie surgical stripping is the follow-up if a real app needs its own cookies. - The hosted default is flipped to
restrictednow, not gated on the #308 e2e. The mechanism (#305 + #306) is complete and unit-tested; #308 becomes the end-to-end proof of an already-shipped default. Keeps "secure by default": a new hosted app is owner-only until the owner opts it public.
exposure TEXT NOT NULL DEFAULT 'public' CHECK (exposure IN ('public','restricted'))oninstances, added to theCREATE TABLEand the idempotentALTERmigration list. Existing rows (appliance + any pre-#306 hosted) migrate topublic, so an upgrade never surprises a live box by gating an app — only new hosted installs defaultrestricted.ExposurePublic/ExposureRestrictedconsts;Instance.Exposurefield (scanned + ininstanceColumns).Createnormalizes an empty exposure topublicand rejects an out-of-range value (defense-in-depth, since the ALTER path can't carry the CHECK — the same pattern asscope).SetInstanceExposure(id, exposure)is the desired-state setter.
AddRoutenow takes aRouteConfig{InstanceID, Host, Upstream, StripCookie, ForwardAuth}.StripCookieaddsheaders.request.delete: [Cookie]to the app reverse_proxy; a non-nilForwardAuthprepends theforward_authhandler in front of it.forwardAuthHandleris the native-JSON form of the Caddyfileforward_authdirective (malmo drives Caddy over its admin API — no Caddyfile): a reverse_proxy to the brain verify endpoint, rewritten toGET <VerifyPath>, with ahandle_responsepolicy — a 2xx copies the identity headers onto the request and falls through to the app; a catch-all block turns any other status (the brain's 401) into a 302 to the box login. The strip lives on the app proxy, not this subrequest, which must forward the cookie to the brain to be verified.caddystays profile-agnostic (concrete value types only; the policy is decided by the caller).
buildRouteConfig(inst, host, upstream)is the single place the strip + gate are decided — "one central route builder is the safety boundary" (the spec's phrase). Appliance returns a bare reverse_proxy; hosted always setsStripCookieand, for arestrictedinstance, theForwardAuth(verify upstream,profile.ForwardAuthVerifyPath,X-Malmo-User/-Idcopy-headers,https://<box-id>.malmo.network/login). All three upstream-flip sites (install,Start, reconcile'sreassertRouting) call it, so a restart never drops the gate.defaultExposure()=restrictedon hosted,publicelsewhere, set on the install row.SetExposure(ctx, id, exposure)persists the column and re-applies the route for a running app (a stopped app has only a splash and picks up the change on next start).SetBrainUpstreamwires the verify dial fromcmd/brain(the sameMALMO_DASHBOARD_BRAIN_UPSTREAMthe dashboard route uses); defaultmalmo-brain:8080.profile.ForwardAuthVerifyPathis the one canonical verify-path string, shared byinternal/api(which serves it) andinternal/lifecycle(which points Caddy at it) since the two packages can't import each other.
PUT /api/v1/apps/{id}/exposure{exposure: "restricted"|"public"}, owner-or-admin (authorizeAppMutation), hosted-only (404 on appliance — exposure is a public-subdomain concept the appliance lacks), elevation-class so it audits success and failure (app.exposure.set). Echoes the updated app DTO.InstanceDTO.exposureis surfaced on every app response so #307's toggle can render the current mode.
- Whole-header strip, not per-cookie. As above — deliberate for v1 (chosen this session). An app that needs its own cookies won't get them in either mode; surgical stripping of just
malmo_forward_authis the follow-up. - Login redirect drops the return URL. A 401 redirects to the box dashboard root (
https://<box-id>.malmo.network/), not back to the app after login. A?next=round-trip needs the SPA to honor it (candidate for #307 or later). - Runtime shape unverified in this slice. The
forward_authJSON is asserted structurally against the recording admin fake (matching the known-good Caddyfile expansion), but real Caddy behavior — the subrequest, the 401→login redirect, the strip actually keeping the cookie off the app — is #308's e2e + cookie-leak probe. - Owner-only, single-user. Inherited from #305: only the box owner's session validates. Multi-user is the additive step (# Deferred).
- Default flip precedes its e2e proof. By choice (
DECISIONS.md): #308 proves an already-shipped default. - The authz-guard 403 isn't audited.
setAppExposureaudits theSetExposuresuccess/failure paths (elevation-class), but the owner-or-admin 403 from the sharedauthorizeAppMutationis not audited — a pre-existing gap shared withstopApp/startApp(onlyuninstallAppaudits its inline household guard). Left as-is here to stay surgical; centralizing the audit insideauthorizeAppMutation(benefiting all three callers) is the right follow-up rather than a local fix. Tracked in #328.
- #307 — the dashboard Only-me / Public toggle, calling
PUT /apps/{id}/exposureand renderingInstanceDTO.exposure(the backend is in place here). - #308 — the hosted e2e lane proving both modes end-to-end through real Caddy + the cookie-leak probe of the strip invariant.