app

package
v0.1.22 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 36 Imported by: 0

Documentation

Index

Constants

View Source
const (
	DefaultPath        = defaultConfigFile
	DefaultSchema      = defaultConfigSchema
	DefaultNetworkID   = defaultNetworkID
	DefaultNetworkName = defaultNetworkName
)
View Source
const InertAgentRegistrationAdviceSuffix = ` Set auth.agent_registration to "token" or "disabled", or bind to a loopback address, unless this is intentional.`

InertAgentRegistrationAdviceSuffix is the exact trailing sentence NonLoopbackAnonymousWriteWarning appends on the agent_registration: open branch, above. Exported as the one shared symbol rather than left as an inline literal so cmd/moltnet's initNonLoopbackWarning (init_listen.go) -- the one caller that needs to strip this exact sentence back off and substitute its own accurate authoring-time advice -- references this constant directly instead of keeping a second, hand-duplicated copy that could silently drift out of sync with this one (round 3's fix for a mutation-proven gap: two independent literals could reorder differently and TrimSuffix would just stop matching, leaving both the stock and the corrected advice printed side by side). Every other caller (`moltnet validate`, server start) prints the warning this constant is part of verbatim, advice included, and must keep doing so until the underlying inertness itself is fixed (see this function's own TODO(setup-p2) above).

View Source
const KeysDirEnv = "MOLTNET_KEYS_DIR"

KeysDirEnv names a durable keys directory explicitly. It wins over every derived location, and is the only way to sign with env-only memory or Postgres configurations.

Variables

View Source
var DefaultDiscoveryOrder = []string{
	defaultConfigFile,
	"moltnet.yaml",
	"moltnet.yml",
	"moltnet.json",
}
View Source
var ErrAuthModeNotOpen = errors.New("auth.mode is not open")

ErrAuthModeNotOpen indicates AddOperatorTokenPreservingOpenMode was asked to repair a Moltnet config whose auth.mode is not "open", outside the one shape PLAN.md phase 7.0's repair path exists for, so it refuses rather than guess what a non-open network wants.

View Source
var ErrAuthTokenIDConflict = errors.New("pairing id collides with a non-pair auth token")

ErrAuthTokenIDConflict indicates a pairing's id collides with an existing auth.tokens[] entry that is not a plain pair-scoped credential.

F5 (confirmed): the pairing id that ends up as AuthTokenWriteback.ID is invite-controlled on both sides of a pairing -- `pair invite --id <id>` picks it directly, and `pair <code>` takes whatever pairing_id the invite names, with no round trip to confirm it first. upsertAuthToken (below) overwrites-by-id unconditionally, so an invite (or a hand-crafted one) naming an id that collides with an operator's own static token -- most dangerously "operator" itself -- used to silently replace that token's value and scopes with a bare [pair]-scoped credential, locking the real operator out of their own network. This is refused even under --force: --force is documented and tested as "overwrite an existing *pairing* with this id", never "overwrite an unrelated non-pair credential" -- those are different invariants and only the first one is what --force opts into.

View Source
var ErrAuthTokensExist = errors.New("auth.tokens already has entries")

ErrAuthTokensExist indicates auth.tokens[] in the target Moltnet config already has at least one entry that AddOperatorToken cannot safely upgrade in place (canUpgradeOperatorOnlyOpenConfig), so it refuses rather than risk creating an unwanted second admin-scoped credential or silently reshaping tokens an operator configured by hand.

View Source
var ErrConsoleTokenIDExists = errors.New("console token id already exists")

ErrConsoleTokenIDExists indicates AddConsoleToken's caller picked token.ID against a config it read at some earlier point (nextConsoleTokenID, cmd/moltnet/auth_token_writeback.go), but the config on disk, read again here, immediately before writing, already has a token using that same id by the time this function actually runs. AddConsoleToken refuses rather than silently overwrite it.

View Source
var ErrPairingExists = errors.New("pairing already exists")

ErrPairingExists indicates a pairings[] entry with the same id already exists in the target Moltnet config file.

View Source
var ErrPairingNotFound = errors.New("pairing not found")

ErrPairingNotFound indicates pairings[] in the target Moltnet config file has no entry with the given id -- RevokePairing has nothing to revoke.

View Source
var ErrRoomConfigExists = errors.New("room already exists in config")

ErrRoomConfigExists indicates rooms[] in the target Moltnet config file already has an entry with the same id as the room WriteRoom was asked to create.

View Source
var ErrRoomConfigNotFound = errors.New("room not found in config")

ErrRoomConfigNotFound indicates rooms[] in the target Moltnet config file has no entry with the given id, so UpdateRoomMembers has nothing to update. Unlike ErrRoomConfigExists (config_writeback_room.go), this is not necessarily a caller mistake: a room can exist on the live service without ever having been declared in this config file (created directly via POST /v1/rooms, the console, or `moltnet apply`, none of which touch rooms[]); see UpdateRoomMembers's doc comment for how callers are meant to treat this case.

Functions

func AddConsoleToken added in v0.1.15

func AddConsoleToken(path string, token AuthTokenWriteback) error

AddConsoleToken appends a single observe-only-scoped auth.tokens[] entry into the Moltnet config file at path, reusing the same writeback machinery as AddOperatorToken. Unlike AddOperatorToken, it never refuses because auth.tokens already has entries: it exists for `moltnet console`'s self-heal path, which by definition only ever runs against a config that already has at least one token (the operator's) but none scoped to exactly [observe], refusing on "tokens already exist" would make it useless for its one caller.

It does set auth.mode to bearer, via the same writeAuthTokensToDoc body AddOperatorToken shares, but for AddConsoleToken's one caller this is never an enabling side effect: self-heal only ever runs against a config that is already in bearer mode (that is its own precondition), so this write leaves auth.mode exactly as it found it in practice. It is documented here only because writeAuthTokensToDoc always does it, unconditionally, for both callers.

The caller is responsible for first picking a non-colliding id (see the id-collision handling token.ID goes through before AddConsoleToken is called), but that pick is made against a config read earlier, which can go stale by the time this function's own read happens (a concurrent `moltnet console` invocation, or a hand edit, landing in between). This function closes that TOCTOU itself: it checks the freshly re-decoded document for token.ID and refuses (ErrConsoleTokenIDExists) rather than trust the caller's earlier pick, so it never silently overwrites a token it was not asked to overwrite.

func AddOperatorToken added in v0.1.15

func AddOperatorToken(path string, operatorToken AuthTokenWriteback, extra ...AuthTokenWriteback) error

AddOperatorToken sets auth.mode to bearer and appends operatorToken, plus any extra tokens (in one atomic write, never a second, separate write that could land only one of them), into the Moltnet config file at path, reusing the same plaintext-preserving writeback machinery as WritePairing (untyped map edit, atomic write, mode 0600, symlink-refusing target).

It refuses with ErrAuthTokensExist when auth.tokens already has entries it cannot safely upgrade, with one exception (PLAN.md phase 7.0's "unblock the --bearer upgrade" fix, added once plain `init` started minting its own operator token, below): when the config's *only* existing token shares operatorToken.ID, canUpgradeOperatorOnlyOpenConfig treats it as exactly the operator-only open config plain `init` writes, and this function upgrades it in place, flipping auth.mode to bearer (via writeAuthTokensToDoc, shared with AddConsoleToken) and writing only the extra tokens, while leaving the existing operator entry completely untouched. That is deliberate, not an oversight: re-writing it here would silently rotate the operator's secret and could widen its scopes to whatever the caller passed (bearerMoltnetConfig-shaped callers pass operatorToken with the "pair" scope plain `init`'s operator token must never carry; see defaultMoltnetConfig's doc comment, templates.go). Every other existing-tokens shape (more than one token, or a single token under a different id) still refuses outright, so `init --bearer` never reshapes a config an operator set up by hand.

`moltnet init --bearer` calls this with both the operator token ([observe, write, admin], unchanged) and a second observe-only "console" token as extra, so a freshly bearer-enabled network always has a token `moltnet console` can hand the browser, closing the gap where init minted a token the console command was never allowed to use. On the upgrade path above, only that console token actually lands; the operator token argument is discarded once its id is confirmed to already exist.

func AddOperatorTokenPreservingOpenMode added in v0.1.15

func AddOperatorTokenPreservingOpenMode(path string, operatorToken AuthTokenWriteback) error

AddOperatorTokenPreservingOpenMode inserts operatorToken into an existing Moltnet config's auth.tokens[] without ever touching auth.mode, unlike writeAuthTokensToDoc (shared by AddOperatorToken and AddConsoleToken), which always flips it to "bearer". It backs plain `init`'s PLAN.md phase 7.0 repair path: a config written before phase 7.0 shipped has auth.mode: open and no operator token at all, so its owner has no way to administer their own network; re-running plain `init` against it now fixes that in place, atomically, while leaving the network's local-first "open" posture exactly as the owner left it.

It only ever writes when the config is genuinely that shape: auth.mode already "open" and auth.tokens[] currently empty. Anything else refuses rather than guess:

  • auth.mode not "open" returns ErrAuthModeNotOpen, outside this repair's stated scope (a hand-edited bearer/none config), and not plain `init`'s concern either way, since it has never rewritten an existing config's auth.mode.
  • any existing auth.tokens[] entries return ErrAuthTokensExist, PLAN.md phase 7.0 leaves such a config untouched rather than guessing whether adding a second admin-scoped credential next to whatever an operator already configured by hand is safe; the caller (runInit) prints a note explaining that rather than silently doing nothing.

func DiscoverPath

func DiscoverPath(explicit string) (string, bool, error)

func GenerateRandomToken added in v0.1.15

func GenerateRandomToken(bits int) (string, error)

GenerateRandomToken returns a base64url-encoded random value with at least bits worth of entropy (rounded up to a whole byte). It backs the relay pairing invite's room name and pairing token: the relay's RELAY_TOKEN is worker-wide, so this entropy is what keeps one invited friend out of another friend's relay room.

func HomeMoltnetDir added in v0.1.15

func HomeMoltnetDir() (string, error)

HomeMoltnetDir returns ~/.moltnet, the root `moltnet init` writes into for global per-network installs when --dir is not given (PLAN.md phase 4's "onboarding fast path").

func InviteReceiptPath added in v0.1.15

func InviteReceiptPath(configPath, pairingID string) string

InviteReceiptPath returns the path WriteInviteReceipt/LoadInviteReceipt/ RemoveInviteReceipt use for pairingID's receipt, alongside the Moltnet config's other per-network runtime state (the .moltnet/ convention relaydeploy.CredentialsPath and register-agent's identity.json already use). It is exported for callers (tests, `pair invite show`) that want the absolute path without performing an operation; the three functions above derive the identical relative path themselves via inviteReceiptRelPath so they can resolve it through an os.Root (see atomicWriteSecretFileInRoot's doc comment) instead of the plain absolute path this function returns.

The filename is a SHA-256 of pairingID -- pairingID is hashed raw, not trimmed. Pairing ids are operator- or invite-supplied strings with no filename-safety grammar of their own -- WritePairing accepts any string as pairing.ID, and even *network* ids only recently gained a restrictive grammar (SETUP.md's Q2). Deriving a path straight from an unvalidated id would let a crafted --id (or an invite's pairing_id field, which `pair <code>` also feeds into a local pairing.ID with no round trip to confirm it) escape .moltnet/invites/ via "..", "/", or similar. Hashing sidesteps that without this package inventing a new pairing-id validation policy it does not own.

Trimming used to happen here, before hashing: that meant " friend " and "friend" hashed to the same file, so writing one clobbered the other's receipt and removing one deleted the other's -- reachable from the network side, since `pair <code>` writes an invite's pairing_id into pairings[] verbatim, with no trim of its own (unlike `pair invite --id`, which does trim before this function ever sees the id). Any forgiveness for an operator's own typo'd whitespace belongs at the CLI boundary that reads --id/positional pairing-id arguments (see runPairInviteShow/runPairRevoke, cmd/moltnet/pair_invite.go and pair_revoke.go), not inside the hash that two different callers' ids must not collide through.

func IsLoopbackListenAddr added in v0.1.15

func IsLoopbackListenAddr(addr string) bool

IsLoopbackListenAddr reports whether addr (a server.listen_addr value, e.g. "127.0.0.1:8787", ":8787", "0.0.0.0:8787", "[::1]:8787") only ever accepts direct TCP connections originating on this machine. An address with no host component (":8787") or an unparseable host is treated as non-loopback, the same "binds every interface" behavior Go's net/http gives an empty host, and the conservative default when this can't be determined for certain: this function's only job is deciding whether to warn, so an ambiguous case should warn rather than stay silent.

A true result here is a narrower claim than "unreachable from off this machine": a loopback bind with a reverse proxy running on the same box (the topology guides/public-open-networks.md and guides/securing-remote-agents.md both recommend) is fully internet reachable through that proxy; every request the app process sees still carries a loopback RemoteAddr, on the proxy's behalf. This function has no request to inspect at server-start / `moltnet validate` time, so it can only ever reason about the bind; a live per-request decision (see internal/transport/discovery.go's requestIsDirectLoopback, used for the GET /install.md anonymous carve-out) has to check the request itself instead, precisely because this bind-only signal is not sufficient there.

func KeysDirForConfigPath added in v0.1.22

func KeysDirForConfigPath(configPath string) string

KeysDirForConfigPath is the keys directory the server uses for the config file at configPath: $MOLTNET_KEYS_DIR when set, else `<dir>/.moltnet/keys`. Every CLI command that reads or writes keys, pins, grants or pending joins resolves through it, so the CLI and the running server share one store.

func ListNetworkIDs added in v0.1.15

func ListNetworkIDs() ([]string, error)

ListNetworkIDs returns the sorted subdirectory names under ~/.moltnet, each one a network id `moltnet init` created there, regardless of whether the network still contains a config file. Unlike resolveHomeNetworkConfig (which only counts a directory when it holds a specific filename), this is used by `moltnet uninstall` to enumerate every network home so a config file removed by hand does not hide a network's leftover service or data from the uninstall plan. A missing ~/.moltnet is not an error: it just means no networks were ever created there.

func LoadApplyFile added in v0.1.12

func LoadApplyFile(path string) (protocol.ApplyConfigRequest, string, error)

func NetworkHomeDir added in v0.1.15

func NetworkHomeDir(id string) (string, error)

NetworkHomeDir returns the per-network directory `moltnet init` creates under ~/.moltnet for network id.

func NonLoopbackAnonymousWriteWarning added in v0.1.15

func NonLoopbackAnonymousWriteWarning(config Config) string

NonLoopbackAnonymousWriteWarning returns a non-empty warning when config resolves to a listen address reachable from outside this machine while its effective auth posture leaves write/admin routes reachable anonymously (PLAN.md phase 6a, item 2, broadened by the phase 6a review's P2-3): that covers both agent self-registration being open ("any reachable host may register an agent") and auth.mode being "none" (every write and admin route is anonymous outright, with no registration step needed at all, the pre-phase-6a default, and strictly worse than open registration). It returns "" when the bind is loopback-only.

The original version of this check keyed only on agent_registration, so a non-loopback bind with the old `auth.mode: none` default, every write and admin route anonymous, warned about nothing, while a safer bearer network with open registration correctly warned. That inversion is the bug this broadened check fixes: mode: none is checked first, and on its own, before agent_registration is even considered.

config.Auth.AgentRegistration is read post-merge (config_load.go's mergeFileConfig already resolves it, including auth.mode: open's implicit override), so the registration-open half fires identically however registration ended up open: an explicit auth.agent_registration: open, or auth.mode: open.

Round 2's P2-4: the registration-open branch also names anonymous read, not just write, when it applies -- auth.mode: open forces auth.public_read: true alongside agent_registration: open (mergeFileConfig, config_load.go), and plain `init`'s own starter config (defaultMoltnetConfig, cmd/moltnet/templates.go) writes a "general" room with visibility: public. Before this fix, a widened bind on that exact default produced a warning naming only write-scoped self-registration -- silent about the same bind also serving that room's full history over REST and its live stream over SSE to any anonymous visitor, no token at all. The remedy is identical either way (tighten auth, or bind to loopback), so this does not change what an operator has to do about it; it only stops describing the exposure incompletely.

Callers: internal/app.New logs this at server start (app.go), and `moltnet validate` (cmd/moltnet/validate.go) prints it so an operator sees the exposure before ever starting the server.

func RemoveFollow added in v0.1.20

func RemoveFollow(path, id string) error

RemoveFollow removes only the subscription. Durable mirror history remains.

func RemoveInviteReceipt added in v0.1.15

func RemoveInviteReceipt(configPath, pairingID string) error

RemoveInviteReceipt deletes pairingID's receipt file, if any. It is RevokePairing's (config_writeback_revoke.go) companion for the receipt the way removeAuthToken is for the auth.tokens[] entry: a revoked pairing must not leave a live-looking invite code recoverable on disk. Removing a receipt that never existed is a silent no-op, matching removeAuthToken/stripPairingFromRoomFederations's own "already-legitimately -absent state" handling.

func RemoveRoomFromConfig added in v0.1.15

func RemoveRoomFromConfig(path string, roomID string) error

RemoveRoomFromConfig deletes the rooms[] entry with the given id from the Moltnet config file at path, preserving every other plaintext secret already there.

P0-1 (confirmed live): store.RemoveRoomContext (internal/store/sql_store.go) is a soft delete -- it tombstones the row (deleted_at) but keeps its primary key. `room remove` used to only call DELETE /v1/rooms/{id} against the running service; the config's own rooms[] entry for that room survived untouched. On the next restart, applyRequestFromConfig (internal/app/app.go) replays every config-declared room: CreateRoomContext's plain INSERT hits the tombstoned primary key and fails with ErrRoomExists, so the apply path falls back to ReconcileRoomContext -- which never finds a live row for a soft-deleted id and returns not-found. The network fails to start. This was pre-existing for any hand-written config room, but `room create` (this same phase) started writing rooms into config, so it now applies to every room that command creates.

The fix mirrors UpdateRoomMembers (config_writeback_membership.go): same document-editing shape, same ErrRoomConfigNotFound for "this room was never declared here" (created directly via the API, the console, or `moltnet apply`, none of which touch rooms[] -- nothing to remove, and no restart hazard either, since applyRequestFromConfig only replays rooms this file actually lists). The write is atomic (temp file + rename), mode 0600, and refuses a symlinked target, matching every other config writeback in this package.

func ResolveConfigPath added in v0.1.15

func ResolveConfigPath(explicit string, id string) (path string, found bool, err error)

ResolveConfigPath implements the shared Moltnet server config discovery order for start/pair/relay/admin/service:

  1. Explicit (--config, or MOLTNET_CONFIG) wins outright.
  2. Otherwise, when id is non-empty: ~/.moltnet/<id>/Moltnet is resolved first. If ~/.moltnet/<id>/ does not exist at all, this falls back to the same cwd discovery order step 3 uses, but ONLY when that cwd candidate sniffs as text AND its own network.id equals id (P0-2, a --dir install resolving by name from its own directory). This is a name match, never blind cwd precedence: a same-named file (or, on a case-insensitive filesystem such as APFS, a same-named compiled binary like ./moltnet) that doesn't self-identify as id is refused, erroring by name.
  3. Otherwise (no explicit, no id), the cwd discovery order (./Moltnet and the alternate filenames DiscoverPath recognizes) is tried, then the sole network directory under ~/.moltnet/, cwd participates only in this no-flag, no-id path (aside from the name-matched fallback in step 2).

found is false, with a nil error, only when nothing exists anywhere in that order; callers that treat a missing config as "use defaults" (start) and callers that treat it as an error ("run moltnet init first": pair, relay) both build on this same tri-state result.

func ResolveNodeConfigPath added in v0.1.15

func ResolveNodeConfigPath(explicit string, id string) (path string, found bool, err error)

ResolveNodeConfigPath applies the same fallback tier to MoltnetNode discovery, used by `moltnet node`: explicit wins outright; a non-empty id resolves ~/.moltnet/<id>/MoltnetNode first, falling back to a name-matched cwd candidate (moltnet.network_id == id) only when ~/.moltnet/<id>/ does not exist at all (P0-2); with neither explicit nor id, cwd is tried before the sole ~/.moltnet/ network. explicit here already carries MOLTNET_NODE_CONFIG, resolved by the caller before this is called.

func UpdateRoomMembers added in v0.1.15

func UpdateRoomMembers(path string, roomID string, add []string, remove []string) error

UpdateRoomMembers applies add/remove membership changes to an existing rooms[] entry's members list in the Moltnet config file at path.

PLAN.md 7A.1 named the bug this closes: `admin room members add` PATCHes the live API only, but startup's applyRequestFromConfig (app.go) replaces every config-declared room's member list wholesale via ReconcileRoomContext, so membership granted only through the API is silently undone at the next restart -- reproduced live as "chat members = [alice] before restart, nil after". `admin room members add` is also named as the pairing-grant aftercare step in the same plan, so the two mechanisms were fighting each other. This gives membership one durable source of truth: the API call still updates the live service immediately (no restart needed to see the effect), and this writeback keeps the config file in sync so the *next* restart reconciles to the same state instead of wiping it.

A caller whose room id is not in rooms[] at all -- see ErrRoomConfigNotFound -- should treat that as "this room's membership cannot survive a restart yet" and warn, not fail: the live API mutation this writeback follows has already succeeded by the time this runs, and a config file that never declared the room in the first place is a pre-existing condition this single call cannot repair (the room itself would need `room create` or `pair invite --room` first).

add and remove are both normalized with protocol.UniqueTrimmedStrings, add is applied first (removal always wins when the same id appears in both, matching protocol.UpdateRoomMembersRequest's own semantics), and both are safe to pass empty. The write is atomic (temp file + rename), mode 0600, and refuses a symlinked target -- the same invariant every other config writeback in this package holds.

func ValidateListenAddr added in v0.1.15

func ValidateListenAddr(addr string) error

ValidateListenAddr validates addr as a syntactically well-formed server.listen_addr value: a "host:port" pair in net.SplitHostPort's grammar (an empty host is fine -- ":8787" binds every interface, exactly matching how net/http.Server.ListenAndServe treats it -- but a missing or non-numeric port is not). It does not attempt to bind addr or resolve its host, and deliberately does not validate the host's own character content beyond what SplitHostPort itself requires (bracket balancing for an IPv6 literal): a stricter DNS-hostname grammar would risk rejecting a real, already-working listen_addr this validator has never seen before -- non-RFC-1123 hostnames (an underscore-containing Docker/Compose service name, for instance) are common enough in practice that guessing at that grammar here is more likely to break a live config than to catch a real typo. It only rejects values that could never be a valid listen_addr syntactically, so an operator sees the mistake immediately rather than only once the server tries and fails to bind (or, for `moltnet init --listen`, not at all until some later `moltnet start`).

Called both from validateServer (config_file.go, on every config load -- closing the "config_file.go validates none today" gap for listen_addr) and directly from `moltnet init --listen` (cmd/moltnet/init.go) before any file is written, so the same grammar is enforced whether a bad address arrives via a hand-edited config file or a flag. TODO(setup-p2): ":http" is a legal service-name port net.Listen itself accepts (it resolves service names via the system's services database) but strconv.Atoi below rejects outright, since it only ever accepts a numeric port. A net.LookupPort("tcp", port) fallback when Atoi fails would close the gap, but that call resolves against the local system's services database (getservbyname on Unix) -- environment-dependent and not guaranteed present in a minimal container, unlike everything else this validator checks -- so it is left as a known, narrow gap rather than making config validation itself non-portable.

mergeFileConfig (config_load.go) used to carry a second, related gap here -- this function trims addr before validating it, so " 127.0.0.1:8787 " (leading/trailing whitespace) passed validation, but assigned config.ListenAddr untrimmed, so the value that actually reached net.Listen at server start still carried the whitespace and failed to bind. Fixed: mergeFileConfig now trims server.listen_addr the same way mergeEnvConfig's envValue already trims MOLTNET_LISTEN_ADDR, so both config sources reach net.Listen identically trimmed.

func WriteFollow added in v0.1.20

func WriteFollow(path string, follow protocol.Follow) error

WriteFollow appends a subscription without round-tripping existing secrets.

func WriteInviteReceipt added in v0.1.15

func WriteInviteReceipt(configPath string, receipt InviteReceipt) error

WriteInviteReceipt persists receipt at InviteReceiptPath(configPath, receipt.PairingID), atomically (temp file + rename in the same directory), mode 0600, refusing a symlinked target -- the same discipline WritePairing/atomicWriteConfigFile apply to the config file itself, since an invite code is exactly as sensitive as the pairing token it accompanies (protocol.Invite's doc comment: "the invite's entire purpose is to carry plaintext secrets").

func WritePairing added in v0.1.15

func WritePairing(path string, pairing PairingWriteback, authToken AuthTokenWriteback, roomIDs []string, force bool) error

WritePairing appends pairing and authToken into the Moltnet config file at path, preserving every other plaintext secret already there. It refuses to overwrite an existing pairings[] entry with the same id unless force is true. The write is atomic (temp file + rename in the same directory), mode 0600, and refuses a symlinked target, matching the server's private-config invariant for files that hold inline secrets.

When roomIDs is non-empty, each named room is created in rooms[] if absent, and its federation is extended to allow pairing.ID: an absent or "none" federation becomes a list naming just this pairing, an existing list gets this pairing appended (idempotently: re-running never duplicates the entry), and an existing "all" federation is left alone. This is the same single atomic write and post-write reload check as the pairing/auth-token writeback, so a room upsert never lands without the pairing it exists for, or vice versa.

func WriteRoom added in v0.1.15

func WriteRoom(path string, room RoomWriteback) error

WriteRoom appends room into the Moltnet config file at path, preserving every other plaintext secret already there. It refuses outright when rooms[] already has an entry with room.ID, unlike WritePairing's --force-overridable ErrPairingExists, `room create` has no in-place-update story yet (that is `admin room members add`/a future `room update`'s job), so a second `room create` for the same id is always a mistake to surface, never a silent overwrite. The write is atomic (temp file + rename in the same directory), mode 0600, and refuses a symlinked target; the same mechanism WritePairing uses, reused here rather than shared with it directly: a room create writes a much wider field set (name, visibility, write_policy, members, credential) than the {id, federation} shape WritePairing's own upsertSharedRoom knows how to extend (PLAN.md 7A.1; upsertSharedRoom is the wrong helper for this precisely because of that narrower shape).

room.Federation must never be nil: PLAN.md's 7A federation rule requires every config-time room to declare an explicit stance so validateRoomFederationStances never trips the first time a pairing is added. Callers that want the "no federation configured yet" default pass protocol.DefaultRoomFederation() explicitly rather than leaving this nil, WriteRoom itself does not substitute a default, so a nil here would write a room this same package's own federation rule then refuses to load.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

func New

func New(config Config) (*App, error)

func (*App) Close added in v0.0.2

func (a *App) Close() error

func (*App) Handler added in v0.0.2

func (a *App) Handler() http.Handler

func (*App) Run

func (a *App) Run(ctx context.Context) error

type AuthTokenWriteback added in v0.1.15

type AuthTokenWriteback struct {
	ID      string
	Value   string
	Network string
	Scopes  []string
}

AuthTokenWriteback is the plaintext pair-scoped auth.tokens[] entry a pair command appends alongside the pairing.

type Config

type Config struct {
	Clock                 *bridgeconfig.ClockConfig
	AllowHumanIngress     bool
	CausalEventsPath      string
	Console               ConsoleConfig
	DebugEvents           bool
	DisableDirectMessages bool
	Auth                  authn.Config
	ListenAddr            string
	NetworkID             string
	NetworkName           string
	Pairings              []protocol.Pairing
	Follows               []protocol.Follow
	PublicBaseURL         string
	Rooms                 []RoomConfig
	Storage               StorageConfig
	Version               string
	// contains filtered or unexported fields
}

func ConfigFromEnv

func ConfigFromEnv(version string) (Config, error)

func LoadConfigForPath added in v0.1.15

func LoadConfigForPath(path string, version string) (Config, error)

LoadConfigForPath loads and validates an explicit Moltnet config path through the same full load path the server uses at startup, including environment overrides. Unlike LoadFile (used by `moltnet validate`), it merges environment configuration, so it is the right check to run after a config writeback: LoadFile alone can pass a config that would fail once the server actually starts.

func LoadFile

func LoadFile(path string, version string) (Config, error)

func (Config) KeysDir added in v0.1.22

func (c Config) KeysDir() string

KeysDir is where this network's signing key and peer pins live: $MOLTNET_KEYS_DIR when set, else next to the config file when there is one (`<dir>/.moltnet/keys`), else next to the durable store file. Empty means no durable location (env-only memory or Postgres without a config file).

type ConsoleAnalyticsConfig added in v0.1.11

type ConsoleAnalyticsConfig struct {
	Provider      string
	MeasurementID string
}

type ConsoleConfig added in v0.1.11

type ConsoleConfig struct {
	Analytics ConsoleAnalyticsConfig
}

type InviteReceipt added in v0.1.15

type InviteReceipt struct {
	PairingID string    `json:"pairing_id"`
	Code      string    `json:"code"`
	Exp       time.Time `json:"exp"`
}

InviteReceipt is the plaintext record `pair invite` (cmd/moltnet/pair_invite.go) persists next to the Moltnet config so the exact code it is about to print survives an interruption between WritePairing's commit and the fmt.Fprintln that shows the code to the operator. Before this file existed, the code and its expiry were never written anywhere (SETUP.md's "no journal" claim, exception #1): the pairing landed in config, but the one secret the peer actually needs to consume it was gone the instant the process died before printing.

Fields are plain strings/time.Time, not protocol.SecretString, for the same reason PairingWriteback (config_writeback.go) uses plain strings: the receipt's only purpose is to hand the exact code back on request, and a redacted round-trip would defeat that.

func LoadInviteReceipt added in v0.1.15

func LoadInviteReceipt(configPath, pairingID string) (receipt InviteReceipt, ok bool, err error)

LoadInviteReceipt reads back the receipt WriteInviteReceipt stored for pairingID against the Moltnet config at configPath. ok is false with a nil error when nothing was ever written for this id -- a pairing generated with --print-only (which never writes a pairing at all), one generated before this feature existed, or one RemoveInviteReceipt already cleared (`pair revoke`).

A decoded receipt with an empty Code is treated as corrupt and returned as an error, not as ok=true: a `null` or `{}` body used to decode to a zero-value InviteReceipt with ok=true, so `pair invite show` returned err=nil and printed an empty line -- a wizard scraping stdout got "" back as "the code," indistinguishable from success. A zero Exp is left alone (still never treated as expired -- Expired's own doc comment): that shape is the documented, deliberate fallback for a hand-edited or pre-validation file, not evidence of corruption on its own.

func (InviteReceipt) Expired added in v0.1.15

func (receipt InviteReceipt) Expired(now time.Time) bool

Expired reports whether receipt's invite has already passed its expiry as of now. A zero Exp (should not happen for a receipt this package wrote, but defensive against a hand-edited or pre-validation file) is never treated as expired -- Validate on the decoded invite is still the authority that actually enforces expiry when the code is used.

type JSONStorageConfig

type JSONStorageConfig struct {
	Path string `json:"path,omitempty" yaml:"path,omitempty"`
}

type PairingRelayWriteback added in v0.1.15

type PairingRelayWriteback struct {
	URL   string
	Room  string
	Token string
}

PairingRelayWriteback is the plaintext pairings[].relay entry.

type PairingWriteback added in v0.1.15

type PairingWriteback struct {
	ID                string
	RemoteNetworkID   string
	RemoteNetworkName string
	Token             string
	Relay             *PairingRelayWriteback
	// Scope "rooms" writes a room link (shared rooms only).
	Scope string
}

PairingWriteback is the plaintext pairings[] entry a pair command appends to a Moltnet config file. It uses plain strings instead of protocol.SecretString on purpose: SecretString marshals as [REDACTED], so round-tripping the loaded config structs through YAML/JSON would corrupt every secret already in the file. WritePairing instead edits the document as an untyped map, so no SecretString ever enters the write path.

type PostgresStorageConfig

type PostgresStorageConfig struct {
	DSN string `json:"dsn,omitempty" yaml:"dsn,omitempty"`
}

type RevokeResult added in v0.1.15

type RevokeResult struct {
	TokenRemoved bool
}

RevokePairing removes pairingID from pairings[], removes the matching auth.tokens[] entry, and strips pairingID out of every room's federation list in the Moltnet config file at path. It is WritePairing's inverse: `pair invite`/`pair <code>` write the peer's inbound credential into auth.tokens[] under the exact same id as the pairing (config_writeback.go's PairingWriteback/AuthTokenWriteback), so a revoke that only removed the pairings[] entry and left that token behind would leave the peer fully authenticated on a pairing that no longer appears anywhere in pairings[]; UX.md and PLAN.md's 7B.3 both call this out explicitly as the part that must not be skipped.

Decision (7B.3's open question, since the plan asked for one and a reason): revoke DOES strip the room federation grant too, not just the pairing and its token. Leaving a room's federation list naming a revoked pairing id is a live grant waiting to be silently reactivated: generating a *new* pairing that happens to reuse the same id (`pair invite --id <id>` again) mints a fresh credential and, with the old federation entry untouched, instantly regains access to every room it names; no separate re-grant step, no visible signal the old grant survived. That makes revocation reversible by re-pairing alone, which defeats the point of a revoke command: an operator revoking access wants it gone until they deliberately grant it again. Stripping the federation entry means re-pairing under the same id starts from zero room access, exactly like a brand new pairing would; getting the old rooms back requires saying so again (`pair invite --room <id>` or `room create --federation`).

A federation list that becomes empty after stripping reverts to the scalar "none": protocol.ValidateRoomFederation rejects an explicit "list" naming zero pairings outright ("federation list must name at least one pairing"), so leaving an emptied list in place would make the room's own config un-reloadable, caught immediately by this call's post-write LoadConfigForPath check (via the caller, revokePairingWithRollback), which would then roll the whole revoke back. "none" is the correct steady state for a room with no remaining federation grants regardless: it is exactly what protocol.DefaultRoomFederation returns for a room that was never federated to begin with. Only "all" federation is left untouched, matching upsertSharedRoom's own asymmetric handling of that case (federatedRoomFederationValue); there is nothing to strip from a room that federates with everyone.

RevokeResult reports what RevokePairing actually found and removed, so a caller (cmd/moltnet/pair_revoke.go) can describe the real outcome instead of assuming every leg it tries to strip was actually present. Namely: a pairing whose auth.tokens[] entry uses a different id than the pairing itself (an operator hand-edited config, most plausibly, since every id `pair invite`/`pair <code>` ever writes matches by construction) leaves the peer's credential live even after a "successful" revoke, and TokenRemoved is how the caller learns that happened instead of printing the same "credential removed" line regardless.

func RevokePairing added in v0.1.15

func RevokePairing(path string, pairingID string) (RevokeResult, error)

Like WritePairing, this refuses a symlinked config target and writes atomically (temp file + rename), mode 0600. It does not itself verify the result reloads cleanly; callers wanting that (and a rollback on failure) use the same LoadConfigForPath-and-restore pattern writePairingWithRollback (cmd/moltnet/pair.go) already establishes for WritePairing.

type RoomConfig

type RoomConfig struct {
	ID          string                   `json:"id" yaml:"id"`
	Name        string                   `json:"name,omitempty" yaml:"name,omitempty"`
	Members     []string                 `json:"members,omitempty" yaml:"members,omitempty"`
	Visibility  string                   `json:"visibility,omitempty" yaml:"visibility,omitempty"`
	WritePolicy string                   `json:"write_policy,omitempty" yaml:"write_policy,omitempty"`
	Federation  *protocol.RoomFederation `json:"federation,omitempty" yaml:"federation,omitempty"`
	Credential  *RoomCredentialConfig    `json:"credential,omitempty" yaml:"credential,omitempty"`
}

type RoomCredentialConfig added in v0.1.14

type RoomCredentialConfig struct {
	Token     protocol.SecretString `json:"token,omitempty" yaml:"token,omitempty"`
	TokenEnv  string                `json:"token_env,omitempty" yaml:"token_env,omitempty"`
	TokenPath string                `json:"token_path,omitempty" yaml:"token_path,omitempty"`
}

func (RoomCredentialConfig) HasTokenSource added in v0.1.14

func (c RoomCredentialConfig) HasTokenSource() bool

func (RoomCredentialConfig) ResolveToken added in v0.1.14

func (c RoomCredentialConfig) ResolveToken() (string, bool, error)

func (RoomCredentialConfig) ResolveTokenPath added in v0.1.14

func (c RoomCredentialConfig) ResolveTokenPath(baseDir string) RoomCredentialConfig

type RoomWriteback added in v0.1.15

type RoomWriteback struct {
	ID          string
	Name        string
	Visibility  string
	WritePolicy string
	Members     []string
	Federation  *protocol.RoomFederation
	Credential  string
}

RoomWriteback is the plaintext rooms[] entry `moltnet room create` appends to a Moltnet config file. Like PairingWriteback (config_writeback.go), it carries Credential as a plain string instead of protocol.SecretString: a typed Config/RoomConfig round-trip would marshal an existing room's credential as [REDACTED] (SecretString's MarshalJSON/MarshalYAML), so WriteRoom edits the document as an untyped map exactly like WritePairing does, and this type mirrors that same plaintext-preserving shape for rooms.

type SQLiteStorageConfig

type SQLiteStorageConfig struct {
	Path string `json:"path,omitempty" yaml:"path,omitempty"`
}

type StorageConfig

type StorageConfig struct {
	Kind     string                `json:"kind" yaml:"kind"`
	JSON     JSONStorageConfig     `json:"json,omitempty" yaml:"json,omitempty"`
	SQLite   SQLiteStorageConfig   `json:"sqlite,omitempty" yaml:"sqlite,omitempty"`
	Postgres PostgresStorageConfig `json:"postgres,omitempty" yaml:"postgres,omitempty"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL