feat(portal): PWA, push, the check-in gate, the native shell, and the docs that were wrong
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:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
flutter_shell/README.md
0 → 100644
flutter_shell/lib/main.dart
0 → 100644
flutter_shell/pubspec.yaml
0 → 100644
Please register or sign in to comment