Moss · README.md

This is README.md as @niscorp/moss 0.8.2 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/moss/README.md

@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 kit
  • terminal/vue — the same, for an app whose kit is Vue
  • terminal/dom — the browser, zero framework, nova's reference kit
  • terminal/tty — a REPL in a real terminal: frames print as text, numbers act (6 clicks, 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 is packages/moss/api.txt in the repository, which a build holds the entries to.
  • @niscorp/moss/node — the Node listener: serve + attachSocket (raw ws), and mountSite(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's features to 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 and extension, the app's own vocabulary: the app supplies the schema that reads it (extension: { schema, check?, prepare?, contract? }) and reads it back with integrations.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/tide is 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 types ServerMessage and Connection. An app served by nisc or serve needs 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, addCanvas and 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 dev adds it for an app behind moss; vite is 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 a WireEnv (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) and mountTerminal (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, for renderDocument's draw.
  • @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 RenderNode trees 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.