zero-rails-adapter 0.3.0 → 0.3.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: 87460dcc3c79305e4ca81c9bd30800866a2a9c97709298955cdf16334723d442
4
- data.tar.gz: 9c76ea660812fb14a1fa52fc148e00e973da5b670635dcd85242d781b93f0958
3
+ metadata.gz: ab52c7684cc6471e482808ce5bcdb64b7aa63ccbf8c9ae736feb535683198204
4
+ data.tar.gz: f69fa8c3101e9f8fff279af69103f5980a323dc856a70dd6b436f8bb92a9b37c
5
5
  SHA512:
6
- metadata.gz: 13941576c897585d1253128412c7a3b062c273e407b1430e51cbae1f0dd9fc4a89d24b5fba7b8f5398ff610d72b64a38b286293af8146f6270bc5db27394a12c
7
- data.tar.gz: e4785424bc04062e227084591faf341260d67177bf73c588494778b4037e25b9642fc6e444718554c90bdf371928ab71f7c01952c1dc4d3627d73c14f5b83ad5
6
+ metadata.gz: a8a68b2c6bc50703669edd95cf46e9b74c0a01c6b4414723a78b07c4158d8b37ef884ae4972e0c0bf5ae684f897c2416f4d2e1b1b8beb1a2d1fd967faa8a36f0
7
+ data.tar.gz: 8690225768276ff9ca61478486674f90b91f3b873ed190d95dc0bb7da5aea2f3a96d32f3c74ef24cc2d21fe54e731f03b136549638d3491dff3a354d1b7fc1d6
data/CHANGELOG.md ADDED
@@ -0,0 +1,150 @@
1
+ # Changelog
2
+
3
+ All notable changes to `zero-rails-adapter` are documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Because the project is still below 1.0, minor releases may contain breaking
7
+ changes; those changes are called out explicitly below.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.3.1] - 2026-07-29
12
+
13
+ ### Security
14
+
15
+ - Changed request verification, authentication, and global mutation
16
+ authorization defaults to fail closed. Applications must now configure every
17
+ gate explicitly.
18
+ - Custom mutators without an `authorize_with` callback are rejected instead of
19
+ executing implicitly. The mutator generator scaffolds an explicit
20
+ deny-by-default callback.
21
+ - Generated initializers now require `ZERO_MUTATE_API_KEY` with `ENV.fetch`
22
+ instead of silently disabling request verification when the variable is
23
+ absent.
24
+ - Internal and database `PushFailed` responses no longer expose raw exception
25
+ messages. Full errors remain available to the configured server logger.
26
+
27
+ ### Fixed
28
+
29
+ - Persist only explicit application and validation failures before advancing
30
+ LMID. Database failures and unexpected Ruby exceptions now roll back the
31
+ mutation completely and return a retry-safe `PushFailed`, allowing the same
32
+ mutation ID to succeed after the underlying problem is fixed.
33
+
34
+ ### Changed
35
+
36
+ - Expanded the Zero 1.8 integration contract to use PostgreSQL UUID primary
37
+ keys and foreign keys and to cover request verification, authentication,
38
+ global and mutator authorization, sanitized internal failures, unchanged
39
+ LMID state, and successful same-ID replay.
40
+ - Updated the contract's direct `ws` dependency from 8.18.3 to 8.21.1.
41
+
42
+ ### Upgrade notes
43
+
44
+ Configure `request_verifier`, `authenticator`, and `authorizer` explicitly
45
+ before accepting mutation traffic. Add `authorize_with` to every custom
46
+ mutator, including mutators that intentionally rely only on the global
47
+ authorizer. Regenerate the initializer if the application still conditionally
48
+ configures `ZERO_MUTATE_API_KEY`.
49
+
50
+ ## [0.3.0] - 2026-07-29
51
+
52
+ ### Breaking changes
53
+
54
+ - Changed model and column exposure from permissive to fail-closed. The adapter
55
+ no longer discovers and publishes every Active Record model by default.
56
+ Applications must define `published_schema` with both the models and columns
57
+ that Zero may replicate.
58
+ - Split the former `model_provider` responsibility into `published_schema` for
59
+ replicated data and `crud_model_provider` for generic writes.
60
+ - Made generic CRUD fully opt-in. `crud_model_provider` now defaults to an empty
61
+ list and `crud_authorizer` denies every operation by default. Publishing a
62
+ model does not make it writable.
63
+ - Changed integer-backed Active Record enum generation from a string
64
+ enumeration to `number()`. String-backed Active Record enums generate
65
+ `string()`, while only PostgreSQL native enums generate Zero enumerations.
66
+
67
+ ### Upgrade notes
68
+
69
+ Before upgrading an application from 0.2.0:
70
+
71
+ 1. Replace `model_provider` with an explicit `published_schema` table-and-column
72
+ allowlist.
73
+ 2. If generic CRUD is required, configure both `crud_model_provider` and
74
+ `crud_authorizer`. Keep them closed and use custom mutators for domain
75
+ operations that enforce application invariants.
76
+ 3. Regenerate `schema.ts` and `mutators.ts`.
77
+ 4. Generate and review the column-limited PostgreSQL publication SQL, apply it
78
+ through the application's normal database process, and point zero-cache at
79
+ that publication.
80
+ 5. Confirm that timestamp consumers treat Zero values as Unix epoch
81
+ milliseconds and update any code that assumed string-valued Rails enums.
82
+
83
+ See the [README](README.md#explicit-publication-schema) for configuration
84
+ examples.
85
+
86
+ ### Added
87
+
88
+ - Added a table-and-column publication schema with validation for primary keys,
89
+ sensitive credentials, framework-internal tables, unknown columns, and
90
+ unsupported PostgreSQL types.
91
+ - Added a generator for reviewable, column-limited PostgreSQL publication SQL.
92
+ - Added `zero_key` for stable synchronization identifiers that differ from the
93
+ Active Record primary key, including composite keys. Separate keys must be
94
+ published and backed by exact unique, non-null database indexes.
95
+ - Added explicit one-hop and two-hop relationship definitions for delegated,
96
+ polymorphic, through, custom-key, and other associations Rails cannot safely
97
+ infer.
98
+ - Added an exact Zero 1.8 integration contract using PostgreSQL logical
99
+ replication, zero-cache, a Rails custom-mutator endpoint, and a real Zero
100
+ client.
101
+ - Added coverage for replication queries, LMID advancement, duplicate
102
+ mutations, failed-mutation skipping, and `_zero_cleanupResults`.
103
+
104
+ ### Changed
105
+
106
+ - Generic update and destroy now locate records through the configured Zero
107
+ key. Generic updates cannot change either the Zero key or the Active Record
108
+ primary key.
109
+ - TypeScript generation now uses `published_schema` for tables and columns and
110
+ generates CRUD mutators only for models returned by `crud_model_provider`.
111
+ - Timestamps are explicitly documented and generated as Unix epoch
112
+ milliseconds on the Zero side.
113
+ - Manual relationships override inferred relationships with the same source
114
+ and name.
115
+
116
+ ### Security
117
+
118
+ - Added default rejection for known credential columns such as password hashes
119
+ and token digests, authentication tables, Active Storage internals, and
120
+ Action Mailbox internals.
121
+ - Publication validation rejects unsupported PostgreSQL types, including
122
+ `citext`; applications must exclude those columns or publish a supported safe
123
+ mirror column.
124
+ - Prevented schema generation and PostgreSQL publication generation from
125
+ silently exposing newly discovered Active Record models or columns.
126
+
127
+ ## [0.2.0] - 2026-07-28
128
+
129
+ ### Added
130
+
131
+ - Initial public release.
132
+ - Added a mountable Rails Engine with Zero mutation endpoints.
133
+ - Added generic Active Record create, update, and destroy operations using
134
+ validations, callbacks, associations, database constraints, and Rails
135
+ transactions.
136
+ - Added explicit custom mutators through a Ruby DSL, base class, registry, and
137
+ Rails generator.
138
+ - Added configurable request verification, authentication, authorization,
139
+ model resolution, writable-attribute policies, and transaction storage.
140
+ - Added atomic Zero LMID tracking, mutation ordering, duplicate detection,
141
+ structured application errors, failed-mutation skipping, and cleanup-result
142
+ handling.
143
+ - Added TypeScript schema and mutator generation from Active Record models.
144
+ - Added Active Support mutation notifications and Rails/PostgreSQL test
145
+ coverage.
146
+
147
+ [Unreleased]: https://github.com/mechiland/zero_rails_adapter/compare/v0.3.1...HEAD
148
+ [0.3.1]: https://github.com/mechiland/zero_rails_adapter/compare/v0.3.0...v0.3.1
149
+ [0.3.0]: https://github.com/mechiland/zero_rails_adapter/releases/tag/v0.3.0
150
+ [0.2.0]: https://rubygems.org/gems/zero-rails-adapter/versions/0.2.0
data/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  `zero-rails-adapter` is a mountable Rails Engine that maps Rocicorp Zero custom
4
4
  mutations directly to existing Active Record models.
5
5
 
6
+ See the [changelog](CHANGELOG.md) for release notes and upgrade instructions.
7
+
6
8
  The default CRUD path does not require a Ruby Mutator class for every model:
7
9
 
8
10
  | Zero mutation | Rails call |
@@ -48,6 +50,10 @@ bundle install
48
50
  bin/rails generate zero_rails_adapter:install
49
51
  ```
50
52
 
53
+ The generated initializer reads `ZERO_MUTATE_API_KEY` with `ENV.fetch`, so a
54
+ missing key fails during application boot instead of disabling request
55
+ verification.
56
+
51
57
  Mount the Engine:
52
58
 
53
59
  ```ruby
@@ -283,6 +289,17 @@ and writable-attribute interfaces below.
283
289
  The generated initializer supports `ZERO_MUTATE_API_KEY` and verifies
284
290
  zero-cache's `X-Api-Key` header using a constant-time comparison.
285
291
 
292
+ All mutation security gates fail closed by default:
293
+
294
+ - `request_verifier` returns `false`.
295
+ - `authenticator` raises `UnauthorizedError`.
296
+ - The global `authorizer` returns `false`.
297
+ - The generic CRUD `crud_authorizer` returns `false`.
298
+
299
+ Applications must configure each gate they use explicitly. An application that
300
+ intentionally permits anonymous identities must still install an authenticator
301
+ that returns `ZeroRailsAdapter::Identity.new`.
302
+
286
303
  The authentication interface receives the Rails request. It can integrate with
287
304
  Devise/Warden, any JWT library, a session, or an application-specific
288
305
  authentication system:
@@ -307,6 +324,10 @@ authorizer then receives the model class as `target` for create, or the loaded
307
324
  record for update and destroy:
308
325
 
309
326
  ```ruby
327
+ config.authorizer = lambda do |context, mutation|
328
+ MutationPolicy.allowed?(context.current_user, mutation.name)
329
+ end
330
+
310
331
  config.crud_authorizer = lambda do |context, action, target, attributes|
311
332
  MutationPolicy.new(
312
333
  context.current_user,
@@ -370,6 +391,10 @@ available:
370
391
  ZeroRailsAdapter.define_mutator "projects.archive" do
371
392
  attribute :id, :string
372
393
 
394
+ authorize_with do |context|
395
+ context.current_user.present?
396
+ end
397
+
373
398
  perform do
374
399
  Project.find(id).archive!(actor: context.current_user)
375
400
  end
@@ -377,7 +402,9 @@ end
377
402
  ```
378
403
 
379
404
  Mutators use Active Model attributes and validations and share a transaction
380
- with application writes and LMID tracking.
405
+ with application writes and LMID tracking. A custom mutator without an
406
+ `authorize_with` callback is rejected. To rely intentionally on the global
407
+ authorizer alone, the mutator must still declare `authorize_with { true }`.
381
408
 
382
409
  ## Mutation Ordering and Transaction Semantics
383
410
 
@@ -389,11 +416,19 @@ For each `(schema, clientGroupID, clientID)`, the adapter:
389
416
  4. Executes the authorized Active Record operation inside a transaction.
390
417
  5. Updates the LMID atomically.
391
418
 
392
- When a business validation or callback fails, the first transaction rolls
393
- back. A second transaction advances the LMID and stores a structured `app`
394
- result so that a bad mutation is not retried forever and later mutations can
395
- continue. If the second transaction also fails, the adapter returns a
396
- retry-safe `PushFailed` response.
419
+ When an explicit `ApplicationError`, argument validation error, or supported
420
+ Active Model/Active Record validation or lifecycle error occurs, the first
421
+ transaction rolls back. A second transaction advances the LMID and stores a
422
+ structured `app` result so that a bad mutation is not retried forever and later
423
+ mutations can continue.
424
+
425
+ Database failures and unexpected Ruby exceptions do not advance LMID or store
426
+ a mutation result. They return a retry-safe `PushFailed`, so the same mutation
427
+ ID can be retried after the underlying problem is fixed. Public database and
428
+ internal failure messages are fixed and sanitized; the configured server
429
+ logger receives the original exception and backtrace. If persisting an
430
+ application failure also fails, the adapter likewise returns `PushFailed`
431
+ without advancing LMID.
397
432
 
398
433
  Each mutation owns an independent transaction. A later failure in the same
399
434
  HTTP batch does not roll back earlier committed mutations.
@@ -446,9 +481,11 @@ bundle exec rake contract
446
481
 
447
482
  That task compiles the generated TypeScript, starts PostgreSQL with logical
448
483
  replication, starts the real zero-cache and Rails mutation endpoint, then uses
449
- a Zero client to mutate and query replicated rows. The generated fixture
450
- includes a separate Zero key and a two-hop relationship. The task also verifies
451
- LMID advancement, duplicate-mutation handling, failure skipping, and
484
+ a Zero client to mutate and query replicated rows. The generated fixture uses
485
+ UUID primary and foreign keys and includes a separate Zero key and a two-hop
486
+ relationship. The task also verifies request verification, authentication,
487
+ global and mutator authorization, sanitized retry-safe failures, same-ID
488
+ replay, LMID advancement, duplicate-mutation handling, failure skipping, and
452
489
  `_zero_cleanupResults`. The contract runs as its own CI job.
453
490
 
454
491
  See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
@@ -27,7 +27,11 @@ module ZeroRailsAdapter
27
27
  ZeroRailsAdapter.configuration.logger&.error(
28
28
  "ZeroRailsAdapter request failed: #{error.class}: #{error.message}"
29
29
  )
30
- render json: push_failed("internal", error.message, []), status: :internal_server_error
30
+ render json: push_failed(
31
+ "internal",
32
+ Processor::INTERNAL_ERROR_MESSAGE,
33
+ []
34
+ ), status: :internal_server_error
31
35
  end
32
36
 
33
37
  private
@@ -6,11 +6,9 @@ ZeroRailsAdapter.configure do |config|
6
6
 
7
7
  # Verify that calls came from your zero-cache instance.
8
8
  # Configure zero-cache with ZERO_MUTATE_API_KEY using the same value.
9
- if ENV["ZERO_MUTATE_API_KEY"].present?
10
- config.request_verifier = ZeroRailsAdapter::RequestVerifiers::ApiKey.new(
11
- key: ENV.fetch("ZERO_MUTATE_API_KEY")
12
- )
13
- end
9
+ config.request_verifier = ZeroRailsAdapter::RequestVerifiers::ApiKey.new(
10
+ key: ENV.fetch("ZERO_MUTATE_API_KEY")
11
+ )
14
12
 
15
13
  # Return an Identity from any authentication system (Devise, JWT, cookies, etc.).
16
14
  # config.authenticator = lambda do |request|
@@ -7,8 +7,11 @@ class <%= class_name %> < ZeroRailsAdapter::Mutator
7
7
  # attribute :title, :string
8
8
  # validates :title, presence: true
9
9
 
10
+ authorize_with do |_context|
11
+ false
12
+ end
13
+
10
14
  def perform
11
- # Use context.current_user, context.user_id, and context.claims for authorization.
12
15
  raise NotImplementedError
13
16
  end
14
17
  end
@@ -9,9 +9,11 @@ module ZeroRailsAdapter
9
9
  :relationship_provider
10
10
 
11
11
  def initialize
12
- @authenticator = ->(_request) { Identity.new }
13
- @request_verifier = ->(_request) { true }
14
- @authorizer = ->(_context, _mutation) { true }
12
+ @authenticator = lambda do |_request|
13
+ raise UnauthorizedError, "Authentication is not configured"
14
+ end
15
+ @request_verifier = ->(_request) { false }
16
+ @authorizer = ->(_context, _mutation) { false }
15
17
  @crud_authorizer = ->(_context, _action, _target, _attributes) { false }
16
18
  @logger = defined?(Rails) ? Rails.logger : nil
17
19
  @transaction_class = ActiveRecord::Base
@@ -61,7 +61,9 @@ module ZeroRailsAdapter
61
61
 
62
62
  def authorize!
63
63
  callback = self.class.authorization_callback
64
- return true unless callback
64
+ unless callback
65
+ raise ForbiddenError, "Mutator authorization is not configured"
66
+ end
65
67
  return true if instance_exec(context, &callback)
66
68
 
67
69
  raise ForbiddenError, "Mutation is not authorized"
@@ -3,6 +3,16 @@
3
3
  module ZeroRailsAdapter
4
4
  class Processor
5
5
  CLEANUP_MUTATION_NAME = "_zero_cleanupResults"
6
+ DATABASE_ERROR_MESSAGE = "Database error"
7
+ INTERNAL_ERROR_MESSAGE = "Internal server error"
8
+ PERSISTABLE_MUTATION_ERRORS = [
9
+ ActiveModel::UnknownAttributeError,
10
+ ActiveModel::ValidationError,
11
+ ActiveRecord::RecordInvalid,
12
+ ActiveRecord::RecordNotDestroyed,
13
+ ActiveRecord::RecordNotFound,
14
+ ActiveRecord::RecordNotSaved
15
+ ].freeze
6
16
 
7
17
  attr_reader :request, :context, :storage
8
18
 
@@ -47,16 +57,20 @@ module ZeroRailsAdapter
47
57
  mutation_ids: request.mutation_ids.drop(processed_count)
48
58
  )
49
59
  rescue ActiveRecord::ActiveRecordError => error
60
+ mutation_ids = request.mutation_ids.drop(processed_count)
61
+ log_push_failure(error, reason: "database", mutation_ids:)
50
62
  push_failed(
51
63
  reason: "database",
52
- message: error.message,
53
- mutation_ids: request.mutation_ids.drop(processed_count)
64
+ message: DATABASE_ERROR_MESSAGE,
65
+ mutation_ids:
54
66
  )
55
67
  rescue StandardError => error
68
+ mutation_ids = request.mutation_ids.drop(processed_count)
69
+ log_push_failure(error, reason: "internal", mutation_ids:)
56
70
  push_failed(
57
71
  reason: "internal",
58
- message: error.message,
59
- mutation_ids: request.mutation_ids.drop(processed_count)
72
+ message: INTERNAL_ERROR_MESSAGE,
73
+ mutation_ids:
60
74
  )
61
75
  end
62
76
 
@@ -84,7 +98,7 @@ module ZeroRailsAdapter
84
98
  already_processed_response(mutation, error)
85
99
  rescue OutOfOrderMutationError
86
100
  raise
87
- rescue StandardError => error
101
+ rescue ApplicationError, *PERSISTABLE_MUTATION_ERRORS => error
88
102
  application_error = normalize_application_error(error)
89
103
  begin
90
104
  persist_failure(mutation, application_error)
@@ -138,8 +152,10 @@ module ZeroRailsAdapter
138
152
  def normalize_application_error(error)
139
153
  return error if error.is_a?(ApplicationError)
140
154
 
141
- if error.respond_to?(:record) && error.record.respond_to?(:errors)
142
- return ApplicationError.new(error.message, details: error.record.errors.to_hash)
155
+ model = error.record if error.respond_to?(:record)
156
+ model ||= error.model if error.respond_to?(:model)
157
+ if model.respond_to?(:errors)
158
+ return ApplicationError.new(error.message, details: model.errors.to_hash)
143
159
  end
144
160
 
145
161
  ApplicationError.new(error.message)
@@ -174,5 +190,17 @@ module ZeroRailsAdapter
174
190
  "mutationIDs" => mutation_ids
175
191
  }
176
192
  end
193
+
194
+ def log_push_failure(error, reason:, mutation_ids:)
195
+ ZeroRailsAdapter.configuration.logger&.error(
196
+ "ZeroRailsAdapter push failed: " \
197
+ "reason=#{reason} request_id=#{request.request_id.inspect} " \
198
+ "mutation_ids=#{mutation_ids.inspect} " \
199
+ "#{error.class}: #{error.message}\n" \
200
+ "#{Array(error.backtrace).join("\n")}"
201
+ )
202
+ rescue StandardError
203
+ nil
204
+ end
177
205
  end
178
206
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZeroRailsAdapter
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zero-rails-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zero Rails Adapter contributors
@@ -35,6 +35,7 @@ executables: []
35
35
  extensions: []
36
36
  extra_rdoc_files: []
37
37
  files:
38
+ - CHANGELOG.md
38
39
  - LICENSE.txt
39
40
  - README.md
40
41
  - app/controllers/zero_rails_adapter/mutations_controller.rb
@@ -87,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
88
  - !ruby/object:Gem::Version
88
89
  version: '0'
89
90
  requirements: []
90
- rubygems_version: 4.0.10
91
+ rubygems_version: 4.0.16
91
92
  specification_version: 4
92
93
  summary: A Rails 8 Active Record adapter for Rocicorp Zero
93
94
  test_files: []