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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +100 -21
- data/app/models/agreements/version.rb +9 -3
- data/lib/agreements/enforcement.rb +6 -3
- data/lib/agreements/engine.rb +2 -0
- data/lib/agreements/version.rb +1 -1
- metadata +57 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81d911005203680cbf4a2c85a8da0c4bec60c04c9094e3e35af19c8e4fb595d7
|
|
4
|
+
data.tar.gz: 67a34497cc8a10c5f2803745e28b45949eb0ef550aa951b57f08f588c1c809cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
42
|
-
the same
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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`,
|
|
61
|
-
`sha256
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
168
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
data/lib/agreements/engine.rb
CHANGED
data/lib/agreements/version.rb
CHANGED
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
|
+
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:
|
|
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.
|
|
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: []
|