Summary
"The Album" is the screen where a Scout user sees every photo they've attached to a visited patch, organized by place, city, state, and campaign. It exists in two layers that are easy to conflate but behave very differently:
- The local album — always on for every user. Photos live in the device's own camera roll; Scout stores only a pointer (a MediaLibrary asset id) and shows the photo by reading it straight off the device. Nothing is uploaded.
- Cloud backup — an account-gated add-on. When (and only when) an account has been granted cloud-album access, matched photos are re-encoded on the device and uploaded to private S3 object storage, and the app can mint shareable links to them.
The old product brief's claim — "Photos stay on the device; what is stored is a reference, never a file" — is true only for an account without cloud-album access, and false the moment access is granted. The codebase itself documents this exact ambiguity and treats it as a compliance-sensitive fact: mobile/src/domain/cloudAlbumCopy.ts:1-13 says outright that the app's own in-app privacy copy "VARIES" and is "TRUE for every account without cloud album access and FALSE for one with it."
Separately, from the "My Visit" tab on a patch's detail screen, a user can voluntarily copy one of their own visit photos into that patch's public community gallery (PatchPhoto, a different model from the album). That is opt-in, reversible, and does not touch the private album photo or its metadata — it uploads a fresh, EXIF-stripped copy.
Status (shipped / beta-badged / flagged off)
- Local album: shipped, on for everyone, no flag.
- Cloud backup, sharing, and the
AlbumAccess/AlbumSharemachinery: fully built and shipped in code, but gated behind a master switch,cloud_album, defined in both flag registries withdefaultEnabled: false:backend/src/admin/feature-flag-definitions.ts:9-16mobile/src/config/feature-flags.ts:8-14- The server fails closed on a missing DB row (
AlbumAccessService.featureEnabled,backend/src/album/album-access.service.ts:32-38): if nobody has explicitly turned the flag on in the admin Feature Flags page, the entire cloud album is off for every account regardless of any grant. - As of 2026-09-13 that master switch IS on in production (
GET https://scout-patches.com/api/feature-flagsreturnscloud_album: true), which is a change from what this section described. It does not mean cloud backup is live: the switch only opens the door, and the per-account grant is the gate that actually decides. Per the project owner on 2026-09-13, no grant has been issued and there is no date for turning it on — so the effective state is still that no user's photos are uploaded, and the "photos stay on your device" claim currently holds for every real account. Do not readcloud_album: trueas "the feature shipped"; check whether grants exist before writing anything that depends on it. - This document cannot verify the flag's current live value in production (that requires a DB query, out of scope here). Treat "shipped but flagged off by default" as the accurate default-state description; whoever maintains prod content should confirm the live row before publishing marketing copy that implies cloud backup is generally available.
- Publish-to-public-gallery (
PatchPhoto/ My Visit tab): shipped, no flag, independent ofcloud_album. dev-album-encodeprobe screen: shipped but dev/admin-gated only (mobile/app/(drawer)/dev-album-encode.tsx:23-27), and explicitly temporary — its own header comment says "Delete this screen... once the encode is dogfooded" (mobile/src/components/dev/AlbumEncodeProbe.tsx:14).
Even when cloud_album is on, an individual account only gets upload/share access if a row exists in AlbumAccess (backend/prisma/schema.prisma:655-674) with no revokedAt and an unexpired expiresAt. Access is granted either by an admin from the Cloud Album admin panel (source: 'admin_grant') or automatically by the referral-reward system (source: 'referral', wired in backend/src/referral/referral-qualification.service.ts — referral mechanics are out of scope for this document).
User-facing surfaces
scout://album→mobile/app/(drawer)/album.tsx. The main album screen, split into the house view-model pattern (AlbumScreenViewModelImplowns every hook,AlbumScreenLayoutis pure, and the grouping helperalbumScreenRowsis shared with the DEV screen mock). It used to delegate to anAlbumScreencomponent undermobile/src/components/album/; that file is gone and the screen now lives in the route. It renders: a rotating "spotlight" hero of random photos (AlbumSpotlightHero, 5s auto-advance,mobile/src/components/album/AlbumSpotlightHero.tsx:29-30), a dimension picker (States / Cities / Campaigns), and rows of places/groups (AlbumRow). States always partition the whole album; Cities and Campaigns do not (a place can belong to zero or several), documented inmobile/src/domain/album.ts:216-232.scout://album-place(dynamic, place or group) →mobile/app/album-place.tsx. Drill-down into one place/city/state/campaign's photo grid and lightbox, and the third album screen to reach the house view-model pattern (AlbumPlaceScreenViewModelImplowns every hook,AlbumPlaceScreenLayoutis pure, and the purealbumPlaceView— caption, sections, share target — is shared with the DEV screen mock). It used to delegate to anAlbumPlaceScreencomponent undermobile/src/components/album/; that file had exactly one consumer — this route, via the barrel — and was pulled up into it and deleted, the same moveAlbumScreen.tsxandCloudAlbumWall.tsxmade. Astatusdiscriminant (loading | error | missing | ready) replaced the compound!node && loadinggating;missingcovers both a bad deep-link key and a place with no photos, becausebuildAlbumnever builds a node for a patch with zero refs.scout://cloud-album→mobile/app/cloud-album.tsx("the wall"). The offer/sales screen for accounts without access — reached only on intent (the locked footer strip's button, or the share icon when the account has no access), never fired unprompted (mobile/app/cloud-album.tsx:7-9). If reached by deep link with access already granted, it shows a simple "already on" confirmation instead of an offer. The screen follows the house view-model pattern (CloudAlbumScreenViewModelImpl/CloudAlbumScreenLayout, plus the exported purecloudAlbumOffer); it used to live inmobile/src/components/album/CloudAlbumWall.tsx, which had exactly one consumer — this route — and was pulled up into it and deleted, the same move the Album screen made withAlbumScreen.tsx.CloudAlbumLockedStrip(mobile/src/components/album/CloudAlbumLockedStrip.tsx): a footer card at the end of the album list for accounts without access, stating the real photo count on the device and inviting the user to unlock backup via a friend referral.CloudUploadDock(mobile/src/components/album/CloudUploadDock.tsx:1-16): a small floating status bar shown app-wide (not just on the Album screen) whenever a backup pass is uploading, paused, failed, or just finished — replaces an earlier version that was trapped on the Album screen only.- My Visit tab (
mobile/src/components/patch-detail-v2/tabs/MyVisitTabV2.tsx), on a patch's detail screen: shows the user's own visit photos for that patch and, per photo, an "Add to gallery" / "Remove" toggle that publishes into the patch's publicPatchPhotogallery. scout://dev-album-encode→AlbumEncodeProbe(dev/admin only): a manual, on-device harness for verifying the encode step against real camera-roll photos.
How it works (end-to-end)
Local album (no cloud access)
- Photos matched to a patch visit are recorded as lightweight references (
VisitPhotoRef: a MediaLibraryassetId+ optionaltakenAt) — populated by the camera-roll import/unlock pipeline, which is out of scope for this document. buildAlbum(mobile/src/domain/album.ts:114-190) joins those refs against the patch catalog, groups them into state → city → place, and is pure/offline — "ZERO MediaLibrary calls" per its own header (mobile/src/domain/album.ts:8).- Display resolves each asset id to a live device URI at render time via
useAlbumPhotoUris(mobile/src/hooks/useAlbumPhotoUris.ts), which callsexpo-media-library'sgetAssetInfoAsyncdirectly against the OS photo library. No network call is made to show these photos. A photo removed from the camera roll is pruned from the album automatically (purgeVisitPhoto).
Cloud backup pipeline (access granted)
syncCloudAlbum()(mobile/src/services/album/albumSyncRunner.ts:169-204) runs on app open, on foreground, and after an import. It first mirrors every (asset, patch) pair the device knows about into a local upload queue, then — if access is confirmed live viaGET /api/album/access— runs one pass ofrunAlbumSync.- For each queued photo, the device resolves the asset (
MediaLibrary.getAssetInfoAsync, withshouldDownloadFromNetwork: trueso an iCloud-optimized/offloaded original is fetched first —mobile/src/services/album/albumSyncRunner.ts:63-70), reads its EXIF/GPS/timestamp, and computes a SHA-256 of the original file bytes (contentHash). - The device calls
POST /api/album/photos/intent(backend/src/album/album-sync.service.ts:52, batches of up to 25 photos,MAX_INTENT_BATCH). The server checks for an existing row matching eithersourceAssetId(same device) orcontentHash(same photo, new device/asset id after a restore); if the object is already on S3 under any patch, it reuses those keys and skips a re-upload. Otherwise it reserves apendingrow and returns presigned S3 PUT URLs for amasterand athumbobject. - The device re-encodes the photo client-side (
mobile/src/lib/albumEncode.ts): the master is resized so its long edge is 1600px and saved as WebP quality 0.82; the thumb is derived from the already-resized master (not the original) and resized again to a 400px long edge, WebP quality 0.75. Neither output file carries EXIF — WebP re-encoding viaexpo-image-manipulatordiscards it, and the app deliberately does not write it back (mobile/src/lib/albumEncode.ts:14-19). - The two derivatives are
PUTdirectly from the phone to S3 using the presigned URLs — the bytes never pass through the Scout backend (mobile/src/services/album/albumSyncRunner.ts:98comment: "the bytes never pass through our server"). - The device calls
POST /api/album/photos/commit. The server does an S3HEADon both objects to confirm they actually landed (backend/src/album/album-sync.service.ts:227-296), generates a blurhash placeholder from the thumb, and only then flips the row(s) — every row sharing thatsourceAssetId, since one photo can be filed under a place, its city, and its state simultaneously — tostatus: 'ready'. - The original EXIF dictionary, GPS coordinates, capture time, and camera make/model are sent to the server separately in the
intentcall and stored as structured columns/JSON on theAlbumPhotorow (exifJson,latitude,longitude,takenAt,cameraMake,cameraModel) — this is the durable, queryable metadata record. It is never embedded in the uploaded image file (mobile/src/lib/albumMetadata.ts:1-13).
Restore on a fresh login / new phone
GET /api/album/photos/library (backend/src/album/album-sync.service.ts:307-360, added in commit 3244965d, "Fix cloud album restore on fresh login (#187)") returns one signed-URL render row per distinct photo (deduped by sourceAssetId, attributed to the most specific non-city/non-state patch). useCloudAlbumPhotos (mobile/src/hooks/useCloudAlbumPhotos.ts) fetches this on the Album screen and merges it with whatever local visit-photo refs exist, so a user who reinstalls the app or logs in on a new device sees their previously backed-up photos immediately via signed S3 URLs — even before any camera-roll matching has run again on the new device. contentHash is what then lets a re-import of the same photos on the new device recognize them as already uploaded rather than re-uploading (backend/src/album/album-sync.service.ts:181-186 and the "content-hash dedupe" test block).
Sharing
POST /api/album/shares(AlbumShareController/AlbumShareService) mints a share for one of seven scopes:album,campaign,collection,state,city,patch,photo(backend/src/album/album-share.service.ts:9-21). Creating a share for a scope that already has a live (non-revoked) link returns the existing one instead of minting a duplicate (backend/src/album/album-share.service.ts:187-190).- The token is 22 base62 characters (~131 bits) from
crypto.randomBytes(backend/src/album/share-token.ts) — the token is the access control; there is no password and no login requirement to view a share. - The public page is
GET /a/:token(PublicAlbumController,backend/src/album/public-album.controller.ts), rendered server-side (backend/views/album/page.hbs) with no authentication. It resolves the share's owner'sreadyphotos for that scope, signsmasterKey/thumbKeyS3 URLs with a 1-hour TTL, and shows: the place/scope name, a coarse location line built from the patch's city/state (not the photo's own GPS coordinates — the page-model code never readslatitude/longitude), a date range derived fromtakenAt(month/year granularity), and the photo grid/lightbox itself. Precise GPS coordinates and EXIF are never rendered on the public page — confirmed by grep:album-page-model.tsandalbum-share.service.tsnever selectlatitude/longitude/exifJsonfor the share path. - Every share load is gated live through
AlbumAccessService.hasAccess(share.userId)(backend/src/album/public-album.controller.ts:75-83), not by reading theAlbumAccessrow directly — so revoking an account's cloud-album access, or turning thecloud_albumflag off globally, immediately 404s every link that account ever published, even ones already handed out. A URL that was already fetched by a browser before revocation stays valid until its 1-hour signed-URL TTL expires (documented limitation,backend/src/album/public-album.controller.ts:19-23). - Revocation UX gap (verified): the mobile app's own share button (
AlbumShareIconButton) deliberately dropped the "share again / stop sharing" flow and offers no way for the user to revoke a link from inside the app (mobile/src/components/album/AlbumShareIconButton.tsx:11-17, "Revocation still exists on the server and in the admin panel; it is simply not a thing this button asks about"). Revocation is currently only reachable viaPOST /api/album/shares/:id/revokedirectly, or by an admin revoking the account's whole cloud-album access.
Publishing a photo to a patch's public gallery (My Visit tab)
This is a separate feature from the private album/cloud-backup pipeline, sharing only the source photo:
- Trigger: tapping "Add to gallery" on a photo in the My Visit tab's lightbox (
useGalleryPublish,mobile/src/hooks/useGalleryPublish.ts). - Copy, not move: the local photo (and, if it exists, its private
AlbumPhotorow/S3 object) is untouched. The hook uploads a new, independent copy viaPOST /patches/:patchId/photos(backend/src/patch-photos/patch-photos.controller.ts:32-59), creating aPatchPhotorow (backend/prisma/schema.prisma:405-431). - Metadata stripping: the server-side
ImageUploadService.uploadResized(backend/src/uploads/image-upload.service.ts:16-38) applies EXIF orientation viasharp().rotate()and then re-encodes to WebP withoutwithMetadata(), which strips EXIF (including GPS) by default. The client also explicitly passescoords: nullfor this path (mobile/src/hooks/useGalleryPublish.ts:236-241, comment: "EXIF GPS is not resolved for album photos on this path... null is the honest value"). - Public storage: unlike the private album's
master/thumbkeys (always signed, never public),PatchPhoto.urlis written throughS3Service.upload(), which "hardcodes a public-read ACL" (perbackend/src/album/album-keys.ts:24-25, describing the deliberate contrast). Anyone can view a published gallery photo without authentication (GET /patches/:patchId/photosusesOptionalJwtAuthGuard,backend/src/patch-photos/patch-photos.controller.ts:26-30). - Confirmation: no separate confirmation dialog is shown before publishing — tapping the affordance publishes immediately (the code frames the tap itself as "the same act as posting",
mobile/src/components/album/AlbumShareIconButton.tsx:9-11, which describes the analogous share button's philosophy;useGalleryPublishhas noAlert.alertgate beforeupload(), only on failure). - Reversibility: fully reversible. Tapping the same affordance again calls
DELETE /photos/:photoId(owner-only,backend/src/patch-photos/patch-photos.service.ts:159-179), which deletes the DB row first, then best-effort deletes the S3 object. - Where it is NOT offered: the Album place/group page's own lightbox (
mobile/app/album-place.tsx) deliberately does not wire uponPublish— see the code comment "NOonPublishhere, deliberately" (mobile/app/album-place.tsx:344). Publishing to the public gallery is reachable only from the My Visit tab on patch detail, not from the private Album. - Dedup: identical bytes republished by the same user on the same patch are idempotent — a
sourceHash(SHA-256 of the original asset, computed client-side) plus a unique constraint on(patchId, userId, sourceHash)means a re-publish returns the existing photo rather than creating a duplicate.
Data model
AlbumPhoto (backend/prisma/schema.prisma:715-755, table album_photos) — one private cloud-backup row per (user, sourceAssetId, patch):
id,userId,patchId— one row per patch a photo is filed under (a photo at a zoo inside a park inside a city inside a state can have up to four rows, one S3 object).sourceAssetId— the MediaLibrary asset id on the originating device. Explicitly documented as "A dedupe key, not an identity" — it changes on every new phone.contentHash— SHA-256 of the original bytes, nullable ("hashing can fail, and rows predate it"). This is what survives a device change and lets restore recognize a re-imported photo.status— string, default'pending'; the only value the server ever writes besides'pending'is'ready'. Verified by grep: no code path inbackend/src/album/ever writesstatus: 'failed'to this table —'failed'is read by the admin stats query (backend/src/album/album-data.service.ts:54) but nothing populates it; upload-attempt failures are tracked only in the client-side local queue (albumUploadQueue), never persisted server-side.masterKey/thumbKey— the private S3 object keys for the two derivatives, nullable untilcommitsucceeds.blurhash,width,height,bytes— display/placeholder facts about the shared object; copied onto every sibling row so all of a photo's rows agree.takenAt,latitude,longitude,orientation,cameraMake,cameraModel,exifJson— the durable metadata record read from the original file before re-encoding.failureReason— nullable string. Verified by grep: the server only ever writesfailureReason: null(on successful intent/commit); nothing inbackend/src/album/writes a non-null value. It exists to be set, but nothing currently sets it to anything but null.deletedAt— nullable timestamp, used as aWHERE deletedAt IS NULLfilter in every read path (album-sync.service.ts,album-data.service.ts,album-share.service.ts). Verified by grep: no code anywhere in the backend ever setsdeletedAtto a non-null value. The actual delete path (AlbumDataService.removePhotos, an explicit lightbox delete) does a harddeleteMany, not a soft delete. This column currently functions as a no-op filter, not a working soft-delete flag.- Unique constraint:
(userId, sourceAssetId, patchId).
AlbumShare (backend/prisma/schema.prisma:759-783, table album_shares):
scope(album | campaign | collection | state | city | patch | photo),scopeKey,patchIds(client-resolved membership forstate/cityscopes only — the server deliberately does not re-derive state/city grouping to avoid a second implementation that could drift from what the owner saw on-screen).token(unique, the bearer credential),title,revokedAt,viewCount(incremented on every page load).
AlbumAccess (backend/prisma/schema.prisma:655-674, table album_access) — one row per user, userId is the primary key:
source('admin_grant'today;'referral'is live perbackend/src/referral/referral-qualification.service.ts, out of scope here),grantedBy,grantedAt,expiresAt(null = permanent),revokedAt,note(operator-facing).- Liveness is decided by one pure function,
isAccessLive(backend/src/album/album-access.rules.ts): false if revoked, false ifexpiresAtis at-or-before now (exclusive boundary), true otherwise. Every authorization surface (guard,GET /album/access, admin summary, the public share loader) routes through this same function rather than re-deriving it.
PatchPhoto (backend/prisma/schema.prisma:405-431, table patch_photos, out of primary scope but the target of the publish flow above) — public gallery photo: patchId, userId, url (public S3), blurhash, sourceHash (nullable, dedupe), unique on (patchId, userId, sourceHash).
API surface
All routes below require JwtAuthGuard (401 for an unauthenticated caller) unless noted. Every write route additionally stacks AlbumAccessGuard (403 ALBUM_ACCESS_REQUIRED for an authenticated account without live access) — the guard never trusts the client's own belief about its access (backend/src/album/album-sync.controller.ts:9-14).
| Route | Guard | Purpose |
|---|---|---|
GET /api/album/access |
Jwt only | Advisory read of the caller's own access state; cached client-side in MMKV, never treated as authorization for a write (backend/src/album/album-access.controller.ts:9-13). |
POST /api/album/photos/intent |
Jwt + AlbumAccess + Throttle 240/hr | Reserve up to 25 rows, return presigned upload URLs or report duplicates. |
POST /api/album/photos/commit |
Jwt + AlbumAccess | Verify S3 HEAD, flip rows to ready, generate blurhash. |
GET /api/album/photos/sync-state |
Jwt + AlbumAccess | Cheap {count, checksum} for reconciliation. |
GET /api/album/photos/library |
Jwt + AlbumAccess | Signed display URLs for restore/fresh-install hydration. |
GET /api/album/photos/manifest |
Jwt + AlbumAccess | Every ready row's ids, for queue reconciliation and share-id backfill. |
POST /api/album/photos/remove |
Jwt + AlbumAccess | Explicit user delete of specific (asset, patch) rows. |
POST /api/album/shares |
Jwt + AlbumAccess | Create/reuse a share link. |
GET /api/album/shares |
Jwt + AlbumAccess | List the caller's own shares. |
POST /api/album/shares/:id/revoke |
Jwt + AlbumAccess | Revoke a share the caller owns. |
GET /a/:token |
None | Public share page HTML. Access-gated indirectly via AlbumAccessService.hasAccess(owner). |
GET /a/:token/card.png |
None | Public Open Graph card image (title + count only, no photo). |
POST /patches/:patchId/photos |
Jwt (separate module) | Publish a PatchPhoto — the public-gallery path, distinct guard stack from the album. |
GET /patches/:patchId/photos |
Optional Jwt | Public gallery list. |
DELETE /photos/:photoId |
Jwt | Unpublish (owner-only). |
Admin-only (not app-facing): GET/POST /admin/api/album-access/:userId (grant/revoke/stats), POST /admin/api/album-access/:userId/data (permanent delete), backed by backend/src/admin/api/album-access-api.service.ts and rendered by backend/admin-ui/src/components/CloudAlbumPanel.tsx.
Key files
backend/src/album/album-sync.service.ts— intent/commit/library/manifest/syncState; the whole server-side upload pipeline.backend/src/album/album-access.service.ts:32-43— thecloud_albumflag gate + per-account liveness check, the single authority every other album surface routes through.backend/src/album/album-access.rules.ts— pure liveness function (isAccessLive).backend/src/album/album-access.guard.ts— 403s any write from an unauthorized account; stacked afterJwtAuthGuard.backend/src/album/album-data.service.ts— account-level stats, hard delete of all cloud data (S3 + rows), explicit per-photo removal.backend/src/album/album-share.service.ts— share scope resolution and creation.backend/src/album/public-album.controller.ts— the unauthenticated public share page and its OG card.backend/src/album/album-keys.ts— S3 key derivation; documents that album objects are always private (never through the public-ACLupload()path).backend/src/uploads/image-upload.service.ts:16-38— the EXIF-stripping resize used by the public gallery publish path (distinct from the album's client-side encode).mobile/src/lib/albumEncode.ts— the client-side re-encode (1600px master / 400px thumb, WebP, no metadata).mobile/src/lib/albumMetadata.ts— documents that metadata is captured and sent separately, never embedded in the file.mobile/src/services/album/albumSync.ts— pure, injectable upload-pass engine (used fromalbumSyncRunner.ts, the native-dependency wiring).mobile/src/services/album/albumSyncRunner.ts— real device wiring: MediaLibrary resolve, PUT with timeout/abort,syncCloudAlbum()entry point called on app open/foreground/import.mobile/src/domain/album.ts— pure local grouping/spotlight logic (state/city/place hierarchy, dimension grouping, spotlight reel).mobile/src/domain/cloudAlbumCopy.ts— the exact in-app privacy copy strings, and the explicit "this promise varies by access" framing that is the basis of this document's privacy section.mobile/src/hooks/useAlbumPhotoUris.ts— local-only photo display, no network.mobile/src/hooks/useCloudAlbumPhotos.ts/mobile/src/domain/cloudAlbumLibrary.ts— fresh-login/restore hydration from the cloud library endpoint.mobile/app/(drawer)/album.tsx— the Album screen itself: view model, pure layout, andalbumScreenRows(the dimension slice the header and the list are both drawn from).mobile/app/album-place.tsx— the place / group drill-down: view model, pure layout, andalbumPlaceView(the caption, the per-place sections and the share target a resolved node is drawn from).mobile/src/components/album/CloudUploadDock.tsx— app-wide backup progress indicator.mobile/app/cloud-album.tsx— the intent-only offer screen ("the wall"): view model, the purecloudAlbumOfferladder derivation, and the layout.mobile/src/components/album/CloudAlbumLockedStrip.tsx— the locked-state discovery footer.mobile/src/components/album/AlbumShareIconButton.tsx— share creation; documents the deliberate absence of an in-app revoke flow.mobile/src/hooks/useGalleryPublish.ts— the My Visit tab's private→public publish/unpublish flow (copy-not-move).mobile/src/components/dev/AlbumEncodeProbe.tsx— the on-device encode verification harness.backend/admin-ui/src/components/CloudAlbumPanel.tsx— admin grant/revoke/permanent-delete UI.
Configuration and flags
cloud_albumfeature flag — master switch,defaultEnabled: falsein both registries (backend/src/admin/feature-flag-definitions.ts:9,mobile/src/config/feature-flags.ts:9). Must be turned on in the admin Feature Flags page before any per-account grant does anything (fail-closed on a missing/absent row).- Per-account
AlbumAccessgrant — separate from the flag; an admin action (or referral reward), not user self-service, and not a purchase (no payment surface anywhere on the cloud-album wall,mobile/app/cloud-album.tsx:15). cloudAlbumWifiOnly(mobile store,mobile/src/domain/store.ts:786,1307) — hardcoded totrueat initialization, and verified by grep to have no setter or UI control anywhere inmobile/appormobile/src/components. Cloud backup currently only ever runs on Wi-Fi; there is no way for a user to opt into cellular backup even though the sync engine supports the toggle (AlbumSyncDeps.wifiOnly,mobile/src/services/album/albumSync.ts:83).- Upload PUT timeout: 60s (
UPLOAD_TIMEOUT_MS,mobile/src/services/album/albumSyncRunner.ts:38-43) — a backgrounded app mid-upload gets aborted rather than hanging forever. - Server throttle: 240 calls/hour per route family (
ThrottlerModule.forRoot,backend/src/album/album.module.ts:27), documented as deliberately not a photo cap — "no photo cap" (backend/src/album/album-sync.controller.ts:25-27). - Batch sizes: server accepts up to 25 photos per
intentcall (MAX_INTENT_BATCH); the client drains in batches of 20 (BATCH,mobile/src/services/album/albumSync.ts:28), up to 500 batches per pass (MAX_BATCHES). - Presigned URL TTLs: share-page images 3600s (
IMAGE_TTL_SECONDS,backend/src/album/public-album.controller.ts:23); library restore URLs also 3600s (backend/src/album/album-sync.service.ts:319,323).
Edge cases and known limits
- Encode verification is portrait-only so far. The on-device probe (
AlbumEncodeProbe) explicitly lists "a HEIC portrait, a HEIC landscape, a screenshot, a panorama, and one photo that is offloaded to iCloud" as the coverage it wants (mobile/src/components/dev/AlbumEncodeProbe.tsx:150-153), meaning the author considered all five categories necessary — but per the project memory this real-device verification has so far only been confirmed for portrait photos. The unit tests (mobile/src/lib/__tests__/albumEncode.test.ts) do exercise landscape, square, undersized, and zero-dimension inputs against the pure resize-planning logic (planAlbumEncode/computeResize) with mockedexpo-image-manipulatorcalls — so the math is tested for all orientations — but a mocked unit test cannot prove thatexpo-image-manipulatoractually decodes a real HEIC landscape, a screenshot (PNG, no EXIF orientation quirks), a panorama (extreme aspect ratio, large pixel count), or an iCloud-optimized original (shouldDownloadFromNetworkfetch path) correctly on a real device. Per the probe's own stated purpose ("Jest cannot prove the things that actually matter... This screen does, on a real camera roll"), those four categories are unverified on real hardware as of this document. This is an unfixable-after-ship risk by the code's own description: "no originals are retained and there is no server-side reprocessing" (mobile/src/components/dev/AlbumEncodeProbe.tsx:9) — a bad encode for one of these categories cannot be corrected after the fact for photos already uploaded. status: 'failed'and non-nullfailureReasononAlbumPhotoare effectively dead on the server. Confirmed by grep acrossbackend/src/album/: nothing ever writes either. The admin panel's "Failed" stat (CloudAlbumPanel.tsx) will report 0 today regardless of real upload failures, because failures are tracked only in the client's local queue and never reported to the server as a row state.deletedAtis a no-op filter, not a working soft delete. Every read path filters ondeletedAt: null, but no code path sets it. The real delete (removePhotos) is a harddeleteMany.- No in-app share revocation. The mobile share button intentionally does not expose "stop sharing" (see API surface / sharing section above). A user who wants to kill a link they've shared must currently ask an operator, or the operator must revoke the account's whole cloud-album access (which darkens every link, not just one).
- Wi-Fi-only backup cannot be disabled by the user. See Configuration section.
- The place / group page never singularises its counts.
albumPlaceView(mobile/app/album-place.tsx:148-152) formats${photoCount} photosand${placeCount} placesunconditionally, so a place with one photo reads "1 photos" and a group with one place reads "1 places". The Album index singularises the same count line (mobile/app/(drawer)/album.tsx), so the two screens disagree. Pinned by theone-photoDEV screen-mock state, which asserts the shipped copy rather than the wanted copy. - The place / group page drops
useContent()'sisLoadinganderror. It takespatches/collections/campaigns/patchCollectionsfor their data only (mobile/app/album-place.tsx:233). While the content query is still pending,patchesis[], sobuildAlbumproduces no places,findAlbumNodereturns null, and — whenever the cloud-album fetch has already settled — the screen renders the terminal "Nothing here / That place is not in your album." rather than the loading skeleton. The same shape of bug has now been found on four migrated screens. - The "Not in a campaign" bucket produces a share target for a campaign that does not exist.
shareScopeForreturns null only for an EMPTY key (mobile/src/domain/albumShareScope.ts:56), and the synthetic rollup's key isUNGROUPED_KEY='__ungrouped__', which is not empty — so the comment on that line ("synthetic rollups") never fires. With cloud-album access, tapping share on that page mints acampaign/__ungrouped__link. Visible in thenot-in-a-campaignDEV screen-mock state. - A URL already fetched survives revocation for up to an hour — the signed S3 URL's own TTL, independent of the share/access check (
backend/src/album/public-album.controller.ts:19-23). - The public share page never shows precise GPS, only the patch's city/state and a month/year date range — verified by absence of
latitude/longitudein the page-model/share-service select statements for that path. - One photo, several rows. A single camera-roll photo that unlocks multiple patches (park + city + state) becomes multiple
AlbumPhotorows sharing one S3 object; several comments in the codebase note a real regression where this once produced duplicate S3 objects ("46 photos became 52 objects",mobile/src/lib/albumEncode.ts/album-keys.tsregion) before being fixed to key by asset id rather than row id. - Consent design: access is granted by an admin (or the referral system) with no separate in-app consent step at grant time — the in-app copy that describes the changed behavior (
cloudAlbumCopy.ts) is, by the code's own comment, "the only notice a granted user receives." There is no dedicated opt-in screen a user must accept before their photos start uploading.
What this feature does NOT do
- It does not keep photos device-only once cloud-album access is granted. The "stays on your device" promise is real, tested, and enforced in copy only for accounts without a grant (
mobile/src/domain/__tests__/cloudAlbumCopy.test.ts:22, "never claims the camera roll stays on the device once access is granted"). Once granted, matched photos are uploaded automatically and continuously — this is not a one-time or user-initiated action per photo. - It does not upload original, full-resolution files. What leaves the device is always a re-encoded, resized derivative (1600px master, 400px thumb, WebP), never the original bytes.
- It does not embed location or camera metadata in any uploaded image file, public or private. GPS/EXIF live only in the
AlbumPhoto.exifJson/latitude/longitudedatabase columns, readable to the owning account and to Scout operators, never to a public share-page visitor and never inside the image bytes themselves. - It does not make a private album photo public by default, or automatically, at any point. A share only exists after a user deliberately taps the share icon; publishing to a patch's public gallery only happens after a user deliberately taps "Add to gallery" on one specific photo.
- It does not delete or move the original when a photo is published to a public gallery — that's a copy. Deleting the local photo, removing it from the private cloud album, or unpublishing the public copy are three independent actions.
- It does not offer any way, currently, for a user to revoke a share link from inside the mobile app.
- It does not charge money for cloud-album access. There is no payment surface anywhere in this feature; access is granted by an admin or earned via referral.
- It does not currently support cellular-only or user-toggleable backup — Wi-Fi is required, and that requirement is not exposed as a setting.
- It has not been verified on real hardware, as of this document, for landscape photos, screenshots, panoramas, or iCloud-optimized (offloaded) originals going through the actual upload pipeline — only portrait has documented real-device verification; the other four are covered by unit tests of the pure resize math only, not an end-to-end device run.
- It does not retroactively fix a bad encode. If an upload processing bug shipped, already-uploaded photos cannot be reprocessed from an original — none is retained server-side.
Tests that cover it
Backend (backend/src/album/__tests__/ and colocated *.spec.ts):
album-access.flag.spec.ts— thecloud_albummaster switch: denies when off even for a granted account, denies an ungranted account even when on, denies (fail-closed) when the flag row doesn't exist, and skips the grant lookup entirely when the flag is off.album-sync.intent.spec.ts— presigned-URL issuance, GPS/metadata persistence, duplicate detection, content-hash dedupe across a device change (including cross-account isolation and not matching non-ready rows), batch-size and empty-batch rejection, per-account scoping.album-sync.commit.spec.ts— real S3 HEAD verification before marking ready, rejecting a photo that never landed or landed only half (master without thumb), per-photo failure isolation within a batch, marking every sibling row ready together, blurhash-failure resilience.album-share.crud.spec.ts— token minting per scope, reuse of a live link, ownership-scoped revoke, rejecting an unrecognized scope or empty scope key.album-share.heading.spec.ts,album-share.resolve.spec.ts— scope-to-patch resolution and share-page heading text.public-album.spec.ts— 404 on unknown/revoked token, 404 when the owner's access has been revoked or expired (not the viewer's), signed 1-hour image URLs, master+thumb serving "both of which carry no metadata" (explicit test name), omitting photos with no master object, view-count increment.album-access.guard.spec.ts,album-access.service.spec.ts,album-access.rules.spec.ts,album-access.controller.spec.ts— the access-liveness boundary logic (revoked, expired-exact-boundary, missing row) at every layer that consumes it.album-data.spec.ts— account-level stats grouping and hard delete (S3-then-rows ordering).delete-user-data-album.spec.ts— confirms account deletion removesalbum_access,album_shares, andalbum_photosrows.album-keys.spec.ts,album-manifest.spec.ts,album-page-model.spec.ts,album-og-card.spec.ts,blurhash-preview.spec.ts,share-token.spec.ts— supporting pure-logic units.
Mobile:
mobile/src/lib/__tests__/albumEncode.test.ts— long-edge clamping correctness for portrait/landscape/square/undersized/degenerate inputs (pure math only, mocked native modules — see "Edge cases" above for what this does and does not prove).mobile/src/services/album/__tests__/albumSync.test.ts/albumSyncRunner.test.ts— the injectable sync engine: Wi-Fi-only gating checked before encoding, duplicate skip, retry/failure accounting, coordinate coercion and range validation (rejectsNaN/Infinity/out-of-range asnullrather than sending garbage), batch-rejection handling, queue persistence.mobile/src/domain/__tests__/cloudAlbumCopy.test.ts— directly tests the privacy-copy contract described above: the no-uploads promise is kept only without access, is never claimed once access is granted, and both states return non-empty, distinct strings.mobile/src/domain/__tests__/albumShareState.test.ts,albumShareScope.test.ts,albumUploadQueue.test.ts,cloudAlbumLibrary.test.ts— share-state matching, scope resolution, queue transitions, restore-merge logic.mobile/src/hooks/__tests__/album.test.ts,useAlbumPhotoUris.test.ts— local grouping and device-URI resolution/pruning.mobile/src/components/album/__tests__/CloudUploadDock.test.tsx,mobile/src/domain/__tests__/cloudUploadDock.test.ts— dock view-state derivation.mobile/screen-tests/album.test.tsx,album-place.test.tsx,cloud-album.test.tsx— full-screen integration tests registered in the screen-test registry per this repo's testing conventions.mobile/src/dev/mocks/album.tsx+mobile/screen-tests/screen-mocks.test.tsx— the Album's eight DEV screen-mock states (states/cities/campaigns dial, one photo, long names, loading, cloud-album error, nothing scanned), each asserted to render real content. Reachable on device atscout://dev-screen-mock/album?state=<slug>.mobile/src/dev/mocks/album-place.tsx+mobile/screen-tests/screen-mocks.test.tsx— the place/group page's ten DEV screen-mock states, seeded from all 55 Massachusetts catalog rows plus the complete membership of the Boston and Salem City Challenges, so every denominator on screen is the catalog's own (Massachusetts 55, Boston 22): a bad key ("Nothing here"), loading, the cloud-album error, one photo, one place's full grid, a place whoselocationImageUrlis genuinely null, the catalog's longest-in-state 65-character name, a City Challenge group, a state group, and the "Not in a campaign" bucket. Reachable atscout://dev-screen-mock/album-place?state=<slug>.mobile/src/dev/mocks/cloud-album.tsx+mobile/screen-tests/screen-mocks.test.tsx— the cloud-album wall's eight DEV screen-mock states, all of them referral-gated and therefore near-unreachable on a device without manipulating a real referral: the shipped offer (3 friends to go), one photo / one friend, the singular "1 more friend", already-permanent, a merchandise rung named as the next prize, a ladder with no milestones, a cold open (no photos, no ladder yet) and the granted "already on" panel. Each asserts its own price-line copy. Reachable atscout://dev-screen-mock/cloud-album?state=<slug>.backend/admin-ui/src/lib/__tests__/album-access-routes.test.ts— admin API route coverage.
Open questions
- Current production value of the
cloud_albumflag cannot be determined from code alone (it's a DB row); this document only establishes the shipped default (off). Confirm the live value before writing marketing copy that assumes cloud backup is broadly available today. - Whether
status: 'failed'/failureReasonare planned to be wired up server-side, or are intentionally vestigial (e.g., reserved for a future server-detected-failure path) — the code gives no indication either way beyond "not currently written." - Whether
deletedAtis a planned future soft-delete (e.g., for a "recently deleted" recovery window) that simply hasn't been implemented yet, or a column that should be removed — no comment in the schema or code explains the discrepancy between the filter usage and the absence of any write. - Real-device verification status for landscape/screenshot/panorama/iCloud-optimized encodes — this document can confirm the tooling exists (
AlbumEncodeProbe) and what it checks, but cannot itself run it on a device; treat the "portrait-only verified" claim as based on prior project history, not something re-verified while writing this document. - Exact retention window, if any, for a "revoked"
AlbumAccessrow's photos before an operator manually deletes them — the admin panel copy says photos "are kept until deleted explicitly," but there's no automatic expiry/cleanup job found inbackend/src/album/; this document did not exhaustively search for a scheduled job elsewhere in the codebase that might do this.