Skip to content

The final dashboard should be deployable to a subpath #316

Description

@slesaad

NASA teams want to put our published dashboards on their own domains. Two targets are already named: the Disasters dashboard at https://science.data.nasa.gov/disasters/data-visualization (planning doc, Slack thread) and Air4US at https://earth.gov/air4us/visualization (placeholder). The arrangement: we hand them one stable web address per dashboard, their CloudFront forwards a path of their choosing to it, and they declare that path in a single header on the forwarding rule itself — nothing about the path is ever configured on our side.

Today this fails completely. When their CloudFront forwards a request it sends the full path — /disasters/data-visualization/index.html — and our dashboard stores its files at the top level with nothing to remove the prefix, so every request misses and the visitor gets a blank page.

The encouraging part, confirmed by serving a real published dashboard under a test prefix and watching every request: the dashboard itself doesn't care where it lives. Its pages find their files relative to their own address, so once the prefix is handled, almost everything works. What remains is the list below.

The current problems

  1. Nothing removes the path prefix, so every request misses our files.
  2. The address must end with a trailing slash or nothing loads.
  3. Admin-uploaded images are saved with addresses that start from the domain root.
  4. Two client files point at the domain root directly.
  5. Some layer and image addresses step upward out of the dashboard's folder.
  6. Republishing never updates a dashboard's infrastructure, so these fixes can't reach existing dashboards.
  7. Republishing can leave the old version visible for up to a day.

The Proposed Solutions

1. Nothing removes the path prefix

CloudFront forwards paths as-is — it never strips the part that matched. Every dashboard already runs a small program in front of every request (a CloudFront Function; today it only checks the password). Extend it: the path owner's forwarding rule attaches one header naming the prefix it matched (X-Forwarded-Prefix), and the function cuts exactly that prefix off the front of the path. No guessing — any prefix, at any depth, changeable by the path owner at will. A request that arrives without the header, like one made directly to the dashboard's own address, is served unchanged. And if the header is missing or wrong on their side, everything fails immediately and obviously — the failure mode you want, versus quietly serving the wrong files.

2. The address must end with a trailing slash

The dashboard's pages locate their files relative to their own address, and to a browser /dashboard and /dashboard/ are different starting points — without the slash, every lookup goes one folder too high and the visitor gets a permanently stuck loading screen. The same function fixes this on its own: when removing the prefix leaves nothing but a missing slash, it redirects to the slash form — so a slash-less link heals itself on the first hop.

3. Uploaded images are saved with domain-root addresses

When an admin uploads an image (Card images, logos), we save its address starting from the domain root. Under a subpath the browser sends that request to the host domain — NASA's server, not our files — and the image breaks. Fix: save the address relative to the dashboard instead, and rebase the values already saved in existing configurations when they're read.

4. Two client files point at the domain root directly

The PDF viewer's helper script and the geologic map patterns are both requested by a hardcoded domain-root address. A two-line change makes them relative.

5. Some addresses step upward out of the dashboard's folder

Older-style tile layers, and the image/3D-model viewer's full-resolution sources, build addresses with "up one folder" steps. At the domain root, stepping up leads nowhere and is harmless; under a prefix, it escapes the dashboard entirely. Published dashboards should not step upward at all.

6. Republishing never updates a dashboard's infrastructure

Each dashboard's AWS pieces — its file storage, its CloudFront, the request-checking function — are created once, at first publish, from a template in our code. Republishing only replaces the files. So a template change like fix #1 would reach only dashboards published after it lands; an existing dashboard could only catch up by being deleted and republished, which gives it a new address — the one thing that must never change, since path owners hardcode it. Fix: make republish also update the AWS pieces in place, grant the publish role the permissions that requires, and add a test that guards against template edits that would silently replace the CloudFront and change its address.

7. Republishing can leave the old version visible for a day

We upload files with no caching instructions, and whoever fronts the dashboard is free to cache its entry page for a long time (a day is a common default) — a cache we cannot flush. Fix: upload every file with caching instructions matched to how it changes — never cache the entry page and configuration, cache the fingerprinted files (files whose names change whenever their content does) forever, short lifetimes for the rest.

What the path owner's CloudFront must do

Part of this issue is a one-page doc, kept in this repo's documentation, that is the canonical "how customers serve a dashboard from their domain" reference: forward the authorization header, do not forward the browser's Host header, declare the forwarded prefix in the X-Forwarded-Prefix header on the forwarding rule, and honor our caching headers. That doc is the entire integration surface — nothing else about their setup concerns us. @amarouane-ABDELHAK is deploying the disasters portal that will front the first of these dashboards (currently at http://science-dev.data.nasa.gov/disasters), so the contract details should be worked out with him.

Done when

  • A published dashboard placed behind a path prefix loads completely — map, panels, uploaded images, PDFs — with no failed requests, with or without a trailing slash on the link.
  • The prefix can be anything, and can change, with no republish and no configuration on our side — the path lives only in the owner's forwarding rule and its header.
  • The dashboard's own direct address (no header present) continues to work unchanged.
  • Republishing an existing dashboard brings its AWS pieces up to date without changing its address.
  • After a republish, a fronting cache that honors caching headers shows the new version immediately.
  • The one-page customer doc exists in the repo's documentation.
Implementation plan — written as of e0cf706 on 2026-08-19. Rough guide; re-verify against latest code.

All problems below were confirmed empirically (a real SERVER=static bundle served under a local prefix, driven with Playwright) or verified directly in code, then adversarially re-reviewed.

1+2 — edge strip + redirect (scripts/lib/cfn-template.js renderAuthFunctionCode, mirrored in infrastructure/cloudfront-function.js, sync-asserted by tests/unit/infrastructure.spec.js:346). Read the x-forwarded-prefix request header (CloudFront lower-cases header names; the upstream's origin config injects it before forwarding, so it reaches our viewer-request function). Validate it — must start with /, no .., treat malformed as absent; normalize a trailing slash off the value. Then: header present and URI starts with the prefix → strip it; stripped result empty (the no-slash entry request) → 301 to original URI + /, re-serializing event.request.querystring into Location (the cloudfront-js-1.0 runtime excludes the query string from request.uri, and is ES5-only); stripped result / → rewrite to /index.html. No header → serve unchanged (direct access to the dashboard's own address). Keep the existing basic-auth check in the same function.

3 — upload path contract: API/Backend/Upload/uploadRouter.js:169 stores savedRelPath = "/" + key; store the key slash-less. The Card resolver (src/essence/Tools/Card/adapters/buildCardData.ts:20) passes leading-/ values through and prefixes everything else with missionPath — it needs an explicit branch treating assets/... as dashboard-root-relative, plus a back-compat rebase for /assets/... values already in configs. This is the only fix that touches stored data.

4 — hardcoded paths: src/essence/Basics/Viewer_/PDFViewer.js:11 (/public/workers/pdf.worker.min.mjs) and src/essence/Basics/Layers_/LayerGeologic/LayerGeologic.js:55 ('/public' + baseUrl). A repo sweep found no others.

5 — climbs: Layers_.getUrl prepends ../../ for throughTileServer/COG tiles (non-Docker branch, Layers_.js:620); Viewer_.js:311,334 prepend ../../../../ for master images and model textures. Guard with isStaticBuild() — already imported in Viewer_.js, needs the import in Layers_.js. Note: the tile climb only fires for classic type: "tile" configs because getUrl never canonicalizes the TileLayer alias (src/essence/Basics/MapEngines/types/engine.ts:106 does) — an inconsistency worth fixing while in there.

6 — UpdateStack: scripts/lib/aws-provision.js imports Create/Describe/Delete only; add UpdateStackCommand and call it from the republish path in scripts/publish-static.js. Traps: wait for UPDATE_COMPLETE (the default CREATE_COMPLETE wait throws); "No updates are to be performed" arrives as a ValidationError — match on message, not name (the name also means "stack doesn't exist" in describeStack); a ROLLBACK_COMPLETE stack can only be deleted — surface that. IAM: cloudformation:UpdateStack + cloudfront:UpdateFunction (no CFN service role, so the task role's own credentials mutate resources) must be added in all three of infrastructure/iam/publish-task-role.json, the module iam.tf, and the bootstrap permissions boundary boundary.tf — the boundary caps everything, so a role-only grant silently fails. Add a unit test pinning the template's logical IDs (DashboardDistribution, DashboardBucket); renaming one, or adding e.g. an explicit BucketName, replaces the resource and mints a new domain.

7 — Cache-Control: no upload in aws-provision.js sets any. Tiering: no-cache on index.html + Missions/<mission>/config.json; long-lived immutable on build/static/{js,css,media} (the content-hashed output) only — public/** and build/static/cesium/** are unhashed but change on upgrades, so short TTL/revalidate. The CopyObject-based asset copy keeps old metadata by default — it needs MetadataDirective: 'REPLACE' (matters for the stable-key mosaic CSV under Missions/<mission>/Data/).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions