Procurator Nodus

Procurator Nodus

Many applications.
One process.
Each kept apart.

A self-hosted substrate that runs all of your apps from one core, hands them sign-in, data, secrets, APIs and serving as shared capabilities, and never lets one app reach into another.

Watch on GitHub Pre-release

Nodus is Latin for knot. The tangle and the hub are the same points.

It can do that.

Everything below is in the release we are building now. Short statements, no hedging. Each one is something you would otherwise assemble from three tools and a weekend.

  1. Several apps. One process. Each one walled off.

    A shop, a blog, an internal tool and an API can share one server and one core, and still none of them can see another's data, sessions or secrets. Isolation is how the system is built, not a setting you remember to turn on.

    one process shop blog intranet api core
  2. One account signs in to every app in a group.

    Put related apps in an app group and a person signs up once: same password, same second factor, one place to reset it. What that account may do is still decided app by app, and apps in other groups never learn the account exists.

    one account one group employee portal warehouse tools scheduling
    Filled dots: what this app has granted that account.
  3. Several organisations. One install.

    Groups are how one install hosts things that must not know about each other: separate companies, departments or brands. Each group has its own sign-in realm and its own users, roles decided per app, and its own data. Nothing crosses a group line unless both sides declare it, or one app calls another's API with an API key scoped to exactly that.

    group Aown usersappappapp group Bown usersappapp group Cown usersappappapp API key core
    Each group: its own sign-in, users, roles and data. The only ways across a line are a declaration by both sides, or a scoped API key.
  4. A static site, a single-page app and a service written in anything, side by side. HTTPS included.

    Serve static files, route a single-page app, or stand a process in another language behind the built-in proxy. Certificates are issued and renewed for you. One place to configure all of it.

    static sitefiles single-page approutes any languageproxied serve https, issued and renewed for you
  5. Start an app in one command.

    Pick a starter: a static site, a React or Astro single-page app, an API-only app, or a Python app. Each one arrives with sign-in pages and the SDK already pointed at your install, registered in a group, bound to a host. Open it and start on the part that is yours.

    pn generate
    $ pn generate app shop --preset=spa --framework=react
    
    --preset=<p>      static | spa | api-only | python-basis
    --framework=<f>   react | astro  (spa preset only)
    --group=<slug>    target app group (created if new)
    $ 
  6. One data model, assembled from several places.

    Declare a model whose parts live in different stores: a row in Postgres, documents in MongoDB, a live field from someone else's HTTP API. Ask for one record and get one object back, already joined. Your code never learns which store held which piece.

    declareillustrative
    // three kinds of source
    "adapters": {
      "pg_main":      { "type": "postgres", "connection": "…" },
      "mongo_orders": { "type": "mongodb",  "connection": "…" },
      "carrier_api":  { "type": "rest",     "connection": "…" }
    }
    
    // the app binds a resource to each, then declares a model
    "resources": {
      "customers": { "backing": { "adapter": "pg_main" } },
      "orders":    { "backing": { "adapter": "mongo_orders" } },
      "shipments": { "backing": { "adapter": "carrier_api" } }
    },
    "data": { "models": {
      "customers": {
        "fields": {
          "id":   { "type": "uuid", "primary": true },
          "name": { "type": "string" },
          "plan": { "type": "string" }
        },
        "relationships": {
          "orders":    { "type": "hasMany", "model": "orders",
                         "foreignKey": "customerId" },
          "shipments": { "type": "hasMany", "model": "shipments",
                         "foreignKey": "customerId" }
        }
      }
    }}
    ask onceillustrative
    const customer = await pn.custom('data.get', {
      resource: 'customers',
      query:    { where: { id: customerId } },
      include:  ['orders', 'shipments'],
    })
    get one object backillustrative
    {
      "id": "…",
      "name": "Northwind Bakery", // a Postgres row
      "plan": "team",
      "orders": [ // MongoDB documents
        { "id": "…", "total": 84.5, "items": [ … ] }
      ],
      "shipments": [ // live from the carrier's API
        { "carrier": "…", "status": "in_transit" }
      ]
    }
  7. Ask once. Every source answers.

    A federated query fans out to several stores at the same time, feeds the result of one into the filter of the next, and returns a single answer that names which parts came back and which did not. The stores can be SQLite, Postgres, MySQL or MariaDB, SQL Server, MongoDB, Redis, Qdrant, or a plain HTTP API.

    one query Postgres MongoDB https apisomeone else's one answer rowsdocslive
    The dotted arc is one source's result feeding the next one's filter. Each part of the answer carries its own status, so a slow or failed source never hides inside a good result.
  8. Three ways in to your data. Pick the one that fits the query.

    The model API for everyday calls. NodusQL when you want one dialect that PN translates for every engine underneath, SQL table, document collection or vector index alike. Declared raw statements for the hard ones: joins, window functions, each database's own features, written as real SQL in your app's own files with named parameters. Raw statements and NodusQL can both act as sources inside a federated read.

    model APIillustrative
    await pn.custom('data.query', {
      resource: 'orders',
      where: { status: 'paid',
               total: { $gt: 50 } },
      limit: 20,
    })
    NodusQLreal syntax
    SELECT id, total, status
    FROM orders
    WHERE status IN ('paid', 'shipped')
      AND total BETWEEN 50 AND 500
      AND tags CONTAINS 'gift'
    ORDER BY placedAt DESC
    LIMIT 20
    declared rawillustrative
    -- statements/top_customers.sql
    SELECT c.id, c.name,
           SUM(o.total) AS spent
    FROM customers c
    JOIN orders o ON o.customer_id = c.id
    WHERE o.placed_at >= :since
    GROUP BY c.id, c.name
    ORDER BY spent DESC
    LIMIT 10
  9. Turn an app into an API in four commands.

    Scaffold it, add an endpoint, deploy, mint a key. No server to write. The same endpoint answers over REST, through the SDK, and as a tool to AI assistants over MCP, each behind an API key you issued and can revoke.

    pn api
    $ pn api new orders --app=shop
    $ pn api endpoint add orders list --read
    $ pn api deploy orders
    $ pn api key create orders --for=warehouse
    ✓ key issued (shown once)
    $ 
  10. Two SDKs, one contract.

    TypeScript and Python, kept at parity by a machine-checked matrix, so a capability in one is a capability in the other. The TypeScript SDK adds React hooks for sign-in, queries, mutations, permissions, uploads and live updates, and Astro helpers for server-side sessions and routes.

    Reactillustrative
    import { useCurrentUser, useQuery } from '@procuratornodus/sdk/react'
    
    const user   = useCurrentUser()
    const orders = useQuery<Order[]>('data.query', {
      resource: 'orders', limit: 20,
    })
    Pythonillustrative
    from procuratornodus import ProcuratorNodusClient
    
    pn = ProcuratorNodusClient(...)
    orders = await pn.custom("data.query", {
        "resource": "orders", "limit": 20,
    })
  11. Live updates, pushed to the app.

    Subscribe to a capability and changes arrive as they happen, over a connection that reconnects on its own when the network drops. Loop over it in plain TypeScript, or hand it to a React hook and render the latest value.

    subscribeillustrative
    const stream = pn.subscribe<Order>('orders.changes', {})
    for await (const change of stream) render(change)
    
    // the same stream, as a hook
    const { data } = useSubscribe<Order>('orders.changes', {})
  12. Secrets stay out of your code. Outbound calls go only where you said they would.

    An API names the secrets it needs and the hosts it will talk to, and that is the whole declaration. The value of a secret never appears in your repository. A call to a host you did not list is refused before it leaves.

    api.jsonillustrative
    "needs": {
      "calls":   ["data.query"],
      "hosts":   ["api.carrier.example"],
      "secrets": { "CARRIER_TOKEN": ["api.carrier.example"] }
    }
    // the token lives in the vault; this file only names it
    // and binds it to the one host it may be sent to
  13. Install it. Answer the wizard. It is a service.

    The setup wizard writes the configuration and registers PN as a system service. The first start prints the link to the admin console, where you add capabilities, configure them and watch health. From the terminal, pn status and pn doctor tell you the same story.

    pn
    $ pn init
    Procurator Nodus — setup wizard
      ✓ Project directory  ~/procurator
      ✓ Process manager    systemd
      ✓ Admin credentials  configured
      ✓ Config written     ~/.pn/config.json
    Setup complete. Run pn start to boot.
    $ pn start
    Starting Procurator Nodus...
    ✓ Server started on port 7600
      Admin: http://localhost:7600/admin_…
    $ 
  14. On one machine, on your own network, or on the open web.

    Install PN for the machine it runs on, for a home or office network, or for public sites. On one machine it answers only that machine, so terminal tools and background jobs can use it with no certificate at all. On a private network it serves over HTTPS with a certificate you provide, to the addresses you allow. On the open web it obtains and renews public certificates itself. Start small and move it out when you are ready.

    this machine only itself your network your certificate open web public certificates
  15. Patch it with one command. If it does not come up healthy, it puts itself back.

    pn update stages the new release, snapshots the old one, switches over and checks health. A failed check puts the release that was working back, without waiting for you. pn rollback does the same on request.

    current patch health check new current failed: back on its own passed
  16. Backups you can carry off the server.

    pn backup writes one sealed file, encrypted and signed, meant to leave the machine. pn restore puts it back on the same server or a fresh one, checks the whole file before writing anything, and signs every old session out so nothing from before the restore stays valid. Daily snapshots stay on the host for the quick cases. Database servers such as Postgres are backed up with their own tools, and their dumps can be sealed into the same file.

    this server sealed backup a fresh server every old session signed out on restore
  17. No telemetry. Your users can take their data with them, or erase it.

    PN does not report how you or your apps use it: no analytics helper, no usage statistics, no crash reports. Once a day it checks for a newer release and tells you; it never installs anything on its own, and you can turn the check off. Export and erasure are capabilities every app inherits, so the people using what you build keep rights over their own data without you writing a line.

    your install no tracking export erase theirs, always

Every error has a code, and every code has a fix

Hundreds of codes, each namespaced by where it comes from, each stable. See one in a log or an API response and run one command: it tells you what caused it, how to fix it, its category and HTTP status, and a hint. The same codes come back through the SDKs, so an app can handle them by name instead of parsing a message. There is pn errors find for when all you have is a phrase.

pn errors
$ pn errors lookup SERVE_028
SERVE_028   serve • config
  spa handler requires `root` — set `root` to the filesystem path of the SPA build output
  severity: error   http: 400   recoverable: no

  CAUSE
    A serve route was registered with `spa: true` but no `root` field. The SPA handler must serve files from a filesystem root.

  REMEDIATION
    Set `root` to the filesystem path of the SPA build output (e.g., `/var/www/app/dist`), in addition to `spa: true`.

  HINT
    The spa handler is a static-file server with single-page-app fallback rewriting; it requires a `root` to serve from. …
$ 

How it holds together

At the center is the core. It is the one thing you install, and its job is to keep apps apart while letting them share what you choose.

Around it sit spokes. Each provides one capability: sign-in, data, secrets, serving, APIs, the admin console. An app uses the spokes it needs, and every request between parts passes through the core, so a spoke never reaches another spoke directly and an app never reaches another app.

You install one package, run a command-line setup, then manage the rest from the admin console. Adding a capability is an install. Adding an app is a declaration.

Core and spokesA central core connected to six capability spokes: identity, data, secrets, APIs, network and admin.CoreIdentityDataSecretsAPIsNetworkAdmin

Hammer stands apart

Prime directive: Protect the system and its users’ data. Nothing outranks it.

Hammer is the overwatch. It runs as its own process beside PN, not inside it, so a failure in the system it guards cannot take the guard down with it. PN does not run without it.

It keeps its own sealed store, separate from PN's, that no app can reach. It keeps the record of what happened in a form where tampering shows. And it watches security signals, not your users: it does not know what they read, only when something in the system starts to behave like a threat.

When a component misbehaves, Hammer does not negotiate with it. It stops it, restarts PN clean, and reports. When PN itself goes wrong, Hammer can stop it.

PN process core Hammer watches its own sealed store
Separate process, separate store, one job.

What it replaces

The services you would otherwise stitch together, each with its own account, config and failure mode, become one install that you update, back up and watch as one thing.

  • A sign-in and user-management serviceAccounts, sessions, second factor, OAuth sign-in and roles, per app, shared across a group when you say so.
  • An API gateway with key managementEndpoints declared once, answered over REST and MCP, behind API keys you mint and revoke, with rate limits per app and per endpoint.
  • A database access layerOne model API and one query language across SQL, document and vector stores, plus HTTP APIs as data sources.
  • A secret storeSecrets declared by name and handed only where they were declared. The values never sit in your code.
  • A reverse proxy and certificate renewalStatic sites, single-page apps and proxied services on one listener, with HTTPS issued and renewed for you.
  • Deploy, update and backup scriptsOne install, one update command with automatic rollback, sealed backups of everything PN holds, restorable onto a fresh server.
  • Monitoring and an audit trailHealth in the admin console and from the terminal, and a record of what happened, kept by Hammer where tampering shows.

A whole company, built as the proving ground

Imaginary Grocery Company is a fictional grocery and logistics business built on PN as a complete reference stack: two groups on one install, with every declaration, model, view and seed file written out. It is the stack PN is proven against before release. At release it goes live here as a working demo with test logins you can try, and its source becomes a public repository you can read, run and borrow from. The apps are not full products; they are complete enough to show what a finished stack looks like.

Internal Operations one employee sign-in, a role per app employee portal stock operations transport dispatch yard board HR admin Customer Services own accounts storefront availability arrives by scoped API key Postgres MongoDB weather api live
Two groups, seven app declarations, six combined views, one install.
  • Inside the companyAn employee portal, stock operations, transport dispatch, a yard board and HR admin. One employee sign-in across all of them, with a different role in each app and a separate session per app.
  • Facing the publicA customer storefront in its own group with its own customer accounts: catalog, availability, cart, checkout, orders. An employee who shops there is a customer there, nothing more.
  • Data from three placesRows in Postgres, documents in MongoDB and a live weather API, combined into dashboards and boards for the portal, the stock desk, dispatch, the yard and the storefront.
  • Across the lineThe storefront learns what is in stock through an API that stock operations exposes behind a scoped API key. The two groups never share a database.

This release already carries the shape of a real organisation, its internal tools and its public sites on one platform, and PN is being built to go further in that direction.

Who it's for

From a single box to an organisation with departments and a public face. The scale changes; the shape of the install does not.

The box in the closet

A home lab, NAS or office server running several tools, on one machine or across your own network. One sign-in for all of them, each tool kept apart from the rest, encrypted connections with your own certificate, updates that roll themselves back, and a backup you can carry to the next box.

The organisation

Internal tools for staff in one group, customer sites in another, more groups for other departments or brands as they come. Staff sign in once with a different role in each tool, customers have their own accounts, partners get API keys, and no group can reach into another. The example above is this shape.

The developer with an API

Four commands from app to endpoint to key. The endpoint answers over REST, through the TypeScript or Python SDK, and as a tool AI assistants can call over MCP, with every error carrying a code the caller can act on.

After the first release

The first release is the foundation. This is where it goes next, planned in the open and without dates.

In the updates that follow

  • A certificate authority PN runs for your own network
  • Public sites and network-only apps on the same install
  • Installing and updating machines with no internet connection
  • Native macOS hosting
  • Transactions across several writes to one database
  • Rate limits per key and per role, and usage metering
  • Fuller admin screens: audit history, alert history, and an undo window for deleted apps
  • One-click installs with hosting providers

Further ahead

  • Selected core components and Hammer rewritten in Rust, where it measurably helps
  • Hammer separated from PN by the operating system, not only by process
  • Horizontal scale-out: capabilities and apps sharded across several servers on one network
  • Multi-region federation: independent PN instances in different regions working together as one system
  • Signed, reviewed extensions from other developers
  • Writes and transactions across several data stores
  • More platforms: Deno, more Linux distributions, and a look at native Windows

What will not change

  • Free and open source, under the Apache-2.0 license
  • No telemetry: PN never reports how you or your apps use it
  • Your apps and your data stay on your server
  • Every app kept apart from every other, by design
Pre-release

Where it stands

Procurator Nodus is in active development and is not yet available to install. At release it will be free and open source under the Apache-2.0 license.

This is the project’s official home. The release, the documentation and the install steps will be published right here.

Watch on GitHub to hear when it arrives.