This document specifies **STA Server**, a clean-room implementation of the
[[OGC-STA-1|OGC SensorThings API Part 1: Sensing v1.1]] packaged as a
Laravel 12 Composer library, together with two normative extensions adopted
for the [[GEONOVUM-TESTBED|Geonovum Testbed Sensordata 2025]]: a **Device-Management
extension** (the *RQ3* / OpenCitySense data model — DeviceModels, Configurations,
DeviceSecrets and the controlling-connector relationship) and a **minimal Tasking
subset** of [[OGC-STA-2]]. It defines the implementation profile of the SensorThings
core that a conforming deployment exposes, the data model and HTTP interface of the
extensions, the task notification mechanism over MQTT, the encrypted-secret handling,
and the authentication and authorization model. It is the normative contract against
which the STA Connector and the STA Portal are built.
This is a living technical specification maintained in the `sta-server`
repository. It tracks the implemented behaviour of the package on its default
branch; the source code is the ultimate reference, and this document is updated
as part of the same change set whenever normative behaviour changes.
Feedback and change proposals are handled through GitHub issues and pull requests
on the repository. There is no external standards body governing this document;
the SensorThings core behaviour it profiles is governed by the OGC.
## Terminology
This section defines the terms used throughout this document. Terms from
[[OGC-STA-1]] (Thing, Location, Sensor, ObservedProperty, Datastream, Observation,
FeatureOfInterest, HistoricalLocation) retain their meaning from that specification.
- host application: the Laravel 12 application into which the STA
Server package is installed. It provides the database connection, the
authentication middleware and the authorization gate abilities.
- connector: an on-premises agent, modelled as a [=Thing=] with `type`
equal to `connector`, that ingests observations and executes [=task=]s on behalf of
devices it controls. Specified separately in the *STA Connector — Technical
Specification*.
- device: a [=Thing=] with `type` equal to `device` that represents a
managed sensor asset and MAY be linked to a controlling [=connector=].
- Thing: a SensorThings Thing as defined in [[OGC-STA-1]], extended by this
specification with a `type` discriminator and a controlling-connector relationship.
- device model: a reusable template describing a class of [=device=]s,
including the sensors it carries and the JSON schema of its configuration.
- configuration: a versioned record of the operating parameters applied to
a [=device=], validated against its [=device model=]'s configuration schema.
- device secret: a credential belonging to a [=device=], stored only as
ciphertext sealed to a [=connector=]'s public key and never decryptable by the
server.
- task: a unit of work submitted to a [=connector=] through a
[=tasking capability=], carrying an `action` and a target.
- tasking capability: a declaration, attached to a [=connector=] [=Thing=],
of the [=task=] actions it accepts.
## Core Sensing Profile {#core-profile}
This section defines the profile of [[OGC-STA-1]] that a server in the **Core Sensing
Profile** conformance class exposes. For all behaviour not constrained here,
[[OGC-STA-1]] is normative.
### Service root and entity sets
A conforming server MUST expose the SensorThings service-root capabilities document at
the configured prefix (`GET /v1.1`), listing the entity-set URLs. The server MUST
implement the following eight core entity sets: `Things`, `Locations`,
`HistoricalLocations`, `Sensors`, `ObservedProperties`, `Datastreams`, `Observations`
and `FeaturesOfInterest`.
The capabilities document MUST list at least the eight core entity sets. It MAY omit
extension entity sets (`DeviceModels`, `Configurations`, `DeviceSecrets`,
`TaskingCapabilities`, `Tasks`) from the `value` array even when the corresponding
extension classes are implemented; clients MUST NOT treat the absence of an extension
entity set from the capabilities document as evidence that the extension is
unavailable, and SHOULD detect extension support by probing the entity set.
### Resource addressing
The server MUST use the OGC parentheses grammar for entity addressing — `Things(1)`,
not `Things/1`. Identifiers are 64-bit integers. Nested navigation paths
(`Things(1)/Datastreams`, `Datastreams(1)/Observations`, and the inverse singletons)
MUST be supported for every relationship advertised in an entity's navigation links: a
server MUST NOT advertise a navigation link it does not route.
### Representation
Collection responses MUST include `@iot.count` when `$count=true` is requested and a
`value` array. Every entity representation MUST include `@iot.id` and `@iot.selfLink`.
Related entities MUST be advertised through `@iot.navigationLink` entries unless
inlined by `$expand`.
### Query options
A conforming server MUST support the following system query options on every
collection endpoint, with the semantics of [[OGC-STA-1]] §9.3 (aligned with
[[ODATA-4]]):
| Option | Requirement |
| --- | --- |
| `$filter` | MUST support comparison (`eq`, `ne`, `lt`, `le`, `gt`, `ge`), logical (`and`, `or`, `not`), and the string functions `startswith`, `endswith`, `contains`. |
| `$orderby` | MUST support one or more properties with `asc`/`desc`. |
| `$select` | MUST project the response to the named properties. |
| `$expand` | MUST inline related entities; nested `$expand` MUST be supported. |
| `$top` | MUST limit the result set. |
| `$skip` | MUST offset the result set. |
| `$count` | MUST, when `true`, include `@iot.count`. |
The server MUST apply a default page size and a maximum page size to `$top`. The
defaults are configurable; the default page size is `100` and the maximum is `10000`.
A request for `$top` greater than the configured maximum MUST be clamped to the
maximum rather than rejected. The server MUST enforce a maximum `$expand` nesting
depth (default `2`); a request exceeding it MUST be rejected with HTTP `400` and the
OGC error envelope ().
### Observations set
Because the Observations set is backed by a time-partitioned hypertable, a server MUST
keep the default ordering of an Observations collection deterministic. When no
`$orderby` is supplied the server SHOULD order by `phenomenonTime` descending. Servers
MAY auto-resolve a `FeatureOfInterest` for an Observation from the associated Thing's
Location when none is supplied, per [[OGC-STA-1]]; a server that does so MUST expose
the resolved feature through the `Observations({id})/FeatureOfInterest` singleton.
### Deviations from the base standard
A server in this profile MAY omit the following optional parts of [[OGC-STA-1]] without
losing conformance to this profile (but it then does not conform to the full OGC
standard): the `$expand` of more than the configured depth, server-driven paging via
`@iot.nextLink` beyond the configured maximum, and the `MultiDatastream` entity. A
server MUST document any such omission in its capabilities or accompanying
documentation.
## Authentication & authorization {#auth}
### Model
The server defines a pluggable authentication boundary. The [=host application=] MUST
contribute an authentication middleware (default `auth:sanctum`) and MUST register two
authorization gate abilities: `sta.read` and `sta.write`. A third ability,
`secrets.write`, MUST additionally gate mutations of [=device secret=]s
().
A conforming server MUST enforce, for every request other than the explicitly
unauthenticated routes below:
- a valid bearer credential, else respond `401`; and
- the gate ability appropriate to the route — `sta.read` for safe methods (`GET`),
`sta.write` for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) — else respond
`403` with the OGC error envelope.
### Tokens
The server issues bearer tokens through Laravel Sanctum personal-access-tokens. The
following authentication endpoints MUST be provided:
| Method & path | Auth | Description |
| --- | --- | --- |
| `POST /v1.1/auth/login` | none | Exchange `{ email, password }` for `{ token }`. |
| `POST /v1.1/auth/logout` | bearer | Revoke the current token. |
| `GET /v1.1/auth/me` | bearer | Return the authenticated user. |
| `GET /v1.1/auth/tokens` | bearer | List the caller's tokens. |
| `POST /v1.1/auth/tokens` | bearer | Create a named token, returning `{ token }`. |
| `DELETE /v1.1/auth/tokens/{id}` | bearer | Revoke a token by numeric id. |
`POST /v1.1/auth/login` MUST be reachable without an authentication context (it is the
bootstrap endpoint) and MUST NOT be subject to the gate. All other `auth/*` routes MUST
require a valid bearer token.
### Cookie bearer fallback
A server MAY accept the bearer token from a cookie when no `Authorization` header is
present, to support browser clients. When enabled, the cookie name is configurable
(default `access_token`). Servers SHOULD disable the cookie fallback in
server-to-server deployments.
## Device-Management Extension {#device-management}
This section is normative for the **Device-Management Extension** conformance class.
It realises the [[OPENCITYSENSE]] device-registration model. Naming follows
OpenCitySense one-to-one.
### Thing extensions {#thing-extensions}
A conforming server MUST extend the [=Thing=] entity with the following properties:
| Property | Type | Requirement |
| --- | --- | --- |
| `type` | string | MUST be one of `device`, `connector`, `other`. Defaults to `device`. The server MUST validate the value at the application layer. |
| `controlling_connector_id` | integer, nullable | MUST reference a [=Thing=] whose `type` is `connector`. MUST be settable only on a Thing whose `type` is `device`. On deletion of the referenced connector the server MUST restrict (reject) the delete while references exist. |
The Thing entity MUST additionally expose, as navigation paths:
- `Things({id})/Configurations` — collection of [=configuration=]s for the Thing.
- `Things({id})/DeviceSecrets` — collection of [=device secret=]s for the Thing.
- `Things({id})/TaskingCapabilities` — collection; valid only when `type=connector`.
- `Things({id})/ControlledDevices` — collection of devices a connector controls; valid
only when `type=connector`.
- `Things({id})/ControllingConnector` — singleton; the connector managing a device;
valid only when `type=device`.
`type` MUST be exposed as an OData-filterable property and MUST be expandable in the
five relationships above.
### DeviceModels {#device-models}
A [=device model=] is a template for a class of devices. The server MUST expose
a `DeviceModels` entity set with full CRUD and the following representation:
| Property | Type | Requirement |
| --- | --- | --- |
| `name` | string | MUST be present. |
| `description` | string, nullable | MAY be present. |
| `config_schema` | JSON object | JSON Schema for the `payload` of a [=configuration=] of this model. Defaults to `{}`. |
| `decoder_ref` | string, nullable | Opaque external reference (URI or name). The decoder entity itself is out of scope. |
| `properties` | JSON object | Free map (manufacturer, model number, firmware…). Defaults to `{}`. |
A DeviceModel MUST expose:
- `DeviceModels({id})/Sensors` — the sensor/observed-property templates bound to the
model through the `device_model_sensors` relationship. Each binding MUST carry a
`unit_of_measurement` (UCUM block) and MAY carry a `name_template` used to
materialise Datastream names during onboarding.
- `DeviceModels({id})/Configurations` — configuration templates for the model
(configurations with `status=template`).
A DeviceModel MAY declare compatibility with connector device models through a
self-referential `CompatibleConnectorModels` relationship; when present, the server
MUST expose it via `$expand`.
### Configurations {#configurations}
A [=configuration=] records the operating parameters applied to a device. The
server MUST expose a `Configurations` entity set with full CRUD and the following
representation:
| Property | Type | Requirement |
| --- | --- | --- |
| `thing_id` | integer, nullable | The device. MUST be null when `status=template`; MUST be present otherwise. |
| `device_model_id` | integer | MUST reference a [=device model=]. |
| `status` | string | MUST be one of `created`, `active`, `inactive`, `removed`, `template`. |
| `valid_from` | timestamp | When the configuration took effect. |
| `payload` | JSON object | MUST validate against the referenced device model's `config_schema`. |
| `schema_snapshot` | JSON object | A copy of `config_schema` at creation time, for audit. The server MUST populate it on create and MUST NOT mutate it thereafter. |
The server MUST enforce the application rule that **at most one** Configuration per
`thing_id` has `status=active`. A configuration with `status=template` MUST have a null
`thing_id`. The server MUST expose `Things({id})/Configurations` as the device-scoped
collection.
### DeviceSecrets {#device-secrets}
A [=device secret=] is a credential the server stores **only as ciphertext**.
The server MUST expose a `DeviceSecrets` entity set with the following representation:
| Property | Type | Requirement |
| --- | --- | --- |
| `thing_id` | integer | MUST reference the owning device. |
| `name` | string | E.g. `appkey`, `nwkkey`, `api_token`. Unique per `(thing_id, name)`. |
| `type` | string | E.g. `appkey`, `oauth_token`, `password`. |
| `value_encrypted` | string | Base64 of a libsodium `crypto_box_seal` ciphertext, sealed to the connector's public key ([[NACL]]). |
| `pubkey_fingerprint` | string | SHA-256 hex of the connector public key used to seal the value, for audit. |
The server MUST NOT be able to decrypt `value_encrypted`; it MUST NOT store, log or
return the plaintext. The following confidentiality rules are normative:
- A **collection** response (`GET /v1.1/DeviceSecrets`, `Things({id})/DeviceSecrets`)
MUST omit `value_encrypted`.
- A **single** entity response MUST omit `value_encrypted` unless the request carries
the explicit `include=ciphertext` query parameter, in which case the server MUST
return the ciphertext (never plaintext).
- Reads MUST require `sta.read`. Every mutation (`POST`, `PUT`, `PATCH`, `DELETE`) MUST
require both `sta.write` and `secrets.write`.
### Storage requirements {#dm-storage}
The extension entities MUST be persisted on the `pgsql_sta` connection. The server MUST
enforce the uniqueness constraints stated above (`(device_model_id, sensor_id,
observed_property_id)` for sensor bindings, `(thing_id, name)` for secrets) at the
database layer. Referential actions MUST follow: cascade on `thing_id`, restrict on
`device_model_id` and on `controlling_connector_id`.
## Tasking Subset {#tasking}
This section is normative for the **Tasking Subset** conformance class. It implements a
minimal subset of [[OGC-STA-2]]: the `TaskingCapability` and `Task` entities only. The
`Actuator` entity is out of scope; the action to perform is carried in
`taskingParameters.action`.
### TaskingCapabilities {#tasking-capabilities}
A [=tasking capability=] declares the task actions a connector accepts. The
server MUST expose a `TaskingCapabilities` entity set with full CRUD and:
| Property | Type | Requirement |
| --- | --- | --- |
| `name` | string | E.g. `connector-ttn-rotterdam.onboard`. |
| `description` | string, nullable | MAY be present. |
| `thing_id` | integer | MUST reference a [=Thing=] whose `type` is `connector`. |
| `tasking_parameters` | JSON object | A schema (SWE-DataRecord-like, free object) describing the `taskingParameters` a Task may carry. |
| `properties` | JSON object | Free map. |
The server MUST expose `TaskingCapabilities({id})/Tasks` and
`Things({id})/TaskingCapabilities`. A connector Thing typically declares one capability
supporting the actions `OnboardDevice`, `OffboardDevice`, `UpdateConfiguration` and
`RotateSecret`.
### Tasks {#tasks}
A [=task=] is a unit of work for a connector. The server MUST expose a `Tasks`
entity set with full CRUD and:
| Property | Type | Requirement |
| --- | --- | --- |
| `tasking_capability_id` | integer | MUST reference a [=tasking capability=]. |
| `creation_time` | timestamp | OGC `creationTime`; defaults to now. |
| `tasking_parameters` | JSON object | MUST contain `action` and `thingId`; MAY contain action-specific parameters. |
| `status` | string | MUST be one of `created`, `pending`, `running`, `completed`, `failed`. Defaults to `created`. |
| `result` | JSON object, nullable | On `completed`/`failed`, MUST carry a message or details. |
| `started_at` | timestamp, nullable | Set when a connector picks the task up. |
| `completed_at` | timestamp, nullable | Set on terminal status. |
The server itself MUST NOT execute tasks; execution is the connector's responsibility.
The server's role is to persist the task, emit the notification () and expose
the status for polling.
### Task notification over MQTT {#mqtt}
On `POST /v1.1/Tasks`, or on a `PATCH /v1.1/Tasks({id})` that transitions `status` to
`pending`, a conforming server MUST publish a notification to the MQTT topic:
```
v1.1/Tasks/{taskingCapabilityId}
```
The message MUST be JSON, with QoS 1 and the retain flag clear, and MUST carry at
least the task id, creation time, status and tasking parameters:
```json
{
"@iot.id": 123,
"creationTime": "2026-05-22T10:11:12Z",
"status": "pending",
"taskingParameters": { "action": "OnboardDevice", "thingId": 456 },
"taskingCapability@iot.navigationLink": "/v1.1/TaskingCapabilities(7)"
}
```
The topic prefix (`v1.1`) MUST be configurable. The broker connection (host, port,
TLS, credentials) MUST be configurable through the environment.
If no broker is configured or the broker is unreachable, the server MUST NOT fail the
write: it MUST complete the `POST`/`PATCH` and SHOULD log a warning. In that case a
connector falls back to polling `GET /v1.1/Tasks?$filter=status eq 'pending'`. A server
MUST therefore keep the queryable `status` field authoritative independently of whether
the notification was delivered.
## Management Interface {#management}
This section is normative for the **Management Interface** conformance class. These
endpoints are not part of SensorThings; they support operation and the portal.
### Health {#health}
`GET /v1.1/health` MUST return `200` with at least `{ "status", "db", "version" }` when
the server and its database are reachable. It MUST require the host authentication
context but MUST NOT require the `sta.read` gate, so a portal can probe liveness
without data access. It MUST verify database reachability before reporting `db: "ok"`.
### Statistics {#stats}
`GET /v1.1/stats` MUST return per-entity-set row counts for dashboards and MUST require
`sta.read`.
### Conformance runner {#conformance-runner}
The server MUST expose an HTTP wrapper around its OGC conformance test runner:
| Method & path | Gate | Description |
| --- | --- | --- |
| `POST /v1.1/conformance/runs` | `sta.write` | Start a run asynchronously; returns a run id (UUID). |
| `GET /v1.1/conformance/runs` | `sta.read` | List runs. |
| `GET /v1.1/conformance/runs/{id}` | `sta.read` | Run metadata by id. |
| `GET /v1.1/conformance/runs/{id}/report.html` | `sta.read` | HTML report. |
Runs are asynchronous; a host MUST run a queue worker for runs to complete.
### Server configuration {#server-config}
`GET /v1.1/config` (`sta.read`) MUST return the effective server configuration
(defaults plus runtime overrides). `POST /v1.1/config` (`sta.write`) MUST apply a
runtime override without requiring a redeploy. The server MUST persist overrides and
MUST apply them ahead of the static defaults.
### Connector proxy {#connector-proxy}
The server MUST provide a read-mostly proxy to an on-premises connector's `/admin/*`
management API so that the connector's admin token never reaches the browser. Routes
are grouped under `GET|POST|PUT|PATCH|DELETE /v1.1/connectors/{connectorId}/…`, where
`{connectorId}` is the connector [=Thing=]'s `name`.
The server MUST hold the connector's admin token server-side (in configuration) and MUST
inject it when forwarding — except for `/connectors/{connectorId}/info`, which the
connector serves ungated and which the server MUST forward without a bearer. Read
forwards MUST require `sta.read`; provisioning forwards that use unsafe verbs (source
create/replace/patch/delete and the lifecycle actions) MUST require `sta.write`. The
proxied surface is specified normatively by the *STA Connector — Technical
Specification*; this server MUST treat it as an opaque forward target and MUST NOT
reinterpret connector responses.
## Error handling {#errors}
All error responses MUST use the OGC error envelope:
```json
{ "code": 404, "description": "Thing(99) not found" }
```
A conforming server MUST use the following HTTP status codes with their stated meaning:
`200` (ok), `201` (created), `204` (deleted / preflight), `400` (malformed request or
query option), `401` (missing or invalid credential), `403` (insufficient ability),
`404` (unknown entity), `422` (validation failure), `500` (server error). The `code`
field in the envelope MUST equal the HTTP status code.
## CORS {#cors}
A conforming server MUST default to **no** CORS headers (server-to-server mode). When
the allowed-origins configuration is non-empty, the server MUST emit
`Access-Control-Allow-Origin` for matching origins and MUST answer `OPTIONS` preflight
requests with `204` and the appropriate CORS headers. The wildcard `*` MUST be
permitted only when credentialed requests are disabled.
## Security & privacy considerations {#security}
- **Secret confidentiality.** [=device secret=]s MUST be sealed to a connector public
key with `crypto_box_seal` ([[NACL]]) before they reach the server. The server MUST
hold only ciphertext and MUST follow the disclosure rules of . A
compromise of the server database MUST NOT reveal secret plaintext.
- **Token handling.** Bearer tokens MUST be transmitted only over TLS in production.
The cookie-bearer fallback () widens the browser attack surface and SHOULD be
disabled where not required.
- **Authorization granularity.** The split between `sta.read`, `sta.write` and the
additional `secrets.write` ability means a read-only or non-secret-bearing client can
be issued a token that cannot mutate secrets. Hosts SHOULD issue least-privilege
tokens.
- **Connector token isolation.** The connector proxy () exists so
that a connector's admin token is never exposed to the browser; implementations MUST
keep that token server-side.
- **MQTT exposure.** Task notifications are published to a broker; the payload carries
task metadata but MUST NOT carry secret plaintext. Brokers SHOULD require TLS and
authentication.
## References
Normative and informative references are listed below; ReSpec resolves RFC entries
automatically and the OGC, Geonovum and libsodium entries from this document's local
bibliography. Key normative references: [[OGC-STA-1]], [[OGC-STA-2]], [[RFC2119]],
[[RFC8174]], [[RFC3339]], [[NACL]]. Key informative references: [[GEONOVUM-TESTBED]],
[[OPENCITYSENSE]], [[FROST]] (the reference implementation), [[OM]], [[ODATA-4]].