NubleStation

This page is written for AI coding agents. It covers every service — environment setup, SDK usage, CLI commands, schema authoring, and what to avoid.

🤖
AGENT.mdAI Guide

Drop into your project root or paste into your agent’s context window — no MDX, plain Markdown.

Download AGENT.md

Covers:🔐Identity· Auth · SSO · sessions🗄️Blaze· Database · schema · CRUD📁Vault· File storage · sharingCLI· init · db push · deploy

Tip

TL;DRpnpm add @nublestation/identity @nublestation/blaze @nublestation/vault. Create clients once at module level. Use auth.requireUser() to gate routes, blaze.db.table.create/list/update/delete for data, vault.upload/download for files. Deploy with nuble deploy --build.


What NubleStation is

NubleStation is a self-hosted backend platform running on a single Linux machine on a LAN. It exposes one API originhttp://api.{org}.local — that routes to four internal services:

Path prefixServiceStatus
/v1/vault/*Vault — file storage✅ Live
/v1/orbit/*Orbit — frontend deploy✅ Live
/v1/db/*Blaze — database✅ Live
/v1/auth/*Identity — auth/SSO✅ Live

Every request from app code must carry an API key (Authorization: Bearer nbl_<id>.<secret>). The Gateway validates the key, resolves it to an app context, and forwards to the right service. One app cannot access another app’s data — isolation is enforced at the platform layer.


Environment setup

You need two values before calling any NubleStation service:

NUBLESTATION_URL=http://api.{org}.local    # e.g. http://api.clinic.local
NUBLESTATION_API_KEY=nbl_<keyId>.<secret>  # from Console → App → Settings → API Keys

For Vite/React apps, prefix with VITE_:

VITE_NUBLESTATION_URL=http://api.clinic.local
VITE_NUBLESTATION_API_KEY=nbl_00366449d578bd6b.JfuicE...

For Node.js apps, read via process.env.

Always add a trailing newline to .env files. Some dotenv parsers skip the last line without one.


Installing the SDK

# Auth / SSO
pnpm add @nublestation/identity

# Database
pnpm add @nublestation/blaze

# File storage
pnpm add @nublestation/vault

# Umbrella client — re-exports all three under one createClient() factory
pnpm add @nublestation/client

@nublestation/client is a thin wrapper that re-exports everything from the individual service packages under a unified createClient() factory. Both approaches reach the same API.


Identity — Auth & SSO

Creating a client

import { createIdentityClient, IdentityError } from "@nublestation/identity";

const auth = createIdentityClient({
  url:         process.env.NUBLESTATION_URL!,       // gateway: http://api.clinic.local
  identityUrl: "http://identity.clinic.local",      // Identity login pages
  app:         "myapp",                             // this app's slug (from Console)
});

Auth is cookie-based SSO. The session cookie is scoped to Domain=.{org}.local and sent automatically to every *.{org}.local subdomain (credentials: "include"). There are no tokens to manage in the browser.


auth.getSession()

Returns the session state for the configured app:

const session = await auth.getSession();

if (session.status === "authenticated") {
  console.log(session.user.displayName); // signed in + has access to this app
}
if (session.status === "forbidden") {
  console.log(session.user.email);       // signed in, but no role on this app
}
if (session.status === "unauthenticated") {
  auth.login();                          // redirect to SSO
}
statususerMeaning
authenticatedSigned in, has a role on this app
forbiddenSigned in, no role on this app (default-deny)
unauthenticatedNo valid session cookie

IdentityUser fields: { id, email, displayName, avatarUrl, role }. role is null outside an app context.


auth.requireUser(opts?) — route guard

Resolves with the user when signed in and allowed. Otherwise redirects to SSO automatically.

// Unauthenticated → browser navigates to identity.{org}.local/authorize
// Forbidden → throws IdentityError(403, "forbidden") unless onForbidden is set
const user = await auth.requireUser();

// Custom forbidden handler — useful for "access denied" screens
const user = await auth.requireUser({
  onForbidden: (u) => showAccessDenied(u.displayName),
  redirectUri: window.location.href,   // where to return after login
});

auth.login() / auth.logout()

auth.login();                                         // navigate to SSO sign-in
auth.login("http://tasks.clinic.local/dashboard");    // explicit return URL
const href = auth.loginUrl();                         // string — use as <a href>

await auth.logout();                                  // revoke session + navigate to login
await auth.logout("http://tasks.clinic.local/bye");   // custom post-logout URL

auth.listAppUsers()

Users who have access to this app (minus the caller). Feeds share-with / assign-to pickers.

const users = await auth.listAppUsers();
// AppUser[] — { id, email, displayName, avatarUrl, role }

Identity error handling

import { IdentityError } from "@nublestation/identity";

try {
  const user = await auth.requireUser();
} catch (err) {
  if (err instanceof IdentityError) {
    // err.status  — HTTP status
    // err.code    — "forbidden" | "unknown_app" | "request_failed" | …
  } else {
    throw err;
  }
}

Blaze — Database

Defining a schema

Define the schema once in a shared file (e.g. src/schema.ts). This is the source of truth for types and migrations.

import { defineSchema, t } from "@nublestation/blaze";

export const schema = defineSchema({
  tasks: t.model({
    title:     t.string().required(),
    status:    t.enum(["todo", "in_progress", "done"]).default("todo"),
    priority:  t.number().default(0),
    assignee:  t.ref("users"),          // FK → platform.users, access-checked
    notes:     t.json(),
    createdAt: t.timestamp().default("now"),
  }),

  appointments: t.model({
    patientId: t.ref("users").required(),
    doctorId:  t.ref("users").required(),
    startsAt:  t.timestamp().required(),
    endsAt:    t.timestamp().required(),
    notes:     t.string(),
  }).index("patientId").index("startsAt"),
});

Field types:

BuilderPostgres typeTS type
t.string()textstring
t.number()integernumber
t.decimal()numericstring (avoids float loss)
t.boolean()booleanboolean
t.uuid()uuidstring
t.timestamp()timestamptzstring (ISO-8601)
t.json<T>()jsonbT (default unknown)
t.enum(["a","b"])text CHECK (IN ...)"a" | "b"
t.ref("table")uuid FKstring

Modifiers (chainable): .required() · .unique() · .index() · .default(value) · .default("now") on timestamps → now().


Pushing the schema

Run after defining or changing the schema. Generates a migration and applies it immediately.

nuble db push

Creating a client

import { createBlazeClient } from "@nublestation/blaze";
import { schema } from "./schema.js";

const blaze = createBlazeClient({
  baseUrl: process.env.NUBLESTATION_URL!,
  apiKey:  process.env.NUBLESTATION_API_KEY!,
  schema,   // optional — enables typed autocomplete on blaze.db.*
});

CRUD operations

// List — paginated
const tasks = await blaze.db.tasks.list();
const page2 = await blaze.db.tasks.list({ limit: 20, offset: 20 });

// Get by ID (throws on 404)
const task  = await blaze.db.tasks.get("uuid");

// Create — only required fields needed; defaults applied server-side
const created = await blaze.db.tasks.create({
  title:    "Check vitals",
  assignee: userId,
});

// Update — partial PATCH semantics
const updated = await blaze.db.tasks.update("uuid", { status: "in_progress" });

// Delete
await blaze.db.tasks.delete("uuid");

All rows are automatically scoped to the calling app’s tenant — you can never read or write another app’s rows.


Type inference

import type { InferSchema } from "@nublestation/blaze";
import { schema } from "./schema.js";

type DB = InferSchema<typeof schema>;
// DB.tasks.Row    → { id: string; title: string; status: "todo"|"in_progress"|"done"; … }
// DB.tasks.Insert → { title: string; assignee?: string; status?: …; … }
// DB.tasks.Update → Partial<DB.tasks.Insert>

Vault — File Storage

Creating a client

Create the client once at module level, not inside a component or function. The factory is pure and has no side effects.

// Node.js / server-side
import { createVaultClient, VaultError } from "@nublestation/vault";

const vault = createVaultClient({
  url:    process.env.NUBLESTATION_URL!,
  apiKey: process.env.NUBLESTATION_API_KEY!,
});
// Vite browser app — env vars baked in at build time
import { createVaultClient, VaultError } from "@nublestation/vault";

const vault = createVaultClient({
  url:    (import.meta.env.VITE_NUBLESTATION_URL as string) || "http://api.nuble.local",
  apiKey: import.meta.env.VITE_NUBLESTATION_API_KEY as string,
});

Or via the umbrella client:

import { createClient } from "@nublestation/client";

const { vault } = createClient({
  url:    process.env.NUBLESTATION_URL!,
  apiKey: process.env.NUBLESTATION_API_KEY!,
});
// vault is the same object as createVaultClient() returns

Collections

Files are grouped into collections — a flat namespace prefix, not a real directory. A collection is created implicitly on first upload and disappears when its last file is deleted. There is no “create collection” call.

Naming rules: [a-zA-Z0-9][a-zA-Z0-9._-]* — no slashes, no .., no spaces.

collection  filename
──────────  ──────────────────
records     john-doe-xray.jpg
reports     q1-2026.pdf
avatars     user-42.png

vault.upload(collection, filename, data)

// Browser File object
const result = await vault.upload("records", file.name, file);

// Uint8Array / ArrayBuffer (Node.js)
import { readFileSync } from "node:fs";
const bytes = readFileSync("./report.pdf");
const result = await vault.upload("reports", "q1.pdf", bytes);

// result shape
console.log(result.id);        // UUID
console.log(result.sizeBytes); // number | null
console.log(result.mimeType);  // detected from magic bytes, not Content-Type
console.log(result.isPublic);  // false — always private by default

Throws VaultError(409, "file_already_exists") if the (collection, filename) pair already exists. Vault has no overwrite — delete first or use a versioned filename.


vault.list(collection?)

const all     = await vault.list();            // all files for this app
const records = await vault.list("records");   // scoped to one collection

// Enumerate collections from the response (no dedicated endpoint exists)
const collections = [...new Set(all.map(f => f.collection))];

Returns FileResult[] — empty array if no files, never null.


vault.download(collection, filename)

const buffer = await vault.download("records", "john-doe-xray.jpg");
// Returns ArrayBuffer — wrap in Blob for browser use

// Browser download
const url = URL.createObjectURL(new Blob([buffer], { type: "image/jpeg" }));
const a   = document.createElement("a");
a.href = url; a.download = "xray.jpg"; a.click();
URL.revokeObjectURL(url); // always revoke to avoid memory leaks

// Node.js — write to disk
import { writeFileSync } from "node:fs";
writeFileSync("./xray.jpg", Buffer.from(buffer));

vault.setPublic(collection, filename, isPublic)

// Make a file publicly accessible (no API key required)
const updated = await vault.setPublic("avatars", "user-42.png", true);

// Public URL (stable until deleted or made private):
// http://api.{org}.local/vault/{appSlug}/{collection}/{filename}
const url = `http://api.clinic.local/vault/myapp/avatars/user-42.png`;

// Revoke public access
await vault.setPublic("avatars", "user-42.png", false);

Important: public files have no-store cache headers — visibility changes take effect immediately on the next request.


vault.delete(collection, filename)

await vault.delete("records", "old-scan.jpg");
// Resolves void. Both disk bytes and DB row are removed. Cannot be undone.

Error handling

import { VaultError } from "@nublestation/vault";

try {
  await vault.upload("docs", "report.pdf", bytes);
} catch (err) {
  if (err instanceof VaultError) {
    // err.status — HTTP status (409, 413, 415, 404, 401…)
    // err.code   — machine-readable string from server
    if (err.status === 409) {
      // conflict — file already exists
    }
    if (err.status === 401) {
      // invalid or missing API key
    }
  } else {
    // network failure or unexpected runtime error — re-throw
    throw err;
  }
}

All error codes:

CodeStatusCause
unauthorized401Missing or invalid API key
invalid_segment400Collection or filename contains illegal characters
file_already_exists409(collection, filename) already exists — delete first
file_too_large413Exceeds app’s max_file_bytes (default 50 MB)
extension_not_allowed415Extension blocked by app’s allowlist
invalid_body400setPublic body missing or isPublic not boolean
not_found404File doesn’t exist
request_failedanyFallback when server returns no parseable error field

Complete Vault example (TypeScript)

import { createVaultClient, VaultError } from "@nublestation/vault";

const vault = createVaultClient({
  url:    process.env.NUBLESTATION_URL!,
  apiKey: process.env.NUBLESTATION_API_KEY!,
});

// Upload with conflict resolution
async function uploadOrReplace(
  collection: string,
  filename: string,
  data: Uint8Array,
) {
  try {
    return await vault.upload(collection, filename, data);
  } catch (err) {
    if (err instanceof VaultError && err.status === 409) {
      await vault.delete(collection, filename);
      return vault.upload(collection, filename, data);
    }
    throw err;
  }
}

// Upload and immediately get a shareable URL
async function uploadPublic(
  collection: string,
  filename: string,
  data: Uint8Array,
  appSlug: string,
  orgDomain: string,
): Promise<string> {
  const file = await vault.upload(collection, filename, data);
  await vault.setPublic(collection, filename, true);
  return `http://api.${orgDomain}.local/vault/${appSlug}/${collection}/${filename}`;
}

CLI — Development & Deployment

Setup

# Install globally
npm install -g @nublestation/cli
# or from this monorepo
pnpm add -g @nublestation/cli

Run once per project. Writes config to ~/.nuble/config (TOML).

nuble init \
  --url  http://api.clinic.local \
  --key  nbl_00366449d578bd6b.JfuicE... \
  --slug myapp
FlagDescription
--urlGateway base URL
--keyApp API key (nbl_<id>.<secret>)
--slugApp slug (must match the slug in the Console)
--profileNamed profile (default: default)

nuble deploy — ship a frontend

Zips a built dist/ directory and uploads it to Orbit. The app becomes live at {slug}.{org}.local immediately.

# Upload an already-built dist/
nuble deploy

# Build first, then deploy — reads .env automatically
nuble deploy --build

# Custom dist path
nuble deploy --dist ./out

Use --build when:

  • The project has a .env file with VITE_* vars that must be baked into the bundle
  • You want a single command workflow: nuble deploy --build

Without --build: you must run pnpm build (or npm run build) yourself before deploying, with env vars already exported.

The --build flag:

  1. Detects your package manager (pnpmyarnnpm, by lockfile)
  2. Reads .env from the current directory and merges it into the build environment
  3. Runs the build script
  4. Zips and uploads dist/

nuble status — health check

nuble status
# Checks Gateway reachability for all configured profiles

Best practices for agents

1. Never hardcode API keys. Read from process.env (Node) or import.meta.env.VITE_* (Vite). Always add a sensible fallback for the URL:

const url = (import.meta.env.VITE_NUBLESTATION_URL as string) || "http://api.nuble.local";

2. Create service clients once at module level. All clients are stateless — every call is an independent fetch(). No connection pool or session to worry about.

3. For Identity: call auth.requireUser() at the top of every protected page/component. It handles all three states automatically — unauthenticated redirects to SSO, forbidden calls your handler or throws, authenticated returns the user.

4. For Blaze: define the schema in a single shared file. src/schema.ts imported by both the client and nuble db push ensures types and migrations are always in sync.

5. Run nuble db push after every schema change. The platform rejects writes to columns that don’t exist yet.

6. Use meaningful Vault collection names. Collections are permanent path segments. Prefer kebab-case. Avoid generic names like files or data — prefer patient-records, invoices, avatars.

7. Filenames are immutable. There is no rename or overwrite. For versioned content, embed the version in the filename: report-v2.pdf, 2026-06-01-scan.jpg.

8. Handle VaultError(409) explicitly. Any upload that may run more than once should handle the conflict case — fail gracefully or delete-and-re-upload.

9. Always revokeObjectURL after browser downloads. Creating object URLs without revoking them leaks memory.

const url = URL.createObjectURL(blob);
a.click();
URL.revokeObjectURL(url); // ← don't skip this

10. For Vite apps: trailing newline in .env. Some dotenv parsers skip the last line without one.

11. Deploy with --build to guarantee env vars are baked in. A pre-built dist/ without the correct VITE_NUBLESTATION_URL will silently use an empty base URL and fail with 405 errors from Caddy’s file server.

12. The API origin is always api.{org}.local. Never construct URLs to individual service ports. All traffic goes through the Gateway. There is one API entry point.


Architecture notes for agents

  • Single host, no clustering. NubleStation runs on one machine. No horizontal scaling, no load balancer.
  • Apps are database rows, not containers. Creating an app in the Console inserts a row and issues an API key. No new process is spawned.
  • Frontends are static files. Caddy serves dist/ from the host filesystem. No SSR, no Node runtime for deployed apps.
  • No internet dependency at runtime. All services (DNS, proxy, storage, DB) run locally. The LAN works without an internet connection.
  • CORS is allowed for *.local and localhost. The Gateway accepts requests from any *.{org}.local subdomain and localhost on any port.
  • Collections are implicit. There is no “create collection” endpoint. Uploading to a collection creates it; deleting the last file removes it.
  • No rename, no overwrite. Delete and re-upload is the only way to replace a file.

Quick reference

// Identity
import { createIdentityClient } from "@nublestation/identity";
const auth = createIdentityClient({ url, identityUrl, app });

await auth.getSession();                       // → SessionState
await auth.requireUser(opts?);                 // → IdentityUser (or redirect)
await auth.getUser();                          // → IdentityUser | null
await auth.hasAccess(role?);                   // → boolean
await auth.listAppUsers();                     // → AppUser[]
auth.login(redirectUri?);                      // navigate to SSO
await auth.logout(redirectTo?);                // revoke session + navigate
auth.loginUrl(redirectUri?);                   // → string (for <a href>)
// Blaze
import { defineSchema, t, createBlazeClient } from "@nublestation/blaze";
const schema = defineSchema({ tasks: t.model({ title: t.string().required() }) });
const blaze  = createBlazeClient({ baseUrl, apiKey, schema });

await blaze.db.tasks.list({ limit?, offset? }); // → Row[]
await blaze.db.tasks.get(id);                   // → Row
await blaze.db.tasks.create(data);              // → Row
await blaze.db.tasks.update(id, partial);       // → Row
await blaze.db.tasks.delete(id);                // → void
// Vault
import { createVaultClient, VaultError } from "@nublestation/vault";
const vault = createVaultClient({ url, apiKey });

await vault.upload(collection, filename, data);        // → FileResult
await vault.list(collection?);                         // → FileResult[]
await vault.download(collection, filename);            // → ArrayBuffer
await vault.setPublic(collection, filename, boolean);  // → FileResult
await vault.delete(collection, filename);              // → void
# CLI
nuble init   --url http://api.{org}.local --key nbl_... --slug myapp
nuble db push                    # push schema changes to Blaze
nuble deploy                     # zip + upload existing dist/
nuble deploy --build             # build (reads .env) + zip + upload
nuble status                     # check gateway health