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:
dispatcherreaches every room offleet: the app grant covers the public roomsnorthandsouth, and thedispatchroom's type grant admitsserviceactors.truck-17may publishlocationandstatusinnorthandsouth, and nothing else. It cannot subscribe, cannot publishcommands, and cannot reach the privatedispatchroom.acme-viewerbelongs to theacmescope, so its topics live underfleet/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
| Field | Required | Meaning |
|---|---|---|
version | yes | Always 1 |
project | yes | The project itself |
accessScopes | Tenants within the project. See Access Scopes | |
apps | Apps, each with its rooms and lobbies | |
actors | Identities that connect, each with its grants | |
signingKeys | Keys for signing client tokens |
project
| Field | Meaning |
|---|---|
name | Required |
description | Free text |
organizationId | Optional. 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.maxConnections | Connection limit, enforced by kraken: a connect beyond it is refused with connection_limit_reached. null means unlimited. |
limits.maxMessageSizeBytes | Passed 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.sessionExpirySeconds | Session 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[]
| Field | Meaning |
|---|---|
slug, name | Required |
description, metadata | Optional |
isActive | Default true. Actors bound to an inactive scope are refused, and live ones are disconnected at their next revalidation with reason scope_inactive. |
apps[]
| Field | Meaning |
|---|---|
slug, name | Required. The slug is the first segment of every topic address in the app, and is used exactly as written. |
topics | Required. The app's topic list. This is the authoritative list for authorization: a room's own topics are not read. |
accessMode | open (the default): every active actor in the project reaches the app without a stored grant. restricted: an actor needs an appAccess grant. |
status | active (the default), disabled or suspended. Only active apps are reachable. |
description | Optional |
hydrationWebhook, triggerWebhook | { "url", "headers" }. Called when an actor subscribes (hydration) or publishes (trigger). See Webhooks. |
topicConfigs | Per-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, lobbies | See below |
apps[].rooms[]
| Field | Meaning |
|---|---|
slug, name | Required |
status | active (the default) or disabled. Only active rooms are reachable. |
typeGrants | Grants for every actor of a type: { "actorType", "permission", "topics", "isActive" }. topics of null (or absent) inherits the app's list. |
description, metadata | Optional |
topics | Stored, 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[]
| Field | Meaning |
|---|---|
slug, name | Required |
rooms | Required. Slugs of rooms in the same app. |
description, metadata | Optional |
See Lobbies.
actors[]
| Field | Meaning |
|---|---|
ref | Required. A handle for this actor within the document, used only to label its minted token in the import response. Not stored. |
name, actorType | Required. Types: device, user, service, session, agent, orchestrator, observer. See Authentication. |
status | active (the default) or disabled |
expiresAt | Optional ISO 8601 time after which the actor is refused |
scopeSlug | Binds the actor to an access scope |
appAccess | App grants: { "appSlug", "permission", "topics", "isActive", "expiresAt" }. Required for every app in restricted mode. topics of null (or absent) inherits the app's list. |
roomAccess | Room 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. |
metadata | Optional |
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[]
| Field | Meaning |
|---|---|
ref | Required. Labels the minted key in the import response. |
name | Required |
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.
| Route | What it does |
|---|---|
GET /health | {"status":"ok","database":"up"}, or degraded when Postgres is unreachable |
GET /swagger | OpenAPI documentation for every route below |
GET /v1/projects | Lists projects: projectId, name, createdAt |
POST /v1/projects/import | Creates a project from a document. 201 with the minted credentials, 400 for an invalid document. |
GET /v1/projects/{projectId}/export | The 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/validate | kraken's /validate |
POST /v1/internal/actors/revalidate | kraken's /revalidate |
POST /v1/internal/actors/check-room-access | kraken's /check-room-access |
POST /v1/internal/subscriptions/update | Records 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.