parse-stack-next 5.2.1 → 5.3.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.
data/lib/parse/stack.rb CHANGED
@@ -548,6 +548,21 @@ module Parse
548
548
  # PARSE_STRICT_POINTER_SHAPES=true
549
549
  @strict_pointer_shapes = ENV["PARSE_STRICT_POINTER_SHAPES"] == "true"
550
550
 
551
+ # Configuration for automatic pluralized class-name aliases. When enabled
552
+ # (the default), referencing the plural form of a {Parse::Object} subclass
553
+ # constant resolves to that class, so `Posts.where(...)` works for a class
554
+ # `Post`. The alias is created lazily on first reference via `const_missing`
555
+ # and points at the same class object, so every class method
556
+ # (`where`, `query`, `count`, `find`, `all`, scopes) works for free and
557
+ # `Posts.parse_class` still returns `"Post"`. Classes whose name already
558
+ # ends in `s` are skipped. Set to false to opt out globally.
559
+ # @example Opt out globally
560
+ # Parse.pluralized_aliases = false
561
+ # @example ENV opt-out
562
+ # PARSE_PLURALIZED_ALIASES=false
563
+ # @see Parse::Core::Querying#pluralized_alias!
564
+ @pluralized_aliases = ENV["PARSE_PLURALIZED_ALIASES"] != "false"
565
+
551
566
  # Tuning bundle for the synchronize-create lock. Per-call kwargs override.
552
567
  # Keys: :ttl (seconds, default 3, max 30), :wait (seconds, default 2.0,
553
568
  # max 30), :on_degraded (:warn, :warn_throttled, :raise, :proceed).
@@ -630,7 +645,8 @@ module Parse
630
645
  :rewrite_lookups, :strict_property_redefinition,
631
646
  :synchronize_create_default, :synchronize_create_options, :synchronize_create_secret,
632
647
  :synchronize_create_store, :synchronize_classes,
633
- :strict_pointer_shapes, :suppress_server_version_warning
648
+ :strict_pointer_shapes, :suppress_server_version_warning,
649
+ :pluralized_aliases
634
650
 
635
651
  # Check whether the Parse Server version deprecation warning is
636
652
  # silenced. Returns true if either the in-process accessor or the
@@ -714,6 +730,140 @@ module Parse
714
730
  @strict_pointer_shapes == true
715
731
  end
716
732
 
733
+ # Whether automatic pluralized class-name aliases are enabled. Defaults
734
+ # to true; opt out with `Parse.pluralized_aliases = false` or
735
+ # `PARSE_PLURALIZED_ALIASES=false`. See {Parse.pluralized_aliases}.
736
+ # @return [Boolean]
737
+ def pluralized_aliases?
738
+ @pluralized_aliases != false
739
+ end
740
+
741
+ # @!visibility private
742
+ # Resolve a (possibly plural) missing constant to its singular
743
+ # {Parse::Object} subclass and install the alias on the referencing
744
+ # module. Returns the class when an alias was created, otherwise nil so
745
+ # the caller (`const_missing`) can fall through to `super` and preserve
746
+ # normal `NameError` / autoloading behavior.
747
+ #
748
+ # Guards (fail-through to nil unless ALL hold):
749
+ # - the feature is enabled,
750
+ # - {Parse::Object} is loaded,
751
+ # - the name singularizes to a *different* string (i.e. looks plural),
752
+ # - the singular form does NOT already end in `s` (per design: classes
753
+ # whose name ends in `s` are not auto-aliased),
754
+ # - the singular constant is defined (searching ancestors so a
755
+ # top-level model is visible from a nested reference) and is a
756
+ # `Parse::Object` subclass,
757
+ # - the plural is not already defined on the referencing module.
758
+ #
759
+ # @param mod [Module] the module/class on which `const_missing` fired.
760
+ # @param name [Symbol] the missing constant name.
761
+ # @return [Class, nil]
762
+ def __pluralized_alias_for(mod, name)
763
+ return nil unless pluralized_aliases?
764
+ return nil unless defined?(Parse::Object)
765
+ str = name.to_s
766
+ singular = str.singularize
767
+ return nil if singular == str
768
+ return nil if singular.end_with?("s")
769
+ sym = singular.to_sym
770
+ return nil unless mod.const_defined?(sym, true)
771
+ klass = mod.const_get(sym)
772
+ return nil unless klass.is_a?(Class) && klass < Parse::Object
773
+ return nil if mod.const_defined?(name, false)
774
+ mod.const_set(name, klass)
775
+ klass
776
+ rescue NameError, LoadError
777
+ # const_get/const_defined? can raise on malformed names or autoload
778
+ # failures; never let alias resolution mask the original lookup.
779
+ nil
780
+ end
781
+
782
+ # Verify that every association target across the loaded {Parse::Object}
783
+ # subclasses resolves to a known Parse class. Covers `belongs_to` and
784
+ # `property … as:` pointer targets (via each class's `references`),
785
+ # `has_many … through: :relation` targets (via `relations`), and the
786
+ # query- and array-backed `has_many` targets (via `has_many_associations`)
787
+ # — the bucket where an `as:` typo otherwise stays latent until the
788
+ # association is first traversed at call time.
789
+ #
790
+ # This is the deferred companion to the definition-time scalar guard in
791
+ # {Parse::Associations::BelongsTo::ClassMethods#belongs_to}: at declaration
792
+ # time a forward reference (a target class that is required later) is legal
793
+ # and indistinguishable from a typo, so the cross-class resolution check is
794
+ # run here — after all models are loaded. Intended to run once at boot, in
795
+ # CI, or from a rake task ("during the upgrade").
796
+ #
797
+ # A target resolves when it is a Parse system class (`_User`, `_Role`,
798
+ # `_Installation`, `_Session`, …) or a registered {Parse::Object} subclass
799
+ # (via {Parse::Model.find_class}). Note this checks against *loaded Ruby
800
+ # models*: if you intentionally point at a server-side class that has no
801
+ # Ruby model, define a stub model for it or exclude it via `classes:`.
802
+ #
803
+ # @param classes [Array<Class>, nil] optional subset of Parse::Object
804
+ # subclasses to check; defaults to every loaded subclass.
805
+ # @raise [ArgumentError] if any target is unresolved, listing each
806
+ # offending `Class#field -> 'Target'`.
807
+ # @return [true] when every association target resolves.
808
+ def validate_associations!(classes: nil)
809
+ models = classes || Parse::Object.descendants
810
+ problems = []
811
+ models.each do |klass|
812
+ next unless klass.respond_to?(:parse_class)
813
+ if klass.respond_to?(:references)
814
+ klass.references.each do |field, target|
815
+ next if _association_target_resolvable?(target)
816
+ # `references` is keyed by the remote (camelCase) column; report the
817
+ # declared Ruby accessor so the operator can find the offending line.
818
+ accessor = (klass.respond_to?(:field_map) && klass.field_map.key(field)) || field
819
+ problems << "#{klass}##{accessor} -> #{target.inspect} (no such Parse class)"
820
+ end
821
+ end
822
+ if klass.respond_to?(:relations)
823
+ klass.relations.each do |field, target|
824
+ next if _association_target_resolvable?(target)
825
+ problems << "#{klass}##{field} (relation) -> #{target.inspect} (no such Parse class)"
826
+ end
827
+ end
828
+ if klass.respond_to?(:has_many_associations)
829
+ klass.has_many_associations.each do |accessor, meta|
830
+ # `:relation`-storage has_many is mirrored into `relations` and is
831
+ # already reported above; only the `:query` and `:array` storage
832
+ # targets (which live nowhere else) need checking here. This is the
833
+ # branch where a `has_many … as:` typo hides, since a query-backed
834
+ # has_many resolves its target lazily at call time.
835
+ next if meta[:storage] == :relation
836
+ target = meta[:target_class]
837
+ next if target.nil? || _association_target_resolvable?(target)
838
+ problems << "#{klass}##{accessor} (has_many #{meta[:storage]}) -> " \
839
+ "#{target.inspect} (no such Parse class)"
840
+ end
841
+ end
842
+ end
843
+ unless problems.empty?
844
+ raise ArgumentError,
845
+ "Unresolved Parse association targets:\n " + problems.join("\n ") +
846
+ "\nRequire/define the target class, or fix the `as:`/`class_name:` name."
847
+ end
848
+ true
849
+ end
850
+
851
+ # @!visibility private
852
+ # Whether an association target class name resolves to a known Parse
853
+ # class. Parse system classes resolve against {Parse::Model::SYSTEM_CLASS_MAP}
854
+ # — both the canonical `_`-prefixed value (`_User`) and the bare-name key
855
+ # (`User`) — even when their Ruby class is not loaded; everything else must
856
+ # resolve via {Parse::Model.find_class}. A leading underscore is NOT a
857
+ # blanket pass: a typo'd system name such as `_Usr` is neither in the map
858
+ # nor a registered model, so it is still surfaced as unresolved.
859
+ def _association_target_resolvable?(target)
860
+ name = target.to_s
861
+ return false if name.empty?
862
+ return true if Parse::Model::SYSTEM_CLASS_MAP.key?(name) ||
863
+ Parse::Model::SYSTEM_CLASS_MAP.value?(name)
864
+ !Parse::Model.find_class(name).nil?
865
+ end
866
+
717
867
  # Check if MCP server feature is enabled
718
868
  # Requires PARSE_MCP_ENABLED=true in environment AND Parse.mcp_server_enabled = true
719
869
  # @return [Boolean]
@@ -851,4 +1001,9 @@ end
851
1001
  # the setter on load.
852
1002
  Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
853
1003
 
1004
+ # Install the lazy pluralized class-name alias hook (Posts -> Post). Loaded
1005
+ # last so Parse::Object and the Parse.__pluralized_alias_for helper are
1006
+ # already defined. Gated at runtime on Parse.pluralized_aliases?.
1007
+ require_relative "model/core/pluralized_aliases"
1008
+
854
1009
  require_relative "stack/railtie" if defined?(::Rails)
@@ -64,6 +64,18 @@ module Parse
64
64
  attr_accessor :master, :user, :installation_id, :params, :function_name, :object, :trigger_name
65
65
  attr_accessor :query, :log, :objects
66
66
  attr_accessor :original, :update, :raw
67
+ # @!attribute [r] session_token
68
+ # The caller's live Parse session token, captured from the incoming
69
+ # webhook payload (`user.sessionToken`) before credentials are scrubbed
70
+ # from {#user} / {#object} / {#original} / {#update}. Present only when
71
+ # the originating request was made by a logged-in user -- a master-key
72
+ # request carries no user and no token, so this is +nil+. It is
73
+ # intentionally NOT one of {ATTRIBUTES}, so it never appears in
74
+ # {#as_json} or in the redacted request log. Reach for it (or the
75
+ # higher-level {#user_client} / {#user_agent}) only when a handler
76
+ # deliberately wants to act on the server as the calling user.
77
+ # @return [String, nil]
78
+ attr_reader :session_token
67
79
  # @!visibility private
68
80
  attr_accessor :webhook_class
69
81
  alias_method :installationId, :installation_id
@@ -78,6 +90,15 @@ module Parse
78
90
  hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
79
91
  @raw = hash
80
92
  @master = hash[:master]
93
+ # Capture the caller's session token from the *unscrubbed* user hash
94
+ # before scrub_credentials strips it below. Parse Server includes
95
+ # `user.sessionToken` on every trigger fired by a logged-in caller
96
+ # (it is absent for master-key-originated requests). Pulling it aside
97
+ # here -- rather than leaving it in @user -- keeps it out of any object
98
+ # a handler might persist and out of #as_json / the request log, while
99
+ # still letting a handler opt in to acting as the calling user via
100
+ # #session_token / #user_client / #user_agent.
101
+ @session_token = self.class.extract_session_token(hash[:user])
81
102
  # Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
82
103
  # Parse Server and, when a webhook key is configured (the default; see
83
104
  # Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
@@ -85,7 +106,8 @@ module Parse
85
106
  # server-authoritative state. A handler is meant to receive the full
86
107
  # object -- createdAt/updatedAt, ACL, internal fields and all. The only
87
108
  # thing stripped here is genuine credential material a handler never
88
- # legitimately needs to read (live session tokens, offline-crackable
109
+ # legitimately needs to read inline (live session tokens -- captured
110
+ # above for opt-in user-scoped clients first -- and offline-crackable
89
111
  # password hashes); see WEBHOOK_TRIGGER_CREDENTIAL_KEYS. Protection
90
112
  # against *persisting* forged privileged fields lives on the write path
91
113
  # (changes_payload emits only declared, dirty-tracked properties), not on
@@ -144,11 +166,39 @@ module Parse
144
166
  end
145
167
  end
146
168
 
169
+ # @!visibility private
170
+ # Pulls the caller's session token out of the (unscrubbed) +user+ hash.
171
+ # Parse Server sends it as the camelCase string key +sessionToken+; this
172
+ # tolerates a symbol key and the snake_case form too, mirroring the
173
+ # leniency in +scrub_credentials+. Returns +nil+ for a blank token or a
174
+ # non-Hash / absent user (a master-key request has no user).
175
+ def self.extract_session_token(user_hash)
176
+ return nil unless user_hash.is_a?(Hash)
177
+ token = user_hash["sessionToken"] || user_hash[:sessionToken] ||
178
+ user_hash["session_token"] || user_hash[:session_token]
179
+ token = token.to_s.strip
180
+ token.empty? ? nil : token
181
+ end
182
+
147
183
  # @return [ATTRIBUTES]
148
184
  def attributes
149
185
  ATTRIBUTES
150
186
  end
151
187
 
188
+ # Redacted inspection. The default Ruby `#inspect` would dump every ivar,
189
+ # including the captured `@session_token` and the *pre-scrub* `@raw` hash
190
+ # (which still holds the caller's sessionToken and any password hashes).
191
+ # That is exactly the surface an error reporter or a stray `p payload`
192
+ # hits, so show only non-sensitive routing fields and a boolean for the
193
+ # token's presence. Use #as_json / the individual accessors for the
194
+ # (already credential-scrubbed) object data.
195
+ def inspect
196
+ "#<#{self.class.name} trigger=#{@trigger_name.inspect} " \
197
+ "function=#{@function_name.inspect} class=#{parse_class.inspect} " \
198
+ "id=#{parse_id.inspect} master=#{@master ? true : false} " \
199
+ "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
200
+ end
201
+
152
202
  # Method to print to standard that utilizes the an internal id to make it easier
153
203
  # to trace incoming requests.
154
204
  def wlog(s)
@@ -170,6 +220,51 @@ module Parse
170
220
  @master.present?
171
221
  end
172
222
 
223
+ # true if this payload carried a caller session token -- i.e. the
224
+ # originating request was made by a logged-in user rather than the
225
+ # master key, so {#user_client} / {#user_agent} can act as that user.
226
+ # @return [Boolean]
227
+ def session_token?
228
+ !@session_token.nil?
229
+ end
230
+
231
+ # An opt-in, user-scoped {Parse::Client} for acting on the server as the
232
+ # webhook's calling user. It mirrors the default client's connection
233
+ # settings (+server_url+, +application_id+, +api_key+) but carries NO
234
+ # master key and BINDS the caller's {#session_token}, so every request it
235
+ # makes -- with no further ceremony -- is authorized by Parse Server as
236
+ # that user: ACL, CLP and +protectedFields+ are all enforced. (A
237
+ # `Parse.with_session` block still overrides the bound token if you need
238
+ # to act as someone else within a call.) Memoized per payload, since each
239
+ # webhook delivery carries a distinct token.
240
+ # @return [Parse::Client, nil] +nil+ when the payload carried no token.
241
+ def user_client
242
+ return nil if @session_token.nil?
243
+ @user_client ||= Parse::Client.client.become(@session_token)
244
+ end
245
+
246
+ # An opt-in, non-master {Parse::Agent} scoped to the webhook caller's
247
+ # session token. Because its client has no master key and it is built
248
+ # with a non-empty +session_token:+, the agent runs in CLIENT MODE:
249
+ # every tool/query routes through a path Parse Server (or the SDK's own
250
+ # ACL/CLP enforcement layer) authorizes as the calling user, with no
251
+ # master-key fallback to silently bypass row-level security. This is the
252
+ # handle to use when a handler should read or act strictly within the
253
+ # caller's permissions. Additional agent options (e.g.
254
+ # +permissions: :readwrite+) may be passed through.
255
+ # @param opts [Hash] extra keyword args forwarded to {Parse::Agent#initialize}.
256
+ # @return [Parse::Agent, nil] +nil+ when the payload carried no token.
257
+ def user_agent(**opts)
258
+ return nil if @session_token.nil?
259
+ require_relative "../agent" unless defined?(Parse::Agent)
260
+ # Strip the two identity kwargs from the passthrough: a Ruby double-splat
261
+ # that repeats an explicit keyword WINS, so user_agent(client: master)
262
+ # or user_agent(session_token: other) would otherwise silently defeat the
263
+ # whole point (scoping to the caller). The scoping is non-negotiable here.
264
+ opts = opts.except(:session_token, :client)
265
+ Parse::Agent.new(session_token: @session_token, client: user_client, **opts)
266
+ end
267
+
173
268
  # @return [String] the name of the Parse class for this request.
174
269
  def parse_class
175
270
  return @webhook_class if @webhook_class.present?
@@ -321,9 +416,57 @@ module Parse
321
416
  return o
322
417
  end
323
418
 
324
- # afterSave on a CREATE (and every other trigger): the full object as the
325
- # server sent it. createdAt/updatedAt survive (only credentials are
326
- # scrubbed), so `new?` / `existed?` read correctly.
419
+ # afterSave on a CREATE: there is no prior persisted state, so every
420
+ # populated data field is new. Build symmetry with the UPDATE path above
421
+ # by seeding the identity / system fields (objectId, timestamps, ACL,
422
+ # className, plus the credential / permission keys) into a pristine
423
+ # object, then overlaying the full object with dirty tracking. Because
424
+ # the overlay's protected_set is exactly the seed key set, the system
425
+ # fields come ONLY from the clean seed and the overlay touches ONLY
426
+ # declared data properties (nil -> value -> changed) -- so `*_changed?`
427
+ # reports every field the create populated while createdAt / updatedAt /
428
+ # ACL stay clean. This lets handlers key off dirty tracking uniformly
429
+ # across create and update (e.g. building a sync payload from changed
430
+ # fields). Credentials / _rperm / _wperm / authData / roles are filtered
431
+ # from the overlay (seeded read-only, never marked changed), and an
432
+ # after-trigger response is only true/false, so nothing here can persist
433
+ # a forged field.
434
+ if after_save? && @object.is_a?(Hash)
435
+ seed_keys = Parse::Properties::PROTECTED_INITIALIZE_KEYS +
436
+ %w[objectId createdAt updatedAt ACL className __type]
437
+ seed = @object.slice(*seed_keys)
438
+ o = Parse::Object.build seed, parse_class
439
+ # `build` applies declared `default:` values onto the seed and then
440
+ # clears changes, baking each default into the "pristine" baseline.
441
+ # Without correction, the overlay's dirty guard (`unless val ==
442
+ # current`) would SUPPRESS marking any create value equal to its
443
+ # default (e.g. status: "draft", count: 0, archived: false), silently
444
+ # defeating this branch's whole purpose. Reset the default-bearing
445
+ # ivars to nil for the fields the overlay is about to set, then
446
+ # re-clear, so the overlay's guard sees a differing current ivar and
447
+ # marks every populated data field changed. (`*_changed?` / `changes`
448
+ # / `changed` then report the field; for a defaulted field the
449
+ # reported prior value is the default rather than nil, since the
450
+ # getter re-derives the default — its prior *effective* value.)
451
+ # `defaults_list` never contains the seeded system fields
452
+ # (objectId/createdAt/updatedAt/ACL), so this cannot disturb them;
453
+ # defaults for fields ABSENT from the payload are left intact so their
454
+ # value still reads through.
455
+ fmap = o.class.respond_to?(:field_map) ? o.class.field_map : {}
456
+ o.class.defaults_list.each do |k|
457
+ wire = (fmap[k] || k).to_s
458
+ next unless @object.key?(wire) || @object.key?(k.to_s)
459
+ o.instance_variable_set(:"@#{k}", nil)
460
+ end
461
+ o.clear_changes!
462
+ o.apply_attributes! @object, dirty_track: true, protected_set: seed_keys
463
+ return o
464
+ end
465
+
466
+ # Every other trigger (afterDelete, afterFind, and any before* path that
467
+ # did not match above): the full object as the server sent it.
468
+ # createdAt/updatedAt survive (only credentials are scrubbed), so
469
+ # `new?` / `existed?` read correctly.
327
470
  Parse::Object.build(@object, parse_class)
328
471
  end
329
472
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.1
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -364,6 +364,7 @@ files:
364
364
  - lib/parse/model/core/field_guards.rb
365
365
  - lib/parse/model/core/indexing.rb
366
366
  - lib/parse/model/core/parse_reference.rb
367
+ - lib/parse/model/core/pluralized_aliases.rb
367
368
  - lib/parse/model/core/properties.rb
368
369
  - lib/parse/model/core/querying.rb
369
370
  - lib/parse/model/core/schema.rb