parse-stack-next 5.3.0 → 5.4.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
@@ -0,0 +1,502 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_support/inflector"
5
+
6
+ module Parse
7
+ class Webhooks
8
+ # Operator-facing audit that cross-references three sources of truth about a
9
+ # Parse application's trigger logic and reports where they disagree:
10
+ #
11
+ # 1. **Model callbacks** — the ActiveModel `before_save` / `after_save` /
12
+ # `after_create` / ... callbacks declared on each {Parse::Object}
13
+ # subclass (app-defined ones, with framework-internal callbacks filtered
14
+ # out by source location).
15
+ # 2. **Local webhook routes** — the blocks registered via
16
+ # `webhook :before_save { ... }` / `Parse::Webhooks.route(...)`, held in
17
+ # {Parse::Webhooks.routes}.
18
+ # 3. **Server triggers** — what is actually registered with Parse Server
19
+ # (`GET hooks/triggers`), so a matching client POST reaches your Rack app.
20
+ #
21
+ # The non-obvious relationship the audit exists to surface (see
22
+ # {file:docs/webhooks_guide.md}): a model's ActiveModel callbacks only run
23
+ # server-side for **non-Ruby clients** when BOTH a local route is registered
24
+ # (so the webhook router has a handler) AND the trigger is registered on Parse
25
+ # Server (so it POSTs at all). Declaring `after_save :send_email` alone does
26
+ # nothing for a JS/Swift/REST/Dashboard write — that write never touches your
27
+ # Ruby process, and the callback is silently skipped.
28
+ #
29
+ # SECURITY POSTURE — mirrors {Parse::Core::Describe}. This is operator-side
30
+ # observability, NOT data exposed to an LLM. The server fetch hits the
31
+ # master-key-only `hooks/triggers` endpoint, so a `network: true` audit
32
+ # requires a master-key client; `network: false` audits callbacks vs. local
33
+ # routes only and needs no credentials. Output is never included in tool
34
+ # responses or any `parse.agent.*` notification payload.
35
+ #
36
+ # @example
37
+ # audit = Parse::Webhooks.trigger_audit # Hash report (network)
38
+ # puts Parse::Webhooks.trigger_audit(pretty: true) # human-readable summary
39
+ # Parse::Webhooks.trigger_audit(network: false) # local-only, no master key
40
+ class TriggerAudit
41
+ # The object-shaped triggers an ActiveModel callback or a webhook block can
42
+ # map to. (Auth / LiveQuery triggers carry no object and have no ActiveModel
43
+ # callback equivalent, so they are surfaced only as server/local routes, not
44
+ # cross-referenced against model callbacks.)
45
+ OBJECT_TRIGGERS = %i[
46
+ before_save after_save before_delete after_delete before_find after_find
47
+ ].freeze
48
+
49
+ # Maps an ActiveModel callback chain + phase to the local trigger name whose
50
+ # webhook handler runs it server-side. `before_create` / `after_create` ride
51
+ # inside the save handler (Parse Server has no create trigger); the webhook
52
+ # router runs the destroy chain inside the beforeDelete handler.
53
+ CALLBACK_TRIGGER_MAP = {
54
+ [:save, :before] => :before_save,
55
+ [:create, :before] => :before_save,
56
+ [:save, :after] => :after_save,
57
+ [:create, :after] => :after_save,
58
+ [:destroy, :before] => :before_delete,
59
+ [:destroy, :after] => :after_delete,
60
+ }.freeze
61
+
62
+ # ActiveModel callback chains + phases with NO server trigger that can run
63
+ # them. The webhook router only runs the `:save` and `:create` chains (plus
64
+ # the destroy chain on beforeDelete) — it never runs `:update` or
65
+ # `:validation`. So these callbacks are LOCAL-ONLY: they fire for
66
+ # Ruby-initiated saves but can never fire for a non-Ruby client, and no
67
+ # trigger registration changes that. Surfaced as an informational note, not
68
+ # a fixable gap.
69
+ LOCAL_ONLY_MAP = {
70
+ [:update, :before] => :before_update,
71
+ [:update, :after] => :after_update,
72
+ [:validation, :before] => :before_validation,
73
+ [:validation, :after] => :after_validation,
74
+ }.freeze
75
+
76
+ # The ActiveModel callback chains we introspect.
77
+ CALLBACK_CHAINS = %i[validation create update save destroy].freeze
78
+
79
+ # Directory under which a callback's source file marks it as
80
+ # framework-internal (defined by the gem) rather than app-defined. Computed
81
+ # from this file's own location: `__dir__` is `<gem>/lib/parse/webhooks`, so
82
+ # its parent is `<gem>/lib/parse`.
83
+ GEM_PARSE_DIR = ::File.expand_path("..", __dir__)
84
+
85
+ # Per-class audit row.
86
+ class ClassAudit
87
+ # @return [String] the Parse class name (e.g. "Post", "_User", "*").
88
+ attr_reader :parse_class
89
+ # @return [Hash{Symbol=>Array<Hash>}] app-defined callbacks keyed by local
90
+ # trigger-ish name (`:before_save`, `:after_create`, ...). Each value is
91
+ # an array of `{ name:, source: }`.
92
+ attr_reader :callbacks
93
+ # @return [Array<Symbol>] local trigger names that have a registered
94
+ # webhook block/route for this class (or via the `*` wildcard route).
95
+ attr_reader :local_routes
96
+ # @return [Hash{Symbol=>String}] server-registered triggers for this class,
97
+ # mapped trigger-name => url. Empty when `network: false`.
98
+ attr_reader :server_triggers
99
+ # @return [Array<Hash>] findings for this class. See {TriggerAudit} for the
100
+ # finding kinds.
101
+ attr_reader :findings
102
+ # @return [Boolean] whether a loaded Parse::Object subclass models this class.
103
+ attr_reader :modeled
104
+
105
+ def initialize(parse_class:, callbacks:, local_routes:, server_triggers:,
106
+ findings:, modeled:)
107
+ @parse_class = parse_class
108
+ @callbacks = callbacks
109
+ @local_routes = local_routes
110
+ @server_triggers = server_triggers
111
+ @findings = findings
112
+ @modeled = modeled
113
+ end
114
+
115
+ # @return [Boolean] true when the class has at least one finding.
116
+ def issues?
117
+ @findings.any?
118
+ end
119
+
120
+ # @return [Hash] a JSON-safe representation of this row.
121
+ def to_h
122
+ {
123
+ parse_class: parse_class,
124
+ modeled: modeled,
125
+ callbacks: callbacks,
126
+ local_routes: local_routes,
127
+ server_triggers: server_triggers,
128
+ findings: findings,
129
+ }
130
+ end
131
+ end
132
+
133
+ # @return [Array<ClassAudit>] one row per audited class, sorted by name.
134
+ attr_reader :classes
135
+ # @return [Boolean] whether the server was queried for registered triggers.
136
+ attr_reader :networked
137
+
138
+ # @param network [Boolean] when true, query Parse Server for registered
139
+ # triggers (requires a master-key client). When false, audit model
140
+ # callbacks against local routes only.
141
+ # @param client [Parse::Client, nil] optional client override for the server
142
+ # fetch.
143
+ # @param include_framework [Boolean] when true, also report gem-internal
144
+ # callbacks (e.g. the `_User` default-ACL callback). Off by default to keep
145
+ # the report focused on app-defined logic.
146
+ def initialize(network: true, client: nil, include_framework: false)
147
+ @networked = network
148
+ @include_framework = include_framework
149
+ @client = client
150
+ @server_lookup = network ? fetch_server_triggers : {}
151
+ @classes = build_classes
152
+ end
153
+
154
+ # @return [Array<Hash>] every finding across all classes, flattened, with the
155
+ # class name folded into each entry. Convenient for programmatic checks
156
+ # (CI fails the build if `gaps.any? { |g| g[:kind] == :callbacks_inert }`).
157
+ def gaps
158
+ @classes.flat_map do |ca|
159
+ ca.findings.map { |f| f.merge(parse_class: ca.parse_class) }
160
+ end
161
+ end
162
+
163
+ # @return [Hash] the full JSON-safe report.
164
+ def to_h
165
+ {
166
+ networked: networked,
167
+ classes: @classes.map(&:to_h),
168
+ summary: summary,
169
+ }
170
+ end
171
+ alias as_json to_h
172
+
173
+ # @return [Hash] finding counts keyed by kind, plus class totals.
174
+ def summary
175
+ counts = Hash.new(0)
176
+ gaps.each { |g| counts[g[:kind]] += 1 }
177
+ {
178
+ classes_audited: @classes.size,
179
+ classes_with_issues: @classes.count(&:issues?),
180
+ findings: counts,
181
+ }
182
+ end
183
+
184
+ # @return [String] a human-readable, `puts`-friendly summary in the style of
185
+ # `Model.describe(pretty: true)`.
186
+ def pretty
187
+ lines = ["Parse trigger audit (#{networked ? "server-compared" : "local-only"}):"]
188
+ @classes.each do |ca|
189
+ header = " #{ca.parse_class}"
190
+ header += " [server-only]" unless ca.modeled
191
+ lines << header
192
+
193
+ ca.callbacks.each do |trigger, cbs|
194
+ names = cbs.map { |c| c[:name] }.join(", ")
195
+ lines << " callback #{trigger}: #{names}"
196
+ end
197
+ lines << " routes: #{ca.local_routes.map(&:to_s).sort.join(", ")}" if ca.local_routes.any?
198
+ if networked && ca.server_triggers.any?
199
+ lines << " server: #{ca.server_triggers.keys.map(&:to_s).sort.join(", ")}"
200
+ end
201
+
202
+ if ca.findings.empty?
203
+ lines << " ok"
204
+ else
205
+ ca.findings.each { |f| lines << " #{finding_glyph(f[:kind])} #{f[:message]}" }
206
+ end
207
+ end
208
+ s = summary
209
+ lines << ""
210
+ lines << "Summary: #{s[:classes_audited]} class(es), " \
211
+ "#{s[:classes_with_issues]} with issues."
212
+ s[:findings].sort.each { |kind, n| lines << " #{kind}: #{n}" }
213
+ lines.join("\n")
214
+ end
215
+ alias to_s pretty
216
+
217
+ private
218
+
219
+ # Pull `hooks/triggers` and build server[className][local_trigger] => url.
220
+ # Raises a clear error (rather than letting the bare REST 403 surface) when
221
+ # no master key is configured — the endpoint is master-key-only.
222
+ def fetch_server_triggers
223
+ client = @client || Parse::Webhooks.client
224
+ if client.respond_to?(:master_key) && client.master_key.blank?
225
+ raise ArgumentError,
226
+ "Parse::Webhooks.trigger_audit requires a master-key client to " \
227
+ "read server triggers (the hooks/triggers endpoint is " \
228
+ "master-key-only). Configure a master key, or pass network: false " \
229
+ "to audit model callbacks against local routes only."
230
+ end
231
+ lookup = Hash.new { |h, k| h[k] = {} }
232
+ client.triggers.results.each do |t|
233
+ next unless t["url"].present?
234
+ name = t["triggerName"]
235
+ klass = t[Parse::Model::KEY_CLASS_NAME] || t["className"]
236
+ next if name.blank? || klass.blank?
237
+ lookup[klass.to_s][name.to_s.underscore.to_sym] = t["url"]
238
+ end
239
+ lookup
240
+ end
241
+
242
+ # The union of every class that any of the three sources knows about, so a
243
+ # server-only trigger (no local model) or a `*` wildcard route still appears.
244
+ def build_classes
245
+ names = Set.new
246
+ Parse.registered_classes.each { |c| names << c.to_s }
247
+ @server_lookup.each_key { |c| names << c.to_s }
248
+ OBJECT_TRIGGERS.each do |trigger|
249
+ route_map = Parse::Webhooks.routes[trigger]
250
+ next if route_map.nil?
251
+ route_map.each_key { |c| names << c.to_s }
252
+ end
253
+ # All trigger chains, in case a non-object trigger (function aside) is
254
+ # registered against a class name.
255
+ Parse::API::Hooks::TRIGGER_NAMES_LOCAL.each do |trigger|
256
+ route_map = Parse::Webhooks.routes[trigger]
257
+ next if route_map.nil?
258
+ route_map.each_key { |c| names << c.to_s }
259
+ end
260
+
261
+ names.to_a.sort.map { |name| audit_class(name) }
262
+ end
263
+
264
+ def audit_class(name)
265
+ klass = name == "*" ? nil : (Parse::Model.find_class(name) rescue nil)
266
+ callbacks = klass ? collect_callbacks(klass) : {}
267
+ routes = collect_local_routes(name)
268
+ server = @server_lookup[name] || {}
269
+ findings = analyze(name, callbacks, routes, server)
270
+ ClassAudit.new(
271
+ parse_class: name,
272
+ callbacks: callbacks,
273
+ local_routes: routes,
274
+ server_triggers: server,
275
+ findings: findings,
276
+ modeled: !klass.nil?,
277
+ )
278
+ end
279
+
280
+ # App-defined ActiveModel callbacks keyed by a trigger-ish name
281
+ # (`:before_save`, `:after_create`, `:before_update`, ...). Framework
282
+ # callbacks are filtered by source location unless include_framework is set.
283
+ def collect_callbacks(klass)
284
+ out = {}
285
+ CALLBACK_CHAINS.each do |chain|
286
+ callback_chain = klass.send("_#{chain}_callbacks")
287
+ callback_chain.each do |cb|
288
+ next unless cb.kind == :before || cb.kind == :after
289
+ entry = describe_callback(klass, cb)
290
+ next if entry[:framework] && !@include_framework
291
+ key = :"#{cb.kind}_#{chain}"
292
+ (out[key] ||= []) << entry.slice(:name, :source).merge(framework: entry[:framework])
293
+ end
294
+ end
295
+ # Drop the framework flag from values when not requested (keeps output lean).
296
+ unless @include_framework
297
+ out.each_value { |arr| arr.each { |h| h.delete(:framework) } }
298
+ end
299
+ out
300
+ end
301
+
302
+ # Resolve a callback's display name, source location, and whether it is
303
+ # framework-internal (defined under the gem's lib/parse).
304
+ def describe_callback(klass, cb)
305
+ filter = cb.filter
306
+ case filter
307
+ when Symbol
308
+ loc = begin
309
+ klass.instance_method(filter).source_location
310
+ rescue NameError
311
+ nil
312
+ end
313
+ { name: filter.to_s, source: format_source(loc), framework: framework_source?(loc) }
314
+ when Proc
315
+ loc = filter.source_location
316
+ { name: "(block)", source: format_source(loc), framework: framework_source?(loc) }
317
+ else # String (eval'd) — uncommon
318
+ { name: "(string)", source: nil, framework: false }
319
+ end
320
+ end
321
+
322
+ def framework_source?(loc)
323
+ return false if loc.nil?
324
+ ::File.expand_path(loc.first.to_s).start_with?(GEM_PARSE_DIR + ::File::SEPARATOR)
325
+ end
326
+
327
+ def format_source(loc)
328
+ return nil if loc.nil?
329
+ "#{loc.first}:#{loc.last}"
330
+ end
331
+
332
+ # Local trigger names with a registered block for this class. The `*`
333
+ # wildcard route applies to every class, so a class inherits any wildcard
334
+ # routes in addition to its own.
335
+ def collect_local_routes(name)
336
+ triggers = []
337
+ Parse::API::Hooks::TRIGGER_NAMES_LOCAL.each do |trigger|
338
+ route_map = Parse::Webhooks.routes[trigger]
339
+ next if route_map.nil?
340
+ if route_map[name].present?
341
+ triggers << trigger
342
+ elsif name != "*" && route_map["*"].present?
343
+ triggers << trigger
344
+ end
345
+ end
346
+ triggers
347
+ end
348
+
349
+ # Cross-reference the three axes and emit findings. Finding kinds:
350
+ #
351
+ # - `:callbacks_inert` — app callbacks exist that map to an object trigger,
352
+ # but the local route and/or the server trigger is missing, so they never
353
+ # run for non-Ruby clients. The headline gap. `missing:` lists which
354
+ # piece(s) are absent (`:route`, `:server`).
355
+ # - `:route_not_registered` — a local webhook block exists but the server
356
+ # trigger is not registered, so Parse Server never POSTs to it.
357
+ # - `:orphan_server_trigger` — a server trigger is registered but there is no
358
+ # local route to handle it; the round-trip is wasted (the router returns a
359
+ # success no-op).
360
+ # - `:local_only_callbacks` — informational: `before_update` / `after_update`
361
+ # / `*_validation` callbacks that no server trigger can ever run.
362
+ def analyze(name, callbacks, routes, server)
363
+ findings = []
364
+ server_known = @networked
365
+
366
+ # Which object triggers do the app callbacks require?
367
+ required = Hash.new { |h, k| h[k] = [] } # trigger => [callback keys]
368
+ callbacks.each_key do |cb_key|
369
+ kind, chain = split_callback_key(cb_key)
370
+ if (trigger = CALLBACK_TRIGGER_MAP[[chain, kind]])
371
+ required[trigger] << cb_key
372
+ end
373
+ end
374
+
375
+ required.each do |trigger, cb_keys|
376
+ has_route = routes.include?(trigger)
377
+ has_server = server.key?(trigger)
378
+ missing = []
379
+ missing << :route unless has_route
380
+ missing << :server if server_known && !has_server
381
+ next if missing.empty?
382
+
383
+ findings << {
384
+ kind: :callbacks_inert,
385
+ trigger: trigger,
386
+ missing: missing,
387
+ callbacks: cb_keys.sort,
388
+ message: inert_message(name, trigger, missing, cb_keys),
389
+ }
390
+ end
391
+
392
+ # Local block registered but no server trigger.
393
+ if server_known
394
+ routes.each do |trigger|
395
+ next unless OBJECT_TRIGGERS.include?(trigger) ||
396
+ Parse::API::Hooks::TRIGGER_NAMES_LOCAL.include?(trigger)
397
+ next if server.key?(trigger)
398
+ # Wildcard-only coverage is reported on the "*" row, not here.
399
+ next unless Parse::Webhooks.routes[trigger]&.key?(name)
400
+ findings << {
401
+ kind: :route_not_registered,
402
+ trigger: trigger,
403
+ message: "Local `webhook :#{trigger}` block for #{name} is not " \
404
+ "registered as a server trigger — run register_triggers! " \
405
+ "so Parse Server POSTs to it.",
406
+ }
407
+ end
408
+
409
+ # Server trigger registered but nothing local handles it.
410
+ server.each_key do |trigger|
411
+ next if routes.include?(trigger)
412
+ findings << {
413
+ kind: :orphan_server_trigger,
414
+ trigger: trigger,
415
+ message: "Server trigger #{trigger} is registered for #{name} but no " \
416
+ "local webhook block handles it — every matching operation " \
417
+ "pays a webhook round-trip that does nothing.",
418
+ }
419
+ end
420
+ end
421
+
422
+ # Local-only callbacks (informational).
423
+ local_only = callbacks.keys.filter_map do |cb_key|
424
+ kind, chain = split_callback_key(cb_key)
425
+ cb_key if LOCAL_ONLY_MAP.key?([chain, kind])
426
+ end
427
+ if local_only.any?
428
+ findings << {
429
+ kind: :local_only_callbacks,
430
+ callbacks: local_only.sort,
431
+ message: "#{name} has local-only callbacks (#{local_only.sort.join(", ")}) " \
432
+ "that no server trigger can run — they fire for Ruby-initiated " \
433
+ "saves but never for non-Ruby clients.",
434
+ }
435
+ end
436
+
437
+ findings
438
+ end
439
+
440
+ # ":before_save" => [:before, :save]; "after_create" => [:after, :create].
441
+ def split_callback_key(cb_key)
442
+ s = cb_key.to_s
443
+ if s.start_with?("before_")
444
+ [:before, s.sub("before_", "").to_sym]
445
+ elsif s.start_with?("after_")
446
+ [:after, s.sub("after_", "").to_sym]
447
+ else
448
+ [nil, nil]
449
+ end
450
+ end
451
+
452
+ def inert_message(name, trigger, missing, cb_keys)
453
+ callbacks = cb_keys.sort.join(", ")
454
+ reason =
455
+ if missing == [:route, :server]
456
+ "neither a local `webhook :#{trigger}` block nor a server trigger is " \
457
+ "registered"
458
+ elsif missing == [:route]
459
+ "no local `webhook :#{trigger}` block is registered to handle it"
460
+ else # [:server]
461
+ "a local block exists but the #{trigger} server trigger is not registered"
462
+ end
463
+ "#{name} callbacks (#{callbacks}) will NOT run for non-Ruby clients: " \
464
+ "#{reason}. Register `webhook :#{trigger}` and run register_triggers!."
465
+ end
466
+
467
+ def finding_glyph(kind)
468
+ case kind
469
+ when :callbacks_inert then "GAP "
470
+ when :route_not_registered then "GAP "
471
+ when :orphan_server_trigger then "WARN"
472
+ when :local_only_callbacks then "note"
473
+ else " "
474
+ end
475
+ end
476
+ end
477
+
478
+ class << self
479
+ # Audit trigger logic across all registered classes, cross-referencing model
480
+ # ActiveModel callbacks, locally registered webhook blocks, and the triggers
481
+ # registered on Parse Server. See {Parse::Webhooks::TriggerAudit}.
482
+ #
483
+ # The server comparison reads the master-key-only `hooks/triggers` endpoint,
484
+ # so `network: true` (the default) requires a master-key client. Pass
485
+ # `network: false` for a credential-free audit of callbacks vs. local routes.
486
+ #
487
+ # @param pretty [Boolean] when true, return the human-readable String summary
488
+ # instead of the Hash report.
489
+ # @param network [Boolean] query Parse Server for registered triggers.
490
+ # @param client [Parse::Client, nil] optional client override.
491
+ # @param include_framework [Boolean] include gem-internal callbacks.
492
+ # @return [Hash, String] the report Hash, or the pretty String when
493
+ # `pretty: true`.
494
+ def trigger_audit(pretty: false, network: true, client: nil, include_framework: false)
495
+ audit = TriggerAudit.new(
496
+ network: network, client: client, include_framework: include_framework
497
+ )
498
+ pretty ? audit.pretty : audit.to_h
499
+ end
500
+ end
501
+ end
502
+ end