Skip to content

Several Apps in one server

Serve several independently configured Apps from one process, each with its own connections and grants, optionally behind one sign-in.

suite.json
{
"port": 4880,
"apps": [
{ "id": "lender", "directory": "./lender", "config": "./lender-connections.json", "mount": "/",
"environment": { "BETTER_AUTH_SECRET": "LENDER_AUTH_SECRET", "DATABASE_URL": "LENDER_DATABASE_URL" } },
{ "id": "dealer", "directory": "./dealer", "config": "./dealer-connections.json", "mount": "/dealer",
"policy": "./dealer/policy.ts",
"environment": { "BETTER_AUTH_SECRET": "DEALER_AUTH_SECRET", "DATABASE_URL": "DEALER_DATABASE_URL" } }
]
}
Terminal window
npx tablewalk suite suite.json # http://127.0.0.1:4880/
  • Each App at its mount, answering as it does when served alone: its own grants, row policy, commands, jobs and receipts.
  • With a suite identity, one account and one sign-in across Apps, and a chooser at /_tablewalk/apps listing only the Apps the account is granted.
  • After sign-in, the suite’s address takes each account to its App (below); one with several Apps and no rule picks from the chooser.
  • A refusal at startup, naming the App and the reason, for anything a suite cannot serve safely (the table below).
  • Use a suite on one machine for a family of Apps that share a host: a desk app, a partner portal and a public site.
  • Use one server per App instead when each scales or deploys on its own (build and deploy).
  • Use --app twice on one server instead when the Apps share one config and neither signs people in.
Key Does Default
apps[].id, directory The App, relative to the suite file; id must match the App’s. A build is served as built required
apps[].config A file holding only connections, in the usual format required
apps[].mount Where it is served; one App may take / required
apps[].environment This App’s variable → the shell variable it is read from; captured at start none
apps[].commands Its commands module, as serve --commands; for a build, its own server/commands.mjs commands.ts beside the App
apps[].policy, jobs The row or tenant policy (required when auth.rows is policy or tenant), and a jobs module none
identity One account and sign-in across Apps (below) each App signs in alone
port, basePath The listener, on 127.0.0.1 4111, none

The contract behind each key, and what every kind of App keeps in a suite, is in docs/suites.md.

Add a top-level identity, and set SUITE_AUTH_SECRET before starting:

"identity": {
"id": "keystone", "name": "Keystone",
"store": "./runtime/identity.db", "grants": "./runtime/app-grants.db",
"environment": { "BETTER_AUTH_SECRET": "SUITE_AUTH_SECRET" }
}

To keep nothing on the suite’s disk, make store and grants both postgres:// URLs (a ${NAME} is read from the environment), two separate databases:

"store": "${SUITE_IDENTITY_URL}", "grants": "${SUITE_GRANTS_URL}"

Provision the identity database once with migratePostgresAuthStore(url) from tablewalk/commands, as for an App’s auth.store; the grants database is set up at the first start. Every App in such a suite must use PostgreSQL connections too.

It also takes providers (default ["email"]), signUp (default "open"), services, the oidc and saml connections those providers name (identity providers), and landing (below). An App’s own auth.providers must be among the suite’s.

A new account has no App access. Its account ID shows in the chooser; grant the first administrator of each App from the command line, passing the current revision so an old command never overwrites a newer grant:

Terminal window
tablewalk suite grant suite.json --app lender --subject ACCOUNT_ID --roles admin --revision 0
tablewalk suite revoke suite.json --app lender --subject ACCOUNT_ID --revision 1

The subject is an account ID, never an email. The same account can be an admin in one App and a reader in another; neither gives anything in a third. After that, an App’s administrator grants and invites within that App.

landing sends a signed-in account from the suite’s address to the first App whose rule it meets: a role it holds there, or ("member": true) a membership in one of a tenant App’s tenants. A rule with neither is the default, last.

"landing": [
{ "app": "lender", "roles": ["underwriter", "funder"] },
{ "app": "leads", "member": true },
{ "app": "customer" }
]

An account’s own saved default App comes first. With no rule met: the root App, if the account has it; the only App it has; else the chooser, which says so when it has none, beside Sign out of the suite.

A link into an App survives sign-in at that App’s door. A page of an App the signed-in account was not granted opens the chooser instead, naming that App; signing in there as an account that has it opens the page. Only a page of a suite App is followed, never another site, an API or the chooser itself. Signing out of any App signs out of all of them.

Preview. The Console (targeting, rollouts, the Operations and Analytics rooms and Governance) and feature flags work as documented and may change in any 1.x release. See status.

With shared identity, add a flag control store and the Console:

"flags": { "store": "postgres://localhost:5432/tablewalk_flags", "environment": "production",
"deploymentId": "keystone-production" },
"console": { "enabled": true }
Terminal window
tablewalk flags provision --suite suite.json # once, before serving
tablewalk migrate stores up --suite suite.json # after an upgrade
tablewalk suite grant suite.json --app tablewalk-console --subject ACCOUNT_ID --roles admitted --revision 0

The control store is its own database; serving only attests it. Open http://127.0.0.1:4880/_tablewalk/console. admitted opens the shell only: the account also needs a role with scoped flags capabilities in the target App (feature flags). The Console reads, sets, kills and reverts flags, with a reason and the current revision on every change, and runs experiments. Edits to a flag are staged and reviewed as a list of changes before they are applied. ⌘K (Ctrl K) jumps to a flag, a job, an App or a room; j and k move through a list.

Console also has an Operations room and an Analytics room. Each answers App by App from that App’s own operations grant, never from Console admission:

operator: { read: true, operations: ['jobs', 'deliveries', 'workflows', 'stores', 'analytics'] },
  • 'jobs' and 'deliveries': one list of work, opened on what needs attention. A row shows its attempts, and dead work can be redriven.
  • 'workflows': workflow instances and their steps, with cancel and retry.
  • 'stores': each store’s layout version, and any upgrade pending. Databases are named, never addressed.
  • 'analytics': events by day and simple breakdowns, each event on its own, and the App’s declared funnels as steps. It is read-only and sends counts only: no email, IP, field value or visitor id.

Every change records the signed-in account as its operator, and a grant revoked mid-session refuses the next change. A grant in one App never reaches another App’s queues. Tenant Apps are not listed yet.

Governance opens for an account holding the App’s own admin role, and changes nothing:

  • Audit log: every command from the App’s journal — who, when, which record (linked) and which fields changed, never their values — and every role change. Search by email or account id, command or resource, and dates.
  • Authority: the App’s authority.lock as sentences (“Role agent can update incident.state”), grouped by role, and what changed since the App last started with a different one. With shared identity, the suite keeps each start’s authority beside the identity store.
  • Accounts: who holds which role, and every change to it. Roles are changed on the suite’s access page, which it links to.

A tenant App opens here for an account its own tenant administration grants, and only to Accounts: the tenants that grant names and, for one, the members whose roles it reaches. Members are changed in the App’s own tenant administration, and Support sessions (on the platform context, for a platform role with operations: ['support']) is a screen in the App; the room links to both.

Refused at startup Why
A tenant App at / with shared identity It would answer the suite’s sign-in and chooser routes
api with shared identity, publicOrigin or hosts, or on a tenant App API tokens live in an App’s own sign-in store, and a suite serves the API on loopback only
An App’s own oidc or saml service, or a provider the suite does not list The suite’s sign-in is the only one
Two Apps naming different caches One budget and one lockout per person
App analytics beside suite analytics, or on a tenant App One analytics owner per App
Jobs on a row-policy App Its row policy owns the App’s commands
  • environment variable LENDER_AUTH_SECRET is not set → the right side of environment names a shell variable; export it before starting.
  • A signed-in account sees no Apps → a new account has no access; grant it with tablewalk suite grant.
  • A landing rule never matches → the account needs a current grant in that App; a rule’s roles are that App’s role names, checked at startup.
  • tablewalk suite grant refuses a stale revision → pass the revision the last change printed.
  • The suite refuses to start on a command journal or flag store → provision stores first; serving only attests them.