// Package api exposes the player-facing HTTP interface. package api import ( "context" "encoding/base64" "encoding/hex" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "halcyon/comms" "halcyon/config" "halcyon/runner" "halcyon/store" "halcyon/vm" "halcyon/world" ) type Server struct { st *store.Store cfg config.Config admin *adminAuth // nil when the admin interface is off mapCache mapCache // the admin map's last response } // New returns the HTTP handler. // // POST /register {"name": "..."} -> player, api_key, ship_id // GET /me -> name, credits, day // GET /market -> ore prices // GET /info -> day and the belt's limits // POST /assemble assembly text -> program (base64), size // GET /ships -> your ships // PUT /ships/{id}/program raw bytecode (ship must be in inventory) // POST /ships/{id}/launch {"slot": n} (optional) (enters the belt on the next run; with a slot, that // slot's code is assembled and sealed in first) // GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples) // PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink) // POST /ships/{id}/scrap scrap a ship in the belt (on the next run it vanishes and a new ship // appears in your inventory) // GET /ships/{id}/downlink raw bytes, X-Downlink-Day header; ?day=N picks an earlier one // GET /ships/{id}/downlinks {"days":[...]} the days with a stored downlink // // Authenticated routes take "Authorization: Bearer ". func New(st *store.Store, cfg config.Config, opts ...Option) *http.ServeMux { s := &Server{st: st, cfg: cfg} for _, o := range opts { o(s) } mux := http.NewServeMux() mux.HandleFunc("POST /register", s.register) mux.HandleFunc("GET /market", s.market) mux.HandleFunc("GET /info", s.info) mux.HandleFunc("POST /assemble", s.assemble) mux.Handle("POST /login", s.auth(s.playerLogin)) mux.Handle("GET /me", s.auth(s.me)) mux.Handle("GET /ships", s.auth(s.ships)) mux.Handle("PUT /ships/{id}/program", s.auth(s.program)) mux.Handle("POST /ships/{id}/launch", s.auth(s.launch)) mux.Handle("POST /ships/{id}/scrap", s.auth(s.scrap)) mux.HandleFunc("POST /admin/api/login", s.adminLogin) mux.HandleFunc("POST /admin/api/logout", s.adminLogout) mux.Handle("GET /admin/api/session", s.adminOnly(s.adminSession)) mux.Handle("GET /admin/api/accounts", s.adminOnly(s.adminAccounts)) mux.Handle("GET /admin/api/accounts/{id}", s.adminOnly(s.adminAccount)) mux.Handle("GET /admin/api/accounts/{id}/slots/{n}", s.adminOnly(s.adminSlotSource)) mux.Handle("GET /admin/api/audit", s.adminOnly(s.adminAudit)) mux.Handle("GET /admin/api/map", s.adminOnly(s.adminMap)) mux.Handle("GET /admin/api/map/orbit", s.adminOnly(s.adminMapOrbit)) // ?ship=N: its predicted coasting path mux.Handle("GET /slots", s.auth(s.listSlots)) mux.Handle("GET /slots/{n}", s.auth(s.getSlot)) mux.Handle("PUT /slots/{n}", s.auth(s.putSlot)) mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot)) mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink)) mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink)) mux.Handle("GET /ships/{id}/downlinks", s.auth(s.downlinks)) return mux } func (s *Server) auth(h func(http.ResponseWriter, *http.Request, store.Player)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok { httpError(w, http.StatusUnauthorized, "missing bearer token") return } p, err := s.st.PlayerByKey(r.Context(), key) if err != nil { if errors.Is(err, store.ErrNotFound) { httpError(w, http.StatusUnauthorized, "invalid api key") } else { fail(w, err) } return } h(w, r, p) }) } func httpError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } // fail maps store errors to HTTP statuses. func fail(w http.ResponseWriter, err error) { switch { case errors.Is(err, store.ErrNotFound): httpError(w, http.StatusNotFound, "not found") case errors.Is(err, store.ErrState): httpError(w, http.StatusConflict, err.Error()) case errors.Is(err, store.ErrTaken): httpError(w, http.StatusConflict, err.Error()) default: httpError(w, http.StatusInternalServerError, "internal error") } } func (s *Server) register(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<10)).Decode(&req); err != nil || len(req.Name) == 0 || len(req.Name) > 40 { httpError(w, http.StatusBadRequest, `body must be {"name": "<1-40 chars>"}`) return } p, key, ship, err := s.st.Register(r.Context(), req.Name) if err != nil { fail(w, err) return } s.audit(r, p.ID, "register", ship, 0, map[string]any{"name": p.Name}) writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship}) } // playerLogin is what the sign-in form calls once it has been given an access key: // it checks the key, returns the account, and puts the sign-in in the audit log. // (Every other request is authenticated by the key alone, so it is not a "login".) func (s *Server) playerLogin(w http.ResponseWriter, r *http.Request, p store.Player) { s.audit(r, p.ID, "login", 0, 0, map[string]any{"user_agent": trunc(r.UserAgent(), 200)}) s.me(w, r, p) } func trunc(s string, n int) string { if r := []rune(s); len(r) > n { return string(r[:n]) } return s } func (s *Server) currentDay(ctx context.Context) int64 { v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay) if !ok { return 0 } n, _ := strconv.ParseInt(v, 10, 64) return n } func (s *Server) me(w http.ResponseWriter, r *http.Request, p store.Player) { writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "credits": p.Credits, "day": s.currentDay(r.Context())}) } func (s *Server) market(w http.ResponseWriter, r *http.Request) { v, ok, err := s.st.GetMeta(r.Context(), runner.MetaMarket) if err != nil { fail(w, err) return } if !ok { v = "null" } w.Header().Set("Content-Type", "application/json") io.WriteString(w, v) } // info reports the day and the limits that apply to programs and messages. func (s *Server) info(w http.ResponseWriter, r *http.Request) { ores := make([]string, world.NumOre) for o := range ores { ores[o] = world.Ore(o).String() } writeJSON(w, http.StatusOK, map[string]any{ "day": s.currentDay(r.Context()), "turns_per_day": s.cfg.TicksPerDay, "turn_seconds": s.cfg.TickSeconds(), "cycles_per_turn": s.cfg.CyclesPerTick, "program_bytes": s.cfg.ProgramBytes, "ram_bytes": s.cfg.RAMBytes, "comm_bytes": s.cfg.CommBytes, "ores": ores, }) } // assemble compiles assembly source with the same assembler as cmd/asm, so // that clients need no assembler of their own. Nothing is stored. func (s *Server) assemble(w http.ResponseWriter, r *http.Request) { b, ok := readBody(w, r, 64<<10) if !ok { return } prog, err := vm.Assemble(string(b)) if err != nil { httpError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "size": len(prog), "instructions": len(prog) / 4, "limit": s.cfg.ProgramBytes, "fits": len(prog) > 0 && len(prog) <= s.cfg.ProgramBytes, "program": base64.StdEncoding.EncodeToString(prog), }) } func (s *Server) ships(w http.ResponseWriter, r *http.Request, p store.Player) { ships, err := s.st.PlayerShips(r.Context(), p.ID) if err != nil { fail(w, err) return } type out struct { ID int64 `json:"id"` Status string `json:"status"` ProgramSize int `json:"program_bytes"` DownlinkDay int64 `json:"downlink_day"` } res := make([]out, 0, len(ships)) for _, sh := range ships { res = append(res, out{sh.ID, sh.Status, sh.ProgramSize, sh.DownlinkDay}) } writeJSON(w, http.StatusOK, res) } func shipID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { httpError(w, http.StatusBadRequest, "bad ship id") return 0, false } return id, true } // readBody reads at most limit bytes and rejects larger bodies. func readBody(w http.ResponseWriter, r *http.Request, limit int) ([]byte, bool) { b, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1)) if err != nil || len(b) > limit { httpError(w, http.StatusRequestEntityTooLarge, "body too large") return nil, false } return b, true } func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } b, ok := readBody(w, r, s.cfg.ProgramBytes) if !ok { return } if err := comms.CheckProgram(b, s.cfg.ProgramBytes); err != nil { httpError(w, http.StatusBadRequest, err.Error()) return } if err := s.st.SetProgram(r.Context(), p.ID, id, b); err != nil { fail(w, err) return } s.audit(r, p.ID, "program_upload", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b)}) w.WriteHeader(http.StatusNoContent) } func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } // An optional {"slot": n} chooses which code slot the ship flies. Without it // the ship launches with the program already sealed in it (PUT .../program). body, ok := readBody(w, r, 1<<10) if !ok { return } if strings.TrimSpace(string(body)) != "" { var req struct { Slot *int `json:"slot"` } if err := json.Unmarshal(body, &req); err != nil { httpError(w, http.StatusBadRequest, `body must be {"slot": <1-16>} or empty`) return } if req.Slot != nil { s.launchFromSlot(w, r, p, id, *req.Slot) return } } if err := s.st.Launch(r.Context(), p.ID, id); err != nil { fail(w, err) return } s.audit(r, p.ID, "launch", id, 0, map[string]any{"program": "already sealed in the ship"}) w.WriteHeader(http.StatusAccepted) } func (s *Server) scrap(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } if err := s.st.Scrap(r.Context(), p.ID, id); err != nil { fail(w, err) return } s.audit(r, p.ID, "scrap", id, 0, nil) w.WriteHeader(http.StatusAccepted) } func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } b, ok := readBody(w, r, s.cfg.CommBytes) if !ok { return } if err := comms.CheckUplink(b, s.cfg.CommBytes); err != nil { httpError(w, http.StatusBadRequest, err.Error()) return } if err := s.st.SetUplink(r.Context(), p.ID, id, b); err != nil { fail(w, err) return } // The whole message is kept: it is at most one kilobyte. s.audit(r, p.ID, "uplink", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b), "hex": hex.EncodeToString(b)}) w.WriteHeader(http.StatusNoContent) } func (s *Server) downlinks(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } if _, _, err := s.st.Downlink(r.Context(), p.ID, id); err != nil { fail(w, err) // not this player's ship return } days, err := s.st.DownlinkDays(r.Context(), p.ID, id) if err != nil { fail(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"days": days}) } func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } var ( data []byte day int64 err error ) if q := r.URL.Query().Get("day"); q != "" { day, err = strconv.ParseInt(q, 10, 64) if err != nil || day < 0 { httpError(w, http.StatusBadRequest, "day must be a non-negative integer") return } data, err = s.st.DownlinkAt(r.Context(), p.ID, id, day) if errors.Is(err, store.ErrNotFound) { httpError(w, http.StatusNotFound, "no downlink for that day") return } } else { data, day, err = s.st.Downlink(r.Context(), p.ID, id) } if err != nil { fail(w, err) return } if day < 0 { httpError(w, http.StatusNotFound, "no downlink yet") return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("X-Downlink-Day", strconv.FormatInt(day, 10)) w.Write(data) }