Full Stack with @nolag/core

The nolag-core repository brings up the whole system on your machine with one script: Postgres, an example host that mounts the @nolag/core library, kraken pointed at it, and a small admin UI. It then imports a demo project and writes out the credentials it minted.

The quickstart is a demonstration, not a deployment. The example host authenticates nobody: anyone who can reach its port can read, create and delete every project and mint credentials for any of them. That is why every published port binds to 127.0.0.1. Do not put it on a network. See what the quickstart is not.

Prerequisites

  • Docker with Docker Compose, Git and curl
  • Node.js 18 or later, to connect a client and run the test suite

Run it

git clone --branch v0.5.0 https://github.com/NoLagApp/nolag-core.git
cd nolag-core
KRAKEN_CONTEXT=https://github.com/NoLagApp/kraken.git#v0.9.0 ./quickstart/quickstart.sh

KRAKEN_CONTEXT tells Docker where to build kraken from. The compose file in nolag-core v0.5.0 points at a kraken branch that no longer exists, so set it to the kraken v0.9.0 release as above, or to the path of a local kraken checkout.

The script:

  1. writes a .env file on its first run, with a generated SIGNING_KEY_ENCRYPTION_KEY, Postgres password and kraken internal secret, and the published ports;
  2. runs docker compose up -d --build --wait, which builds core, the UI and kraken from source and waits until every service reports healthy (the first build takes a few minutes);
  3. imports quickstart/demo-project.json through the host's POST /v1/projects/import;
  4. writes the minted credentials to quickstart/credentials.json, readable only by you.

When it finishes:

ServiceAddress
Admin UIhttp://localhost:3401
Example host (core's HTTP API)http://localhost:3400, with OpenAPI docs at /swagger
Brokerws://localhost:8410/ws
Postgreslocalhost:5442, user nolag, database nolag_core

The ports come from .env (UI_PORT, CORE_PORT, KRAKEN_PORT, POSTGRES_PORT), so change them there if something else holds one. Port 18843 is published for kraken's MQTT listener too, but that listener does not accept connections in kraken v0.9.0.

Check both halves:

curl localhost:3400/health
# {"status":"ok","database":"up"}
curl localhost:8410/health
# {"status":"ok"}

The demo project

quickstart/demo-project.json defines:

AppAccessTopicsRooms
chatopen: every active actor in the project reaches it without a grantmessages, typinggeneral, random, and vip, which a type grant makes private to service actors
opsrestricted: needs an explicit grant on the actoralertscontrol
ActorTypeNotes
alice, bobuserNo grants of their own: they reach the open chat app only
opsbotserviceGranted pubSub on the ops app; reaches the vip room through the type grant
acme-devicedeviceBound to access scope acme
globex-devicedeviceBound to access scope globex

It also creates one signing key, browser, for client tokens.

quickstart/credentials.json holds what the import minted:

{
  "projectId": "01a11ee7-a399-74bc-b934-161d1cc618a4",
  "actors": [
    { "ref": "alice", "keyId": "at_live_9c6b0cdc3c4f", "accessToken": "at_live_9c6b0cdc3c4f.<secret>" }
  ],
  "signingKeys": [
    { "ref": "browser", "keyId": "sk_live_d4dcb310e8b8", "signingKey": "sk_live_d4dcb310e8b8.<secret>" }
  ]
}

(Shortened: there is one entry per actor.) The secrets appear only here. Core stores hashes of actor secrets and an encrypted copy of signing key secrets, so they cannot be shown again. Running the script a second time imports a second demo project with new credentials, rather than failing.

App and room slugs are used exactly as written in the document, so topics are addressed as chat/general/messages. An actor bound to an access scope addresses chat/acme/general/messages; it may also leave the scope out and kraken inserts it. See Access Scopes.

Connect a client

From a new directory next to your nolag-core checkout:

mkdir core-client && cd core-client
npm init -y
npm install @nolag/js-sdk

Save this as connect.mjs and run it with node connect.mjs:

import { readFileSync } from "node:fs";
import { NoLag } from "@nolag/js-sdk";

const creds = JSON.parse(readFileSync("../nolag-core/quickstart/credentials.json", "utf8"));
const token = (ref) => creds.actors.find((a) => a.ref === ref).accessToken;
const url = "ws://localhost:8410/ws";

const bob = NoLag(token("bob"), { url });
bob.on("chat/general/messages", (data) => console.log("bob got:", data));
bob.on("connect", () => {
  bob.subscribe("chat/general/messages", (err) => {
    console.log(err ? `subscribe failed: ${err.message}` : "bob is subscribed");
  });
});
await bob.connect();

const alice = NoLag(token("alice"), { url });
await alice.connect();
alice.emit("chat/general/messages", { text: "hello from alice" }, (err) => {
  console.log(err ? `publish failed: ${err.message}` : "alice published");
});
bob is subscribed
alice published
bob got: { text: 'hello from alice' }

The other rules in the demo project hold too. We checked each of these with the quickstart's own tokens:

ActorSubscribes toResult
aliceops/control/alertsRefused, unknown_topic (42940): ops is restricted and alice has no grant
alicechat/vip/messagesRefused, unknown_topic: the room is private to service actors
opsbotops/control/alertsAccepted
opsbotchat/vip/messagesAccepted, through the type grant
acme-devicechat/acme/general/messagesAccepted
acme-devicechat/general/messagesAccepted: kraken inserts the actor's scope

To connect a browser with a client token instead, sign a JWT with the browser signing key from credentials.json, as shown in Client Tokens.

Verify the stack

The repository carries an acceptance suite that drives the running stack with a real @nolag/js-sdk client: authentication, a pub/sub round trip, restricted apps, private rooms, and isolation between the two tenants. In the nolag-core directory:

npm ci
npm run test:stack

Against the stack above, all 19 tests passed. The suite starts by checking that kraken refuses a token core never issued, so a broker accidentally left on its static token file fails the run instead of passing it.

Reconnects

We restarted kraken with docker compose restart kraken while clients were connected. They reconnected by themselves, but kraken did not restore their subscriptions. The quickstart runs kraken with CONTROL_BACKEND=noop, so kraken never tells core what a connection subscribed to, and core has nothing to hand back on reconnect. A client that subscribes in its connect handler, as connect.mjs does, carried on receiving messages.

Stop and clean up

docker compose down      # stop, and keep the database
docker compose down -v   # stop, and delete the database volume

What the quickstart is not

Every item here is a deliberate omission, listed so nobody discovers it the hard way:

  • No authentication on the example host, and no TLS anywhere.
  • Secrets in a plaintext .env, generated by the script and readable only by you. That is not secret management.
  • Postgres in a container with a local volume, and no backups, replication or tuning.
  • One kraken node. No clustering or failover; a kraken restart drops every connection.
  • Message recording off (RECORD_MESSAGES=false). kraken has no history API either way.
  • An admin UI with no accounts, because the host behind it has none. It lists, reads, creates (from a project document) and deletes projects. Set CORS_ORIGINS if you serve it from another origin; * is ignored.

Running core in your own host

The library is the deliverable; the example host is about two hundred lines that show the wiring. A host of your own does three things:

  1. Owns the database connection. Pass core's entities and migrations to your TypeORM DataSource as the exported arrays, not a glob: a glob relative to your own source never reaches into node_modules, and core's tables would silently not exist.
  2. Mounts core with CoreModule.forRoot({ signingKeyEncryptionKey, defaultLimits }). Core reads no environment variables and opens no connections of its own.
  3. Exposes the facades behind your own authentication. The broker-facing routes call AuthzFacade and return its results through the exported toBroker* adapters, which define the wire format kraken reads. Configuration routes call ProjectConfigFacade, ActorTokenFacade, SigningKeyFacade and the rest.
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CoreModule, allCoreMigrations, coreEntities } from "@nolag/core";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      url: process.env.DATABASE_URL,
      entities: [...coreEntities],
      migrations: [...allCoreMigrations],
      migrationsRun: true, // apply core's migrations at startup
    }),
    CoreModule.forRoot({ signingKeyEncryptionKey: process.env.SIGNING_KEY_ENCRYPTION_KEY }),
  ],
})
export class HostModule {}

Then point kraken at your broker-facing routes with AUTH_BACKEND=http and AUTH_HTTP_URL, and set BACKEND_SECRET to the bearer secret your routes require. The routes the example host serves, and the document it imports, are described in Project Document. The contract kraken expects is in HTTP Auth Contract.

Next steps