package api import ( "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/store" ) // workloadEnvRow is the JSON shape returned to clients. Plaintext is // redacted for encrypted entries — once a value is encrypted, the // server treats it as write-only. To rotate, the operator submits a new // value; to read, they have to look at the running container. type workloadEnvRow struct { ID string `json:"id"` WorkloadID string `json:"workload_id"` Key string `json:"key"` Value string `json:"value"` Encrypted bool `json:"encrypted"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func (s *Server) listWorkloadEnv(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } rows, err := s.store.ListWorkloadEnv(id) if err != nil { respondError(w, http.StatusInternalServerError, "list workload env") return } out := make([]workloadEnvRow, 0, len(rows)) for _, e := range rows { row := workloadEnvRow{ ID: e.ID, WorkloadID: e.WorkloadID, Key: e.Key, Encrypted: e.Encrypted, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, } if e.Encrypted { row.Value = "" // write-only after encryption } else { row.Value = e.Value } out = append(out, row) } respondJSON(w, http.StatusOK, out) } // setWorkloadEnvRequest is the POST/PUT body. Encrypted=true causes the // server to encrypt the value at rest with the global encryption key. type setWorkloadEnvRequest struct { Key string `json:"key"` Value string `json:"value"` Encrypted bool `json:"encrypted"` } func (s *Server) setWorkloadEnv(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } var req setWorkloadEnvRequest if !decodeJSONStrict(w, r, &req) { return } req.Key = strings.TrimSpace(req.Key) if req.Key == "" { respondError(w, http.StatusBadRequest, "key is required") return } if !validEnvKey(req.Key) { respondError(w, http.StatusBadRequest, "key must match [A-Za-z_][A-Za-z0-9_]*") return } value := req.Value if req.Encrypted && value != "" { enc, err := crypto.Encrypt(s.encKey, value) if err != nil { respondError(w, http.StatusInternalServerError, "encrypt value") return } value = enc } row, err := s.store.SetWorkloadEnv(store.WorkloadEnv{ WorkloadID: id, Key: req.Key, Value: value, Encrypted: req.Encrypted, }) if err != nil { slog.Error("set workload env", "workload", id, "key", req.Key, "error", err) respondError(w, http.StatusInternalServerError, "set workload env") return } respondJSON(w, http.StatusOK, workloadEnvRow{ ID: row.ID, WorkloadID: row.WorkloadID, Key: row.Key, Value: "", // never echo even fresh writes — caller already has it Encrypted: row.Encrypted, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }) } func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) { envID := chi.URLParam(r, "envID") if err := s.store.DeleteWorkloadEnv(envID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload env") return } respondError(w, http.StatusInternalServerError, "delete workload env") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": envID}) } // Workload-level webhook URL handlers were dropped in the hard legacy // cutover: the old `/api/webhook/workloads/{secret}` route is gone, so // minting a workload secret would hand operators a URL that 404s. The // inbound webhook surface is now exclusively first-class triggers // (`/api/webhook/triggers/{secret}`); use the trigger CRUD + bindings // endpoints to wire a workload to inbound deploys. // validEnvKey accepts POSIX-style env names. Rejects anything that would // confuse Docker's env parser (=, spaces, control chars). func validEnvKey(k string) bool { if len(k) == 0 || len(k) > 256 { return false } for i, ch := range k { switch { case ch >= 'A' && ch <= 'Z', ch >= 'a' && ch <= 'z', ch == '_': continue case (ch >= '0' && ch <= '9') && i > 0: continue default: return false } } return true }