# Dynamic PWA Manifest with Organization Logo

**Date**: 2026-04-02
**Based on**: [PWA Dynamic Logo Research](pwa-dynamic-logo-research.md)
**Approach**: Option A — Dynamic Worker Manifest with cookie-based org identification

---

## Overview

Enable organizations to upload a custom logo that replaces the default XTablo icons in the installed PWA. The Cloudflare Worker serves a dynamic `manifest.webmanifest` that includes org-specific icon URLs when a cookie identifies the current org. The server resizes the uploaded logo into all required PWA icon sizes and stores them in R2.

---

## 1. Database — `logo_url` on Organizations

**Migration**: Add a nullable `logo_url` column to the `organizations` table.

```sql
ALTER TABLE public.organizations ADD COLUMN logo_url text;
```

When null, the worker serves the default XTablo icons. When set, it contains the R2 base path for the org's icons (e.g., `org-icons/42/`).

---

## 2. R2 Storage — Icon Variants

**Bucket prefix**: `org-icons/{org_id}/`

**Generated sizes** (from a single uploaded image, minimum 512x512):

| File | Size | Purpose |
|------|------|---------|
| `icon-16.png` | 16x16 | Favicon |
| `icon-32.png` | 32x32 | Favicon |
| `icon-180.png` | 180x180 | Apple touch icon |
| `icon-192.png` | 192x192 | PWA icon |
| `icon-512.png` | 512x512 | PWA icon |
| `icon-512-maskable.png` | 512x512 | PWA maskable icon (with safe zone padding) |

**Resizing**: Done server-side with `sharp` on the API server (Cloud Run).

---

## 3. Logo Upload API

**Endpoint**: `PATCH /api/v1/users/organization` (extend existing)

The existing endpoint accepts `{ name }`. Extend to:

```typescript
{
  name?: string;
  logo?: { content: string; contentType: string } | null;
}
```

- `logo` with content: upload and resize the image, store in R2, set `logo_url`
- `logo: null`: delete icons from R2, clear `logo_url`
- `logo` omitted: no change to the logo

**Server-side flow**:
1. Validate: must be PNG, JPG, or WebP; minimum 512x512 pixels
2. Resize with `sharp` into the 6 variants listed above
3. Upload all variants to R2 at `org-icons/{org_id}/icon-{size}.png`
4. Update `organizations.logo_url` with the base path (`org-icons/{org_id}/`)
5. Return updated organization data

**Icon serving endpoint**: `GET /api/v1/org-icons/:orgId/icon-:size.png`

- Public (no auth required) — icons must be fetchable by the browser during PWA install
- Fetches from R2 and returns the image
- Cache headers: `Cache-Control: public, max-age=86400`

---

## 4. Cookie-Based Org Identification

**Cookie name**: `x-org-id`
**Set by**: Frontend, as a side effect in the `useOrganization` hook
**Value**: The organization's numeric `id`
**Attributes**: `path=/; secure; samesite=lax; max-age=31536000`
**Cleared on**: Logout

The cookie is read by the Cloudflare Worker when serving the manifest. It allows the worker to identify the org without requiring authentication (the manifest fetch is unauthenticated).

---

## 5. Dynamic Manifest in the Cloudflare Worker

**Intercept path**: `/manifest.webmanifest`

**Worker logic**:
1. Parse `x-org-id` cookie from the request
2. If cookie present: build manifest with org-specific icon URLs pointing to `/api/v1/org-icons/{org_id}/icon-{size}.png`
3. If cookie absent: build manifest with default XTablo icons from `/pwa-icons/`
4. Return JSON with `Content-Type: application/manifest+json`

The worker does not check whether the org actually has a logo. It always builds org-specific icon URLs when the cookie is present. The icon-serving API endpoint handles the fallback: if the requested org icon doesn't exist in R2, it returns the default XTablo icon for that size. This keeps the worker stateless (no DB or R2 access needed).

**Manifest content** (same structure as the current static manifest, only icons change):

```json
{
  "name": "XTablo",
  "short_name": "XTablo",
  "description": "Collaborative project management for construction teams",
  "start_url": "/",
  "display": "standalone",
  "orientation": "any",
  "theme_color": "#1e1b2e",
  "background_color": "#1e1b2e",
  "icons": [
    { "src": "/api/v1/org-icons/42/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/api/v1/org-icons/42/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/api/v1/org-icons/42/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
```

**Caching**: `Cache-Control: no-cache` on the manifest response. It's tiny JSON and must reflect the current org on every page load.

**vite-plugin-pwa change**: Set `manifest: false` in the VitePWA config to stop generating a static manifest. Keep the `<link rel="manifest" href="/manifest.webmanifest">` tag manually in `index.html`. The plugin continues to handle service worker generation and precaching.

---

## 6. Settings Page — Logo Upload UI

**Location**: Extend the organization section in `apps/main/src/pages/settings.tsx`

**UI elements**:
- Current org logo display (or placeholder when none set)
- Click-to-upload / drag-and-drop zone for a single image
- Client-side validation: image file type, minimum 512x512 resolution
- Sends base64 content to `PATCH /api/v1/users/organization` with the `logo` field
- On success: invalidates the org query cache (existing React Query pattern)
- "Remove logo" button: sends `logo: null` to clear

---

## 7. What's Explicitly Out of Scope

- **Runtime favicon swap** (`useOrgFavicon()` hook) — the static favicon is fine for now; the dynamic manifest handles the installed PWA icon
- **Subdomain-based org routing** — cookie-based identification is sufficient
- **Per-org PWA name** — always "XTablo"
- **Per-org theme_color** — always `#1e1b2e`

---

## 8. Known Limitations

| Limitation | Impact |
|------------|--------|
| iOS never updates installed PWA icons | Users must reinstall to see a new org logo |
| Android updates icons with multi-day delay | Not suitable for real-time branding changes |
| Cookie must be set before manifest fetch | First visit (unauthenticated) always gets default icons; org icons appear after login + next page load |
| Manifest `no-cache` means a worker request on every page load | Negligible cost — tiny JSON, fast worker response |
