postnhost 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +27 -0
  4. data/app/assets/builds/postnhost/application.css +1 -1
  5. data/app/controllers/postnhost/api/base_controller.rb +34 -0
  6. data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
  7. data/app/controllers/postnhost/api/v1/mcp_controller.rb +53 -0
  8. data/app/controllers/postnhost/api_tokens_controller.rb +34 -0
  9. data/app/controllers/postnhost/articles/translations_controller.rb +2 -9
  10. data/app/controllers/postnhost/articles_controller.rb +5 -6
  11. data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
  12. data/app/controllers/postnhost/management_preview_controller.rb +17 -0
  13. data/app/controllers/postnhost/oauth/authorizations_controller.rb +50 -0
  14. data/app/controllers/postnhost/oauth/metadata_controller.rb +25 -0
  15. data/app/controllers/postnhost/oauth/public_controller.rb +16 -0
  16. data/app/controllers/postnhost/oauth/registrations_controller.rb +29 -0
  17. data/app/controllers/postnhost/oauth/tokens_controller.rb +19 -0
  18. data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
  19. data/app/controllers/postnhost/sessions_controller.rb +2 -1
  20. data/app/models/postnhost/api_token.rb +26 -0
  21. data/app/models/postnhost/oauth_client.rb +29 -0
  22. data/app/models/postnhost/oauth_grant.rb +32 -0
  23. data/app/models/postnhost/user.rb +3 -0
  24. data/app/services/postnhost/api/v1/openapi.rb +27 -0
  25. data/app/services/postnhost/management/catalog.rb +151 -0
  26. data/app/services/postnhost/management/command.rb +174 -0
  27. data/app/services/postnhost/management/content_actions.rb +75 -0
  28. data/app/services/postnhost/management/input.rb +49 -0
  29. data/app/services/postnhost/management/mcp.rb +54 -0
  30. data/app/services/postnhost/management/preview.rb +25 -0
  31. data/app/services/postnhost/management/resources.rb +30 -0
  32. data/app/services/postnhost/management/schema_fields.rb +25 -0
  33. data/app/services/postnhost/management/serializer.rb +40 -0
  34. data/app/services/postnhost/management/settings_actions.rb +64 -0
  35. data/app/services/postnhost/management/upload.rb +18 -0
  36. data/app/services/postnhost/translation/enqueue.rb +59 -0
  37. data/app/views/layouts/postnhost/_sidebar.html.erb +7 -0
  38. data/app/views/postnhost/api_tokens/index.html.erb +92 -0
  39. data/app/views/postnhost/oauth/authorizations/new.html.erb +18 -0
  40. data/config/routes.rb +21 -0
  41. data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
  42. data/db/migrate/20261025120000_add_mcp_oauth.rb +23 -0
  43. data/lib/postnhost/engine.rb +7 -0
  44. data/lib/postnhost/oauth_discovery.rb +22 -0
  45. data/lib/postnhost/version.rb +1 -1
  46. metadata +32 -1
@@ -0,0 +1,34 @@
1
+ module Postnhost
2
+ module Api
3
+ # Bearer-only boundary intentionally excludes host session callbacks.
4
+ # rubocop:disable-next Rails/ApplicationController
5
+ class BaseController < ActionController::Base
6
+ skip_forgery_protection
7
+ before_action :prevent_caching
8
+ before_action :authenticate_token!
9
+
10
+ private
11
+
12
+ attr_reader :api_user
13
+
14
+ def authenticate_token!
15
+ scheme, secret = request.authorization.to_s.split(" ", 2)
16
+ token = ApiToken.authenticate(secret) if scheme&.casecmp?("Bearer")
17
+ token = nil if token&.resource.present? && token.resource != request.base_url + request.path
18
+ @api_user = token&.user
19
+ return if api_user
20
+
21
+ response.set_header("WWW-Authenticate", bearer_challenge)
22
+ render json: { errors: ["A valid bearer token is required"] }, status: :unauthorized
23
+ end
24
+
25
+ def bearer_challenge
26
+ 'Bearer realm="PostnHost"'
27
+ end
28
+
29
+ def prevent_caching
30
+ response.set_header("Cache-Control", "no-store")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ module Postnhost
2
+ module Api
3
+ module V1
4
+ class CommandsController < Api::BaseController
5
+ def index
6
+ render json: { commands: Management::Catalog.tools }
7
+ end
8
+
9
+ def schema
10
+ server = request.base_url + api_v1_commands_path.delete_suffix("/commands")
11
+ render json: Openapi.document(server:)
12
+ end
13
+
14
+ def create
15
+ input = params.fetch(:arguments, ActionController::Parameters.new)
16
+ unless input.is_a?(ActionController::Parameters)
17
+ render json: { errors: ["arguments must be an object"] }, status: :bad_request
18
+ return
19
+ end
20
+ arguments = input.to_unsafe_h
21
+ result = Management::Command.call(name: params[:command], arguments:, user: api_user, script_name: request.script_name)
22
+ render json: { data: result.value, errors: result.errors }, status: result.status
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ module Postnhost
2
+ module Api
3
+ module V1
4
+ class McpController < Api::BaseController
5
+ before_action :validate_transport!
6
+
7
+ def create
8
+ message = JSON.parse(request.raw_post)
9
+ unless message.is_a?(Hash)
10
+ render json: { jsonrpc: "2.0", id: nil, error: { code: -32_600, message: "Invalid Request" } }, status: :bad_request
11
+ return
12
+ end
13
+ result = Management::Mcp.new(user: api_user, script_name: request.script_name).call(message)
14
+ result ? render(json: result) : head(:accepted)
15
+ rescue JSON::ParserError
16
+ render json: { jsonrpc: "2.0", id: nil, error: { code: -32_700, message: "Parse error" } }, status: :bad_request
17
+ end
18
+
19
+ def unsupported
20
+ response.set_header("Allow", "POST")
21
+ head :method_not_allowed
22
+ end
23
+
24
+ private
25
+
26
+ def bearer_challenge
27
+ %(Bearer resource_metadata="#{postnhost.oauth_resource_metadata_url}", scope="mcp")
28
+ end
29
+
30
+ def validate_transport!
31
+ origin = request.headers["Origin"]
32
+ if origin.present? && origin != request.base_url
33
+ head :forbidden
34
+ return
35
+ end
36
+ version = request.headers["MCP-Protocol-Version"]
37
+ if version.present? && Management::Mcp::VERSIONS.exclude?(version)
38
+ head :bad_request
39
+ return
40
+ end
41
+ return unless request.post?
42
+
43
+ unless request.media_type == "application/json"
44
+ head :unsupported_media_type
45
+ return
46
+ end
47
+ accept = request.headers["Accept"].to_s
48
+ head :not_acceptable unless accept.include?("application/json") && accept.include?("text/event-stream")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,34 @@
1
+ module Postnhost
2
+ class ApiTokensController < ApplicationController
3
+ before_action :authenticate_user!
4
+ before_action :prevent_caching
5
+ layout "postnhost/product"
6
+
7
+ def index
8
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
9
+ end
10
+
11
+ def create
12
+ @api_token, @secret = ApiToken.issue!(user: current_user, **token_params.to_h.symbolize_keys)
13
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
14
+ render :index
15
+ rescue ActiveRecord::RecordInvalid => e
16
+ redirect_to api_tokens_path, alert: e.record.errors.full_messages.to_sentence
17
+ end
18
+
19
+ def destroy
20
+ current_user.api_tokens.find(params[:id]).revoke!
21
+ redirect_to api_tokens_path, notice: "API token revoked."
22
+ end
23
+
24
+ private
25
+
26
+ def prevent_caching
27
+ response.set_header("Cache-Control", "no-store")
28
+ end
29
+
30
+ def token_params
31
+ params.require(:api_token).permit(:name)
32
+ end
33
+ end
34
+ end
@@ -6,15 +6,8 @@ module Postnhost
6
6
  def new; end
7
7
 
8
8
  def create
9
- language_ids = params[:language_ids] || []
10
-
11
- language_ids.each do |language_id|
12
- Postnhost::Translation::ArticleVariantJob.perform_later(@article.id, language_id.to_i)
13
-
14
- @article.article_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
15
- end
16
-
17
- redirect_to article_variants_path(@article)
9
+ result = Postnhost::Translation::Enqueue.call(record: @article, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
10
+ redirect_to article_variants_path(@article), **(result.success? ? {} : { alert: result.errors.to_sentence })
18
11
  end
19
12
 
20
13
  private
@@ -11,10 +11,9 @@ module Postnhost
11
11
  @status = Postnhost::Article.normalize_status(params[:status])
12
12
  @sort = Postnhost::Article.normalize_sort(params[:sort])
13
13
 
14
- articles = current_user.articles
15
- .includes(:language, :article_snapshot)
16
- .with_status(@status)
17
- .sorted_by(@sort)
14
+ articles = Postnhost::Article.includes(:language, :article_snapshot)
15
+ .with_status(@status)
16
+ .sorted_by(@sort)
18
17
 
19
18
  @pagy, @articles = pagy(:offset, articles, limit: PAGE_SIZE)
20
19
  end
@@ -30,7 +29,7 @@ module Postnhost
30
29
  @categories = Postnhost::Category.all
31
30
  @users = Postnhost::User.order(:name, :email)
32
31
  @languages = Postnhost::Language.order(default: :desc, name: :asc)
33
- @articles = current_user.articles.where.not(id: @article.id).order(:title)
32
+ @articles = Postnhost::Article.where.not(id: @article.id).order(:title)
34
33
  end
35
34
 
36
35
  def update
@@ -45,7 +44,7 @@ module Postnhost
45
44
  @categories = Postnhost::Category.all
46
45
  @users = Postnhost::User.order(:name, :email)
47
46
  @languages = Postnhost::Language.order(default: :desc, name: :asc)
48
- @articles = current_user.articles.where.not(id: @article.id).order(:title)
47
+ @articles = Postnhost::Article.where.not(id: @article.id).order(:title)
49
48
  render :edit, status: :unprocessable_content
50
49
  end
51
50
  end
@@ -4,15 +4,8 @@ module Postnhost
4
4
  before_action :load_category
5
5
 
6
6
  def create
7
- language_ids = params[:language_ids] || []
8
-
9
- language_ids.each do |language_id|
10
- Postnhost::Translation::CategoryVariantJob.perform_later(@category.id, language_id.to_i)
11
-
12
- @category.category_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
13
- end
14
-
15
- redirect_to category_variants_path(@category)
7
+ result = Postnhost::Translation::Enqueue.call(record: @category, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
8
+ redirect_to category_variants_path(@category), **(result.success? ? {} : { alert: result.errors.to_sentence })
16
9
  end
17
10
 
18
11
  private
@@ -0,0 +1,17 @@
1
+ module Postnhost
2
+ # Used only by Management::Preview for an authenticated in-process render.
3
+ # There are no routes to this controller.
4
+ class ManagementPreviewController < Public::ArticlesController
5
+ helper_method :postnhost
6
+
7
+ private
8
+
9
+ def postnhost
10
+ @postnhost ||= ActionDispatch::Routing::RoutesProxy.new(Engine.routes, self, Engine.routes.url_helpers)
11
+ end
12
+
13
+ def current_user
14
+ request.env.fetch("postnhost.management_user")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class AuthorizationsController < ApplicationController
4
+ protect_from_forgery with: :exception
5
+ before_action :prevent_caching
6
+ before_action :validate_request!
7
+ before_action :require_oauth_user!
8
+ layout "postnhost/product"
9
+
10
+ def new; end
11
+
12
+ def create
13
+ result = if params[:decision] == "approve"
14
+ { code: OauthGrant.issue!(oauth_client: @client, user: current_user,
15
+ **@oauth_request.slice(:redirect_uri, :resource, :code_challenge).symbolize_keys) }
16
+ else
17
+ { error: "access_denied" }
18
+ end
19
+ uri = URI.parse(@oauth_request[:redirect_uri])
20
+ uri.query = [uri.query.presence, { **result, state: @oauth_request[:state] }.to_query].compact.join("&")
21
+ redirect_to uri.to_s, allow_other_host: true
22
+ end
23
+
24
+ private
25
+
26
+ def prevent_caching
27
+ response.set_header("Cache-Control", "no-store")
28
+ response.set_header("Referrer-Policy", "no-referrer")
29
+ end
30
+
31
+ def validate_request!
32
+ @oauth_request = params.permit(:client_id, :redirect_uri, :response_type, :scope, :state,
33
+ :code_challenge, :code_challenge_method, :resource).to_h.with_indifferent_access
34
+ @client = OauthClient.find_by(client_id: @oauth_request[:client_id])
35
+ valid = @client&.redirect_uris&.include?(@oauth_request[:redirect_uri]) &&
36
+ @oauth_request[:response_type] == "code" && @oauth_request[:code_challenge_method] == "S256" &&
37
+ @oauth_request[:code_challenge].to_s.match?(/\A[A-Za-z0-9_-]{43}\z/) &&
38
+ @oauth_request[:scope].to_s.in?(["", "mcp"]) && @oauth_request[:resource] == postnhost.api_v1_mcp_url
39
+ render plain: "Invalid OAuth authorization request", status: :bad_request unless valid
40
+ end
41
+
42
+ def require_oauth_user!
43
+ return if user_signed_in?
44
+
45
+ session[:oauth_return_to] = postnhost.oauth_authorize_path(@oauth_request)
46
+ authenticate_user!
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class MetadataController < PublicController
4
+ def resource
5
+ render json: {
6
+ resource: postnhost.api_v1_mcp_url,
7
+ authorization_servers: [request.base_url + request.script_name],
8
+ scopes_supported: ["mcp"], bearer_methods_supported: ["header"]
9
+ }
10
+ end
11
+
12
+ def server
13
+ render json: {
14
+ issuer: request.base_url + request.script_name,
15
+ authorization_endpoint: postnhost.oauth_authorize_url,
16
+ token_endpoint: postnhost.oauth_token_url,
17
+ registration_endpoint: postnhost.oauth_register_url,
18
+ response_types_supported: ["code"], grant_types_supported: ["authorization_code"],
19
+ token_endpoint_auth_methods_supported: ["none"],
20
+ code_challenge_methods_supported: ["S256"], scopes_supported: ["mcp"]
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ module Postnhost
2
+ module Oauth
3
+ # OAuth protocol endpoints do not use CMS sessions for authentication.
4
+ # rubocop:disable-next Rails/ApplicationController
5
+ class PublicController < ActionController::Base
6
+ skip_forgery_protection
7
+ before_action :prevent_caching
8
+
9
+ private
10
+
11
+ def prevent_caching
12
+ response.set_header("Cache-Control", "no-store")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class RegistrationsController < PublicController
4
+ def create
5
+ unless params[:token_endpoint_auth_method].to_s.in?(["", "none"]) &&
6
+ supported_grants? &&
7
+ (params[:response_types].nil? || params[:response_types] == ["code"])
8
+ render json: { error: "invalid_client_metadata" }, status: :bad_request
9
+ return
10
+ end
11
+
12
+ client = OauthClient.create!(name: params[:client_name].presence || "MCP client", redirect_uris: params[:redirect_uris])
13
+ render json: {
14
+ client_id: client.client_id, client_name: client.name, redirect_uris: client.redirect_uris,
15
+ token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"]
16
+ }, status: :created
17
+ rescue ActiveRecord::RecordInvalid
18
+ render json: { error: "invalid_client_metadata" }, status: :bad_request
19
+ end
20
+
21
+ private
22
+
23
+ def supported_grants?
24
+ grants = params[:grant_types] || ["authorization_code"]
25
+ grants.is_a?(Array) && grants.include?("authorization_code") && (grants - %w[authorization_code refresh_token]).empty?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class TokensController < PublicController
4
+ def create
5
+ unless params[:grant_type] == "authorization_code"
6
+ render json: { error: "unsupported_grant_type" }, status: :bad_request
7
+ return
8
+ end
9
+
10
+ secret = OauthGrant.exchange(**%i[code client_id redirect_uri resource code_verifier].index_with { |key| params[key].to_s })
11
+ if secret
12
+ render json: { access_token: secret, token_type: "Bearer", scope: "mcp" }
13
+ else
14
+ render json: { error: "invalid_grant" }, status: :bad_request
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -6,15 +6,8 @@ module Postnhost
6
6
  def new; end
7
7
 
8
8
  def create
9
- language_ids = params[:language_ids] || []
10
-
11
- language_ids.each do |language_id|
12
- Postnhost::Translation::PageVariantJob.perform_later(@page.id, language_id.to_i)
13
-
14
- @page.page_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
15
- end
16
-
17
- redirect_to page_variants_path(@page)
9
+ result = Postnhost::Translation::Enqueue.call(record: @page, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
10
+ redirect_to page_variants_path(@page), **(result.success? ? {} : { alert: result.errors.to_sentence })
18
11
  end
19
12
 
20
13
  private
@@ -9,8 +9,9 @@ module Postnhost
9
9
  user = Postnhost::User.find_by(email: session_params[:email].to_s.downcase.strip)
10
10
 
11
11
  if user&.authenticate(session_params[:password].to_s)
12
+ oauth_return_to = session[:oauth_return_to]
12
13
  sign_in(user)
13
- redirect_to articles_path
14
+ redirect_to oauth_return_to || articles_path
14
15
  else
15
16
  flash.now[:alert] = "Invalid email or password."
16
17
  render :new, status: :unprocessable_content
@@ -0,0 +1,26 @@
1
+ require "digest"
2
+ require "securerandom"
3
+
4
+ module Postnhost
5
+ class ApiToken < ApplicationRecord
6
+ belongs_to :user, class_name: "Postnhost::User"
7
+ validates :name, :token_digest, presence: true
8
+ validates :token_digest, uniqueness: true
9
+
10
+ def self.issue!(user:, name:, resource: nil)
11
+ secret = "pnh_#{SecureRandom.urlsafe_base64(32)}"
12
+ token = create!(user:, name:, resource:, token_digest: Digest::SHA256.hexdigest(secret))
13
+ [token, secret]
14
+ end
15
+
16
+ def self.authenticate(secret)
17
+ return if secret.blank?
18
+
19
+ find_by(token_digest: Digest::SHA256.hexdigest(secret), revoked_at: nil)
20
+ end
21
+
22
+ def revoke!
23
+ update!(revoked_at: Time.current)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ require "uri"
2
+
3
+ module Postnhost
4
+ class OauthClient < ApplicationRecord
5
+ has_many :oauth_grants, class_name: "Postnhost::OauthGrant", dependent: :destroy
6
+ has_secure_token :client_id, length: 32
7
+
8
+ validates :name, presence: true, length: { maximum: 100 }
9
+ validate :safe_redirect_uris
10
+
11
+ private
12
+
13
+ def safe_redirect_uris
14
+ return if redirect_uris.is_a?(Array) && redirect_uris.size.between?(1, 10) && redirect_uris.all? { |value| safe_redirect_uri?(value) }
15
+
16
+ errors.add(:redirect_uris, "must contain HTTPS URLs or HTTP loopback URLs")
17
+ end
18
+
19
+ def safe_redirect_uri?(value)
20
+ return false unless value.is_a?(String) && value.length <= 2048
21
+
22
+ uri = URI.parse(value)
23
+ uri.host.present? && uri.userinfo.nil? && uri.fragment.nil? &&
24
+ (uri.scheme == "https" || (uri.scheme == "http" && %w[localhost 127.0.0.1 [::1]].include?(uri.host)))
25
+ rescue URI::InvalidURIError
26
+ false
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ require "base64"
2
+ require "digest"
3
+
4
+ module Postnhost
5
+ class OauthGrant < ApplicationRecord
6
+ belongs_to :oauth_client, class_name: "Postnhost::OauthClient"
7
+ belongs_to :user, class_name: "Postnhost::User"
8
+
9
+ def self.issue!(**attributes)
10
+ code = SecureRandom.urlsafe_base64(32)
11
+ create!(**attributes, code_digest: Digest::SHA256.hexdigest(code), expires_at: 5.minutes.from_now)
12
+ code
13
+ end
14
+
15
+ def self.exchange(code:, client_id:, redirect_uri:, resource:, code_verifier:)
16
+ return unless code_verifier.match?(/\A[A-Za-z0-9._~-]{43,128}\z/)
17
+
18
+ transaction do
19
+ grant = lock.find_by(code_digest: Digest::SHA256.hexdigest(code))
20
+ return unless grant&.expires_at&.future? && grant.oauth_client.client_id == client_id &&
21
+ grant.redirect_uri == redirect_uri && grant.resource == resource
22
+
23
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
24
+ return unless ActiveSupport::SecurityUtils.secure_compare(grant.code_challenge, challenge)
25
+
26
+ token = ApiToken.issue!(user: grant.user, name: grant.oauth_client.name, resource: grant.resource)
27
+ grant.destroy!
28
+ token.last
29
+ end
30
+ end
31
+ end
32
+ end
@@ -5,6 +5,9 @@ module Postnhost
5
5
 
6
6
  self.table_name = "postnhost_users"
7
7
 
8
+ has_many :api_tokens, class_name: "Postnhost::ApiToken", dependent: :destroy
9
+ has_many :oauth_grants, class_name: "Postnhost::OauthGrant", dependent: :destroy
10
+
8
11
  has_secure_password
9
12
 
10
13
  mount_uploader :avatar_file, Postnhost::AvatarUploader
@@ -0,0 +1,27 @@
1
+ module Postnhost
2
+ module Api
3
+ module V1
4
+ module Openapi
5
+ def self.document(server:)
6
+ { openapi: "3.1.0", info: { title: "PostnHost CMS management API", version: "1" }, servers: [{ url: server }],
7
+ security: [{ bearerAuth: [] }], components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } },
8
+ paths: Management::Catalog.tools.to_h { |tool| ["/commands/#{tool[:name]}", { post: operation(tool) }] } }
9
+ end
10
+
11
+ def self.operation(tool)
12
+ { operationId: tool[:name], summary: tool[:description],
13
+ requestBody: { required: true, content: { "application/json" => { schema: { type: "object", required: ["arguments"], properties: { arguments: tool[:inputSchema] } } } } },
14
+ responses: {
15
+ "200" => { description: "Command completed. JSON contains data and errors." },
16
+ "202" => { description: "Translation queued. Poll the variant's generating field." },
17
+ "400" => { description: "Invalid arguments." }, "401" => { description: "Invalid bearer token." },
18
+ "404" => { description: "Record not found in the requested scope." },
19
+ "409" => { description: "Conflicting data or protected action." },
20
+ "422" => { description: "Validation or publication failure. Bulk results contain per-record failures." },
21
+ "503" => { description: "Translation queue unavailable. Data identifies queued work and failed_language_ids to retry." }
22
+ } }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end