package platform import ( "database/sql" "encoding/json" "io" "net/http" "sort" "time" ) const canvasOperationsAction = "headless.operations.v1" func (s *service) submitCanvasOperations(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodPost { reject(w, 405, "method_not_allowed") return nil } if !acceptsCanvasV2(r) { reject(w, 409, "canvas_schema_upgrade_required") return nil } key := canvasIdempotencyKey(r) if !uuidSlugPattern.MatchString(key) { reject(w, 400, "invalid_idempotency_key") return nil } var input canvasOperationBatch decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, canvasLimit)) decoder.DisallowUnknownFields() if decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF || input.ProtocolVersion != canvasOperationsProtocol || input.RequestID != key || len(input.Operations) == 0 || len(input.Operations) > 2500 || len(input.ExpectedVersions) == 0 || len(input.ExpectedVersions) > 64 { reject(w, 400, "invalid_canvas_operation_batch") return nil } workspace, board := r.PathValue("workspace"), r.PathValue("material") if _, ok := input.ExpectedVersions[board]; !ok { reject(w, 428, "version_required") return nil } participants := make([]string, 0, len(input.ExpectedVersions)) for id, version := range input.ExpectedVersions { if !uuidSlugPattern.MatchString(id) || version < 1 { reject(w, 400, "invalid_canvas_operation_batch") return nil } participants = append(participants, id) } for _, operation := range input.Operations { if _, ok := input.ExpectedVersions[operation.CanvasID]; !ok { reject(w, 428, "version_required") return nil } } sort.Strings(participants) tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() // lockMaterial serializes the workspace; sorted participants preserve a stable lock order. for _, id := range participants { kind, role, e := lockMaterial(r.Context(), tx, project, workspace, id, account) if e != nil { return e } if kind != "board" { reject(w, 400, "invalid_material") return nil } if role == "viewer" { reject(w, 403, "forbidden") return nil } } // Resolve the same request before comparing expected versions: exact retry after lost ACK succeeds. done, err := transactionRetry(w, r, tx, project, account, board, canvasOperationsAction, key, payloadDigest(input)) if err != nil { return err } if done { return nil } before := make([]canvasSnapshot, 0, len(participants)) initial := map[string]canvasSnapshot{} for _, id := range participants { snapshot, e := loadCanvas(r, tx, project, workspace, id) if e != nil { return e } if snapshot.Version != input.ExpectedVersions[id] { return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 412, response{"error": "version_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil) } initial[id] = snapshot before = append(before, snapshot) } staged, err := applyCanvasOperations(initial, input.Operations) if err != nil { return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_canvas_operation", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil) } snapshots := make([]canvasSnapshot, 0, len(participants)) versions := map[string]int64{} // Validate all participant documents before writing any of them. for _, id := range participants { snapshot := staged[id] valid, e := validateCanvasRelations(r, tx, project, workspace, id, initial[id].Document, snapshot.Document) if e != nil { return e } if !valid { return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 409, response{"error": "ownership_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil) } if !s.validateCanvasAIModels(w, r, snapshot.Document) { return nil } valid, e = s.validateCanvasAssets(r, tx, project, workspace, account, snapshot.Document) if e != nil { return e } if !valid { return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_asset_reference", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil) } snapshots = append(snapshots, snapshot) versions[id] = snapshot.Version } for _, snapshot := range snapshots { if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil { return err } } operationIDs := make([]string, 0, len(input.Operations)) for _, operation := range input.Operations { operationIDs = append(operationIDs, operation.ID) } body := response{"protocol_version": canvasOperationsProtocol, "status": "applied", "request_id": key, "operation_ids": operationIDs, "snapshots": snapshots, "applied_versions": versions, "parent": staged[board]} return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 200, body, before, versions) } // A terminal rejection also reserves the frozen key/body. Losing that response must // not turn a definite zero-write result into a future execution of the same request. func commitCanvasOperationReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, account, board string, input canvasOperationBatch, status int, body response, before []canvasSnapshot, versions map[string]int64) error { var transactionID string if err := tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&transactionID); err != nil { return err } body["transaction_id"] = transactionID body["protocol_version"] = canvasOperationsProtocol expires := time.Now().Add(7 * 24 * time.Hour) bodyRaw, err := json.Marshal(body) if err != nil { return err } beforeRaw, err := json.Marshal(before) if err != nil { return err } versionsRaw, err := json.Marshal(versions) if err != nil { return err } _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, project, r.PathValue("workspace"), board, transactionID, account, canvasOperationsAction, input.RequestID, payloadDigest(input), status, bodyRaw, beforeRaw, versionsRaw, expires) if err != nil { return err } if err = tx.Commit(); err != nil { return err } if version := versions[board]; version > 0 { setVersion(w, version) } reply(w, status, body) return nil } func (s *service) getCanvasOperation(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodGet { reject(w, 405, "method_not_allowed") return nil } key := r.PathValue("request") if !uuidSlugPattern.MatchString(key) { reject(w, 400, "invalid_request_id") return nil } tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() workspace, board := r.PathValue("workspace"), r.PathValue("material") if _, _, err = lockMaterial(r.Context(), tx, project, workspace, board, account); err != nil { return err } var raw json.RawMessage err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_transactions WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND action=$5 AND idempotency_key=$6`, project, workspace, board, account, canvasOperationsAction, key).Scan(&raw) if err == sql.ErrNoRows { reply(w, 404, response{"status": "unknown", "error": "operation_receipt_unknown", "request_id": key}) return nil } if err != nil { return err } var result struct { Snapshots []canvasSnapshot `json:"snapshots"` } if err = json.Unmarshal(raw, &result); err != nil { return err } for _, snapshot := range result.Snapshots { if _, _, err = lockMaterial(r.Context(), tx, project, workspace, snapshot.BoardID, account); err != nil { return err } } if err = tx.Rollback(); err != nil { return err } reply(w, 200, raw) return nil }