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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/CHANGELOG.md +461 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +160 -3
- data/Rakefile +52 -3
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +5 -0
- data/docs/mcp_guide.md +59 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +55 -2
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +180 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/querying.rb +3 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +17 -0
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +252 -7
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- 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
|