---
title: Project Document
description: "The JSON document @nolag/core imports to create a whole project (apps, rooms, lobbies, access scopes, actors and signing keys) and exports again without secrets, plus the HTTP routes of core's example host."
---

# 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

```json
{
  "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:

```bash
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:

```json
{
  "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:

```bash
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](/docs/scopes) |
| `apps` | | Apps, each with its rooms and lobbies |
| `actors` | | Identities that connect, each with its grants |
| `signingKeys` | | Keys for signing [client tokens](/docs/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](/docs/concepts/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](/docs/concepts/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](/docs/authentication#actor-types). |
| `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](/docs/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](/docs/self-hosting/http-auth), 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](/docs/self-hosting/full-stack#running-core-in-your-own-host).
