mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
89a9fb0241
fix(server): a refused collection prefix reaches the caller as a 400 naming the rule (BUG-2951)
BUG-2943 made the store refuse a prefix outside the grammar, with a message
naming the rule and an example. Three of the four doors onto that refusal threw
the message away: they mapped conflict shapes and sent everything else to
writeInternalError, so `pad collection update docs --prefix "ab1"` answered
"An internal error occurred". The refusal kept its data-protection value and
lost its entire teaching value — a 500 with no text reads as an outage, so the
honest user response is to retry or report one.
An MCP agent was told something worse than nothing. internal/mcp classifies a
stdio failure by matching CLI stderr prose; the generic message matches none of
the validation patterns, so a permanently-invalid prefix arrived as the
RETRYABLE server_error code and the correct agent response was to retry a call
that can never succeed. Nothing in internal/mcp changes here: the store's own
message now reaches stderr and the existing `invalid` pattern recognises it.
Both directions are pinned by tests, including the negative control that the
old generic string still classifies as server_error — which is right for a real
internal failure, and is why the refusal had to stop wearing that message.
The population, read door by door rather than grepped:
- CREATE and UPDATE lost the message entirely (500, no text).
- DELETE kept it via strings.Contains on the store's error text — the right
status by the wrong mechanism: a reworded refusal became a 500 silently.
- Workspace IMPORT kept the text under a 500, while its sibling bundle-import
door already answered 400 for the same class.
store.ValidationError carries the caller-facing Reason, with AsValidationError
for the doors, following the InvalidDocumentTitleError precedent in the same
package. Constructing it is the per-site DECISION that a message is safe to
show; the alternative — returning err.Error() from the generic path — makes
that decision by default for every error any layer may later add. Doors render
Reason, never Error(), because Error() carries the sentinel prefix and whatever
a call path wrapped around it; a test helper asserts no response leaks that
prefix, after a mutant swapping Reason for Error() survived every message
assertion (Reason is a substring of Error(), so a contains-check cannot see it).
Two sites are deliberately NOT converted, both read and left:
- The template-seeding trait validation (collections.go) checks FIRST-PARTY
template code, not caller input. A 500 is the honest answer there.
- The two expected_updated_at refusals are converted for uniformity but are
unreachable through HTTP — both doors validate the token at the boundary.
They are defence in depth, not live paths.
WIRE CHANGE: POST /workspaces/import now answers 400 for a caller-input refusal
where it answered 500. The code string (import_failed) and the message are
unchanged, and its sibling bundle-import door has always answered 400 for this
class, so this aligns two doors onto one refusal. Ruled by the lead rather than
decided here. "Cannot delete a default collection" stays 400.
Codex round 1 caught the consumer this change created: the tar.gz import door
renders a bundle failure through its own envelope and its fallback wraps
err.Error(), so the very edit that made the JSON door actionable moved the
sentinel prefix into the bundle door's message. It now detects the type and
renders Reason in its own "Bundle pad-export.json is not importable" envelope,
with a test and a mutant. A producer change is not finished until its consumers
have been read; round 2 was CLEAN.
Verified by negative control (each door arm removed in turn) and a ten-mutant
matrix in which every arm removal, every store site reverted to fmt.Errorf, and
both Reason→Error() swaps are killed by a named test. One matrix attribution
was wrong on first run — a store test appeared as a casualty of a server-side
mutant — and re-running it in isolation showed the mutant does not affect it;
the runner had attributed every FAIL line in a two-package run to the mutation.
Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
938 lines
36 KiB
Go
938 lines
36 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/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 — PAT auth, web session, local stdio, or
|
|
// wildcard consent). 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
|
|
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{}
|
|
}
|
|
writeJSON(w, http.StatusOK, workspaces)
|
|
return
|
|
}
|
|
|
|
// Pre-auth / fresh-install bootstrap: list everything so the setup
|
|
// flow can find any seeded workspace.
|
|
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
|
|
}
|
|
// 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
|
|
if mint.OwnerID != "" {
|
|
input.OwnerID = mint.OwnerID
|
|
}
|
|
|
|
ws, err := s.store.CreateWorkspace(input)
|
|
if err != nil {
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Workspace created but failed to seed collections: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Add the creator as workspace owner.
|
|
//
|
|
// The error is still not fatal here (changing that is BUG-2715 — a failure
|
|
// leaves an OWNERLESS workspace and a 201), but it is no longer discarded
|
|
// silently. TASK-2658 gave AddWorkspaceMember a second way to fail: it now
|
|
// writes member.joined transactionally, so an outbox failure rolls the
|
|
// membership back too. Widening a swallowed error without at least making
|
|
// it visible is how a new failure mode goes unnoticed for a year.
|
|
if userID := currentUserID(r); userID != "" {
|
|
if err := s.store.AddWorkspaceMember(ws.ID, userID, "owner"); err != nil {
|
|
slog.Error("workspace created but creator was not added as owner",
|
|
"workspace_id", ws.ID, "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
|
|
// 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.maybeAutoAddCreatorConnection(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 has
|
|
// `may_create_workspaces=true`. 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 (user explicitly scoped out creation power at
|
|
// consent time or via the connections-page mutation UI). Since
|
|
// IDEA-2756 handleCreateWorkspace refuses a flag-off connection
|
|
// before it reaches here, so in the common case this branch does
|
|
// not fire — but it is NOT unreachable, and calling it dead would
|
|
// be wrong twice over. The gate reads the connection, and this
|
|
// function reads it AGAIN after the workspace is created; a user
|
|
// revoking creation power from /console/connected-apps in between
|
|
// (PATCH /connected-apps/{id}/flags) lands exactly here, and the
|
|
// workspace then exists without silently joining a connection whose
|
|
// grant was withdrawn mid-flight.
|
|
//
|
|
// What this check does NOT do is close that window — it narrows it.
|
|
// The read below and the AddConnectionWorkspace insert after it are
|
|
// separate unconditional statements, so a revocation landing between
|
|
// THEM still adds the workspace. That residual race is BUG-2792:
|
|
// pre-existing, unchanged by IDEA-2756, and needing an atomic
|
|
// check-and-insert at the store layer rather than another read here.
|
|
//
|
|
// (Codex round 3 caught the earlier "unreachable / dead code" claim
|
|
// in this comment — written from the call graph alone, which cannot
|
|
// see a concurrent write between two reads. Round 4 then caught the
|
|
// replacement claiming more safety than the code delivers. Both
|
|
// errors were the same shape in opposite directions.)
|
|
//
|
|
// 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
|
|
}
|
|
conn, err := s.store.GetOAuthConnection(requestID)
|
|
if err != nil || conn == nil {
|
|
// Includes ErrOAuthConnectionNotFound (pre-Phase-C grant) and
|
|
// any I/O error. Silent — the workspace is already created,
|
|
// the auto-add is a convenience the user can recover via the
|
|
// Connect modal.
|
|
return
|
|
}
|
|
if !conn.MayCreateWorkspaces {
|
|
// User declined creation power at consent. Respect that —
|
|
// the workspace exists but doesn't auto-join the connection;
|
|
// the user can claim it post-hoc via the Connect modal if
|
|
// they change their mind.
|
|
return
|
|
}
|
|
if err := s.store.AddConnectionWorkspace(requestID, workspaceID, store.AddedByAgentCreate); err != nil {
|
|
// Idempotent on the store side — re-creation through the
|
|
// same connection (very unlikely with fresh IDs) would no-op.
|
|
// Any error here is genuinely unexpected; log so ops sees it.
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, ws)
|
|
}
|
|
|
|
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 {
|
|
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
|
|
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
|
|
}
|
|
err := s.store.DeleteWorkspace(ws.Slug)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
|
|
return
|
|
}
|
|
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 (PAT / web session) → no gate.
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
writeError(w, http.StatusNotFound, "not_found", "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.ExportWorkspace(ws.Slug)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-export.json"`, ws.Slug))
|
|
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
|
|
}
|
|
|
|
// 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
|
|
ws, err := s.store.ImportWorkspace(&data, newName, userID, mint.Source)
|
|
if err != nil {
|
|
// 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
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "import_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
// Add the importer as workspace owner (mirrors handleCreateWorkspace).
|
|
// Same posture as that path: not fatal (BUG-2715), but not discarded —
|
|
// an import that silently fails here returns 201 for a workspace nobody
|
|
// can administer.
|
|
if userID != "" {
|
|
if err := s.store.AddWorkspaceMember(ws.ID, userID, "owner"); err != nil {
|
|
slog.Error("workspace imported but importer was not added as owner",
|
|
"workspace_id", ws.ID, "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
repair.SetHeader(w)
|
|
writeJSON(w, http.StatusCreated, ws)
|
|
}
|