Skip to content

offline sync replays captured evidence upload URLs and headers: expiring signed URLs strand evidence uploads forever #435

Description

@kilodesodiq-arch

Problem

The mobile offline queue captures an evidence upload's full credential material and replays it verbatim, with no way to refresh expiring credentials. In app/mobile/src/services/syncQueue.ts:

export interface EvidenceUploadPayload {
  aidId: string;
  url: string;
  method?: 'POST' | 'PUT' | 'PATCH';
  headers?: Record<string, string>;
  body?: string;
}
// ...
case 'evidence-upload': {
  const { url, method = 'POST', headers, body } = action.payload as EvidenceUploadPayload;
  const response = await fetch(url, { method, headers, body });   // ← replays the stored url/headers
  ...
}

The queued action is persisted to AsyncStorage (persistQueue) and retried on an exponential backoff up to MAX_RETRY_DELAY_MS = 15 * 60 * 1000 with maxRetries (default 5), so a single evidence upload can be replayed across many minutes/hours using the original url and headers.

Consequence: if the upload url is a signed/pre-signed URL — or headers carries a short-lived token — the credential expires while the device is offline. When connectivity returns, every retry posts to an expired URL and receives 403/expired, the action transitions to failed after maxRetries, and the evidence upload is stranded. Because retries reuse the same expired credential, the backoff cannot recover; only a manual re-dispatch with a fresh URL would. The backend moved large uploads to signed URLs (tracked as #244), which makes this the normal path, not an edge case.

Root cause

The queue stores a complete, frozen HTTP request (URL + headers + body) and has no "refresh credential before retry" hook; credential expiry is not modelled in the retry lifecycle.

Why this is architecturally hard

  1. The queue is intentionally credential-agnostic, which is the bug. It treats every action as a pure function of its payload; supporting refresh requires a per-action refresh(): Promise<Payload> capability and a way to detect credential-expiry failures (403/401 with a specific shape) distinct from genuinely invalid uploads.
  2. Evidence may be large PII. The body is persisted to AsyncStorage, so re-signing must be possible without re-hydrating/re-sending the whole payload redundantly, and any refresh must not leak or duplicate the body.
  3. It interacts with saver mode and idempotency. flushPendingNetworkActions runs with saverMode throttling and claim-submission idempotency; adding a refresh step must not re-upload an already-uploaded body twice or break the isRetryableError classification.
  4. The backend is the source of truth for signed URLs. A correct fix likely needs a backend "re-issue signed URL" endpoint the queue can call on refresh, which is a cross-service contract, not a mobile-only change.

Proposed design

Add an optional per-action refresh hook (e.g. evidence-upload calls a re-sign endpoint) invoked when a retry fails with a credential-expiry error, and classify 401/403-with-expiry as retryable-with-refresh. Persist only a stable aidId/claimId and fetch a fresh signed URL at flush time, rather than freezing the URL in the payload.

Acceptance criteria

Service

  • An evidence upload whose signed URL has expired is re-signed and retried on flush rather than failing after maxRetries.
  • A genuinely invalid upload (bad body, permanent 4xx) still fails without infinite refresh.
  • No duplicate upload occurs when a refresh+retry overlaps a prior in-flight attempt.

Tests

  • Tests cover: expired-credential retry triggers refresh; permanent 4xx fails; saver-mode throttling still bounds refreshes.

Out of scope

Encrypted-at-rest AsyncStorage and the mobile offline-queue coverage tests are separate issues.

Getting started

Files: app/mobile/src/services/syncQueue.ts, app/mobile/src/services/aidApi.ts, app/mobile/src/contexts/SyncContext.tsx.

cd app/mobile
pnpm test
pnpm lint

Good first files to read: services/syncQueue.ts (runAction/flushPendingNetworkActions/EvidenceUploadPayload) and services/aidApi.ts for the current upload path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third Campaignarea:mobileMobile (Expo / React Native) areabugSomething isn't workinghighHigh severity issues

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions