agreements 0.4.0 → 0.4.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: 921978d679f2c21c42fda41e4caa14e844e5fbfbb57a10c78738811441814547
4
- data.tar.gz: 342464faa1422920f63ca881f7d64728d35e6b88c7e595d351a06c181a7692cd
3
+ metadata.gz: b66e31588c3340afd328330ca457833673b95a0a37e446f21c8f792b1aa7bd9b
4
+ data.tar.gz: 301e25cb6b409ef8267c7aabf3f819e4877207bc85353ddcab988b48a6bf4aa4
5
5
  SHA512:
6
- metadata.gz: c1cf67625ad815b0e3df2033a609cd510cf9c4ca97888b518c842546bb174b3c22abfd7bfc2670d0756337c3e24465b2e70b4a52887dbbf7e0a1a9c7c78a939b
7
- data.tar.gz: 60cd61c042671c9bcb86f1e2f3c37ceeba9bfa2600aee3082766fe8b2124a38a7c81bc3b7ae5328f65f6265d6ac8ac6ef2f77416cd3d28547fd1c70bb47607a3
6
+ metadata.gz: a847857ad1cd3391d5f97b4e1491023dfc6d853bb76589c5f7b24ac58554ac7dbfe598360b66b1f92a05a2ce4aa99e9d081eac7c8bd2ece7390081d2ac6ffc70
7
+ data.tar.gz: edfdbb93fb962b18232e1794f1aee4303ea0c4941ab8e392df2dae446a013771c701274f36b3c17a088f2d6cb83237c6dea73630cb83e8e32c815d808e1879a2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 - 2026-11-12
4
+
5
+ - Rewrite the README around a quick start, guides and an API reference.
6
+ - Clearer gem summary and description, and a documentation link on RubyGems.
7
+
3
8
  ## 0.4.0 - 2026-11-12
4
9
 
5
10
  - Add an optional `effective_at` to versions, so a version can be inserted when
data/README.md CHANGED
@@ -1,64 +1,120 @@
1
1
  # agreements
2
2
 
3
- Auditable acceptance of externally hosted legal agreements for Rails.
3
+ [![Gem Version](https://img.shields.io/gem/v/agreements)](https://rubygems.org/gems/agreements)
4
+ [![CI](https://github.com/yshmarov/agreements/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/agreements/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
4
6
 
5
- `agreements` answers one durable question:
6
-
7
- > Which agreement version and localized statement did this subject accept,
8
- > who performed the acceptance, under what authority, and when?
9
-
10
- It deliberately does not host legal documents or provide a legal CMS. Your
11
- legal or marketing site remains authoritative; the gem stores immutable
12
- version metadata and append-only acceptance evidence in your own database.
13
-
14
- ## Install
7
+ Record who accepted which version of your Terms, Privacy Notice or Data
8
+ Processing Agreement, and ask again when the documents change. For Rails.
15
9
 
16
10
  ```ruby
17
- # Gemfile
18
- gem "agreements"
11
+ Agreements.pending_version("user_terms", subject: current_user)
12
+ # => nil when the user accepted the current Terms, or the version they still need to accept
19
13
  ```
20
14
 
21
- ```bash
22
- bundle install
23
- bin/rails generate agreements:install
24
- bin/rails db:migrate
15
+ ## Why you need it
16
+
17
+ Most apps store terms acceptance as a timestamp, such as
18
+ `users.accepted_terms_at`. That works until somebody asks a real question:
19
+
20
+ - **Which version** of the Terms did this user accept, and what did it say?
21
+ - **What exactly did they tick?** In which language?
22
+ - **Who accepted for the company?** Was it the account owner or an ordinary
23
+ member?
24
+ - **The Terms changed last month.** Who still has to accept the new version?
25
+ - **The form was open during a deploy.** Did the user accept the old version or
26
+ the new one?
27
+
28
+ Procurement teams, auditors and lawyers ask these questions. A timestamp cannot
29
+ answer them. This gem can, with two database tables and a few method calls.
30
+
31
+ ## What it looks like in your app
32
+
33
+ 1. **First deploy.** Every signed-in user who has not accepted your Terms is
34
+ sent to your acceptance page on their next page view. They tick the box and
35
+ continue to the page they wanted.
36
+ 2. **Your lawyer updates the Terms.** You publish the new document and add the
37
+ new version with the date it takes effect, for example in 14 days. Nothing
38
+ changes for users before that date. You can show a notice in the meantime.
39
+ 3. **On the effective date,** each user is asked once to accept the new version.
40
+ 4. **An auditor asks** "Who accepted the DPA for Acme, and what did they see?"
41
+ You answer with one query:
42
+
43
+ ```text
44
+ agreement: organization_dpa, version 2026-09-13
45
+ subject: gid://my-app/Organization/42 the party bound by the agreement
46
+ actor: gid://my-app/User/7 the person who clicked
47
+ authority: organization_owner why that person could accept
48
+ statement: "J'accepte le contrat de traitement des données au nom de cette organisation."
49
+ locale: fr
50
+ accepted: 2026-09-14 09:12:03 UTC
25
51
  ```
26
52
 
27
- The generator adds two tables and nothing else. There is no route, controller,
28
- view, initializer, JavaScript, CSS, or admin dashboard to integrate.
53
+ Records cannot be changed or deleted through the model API.
29
54
 
30
- Ruby >= 3.2 · Rails >= 7.1 and < 9 · PostgreSQL and SQLite
55
+ ## Is it for you?
31
56
 
32
- ### Upgrading from 0.3
57
+ **Use it when**
33
58
 
34
- Version 0.4 adds a nullable `effective_at` column to `agreements_versions`:
59
+ - you have Terms, a Privacy Notice or a DPA, and you must prove acceptance later;
60
+ - a company accepts an agreement through one of its users (B2B SaaS);
61
+ - your documents change, and you must ask people again;
62
+ - you show the acceptance checkbox in more than one language.
35
63
 
36
- ```bash
37
- bin/rails generate migration AddEffectiveAtToAgreementsVersions effective_at:datetime
38
- bin/rails db:migrate
39
- ```
64
+ **Use something else when**
40
65
 
41
- Existing rows keep `effective_at` empty and stay in effect from their creation.
66
+ - you need signatures for contracts. Use an e-signature service;
67
+ - you need to record and withdraw GDPR consent, such as marketing consent;
68
+ - you need IP addresses, signed receipts or a ready-made form builder.
69
+ [clickwrap](https://github.com/rameerez/clickwrap) covers that bigger scope.
42
70
 
43
- ### Upgrading from 0.2
71
+ ## Trust
44
72
 
45
- Version 0.3 has no schema change. `require_agreement` now answers a pending
46
- non-HTML request with `403 Forbidden` instead of letting it through. If your
47
- API already checks `pending_version` before calling it, nothing changes.
73
+ - **In production.** [EthicsPortal](https://ethicsportal.eu), whistleblower
74
+ software for EU organisations, uses it for user Terms and organisation DPAs.
75
+ The gem was extracted from that code in August 2026.
76
+ - **Small.** About 250 lines of Ruby. You can read all of it in one sitting.
77
+ - **Tested** in CI on every push: Ruby 3.2 to 4.0 with Rails 7.1 to 8.1 on
78
+ SQLite, and Rails 8.1 on PostgreSQL.
79
+ - **Safe releases.** Releases are built and published from
80
+ [GitHub Actions](.github/workflows/release.yml) with RubyGems trusted
81
+ publishing. Gem ownership requires multi-factor authentication.
82
+ - **Few dependencies.** Action Pack, Active Record, Railties and Global ID. No
83
+ JavaScript, no background jobs, no external services.
84
+ - **Your data stays in your database.** The gem makes no network calls and
85
+ collects no telemetry. It does not store IP addresses or user agents.
86
+ - **Security:** see [SECURITY.md](SECURITY.md). Report vulnerabilities through
87
+ [private reporting](https://github.com/yshmarov/agreements/security/advisories/new).
88
+ - **Changes** are listed in the [CHANGELOG](CHANGELOG.md). See
89
+ [Versioning](#versioning) for what may break before 1.0.
48
90
 
49
- ### Upgrading from 0.1
91
+ ## Quick start
50
92
 
51
- Version 0.2 adds non-null `acceptance_statement` and `locale` columns to
52
- `agreements_acceptances`. Add them in a host migration before upgrading. For
53
- existing evidence, copy each version's canonical statement and use an honest
54
- locale such as `und` when the displayed locale is unknown.
93
+ Ruby 3.2 or later, Rails 7.1 or later. The example uses a `Current.user`, as
94
+ created by the Rails 8 authentication generator. Use your own current user.
95
+
96
+ ### 1. Install
97
+
98
+ ```ruby
99
+ # Gemfile
100
+ gem "agreements"
101
+ ```
55
102
 
56
- ## Define a version
103
+ ```bash
104
+ bundle install
105
+ bin/rails generate agreements:install
106
+ bin/rails db:migrate
107
+ ```
108
+
109
+ The generator adds one migration with two tables: `agreements_versions` and
110
+ `agreements_acceptances`. It adds no routes, views or configuration.
57
111
 
58
- Agreement versions are deployed data. Insert them in a data migration, and keep
59
- the same list in your seeds so fresh databases match production:
112
+ ### 2. Add your first version
113
+
114
+ A version is data that you deploy. Add it in a migration:
60
115
 
61
116
  ```ruby
117
+ # db/migrate/20260816000000_add_user_terms_2026_08_16.rb
62
118
  class AddUserTerms20260816 < ActiveRecord::Migration[8.1]
63
119
  class Version < ActiveRecord::Base
64
120
  self.table_name = "agreements_versions"
@@ -91,193 +147,221 @@ class AddUserTerms20260816 < ActiveRecord::Migration[8.1]
91
147
  end
92
148
  ```
93
149
 
94
- The migration defines its own model so it keeps working when the gem's model
95
- changes later. The final check stops a deploy if a version label already exists
96
- with different content: a deployed version must never change. Use the same
97
- create-then-check loop in your seeds.
98
-
99
- A version without `effective_at` is current as soon as it is inserted. There is
100
- no draft, publish or activate step. A correction or legal update is a new
101
- immutable row with a new version label.
150
+ - Use your own Rails version in `Migration[8.1]`.
151
+ - The documents stay on your website. The gem stores only their title, URL and
152
+ an optional SHA-256 digest.
153
+ - The migration defines its own model, so it keeps working when the gem changes.
154
+ - The final check stops a deploy if the version already exists with different
155
+ content. A deployed version must never change.
156
+ - Add the same versions to `db/seeds.rb`, so a fresh database has them too.
102
157
 
103
- ### Give notice before a version takes effect
104
-
105
- Terms often promise notice before a change applies, for example "at least 14
106
- days before the changes take effect". Insert the version when you send the
107
- notice, with the date it takes effect:
158
+ ### 3. Add the acceptance page
108
159
 
109
160
  ```ruby
110
- VERSION = {
111
- agreement_key: "user_terms",
112
- version: "2026-12-01",
113
- effective_at: Time.utc(2026, 12, 1),
114
- # acceptance_statement, documents ...
115
- }.freeze
161
+ # config/routes.rb
162
+ resource :terms_acceptance, only: %i[show create]
116
163
  ```
117
164
 
118
- Until that time the previous version stays current, nobody is asked to accept,
119
- and `accept!` refuses the new version. From that time the new version is
120
- current and every subject who has not accepted it is pending. Show the notice
121
- in your app with:
122
-
123
165
  ```ruby
124
- Agreements.upcoming_version("user_terms") # => the next scheduled version, or nil
125
- ```
166
+ # app/controllers/terms_acceptances_controller.rb
167
+ class TermsAcceptancesController < ApplicationController
168
+ skip_before_action :require_terms
169
+
170
+ def show
171
+ @version = Agreements.current_version("user_terms")
172
+ raise ActiveRecord::RecordNotFound unless @version
173
+ end
126
174
 
127
- The current version is the one that took effect most recently: its
128
- `effective_at`, or its creation time when `effective_at` is empty. When two
129
- versions take effect at the same time, the one inserted later wins.
175
+ def create
176
+ @version = Agreements.current_version("user_terms")
177
+ return render(:show, status: :unprocessable_content) unless params.dig(:acceptance, :confirmed) == "1"
178
+
179
+ Agreements.accept!(
180
+ "user_terms",
181
+ version_id: params.dig(:acceptance, :agreement_version_id),
182
+ subject: Current.user,
183
+ actor: Current.user,
184
+ authority: "self",
185
+ acceptance_statement: t("terms_acceptances.statement"),
186
+ locale: I18n.locale.to_s
187
+ )
188
+ redirect_to agreement_return_location || root_path, status: :see_other
189
+ rescue Agreements::VersionNotCurrent => error
190
+ raise ActiveRecord::RecordNotFound unless error.current_version
191
+
192
+ @version = error.current_version
193
+ flash.now[:alert] = t("terms_acceptances.changed")
194
+ render :show, status: :unprocessable_content
195
+ end
196
+ end
197
+ ```
130
198
 
131
- Subjects cannot accept a scheduled version early. Someone who accepts the
132
- current version during the notice period is asked again on the effective date.
199
+ ```erb
200
+ <%# app/views/terms_acceptances/show.html.erb %>
201
+ <%= form_with scope: :acceptance, url: terms_acceptance_path do |form| %>
202
+ <%= form.hidden_field :agreement_version_id, value: @version.id %>
203
+
204
+ <%= form.label :confirmed do %>
205
+ <%= form.check_box :confirmed, required: true %>
206
+ <%= t("terms_acceptances.statement") %>
207
+ <% end %>
208
+
209
+ <ul>
210
+ <% @version.documents.each do |document| %>
211
+ <li><%= link_to document["title"], document["url"], target: "_blank" %></li>
212
+ <% end %>
213
+ </ul>
214
+
215
+ <%= form.submit t("terms_acceptances.accept") %>
216
+ <% end %>
217
+ ```
133
218
 
134
- A scheduled version cannot have acceptances before it takes effect. To cancel
135
- it, delete it in a data migration with a migration-local model before its
136
- effective date. After that date it is evidence like any other version.
219
+ ```yaml
220
+ # config/locales/en.yml
221
+ en:
222
+ terms_acceptances:
223
+ statement: I accept the Terms of Service and acknowledge the Privacy Notice.
224
+ accept: Accept and continue
225
+ changed: The Terms changed while this page was open. Please review them again.
226
+ ```
137
227
 
138
- Document references accept `title`, an HTTPS `url`, an optional 64-character
139
- `sha256`, and an optional `key`. Use `key` (lowercase letters, digits and
140
- underscores) to look up a translated link title, so your views do not depend on
141
- the English `title`. The gem validates a supplied digest but does not fetch
142
- remote pages, calculate hashes, or retain document bytes. Hashing and archival
143
- belong in the trusted legal-document publishing process.
228
+ - `require_terms` is defined in step 4. Add both steps before you restart the
229
+ server.
230
+ - The hidden field sends the version the user saw. If a newer version became
231
+ current in the meantime, `accept!` raises `Agreements::VersionNotCurrent` and
232
+ the page shows the new version.
233
+ - `acceptance_statement` is the exact sentence the user saw, in their language.
234
+ It is stored with the acceptance.
235
+ - Pass `subject`, `actor` and `authority` from the server. Never take them from
236
+ request parameters.
237
+ - A double click or a retry returns the first acceptance. It does not create a
238
+ second one.
144
239
 
145
- ## Ask what is pending
240
+ ### 4. Require acceptance
146
241
 
147
242
  ```ruby
148
- version = Agreements.current_version("user_terms")
149
- pending = Agreements.pending_version("user_terms", subject: current_user)
150
- ```
243
+ # app/controllers/application_controller.rb
244
+ class ApplicationController < ActionController::Base
245
+ include Agreements::Enforcement
151
246
 
152
- Subjects and actors may be records responding to `to_global_id`, or explicit
153
- non-empty opaque strings. The gem stores only the resulting keys; it does not
154
- own authentication, tenancy, roles, or authorization.
247
+ before_action :require_terms, if: -> { Current.user }
155
248
 
156
- A Global ID contains your application name and the model class, for example
157
- `gid://my-app/User/1`. If either changes, old acceptances no longer match and
158
- every subject is asked to accept again. Pin the application name now so a
159
- module rename cannot change it:
249
+ private
160
250
 
161
- ```ruby
162
- # config/application.rb
163
- config.global_id.app = "my-app"
251
+ def require_terms
252
+ require_agreement("user_terms", subject: Current.user, location: main_app.terms_acceptance_path)
253
+ end
254
+ end
164
255
  ```
165
256
 
166
- Before you rename a model, plan how to carry its acceptances over. Acceptance
167
- rows are read only through the model API.
257
+ When the user has not accepted the current version:
168
258
 
169
- ## Render the host-owned form
259
+ - an HTML or Turbo Stream request is redirected to the acceptance page. After
260
+ acceptance, `agreement_return_location` sends the user back to the page they
261
+ asked for (GET and HEAD requests only; a form submission is never replayed);
262
+ - any other request (JSON, CSV, PDF) gets an empty `403 Forbidden`.
170
263
 
171
- Keep the page, routes, authorization, document-link markup, and copy in your
172
- application. Submit the exact displayed version as a hidden field:
264
+ Use `main_app.` for the location so the check also works inside mounted
265
+ engines.
173
266
 
174
- ```erb
175
- <%= form.hidden_field :agreement_version_id, value: @version.id %>
176
- <%= form.check_box :confirmed, required: true %>
177
- ```
267
+ ### 5. Update your tests
178
268
 
179
- Then record only that version while it remains current:
269
+ With enforcement on, most tests need users who have already accepted:
180
270
 
181
271
  ```ruby
182
- statement = I18n.t("agreements.user_terms.statement")
272
+ # test/test_helper.rb
273
+ require "agreements/test_helper"
183
274
 
184
- acceptance = Agreements.accept!(
185
- "user_terms",
186
- version_id: params.dig(:acceptance, :agreement_version_id),
187
- subject: current_user,
188
- actor: current_user,
189
- authority: "self",
190
- acceptance_statement: statement,
191
- locale: I18n.locale.to_s
192
- )
275
+ class ActiveSupport::TestCase
276
+ include Agreements::TestHelper
277
+ end
193
278
  ```
194
279
 
195
- `Agreements.accept!` resolves the current version server-side, requires the
196
- submitted ID to match it, derives opaque keys from the server-owned subject and
197
- actor, and records the exact localized plain-text statement selected by the
198
- host. Do not accept either value from browser parameters. Retries, double
199
- clicks, and concurrent submissions return the original acceptance evidence.
280
+ ```ruby
281
+ accept_agreement("user_terms", subject: users(:alice))
282
+ ```
200
283
 
201
- A missing, malformed, wrong-agreement, or stale ID raises
202
- `Agreements::VersionNotCurrent`. Its `current_version` is ready to render. It
203
- is `nil` when no version exists for that key yet:
284
+ Add the current versions to your fixtures or test setup first. The helper raises
285
+ when no current version exists.
204
286
 
205
- ```ruby
206
- rescue Agreements::VersionNotCurrent => error
207
- raise ActiveRecord::RecordNotFound unless error.current_version
287
+ ## Guides
208
288
 
209
- @version = error.current_version
210
- render :show, status: :unprocessable_content
211
- end
212
- ```
289
+ ### Agreements for a company (DPA)
213
290
 
214
- Authorization stays in the host. For an organization DPA, for example, verify
215
- the actor is the current owner before calling:
291
+ The organisation is the **subject**: it is bound by the agreement. The user who
292
+ clicks is the **actor**. Check in your app that the actor may accept, then
293
+ record the authority you checked:
216
294
 
217
295
  ```ruby
218
296
  Agreements.accept!(
219
297
  "organization_dpa",
220
298
  version_id: params.dig(:acceptance, :agreement_version_id),
221
- subject: current_organization,
222
- actor: current_user,
299
+ subject: Current.organization,
300
+ actor: Current.user,
223
301
  authority: "organization_owner",
224
- acceptance_statement: I18n.t("agreements.organization_dpa.statement"),
302
+ acceptance_statement: t("dpa_acceptances.statement", organization: Current.organization.name),
225
303
  locale: I18n.locale.to_s
226
304
  )
227
305
  ```
228
306
 
229
- Uniqueness belongs to the organization subject, so a later ownership transfer
230
- does not invalidate an existing acceptance.
231
-
232
- ## Enforce an agreement
233
-
234
- Include the small controller concern in your application controller:
235
-
236
- ```ruby
237
- class ApplicationController < ActionController::Base
238
- include Agreements::Enforcement
239
- end
240
- ```
307
+ The acceptance belongs to the organisation. If ownership moves to another user
308
+ later, the organisation does not have to accept again.
241
309
 
242
- Call it from a host-owned before action after authentication and tenant context
243
- are established:
310
+ `require_agreement` returns `nil`, `:redirected` or `:forbidden`. Use it to
311
+ skip work after a halted request, for example Pundit's `verify_authorized`:
244
312
 
245
313
  ```ruby
246
- def require_organization_dpa
247
- require_agreement(
248
- "organization_dpa",
249
- subject: Current.organization,
250
- location: organization_dpa_path(Current.organization)
251
- )
252
- end
314
+ skip_authorization if require_agreement("organization_dpa", subject: Current.organization, location: dpa_path)
253
315
  ```
254
316
 
255
- When the agreement is pending:
256
-
257
- - an HTML or Turbo Stream request is redirected to `location` with
258
- `303 See Other`, and the method returns `:redirected`;
259
- - any other request (JSON, CSV, PDF, API) gets an empty `403 Forbidden`, and
260
- the method returns `:forbidden`.
317
+ ### Change your Terms with notice
261
318
 
262
- The return value lets you skip work that should not run after a halted
263
- request, such as Pundit's `verify_authorized`:
319
+ Terms often promise notice before a change applies, such as "at least 14 days
320
+ before the changes take effect". Add the new version when you send the notice,
321
+ with the date it takes effect:
264
322
 
265
323
  ```ruby
266
- skip_authorization if require_agreement("organization_dpa", subject: Current.organization, location: path)
324
+ VERSION = {
325
+ agreement_key: "user_terms",
326
+ version: "2026-12-01",
327
+ effective_at: Time.utc(2026, 12, 1),
328
+ acceptance_statement: "I accept the Terms of Service and acknowledge the Privacy Notice.",
329
+ documents: [{ key: "terms_of_service", title: "Terms of Service", url: "https://example.com/terms-2026-12-01" }]
330
+ }.freeze
267
331
  ```
268
332
 
269
- Only GET and HEAD return locations are remembered. A blocked mutation is never
270
- replayed after acceptance. Consume the safe same-origin path after a successful
271
- acceptance:
333
+ - Until `effective_at`, the previous version stays current. Nobody is asked to
334
+ accept, and `accept!` refuses the new version.
335
+ - `Agreements.upcoming_version("user_terms")` returns the scheduled version, so
336
+ you can show a notice in the app.
337
+ - From `effective_at`, the new version is current, and everyone who has not
338
+ accepted it is asked once.
339
+ - Without `effective_at`, a version is current as soon as it is inserted.
340
+ - The current version is the one that took effect most recently. If two take
341
+ effect at the same time, the one inserted later wins.
342
+ - To cancel a scheduled version before its date, delete it in a data migration
343
+ with a migration-local model. It cannot have acceptances yet.
344
+ - Publish the new document URL before the version takes effect. Use a new URL
345
+ for each version, so old acceptances keep pointing at the text people saw.
346
+
347
+ ### Show translated document links
348
+
349
+ Give each document a `key`. Look up the link title in your locale files, so
350
+ your views do not depend on the English `title`:
272
351
 
273
- ```ruby
274
- redirect_to agreement_return_location || dashboard_path
352
+ ```erb
353
+ <% @version.documents.each do |document| %>
354
+ <%= link_to t("documents.#{document["key"]}"), document["url"] %>
355
+ <% end %>
275
356
  ```
276
357
 
277
- If your API has its own error format, check before `require_agreement` runs:
358
+ ### APIs and JSON
359
+
360
+ `require_agreement` answers non-HTML requests with an empty `403`. If your API
361
+ has its own error format, check first:
278
362
 
279
363
  ```ruby
280
- def require_organization_dpa
364
+ def require_dpa
281
365
  if api_request?
282
366
  if Agreements.pending_version("organization_dpa", subject: Current.organization)
283
367
  render json: { error: "agreement_required" }, status: :forbidden
@@ -285,139 +369,172 @@ def require_organization_dpa
285
369
  return
286
370
  end
287
371
 
288
- require_agreement("organization_dpa", subject: Current.organization, location: organization_dpa_path(Current.organization))
372
+ require_agreement("organization_dpa", subject: Current.organization, location: main_app.dpa_path)
289
373
  end
290
374
  ```
291
375
 
292
- When no version exists for a key, nothing is pending and nothing is blocked.
293
- Insert the first version before you rely on enforcement.
294
-
295
- ## Test your application
376
+ ### Answer an audit request
296
377
 
297
- Once enforcement is on, most of your tests need subjects that have already
298
- accepted. Include the test helper:
378
+ Acceptances are ordinary Active Record rows:
299
379
 
300
380
  ```ruby
301
- # test/test_helper.rb
302
- require "agreements/test_helper"
303
-
304
- class ActiveSupport::TestCase
305
- include Agreements::TestHelper
306
- end
381
+ Agreements::Acceptance
382
+ .includes(:agreement_version)
383
+ .where(subject_key: organization.to_global_id.to_s)
384
+ .order(:accepted_at)
307
385
  ```
308
386
 
387
+ The gem has no admin screen and no export. Build one when a real audit tells
388
+ you which format and access controls it needs.
389
+
390
+ ### Keep subject keys stable
391
+
392
+ Subjects and actors are stored as Global ID strings, such as
393
+ `gid://my-app/User/1`. The string contains your app name and the model class.
394
+ If either changes, old acceptances no longer match and everyone is asked again.
395
+ Pin the app name now:
396
+
309
397
  ```ruby
310
- accept_agreement("user_terms", subject: user)
311
- accept_agreement("organization_dpa", subject: organization, actor: owner, authority: "organization_owner")
398
+ # config/application.rb
399
+ config.global_id.app = "my-app"
312
400
  ```
313
401
 
314
- `accept_agreement` accepts the current version with its canonical statement and
315
- the `en` locale. It raises when no current version exists, so insert versions
316
- in your fixtures or setup first.
402
+ Before you rename a model, plan how to carry its acceptances over. You can also
403
+ pass your own non-empty string as subject or actor.
317
404
 
318
- ## Evidence model
405
+ ## API reference
319
406
 
320
- `agreements_versions` contains:
407
+ ### Module methods
321
408
 
322
- - agreement key;
323
- - human version label;
324
- - exact acceptance statement;
325
- - external document references and optional SHA-256 digests;
326
- - optional effective date;
327
- - timestamps.
409
+ | Method | Returns |
410
+ |---|---|
411
+ | `Agreements.current_version(key)` | The `Agreements::Version` in effect now, or `nil` |
412
+ | `Agreements.upcoming_version(key)` | The next scheduled version, or `nil` |
413
+ | `Agreements.pending_version(key, subject:)` | The current version if `subject` has not accepted it, otherwise `nil` |
414
+ | `Agreements.accept!(key, version_id:, subject:, actor:, authority:, acceptance_statement:, locale:)` | The new or existing `Agreements::Acceptance` |
415
+ | `Agreements.identity_key(record_or_string)` | The stored key for a subject or actor |
328
416
 
329
- `agreements_acceptances` contains:
417
+ `accept!` raises:
330
418
 
331
- - agreement-version foreign key;
332
- - opaque subject and actor keys;
333
- - authority;
334
- - exact localized acceptance statement and locale;
335
- - server acceptance time;
336
- - timestamps.
419
+ - `Agreements::VersionNotCurrent` when `version_id` is missing, malformed, for
420
+ another agreement or no longer current. `error.current_version` is the
421
+ version to show, or `nil` when none exists;
422
+ - `Agreements::InvalidIdentity` when a subject or actor is neither a non-empty
423
+ string nor a record with `to_global_id`;
424
+ - `ActiveRecord::RecordInvalid` when `authority`, `acceptance_statement` or
425
+ `locale` is blank, or `authority` has characters other than `a-z`, `0-9` and `_`.
337
426
 
338
- Persisted versions and acceptances are read-only through the model API. The gem
339
- does not claim protection from a privileged database owner.
427
+ ### Controller methods (`include Agreements::Enforcement`)
340
428
 
341
- For an exceptional audit request, query ordinary Active Record rows:
429
+ | Method | Behaviour |
430
+ |---|---|
431
+ | `require_agreement(key, subject:, location:)` | `nil` when nothing is pending. Otherwise redirects HTML and Turbo Stream requests to `location` (returns `:redirected`) or renders `403` for others (returns `:forbidden`) |
432
+ | `agreement_return_location` | The same-origin path saved by `require_agreement`, read once, or `nil` |
342
433
 
343
- ```ruby
344
- Agreements::Acceptance
345
- .includes(:agreement_version)
346
- .where(subject_key: current_organization.to_global_id.to_s)
347
- ```
434
+ ### Test helper (`require "agreements/test_helper"`)
435
+
436
+ | Method | Behaviour |
437
+ |---|---|
438
+ | `accept_agreement(key, subject:, actor: subject, authority: "self", locale: "en")` | Accepts the current version with its canonical statement |
348
439
 
349
- Build an export only when a real audit defines the required format and access
350
- controls.
440
+ ### Models
351
441
 
352
- ## Shipping version two
442
+ `Agreements::Version`
353
443
 
354
- 1. Publish immutable/versioned legal-document URLs and any verified digests.
355
- 2. Add the finished bundle to the host's seed registry.
356
- 3. Add a host data migration that inserts the same immutable version, with
357
- `effective_at` if your terms promise notice.
358
- 4. Deploy. The new row becomes current at once, or on its effective date, and
359
- subjects missing it are prompted.
444
+ | Attribute | Notes |
445
+ |---|---|
446
+ | `agreement_key` | `a-z`, `0-9` and `_`, for example `user_terms` |
447
+ | `version` | Your label, for example `2026-08-16`. Unique per key |
448
+ | `acceptance_statement` | The canonical statement for this version |
449
+ | `documents` | At least one `{ title:, url:, sha256:, key: }`. `url` must be HTTPS. `sha256` and `key` are optional |
450
+ | `effective_at` | Optional. The version is current from this time |
360
451
 
361
- Production deployments normally run migrations, not seeds. Changing a seed
362
- registry alone does not deploy a new version to an existing installation.
452
+ `Agreements::Acceptance`
453
+
454
+ | Attribute | Notes |
455
+ |---|---|
456
+ | `agreement_version` | The accepted version |
457
+ | `subject_key`, `actor_key` | Global ID strings or your own strings |
458
+ | `authority` | Why the actor could accept, for example `self` or `organization_owner` |
459
+ | `acceptance_statement`, `locale` | The exact sentence shown and its language |
460
+ | `accepted_at` | Server time of acceptance |
461
+
462
+ Both models are read-only once saved. Updates and deletes through the model API
463
+ raise `ActiveRecord::ReadOnlyRecord`. A database user with direct SQL access
464
+ can still change rows.
363
465
 
364
466
  ## What the evidence proves
365
467
 
366
468
  The gem records which version and which sentence a subject accepted, who did
367
- it, under what authority, and when. That is the part your application can
368
- prove later. Some questions it cannot answer for you:
469
+ it, under what authority, and when. Some questions it cannot answer for you:
369
470
 
370
471
  - **The whole page counts.** Courts look at the entire screen, not only the
371
472
  checkbox. In *Berman v. Freedom Financial Network* (9th Cir. 2022) the terms
372
473
  did not bind the user: the notice was small grey text, the links did not
373
474
  look like links, and the button said "Continue" without saying that it meant
374
- acceptance. Your layout, contrast and button text decide this. The gem does
375
- not see them.
475
+ acceptance. Your layout, contrast and button text decide this.
376
476
  - **It is not a qualified electronic signature.** Under eIDAS (Regulation (EU)
377
477
  No 910/2014, Article 25), an electronic signature cannot be denied legal
378
478
  effect only because it is electronic or not qualified. A checkbox is still
379
479
  not a qualified electronic signature, and this gem does not create one.
380
480
  - **Acknowledging a privacy notice is not consent.** Under the GDPR you inform
381
- people (Article 13). Consent (Articles 6 and 7) is a separate, specific and
382
- withdrawable choice. Keep the two apart in your copy and your records.
481
+ people (Article 13). Consent (Articles 6 and 7) is a separate, specific
482
+ choice that people can withdraw. Keep the two apart.
383
483
  - **The text of your terms still matters.** In the EU, an unfair term in a
384
484
  consumer contract does not bind the consumer (Directive 93/13/EEC), however
385
485
  clearly it was accepted.
386
- - **Identity and authority are yours to check.** The gem stores the actor and
387
- the authority you pass. It does not verify who the person is or whether they
388
- may bind an organization.
389
-
390
- Your counsel decides what your agreements need. The gem keeps the record of
391
- what was accepted.
392
-
393
- ## Not this gem
394
-
395
- - Legal-document hosting, editing, rendering, uploads, or a CMS.
396
- - Draft/publish/activation workflows or an admin dashboard.
397
- - Electronic signatures or identity proofing.
398
- - IP addresses, user agents, fingerprints, geolocation, or request provenance.
399
- - Presentation manifests, browser snapshots, scroll tracking, or one-time nonces.
400
- - Consent withdrawal, declarations, attestations, authorizations, retention,
401
- legal holds, integrity chains, or compliance reporting.
402
- - Application-specific onboarding, marketing consent, tenancy, ownership,
403
- layouts, routes, or copy.
404
-
405
- Those are real product categories. They are not prerequisites for proving an
406
- ordinary Terms or organization-DPA acceptance. If you need consent withdrawal,
407
- signed receipts, request evidence or a form builder, look at
408
- [clickwrap](https://github.com/rameerez/clickwrap).
409
-
410
- ## Later, only after a concrete trigger
411
-
412
- - **Atomic protected actions:** if evidence must accompany a payout, data
413
- handoff, or contract execution, commit the evidence and action in one
414
- transaction.
415
- - **Standalone receipts:** if counsel or an auditor requests a repeatable
416
- package, export the smallest proven evidence set as canonical JSON or HTML.
417
- - **Automated digest verification:** if exact bytes must be reproduced, verify
418
- and archive them in the trusted document-publishing pipeline, not during a
419
- user's acceptance request.
486
+ - **Identity and authority are your checks.** The gem stores the actor and the
487
+ authority you pass. It does not verify who the person is or whether they may
488
+ bind an organisation.
489
+
490
+ Your lawyer decides what your agreements need. The gem keeps the record.
491
+
492
+ ## What this gem does not do
493
+
494
+ - Host, edit or render legal documents.
495
+ - Drafts, approval workflows or an admin dashboard.
496
+ - Electronic signatures or identity checks.
497
+ - IP addresses, user agents or other request data.
498
+ - Consent withdrawal, retention rules or reports.
499
+ - Your routes, views, copy, onboarding or authorization.
500
+
501
+ ## Versioning
502
+
503
+ The gem follows semantic versioning. Before 1.0, a minor release (0.x.0) may
504
+ change behaviour or the schema. Each such release lists the change and the
505
+ upgrade steps in the [CHANGELOG](CHANGELOG.md) and below. Patch releases
506
+ (0.x.y) never break your app.
507
+
508
+ ### Upgrading from 0.3
509
+
510
+ Version 0.4 adds a nullable `effective_at` column to `agreements_versions`:
511
+
512
+ ```bash
513
+ bin/rails generate migration AddEffectiveAtToAgreementsVersions effective_at:datetime
514
+ bin/rails db:migrate
515
+ ```
516
+
517
+ Existing rows keep `effective_at` empty and stay in effect from their creation.
518
+
519
+ ### Upgrading from 0.2
520
+
521
+ Version 0.3 has no schema change. `require_agreement` now answers a pending
522
+ non-HTML request with `403 Forbidden` instead of letting it through. If your
523
+ API already checks `pending_version` before calling it, nothing changes.
524
+
525
+ ### Upgrading from 0.1
526
+
527
+ Version 0.2 adds non-null `acceptance_statement` and `locale` columns to
528
+ `agreements_acceptances`. Add them in a migration before upgrading. For existing
529
+ rows, copy each version's canonical statement and use `und` as the locale when
530
+ the displayed language is unknown.
531
+
532
+ ## Contributing
533
+
534
+ Bug reports and pull requests are welcome on
535
+ [GitHub](https://github.com/yshmarov/agreements/issues). See
536
+ [CONTRIBUTING.md](CONTRIBUTING.md) for setup and scope.
420
537
 
421
538
  ## License
422
539
 
423
- MIT.
540
+ [MIT](MIT-LICENSE). Maintained by [Yaroslav Shmarov](https://github.com/yshmarov).
data/SECURITY.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Security
2
2
 
3
- Report vulnerabilities privately through GitHub's security advisory feature.
3
+ ## Reporting
4
+
5
+ Report vulnerabilities privately through
6
+ [GitHub private reporting](https://github.com/yshmarov/agreements/security/advisories/new).
7
+ Do not open a public issue.
8
+
9
+ ## Supported versions
10
+
11
+ Security fixes go to the latest release. Before 1.0 that is the latest 0.x
12
+ version.
13
+
14
+ ## Scope
4
15
 
5
16
  The gem resolves actor, subject, authority, and acceptance time from values the
6
17
  host passes server-side. A submitted version ID is accepted only when it matches
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agreements
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
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.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -84,11 +84,12 @@ dependencies:
84
84
  - !ruby/object:Gem::Version
85
85
  version: '9'
86
86
  description: |
87
- A small Rails engine that stores immutable external agreement-version
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.
87
+ Versioned acceptance of legal agreements for Rails. Stores which version
88
+ and which localized sentence was accepted, by whom, for whom (a user or a
89
+ company), under what authority and when. Redirects users who still have to
90
+ accept, supports a notice period before new terms take effect, and rejects
91
+ submissions of an outdated version. Two tables, no UI, no external services.
92
+ In production at EthicsPortal.
92
93
  email:
93
94
  - yashm@outlook.com
94
95
  executables: []
@@ -120,6 +121,7 @@ metadata:
120
121
  homepage_uri: https://github.com/yshmarov/agreements
121
122
  source_code_uri: https://github.com/yshmarov/agreements/tree/main
122
123
  changelog_uri: https://github.com/yshmarov/agreements/blob/main/CHANGELOG.md
124
+ documentation_uri: https://github.com/yshmarov/agreements#readme
123
125
  bug_tracker_uri: https://github.com/yshmarov/agreements/issues
124
126
  rubygems_mfa_required: 'true'
125
127
  rdoc_options: []
@@ -138,5 +140,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
140
  requirements: []
139
141
  rubygems_version: 4.0.20
140
142
  specification_version: 4
141
- summary: Auditable acceptance of externally hosted legal agreements for Rails.
143
+ summary: Record who accepted which version of your Terms, Privacy Notice or DPA, and
144
+ ask again when they change.
142
145
  test_files: []