Skip to content

Let an owner-only app keep one door open for its own API (#415) - #416

Merged
onel merged 3 commits into
devfrom
415-path-scoped-exposure
Aug 14, 2026
Merged

Let an owner-only app keep one door open for its own API (#415)#416
onel merged 3 commits into
devfrom
415-path-scoped-exposure

Conversation

@onel

@onel onel commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

What & why

Hosted exposure is whole-app and binary, so an app that pairs a token-authed API with a session-authed UI had to be made fully public for its SDK to work — which dropped the box login in front of the UI too. Langfuse ships with exactly that limitation; Laminar hits it worse, because its self-hosted UI signs in any email with no password.

A manifest may now declare access.public_paths. A restricted app's route becomes one Caddy subroute: the declared paths proxy straight to the app, everything else stays gated. One route and one @id, so upsertRoute's insert-at-0 and the catch-all's evaluation order are untouched, and the proxy handler is built once and used on both branches so the #335 per-cookie strip cannot land on one and not the other.

Two decisions widened the issue's shape, both deliberate:

  • The identity-header scrub is now unconditional on every hosted app route, not only where the gate runs. The gate does not run on a public path, so nothing there would overwrite a caller-supplied X-Malmo-User — the app would get a brain-vouched header on one path and a forged one on another with no way to tell them apart. The same hole already existed for a fully public app: one that learned to trust the header while restricted kept trusting it the moment the owner flipped the toggle. One line closes both.
  • The dashboard label ships in this PR, on both responses that report an app's exposure — the detail GET and the toggle's echo, through one withPublicPaths helper — because the label is built from exposure and paths together. An app that declares public paths names them on its Access control instead of claiming a bare "Only you can open it". The paths are read from the instance's manifest copy — the same file the route was built from — so the label cannot drift from the route when the catalog moves on.

Spec(s) touched

  • docs/specs/APP_MANIFEST.md — new # E2 (the field, its two allowed shapes, and why catalog review rather than the validator is the trust boundary).
  • docs/specs/ENVIRONMENT.md # Per-app owner-only access — the subroute shape and the unconditional scrub.
  • docs/specs/DASHBOARD.md # Settings — the Access control must name the open paths.
  • docs/specs/UPDATES.md # Auto-apply unless permissions expand — a new or widened public_paths entry prompts the instance owner. Specified, not built: the permission diff itself is unimplemented, and this PR does not implement it.
  • docs/architecture.md — the lifecycle and caddy rows.

No DECISIONS.md entry: nothing locked flipped.

What was tested

  • make check equivalent green: gofmt, vet, OpenAPI freshness, and the full Go suite minus pamverifier / host-agent-real (this machine has a pre-existing cgo C.RTLD_NEXT failure in github.com/msteinert/pam/v2 that predates the branch — those two packages are CI-proven).
  • make check-web green: npm ci, gen:api (the generated schema is committed and fresh), vue-tsc + production build.
  • New tests: 3 in internal/caddy (subroute shape, scrub-is-first across all three route shapes, public paths ignored without a gate), 5 in internal/lifecycle (carve-out on install, scrub in every exposure, public app drops the paths, appliance unchanged, reconcile rebuilds the carve-out), ~20 validation cases in internal/manifest, 2 in internal/api (the DTO reads the instance's manifest copy).
  • Real Caddy: the hosted lane's access boot. Its test-catalog whoami now declares public_paths: ["/v1", "/v1/*"], and the boot asserts what no unit test can reach — declared paths answer anonymously; a forged X-Malmo-User never reaches the app on the public branch; the same forgery on the gated branch is replaced by the brain's vouched value rather than passed through or dropped; malmo_forward_auth is stripped on the public branch too while the app's own cookie survives; and a bypass table (/v1extra, /v1/../, //v1/, /v1/%2e%2e/, /V1x, /admin) all 302 to the box login. Requests are written raw onto the socket, so no client-side normalization softens them.

Known gaps & deviations

  • The bypass table is asserted from Caddy's documented behaviour and has not yet been observed. The lane is CI-only (root + KVM). A red boot there is a finding, not a flake: it would mean the matcher and the app can disagree about what a path is, and the fix is a deny rule in front of the carve-out, not a looser assertion.
  • Nothing in the catalog declares the field yet, so this ships inert. Langfuse's external-sdk-needs-public-app limitation and Laminar's screening note stay accurate until a malmoos/store manifest declares its ingestion paths.
  • The 16-entry cap is not a security boundary. An author with enough entries can still cover an app; the cap keeps the list reviewable, and catalog review is what stops the case it cannot. Stated in the spec rather than implied.
  • No audit trail on the public branch. Anonymous requests bypass the brain entirely, so Activity never sees them, and there is no rate limit in front of an app's public path.

Platform gaps

None (not a catalog PR).

Definition of done

  • Behavior works in the inner loop, and the parts that integrate with real Caddy are proven in the hosted access boot rather than by unit tests alone.
  • Tests added at the right layer; Go + web gates green (see What was tested for the two CI-proven packages).
  • No test deleted or newly skipped; no new environment-gated test.
  • Progress entry written (docs/progress/path-scoped-app-exposure.md); progress index row appended at the bottom.
  • No catalog-wire change (internal/catalog/published.go carries manifest.yml as verbatim bytes, so the new field reaches a box unchanged and the control plane needs no change).
  • Per-app wire field: the field travels inside the manifest bytes the box already parses; a box on an older build ignores an unknown key, so no snapshot rejection and no schema bump.
  • Specs updated; no locked decision flipped.
  • No section-sign symbol, no hard-wrapped markdown, log/slog only.
  • Branch off dev, PR into dev.

Closes #415

Hosted exposure was whole-app and binary, so an app that pairs a
token-authed API with a session-authed UI had to go fully public for
its SDK to work — which dropped the box login in front of the UI too.

A manifest may now declare `access.public_paths`. A restricted app's
route becomes one Caddy subroute: the declared paths proxy straight to
the app, everything else stays gated. One route and one @id, so the
insert-at-0 ordering against the catch-all is unchanged, and the proxy
handler is built once and used on both branches so the per-cookie strip
cannot land on one and not the other.

The identity headers are now scrubbed on every hosted app route, not
only where the gate runs. The gate does not run on a public path, so
nothing there would overwrite a caller-supplied X-Malmo-User; the same
hole already existed for a fully public app.

The dashboard names the open paths instead of claiming a bare "Only me",
reading them from the instance's own manifest copy so the label cannot
drift from the route. The hosted lane's access boot carries the proof
through real Caddy, including a bypass table that must stay gated.
@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR should not merge until manifest-read failures can no longer make the dashboard conceal anonymous paths that remain active in Caddy.

A previously configured carve-out remains live when reconciliation cannot reload the instance manifest, while the API suppresses that same error and the UI renders an unqualified owner-only label.

Files Needing Attention: internal/api/api.go, internal/lifecycle/lifecycle.go, web-ui/src/views/settings/InstalledAppDetailSection.vue

Important Files Changed

Filename Overview
internal/api/api.go Adds public_paths enrichment, but manifest failures still produce an empty field while an existing anonymous Caddy route can remain active.
internal/api/appexposure.go Enriches the exposure mutation response with instance-manifest public paths through the shared helper.
internal/caddy/caddy.go Adds path-scoped subrouting and unconditional request identity-header scrubbing while sharing the proxy handler between branches.
internal/lifecycle/lifecycle.go Passes manifest access declarations into centralized hosted route construction and reconciliation.
internal/manifest/manifest.go Introduces and validates the optional access.public_paths manifest field.
web-ui/src/views/settings/InstalledAppDetailSection.vue Displays declared anonymous paths alongside the restricted-access label.

Reviews (3): Last reviewed commit: "Fix the bypass table's wrong expectation..." | Re-trigger Greptile

…l GET

The access label is exposure plus declared public paths together, so the
toggle's echo carrying only the exposure would render "Only me" for an
app with open paths until the next refetch. Both responses now go
through one withPublicPaths helper.
Comment thread internal/api/api.go Outdated
…ailure

Two things the first cloud run found.

//v1/ never reaches the gate: Caddy collapses the duplicate slash and
301s to the normalized path before matching, so the app is not reached
and the expectation was wrong, not the probe. The table now asserts the
claim that matters — an undeclared path never reaches the app upstream
anonymously — and allows the merge-redirect shape for that entry.

Worse, that failure was reported green. run-cloud-tests.sh matched the
guest verdict with a *PASS* glob and the reason read 'PATH GATE BYPASS',
which contains PASS, so a red access boot printed 'boot access OK'. The
verdict is now matched exactly. Any failure text containing bypass or
password would have done this to any boot in this lane.
@onel
onel merged commit 3de7719 into dev Aug 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted: path-scoped app exposure — keep the UI owner-only while a token-authed API stays public

1 participant