Skip to main content

Integration system

An integration adds an application feature through a manifest and, for trusted built-ins, provider or UI code. Geocoding, routing, transit, map overlays, photos, reviews and weather use this system.

Use this reference when declaring a manifest, choosing a provider contract or working with the host lifecycle. For a new built-in, follow Writing an integration. For a separately published declarative artifact or companion service, follow Building an external extension.

How it works explains the service, integration and capability model. Integrations & bindings covers operator configuration, secrets and service selection.

The main implementation boundaries are:

BoundaryCurrent source
Portable manifest validationpackages/extension-contracts/src/integration.ts
Trusted runtime context and exportspackages/integration-framework/src/context.ts and src/index.ts
Discovery, setup, activation and retirementapps/api/src/integration-host.ts
Dynamic route dispatchapps/api/src/integration-routes.ts
Built-in web-host APIapps/web/src/integration-api/

Anatomy of an integration​

An integration is a directory. Built-in integrations live under integrations/ and run directly from workspace TypeScript source; community integrations live under custom_integrations/ and contain declarative metadata and safe static assets (see Built-in versus community). The directory can contain these entry files:

  • manifest.json: declarative metadata. Required. This is what the host discovers and validates; an integration with only a manifest and no code is a valid (if inert) integration.
  • index.ts: the trusted built-in runtime entry point. Optional and not permitted in an installable community artifact. It exports a setup(ctx) function that the host invokes once at load time, passing the IntegrationContext. This is where an integration registers providers, routes, and lifecycle hooks.

Most integrations also ship strings/<locale>.json localization files and, when they have a frontend, a map overlay: the MapLibre layer component, plus optional legend and panel components. An overlay's legend can be declared in the manifest instead of shipped as code. See Map overlays.

A backend entry point wires its implementation into the context:

import type { IntegrationContext } from "@openmapx/integration-framework";

export function setup(ctx: IntegrationContext): void {
ctx.registerWeatherProvider(openMeteoProvider);
}

The manifest​

The portable Zod schema lives in packages/extension-contracts/src/integration.ts and is re-exported by packages/integration-framework/src/manifest.ts. validateManifest adds semantic checks beyond the schema shape. The fields below are the ones that matter for understanding the system; the schema is the source of truth for the exhaustive list.

Identity and classification​

{
id: string; // lowercase, hyphen-separated — /^[a-z0-9][a-z0-9-]*$/
name?: string; // display name; falls back to id
version?: string;
description?: string;
author?: string;
license?: string; // SPDX id of the integration's own code
documentation?: string;
platform?: string; // minimum platform version, "major.minor"
quality?: "built-in" | "community-verified" | "community";
}

The id names the integration's directory, its config cache namespace, its environment-variable prefix, and its API route prefix (/api/integrations/<id>/...). It is constrained to a safe slug because it also names files, URLs, and generated build-time identifiers.

Domains​

{
domains: string[]; // required, at least one
}

domains is the integration's most important declaration. A domain is the feature area an integration participates in: routing, geocoding, transit, weather, data-source, and so on. Domains are the join key between integrations and the orchestrators that consume them: a domain orchestrator asks the host for enabled integrations in its domain. Each orchestrator defines its own selection policy, such as first-success fallback or parallel merging. The field is a free-form string array, so one integration can serve several domains, and frontend-only conventions (for example map-overlay, which has no backend registrar) are expressed the same way. The domains that carry a typed provider contract are enumerated in Domains and provider contracts.

Wiring: dependencies and requirements​

{
dependencies?: string[]; // other integration ids that must load first
requires?: Array<{ service?: string; capability?: string; optional?: boolean }>;
}

dependencies controls load order: the host topologically sorts integrations so a dependency is set up before its dependents. requires declares the backend services the integration needs. Each entry sets exactly one of service (a specific service slug) or capability (any service that provides that capability), and may be marked optional. How these resolve at load time is described in Capability requirement resolution.

Frontend and backend surfaces​

{
frontend?: {
mapLayer?: boolean; // ships a code map-layer component
sharedMapLayer?: string; // sibling providers mount one common layer/legend
legend?: boolean; // ships a code legend component
panel?: boolean; // ships a code side-panel component
searchCategory?: { id: string; label?: string; showInChipBar?: boolean; iconPath?: string };
layerSelector?: {
group: "map-details" | "map-tools" | "map-types";
labelKey: string;
icon?: string;
preview?: string | null; // integration-root-relative SVG, e.g. "preview.svg"
quickSelector?: boolean; // include in the desktop quick selector
};
overlay?: {
excludes?: string[]; // overlay ids this one is mutually exclusive with
minZoom?: number;
// Declarative legend — the host renders it, so no legend.tsx is needed
// (see "Map overlays" below):
legend?: {
kind: "categorical" | "ramp";
title?: string; titleKey?: string;
items?: Array<{ color: string; label?: string; labelKey?: string }>; // categorical
stops?: Array<{ value: number; color: string }>; // ramp
};
};
};
backend?: {
routes?: boolean; // registers routes via ctx.registerRoute
cron?: string | null; // declaration; the host does not schedule it
};
}

mapLayer / legend / panel advertise trusted built-in code components. Installable community manifests that set those flags are rejected until a cross-origin sandbox and capability protocol exist. searchCategory and layerSelector are pure declarations the host renders (a search-bar category chip; a layer-selector entry). overlay carries mutual-exclusion rules (excludes), minZoom, and an optional declarative legend the host draws from the manifest, which an overlay can use instead of shipping a legend.tsx. backend.routes signals that the integration registers HTTP routes; backend.cron is a declaration only; the current API host has no cron scheduler. A built-in that starts a timer owns its cleanup through onShutdown. How an overlay renders is detailed in Map overlays.

Several enabled providers can intentionally share one overlay, as Mapillary and Panoramax do for street-level-imagery. Every participant must declare the same nonempty frontend.sharedMapLayer key. The rendering hosts mount the shared layer and legend once, the layer selector exposes one toggle, and the overlay registry creates one entry using the first enabled participant's overlay metadata in registry order. Participants should declare the same overlay rules and selector metadata and render all contributing providers through the common component. Each provider captures its own supplying-source receipts at acquisition; the shared renderer projects only the receipts for content it actually displays. Two frontend owners resolving to the same overlay without a matching shared-layer declaration remain an error.

When layerSelector.preview is set, it names an SVG file relative to the integration root, such as preview.svg or assets/layer-preview.svg. The host serves the file and renders it as an external decorative image; it does not execute preview code or insert the SVG markup into the page. Preview assets must be SVG files no larger than 64 KiB. Remote URLs, data URLs, inline SVG values, absolute paths, and paths that escape the integration directory are not accepted. If preview is omitted or null, or if the image cannot be loaded at runtime, the layer selector uses its generic placeholder.

infrastructure optionally declares dataRequirements: string[] and planetScale: boolean for administration information. These fields do not resolve a service or fetch a dataset; use requires for service wiring.

Config schema​

{
configSchema?: Record<string, unknown>; // JSON-Schema-shaped
}

configSchema is a JSON-Schema-like object describing the integration's settings. The admin panel renders it as a form, and the host uses it to know which keys exist, their defaults, and which are secrets. A property marked "x-openmapx-secret": true is a credential: it is never shown in the config form or in API responses and is set through the Credentials tab instead. The config cascade resolves defaults, database values, vault secrets, config.json and environment overrides in that order. See the admin page. An integration that declares any secret field will refuse to load at startup unless the vault key (OPENMAPX_SECRETS_KEY) is present, so a missing key is a hard boot error rather than a silent degrade.

Personal-data storage declarations​

Built-in integrations also declare subjectData. The portable schema makes this field optional, but pnpm check-subject-data requires it for repository integrations. For an integration that stores no personal data:

{
"subjectData": {
"storesPersonalData": false,
"strategy": "not_personal",
"registrationIds": [],
"operatorInstructions": null
}
}

Storage requiring automated collection uses strategy: "collector" and actual privacy-catalogue registration IDs. Operator-managed handling uses strategy: "operator_task" and bounded instructions. A declaration does not register a collector or grant database access. Transmitting a query upstream still needs source/privacy disclosures even when the integration stores none.

Health checks​

{
healthCheck?: HealthCheck | HealthCheck[];
}

interface HealthCheck {
name?: string;
type: "http" | "ping" | "tcp" | "custom";
url?: string;
urlTemplate?: string; // ${configKey} placeholders
headers?: Record<string, string>;
requiredConfigKeys?: string[]; // unconfigured if any is unset
category?: string;
}

A healthCheck makes the integration probeable. urlTemplate and headers interpolate ${configKey} placeholders resolved through the config cascade, so a probe can carry an API key without hard-coding it. If a key listed in requiredConfigKeys is unset, the probe reports unconfigured rather than attempting a request without the required configuration. An array of checks rolls up into one signal: all must pass. The states and how they surface are described on the admin page.

Data sources​

{
dataSources?: Array<{
sourceId: string; // stable, globally unique
domain?: string; // one of `domains`; defaults to the first
name: string;
url: string;
licensing: Licensing;
attribution?: string;
providerCountry: string; // ISO country code
providerPrivacyUrl: string;
owner?: string;
termsUrl?: string;
methodologyUrl?: string;
dataUseClass?: "open" | "attribution" | "share-alike" | "non-commercial" | "restricted" | "unknown";
credentialOwner?: "none" | "instance-operator" | "openmapx-project" | "end-user";
reviewedAt?: string; // ISO date, YYYY-MM-DD
commercialUse?: "yes" | "no" | "conditional" | "unknown";
endUserExposure?: "direct" | "mixed" | "proxied" | "server-only" | "build-time";
personalData?: boolean;
cookies?: boolean;
dpaAvailable?: boolean;
/* … */
}>;
}

dataSources is one entry per upstream API or dataset the integration touches. It is the single home for credit and privacy metadata: the legal pages (/privacy, /terms) and the per-result attribution chips are generated from these entries, and the legal-table policy check fails if any generated table cell would be empty when pnpm check-legal-tables runs. The sourceId is globally unique across all integrations, which lets attribution be resolved by source no matter which integration declared it. dataSourceToAttribution() and the framework's contributor helpers convert declared metadata into canonical receipts. Capture that metadata when acquiring the data; the current catalogue is not evidence that every listed source supplied a result. Runtime integrations supply metadata with setDataSources (see Runtime data sources); per-record receipts also retain exact runtime-feed and required notice metadata.

Use the shared licence metadata contract: scoped subjects, registered SPDX expressions, captured custom definitions and explicit unknown or per-item states. The host derives labels and links; data sources do not provide scalar display labels. Preserve publisher notices and actual account conditions separately from data grants.

An integration in several domains whose sources serve different ones sets domain on each source that does not belong to its first domain. The source is then listed under that domain's section of the legal pages. A map overlay credits only the sources that supplied its rendered records. The built-in OpenConditions integration sets it on every source it supplies at runtime (see below), so its fuel feeds are not credited on the traffic overlays.

Source IDs connect manifests to results

A provider resolves each result's exact sourceId against metadata captured before acquisition and retains the resulting receipt with the record. Rendering uses that receipt, never today's manifest entry. Keep sourceId values stable so new acquisitions and source/privacy disclosures identify the same supplier.

Captured contributor contract​

Canonical Attribution is defined in @openmapx/mobility-core/attribution:

interface Attribution {
sourceId: string;
name: string;
url?: string;
licensing?: Licensing;
attributionText?: string;
publisher?: { name: string; url?: string };
retrievedAt?: string;
notes?: string;
}

A record carries contributors?: readonly Attribution[]. A supplied array, including [], is authoritative. Only undefined means unavailable captured provenance. parseContributors(unknown) accepts a validated array or its JSON encoding and returns a copied snapshot; malformed input returns undefined, while "[]" returns []. Unknown provenance stays unknown. A missing exact source/feed identity must never expand to every source in an integration or domain, or be guessed from an ID prefix. A proven uniform single-supplier acquisition can stamp that supplier at its adapter boundary.

Use snapshotContributors, withContributors, contributorsForRecords, and withFeatureContributors from @openmapx/integration-framework in built-ins. The same portable helpers are exported from @openmapx/mobility-core/contributors. They copy metadata, including publisher objects, and preserve distinct notices/URLs/licenses from the same sourceId. Do not deduplicate by source ID alone. Keep mandatory redistribution notices with the supplying dataset. Resolve a catalogue child's explicit canonical parent credit identity when the producer contract establishes that relationship; never infer the parent from a name or remove its notice.

Capture metadata before the first await for acquisition, then stamp returned records before caching, validation, deduplication, truncation, or publication. For known IDs use resolveIntegrationContributors(ctx, sourceIds); when a backend/feed is identified only by the response, capture a resolution catalogue with captureIntegrationContributorCatalogue(ctx, domain) before awaiting, then use resolveCapturedContributors(catalogue, integrationId, evidence). That catalogue is a lookup, never a display union. Its single-declared-supplier compatibility option requires a proven uniform adapter. Registry reloads and new responses must not rewrite old cached record or tile generations.

Road-condition events follow the same contract. Their trusted provider acquisition captures exact source or explicit parent receipts before awaiting; paginated OpenConditions reads capture each page independently. Source allowlisting and routing rights remain separate, live policy checks. GeoJSON, the core API reader and derived navigation alerts carry the receipts, including native []. Rendering and locale/style republishing use those stored receipts only. Historical events without them may use their recorded attribution as bounded compatibility; a source ID alone cannot reconstruct a publisher or today's required notice.

Reviews and review aggregates carry their own contributors. The shared captureReviewProviderContributors wrapper snapshots declared metadata before each read, resolves the returned exact source (or the proven provider ID), and clones results before caller caches. Supplied receipts, including [], remain authoritative; an item's content licence is not replaced by service metadata. Review orchestration applies this wrapper before API/UI consumers receive the results. Deduplication and display union only retained reviews or the displayed aggregate; disabled providers or catalogue changes cannot relabel cached content. Cache namespaces must distinguish captured results from older receipt-less data.

Compute result-envelope credits from the final retained records. A discarded duplicate contributes nothing; an actually merged field or external identity keeps its supplier's receipt. Clone cached bases before applying enrichment or realtime deltas. DTOs, validation, offline copies, GeoJSON and MVT must retain these snapshots. Legacy attributions fields remain at named compatibility boundaries; normalize their format with legacyAttributionsToContributors, rather than treating their presence as permission to overwrite contributors.

Mixed records also carry fieldContributors, keyed by displayed fields such as name, externalIds.wikidata, or a structured container. Use contributorsForRecordFields(record, displayedFields) to project only fields actually displayed. With no field map it uses the record receipt as a legacy boundary; with a field map, a missing field stays unknown. An explicit container key certifies its child tree, and the nearest child entry overrides it, including []. captureFieldContributors(record, field, targetField?) copies the entire winning container tree when moving or replacing a field; copying just the parent would lose authoritative empty children. Facts/photos keep item receipts and filtered views union only represented items. A Commons photo also needs its own author, license and file-page identity; a Wikimedia publisher receipt cannot substitute for that per-file metadata. Recover missing metadata or defer that file while keeping valid cached receipts.

Acquisition boundaries inside providers own their receipts too: a wrapper cannot stamp today's catalogue onto an already cached hotel offer, tide prediction or observation. Independently refreshed fields retain their own fieldContributors. Availability receipts remain separate from quote receipts. Derived values, such as detours or animated positions, retain the receipts of their actual inputs; prepare animation ownership once per update rather than recalculating it in frame callbacks. Marker, label and popup channels project their own fields.

OpenConditions expanded readings carry a bounded captured provenance credit snapshot, independently of the feature and current source catalogue. Their contributors source IDs identify fusion participants for filtering; they are not historical credit evidence. Preserve distinct captured snapshots from the same source ID and use the selected reading's capture for its displayed values.

Saved places and labels use the focused @openmapx/core/saved-place-contributors contract: bounded name, address, lat and lng field receipts persist through storage, offline mirroring, sharing and GPX/KML/GeoJSON export and import. User edits clear ownership only for values that actually change. Static files retain complete notices and links alongside portable metadata; their credits must remain meaningful without a running OpenMapX instance.

Contributor metadata is data, not executable presentation or permission. Host renderers use the shared credit HTML builders/sanitizer; names are text, only allowed markup and HTTP(S) links survive. Parsing a receipt establishes its shape, not HTML safety or a new source's privacy/rights clearance. Static upstreams still require manifest source/privacy metadata and source-ID-keyed localized disclosures. A producer's selected display lineage must not replace its broader rights/audit dependency set.

Reviewed placement of current credits​

Use the shared attribution placement contract. Producer-captured per-binding source/product policies override reviewed exact licence defaults. The host renders permanent notices and one-action full details uniformly on map, content and image surfaces; do not infer placement from source names or hide mandatory notices using maxVisible. Preserve actual authors, publishers and API branding with each record/view.

Readable credit details​

The host keeps structured captured identities and licence bindings alongside the sanitized complete notice. projectAttributionPresentations groups equivalent source identities without discarding distinct destinations, supplied notices or grants. Its details projection feeds the shared credits dialog: source headings, content licences, provider notices, and separate software/service rows. Placement policy reasons and acquisition diagnostics are not display credit copy. Never reconstruct this view by concatenating source names and all binding subjects.

For file metadata, keep source/acquisition descriptions separate from required permission, copyright and restriction notices. Commons Credit supplies PlacePhoto.sourceDescription; explicit Attribution still replaces the default artist/source credit. A repeated UsageTerms label may be omitted only when its canonical grant matches; every supplied source/rights link is retained. Captured receipts preserve the original source evidence. The photo UI may omit optional public-domain acquisition prose, while explicit required attribution, custom credits and unresolved file requirements remain available in their required presentation. Compact image credits link creator, file and licence rather than printing raw archive URLs.

Runtime data sources​

Some integrations front an upstream whose feeds change without an OpenMapX release. Such an integration sets "runtimeDataSources": true in its manifest, leaves dataSources out, and supplies the list from code:

export async function setup(ctx: IntegrationContext) {
const accepted = ctx.setDataSources(await loadSourcesFromUpstream(ctx));
serveOnly(accepted);
}

Manifest validation rejects the flag next to a non-empty static dataSources list. setDataSources throws for an integration without the flag. Each source in the list is validated like a static entry, with validateDataSource from @openmapx/integration-framework: the required fields must be present and domain must be one of the manifest's domains. The host drops an invalid source, a repeated sourceId, or a sourceId another live integration already declares, and logs a warning that names it (and, for the last, both integrations). It keeps the rest.

setDataSources returns the accepted list. A dropped source is neither credited nor gated by the data-use policy, so the integration serves records only of the sources in the returned list, never of the list it passed. The mock context of @openmapx/integration-framework/testing applies the same rules within one integration.

The accepted list replaces manifest.dataSources. From then on:

  • GET /api/integrations serves it, so legal catalogues show it within one registry poll. Existing displayed/cached results keep their captured receipts.
  • The attribution index is rebuilt from the live registry for future acquisition snapshots; it must not rewrite earlier record or tile receipts.
  • The data-use policy is re-evaluated. Its rules match on commercialUse, so a source the operator's policy excludes is excluded as soon as it appears.

Call setDataSources again whenever the upstream list changes. A reload re-reads the manifest from disk, so each new generation starts with an empty list and must supply its sources again. The retired generation's list is never served or indexed.

The legal tables need a purpose and the data sent for every source. A runtime integration can't key these strings by sourceId, so it provides one entry per domain under dataSources in strings/<locale>.json. The keys have the form domain:<domain>:

{
"dataSources": {
"domain:road-conditions": { "purpose": "…", "dataSent": "…" },
"domain:fuel-stations": { "purpose": "…", "dataSent": "…" }
}
}

pnpm check-legal-tables requires a complete entry and a legal section heading for every declared domain. It rejects any other key. The other manifest gates (check-feed-ids, check-data-flows, check-credential-keys, check-legal-urls) name a runtime integration in their output instead of reading its sources. Because the hosts it contacts are not declared, check-data-flows holds its code to the allowlist and checks its media hosts as server-only. check-credential-keys fails a credential-keyed integration that sets the flag.

The built-in openconditions integration is a runtime integration. Its sources are the feeds of the OpenConditions instance at OPENCONDITIONS_URL, which the instance lists at GET /sources. The integration reads that list during setup() and every five minutes after, and maps each entry to a data source:

  • the feed id becomes the sourceId;
  • the feed's homepage becomes the credit link (url);
  • the licence, attribution, terms and privacy URL carry over;
  • the feed's rights become commercialUse and redistribution (a granted redistribution right of a share-alike feed is conditional); its terms note becomes a usage condition, and a share-alike feed gets one more, stating that a database derived from it must be published under the same licence;
  • providerCountry is the feed's country, or EU / INT for a European or international feed;
  • every source is server-only, since OpenMapX reads it through OpenConditions.

OpenConditions roads feeds are credited under road-conditions, fuel feeds under fuel-stations, parking feeds under parking-sites, charging feeds under charging-sites, cameras feeds under cameras, hazards feeds under hazards, weather feeds under road-weather, vehicles feeds under service-vehicles, and border feeds under border-crossings; the roads domain's message-sign feeds are credited under road-conditions too. Feeds of other OpenConditions domains are left out. OpenConditions lists restricted feeds too, since the list is metadata, not records. Each /sources answer names the scope it was served in, and an answer without one is malformed (the last good list stays). In the public scope, which never serves a restricted feed's records, the integration lists only the unrestricted feeds as data sources; in the operator scope it lists every feed. The scope is OpenConditions' own: a token set in OpenMapX but not held by the instance still reads in public scope and credits no restricted feed. The instance's own community reports are no feed: the answer states their credit beside the feeds as crowd (name, licence and an optional link), and a fuel, parking, charging or camera reading they contributed to credits them with it. While the answer states no crowd credit, such a reading is left out, as is any reading with a contributor that is not on the list. The providers serve only the sources the host accepted (see above). A record placed with a third-party location table (location.locationTable) also credits that table under the record's feed id; see Location tables.

Its domain:<domain> strings describe what each domain's sources receive in general terms, without naming a source, since the instance decides its feeds. Revisit them whenever OpenConditions adds on-demand sources to a domain: the road-conditions string states that none fetch on demand today.

setup() waits up to three seconds for the first list before it registers the providers, so credits and legal pages are complete once the providers serve. If the instance is unreachable, the providers still register. The list follows once a read succeeds; a failed read is retried after 30 seconds and keeps the last good list. The outage is logged once, and so is the recovery.

The providers fail closed. Until the first list arrives, the data-use policy has nothing to gate, so they serve nothing:

  • a road display or flow read is empty;
  • a routing read rejects, as it does when OpenConditions is down;
  • a fuel search returns no stations and is marked partial: "unavailable". The data-source route never caches a partial answer, and the client asks again a few times in the next seconds. Zooming in would not help, so the fuel list does not suggest it; it does for partial: "area", which the provider sends when OpenConditions reports incomplete coverage or the read stops at its station cap.

After the first list, a feed record whose source is not listed is left out. This also holds for a feed a later refresh drops. Crowd and federated road reports are not catalogue feeds and pass. Fuel credits that a record carries without a link take the homepage from the live list.

The integration context​

When the host calls setup(ctx), it passes an IntegrationContext: the integration's entire interface to the platform. The integration never imports the API server, the database driver, or Redis directly; it uses the context, which the host backs with concrete implementations. The structural definition lives in packages/integration-framework/src/context.ts.

Identity and configuration​

interface IntegrationContext {
readonly id: string;
readonly manifest: IntegrationManifest; // the parsed manifest
readonly config: Record<string, unknown>; // resolved config (cascade applied)
// …
}

config arrives fully resolved: every layer of the cascade has already been applied, so the integration reads plain values and does not concern itself with where they came from.

Services available to integration code​

interface IntegrationContext {
readonly http: HttpClient; // fetch wrapper with optional Redis caching
readonly cache: CacheClient; // namespaced KV (int:<id>:<key>) with withCache()
readonly db?: DatabaseClient; // only when the manifest requires postgis
readonly log: Logger; // tagged structured logger
readonly secrets: SecretsClient; // decrypted vault access
// …
}
  • http is a small client over fetch that can transparently cache GET responses in Redis when the call opts in with a TTL.
  • cache is a key-value store namespaced per integration (int:<id>:<key>), with a withCache(key, ttl, fn) read-through helper. Namespacing means one integration cannot collide with another's keys. Use withCache for provider acquisition: the host captures an invalidation epoch before the read/loader and atomically refuses a fill whose epoch changed. Direct set captures its invocation epoch, so it cannot identify a provider request that began earlier. HTTP GET caching uses the same acquisition fence.
  • db is present only when the manifest requires the postgis service; otherwise it is undefined.
  • log is a structured logger tagged with the integration id.
  • secrets retrieves a decrypted credential from the vault.

Shared and local caches​

Redis owns shared results, distributed refresh leases and quota counters. A process-local LRU is only a disposable copy or a bounded lookup optimization; it does not coordinate workers or enforce upstream quotas.

cache.onInvalidate?.(listener) is the host administration notification port. Registration immediately reports { available: boolean } and returns an unsubscribe function. Clear local copies and advance their fill generation on every event. While unavailable, bypass local reads and writes. Unsubscribe on shutdown, and prevent an old pending response from repopulating retired state. Clients without host administration, such as CLI/test fixtures, may omit this capability.

Admin cache clears notify affected integration scopes before and after Redis SCAN/UNLINK deletion, including when Redis is not configured. A targeted glob conservatively flushes the whole overlapping integration's local copies; Redis deletion retains its exact scope. Redis Pub/Sub propagates notifications across API workers asynchronously. A disconnect or heartbeat timeout clears and disables local copies; subscription acknowledgement clears them again before reuse. This is not a linearizable cross-worker read barrier: notifications are asynchronous and Pub/Sub can lose messages during a connection failure.

A shared epoch and renewable clear leases under openmapx:cache-control: fence old fills and writes during concurrent clears. These control keys are outside the disposable int:/cache: prefixes. New callers use the current epoch in their single-flight identity, so they do not join pre-clear work. Redis control operations have a three-second deadline. A failed epoch capture skips the shared write; a failed clear control operation fails the clear request. These fences apply to host HTTP/cache acquisition, not the separate quota runtime's policy.

For additional derived cache entries, managed hosts offer const writer = await ctx.cache.captureWriter?.() before acquisition. Use writer?.set(key, value, ttlSeconds) after the upstream await: it retains the original administrative invalidation fence. A direct ctx.cache.set captures its fence only when that write starts. Do not recapture after acquisition if the initial capture fails; hosts without this capability keep derived entries local. The writer fences the captured Redis invalidation epoch; callers must also guard local state/lifetime through onInvalidate and disposal. It does not create a linearizable cross-worker read barrier. Shared cache reads must retain the acquisition's original receipts and expiry.

The server uses lru-cache for four stores. Limits are entry budgets, not heap byte guarantees. Reverse-city and reverse-country stores cache successful negative results; transport failures are not cached. Reads update eviction recency without extending the fixed TTL.

StoreMaximum entriesFreshness
Mobility reverse-city4,09624 hours
Geocoding reverse-country4,09624 hours
GBFS resolved coverage10,0005 minutes
Geocoding autocomplete L11,000Soft: 5 minutes; hard: 2 hours

Autocomplete serves soft-stale values while refreshing in the background and evicts them at the hard deadline. Its L1 belongs to one integration setup. Geocoding shared keys hash the current configuration, provider declarations and resolved service bindings, without exposing credential text. The host captures immutable resolved bindings for each loaded integration generation; those internal snapshots are not exposed by runtime metadata. Country lookups capture the Nominatim endpoint, and GBFS coverage keys include both system ID and discovery URL.

A second tier of services is optional: present in production and sometimes absent in tests and CLI scripts. Consumers must define whether absence is safe for their upstream policy:

interface IntegrationContext {
readonly attributionIndex?: AttributionIndexHandle; // resolve sourceIds + MOTIS feeds
readonly providerHealth?: ProviderHealthHandle; // record latency/outcome; cooldowns
readonly metricsRecorder?: MetricsRecorder; // OpenTelemetry per-call counters
readonly upstreamRuntime?: UpstreamRuntime; // distributed cache/lease/quota
readonly cursorCodec?: OpaqueCursorCodec; // signed, purpose-scoped cursors
// …
}

These let an orchestrator skip a provider that is in a failure cooldown, record per-call latency and outcome, and resolve attribution rows without the framework taking a compile-time dependency on the API server (they are declared as structural interfaces and injected by the host).

upstreamRuntime is the production primitive for quota-bound acquisition. Its cache returns fresh, stale, stale-if-error, or miss; a refresh lease is owner-released; and all named quota windows are checked and consumed atomically. Do not replace it with process-local maps or counters in production. Redis failure must deny a new quota-consuming refresh, although eligible stale data may still be returned with its original evidence time.

Shared request admission​

upstreamRuntime.admitSharedRequest?({ scope, key, policy?, signal? }) is a separate opt-in capability for upstream identities shared across integrations and replicas. scope and key are nonempty strings supplied by the consumer; the host hashes them into upstream-shared: keys outside integration cache scopes. Choose the identity from the upstream's actual quota boundary. Do not include integration IDs, result receipts, bounding boxes or reload generations when those do not change that boundary. Result-cache identity may need those values even though admission identity must omit them.

A numeric policy contains id, limit, durationMs and minIntervalMs. Admission atomically checks a shared cooldown and the policy's counter/spacing using Redis time. The window starts on its first admitted request; each policy ID has its own counter and spacing, while cooldown is shared by the whole scope/key. Allowed decisions return remaining (or null without a numeric policy); denials identify spacing, quota, cooldown or store_unavailable and return retryAfterMs where known. Calling without policy checks cooldown without inventing numeric quota counters.

deferSharedRequests?({ scope, key, delayMs, signal? }) extends a shared cooldown without shortening an existing one. Both methods support caller cancellation and bound each store operation to three seconds. A dispatched Redis command cannot be recalled: cancellation/timeout may leave a conservative reservation or cooldown, but its late result must not admit the retired caller or publish data. Admin cache clears target int:* / cache:*, preserving shared admission keys. An explicit store wipe, including CLI cache clear --all (FLUSHDB), removes them.

This capability does not change existing read/write, lease or consumeQuota consumer policies. Consumers decide whether spacing can wait, whether failures may use eligible stale evidence, and whether an absent runtime is allowed. A supplied but unavailable admission runtime must never silently become local quota enforcement. It cannot count traffic from external applications that reuse the same upstream client identity outside the shared store.

The Entur clients require both admission methods and working Redis for fresh Journey Planner requests. Their identity is upstream origin + client name + API family, with separate trip/other classes and family-wide cooldown. Other Entur APIs have no numeric policy: only an entirely absent runtime permits a bounded per-client observed cooldown fallback. The Entur runtime guide documents those limits and the separate national-situations cache contract.

Use http.getResponse() or http.postResponse() for JSON when status or allowlisted rate-limit headers matter, http.getBytes() for buffered binary payloads, and http.getText() / http.postText() for XML or other text. postText() sends its string unchanged; set the request Content-Type in headers. Unlike get() and post(), these methods return a non-success status instead of throwing for that status. JSON methods still require a valid JSON body. Text and streaming methods return bounded error bodies regardless of their media type, so callers can handle a plain-text 404 from a JSON endpoint. For getText(), postText() and getStream(), optional successStatuses narrows which statuses require the success contentTypes allowlist. It must be a nonempty list of integer 2xx statuses; omission preserves validation for all 2xx responses. For example, [200] lets a caller classify a headerless 204 or plain-text 201 before decoding. By default, all statuses retain the same byte ceiling and body deadline. For metadata-only error classification, discardErrorBody: true cancels unsuccessful bodies immediately and returns an empty body with the status and selected headers. This lets a caller honor Retry-After on a non-JSON, oversized or stalled HTTP 429 without consuming its body. Successful bodies retain all media, byte and deadline checks. JSON and byte methods ignore these two options and retain their existing media validation.

Every response method requires maxBytes and an exact contentTypes allowlist. fallbackContentType applies only when the upstream omits Content-Type, and must itself be allowlisted. The byte ceiling checks declared size and actual decoded response bytes, including error bodies unless explicitly discarded.

http.getStream() returns { status, headers, stream }, where stream is a pull-driven ReadableStream<Uint8Array>. Consume or cancel it; the host retains the byte ceiling and combined caller/timeout signal through the entire body. Consumer cancellation, timeouts and rejection of a response cancel its upstream reader. This allows archive downloads to stream to disk without buffering them.

Use redirect: "error" to reject redirects or redirect: { allowedHosts: ["downloads.example.org"], follow203: true } to follow explicitly trusted targets. Host entries accept exact hostnames or *.example.org suffixes. follow203 opts into APIs that use 203 with Location; maxRedirects optionally lowers the default limit of eight. Every redirect is checked, and the original timeout covers the whole chain and body. Standard authorization, cookie and proxy-authorization headers are removed permanently after a cross-origin hop; same-origin redirects retain them. Trusted redirects reject HTTPS-to-HTTP downgrades and URL credentials before contacting the target. Omitting redirect retains native fetch redirect behavior. Integration-local raw fetch is not an escape hatch for provider work.

Route queries arrive exactly as Fastify parsed them: each value is a string, an array for a repeated key, or undefined. Use scalarQuery/scalarQueries and reject repeated scalar keys. Never coerce an array with String(...) or select one value silently.

Opaque cursors are HMAC-signed and purpose-scoped. Bind their encoded value to the normalized query/provider context and give it a finite expiry; decoding a cursor under another route purpose must fail.

Registering capabilities​

The context exposes one typed registrar per provider contract. Each pushes the provider onto the integration's per-domain provider list, where the matching orchestrator picks it up at request time:

interface IntegrationContext {
registerTransitProvider(p: TransitProvider): void; // → "transit"
registerRealtimeProvider(p: RealtimeProvider): void; // → "live-transit"
registerMobilityDataSource(p: MobilityDataSourceProvider): void; // → "data-source"
registerWeatherProvider(p: WeatherProvider): void; // → "weather"
registerAirQualityProvider(p: AirQualityProvider): void; // → "air-quality"
registerGeocodingProvider(p: GeocodingProvider): void; // → "geocoding"
registerRoutingProvider(p: RoutingProvider): void; // → "routing"
registerRideProvider(p: RideProvider): void; // → "ride-hailing"
registerPhotoProvider(p: PhotoProvider): void; // → "photos"
registerStreetLevelProvider(p: StreetLevelProvider): void; // → "street-level-imagery"
registerReviewProvider(p: ReviewProvider): void; // → "reviews"
registerPoiSearchProvider(p: PoiSearchProvider): void; // → "poi-search"
registerSearchSuggestionProvider(p: SearchSuggestionProvider): void; // → "search-suggestions"
registerKnowledgeProvider(p: KnowledgeProvider): void; // → "knowledge"
registerGtfsCatalogProvider(p: GtfsCatalogProvider): void; // → "gtfs-catalog"
registerRoadConditionsProvider(p: RoadConditionsProvider): void; // → "road-conditions"
registerFuelStationProvider(p: FuelStationProvider): void; // → "fuel-stations"
registerParkingSiteProvider(p: ParkingSiteProvider): void; // → "parking-sites"
registerChargingSiteProvider(p: ChargingSiteProvider): void; // → "charging-sites"
registerCameraProvider(p: CameraProvider): void; // → "cameras"
registerMessageSignProvider(p: MessageSignProvider): void; // → "message-signs"
registerRoadWeatherProvider(p: RoadWeatherProvider): void; // → "road-weather"
registerServiceVehicleProvider(p: ServiceVehicleProvider): void; // → "service-vehicles"
registerBorderCrossingProvider(p: BorderCrossingProvider): void; // → "border-crossings"
registerHazardsProvider(p: HazardsProvider): void; // → "hazards"

registerRoute(method, path, handler, options?): void; // options.rateLimitTier
registerHealthCheck(fn: CustomHealthCheckFn): void; // overrides manifest probe
registerDisclosure(d: Disclosure): void; // surfaces a capability note
setDataSources(list: IntegrationDataSource[]): IntegrationDataSource[]; // runtimeDataSources only; returns the accepted list
}

The typed registrars enforce the contract shape at compile time, so the orchestrator can dispatch on a declared capability rather than reflecting over the object. registerRoute mounts a route under the integration's prefix; passing { requireAuth: true } makes the host reject unauthenticated callers with 401 before the handler runs. Every route consumes exactly one host limiter bucket: rateLimitTier defaults to public, upstream-heavy point/list routes use expensive, and tile fan-out uses tile. Do not add integration paths to the global URL-pattern limiter.

Provider health accepts only closed outcomes. Timeouts, connection failures, upstream 5xx responses, authentication failures, and invalid payloads affect the circuit; valid empty results, policy/input rejection, quota denial, and caller cancellation remain observable without penalizing the provider. Keep bounded operator detail separate from the outcome code. Air-quality metrics likewise accept only closed method, outcome, cache, evidence-class, rejection, quota, and compatibility fields; user strings and location identifiers are not labels.

Lifecycle, events, and discovery​

interface IntegrationContext {
emit(event: string, data: unknown): void;
on(event: string, handler: (data: unknown) => void): () => void;
onActivate(activate: () => void, rollback?: () => void): void;
onShutdown(cleanup: () => Promise<void>): void;
getIntegrationsByDomain(domain: string): LoadedIntegration[];

getRequiredService(key: string): { serviceId: string; url: string; enabled: boolean } | null;
getDisallowedSourceIds?(): Promise<Set<string>>;
getDisallowedIntegrationIds?(): Promise<Set<string>>;
}

emit/on are a lightweight event bus shared across integrations. onActivate publishes a synchronous process-wide mutation when the generation activates. During reload, the host defers it until staging succeeds; a rollback callback restores the old state if activation fails. Use it for shared setters or registries that staging must not change early. Keep ordinary provider state local to setup where possible.

onShutdown registers cleanup run when a generation retires or is discarded. Register cleanup as resources are created. Abort pending requests, cancel timers and unsubscribe listeners; prevent retired requests from publishing into new state. getIntegrationsByDomain lets an orchestrator enumerate its peers. getRequiredService(key) is how an integration reads a resolved requirement at runtime (see below). The two getDisallowed* resolvers expose the operator's data-use policy: orchestrators skip providers whose source or whole integration the policy disallows, and fall back to the next.

Domains and provider contracts​

For the data-rich domains, the framework defines TypeScript provider contracts in packages/integration-framework/src/contracts/. A provider implements the interface; the domain orchestrator consumes the typed shape. The contracts in the mobility domains return their data wrapped in a MobilityResult<T>, so attribution and freshness flow through every call unmodified.

DomainContractWhat providers in it do
geocodingGeocodingProviderForward geocode, autocomplete, reverse geocode.
ride-hailingRideProviderAvailability, fares and booking handoffs; driving geometry comes from routing.
air-qualityAirQualityProviderCurrent, forecast, station and raster evidence; see Air-quality integrations.
search-suggestionsSearchSuggestionProviderUnified autocomplete contributions.
street-level-imageryStreetLevelProviderImage search, links, coverage and nearby street-level imagery.
routingRoutingProviderTurn-by-turn directions, plus optional isochrones and map-matching.
transitTransitProviderStops, departures/arrivals, routes, trip planning, vehicle positions, alerts.
live-transitRealtimeProviderRealtime overlays: vehicle positions, service alerts, trip-update deltas.
data-sourceMobilityDataSourceProviderMap data sources: bike/car/scooter sharing, and the user-facing merger over the charging-sites, parking-sites, fuel-stations and cameras providers.
weatherWeatherProviderCurrent conditions, hourly and daily forecasts.
photosPhotoProviderPlace imagery, including fast OSM-tag-based hero lookups.
reviewsReviewProviderFetch, aggregate, and submit place reviews.
poi-searchPoiSearchProviderCategory and free-text POI search within a bounding box.
knowledgeKnowledgeProviderPlace enrichment from reference sources.
gtfs-catalogGtfsCatalogProviderList GTFS feeds for the schedule-feed importer to ingest.
road-conditionsRoadConditionsProviderLive road/traffic conditions: incidents, roadworks, closures, and per-segment congestion speeds.
charging-sitesChargingSiteProviderEV charging sites with charge-point status and tariffs, merged by the ev-charging data source.
parking-sitesParkingSiteProviderParking facilities with live occupancy, merged by the parking data source.
fuel-stationsFuelStationProviderFuel stations with per-grade prices, merged by the fuel data source.
camerasCameraProviderTraffic and scenic cameras with their views, current stills and streams, merged by the webcam data source.
message-signsMessageSignProviderDynamic message signs and the pages each shows, merged by the message-signs data source.
road-weatherRoadWeatherProviderRoad weather stations with the surface and air values they measure, merged by the road-weather data source.
service-vehiclesServiceVehicleProviderWhere road service vehicles are and the work they report, merged by the road-maintenance data source.
border-crossingsBorderCrossingProviderLand border crossings with each queue's wait in each direction, merged by the border-crossings data source.
hazardsHazardsProviderWeather alerts, natural hazards, fire detections and their density grid, read by the four hazard overlays through createHazardsOrchestrator.

The contracts share these conventions:

  • Capability self-description. TransitProvider and RealtimeProvider carry a capabilities object declaring which optional methods they actually implement, plus coverage (a bounding box or { all: true }) and a numeric priority (lower wins). The orchestrator reads these to route a request to the right provider instead of probing methods at runtime.
  • Optional methods. Most contract methods are optional; a provider implements only what it supports, and the capability object (or simple presence) tells the orchestrator what is available.
  • Attribution on the provider. Mobility contracts expose attribution directly, and transit additionally offers getFeedAttribution() for integrations that front many feeds, each with its own license.

Domains without a typed contract, such as map-overlay, still appear in domains. They describe UI surfaces rather than a registered provider object.

Continue with Data-source integrations, Transit integrations or Ride integrations for the domain-specific rules.

Map overlays​

A trusted built-in integration that draws on the map (domains includes map-overlay) ships a map-layer.tsx. Its legend is the one part it can declare instead of writing.

map-overlay is the internal rendering classification for such integrations; it does not by itself create a user control. A user-selectable overlay also declares frontend.layerSelector and frontend.overlay, which register overlay state and its ov= URL token. An integration that declares only frontend.mapLayer is ambient map content: the host mounts it while it is enabled, it owns its own lifecycle and credits, and it has no selector, legend, overlay store or URL state. overlay-ambient-places is the built-in example.

Declarative legends (no shipped legend code)​

frontend.overlay.legend describes the legend in the manifest and a generic host renderer draws it, so an overlay needs no legend.tsx. Declare a kind of categorical (discrete items, each a color plus a label or labelKey) or ramp (a value→color gradient given as stops), with an optional title or titleKey. The host escapes every value it renders.

frontend.overlay also carries excludes (sibling overlays that switch off when this one switches on) and minZoom.

Map layers (shipped components)​

For trusted built-ins the layer itself is a shipped component: map-layer.tsx, with the frontend.mapLayer flag set (and frontend.legend plus a legend.tsx when the declarative legend above isn't expressive enough). Built-in overlays use the web host's focused integration API instead of importing arbitrary application modules:

import { useMemo } from "react";
import { EMPTY_FEATURE_COLLECTION } from "@/integration-api/map/mapLayerGroup";
import { useMapLayerGroup } from "@/integration-api/map/useMapLayerGroup";

export default function MapLayer() {
const visible = useMyOverlayStore((s) => s.layerVisible);
useMapLayerGroup(
useMemo(
() =>
visible
? {
sources: { "my-source": { type: "geojson", data: EMPTY_FEATURE_COLLECTION } },
layers: [
{ id: "my-circles", type: "circle", source: "my-source", slot: "overlay-points" },
],
}
: null,
[visible],
),
);
return null;
}

A style change (theme, basemap or satellite switch) destroys every source and layer an overlay added, so installation is a reconciliation, not a one-off call. Choose the narrowest lifecycle primitive:

  • useMapLayerGroup from @/integration-api/map/useMapLayerGroup declares the sources and slotted layers as data. It reconciles on every render, rebuilds inside the style change, applies a changed layer spec, and tears everything down when the descriptor is null. Put GeoJSON data in the descriptor, or declare EMPTY_FEATURE_COLLECTION and publish features through useGeoJsonSourceDataBridge, which replays the latest payload onto a recreated source. Its stable identity keeps a re-memoized descriptor from resetting published data.
  • useStyleSyncedLayer covers one source and one layer whose source cannot be declared as data, such as an attributed raster acquired through addAttributedRasterSource.
  • subscribeStyleLoaded from @/integration-api/map/styleLoadedSync runs an imperative reconciliation now and after every style mutation. Use it for placement relative to basemap layers, custom layers or per-frame updates. The callback runs often and must be cheap and idempotent: adding a source, layer or image emits another styledata. Return its unsubscribe from the effect, and remove what it added with removeSlottedLayers.

Installation needs a parsed stylesheet, not loaded tiles. Do not gate it, or asynchronous image completion, on isStyleLoaded() or an idle event: an unrelated pending source or continuous animation would leave the overlay missing. Fence asynchronous completions by the current effect, map and source identity. Layer-scoped event listeners tolerate a layer that does not exist yet, so bind interactions to the overlay's visibility rather than to installation.

The modules under apps/web/src/integration-api/ are an app-local API for trusted integrations compiled into the OpenMapX web build. They group map, overlay, runtime, and reusable component capabilities behind focused import paths. They are not a package, a remotely consumable extension SDK, or a same-origin script interface. Production frontend sources under integrations/ may import only these web-host paths; a boundary test rejects private web imports, path escapes, broad barrels, and dynamic host entry points.

Crediting an overlay's sources​

The map layer owns the credits for content actually rendered; a legend's visibility never controls that ownership. Trusted built-ins import the focused host APIs:

import { useRenderedMapAttributions } from "@/integration-api/overlay/useRenderedMapAttributions";
import { useRasterMapAttributions } from "@/integration-api/overlay/useRasterMapAttributions";
import { useMarkerMapAttributions } from "@/integration-api/overlay/useMarkerMapAttributions";

withFeatureContributors(feature, captured) writes the immutable receipt as JSON in properties._mapAttributions for GeoJSON/MVT. Register the actual layer IDs with useRenderedMapAttributions(key, layerIds, visible, options?). The shared per-map scheduler refreshes after viewport/filter/zoom/source and placement changes on a shared 250 ms interval. Ordinary overlays use one union viewport query per scheduled pass. Native basemap receipts are published separately on installation/style events and never add viewport queries. It preserves visible old receipts during refresh/worker lag and does not wait for unrelated sources to become idle. Each hook instance owns its registration, even if readable keys are reused.

options.layerContributors is a captured per-layer lookup only for proven uniform supplier layers. A bounded resolveFeatureContributors(feature) can project captured field ownership for the feature's rendered band. undefined falls through to the exact serialized feature receipt, then uniform-layer receipt; explicit [] stops fallback. Never resolve heterogeneous records from a whole query response. For example, FLOW casing owns geometry, while a color band owns only the fields used for its color/opacity; popup fields are projected separately.

Visibility follows evaluated paint and actual rendered channels: stroke-only circles, visible labels without icons, and overlapping heatmap kernels can contribute. Independently placed icon/text channels require separate query layers. Layer min/max zoom governs disappearance; a source's maximum zoom still permits overzoom. Partial alpha gradients require split queryable geometry or a trusted complementaryGradientGroups certificate with the same geometry and receipt and complete alpha coverage. Threshold heatmap palettes need a host adapter that proves the painted pixels.

The tracker groups queried hits by layer and memoizes validated presentations by complete captured content, including notices, timestamps and placement policies. It still observes render/idle changes for late symbol placement and transitions. For a trusted built-in resolveFeatureContributors callback, options.featureContributorKey(feature) can identify identical projection inputs within one layer and refresh. Include every input that can change the returned receipts, including explicit absence; never key only by provider/source ID. Omit this optimization when equivalence cannot be proven. Keys and resolver results are not reused across owners, layers, refreshes or option updates, and every hit still passes its evaluated paint visibility checks before grouping. Remote records or community payloads cannot supply these callbacks.

Dense traffic-flow tiles deliberately use conservative tile coverage, rather than per-road viewport hit testing. The trusted host factory createVectorTileCoverageContributorResolver in @/integration-api/overlay/vectorTileCoverageContributors reads captured field receipts from currently renderable, nonempty uploaded vector-tile buckets. Pass its result through options.resolveTileCoverageContributors and leave those layers out of the exact query layer list. This includes suppliers from offscreen portions of edge tiles (and potentially filtered/occluded features); it is an explicit overcredit tradeoff, never evidence of an exact viewport hit. Incidents and ordinary overlays keep exact rendered attribution. Do not opt other layers into this scope without deciding that extra tile suppliers are acceptable.

Create the resolver once with immutable options. Its equivalence key must cover all field-projection inputs for every configured layer, including full captured receipts and explicit absence. It scans properties once per raw payload/encoding, then reuses immutable projected snapshots through style reparsing, overzoom and refresh failures that retain drawable data. Errored/unloaded tiles that the renderer stops drawing contribute nothing. Successful replacement, source replacement, tile eviction, layer visibility/zoom and evaluated constant zero paint update the current union. No source catalog, provider manifest or successful-request history substitutes for retained tile receipts. The host owns the version-pinned MapLibre bridge; community payloads cannot configure executable resolvers. FLOW popup credits remain projected separately from the clicked feature's actual displayed fields.

DOM markers use useMarkerMapAttributions(key, element, captured), which checks painted marker visibility in the map container. Custom GPU/DOM drawing may use options.resolveCustomContributors, a trusted host callback proving the currently painted content and returning its captured receipts. It must not be a toggle, allocation counter, or response-wide union. Neither callbacks nor gradient certificates may come from community payloads.

For raster content, use useRasterMapAttributions(key, descriptors, visible). Each descriptor contains { layerId, sourceId, contributors } for the actual mounted supplier, captured before its acquisition. Add the source with the focused addAttributedRasterSource helper before adding its layer, and call updateRasterAcquisitionContributors before changing tiles/URLs for future acquisitions. Both live in @/integration-api/overlay/rasterMapContribution. Opacity-zero preload frames, missing-key suppliers and failed first loads owe no active-frame credit; retained successful tiles and fading parents keep their original receipt during partial failures. The hook combines only content actually sampled by the raster renderer, including old and new suppliers during a fade.

The host adapter wraps MapLibre's public RasterTileSource.loadTile acquisition entry point and observes the texture/loaded commit for each invocation. It also uses MapLibre 6.13's retained tile coordinates and evaluated raster paint to prove visible content. This engine coupling belongs in the host adapter, not in a provider. A MapLibre upgrade needs real-engine tests for initial load/failure, concurrent completion order and texture reuse, silent expiry/reload, partial failure, eviction/pan, fading parents, overzoom and style reload. A source settled event or a successful request in the past cannot prove present pixels.

Native basemap receipts bind to the installed style/layer/source generation; pending or failed style acquisition keeps the retained generation's receipts. Source-object identity preserves the original supplier when a style diff retains cached content. Style-only backgrounds never establish a data supplier. The host conservatively credits the union of installed native receipts, including hidden, out-of-zoom, offscreen or raster-covered layers. It updates on installation and style events, without native feature queries, repaint requests, evaluated-paint exposure analysis, raster coverage clipping, mesh readback or tile-domain queries. Basemap crediting therefore does not depend on projection, terrain, placement, alpha or the current viewport. Overlay and incident record-level contribution filtering remains independent and uses the rendered-content APIs above.

A MapLibre upgrade must still verify installed style/layer/source identity and style-load event ordering, including retained content during pending or failed style changes. Raster overlays retain their separate texture/fade adapter and its real-engine validation requirements described above.

useMapAttributions remains a lower-level owner for already projected visible popup/custom credits, not a geometry visibility detector. Capture exact suppliers before acquisition and project the displayed record/field receipts; do not register credits from toggles, whole manifests/domains or live registry lookups. A static wiring guard checks actual focused host calls/imports and excludes test-only mentions; behavioral provider/rendering tests prove the supplied receipts and visible projection.

Community presentation boundary​

Community integration artifacts are declarative-only. The installer rejects map-layer.tsx, legend.tsx, panel.tsx, and prebuilt frontend bundles; app-api does not serve a bundle route; and the web provider never appends community scripts. This is deliberate: a same-origin script has the signed-in user's full authority, and CSP cannot sandbox code the application intentionally executes. The app-local integration API does not alter this boundary and is unavailable to installed extensions. Community presentation JavaScript remains prohibited. Executable community presentation will remain disabled until it can run on a separate origin (or equivalently isolated worker) behind a narrow, versioned message/capability API. Static SVG previews and host-rendered manifest fields remain supported.

Lifecycle: discovery, loading, and hosting​

The runtime owner of integrations is the integration host inside the API server (apps/api/src/integration-host.ts). It runs the full lifecycle at boot, and a reduced version on reload.

1. Discovery​

The host scans each configured integration directory (integrations/ for built-ins, custom_integrations/ for community installs), reads every manifest.json, and skips directories that begin with _ or have no manifest. Discovery runs no integration code. An integration that later fails during setup is still known to the host by ID.

2. Validation and ordering​

Each manifest is validated. The required invariants are minimal: an id, at least one domain, and a healthCheck whenever the integration declares a required (non-optional) service dependency. Invalid manifests are skipped with a warning rather than aborting the boot. Surviving integrations are then topologically sorted by dependencies so each loads after the integrations it depends on; a dependency cycle, or a dependency that is not installed, drops the affected integration with a warning.

Before any integration loads, the host also performs a fail-fast check: if any discovered manifest declares secret config fields but OPENMAPX_SECRETS_KEY is unset, boot aborts with a clear remediation message rather than failing later on a user request.

3. Per-integration setup​

For each integration in dependency order the host:

  1. resolves config through the cascade and emits advisory warnings for missing required keys or out-of-enum values (never blocking);
  2. builds the per-integration clients (logger, HTTP, cache, and a db handle only when the manifest requires postgis);
  3. resolves the requires entries against the service registry and capability bindings (see below);
  4. assembles the IntegrationContext and, if the integration has a backend entry point, imports it and awaits setup(ctx).

A disabled integration (its enabled config resolves to false) is recorded but never set up. If setup(ctx) throws, the host marks the integration disabled, emits an error event, and continues: one broken integration does not take down the rest.

4. Serving​

The host then exposes the integration surface to the rest of the API:

  • GET /api/integrations lists the enabled integrations and their metadata, alongside the framework's shared localized strings.
  • Integration-defined routes are served through a single mini-router (/api/integrations/:id/*). Fastify cannot register routes at runtime, so the host catches the wildcard and dispatches to the matching registerRoute handler. This allows reload without registering new Fastify routes.
  • Health checks are seeded a few seconds after boot and refreshed on an interval.

Reload versus restart​

In development, POST /api/integrations/reload re-runs discovery and setup. The host builds a detached generation, then synchronously swaps its registry, dynamic routes, POI registrations, and event subscriptions before retiring the old generation. A staging/setup failure leaves the serving generation untouched. Failures after activation require inspecting the serving state; reload is not a transaction across the database, host configuration and every follow-up index. Config and binding changes therefore take effect without exposing a partially rebuilt host. Trusted built-in backend imports are cache-busted by the entry file mtime and size. Changes only in transitive modules can remain in the ESM cache; restart the API when those changes are not reflected.

Capability requirement resolution​

Each requires entry is resolved once at load time and exposed to the integration through ctx.getRequiredService(key). The resolution rules:

  • { service: "<id>" } is satisfied when that specific service is installed and enabled. The lookup key is the service id.
  • { capability: "<cap>" } with exactly one installed provider auto-selects it. With multiple providers it is ambiguous and stays unresolved until an administrator picks a binding; the host logs a warning meanwhile. The lookup key is the capability name.
  • { optional: true } that goes unresolved falls through silently. The integration chooses whether to use a declared public fallback, omit that capability or report unavailability. A fallback must retain source-policy and privacy controls.

Inside setup, an integration reads the resolved target and applies its own fallback:

export function setup(ctx: IntegrationContext) {
const engine = ctx.getRequiredService("routing-engine");
const baseUrl = engine?.url ?? (ctx.config.baseUrl as string) ?? PUBLIC_FALLBACK;
// … register a provider that calls baseUrl
}

A resolved entry returns { serviceId, url, enabled }, where url is the service's address on the private Docker network. Whether a capability requirement with several candidates resolves at all depends on the administrator's binding: the stored (integration, capability) → service mapping chosen in the admin panel. The host loads all bindings once at startup and re-reads them on reload. The full binding workflow is documented on the Integrations & bindings page.

Built-in versus community​

The host treats the two install locations differently, and the isBuiltIn flag on a loaded integration drives the distinction:

  • Built-in integrations ship in the repository under integrations/. They run directly from workspace TypeScript source. The host imports their index.ts; no separate integration build is needed. They carry quality: "built-in".
  • Community integrations are installed under custom_integrations/. Their manifests and safe static assets are discovered, but backend and frontend JavaScript is never imported by app-api: that process holds database credentials and application secrets and is not a sandbox. Host and Docker operations are separately owned by the operations agent. The installer rejects such entry points. Backend behavior belongs in a separately containerized service component; executable presentation remains disabled until a cross-origin sandbox exists. A community integration may declare a minimum platform version in its manifest; if the running platform's major version differs, or its minor is lower, the host skips the integration with a warning. The platform version uses simple major.minor semantics: the major is bumped for breaking protocol changes, the minor for additive ones. This protocol version is independent of product, npm package and extension release versions. The portable @openmapx/extension-contracts package supplies the canonical authoring types, validators and structural JSON Schemas; it does not expose host services or grant runtime privileges. See the external extension guide for standalone tooling and the current publication status.

Installing and managing community integrations is an administrator task; see Community extensions.