Everything needed to host the TabMates web (WasmJS) build in production: cross-origin isolation headers, Content-Security-Policy, CORS / websocket origin configuration, and the GitHub Pages deploy workflow. Local development is unaffected by most of this — the webpack dev server already sends the right headers and proxies the backend same-origin.
The web app stores its Room database in the browser's Origin Private File System, which
requires a cross-origin-isolated context. Whatever serves the production web build must send
these headers on every response (the dev server already does, see
composeApp/webpack.config.d/headers.js):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Without them crossOriginIsolated is false, the SQLite worker cannot initialize OPFS, and the
app runs without local persistence. HTTPS is also required (secure context). COEP is
credentialless rather than require-corp because the Cloudflare Turnstile widget (an iframe
from challenges.cloudflare.com, used for the auth bot-check) fails under require-corp;
credentialless still yields crossOriginIsolated, so OPFS/SQLite is unaffected. Under
credentialless a cross-origin no-cors subresource (the gstatic Firebase scripts) loads without
credentials and needs no CORP header; a new cross-origin <script>/<img>/font that must send
credentials still needs CORS or CORP, or the browser will block it.
On hosts that cannot set response headers (GitHub Pages), the vendored
composeApp/src/wasmJsMain/resources/coi-serviceworker.js (first script in index.html)
provides the same isolation: it registers a root-scope service worker that injects the headers
into every response, at the cost of one automatic reload on first visit. It prefers COEP
credentialless and degrades to require-corp automatically; both yield crossOriginIsolated.
It no-ops when the server already sends the headers, so dev is unaffected. Because it owns the
root service-worker scope, the Firebase messaging worker is registered under a dedicated
sub-scope in firebase-init.js — never register another service worker without an explicit
non-root scope, or COOP/COEP injection silently dies on the next reload.
The web build is an installable, offline-capable PWA (Add to Home Screen / Install on
Android, iOS and desktop). Two pieces provide this, both shipped as static resources under
composeApp/src/wasmJsMain/resources/ (auto-bundled into the dist, no workflow change):
- Web app manifest —
manifest.webmanifest(linked fromindex.html, plustheme-color,apple-touch-iconand theapple-mobile-web-app-*tags iOS needs since it ignores the manifest). Icons live inicons/(192/512any, a 512maskableon the brand background, a 180 apple-touch-icon, a 32 favicon).theme_coloris the brand primary#b05530,background_color#fffbff. The manifest and its same-origin PNG icons satisfy the deploy CSP (default-src/img-src 'self') as-is..webmanifestis servedapplication/manifest+jsonby Pages; if a host serves it wrong, rename tomanifest.json. - Offline app-shell cache — folded into
coi-serviceworker.js, not a second worker, because the root scope must stay with the COOP/COEP injector (see above). Itsinstallhandler eagerly precaches every entry inPRECACHE_MANIFEST— a list of every real file in the production dist, injected into the worker's own source by the deploy workflow right after:composeApp:wasmJsBrowserDistribution(see "Deploying to GitHub Pages" below) — so the whole app shell is cached up front, not just whatever screens a user happens to visit. The manifest is embedded inline in the service worker's own script, not fetched from a side file: a browser only re-runsinstallwhen the service worker's own script bytes change, so a separate manifest file's contents wouldn't reliably trigger a re-cache. Precaching is best-effort (one failed asset can't abortinstall) and self-pruning (entries no longer in the manifest, e.g. a prior deploy's content-hashed.wasmfilename, are deleted on every install), soSHELL_CACHEstays current automatically on routine deploys. Thefetchhandler still backs this up at runtime (stale-while-revalidate for assets, network-first with a cached-/fallback for navigations), and passes every served response — network or cache — throughwithCoiHeaders(), so cross-origin isolation (and therefore OPFS/SQLite) still holds on an offline launch. Cross-origin requests (gstatic Firebase) and non-GETs keep the original network-only path and are never cached. BumpSHELL_CACHE(tabmates-shell-vN) only to force a full manual reset (e.g. a caching-strategy change) — it's no longer the routine invalidation path.
Because the offline data layer (Room in OPFS, durable outbox, delta + reconnect sync) is already
shared commonMain code, and the app shell is now precached in full on install, the installed PWA
works offline for every screen immediately after the first successful visit — not just ones
already opened — and syncs on reconnect, the same behaviour as Android. Firebase push is the one
thing that needs the network: firebase-init.js (same-origin, cached) guards the missing
cross-origin SDK and installs no-op glue so an offline launch still boots; push resumes on the next
online run.
Verify after a build (:composeApp:wasmJsBrowserDistribution, served over HTTPS/localhost):
DevTools → Application → Manifest is installable with no errors, the service worker is
activated and controlling, crossOriginIsolated === true, Cache Storage → tabmates-shell-v1
holds the full asset list, and with Network → Offline you can navigate directly to a screen not
yet visited this session and have it render fully — not just reload /. Note a locally-served
raw Gradle dist ships an empty PRECACHE_MANIFEST (only the deploy workflow injects it), so
testing the real eager-precache path requires either running that workflow step's find/jq/sed
snippet locally against the dist first, or testing a deployed build.
The web session/tokens live in localStorage (KSafe), which any script on the origin can read,
so a CSP is the main defense against token exfiltration via XSS. Serve it as an HTTP header
when the host allows it; on GitHub Pages the deploy workflow injects it as a <meta> tag into
the built index.html instead (.github/workflows/deploy-web.yml), with connect-src derived
from the build's API/WS origins. A <meta> CSP ignores frame-ancestors by spec, so
clickjacking protection is unavailable on header-less hosts — everything else below applies.
The tag is not in the source index.html on purpose: the dev server talks plain ws://
same-origin, which 'self' does not reliably cover. Baseline:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval' https://www.gstatic.com https://challenges.cloudflare.com; # wasm-unsafe-eval is REQUIRED for Compose/WasmJS; cloudflare = Turnstile api.js
frame-src https://challenges.cloudflare.com; # the Turnstile challenge iframe
worker-src 'self' blob:; # the SQLite web worker
connect-src 'self' https://<api-host> wss://<api-host>; # this environment's API http + ws origins
img-src 'self' data:;
style-src 'self' 'unsafe-inline';
frame-ancestors 'none';
There is no frame-src fallback to 'self' here — without an explicit frame-src it falls back to
default-src 'self', which would block the Turnstile iframe even after the COEP fix.
Omitting wasm-unsafe-eval prevents the WasmJS bundle from loading at all; omitting the API
origin from connect-src breaks every API/websocket call. Verify no CSP violations appear in
the console after deploying.
The hosted web app calls the backend cross-origin, so its origin must be listed in both
server settings: TABMATES_CORS_ALLOWED_ORIGINS (HTTP) and — because the websocket handshake
carries the page origin — the websocket allowed-origin list (production reuses
TABMATES_CORS_ALLOWED_ORIGINS for this). Missing either one fails silently: HTTP is blocked by
CORS, or the wss upgrade is rejected 403.
Local dev never exercises this — it goes same-origin through the webpack proxy — so smoke-test
from a real cross-origin build before launch: serve the web app from an origin different from
the API and confirm the preflight OPTIONS returns the Access-Control-Allow-Origin header, an
authenticated request succeeds, and the wss /ws/group upgrade connects. The production web
build must set BASE_URL_HTTP_WEB to the real API origin (there is no proxy in production); the
wss origin is derived from it.
Browsers cannot set headers on a WebSocket handshake, so WebSocketTransport sends the JWT as an
access_token query parameter, which the server accepts on /ws/group only. On web that is the
only query param (no api_key; the server recognizes the browser by its allow-listed origin);
native clients additionally send the api_key query param and the header pair. The token can
therefore appear in reverse-proxy access logs — redact query strings for /ws/group there.
.github/workflows/deploy-web.yml builds :composeApp:wasmJsBrowserDistribution on every tag
push (and manually via Run workflow), injects the offline precache manifest, the CSP meta tag
and CNAME, and publishes to
GitHub Pages at app.tabmates.de. One-time setup:
- Secrets:
BASE_URL_HTTP_WEB→ the target backend (e.g.https://<api-host>);API_KEYandBASE_URL_HTTPare reused from the Android release setup. - Pages: repo Settings → Pages → Source: GitHub Actions; Custom domain:
app.tabmates.de; enable Enforce HTTPS once the certificate is issued. - DNS:
appCNAME→<github-org>.github.io.(lowercase). - Backend: add
https://app.tabmates.detoTABMATES_CORS_ALLOWED_ORIGINSon the target environment and restart it.
The web bundle no longer ships x-api-key (the wasmJs BuildKonfig nulls it, so neither the REST
header nor the /ws/group api_key param is sent). That key was never secret in a browser
(readable in DevTools, replayable), so the server now recognizes browser traffic by its allow-listed
Origin instead, and gates the abuse-prone unauthenticated auth endpoints with a Cloudflare
Turnstile bot-check (cf-turnstile-response, from an invisible widget — see the Turnstile CSP/COEP
notes above). Real authorization is still the per-user JWT. Native clients keep sending the
real api-key and are Turnstile-exempt. The web API_KEY CI secret is left in place for now
(harmless, unused by web) until native no longer shares the pipeline expectation.
One URL — https://app.tabmates.de/<path> — is meant to open the installed app (Android
App Links) when present, and otherwise load the web client at the same URL, with no
"Open in app?" interstitial. Both platforms resolve the URL through the same shared code
(composeApp/src/commonMain/.../deeplink/), so there is no per-platform link logic.
Key point: the user-facing link host is decoupled from the backend API host. Links and
deep-link matching use BASE_URL_PUBLIC (e.g. https://app.tabmates.de), not the API
BASE_URL_HTTP. BASE_URL_PUBLIC is required to build (like BASE_URL_HTTP); for local
dev set it to your BASE_URL_HTTP value. Currently deep-linkable: /verify/<token>,
/reset-password/<token>, /join/<token> (invites), and /open (stateless — claims the path
so the app opens, resolves to no route). All three token routes carry the token as a path
segment, not a query param, and their manifest pathPrefix keeps the trailing slash so a
tokenless /verify is not claimed. /groups/<id> resolves in-app from notifications but is
intentionally not an external App Link.
Web fallback (app not installed). GitHub Pages is static with no SPA rewrite, so a direct
hit to a sub-path (/join/<token>) would 404. composeApp/src/wasmJsMain/resources/404.html
stashes the original URL in sessionStorage and redirects to the root (a real file, so it is
reload-safe with coi-serviceworker); webMain/.../main.kt then consumes the stashed URL and
feeds the shared DeepLinkHandler. The address bar stays on / — consistent with the app,
which never syncs the URL to in-app navigation.
Android App Links verification. The manifest sets autoVerify="true" for
https://app.tabmates.de/… (the host is hardcoded in AndroidManifest.xml and must match the
prod BASE_URL_PUBLIC host; the lint check AppLinkUrlError rejects a placeholder host).
Verification requires
https://app.tabmates.de/.well-known/assetlinks.json to be reachable — it ships as a static
resource (composeApp/src/wasmJsMain/resources/.well-known/assetlinks.json) and is served with
application/json by Pages (.json extension). Fill in the SHA-256 placeholder with the
Play App Signing certificate fingerprint from Play Console → App integrity → App signing
key certificate (it is public, not a secret). After deploying, confirm:
curl -sI https://app.tabmates.de/.well-known/assetlinks.json # 200, content-type: application/json
adb shell pm verify-app-links --re-verify de.tabmates.androidapp
adb shell pm get-app-links de.tabmates.androidapp # expect: verified
Debug builds are signed with the debug key, which is not in assetlinks.json, so they will
not auto-verify — test the installed-app path with a release build (or the tabmates:// custom
scheme). For manual testing:
adb shell am start -a android.intent.action.VIEW -d "https://app.tabmates.de/join/TESTTOKEN".
Backend (separate repo) — required for auth email links. The emailed
links must be emitted on the public host (app.tabmates.de) as /verify/<token> and
/reset-password/<token> (and group-notification deep links likewise), so they match the app's
registered links and open the app/web rather than hitting the API host directly. These are
link paths only — the endpoints the app then POSTs to stay /api/auth/verify and
/api/auth/reset-password on the API host, with the token in the body. The app matches nothing
else: an email still carrying the old /api/auth/… link — or the query form /verify?token=…,
which is no longer an App Link since the manifest prefix requires the trailing slash — drops its
token and lands on the app root. Same for an invite still on /j/<token>.
Tokens must be URL-safe: the path is percent-decoded before the segment is split, so a token
containing an encoded / (%2F) would be truncated at that point.