Skip to content

PLATFORM CONVENTIONS

Quick reference for developers and AI agents. Everything here reflects how the platform actually works in production. If your code contradicts this document, your code has a bug.

Last verified: 2026-05-08 (NGSI-LD compliance hardening — unified inject_fiware_headers, URN entity IDs, SDM type allowlisting)


There is ONE auth mechanism: httpOnly cookie nkz_token.

Browser → POST /api/auth/session (body: {token}) → Set-Cookie: nkz_token (httpOnly, Secure, SameSite=Strict, domain=.robotika.cloud)
Browser → DELETE /api/auth/session → Clear cookie
LayerHow it gets the JWT
Frontend (host)Never reads the token directly. credentials: 'include' on every fetch.
Frontend (IIFE modules)Same — credentials: 'include'. The SDK handles it.
api-gatewayget_request_token(): reads Authorization: Bearer header first, falls back to nkz_token cookie.
Module backends (direct ingress)Must implement cookie fallback themselves. Pattern: check Bearer header → fall back to request.cookies.get("nkz_token"). See agrienergy/middleware/__init__.py.
  • Never store tokens in localStorage or sessionStorage.
  • Never pass tokens in query strings.
  • Never expose tokens via window.__nekazariAuthContext (it only has isAuthenticated, user, tenantId, roles — no token).
  • All fetch calls must use credentials: 'include'.

1b. IoT Device Provisioning (FIWARE Standard)

Section titled “1b. IoT Device Provisioning (FIWARE Standard)”

The platform follows the FIWARE IoT Agent JSON standard for connecting physical devices to the digital twin layer.

Device/Gateway → MQTT (Mosquitto) → IoT Agent JSON 3.13.0 → Orion-LD (NGSI-LD)
↑ ↑ ↓
/<tenant_apikey>/<device_id>/attrs NGSI-LD native Subscription (throttle 30s)
{"attr": value} + appendMode ↓
telemetry-worker → TimescaleDB
RuleDetail
One apikey per tenantget_or_create_service_group(tenant_id) in sdm_api.py retrieves/creates a tenant-level apikey. All devices in a tenant share it.
Topic format/<tenant_apikey>/<device_id>/attrs
Payload format{"attributeName": value} (FIWARE IoT Agent JSON standard)
IoT Agent modeNGSI-LD native (IOTA_CB_NGSI_VERSION=ld) + IOTA_APPEND_MODE=true + IOTA_EXPLICIT_ATTRS=false
IoT Agent version3.13.0 — uses IOTA_MONGO_URI (not USER/PASSWORD, driver 6.x bug)
Mosquitto ACLiot-agent user MUST have topic readwrite # or receives ZERO messages
Entity types with IoTAgriSensor, Sensor, Actuator, WeatherStation, AgriculturalTractor, LivestockAnimal, AgriculturalMachine
MQTT external endpointMQTT_EXTERNAL_HOST / MQTT_EXTERNAL_PORT in nekazari-config ConfigMap
CredentialsShown ONCE at creation. Cannot be recovered.
  • Generate per-device apikeys (causes MEASURES-004: Device not found)
  • Set IOTA_APPEND_MODE=false (causes entity does not have such attribute)
  • Use IOTA_MONGO_USER/IOTA_MONGO_PASSWORD (driver 6.x auth bug — use IOTA_MONGO_URI)
  • Mix v2 and LD entities in same tenant (type expansion conflict)
  • Hardcode MQTT_EXTERNAL_HOST — always use ConfigMap
  • Phase 1 (current): explicitAttrs=false — pragmatic for controlled DaTaK devices
  • Phase 2 (multi-tenant/third-party): migrate to explicitAttrs=true (Schema-First) for Relationship support, unitCode metadata, data contracts
  • Kafka: not needed until >2,000 devices. asyncpg pool + NGSI-LD throttle handles up to ~5,000 sensors
  • TROE+Mintaka: evaluate for Phase 2 datahub (standard ETSI temporal API, replaces custom worker)

1c. External API Access (PAT — Personal Access Tokens)

Section titled “1c. External API Access (PAT — Personal Access Tokens)”

External applications (PowerBI, Tableau, Python, custom apps) authenticate via Personal Access Tokens instead of browser cookies.

nkz_pat_<43 random chars>

Generated via secrets.token_urlsafe(32). SHA-256 hash stored in api_keys table; raw token shown only once at creation.

Each PAT has one or more scopes. The api-gateway enforces (HTTP method, path prefix) pairs:

ScopeAllowed routes
timeseriesGET/POST /api/timeseries/*
entitiesGET /ngsi-ld/v1/entities*, POST /ngsi-ld/v1/entityOperations/query
exportPOST /api/datahub/export, POST /api/datahub/timeseries/align
telemetryGET /api/devices/*, GET /api/sensors*

All scopes are read-only. PATs cannot create, update, or delete entities.

  • Entity queries via PAT: max 500 per page (default 100 if absent)
  • Export rows via PAT: max 10,000
  • Orion-LD Link header is forwarded transparently for pagination
  • Creation: POST /api/tenant/api-keys (auth: user JWT)
  • Listing: GET /api/tenant/api-keys (returns metadata including scopes, never the raw token)
  • Validation: POST /internal/validate-pat (auth: X-Internal-Secret, called by api-gateway)
  • Revocation: DELETE /api/tenant/api-keys/<id> (soft-delete: sets is_active=false)
  • Expiry: Optional expires_at field; rejected at creation if in the past
  • Cache: Redis TTL 300s; revoked tokens may remain active up to 5 min
External App → HTTPS → api-gateway
├── enforce_pat_scopes(): validates (method, path) against PAT scopes
├── enforce_pat_pagination(): caps limit/max_rows
└── proxy to backend (Orion-LD, timeseries-reader, DataHub BFF)
↑ auth: gateway service JWT + X-Delegated-Tenant-ID
  • PAT management UI is in DataHub → Integrations
  • PAT scope mapping lives in PAT_SCOPE_ROUTES constant in fiware_api_gateway.py
  • The DATAHUB_BFF_URL env var must point to http://datahub-api-service:8000
  • Never expose raw PAT in logs — PatSanitizingFilter redacts nkz_pat_* patterns

Both tenant-webhook and tenant-user-api use the password grant with admin-cli:

data = {
'grant_type': 'password',
'client_id': 'admin-cli',
'username': os.getenv('KEYCLOAK_ADMIN_USER', 'admin'),
'password': os.getenv('KEYCLOAK_ADMIN_PASSWORD', ''),
}
# POST to: http://keycloak-service:8080/auth/realms/master/protocol/openid-connect/token

Never use client_credentials grant. Env vars come from keycloak-secret K8s Secret.

All services connecting to Keycloak inside the cluster must use:

http://keycloak-service:8080/auth

The /auth suffix is required. Without it → 403/404 on admin API calls.

Custom user attributes must be registered in the realm User Profile before they can be set on users:

PUT /auth/admin/realms/{realm}/users/profile

Without registration, Keycloak 26 silently discards attributes on PUT (returns 204 but ignores them). This applies to all custom attributes: tenant_id, tenant, plan, max_users, max_robots, max_sensors, activation_code, created_by, is_owner.

Setup script: scripts/keycloak-setup-mappers.sh (handles both mapper and User Profile registration).

The nekazari-frontend client has a tenant_id User Attribute mapper:

  • User Attribute: tenant_id → Claim: tenant_id
  • Added to: ID token + access token + userinfo
  • Type: String (not multivalued)
RoleAssigned toMeaning
PlatformAdminPlatform operatorsFull admin access, all tenants
TenantAdminFirst user of a tenant (owner)Manage own tenant users/settings
FarmerAdditional tenant usersStandard access within tenant

Every request is scoped to a tenant. The flow:

JWT token contains: { tenant_id: "asociacinallotarra", ... } ← canonical claim: tenant_id (snake_case)
api-gateway extracts tenant_id from JWT claims
normalize_tenant_id("asociacinallotarra") → "asociacinallotarra"
Injects headers for internal services:
NGSILD-Tenant: asociacinallotarra ← canonical (ETSI NGSI-LD spec)
Fiware-Service: asociacinallotarra ← legacy FIWARE v2 compat (KEEP — Orion-LD resolves both to same namespace)
Fiware-ServicePath: / ← required by FIWARE v2 convention
X-Tenant-ID: asociacinallotarra
Input → Output
"My-Farm" → "my_farm"
"Test Tenant" → "test_tenant"
"UPPERCASE" → "uppercase"
"a-b-c" → "a_b_c"

Function: normalize_tenant_id() in common/tenant_utils.py (lowercase, hyphens→underscores, strip special chars, 3-63 chars).

When a user activates with a NEK code, the tenant ID is generated as:

normalized = _normalize_tenant_slug(tenant_name) # slugify: lowercase, remove accents/special chars
tenant_id = f"tenant-{normalized}" # prefix with "tenant-"

When a user self-registers (free trial), the tenant ID is the normalized organization name without prefix.

Calling…HeaderWho sets it
Orion-LD (NGSI-LD broker)NGSILD-Tenant (canonical, ETSI spec)api-gateway (automatic)
Internal backend servicesX-Tenant-IDapi-gateway (automatic)
Module backends (direct ingress)Extract from X-Tenant-ID header (if routed via gateway) or JWT tenant_id claimModule’s own middleware

Both headers are permanent: All platform services and modules MUST send both NGSILD-Tenant AND Fiware-Service with the same normalized tenant ID. Orion-LD uses Fiware-Service as a fallback namespace; sending only one header causes tenant isolation failures. Always include Fiware-ServicePath: /. Use the canonical inject_fiware_headers() from common/auth_middleware.py or ngsi_headers.py for standalone modules.

  • Always normalize before using as DB schema name, MongoDB collection, MinIO path, or SQL identifier.
  • Never trust tenant ID from request body or query params — always from JWT.
  • Default tenant (no tenant in JWT): "default".

Admin panel (PlatformAdmin)
→ POST /api/admin/activations (email, plan)
→ Generates NEK-XXXX-XXXX-XXXX code, sends email
User opens /activate
→ Enters: code, email, tenant_name, password
→ POST /webhook/activate
tenant-webhook:
1. validate_activation_code(code, email) — checks public.activation_codes
2. create_tenant_resources(tenant_id, plan_info) — K8s namespace (optional)
3. ensure_tenant_record() — INSERT into public.tenants
4. INSERT into public.farmers (owner)
5. create_keycloak_user() — sets attributes: tenant_id, plan, max_*, is_owner=true
6. Assigns TenantAdmin role + tenant group in KC
User logs in → JWT contains tenant_id claim → dashboard loads
User opens /register
→ POST /webhook/register (email, organization_name, password)
→ Same flow but no NEK code, plan=basic, 30-day trial
TableSchemaPurpose
activation_codespublicNEK codes (pending/used/revoked)
tenantspublicTenant records (plan, limits, status)
farmerspublicUser records (email, tenant_id)

Two valid patterns. Choose based on Content-Type:

POST /ngsi-ld/v1/entities HTTP/1.1
Content-Type: application/json
Link: <http://api-gateway-service:5000/ngsi-ld-context.json>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
NGSILD-Tenant: my_farm
Fiware-Service: my_farm
Fiware-ServicePath: /
{"id": "urn:ngsi-ld:AgriParcel:my_farm:001", "type": "AgriParcel", "name": {"type": "Property", "value": "North field"}}

Pattern B: application/ld+json + @context in body

Section titled “Pattern B: application/ld+json + @context in body”
POST /ngsi-ld/v1/entities HTTP/1.1
Content-Type: application/ld+json
NGSILD-Tenant: my_farm
Fiware-Service: my_farm
Fiware-ServicePath: /
{"@context": "http://api-gateway-service:5000/ngsi-ld-context.json", "id": "urn:ngsi-ld:AgriParcel:my_farm:001", "type": "AgriParcel", "name": {"type": "Property", "value": "North field"}}
  • Never mix: if @context is in the body, do NOT send the Link header (Orion rejects it).
  • Never use external context URLs in production (https://raw.githubusercontent.com/smart-data-models/...). Always use the local gateway context: http://api-gateway-service:5000/ngsi-ld-context.json.
  • Services inside nkz/services/: Import inject_fiware_headers() from common/auth_middleware.py. It handles tenant normalization, both headers, Content-Type, Link, and @context mutual exclusivity.
  • Standalone modules (separate repos): Copy ngsi_headers.py into backend/app/common/ and use inject_fiware_headers() from there. See nkz-module-carbon/backend/app/common/ or the module template for reference.
  • The api-gateway handles headers automatically for proxied requests (through /ngsi-ld/* and /api/* routes).
  • For direct Orion calls from backend services (bypassing the gateway), you MUST use inject_fiware_headers().
  • Frontend consumption: use options=keyValues for simple JSON responses.

Use SDM vocabulary. This table is the canonical list of types used in NKZ:

Entities you create via wizard / SDM Integration

Section titled “Entities you create via wizard / SDM Integration”
CategoryTypeNotes
ParcelasAgriParcelPrimary land unit. Has location (GeoJSON polygon), area (hectares)
SensoresAgriSensorIoT sensor. Replaces legacy Device type. Gets MQTT credentials on creation
TractoresAgriculturalTractorFarm machinery with J1939/ISOBUS
ImplementosAgriculturalImplementAttachments (plough, sprayer, etc.)
EdificiosBuildingFarm buildings
AguaWaterSource, Well, Spring, Pond, IrrigationOutlet, IrrigationSystemWater infrastructure
EnergíaPhotovoltaicInstallation, EnergyStorageSystemSolar + batteries
GanaderíaLivestockAnimal, LivestockGroup, LivestockFarmAnimals + farms
RobotsAgriculturalRobotAutonomous machines
LegacyDeviceGeneric IoT device. Prefer AgriSensor for new entities. Kept for backwards compatibility

Entities created by backend services (not via wizard)

Section titled “Entities created by backend services (not via wizard)”
TypeCreated byNotes
WeatherObservedweather-workerHourly weather data per parcel
AgriParcelRecordtelemetry-workerSensor measurements linked to parcels
urn:ngsi-ld:{Type}:{tenant_id}:{uuid|custom_id}

Generated by _build_ngsild_urn() in entity-manager/blueprints/entities.py. The tenant segment is mandatory for multi-tenant isolation. UUIDs are v4 by default; custom IDs are sanitized (colons→hyphens, spaces→underscores).

Examples: urn:ngsi-ld:AgriParcel:my_farm:a1b2c3d4e5f67890, urn:ngsi-ld:AgriSensor:allotarra:montiko-sensor-01

Existing entity IDs are immutable — never rename entities already in Orion.

Use the canonical function to extract a display name from an NGSI-LD entity:

  • Python: get_entity_display_name(entity) from common/entity_utils.py
  • TypeScript: getEntityDisplayName(entity) from @nekazari/sdk (ngsi/helpers.ts)

Logic: entity.name (string) > entity.name.value (Property format) > entity.id (fallback).

One canonical env var: CONTEXT_URL. Default: http://api-gateway-service:5000/ngsi-ld-context.json.

Every service must use: CONTEXT_URL = os.getenv("CONTEXT_URL", "http://api-gateway-service:5000/ngsi-ld-context.json")

  • Never invent new types if an SDM type exists (e.g., don’t use Parcel, Sensor, Robot).
  • The SDM catalog in sdm-integration/sdm_api.py defines all available types. To add a new type, add it there.
  • IoT types (AgriSensor, Sensor, Actuator, WeatherStation, AgriculturalTractor, LivestockAnimal, AgriculturalMachine) automatically get MQTT credentials provisioned on creation.
  • Never hardcode context URLs — always use the CONTEXT_URL env var.

Numeric properties must include unitCode using UN/CEFACT Common Codes:

MeasurementunitCodeWrong
Temperature (°C)CEL"ºC", "celsius"
Pressure (hPa)HPAA97, "hPa"
Area (hectares)HAR"ha", "hectareas"
PercentageP1"%", "percent"
Wind speed (m/s)MTS"m/s", "Km/h"
Precipitation (mm)MMT"mm"
Irradiance (W/m²)D54"W/m2"

Example:

{
"atmosphericPressure": {
"type": "Property",
"value": 1013.25,
"unitCode": "HPA"
}
}

Ingress-level routing (nkz.robotika.cloud)

Section titled “Ingress-level routing (nkz.robotika.cloud)”

Traefik ingress routes by longest prefix match. Not all requests pass through api-gateway:

Routes through api-gateway (auth + tenant injection automatic)

Section titled “Routes through api-gateway (auth + tenant injection automatic)”
PathFinal backendNotes
/api/auth/sessiongateway itselfCookie set/clear
/api/public/platform-settingsentity-manager-service:5000Public boot config
/api/weather/*weather-worker:8080Weather data
/api/timeseries/*timeseries-reader-service:8000Historical data
/api/vegetation/*api-gateway → vegetation-prime-api:8000NDVI/satellite
/api/gis/*gateway itselfGIS utilities
/api/v1/profiles/*gateway itselfDevice profiles
/api/iot/provision-mqttgateway itselfMQTT provisioning
/api/* (catch-all)gatewayAny unmatched /api/ path

Routes that bypass api-gateway (direct ingress)

Section titled “Routes that bypass api-gateway (direct ingress)”

These services receive traffic directly from ingress — no gateway auth/tenant injection:

PathBackend serviceAuth
/webhook/*tenant-webhook-service:8080Own validation (public endpoints for activation)
/api/admin/*tenant-webhook-service:8080 (some routes)Own JWT validation
/api/tenant/users/*tenant-user-api-service:5000Own JWT validation
/api/tenant/services/*tenant-webhook-service:8080Own JWT validation
/api/risks/*risk-api-service:5000Own JWT validation
/api/modules/*entity-manager-service:5000Direct ingress
/api/assets/*entity-manager-service:5000Direct ingress
/api/intelligence/*intelligence-api-service:8000Direct ingress
/api/cadastral-api/*catastro-spain-api-service:8000Direct ingress
/api/lidar/*lidar-api-service:8000Direct ingress
/api/datahub/*datahub-api-service:8000Direct ingress
/api/odoo/*odoo-backend-service:8069Direct ingress
/ngsi-ld/*api-gateway (nkz) / api-gateway (nekazari)Via gateway
/sdm/*sdm-integration-service:5000Direct ingress

Admin route splitting (inside api-gateway)

Section titled “Admin route splitting (inside api-gateway)”

When /api/admin/* reaches api-gateway (catch-all), the gateway uses ADMIN_ROUTE_MAP to split:

Sub-pathDestination
audit-logs, terms, platform-settings, tenant-usage, assets, parcelsentity-manager-service:5000
tenants, activations, tenant-limits, api-keys, users, platform-credentialstenant-webhook-service:8080

Note: /api/admin/* also has a direct ingress rule to tenant-webhook. The gateway catch-all and the direct ingress compete — Traefik evaluates by longest prefix, so more specific sub-paths may hit the gateway while /admin hits tenant-webhook directly.

Frontend domain. Module backends served alongside the host SPA:

PathBackend serviceIngress name
/api/agrienergy/*agrienergy-api-service:8000agrienergy-api-frontend-host
/api/connectivity/*connectivity-api-service:8000connectivity-api-frontend-host
/api/datahub/*datahub-api-service:8000datahub-api-frontend-host
/api/vegetation/*vegetation-prime-api-service:8000vegetation-module-ingress
/modules/*frontend-static-service:80Main ingress (module bundles from MinIO)
/frontend-static-service:80Main ingress (host SPA)

Webhook routes (tenant-webhook-service:8080)

Section titled “Webhook routes (tenant-webhook-service:8080)”
PathAuthPurpose
POST /webhook/activateNone (public)Activate NEK code, create tenant + user
POST /webhook/registerNone (public)Self-registration (free trial)
GET /healthNone (exempt from rate limiter)K8s probes
  • api-gateway routes use prefix /api/. The gateway receives the full path including /api/.
  • Services with direct ingress must validate JWT themselves (JWKS from Keycloak).
  • Frontend calls: VITE_API_URL (https://nkz.robotika.cloud) for gateway routes; relative paths for direct-ingress modules on nekazari.robotika.cloud.
  • /health endpoints must have @limiter.exempt — K8s probes exhaust rate limits otherwise.

  • Coordinate order: [longitude, latitude] (NOT [lat, lon]).
  • CRS: WGS84 (EPSG:4326). Always.
  • Location property in NGSI-LD:
{
"location": {
"type": "GeoProperty",
"value": {
"type": "Point",
"coordinates": [-2.6189, 42.8467]
}
}
}

There is one shared i18next instance for the host. Do not add a second loader or duplicate JSON trees.

PieceRole
NekazariI18nProvider (@nekazari/sdk)Async initI18n + I18nextProvider; must wrap the tree before any useTranslation. Config: apps/host/src/config/hostI18nConfig.ts (loadPath: /locales/{{lng}}/{{ns}}.json, ns: common, navigation, layout).
I18nProvider (apps/host/src/context/I18nContext.tsx)Compatibility only: useI18n().t(...) delegates to the same i18n via i18n.t(realKey, { ns }). It does not load flat /locales/{lang}.json.
useTranslation(ns?)From @nekazari/sdk in host code (same React context as the provider). Avoid importing react-i18next directly in the host to prevent split-brain context.
  • Source of truth: nkz/apps/host/public/locales/{lang}/{namespace}.json
  • Languages: es, en, ca, eu, fr, pt
  • Namespaces loaded at init: common, navigation, layout (must stay in sync with hostI18nConfig.namespaces and with knownNamespaces in I18nContext.tsx if you extend them).
  • If the first dot segment is common, navigation, or layout, that segment is treated as the namespace and the rest is the key inside that file (e.g. navigation.dashboard → namespace navigation, key dashboard).
  • Any other first segment (e.g. dashboard.title, wizard.sdm_guide.help_button) is looked up in common with the full key string.

So: add nested keys under the correct *.json namespace file; do not rely on removed flat public/locales/{lang}.json files.

  • Prefer useTranslation('common') (or navigation / layout) and keys without a fake namespace prefix.
  • Default namespace is common if you call useTranslation() with no argument.

Bundle translations in the module, then register against the shared instance, e.g.:

i18n.addResourceBundle(lang, 'common', translations, true, true);
  • Minimum languages: es + en for every new key.
  • New namespaces require: JSON files per language, hostI18nConfig.namespaces, and (if used via useI18n) knownNamespaces in I18nContext.tsx.
  • After changing i18n init or providers, rebuild the host Docker image (SDK is built inside the Dockerfile before Vite).

  • Output: single nekazari-module.js file (module-builder default is nkz-module.js but all production modules use nekazari-module.js).
  • JSX: "jsx": "react" (classic transform). Never "react-jsx".
  • Externals: react→React, react-dom→ReactDOM, react-router-dom→ReactRouterDOM, @nekazari/sdk→__NKZ_SDK__, @nekazari/ui-kit→__NKZ_UI__.
  • Entry: src/moduleEntry.tswindow.__NKZ__.register({ id, viewerSlots, version }).
  • Deploy: upload to MinIO nekazari-frontend/modules/{moduleId}/nekazari-module.js.
  • Module id must match marketplace_modules.id in the database exactly.

12. Billing & subscription roles (Keycloak)

Section titled “12. Billing & subscription roles (Keycloak)”

Canonical realm role names for Stripe ↔ Keycloak orchestration (billing module + api-gateway):

RoleMeaning
role_pro_trialSubscription in trial
role_pro_activePaid / active access
role_pro_expiredTerminal non-payment or cancellation; read-only platform lock (gateway mutating API → 403)
  • Never use legacy names such as role_locked in new code, docs, or Keycloak configuration — use role_pro_expired.
  • Checkout trial length is configured with STRIPE_TRIAL_PERIOD_DAYS (default 45 in billing module settings); keep product copy and Stripe dashboard aligned.
  • Billing admin HTTP routes accept PlatformAdmin or TenantAdmin JWT roles; tenant/user context always comes from JWT claims, never from the request body.