@niscorp/moss
The nisc application server. A principal logs in, a charter resolves, and what arrives over the socket is their application — the actions they may use, the data those actions may touch — derived from authored artifacts, live and revocable, identical in mechanism for humans and AI agents.
Moss does not host code and guard it. It serves existence: a resolved catalog is the application. The warehouse kiosk granted two actions is not a locked-down app; it is a two-action app. There is nothing else to render, invoke, or attack.
On npm, and still early. The data/policy plane, the socket, per-principal server shells, ring-2 served layout variants, the in-process function seam, server-drawn documents and pages, and the canvas terminal (the wire plus five swappable render targets) are built and tested. The fn host (
/fns), the artifact library, the projection model, and scale-out are specified and pending. See DESIGN.md for the thesis, how the code is laid out, and the unbuilt ladder.
Install
pnpm add @niscorp/moss @niscorp/charter @niscorp/nova @niscorp/prism @niscorp/strata @niscorp/vex zod
# the nisc packages moss composes and zod are required peers — the app owns
# the one copy of each. hono, the Node listener and ws come with moss as
# regular dependencies. The optional peers, each needed by one subpath only:
pnpm add @niscorp/tide # ./tide (an app that runs tide)
pnpm add react react-dom # ./terminal/react (and its /server)
pnpm add vue # ./terminal/vue (and its /server)
pnpm add ink react # ./terminal/ink
pnpm add -D vite # ./vite
Node ≥ 22.12.
The shape
An app hands moss its artifacts (defineApp) and an environment (NiscRuntime — a database and how a session token is verified, optionally a cache). Everything mechanical is derived: the data layer from the schema, per-principal policy and catalogs from the charter, the server shells from the manifest. The manifest is read by a schema and frozen when it is handed over, and the server refuses to boot on one that does not parse, or on an incoherent charter.
import { defineApp } from '@niscorp/moss';
import { serve } from '@niscorp/moss/node';
const app = defineApp({
charter, // the policy document (resolved per principal)
assignments, // principal → roles
actions, // the ActionDefinitions the app ships
layouts, // ring 2: layout variants by minted id — { action, layout },
// substituted per principal at shell build. The base is the
// floor; variants enrich upward as grants, never reduce
entries, // the reads and writes the app serves — the API surface, as data
behaviors, // row-level scope semantics; each role's `scoping` picks among them,
// one policy per role, merged — a person may hold several
resources, // entity subgraphs → /api/<name>/vex
shell, // the canvas manifest (the shell runs on the server) — the app
pages, // what is drawn at a path and kept by nothing — optional
functions, // the in-process fn seam (agents, sign-in) — optional
});
const server = await serve(app, { pool, db, session: 'sessions', port: 3000 });
// `session` is required: 'sessions' (moss's stored credential), 'dev-open'
// (every well-formed token trusted — harnesses), or the app's own verifier.
// boot refusal here (createServer runs inside serve); HTTP + ws in one
The charter defines a role named public. It is what a request with no session
resolves to, and a signed-in principal with no assignment. public: [] grants
nothing. A charter without the role still boots, and those requests then fail
with Unknown role "public".
The first screen can arrive with the page
A page request can be answered with the screen itself: the caller's shell is
read as it stands (shells.snapshot), drawn to a string by the app's own kit
(terminal/react/server, /vue/server, /dom/server), and written into the
app's index.html beside the snapshot it was drawn from. The browser's terminal
starts from that snapshot and adopts the elements already there — a first frame
delivered early, with the socket still the authority.
import { renderDocument } from '@niscorp/moss';
import { renderSnapshot } from '@niscorp/moss/terminal/react/server';
server.get('*', async (c) => {
const page = await renderDocument({
server,
template: indexHtml,
request: { path: c.req.path, cookie: c.req.header('cookie'), host: c.req.header('host') },
draw: (snapshot) => renderSnapshot({ snapshot, registry, slotWrapper }),
});
return c.html(page.html, 200, page.headers);
});
// the browser's entry
const drawn = readDocumentSnapshot();
createWire({ env: browserEnv(), ...(drawn ? { initial: drawn } : {}) });
Who is asking is read off the browser's session cookie. Its name ends with the
port a page is on, so the request's Host is how a page on a port finds it;
mountSite (@niscorp/moss/node) and the dev plugin are this route, written
once. Who may keep the page follows from who asked: drawn for nobody it is the
same for everybody; drawn for somebody it is private, no-store. A page that
cannot be drawn goes out undrawn.
The page's <head> is a node in a layout too — nova's nova:head, holding
the elements a head holds, bound to the action's data. It is read off the
snapshot and written into the template, and the terminal keeps the page's head
on it as the screen moves. With site, every path says its own canonical
address.
pages are the other thing a path can lead to: a shell manifest drawn for
whoever asks and then let go — a welcome, the docs. A page with nothing left to
happen opens no socket, and exportDocuments writes pages as files. See
DOCS.md § The document and
DESIGN.md § Pages.
The client is a terminal — any terminal
moss serves the application: per-principal trees down the socket, events up. What paints them is a render target, and targets are interchangeable over one wire:
terminal/react— the browser, with the app's styled component kitterminal/vue— the same, for an app whose kit is Vueterminal/dom— the browser, zero framework, nova's reference kitterminal/tty— a REPL in a real terminal: frames print as text, numbers act (6clicks, words fill the input)terminal/ink— a full-screen TUI: same numbers, live focus, color
One wire, one session, one server. Sign in from the REPL and the TUI is already signed in — the token file is the terminal's localStorage. The server never learns which target rendered the frame; policy, layouts, and data are decided per principal, never per client.
Every target is told the connection's status the same way, because a screen
left up by a dead socket looks exactly like a live one. The REPL and the TUI
print it. The three browser targets say it on their root element
(data-wire-status="closed") and draw nothing: what a lost connection looks
like is the app's stylesheet's.
── main ────────────────────────────────
Sign in to Relay
[1] ⟨alex, jordan or sam⟩
[2] (Send magic link)
› alex
› 2
Magic link sent to [email protected]. The email is faked — the link is right here:
[1] (Open magic link)
Under the hood: ./client is the wire (a plain-TS protocol client: token
slot, one socket at a time, reconnect, session lifecycle — host-shaped pieces
injected as a WireEnv, browser by default, ./client/node for a plain
process); ./terminal is the conductor and the hot-swap switcher; targets
close over their own surface (a DOM root, a stdio pair), so the contract is
surface-blind. Framework-shaped code lives only in the target subpaths,
never in moss core.
Subpaths
@niscorp/moss— the server:defineApp,createServer, the drawn document (renderDocument,exportDocuments), moss's stored session credential (mintSession),migrateTables, and the types an app names (NiscApp,NiscRuntime,MossServer,FunctionSession). DOCS.md documents every name each entry exports; the list itself ispackages/moss/api.txtin the repository, which a build holds the entries to.@niscorp/moss/node— the Node listener:serve+attachSocket(rawws), andmountSite(server, { dist, draw })— the built terminal served by the same process, every page drawn. Bun swaps this file, never the app.@niscorp/moss/integrations— a feature, not part of the core:createIntegrations(config)makes the object an app puts in its manifest'sfeaturesto take in separate services that add screens (registration, approval, the proxy, webhooks), and carries the calls the app makes on it afterwards. A bundle is nisc's own five fields andextension, the app's own vocabulary: the app supplies the schema that reads it (extension: { schema, check?, prepare?, contract? }) and reads it back withintegrations.listIntegrations().@niscorp/moss/tide— a feature too:createTideFacts({ tide, identity, chain })mints each committed write into tide as a fact, for the identity the write's own scope names. It is what moss keeps of hosting tide: the Postgres store (@niscorp/tide/postgres) and the driver (createTideDriver) are tide's own, and@niscorp/tideis an optional peer.@niscorp/moss/protocol— for code that speaks the wire itself, a client written by hand or a transport other than./node:PROTOCOL,offerToken, the two close codes a terminal recovers from, and the typesServerMessageandConnection. An app served byniscorserveneeds none of it.@niscorp/moss/unverified—unverifiedShell(session), the whole nova shell of a session. On the shell a session hands out, the eight members that change what a shell is made of (registerAction,setLayout,addCanvasand their kin) throw, because what they add is not verified by moss.@niscorp/moss/vite—mossDev({ app }): the app server inside vite's dev process — loaded through vite (an edit re-boots it), the socket answered by the app that is booted, pages drawn, moss's paths answered, a dev-only/dev/as/<who>.nisc devadds it for an app behind moss;viteis an optional peer.@niscorp/moss/client— the wire:createWire(), the app end of the socket. Plain TypeScript, zero React, zero globals — the host comes in as aWireEnv(default:browserEnv(): the page's location, and the session kept by the browser in a cookie the page cannot read, or in localStorage where the page is not served by its app).@niscorp/moss/client/node— the Node host env:nodeEnv({ url, tokenFile? })runs the same wire on a plain Node (or Bun) process — token in a file, the runtime's WHATWG WebSocket.@niscorp/moss/terminal— the terminal:createTerminal(one target, one wire) andmountTerminal(hot-swaps render targets on a hotkey over one wire; the session survives the swap). Framework-blind, surface-blind.@niscorp/moss/terminal/react— the React render target:reactTarget({ root, registry, slotWrapper? })binds the app's component registry to the wire via nova's React adapter.@niscorp/moss/terminal/vue— the Vue render target:vueTarget({ root, registry, slotWrapper? })binds the app's Vue component registry to the wire via nova's Vue adapter; updates re-render reactively, never remount.@niscorp/moss/terminal/dom— the plain-DOM render target:domTarget({ root })renders with nova's DOM adapter and default kit. Zero framework.@niscorp/moss/terminal/react/server,/vue/server,/dom/server—renderSnapshot(...): a shell snapshot drawn to a string by the same kit, forrenderDocument'sdraw.@niscorp/moss/terminal/tty— the line-terminal render target:ttyTarget({ input, output })runs the app as a REPL in a real terminal — served frames print as text with numbered markers, typing acts on them (numbers tap, words fill), and the same events ride the wire. Zero framework, zero DOM.@niscorp/moss/terminal/ink— the full-screen terminal render target:inkTarget()runs the app as a TUI — nova's Ink kit on the React adapter's walker. Same[n]addressing as the REPL (typed digits click/flip/focus), plus Tab/arrows and live typing. ESM-only, like ink.
What it serves
/catalog— the application, resolved for you (granted action ids + a version token)./api/vex,/api/<resource>/vex— reads and writes, locked (replay-only), scoped per principal. The model never writes SQL; the policy it can't see enforces access.- the socket — the authority channel: the served frame and per-canvas
RenderNodetrees down,NovaEvents up (a message a terminal sends is at most 256 KB). Session lifecycle (sign-in grant, sign-out revoke) rides it. Two optional reductions sit under it, both invisible to an app:permessage-deflate(on by default) and frame deltas (shellFrameDelta, off) — a changed canvas sent as a checksummed delta against the frame the terminal already holds, 1–4% of the frame on an in-place change. See DOCS.md § Wire size.
Tables and documents, versioned
moss creates no table on its own: its tables (MOSS_SEQUENCE, plus the
sessions table's sequence when the runtime's session is 'sessions'), the
vex cache's and the ones the app's features keep go through one
strata ledger run at boot — once,
recorded, refused if the ledger was edited or written by newer code. Tide's
tables are tide's (TIDE_SEQUENCE, from @niscorp/tide/postgres): they go
through the same ledger when createTideStore(pool) is called, or in moss's
own run when the app hands the sequence over in runtime.tables.
migrations: 'verify' on the
runtime refuses to boot with anything pending. Stored integration actions carry
a grammar stamp and are upgraded at boot, at intake and on read — through
nova's and Prism's grammars plus the app's own (NiscApp.grammars); a bundle or
row written by newer grammars is refused. See DOCS.md.
See DESIGN.md for the inversion and DOCS.md for the full API.