parse-stack-next 5.6.0 → 5.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +489 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +219 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +366 -113
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +6 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +6 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +136 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +120 -5
- data/parse-stack-next.gemspec +6 -6
- metadata +10 -1
data/lib/parse/clp_scope.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Parse
|
|
|
11
11
|
|
|
12
12
|
def initialize(class_name, operation, reason = nil)
|
|
13
13
|
@class_name = class_name
|
|
14
|
-
@operation
|
|
14
|
+
@operation = operation
|
|
15
15
|
super(reason || "CLP denied: #{operation} on #{class_name}")
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -44,6 +44,31 @@ module Parse
|
|
|
44
44
|
# branch on `kind` before dereferencing.
|
|
45
45
|
CacheEntry = Struct.new(:kind, :clp, :fetched_at, keyword_init: true)
|
|
46
46
|
|
|
47
|
+
# Result of evaluating a single CLP branch for object-access inspection.
|
|
48
|
+
# This is intentionally richer than {permits?}: Parse Server only applies
|
|
49
|
+
# pointer permissions when no public/user/role branch already grants the
|
|
50
|
+
# operation, and callers must then inspect the target row before answering.
|
|
51
|
+
AccessEvaluation = Struct.new(
|
|
52
|
+
:status, :via, :pointer_fields, :role_claims, :reason,
|
|
53
|
+
keyword_init: true,
|
|
54
|
+
) do
|
|
55
|
+
def allowed?
|
|
56
|
+
status == :allowed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def denied?
|
|
60
|
+
status == :denied
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def unknown?
|
|
64
|
+
status == :unknown
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def row_check_required?
|
|
68
|
+
status == :allowed && pointer_fields&.any?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
47
72
|
# Positive-cache TTL (seconds): how long a successful schema fetch
|
|
48
73
|
# is reused. Mirrors the previous module-level `@cache_ttl` knob;
|
|
49
74
|
# kept identical to preserve backwards-compatible cache behavior.
|
|
@@ -64,11 +89,11 @@ module Parse
|
|
|
64
89
|
class << self
|
|
65
90
|
attr_accessor :cache_ttl, :schema_client
|
|
66
91
|
|
|
67
|
-
def permits?(class_name, op, permission_strings)
|
|
92
|
+
def permits?(class_name, op, permission_strings, client: nil)
|
|
68
93
|
return true if permission_strings.nil? # master-key bypass
|
|
69
94
|
return true unless OPERATIONS.include?(op)
|
|
70
95
|
|
|
71
|
-
entry = fetch(class_name)
|
|
96
|
+
entry = fetch(class_name, client: client)
|
|
72
97
|
# `fetch` never returns nil now — it returns an `:unresolvable`
|
|
73
98
|
# CacheEntry on failure so callers must branch on `kind`.
|
|
74
99
|
case entry.kind
|
|
@@ -120,14 +145,14 @@ module Parse
|
|
|
120
145
|
false
|
|
121
146
|
end
|
|
122
147
|
|
|
123
|
-
def assert_permitted!(class_name, op, permission_strings)
|
|
124
|
-
return if permits?(class_name, op, permission_strings)
|
|
148
|
+
def assert_permitted!(class_name, op, permission_strings, client: nil)
|
|
149
|
+
return if permits?(class_name, op, permission_strings, client: client)
|
|
125
150
|
raise Denied.new(class_name, op,
|
|
126
|
-
|
|
151
|
+
"CLP refuses #{op} on '#{class_name}' for the current scope.")
|
|
127
152
|
end
|
|
128
153
|
|
|
129
|
-
def pointer_fields_for(class_name, op)
|
|
130
|
-
entry = fetch(class_name)
|
|
154
|
+
def pointer_fields_for(class_name, op, client: nil)
|
|
155
|
+
entry = fetch(class_name, client: client)
|
|
131
156
|
# No CLP at all, or schema unresolvable: there's no
|
|
132
157
|
# pointerFields constraint to apply. (For :unresolvable the
|
|
133
158
|
# caller's `permits?` already failed closed; this helper just
|
|
@@ -141,10 +166,124 @@ module Parse
|
|
|
141
166
|
arr.empty? ? nil : arr
|
|
142
167
|
end
|
|
143
168
|
|
|
144
|
-
|
|
169
|
+
# Return Parse Server's top-level `readUserFields` or
|
|
170
|
+
# `writeUserFields` row constraint for an operation. Unlike an
|
|
171
|
+
# operation's `pointerFields` grant, these keys live alongside the
|
|
172
|
+
# operation maps in `classLevelPermissions` and therefore need their
|
|
173
|
+
# own lookup.
|
|
174
|
+
#
|
|
175
|
+
# @param class_name [String] Parse class name.
|
|
176
|
+
# @param op [Symbol] CLP operation.
|
|
177
|
+
# @return [Array<String>, nil] pointer field names, or nil when the
|
|
178
|
+
# operation has no corresponding user-field constraint.
|
|
179
|
+
def user_fields_for(class_name, op, client: nil)
|
|
180
|
+
key = case op.to_sym
|
|
181
|
+
when :find, :get, :count then "readUserFields"
|
|
182
|
+
when :create, :update, :delete then "writeUserFields"
|
|
183
|
+
end
|
|
184
|
+
return nil if key.nil?
|
|
185
|
+
|
|
186
|
+
entry = fetch(class_name, client: client)
|
|
187
|
+
return nil if entry.kind == :no_clp || entry.kind == :unresolvable
|
|
188
|
+
|
|
189
|
+
fields = entry.clp[key] || entry.clp[key.to_sym]
|
|
190
|
+
arr = Array(fields).map(&:to_s)
|
|
191
|
+
arr.empty? ? nil : arr
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Evaluate the same mutually-exclusive CLP branches Parse Server uses
|
|
195
|
+
# before applying a query to a particular row. Public, direct-user, and
|
|
196
|
+
# role grants bypass pointer constraints. `requiresAuthentication` does
|
|
197
|
+
# not: when pointer/user fields exist, a concrete authenticated `_User`
|
|
198
|
+
# id must still match the row.
|
|
199
|
+
#
|
|
200
|
+
# Unlike {permits?}, schema lookup failures are reported as `:unknown`
|
|
201
|
+
# rather than collapsed into a denial. Authorization-enforcing callers
|
|
202
|
+
# can still fail closed by accepting only {AccessEvaluation#allowed?}.
|
|
203
|
+
#
|
|
204
|
+
# @param class_name [String] Parse class name.
|
|
205
|
+
# @param op [Symbol] one of {OPERATIONS}.
|
|
206
|
+
# @param claims [Enumerable<String>] public, user, and role claims.
|
|
207
|
+
# @param authenticated [Boolean] whether authentication is established.
|
|
208
|
+
# @param user_id [String, nil] concrete authenticated `_User.objectId`.
|
|
209
|
+
# @param client [Parse::Client] application whose schema owns the CLP.
|
|
210
|
+
# @return [AccessEvaluation]
|
|
211
|
+
def evaluate_access(class_name, op, claims:, authenticated:, user_id: nil, client: nil)
|
|
212
|
+
op = op.to_sym
|
|
213
|
+
unless OPERATIONS.include?(op)
|
|
214
|
+
return access_evaluation(:unknown, reason: :unsupported_operation)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
entry = fetch(class_name, client: client)
|
|
218
|
+
case entry.kind
|
|
219
|
+
when :unresolvable
|
|
220
|
+
return access_evaluation(:unknown, reason: :clp_unresolvable)
|
|
221
|
+
when :no_clp
|
|
222
|
+
return access_evaluation(:allowed, via: :public_default)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
op_map = entry.clp[op.to_s] || entry.clp[op]
|
|
226
|
+
# Parse Server treats an omitted operation map as public. Because that
|
|
227
|
+
# is already a base grant, grouped pointer fields do not narrow it.
|
|
228
|
+
return access_evaluation(:allowed, via: :public_default) if op_map.nil?
|
|
229
|
+
unless op_map.is_a?(Hash)
|
|
230
|
+
return access_evaluation(:unknown, reason: :malformed_clp)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
claim_set = claims.is_a?(Set) ? claims : Set.new(Array(claims).map(&:to_s))
|
|
234
|
+
role_claims = op_map.each_with_object([]) do |(principal, allowed), memo|
|
|
235
|
+
key = principal.to_s
|
|
236
|
+
memo << key if allowed == true && key.start_with?("role:")
|
|
237
|
+
end.freeze
|
|
238
|
+
|
|
239
|
+
if op_map["*"] == true || op_map[:"*"] == true
|
|
240
|
+
return access_evaluation(:allowed, via: :public, role_claims: role_claims)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
direct_claim = op_map.find do |principal, allowed|
|
|
244
|
+
key = principal.to_s
|
|
245
|
+
allowed == true && key != "*" && key != "requiresAuthentication" &&
|
|
246
|
+
key != "pointerFields" && claim_set.include?(key)
|
|
247
|
+
end
|
|
248
|
+
if direct_claim
|
|
249
|
+
return access_evaluation(
|
|
250
|
+
:allowed,
|
|
251
|
+
via: direct_claim.first.to_s.start_with?("role:") ? :role : :user,
|
|
252
|
+
role_claims: role_claims,
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
pointer_fields = pointer_fields_from(entry.clp, op)
|
|
257
|
+
requires_authentication =
|
|
258
|
+
op_map["requiresAuthentication"] == true || op_map[:requiresAuthentication] == true
|
|
259
|
+
|
|
260
|
+
if pointer_fields.any?
|
|
261
|
+
if authenticated == true && !user_id.to_s.empty?
|
|
262
|
+
return access_evaluation(
|
|
263
|
+
:allowed,
|
|
264
|
+
via: :pointer,
|
|
265
|
+
pointer_fields: pointer_fields,
|
|
266
|
+
role_claims: role_claims,
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
status = authenticated == true ? :unknown : :denied
|
|
271
|
+
reason = authenticated == true ? :concrete_user_required : :authentication_required
|
|
272
|
+
return access_evaluation(status, role_claims: role_claims, reason: reason)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if requires_authentication && authenticated == true
|
|
276
|
+
return access_evaluation(:allowed, via: :authenticated, role_claims: role_claims)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
reason = requires_authentication ? :authentication_required : :clp_denied
|
|
280
|
+
access_evaluation(:denied, role_claims: role_claims, reason: reason)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def protected_fields_for(class_name, permission_strings, client: nil)
|
|
145
284
|
return EMPTY_SET if permission_strings.nil?
|
|
146
285
|
|
|
147
|
-
entry = fetch(class_name)
|
|
286
|
+
entry = fetch(class_name, client: client)
|
|
148
287
|
# No CLP / unresolvable: nothing to strip. For :unresolvable,
|
|
149
288
|
# `permits?` already refused the query, so this branch is only
|
|
150
289
|
# reached when callers ask for the protected-fields set directly
|
|
@@ -180,8 +319,15 @@ module Parse
|
|
|
180
319
|
documents.select { |doc| any_pointer_matches?(doc, pointer_fields, user_id.to_s) }
|
|
181
320
|
end
|
|
182
321
|
|
|
183
|
-
def invalidate!(class_name)
|
|
184
|
-
|
|
322
|
+
def invalidate!(class_name, client: nil)
|
|
323
|
+
class_key = class_name.to_s
|
|
324
|
+
@cache_mutex.synchronize do
|
|
325
|
+
if client
|
|
326
|
+
@cache.delete(cache_key(class_key, client))
|
|
327
|
+
else
|
|
328
|
+
@cache.delete_if { |(_scope, cached_class), _entry| cached_class == class_key }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
185
331
|
nil
|
|
186
332
|
end
|
|
187
333
|
|
|
@@ -196,7 +342,11 @@ module Parse
|
|
|
196
342
|
|
|
197
343
|
def cache_stats
|
|
198
344
|
@cache_mutex.synchronize do
|
|
199
|
-
{
|
|
345
|
+
{
|
|
346
|
+
size: @cache.size,
|
|
347
|
+
class_names: @cache.keys.map(&:last).uniq.sort,
|
|
348
|
+
scopes: @cache.keys.map(&:first).uniq.sort,
|
|
349
|
+
}
|
|
200
350
|
end
|
|
201
351
|
end
|
|
202
352
|
|
|
@@ -205,11 +355,11 @@ module Parse
|
|
|
205
355
|
# `:no_clp` (matches the public-default semantics Parse Server
|
|
206
356
|
# exposes when no CLP is configured); a non-empty `clp` is
|
|
207
357
|
# recorded as `:cached_clp` (the standard happy path).
|
|
208
|
-
def __cache_put(class_name, clp:)
|
|
358
|
+
def __cache_put(class_name, clp:, client: nil)
|
|
209
359
|
normalized = clp || {}
|
|
210
360
|
kind = normalized.empty? ? :no_clp : :cached_clp
|
|
211
361
|
entry = CacheEntry.new(kind: kind, clp: normalized, fetched_at: monotonic_now)
|
|
212
|
-
@cache_mutex.synchronize { @cache[class_name
|
|
362
|
+
@cache_mutex.synchronize { @cache[cache_key(class_name, client)] = entry }
|
|
213
363
|
entry
|
|
214
364
|
end
|
|
215
365
|
|
|
@@ -233,23 +383,24 @@ module Parse
|
|
|
233
383
|
# An empty `class_name` short-circuits to an `:unresolvable`
|
|
234
384
|
# entry — `permits?` will refuse the call rather than dispatching
|
|
235
385
|
# `schema("")` to the upstream client.
|
|
236
|
-
def fetch(class_name)
|
|
237
|
-
|
|
238
|
-
return unresolvable_entry if
|
|
386
|
+
def fetch(class_name, client: nil)
|
|
387
|
+
class_key = class_name.to_s
|
|
388
|
+
return unresolvable_entry if class_key.empty?
|
|
389
|
+
|
|
390
|
+
resolved_client = client || schema_client || default_client_safe
|
|
391
|
+
key = cache_key(class_key, resolved_client)
|
|
239
392
|
|
|
240
393
|
cached = @cache_mutex.synchronize { @cache[key] }
|
|
241
394
|
return cached if cached && !stale?(cached)
|
|
242
395
|
|
|
243
|
-
|
|
244
|
-
entry =
|
|
245
|
-
if client.nil?
|
|
396
|
+
entry = if resolved_client.nil?
|
|
246
397
|
# No client configured (Parse.setup never called, etc.) —
|
|
247
398
|
# treat as unresolvable so we fail closed instead of
|
|
248
399
|
# crashing inside the begin block with NoMethodError.
|
|
249
400
|
unresolvable_entry
|
|
250
401
|
else
|
|
251
402
|
begin
|
|
252
|
-
response =
|
|
403
|
+
response = resolved_client.schema(class_key)
|
|
253
404
|
if response&.success?
|
|
254
405
|
schema = response.result || {}
|
|
255
406
|
clp = schema["classLevelPermissions"] || {}
|
|
@@ -318,6 +469,46 @@ module Parse
|
|
|
318
469
|
Parse::Client.client(:default)
|
|
319
470
|
end
|
|
320
471
|
|
|
472
|
+
# Cache CLP by Parse application, not merely by class name. Two clients
|
|
473
|
+
# can legitimately point at different applications that both contain a
|
|
474
|
+
# `Document` class with unrelated permissions.
|
|
475
|
+
def cache_key(class_name, client = nil)
|
|
476
|
+
resolved_client = client || schema_client || default_client_safe
|
|
477
|
+
scope = if resolved_client.nil?
|
|
478
|
+
"client:none"
|
|
479
|
+
elsif resolved_client.respond_to?(:server_url) &&
|
|
480
|
+
resolved_client.respond_to?(:application_id)
|
|
481
|
+
"app:#{resolved_client.server_url}\u0000#{resolved_client.application_id}"
|
|
482
|
+
else
|
|
483
|
+
"client:#{resolved_client.object_id}"
|
|
484
|
+
end
|
|
485
|
+
[scope.freeze, class_name.to_s.freeze].freeze
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def access_evaluation(status, via: nil, pointer_fields: EMPTY_SET,
|
|
489
|
+
role_claims: EMPTY_SET, reason: nil)
|
|
490
|
+
AccessEvaluation.new(
|
|
491
|
+
status: status,
|
|
492
|
+
via: via,
|
|
493
|
+
pointer_fields: Array(pointer_fields).map(&:to_s).uniq.freeze,
|
|
494
|
+
role_claims: Array(role_claims).map(&:to_s).uniq.freeze,
|
|
495
|
+
reason: reason,
|
|
496
|
+
).freeze
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def pointer_fields_from(clp, op)
|
|
500
|
+
op_map = clp[op.to_s] || clp[op]
|
|
501
|
+
per_operation = if op_map.is_a?(Hash)
|
|
502
|
+
op_map["pointerFields"] || op_map[:pointerFields]
|
|
503
|
+
end
|
|
504
|
+
grouped_key = case op.to_sym
|
|
505
|
+
when :find, :get, :count then "readUserFields"
|
|
506
|
+
when :create, :update, :delete then "writeUserFields"
|
|
507
|
+
end
|
|
508
|
+
grouped = grouped_key && (clp[grouped_key] || clp[grouped_key.to_sym])
|
|
509
|
+
(Array(per_operation) + Array(grouped)).map(&:to_s).reject(&:empty?).uniq.freeze
|
|
510
|
+
end
|
|
511
|
+
|
|
321
512
|
def user_identity?(entry)
|
|
322
513
|
s = entry.to_s
|
|
323
514
|
s != "*" && !s.start_with?("role:")
|
|
@@ -339,20 +530,26 @@ module Parse
|
|
|
339
530
|
pointer_fields.any? do |field|
|
|
340
531
|
val = doc[field] || doc[field.to_sym]
|
|
341
532
|
if val.is_a?(Hash)
|
|
342
|
-
return true if val
|
|
533
|
+
return true if user_pointer_matches?(val, user_id)
|
|
343
534
|
elsif val.is_a?(Array)
|
|
344
|
-
return true if val.any?
|
|
345
|
-
v.is_a?(Hash) && (v["objectId"] == user_id || v[:objectId] == user_id)
|
|
346
|
-
end
|
|
535
|
+
return true if val.any? { |v| user_pointer_matches?(v, user_id) }
|
|
347
536
|
end
|
|
348
537
|
mongo_val = doc["_p_#{field}"] || doc[:"_p_#{field}"]
|
|
349
538
|
if mongo_val.is_a?(String) && mongo_val.include?("$")
|
|
350
|
-
|
|
351
|
-
return true if oid == user_id
|
|
539
|
+
klass, oid = mongo_val.split("$", 2)
|
|
540
|
+
return true if klass == Parse::Model::CLASS_USER && oid == user_id
|
|
352
541
|
end
|
|
353
542
|
false
|
|
354
543
|
end
|
|
355
544
|
end
|
|
545
|
+
|
|
546
|
+
def user_pointer_matches?(value, user_id)
|
|
547
|
+
return false unless value.is_a?(Hash)
|
|
548
|
+
type = value["__type"] || value[:__type]
|
|
549
|
+
klass = value["className"] || value[:className]
|
|
550
|
+
oid = value["objectId"] || value[:objectId]
|
|
551
|
+
type == Parse::Model::TYPE_POINTER && klass == Parse::Model::CLASS_USER && oid == user_id
|
|
552
|
+
end
|
|
356
553
|
end
|
|
357
554
|
|
|
358
555
|
@cache_ttl = POSITIVE_TTL
|
data/lib/parse/console.rb
CHANGED
|
@@ -26,7 +26,7 @@ require "timeout"
|
|
|
26
26
|
|
|
27
27
|
module Parse
|
|
28
28
|
module Console
|
|
29
|
-
DEFAULT_WATCH_EVENTS
|
|
29
|
+
DEFAULT_WATCH_EVENTS = [:create, :update, :delete, :enter, :leave].freeze
|
|
30
30
|
DEFAULT_WAIT_FOR_EVENTS = [:create, :enter].freeze
|
|
31
31
|
|
|
32
32
|
module_function
|
|
@@ -120,9 +120,9 @@ module Parse
|
|
|
120
120
|
# @return [Parse::Object] the matched row.
|
|
121
121
|
# @raise [Timeout::Error] when `timeout:` elapses with no match.
|
|
122
122
|
def wait_for(klass, where: {}, on: nil, timeout: nil, fields: nil,
|
|
123
|
-
|
|
123
|
+
session_token: nil, &predicate)
|
|
124
124
|
events = Array(on || DEFAULT_WAIT_FOR_EVENTS).map(&:to_sym)
|
|
125
|
-
queue
|
|
125
|
+
queue = Queue.new
|
|
126
126
|
sub = _open_subscription(klass, where: where, fields: fields, session_token: session_token)
|
|
127
127
|
|
|
128
128
|
events.each do |ev|
|
|
@@ -83,8 +83,8 @@ module Parse
|
|
|
83
83
|
# @param on_progress [#call, nil] callable invoked after each
|
|
84
84
|
# successful batch with `done:, total:, batch_index:, batch_count:`.
|
|
85
85
|
def initialize(provider, batch_size: nil, requests_per_minute: nil,
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
max_attempts: 5, base_delay: 2.0, max_delay: 60.0,
|
|
87
|
+
jitter: 0.25, retry_on: nil, on_progress: nil)
|
|
88
88
|
unless provider.is_a?(Provider)
|
|
89
89
|
raise ArgumentError,
|
|
90
90
|
"Parse::Embeddings::BatchEmbedder expects a Parse::Embeddings::Provider " \
|
|
@@ -164,7 +164,7 @@ module Parse
|
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
def backoff_delay(attempt)
|
|
167
|
-
delay = [@base_delay * (2**(attempt - 1)), @max_delay].min
|
|
167
|
+
delay = [@base_delay * (2 ** (attempt - 1)), @max_delay].min
|
|
168
168
|
delay * (1.0 + rand * @jitter)
|
|
169
169
|
end
|
|
170
170
|
|
|
@@ -313,15 +313,15 @@ module Parse
|
|
|
313
313
|
# to the other poisons the narrower/wider field.
|
|
314
314
|
def key_for(provider, input, input_type)
|
|
315
315
|
model = begin
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
provider.model_name
|
|
317
|
+
rescue NotImplementedError
|
|
318
|
+
"unknown"
|
|
319
|
+
end
|
|
320
320
|
dims = begin
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
provider.dimensions
|
|
322
|
+
rescue NotImplementedError
|
|
323
|
+
"unknown"
|
|
324
|
+
end
|
|
325
325
|
"#{provider.class.name}|#{model}|#{dims}|#{input_type}|#{Digest::SHA256.hexdigest(input.to_s)}"
|
|
326
326
|
end
|
|
327
327
|
|
|
@@ -347,15 +347,15 @@ module Parse
|
|
|
347
347
|
def instrument_hit(provider, input_type)
|
|
348
348
|
return unless defined?(ActiveSupport::Notifications)
|
|
349
349
|
model = begin
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
350
|
+
provider.model_name
|
|
351
|
+
rescue NotImplementedError
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
354
|
dims = begin
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
355
|
+
provider.dimensions
|
|
356
|
+
rescue NotImplementedError
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
359
|
payload = {
|
|
360
360
|
provider: provider.class.name,
|
|
361
361
|
model: model,
|
|
@@ -366,7 +366,7 @@ module Parse
|
|
|
366
366
|
cached: true,
|
|
367
367
|
error: nil,
|
|
368
368
|
}
|
|
369
|
-
ActiveSupport::Notifications.instrument(Provider::AS_NOTIFICATION_NAME, payload) {}
|
|
369
|
+
ActiveSupport::Notifications.instrument(Provider::AS_NOTIFICATION_NAME, payload) { }
|
|
370
370
|
end
|
|
371
371
|
end
|
|
372
372
|
end
|
|
@@ -68,29 +68,29 @@ module Parse
|
|
|
68
68
|
class RateLimitError < Error; end
|
|
69
69
|
class TransientError < Error; end
|
|
70
70
|
|
|
71
|
-
DEFAULT_BASE_URL
|
|
72
|
-
DEFAULT_MODEL
|
|
73
|
-
DEFAULT_TIMEOUT
|
|
71
|
+
DEFAULT_BASE_URL = "https://api.cohere.com/v1"
|
|
72
|
+
DEFAULT_MODEL = "embed-english-v3.0"
|
|
73
|
+
DEFAULT_TIMEOUT = 30
|
|
74
74
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
75
75
|
DEFAULT_MAX_RETRIES = 3
|
|
76
76
|
# Cohere documents a hard cap of 96 inputs per `/embed` call.
|
|
77
|
-
DEFAULT_BATCH_SIZE
|
|
78
|
-
MAX_RESPONSE_BYTES
|
|
77
|
+
DEFAULT_BATCH_SIZE = 96
|
|
78
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
79
79
|
|
|
80
80
|
MODEL_DEFAULT_DIMENSIONS = {
|
|
81
|
-
"embed-v4.0"
|
|
82
|
-
"embed-english-v3.0"
|
|
83
|
-
"embed-multilingual-v3.0"
|
|
84
|
-
"embed-english-light-v3.0"
|
|
85
|
-
"embed-multilingual-light-v3.0"
|
|
81
|
+
"embed-v4.0" => 1536,
|
|
82
|
+
"embed-english-v3.0" => 1024,
|
|
83
|
+
"embed-multilingual-v3.0" => 1024,
|
|
84
|
+
"embed-english-light-v3.0" => 384,
|
|
85
|
+
"embed-multilingual-light-v3.0" => 384,
|
|
86
86
|
}.freeze
|
|
87
87
|
|
|
88
88
|
MODEL_MAX_INPUT_TOKENS = {
|
|
89
|
-
"embed-v4.0"
|
|
90
|
-
"embed-english-v3.0"
|
|
91
|
-
"embed-multilingual-v3.0"
|
|
92
|
-
"embed-english-light-v3.0"
|
|
93
|
-
"embed-multilingual-light-v3.0"
|
|
89
|
+
"embed-v4.0" => 128_000,
|
|
90
|
+
"embed-english-v3.0" => 512,
|
|
91
|
+
"embed-multilingual-v3.0" => 512,
|
|
92
|
+
"embed-english-light-v3.0" => 512,
|
|
93
|
+
"embed-multilingual-light-v3.0" => 512,
|
|
94
94
|
}.freeze
|
|
95
95
|
|
|
96
96
|
# Models that accept Cohere's `output_dimension` Matryoshka
|
|
@@ -115,10 +115,10 @@ module Parse
|
|
|
115
115
|
# `:unknown_type` to `"search_document"` would mask cache-key
|
|
116
116
|
# bugs in higher layers (the value participates in cache keys).
|
|
117
117
|
INPUT_TYPE_WIRE_VALUES = {
|
|
118
|
-
search_query:
|
|
118
|
+
search_query: "search_query",
|
|
119
119
|
search_document: "search_document",
|
|
120
|
-
classification:
|
|
121
|
-
clustering:
|
|
120
|
+
classification: "classification",
|
|
121
|
+
clustering: "clustering",
|
|
122
122
|
}.freeze
|
|
123
123
|
|
|
124
124
|
# @param api_key [String] required. Sent as `Authorization: Bearer …`.
|
|
@@ -462,7 +462,7 @@ module Parse
|
|
|
462
462
|
next
|
|
463
463
|
end
|
|
464
464
|
raise BadRequestError,
|
|
465
|
-
"Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?(
|
|
465
|
+
"Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?("/") ? path : "/#{path}"}."
|
|
466
466
|
end
|
|
467
467
|
end
|
|
468
468
|
|
|
@@ -498,8 +498,7 @@ module Parse
|
|
|
498
498
|
"Parse::Embeddings::Cohere: response body is not a JSON object."
|
|
499
499
|
end
|
|
500
500
|
embeddings = payload["embeddings"]
|
|
501
|
-
vectors =
|
|
502
|
-
case embeddings
|
|
501
|
+
vectors = case embeddings
|
|
503
502
|
when Hash
|
|
504
503
|
f = embeddings["float"]
|
|
505
504
|
unless f.is_a?(Array)
|
|
@@ -521,7 +520,7 @@ module Parse
|
|
|
521
520
|
end
|
|
522
521
|
|
|
523
522
|
def backoff_seconds(attempt)
|
|
524
|
-
[0.5 * (2**(attempt - 1)), 30.0].min
|
|
523
|
+
[0.5 * (2 ** (attempt - 1)), 30.0].min
|
|
525
524
|
end
|
|
526
525
|
|
|
527
526
|
def retry_after_seconds(response)
|
|
@@ -56,6 +56,7 @@ module Parse
|
|
|
56
56
|
class InvalidImageType < Parse::Embeddings::Error
|
|
57
57
|
# @return [Symbol] failure-mode tag.
|
|
58
58
|
attr_reader :reason
|
|
59
|
+
|
|
59
60
|
def initialize(reason, message)
|
|
60
61
|
@reason = reason
|
|
61
62
|
super(message)
|
|
@@ -76,6 +77,7 @@ module Parse
|
|
|
76
77
|
"#<Parse::Embeddings::ImageFetch::FetchedImage mime_type=#{mime_type.inspect} " \
|
|
77
78
|
"bytes=#{bytes.respond_to?(:bytesize) ? bytes.bytesize : 0} url=#{url.inspect}>"
|
|
78
79
|
end
|
|
80
|
+
|
|
79
81
|
alias_method :to_s, :inspect
|
|
80
82
|
end
|
|
81
83
|
|
|
@@ -88,11 +90,11 @@ module Parse
|
|
|
88
90
|
# against the sniffed type. Extensions not listed here are ignored
|
|
89
91
|
# (the magic bytes alone govern).
|
|
90
92
|
EXTENSION_MIME = {
|
|
91
|
-
".jpg"
|
|
93
|
+
".jpg" => "image/jpeg",
|
|
92
94
|
".jpeg" => "image/jpeg",
|
|
93
|
-
".jpe"
|
|
94
|
-
".png"
|
|
95
|
-
".gif"
|
|
95
|
+
".jpe" => "image/jpeg",
|
|
96
|
+
".png" => "image/png",
|
|
97
|
+
".gif" => "image/gif",
|
|
96
98
|
".webp" => "image/webp",
|
|
97
99
|
}.freeze
|
|
98
100
|
|
|
@@ -109,8 +111,8 @@ module Parse
|
|
|
109
111
|
return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
|
|
110
112
|
b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
|
|
111
113
|
return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
|
|
112
|
-
return "image/png"
|
|
113
|
-
return "image/gif"
|
|
114
|
+
return "image/png" if b.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
115
|
+
return "image/gif" if b.start_with?("GIF87a".b) || b.start_with?("GIF89a".b)
|
|
114
116
|
if b.start_with?("RIFF".b) && b.byteslice(8, 4) == "WEBP".b
|
|
115
117
|
return "image/webp"
|
|
116
118
|
end
|
|
@@ -177,26 +179,26 @@ module Parse
|
|
|
177
179
|
def verify!(bytes, url: nil)
|
|
178
180
|
if bytes.nil? || bytes.empty?
|
|
179
181
|
raise InvalidImageType.new(:empty,
|
|
180
|
-
|
|
182
|
+
"Parse::Embeddings::ImageFetch: downloaded body is empty.")
|
|
181
183
|
end
|
|
182
184
|
mime = sniff_mime(bytes)
|
|
183
185
|
if mime.nil?
|
|
184
186
|
raise InvalidImageType.new(:unknown_magic,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
"Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
|
|
188
|
+
"format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
|
|
189
|
+
"unrecognized content is refused outright.")
|
|
188
190
|
end
|
|
189
191
|
allowed = Parse::Embeddings.allowed_image_types
|
|
190
192
|
unless allowed.include?(mime)
|
|
191
193
|
raise InvalidImageType.new(:type_not_allowed,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
"Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
|
|
195
|
+
"Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
|
|
194
196
|
end
|
|
195
197
|
ext_mime = extension_mime(url)
|
|
196
198
|
if ext_mime && ext_mime != mime
|
|
197
199
|
raise InvalidImageType.new(:extension_mismatch,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
"Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
|
|
201
|
+
"but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
|
|
200
202
|
end
|
|
201
203
|
mime
|
|
202
204
|
end
|
|
@@ -210,10 +212,10 @@ module Parse
|
|
|
210
212
|
def extension_mime(url)
|
|
211
213
|
return nil unless url.is_a?(String)
|
|
212
214
|
path = begin
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
URI.parse(url).path.to_s
|
|
216
|
+
rescue URI::InvalidURIError
|
|
217
|
+
return nil
|
|
218
|
+
end
|
|
217
219
|
dot = path.rindex(".")
|
|
218
220
|
return nil if dot.nil?
|
|
219
221
|
EXTENSION_MIME[path[dot..].to_s.downcase]
|
|
@@ -231,10 +233,9 @@ module Parse
|
|
|
231
233
|
# @param mime [String] sniffed MIME type.
|
|
232
234
|
# @return [String] bytes with metadata removed.
|
|
233
235
|
def strip_metadata(bytes, mime)
|
|
234
|
-
stripped =
|
|
235
|
-
case mime
|
|
236
|
+
stripped = case mime
|
|
236
237
|
when "image/jpeg" then strip_jpeg_app1(bytes)
|
|
237
|
-
when "image/png"
|
|
238
|
+
when "image/png" then strip_png_exif(bytes)
|
|
238
239
|
when "image/webp" then strip_webp_metadata(bytes)
|
|
239
240
|
else return bytes
|
|
240
241
|
end
|