This page is written for AI coding agents. It covers every service — environment setup, SDK usage, CLI commands, schema authoring, and what to avoid.
✦ Tip
TL;DR — pnpm 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 origin — http://api.{org}.local — that routes to four internal services:
| Path prefix | Service | Status |
|---|---|---|
/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
}
status | user | Meaning |
|---|---|---|
authenticated | ✅ | Signed in, has a role on this app |
forbidden | ✅ | Signed in, no role on this app (default-deny) |
unauthenticated | — | No 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:
| Builder | Postgres type | TS type |
|---|---|---|
t.string() | text | string |
t.number() | integer | number |
t.decimal() | numeric | string (avoids float loss) |
t.boolean() | boolean | boolean |
t.uuid() | uuid | string |
t.timestamp() | timestamptz | string (ISO-8601) |
t.json<T>() | jsonb | T (default unknown) |
t.enum(["a","b"]) | text CHECK (IN ...) | "a" | "b" |
t.ref("table") | uuid FK | string |
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:
| Code | Status | Cause |
|---|---|---|
unauthorized | 401 | Missing or invalid API key |
invalid_segment | 400 | Collection or filename contains illegal characters |
file_already_exists | 409 | (collection, filename) already exists — delete first |
file_too_large | 413 | Exceeds app’s max_file_bytes (default 50 MB) |
extension_not_allowed | 415 | Extension blocked by app’s allowlist |
invalid_body | 400 | setPublic body missing or isPublic not boolean |
not_found | 404 | File doesn’t exist |
request_failed | any | Fallback 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
nuble init — link a project
Run once per project. Writes config to ~/.nuble/config (TOML).
nuble init \
--url http://api.clinic.local \
--key nbl_00366449d578bd6b.JfuicE... \
--slug myapp
| Flag | Description |
|---|---|
--url | Gateway base URL |
--key | App API key (nbl_<id>.<secret>) |
--slug | App slug (must match the slug in the Console) |
--profile | Named 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
.envfile withVITE_*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:
- Detects your package manager (
pnpm→yarn→npm, by lockfile) - Reads
.envfrom the current directory and merges it into the build environment - Runs the
buildscript - 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
*.localandlocalhost. The Gateway accepts requests from any*.{org}.localsubdomain andlocalhoston 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