clickwrap 0.3.1 → 0.3.2

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: 55f77c92e4ac5400a7c7fba781bd2a592875f3deef59fd8b3c1d6269e302ffc6
4
- data.tar.gz: f15af7f288d8349bc8cd322b28b472e68bca750a8c17718648d7fa2db3877021
3
+ metadata.gz: 1ea29dba21d2740876c9708ad687520310951911eab737844b59577fbbe24464
4
+ data.tar.gz: 01140025f14f2c137ba35a25ab694898d31fdbc719a42a488d63204007b0dc85
5
5
  SHA512:
6
- metadata.gz: fceb30a214fe8e4cdfcc19a775f8082fefe61424eba2e463958240dec07a0c8d099e1d3c6e5e0b9d67bd47eb11a445ee591f4b520c08f847479fbed277f197f2
7
- data.tar.gz: e8d53673c7a5d9ba553078ace895a187e404b6a486525ee56d362f584e262f3bb9f9000287bf57311b22a40e2049140867f5eefdb300e945a7d69d25ff31dcf4
6
+ metadata.gz: 752ed3dda0897564843222843c444a9c5c945493152ec04e697241871afda8df0eeea9635b3f07296a392f58c46abe763366daa759f0916cd5bb455f56a04e01
7
+ data.tar.gz: 71e5c6512a165734f941b7e5483b399e8d46e83a3963e05af5ae2d3e077dbcf4eac0b0e7eddebc9ff61b7a9d3b73c551502fb434388c65c1830e2b75a6e0add1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.2] - 2026-08-20
10
+
11
+ ### Fixed — two guards that looked like they held
12
+
13
+ - **Canonical JSON refuses invalid bytes whatever their encoding tag.**
14
+ `valid_encoding?` is always true on an `ASCII-8BIT` string — BINARY has no
15
+ invalid byte sequences by definition — and BINARY is exactly what Rack and
16
+ CDN headers deliver, so the UTF-8 guard was a no-op for the ten
17
+ request-evidence values most likely to be malformed. Invalid bytes passed
18
+ through and produced canonical JSON that was not itself valid UTF-8, which
19
+ RFC 8785 forbids and a verifier in another language may reject or normalize
20
+ into a different digest. The guard now normalizes the tag before validating.
21
+ **No digest ever written changes**: bytes that are valid UTF-8 canonicalize
22
+ byte-identically whether they arrive tagged BINARY or UTF-8, which a test
23
+ pins. And a stored value that can no longer be canonicalized now reports a
24
+ binding mismatch instead of raising out of the integrity check — a check
25
+ that crashes tells an operator nothing except that the tool broke.
26
+ - **`record_ip_geolocation(country: nil)` is refused instead of quietly
27
+ enabling three fields.** With plain `nil` keyword defaults an explicit nil
28
+ was indistinguishable from an omitted keyword, so a policy written as
29
+ `record_ip_geolocation(country: settings[:geo])` with an empty setting fell
30
+ through to the coarse-trio default — enabling a category of personal data as
31
+ a side effect, which the frictionless pass never relaxed. A sentinel now
32
+ tells the two apart: unmentioned still gets the coarse trio, `false` still
33
+ reaches the coherence check that names `do_not_record_ip_geolocation`, and
34
+ `nil` raises a sentence.
35
+ - **A scaffolding `legal_basis_reference:` is refused like a scaffolding
36
+ `because:`.** The reference lands in the compiled policy revision and every
37
+ receipt built from it, permanently, where "TODO: ask legal" reads as a
38
+ reviewed determination rather than an omission. The option stays optional;
39
+ text the host actually wrote has to be text they meant.
40
+
41
+ ### Documentation
42
+
43
+ - The installer's purpose and retention prompts now describe what the
44
+ installer actually does since 0.3.x: a blank purpose is accepted (the gem
45
+ records its own stated purpose, marked as the gem's), a blank period keeps
46
+ pace with the evidence it corroborates, and only scaffolding text or a
47
+ negative period stops generation.
48
+
9
49
  ## [0.3.1] - 2026-08-20
10
50
 
11
51
  ### Changed — the rest of the collection friction, and the principle behind removing it
data/guides/migrating.md CHANGED
@@ -333,3 +333,26 @@ never satisfy a human-action predicate.)
333
333
  | [FinePrint README at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25) | Pinned source code |
334
334
  | [FinePrint signature model at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33) | Pinned source code |
335
335
  | The never-synthesize rule, the `unknown:` vocabulary, and the dry-run-first workflow | Product-design inference |
336
+
337
+ ## Completeness: enumerate writers, not rows
338
+
339
+ A migration inventory built by looking at what the database HOLDS will miss
340
+ what the code can WRITE. One production integration classified 52 distinct
341
+ audit event types from its data — the codebase carried ~150: everything rare
342
+ (incident and recovery paths that had never fired) and everything new
343
+ (writers added after the cutover) was invisible to the data-only sweep, and
344
+ one of the missed stores turned out to hold the product's most consequential
345
+ assent act.
346
+
347
+ Sweep three ways, and reconcile the lists:
348
+
349
+ 1. **Schema**: every table and column whose name says assent — accepted,
350
+ declared, consent, attest, authoriz, acknowledg, signed.
351
+ 2. **Writers**: every place code can create such a record — grep the literals
352
+ (`event_type:`, ledger inserts), not the rows.
353
+ 3. **Structure**: every model already carrying `has_clickwrap_evidence`, and
354
+ every dual-write site.
355
+
356
+ Then write a disposition for every entry — import it, "not a clickwrap" with
357
+ the reason, or "provider record" — in a tracked document. Silently ignoring a
358
+ store is not a valid disposition.
@@ -575,3 +575,27 @@ can never undo or stand in for the Clickwrap event. Clickwrap ships no Footprint
575
575
  | [Trackdown v0.4.0 result object](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/location_result.rb), [configuration](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/configuration.rb), [Cloudflare provider](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/providers/cloudflare_provider.rb), [MaxMind provider](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/providers/maxmind_provider.rb), [PR #9](https://github.com/rameerez/trackdown/pull/9) | Pinned released source and project change record |
576
576
  | [Footprinted pinned model](https://github.com/rameerez/footprinted/blob/03b714bd3fa31368a8ce6695433386128fb6f91c/lib/footprinted/footprint.rb#L7-L52), [tracking concern](https://github.com/rameerez/footprinted/blob/03b714bd3fa31368a8ce6695433386128fb6f91c/lib/footprinted/model.rb#L7-L65) | Pinned source code |
577
577
  | The field selection, the ordering of evidentiary priority, and every API prescription above | Product-design inference |
578
+
579
+ ## Rendering recorded values: encodings survive encryption
580
+
581
+ Rack hands header strings to Ruby tagged `ASCII-8BIT`, and encrypted
582
+ attributes preserve the original encoding through decryption — so a recorded
583
+ `browser_user_agent` read back from the annex is BINARY-tagged even when its
584
+ bytes are perfectly valid UTF-8. Concatenating it into a UTF-8 view buffer
585
+ raises `Encoding::CompatibilityError`, and because only live captures carry
586
+ an annex, the failure hides until the first real signup reaches an admin
587
+ screen (it took down a production operator panel exactly that way).
588
+
589
+ Normalize at the render boundary, not in storage — the stored bytes are the
590
+ evidence:
591
+
592
+ ```ruby
593
+ # clickwrap-doc-test: syntax-only — a host view helper, not gem configuration
594
+ def rendered_recorded_text(value)
595
+ utf8 = value.to_s.dup.force_encoding(Encoding::UTF_8)
596
+ utf8.valid_encoding? ? utf8 : utf8.scrub
597
+ end
598
+ ```
599
+
600
+ The gem's own screens never render raw annex values, so they are not exposed;
601
+ any host view that does — an operator panel, an export page — is.
@@ -132,10 +132,26 @@ module Clickwrap
132
132
  end
133
133
 
134
134
  def write_string(string, buffer)
135
- raise SerializationError, "Canonical JSON strings must be valid UTF-8" unless string.valid_encoding?
135
+ # `valid_encoding?` is ALWAYS true on an ASCII-8BIT string — BINARY has
136
+ # no invalid byte sequences by definition — and BINARY is exactly what
137
+ # Rack and CDN headers deliver, so this guard was a no-op for the
138
+ # strings most likely to be malformed. Ten request-evidence columns
139
+ # reach it.
140
+ #
141
+ # Normalizing the tag before validating is safe for every digest ever
142
+ # written: bytes that ARE valid UTF-8 canonicalize byte-identically
143
+ # whether they arrive tagged BINARY or UTF-8 (measured, and pinned by a
144
+ # test below). What changes is only the case that was broken —
145
+ # genuinely invalid bytes used to emit canonical JSON that was itself
146
+ # not valid UTF-8, which RFC 8785 forbids and a verifier in another
147
+ # language may reject or normalize into a different digest. That is the
148
+ # "still verifiable years later" promise failing silently, so it is
149
+ # refused at write time instead.
150
+ utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
151
+ raise SerializationError, "Canonical JSON strings must be valid UTF-8" unless utf8.valid_encoding?
136
152
 
137
153
  buffer << '"'
138
- string.each_char do |char|
154
+ utf8.each_char do |char|
139
155
  escape = ESCAPES[char]
140
156
  buffer << if escape
141
157
  escape
@@ -257,9 +257,21 @@ module Clickwrap
257
257
  # and nothing finer, because that is what "IP geolocation" means in this
258
258
  # gem when nobody narrows it. Naming even one field means you are
259
259
  # choosing the set yourself, and then the set is exactly what you named.
260
- def record_ip_geolocation(country: nil, region: nil, city: nil, postal_code: nil,
261
- latitude_and_longitude: nil, timezone: nil, continent: nil,
262
- metro_code: nil, accuracy_radius_in_kilometers: nil,
260
+ # Distinguishes "the policy never mentioned this field" from "the policy
261
+ # passed nil for it". With plain `nil` defaults the two are identical, and
262
+ # `record_ip_geolocation(country: settings[:geo])` with an empty setting
263
+ # silently enabled the coarse trio — enabling a category of personal data
264
+ # as a side effect, which is the one thing the frictionless pass never
265
+ # relaxed.
266
+ UNMENTIONED_FIELD = Object.new.freeze
267
+ private_constant :UNMENTIONED_FIELD
268
+
269
+ def record_ip_geolocation(country: UNMENTIONED_FIELD, region: UNMENTIONED_FIELD,
270
+ city: UNMENTIONED_FIELD, postal_code: UNMENTIONED_FIELD,
271
+ latitude_and_longitude: UNMENTIONED_FIELD,
272
+ timezone: UNMENTIONED_FIELD, continent: UNMENTIONED_FIELD,
273
+ metro_code: UNMENTIONED_FIELD,
274
+ accuracy_radius_in_kilometers: UNMENTIONED_FIELD,
263
275
  using: nil, encrypted: nil, delete_after: nil, retain_until: nil,
264
276
  fail_if_unavailable: false, because: nil,
265
277
  legal_basis_reference: nil,
@@ -415,14 +427,28 @@ module Clickwrap
415
427
  end
416
428
  end
417
429
 
418
- # `nil` means "the policy did not mention this field"; `false` means "the
419
- # policy named it and turned it off". The distinction is the whole reason
420
- # the keywords default to nil: a policy that mentions nothing gets the
421
- # coarse trio, and a policy that explicitly sets every field to false
422
- # still reaches the coherence check that tells it to say
430
+ # An UNMENTIONED field means "the policy did not mention this"; `false`
431
+ # means "the policy named it and turned it off". A policy that mentions
432
+ # nothing gets the coarse trio; a policy that explicitly sets every field
433
+ # to false still reaches the coherence check that tells it to say
423
434
  # `do_not_record_ip_geolocation` instead.
435
+ #
436
+ # An explicit `nil` is neither, and is refused rather than guessed: it is
437
+ # almost always a variable that came out empty, and treating it as "the
438
+ # policy said nothing" would turn a missing setting into three enabled
439
+ # fields of personal data.
424
440
  def default_ip_geolocation_fields_when_none_named(named)
425
- return named.transform_values { |value| value == true } if named.any? { |_, value| !value.nil? }
441
+ ambiguous = named.select { |_, value| value.nil? }.keys
442
+ unless ambiguous.empty?
443
+ raise DefinitionError,
444
+ "Policy #{@key} passes nil for #{ambiguous.join(", ")} in " \
445
+ "`record_ip_geolocation`. Say `true` or `false` for each field you name, " \
446
+ "or leave it out entirely — Clickwrap will not read an empty value as " \
447
+ "permission to record it, and it will not read it as silence either."
448
+ end
449
+
450
+ mentioned = named.reject { |_, value| value.equal?(UNMENTIONED_FIELD) }
451
+ return named.transform_values { |value| value == true } if mentioned.any?
426
452
 
427
453
  Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
428
454
  [field, Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.include?(field)]
@@ -302,11 +302,18 @@ module Clickwrap
302
302
  key = Clickwrap.config.request_evidence_binding_key_for(key_id)
303
303
  return false if key.nil?
304
304
 
305
- computed = Digest.keyed_digest(
306
- CanonicalJson.generate(binding_body_for(category)),
307
- key: key,
308
- algorithm: digest_algorithm
309
- )
305
+ # A stored value that can no longer be canonicalized cannot reproduce its
306
+ # binding, and saying so is the honest answer — an integrity check that
307
+ # raises tells an operator nothing except that the tool broke.
308
+ begin
309
+ computed = Digest.keyed_digest(
310
+ CanonicalJson.generate(binding_body_for(category)),
311
+ key: key,
312
+ algorithm: digest_algorithm
313
+ )
314
+ rescue CanonicalJson::SerializationError
315
+ return false
316
+ end
310
317
  Digest.secure_compare?(computed, digest)
311
318
  end
312
319
 
@@ -229,6 +229,20 @@ module Clickwrap
229
229
  "purpose."
230
230
  end
231
231
 
232
+ # The legal-basis reference goes into the compiled policy revision and
233
+ # every receipt built from it, permanently. Scaffolding there is worse
234
+ # than an omission: an omission reads as "the host said nothing", while
235
+ # "TODO: ask legal" reads as a reviewed determination to anyone who finds
236
+ # it later. Same rule as `because:` — the option is optional, but text
237
+ # the host actually wrote has to be text they meant.
238
+ if ReviewedText.placeholder?(setting.legal_basis_reference)
239
+ raise DefinitionError,
240
+ "Policy #{policy_key} records #{category} with a `legal_basis_reference:` " \
241
+ "that is still scaffolding text (#{setting.legal_basis_reference.inspect}). " \
242
+ "Replace it with the application's own reference, or drop the option — " \
243
+ "Clickwrap would rather record nothing than record a TODO as a legal basis."
244
+ end
245
+
232
246
  return unless setting.delete_after && setting.delete_after.to_i <= 0
233
247
 
234
248
  raise DefinitionError,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clickwrap
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
 
6
6
  # The canonical schema version for receipts, event digests, and presentation
7
7
  # manifests. This is deliberately independent of VERSION: gem releases may
@@ -980,15 +980,18 @@ module Clickwrap
980
980
 
981
981
  def ask_purpose(label)
982
982
  say "\n Why does the application need #{label}? One plain sentence, in your own"
983
- say " words — it goes into the initializer and the privacy inventory. A blank"
984
- say " or scaffolding answer stops generation before Clickwrap writes any files."
983
+ say " words — it goes into the initializer and the privacy inventory. Leave it"
984
+ say " blank and Clickwrap records its own stated purpose instead, marked as the"
985
+ say " gem's; a scaffolding answer (TODO, FIXME) stops generation before any"
986
+ say " files are written."
985
987
  ask(" Purpose:").to_s.strip
986
988
  end
987
989
 
988
990
  def ask_retention_days(label)
989
991
  say "\n After how many days should Clickwrap delete #{label}?"
990
- say " Clickwrap does not invent a period. Enter the positive number your application"
991
- say " has reviewed; a blank or zero answer stops generation before files are written."
992
+ say " Enter the number of days your application reviewed, or leave it blank and"
993
+ say " #{label} keeps pace with the evidence it corroborates kept until a"
994
+ say " reviewed disposition removes it. A negative number stops generation."
992
995
  ask(" Days:").to_s.strip.to_i
993
996
  end
994
997
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickwrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez