agreements 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +21 -0
- data/README.md +226 -0
- data/SECURITY.md +10 -0
- data/app/models/agreements/acceptance.rb +22 -0
- data/app/models/agreements/application_record.rb +7 -0
- data/app/models/agreements/version.rb +74 -0
- data/lib/agreements/enforcement.rb +37 -0
- data/lib/agreements/engine.rb +6 -0
- data/lib/agreements/errors.rb +16 -0
- data/lib/agreements/identity.rb +19 -0
- data/lib/agreements/recorder.rb +16 -0
- data/lib/agreements/version.rb +5 -0
- data/lib/agreements.rb +44 -0
- data/lib/generators/agreements/install/install_generator.rb +26 -0
- data/lib/generators/agreements/install/templates/create_agreements_tables.rb.tt +33 -0
- data/lib/generators/agreements/migration_helpers.rb +28 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6d0c0ec7930db8014c275731beebb35381ce05acc26e7b56ec68e3a6e1d8dcd1
|
|
4
|
+
data.tar.gz: ec47850af89352033e3ff2f9458073fcc33508b6cfeaddb97687a9f969b3b828
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48879a71eb8489ee500f01172ba0e25488730aeb263c41478b9f3f910cf6a7c6c12bd7c1ae03e8244b65fe9f733ec9e2949010ac0482ba51d1bcf59313663a65
|
|
7
|
+
data.tar.gz: a67ad2a4e8282b7b8a1f1fe71842e3797123eb3611d1a36c4dd60b163085a325846797431ff67c92aca9224fba3bd5c2cacaca804dd0255bc24628508f3d4351
|
data/CHANGELOG.md
ADDED
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yaroslav Shmarov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# agreements
|
|
2
|
+
|
|
3
|
+
Auditable acceptance of externally hosted legal agreements for Rails.
|
|
4
|
+
|
|
5
|
+
`agreements` answers one durable question:
|
|
6
|
+
|
|
7
|
+
> Which agreement version did this subject accept, who performed the
|
|
8
|
+
> 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
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# Gemfile
|
|
18
|
+
gem "agreements"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
bin/rails generate agreements:install
|
|
24
|
+
bin/rails db:migrate
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The generator adds two tables and nothing else. There is no route, controller,
|
|
28
|
+
view, initializer, JavaScript, CSS, or admin dashboard to integrate.
|
|
29
|
+
|
|
30
|
+
Ruby >= 3.2 · Rails >= 7.1 and < 9 · PostgreSQL and SQLite
|
|
31
|
+
|
|
32
|
+
## Define a version
|
|
33
|
+
|
|
34
|
+
Agreement versions are deployed data. Create them in a data migration and keep
|
|
35
|
+
the same registry in your seeds for fresh databases:
|
|
36
|
+
|
|
37
|
+
```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
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The newest inserted row for an agreement key is current immediately. There is
|
|
50
|
+
no draft, publish, activate, or scheduling lifecycle. A correction or legal
|
|
51
|
+
update is a new immutable row with a new version label.
|
|
52
|
+
|
|
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.
|
|
57
|
+
|
|
58
|
+
## Ask what is pending
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
version = Agreements.current_version("user_terms")
|
|
62
|
+
pending = Agreements.pending_version("user_terms", subject: current_user)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Subjects and actors may be records responding to `to_global_id`, or explicit
|
|
66
|
+
non-empty opaque strings. The gem stores only the resulting keys; it does not
|
|
67
|
+
own authentication, tenancy, roles, or authorization.
|
|
68
|
+
|
|
69
|
+
## Render the host-owned form
|
|
70
|
+
|
|
71
|
+
Keep the page, routes, authorization, document-link markup, and copy in your
|
|
72
|
+
application. Submit the exact displayed version as a hidden field:
|
|
73
|
+
|
|
74
|
+
```erb
|
|
75
|
+
<%= form.hidden_field :agreement_version_id, value: @version.id %>
|
|
76
|
+
<%= form.check_box :confirmed, required: true %>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then record only that version while it remains current:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
acceptance = Agreements.accept!(
|
|
83
|
+
"user_terms",
|
|
84
|
+
version_id: params.dig(:acceptance, :agreement_version_id),
|
|
85
|
+
subject: current_user,
|
|
86
|
+
actor: current_user,
|
|
87
|
+
authority: "self"
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`Agreements.accept!` resolves the current version server-side, requires the
|
|
92
|
+
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.
|
|
95
|
+
|
|
96
|
+
A missing, malformed, wrong-agreement, or stale ID raises
|
|
97
|
+
`Agreements::VersionNotCurrent`. Its `current_version` is ready to render:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
rescue Agreements::VersionNotCurrent => error
|
|
101
|
+
@version = error.current_version
|
|
102
|
+
render :show, status: :unprocessable_content
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Authorization stays in the host. For an organization DPA, for example, verify
|
|
107
|
+
the actor is the current owner before calling:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
Agreements.accept!(
|
|
111
|
+
"organization_dpa",
|
|
112
|
+
version_id: params.dig(:acceptance, :agreement_version_id),
|
|
113
|
+
subject: current_organization,
|
|
114
|
+
actor: current_user,
|
|
115
|
+
authority: "organization_owner"
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Uniqueness belongs to the organization subject, so a later ownership transfer
|
|
120
|
+
does not invalidate an existing acceptance.
|
|
121
|
+
|
|
122
|
+
## Enforce an agreement
|
|
123
|
+
|
|
124
|
+
Include the small controller concern in your application controller:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class ApplicationController < ActionController::Base
|
|
128
|
+
include Agreements::Enforcement
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Call it from a host-owned before action after authentication and tenant context
|
|
133
|
+
are established:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
def require_organization_dpa
|
|
137
|
+
require_agreement(
|
|
138
|
+
"organization_dpa",
|
|
139
|
+
subject: Current.organization,
|
|
140
|
+
location: organization_dpa_path(Current.organization)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
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:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
redirect_to agreement_return_location || dashboard_path
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
API and headless behavior remains host-owned: use `pending_version` and return
|
|
154
|
+
the response contract your application supports.
|
|
155
|
+
|
|
156
|
+
## Evidence model
|
|
157
|
+
|
|
158
|
+
`agreements_versions` contains:
|
|
159
|
+
|
|
160
|
+
- agreement key;
|
|
161
|
+
- human version label;
|
|
162
|
+
- exact acceptance statement;
|
|
163
|
+
- external document references and optional SHA-256 digests;
|
|
164
|
+
- timestamps.
|
|
165
|
+
|
|
166
|
+
`agreements_acceptances` contains:
|
|
167
|
+
|
|
168
|
+
- agreement-version foreign key;
|
|
169
|
+
- opaque subject and actor keys;
|
|
170
|
+
- authority;
|
|
171
|
+
- server acceptance time;
|
|
172
|
+
- timestamps.
|
|
173
|
+
|
|
174
|
+
Persisted versions and acceptances are read-only through the model API. The gem
|
|
175
|
+
does not claim protection from a privileged database owner.
|
|
176
|
+
|
|
177
|
+
For an exceptional audit request, query ordinary Active Record rows:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
Agreements::Acceptance
|
|
181
|
+
.includes(:agreement_version)
|
|
182
|
+
.where(subject_key: current_organization.to_global_id.to_s)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Build an export only when a real audit defines the required format and access
|
|
186
|
+
controls.
|
|
187
|
+
|
|
188
|
+
## Shipping version two
|
|
189
|
+
|
|
190
|
+
1. Publish immutable/versioned legal-document URLs and any verified digests.
|
|
191
|
+
2. Add the finished bundle to the host's seed registry.
|
|
192
|
+
3. Add a host data migration that inserts the same immutable version.
|
|
193
|
+
4. Deploy. The new row becomes current and subjects missing it are prompted.
|
|
194
|
+
|
|
195
|
+
Production deployments normally run migrations, not seeds. Changing a seed
|
|
196
|
+
registry alone does not deploy a new version to an existing installation.
|
|
197
|
+
|
|
198
|
+
## Not this gem
|
|
199
|
+
|
|
200
|
+
- Legal-document hosting, editing, rendering, uploads, or a CMS.
|
|
201
|
+
- Draft/publish/activation workflows or an admin dashboard.
|
|
202
|
+
- Electronic signatures or identity proofing.
|
|
203
|
+
- IP addresses, user agents, fingerprints, geolocation, or request provenance.
|
|
204
|
+
- Presentation manifests, snapshots, scroll tracking, or one-time nonces.
|
|
205
|
+
- Consent withdrawal, declarations, attestations, authorizations, retention,
|
|
206
|
+
legal holds, integrity chains, or compliance reporting.
|
|
207
|
+
- Application-specific onboarding, marketing consent, tenancy, ownership,
|
|
208
|
+
layouts, routes, or copy.
|
|
209
|
+
|
|
210
|
+
Those are real product categories. They are not prerequisites for proving an
|
|
211
|
+
ordinary Terms or organization-DPA acceptance.
|
|
212
|
+
|
|
213
|
+
## Later, only after a concrete trigger
|
|
214
|
+
|
|
215
|
+
- **Atomic protected actions:** if evidence must accompany a payout, data
|
|
216
|
+
handoff, or contract execution, commit the evidence and action in one
|
|
217
|
+
transaction.
|
|
218
|
+
- **Standalone receipts:** if counsel or an auditor requests a repeatable
|
|
219
|
+
package, export the smallest proven evidence set as canonical JSON or HTML.
|
|
220
|
+
- **Automated digest verification:** if exact bytes must be reproduced, verify
|
|
221
|
+
and archive them in the trusted document-publishing pipeline, not during a
|
|
222
|
+
user's acceptance request.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT.
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities privately through GitHub's security advisory feature.
|
|
4
|
+
|
|
5
|
+
The gem resolves actor, subject, authority, and acceptance time from values the
|
|
6
|
+
host passes server-side. A submitted version ID is accepted only when it matches
|
|
7
|
+
the current version for the expected agreement key.
|
|
8
|
+
|
|
9
|
+
Persisted records are read-only through the model API. This does not protect
|
|
10
|
+
against a privileged database owner or direct SQL mutation.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agreements
|
|
4
|
+
class Acceptance < ApplicationRecord
|
|
5
|
+
belongs_to :agreement_version,
|
|
6
|
+
class_name: "Agreements::Version",
|
|
7
|
+
inverse_of: :acceptances
|
|
8
|
+
|
|
9
|
+
validates :subject_key, :actor_key, :authority, :accepted_at, presence: true
|
|
10
|
+
validates :authority, format: { with: /\A[a-z0-9_]+\z/ }
|
|
11
|
+
|
|
12
|
+
def readonly?
|
|
13
|
+
persisted?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def delete
|
|
17
|
+
raise ActiveRecord::ReadOnlyRecord, "Agreements::Acceptance is read only" if persisted?
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Agreements
|
|
6
|
+
class Version < ApplicationRecord
|
|
7
|
+
SHA256_PATTERN = /\A[0-9a-f]{64}\z/i
|
|
8
|
+
|
|
9
|
+
has_many :acceptances,
|
|
10
|
+
class_name: "Agreements::Acceptance",
|
|
11
|
+
foreign_key: :agreement_version_id,
|
|
12
|
+
inverse_of: :agreement_version,
|
|
13
|
+
dependent: :restrict_with_exception
|
|
14
|
+
|
|
15
|
+
validates :agreement_key, presence: true, format: { with: /\A[a-z0-9_]+\z/ }, uniqueness: { scope: :version }
|
|
16
|
+
validates :version, :acceptance_statement, presence: true
|
|
17
|
+
validate :documents_are_external_references
|
|
18
|
+
|
|
19
|
+
def self.current_for(agreement_key)
|
|
20
|
+
where(agreement_key: agreement_key).order(created_at: :desc, id: :desc).first
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def accepted_by?(subject)
|
|
24
|
+
acceptances.exists?(subject_key: Agreements.identity_key(subject))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def readonly?
|
|
28
|
+
persisted?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete
|
|
32
|
+
raise ActiveRecord::ReadOnlyRecord, "Agreements::Version is read only" if persisted?
|
|
33
|
+
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def documents_are_external_references
|
|
40
|
+
unless documents.is_a?(Array) && documents.any?
|
|
41
|
+
errors.add(:documents, :invalid)
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
errors.add(:documents, :invalid) unless documents.all? { |document| valid_document?(document) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid_document?(document)
|
|
49
|
+
attributes = document.stringify_keys if document.is_a?(Hash)
|
|
50
|
+
attributes && allowed_document_attributes?(attributes) && complete_document_attributes?(attributes)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def allowed_document_attributes?(attributes)
|
|
54
|
+
attributes.keys.all? { |key| %w[title url sha256].include?(key) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def complete_document_attributes?(attributes)
|
|
58
|
+
attributes["title"].present? &&
|
|
59
|
+
external_https_url?(attributes["url"]) &&
|
|
60
|
+
valid_sha256?(attributes["sha256"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def external_https_url?(url)
|
|
64
|
+
uri = URI.parse(url.to_s)
|
|
65
|
+
uri.is_a?(URI::HTTPS) && uri.host.present?
|
|
66
|
+
rescue URI::InvalidURIError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def valid_sha256?(sha256)
|
|
71
|
+
sha256.blank? || sha256.match?(SHA256_PATTERN)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Agreements
|
|
6
|
+
module Enforcement
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def require_agreement(agreement_key, subject:, location:)
|
|
12
|
+
return unless agreement_enforcement_request?
|
|
13
|
+
return unless Agreements.pending_version(agreement_key, subject: subject)
|
|
14
|
+
|
|
15
|
+
remember_agreement_return_location
|
|
16
|
+
redirect_to location, status: :see_other
|
|
17
|
+
:redirected
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def agreement_enforcement_request?
|
|
21
|
+
request.format.html? || request.format == Mime[:turbo_stream]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remember_agreement_return_location
|
|
25
|
+
if request.get? || request.head?
|
|
26
|
+
session[:return_to_after_agreement] = request.fullpath
|
|
27
|
+
else
|
|
28
|
+
session.delete(:return_to_after_agreement)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def agreement_return_location
|
|
33
|
+
path = session.delete(:return_to_after_agreement)
|
|
34
|
+
path if path&.start_with?("/") && !path.start_with?("//")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agreements
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class InvalidIdentity < Error; end
|
|
7
|
+
|
|
8
|
+
class VersionNotCurrent < Error
|
|
9
|
+
attr_reader :current_version
|
|
10
|
+
|
|
11
|
+
def initialize(current_version)
|
|
12
|
+
@current_version = current_version
|
|
13
|
+
super("The submitted agreement version is not current")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agreements
|
|
4
|
+
module Identity
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def key(identity)
|
|
8
|
+
value = identity.is_a?(String) ? identity : global_id_for(identity)
|
|
9
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
10
|
+
|
|
11
|
+
raise InvalidIdentity, "identity must be a non-empty String or respond to #to_global_id"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def global_id_for(identity)
|
|
15
|
+
identity.to_global_id.to_s if identity.respond_to?(:to_global_id)
|
|
16
|
+
end
|
|
17
|
+
private_class_method :global_id_for
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agreements
|
|
4
|
+
class Recorder
|
|
5
|
+
def self.call(agreement_version:, subject:, actor:, authority:)
|
|
6
|
+
Acceptance.create_or_find_by!(
|
|
7
|
+
agreement_version: agreement_version,
|
|
8
|
+
subject_key: Agreements.identity_key(subject)
|
|
9
|
+
) do |acceptance|
|
|
10
|
+
acceptance.actor_key = Agreements.identity_key(actor)
|
|
11
|
+
acceptance.authority = authority
|
|
12
|
+
acceptance.accepted_at = Time.current
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/agreements.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "agreements/version"
|
|
4
|
+
require "agreements/errors"
|
|
5
|
+
require "agreements/identity"
|
|
6
|
+
require "agreements/recorder"
|
|
7
|
+
require "agreements/enforcement"
|
|
8
|
+
require "agreements/engine"
|
|
9
|
+
|
|
10
|
+
module Agreements
|
|
11
|
+
def self.table_name_prefix
|
|
12
|
+
"agreements_"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def current_version(agreement_key)
|
|
17
|
+
Version.current_for(agreement_key)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pending_version(agreement_key, subject:)
|
|
21
|
+
current_version(agreement_key).then do |version|
|
|
22
|
+
version unless version.nil? || version.accepted_by?(subject)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def accept!(agreement_key, version_id:, subject:, actor:, authority:)
|
|
27
|
+
version = current_version(agreement_key)
|
|
28
|
+
raise VersionNotCurrent, version unless version&.id.to_s == version_id.to_s
|
|
29
|
+
|
|
30
|
+
Recorder.call(
|
|
31
|
+
agreement_version: version,
|
|
32
|
+
subject: subject,
|
|
33
|
+
actor: actor,
|
|
34
|
+
authority: authority
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def identity_key(identity)
|
|
39
|
+
Identity.key(identity)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_constant :Recorder
|
|
44
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "../migration_helpers"
|
|
6
|
+
|
|
7
|
+
module Agreements
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationHelpers
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
desc "Installs the agreement versions and acceptances migration."
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template "create_agreements_tables.rb.tt", "db/migrate/create_agreements_tables.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post_install
|
|
21
|
+
say "\nagreements installed. Run `bin/rails db:migrate`.", :green
|
|
22
|
+
say "Define finished agreement versions in host data migrations and seeds."
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateAgreementsTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :agreements_versions<%= primary_key_type_option %> do |t|
|
|
6
|
+
t.string :agreement_key, null: false
|
|
7
|
+
t.string :version, null: false
|
|
8
|
+
t.text :acceptance_statement, null: false
|
|
9
|
+
t.json :documents, null: false, default: []
|
|
10
|
+
t.timestamps
|
|
11
|
+
|
|
12
|
+
t.index %i[agreement_key version], unique: true
|
|
13
|
+
t.index %i[agreement_key created_at]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
create_table :agreements_acceptances<%= primary_key_type_option %> do |t|
|
|
17
|
+
t.references :agreement_version,
|
|
18
|
+
null: false,
|
|
19
|
+
index: false,
|
|
20
|
+
foreign_key: { to_table: :agreements_versions }<%= reference_type_option %>
|
|
21
|
+
t.string :subject_key, null: false
|
|
22
|
+
t.string :actor_key, null: false
|
|
23
|
+
t.string :authority, null: false
|
|
24
|
+
t.datetime :accepted_at, null: false
|
|
25
|
+
t.timestamps
|
|
26
|
+
|
|
27
|
+
t.index %i[agreement_version_id subject_key],
|
|
28
|
+
unique: true,
|
|
29
|
+
name: "index_agreements_acceptances_on_version_and_subject"
|
|
30
|
+
t.index :subject_key
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agreements
|
|
4
|
+
module Generators
|
|
5
|
+
module MigrationHelpers
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def migration_version
|
|
9
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def primary_key_type_option
|
|
13
|
+
type = primary_key_type
|
|
14
|
+
type ? ", id: :#{type}" : ""
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reference_type_option
|
|
18
|
+
type = primary_key_type
|
|
19
|
+
type ? ", type: :#{type}" : ""
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def primary_key_type
|
|
23
|
+
config = Rails.configuration.generators
|
|
24
|
+
config.options[config.orm][:primary_key_type]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: agreements
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yaroslav Shmarov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
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
|
+
description: |
|
|
33
|
+
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.
|
|
38
|
+
email:
|
|
39
|
+
- yashm@outlook.com
|
|
40
|
+
executables: []
|
|
41
|
+
extensions: []
|
|
42
|
+
extra_rdoc_files: []
|
|
43
|
+
files:
|
|
44
|
+
- CHANGELOG.md
|
|
45
|
+
- MIT-LICENSE
|
|
46
|
+
- README.md
|
|
47
|
+
- SECURITY.md
|
|
48
|
+
- app/models/agreements/acceptance.rb
|
|
49
|
+
- app/models/agreements/application_record.rb
|
|
50
|
+
- app/models/agreements/version.rb
|
|
51
|
+
- lib/agreements.rb
|
|
52
|
+
- lib/agreements/enforcement.rb
|
|
53
|
+
- lib/agreements/engine.rb
|
|
54
|
+
- lib/agreements/errors.rb
|
|
55
|
+
- lib/agreements/identity.rb
|
|
56
|
+
- lib/agreements/recorder.rb
|
|
57
|
+
- lib/agreements/version.rb
|
|
58
|
+
- lib/generators/agreements/install/install_generator.rb
|
|
59
|
+
- lib/generators/agreements/install/templates/create_agreements_tables.rb.tt
|
|
60
|
+
- lib/generators/agreements/migration_helpers.rb
|
|
61
|
+
homepage: https://github.com/yshmarov/agreements
|
|
62
|
+
licenses:
|
|
63
|
+
- MIT
|
|
64
|
+
metadata:
|
|
65
|
+
homepage_uri: https://github.com/yshmarov/agreements
|
|
66
|
+
source_code_uri: https://github.com/yshmarov/agreements/tree/main
|
|
67
|
+
changelog_uri: https://github.com/yshmarov/agreements/blob/main/CHANGELOG.md
|
|
68
|
+
bug_tracker_uri: https://github.com/yshmarov/agreements/issues
|
|
69
|
+
rubygems_mfa_required: 'true'
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '3.2'
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 4.0.10
|
|
85
|
+
specification_version: 4
|
|
86
|
+
summary: Auditable acceptance of externally hosted legal agreements for Rails.
|
|
87
|
+
test_files: []
|