clickwrap 0.3.3 → 0.3.4
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 +43 -0
- data/README.md +14 -0
- data/guides/consent-and-lifecycle.md +1 -0
- data/lib/clickwrap/actor_proxy.rb +43 -0
- data/lib/clickwrap/document_versions_in_force.rb +48 -0
- data/lib/clickwrap/models/document.rb +1 -7
- data/lib/clickwrap/models/document_version.rb +11 -0
- data/lib/clickwrap/models/statement_state.rb +20 -0
- data/lib/clickwrap/verification.rb +1 -18
- data/lib/clickwrap/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a3a937701ee11dbf64dc080a8299fb1130210c6776b8693f79e8ed8d42a3fd8
|
|
4
|
+
data.tar.gz: 52aec9d2d61684846076dbf8250530c73246da207533cfcc032855004b5a8680
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 170382234eebd4e10dea7d3248d8e829d0db40f764441ad264335d0b73dcb5806b71cf03078a1ed582053ff3e5b971d7f8b8309e56df0845f77f38c63346cc52
|
|
7
|
+
data.tar.gz: 7be06ef7af2f171fbcde39759f19dd81096307e095e60944df357c2aba58f8614b17c1335e8efa26a78fbb459aafb3a36baef4b479ac0513fdb66fc55420d6c0
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.4] - 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Added — ask whether the Terms in force were agreed, under any policy
|
|
12
|
+
|
|
13
|
+
- **`user.clickwraps.agreed_to_current_version_of?(:terms)`.** An invoicing
|
|
14
|
+
app on the fleet records the Terms agreement under two policies, signup and
|
|
15
|
+
re-acceptance, and had to write this query itself, because `current_for?`
|
|
16
|
+
reads one policy and `agreed_to?` ignores versions. The predicate is true
|
|
17
|
+
when a satisfied agreement to that statement, under any policy, recorded
|
|
18
|
+
the version in force now of each document the statement binds. A version
|
|
19
|
+
with a later `effective_at` counts only from that time. Until then, the
|
|
20
|
+
agreement to the version in force still counts. No agreement is false, and
|
|
21
|
+
so is an agreement under a policy that is no longer declared. It takes the
|
|
22
|
+
`subject:`, `tenant:` and `acting_for:` keywords of `agreed_to?`, with the
|
|
23
|
+
same defaults.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
requires_clickwrap :current_terms,
|
|
27
|
+
unless: -> { current_user.clickwraps.agreed_to_current_version_of?(:terms) }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Fixed — tenant evidence re-asks when the shared document moves on
|
|
31
|
+
|
|
32
|
+
- **`require_current_version: true` never re-asked evidence recorded in a
|
|
33
|
+
tenant that was offered the shared document.** The presenter offers a
|
|
34
|
+
tenant its own document and falls back to the shared one. The staleness
|
|
35
|
+
check looked only for the tenant's own document, found none, and answered
|
|
36
|
+
"current" whatever version was in force. The check now resolves the
|
|
37
|
+
document the way the presenter does, so a new shared version re-asks that
|
|
38
|
+
evidence. Evidence against a tenant's own document is unchanged.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- The "is this version current?" rule moved from a private method of
|
|
43
|
+
`Clickwrap::Verification` to `StatementState#document_key_with_unseen_current_version`.
|
|
44
|
+
`require_current_version: true` and the new predicate call the same method,
|
|
45
|
+
so they cannot disagree.
|
|
46
|
+
- The rule reads version ids only, through `Clickwrap::DocumentVersionsInForce`,
|
|
47
|
+
and never loads document bytes. The predicate looks up each document once
|
|
48
|
+
per call, however many policies recorded the agreement.
|
|
49
|
+
`Document#current_version` and this lookup share one scope,
|
|
50
|
+
`DocumentVersion.presentable_newest_first`.
|
|
51
|
+
|
|
9
52
|
## [0.3.3] - 2026-08-31
|
|
10
53
|
|
|
11
54
|
### Fixed — the actor class survives a reload
|
data/README.md
CHANGED
|
@@ -569,6 +569,7 @@ The actor proxy is the everyday API:
|
|
|
569
569
|
```ruby
|
|
570
570
|
user.clickwraps.current_for?(:signup)
|
|
571
571
|
user.clickwraps.agreed_to?(:terms)
|
|
572
|
+
user.clickwraps.agreed_to_current_version_of?(:terms)
|
|
572
573
|
user.clickwraps.consented_to?(:product_updates)
|
|
573
574
|
user.clickwraps.declared?(:independent_contractor, subject: scheme)
|
|
574
575
|
user.clickwraps.authorized?(:withdrawal, subject: withdrawal)
|
|
@@ -623,6 +624,17 @@ Publish a new version and `current_for?` flips to `false` for everyone who accep
|
|
|
623
624
|
bin/rails clickwrap:reacceptance:plan POLICY=current_terms
|
|
624
625
|
```
|
|
625
626
|
|
|
627
|
+
`current_for?(:current_terms)` reads only the evidence recorded under `:current_terms`. A person who signed up an hour ago agreed to the Terms in force under `:signup`, and has nothing under `:current_terms` yet. A gate on that policy alone sends them back to accept the same text again. Ask across every policy that records the agreement instead:
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
class DashboardController < ApplicationController
|
|
631
|
+
requires_clickwrap :current_terms,
|
|
632
|
+
unless: -> { current_user.clickwraps.agreed_to_current_version_of?(:terms) }
|
|
633
|
+
end
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
`agreed_to_current_version_of?` applies the rule `require_current_version: true` applies. A version published with a later `effective_at` counts only from that time, so the gate starts on the effective date and not on the deploy that gives notice. Until then, the agreement to the version in force still counts. It takes the `subject:`, `tenant:` and `acting_for:` keywords of `agreed_to?`, with the same defaults. Like `agreed_to?`, it reads the current-state projection; `Clickwrap.verify` is the call that rechecks the evidence itself.
|
|
637
|
+
|
|
626
638
|
## Consent that can actually be withdrawn
|
|
627
639
|
|
|
628
640
|
Consent is purpose-specific, initially unselected, and separate from Terms:
|
|
@@ -1015,6 +1027,8 @@ class ApplicationController < ActionController::Base
|
|
|
1015
1027
|
end
|
|
1016
1028
|
```
|
|
1017
1029
|
|
|
1030
|
+
A person who accepted the Terms in force at signup did so under `:signup`, not under `:current_terms`. Let them through as [Reacceptance when documents change](#reacceptance-when-documents-change) shows.
|
|
1031
|
+
|
|
1018
1032
|
Publish the new version and every signed-in user gets redirected to the acceptance screen on their next request — and sent back to wherever they were going the moment they accept. Preview the blast radius before you activate it:
|
|
1019
1033
|
|
|
1020
1034
|
```bash
|
|
@@ -256,6 +256,7 @@ way past — it is the shape of the code.
|
|
|
256
256
|
user.clickwraps.current_for?(:signup)
|
|
257
257
|
user.clickwraps.required_for?(:current_terms)
|
|
258
258
|
user.clickwraps.agreed_to?(:terms)
|
|
259
|
+
user.clickwraps.agreed_to_current_version_of?(:terms) # under any policy
|
|
259
260
|
user.clickwraps.acknowledged?(:privacy_notice)
|
|
260
261
|
user.clickwraps.consented_to?(:product_updates)
|
|
261
262
|
user.clickwraps.declared?(:independent_contractor, subject: scheme)
|
|
@@ -34,6 +34,37 @@ module Clickwrap
|
|
|
34
34
|
satisfied?("agreement", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Whether the actor agreed to the version in force now of each document
|
|
38
|
+
# this statement binds, under ANY policy that records the statement.
|
|
39
|
+
#
|
|
40
|
+
# Neither neighbour answers this. `agreed_to?` reads the newest agreement
|
|
41
|
+
# and ignores versions. `current_for?` checks versions inside one policy
|
|
42
|
+
# only. A signup form and a re-acceptance screen usually record the same
|
|
43
|
+
# `agree_to :terms` under two policies, so a person who signed up an hour
|
|
44
|
+
# ago has the version in force under `:signup` and nothing under
|
|
45
|
+
# `:current_terms`. A gate on `current_for?(:current_terms)` alone sends
|
|
46
|
+
# that person back to accept the same text again.
|
|
47
|
+
#
|
|
48
|
+
# "In force" is the rule `require_current_version: true` applies: a version
|
|
49
|
+
# with a later `effective_at` counts only from that time, and until then
|
|
50
|
+
# the acceptance of the version it replaces still counts. An agreement that
|
|
51
|
+
# no longer stands does not count. No agreement at all is false.
|
|
52
|
+
#
|
|
53
|
+
# The scope keywords mean what they mean on `agreed_to?`, and so do their
|
|
54
|
+
# defaults: no subject, no tenant, not acting for anyone. That default is
|
|
55
|
+
# where a `tenant_is :not_applicable` policy records its evidence.
|
|
56
|
+
def agreed_to_current_version_of?(statement_key, subject: nil, tenant: nil, acting_for: nil)
|
|
57
|
+
# Each policy that recorded the agreement usually binds the same
|
|
58
|
+
# documents, so their versions in force are looked up once per call.
|
|
59
|
+
versions_in_force = DocumentVersionsInForce.new
|
|
60
|
+
|
|
61
|
+
states(subject: subject, tenant: tenant, acting_for: acting_for)
|
|
62
|
+
.for_statement(statement_key)
|
|
63
|
+
.where(kind: "agreement")
|
|
64
|
+
.order(effective_at: :desc)
|
|
65
|
+
.any? { |state| state.satisfies? && agreed_to_current_versions?(state, versions_in_force) }
|
|
66
|
+
end
|
|
67
|
+
|
|
37
68
|
def acknowledged?(statement_key, subject: nil, tenant: nil, acting_for: nil)
|
|
38
69
|
satisfied?("acknowledgment", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
|
|
39
70
|
end
|
|
@@ -105,6 +136,18 @@ module Clickwrap
|
|
|
105
136
|
)
|
|
106
137
|
end
|
|
107
138
|
|
|
139
|
+
# The documents come from the statement as its policy declares it now,
|
|
140
|
+
# exactly as `require_current_version` reads them. Evidence under a policy
|
|
141
|
+
# that is no longer declared has nothing to check against, and "cannot
|
|
142
|
+
# check" is never "yes".
|
|
143
|
+
def agreed_to_current_versions?(state, versions_in_force)
|
|
144
|
+
statement = Clickwrap.policies[state.policy_key]&.statement(state.statement_key)
|
|
145
|
+
return false unless statement
|
|
146
|
+
|
|
147
|
+
state.document_key_with_unseen_current_version(statement.document_keys,
|
|
148
|
+
versions_in_force: versions_in_force).nil?
|
|
149
|
+
end
|
|
150
|
+
|
|
108
151
|
def satisfied?(kind, statement_key, subject:, tenant:, acting_for:)
|
|
109
152
|
state = state_for(kind, statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
|
|
110
153
|
!state.nil? && state.satisfies?
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The id of the version in force of each document: the version
|
|
5
|
+
# `Document#current_version` returns, for one locale at one moment.
|
|
6
|
+
#
|
|
7
|
+
# A re-acceptance gate asks this on every protected write, once for each
|
|
8
|
+
# policy that recorded the agreement. Those policies usually bind the same
|
|
9
|
+
# documents, so each document is looked up once and the answer is kept for
|
|
10
|
+
# the life of this object. The lookup reads ids only, because a version row
|
|
11
|
+
# also holds the document's bytes and this question needs none of them.
|
|
12
|
+
#
|
|
13
|
+
# A tenant's own document wins over the shared one, as the presenter chooses
|
|
14
|
+
# them. Evidence recorded in a tenant that was offered the shared document
|
|
15
|
+
# is therefore compared with the shared document's version in force.
|
|
16
|
+
class DocumentVersionsInForce
|
|
17
|
+
def initialize(locale: I18n.locale, at: Clickwrap.now)
|
|
18
|
+
@locale = locale
|
|
19
|
+
@at = at
|
|
20
|
+
@ids = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# nil when the document is not published or has no version in force.
|
|
24
|
+
def id_for(document_key, tenant_key: nil)
|
|
25
|
+
key = [document_key.to_s, tenant_key.presence]
|
|
26
|
+
return @ids[key] if @ids.key?(key)
|
|
27
|
+
|
|
28
|
+
@ids[key] = look_up(*key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def look_up(document_key, tenant_key)
|
|
34
|
+
document_id = document_id_for(document_key, tenant_key)
|
|
35
|
+
return nil unless document_id
|
|
36
|
+
|
|
37
|
+
DocumentVersion.where(document_id: document_id).presentable_newest_first(@at, @locale).pick(:id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def document_id_for(document_key, tenant_key)
|
|
41
|
+
ids = Document.where(document_key: document_key, tenant_key: [tenant_key, nil].uniq)
|
|
42
|
+
.pluck(:tenant_key, :id)
|
|
43
|
+
.to_h
|
|
44
|
+
|
|
45
|
+
ids.fetch(tenant_key) { ids[nil] }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -28,13 +28,7 @@ module Clickwrap
|
|
|
28
28
|
# always writes an `effective_at`, so no NULL reaches this comparison and
|
|
29
29
|
# PostgreSQL and SQLite agree about which document the server offered.
|
|
30
30
|
def current_version(locale: I18n.locale, at: Clickwrap.now)
|
|
31
|
-
versions
|
|
32
|
-
.published
|
|
33
|
-
.effective_at_or_before(at)
|
|
34
|
-
.not_retired_at(at)
|
|
35
|
-
.for_locale(locale)
|
|
36
|
-
.order(effective_at: :desc, published_at: :desc, created_at: :desc)
|
|
37
|
-
.first
|
|
31
|
+
versions.presentable_newest_first(at, locale).first
|
|
38
32
|
end
|
|
39
33
|
|
|
40
34
|
def version(label, locale: I18n.locale)
|
|
@@ -34,6 +34,17 @@ module Clickwrap
|
|
|
34
34
|
scope :effective_at_or_before, ->(moment) { where(effective_at: ..moment).or(where(effective_at: nil)) }
|
|
35
35
|
scope :not_retired_at, ->(moment) { where(retired_at: nil).or(where(retired_at: moment...)) }
|
|
36
36
|
|
|
37
|
+
# The versions a policy may present at `moment` in `locale`, the one to
|
|
38
|
+
# present first. `Document#current_version` and
|
|
39
|
+
# `DocumentVersionsInForce` both read this scope, so they cannot disagree.
|
|
40
|
+
scope :presentable_newest_first, lambda { |moment, locale|
|
|
41
|
+
published
|
|
42
|
+
.effective_at_or_before(moment)
|
|
43
|
+
.not_retired_at(moment)
|
|
44
|
+
.for_locale(locale)
|
|
45
|
+
.order(effective_at: :desc, published_at: :desc, created_at: :desc)
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
# Publishing freezes content. Editing a published version is refused here
|
|
38
49
|
# rather than in a code review, because the whole promise of this table is
|
|
39
50
|
# that its rows do not change.
|
|
@@ -86,6 +86,26 @@ module Clickwrap
|
|
|
86
86
|
true
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# The first of these documents whose version in force now is not one this
|
|
90
|
+
# state recorded, or nil when this state recorded each of them. "In force"
|
|
91
|
+
# is `Document#current_version` for the request's locale, with this
|
|
92
|
+
# state's tenant document first and the shared document after it, as the
|
|
93
|
+
# presenter offered them. So a version published with a later
|
|
94
|
+
# `effective_at` changes nothing before that time, and a document with no
|
|
95
|
+
# version in force changes nothing either. `require_current_version: true`
|
|
96
|
+
# and `agreed_to_current_version_of?` both ask this method, so a gate and
|
|
97
|
+
# a predicate never disagree about which version is current. Pass one
|
|
98
|
+
# `versions_in_force` to several states to look each document up once.
|
|
99
|
+
def document_key_with_unseen_current_version(document_keys, versions_in_force: DocumentVersionsInForce.new)
|
|
100
|
+
recorded = Array(document_version_ids).map(&:to_s)
|
|
101
|
+
|
|
102
|
+
Array(document_keys).find do |document_key|
|
|
103
|
+
current_id = versions_in_force.id_for(document_key, tenant_key: tenant_key)
|
|
104
|
+
|
|
105
|
+
current_id && !recorded.include?(current_id.to_s)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
89
109
|
# Why it does not, as one of the stable error symbols applications branch
|
|
90
110
|
# on. Never an English string: an authorization decision should not depend
|
|
91
111
|
# on parsing a message.
|
|
@@ -218,7 +218,7 @@ module Clickwrap
|
|
|
218
218
|
end
|
|
219
219
|
|
|
220
220
|
if statement.requires_current_version?
|
|
221
|
-
stale =
|
|
221
|
+
stale = state.document_key_with_unseen_current_version(statement.document_keys)
|
|
222
222
|
|
|
223
223
|
if stale
|
|
224
224
|
return Result.failure(:unseen_document_version,
|
|
@@ -482,23 +482,6 @@ module Clickwrap
|
|
|
482
482
|
value = statement.subject_fingerprint_with.call(subject)
|
|
483
483
|
value.nil? ? nil : Digest.digest(value.to_s)
|
|
484
484
|
end
|
|
485
|
-
|
|
486
|
-
# Returns the document key whose current published version is newer than
|
|
487
|
-
# the one this evidence was captured against, or nil when everything the
|
|
488
|
-
# statement requires is still current.
|
|
489
|
-
def stale_document?(statement, state)
|
|
490
|
-
recorded = Array(state.document_version_ids).map(&:to_s)
|
|
491
|
-
|
|
492
|
-
statement.document_keys.find do |document_key|
|
|
493
|
-
document = ::Clickwrap::Document.find_by(
|
|
494
|
-
document_key: document_key,
|
|
495
|
-
tenant_key: state.tenant_key.presence
|
|
496
|
-
)
|
|
497
|
-
current = document&.current_version(locale: I18n.locale)
|
|
498
|
-
|
|
499
|
-
current && !recorded.include?(current.id.to_s)
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
485
|
end
|
|
503
486
|
end
|
|
504
487
|
end
|
data/lib/clickwrap/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clickwrap
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-24 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: actionpack
|
|
@@ -217,6 +217,7 @@ files:
|
|
|
217
217
|
- lib/clickwrap/document_renderer.rb
|
|
218
218
|
- lib/clickwrap/document_renderers/markdown.rb
|
|
219
219
|
- lib/clickwrap/document_renderers/markdown_rails.rb
|
|
220
|
+
- lib/clickwrap/document_versions_in_force.rb
|
|
220
221
|
- lib/clickwrap/dsl/policy_builder.rb
|
|
221
222
|
- lib/clickwrap/dsl/retention_builder.rb
|
|
222
223
|
- lib/clickwrap/durable_commit_callback.rb
|