agreements 0.1.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: 6d0c0ec7930db8014c275731beebb35381ce05acc26e7b56ec68e3a6e1d8dcd1
4
- data.tar.gz: ec47850af89352033e3ff2f9458073fcc33508b6cfeaddb97687a9f969b3b828
3
+ metadata.gz: 81d911005203680cbf4a2c85a8da0c4bec60c04c9094e3e35af19c8e4fb595d7
4
+ data.tar.gz: 67a34497cc8a10c5f2803745e28b45949eb0ef550aa951b57f08f588c1c809cb
5
5
  SHA512:
6
- metadata.gz: 48879a71eb8489ee500f01172ba0e25488730aeb263c41478b9f3f910cf6a7c6c12bd7c1ae03e8244b65fe9f733ec9e2949010ac0482ba51d1bcf59313663a65
7
- data.tar.gz: a67ad2a4e8282b7b8a1f1fe71842e3797123eb3611d1a36c4dd60b163085a325846797431ff67c92aca9224fba3bd5c2cacaca804dd0255bc24628508f3d4351
6
+ metadata.gz: 6f78483f20c53fdb31f4c0537c96016d216efb6b362ea35bdb8090752c6585a4ad76f19885cd81f051f55955d355bb58510edb0031266eef37bc32e89c45ed84
7
+ data.tar.gz: 6c64d27234a8d20e0bdece9b695ec67e3272da6df194abbe4f894490831f9659361e0891a3a70a38a2ed375e3caf773bacd13586fbc358381e53df39213ada35
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
14
+ ## 0.2.0 - 2026-08-27
15
+
16
+ - Record the exact localized acceptance statement and locale shown by the host.
17
+ - Publish releases through RubyGems trusted publishing.
18
+
3
19
  ## 0.1.0 - 2026-08-25
4
20
 
5
21
  - Extract the agreement-version and acceptance primitives proven in EthicsPortal.
data/README.md CHANGED
@@ -4,8 +4,8 @@ Auditable acceptance of externally hosted legal agreements for Rails.
4
4
 
5
5
  `agreements` answers one durable question:
6
6
 
7
- > Which agreement version did this subject accept, who performed the
8
- > acceptance, under what authority, and when?
7
+ > Which agreement version and localized statement did this subject accept,
8
+ > who performed the acceptance, under what authority, and when?
9
9
 
10
10
  It deliberately does not host legal documents or provide a legal CMS. Your
11
11
  legal or marketing site remains authoritative; the gem stores immutable
@@ -29,31 +29,72 @@ 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
+
38
+ ### Upgrading from 0.1
39
+
40
+ Version 0.2 adds non-null `acceptance_statement` and `locale` columns to
41
+ `agreements_acceptances`. Add them in a host migration before upgrading. For
42
+ existing evidence, copy each version's canonical statement and use an honest
43
+ locale such as `und` when the displayed locale is unknown.
44
+
32
45
  ## Define a version
33
46
 
34
- Agreement versions are deployed data. Create them in a data migration and keep
35
- 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:
36
49
 
37
50
  ```ruby
38
- Agreements::Version.create!(
39
- agreement_key: "user_terms",
40
- version: "2026-08-16",
41
- acceptance_statement: "I accept the Terms of Service and acknowledge the Privacy Notice.",
42
- documents: [
43
- { title: "Terms of Service", url: "https://example.com/terms" },
44
- { title: "Privacy Notice", url: "https://example.com/privacy" }
45
- ]
46
- )
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
47
81
  ```
48
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
+
49
88
  The newest inserted row for an agreement key is current immediately. There is
50
89
  no draft, publish, activate, or scheduling lifecycle. A correction or legal
51
90
  update is a new immutable row with a new version label.
52
91
 
53
- Document references accept `title`, an HTTPS `url`, and an optional 64-character
54
- `sha256`. The gem validates a supplied digest but does not fetch remote pages,
55
- calculate hashes, or retain document bytes. Hashing and archival belong in the
56
- 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.
57
98
 
58
99
  ## Ask what is pending
59
100
 
@@ -66,6 +107,19 @@ Subjects and actors may be records responding to `to_global_id`, or explicit
66
107
  non-empty opaque strings. The gem stores only the resulting keys; it does not
67
108
  own authentication, tenancy, roles, or authorization.
68
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
+
69
123
  ## Render the host-owned form
70
124
 
71
125
  Keep the page, routes, authorization, document-link markup, and copy in your
@@ -79,25 +133,33 @@ application. Submit the exact displayed version as a hidden field:
79
133
  Then record only that version while it remains current:
80
134
 
81
135
  ```ruby
136
+ statement = I18n.t("agreements.user_terms.statement")
137
+
82
138
  acceptance = Agreements.accept!(
83
139
  "user_terms",
84
140
  version_id: params.dig(:acceptance, :agreement_version_id),
85
141
  subject: current_user,
86
142
  actor: current_user,
87
- authority: "self"
143
+ authority: "self",
144
+ acceptance_statement: statement,
145
+ locale: I18n.locale.to_s
88
146
  )
89
147
  ```
90
148
 
91
149
  `Agreements.accept!` resolves the current version server-side, requires the
92
150
  submitted ID to match it, derives opaque keys from the server-owned subject and
93
- actor, and records one acceptance per subject and version. Retries, double
94
- clicks, and concurrent submissions return the original acceptance.
151
+ actor, and records the exact localized plain-text statement selected by the
152
+ host. Do not accept either value from browser parameters. Retries, double
153
+ clicks, and concurrent submissions return the original acceptance evidence.
95
154
 
96
155
  A missing, malformed, wrong-agreement, or stale ID raises
97
- `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:
98
158
 
99
159
  ```ruby
100
160
  rescue Agreements::VersionNotCurrent => error
161
+ raise ActiveRecord::RecordNotFound unless error.current_version
162
+
101
163
  @version = error.current_version
102
164
  render :show, status: :unprocessable_content
103
165
  end
@@ -112,7 +174,9 @@ Agreements.accept!(
112
174
  version_id: params.dig(:acceptance, :agreement_version_id),
113
175
  subject: current_organization,
114
176
  actor: current_user,
115
- authority: "organization_owner"
177
+ authority: "organization_owner",
178
+ acceptance_statement: I18n.t("agreements.organization_dpa.statement"),
179
+ locale: I18n.locale.to_s
116
180
  )
117
181
  ```
118
182
 
@@ -142,16 +206,45 @@ def require_organization_dpa
142
206
  end
143
207
  ```
144
208
 
145
- It redirects pending HTML/Turbo requests and remembers only GET or HEAD return
146
- locations. A blocked mutation is never replayed after acceptance. Consume the
147
- 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:
148
226
 
149
227
  ```ruby
150
228
  redirect_to agreement_return_location || dashboard_path
151
229
  ```
152
230
 
153
- API and headless behavior remains host-owned: use `pending_version` and return
154
- 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.
155
248
 
156
249
  ## Evidence model
157
250
 
@@ -168,6 +261,7 @@ the response contract your application supports.
168
261
  - agreement-version foreign key;
169
262
  - opaque subject and actor keys;
170
263
  - authority;
264
+ - exact localized acceptance statement and locale;
171
265
  - server acceptance time;
172
266
  - timestamps.
173
267
 
@@ -201,7 +295,7 @@ registry alone does not deploy a new version to an existing installation.
201
295
  - Draft/publish/activation workflows or an admin dashboard.
202
296
  - Electronic signatures or identity proofing.
203
297
  - IP addresses, user agents, fingerprints, geolocation, or request provenance.
204
- - Presentation manifests, snapshots, scroll tracking, or one-time nonces.
298
+ - Presentation manifests, browser snapshots, scroll tracking, or one-time nonces.
205
299
  - Consent withdrawal, declarations, attestations, authorizations, retention,
206
300
  legal holds, integrity chains, or compliance reporting.
207
301
  - Application-specific onboarding, marketing consent, tenancy, ownership,
@@ -6,7 +6,7 @@ module Agreements
6
6
  class_name: "Agreements::Version",
7
7
  inverse_of: :acceptances
8
8
 
9
- validates :subject_key, :actor_key, :authority, :accepted_at, presence: true
9
+ validates :subject_key, :actor_key, :authority, :acceptance_statement, :locale, :accepted_at, presence: true
10
10
  validates :authority, format: { with: /\A[a-z0-9_]+\z/ }
11
11
 
12
12
  def readonly?
@@ -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
@@ -2,13 +2,15 @@
2
2
 
3
3
  module Agreements
4
4
  class Recorder
5
- def self.call(agreement_version:, subject:, actor:, authority:)
5
+ def self.call(agreement_version:, evidence:)
6
6
  Acceptance.create_or_find_by!(
7
7
  agreement_version: agreement_version,
8
- subject_key: Agreements.identity_key(subject)
8
+ subject_key: Agreements.identity_key(evidence.fetch(:subject))
9
9
  ) do |acceptance|
10
- acceptance.actor_key = Agreements.identity_key(actor)
11
- acceptance.authority = authority
10
+ acceptance.actor_key = Agreements.identity_key(evidence.fetch(:actor))
11
+ acceptance.authority = evidence.fetch(:authority)
12
+ acceptance.acceptance_statement = evidence.fetch(:acceptance_statement)
13
+ acceptance.locale = evidence.fetch(:locale)
12
14
  acceptance.accepted_at = Time.current
13
15
  end
14
16
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agreements
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/agreements.rb CHANGED
@@ -23,15 +23,21 @@ module Agreements
23
23
  end
24
24
  end
25
25
 
26
- def accept!(agreement_key, version_id:, subject:, actor:, authority:)
26
+ # Explicit keywords keep this evidence boundary distinct from browser params.
27
+ # rubocop:disable-next Metrics/ParameterLists
28
+ def accept!(agreement_key, version_id:, subject:, actor:, authority:, acceptance_statement:, locale:)
27
29
  version = current_version(agreement_key)
28
30
  raise VersionNotCurrent, version unless version&.id.to_s == version_id.to_s
29
31
 
30
32
  Recorder.call(
31
33
  agreement_version: version,
32
- subject: subject,
33
- actor: actor,
34
- authority: authority
34
+ evidence: {
35
+ subject: subject,
36
+ actor: actor,
37
+ authority: authority,
38
+ acceptance_statement: acceptance_statement,
39
+ locale: locale
40
+ }
35
41
  )
36
42
  end
37
43
 
@@ -21,6 +21,8 @@ class CreateAgreementsTables < ActiveRecord::Migration<%= migration_version %>
21
21
  t.string :subject_key, null: false
22
22
  t.string :actor_key, null: false
23
23
  t.string :authority, null: false
24
+ t.text :acceptance_statement, null: false
25
+ t.string :locale, null: false
24
26
  t.datetime :accepted_at, null: false
25
27
  t.timestamps
26
28
 
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.1.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
  - - ">="
@@ -31,10 +85,10 @@ dependencies:
31
85
  version: '9'
32
86
  description: |
33
87
  A small Rails engine that stores immutable external agreement-version
34
- metadata and append-only acceptance evidence. It distinguishes the subject
35
- bound by an agreement from the actor accepting under recorded authority,
36
- rejects stale rendered versions, and leaves documents, authentication,
37
- authorization, routes, and UI in the host application.
88
+ metadata and append-only localized acceptance evidence. It distinguishes
89
+ the subject bound by an agreement from the actor accepting under recorded
90
+ authority, rejects stale rendered versions, and leaves documents,
91
+ authentication, authorization, routes, and UI in the host application.
38
92
  email:
39
93
  - yashm@outlook.com
40
94
  executables: []
@@ -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.10
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: []