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
- Variables
- func DefaultDir() (string, error)
- type DockLink
- type Invite
- type Meta
- type Player
- type Store
- func (s *Store) DemoteAdmin(fingerprint string) error
- func (s *Store) Enroll(code, fingerprint, handle string) (Player, error)
- func (s *Store) EnsureHost(handle string) (Player, error)
- func (s *Store) FindPlayer(fingerprint string) (Player, error)
- func (s *Store) HasPayload(fingerprint string) bool
- func (s *Store) LatestSimTime() (time.Time, bool)
- func (s *Store) LoadPlayer(fingerprint string) (*sim.World, error)
- func (s *Store) MayAdminister(fingerprint string) bool
- func (s *Store) MayDelegate(fingerprint string) bool
- func (s *Store) MayRemove(actor, target string) bool
- func (s *Store) Meta() (Meta, error)
- func (s *Store) MintInvite(handle string) (Invite, error)
- func (s *Store) Peek(code string) (Invite, error)
- func (s *Store) PromoteAdmin(fingerprint string) error
- func (s *Store) RemovePlayer(fingerprint string) error
- func (s *Store) RevokeInvite(code string) error
- func (s *Store) SavePlayer(fingerprint string, w *sim.World) error
- func (s *Store) SetDocks(docks []DockLink) error
Constants ¶
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.
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.
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 ¶
var ( ErrUnknownInvite = errors.New("sessiondir: unknown or already-used invite code") ErrNotEnrolled = errors.New("sessiondir: fingerprint not in roster") )
Functions ¶
func DefaultDir ¶
DefaultDir is $XDG_STATE_HOME/terminal-space-program/session (falling back to ~/.local/state), sibling of save.json and saves/.
Types ¶
type DockLink ¶ added in v0.28.0
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 ¶
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
DemoteAdmin returns an admin to guest by fingerprint. Idempotent for a player already a guest; rejects the host and unknown fingerprints.
func (*Store) Enroll ¶
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 ¶
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 ¶
FindPlayer looks a fingerprint up in the roster.
func (*Store) HasPayload ¶
HasPayload reports whether the player has a persisted world.
func (*Store) LatestSimTime ¶
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 ¶
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
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
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
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) MintInvite ¶
MintInvite creates a one-time code pre-bound to handle.
func (*Store) Peek ¶
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
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 ¶
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 ¶
RevokeInvite deletes an unredeemed code. ErrUnknownInvite when the code doesn't exist (already redeemed, already revoked, or a typo).
func (*Store) SavePlayer ¶
SavePlayer persists the player's world as a save envelope at the package's current SchemaVersion (the existing save machinery, arbitrary path).