Terminal-native station network / self-hosted app protocol

PhosphorNet

PhosphorNet is a small network where stations host doors, the client draws the screen locally, and you can still see who and what you are talking to. It is built for people who like terminals, self-hosting, and systems with clear boundaries.

phosphor renders.
phosphord thinks.
doors define behavior.
switchboard helps nodes connect.
Ed25519 signs in.
WebSocket carries the session.
SQLite remembers.

Why it exists

PhosphorNet is for people who miss computers that felt like places.

It gives you a station you enter, a door you open, a name you recognize, and a system you can inspect. It keeps the social feeling of an old BBS without handing remote code control over your terminal.

01 / Overview

PhosphorNet turns a network into a place.

The project borrows from BBSes, old online services, and classic terminal networking, but it is not a browser, not SSH, and not a remote code runner. The client draws the interface locally while the node handles the actual door logic.

The point is not to hide the network. The point is to make identity, access, routing, and interaction visible enough that the station feels small, understandable, and actually yours.

Product goal

A BBS-style network where computers feel like places again.

Technical goal

Small Go binaries, JSON screens, Ed25519 login, SQLite state, and clear boundaries.

Out for now

Keep federation, DHTs, mobile, browser clients, and big social systems out of the first slice.

Design principle

The client draws. The node thinks. Doors define behavior. Ed25519 signs in. WebSocket carries the session.

PhosphorNet lobby screen with doors list, station status, presence, and station notice
The lobby is the front door. You see the station, the doors, who is around, and what the node wants you to know.

02 / Architecture

Three runtime pieces make the platform feel complete.

phosphor is the trusted terminal client. phosphord is the daemon that hosts doors and handles station rules. switchboard helps nodes connect when direct access is awkward.

phosphor

  • Draws JSON screens locally.
  • Stores passports and known-node pins.
  • Turns terminal input into simple actions.
  • Shows the trust prompt and trusted chrome.

phosphord

  • Serves WebSocket sessions.
  • Checks users with Ed25519 login signatures.
  • Hosts embedded Lua doors and stdio doors.
  • Keeps state and policy in SQLite.

switchboard

  • Accepts outbound node registrations.
  • Keeps track of which node is where.
  • Forwards frames between peers.
  • Stays out of identity and source data.

03 / Stack

The stack is intentionally small and boring in the good way.

The stack is compact on purpose: Go for the binaries, Bubble Tea and Lip Gloss for the client, WebSocket for transport, JSON for screens, SQLite for persistence, TOML for config, and Ed25519 for identity.

Layer Choice Why it fits
Client Go + Bubble Tea Single binary, fast startup, and a clean loop for terminal rendering.
Node Go Concurrency, networking, and easy static builds for stations.
Door runtime Lua by default, stdio for Python or other languages Embedded scripting for common doors without making every door a separate service.
Transport WebSocket Proxy-friendly and easy to inspect.
State SQLite Single-file persistence for personal and community stations.
  • bubbletea
  • lipgloss
  • bubbles
  • glamour
  • nhooyr.io/websocket
  • modernc.org/sqlite
  • goose
  • log/slog

04 / Setup

Local development starts with one node and one client.

The setup guide assumes the current repository root, a Go toolchain that matches go.mod, and a terminal that handles alternate-screen TUIs. For local development, run phosphord in one terminal and phosphor in another.

Bring up a station

go run ./cmd/phosphord init --name localbox --out node.toml
go run ./cmd/phosphord serve --config node.toml

This writes a starter station config, seeds the local admin passport, and starts the node on :7707 by default.

Connect the client

go run ./cmd/phosphor connect --addr wss://127.0.0.1:7707/ws --quick

Quick mode uses disposable client state under /tmp/phosphornet-quick/ so local iteration stays light.

Check the node

curl -k https://127.0.0.1:7707/healthz
go run ./cmd/switchboard serve --listen :7710

The node and switchboard both expose health checks so you can check the process before opening a session.

Build and test

go build ./cmd/phosphor
go build ./cmd/phosphord
go build ./cmd/switchboard
go test ./...

There is also a Python bytecode compile check for the SDK files when door work touches the stdio path.

05 / Identity and trust

Transport security and station identity are separate things.

That split is deliberate. The client can accept a self-signed station certificate while still pinning the station's Ed25519 key separately. The trust prompt shows transport encryption, certificate status, hostname verification, and the station key as separate facts.

Passport

Your portable Ed25519 login key. The private key stays on your machine.

  • Lives under ~/.config/phosphornet/passport.toml by default.
  • Used for sign-in with the station.
  • Identifies the person across stations.

Known node pin

The client keeps a per-address memory of station identity, like SSH known hosts.

  • Lives under ~/.config/phosphornet/known_nodes.toml.
  • Protects against silent station replacement.
  • Can be explicitly replaced during local development.
Trust readout

TLS says whether transport is encrypted. The certificate says whether a hostname checks out. The pinned Ed25519 station key says whether this is the same station you saw before.

First connection trust screen showing transport details, station identity, and trust prompt
The trust screen is where you pin a station and decide whether it is the one you meant to visit.

06 / Runtime notes

The door runtime speaks one simple shape.

The runtime version is phosphornet.door.runtime.v1. Doors get a request, return a response, and follow the same lifecycle hooks: init, view, update, on_join, on_leave, and tick.

{
  "contract_version": "phosphornet.door.runtime.v1",
  "door": { "id": "chat", "name": "Chat" },
  "lifecycle": "update",
  "ctx": {
    "session": { "id": "s1" },
    "user": { "public_key": "...", "fingerprint": "ABCD1234" },
    "node": { "id": "...", "name": "localbox" },
    "state": { "user": {}, "room": {}, "global": {} }
  },
  "event": { "kind": "action", "target": "chat-actions", "action": "send_message" }
}

Event kinds

  • action
  • select
  • submit
  • key
  • focus

State scopes

  • user - per user, per door.
  • room - one room per door for now.
  • global - node-wide door state that only admins can change.

Effects

  • state ops
  • broadcasts
  • notifications
  • transitions
  • admin ops
Session recovery note

Reconnects are short-lived and explicit. If you drop and come back quickly, the node may reopen the old door, but scroll position, focus, and input drafts are not restored.

07 / Writing doors

Doors are server-side programs with a narrow interface.

Lua is the easy default because it runs inside the node, but Python works too through stdio when a door ships its own command or image. The language matters less than the split: the node owns behavior and the client draws the screen.

Door manifest

id = "hello"
name = "Hello"
entry = "app.lua"
visibility = "public"
access = "public"

Visibility decides whether the door shows in the rail. Access decides who can open it. Capabilities decide what the door is allowed to ask for.

Minimal Lua shape

local ui = phosphornet.ui

function view(ctx)
  return ui.screen({
    ui.header("HELLO"),
    ui.panel("Welcome", {
      ui.text("Station: " .. (ctx.node.name or "unknown")),
      ui.button("ping-button", "Ping", "ping"),
    })
  })
end

Lua doors use phosphornet.ui. Python doors use the same request/response shape through stdio and the bundled SDK.

  • state:user:read
  • state:room:write
  • broadcast:room
  • notify:self
  • capture_keys
  • transition:open_door
  • admin:reload_manifests
  • admin:moderate_users

Door settings, Podman defaults, and host execution rules are all part of the package.

08 / Built-in doors

The bundled doors show what the project feels like in use.

Chat is fast and noisy. The forum is slower and longer-lived. A forum thread is where the long post lives, with replies and moderation tools around it.

Chat door with message log, help text, and input line
Chat is quick and noisy, with slash commands and a live input line at the bottom.

Chat

This is the fast room for back-and-forth talk. It feels more like an IRC channel than a feed, with commands, presence, and a live message line.

The point is to keep the conversation moving without making the interface feel like a web app.

Forum door showing a thread list and new thread action
The forum is slower on purpose: a thread list, a new-thread action, and room to write something longer.

Forum

The forum is for slower posts, announcements, and threads that need space to breathe. It keeps the structure obvious: thread list first, then content.

It feels closer to an old-school board than a social timeline, which is the point.

Forum thread view showing a post with markdown content, moderation actions, and replies
A forum thread can carry markdown, moderation actions, and replies without feeling like a different product.

Forum thread

The thread view is where a post opens up into something you can read, answer, and moderate. Markdown lives here, along with replies and simple admin actions.

It is a small example of the project idea: a station can host a room that feels lived-in without becoming complicated.

09 / Configuration

TOML keeps station and door configuration readable.

The node config owns the station name, listen address, keys, doors directory, database path, access policy, and runtime defaults. Door manifests own the entry file, visibility, access, and settings.

Node configuration

name = "localbox"
listen_addr = ":7707"
doors_dir = "./doors"
database = "./phosphornet.db"

[access]
mode = "public"
admins = ["base64-ed25519-admin-public-key"]

[runtime]
default_runtime = "lua"

Access and runtime notes

  • public and invite_only are the current station access modes.
  • Admin-only doors still need the right admin powers to do privileged work.
  • Lua sandboxes can be strict, standard, unsafe, or custom.
  • Stdio doors can run directly or inside a hardened Podman profile.

10 / Door config

This is where a door gets its shape and its rules.

A door manifest tells the station what the door is called, where to load it from, who can open it, whether it shows in the rail, and what extra powers it can ask for. If the manifest is the front door sign, the settings are the knobs on the wall.

What goes in the manifest

id = "chat"
name = "Chat"
entry = "app.lua"
visibility = "public"
access = "public"

[settings.topic]
type = "string"
label = "Room topic"
default = "phosphornet room"

What the fields do

  • id gives the door its stable name.
  • name is what people see in the rail.
  • entry points at the door code.
  • visibility decides whether it shows up.
  • access decides who can open it.
  • capabilities decide what it can ask the node to do.
Good door config feels boring

If a door is meant to be public, the manifest should say that plainly. If it needs an admin, a short allowlist, a custom topic, or a special capability, that should be obvious without hunting through the code.

11 / Podman

Podman isolation is the safe default for stdio doors.

Stdio doors can run straight on the host when you explicitly trust them, but the safer path is to run them in a container with no network, a read-only filesystem, and tight resource limits. That keeps third-party doors from acting like tiny surprise daemons on your box.

Host mode

Use this when you control the code and want the simplest local dev loop.

runtime = "stdio"
command = ["python3", "app.py"]

[isolation]
mode = "host"

Podman mode

Use this when you want the station to launch the door inside a container instead of directly on the machine.

runtime = "stdio"

[isolation]
image = "localhost/phosphornet/weather-door:0.1.0"
network = "none"
read_only = true
timeout_ms = 1500
memory = "128m"
cpus = 0.25
pids_limit = 64

Why use it

  • Less chance a door reaches out to the network.
  • Less chance a door scribbles on the host filesystem.
  • Less chance a bad door eats your CPU or RAM.

How it feels

  • The node still owns the request and response.
  • The door still speaks the same runtime shape.
  • The container is just a tighter box around the process.

When to keep it on the host

  • Local experiments.
  • Doors you fully trust.
  • Cases where you want fast iteration over isolation.
Plain version

Host mode is for trusted local work. Podman mode is for everything that should stay boxed in.

12 / Operations

The boring operator story matters because the station has memory.

SQLite stores users, roles, door state, station settings, moderation state, audit events, and admin config. A real backup is more than the database: node.toml, the database, node key material, and the door directory all travel together.

Backup and restore

  • Stop phosphord before a simple file backup.
  • Copy node.toml, the configured database, and doors_dir.
  • Keep node private keys together with station configuration.
  • Restore the whole bundle, not just the SQLite file.

Moderation primitives

  • Ban and mute keys at the station-policy layer.
  • Freeze doors when station policy needs a pause.
  • Rate-limit abusive sessions and inspect recent events.
  • Keep appeals simple until the project needs more.
phosphord database path=/srv/phosphornet/phosphornet.db schema_version=5

Durable audit events, JSONL mirroring, maintenance mode, and the fact that deleting the node key makes the station a different identity even if the database survives are all part of the operator story.

13 / Test confidence

The current test index already covers the hard edges.

The test index maps release promises to package tests and integration coverage. It is a handy reality check because it shows what already works and where more work still matters.

Covered end to end

Login checks, node key pinning, UI validation, state updates, broadcast fanout, and moderation flows.

Runtime safety

Bad stdio output, Lua sandbox escape attempts, Podman isolation, and response parsing are covered.

Operator confidence

Audit logging, admin checks, reconnect behavior, and manifest rejection are tracked explicitly.