Project Document

@nolag/core creates a project from a single JSON document and can export any project back to the same format. Everything in it is addressed by slug rather than by id, so a document exported from one deployment imports cleanly into another.

An import always creates a new project and returns the credentials it minted, once. An export contains no secrets, so it is safe to keep in version control; importing it again mints fresh credentials.

An example

{
  "version": 1,
  "project": {
    "name": "Fleet",
    "description": "Vehicles, dispatchers and a customer-facing web app.",
    "limits": { "maxConnections": 500 }
  },
  "accessScopes": [
    { "slug": "acme", "name": "Acme Logistics" }
  ],
  "apps": [
    {
      "slug": "fleet",
      "name": "Fleet",
      "accessMode": "restricted",
      "topics": ["location", "status", "commands"],
      "triggerWebhook": {
        "url": "https://hooks.example.com/nolag/fleet",
        "headers": { "Authorization": "Bearer replace-me" }
      },
      "rooms": [
        { "slug": "north", "name": "North depot" },
        { "slug": "south", "name": "South depot" },
        {
          "slug": "dispatch",
          "name": "Dispatch",
          "typeGrants": [{ "actorType": "service", "permission": "pubSub" }]
        }
      ],
      "lobbies": [
        { "slug": "all-depots", "name": "All depots", "rooms": ["north", "south"] }
      ]
    }
  ],
  "actors": [
    {
      "ref": "dispatcher",
      "name": "Dispatch service",
      "actorType": "service",
      "appAccess": [{ "appSlug": "fleet", "permission": "pubSub" }]
    },
    {
      "ref": "truck-17",
      "name": "Truck 17",
      "actorType": "device",
      "appAccess": [{ "appSlug": "fleet", "permission": "publish", "topics": ["location", "status"] }]
    },
    {
      "ref": "acme-viewer",
      "name": "Acme operations",
      "actorType": "user",
      "scopeSlug": "acme",
      "appAccess": [{ "appSlug": "fleet", "permission": "subscribe" }]
    }
  ],
  "signingKeys": [
    { "ref": "web", "name": "Customer web app" }
  ]
}

We imported this document into core v0.5.0 and connected each actor. What each one may do:

  • dispatcher reaches every room of fleet: the app grant covers the public rooms north and south, and the dispatch room's type grant admits service actors.
  • truck-17 may publish location and status in north and south, and nothing else. It cannot subscribe, cannot publish commands, and cannot reach the private dispatch room.
  • acme-viewer belongs to the acme scope, so its topics live under fleet/acme/<room>/<topic>, apart from everyone else's. It sees only what actors in the same scope publish.

Importing and exporting

With core's example host:

curl -X POST http://localhost:3400/v1/projects/import \
  -H 'Content-Type: application/json' \
  --data-binary @project.json

A successful import returns 201 with every credential it minted, keyed by the ref you gave each actor and signing key:

{
  "projectId": "01a11efb-7c17-72da-81c3-4d4c15e4c609",
  "actors": [
    { "ref": "dispatcher", "keyId": "at_live_69a42ea6117c", "accessToken": "at_live_69a42ea6117c.<secret>" }
  ],
  "signingKeys": [
    { "ref": "web", "keyId": "sk_live_34fc93326d35", "signingKey": "sk_live_34fc93326d35.<secret>" }
  ]
}

Store these straight away: core keeps only a hash of each actor secret and an encrypted copy of each signing key secret, and shows neither again.

To export:

curl http://localhost:3400/v1/projects/01a11efb-7c17-72da-81c3-4d4c15e4c609/export

The export has the same shape as the import document. Each actor's and signing key's ref becomes its key id, and no secret appears anywhere.

The import runs in a single transaction, so a document that fails part-way writes nothing. It is refused with 400 if version is not 1, if a slug breaks the slug rule, if a property is not part of the format (the example host rejects unknown properties rather than ignoring them), if two access scopes, apps, actors or signing keys share a slug or ref, or if anything refers to a scope, app or room the document does not define.

Field reference

Slugs (projects aside, everything has one) are 1 to 100 characters of lowercase letters, digits and hyphens, starting and ending with a letter or digit. They appear in topic addresses, which is why the set is narrow.

Topic names go in the last segment of an address. Keep them to letters, digits, _, - and :, with no /: a slash would change the shape of every address the topic appears in. (Core enforces this rule on its app and room facades; the import format accepts any string, so the rule is yours to keep.)

Top level

FieldRequiredMeaning
versionyesAlways 1
projectyesThe project itself
accessScopesTenants within the project. See Access Scopes
appsApps, each with its rooms and lobbies
actorsIdentities that connect, each with its grants
signingKeysKeys for signing client tokens

project

FieldMeaning
nameRequired
descriptionFree text
organizationIdOptional. Must be a UUID: any other string makes the import fail. Core stores it and never interprets it, but kraken counts connections for limits.maxConnections per organization id (see below).
limits.maxConnectionsConnection limit, enforced by kraken: a connect beyond it is refused with connection_limit_reached. null means unlimited.
limits.maxMessageSizeBytesPassed to kraken, which in v0.9.0 does not enforce it. Every publish is held to kraken's fixed 921,600 byte ceiling instead.
limits.sessionExpirySecondsSession lifetime for agent and orchestrator actors, used only with kraken's MQTT broker backend

kraken counts connections per organization id, not per project, across every project that shares the id. Projects that leave organizationId unset all share a single count: with core v0.5.0 and kraken v0.9.0, one connection to a project limited to one connection also refused the first connection to a second project without an organization id. Give every project that has a connection limit its own organizationId.

A document without limits leaves the project on the host's defaults (the example host reads DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_MESSAGE_SIZE_BYTES and DEFAULT_SESSION_EXPIRY_SECONDS, which default to unlimited, unlimited and 3600).

accessScopes[]

FieldMeaning
slug, nameRequired
description, metadataOptional
isActiveDefault true. Actors bound to an inactive scope are refused, and live ones are disconnected at their next revalidation with reason scope_inactive.

apps[]

FieldMeaning
slug, nameRequired. The slug is the first segment of every topic address in the app, and is used exactly as written.
topicsRequired. The app's topic list. This is the authoritative list for authorization: a room's own topics are not read.
accessModeopen (the default): every active actor in the project reaches the app without a stored grant. restricted: an actor needs an appAccess grant.
statusactive (the default), disabled or suspended. Only active apps are reachable.
descriptionOptional
hydrationWebhook, triggerWebhook{ "url", "headers" }. Called when an actor subscribes (hydration) or publishes (trigger). See Webhooks.
topicConfigsPer-topic settings. Per-topic webhooks go here, as { "<topic>": { "webhooks": { "onSubscribe": { "url", "headers" }, "onPublish": { "url", "headers" } } } }, and take precedence over the app-level ones.
rooms, lobbiesSee below

apps[].rooms[]

FieldMeaning
slug, nameRequired
statusactive (the default) or disabled. Only active rooms are reachable.
typeGrantsGrants for every actor of a type: { "actorType", "permission", "topics", "isActive" }. topics of null (or absent) inherits the app's list.
description, metadataOptional
topicsStored, but not read during authorization

A room is public until something grants access to it. As soon as any grant names the room, whether a typeGrants entry on the room or a roomAccess entry on an actor, the room becomes private, and only actors holding a grant for it get in. That includes actors whose app grant would otherwise cover it. We confirmed this with core v0.5.0: one actor's roomAccess on a room shut out another actor that had a full pubSub grant on the app.

apps[].lobbies[]

FieldMeaning
slug, nameRequired
roomsRequired. Slugs of rooms in the same app.
description, metadataOptional

See Lobbies.

actors[]

FieldMeaning
refRequired. A handle for this actor within the document, used only to label its minted token in the import response. Not stored.
name, actorTypeRequired. Types: device, user, service, session, agent, orchestrator, observer. See Authentication.
statusactive (the default) or disabled
expiresAtOptional ISO 8601 time after which the actor is refused
scopeSlugBinds the actor to an access scope
appAccessApp grants: { "appSlug", "permission", "topics", "isActive", "expiresAt" }. Required for every app in restricted mode. topics of null (or absent) inherits the app's list.
roomAccessRoom grants: { "appSlug", "roomSlug", "permission", "topics", "isActive", "expiresAt", "role" }. role is a display label only. In a restricted app the actor also needs an appAccess grant.
metadataOptional

permission is subscribe, publish or pubSub everywhere. A grant past its expiresAt, or with isActive false, is ignored. When an actor holds both kinds, a grant naming the actor takes precedence over a type grant on the room.

signingKeys[]

FieldMeaning
refRequired. Labels the minted key in the import response.
nameRequired

Importing signing keys needs the host to have a signing key encryption key configured. See Client Tokens.

The example host's routes

The example host in the nolag-core repository serves the import and export routes above and the endpoints kraken calls, and nothing else. None of them asks for any credential, and the example host does not check kraken's BACKEND_SECRET either. Keep it on localhost, or put it behind your own authentication.

RouteWhat it does
GET /health{"status":"ok","database":"up"}, or degraded when Postgres is unreachable
GET /swaggerOpenAPI documentation for every route below
GET /v1/projectsLists projects: projectId, name, createdAt
POST /v1/projects/importCreates a project from a document. 201 with the minted credentials, 400 for an invalid document.
GET /v1/projects/{projectId}/exportThe project as a document, without secrets. 404 for an unknown project.
DELETE /v1/projects/{projectId}Deletes the project and everything in it. 204.
POST /v1/internal/actors/validatekraken's /validate
POST /v1/internal/actors/revalidatekraken's /revalidate
POST /v1/internal/actors/check-room-accesskraken's /check-room-access
POST /v1/internal/subscriptions/updateRecords one subscribe or unsubscribe for an actor, { "actorTokenId", "topic", "action", "loadBalance", "loadBalanceGroup", "filters" }. 204. kraken v0.9.0 does not call this route.

So kraken's AUTH_HTTP_URL for this host is http://<host>:3000/v1/internal/actors. The three actor routes follow the HTTP auth contract, and always answer 200, with the denial in the body.

There is no route for a single app, room, actor or signing key. To change one, export the project, edit the document and import it as a new project (which mints new credentials), or write a host of your own on core's facades. See Full Stack.