sessiondir

package
v0.32.1 Latest Latest
Warning

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

Go to latest
Published: Aug 1, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package sessiondir is the server-side session directory for multiplayer (v0.27 S3, ADR 0034): a versioned session.json holding the player roster, outstanding invite codes, and the body-catalog hash, plus one save envelope per enrolled player (current save.SchemaVersion). The local single-player save.json and saves/ directory are never touched by anything here.

The Store serialises in-process mutations behind a mutex — every ssh session lives in the host's process (the ssh-only MVP has no wire), so this is the whole concurrency story. A `serve invite` CLI run against a live server is a separate process and races last-write-wins; acceptable while sessions are friends-hosted.

Index

Constants

View Source
const (
	RoleHost  = "host"
	RoleAdmin = "admin"
	RoleGuest = "guest"
)

Roster roles. Host is the session's root operator (ADR 0034): the player whose machine runs the session, with invite/removal authority and the sole power to delegate administration. Admin is a guest the host has promoted — it carries the invite/removal capability but not delegation (single-rooted escalation, v0.30 S2). Everyone else is a guest. The role is a plain persisted string on the roster entry, so adding admin is additive: no MetaVersion bump, no migration.

View Source
const HostFingerprint = "local"

HostFingerprint marks the host's roster entry: the host plays in-process over local stdio, so there is no ssh key to print.

View Source
const MetaVersion = 2

MetaVersion is the session.json schema version. Bump + migrate on shape changes, mirroring the save-envelope discipline.

v1 (v0.27): roster + invites + catalog hash. v2 (v0.28 S5): adds Docks — the cross-player-dock cross-ref, so a guest riding another player's stack (whose craft therefore isn't in its own payload) resumes docked-as-guest on reconnect. A v1 session.json migrates forward (Docks defaults empty) via migrateMetaV1ToV2; live v0.27 sessions never break.

Variables

View Source
var (
	ErrUnknownInvite = errors.New("sessiondir: unknown or already-used invite code")
	ErrNotEnrolled   = errors.New("sessiondir: fingerprint not in roster")
)

Functions

func DefaultDir

func DefaultDir() (string, error)

DefaultDir is $XDG_STATE_HOME/terminal-space-program/session (falling back to ~/.local/state), sibling of save.json and saves/.

Types

type DockLink struct {
	ID            uint64 `json:"id"`
	Owner         string `json:"owner"`
	OwnerHandle   string `json:"owner_handle,omitempty"`
	DockerCraftID uint64 `json:"docker_craft_id"`
	CompositeID   uint64 `json:"composite_id,omitempty"`
	GuestOwner    string `json:"guest_owner"`
	GuestHandle   string `json:"guest_handle,omitempty"`
	GuestCraftID  uint64 `json:"guest_craft_id"`
	Phase         int    `json:"phase"`
}

DockLink is one cross-player dock's durable cross-ref (v0.28 S5, ADR 0034 §6). It is the persisted, serialisable subset of the live relay.DockRecord — enough for a reconnecting session to resume: the stack owner + composite, and the guest player + their craft riding in it. The transient in-flight payloads (the craft handoffs) are NOT persisted; a dock that was mid-handshake at shutdown resolves fresh. Phase is the relay.DockPhase int (0 pending / 1 active); sessiondir stays below relay so it carries the raw int rather than importing it.

type Invite

type Invite struct {
	Code      string    `json:"code"`
	Handle    string    `json:"handle"`
	CreatedAt time.Time `json:"created_at"`
}

Invite is one outstanding (unredeemed) invite code. The handle is pre-bound at mint and editable at enroll (ADR 0034 addendum).

type Meta

type Meta struct {
	Version         int      `json:"version"`
	BodyCatalogHash string   `json:"body_catalog_hash"`
	Roster          []Player `json:"roster"`
	Invites         []Invite `json:"invites"`
	// Docks is the cross-player-dock cross-ref (v2+, v0.28 S5). Empty
	// in a fresh or non-docking session; the serve layer syncs it from
	// the live relay ledger on change.
	Docks []DockLink `json:"docks,omitempty"`
}

Meta is the session.json shape.

type Player

type Player struct {
	Fingerprint string    `json:"fingerprint"`
	Handle      string    `json:"handle"`
	Role        string    `json:"role"`
	EnrolledAt  time.Time `json:"enrolled_at"`
	Calibrated  bool      `json:"calibrated"`
}

Player is one roster entry. Fingerprint is the ssh public-key SHA256 fingerprint (the stable identity — handles are editable).

type Store

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

Store owns one session directory. All mutations re-read session.json under the lock, so a CLI mint between server reads is picked up on the next connect.

func Open

func Open(dir string) (*Store, error)

Open creates the directory if needed and initialises session.json on first use, stamping the current body-catalog hash.

func (*Store) DemoteAdmin added in v0.30.0

func (s *Store) DemoteAdmin(fingerprint string) error

DemoteAdmin returns an admin to guest by fingerprint. Idempotent for a player already a guest; rejects the host and unknown fingerprints.

func (*Store) Enroll

func (s *Store) Enroll(code, fingerprint, handle string) (Player, error)

Enroll redeems the code (one-time) and adds the player to the roster in a single locked step. The code is re-validated here, so a Peek that raced another enrollment fails cleanly instead of double-spending. Calibrated is stamped true — the enroll flow runs behind the calibration card.

func (*Store) EnsureHost

func (s *Store) EnsureHost(handle string) (Player, error)

EnsureHost auto-enrolls the serving player as roster entry #1 with the Host role on first --serve (idempotent — an existing host entry is returned untouched, so a renamed handle survives restarts).

func (*Store) FindPlayer

func (s *Store) FindPlayer(fingerprint string) (Player, error)

FindPlayer looks a fingerprint up in the roster.

func (*Store) HasPayload

func (s *Store) HasPayload(fingerprint string) bool

HasPayload reports whether the player has a persisted world.

func (*Store) LatestSimTime

func (s *Store) LatestSimTime() (time.Time, bool)

LatestSimTime scans every persisted player payload for the maximum stored subspace time — offline players hold the frontier (v0.27 S4, ADR 0034: you can never start in someone's past, online or not). ok is false when no payload parses. Unreadable files are skipped: frontier is a floor, not an integrity check.

func (*Store) LoadPlayer

func (s *Store) LoadPlayer(fingerprint string) (*sim.World, error)

LoadPlayer restores the player's world. fs.ErrNotExist means no payload yet (first session); save.ErrCatalogMismatch propagates so the connect path can reject rather than corrupt (ADR 0034 — reuses the existing save mechanism).

func (*Store) MayAdminister added in v0.30.0

func (s *Store) MayAdminister(fingerprint string) bool

MayAdminister reports whether the given fingerprint may perform session-admin actions — minting/revoking invites and removing players. It is the single authorization predicate the serve-layer handler consults before acting (v0.30 S1, #222): authorization is a capability decided here, in the store, not a UI-presentation detail. Today only the host qualifies; the admin role (v0.30 S2) extends roleMayAdminister without touching any caller. An unknown or unenrolled fingerprint may not administer.

func (*Store) MayDelegate added in v0.30.0

func (s *Store) MayDelegate(fingerprint string) bool

MayDelegate reports whether the fingerprint may promote a guest to admin or demote an admin back to guest. Single-rooted escalation (v0.30 S2): only the host delegates administration — an admin can neither create nor remove another admin. Kept distinct from MayAdminister so the escalation tree stays single-rooted.

func (*Store) MayRemove added in v0.30.0

func (s *Store) MayRemove(actor, target string) bool

MayRemove reports whether actor may remove target from the roster (v0.30 S3, #224) — the guardrail matrix for the first admin power that can lock someone out. The rules keep escalation single-rooted:

  • the actor must carry the admin capability (host or admin);
  • nobody removes themselves (avoids a self-inflicted lockout);
  • nobody removes the host (the session's root);
  • an admin may not remove another admin — only the host may (mirrors promotion being host-only).

Both fingerprints must be enrolled. The store's RemovePlayer still guards the host independently; this predicate is the actor-aware gate the serve handler consults before calling it.

func (*Store) Meta

func (s *Store) Meta() (Meta, error)

Meta returns a fresh read of session.json.

func (*Store) MintInvite

func (s *Store) MintInvite(handle string) (Invite, error)

MintInvite creates a one-time code pre-bound to handle.

func (*Store) Peek

func (s *Store) Peek(code string) (Invite, error)

Peek validates an invite code without consuming it — the enroll flow shows the pre-bound handle for editing before committing.

func (*Store) PromoteAdmin added in v0.30.0

func (s *Store) PromoteAdmin(fingerprint string) error

PromoteAdmin grants the admin role to an enrolled guest by fingerprint. Idempotent — re-promoting an admin is a no-op. The host is rejected (already root) and an unknown fingerprint returns ErrNotEnrolled. Only the host should call this (enforced at the handler via MayDelegate); the store guards the host-role invariant.

func (*Store) RemovePlayer

func (s *Store) RemovePlayer(fingerprint string) error

RemovePlayer drops a guest from the roster: their key no longer resumes and they'd need a fresh invite. The persisted payload stays on disk — a re-invited player finds their program intact. The host entry can't be removed. A live session isn't kicked (MVP): removal gates the NEXT connect.

func (*Store) RevokeInvite

func (s *Store) RevokeInvite(code string) error

RevokeInvite deletes an unredeemed code. ErrUnknownInvite when the code doesn't exist (already redeemed, already revoked, or a typo).

func (*Store) SavePlayer

func (s *Store) SavePlayer(fingerprint string, w *sim.World) error

SavePlayer persists the player's world as a save envelope at the package's current SchemaVersion (the existing save machinery, arbitrary path).

func (*Store) SetDocks added in v0.28.0

func (s *Store) SetDocks(docks []DockLink) error

SetDocks persists the cross-player-dock cross-ref (v0.28 S5). The serve layer calls it when the live relay ledger changes, so a reconnecting guest resumes docked-as-guest. Re-reads under the lock so a concurrent roster edit isn't clobbered.

Jump to

Keyboard shortcuts

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