PWA install conflict: hub scope: '/' blocks standalone installs on same origin #41

Closed
opened 2026-05-05 13:46:15 +00:00 by padreug · 1 comment
Owner

Symptom

Installing app.${domain} as a PWA, then trying to install any standalone (e.g. libra.${domain}) makes Chrome say the app is already installed — no separate home-screen entry, no separate install prompt.

Reproduced on Chrome with app.ariege.io (hub) and libra.ariege.io (standalone).

Diagnosis

The standalone subdomains 301-redirect to the path-mount on the hub origin (intentional, mkRedirectVhost in modules/services/standalones.nix:140-143):

$ curl -sI https://libra.ariege.io/manifest.webmanifest
HTTP/2 301
location: https://app.ariege.io/libra//manifest.webmanifest

So Chrome resolves the libra manifest at app.ariege.io/libra/manifest.webmanifest. The standalone's manifest declares scope: '/libra/', but the hub's manifest at app.ariege.io/manifest.webmanifest declares scope: '/', which claims the entire origin — including /libra/. Once the hub PWA is installed, Chrome treats every page under app.ariege.io/ as "inside an installed PWA" and suppresses the install affordance for nested manifests.

The standalones' id fields are unique (aio-community-hub, libra-accounting, market-app, etc.), so this is not an id collision — it's purely the hub's overly-broad scope.

The 301 redirect is intentional (best-of-both-worlds: subdomain and path-mount both reach the standalone) and should stay. The fix is on the hub side.

(Aside: the redirect target also has a double slash — https://app.ariege.io/libra//manifest.webmanifest — worth fixing in passing.)

Fix options

Option 1 — drop the hub PWA install (simpler)

Remove the manifest <link> and the VitePWA({ manifest }) block from vite.config.ts so the hub is just a regular launcher page. Standalones at /libra/, /market/, etc. become independently installable.

Trade-off: users lose the ability to install the launcher itself on the home screen. But the hub is just an 8-tile chooser that hands off to standalones — most users would install the standalones they actually use, not the chooser.

Option 2 — move the hub to /hub/ (preserves hub install)

  • Build hub with VITE_BASE_PATH=/hub/.
  • vite.config.ts manifest: start_url: '/hub/', scope: '/hub/'.
  • nginx: 301 //hub/ on app.${domain} (root only — exact match, not prefix; standalones at /libra/, /market/, etc. continue to serve their dist).
  • services.castle-standalones.parentDomain redirect targets unchanged (/libra/ etc. still mount where they did).

Hub stays installable on its own scope; standalones are siblings, not children — each installable independently. No PWA UX collision.

Trade-off: users see a 301 hop on first visit; bookmarks pointing at bare app.${domain} still work via the redirect. Slightly more nginx config.

Recommendation

Option 1 unless we have a specific reason to keep the hub installable. Option 2 if we want both the hub and the standalones home-screen-installable.

Acceptance

  • After fix, installing the hub PWA does NOT suppress install on any standalone subdomain.
  • Installing libra.${domain} as a PWA produces a separate home-screen entry from the hub PWA.
  • Chrome (Android + desktop) reproduces above. iOS Safari spot-check (separate dedupe rules — may need follow-up).
  • mkRedirectVhost no longer emits the // double-slash in Location:.

References

  • vite.config.ts (hub manifest, scope: '/', id: 'aio-community-hub')
  • vite.libra.config.ts:85-87 (libra manifest, scope: process.env.VITE_BASE_PATH || '/', id: 'libra-accounting')
  • modules/services/standalones.nix:140-143 (intentional subdomain → path-mount redirect, target of fix is on the hub side, not here)
  • W3C manifest spec — scope is a single path prefix, no exclusion mechanism, so a hub at / cannot avoid claiming child paths.
## Symptom Installing `app.${domain}` as a PWA, then trying to install any standalone (e.g. `libra.${domain}`) makes Chrome say the app is already installed — no separate home-screen entry, no separate install prompt. Reproduced on Chrome with `app.ariege.io` (hub) and `libra.ariege.io` (standalone). ## Diagnosis The standalone subdomains 301-redirect to the path-mount on the hub origin (intentional, `mkRedirectVhost` in `modules/services/standalones.nix:140-143`): ``` $ curl -sI https://libra.ariege.io/manifest.webmanifest HTTP/2 301 location: https://app.ariege.io/libra//manifest.webmanifest ``` So Chrome resolves the libra manifest at `app.ariege.io/libra/manifest.webmanifest`. The standalone's manifest declares `scope: '/libra/'`, but the **hub's manifest** at `app.ariege.io/manifest.webmanifest` declares `scope: '/'`, which claims the entire origin — including `/libra/`. Once the hub PWA is installed, Chrome treats every page under `app.ariege.io/` as "inside an installed PWA" and suppresses the install affordance for nested manifests. The standalones' `id` fields are unique (`aio-community-hub`, `libra-accounting`, `market-app`, etc.), so this is not an `id` collision — it's purely the hub's overly-broad scope. The 301 redirect is intentional (best-of-both-worlds: subdomain *and* path-mount both reach the standalone) and should stay. The fix is on the hub side. (Aside: the redirect target also has a double slash — `https://app.ariege.io/libra//manifest.webmanifest` — worth fixing in passing.) ## Fix options ### Option 1 — drop the hub PWA install (simpler) Remove the manifest `<link>` and the `VitePWA({ manifest })` block from `vite.config.ts` so the hub is just a regular launcher page. Standalones at `/libra/`, `/market/`, etc. become independently installable. **Trade-off:** users lose the ability to install the launcher itself on the home screen. But the hub is just an 8-tile chooser that hands off to standalones — most users would install the standalones they actually use, not the chooser. ### Option 2 — move the hub to `/hub/` (preserves hub install) - Build hub with `VITE_BASE_PATH=/hub/`. - `vite.config.ts` manifest: `start_url: '/hub/'`, `scope: '/hub/'`. - nginx: 301 `/` → `/hub/` on `app.${domain}` (root only — exact match, not prefix; standalones at `/libra/`, `/market/`, etc. continue to serve their dist). - `services.castle-standalones.parentDomain` redirect targets unchanged (`/libra/` etc. still mount where they did). Hub stays installable on its own scope; standalones are siblings, not children — each installable independently. No PWA UX collision. **Trade-off:** users see a 301 hop on first visit; bookmarks pointing at bare `app.${domain}` still work via the redirect. Slightly more nginx config. ## Recommendation Option 1 unless we have a specific reason to keep the hub installable. Option 2 if we want both the hub and the standalones home-screen-installable. ## Acceptance - [ ] After fix, installing the hub PWA does NOT suppress install on any standalone subdomain. - [ ] Installing `libra.${domain}` as a PWA produces a separate home-screen entry from the hub PWA. - [ ] Chrome (Android + desktop) reproduces above. iOS Safari spot-check (separate dedupe rules — may need follow-up). - [ ] `mkRedirectVhost` no longer emits the `//` double-slash in `Location:`. ## References - `vite.config.ts` (hub manifest, `scope: '/'`, `id: 'aio-community-hub'`) - `vite.libra.config.ts:85-87` (libra manifest, `scope: process.env.VITE_BASE_PATH || '/'`, `id: 'libra-accounting'`) - `modules/services/standalones.nix:140-143` (intentional subdomain → path-mount redirect, target of fix is on the hub side, not here) - W3C manifest spec — scope is a single path prefix, no exclusion mechanism, so a hub at `/` cannot avoid claiming child paths.
Author
Owner

Live verification on production via Playwright

Confirmed the diagnosis end-to-end against https://libra.ariege.io/ from a headed Chromium session.

Redirect chain captured:

GET  https://libra.ariege.io/                       → 301
GET  https://app.ariege.io/libra/                   → 200
GET  https://app.ariege.io/libra/manifest.webmanifest → 200

(Plus a follow-on redirect to /libra/login — auth gate, irrelevant to install behaviour.)

Live manifest served at https://app.ariege.io/libra/manifest.webmanifest:

{
  "id": "libra-accounting",
  "start_url": "/libra/",
  "scope": "/libra/",
  "name": "Libra — Team Accounting"
}

Live manifest served at https://app.ariege.io/manifest.webmanifest:

{
  "id": "aio-community-hub",
  "start_url": "/",
  "scope": "/",
  "name": "AIO - Community Hub"
}

Service worker registration on the libra side:

scope:  https://app.ariege.io/libra/
active: https://app.ariege.io/libra/sw.js

So once a user installs the hub PWA at app.ariege.io/, every navigation under app.ariege.io/libra/ falls inside both the hub's scope: '/' AND libra's scope: '/libra/'. Chrome's install affordance treats the page as "inside an installed PWA" and suppresses install for the more-specific libra manifest. The PWA id mismatch isn't enough to break the tie because scope ownership is evaluated first.

This rules out any other hypotheses (icon hash collision, manifest URL mismatch, SW scope misregistration). The fix has to be on the hub side, exactly as proposed in the original report. Recommendation stands: option 1 (drop hub PWA install) unless we have a specific reason to keep the launcher home-screen-installable.

### Live verification on production via Playwright Confirmed the diagnosis end-to-end against `https://libra.ariege.io/` from a headed Chromium session. **Redirect chain captured:** ``` GET https://libra.ariege.io/ → 301 GET https://app.ariege.io/libra/ → 200 GET https://app.ariege.io/libra/manifest.webmanifest → 200 ``` (Plus a follow-on redirect to `/libra/login` — auth gate, irrelevant to install behaviour.) **Live manifest served at `https://app.ariege.io/libra/manifest.webmanifest`:** ```json { "id": "libra-accounting", "start_url": "/libra/", "scope": "/libra/", "name": "Libra — Team Accounting" } ``` **Live manifest served at `https://app.ariege.io/manifest.webmanifest`:** ```json { "id": "aio-community-hub", "start_url": "/", "scope": "/", "name": "AIO - Community Hub" } ``` **Service worker registration on the libra side:** ``` scope: https://app.ariege.io/libra/ active: https://app.ariege.io/libra/sw.js ``` So once a user installs the hub PWA at `app.ariege.io/`, every navigation under `app.ariege.io/libra/` falls inside both the hub's `scope: '/'` AND libra's `scope: '/libra/'`. Chrome's install affordance treats the page as "inside an installed PWA" and suppresses install for the more-specific libra manifest. The PWA `id` mismatch isn't enough to break the tie because scope ownership is evaluated first. This rules out any other hypotheses (icon hash collision, manifest URL mismatch, SW scope misregistration). The fix has to be on the hub side, exactly as proposed in the original report. Recommendation stands: **option 1 (drop hub PWA install)** unless we have a specific reason to keep the launcher home-screen-installable.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/webapp#41
No description provided.