parse-stack-next 5.0.0 → 5.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95fffb5091fe5273dd4a82cd2083362845f0622113b86db727d754d4d1c55f66
4
- data.tar.gz: 46f16ac282f1d9ba487b0638af7dfc9de82e5ae7f9e710e66280dfc52db56e10
3
+ metadata.gz: fa424fed1ae3d44678d301ca6fca95d383deda63edfcc645f1bfaafd030a24f3
4
+ data.tar.gz: b96f56fea9dbbeb76650736bee52be70546c02180be8ead3ef0ef392c041884f
5
5
  SHA512:
6
- metadata.gz: 68edaa682a0f92a59c66ce0500e08c99520443d41e28b5ff5486aed6a0e594ac6fb38c1a5939f5cada7f5ade2d489fd0ffe64d0a14e04a7738cdbde8a66c2e55
7
- data.tar.gz: 236e03357d93f02ca40692ed54ee074b6bff194f21e145e785305004c6e0767a1ec1b9e6e8d43d8f83d886fa41d469aac14b65096b1b8e29cced13ba60b4f865
6
+ metadata.gz: 1c176aa65bd1694c281f2566e69a554ee2d80d090e69969ee5f26e093d11558bbbcbb6da052b9c56e3b9fe95e9c488657b570bb500bf0e5ed3b3cbd72e9f3da8
7
+ data.tar.gz: 9895c24fdb645b06a1cb799da8ded92dc2a1cb5647d34ea5b0f0fd5bef849ff6768ec16098cb70c7b33a5a53f1773ea5487055f8ad1766ffecfe83a9964548d3
data/.bundle/config CHANGED
@@ -1,2 +1,4 @@
1
1
  ---
2
2
  BUNDLE_FORCE_RUBY_PLATFORM: "true"
3
+ BUNDLE_PATH: "/home/runner/work/parse-stack-next/parse-stack-next/vendor/bundle"
4
+ BUNDLE_DEPLOYMENT: "true"
@@ -0,0 +1,32 @@
1
+ name: Release Gem
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ push:
10
+ name: Push to RubyGems
11
+ runs-on: ubuntu-latest
12
+ environment: rubygems
13
+ permissions:
14
+ id-token: write
15
+ contents: read
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ # bundler-cache (next step) writes --local path / --local
20
+ # deployment into the tracked `.bundle/config`, which would leave
21
+ # the working tree dirty and trip `rake release`'s
22
+ # `release:guard_clean` check. Tell git to ignore worktree
23
+ # mutations to this file so guard_clean passes while still
24
+ # letting Bundler resolve vendor/bundle in the next step.
25
+ - run: git update-index --skip-worktree .bundle/config
26
+
27
+ - uses: ruby/setup-ruby@v1
28
+ with:
29
+ ruby-version: "3.4"
30
+ bundler-cache: true
31
+
32
+ - uses: rubygems/release-gem@v1
@@ -0,0 +1,3 @@
1
+ {
2
+ "makefile.configureOnOpen": false
3
+ }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.0.1
4
+
5
+ #### Redis cache wrapper compatibility with `Parse::CreateLock`
6
+
7
+ - **FIXED**: `Parse::CreateLock.synchronize` (and therefore `first_or_create!` / `create_or_update!`) failed to acquire cross-process locks when the configured cache was a `Parse::Cache::Redis` wrapper. The lock implementation calls `store.create(key, owner, expires: ttl)` (Moneta's atomic SETNX), but the wrapper only forwarded `[]`, `key?`, `delete`, and `store` to the pooled Moneta backend. Every acquire raised `NoMethodError: undefined method 'create' for an instance of Parse::Cache::Redis`, which the lock caught, logged as `[Parse::CreateLock] acquire error (NoMethodError)`, and treated as contention — so the call spun on the polling loop until the wait budget elapsed and raised `Parse::CreateLockTimeoutError`. (`lib/parse/cache/redis.rb`, `lib/parse/cache/pool.rb`)
8
+ - **FIXED**: `Parse::CreateLock.degraded_store?` classified the `Parse::Cache::Redis` wrapper as a healthy cross-process store (the wrapper has no Moneta `.adapter` chain to walk and its class name does not match the `Memory`/`Null` heuristic), so the lock never fell back to the in-process `Mutex` path when `#create` was unavailable. The detector now special-cases the `Parse::Cache::Redis` wrapper and additionally treats any store that does not respond to `#create` as degraded, so older custom store implementations that pre-date this requirement degrade gracefully instead of timing out. (`lib/parse/model/core/create_lock.rb`)
9
+ - **NEW**: `Parse::Cache::Redis#create` and `Parse::Cache::Pool#create` forward atomic SETNX semantics to the pooled Moneta-Redis store. `#increment` is forwarded on both for Moneta surface parity so counter / rate-limit use cases work transparently through the pool. (`lib/parse/cache/redis.rb`, `lib/parse/cache/pool.rb`)
10
+ - **CHANGED**: The one-time `[Parse::CreateLock:SECURITY]` warning emitted when no `PARSE_STACK_LOCK_SECRET` is configured against a Redis-backed store now also documents the lock-pinning risk that arises when the response cache and lock store share a Redis DB. Without an HMAC secret the lock keys are a plain SHA256 digest of `(app_id, parse_class, principal, query_attrs)` — guessable for any caller who knows the schema — so an adversary with write access to `Parse.cache` can plant `parse-stack:foc:v1:<sha>` to suppress `first_or_create!` / `create_or_update!` for a tuple until TTL expiry. The warning now tells operators to either set `PARSE_STACK_LOCK_SECRET` or point `Parse.synchronize_create_store` at a separate Redis DB. (`lib/parse/model/core/create_lock.rb`)
11
+ - **NEW**: `Parse::Cache::Redis#clear(scope:)` accepts an explicit `scope:` namespace argument that SCAN-deletes `<scope>:*` regardless of how the wrapper was constructed. This is the targeted escape hatch for ops tooling and multi-tenant deployments where the wrapper was built without a configured `@namespace` but the caller still wants to evict a specific prefix without `FLUSHDB`-ing siblings (or wiping the `parse-stack:foc:v1:*` create-lock keys that live on the same DB). Trailing `:` in the input is stripped so `"tenant_x"` and `"tenant_x:"` are equivalent. The `scope:` argument is strictly validated and raises `ArgumentError` when it is not a `String`, is empty (or `":"` only), or contains Redis SCAN glob metacharacters (`*`, `?`, `[`, `]`, `\`) or a NUL byte — otherwise `scope: "*"` would expand the SCAN pattern and delete every key on the DB, defeating the whole point of keeping `flush_db!` as the explicit wide-blast-radius escape hatch. The no-argument form preserves the previous semantics — namespace-scoped SCAN-delete when `@namespace` is set, full `FLUSHDB` otherwise — so existing `Parse::Client#clear_cache!` callers are unaffected. (`lib/parse/cache/redis.rb`)
12
+
3
13
  ### 5.0.0
4
14
 
5
15
  #### Client-mode `Parse::Agent`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.0.0)
4
+ parse-stack-next (5.0.1)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
data/README.md CHANGED
@@ -175,7 +175,7 @@ result = Parse.call_function :myFunctionName, {param: value}
175
175
 
176
176
  ## Release History
177
177
 
178
- **Current version: 5.0.0** | **Ruby 3.2+ required**
178
+ **Current version: 5.0.1** | **Ruby 3.2+ required**
179
179
 
180
180
  The 5.0 highlights (vector search / RAG, pooled Redis cache, AS::N instrumentation, MCP transport hardening, GraphQL type generation) are summarized in the [What's new in 5.0](#whats-new-in-50) section above. Earlier releases are recorded below.
181
181
 
@@ -51,6 +51,21 @@ module Parse
51
51
  @pool.with { |store| store.store(key, value, options) }
52
52
  end
53
53
 
54
+ # Atomic SETNX-style write. Required by `Parse::CreateLock` to acquire
55
+ # cross-process locks against Redis-backed stores. Forwards to the
56
+ # underlying Moneta store's `#create`, which returns `true` only if
57
+ # the key was absent and is now set.
58
+ def create(key, value, options = {})
59
+ @pool.with { |store| store.create(key, value, options) }
60
+ end
61
+
62
+ # Atomic counter increment. Forwarded for parity with Moneta so
63
+ # callers expecting the full Moneta surface (counters, rate limits)
64
+ # work transparently through the pool.
65
+ def increment(key, amount = 1, options = {})
66
+ @pool.with { |store| store.increment(key, amount, options) }
67
+ end
68
+
54
69
  # Clear the underlying backend. Pooled Moneta stores all point at the
55
70
  # same Redis DB, so a single checkout suffices — issuing `clear` on
56
71
  # one connection flushes the DB for every connection.
@@ -105,6 +105,19 @@ module Parse
105
105
  @pool.store(key, value, options)
106
106
  end
107
107
 
108
+ # Atomic SETNX. Required so `Parse::CreateLock` can acquire
109
+ # cross-process locks when this wrapper is the configured cache /
110
+ # `synchronize_create_store`. Returns `true` only when the key did
111
+ # not already exist.
112
+ def create(key, value, options = {})
113
+ @pool.create(key, value, options)
114
+ end
115
+
116
+ # Atomic counter increment. Forwarded for Moneta surface parity.
117
+ def increment(key, amount = 1, options = {})
118
+ @pool.increment(key, amount, options)
119
+ end
120
+
108
121
  # Clear cached entries belonging to this wrapper. Required for
109
122
  # `Parse::Client#clear_cache!` compatibility.
110
123
  #
@@ -115,8 +128,22 @@ module Parse
115
128
  # the backing DB — same blast radius as previous versions, but
116
129
  # only for unnamespaced deployments. To opt into the wide
117
130
  # FLUSHDB explicitly (e.g. ops tooling), call {#flush_db!}.
118
- def clear
119
- if @namespace
131
+ #
132
+ # @param scope [String, nil] explicit namespace prefix to scan-delete.
133
+ # When provided, overrides the wrapper's configured `@namespace` and
134
+ # SCAN-deletes `<scope>:*` regardless of how the wrapper was built.
135
+ # This is the safe escape hatch for tenants that share a non-
136
+ # namespaced wrapper but still want to evict only their own keys
137
+ # without `FLUSHDB`-ing siblings (and without wiping
138
+ # `parse-stack:foc:v1:*` create-lock keys that live on the same DB).
139
+ # The scope must be a non-empty String; the trailing `:` is added
140
+ # automatically and any trailing `:` in the input is stripped so
141
+ # `"tenant_x"` and `"tenant_x:"` are equivalent.
142
+ def clear(scope: nil)
143
+ if scope
144
+ prefix = validate_scope!(scope)
145
+ delete_keys_matching!("#{prefix}:*")
146
+ elsif @namespace
120
147
  delete_keys_matching!("#{@namespace}:*")
121
148
  else
122
149
  @pool.clear
@@ -185,6 +212,38 @@ module Parse
185
212
  s = ns.to_s.chomp(":")
186
213
  s.empty? ? nil : s
187
214
  end
215
+
216
+ # Validate a caller-supplied `scope:` for `clear(scope:)`. Returns the
217
+ # normalized prefix or raises ArgumentError. We enforce:
218
+ #
219
+ # - must be a String (Symbol / Integer / nil would silently `.to_s`
220
+ # under `normalize_namespace` and expand the deletion target —
221
+ # `scope: 0` would clear `0:*`)
222
+ # - must be non-empty after trimming a trailing `:`
223
+ # - must not contain Redis SCAN glob metacharacters (`*`, `?`, `[`,
224
+ # `]`, `\`) — otherwise `scope: "*"` would SCAN-delete the whole
225
+ # DB, defeating the whole point of having `flush_db!` as the
226
+ # explicit wide-blast-radius escape hatch
227
+ # - must not contain a null byte (defense-in-depth against keys
228
+ # crafted to terminate early in some Redis client paths)
229
+ GLOB_METACHARS = /[\*\?\[\]\\\x00]/.freeze
230
+ private_constant :GLOB_METACHARS
231
+
232
+ def validate_scope!(scope)
233
+ unless scope.is_a?(String)
234
+ raise ArgumentError, "scope: must be a String (got #{scope.class})"
235
+ end
236
+ prefix = scope.chomp(":")
237
+ if prefix.empty?
238
+ raise ArgumentError, "scope: must be a non-empty namespace string"
239
+ end
240
+ if prefix.match?(GLOB_METACHARS)
241
+ raise ArgumentError,
242
+ "scope: must not contain Redis SCAN glob characters (*, ?, [, ], \\, or NUL); " \
243
+ "use flush_db! for a full-DB flush"
244
+ end
245
+ prefix
246
+ end
188
247
  end
189
248
  end
190
249
  end
@@ -255,6 +255,13 @@ module Parse
255
255
 
256
256
  def degraded_store?(store)
257
257
  return true if store.nil?
258
+ # The Parse::Cache::Redis wrapper (and its Pool) are known
259
+ # cross-process stores even though they don't expose a Moneta
260
+ # `.adapter` chain to walk. Anything that can't forward `#create`
261
+ # cannot serve as a lock store, so fall back to the process-local
262
+ # path rather than spinning until timeout on NoMethodError.
263
+ return false if defined?(Parse::Cache::Redis) && store.is_a?(Parse::Cache::Redis)
264
+ return true unless store.respond_to?(:create)
258
265
  bottom = walk_to_adapter(store)
259
266
  return true if bottom.nil?
260
267
  klass_name = bottom.class.name.to_s
@@ -366,8 +373,13 @@ module Parse
366
373
  @plain_sha_warned = true
367
374
  warn "[Parse::CreateLock:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
368
375
  "Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
369
- "Lock keys are deterministic and may expose query_attrs content via Redis MONITOR/snapshots. " \
370
- "Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying."
376
+ "Risks of running without an HMAC secret: (1) lock keys are deterministic and may expose query_attrs " \
377
+ "content via Redis MONITOR/snapshots; (2) when the response cache and the lock store share a Redis DB, " \
378
+ "any caller with write access to Parse.cache can plant a `parse-stack:foc:v1:<sha>` key under a guessable " \
379
+ "digest of (app_id, class, principal, query_attrs) and suppress first_or_create!/create_or_update! for " \
380
+ "that tuple until TTL expiry — a targeted DoS / create-pinning primitive. " \
381
+ "Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying, or " \
382
+ "point Parse.synchronize_create_store at a separate Redis DB from the response cache."
371
383
  end
372
384
 
373
385
  def instrument(event, key, payload = {})
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.0.0"
9
+ VERSION = "5.0.1"
10
10
  end
11
11
  end
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.0.0
4
+ version: 5.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anthony Persaud
@@ -212,10 +212,12 @@ files:
212
212
  - ".env.test"
213
213
  - ".github/workflows/codeql.yml"
214
214
  - ".github/workflows/docs.yml"
215
+ - ".github/workflows/release.yml"
215
216
  - ".github/workflows/ruby.yml"
216
217
  - ".gitignore"
217
218
  - ".ruby-version"
218
219
  - ".solargraph.yml"
220
+ - ".vscode/settings.json"
219
221
  - CHANGELOG.md
220
222
  - Gemfile
221
223
  - Gemfile.lock