Repository navigation
feat(geocode): Places autocomplete, Barbados-bounded fallback chain, and a required located address - #2941
feat(geocode): Places autocomplete, Barbados-bounded fallback chain, and a required located address#2941sajclarke wants to merge 22 commits into
Conversation
|
⏳ I'm reviewing this pull request for security vulnerabilities and code quality issues. I'll provide an update when I'm done |
|
✅ I finished the code review, and didn't find any security or code quality issues. |
Amplify PR previews
Commit:
|
…p-pin fallback (#2679) API - Split the geocoder into a provider chain: GoogleProvider and NominatimProvider behind a GeocodeProvider interface. Order comes from GEOCODE_PROVIDERS (default google,nominatim); Google is skipped until GOOGLE_GEOCODING_API_KEY is set, so nothing changes until the key lands (case-management#368). - A provider that fails (quota, budget, outage) falls through to the next. When none can answer, /geocode returns 503 instead of an empty list. - Results are restricted to Barbados: Nominatim gets a bounded viewbox, Google a country:BB filter and bounds, and the service drops anything outside the island's bounds whichever provider returned it. Forms - When suggestions are unavailable, an address-lookup that writes a routing coordinate shows a map. The applicant places a pin by dragging it, tapping the map or using the arrow keys. The guidance is plain, with no provider names or errors. A placed pin survives later edits to the address. - Leaflet is lazy-loaded, so it stays out of the main bundle. Smoke - New mockGeocoder(page) helper answers /geocode from canned Barbados places. Every geocoding smoke spec registers it, so the suite never calls a live provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fer the pin map when nothing matches Google runs the parish, postcode and country together with no commas, so the comma-based parser put "Saint Michael BB120004Barbados" into address line 2. Strip them from the last part, drop plus codes, and keep a typed place name as line 1 when Google matched a named place. When a lookup answers but finds nothing, the address field now says so and offers a link that opens the pin map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swap the Google provider from the Geocoding API to Places API (New): Autocomplete for suggestions (places by name, restricted to Barbados) and Place Details (location + address parts) for the coordinate of the pick, grouped into one billed session by a client session token. A new GET /geocode/place/:placeId resolves the pick; Nominatim results still carry their coordinate. The address field renders its own ARIA combobox (design-system combobox classes) so each row shows the name with the typed part in bold, where it is in the muted colour, and the "Powered by Google" mark Google's terms require. Picking a place fills line 1 with its name and line 2 with the street and town. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the applicant drops the map pin (drag, click or arrow keys), look up the address at that point once it settles and fill address line 1, line 2 and the parish. The routing coordinate stays the pin's own. A new GET /geocode/reverse?lat=&lon= answers from the provider chain (Google Geocoding API, Nominatim /reverse as the fallback). An edit made while the lookup runs is never overwritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ap be closed Leaflet stacks its panes and controls at z-index 400-1000, so the open suggestion list (z-index 10) slid under the map. Isolate the map's stacking context. Add a "Hide the map" link (a placed pin stays the coordinate; focus returns to the address) and close the map when a suggestion is picked, since the pick replaces the pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s cleared They were filled for the old address; left behind they read as stale answers and a stale coordinate (or placed pin) would still route the application. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e's policy Google's Places/Geocoding policies require the Google Maps logo or the text "Google Maps" (Roboto/sans-serif 400, 12-16px, #5E5E5E, unmodified, untranslated) — not "Powered by Google". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ding it - An address-lookup field that writes a routing coordinate no longer accepts a typed address alone: Continue asks the applicant to choose it from the list or find it on the map. The no-match hint no longer offers "type it yourself" — it links to the map. - Editing an address the lookup located asks first (cancel keeps it); going ahead clears line 2, the parish, the coordinate and the pin. - Changing the parish of a located address keeps the lines but drops the coordinate (it was for the old parish) and points to the map. - The hairdresser smoke's free-typed run becomes the map run: the typed address is refused, then placed on the map, and still routes. The API's parish fill stays as a server-side safety net. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93fae3f to
f9aee59
Compare
…der names none Google's Place Details carry no parish for some places (e.g. Kensington Mall), so picking one left the parish select empty. When a picked place or a map pin comes back without a parish, read it off the map: point-in-polygon against geoBoundaries' parish outlines (CC BY 4.0, the data the landing water-outages page already uses), with the nearest boundary for a point just off the generalised coastline. Shipped as a catchment .geojson asset, which the API build already copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t be dismissed by accident Replace window.confirm with a modal native <dialog> (focus trapped, page inert). Only its two buttons answer it: Escape is ignored, and if the browser force-closes it (Chrome on a repeated Escape) that counts as keeping the address. The dialog itself takes focus, so the keystroke that opened it (a space typed in line 1) cannot also answer it on key-up; focus returns to the field once it closes. Changing the parish of a located address now asks too: keeping the parish puts it back; changing it keeps the lines, drops the coordinate and points to the map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parish of an address block that writes a routing coordinate now comes only from the location — the picked place, the pin, or the coordinate's parish boundary. The select stays visible but is disabled, with the hint "We fill this in from the address you choose." The value stays in form state, which is what is submitted (data-value mirrors it for tests). Line 2 stays editable for unit/house detail. - buildForm flags those parish fields (filledByAddressLookup); they skip client validation, as the address field already requires a located address. - The parish-change dialog and watcher go: the parish can no longer be changed by hand. Editing line 1 still asks, and now says the parish is cleared too. - GET /geocode/reverse always returns the parish for a point in Barbados, even with no street address there or no provider answering, so a pin always sets it. - Smoke specs assert the locked parish's data-value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… dialog for now - Once the address is located (pick, place or pin), an inline warning says "Your request will be routed to <polyclinic>." — the serving polyclinic, the one the confirmation names (Frederick Miller's area → St. Philip). It goes as soon as the address changes. New GET /geocode/polyclinic?lat=&lon= (CatchmentRoutingService.servingPolyclinicAt; 404 where none serves). - Editing line 1 of a located address no longer asks first (for now — the ConfirmDialog component stays for when it returns). The edit drops the coordinate, pin and locked parish; line 2 stays. - The locked parish loses its hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… runs mockGeocoder only intercepted /geocode, so the hairdresser map run's pin called /geocode/reverse live — a Google Geocoding API request on every CI run, against #2679's rule that smoke tests never call the live provider. Also answer /geocode/reverse (nearest mock place's parish, no street address) and /geocode/place/:id (404; the mock suggestions never need it). The polyclinic lookup stays live: it is our own catchment code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…llback-2679 # Conflicts: # apps/forms/e2e/smoke/apply-for-offensive-matter-licence.smoke.spec.ts # apps/forms/e2e/smoke/apply-for-offensive-waste-licence.smoke.spec.ts
# Conflicts: # apps/forms/e2e/smoke/apply-for-lodging-barracks-licence.smoke.spec.ts
Zainab980
left a comment
There was a problem hiding this comment.
@sajclarke big, careful piece of work. One fix before merge, plus two things to settle before release.
Verified: I read the PR, all 16 commits and the diff (0 behind main, merges clean), and traced pick → pin → reverse → polyclinic → parish lock/validation, plus throttling, caching, the server parish handling and the build assets. Tests at head pass (API 280, forms 455), and both .geojson files land in dist. I wrote a throwaway probe spec for #1 (not committed). I didn't run smoke or hit Google.
What's strong
- Provider chain from
GEOCODE_PROVIDERS, with an unknown name failing at boot, and every provider's results bounded to Barbados. - 503 instead of
[]when lookup is down, so "no such address" and "lookup unavailable" can't be confused, and the field offers the pin. reverse()never fails server-side: if every provider is down, it still returns the pin's coordinate plus the parish from the map.parishAtfalls back to the nearest vertex for points just off the generalised coastline, and the specs use real Places/Geocoding response shapes.
1. A pinned address can reach submit with no parish — fix before merge (inline)
2. One throttle bucket for every citizen — fix before release (inline)
3. Caching Google predictions may breach the terms — add to the (C) decision (inline)
4. The description no longer matches the code — nit
It still describes a native confirm() and "changing the parish drops the coordinate", and says the applicant picks a missing parish. Since 068c9221 / 1895c3e0 the parish is locked, no prompt fires, and /geocode/polyclinic plus "routed to …" are new. QA will test what the description says. ConfirmDialog and its CSS also ship unused ("for when it returns"); fine if it's coming back soon.
Security: no injection; place id and session token are validated, and the redirect target and Barbados bounds are server-side. Cost exposure is #2. Performance: Leaflet is lazy-loaded ✓. Conventions: OK.
| {...sharedProps} | ||
| {...requiredProps} | ||
| {...(filledByAddress && { | ||
| disabled: true, |
There was a problem hiding this comment.
Fix before merge: the parish is locked even when it's empty. If the pin's reverse lookup fails (address-lookup-field.tsx L285 .catch(() => {})), the coordinate is written but the parish never is. This select is disabled, validation-builder skips it, and the API still requires it (Parish is required). Nothing server-side fills parish from the coordinate (fillParishRoutingCoordinate only goes the other way), so submit 422s on a field the applicant can't touch. Verified: with the spec's default rejected reverseGeocode, a placed pin writes 13.100000,-59.600000 and never writes event-parish.
Smallest fix: lock only a filled parish, and validate it when it's empty:
- {...(filledByAddress && {
+ {...(filledByAddress && selectValue && {plus making the filledByAddressLookup skip in validation-builder.ts apply only when the value is set. Better still, fill it server-side from parishAt(coordinate) when it arrives empty, so any client failure is covered.
There was a problem hiding this comment.
Resolved in 8465b7a, on the API side.
- The API now fills an empty parish from the coordinate. It does this before validation:
fillRoutingParishinparish-routing-point.ts, called at the start ofSubmissionPipelineService.run. - Thus a failed pin lookup on the client does not stop a submission.
- If there is no coordinate, the API still requires the parish.
- Specs:
parish-routing-point.spec.tsandsubmission-pipeline.service.spec.ts.
We did not unlock the empty select. renderSelectField cannot keep state. If we unlock the empty field, it locks again after the applicant selects a parish. Then the applicant cannot correct a wrong selection.
| * `GET /geocode/reverse?lat=&lon=` — the address at a pin on the map. Always | ||
| * carries the parish; the address lines are empty where none was found. | ||
| */ | ||
| @Get("reverse") |
There was a problem hiding this comment.
Fix before release: apps/api doesn't set trust proxy (see the note in journey.controller.ts), so ThrottlerGuard keys on the ALB hop: 30 req / 10s here is shared by every applicant on a task. One address is now several calls (debounced searches + place + polyclinic + reverse), so a busy hour returns 429s. Previously a typed address was an acceptable fallback; now a located address is mandatory, and a 429 on reverse is the likely trigger for the empty-parish dead end. Options: app.set("trust proxy", 1) (fixes every throttle), or a per-IP WAF rule for /geocode*.
There was a problem hiding this comment.
Resolved in 8465b7a.
main.tsnow setstrust proxyto1. The ALB is the only proxy. The DNS records for the API go directly to the ALB, with no CloudFront.ThrottlerGuardnow uses the address of the applicant. Each applicant has a different bucket.- Express uses only the last entry in
X-Forwarded-For. The ALB writes that entry. Thus a client cannot change its address with this header. - Caution: This change applies to all throttled endpoints, not only to
/geocode. - The comments in
journey.controller.tsandthrottler-config.spec.tsnow agree with this change. The journey endpoint still skips the throttle.
| (r.placeId !== undefined && r.lat === undefined) || | ||
| isWithinBarbados(Number(r.lat), Number(r.lon)), | ||
| ); | ||
| this.remember(key, results); |
There was a problem hiding this comment.
Before release: this caches Google Autocomplete predictions (names and addresses) in memory for an hour. As I read the Maps Platform terms (§3.2.3(b) "No caching", plus the Places carve-outs), only place IDs, and lat/lng for up to 30 days, may be cached. Worth adding to the option (C) check alongside the "near a non-Google map" question. Skipping the cache for Google results (or caching only Nominatim's) would avoid it.
There was a problem hiding this comment.
Resolved in 5c485be.
- Each provider now tells if its terms permit a cache (
cacheableonGeocodeProvider). Only Nominatim permits a cache. - The API does not cache Google search results.
- The API caches a result only from the first provider. Thus fallback results do not stay in the cache after an outage.
- Spec: "does not cache a provider whose terms forbid it" in
geocode.service.spec.ts. - Option (C) in the description now covers only the "near a non-Google map" question.
ihtishamtanveer
left a comment
There was a problem hiding this comment.
Strong, security-conscious work — recommend approve. This is a big change (48 files / +3.5k), so I went deep on the API + security surface and the new client contract, and sampled the rest.
Verified
- Input validation before anything hits an upstream URL:
PLACE_ID(^[\w-]{1,512}$) andSESSION_TOKEN(^[\w-]{1,64}$) gateresolve/session;placeIdis alsoencodeURIComponent'd; the user query goes in the Places POST body, not a URL — no injection. The new regexes (incl.ROUTING_COORDINATE) are all linear — no ReDoS. - Bounds everywhere:
isWithinBarbadoson search results, resolved places, and reverse input (which also absorbs the controller'sNumber(lat)→NaNcase → 400). Country predictions dropped so routing can't centroid to an arbitrary polyclinic. - Key stays server-side (forms → API → Google); Places autocomplete + details send it via the
X-Goog-Api-Keyheader, not the URL. - Cost/abuse controls are good: controller
@Throttle(30/10s, 200/60s),MIN_QUERY_LENGTH=3, 1h bounded cache (200), and Places session tokens. - Graceful config: no key ⇒ Google skipped, runs on Nominatim (today's behaviour); unknown
GEOCODE_PROVIDERSname fails at startup — so merging is safe before the key is provisioned (feature is dark until then). - Validator correct: the "must be located" rule only applies when
htmlType === "address-lookup"andgeocodeTargets.coordinatesFieldIdis set (routing fields); non-routing address fields still accept typed input; lookup-filled parish is read-only. - 503 contract:
searchAddressesthrows on!res.okand the field handles it — intentional, tested.
Non-blocking notes
- Google key in the
reverseURL query. Unavoidable for the classic Geocoding API, and current logging is safe (onlyerror.message). To keep it safe: never log the axios error'sconfig/request/toJSON()for that call (would expose?key=), and note the key appears in any upstream/proxy access logs for that host. A separately-restricted key for Geocoding would be belt-and-braces. - Set a Google-side daily budget cap / quota when provisioning the key (ties to the case-management#368 gate) — the app-side throttle is per-client, so a cost-DoS across many clients is only truly bounded by a Google budget/quota.
- 503 is a new contract for
/geocode(was[]). The address field handles the throw — just double-check no other consumer still expects[].
Scope: deep-read the geocode API (controller/service/providers), the forms geocode client, and the located-address validator; did not line-read every frontend file (combobox a11y, Leaflet pin-map, the 13 smoke-spec updates) — lower security risk and well-covered (API 1816 / forms 982 pass).
Correct, well-tested, cost- and security-aware; the only real pre-release items (Google key + budget cap) are already called out.
- search(): work out the parish of a result placed without one (Nominatim), since a located address's parish is locked and the submission refuses it. - reverse(): ask the next provider when one has no address at the pin (Google often has only a plus code for a rural pin), rather than stopping. - Cache only the primary provider's search answer, so a fallback's results do not outlive the outage that caused them. - A placed pin survives edits to line 1: the pin is the location and the text only describes it, so correcting the address it filled in no longer drops the coordinate and loops the applicant back to the map. - Clear the "just selected" flag on every keystroke, so a pick or pin that wrote text already in the input cannot swallow the next lookup. - Session token from uuid, not crypto.randomUUID(), which throws outside a secure context and silently killed the lookup. - Continue waits for a picked place's coordinate (onDynamicAsync) instead of refusing the pick as not located while it loads. - Make the map pin keyboard-operable on the marker's `add` event; the passive effect ran before react-leaflet had created the marker. - Share BARBADOS_BOUNDS, the ray-cast inRing and ROUTING_COORDINATE instead of redefining them; delete the unused confirm dialog and its CSS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ALB hop Review follow-ups for #2679. - A located address's parish is read-only on the form and written by the address-at-the-pin lookup. When that lookup fails on the client (network error, 429), the coordinate is written but the parish is not, and the submission was refused for a field the applicant cannot change. The submission pipeline now fills an empty parish from the routing coordinate (parishAt) before validation. - Set Express `trust proxy` to one hop (the ALB), so ThrottlerGuard keys on the citizen instead of the balancer. Every throttled endpoint was sharing one bucket across all applicants on a task; the geocode endpoints, several calls per address, made that a likely 429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed at 8465b7a2. The two latest commits fix most of what I'd drafted against fc229020:
- Empty locked parish: search now fills it with
parishAt, andfillRoutingParishfills it before validation in the submission pipeline. Both have specs. - Search cache: only the primary provider's answer is cached.
- Reverse lookup: a lookup with no address now moves on to the next provider instead of stopping.
- Dead code and duplication:
ConfirmDialogand its CSS are gone.inRing,BARBADOS_BOUNDSandROUTING_COORDINATEare each defined once and shared.
The API side and the combobox/ARIA work are solid, and CI is green. One thing still blocks. Update: all addressed at d9b5ae1a, see the approval below.
Blocking (resolved in 8ab4863)
The pin map has no tiles in any deployed environment. The apps/forms CSP at amplify.yml:115 is still img-src 'self' data:. Only landing's CSP (line 43) allows https://tile.openstreetmap.org, so the browser blocks every tile location-pin-map.tsx requests. The applicant gets a grey box to click on. On routing forms that box is the only way past an address the lookup can't find, and a place that fails to resolve also opens it. Neither local runs nor the smoke tests catch this, because vite dev sends no CSP and the smoke spec clicks the map without looking at it. Fix: add the tile host to img-src on line 115. The inline comment is on the tile URL.
Non-blocking (inline)
- Race: a late
resolvePlaceanswer can overwrite a pin placed after it. - Map loading: the lazy-loaded map has no error boundary.
- Fetching:
HttpModulehas no timeout, so a provider that hangs never falls through to the next. - Sea pins: a pin dropped in the sea still gets the nearest parish.
- Nits:
- the session-token length check
- an empty provider chain fails silently
- the test-only
data-valueattribute aria-controlsis only set while the list is open- the smoke spec clicks the centre of the map
PR description is out of date
Please update it before merge:
- Edit confirmation: it still says this uses native
confirm(), but nothing callsconfirm()and the dialog has been deleted. - Parish: "Changing the parish… drops the coordinate" and "the applicant picks the parish" no longer apply, because the parish is disabled.
- Tests: "the confirmation prompt and cancel" and "the parish change" aren't covered by any test.
- Reverse lookup status: "When no provider can answer, the endpoints return 503", but
/geocode/reversereturns 200 with only the parish. - Missing items:
GET /geocode/polyclinic, the "routed to …" notice, thetrust proxychange inmain.ts, and the submission-side parish fill aren't mentioned.
Out of scope — follow-ups, not blockers
- Provider policies: OSM's tile usage policy and Nominatim's ban on autocomplete-as-you-type both apply to a production government service. They belong with the Google ToS A/B/C decision. Nominatim autocomplete predates this PR.
- Smoke coverage: every smoke test now mocks
/geocode*, so none of them reaches a real provider. A scheduled Nominatim-only check would cover that. - Map tests:
location-pin-map.tsxhas none.clampToBarbadosis pure and easy to test. - Pre-existing:
searchlogs the applicant's typed query in its warn line, which puts address PII in the logs. The cache never drops expired entries. Both were the same at base.
| > | ||
| <TileLayer | ||
| attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' | ||
| url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" |
There was a problem hiding this comment.
Blocking. The forms CSP (amplify.yml:115, img-src 'self' data:) blocks this host, so no tiles load in any deployed environment. Only landing's CSP (line 43) allows https://tile.openstreetmap.org. Please add it to the forms img-src in this PR. vite dev sends no CSP, and the smoke spec clicks the map without needing tiles, so neither catches this.
There was a problem hiding this comment.
Resolved in 8ab4863.
- The forms CSP (
amplify.yml:115) now hasimg-src 'self' data: https://tile.openstreetmap.org. This agrees with the landing CSP. - The tile layer was the only image host that the CSP blocked. The pin icon is a
divIconand needs no image. - Not tested yet: tiles in the Amplify preview. Please look at the map on the preview after the build.
| locatingPlace(field.id, lookup); | ||
| }; | ||
|
|
||
| const placePin = (lat: number, lon: number) => { |
There was a problem hiding this comment.
Non-blocking (race). placePin doesn't clear pendingPlace. The "Find it on the map" link can still be on screen after an earlier invalid Continue while a place's resolvePlace is in flight. If the applicant drops a pin then, the late answer overwrites the pin's coordinate, and the pin and its reverse-filled address stay as they were. locatingPlace also makes Continue wait for that stale lookup. Fix: add pendingPlace.current = null; at the top of placePin.
There was a problem hiding this comment.
Resolved in 8ab4863.
placePinnow setspendingPlace.current = nullbefore it writes the pin. Thus a lateresolvePlaceanswer does not overwrite the pin.- New spec: "keeps a pin placed while the pick's coordinate was still loading". It fails without the fix.
- Continue can still wait for the old lookup. The wait is short, and after the wait the validator reads the pin's coordinate. Thus the result is correct.
| ? "We cannot suggest addresses at the moment. Type your address, then move the pin on the map to where it is." | ||
| : "Move the pin on the map to where the address is."} | ||
| </Hint> | ||
| <Suspense fallback={null}> |
There was a problem hiding this comment.
Non-blocking. If the lazy chunk fails to load (for example a stale hash after a deploy), the error goes past this Suspense to the route error component, which replaces the whole form page. On routing forms the map is the only way through. A small error boundary here that falls back to the "suggestions unavailable" hint would keep the applicant on the page.
There was a problem hiding this comment.
Resolved in 8ab4863.
- A small error boundary (
MapLoadBoundary) is now around theSuspenseof the map. - If the map chunk does not load, the form page stays. The field shows: "We cannot show the map at the moment. Refresh the page to try again."
- We did not use the "suggestions unavailable" hint. That hint tells the applicant to type the address, but a routing field does not accept a typed address.
- New spec: "keeps the page when the map cannot load".
| /** Barbados-locked address-lookup proxy over a configurable provider chain. */ | ||
| @Module({ | ||
| imports: [HttpModule], | ||
| imports: [HttpModule, CatchmentModule], |
There was a problem hiding this comment.
Suggestion, non-blocking. The fallback relies on a failing provider failing fast. HttpModule has no timeout (it had none at base either), so if Google hangs, the request hangs and Nominatim is never tried. HttpModule.register({ timeout: 5000 }) turns a hang into a rejection that falls through to the next provider.
There was a problem hiding this comment.
Resolved in 8ab4863.
GeocodeModulenow importsHttpModule.register({ timeout: 5000 }).- If a provider does not answer in 5 seconds, the request fails. Then the chain tries the next provider.
| if (polygons.some((rings) => inPolygon(lon, lat, rings))) return parish; | ||
| } | ||
|
|
||
| let nearest = ""; |
There was a problem hiding this comment.
Decision, non-blocking. The nearest-vertex fallback has no distance cap, so any point inside the bounding box gets a parish, open sea included: parishAt(13.34, -59.41) returns st-john. With this PR that matters in more places. Search results and fillRoutingParish both use it now, so a pin in the sea routes on the guessed parish. If that isn't intended, cap the fallback at a few hundred metres and return "" beyond it.
There was a problem hiding this comment.
Resolved in 8ab4863. Decision: cap the fallback at 1 km.
parishAtnow finds the nearest parish outline by the distance to each segment, not to each vertex. It gives a parish only if that distance is 1 km or less. A point further out is at sea, andparishAtreturns"".- Why 1 km, not a few hundred metres: The outlines are coarse (1,010 vertices, segments of up to 8 km). On the St Philip coast, the real shore is outside the outline. We measured Crane Beach at 526 m and Bottom Bay at 630 m from it. A 500 m cap refused those addresses.
- Sea points:
parishAt(13.34, -59.41)is 18 km from the outline and now returns"". A point 2 km off the west coast (13.17, -59.66) also returns"". - Pin in the sea: The reverse lookup gives no parish. The field then removes the coordinate and shows: "The pin is in the sea. Move it to where the address is." Thus Continue refuses the address until the applicant moves the pin.
- Specs:
parish-at.spec.ts(two beaches, two sea points) and "locates nothing with a pin in the sea, and says to move it".
| const DEFAULT_PROVIDER_ORDER = "google,nominatim"; | ||
| // Both go into an upstream URL or body, so only plain token characters pass. | ||
| const PLACE_ID = /^[\w-]{1,512}$/; | ||
| const SESSION_TOKEN = /^[\w-]{1,64}$/; |
There was a problem hiding this comment.
Nit. Places API (New) documents a session token of at most 36 characters. A token of 37–64 characters passes this check, Google returns 400, and search silently falls back. The forms app sends a v4 UUID (36 characters), so only direct callers are affected. {1,36} matches the documented limit.
There was a problem hiding this comment.
Resolved in 8ab4863.
SESSION_TOKENis now^[\w-]{1,36}$. This agrees with the documented limit.- New spec: a 36-character token (a v4 UUID) goes to the provider. A 37-character token does not.
| http: HttpService, | ||
| env: NodeJS.ProcessEnv, | ||
| ): GeocodeProvider[] { | ||
| const order = (env.GEOCODE_PROVIDERS ?? DEFAULT_PROVIDER_ORDER) |
There was a problem hiding this comment.
Nit. An unknown provider name stops startup, but an empty chain doesn't. GEOCODE_PROVIDERS="", or google alone without a key, gives [], and every search then returns 503 with nothing in the log to say why. A startup error or warning when providers.length === 0 would make that visible. Also, buildProviders takes env, but NominatimProvider still reads process.env.NOMINATIM_BASE_URL directly.
There was a problem hiding this comment.
Resolved in 8ab4863.
- Empty chain:
buildProvidersnow logs a warning at startup if the chain is empty. The warning gives the value ofGEOCODE_PROVIDERSand tells that Google needsGOOGLE_GEOCODING_API_KEY. - We used a warning, not an error. An error stops the API. An environment without a key must still start.
NOMINATIM_BASE_URL:buildProvidersnow reads it fromenvand gives it to theNominatimProviderconstructor. The provider does not readprocess.env.- New specs: "warns at startup when no provider is configured" and "passes NOMINATIM_BASE_URL to the Nominatim provider".
| {...requiredProps} | ||
| {...(filledByAddress && { | ||
| disabled: true, | ||
| "data-value": selectValue ?? "", |
There was a problem hiding this comment.
Nit. data-value exists only for tests. A disabled <select> still exposes .value. select-field.spec.tsx already asserts toHaveValue("st-michael") on the disabled control, and Playwright's toHaveValue works on disabled selects too. Consider dropping it; the smoke edits that switched to it could then be reverted.
There was a problem hiding this comment.
Resolved in 8ab4863.
- We removed
data-valuefrom the locked parish select. - The smoke specs now read the value of the disabled select again, as on
main:select[id=…]with.not.toHaveValue(""). Ineho-frederick-miller-local, the spec uses.inputValue()again. - The hairdresser spec does not go back to
main, because the parish is now locked there. It uses.not.toHaveValue("")with the step timeout. select-field.spec.tsxkeepstoHaveValue("st-michael")on the disabled control.
| role="combobox" | ||
| aria-autocomplete="list" | ||
| aria-expanded={expanded} | ||
| aria-controls={expanded ? listId : undefined} |
There was a problem hiding this comment.
Nit. In the ARIA 1.2 combobox pattern, aria-controls stays on the input whether or not the popup is open. The APG examples keep it set and hide the listbox. Optional.
There was a problem hiding this comment.
Resolved in 8ab4863.
aria-controlsis now always on the input.- The listbox stays in the DOM. It has the
hiddenattribute while the list is closed. A CSS rule (.address-combobox__popup[hidden]) keeps it hidden, because the popup class setsdisplay: flex. - New spec: "keeps aria-controls on the input while the list is closed".
| await page.getByRole("button", { name: "Find it on the map" }).click(); | ||
| const map = page.locator(".address-pin-map"); | ||
| await expect(map).toBeVisible({ timeout: STEP_TIMEOUT }); | ||
| await map.click(); |
There was a problem hiding this comment.
Nit (fragility). map.click() clicks the container's centre, which is CENTRE, where the default pin is anchored. A click on the marker doesn't fire the map's click, so this could miss. Clicking at an offset (e.g. { position: { x: 60, y: 60 } }) avoids that.
There was a problem hiding this comment.
Resolved in 8ab4863.
- The spec now clicks the map at
{ x: 120, y: 120 }, not at the centre. - This point is not on the unplaced pin at the centre. It is also not on the zoom control at the top-left. The map is 20rem (320 px) high.
The Maps Platform terms (§3.2.3(b)) allow caching only place IDs, not Autocomplete predictions. Providers now declare whether their answers may be cached; only Nominatim does. With the default `google,nominatim` order the search cache is therefore unused while Google answers, and still bounds Nominatim's public rate limit when it is the primary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changes after the reviewsThis comment gives the changes since the reviews on Items from @Zainab9801. Empty parish after a pin (fix before merge)
2. One throttle bucket for all applicants (fix before release)
3. Cache of Google predictions (before release)
4. PR description (nit)
Other fixes from an internal review (
|
- CSP: allow https://tile.openstreetmap.org in the forms img-src, so the pin map shows its tiles in deployed environments (blocking). - A pin placed while a pick's coordinate is still loading clears the pending pick, so the late answer no longer overwrites the pin. - An error boundary round the lazy map keeps the form page when the map chunk fails to load. - HttpModule timeout of 5 s, so a provider that hangs falls through to the next one. - parishAt: the coastal fallback takes the nearest parish outline only within 1 km (point-to-segment). A point further out is at sea and has no parish; a pin there locates nothing and the field says to move it. - Session token limited to 36 characters (Places API (New)). - Warn at startup when the provider chain is empty; NOMINATIM_BASE_URL now comes through buildProviders' env. - Drop the test-only data-value on the locked parish; smoke specs read the disabled select's value again. - aria-controls stays on the address input; the listbox stays in the DOM, hidden while closed (ARIA 1.2). - Hairdresser smoke clicks the map off-centre, clear of the unplaced pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ihtishamtanveer
left a comment
There was a problem hiding this comment.
Re-reviewed the delta since my last pass (head 8ab48637). The follow-ups are thorough and the blocker IsaiahSama raised is resolved.
Resolved ✅
- 🔴 Blocker — map tiles CSP:
amplify.yml:115(forms CSP) now includeshttps://tile.openstreetmap.orginimg-src, so the pin map renders in deployed envs rather than a grey box. trust proxy= 1 — trusts exactly the ALB hop, soreq.ipis the real client and X-Forwarded-For can't be spoofed (correct, not a blindtrue); keeps the throttler sound.- HTTP timeout —
HttpModule.register({ timeout: 5000 }), so a hanging provider times out and the chain falls through. - Caching is now ToS-safe — only the primary,
cacheableprovider is cached (index === 0 && provider.cacheable), so Google Places answers are never cached and a fallback's answer can't outlive the outage that caused it. - Empty locked parish filled from the coordinate (search results + submission pipeline); reverse now continues past an empty answer (rural plus-code pins); session token tightened to
{1,36}; empty provider chain warns loudly instead of silently 503-ing. - Race handled — latest-wins sequence counter +
AbortControllerso a lateresolvePlace/search can't overwrite a newer pin; lazy map has a load-failure fallback. - PR description updated (no more
confirm()/ parish-picks claims).
Still standing (non-blocking)
- Logging hygiene (low-sev): the
reversekey is in the Geocoding URL — never log the axiosconfig/toJSON()for that call; andsearchlogs the applicant's typed query (lookup failed for "${query}"), which is address PII. Flagged by IsaiahSama as pre-existing, but this PR widens the surface (Google now in the chain) — redacting to a length/hash would close it. - Google-side budget cap/quota on the key (ties to case-management#368) — the app throttle is per-client, so only a Google budget bounds a multi-client cost-DoS.
- A few nits remain for @IsaiahSama to clear (sea-pin → nearest parish,
aria-controlsonly while open, thedata-valuetest attr) — none blocking.
Net: materially improved — the blocker is gone and the follow-ups are correct. Only logging-hygiene + the ops budget cap are worth tidying, and neither blocks. reviewDecision is still CHANGES_REQUESTED only because @IsaiahSama's review hasn't been re-submitted; his blocker is addressed in code, so it's ready for a re-review to clear.
Zainab980
left a comment
There was a problem hiding this comment.
Re-reviewed at 8ab4863. All three of my items are fixed, and I verified Isaiah's tile blocker on the deployed preview. LGTM from me 👍
Verified: I read all 13 threads, both reviews and the summary comment, and checked each fix at head (6 behind main, clean, CI green). I didn't run suites or smoke.
Resolved
- Empty locked parish:
fillRoutingParishruns before validation. All 14 locked parishes are the routing parish, so the fill covers each one. - Throttle bucket:
trust proxy= 1 keys on the citizen, and the last XFF hop is the ALB's, so it can't be spoofed. - Google cache: only
index === 0 && provider.cacheableis cached. - Map tiles (Isaiah's blocker): on the forms preview at
8ab4863, the served CSP isimg-src 'self' data: https://tile.openstreetmap.org, and an OSM tile loads under it. I didn't open the real map on a routed form. - The description now matches the code.
Before merge, cheap: this closes #2679, so Isaiah's out-of-scope items will only live in a closed PR. Please file one follow-up issue: OSM tile and Nominatim usage policies (Nominatim is the only provider until the key lands), the typed query in the search warn log (PII), expired cache entries never evicted, location-pin-map tests, and a real-provider smoke check.
One small edge, non-blocking (inline): since the 1 km sea cap, a picked place with no parish keeps its coordinate, unlike a sea pin.
| // Some places carry no parish (Kensington Mall): read it off the map. | ||
| return { | ||
| ...place, | ||
| parish: place.parish || parishAt(Number(place.lat), Number(place.lon)), |
There was a problem hiding this comment.
Non-blocking, a knock-on from the 1 km cap on parish-at.ts: the pin path treats "no parish" as "in the sea" and drops the coordinate, but a picked place doesn't. A parish-less place more than 1 km beyond the outline now returns parish: "" here, the client keeps the coordinate, the locked parish stays empty, and fillRoutingParish gets "" too. That's a 422 the applicant can't fix. Rare, but the same rule closes it:
const parish = place.parish || parishAt(Number(place.lat), Number(place.lon));
// No parish means it's offshore, as for a sea pin: routes nowhere, so the field offers the map.
if (!parish) throw new NotFoundException("Place not found");
return { ...place, parish };There was a problem hiding this comment.
Resolved in 6c2abbe.
resolve()now uses your rule. A place with no parish is offshore, andresolve()answers 404. The address field then offers the map, as for a sea pin.search()uses the same rule. It drops a result that has a coordinate but no parish. Thus the applicant cannot select an offshore result.- Specs: "refuses a place offshore, where there is no parish" and "drops a result offshore, where there is no parish".
- resolve() and search() now treat a place with no parish (more than 1 km off the parish outlines, since the sea cap) as offshore, as a sea pin already is: resolve answers 404 so the address field offers the map, and search drops the result. Before, the client kept the coordinate with an empty locked parish, and the submission was refused on a field the applicant could not change. (Zainab980) - The search failure warning gives the query's length, not the query: an applicant's typed address is personal data. The reverse failure warning rounds the pin to ~1 km. (ihtishamtanveer, IsaiahSama) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changes after the second reviewsThis comment gives the changes in Offshore place (@Zainab980, inline)
Logging (@ihtishamtanveer, @IsaiahSama)
Follow-up issue (@Zainab980)
Not in code
Tests: API 1912 pass. The API build, @IsaiahSama, the CSP blocker and all the inline items are resolved. Please review again. |
|
@IsaiahSama, thank you for the review. This comment tells what we fixed. The fixes are in Blocker
Inline items
Other items from your review
Each inline comment has a reply with the details. Please review again. |
…llback-2679 # Conflicts: # apps/forms/src/lib/form-builder/validation-builder.spec.ts
IsaiahSama
left a comment
There was a problem hiding this comment.
Re-reviewed at d9b5ae1a. Approving: every earlier thread is closed, and what's left below is non-blocking.
My earlier threads, all closed:
- CSP (was blocking): the forms
img-srcnow allowshttps://tile.openstreetmap.org(amplify.yml:115). - Race:
placePinclearspendingPlace. Spec: "keeps a pin placed while the pick's coordinate was still loading". - Map loading:
MapLoadBoundarywraps the map'sSuspense. Spec: "keeps the page when the map cannot load". - Timeout:
HttpModule.register({ timeout: 5000 }). - Sea pins:
parishAtis capped at 1 km from the outline. Run at this commit: open sea and 2 km off the west coast return"", and Crane Beach keepsst-philip. - Nits:
- the session token is now
{1,36} - an empty provider chain warns at startup
data-valueis gonearia-controlsis always set, with the listboxhidden- the smoke clicks off-centre (the mocked reverse lookup gives it a parish, so the pin is accepted)
- the session token is now
Zainab980's threads also check out: server-side parish fill, trust proxy, no caching of Google answers, and an offshore place answers 404.
The PR description now matches the code.
Non-blocking (inline):
pinOffshoreisn't reset when a pick or a cleared address drops the pin.- From the main merge:
collectStepErrorCodesdoesn't skip the locked parish.
Still to check by eye: map tiles on the Amplify preview.
| markLocated(); | ||
| // The pick replaces any pin: drop it, close the map, and let no late | ||
| // address-at-the-pin answer overwrite the pick. | ||
| setPin(null); |
There was a problem hiding this comment.
Nit, non-blocking. pinOffshore is only reset in placePin (L307). Here and in clearFoundDetails (L215), the pin is dropped but the flag stays set. If the map then reopens (e.g. resolvePlace fails after this pick and calls setShowPinMap(true)), the hint at L400 says "The pin is in the sea…" with no pin on the map. Fix: add setPinOffshore(false) next to each setPin(null).
| ): FieldValidationProperties => { | ||
| // A parish filled from a located address is read-only: the address field | ||
| // already refuses an address that is not located, so it has nothing to check. | ||
| if (field.filledByAddressLookup) { |
There was a problem hiding this comment.
Non-blocking, from the main merge. stepPassesValidation (L378, #2812/#2937) judges off-screen steps with collectStepErrorCodes, which doesn't have this skip.
- Scenario: the pin's reverse lookup failed, so the locked parish is empty (the API fills it in at submission).
- On the return from a Change, that step counts as failing, and the applicant is sent back to it.
- Its own Continue then passes, so this is a detour, not a dead end.
Fix: add field.filledByAddressLookup || to the continue condition at L339–343, so the two validators agree.
Closes #2679
Address lookup now finds places by name, the way Google Maps does ("kensin" → Kensington Oval, Kensington Mall…). An address that routes an application must be located: picked from the list, or placed on a map when it can't be found.
API (
apps/api/src/geocode)GEOCODE_PROVIDERS(defaultgoogle,nominatim) sets the order. Google is skipped untilGOOGLE_GEOCODING_API_KEYis set, so an environment without the key keeps today's behaviour (Nominatim only). An unknown provider name stops the API at startup.includedRegionCodes: ["bb"]plus the island's bounds). Each result returns the place name, where it is, the matched characters and aplaceId.GET /geocode/place/:placeId?session=looks up the coordinate and parish of a picked place (Place Details, location and address parts only).GET /geocode/reverse?lat=&lon=returns the address at a map pin, from Google's Geocoding API with Nominatim/reverseas the fallback. It always returns the parish, read off the parish map when no provider names one or none can answer.GET /geocode/polyclinic?lat=&lon=names the polyclinic a located address routes to (404 where none serves), so the field can say so.[].parishAt).parishAttakes the nearest parish outline only within 1 km, because the outlines are coarse and the real shore is up to ~630 m outside them (Bottom Bay). A point further out is at sea and has no parish./geocode/place/:idanswers 404 (the field offers the map) and search drops it.Saint Michael BB120004Barbados). The parser now removes the country, postcode and plus code (493G+XXX) before splitting into line 1, line 2 and parish, so the parish no longer ends up in line 2.Forms address field
<id>-listboxid that smoke specs target.https://tile.openstreetmap.orginimg-src; without it the deployed map had no tiles.Submission (
apps/api)trust proxyis set to one hop (the ALB). Without it,ThrottlerGuardkept one bucket for everyone on a task, and one address is several geocode calls.X-Forwarded-Foris trusted, so a client cannot spoof its address.Testing
DB_HOST= vitest run).nx run-many -t build --exclude=landingandtsc -b --forceboth pass.Kensington Oval/President Kennedy Drive, Bridgetown/ St. Michael /13.104995,-59.622524.Hwy 3A/Applewhaites/ St. Thomas.Before release
GOOGLE_GEOCODING_API_KEYper environment (waiting on govtech-bb/case-management#368).🤖 Generated with Claude Code