Skip to main content

OSM contributions (developer notes)

This is the safety design behind the place contribution feature. Read it before changing anything under apps/api/src/services/osm-contributions/, packages/presets/src/editor-*, or the contribution UI.

Contributions publish to OpenStreetMap under the linked account. Preserve the server-owned tag policy, exact-version check, and timeout reconciliation before changing the write path.

Trust boundary​

browser → semantic operations only
↓
apps/api → live OSM read, tag policy, XML, changeset lifecycle
↓
OpenStreetMap

The browser can express only a small semantic operation: "set the name to X", "remove the phone number", "change the category to this preset id". It cannot send arbitrary tag keys or tag maps, geometry, an arbitrary changeset, or an upstream URL. Those requests are rejected by .strict() Zod schemas in @openmapx/core before a route handler runs. The client does supply a positive baseVersion as an optimistic-concurrency precondition. The server reads the live OSM element, verifies that version, and owns the outbound XML and published version.

The server's tag-policy.ts converts semantic operations into tags. Preview and publish call the same pure function; publish also requires the previewed element version to remain current.

Tag ownership​

Each field owns a bounded set of keys:

FieldOwned keys
Namename only; excludes name:<lang>, alt_name, and official_name
Categorythe concrete identifying tags of one iD preset
Addressonly addr:* keys already present on that exact element
Opening hoursopening_hours
Phone / email / websitewhichever of x / contact:x already exists
Accessibilitywheelchair

When both alias keys exist, the field is disabled rather than merged. When no address exists, one cannot be introduced.

After the mutation, assertOnlyOwnedKeysChanged() compares the result against the base and throws if any key outside the computed ownership set differs. This runtime check prevents a future field from silently changing unrelated keys.

Preset policy​

@openmapx/presets' editor index is deliberately separate from the search index, so changing an editor safety rule can never move a place-search result. A category is editable only when the match is a unique concrete preset. Ties, wildcard-only matches (shop=*), deprecated presets and lifecycle states (disused:, demolished:, …) all disable the field.

Way geometry is inferred conservatively: a closed way is not evidence of an area. Only explicit area=yes/area=no or an unambiguous geometry-neutral preset match decides; anything else is unknown, which disables category editing while leaving scalar fields available.

Full-element preservation​

OSM's update endpoint requires a complete representation, so a missing <nd> or <member> silently destroys geometry. OsmWritableElement therefore requires the geometry/member properties at compile time, and osm-xml.ts builds output with fast-xml-parser, which handles escaping consistently. Unknown tags, localized names, node references, relation members and roles all survive verbatim.

Exact versioning and the changeset lifecycle​

publish() runs in a fixed order, and the service tests assert the call order:

  1. validate the request;
  2. check both feature flags and the live account state;
  3. acquire the submission guard / idempotency record;
  4. fetch the current element;
  5. require current.version === baseVersion exactly;
  6. apply the policy and verify the complete result;
  7. create the changeset;
  8. update exactly one element;
  9. close the changeset in finally;
  10. store the sanitized outcome and release the guard.

Everything that can fail validation happens before a changeset exists. Closing is the only safely repeatable mutation here, and only when the changeset is confirmed still open. Record an unconfirmed close as an operational signal; it must not turn a confirmed element update into a client-visible failure.

Timeout reconciliation​

A lost response is never retried blindly. If an update may have been transmitted, the service re-reads the element and the known changeset:

  • matching changeset id, incremented version and the exact expected tags → success;
  • byte-identical unchanged base → safe failure, retryable;
  • anything else → AMBIGUOUS_RESULT with trusted inspection links and no retry.

If changeset creation itself times out without returning an id, there is nothing to address: no update is sent, creation is not retried, and the unknown empty changeset is left for OSM to expire.

Token handling​

Sign-in keeps the minimal openid read_prefs scopes. write_api and write_notes are requested incrementally, only when someone starts contributing.

Provider tokens are encrypted at rest (account.encryptOAuthTokens). Resolve the usable token through Better Auth's public server API; do not read its encrypted database column directly. Permission is read from OSM's own /permissions endpoint, never from the locally stored scope string, so a revoked authorization cannot look effective.

warning

Better Auth's generic-OAuth link route uses body.scopes || configuredScopes. A supplied array replaces the provider defaults; the sign-in route concatenates scopes instead. Since one provider token is stored per account, an authorization request must always carry base identity plus the intended action scope plus any contribution scope already in effect. Sending only the missing scope silently revokes the other one.

Idempotency and rate limits​

Two records per submission: a short lock keyed by user + element + operation, and a longer idempotency record keyed by user + operation + the client's UUID. Both keys are HMAC-SHA-256 digests under a server-only secret, and stored values hold only public result ids, trusted URLs and timestamps.

Redis is used when configured, with a bounded in-memory fallback. The exact OSM element version remains the cross-instance data-safety boundary even if a local lock or idempotency record is unavailable.

Per-user limits default to 60 reads, 30 previews, 10 publishes and 5 notes per 10 minutes, each tunable via RATE_LIMIT_OSM_CONTRIBUTION_*.

Telemetry is content-free​

Two instruments only:

osm_contribution_operations_total{operation,outcome}
osm_contribution_operation_duration_ms{operation,outcome}

Both labels are closed enums. No user or account id, element type or id, field, preset, locale, evidence, source, changeset or note id, token, URL, request id or response body may ever become a label or a log argument. Logs carry the operation, outcome, duration, and a randomized request ID only.

Feature flags​

  • OSM_CONTRIBUTIONS_ENABLED: master flag. Controls discovery: the public /api/capabilities response exposes only whether this and OAuth configuration are on, so signed-out clients can hide unreleased UI without revealing anyone's linked-account state.
  • OSM_DIRECT_EDITING_ENABLED: independent kill switch for element writes. Turning it off does not convert a requested edit into a note.

Both default to false. Every mutation checks the validated process configuration, so a cached UI cannot bypass the server guard. Configuration is cached by getOsmConfig(); after changing environment values, recreate/restart the API with the updated configuration.

Adding a field later​

A new editable fact cannot be added in the browser alone. Every field needs all of the following before it ships:

  1. the authoritative OSM tag/preset semantics, written down;
  2. the exact owned keys, and the alias rule when more than one key exists;
  3. which geometries it applies to;
  4. what deletion means for it, and whether deletion is safe at all;
  5. visible, non-lossy validation with tested boundaries;
  6. its appearance in the exact tag diff;
  7. preservation tests proving unrelated tags and structures are untouched;
  8. UI copy, translations, and any legal/privacy copy it affects;
  9. verification against the OSM development API.

Incident response​

If an ambiguous result, a preservation bug, a content leak, an unexpected OSM community complaint or repeated upstream write failures appear, set OSM_DIRECT_EDITING_ENABLED=false immediately. Disable the master flag as well if account gating or notes are implicated. These are environment settings cached at process startup/first use. Apply the changed values and recreate/restart app-api; editing an environment file alone does not update a running process. Confirm the effective capability and mutation response after restart.

This is not an automated edit process​

Every changeset here is initiated by a person, for one element, with their own comment and stated source. If future behavior ever becomes automated or batched, stop and follow the automated edits code of conduct before implementing it.

Seek review from experienced OpenStreetMap contributors before broad rollout, especially for category transitions and evidence wording. Record actionable feedback in tracked issues; keep it out of telemetry.