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:
| Field | Owned keys |
|---|---|
| Name | name only; excludes name:<lang>, alt_name, and official_name |
| Category | the concrete identifying tags of one iD preset |
| Address | only addr:* keys already present on that exact element |
| Opening hours | opening_hours |
| Phone / email / website | whichever of x / contact:x already exists |
| Accessibility | wheelchair |
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:
- validate the request;
- check both feature flags and the live account state;
- acquire the submission guard / idempotency record;
- fetch the current element;
- require
current.version === baseVersionexactly; - apply the policy and verify the complete result;
- create the changeset;
- update exactly one element;
- close the changeset in
finally; - 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_RESULTwith 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.
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/capabilitiesresponse 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:
- the authoritative OSM tag/preset semantics, written down;
- the exact owned keys, and the alias rule when more than one key exists;
- which geometries it applies to;
- what deletion means for it, and whether deletion is safe at all;
- visible, non-lossy validation with tested boundaries;
- its appearance in the exact tag diff;
- preservation tests proving unrelated tags and structures are untouched;
- UI copy, translations, and any legal/privacy copy it affects;
- 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.