agreements 0.2.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbb4e3cb21a8e743ea9fab48f117242cad49d78e752258bbb992894498355be4
4
- data.tar.gz: f2160a49ff3799851018060c773643c9b829bad4a1d3e22944913cef62dae501
3
+ metadata.gz: 81d911005203680cbf4a2c85a8da0c4bec60c04c9094e3e35af19c8e4fb595d7
4
+ data.tar.gz: 67a34497cc8a10c5f2803745e28b45949eb0ef550aa951b57f08f588c1c809cb
5
5
  SHA512:
6
- metadata.gz: 70299f73fe7bc2b6760aae7b1389fa3913298175554ac001963270372c97af651bec611613ae51d367a320dd6d785b154517cccc1c468eece83f7b25e3c0212c
7
- data.tar.gz: 7a27e58cf17b1068ecee2705f510f38f6900829eb77c5177b8a77237cb19be6e581b728f8a2eba56f973e8850977fa72e612af28dbdbafd1d787d11d86ad2e60
6
+ metadata.gz: 6f78483f20c53fdb31f4c0537c96016d216efb6b362ea35bdb8090752c6585a4ad76f19885cd81f051f55955d355bb58510edb0031266eef37bc32e89c45ed84
7
+ data.tar.gz: 6c64d27234a8d20e0bdece9b695ec67e3272da6df194abbe4f894490831f9659361e0891a3a70a38a2ed375e3caf773bacd13586fbc358381e53df39213ada35
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-11-12
4
+
5
+ - Answer pending non-HTML requests with `403 Forbidden` instead of letting them
6
+ through. `require_agreement` returns `:forbidden` for them.
7
+ - Reject `/\host` return paths, using Rails' `url_from`.
8
+ - Accept an optional `key` on document references for translated link titles.
9
+ - Depend on Action Pack, Active Record, Railties and Global ID instead of all
10
+ of Rails.
11
+ - Document the safe data-migration pattern, Global ID app pinning and a `nil`
12
+ `current_version` on `VersionNotCurrent`.
13
+
3
14
  ## 0.2.0 - 2026-08-27
4
15
 
5
16
  - Record the exact localized acceptance statement and locale shown by the host.
data/README.md CHANGED
@@ -29,6 +29,12 @@ view, initializer, JavaScript, CSS, or admin dashboard to integrate.
29
29
 
30
30
  Ruby >= 3.2 · Rails >= 7.1 and < 9 · PostgreSQL and SQLite
31
31
 
32
+ ### Upgrading from 0.2
33
+
34
+ Version 0.3 has no schema change. `require_agreement` now answers a pending
35
+ non-HTML request with `403 Forbidden` instead of letting it through. If your
36
+ API already checks `pending_version` before calling it, nothing changes.
37
+
32
38
  ### Upgrading from 0.1
33
39
 
34
40
  Version 0.2 adds non-null `acceptance_statement` and `locale` columns to
@@ -38,29 +44,57 @@ locale such as `und` when the displayed locale is unknown.
38
44
 
39
45
  ## Define a version
40
46
 
41
- Agreement versions are deployed data. Create them in a data migration and keep
42
- the same registry in your seeds for fresh databases:
47
+ Agreement versions are deployed data. Insert them in a data migration, and keep
48
+ the same list in your seeds so fresh databases match production:
43
49
 
44
50
  ```ruby
45
- Agreements::Version.create!(
46
- agreement_key: "user_terms",
47
- version: "2026-08-16",
48
- acceptance_statement: "I accept the Terms of Service and acknowledge the Privacy Notice.",
49
- documents: [
50
- { title: "Terms of Service", url: "https://example.com/terms" },
51
- { title: "Privacy Notice", url: "https://example.com/privacy" }
52
- ]
53
- )
51
+ class AddUserTerms20260816 < ActiveRecord::Migration[8.1]
52
+ class Version < ActiveRecord::Base
53
+ self.table_name = "agreements_versions"
54
+ end
55
+
56
+ VERSION = {
57
+ agreement_key: "user_terms",
58
+ version: "2026-08-16",
59
+ acceptance_statement: "I accept the Terms of Service and acknowledge the Privacy Notice.",
60
+ documents: [
61
+ { key: "terms_of_service", title: "Terms of Service", url: "https://example.com/terms-2026-08-16" },
62
+ { key: "privacy_notice", title: "Privacy Notice", url: "https://example.com/privacy-2026-08-16" }
63
+ ]
64
+ }.freeze
65
+
66
+ def up
67
+ version = Version.find_or_create_by!(VERSION.slice(:agreement_key, :version)) do |record|
68
+ record.assign_attributes(VERSION)
69
+ end
70
+
71
+ unless version.acceptance_statement == VERSION[:acceptance_statement] &&
72
+ version.documents == VERSION[:documents].as_json
73
+ raise "#{version.agreement_key} #{version.version} already exists with different content"
74
+ end
75
+ end
76
+
77
+ def down
78
+ raise ActiveRecord::IrreversibleMigration, "Agreement versions are evidence"
79
+ end
80
+ end
54
81
  ```
55
82
 
83
+ The migration defines its own model so it keeps working when the gem's model
84
+ changes later. The final check stops a deploy if a version label already exists
85
+ with different content: a deployed version must never change. Use the same
86
+ create-then-check loop in your seeds.
87
+
56
88
  The newest inserted row for an agreement key is current immediately. There is
57
89
  no draft, publish, activate, or scheduling lifecycle. A correction or legal
58
90
  update is a new immutable row with a new version label.
59
91
 
60
- Document references accept `title`, an HTTPS `url`, and an optional 64-character
61
- `sha256`. The gem validates a supplied digest but does not fetch remote pages,
62
- calculate hashes, or retain document bytes. Hashing and archival belong in the
63
- trusted legal-document publishing process.
92
+ Document references accept `title`, an HTTPS `url`, an optional 64-character
93
+ `sha256`, and an optional `key`. Use `key` (lowercase letters, digits and
94
+ underscores) to look up a translated link title, so your views do not depend on
95
+ the English `title`. The gem validates a supplied digest but does not fetch
96
+ remote pages, calculate hashes, or retain document bytes. Hashing and archival
97
+ belong in the trusted legal-document publishing process.
64
98
 
65
99
  ## Ask what is pending
66
100
 
@@ -73,6 +107,19 @@ Subjects and actors may be records responding to `to_global_id`, or explicit
73
107
  non-empty opaque strings. The gem stores only the resulting keys; it does not
74
108
  own authentication, tenancy, roles, or authorization.
75
109
 
110
+ A Global ID contains your application name and the model class, for example
111
+ `gid://my-app/User/1`. If either changes, old acceptances no longer match and
112
+ every subject is asked to accept again. Pin the application name now so a
113
+ module rename cannot change it:
114
+
115
+ ```ruby
116
+ # config/application.rb
117
+ config.global_id.app = "my-app"
118
+ ```
119
+
120
+ Before you rename a model, plan how to carry its acceptances over. Acceptance
121
+ rows are read only through the model API.
122
+
76
123
  ## Render the host-owned form
77
124
 
78
125
  Keep the page, routes, authorization, document-link markup, and copy in your
@@ -106,10 +153,13 @@ host. Do not accept either value from browser parameters. Retries, double
106
153
  clicks, and concurrent submissions return the original acceptance evidence.
107
154
 
108
155
  A missing, malformed, wrong-agreement, or stale ID raises
109
- `Agreements::VersionNotCurrent`. Its `current_version` is ready to render:
156
+ `Agreements::VersionNotCurrent`. Its `current_version` is ready to render. It
157
+ is `nil` when no version exists for that key yet:
110
158
 
111
159
  ```ruby
112
160
  rescue Agreements::VersionNotCurrent => error
161
+ raise ActiveRecord::RecordNotFound unless error.current_version
162
+
113
163
  @version = error.current_version
114
164
  render :show, status: :unprocessable_content
115
165
  end
@@ -156,16 +206,45 @@ def require_organization_dpa
156
206
  end
157
207
  ```
158
208
 
159
- It redirects pending HTML/Turbo requests and remembers only GET or HEAD return
160
- locations. A blocked mutation is never replayed after acceptance. Consume the
161
- safe same-origin path after a successful acceptance:
209
+ When the agreement is pending:
210
+
211
+ - an HTML or Turbo Stream request is redirected to `location` with
212
+ `303 See Other`, and the method returns `:redirected`;
213
+ - any other request (JSON, CSV, PDF, API) gets an empty `403 Forbidden`, and
214
+ the method returns `:forbidden`.
215
+
216
+ The return value lets you skip work that should not run after a halted
217
+ request, such as Pundit's `verify_authorized`:
218
+
219
+ ```ruby
220
+ skip_authorization if require_agreement("organization_dpa", subject: Current.organization, location: path)
221
+ ```
222
+
223
+ Only GET and HEAD return locations are remembered. A blocked mutation is never
224
+ replayed after acceptance. Consume the safe same-origin path after a successful
225
+ acceptance:
162
226
 
163
227
  ```ruby
164
228
  redirect_to agreement_return_location || dashboard_path
165
229
  ```
166
230
 
167
- API and headless behavior remains host-owned: use `pending_version` and return
168
- the response contract your application supports.
231
+ If your API has its own error format, check before `require_agreement` runs:
232
+
233
+ ```ruby
234
+ def require_organization_dpa
235
+ if api_request?
236
+ if Agreements.pending_version("organization_dpa", subject: Current.organization)
237
+ render json: { error: "agreement_required" }, status: :forbidden
238
+ end
239
+ return
240
+ end
241
+
242
+ require_agreement("organization_dpa", subject: Current.organization, location: organization_dpa_path(Current.organization))
243
+ end
244
+ ```
245
+
246
+ When no version exists for a key, nothing is pending and nothing is blocked.
247
+ Insert the first version before you rely on enforcement.
169
248
 
170
249
  ## Evidence model
171
250
 
@@ -5,6 +5,7 @@ require "uri"
5
5
  module Agreements
6
6
  class Version < ApplicationRecord
7
7
  SHA256_PATTERN = /\A[0-9a-f]{64}\z/i
8
+ KEY_PATTERN = /\A[a-z0-9_]+\z/
8
9
 
9
10
  has_many :acceptances,
10
11
  class_name: "Agreements::Acceptance",
@@ -12,7 +13,7 @@ module Agreements
12
13
  inverse_of: :agreement_version,
13
14
  dependent: :restrict_with_exception
14
15
 
15
- validates :agreement_key, presence: true, format: { with: /\A[a-z0-9_]+\z/ }, uniqueness: { scope: :version }
16
+ validates :agreement_key, presence: true, format: { with: KEY_PATTERN }, uniqueness: { scope: :version }
16
17
  validates :version, :acceptance_statement, presence: true
17
18
  validate :documents_are_external_references
18
19
 
@@ -51,13 +52,18 @@ module Agreements
51
52
  end
52
53
 
53
54
  def allowed_document_attributes?(attributes)
54
- attributes.keys.all? { |key| %w[title url sha256].include?(key) }
55
+ attributes.keys.all? { |key| %w[key title url sha256].include?(key) }
55
56
  end
56
57
 
57
58
  def complete_document_attributes?(attributes)
58
59
  attributes["title"].present? &&
59
60
  external_https_url?(attributes["url"]) &&
60
- valid_sha256?(attributes["sha256"])
61
+ valid_sha256?(attributes["sha256"]) &&
62
+ valid_document_key?(attributes["key"])
63
+ end
64
+
65
+ def valid_document_key?(key)
66
+ key.nil? || key.to_s.match?(KEY_PATTERN)
61
67
  end
62
68
 
63
69
  def external_https_url?(url)
@@ -9,9 +9,13 @@ module Agreements
9
9
  private
10
10
 
11
11
  def require_agreement(agreement_key, subject:, location:)
12
- return unless agreement_enforcement_request?
13
12
  return unless Agreements.pending_version(agreement_key, subject: subject)
14
13
 
14
+ unless agreement_enforcement_request?
15
+ head :forbidden
16
+ return :forbidden
17
+ end
18
+
15
19
  remember_agreement_return_location
16
20
  redirect_to location, status: :see_other
17
21
  :redirected
@@ -30,8 +34,7 @@ module Agreements
30
34
  end
31
35
 
32
36
  def agreement_return_location
33
- path = session.delete(:return_to_after_agreement)
34
- path if path&.start_with?("/") && !path.start_with?("//")
37
+ url_from(session.delete(:return_to_after_agreement))
35
38
  end
36
39
  end
37
40
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "global_id/railtie"
4
+
3
5
  module Agreements
4
6
  class Engine < ::Rails::Engine
5
7
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agreements
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agreements
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -10,7 +10,61 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: rails
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activerecord
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ - !ruby/object:Gem::Dependency
53
+ name: globalid
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '1.0'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '1.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: railties
14
68
  requirement: !ruby/object:Gem::Requirement
15
69
  requirements:
16
70
  - - ">="
@@ -81,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
135
  - !ruby/object:Gem::Version
82
136
  version: '0'
83
137
  requirements: []
84
- rubygems_version: 4.0.16
138
+ rubygems_version: 4.0.20
85
139
  specification_version: 4
86
140
  summary: Auditable acceptance of externally hosted legal agreements for Rails.
87
141
  test_files: []