• Mahmoud Aglan's avatar
    feat(portal): PWA, push, the check-in gate, the native shell, and the docs that were wrong · 1819b943
    Mahmoud Aglan authored
    S6, S7, the staff half of S8, S9 and S10, plus the admin screens S3 and S5
    were waiting on.
    
    PWA (S6)
    --------
    The worker lives at /app/sw.js and is generated per deploy, because the
    precache list comes from public/build/manifest.json and the cache name is a
    hash of it — a deploy evicts the old cache instead of leaving a worker
    serving asset URLs that no longer exist.
    
    Its scope is /app/, not the root: a root worker would control /dashboard and
    /api too, serving admins a stale shell and leaving cached credentialed
    responses on a shared front-desk tablet.
    
    HTML is never precached. wire:navigate swaps <head> wholesale and prefetches
    on hover, so an HTML cache fills with unvisited pages and then injects @vite
    hashes from a build that no longer exists — a blank page with no error.
    /livewire/* is never cached at all: its snapshot checksum is bound to APP_KEY
    and the session, so a replayed one is a corrupt-snapshot error rather than a
    stale render. The only offline artifact is a static page with no session and
    no CSRF token in it.
    
    nginx gets exact-match locations for /app/sw.js and /app/manifest.webmanifest.
    Both end in an extension the static-asset regex claims, and that regex ends in
    try_files $uri =404 — so without these the worker 404s before reaching PHP.
    
    Push (S7)
    ---------
    FCM, not VAPID. kreait/firebase-php is installed, device_tokens exists, twelve
    listeners already funnel through PushNotificationService, every client has
    their own Firebase project, and FCM HTTP v1 delivers to Web Push endpoints
    with the same CloudMessage and the same token column. VAPID buys independence
    from Google — not a constraint here — for a second sender, table, log path and
    prune policy.
    
    So the change is: platform CHECK widened to include 'web', a user_agent column
    for sensible pruning, and a unique index on (device_token, user_id) — never on
    the token alone, which is what the deleted DeviceController keyed on, letting
    anyone claim anyone's token so the victim's phone received the attacker's
    notifications. Duplicates are cleared before the index, because a failed
    migration blocks every later one on that client forever.
    
    The check-in gate (S8)
    ----------------------
    Staff-scan only. The printed-poster direction stays cut: a printed QR is a
    public, permanent, non-secret string, so rotation is impossible by
    construction — it proves the member once visited, or knows someone who did.
    
    The scanner screen works with a connected barcode reader by default and uses
    BarcodeDetector where the browser has it, because most reception desks have
    the reader and not the camera permission.
    
    A real bug the tests caught: participants.status is cast to an enum, so
    comparing it to the string 'active' was always false — the gate would have
    turned everyone away.
    
    The native shell (S9)
    ---------------------
    flutter_shell/ holds one long-lived Sanctum token in the Keychain or
    EncryptedSharedPreferences with the single ability portal:session, and
    exchanges it at /app/session-exchange for an ordinary web session in the
    WebView's own jar. The token never reaches JavaScript. /app/* is never
    exempted from CSRF — that shortcut is what turns a wrapper from safe into
    trivially exploitable.
    
    Every bridge is an exported native capability, so each is narrow and checked
    natively: the host allowlist is compared against the origin read from the
    controller, never from the page; biometrics gate a native action and return
    nothing the page can use as an authorisation decision; QR is decoded natively
    and only the string crosses.
    
    flutter_inappwebview rather than webview_flutter, because <input type="file">
    is inert in a bare Android WebView without onShowFileChooser — and that single
    gap breaks the transfer-proof upload, which is the portal's whole money path.
    
    Two endpoints only, and they are the only routes on the sanctum guard. The
    deleted API minted tokens with mobile:* — every endpoint it would ever grow.
    
    E3's recommendation stands and the shell is not shipped this cycle. It exists
    so that shipping is a decision rather than a project.
    
    App content (S10)
    -----------------
    One `channel` column on website_news and website_sections instead of the
    parallel CMS the plan called for. website_sections, website_news,
    website_menus, media, a page builder and website:blueprint export|import all
    already exist; a second CMS is a second migration surface, a second editor to
    keep in step, and a second place for content to go missing, forever.
    
    Admin screens
    -------------
    Portal invitations, where the raw link exists for exactly one render and is
    never recoverable afterwards. The duplicate-account merge screen E4 asked for
    — the prerequisite for ever putting a unique index on users.phone, and the
    reason ambiguous phone logins can be refused rather than guessed. Both move
    references rather than deleting rows: a deleted user id in a financial record
    is worse than a duplicate account.
    
    Documentation that was actively wrong
    -------------------------------------
    docs/agent-rules/05-financial-integrity.md described double-entry as two rows
    with a type of 'debit' or 'credit', and 16-enums-and-checks.md registered that
    vocabulary. That schema has never existed — 2024_01_01_000013 created the
    single-row shape with both account columns from the start. Anyone writing code
    from that text got a mass-assignment no-op and a row that silently said
    nothing. CLAUDE.md repeated the same claim, and also said Livewire 3 while
    composer.json says ^4.3 — a difference that decides whether a public property
    is an IDOR.
    
    The test suite is now symmetrical: tests that build their own tables skip off
    SQLite, tests that need a real tenant skip off Postgres, so the whole file
    runs clean under either connection instead of one of them being a lie.
    
    Suite: 76 pass on SQLite (24 skipped), and against the restored tenant
    PortalSmokeTest 3/3, PaymentProofTest 11/11, CheckInScanTest 10/10.
    portal.css is 4.98 kB gzipped against app.css at 31.10 kB.
    Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
    1819b943