No description
  • TypeScript 93.3%
  • JavaScript 5.5%
  • Dockerfile 0.6%
  • HTML 0.4%
  • CSS 0.2%
Find a file
2026-07-31 10:27:50 -04:00
.claude feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
.forgejo/workflows ci: do not fail the build when the registry refuses the cache manifest 2026-07-30 22:57:11 -04:00
docker ci: build and publish the container image from Forgejo 2026-07-30 22:56:12 -04:00
public/icons feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
scripts feat: prod smoke test, and make HEIC conversion structurally guaranteed 2026-07-30 22:49:01 -04:00
src feat: implement SquareTile component and increase thumbnail resolution for improved grid layout and image quality 2026-07-31 10:27:50 -04:00
worker feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
.dockerignore ci: build and publish the container image from Forgejo 2026-07-30 22:56:12 -04:00
.gitattributes chore: normalise line endings to LF 2026-07-30 22:56:39 -04:00
.gitignore feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
docker-compose.yaml feat: add docker-compose configuration and implement server-side search and filtering for workspaces 2026-07-31 08:21:05 -04:00
Dockerfile ci: build and publish the container image from Forgejo 2026-07-30 22:56:12 -04:00
index.html feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
package-lock.json feat: add HEIC support on desktop via on-demand WASM decoding with heic-to 2026-07-31 10:17:06 -04:00
package.json feat: add HEIC support on desktop via on-demand WASM decoding with heic-to 2026-07-31 10:17:06 -04:00
README.md ci: build and publish the container image from Forgejo 2026-07-30 22:56:12 -04:00
tsconfig.json feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
tsconfig.tsbuildinfo feat: implement SquareTile component and increase thumbnail resolution for improved grid layout and image quality 2026-07-31 10:27:50 -04:00
tsconfig.worker.json feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00
vite.config.ts feat: add HEIC support on desktop via on-demand WASM decoding with heic-to 2026-07-31 10:17:06 -04:00
wrangler.toml feat: third-party field client for reliable iOS PWA evidence upload 2026-07-30 22:25:06 -04:00

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:

  1. One stable clientUploadId per capture, generated at capture, never regenerated. It is sent as idempotencyKey and queried as clientUploadId.
  2. Exactly one upload in flight, owned by one drain loop that cannot re-enter. No worker pool, no leases, no cross-worker cancellation.
  3. 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.
  4. Confirm before deleting. A 2xx is not enough to drop the local original — the reconcile call must report storagePersisted.
  5. Checkpoint on background. visibilitychange / pagehide abort the in-flight request, keep the bytes and the ID, and do not count it as a failed attempt.
  6. Permanent failure is an allowlist: 400 and 413 only. Anything unrecognised keeps retrying, because mislabelling a transient failure costs a technician a photo.
  7. navigator.wakeLock is 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 found and storagePersisted, with the same media id;
  • re-sending the same idempotencyKey returns 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/heic upload 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:

  1. Install to the Home Screen. Confirm sign-in survives a force-quit.
  2. 30 photos in one session; confirm all 30 reach the workspace and the queue empties.
  3. Airplane mode mid-upload, then reconnect. No duplicates, no losses.
  4. Wi-Fi to cellular and back, mid-upload.
  5. Lock the phone mid-upload. Background the app mid-upload. Swipe it away mid-upload. Relaunch each time and confirm the queue resumes.
  6. Record a video near the 90 MB limit; confirm it uploads or is refused clearly, never silently.
  7. Sign out with items queued; sign back in; confirm they still upload.
  8. Fill the phone's storage and confirm capture refuses cleanly.
  9. 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.