mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
5035168e5c
* wip(security): BUG-3982 pinned-PAT enforcement, in progress Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1) r1 rulings (lead, on BUG-3982): - F1: docs/api.md says details.token_workspace is absent for a deleted pin. - F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one statement); any other id is the missing-id 404. - Q1: an MCP tool mapped to a global route (pad_workspace create) is refused for a pinned PAT, tested through wireMCP. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test(e2e): the suite token is unpinned (BUG-3982 CI) global-setup minted the browser suite's token with a workspace_id on a stale premise (api_tokens once required one). Under BUG-3982 a pinned token is refused the global routes the UI calls on every page, so the E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and /auth/export. task-3415's deliberate pin is kept. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3) GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter, but the token route gate read "import-status" as a slug and admitted a pinned token, which could then read the user's import outcome for any workspace by key. It joins reorder, import and deleted as a refused segment. The route walk skipped every route the gate admits, so it could not catch a route admitted wrongly. Each admitted route must now sit inside the workspace subrouter or be in a hand-written expected list, and the list must match what the gate admits. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4) r4 rulings (lead, on BUG-3982): - On every door that takes a workspace by name (RequireWorkspaceAccess, SSE /events?workspace=, search ?workspace=), a pinned token naming any other workspace is refused before the name is resolved (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug get byte-identical refusals. The resolved-ID compares stay behind it. - The email-restricted share link and the invitation preview read the caller through signedInFor: a token pinned to another workspace counts as signed out there. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4) The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant rerun showed that mutant surviving. This direct test, shaped like TestBUG3982_WorkspaceAccessComparesThePinByID, holds it. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5) r5 rulings (lead, on BUG-3982): - authorizeCrossWorkspace refuses a pinned token naming any other target_workspace before resolving it, so copy and preflight give a missing, non-member and member target the same refusal. The OAuth allow-list check, reported after the role check, is unchanged. - The /-/r/ resolver checks the pin by name before resolving; its answer was already the same 404. - Both join the identical-bytes test; the resolver also gets a by-ID test, since its resolved-ID compare is now behind the by-name one. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6) r6 rulings (lead, on BUG-3982): - authorizeCrossWorkspace compares the pin by resolved ID behind its by-name check, like RequireWorkspaceAccess, SSE, the resolver and collab, with a by-ID test. - Collab's resolved-ID compare gets the by-ID test the others have. - docs/api.md: five BUG-3982 sentences appended without a separator now end the sentence before them. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
1103 lines
43 KiB
Go
1103 lines
43 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/collections"
|
|
"github.com/PerpetualSoftware/pad/internal/events"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
"github.com/PerpetualSoftware/pad/internal/watchevents"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// filterWorkspacesByTokenAllowlist returns the subset of wss the request's
|
|
// OAuth consent allow-list permits, or wss unchanged when no per-slug gate
|
|
// applies (nil/wildcard allow-list — an unpinned PAT, web session, local stdio, or
|
|
// wildcard consent). A workspace-pinned PAT carries the one-slug allow-list
|
|
// (BUG-3982). Shared by the workspace-global reads that sit outside
|
|
// the /{slug} subrouter (list, deleted-list, search fan-out) and therefore
|
|
// never pass through RequireWorkspaceAccess's allow-list gate. See BUG-2102.
|
|
func filterWorkspacesByTokenAllowlist(ctx context.Context, wss []models.Workspace) []models.Workspace {
|
|
allow := TokenAllowedWorkspaceSet(ctx)
|
|
if allow == nil {
|
|
return wss
|
|
}
|
|
out := make([]models.Workspace, 0, len(wss))
|
|
for _, ws := range wss {
|
|
if _, ok := allow[ws.Slug]; ok {
|
|
out = append(out, ws)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeWorkspaceInput(input *models.WorkspaceCreate) error {
|
|
if input == nil {
|
|
return nil
|
|
}
|
|
|
|
settings, err := models.NormalizeWorkspaceSettings(input.Settings)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid settings JSON: %w", err)
|
|
}
|
|
if input.Context != nil {
|
|
settings, err = models.ApplyWorkspaceContext(settings, input.Context)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid workspace context: %w", err)
|
|
}
|
|
}
|
|
input.Settings = settings
|
|
return nil
|
|
}
|
|
|
|
func normalizeWorkspaceUpdateInput(input *models.WorkspaceUpdate) error {
|
|
if input == nil {
|
|
return nil
|
|
}
|
|
|
|
if input.Settings != nil {
|
|
settings, err := models.NormalizeWorkspaceSettings(*input.Settings)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid settings JSON: %w", err)
|
|
}
|
|
input.Settings = &settings
|
|
}
|
|
|
|
if input.Context != nil {
|
|
base := "{}"
|
|
if input.Settings != nil {
|
|
base = *input.Settings
|
|
}
|
|
settings, err := models.ApplyWorkspaceContext(base, input.Context)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid workspace context: %w", err)
|
|
}
|
|
input.Settings = &settings
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
resp := map[string]interface{}{"status": "ok"}
|
|
if s.version != "" {
|
|
resp["version"] = s.version
|
|
}
|
|
if s.commit != "" {
|
|
resp["commit"] = s.commit
|
|
}
|
|
if s.buildTime != "" {
|
|
resp["build_time"] = s.buildTime
|
|
}
|
|
resp["cloud_mode"] = s.cloudMode
|
|
// MCP is turned on but cannot be served (TASK-1069). A flag only: the
|
|
// reason names configuration, which is for the admin panel, not an
|
|
// unauthenticated endpoint. Absent when MCP is off or available.
|
|
if s.MCPBlockedReason() != "" {
|
|
resp["mcp_blocked"] = true
|
|
}
|
|
if s.webIdentity != nil {
|
|
resp["web"] = s.webIdentity
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleHealthLive is a lightweight liveness probe — always returns 200 if the
|
|
// process is running. Kubernetes uses this to decide whether to restart the pod.
|
|
func (s *Server) handleHealthLive(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleHealthReady is a readiness probe — returns 200 only when the service
|
|
// can accept traffic (DB connection healthy). Kubernetes uses this to decide
|
|
// whether to route traffic to the pod.
|
|
func (s *Server) handleHealthReady(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.Ping(); err != nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"status": "not ready",
|
|
"error": "database unavailable",
|
|
})
|
|
return
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"status": "ready",
|
|
}
|
|
|
|
// Include connection pool stats (useful for debugging, not required for pass/fail).
|
|
dbStats := s.store.DB().Stats()
|
|
resp["db"] = map[string]interface{}{
|
|
"open_connections": dbStats.OpenConnections,
|
|
"in_use": dbStats.InUse,
|
|
"idle": dbStats.Idle,
|
|
"driver": string(s.store.D().Driver()),
|
|
}
|
|
|
|
// Redis reachability, INFORMATIONAL — it never changes the 200/503
|
|
// (BUG-2727). Readiness stays database-only because every item-write path,
|
|
// the REST API and the web UI work with Redis down; gating on it would
|
|
// pull healthy replicas out of the load balancer over a degraded
|
|
// feature. What Redis being down actually costs is named on the
|
|
// `degrades` field so a reader does not have to know the architecture
|
|
// to interpret the flag.
|
|
//
|
|
// Absent when no prober is wired, which means no Redis is configured.
|
|
// A `false` there would say "Redis is down" about a deployment that
|
|
// has none.
|
|
if s.redisHealth != nil {
|
|
status := s.redisHealth.Status()
|
|
redisResp := map[string]interface{}{
|
|
"reachable": status.Reachable,
|
|
"probed": status.Probed,
|
|
}
|
|
if status.Error != "" {
|
|
redisResp["error"] = status.Error
|
|
}
|
|
if !status.LastCheck.IsZero() {
|
|
redisResp["last_check"] = status.LastCheck.UTC().Format(time.RFC3339)
|
|
}
|
|
if status.Probed && !status.Reachable {
|
|
redisResp["degrades"] = []string{
|
|
// NOT "cross-instance" — the activity bus does not fall
|
|
// back to a local fan-out when its Redis publish fails
|
|
// (internal/events/redis_bus.go: Publish logs and
|
|
// returns), so subscribers on THIS instance stop
|
|
// receiving too. An operator told only about
|
|
// cross-instance delivery would conclude local streams
|
|
// were healthy and look elsewhere.
|
|
"all activity events, including to clients on this instance",
|
|
"watch notifications",
|
|
"session presence and session-targeted push",
|
|
}
|
|
}
|
|
resp["redis"] = redisResp
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
|
type templateInfo struct {
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
Collections []string `json:"collections"`
|
|
}
|
|
templates := collections.ListTemplates()
|
|
result := make([]templateInfo, 0, len(templates))
|
|
for _, t := range templates {
|
|
colls := make([]string, 0, len(t.Collections))
|
|
for _, c := range t.Collections {
|
|
colls = append(colls, c.Icon+" "+c.Name)
|
|
}
|
|
result = append(result, templateInfo{
|
|
Name: t.Name,
|
|
Category: t.Category,
|
|
Description: t.Description,
|
|
Icon: t.Icon,
|
|
Collections: colls,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
|
|
// Authenticated users — including admins — see only workspaces they're
|
|
// a member of (which includes ones they own, since owners get a
|
|
// workspace_members row at creation time). Server admins previously got
|
|
// the unfiltered list here, which leaked workspace metadata into their
|
|
// "shared with me" switcher even though they weren't members
|
|
// (BUG-982). Cross-tenant visibility for admins is available through
|
|
// the admin panel routes (/api/v1/admin/...), which call
|
|
// ListWorkspaces() directly with the appropriate auth gate.
|
|
if user != nil {
|
|
workspaces, err := s.store.GetUserWorkspaces(user.ID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// OAuth consent scoping (BUG-2102): this route is workspace-global
|
|
// (no {slug} path param), so RequireWorkspaceAccess — the sole
|
|
// enforcer of the token allow-list — never runs. Filter here so a
|
|
// consent-scoped token can't enumerate the slugs of workspaces it
|
|
// wasn't granted. No-op for web-session / PAT auth (nil allow-list).
|
|
workspaces = filterWorkspacesByTokenAllowlist(r.Context(), workspaces)
|
|
if workspaces == nil {
|
|
workspaces = []models.Workspace{}
|
|
}
|
|
// TASK-896: the list carries the partial-import STATUS only; the
|
|
// note is read through GET /workspaces/{slug}, which knows the
|
|
// caller's role there.
|
|
if len(workspaces) > 0 {
|
|
ids := make([]string, len(workspaces))
|
|
for i := range workspaces {
|
|
ids[i] = workspaces[i].ID
|
|
}
|
|
statuses, err := s.store.ListWorkspaceImportStatuses(ids)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
for i := range workspaces {
|
|
if st, ok := statuses[workspaces[i].ID]; ok {
|
|
workspaces[i].ImportStatus = &models.WorkspaceImportStatus{Status: st.Status}
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, workspaces)
|
|
return
|
|
}
|
|
|
|
// A user-less legacy workspace token sees its own workspace only
|
|
// (BUG-3353): it used to fall into the bootstrap branch below and list
|
|
// every tenant on the instance.
|
|
if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" {
|
|
ws, err := s.store.GetWorkspaceByID(tokenWsID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
out := []models.Workspace{}
|
|
if ws != nil {
|
|
out = append(out, *ws)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return
|
|
}
|
|
|
|
// Pre-auth / fresh-install bootstrap: list everything so the setup
|
|
// flow can find any seeded workspace. Only on an instance that READS as
|
|
// empty (BUG-3353; the BUG-3334 rule for the count).
|
|
if count, err := s.userCount(); err != nil || count != 0 {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
workspaces, err := s.store.ListWorkspaces()
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if workspaces == nil {
|
|
workspaces = []models.Workspace{}
|
|
}
|
|
writeJSON(w, http.StatusOK, workspaces)
|
|
}
|
|
|
|
func (s *Server) handleReorderWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|
userID := currentUserID(r)
|
|
if userID == "" {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
var input []struct {
|
|
Slug string `json:"slug"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
for _, item := range input {
|
|
ws, err := s.store.GetWorkspaceBySlug(item.Slug)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if ws == nil {
|
|
continue
|
|
}
|
|
// OAuth consent scoping (BUG-3255): like the list endpoint above, this
|
|
// route is workspace-global, so RequireWorkspaceAccess never checks the
|
|
// token allow-list here. Skip a workspace outside it silently, the same
|
|
// answer an unknown or non-member slug gets, so the response still says
|
|
// nothing about which slugs exist. No-op for web session / unpinned PAT auth.
|
|
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
|
|
continue
|
|
}
|
|
// Skip silently if the user is not a member of this workspace.
|
|
// UpdateWorkspaceSortOrder is scoped to the caller's own
|
|
// workspace_members row (WHERE user_id = ? AND workspace_id = ?),
|
|
// so a non-member's PATCH touches zero rows and returns
|
|
// sql.ErrNoRows — there's no cross-workspace write or leak here
|
|
// even for an attacker-supplied slug list. (The old comment cited
|
|
// "admin sees all workspaces"; that rationale is stale —
|
|
// handleListWorkspaces returns membership-only for all
|
|
// authenticated users including admins since BUG-982, so the
|
|
// switcher never surfaces non-member workspaces to reorder.
|
|
// BUG-1618 confirmed this site needs no auth gate.)
|
|
if err := s.store.UpdateWorkspaceSortOrder(userID, ws.ID, item.SortOrder); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
continue
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// requireWorkspaceCreationConsent gates a request that MINTS a workspace
|
|
// on the calling OAuth connection's `may_create_workspaces` grant.
|
|
// Returns true when the request may proceed; on false it has already
|
|
// written the response.
|
|
//
|
|
// Two callers, which are the two endpoints an OAuth-bound caller can
|
|
// mint through: handleCreateWorkspace and handleImportWorkspace. NOT
|
|
// every path to store.CreateWorkspace — autoCreateWorkspace
|
|
// (handlers_cloud.go) mints a workspace during registration, bootstrap
|
|
// and OAuth-login, and is deliberately outside this gate: it runs at
|
|
// signup with no OAuth connection in context, provisioning the user's
|
|
// own first workspace rather than acting for a connected app.
|
|
//
|
|
// Ruled by Dave on IDEA-2756 (2026-08-26): the consent screen's "may
|
|
// create workspaces" checkbox is a permission on whether the connected
|
|
// token has the right to CREATE a workspace, and it has to be true to
|
|
// what a user would honestly expect from the option. Before this, the
|
|
// flag gated only maybeAutoAddCreatorConnection's allow-list insert —
|
|
// so a connection whose user explicitly left the box unticked could
|
|
// still create workspaces, it just could not then see them. A
|
|
// permission that does not prevent the action it names is a consent
|
|
// mismatch, and the behaviour-change-for-existing-connections argument
|
|
// lost to honest consent semantics.
|
|
//
|
|
// Shape mirrors handleAuditLog's consent refusal (BUG-2102): a hard 403
|
|
// rather than a narrowed response, because there is no narrower version
|
|
// of creating a workspace.
|
|
//
|
|
// Three non-refusal cases, each deliberate:
|
|
//
|
|
// - Not an OAuth grant (PAT, CLI session token, local stdio — no
|
|
// request_id in context). Creation rides on ordinary account
|
|
// authority; this flag has no opinion about it.
|
|
// - ErrOAuthConnectionNotFound — no connection row for this grant.
|
|
// The expected cause is a pre-Phase-C grant not yet backfilled, and
|
|
// the backfill mints those with may_create_workspaces ON
|
|
// (oauth_connections_backfill.go), so allowing here is that same
|
|
// default applied early rather than a gap. Stated as the expected
|
|
// cause and not the only one, because the code cannot tell them
|
|
// apart: any missing row takes this branch.
|
|
// Note the deliberate asymmetry with maybeAutoAddCreatorConnection,
|
|
// which treats not-found as "no auto-add": that path is declining a
|
|
// convenience, this one would be inventing a refusal.
|
|
// - Flag set — proceeds, and the auto-add downstream is unchanged.
|
|
//
|
|
// A real I/O error reading the connection FAILS CLOSED with a 500. The
|
|
// alternative — allowing the create when the deciding state could not
|
|
// be read — silently grants a permission the user declined, on the
|
|
// strength of a database blip. It is a 500 and not stored_state_
|
|
// unreadable because the state is not unreadable-in-principle; the read
|
|
// failed, the fault is ours, and a retry can legitimately succeed.
|
|
func (s *Server) requireWorkspaceCreationConsent(w http.ResponseWriter, r *http.Request) bool {
|
|
kind, requestID := MCPTokenIdentityFromContext(r.Context())
|
|
if kind != "oauth" || requestID == "" {
|
|
return true
|
|
}
|
|
conn, err := s.store.GetOAuthConnection(requestID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrOAuthConnectionNotFound) {
|
|
return true
|
|
}
|
|
slog.Error("workspace creation consent check failed to read connection",
|
|
"request_id", requestID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error",
|
|
"Could not verify this connection's workspace-creation permission")
|
|
return false
|
|
}
|
|
if !conn.MayCreateWorkspaces {
|
|
// The quoted string is the checkbox's ACTUAL label, verbatim from
|
|
// the consent template (handlers_oauth.go) and the connections
|
|
// page (web console). A remedy that names a control the user
|
|
// cannot find is not a remedy; if either label is reworded, this
|
|
// message is part of that change.
|
|
//
|
|
// Both remedies are named because there are two: a fresh
|
|
// authorization, and flipping the flag on the EXISTING
|
|
// connection via PATCH /connected-apps/{id}/flags, which the
|
|
// console page drives. Saying only "re-authorize" would send a
|
|
// user through a longer path than they need.
|
|
writeError(w, http.StatusForbidden, "forbidden",
|
|
"This connection is not permitted to create workspaces. "+
|
|
"Re-authorize it with \"Let this app create new workspaces\" enabled, "+
|
|
"or enable it for this connection under /console/connected-apps.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) handleCreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
// Every precondition that does not need the body, from the one place
|
|
// both mint doors call (BUG-2809). Before decoding, so a refusal never
|
|
// depends on body validity and cannot be probed by shape.
|
|
mint, ok := s.beginWorkspaceMint(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var input models.WorkspaceCreate
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
// The payload-shaped preconditions, from that same place.
|
|
if err := validateWorkspaceMintPayload(input.Name, &input.Settings); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
// Context is a create-only input concept (an export carries none), so it
|
|
// stays here rather than in the shared step. Settings are already
|
|
// normalized above, which is what this call would otherwise redo.
|
|
if err := normalizeWorkspaceInput(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
// Attribution and ownership come from beginWorkspaceMint, which derived
|
|
// both AUTHORITATIVELY from the request's auth shape — never from the
|
|
// request body (WorkspaceCreate.Source is `json:"-"` for exactly this
|
|
// reason). A cli/mcp origin is what tells the dashboard an agent is
|
|
// already connected right after `pad init`, so a web client must not be
|
|
// able to spoof it to suppress the connect-agent/onboarding prompts
|
|
// (BUG-1557). The import door now gets the same value from the same
|
|
// place, which it previously got not at all.
|
|
input.Source = mint.Source
|
|
// The owner is the authenticated user, never the body (BUG-3353): a
|
|
// body owner_id let a caller with no user attribute a workspace to
|
|
// anyone, spend their plan quota, and plant it in their reach. The
|
|
// only no-user mint left is the fresh-install one, which has no user
|
|
// to name.
|
|
input.OwnerID = mint.OwnerID
|
|
|
|
// The limit is enabled only for an AUTHENTICATED owner (mint.OwnerID),
|
|
// which input.OwnerID now always equals. The pre-check in
|
|
// beginWorkspaceMint makes the same decision.
|
|
ws, err := s.store.CreateWorkspace(input, s.planLimitMintOpts(mint.OwnerID)...)
|
|
if err != nil {
|
|
if writeStorePlanLimitError(w, r, err, "") {
|
|
return
|
|
}
|
|
if writeWorkspaceSlugContended(w, err) {
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Seed collections for the new workspace using the requested template
|
|
if err := s.store.SeedCollectionsFromTemplate(ws.ID, input.Template); err != nil {
|
|
// COMPENSATE HERE TOO (BUG-2715, codex round 4). This step's failure
|
|
// produced the same husk as the owner step's — a live, ownerless,
|
|
// unseeded workspace still holding its slug, so the retry landed on
|
|
// `name-2` — and the old message said so out loud ("Workspace created
|
|
// but failed to seed collections") while leaving the caller nothing they
|
|
// could use or remove. Compensating only the owner step would have left
|
|
// the invariant true of one failure point and false of its neighbour.
|
|
userID := currentUserID(r)
|
|
msg := "Failed to seed collections: " + err.Error()
|
|
if rerr := s.removeUnusableWorkspace("create workspace (seed)", ws.ID, ws.Slug, userID, err); rerr != nil {
|
|
slog.Error("workspace seeding failed and the workspace could not be removed",
|
|
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "error", rerr)
|
|
// BUG-3479: the caller is told the half-made workspace survived,
|
|
// since it holds the name and a retry will land on name-2.
|
|
msg += fmt.Sprintf("; the unusable workspace %q could not be removed and still holds its name (see the server log)", ws.Slug)
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", msg)
|
|
return
|
|
}
|
|
|
|
// Add the creator as workspace owner. FATAL as of BUG-2715: a workspace
|
|
// nobody can administer is worse than no workspace, so the door answers
|
|
// with a usable workspace or with nothing. See addOwnerOrCompensate for the
|
|
// reconcile-before-destroying rule and for the crash window this carries.
|
|
if userID := currentUserID(r); userID != "" {
|
|
if err := s.addOwnerOrCompensate("create workspace", ws.ID, ws.Slug, userID); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// OAuth connection auto-add (PLAN-1519 / TASK-1521 / IDEA-1517 §1):
|
|
// when the creating call came over an OAuth-bound MCP session AND
|
|
// that connection has `may_create_workspaces=true`, immediately
|
|
// add the new workspace to the connection's allow-list with
|
|
// added_by='agent-create'. The agent can then use the workspace
|
|
// without a re-auth round-trip — the whole point of the "agent
|
|
// creates and immediately uses" flow.
|
|
//
|
|
// Best-effort: any error here logs but does NOT fail the response.
|
|
// The workspace already exists; failing the response would be
|
|
// confusing ("create succeeded but you got an error") and the user
|
|
// can always re-grant via the Connect-project modal. PAT auth has
|
|
// no request_id and the no-op short-circuits at the kind check.
|
|
s.finishWorkspaceMint(r, ws.ID)
|
|
|
|
writeJSON(w, http.StatusCreated, ws)
|
|
}
|
|
|
|
// maybeAutoAddCreatorConnection inserts the newly-created workspace
|
|
// into the calling OAuth connection's allow-list when the grant carries
|
|
// `may_create_workspaces=true` AT THE MOMENT OF THE WRITE. No-op when:
|
|
//
|
|
// - The calling token isn't an OAuth grant (PAT, CLI session token —
|
|
// they don't carry a request_id).
|
|
//
|
|
// - The grant's connection row doesn't exist (pre-Phase-C tokens
|
|
// fall here until backfill).
|
|
//
|
|
// - The flag is off. Since IDEA-2756 handleCreateWorkspace refuses a
|
|
// flag-off connection before it reaches here, so this case arises
|
|
// when the user revokes creation power from /console/connected-apps
|
|
// (PATCH /connected-apps/{id}/flags) after that gate ran. The
|
|
// workspace then exists without joining a connection whose grant was
|
|
// withdrawn mid-flight.
|
|
//
|
|
// The flag check and the insert are ONE store statement
|
|
// (AddCreatedWorkspaceIfPermitted), not a read here followed by an
|
|
// unconditional insert. Two statements left a window in which a
|
|
// revocation committing between them still added the workspace
|
|
// (BUG-2792). The store method's comment explains why Postgres also
|
|
// needs a row lock for the single statement to close that window.
|
|
//
|
|
// Errors are logged at WARN, never propagated. The caller's response
|
|
// must not fail because of an auth-bookkeeping issue post-creation.
|
|
func (s *Server) maybeAutoAddCreatorConnection(r *http.Request, workspaceID string) {
|
|
kind, requestID := MCPTokenIdentityFromContext(r.Context())
|
|
if kind != "oauth" || requestID == "" {
|
|
return
|
|
}
|
|
if s.autoAddPreInsertHook != nil {
|
|
s.autoAddPreInsertHook(requestID, workspaceID)
|
|
}
|
|
if err := s.store.AddCreatedWorkspaceIfPermitted(requestID, workspaceID); err != nil {
|
|
// A missing connection or an unset flag is a silent no-op in the
|
|
// store, so an error here is an unexpected store error: rare,
|
|
// possibly transient (a busy SQLite), and worth logging so ops
|
|
// sees it. The workspace itself is already created either way.
|
|
slog.Warn("auto-add workspace to OAuth connection failed",
|
|
"request_id", requestID,
|
|
"workspace_id", workspaceID,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := s.getWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// BUG-3254: mark a guest here as the list endpoint does. RequireWorkspaceAccess
|
|
// admitted this caller on grants alone and recorded that as the "guest" role.
|
|
// A client that reaches the workspace by URL resolves it through THIS endpoint,
|
|
// and without the flag it rendered member chrome.
|
|
ws.IsGuest = workspaceRole(r) == "guest"
|
|
if !s.attachImportStatus(w, r, ws) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, ws)
|
|
}
|
|
|
|
// attachImportStatus sets ws.ImportStatus from the partial-import marker
|
|
// (TASK-896). The note is shown to owners and instance admins only; anyone
|
|
// else who can read the workspace sees just the status. Answers 500 and
|
|
// returns false on a store error.
|
|
func (s *Server) attachImportStatus(w http.ResponseWriter, r *http.Request, ws *models.Workspace) bool {
|
|
st, err := s.store.GetWorkspaceImportStatus(ws.ID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if st != nil && !mayReadImportNote(r) {
|
|
st.Note = ""
|
|
}
|
|
ws.ImportStatus = st
|
|
return true
|
|
}
|
|
|
|
// mayReadImportNote: the workspace's owners, and instance admins who are
|
|
// MEMBERS of it. An admin admitted only through a guest grant reads the
|
|
// workspace as a guest and gets the status alone (codex r2).
|
|
func mayReadImportNote(r *http.Request) bool {
|
|
role := workspaceRole(r)
|
|
if role == "owner" {
|
|
return true
|
|
}
|
|
if role == "" || role == "guest" {
|
|
return false
|
|
}
|
|
u := currentUser(r)
|
|
return u != nil && u.Role == "admin"
|
|
}
|
|
|
|
func (s *Server) handleUpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "owner") {
|
|
return
|
|
}
|
|
existing, ok := s.getWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var input models.WorkspaceUpdate
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
if err := normalizeWorkspaceUpdateInput(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
ws, err := s.store.UpdateWorkspace(existing.Slug, input)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if ws == nil {
|
|
writeWorkspaceNotFound(w, "Workspace not found")
|
|
return
|
|
}
|
|
if input.ClearImportStatus {
|
|
if err := s.store.ClearWorkspaceImportStatus(ws.ID); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if !s.attachImportStatus(w, r, ws) {
|
|
return
|
|
}
|
|
|
|
s.publishEvent(events.WorkspaceUpdated, ws.ID, "", ws.Name, "", "", "")
|
|
|
|
writeJSON(w, http.StatusOK, ws)
|
|
}
|
|
|
|
func (s *Server) handleDeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "owner") {
|
|
return
|
|
}
|
|
ws, ok := s.getWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Read before the write, like every lifecycle door (TASK-3272). A soft
|
|
// delete keeps these rows, but reading first keeps one rule for all.
|
|
accessUsers := s.workspaceAccessUsers(ws.ID)
|
|
err := s.store.DeleteWorkspace(ws.Slug)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
// Resolved above and gone by the time the delete ran: the
|
|
// workspace itself no longer resolves, so it is the marked 404.
|
|
writeWorkspaceNotFound(w, "Workspace not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
// Any other failure is not evidence the workspace is gone, and
|
|
// must not carry the marker that tells a client to stop asking
|
|
// about it (BUG-3069).
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
s.publishWorkspaceAccessChangedFromRequest(r, ws.ID, watchevents.AccessDeleted, accessUsers...)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// deletedWorkspaceResponse is one entry in the GET /workspaces/deleted
|
|
// payload: the soft-deleted workspace plus purge-window fields the UI
|
|
// renders as "N days left" before it's permanently removed. PurgeAt and
|
|
// DaysLeft are BOTH derived from workspacePurgeRetention so restore and
|
|
// the purge sweeper (workspace_purge.go) share exactly one window — no
|
|
// drift.
|
|
type deletedWorkspaceResponse struct {
|
|
models.Workspace
|
|
// PurgeAt is when the retention sweeper will hard-delete the
|
|
// workspace (deleted_at + workspacePurgeRetention).
|
|
PurgeAt time.Time `json:"purge_at"`
|
|
// DaysLeft is whole days remaining until PurgeAt, rounded up and
|
|
// clamped at 0. 0 means it's eligible for purge on the next sweep.
|
|
DaysLeft int `json:"days_left"`
|
|
}
|
|
|
|
// handleListDeletedWorkspaces lists the soft-deleted workspaces the
|
|
// caller OWNS that are still restorable — i.e. not yet past the purge
|
|
// retention window. Owner-scoped inside the store query; the cutoff is
|
|
// derived from workspacePurgeRetention so this list and the purge
|
|
// sweeper agree on exactly which workspaces are recoverable.
|
|
func (s *Server) handleListDeletedWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|
userID := currentUserID(r)
|
|
if userID == "" {
|
|
// No authenticated user (fresh install / pre-auth) has no owned
|
|
// workspaces to restore — return an empty list rather than 401
|
|
// so the switcher's "recently deleted" section just renders
|
|
// nothing.
|
|
writeJSON(w, http.StatusOK, []deletedWorkspaceResponse{})
|
|
return
|
|
}
|
|
|
|
// Use the retention the purge sweeper actually enforces (which may be
|
|
// overridden via SetWorkspacePurgeConfig / PAD_WORKSPACE_PURGE_RETENTION)
|
|
// so the restore window + purge_at/days_left never drift from the
|
|
// horizon at which the workspace is really hard-deleted.
|
|
retention := s.effectivePurgeRetention()
|
|
cutoff := time.Now().UTC().Add(-retention)
|
|
workspaces, err := s.store.ListDeletedWorkspaces(userID, cutoff)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// OAuth consent scoping (BUG-2102): workspace-global route, outside
|
|
// RequireWorkspaceAccess. A consent-scoped token must not enumerate
|
|
// soft-deleted workspaces it wasn't granted. No-op for PAT / web session.
|
|
workspaces = filterWorkspacesByTokenAllowlist(r.Context(), workspaces)
|
|
|
|
now := time.Now().UTC()
|
|
out := make([]deletedWorkspaceResponse, 0, len(workspaces))
|
|
for _, ws := range workspaces {
|
|
entry := deletedWorkspaceResponse{Workspace: ws}
|
|
if ws.DeletedAt != nil {
|
|
entry.PurgeAt = ws.DeletedAt.Add(retention)
|
|
days := int(math.Ceil(entry.PurgeAt.Sub(now).Hours() / 24))
|
|
if days < 0 {
|
|
days = 0
|
|
}
|
|
entry.DaysLeft = days
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// handleRestoreWorkspace un-soft-deletes a workspace, resurfacing it (and
|
|
// everything transitively hidden underneath) intact. Owner-only, mirroring
|
|
// the Danger-Zone gating on handleDeleteWorkspace — but it can't lean on
|
|
// RequireWorkspaceAccess/requireMinRole because those resolve only LIVE
|
|
// workspaces (`deleted_at IS NULL`), so a soft-deleted workspace 404s
|
|
// before any handler runs. Instead it resolves the soft-deleted row
|
|
// directly and checks ownership here.
|
|
//
|
|
// Status codes:
|
|
// - 404 — no restorable soft-deleted workspace with that slug (already
|
|
// live, unknown, or hard-purged).
|
|
// - 403 — the workspace exists but the caller doesn't own it.
|
|
// - 200 — restored; returns the now-live workspace.
|
|
func (s *Server) handleRestoreWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
slug := chi.URLParam(r, "slug")
|
|
|
|
ws, err := s.store.GetDeletedWorkspaceBySlug(slug)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if ws == nil {
|
|
// Not soft-deleted (live), unknown, or already hard-purged —
|
|
// nothing to restore.
|
|
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
|
|
return
|
|
}
|
|
|
|
// OAuth consent scoping (BUG-2102): this route is a sibling of the
|
|
// /{slug} subrouter, so RequireWorkspaceAccess never gates it. A
|
|
// consent-scoped token must not restore a workspace outside its
|
|
// allow-list, even one the user owns. Return the same 404 as
|
|
// "not restorable" so the token can't probe which slugs exist.
|
|
// nil/wildcard allow-list (unpinned PAT / web session) → no gate. A pinned
|
|
// PAT is refused earlier, by the route gate (BUG-3982).
|
|
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
|
|
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
|
|
return
|
|
}
|
|
|
|
// Owner-only, with NO fresh-install / UserCount==0 bypass. A
|
|
// soft-deleted workspace existing while zero users remain is precisely
|
|
// the account-deletion case (DeleteAccountAtomic soft-deletes the
|
|
// owner's workspaces, then removes the owner) — a bypass there would
|
|
// let an unauthenticated caller restore an account-deleted workspace by
|
|
// guessing its slug and resurface data whose owner is gone. On a
|
|
// genuine fresh install there are no soft-deleted workspaces to restore
|
|
// anyway, so requiring the owner unconditionally loses nothing.
|
|
userID := currentUserID(r)
|
|
if userID == "" || ws.OwnerID != userID {
|
|
// Not the owner. If the owner user no longer exists — the
|
|
// account-deletion case, where DeleteAccountAtomic soft-deleted
|
|
// this workspace and then removed its owner — NO live user could
|
|
// ever restore it, so return the same 404 as "not restorable"
|
|
// instead of 403. That stops a guessed slug from confirming an
|
|
// account-deleted workspace row still exists. A genuine non-owner
|
|
// attempt on a live-owned workspace still gets 403. (GetUser only
|
|
// runs on this rejection path, never the owner's success path.)
|
|
if owner, _ := s.store.GetUser(ws.OwnerID); owner == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the workspace owner can restore it")
|
|
return
|
|
}
|
|
|
|
// Enforce the SAME purge horizon the deleted-list uses so restore and
|
|
// the list agree: a workspace older than the retention window is
|
|
// already eligible for hard-purge and is hidden from the list, so it
|
|
// must not be restorable by slug either (the sweeper may not have run
|
|
// yet — no attachments registry, deferred blob, or startup lag). Checked
|
|
// after the owner gate so a non-owner can't probe window state.
|
|
cutoff := time.Now().UTC().Add(-s.effectivePurgeRetention())
|
|
if ws.DeletedAt == nil || !ws.DeletedAt.After(cutoff) {
|
|
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
|
|
return
|
|
}
|
|
|
|
if err := s.store.RestoreWorkspace(slug); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// Raced with a concurrent restore/purge between the lookup and
|
|
// the update — treat as nothing-to-restore.
|
|
writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// Published once the restore commits, before the re-fetch below can 500
|
|
// over it (TASK-3272, codex round 1's class).
|
|
s.publishWorkspaceAccessChangedFromRequest(r, ws.ID, watchevents.AccessRestored, s.workspaceAccessUsers(ws.ID)...)
|
|
|
|
// Re-fetch the now-live row so the response carries the fully hydrated,
|
|
// un-deleted workspace.
|
|
restored, err := s.store.GetWorkspaceBySlug(slug)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if restored == nil {
|
|
// Extremely unlikely (just restored), but don't lie about success.
|
|
writeWorkspaceNotFound(w, "Workspace not found")
|
|
return
|
|
}
|
|
|
|
// Log the restore in the (now live) workspace's activity feed, mirroring
|
|
// how item restore logs a "restored" action.
|
|
s.logActivity(restored.ID, "", "restored", r)
|
|
s.publishEvent(events.WorkspaceUpdated, restored.ID, "", restored.Name, "", "", "")
|
|
|
|
writeJSON(w, http.StatusOK, restored)
|
|
}
|
|
|
|
func (s *Server) handleExportWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
// `?format=tar` switches to the tar.gz bundle that includes
|
|
// attachment blobs (TASK-884). Default stays JSON for backward
|
|
// compat — existing automation hitting this endpoint without a
|
|
// query param keeps working unchanged. The CLI's
|
|
// `pad workspace export` opts into the bundle by default.
|
|
if strings.EqualFold(r.URL.Query().Get("format"), "tar") {
|
|
s.handleExportWorkspaceBundle(w, r)
|
|
return
|
|
}
|
|
|
|
if !requireMinRole(w, r, "owner") {
|
|
return
|
|
}
|
|
ws, ok := s.getWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !s.requireUnrestrictedExportAccess(w, r, ws.ID) {
|
|
return
|
|
}
|
|
export, _, err := s.store.ExportWorkspaceSnapshot(r.Context(), ws.Slug, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Disposition", contentDisposition("attachment", ws.Slug+"-export.json"))
|
|
writeJSON(w, http.StatusOK, export)
|
|
}
|
|
|
|
func (s *Server) handleImportWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
// Every pre-body precondition, from the one place both mint doors call
|
|
// (BUG-2809). This used to be two gates written out here, each added
|
|
// separately after a reviewer found the create door had it and this one
|
|
// did not: the OAuth consent grant (IDEA-2756) and the user-scoped plan
|
|
// limit (BUG-2793).
|
|
//
|
|
// The PLACEMENT is the load-bearing part rather than the call. It sits
|
|
// ABOVE the Content-Type dispatch, so the tar.gz bundle path is covered
|
|
// by this same line rather than needing its own, and above either body
|
|
// read, so a refused caller never uploads anything. The two reads have
|
|
// different bounds (the JSON path's 64 MiB decodeJSONWithLimit, the
|
|
// bundle path's own configurable and much larger limit); the gate
|
|
// precedes both.
|
|
//
|
|
// Reachability of the consent half, stated precisely because the create
|
|
// door's is different: NO OAuth-bound caller can reach this handler
|
|
// today. The OAuth identity is stashed only by MCPBearerAuth, mounted on
|
|
// /mcp alone, so an OAuth connection reaches an /api/v1 handler only
|
|
// through the in-process MCP dispatcher — and its route table has no
|
|
// `workspace import` action. The gate is correct but currently
|
|
// unexercised in production: it exists so that adding that action later
|
|
// cannot silently reopen the door.
|
|
mint, ok := s.beginWorkspaceMint(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// After the mint gate and before either body read: both body shapes read
|
|
// under the per-Read deadline rather than the server-wide one (BUG-3184).
|
|
s.withImportReadDeadline(w, r)
|
|
|
|
// Content-Type dispatch:
|
|
// application/gzip / application/x-gzip / application/x-tar
|
|
// → tar.gz bundle path (TASK-885) — handles attachments.
|
|
// anything else → JSON path (legacy items-only).
|
|
//
|
|
// We prefer Content-Type over file-magic sniffing so a misnamed
|
|
// upload fails fast with a clear error rather than silently going
|
|
// through the wrong code path. The CLI's pad import command sets
|
|
// the right header based on the file extension; web UI does the
|
|
// same when uploading a .tar.gz.
|
|
ct := strings.TrimSpace(r.Header.Get("Content-Type"))
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
ct = ct[:i]
|
|
}
|
|
ct = strings.ToLower(strings.TrimSpace(ct))
|
|
if ct == "application/gzip" || ct == "application/x-gzip" || ct == "application/x-tar" {
|
|
// mint travels as an ARGUMENT rather than on the Server: it is
|
|
// per-request state, and the two things it carries (owner, source)
|
|
// are exactly the two a concurrent request would differ on.
|
|
s.handleImportWorkspaceBundle(w, r, mint)
|
|
return
|
|
}
|
|
|
|
var data models.WorkspaceExport
|
|
// WorkspaceExport contains all collections, items, comments, and item
|
|
// versions for the workspace — even a modest project export blows past
|
|
// the default 2 MiB decodeJSON cap. 64 MiB is well above any realistic
|
|
// single-workspace backup while still far from the heap-exhaustion
|
|
// range the default cap protects against.
|
|
//
|
|
// --repair-nul (DOC-2823 S3 / BUG-2810). The default is strict; the flag
|
|
// buys the body ONE repair attempt and then runs the same gate on the
|
|
// repaired bytes, so this is not a decode path that skips the check.
|
|
repair := &nulRepairTally{Enabled: wantsNULRepair(r)}
|
|
var decodeErr error
|
|
if repair.Enabled {
|
|
decodeErr = decodeJSONRepairingNUL(r, &data, 64<<20, repair)
|
|
} else {
|
|
decodeErr = decodeJSONWithLimit(r, &data, 64<<20)
|
|
}
|
|
if decodeErr != nil {
|
|
msg := "invalid export data: " + decodeErr.Error()
|
|
if errors.Is(decodeErr, errJSONBodyNUL) {
|
|
// The strict refusal NAMES the remedy, per Dave's day-54 ruling —
|
|
// and TestImportStrictRefusalNamesTheWorkingRemedy drives the named
|
|
// flag against this exact failing body, because a suggested remedy
|
|
// is an untested contract claim until it has been run (PATTE-135).
|
|
msg += nulRepairRemedy(repair)
|
|
}
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
return
|
|
}
|
|
|
|
// Optional: override workspace name via query param
|
|
newName := r.URL.Query().Get("name")
|
|
|
|
// The payload-shaped preconditions, from the same place the create door
|
|
// calls (BUG-2809). The EFFECTIVE name is checked — the override when
|
|
// one was given, the bundle's own otherwise — because that is what
|
|
// becomes the slug, and an empty name slugifies to an EMPTY SLUG, which
|
|
// is a routing key. Measured before the fix: the first such import took
|
|
// the empty slug and a second landed on "-2".
|
|
effectiveName := data.Workspace.Name
|
|
if newName != "" {
|
|
effectiveName = newName
|
|
}
|
|
if verr := validateWorkspaceMintPayload(effectiveName, &data.Workspace.Settings); verr != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", verr.Error())
|
|
return
|
|
}
|
|
|
|
// Owner and source both come from the mint context resolved above, so
|
|
// an imported workspace is attributed the same way a created one is
|
|
// (BUG-1557 — import previously got no source at all).
|
|
userID := mint.OwnerID
|
|
// BUG-3032: read the bundle's stale-body marker BEFORE the import, while
|
|
// `data` is still the thing the exporter wrote. Nothing after this point can
|
|
// recover the fact — the destination has no op-log to evaluate against.
|
|
staleBodies := &staleBodyTally{}
|
|
staleBodies.Observe(&data)
|
|
|
|
ws, importReport, err := s.store.ImportWorkspaceWithReport(&data, newName, userID, mint.Source, s.planLimitMintOpts(userID)...)
|
|
if err != nil {
|
|
if writeStorePlanLimitError(w, r, err, "") {
|
|
return
|
|
}
|
|
// A refusal about the EXPORT the caller supplied is a 400, not a 500
|
|
// (BUG-2951). This door answered 500 for every failure, including the
|
|
// prefix-grammar refusal whose message tells the caller which
|
|
// collection to edit and re-import — an actionable instruction
|
|
// delivered under a status that says "the server broke, try later".
|
|
// The sibling bundle-import door (handlers_import_bundle.go) already
|
|
// answers 400 for this class; this aligns the two.
|
|
if v, ok := store.AsValidationError(err); ok {
|
|
writeError(w, http.StatusBadRequest, "import_failed", v.Reason)
|
|
return
|
|
}
|
|
if writeWorkspaceSlugContended(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "import_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
// Add the importer as workspace owner (mirrors handleCreateWorkspace).
|
|
// Same posture as that path, which is now FATAL (BUG-2715): an import that
|
|
// fails here used to return 201 for a workspace nobody could administer.
|
|
if userID != "" {
|
|
if err := s.addOwnerOrCompensate("import workspace", ws.ID, ws.Slug, userID); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if repair.Enabled && repair.Replaced > 0 {
|
|
slog.Info("workspace import repaired NUL escapes on the operator's instruction",
|
|
"workspace_id", ws.ID, "replaced", repair.Replaced)
|
|
}
|
|
if staleBodies.Count > 0 {
|
|
slog.Info("workspace import carried item bodies that were behind their live collaborative documents",
|
|
"workspace_id", ws.ID, "stale_bodies", staleBodies.Count)
|
|
}
|
|
// Post-creation side effects, on the success path only (BUG-2794).
|
|
s.finishWorkspaceMint(r, ws.ID)
|
|
repair.SetHeader(w)
|
|
staleBodies.SetHeader(w)
|
|
setImportCollapsedHeader(w, importReport)
|
|
writeJSON(w, http.StatusCreated, importedWorkspaceResponse{
|
|
Workspace: ws,
|
|
DroppedUniqueValues: importReport.DroppedUniqueValues,
|
|
})
|
|
}
|
|
|
|
// writeWorkspaceSlugContended answers 409 when every attempt to mint a
|
|
// workspace lost its slug to a concurrent create (BUG-3307). That is
|
|
// contention, which a retry resolves, not a server fault.
|
|
func writeWorkspaceSlugContended(w http.ResponseWriter, err error) bool {
|
|
var ce *store.WorkspaceSlugContendedError
|
|
if !errors.As(err, &ce) {
|
|
return false
|
|
}
|
|
writeError(w, http.StatusConflict, "conflict", "Too many workspaces with this name are being created at once; try again")
|
|
return true
|
|
}
|