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, a user can voluntarily post one of their own visit photos to that patch's public community board — from the photo lightbox on a patch's "My Visit" tab or on an Album place page (built 2026-10-11, shipped in v1.1.106; it replaces publishing to the patch's public gallery, PatchPhoto). That is opt-in and per photo, and does not touch the private album photo — it uploads a fresh, re-encoded copy whose file carries no metadata, but the photo's location goes with the post and is public.
Status (shipped / beta-badged / flagged off)
- 2026-10-19: no sharing and no cloud-album upsell in the app. While the
invite-a-friend flow is rethought (it became "10 friends → patch on us", see
referral-and-invite.md), the Album's share icon,
the place page's share icon, the "Cloud album" footer strip, and My Visit's
per-photo share link were removed from the app in code — not behind a flag.
AlbumShareIconButtonis kept on disk, unrendered, so bringing sharing back is re-adding a line.CloudAlbumLockedStripand the/cloud-albumwall were removed 2026-10-25; a paywall for a not-yet-live paid tier will replace them. + Post to a board is unaffected. The server's share endpoints and/a/:tokenpages are untouched, and the referral ladder no longer grants album access at any rung. Older app builds still show the strip and the share icon until people update. - Redesigned Places and the map (flag
album_places_redesign,defaultEnabled: false, both registries:mobile/src/config/feature-flags.ts:27,backend/src/admin/feature-flag-definitions.ts:33): built, off by default, but ON in production —GET https://scout-patches.com/api/feature-flagsreturned"album_places_redesign":trueon 2026-10-29 (same pattern ascloud_album, alsotruelive). A released app that reaches the flag endpoint therefore shows the redesign: the Places side of the Album becomes cover cards, a group page and a place page get new layouts, andscout://album-mapopens. A client that cannot reach the endpoint falls back to the registry default and shows the old album. See "The Places redesign" under How it works. Re-check the live flag before saying it is 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 (
EntitlementsService.cloudAlbumEnabledin the backend's entitlement service, since 2026-10-25; it used to beAlbumAccessService.featureEnabled): 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.
- A "Story" view of the Album, the per-account backup switch and "Remove backed-up copies": built 2026-10-25 on
develop, behind a feature flag that is OFF (default off, defined in both flag registries) and not in any released app. With the flag off the Album renders exactly as described below and none of the new surfaces exist or make a request: the Album makes the same single cloud-library request it always made (an early build of this work made a second one; fixed and pinned by call-count tests). With it on, the Album gains a Story | Places switch (Story is the default view), a Story home of dated chapters (a chapter is a run of dated photos with no gap over 48 hours, capped at 21 days, at least two photos; seemobile/src/domain/albumChapters.ts), a chapter page atscout://album-chapter?key=ch-<ms>, a cloud-backup button and sheet, and a one-time sheet asking certain accounts whether to back up: those given the album without asking for it, that have not chosen yet. Accounts granted the album by an admin never see it. The Story home and the chapter page show only to an account with the album; anyone else sees an inline screen for accounts without the album in their place. Chapter titles a user renames are stored on the device with the photos, not synced. There is no chapter sharing. - Post a visit photo to the patch's board (My Visit tab and Album place page): built 2026-10-11 (
62082888), shipped in v1.1.106 and live in the App Store's 1.1.107, no flag, independent ofcloud_album. It replaces publish-to-public-gallery (PatchPhoto), which is what store builds before 1.1.106 still offer on My Visit; the new build no longer publishes to or shows the gallery. 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:876-895) with no revokedAt and an unexpired expiresAt. Access is granted by an admin from the Cloud Album admin panel (source: 'admin_grant'). There is no referral path to album access any more: the referral ladder's album grant was removed 2026-10-25 (ReferralQualificationService writes no AlbumAccess; see referral-and-invite.md), and source: 'referral' rows written earlier are left as they were.
Since 2026-10-25 the album gate is an entitlement resolver (the pure resolveEntitlements function in the backend's entitlement rules) built for a paid tier that is not live yet. AlbumAccessService.hasAccess (backend/src/album/album-access.service.ts:25-27) returns EntitlementsService.forUser(...).album, always ANDed with cloud_album. Today it grants album access only through admin grants, exactly as before; no other path to access exists in the product yet.
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.- Story | Places switch and the Story home (behind the unreleased flag):
mobile/app/(drawer)/album.tsxwithmobile/src/components/album/story/. Places is the screen described above, untouched; Story is the default every open (the choice is not remembered). The Story home shows a year card and the chapters, newest first, as covers. scout://album-chapter(paramkey,ch-<first photo time in ms>; behind the unreleased flag) →mobile/app/album-chapter.tsx. One chapter: a hero with the title, a stat row (places, photos, days, patches collected), a photo grid whose lightbox carries every photo, and a route drawn over the places in visit order. "Rename chapter" lives in the more menu. An unknown key reads "This chapter isn't here any more."; an account without the album sees the inline screen for accounts without the album; with the flag off the route redirects to/album. It has no share control.- Backup button and sheet (behind the unreleased flag):
mobile/src/components/album/backup/. A cloud button beside the menu button opens a sheet with a "Back up my album" switch (only for an account with the album) and, when backup is off and the account still holds backed-up copies, "Remove backed-up copies" (confirmed with the real count, "The photos on this phone stay."). The button shows for an account with the album, or for one that no longer has it but still holds copies, so those copies can always be removed. - Choose-backup sheet (behind the unreleased flag): the heads-up sheet in the same folder (
backup/). Shown once per Album open only to certain accounts given the album without asking for it, while they have not chosen: "Back up my album" or "Not now". Swiping it away decides nothing. An account granted the album by an admin never sees it (it is backed up unless it turns backup off). It stays hidden while the backup sheet is open, so the two never stack. 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 and sections — 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./cloud-album(the offer wall) andCloudAlbumLockedStrip: removed 2026-10-25. Nothing in the app opened either since 2026-10-19. The route, its screen, its DEV mock, its screen test, its deep link and its board frame are gone; a paywall for a not-yet-live paid tier will replace them.AlbumShareIconButtonnow renders nothing for an account without access (it used to open the wall).- No share icon on the Album or a place / group page, and no share link on a My Visit photo, since 2026-10-19 — with or without access.
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. Since v1.1.106 (built 2026-10-11) a photo opens in the lightbox with a + Post pill that posts it to the patch's board, a posted photo's tile wears a "↑ N Posted" tag (MyVisitTabV2.tsx:159-171), and the caption reads "Only you can see these. Tap one to post it to the <Patch> board." (:195). Store builds before 1.1.106 offer "Add to gallery" here instead. - Album place page Post (new, same build): an Album place page's lightbox offers the same + Post on each photo, posting to that place's board (
mobile/app/album-place.tsx:337-361). Before this build the place page offered no publish at all. scout://album-map→mobile/app/album-map.tsx(a full-screen modal; behindalbum_places_redesign, with the flag off it redirects to the Album, and a cold deep link waits for the server flags first). "Your America": a map of the 50 states, then a state, then a city. Opened by the Map pill at the end of the dimension chips on the redesigned Places list. See "The Places redesign".- Redesigned Places list, group page and place page (behind
album_places_redesign):mobile/app/(drawer)/album.tsxrendersPlacesList(mobile/src/components/album/places/PlacesList.tsx) in place of the rows when the flag is on;mobile/app/album-place.tsxrendersAlbumPlaceRedesignBody. Flag off: today's screens, untouched. 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 resolves each asset throughlookupVisitPhotoAsset(mobile/src/hooks/visitPhotoLookup.ts): first the platform asset reference (nextAssetRef, aph://orcontent://URI, confirmed byMediaAsset.getShape()), and only if that failsexpo-media-library'sgetAssetInfoAsyncas a fallback; a ref is pruned only when both agree it is gone. No network call is made to show these photos. A photo removed from the camera roll is pruned from the album automatically (purgeVisitPhoto).
The Places redesign (flag album_places_redesign)
All of it reads the same buildAlbum / groupAlbum data as today's album (mobile/src/domain/album.ts); only the drawing changes. Pure view logic is in mobile/src/domain/albumPlacesView.ts (groupCover, rowAccent, placesSubtitle, coverKicker, sectionPreview) and mobile/src/domain/albumMap.ts.
- Places list (
PlacesList,CoverCard,StateAccent): a title and count line, the States / Cities / Campaigns chips closed by a Map pill, then one cover card per group: its cover photo, a kicker such as "2 of 55 places", the title, the photo line, the state's outline with a dot per place, and a progress line along the bottom. - Group page (
album-place.tsx,AlbumHero,PlaceSection): one still hero photo (not the old spotlight reel), a stats row, then a section per place: header, then a 3-photo lead grid (first photo tall, two stacked beside it, "+N" over the third when the place holds more). The header opens that place's own page. - Place page (
album-place.tsx,InsideTheLine): the hero, the stats row, "Inside the line" (the patch's real unlock boundary with one of your photos clipped inside it; drawn only when the patch has a real polygon, nothing for a circle fallback), then every photo in a square 3-column grid (photo dimensions are not stored, so there is no portrait-tall tile). Remove, Post to board and the lightbox are the same as today. - Cover photos, two paths. The list's cover cards, the group and place heroes and the section tiles are drawn with
expo-image, which reads the device's own photo reference, so they resolve throughuseAlbumPhotoUrislike the rest of the Album: theph:///content://asset ref, or the photo's cloud copy when it has one. Nothing is shrunk for them. A cover or hero that fails to load falls back to the group's next photo, for that visit only (groupCoverwith a dead set); a photo that never resolves on web can stay blank. Only the drawings that cannot read an asset ref, "Inside the line" (react-native-svg) and the map's state fills (Skia), go throughuseCoverFile/useCoverFiles(mobile/src/hooks/useCoverFile.ts): a cloud copy is used as is, and a camera-roll photo is shrunk to a temporary JPEG file. A photo that is not on the phone (an iCloud-only original) is never downloaded for this; it resolves to nothing. Shrinks run two at a time (MAX_SHRINKS).- "Inside the line" shrinks its one photo at a 400 px long edge; the file is deleted when the card lets go of it.
- The map's state fills are shrunk at 512 px (
FILL_EDGEinmobile/app/album-map.tsx), and at the state level the focused state gets its own 1600 px file (FOCUS_FILL_EDGE), since it is drawn screen-wide. That shrink starts only once the glide has landed (useLandedLevelinalbum-map.tsx; at once with Reduce Motion), so it never competes with the glide. A fill shows the visited gold tint until its photo has decoded. A fill whose photo is not on the phone moves on to the state's next photo (countryStateswith a dead set inmobile/src/domain/albumMap.ts); a state with none left keeps a flat gold tint. The map's files are kept for the rest of the app session, so reopening the map reuses them (the 64 most recently used are kept,KEPT_FILES; older ones are deleted).
- The map (
album-map.tsx, the album's source for the sharedAtlasScreen.tsx—useAtlasViewModel+AtlasScreenLayout, which the 50 States screen also uses, see field-records-and-web-surfaces.md;Atlas,MapCard,CityStreetMapinmobile/src/components/album/map/; patch art throughPlacePatchArtinmobile/src/components/patch-art/): three levels and nothing between them.- Country: the 50 states (the app's shipped Albers USA outlines) on one Skia canvas (
@shopify/react-native-skia, GPU-drawn; each outline parsed once per app session). A visited state is filled with its cover photo and edged in gold; an unvisited one is a faint outline. DC is a dot, not one of the 50. A line under the title counts places outside the 50 states ("N places outside the 50 states"), shown only when there are some. - State: that state framed, the others dimmed (their outlines to 55%, their photos to 18%, so only the framed state reads as a photo), a photo bubble per city that shows the city's number of places.
- City: the city on the app's own MapLibre street map (the field-guide basemap), a marker per place with its patch art (a placeholder when it has none) and its name.
The card along the bottom is the discovery map's own sheet (
ConsoleSheetinmobile/src/components/map/, shared with itsPatchConsole;MapCardinsrc/components/album/map/), so the two maps' cards are one design (2026-10-28): a photo fading into the black, art floating up off the sheet, the ring header's title (HeroTitle) and a See album button in the card's colour (HeroCtaRow). A place: your newest photo of it (the place's own photo until yours resolves), its patch art (the Clasp pin placeholder without art), its campaign line in its campaign's colour (Home'stargetBase— Ghost Adventures over the City Challenge it also stands in; "Your album" in house brass for a place in no campaign), its rarity and "2 photos · Jun 2024". A state: your cover photo of it, its outline with a dot per place floating above, "Your album", "7 of 75 places · 129 photos". An unvisited state: its outline, "Not yet", "Your first patch here is waiting.", and no button. See album opens the state's or the place's album page (withfrom=map). On iOS a page pushed over the map's fullScreenModal is presented as a page sheet: it starts below the status bar and a swipe down closes it, so that page's hero draws no back button and keeps no status-bar band (presentedAsSheetinalbum-place.tsx,AlbumHero'ssheet); a place opened from inside that sheet stacks as one more sheet and says so too. Android shows the same push full screen, where swipe-down does nothing, so it keeps the back button (and the hardware/gesture back). There is no Go collect it, and no info button for the patch's own page (see "What this feature does NOT do"). Every level frames ABOVE the card's footprint (CARD_SPACE= 325 pt over the safe area, inalbumMap.ts, measured on an iPhone 17 Pro Max: the 293 pt sheet plus the art's lift), card or no card, so a card never covers the framed state, its bubbles or a city's markers, and showing one never re-frames the map. The header button closes the map at the country and goes up a level below it; Android's hardware back does the same (below the country it goes up a level, at the country it closes the map).
- Country: the 50 states (the app's shipped Albers USA outlines) on one Skia canvas (
- Taps (
hitStateinalbumMap.ts): a tap picks the state whose outline contains it. Only a tap outside every outline (water) reaches the nearest small state, withinHIT_SIZE / 2= 22 pt (HIT_SIZE = 44inalbumMap.ts), so a tiny state's padding never steals a tap from its big neighbour. At the country level a tap withinDC_DOT_PT= 8 pt of the DC dot is DC (DC's point lies inside Maryland's outline); below the country the same point is Maryland. - Screen readers: the canvas has no per-shape elements, so each state on screen carries an invisible native target over its outline (
StateTargetsinAtlas.tsx), a button named for the state; activating it (VoiceOver / TalkBack double-tap) goes into the state like a tap. At the country all 50 states and DC have one; below it, only the states the frame shows. The targets take no touches; every finger tap goes throughhitState. - Motion: the drawing glides between levels over 480 ms; the level and the copy change at once. At a city the glide lands and then the street map fades in; going up it fades out first. With Reduce Motion on, the glide is skipped. The glide runs on the UI thread: the world group's Skia
transformand the outlines' widths (divided by the live scale, so they stay hairlines) are Reanimated derived values, so a glide frame makes no React render and no Fabric commit. (Until 2026-10-28 the map wasreact-native-svg, whose animated groupmatrixsent every frame through a Fabric commit and a full CPU redraw, on Android into a new screen-sized bitmap per frame: the glide stuttered, worst zooming in.) In a dev build each glide logs its frame pacing to the Metro console, e.g.[album-map glide] 29 frames @ 60 Hz, 0 dropped (100% smooth), longest 17.1 ms(mobile/src/dev/frameReport.ts); a release build never starts that counter. - The city level's street map is not interactive (no pan or zoom; the levels are the navigation). Markers that would overlap are nudged apart on screen (positions approximated with the US map projection,
streetMarkerOffsets), and the map attribution sits above the bottom fade, and above the card's sheet while a place's card is up (as the discovery map keeps it above its console). The street map's fit pads its foot by the card's footprint (streetPadding).
Cloud backup pipeline (access granted)
syncCloudAlbum()(mobile/src/services/album/albumSyncRunner.ts:177-238) 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 — ifGET /api/album/accesssaysalbumandbackup— runs one pass ofrunAlbumSync. Access alone does not upload: an account with the album whose backup is off queues nothing, and the server answers 409ALBUM_BACKUP_OFFto an upload intent. The device keeps both answers (cloudAlbumHasAccessfromalbum,cloudAlbumBackupOnfrombackup; it never reads the legacyhasAccess) and gates uploads onselectAlbumUploading(mobile/src/domain/store.ts), not on access. With the album but backup off, no pass runs, but photos deleted in the lightbox are still sent to the server for removal (albumSyncRunner.ts:203-210).- Backup turned off during a pass stops the pass: the device re-checks before every batch, and a 409
ALBUM_BACKUP_OFFfrom the server ends it at once, with no more photos read or encoded, no photo marked failed and no error shown (mobile/src/services/album/albumSync.ts:347-349).
- Backup turned off during a pass stops the pass: the device re-checks before every batch, and a 409
- 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 eight scopes:album,campaign,collection,state,city,patch,photo,chapter(backend/src/album/album-share.service.ts:6-26). Achaptershare is a trip chapter: the owner's photos at the places the app sends, taken inside that trip's dates. It needs a title (1-120 characters) and an inclusivefromAt/toAtwindow (ISO,fromAt <= toAt), and the other scopes reject a window (backend/src/album/album-share.service.ts:224-245); the photo filter istakenAt BETWEEN fromAt AND toAton top of the place filter, and a chapter row missing its window shows nothing rather than every photo at those places (backend/src/album/album-share.service.ts:146-155). The key is the chapter keych-<first photo time in ms>, so asking again for the same chapter returns the existing link even if the title or window differs (backend/src/album/album-share.service.ts:252-256). The page headline is the title under a "TRIP CHAPTER" kicker (backend/src/album/album-share.service.ts:342-348). 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:249-257).- 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). - No in-app sharing since 2026-10-19 (see Status). The rest of this section describes the server, which is unchanged, and the share button as it worked before it was removed.
- 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.
Posting a visit photo to a patch's board (My Visit tab, Album place page)
Built 2026-10-11, shipped in v1.1.106. This replaced publishing to the patch's public gallery (below), and is a separate feature from the private album/cloud-backup pipeline, sharing only the source photo. Full mechanics live in community-and-moderation; what matters for the album:
- Trigger: tapping + Post on one of your own visit photos in the lightbox (
mobile/src/components/lightbox/LightboxChrome.tsx:131-180), opened from the My Visit tab or an Album place page. Since the quick photo post (2026-10-25, not yet in a store release) that one tap posts THAT photo at once — no sheet, no title, no description, no confirmation step (postItem,mobile/src/components/lightbox/LightboxScreen.tsx:95-106, throughmobile/src/hooks/usePhotoPost.ts). The pill reads "Posting" with a spinner while it goes up, then "Posted", and a toast says "Posted to the <Patch> board" with View; a failure shows a toast with Retry and the pill returns to + Post. The tap is the decision — until 2026-10-25 a title-and-description sheet stood between the tap and the post, and store builds from v1.1.106 up to the release that ships this still work that way. (The gallery button before 1.1.106 confirmed every publish and unpublish with a nativeAlert, removed with it. The trash button still confirms a removal,LightboxChrome.tsx:192-221.) - Copy, not move: the local photo (and any private
AlbumPhotorow/S3 object) is untouched.prepareVisitPhotos(mobile/src/services/visitPostSubmit.ts:68-97) reads the photo from its library file (mobile/src/lib/assetFile.ts), andcreatePhotoPost(visitPostSubmit.ts:103-119) uploads a new, independent copy throughPOST /post-images, then creates one untitled communityPost(title: '',body: '') on the patch's board. The lightbox posts one photo per tap; it no longer bundles the visit's other photos into the same post. - Metadata: the server re-encodes the upload to WebP after applying EXIF rotation (
ImageUploadService.uploadResized,backend/src/uploads/image-upload.service.ts:25-53, called frombackend/src/community-posts/community-posts.controller.ts:155-169), so the image file carries no EXIF. But the photo's location travels separately and is public: the app reads the asset's own GPS (readAssetCoords,mobile/src/lib/assetLocation.ts:24-31) and sends it asPost.imageCoords, which the API returns to every client. The app shows it only as a "Taken here" pin. The old sheet's footer ("Where each photo was taken goes with it · Anyone can see posts on this board") was the only on-screen notice; the one-tap post carries none. The post also stores the SHA-256 of each original asset (Post.imageSourceHashes), returned to the author only. - Public storage: post images go through the same public-read
S3Service.upload()the old gallery used; anyone can see a post and its photos without signing in. - Reversibility: delete the post on the post screen (
/post/<id>, the owner's ⋯ menu). The app then forgets the photo's "Posted" state on the next fresh board read (My Visit's reconcile) and the photo can be posted again. There is no "unpost" in the lightbox. - "Posted" on the Album place page comes from the device record alone. That page can span several patches and runs no board query, so it reads the persisted
postedVisitPhotosrecord synchronously (mobile/app/album-place.tsx:337,:352-360). My Visit runs the hook that reconciles the record against the board (useVisitPhotoPosting) and heals it for the Album too. So after a post is deleted elsewhere, the Album keeps showing Posted until My Visit has been opened on that patch. - Dedup: none on the server — posting the same photo twice makes two posts. The app stops you by showing an already-posted photo as Posted (tap = open the post) instead of + Post.
The retired public gallery (PatchPhoto) — store builds before 1.1.106 only
In store builds before 1.1.106, the My Visit tab's lightbox offers "Add to gallery", which copies a visit photo into the patch's public PatchPhoto gallery via POST /patches/:patchId/photos (backend/src/patch-photos/patch-photos.controller.ts:32-59, re-encoded by the same EXIF-stripping uploadResized, with no location sent), dedupes by sourceHash on (patchId, userId, sourceHash), and unpublishes with DELETE /photos/:photoId (backend/src/patch-photos/patch-photos.service.ts:159-179). The new build deletes that client path (useGalleryPublish, publishedPhotos) and no longer shows the gallery on the new patch screen; the rows, their S3 objects and the endpoints stay, for older builds and the previous patch screen (flag new_patch_detail_screen off).
Data model
AlbumPhoto (backend/prisma/schema.prisma:990-1030, 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:1034-1062, table album_shares):
scope(album | campaign | collection | state | city | patch | photo | chapter),scopeKey,fromAt/toAt(chapteronly: the inclusivetakenAtwindow,backend/prisma/schema.prisma:1054-1055),patchIds(client-resolved membership forstate/city/chapterscopes 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:876-895, table album_access) — one row per user, userId is the primary key:
source('admin_grant'today;'referral'only on rows written before the referral grant was removed 2026-10-25 — nothing writes it now),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 — the app-facing ones via the pure entitlement resolver,resolveEntitlements. - The album decision now goes through the entitlement resolver; a live row (
admin_grant, or a legacyreferralrow) takes precedence over any other source. Other sources, when they exist, write no row.
PatchPhoto (backend/prisma/schema.prisma:531, table patch_photos, out of primary scope; the target of the retired publish flow above, still written by store builds before 1.1.106) — 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:5-15). Returns { hasAccess, album, backup, source } from EntitlementsService.stateFor (album-access.controller.ts:55-65): album is whether the account has the cloud album; backup is whether this account's photos may be uploaded right now (the album plus the account's own backup choice); hasAccess is whether this device may back up now (album and backup together), the one field older app builds read, so a build that predates the backup choice never uploads for an account that has not chosen. source can now carry values other than the grant sources. Fixed 2026-10-25: until then it read album_access directly and ignored cloud_album — it told a granted account "you have the album" (and the app started syncing) while the flag was off and every write the guard re-checked was refused. The service's own comment claimed the endpoint honoured the flag; it did not. |
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. With backup off it instead deletes the caller's uploaded objects that no ready row points at (uploads that finished after backup was turned off) and answers 409 ALBUM_BACKUP_OFF (backend/src/album/album-sync.service.ts:266-276). |
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. |
PUT /api/album/backup |
Jwt + Throttle 30/hr per account | Switch cloud backup on or off for the account ({ enabled }). While it is off, POST /api/album/photos/intent and commit answer 409 ALBUM_BACKUP_OFF. |
GET /api/album/copies |
Jwt + Throttle 240/hr per account (no AlbumAccess) | { count }: how many backed-up photos the caller holds (ready, one per image). Readable without the album so the app can offer "Remove backed-up copies" to anyone who still has copies. |
DELETE /api/album/photos |
Jwt + Throttle 30/hr per account (no AlbumAccess) | Remove all of the caller's backed-up copies: every object the rows name AND everything stored under the account's own storage prefix, so an upload that landed after its row was gone is removed too (AlbumDataService.deleteAll, backend/src/album/album-data.service.ts:87-101). Refused with 409 ALBUM_BACKUP_ON while backup is on (backend/src/album/album-access.controller.ts:115-127). |
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 /post-images then POST /patches/:patchId/posts |
Jwt (community module) | Post visit photos to the patch's board (since v1.1.106): one upload per photo, then one post with images, imageCoords (public) and imageSourceHashes (author-only). Distinct guard stack from the album. |
POST /patches/:patchId/photos |
Jwt (separate module) | Publish a PatchPhoto — the retired public-gallery path, still called by store builds before 1.1.106 and the previous patch screen. |
GET /patches/:patchId/photos |
Optional Jwt | Public gallery list (same — not read by the new patch screen). |
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:25-27—hasAccess, the single authority every other album surface routes through; delegates toEntitlementsService.forUser(...).album.- The backend's entitlement service (
EntitlementsService) — thecloud_albumflag gate and the reads behind the album decision; its entitlement rules (resolveEntitlements) — the pure decision.backend/src/album/album-access.service.tsis the album's entry point into both. 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:25-53— the EXIF-stripping resize used by the public paths — post images and the retired gallery (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" framing that is the basis of this document's privacy section. The variable is whether photos are being backed up (privacyCopyFor(uploading), fedselectAlbumUploading: access and backup on), not whether the account has access — an account with access but backup off still reads "Your photos stay on your device."mobile/src/hooks/useAlbumBackup.ts— the backup choice (PUT /api/album/backup) and "remove backed-up copies" (DELETE /api/album/photos).mobile/src/hooks/useAlbumPhotoUris.ts— local-only photo display, no network (resolves throughvisitPhotoLookup.ts: platform asset ref first,getAssetInfoAsyncas fallback).mobile/app/album-map.tsx,mobile/src/components/album/map/,mobile/src/domain/albumMap.ts— the Places redesign's three-level map (country, state, city), tap resolution (hitState), framing (frameFor,countryView,stateView,cityView), city bubbles (cityBubbles) and street-map marker spreading (streetMarkerOffsets).mobile/src/components/album/places/,mobile/src/domain/albumPlacesView.ts— the redesigned cover-card list, hero, group section and "Inside the line" card, and their pure view logic.mobile/src/hooks/useCoverFile.ts— resolves a cover photo to a filereact-native-svgor Skia can draw (cloud copy as is, or a shrunk temporary JPEG).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-chapter.tsx,mobile/src/components/album/story/,mobile/src/components/album/backup/,mobile/src/domain/albumChapters.ts,mobile/src/hooks/useAlbumChapters.ts— the unreleased Story view: switch, home, chapter page, the pure chapter rules, and the backup button / sheets.mobile/app/album-place.tsx— the place / group drill-down: view model, pure layout, andalbumPlaceView(the caption and the per-place sections a resolved node is drawn from).mobile/src/components/album/CloudUploadDock.tsx— app-wide backup progress indicator.mobile/src/components/album/AlbumShareIconButton.tsx— share creation; documents the deliberate absence of an in-app revoke flow (unrendered since 2026-10-19).mobile/src/hooks/useVisitPhotoPosting.ts— My Visit's "is this photo posted?" (the persisted record plus a reconcile against the board's posts); replacesuseGalleryPublish.ts, deleted 2026-10-11.mobile/src/domain/visitPost.ts(visitPostFields, the lightbox fields My Visit and the Album place page both build) andmobile/src/domain/postedVisitPhotos.ts(the persisted assetId → postId record's reducers).mobile/src/services/visitPostSubmit.ts— hash, location and upload per photo, then one post (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).album_places_redesignfeature flag — gates the redesigned Places list, group and place pages andscout://album-map.defaultEnabled: falsein both registries (mobile/src/config/feature-flags.ts:27,backend/src/admin/feature-flag-definitions.ts:33) but readstruein production (verified 2026-10-29). Off: the album is exactly as before, and/album-mapredirects to the Album.- Per-account
AlbumAccessgrant — separate from the flag; an admin action (referrals grant none, removed 2026-10-25), not user self-service, and not a purchase (no payment surface anywhere; the old cloud-album offer wall was removed 2026-10-25). 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
-
Places redesign (flag on): the place page's grid is square 3-up because photo dimensions are not stored. A cover photo that fails to load falls back to the group's next photo for that visit; one that never resolves on web can stay blank. "Inside the line" draws nothing for a patch without a real polygon. DC sits inside Maryland's outline, so its dot is tappable at the country level only. A place outside the 50 states (a territory, or abroad) is not drawn; it is counted in the "outside the 50 states" line.
-
Album link shares were measured (2026-09-30, until the share controls were removed 2026-10-19):
AlbumShareIconButtonand the My Visit photo share go throughanalytics.trackShare(surface: 'album'/'patch_visit',content: 'album_link'), sendingshare_initiatedandshare_completed(analytics-events.md). -
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. -
(Moot since 2026-10-19 — no share control renders.) The "Not in a campaign" bucket produced 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 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." Since 2026-10-25 the server has a second gate, the account's own backup choice (profiles.album_backup: on, off, or never chosen): certain accounts given the album without asking for it upload nothing until they choose, while an account granted the album by an admin is backed up unless it turns backup off (backupOn, in the backend's entitlement rules). The app can set the choice only from the backup sheet and the choose-backup sheet, both behind the unreleased flag; with the flag off nothing in the app changes the choice, and admin-granted accounts upload by default as before. -
Copies are removed on request, never automatically. Turning backup off stops the device at its next batch and leaves the copies already backed up on the server until the account asks for them to be removed. Uploads that finish after backup was turned off are deleted when the device reports them (
commitanswers 409). The removal is refused (409ALBUM_BACKUP_ON) while backup is on, and is open to an account that has lost the album. One window remains: an upload URL lives 1 hour, so a photo whose upload lands after the removal, on a phone that is closed before it reports the upload, stays stored until the next removal or account deletion.
What this feature does NOT do
- The Places redesign (flag
album_places_redesign) has no free zoom or drag: the map moves only by tapping a state, a city or a patch, and the street map at the city level cannot be panned or zoomed. - It does not group places into regions, and does not draw territories or provinces: the map is the 50 states plus a DC dot. Places elsewhere appear only as a count.
- It does not turn the map sideways: the app is portrait-locked and the map has no landscape view.
- It does not add a share button to the redesigned list, group page, place page or map. Sharing stays as removed on 2026-10-19.
- The map's card does not open a patch's own detail page (no info button; See album only).
patch-modalis a card push, and from the map's full-screen modal it lands on the root stack behind the map, unseen, where the map's close then pops it instead of closing. Left out on 2026-10-28 until that page can be presented over the map. The card never offers Go collect it either: the map is your album, not the hunt. - It is not on by registry default:
album_places_redesignshipsfalsein both registries, so a client that cannot read the live flag endpoint shows the old album. In production the flag is on (readtrueon 2026-10-29), so members on a current build do see the redesign — do not describe it as off for everyone. - 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 and while backup is on (the default for an admin grant; certain accounts must choose it first), matched photos are uploaded automatically on every app open — this is not a one-time or user-initiated action per photo. With backup off nothing more is uploaded, and the copies already stored stay until removed. - 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. For the cloud album, 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. The one exception is a photo the user posts to a board (new build): its coordinates go inPost.imageCoords, which is public (see the line below). - It does not offer any way to share an album, a place or a photo from inside the app since 2026-10-19, and does not advertise the cloud album anywhere a user would see it (the offer wall was removed 2026-10-25). Do not describe album sharing as an app feature.
- 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; a photo reaches a patch's board only after a user taps + Post on that photo (one tap since the quick photo post, 2026-10-25; v1.1.106 up to that release also asks for a title first; builds before 1.1.106: "Add to gallery").
- It does not keep a posted photo's location private. A visit photo posted to a board carries where it was taken (
Post.imageCoords), public through the API to anyone; the app itself shows it only as a "Taken here" pin, never on a map. (The image file itself still carries no metadata.) - It does not delete or move the original when a photo is posted (or, on current builds, published to the gallery) — that's a copy. Deleting the local photo, removing it from the private cloud album, and deleting the post are independent actions.
- It does not check the server for "Posted" on the Album place page — that page reads the device record only (see above).
- It does not delete or migrate the old gallery photos (
PatchPhoto). The new build just stops showing and writing them. - 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 today. In a released app access is granted by an admin (referrals cannot grant it any more, removed 2026-10-25). The gate reads paid entitlements and an inline screen for accounts without the album is built, but both sit behind the unreleased flag (see Status) and nothing in a released app can reach them.
- It does not share a chapter: the Story view has no share button, link or card. It does not sync chapter titles between devices, and does not remove backed-up copies when backup is turned off (only on request, with backup off). A single photo deleted in the lightbox does lose its stored copy, with backup on or off.
- It does not currently support cellular backup — Wi-Fi is required, and that requirement is not exposed as a setting. (Backup itself can be switched on or off per account, from the backup sheet, once the unreleased flag is on; the switch is not a Wi-Fi switch.)
- 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. The service and controller specs build the realEntitlementsServiceover a Prisma mock (backend/test-support/entitlements-mock.ts) and also pin the other entitlement sources; the controller spec holds the 2026-10-25 regression (a live grant withcloud_albumoff →{ hasAccess: false, album: false, source: null, backup: false }, seen failing against the old controller) and pinshasAccess = album && backup(an account with the album that has not chosen backup readshasAccess: false, album: true; an account with the album and backup on readshasAccess: true).backend/api-tests/entitlements.api.spec.ts— the same regression over HTTP against a real database, plusGET /api/entitlements.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.- Backup choice and Story (unreleased flag):
album-sync.intent.spec.ts(409ALBUM_BACKUP_OFF, nothing presigned),album-access.controller.spec.tsandbackend/api-tests/album-backup.api.spec.ts(PUT /api/album/backup,GET /api/album/copies,DELETE /api/album/photosincl. for an account with no album),album-backup.db.spec.tsin the backend's entitlement tests (NULL default, the CHECK constraint); mobilehook-tests/useAlbumBackup.test.tsx,screen-tests/album.test.tsx(Story default, the backup button and sheet, Remove only with backup off and copies, the choose-backup sheet),screen-tests/album-chapter.test.tsxandsrc/domain/__tests__/albumChapters.test.ts. mobile/src/domain/__tests__/cloudAlbumCopy.test.ts— directly tests the privacy-copy contract described above: the no-uploads promise is kept only while photos are not being backed up, is never claimed while they are,selectAlbumUploadingis true only with access AND backup on, and both states return non-empty, distinct strings.- Backup off and removal (unreleased flag):
mobile/src/services/album/__tests__/albumSync.test.ts("backup turned off mid-pass": one request, no further photos read, no attempt charged),albumSyncRunner.test.ts(removals flush with backup off;albumread, nothasAccess),backend/src/album/__tests__/album-data.spec.ts(the removal purges the account's storage prefix),album-sync.commit.spec.ts(commitwith backup off answers 409 and deletes only unreferenced objects), andscreen-tests/album.test.tsx(the flag-off Album makes one cloud-library request, not two). 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— full-screen integration tests registered in the screen-test registry per this repo's testing conventions.- Post from My Visit (2026-10-11):
mobile/screen-tests/album-place.test.tsx— an album photo offers Post naming that place's board, and an already-posted one opens as Posted from the device record;mobile/screen-tests/patch-modal-id.test.tsx— My Visit's Posted tag (with votes, paired with none and with another patch's record) and caption;mobile/hook-tests/useVisitPhotoPosting.test.tsx— the record/board reconcile, including every case where it must keep a record;mobile/src/domain/__tests__/store.postedVisitPhotos.test.ts— the oldpublishedPhotoskey is dropped on rehydrate and the record is cleared on an account change;mobile/src/services/__tests__/visitPostSubmit.test.ts— parallel coords and hashes, null entries when a photo can't be hashed or located, nothing created when an upload fails. 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>.- Places redesign:
mobile/screen-tests/album-map.test.tsx(the three levels, state/DC/water tap resolution, cover fill, the card (place, state and unvisited, its campaign line, photo and See album), framing above the card and the attribution above its sheet, the screen-reader targets, the UI-thread glide transform, the 1600 px shrink waiting for the glide to land, close and back, the off-map line, the street map's markers, framing, fade and attribution, each paired with its opposite),album.test.tsxandalbum-place.test.tsx(the redesigned list, group and place pages),mobile/src/domain/__tests__/albumMap.test.ts,albumPlacesView.test.ts,mobile/src/dev/__tests__/frameReport.test.ts(the dev glide counter),mobile/src/hooks/__tests__/useCoverFile.test.ts, and the DEV mock states inmobile/src/dev/mocks/album-map.tsx. - History, not a current file: the cloud-album offer wall's screen (
mobile/app/cloud-album.tsx), its screen test and its DEV mock (mobile/src/dev/mocks/cloud-album.tsx, seven wall states) were all deleted ine241acca(2026-10-26) with the wall. Nothing undermobile/src/dev/mocks/is namedcloud-albumtoday (verified 2026-10-29). 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.