calmlens 0.1.0

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.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+
6
+ # Webhook header names, signature tolerance, event type and verification helpers.
7
+ module CalmLens
8
+ # Header carrying the delivery ID. Retries of one delivery repeat it: use it to skip duplicates.
9
+ WEBHOOK_DELIVERY_HEADER = 'x-calmlens-delivery'
10
+ # Header carrying the Unix time (seconds) the delivery was signed.
11
+ WEBHOOK_TIMESTAMP_HEADER = 'x-calmlens-timestamp'
12
+ # Header carrying `v1=<hex HMAC-SHA256>` signatures, comma-separated.
13
+ WEBHOOK_SIGNATURE_HEADER = 'x-calmlens-signature'
14
+ # Deliveries whose timestamp is further than this from the receiver's clock are rejected.
15
+ WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300
16
+
17
+ # A verified webhook delivery: `{id, type, createdAt, projectId, data}`, where `data` holds the
18
+ # event's `severity`, `title`, `description`, `assetId`, `asset` and `facts`.
19
+ #
20
+ # @!attribute id [String] the event ID.
21
+ # @!attribute type [String] such as `asset.approved`. New types may appear: ignore unknown ones.
22
+ # @!attribute created_at [Integer] milliseconds since the epoch.
23
+ # @!attribute project_id [String]
24
+ # @!attribute data [Hash{String => Object}] the event fields, with the API's camelCase keys.
25
+ WebhookEvent = Struct.new(:id, :type, :created_at, :project_id, :data, keyword_init: true) do
26
+ # @return [String, nil]
27
+ def asset_id = data['assetId']
28
+
29
+ # @return [Array<Hash>] `{'key', 'value', 'url'?}` entries registered for the event type.
30
+ def facts = data['facts'].is_a?(Array) ? data['facts'] : []
31
+
32
+ # @return [Boolean] true for a test send from the portal or CLI.
33
+ def test? = facts.any? { |fact| fact.is_a?(Hash) && fact['key'] == 'test' && fact['value'] == true }
34
+ end
35
+
36
+ # Webhook signing and verification. Always verify the exact bytes received: read the raw body
37
+ # (`request.raw_post` in Rails, `request.body.read` in Rack and Sinatra) before parsing it.
38
+ module Webhooks
39
+ module_function
40
+
41
+ # Builds the `x-calmlens-signature` value for a body, for example to test your receiver.
42
+ # @param payload [String] the raw body
43
+ # @param timestamp [String, Integer] Unix seconds
44
+ # @param secret [String]
45
+ # @return [String] `v1=<hex>`
46
+ def signature_header(payload:, timestamp:, secret:)
47
+ raw = raw_body(payload)
48
+ raise Internal.invalid('secret must be a non-empty String') unless secret.is_a?(String) && !secret.empty?
49
+
50
+ "v1=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp.to_s.b}.".b + raw)}"
51
+ end
52
+
53
+ # Checks a delivery's signature and freshness in constant time.
54
+ # @param payload [String] the raw request body, exactly as received
55
+ # @param signature [String, nil] the `x-calmlens-signature` header
56
+ # @param timestamp [String, nil] the `x-calmlens-timestamp` header
57
+ # @param secret [String] the webhook's signing secret
58
+ # @param tolerance_seconds [Integer]
59
+ # @param now [Integer] Unix seconds
60
+ # @return [Boolean]
61
+ # @raise [InvalidArgumentError] when payload is not a String, such as an already parsed Hash.
62
+ def verify(payload:, signature:, timestamp:, secret:, tolerance_seconds: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,
63
+ now: Time.now.to_i)
64
+ raw = raw_body(payload)
65
+ stamp = timestamp.to_s.strip
66
+ return false if !secret.is_a?(String) || secret.empty? || signature.to_s.empty? || !stamp.match?(/\A[0-9]+\z/)
67
+ return false if (now.floor - stamp.to_i).abs > tolerance_seconds
68
+
69
+ expected = signature_header(payload: raw, timestamp: stamp, secret: secret).delete_prefix('v1=')
70
+ signature.to_s.split(',').map(&:strip).select { |entry| entry.start_with?('v1=') }.any? do |entry|
71
+ candidate = entry.delete_prefix('v1=').downcase
72
+ candidate.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(candidate, expected)
73
+ end
74
+ end
75
+
76
+ # Verifies a delivery, then parses it.
77
+ # @param (see #verify)
78
+ # @return [WebhookEvent]
79
+ # @raise [WebhookVerificationError] `sdk/invalid-webhook-signature` when verification fails, or
80
+ # `sdk/invalid-webhook-payload` when the verified body is not an event.
81
+ def parse_event(payload:, signature:, timestamp:, secret:,
82
+ tolerance_seconds: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, now: Time.now.to_i)
83
+ unless verify(payload: payload, signature: signature, timestamp: timestamp, secret: secret,
84
+ tolerance_seconds: tolerance_seconds, now: now)
85
+ raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_SIGNATURE,
86
+ 'Webhook signature or timestamp did not verify')
87
+ end
88
+
89
+ decode_event(payload)
90
+ end
91
+
92
+ def decode_event(payload)
93
+ body = JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
94
+ unless body.is_a?(Hash) && body['id'].is_a?(String) && body['type'].is_a?(String) && body['createdAt'].is_a?(Integer) &&
95
+ body['projectId'].is_a?(String) && body['data'].is_a?(Hash)
96
+ raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not a CalmLens event')
97
+ end
98
+
99
+ WebhookEvent.new(id: body['id'], type: body['type'], created_at: body['createdAt'],
100
+ project_id: body['projectId'], data: body['data'])
101
+ rescue JSON::ParserError, EncodingError => e
102
+ raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not JSON'), cause: e
103
+ end
104
+
105
+ def raw_body(payload)
106
+ return payload.b if payload.is_a?(String)
107
+
108
+ raise Internal.invalid("Webhook payload must be the raw body String, not a #{payload.class}. " \
109
+ 'Read request.raw_post (Rails) or request.body.read (Rack, Sinatra) before parsing.')
110
+ end
111
+ private_class_method :decode_event, :raw_body
112
+ end
113
+
114
+ # @see Webhooks.signature_header
115
+ def self.create_webhook_signature_header(...) = Webhooks.signature_header(...)
116
+
117
+ # @see Webhooks.verify
118
+ def self.verify_webhook_signature(...) = Webhooks.verify(...)
119
+
120
+ # @see Webhooks.parse_event
121
+ def self.parse_webhook_event(...) = Webhooks.parse_event(...)
122
+ end
data/lib/calmlens.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CalmLens server-side SDK: moderation, uploads, events, webhooks and CDN delivery.
4
+ # Start with {CalmLens::ServerClient}; see {CalmLens::Webhooks} to receive deliveries.
5
+ module CalmLens
6
+ end
7
+
8
+ require_relative 'calmlens/version'
9
+ require_relative 'calmlens/error_codes'
10
+ require_relative 'calmlens/errors'
11
+ require_relative 'calmlens/internal'
12
+ require_relative 'calmlens/transforms'
13
+ require_relative 'calmlens/response'
14
+ require_relative 'calmlens/transport'
15
+ require_relative 'calmlens/upload_body'
16
+ require_relative 'calmlens/session'
17
+ require_relative 'calmlens/resource'
18
+ require_relative 'calmlens/assets'
19
+ require_relative 'calmlens/folders'
20
+ require_relative 'calmlens/text'
21
+ require_relative 'calmlens/upload_tokens'
22
+ require_relative 'calmlens/events'
23
+ require_relative 'calmlens/cdn'
24
+ require_relative 'calmlens/lenses'
25
+ require_relative 'calmlens/server_client'
26
+ require_relative 'calmlens/webhooks'
data/sig/calmlens.rbs ADDED
@@ -0,0 +1,273 @@
1
+ # Public API of the calmlens gem. Request and response shapes are in sig/types.rbs and error codes
2
+ # in sig/error_codes.rbs.
3
+ module CalmLens
4
+ VERSION: String
5
+
6
+ WEBHOOK_DELIVERY_HEADER: String
7
+ WEBHOOK_TIMESTAMP_HEADER: String
8
+ WEBHOOK_SIGNATURE_HEADER: String
9
+ WEBHOOK_SIGNATURE_TOLERANCE_SECONDS: Integer
10
+
11
+ module SdkErrorCodes
12
+ INVALID_ARGUMENT: String
13
+ INVALID_CONFIG: String
14
+ TRANSPORT_ERROR: String
15
+ TIMEOUT: String
16
+ INVALID_RESPONSE: String
17
+ HTTP_ERROR: String
18
+ RESPONSE_TOO_LARGE: String
19
+ INVALID_WEBHOOK_SIGNATURE: String
20
+ INVALID_WEBHOOK_PAYLOAD: String
21
+ end
22
+
23
+ class Error < StandardError
24
+ attr_reader code: String
25
+ # Extra details from the API error body (any JSON), plus `"network" => true` for API errors.
26
+ attr_reader details: Hash[String, json_value]
27
+ attr_reader status: Integer?
28
+ attr_reader request_id: String?
29
+ attr_reader headers: Hash[String, String]
30
+ def initialize: (String code, ?String? message, ?details: Hash[String, json_value], ?status: Integer?,
31
+ ?headers: Hash[String, String], ?request_id: String?) -> void
32
+ def retry_after: () -> Float?
33
+ def not_found?: () -> bool
34
+ def retryable?: () -> bool
35
+ def self.build: (String code, ?String? message, ?status: Integer?, ?details: Hash[String, json_value],
36
+ ?headers: Hash[String, String], ?request_id: String?) -> Error
37
+ end
38
+ class APIError < Error end
39
+ class BadRequestError < APIError end
40
+ class AuthenticationError < APIError end
41
+ class PermissionDeniedError < APIError end
42
+ class NotFoundError < APIError end
43
+ class ConflictError < APIError end
44
+ class RateLimitError < APIError end
45
+ class ServerError < APIError end
46
+ class ConnectionError < Error end
47
+ class TimeoutError < ConnectionError end
48
+ class InvalidResponseError < Error end
49
+ class InvalidArgumentError < Error end
50
+ class ConfigurationError < InvalidArgumentError end
51
+ class WebhookVerificationError < Error end
52
+
53
+ class Response
54
+ attr_reader status: Integer
55
+ attr_reader headers: Hash[String, String]
56
+ # Give `body:` for a complete body, or a reader block that yields each String chunk to the
57
+ # block it receives. RBS cannot describe a block that takes a block, hence `(?)`.
58
+ def initialize: (status: Integer | String, ?headers: Hash[String | Symbol, String | Array[String]], ?body: String?)
59
+ ?{ (?) -> void } -> void
60
+ def request_id: () -> String?
61
+ def content_type: () -> String?
62
+ def content_length: () -> Integer?
63
+ def success?: () -> bool
64
+ def each_chunk: () { (String chunk) -> void } -> nil
65
+ | () -> Enumerator[String, nil]
66
+ def body: (?limit: Integer?) -> String
67
+ end
68
+
69
+ module Transport
70
+ class Request < Struct[untyped]
71
+ attr_accessor method: String
72
+ attr_accessor url: String
73
+ attr_accessor headers: Hash[String, String]
74
+ attr_accessor body: (String | _Reader)?
75
+ attr_accessor open_timeout: number
76
+ attr_accessor timeout: number
77
+ def initialize: (method: String, url: String, headers: Hash[String, String], body: (String | _Reader)?,
78
+ open_timeout: number, timeout: number) -> void
79
+ end
80
+
81
+ interface _Reader
82
+ def read: (?Integer? length, ?String? buffer) -> String?
83
+ end
84
+
85
+ # A custom transport receives each request and either yields a {Response} to the block (and
86
+ # returns the block's result) or returns a response: a {Response}, or any object with `status`
87
+ # (or `code`), `headers` (or `to_hash`) and `body`. The return value is untyped because the
88
+ # client only inspects it when the transport did not yield.
89
+ interface _Transport
90
+ def call: (Request request) { (Response response) -> untyped } -> untyped
91
+ end
92
+
93
+ class NetHTTP
94
+ def initialize: () -> void
95
+ # Returns the block's result.
96
+ def call: [T] (Request request) { (Response response) -> T } -> T?
97
+ def close: () -> void
98
+ end
99
+ end
100
+
101
+ class ServerClient
102
+ DEFAULT_BASE_URL: String
103
+ DEFAULT_CDN_BASE_URL: String
104
+ attr_reader assets: Assets
105
+ attr_reader folders: Folders
106
+ attr_reader text: Text
107
+ attr_reader upload_tokens: UploadTokens
108
+ attr_reader events: Events
109
+ attr_reader cdn: CDN
110
+ attr_reader project_id: String
111
+ attr_reader base_url: String
112
+ attr_reader cdn_base_url: String
113
+ def initialize: (api_key: String, project_id: String, ?base_url: String, ?cdn_base_url: String, ?timeout: number,
114
+ ?open_timeout: number, ?max_retries: Integer, ?retry_base_delay: number,
115
+ ?transport: Transport::_Transport?) -> void
116
+ def close: () -> nil
117
+ def ensure_lenses: (ensure_lenses_options options) -> Array[lens]
118
+ def ensure_lens: (ensure_lens_options options) -> lens
119
+ end
120
+
121
+ class Resource
122
+ # Resources are created by {ServerClient}; the session is internal.
123
+ private def initialize: (untyped session) -> void
124
+ end
125
+
126
+ class Assets < Resource
127
+ # Moderation statuses that carry a decision.
128
+ DECIDED_MODERATION_STATUSES: Array[String]
129
+ # Processing statuses after which no decision arrives without a retry.
130
+ FINISHED_PROCESSING_STATUSES: Array[String]
131
+
132
+ def search: (asset_search_options options) -> asset_search_response
133
+ def list: (?list_assets_options options) -> asset_page
134
+ def each: (?list_assets_options options) { (asset) -> void } -> nil
135
+ | (?list_assets_options options) -> Enumerator[asset, nil]
136
+ def list_children: (list_asset_children_options options) -> asset_page
137
+ def each_child: (list_asset_children_options options) { (asset) -> void } -> nil
138
+ | (list_asset_children_options options) -> Enumerator[asset, nil]
139
+ def retrieve: (asset_id_options options) -> asset
140
+ def wait_for_decision: (wait_for_decision_options options) -> asset
141
+ def update: (update_asset_options options) -> asset
142
+ def retry: (asset_id_options options) -> asset
143
+ def delete_content: (asset_id_options options) -> asset
144
+ def delete: (asset_id_options options) -> nil
145
+ def move: (move_asset_options options) -> asset
146
+ def create_signed_url: (create_signed_url_options options) -> signed_url
147
+ def create: (upload_options options) -> asset
148
+ def moderate: (upload_options options) -> asset
149
+ def upload_part: (upload_part_options options) -> asset
150
+ end
151
+
152
+ class Folders < Resource
153
+ def list: (?list_folders_options options) -> Array[folder]
154
+ def create: (create_folder_options options) -> folder
155
+ def retrieve: (folder_id_options options) -> folder
156
+ def update: (update_folder_options options) -> folder
157
+ def move: (move_folder_options options) -> folder
158
+ def delete: (folder_id_options options) -> nil
159
+ end
160
+
161
+ class UploadTokens < Resource
162
+ TOKEN_PREFIX: String
163
+ SIGNING_CONTEXT: String
164
+ DEFAULT_TTL_SECONDS: Integer
165
+ MAX_TTL_SECONDS: Integer
166
+ private def initialize: (untyped session, String api_key) -> void
167
+ def sign: (sign_upload_token_options options) -> signed_upload_token
168
+ end
169
+
170
+ class Text < Resource
171
+ private def initialize: (untyped session, Assets assets) -> void
172
+ def moderate: (moderate_text_options options) -> asset
173
+ end
174
+
175
+ class Events < Resource
176
+ def list: (?list_events_options options) -> events_page
177
+ def each_page: (?list_events_options options) { (events_page) -> void } -> nil
178
+ | (?list_events_options options) -> Enumerator[events_page, nil]
179
+ def each: (?list_events_options options) { (event) -> void } -> nil
180
+ | (?list_events_options options) -> Enumerator[event, nil]
181
+ end
182
+
183
+ class CDN < Resource
184
+ def get_asset_url: (asset_url_options options) -> String
185
+ def serve: (serve_options options) -> Response
186
+ | [T] (serve_options options) { (Response) -> T } -> T
187
+ end
188
+
189
+ class WebhookEvent < Struct[untyped]
190
+ attr_accessor id: String
191
+ attr_accessor type: event_type
192
+ # Milliseconds since the Unix epoch.
193
+ attr_accessor created_at: Integer
194
+ attr_accessor project_id: String
195
+ attr_accessor data: webhook_event_data
196
+ def initialize: (id: String, type: event_type, created_at: Integer, project_id: String, data: webhook_event_data) -> void
197
+ def asset_id: () -> String?
198
+ def facts: () -> Array[event_fact]
199
+ def test?: () -> bool
200
+ end
201
+
202
+ module Webhooks
203
+ def self.signature_header: (payload: String, timestamp: String | Integer, secret: String) -> String
204
+ def self.verify: (payload: String, signature: String?, timestamp: String?, secret: String, ?tolerance_seconds: Integer,
205
+ ?now: number) -> bool
206
+ def self.parse_event: (payload: String, signature: String?, timestamp: String?, secret: String,
207
+ ?tolerance_seconds: Integer, ?now: number) -> WebhookEvent
208
+ end
209
+
210
+ def self.create_webhook_signature_header: (payload: String, timestamp: String | Integer, secret: String) -> String
211
+ def self.verify_webhook_signature: (payload: String, signature: String?, timestamp: String?, secret: String,
212
+ ?tolerance_seconds: Integer, ?now: number) -> bool
213
+ def self.parse_webhook_event: (payload: String, signature: String?, timestamp: String?, secret: String,
214
+ ?tolerance_seconds: Integer, ?now: number) -> WebhookEvent
215
+
216
+ # require 'calmlens/testing'
217
+ module Testing
218
+ # The mock's in-memory records. Change them to arrange a test, such as setting an asset's `moderation.status`.
219
+ class State < Struct[untyped]
220
+ attr_accessor assets: Hash[String, asset]
221
+ attr_accessor folders: Hash[String, folder]
222
+ # Stored bytes by asset ID, with each part's bytes by part number.
223
+ attr_accessor content: Hash[String, { "type" => String, "parts" => Hash[Integer, String] }]
224
+ attr_accessor events: Array[event]
225
+ attr_accessor lenses: Hash[String, lens]
226
+ end
227
+ end
228
+
229
+ class MockServerClient < ServerClient
230
+ class Request < Struct[untyped]
231
+ attr_accessor method: String
232
+ attr_accessor url: String
233
+ attr_accessor path: String
234
+ attr_accessor query: Hash[String, String]
235
+ attr_accessor headers: Hash[String, String]
236
+ attr_accessor body: String?
237
+ def json: () -> json_value
238
+ end
239
+
240
+ interface _Clock
241
+ # The current time in milliseconds since the Unix epoch.
242
+ def call: () -> Integer
243
+ end
244
+
245
+ interface _IdFactory
246
+ def call: () -> String
247
+ end
248
+
249
+ # Where a mock upload ends up: `pending` (still processing), `approved` or `rejected`
250
+ # (processing done with that decision), or `failed` (processing failed, retryable).
251
+ type outcome = "pending" | "approved" | "rejected" | "failed"
252
+
253
+ # An outcome, fields to deep-merge into the asset, or a callable that receives a copy of the
254
+ # asset and returns either.
255
+ type analyzed_outcome = outcome | Hash[String, json_input] | _AnalyzedOutcome
256
+
257
+ interface _AnalyzedOutcome
258
+ def call: (asset asset) -> (String | Hash[String, json_input])
259
+ end
260
+
261
+ def initialize: (?project_id: String, ?base_url: String, ?cdn_base_url: String, ?now: _Clock, ?create_id: _IdFactory,
262
+ ?queued_outcome: outcome, ?analyzed_outcome: analyzed_outcome, ?timeout: number,
263
+ ?open_timeout: number, ?max_retries: Integer, ?retry_base_delay: number) -> void
264
+ def state: () -> Testing::State
265
+ # `body` is any JSON value, encoded as the response body.
266
+ def enqueue: (body: json_input, ?status: Integer, ?headers: Hash[String, String]) -> self
267
+ def enqueue_raw: (body: String, ?status: Integer, ?headers: Hash[String, String]) -> self
268
+ def enqueue_error: (Exception error) -> self
269
+ def enqueue_event: (mock_event_input event) -> event
270
+ def requests: () -> Array[Request]
271
+ def remaining_responses: () -> Integer
272
+ end
273
+ end
@@ -0,0 +1,67 @@
1
+ # Mirrors lib/calmlens/error_codes.rb, which packages/sdk-contract/generate.ts generates from
2
+ # packages/shared/src/PublicErrorCodes.ts. test/signature_test.rb fails when the two differ.
3
+ module CalmLens
4
+ module ErrorCodes
5
+ INVALID_CURSOR: String
6
+ MISSING_TOKEN: String
7
+ INVALID_API_KEY: String
8
+ API_KEY_PROJECT_MISMATCH: String
9
+ API_KEY_NOT_ACCEPTED: String
10
+ FILE_TOO_LARGE: String
11
+ IMAGE_REQUIRES_MULTIPART_UPLOAD: String
12
+ MULTIPART_IMAGE_TOO_SMALL: String
13
+ MULTIPART_IMAGE_TOO_LARGE: String
14
+ IMAGE_EXCEEDED_MAX_DIMENSIONS: String
15
+ UPLOAD_LIMIT_EXCEEDED: String
16
+ SPEND_LIMIT_REACHED: String
17
+ INVALID_UPLOAD_MIME_TYPE: String
18
+ INVALID_EVENT_CURSOR: String
19
+ INVALID_UPLOAD_PAYLOAD: String
20
+ USE_UPLOAD_PART: String
21
+ UPLOAD_NOT_OPEN: String
22
+ INVALID_UPLOAD_PART_NUMBER: String
23
+ UPLOAD_MISSING: String
24
+ UPLOAD_SIZE_MISMATCH: String
25
+ INVALID_IDEMPOTENCY_KEY: String
26
+ IDEMPOTENCY_KEY_REUSED: String
27
+ IDEMPOTENT_REQUEST_IN_PROGRESS: String
28
+ ASSET_NOT_FOUND: String
29
+ PROJECT_NOT_FOUND: String
30
+ ASSET_UPDATE_FAILED: String
31
+ ASSET_SAFETY_HELD: String
32
+ ASSET_NOT_RETRYABLE: String
33
+ ASSET_CONTENT_UNAVAILABLE: String
34
+ SUPPORT_TICKET_NOT_FOUND: String
35
+ DUPLICATE_EXTERNAL_ID: String
36
+ FOLDER_NOT_FOUND: String
37
+ DUPLICATE_FOLDER_NAME: String
38
+ FOLDER_NOT_EMPTY: String
39
+ FOLDER_PARENT_CYCLE: String
40
+ LENS_VERSION_CONFLICT: String
41
+ UPLOAD_PRESET_NOT_FOUND: String
42
+ UPLOAD_PRESET_DISABLED: String
43
+ UPLOAD_PRESET_TOKEN_REQUIRED: String
44
+ REMOTE_URL_NOT_ALLOWED: String
45
+ WEBSITE_RATE_LIMIT_EXCEEDED: String
46
+ UPLOAD_PRESET_CONTENT_TYPE_NOT_ALLOWED: String
47
+ UPLOAD_PRESET_TOO_LARGE: String
48
+ UPLOAD_PRESET_DAILY_LIMIT_REACHED: String
49
+ UPLOAD_PRESET_ORIGIN_NOT_ALLOWED: String
50
+ UPLOAD_PRESET_FIELD_NOT_ALLOWED: String
51
+ UPLOAD_TOKEN_INVALID: String
52
+ UPLOAD_TOKEN_EXPIRED: String
53
+ UPLOAD_TOKEN_USED: String
54
+ INVALID_REMOTE_URL: String
55
+ REMOTE_URL_UNAVAILABLE: String
56
+ APPEAL_NOT_FOUND: String
57
+ APPEAL_JUDGMENT_NOT_FOUND: String
58
+ APPEAL_ASSET_NOT_FOUND: String
59
+ APPEAL_PERMISSION_DENIED: String
60
+ APPEAL_ALREADY_REVIEWED: String
61
+ APPEAL_DECISION_CHANGED: String
62
+ APPEAL_INVALID_ACTION: String
63
+ APPEAL_SAFETY_HELD: String
64
+ APPEAL_CONTENT_UNAVAILABLE: String
65
+ STATUSES: Hash[String, Integer]
66
+ end
67
+ end