- TypeScript 93.3%
- JavaScript 5.5%
- Dockerfile 0.6%
- HTML 0.4%
- CSS 0.2%
|
All checks were successful
build-image / image (push) Successful in 26s
|
||
|---|---|---|
| .claude | ||
| .forgejo/workflows | ||
| docker | ||
| public/icons | ||
| scripts | ||
| src | ||
| worker | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| docker-compose.yaml | ||
| Dockerfile | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsconfig.tsbuildinfo | ||
| tsconfig.worker.json | ||
| vite.config.ts | ||
| wrangler.toml | ||
JEH Field
A third-party field client for the Job Evidence Hub API. It does one job well: capture jobsite evidence and get it onto the server without losing any of it.
It exists because the first-party iOS Home Screen PWA loses uploads, and neither the backend nor the existing frontend can be changed.
Scope: sign in, browse workspaces, open a workspace and see everything already uploaded to it, capture photos / videos / document shots, pick existing media from the phone, upload. Nothing else. Checklists, comments, documents, annotation, reports and maps stay in the Flutter and web apps.
Why the architecture looks like this
Everything is same-origin
The API's CORS policy is an exact-origin allowlist that fails closed:
// backend/src/JobEvidenceHub.Api/Program.cs
policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials();
if (frontendCorsOrigins.Length > 0) { policy.WithOrigins(frontendCorsOrigins); return; }
policy.SetIsOriginAllowed(_ => false);
A browser client on a new hostname cannot call that API — there is no header,
mode, or client-side trick that changes it. So this app does not try to be
cross-origin. worker/index.ts serves the built app and proxies /api/* to
https://api.jobevidencehub.com from one hostname. The browser makes
same-origin requests; the worker-to-API hop is server-to-server, sends no
Origin header, and CORS never engages.
In development, vite.config.ts proxies /api the same way, so no code
anywhere knows whether it is proxied.
This means the proxy sees every session token and every photo. It is a third-party relay for company evidence. Get that signed off before pointing field users at it, and run it on an account that outlives whoever set it up.
Bearer tokens, never cookies
POST /api/auth/mobile/session returns an opaque jeh_mobile_ token that the
API accepts as Authorization: Bearer. An iOS Home Screen PWA has its own
cookie jar and ITP evicts cookies without warning, which is one of the ways the
existing client's session dies mid-upload. The worker strips Set-Cookie on
the way back so no cookie session can be established by accident.
Photos are compressed before they are stored or sent
src/capture/image.ts decodes to an ImageBitmap, draws to an
OffscreenCanvas at a 2048px long edge and re-encodes as JPEG quality 0.8.
- A 12MP original decodes to ~50MB of RGBA. Holding several is how WebKit decides to kill a Home Screen PWA — and a killed PWA is an upload that silently never happened. Pixels stay in bitmap/blob handles outside the JS heap and are closed immediately.
- iOS hands back HEIC from the camera roll, which the API has no reason to accept. The canvas round-trip normalises the format and bakes in EXIF rotation.
- Measured on a synthetic 4000x3000 capture: 358 KB in, 36 KB out. A real 12MP photo goes from roughly 4 MB to a few hundred KB, comfortably under the endpoint's 25 MB limit even on a bad uplink.
The upload queue is deliberately small
See src/upload/queue.ts. The first-party PWA lost evidence to a pool of
workers that retired each other mid-flight, leaked leases, and minted a fresh
idempotency ID on retry — so a completed server-side upload could neither be
recognised nor safely re-sent (docs/31_IOS_PWA_UPLOAD_RECOVERY.md,
docs/33_IOS_PWA_WORKER_LOOP_RECOVERY.md in the main repo).
The rules here are the inverse:
- One stable
clientUploadIdper capture, generated at capture, never regenerated. It is sent asidempotencyKeyand queried asclientUploadId. - Exactly one upload in flight, owned by one drain loop that cannot re-enter. No worker pool, no leases, no cross-worker cancellation.
- Ask, never guess. Any item with a previous attempt calls
GET /api/workspaces/{id}/media-upload-status?kind=&clientUploadId=before resending. Duplicates are impossible; lost originals are impossible. - Confirm before deleting. A 2xx is not enough to drop the local original —
the reconcile call must report
storagePersisted. - Checkpoint on background.
visibilitychange/pagehideabort the in-flight request, keep the bytes and the ID, and do not count it as a failed attempt. - Permanent failure is an allowlist: 400 and 413 only. Anything unrecognised keeps retrying, because mislabelling a transient failure costs a technician a photo.
navigator.wakeLockis held while draining. It is the only lever a PWA has over iOS suspending it — there is no background upload API to fall back on.
Media needs an authenticated fetch, not an <img src>
Every media endpoint requires Authorization: Bearer, and <img> / <video>
never send headers. Passing the token in the query string would work and would
also write a live credential into proxy logs, referrers and history, so
src/media/authedMedia.ts fetches each asset with the header and hands the
element an object URL.
Object URLs are then a resource this app owns. Tiles load lazily through an
IntersectionObserver, and an LRU caps how many decoded assets exist at once —
a workspace with two hundred photos would otherwise fire two hundred requests on
mount and hold two hundred bitmaps. Videos are not auto-loaded at all: the
viewer shows the size and waits for a tap, because that can be tens of
megabytes of the technician's data plan.
Storage stays small by construction
- Photos are compressed ~10x before they are ever written.
- Bytes are deleted the moment the server confirms.
- A 400 MB queue budget and a check against
navigator.storage.estimate()refuse new captures rather than fill the phone. navigator.storage.persist()is requested at sign-in so queued originals are not silently evicted.- Camera captures go through a file input, which on iOS does not write to the camera roll.
Deviations from the Flutter app
Same visual language — same teal (#008080), Manrope, 12px radii, black
full-screen capture with PHOTO / VIDEO / SCAN / UPLOAD and the raised teal
capture button. What is not the same, and why:
| Flutter | Here | Why |
|---|---|---|
| Live camera preview for all modes | Live preview for PHOTO only, system camera for VIDEO/SCAN, with automatic fallback if getUserMedia is refused |
Safari's MediaRecorder has produced containers the API rejects, and a recording that dies with the tab is worse than one extra tap |
| VisionKit / ML Kit document scanner | Photo plus a grayscale + histogram-anchored contrast pass | No browser API does edge detection or perspective correction without shipping an OpenCV build. The filter does the part that decides whether a page is readable |
| GPS indicator on the capture screen | Not shown | The photo upload endpoint has no coordinate fields, so the indicator would be decorative |
| Offline-first local mirror of all entities | Workspaces are fetched live; only the upload queue is local | Out of scope — this client does not own any entity but the capture |
| Annotation, tags, before/after, rephoto ghost | Not implemented | Out of scope |
HEIC is the app's problem, not the technician's
The API accepts image/jpeg, image/png and image/webp. An iPhone camera
roll hands over image/heic. Nobody is asked to change a camera setting and no
one has to send a photo to anyone for conversion: every capture is decoded and
re-encoded to JPEG before it is queued, so the format the phone chose stops
mattering at the first step.
Verified — a file arriving exactly as the camera roll presents one:
in: IMG_4471.HEIC image/heic 98,453 bytes
queued: capture-….jpg image/jpeg 41,990 bytes
Content type, filename and blob type are all normalised. Two guards keep it
that way: processImage refuses to return anything that is not image/jpeg,
and UploadQueue.enqueue refuses any blob outside the server's allowlist — so a
future change that forgets the conversion fails at capture, in the technician's
hand, rather than as a 400 hours later.
Videos need no conversion: video/quicktime is on the server's video
allowlist, so an iPhone .mov uploads as-is.
Testing against the production API
JEH_EMAIL=you@example.com JEH_PASSWORD=... npm run smoke
Node makes these calls, so CORS does not apply and this runs anywhere. It signs in, lists workspaces, uploads a photo, and checks the things this client's design depends on:
- the status endpoint reports an unknown capture as
found: false; - after upload it reports
foundandstoragePersisted, with the same media id; - re-sending the same
idempotencyKeyreturns the same photo id, and the workspace gains exactly one photo across two uploads; - thumbnail and preview return bytes, and are refused without a bearer token;
- an
image/heicupload is rejected with a 400 — the reason the capture pipeline re-encodes.
It deletes its test photo unless you pass --keep.
npm run smoke -- --workspace <guid> # a specific workspace
npm run smoke -- --base http://localhost:5173 # through the local proxy
npm run smoke -- --file ./photo.jpg # a real photo, not the built-in 1x1
Testing without polluting production
The backend classifies a workspace as QA data by name
(QaTestDataClassifier): a workspace number starting CLOUD-QA- or
CLOUD-HARD-QA-, or a workspace number or customer name containing
SMOKE TEST. Those are excluded from normal list and search results, and an
admin can preview and purge them via
GET/POST /api/admin/maintenance/qa-test-data[/cleanup].
Create one in the Flutter or web app, then open it here directly at
/workspaces/<guid> or /capture/<guid> — this client resolves a workspace by
id, so a QA-marked workspace works even though it never appears in the list.
Testing on an actual iPhone
The local dev server is not enough: iOS gives getUserMedia, service workers
and Home Screen install only to a secure origin, and a LAN IP is not one.
Deploy instead —
npm run deploy
— and open the workers.dev URL on the phone, then Add to Home Screen. That is
also the only configuration in which the upload behaviour under test is the real
one.
Running it
npm install
npm run dev
Then open http://localhost:5173. /api proxies to production, so a real
account signs in against real data.
npm run build
npx wrangler dev --port 8788 --local
serves the built app through the actual worker, which is worth doing before any deploy — it is the only way to exercise the proxy's streaming multipart path.
npm run deploy
Running it as a container
There are two ways to run this, and they do the same job: a Cloudflare Worker
(worker/index.ts) or a container. Both exist only to serve the app and
/api/* from one origin.
.forgejo/workflows/build-image.yml builds the image and pushes it to
git.shiraki.ca/yami/jeh-field on a push to main (tagged main and by commit
sha) and on a v* tag, which is the only thing that moves latest.
docker run -p 8080:80 git.shiraki.ca/yami/jeh-field:latest
The container is the worker expressed as nginx: same origin, cookies stripped in
both directions, Origin and the forwarded-header chain removed, and upload
bodies streamed rather than buffered to disk.
| Variable | Default | Notes |
|---|---|---|
API_ORIGIN |
https://api.jobevidencehub.com |
Upstream API |
API_HOST |
api.jobevidencehub.com |
Host header and TLS SNI |
API_RESOLVER |
1.1.1.1 |
Set to 127.0.0.11 on a compose network |
Serve it over HTTPS. Camera access, the service worker, Home Screen install and the wake lock during uploads are all gated on a secure origin. A plain-HTTP deployment loses every one of them, quietly.
Registry auth falls back to the automatic Actions token. If the repository does
not have package write permission, add REGISTRY_USER and REGISTRY_TOKEN
secrets.
Before this goes to a technician
Everything below has been verified on a desktop browser against the live API: sign-in, session restore, 401 auto-sign-out, workspace list, workspace detail with authenticated thumbnails, full-screen viewer, gated video load, capture, compress, review, enqueue, upload attempt, retained original on failure, and the worker proxy including a streamed multipart POST.
A 12-capture batch — past the point where the Next.js client stops attempting — was driven through the queue with a stubbed transport:
maxInFlight: 1 uploads: 12 statusChecks: 13
13/13 uploaded blobs still held: 0 bytes on device: 0
One upload in flight at every moment, every item attempted, and the one item carrying a previous failed attempt was recognised by its reconcile call and confirmed rather than re-uploaded.
None of it has run on an iPhone. The failure this app exists to fix only reproduces on a real device, so this list is a release gate, not a suggestion:
- Install to the Home Screen. Confirm sign-in survives a force-quit.
- 30 photos in one session; confirm all 30 reach the workspace and the queue empties.
- Airplane mode mid-upload, then reconnect. No duplicates, no losses.
- Wi-Fi to cellular and back, mid-upload.
- Lock the phone mid-upload. Background the app mid-upload. Swipe it away mid-upload. Relaunch each time and confirm the queue resumes.
- Record a video near the 90 MB limit; confirm it uploads or is refused clearly, never silently.
- Sign out with items queued; sign back in; confirm they still upload.
- Fill the phone's storage and confirm capture refuses cleanly.
- Confirm a photo taken in the app does not appear in the camera roll.
Record the iPhone model, iOS version, and server media IDs for each.