clickwrap 0.0.0 → 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 +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Clickwrap
|
|
7
|
+
module Generators
|
|
8
|
+
# Links a domain table to the clickwrap evidence that authorized its rows:
|
|
9
|
+
#
|
|
10
|
+
# bin/rails generate clickwrap:link payouts_withdrawals
|
|
11
|
+
#
|
|
12
|
+
# writes the migration for a `clickwrap_event_id` column (ULID string,
|
|
13
|
+
# indexed, nullable, no foreign key — see the migration's own comment for
|
|
14
|
+
# why each of those is deliberate). Pair it with `has_clickwrap_evidence`
|
|
15
|
+
# on the model and assign `pending_receipt.event_id` inside
|
|
16
|
+
# `capture_clickwrap_and!`.
|
|
17
|
+
class LinkGenerator < Rails::Generators::Base
|
|
18
|
+
include ActiveRecord::Generators::Migration
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
|
|
22
|
+
argument :table_name, type: :string,
|
|
23
|
+
desc: "The domain table whose rows are authorized by a clickwrap capture"
|
|
24
|
+
|
|
25
|
+
def create_link_migration
|
|
26
|
+
migration_template "link_clickwrap_event_migration.rb.erb",
|
|
27
|
+
"db/migrate/add_clickwrap_event_to_#{table_name}.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def explain
|
|
31
|
+
say ""
|
|
32
|
+
say "Next, on the model:"
|
|
33
|
+
say ""
|
|
34
|
+
say " has_clickwrap_evidence policy: :your_policy,"
|
|
35
|
+
say " statement: :your_statement,"
|
|
36
|
+
say " actor: :user,"
|
|
37
|
+
say " subject: :self"
|
|
38
|
+
say ""
|
|
39
|
+
say "and assign the event inside the capture that authorizes the row:"
|
|
40
|
+
say ""
|
|
41
|
+
say " capture_clickwrap_and!(:your_policy) do |pending_receipt|"
|
|
42
|
+
say " record.clickwrap_event_id = pending_receipt.event_id"
|
|
43
|
+
say " record.save!"
|
|
44
|
+
say " record # returned to the protected-outcome recorder"
|
|
45
|
+
say " end"
|
|
46
|
+
say ""
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def migration_class_name
|
|
52
|
+
"AddClickwrapEventTo#{table_name.camelize}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Clickwrap
|
|
6
|
+
module Generators
|
|
7
|
+
# `rails generate clickwrap:policy contractor_declaration declare
|
|
8
|
+
# --statement-text="I declare ..."` — one compiling policy and its test.
|
|
9
|
+
#
|
|
10
|
+
# The verb and exact first-person statement are required because choosing or
|
|
11
|
+
# inventing either would silently make product/legal meaning on the host's
|
|
12
|
+
# behalf. Once they are supplied, the generated file compiles immediately;
|
|
13
|
+
# it never writes an empty policy or an executable TODO placeholder.
|
|
14
|
+
#
|
|
15
|
+
# The test comes with it for the same reason tests come with a model: a
|
|
16
|
+
# policy is executable meaning, and "does this still say what we think it
|
|
17
|
+
# says" is a question worth asking on every commit.
|
|
18
|
+
class PolicyGenerator < Rails::Generators::Base
|
|
19
|
+
source_root File.expand_path("templates", __dir__)
|
|
20
|
+
desc "Create one compiling clickwrap policy and its test"
|
|
21
|
+
|
|
22
|
+
VERBS = %w[agree_to acknowledge consent_to declare attest authorize].freeze
|
|
23
|
+
|
|
24
|
+
argument :name, type: :string, banner: "POLICY_NAME"
|
|
25
|
+
argument :verb, type: :string, banner: VERBS.join("|")
|
|
26
|
+
|
|
27
|
+
class_option :statement_text, type: :string, required: true,
|
|
28
|
+
desc: "Exact first-person assertion for the generated control"
|
|
29
|
+
class_option :statement_key, type: :string,
|
|
30
|
+
desc: "Stable statement key (defaults to the policy name)"
|
|
31
|
+
class_option :document, type: :string,
|
|
32
|
+
desc: "Declared Clickwrap document key to link (optional)"
|
|
33
|
+
class_option :retention_class, type: :string, default: "ordinary_agreement_evidence",
|
|
34
|
+
desc: "Existing Clickwrap retention class"
|
|
35
|
+
class_option :withdrawal_path, type: :string,
|
|
36
|
+
desc: "Required remediation path for consent_to"
|
|
37
|
+
|
|
38
|
+
def validate_arguments!
|
|
39
|
+
return if VERBS.include?(policy_verb) && (!consent? || withdrawal_path.present?)
|
|
40
|
+
|
|
41
|
+
unless VERBS.include?(policy_verb)
|
|
42
|
+
raise Thor::Error,
|
|
43
|
+
"#{verb.inspect} is not a Clickwrap policy verb. Choose one of: #{VERBS.join(", ")}."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
raise Thor::Error,
|
|
47
|
+
"A consent_to policy needs --withdrawal-path=/your/settings/page so the " \
|
|
48
|
+
"person has a concrete way to withdraw it."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_policy_file
|
|
52
|
+
template "policy.rb.erb", "config/clickwrap/#{policy_key}.rb"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def create_policy_test
|
|
56
|
+
template "policy_test.rb.erb", "test/clickwrap/#{policy_key}_policy_test.rb"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def display_next_steps
|
|
60
|
+
say "\n☑️ Compiling policy created.", :green
|
|
61
|
+
say "\nTo finish it:"
|
|
62
|
+
say " 1. Review the verb and exact statement in config/clickwrap/#{policy_key}.rb."
|
|
63
|
+
say " 2. If it references a document, declare and publish that document."
|
|
64
|
+
say " 3. Replace the generated test skips with your real actor and submission flow."
|
|
65
|
+
say "\nThe policy compiles at boot: a missing document, a duplicate statement key, a"
|
|
66
|
+
say "consent without a withdrawal path, or an indefinite one-time authorization is a"
|
|
67
|
+
say "startup failure with a sentence explaining it.\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def policy_key
|
|
73
|
+
name.to_s.underscore.tr("-", "_").tr("/", "_")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def policy_verb = verb.to_s
|
|
77
|
+
|
|
78
|
+
def statement_key
|
|
79
|
+
(options[:statement_key].presence || policy_key).underscore.tr("-", "_").tr("/", "_")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def statement_text = options.fetch(:statement_text).to_s
|
|
83
|
+
|
|
84
|
+
def document_key
|
|
85
|
+
key = options[:document].presence&.underscore
|
|
86
|
+
key&.tr("-", "_")&.tr("/", "_")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def retention_class_key = options.fetch(:retention_class).to_s.underscore.tr("-", "_").tr("/", "_")
|
|
90
|
+
def withdrawal_path = options[:withdrawal_path].to_s.strip
|
|
91
|
+
def consent? = policy_verb == "consent_to"
|
|
92
|
+
|
|
93
|
+
def evidence_kind
|
|
94
|
+
{
|
|
95
|
+
"agree_to" => "agreement",
|
|
96
|
+
"acknowledge" => "acknowledgment",
|
|
97
|
+
"consent_to" => "consent",
|
|
98
|
+
"declare" => "declaration",
|
|
99
|
+
"attest" => "attestation",
|
|
100
|
+
"authorize" => "authorization"
|
|
101
|
+
}.fetch(policy_verb)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def statement_options
|
|
105
|
+
options = ["statement: #{statement_text.inspect}"]
|
|
106
|
+
options.unshift("document: :#{document_key}") if document_key
|
|
107
|
+
options.unshift("document: nil") unless document_key
|
|
108
|
+
options << "optional: true" if consent?
|
|
109
|
+
options << "withdrawal_path: #{withdrawal_path.inspect}" if consent?
|
|
110
|
+
options
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_class_name
|
|
114
|
+
"#{policy_key.camelize}PolicyTest"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
# Clickwrap database hardening — OPT-IN, PostgreSQL only
|
|
5
|
+
# ==============================================================================
|
|
6
|
+
#
|
|
7
|
+
# Clickwrap's models make unsupported mutation loud. This migration adds a
|
|
8
|
+
# narrower database control for writes that bypass models: direct SQL, `delete`,
|
|
9
|
+
# `delete_all`, `update_column`, and `update_all`.
|
|
10
|
+
#
|
|
11
|
+
# WHAT THIS CLAIMS, IN FULL
|
|
12
|
+
#
|
|
13
|
+
# It rejects unsupported UPDATE and DELETE paths while these triggers are
|
|
14
|
+
# installed and the writer is not able to disable or replace them.
|
|
15
|
+
#
|
|
16
|
+
# WHAT IT DOES NOT CLAIM
|
|
17
|
+
#
|
|
18
|
+
# It does not make evidence impossible to alter. A database owner, superuser,
|
|
19
|
+
# migration role, or anyone able to drop triggers can remove this control. It
|
|
20
|
+
# does not authenticate a person, establish when an event happened, or make a
|
|
21
|
+
# local digest independent of the database that stores it.
|
|
22
|
+
#
|
|
23
|
+
# WHAT REMAINS POSSIBLE
|
|
24
|
+
#
|
|
25
|
+
# * A new event may be finalized once while its INSERT transaction is open.
|
|
26
|
+
# * Host records may be deleted, nulling only optional polymorphic pointers; the
|
|
27
|
+
# stable actor/subject/represented-party references remain unchanged.
|
|
28
|
+
# * A request-evidence annex may be linked once.
|
|
29
|
+
# * A legal-hold flag may change through Clickwrap's recorded hold lifecycle.
|
|
30
|
+
# * Core payload may be removed only when a finalized linked disposition event
|
|
31
|
+
# already identifies the event and its original digest. Child statement and
|
|
32
|
+
# document rows may be deleted only after that marker exists.
|
|
33
|
+
#
|
|
34
|
+
# Optional personal request-evidence annex values remain separately disposable
|
|
35
|
+
# in `clickwrap_request_evidence` on their reviewed schedules, so this migration
|
|
36
|
+
# does not protect that table from its own named disposition operations.
|
|
37
|
+
#
|
|
38
|
+
# Reversible: `rails db:rollback` drops only the functions and triggers created
|
|
39
|
+
# below. It changes no evidence rows.
|
|
40
|
+
# ==============================================================================
|
|
41
|
+
class ClickwrapDatabaseHardening < ActiveRecord::Migration<%= migration_version %>
|
|
42
|
+
# This is a generation-time snapshot of the runtime's named write sets. It is
|
|
43
|
+
# intentionally self-contained: a host must be able to rebuild a database
|
|
44
|
+
# from this migration after upgrading Clickwrap without asking a newer gem to
|
|
45
|
+
# reinterpret an older database contract.
|
|
46
|
+
EVENT_WRITE_SETS = {
|
|
47
|
+
<%= event_write_sets_for_migration %>
|
|
48
|
+
}.transform_values { |columns| columns.map(&:to_s).sort.freeze }.freeze
|
|
49
|
+
|
|
50
|
+
EVIDENCE_CHILD_TABLES = %w[
|
|
51
|
+
clickwrap_event_statements clickwrap_event_documents
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
def up
|
|
55
|
+
return say_unsupported_adapter unless postgresql?
|
|
56
|
+
|
|
57
|
+
execute <<~SQL
|
|
58
|
+
CREATE OR REPLACE FUNCTION clickwrap_reject_evidence_write() RETURNS trigger AS $function$
|
|
59
|
+
BEGIN
|
|
60
|
+
RAISE EXCEPTION
|
|
61
|
+
'clickwrap: % rejects this %. Use a named Clickwrap lifecycle or disposition operation.',
|
|
62
|
+
TG_TABLE_NAME,
|
|
63
|
+
TG_OP
|
|
64
|
+
USING ERRCODE = 'restrict_violation';
|
|
65
|
+
END;
|
|
66
|
+
$function$ LANGUAGE plpgsql;
|
|
67
|
+
SQL
|
|
68
|
+
|
|
69
|
+
execute <<~SQL
|
|
70
|
+
CREATE OR REPLACE FUNCTION clickwrap_guard_event_update() RETURNS trigger AS $function$
|
|
71
|
+
BEGIN
|
|
72
|
+
-- An unfinalized row is visible only inside its INSERT transaction. The
|
|
73
|
+
-- application builds children/outcome and then changes event_digest from
|
|
74
|
+
-- NULL to a value exactly once before COMMIT.
|
|
75
|
+
IF OLD.event_digest IS NULL THEN
|
|
76
|
+
RETURN NEW;
|
|
77
|
+
END IF;
|
|
78
|
+
|
|
79
|
+
-- A hold is a mutable projection backed by immutable hold lifecycle
|
|
80
|
+
-- rows. No other event field may ride along with this update.
|
|
81
|
+
IF (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("legal_hold"))})
|
|
82
|
+
IS NOT DISTINCT FROM
|
|
83
|
+
(#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("legal_hold"))}) THEN
|
|
84
|
+
RETURN NEW;
|
|
85
|
+
END IF;
|
|
86
|
+
|
|
87
|
+
-- The annex pointer is attached once; its keyed category digests were
|
|
88
|
+
-- already covered by the finalized event digest.
|
|
89
|
+
IF OLD.request_evidence_id IS NULL
|
|
90
|
+
AND NEW.request_evidence_id IS NOT NULL
|
|
91
|
+
AND (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("request_evidence_link"))})
|
|
92
|
+
IS NOT DISTINCT FROM
|
|
93
|
+
(#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("request_evidence_link"))}) THEN
|
|
94
|
+
RETURN NEW;
|
|
95
|
+
END IF;
|
|
96
|
+
|
|
97
|
+
-- Deleting a host actor/subject/represented party may only null its
|
|
98
|
+
-- convenience pointer. Stable references and every covered fact stay.
|
|
99
|
+
IF (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("pointer_nullification"))})
|
|
100
|
+
IS NOT DISTINCT FROM
|
|
101
|
+
(#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("pointer_nullification"))})
|
|
102
|
+
AND ((NEW.actor_type IS NULL AND NEW.actor_id IS NULL)
|
|
103
|
+
OR (NEW.actor_type IS NOT DISTINCT FROM OLD.actor_type
|
|
104
|
+
AND NEW.actor_id IS NOT DISTINCT FROM OLD.actor_id))
|
|
105
|
+
AND ((NEW.represented_party_type IS NULL AND NEW.represented_party_id IS NULL)
|
|
106
|
+
OR (NEW.represented_party_type IS NOT DISTINCT FROM OLD.represented_party_type
|
|
107
|
+
AND NEW.represented_party_id IS NOT DISTINCT FROM OLD.represented_party_id))
|
|
108
|
+
AND ((NEW.subject_type IS NULL AND NEW.subject_id IS NULL)
|
|
109
|
+
OR (NEW.subject_type IS NOT DISTINCT FROM OLD.subject_type
|
|
110
|
+
AND NEW.subject_id IS NOT DISTINCT FROM OLD.subject_id)) THEN
|
|
111
|
+
RETURN NEW;
|
|
112
|
+
END IF;
|
|
113
|
+
|
|
114
|
+
-- Core disposition is a one-way transition whose linked, finalized
|
|
115
|
+
-- disposition event names both this event and its original digest.
|
|
116
|
+
IF OLD.core_event_disposed_at IS NULL
|
|
117
|
+
AND NEW.core_event_disposed_at IS NOT NULL
|
|
118
|
+
AND NEW.core_event_disposition_event_id IS NOT NULL
|
|
119
|
+
AND (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("disposition"))})
|
|
120
|
+
IS NOT DISTINCT FROM
|
|
121
|
+
(#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("disposition"))})
|
|
122
|
+
AND NEW.actor_type IS NULL AND NEW.actor_id IS NULL
|
|
123
|
+
AND NEW.actor_reference = '' AND NEW.actor_snapshot = '{}'::jsonb
|
|
124
|
+
AND NEW.represented_party_type IS NULL AND NEW.represented_party_id IS NULL
|
|
125
|
+
AND NEW.represented_party_reference = ''
|
|
126
|
+
AND NEW.authority_source IS NULL AND NEW.authority_role IS NULL
|
|
127
|
+
AND NEW.authority_verified_at IS NULL AND NEW.authority_details = '{}'::jsonb
|
|
128
|
+
AND NEW.tenant_key = ''
|
|
129
|
+
AND NEW.subject_type IS NULL AND NEW.subject_id IS NULL
|
|
130
|
+
AND NEW.subject_key = '' AND NEW.subject_fingerprint IS NULL
|
|
131
|
+
AND NEW.authentication_method IS NULL AND NEW.authentication_context = '{}'::jsonb
|
|
132
|
+
AND NEW.idempotency_key IS NULL
|
|
133
|
+
AND NEW.http_request_id IS NULL AND NEW.http_route_name IS NULL
|
|
134
|
+
AND NEW.presentation_id IS NULL AND NEW.presentation_manifest IS NULL
|
|
135
|
+
AND NEW.presentation_manifest_digest IS NULL
|
|
136
|
+
AND NEW.protected_outcome IS NULL
|
|
137
|
+
AND NEW.provider_receipt IS NULL AND NEW.provider_verification IS NULL
|
|
138
|
+
AND NEW.reason IS NULL
|
|
139
|
+
AND EXISTS (
|
|
140
|
+
SELECT 1
|
|
141
|
+
FROM clickwrap_events disposition
|
|
142
|
+
WHERE disposition.id = NEW.core_event_disposition_event_id
|
|
143
|
+
AND disposition.event_type = 'disposition'
|
|
144
|
+
AND disposition.event_digest IS NOT NULL
|
|
145
|
+
AND disposition.protected_outcome
|
|
146
|
+
-> 'core_event_disposition' ->> 'event_id' = OLD.id
|
|
147
|
+
AND disposition.protected_outcome
|
|
148
|
+
-> 'core_event_disposition' ->> 'original_event_digest' = OLD.event_digest
|
|
149
|
+
) THEN
|
|
150
|
+
RETURN NEW;
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
RAISE EXCEPTION
|
|
154
|
+
'clickwrap: clickwrap_events rejects this UPDATE. Use a named Clickwrap lifecycle or disposition operation.'
|
|
155
|
+
USING ERRCODE = 'restrict_violation';
|
|
156
|
+
END;
|
|
157
|
+
$function$ LANGUAGE plpgsql;
|
|
158
|
+
SQL
|
|
159
|
+
|
|
160
|
+
execute <<~SQL
|
|
161
|
+
CREATE OR REPLACE FUNCTION clickwrap_guard_evidence_child_write() RETURNS trigger AS $function$
|
|
162
|
+
BEGIN
|
|
163
|
+
IF TG_OP = 'DELETE' AND EXISTS (
|
|
164
|
+
SELECT 1
|
|
165
|
+
FROM clickwrap_events source_event
|
|
166
|
+
JOIN clickwrap_events disposition
|
|
167
|
+
ON disposition.id = source_event.core_event_disposition_event_id
|
|
168
|
+
WHERE source_event.id = OLD.event_id
|
|
169
|
+
AND source_event.core_event_disposed_at IS NOT NULL
|
|
170
|
+
AND disposition.event_type = 'disposition'
|
|
171
|
+
AND disposition.event_digest IS NOT NULL
|
|
172
|
+
AND disposition.protected_outcome
|
|
173
|
+
-> 'core_event_disposition' ->> 'event_id' = source_event.id
|
|
174
|
+
AND disposition.protected_outcome
|
|
175
|
+
-> 'core_event_disposition' ->> 'original_event_digest' = source_event.event_digest
|
|
176
|
+
) THEN
|
|
177
|
+
RETURN OLD;
|
|
178
|
+
END IF;
|
|
179
|
+
|
|
180
|
+
RAISE EXCEPTION
|
|
181
|
+
'clickwrap: % rejects this %. Evidence children may be deleted only by a documented core disposition.',
|
|
182
|
+
TG_TABLE_NAME,
|
|
183
|
+
TG_OP
|
|
184
|
+
USING ERRCODE = 'restrict_violation';
|
|
185
|
+
END;
|
|
186
|
+
$function$ LANGUAGE plpgsql;
|
|
187
|
+
SQL
|
|
188
|
+
|
|
189
|
+
install_event_triggers
|
|
190
|
+
install_child_triggers
|
|
191
|
+
|
|
192
|
+
say "clickwrap: PostgreSQL update/delete protection installed on finalized events and their evidence children."
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def down
|
|
196
|
+
return unless postgresql?
|
|
197
|
+
|
|
198
|
+
execute "DROP TRIGGER IF EXISTS clickwrap_events_guard_update ON clickwrap_events;"
|
|
199
|
+
execute "DROP TRIGGER IF EXISTS clickwrap_events_reject_delete ON clickwrap_events;"
|
|
200
|
+
|
|
201
|
+
EVIDENCE_CHILD_TABLES.each do |table|
|
|
202
|
+
execute "DROP TRIGGER IF EXISTS #{table}_guard_write ON #{table};"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
execute "DROP FUNCTION IF EXISTS clickwrap_guard_event_update();"
|
|
206
|
+
execute "DROP FUNCTION IF EXISTS clickwrap_guard_evidence_child_write();"
|
|
207
|
+
execute "DROP FUNCTION IF EXISTS clickwrap_reject_evidence_write();"
|
|
208
|
+
|
|
209
|
+
say "clickwrap: PostgreSQL update/delete protection removed. No evidence rows were changed."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def install_event_triggers
|
|
215
|
+
execute "DROP TRIGGER IF EXISTS clickwrap_events_guard_update ON clickwrap_events;"
|
|
216
|
+
execute <<~SQL
|
|
217
|
+
CREATE TRIGGER clickwrap_events_guard_update
|
|
218
|
+
BEFORE UPDATE ON clickwrap_events
|
|
219
|
+
FOR EACH ROW EXECUTE FUNCTION clickwrap_guard_event_update();
|
|
220
|
+
SQL
|
|
221
|
+
|
|
222
|
+
execute "DROP TRIGGER IF EXISTS clickwrap_events_reject_delete ON clickwrap_events;"
|
|
223
|
+
execute <<~SQL
|
|
224
|
+
CREATE TRIGGER clickwrap_events_reject_delete
|
|
225
|
+
BEFORE DELETE ON clickwrap_events
|
|
226
|
+
FOR EACH ROW EXECUTE FUNCTION clickwrap_reject_evidence_write();
|
|
227
|
+
SQL
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def install_child_triggers
|
|
231
|
+
EVIDENCE_CHILD_TABLES.each do |table|
|
|
232
|
+
execute "DROP TRIGGER IF EXISTS #{table}_guard_write ON #{table};"
|
|
233
|
+
execute <<~SQL
|
|
234
|
+
CREATE TRIGGER #{table}_guard_write
|
|
235
|
+
BEFORE UPDATE OR DELETE ON #{table}
|
|
236
|
+
FOR EACH ROW EXECUTE FUNCTION clickwrap_guard_evidence_child_write();
|
|
237
|
+
SQL
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def jsonb_without(row_name, columns)
|
|
242
|
+
(["to_jsonb(#{row_name})"] + columns.map { |column| "- #{connection.quote(column)}" }).join(" ")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def postgresql?
|
|
246
|
+
connection.adapter_name.to_s.downcase.match?(/postgres|postgis/)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def say_unsupported_adapter
|
|
250
|
+
say "clickwrap: no database-level update/delete protection was installed."
|
|
251
|
+
say " This opt-in migration is implemented and tested only for PostgreSQL.", true
|
|
252
|
+
say " SQLite has no separate runtime role; any writer can drop its own trigger.", true
|
|
253
|
+
say " On MySQL, use a runtime role whose grants match your reviewed write paths.", true
|
|
254
|
+
say " Model refusals and digest verification continue to work on every supported adapter.", true
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
# Clickwrap documents, policies, and retention classes
|
|
5
|
+
# ==============================================================================
|
|
6
|
+
#
|
|
7
|
+
# This file is ordinary Ruby so it is reviewable in a pull request and deploys
|
|
8
|
+
# with the code that depends on it. It is re-read on every boot and every
|
|
9
|
+
# development reload, and a mistake is a boot failure with a sentence explaining
|
|
10
|
+
# it — not a surprise the first time someone tries to sign up.
|
|
11
|
+
#
|
|
12
|
+
# Three kinds of declaration live here:
|
|
13
|
+
#
|
|
14
|
+
# Clickwrap.document versioned source and rendered bytes used in offers
|
|
15
|
+
# Clickwrap.policy what the server offers and will accept
|
|
16
|
+
# Clickwrap.retention how long each part of the evidence lives
|
|
17
|
+
#
|
|
18
|
+
# The browser never chooses any of it. Policy key, revision, document versions,
|
|
19
|
+
# validity, subject binding, retention, and request-evidence fields are resolved
|
|
20
|
+
# on the server and rechecked when the form comes back.
|
|
21
|
+
# ==============================================================================
|
|
22
|
+
|
|
23
|
+
# --- Documents ----------------------------------------------------------------
|
|
24
|
+
#
|
|
25
|
+
# A logical document (:terms) is separate from its immutable versions. Declaring
|
|
26
|
+
# a version here does not publish it: `bin/rails clickwrap:publish` reads the
|
|
27
|
+
# bytes once, digests them, and freezes a database snapshot. From then on the
|
|
28
|
+
# snapshot is the evidence and the file is only where it came from.
|
|
29
|
+
#
|
|
30
|
+
# Every version has a LABEL YOU OWN, and the file that holds the words is where
|
|
31
|
+
# that label belongs. A page with leading YAML front matter names its own
|
|
32
|
+
# version, so there is no `version:` line here and no second copy of the label
|
|
33
|
+
# to drift:
|
|
34
|
+
#
|
|
35
|
+
# ---
|
|
36
|
+
# title: Terms of Service
|
|
37
|
+
# last_updated: <%= Date.today.iso8601 %>
|
|
38
|
+
# ---
|
|
39
|
+
#
|
|
40
|
+
# `last_updated:` is the usual key, because a page that tells readers when it
|
|
41
|
+
# last changed already has one. `clickwrap_version:` wins when both are present,
|
|
42
|
+
# which is how a same-day correction gets a fresh label while the date readers
|
|
43
|
+
# see stays put. So changing a legal text is one edit in one file, then
|
|
44
|
+
# `bin/rails clickwrap:publish` — and reusing a label over changed bytes is
|
|
45
|
+
# refused rather than quietly accepted, which is what keeps the label and the
|
|
46
|
+
# bytes describing the same document. A file with neither key and no explicit
|
|
47
|
+
# `version:` fails the boot with a sentence; Clickwrap never invents a label.
|
|
48
|
+
#
|
|
49
|
+
# `version:` here stays the way to label a source that cannot carry front matter
|
|
50
|
+
# — a PDF, an HTML fragment, or a `resolver:` whose bytes are only read at
|
|
51
|
+
# publish time:
|
|
52
|
+
#
|
|
53
|
+
# Clickwrap.document :handbook,
|
|
54
|
+
# version: "<%= Date.today.iso8601 %>",
|
|
55
|
+
# from: Rails.root.join("app/content/legal/handbook.pdf")
|
|
56
|
+
#
|
|
57
|
+
# Whether a change is material enough to require re-acceptance is your decision.
|
|
58
|
+
# Clickwrap enforces the rule it is given; it does not decide materiality.
|
|
59
|
+
|
|
60
|
+
<%- unless terms_document_names_its_own_version? -%>
|
|
61
|
+
# <%= File.basename(terms_document_path) %> carries no version key yet. Move this label into the page's own
|
|
62
|
+
# `last_updated:` front matter and delete the line, so the text names its version.
|
|
63
|
+
<%- end -%>
|
|
64
|
+
Clickwrap.document :terms,
|
|
65
|
+
<%- unless terms_document_names_its_own_version? -%>
|
|
66
|
+
version: "<%= Date.today.iso8601 %>",
|
|
67
|
+
<%- end -%>
|
|
68
|
+
from: Rails.root.join("<%= terms_document_path %>")
|
|
69
|
+
|
|
70
|
+
<%- unless privacy_document_names_its_own_version? -%>
|
|
71
|
+
# <%= File.basename(privacy_document_path) %> carries no version key yet. Move this label into the page's own
|
|
72
|
+
# `last_updated:` front matter and delete the line, so the text names its version.
|
|
73
|
+
<%- end -%>
|
|
74
|
+
Clickwrap.document :privacy_notice,
|
|
75
|
+
<%- unless privacy_document_names_its_own_version? -%>
|
|
76
|
+
version: "<%= Date.today.iso8601 %>",
|
|
77
|
+
<%- end -%>
|
|
78
|
+
from: Rails.root.join("<%= privacy_document_path %>")
|
|
79
|
+
|
|
80
|
+
# Several locales are several versions of the same logical document. Each is
|
|
81
|
+
# published and digested separately, each names its own version in its own front
|
|
82
|
+
# matter, and the receipt records which one the accepted server offer bound:
|
|
83
|
+
#
|
|
84
|
+
# Clickwrap.document :terms,
|
|
85
|
+
# locale: :es,
|
|
86
|
+
# effective_at: Time.utc(<%= Date.today.year %>, <%= Date.today.month %>, <%= Date.today.day %>),
|
|
87
|
+
# from: Rails.root.join("app/content/legal/terms.es.md")
|
|
88
|
+
|
|
89
|
+
# --- Retention ----------------------------------------------------------------
|
|
90
|
+
#
|
|
91
|
+
# Clickwrap does not know how long your evidence should live and will not
|
|
92
|
+
# pretend to. What it does is make a reviewed decision executable and auditable,
|
|
93
|
+
# and keep two clocks apart:
|
|
94
|
+
#
|
|
95
|
+
# the CORE EVENT — what was agreed, by whom, to which version
|
|
96
|
+
# the ANNEX — optional personal request evidence attached to it
|
|
97
|
+
#
|
|
98
|
+
# They are separate because they answer to different rules. Deleting the annex
|
|
99
|
+
# removes the encrypted value and appends a disposition event; the agreement it
|
|
100
|
+
# accompanied stays intact and still verifies. Nothing cascades: deleting an
|
|
101
|
+
# actor account never silently destroys the record of what they agreed to.
|
|
102
|
+
#
|
|
103
|
+
# THE PERIODS BELOW ARE PLACEHOLDERS, NOT RECOMMENDATIONS. Replace them with
|
|
104
|
+
# periods you have reviewed for your jurisdiction, your obligations, and your
|
|
105
|
+
# disputes. `bin/rails clickwrap:retention:plan` shows what any of this would
|
|
106
|
+
# delete before anything is deleted.
|
|
107
|
+
|
|
108
|
+
Clickwrap.retention :ordinary_agreement_evidence do
|
|
109
|
+
retain_core_event_for 6.years
|
|
110
|
+
|
|
111
|
+
# These only matter if a policy or the initializer enables the field at all.
|
|
112
|
+
# A rule for something you never collect costs nothing and stops a future
|
|
113
|
+
# "we turned it on and forgot the clock".
|
|
114
|
+
delete_recorded_ip_address_after 90.days
|
|
115
|
+
delete_recorded_browser_user_agent_after 90.days
|
|
116
|
+
delete_recorded_ip_geolocation_after 90.days
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A regulated schedule that a duration cannot express — "the later of five years
|
|
120
|
+
# after capture and three years after the subject closed" — names a host event
|
|
121
|
+
# instead, and registers the calculation in config/initializers/clickwrap.rb:
|
|
122
|
+
#
|
|
123
|
+
# Clickwrap.retention :regulated_evidence do
|
|
124
|
+
# retain_core_event_until :regulated_evidence_retention_ends
|
|
125
|
+
# retain_recorded_ip_address_until :security_evidence_retention_ends
|
|
126
|
+
# retain_recorded_browser_user_agent_until :security_evidence_retention_ends
|
|
127
|
+
# retain_recorded_ip_geolocation_until :security_evidence_retention_ends
|
|
128
|
+
# end
|
|
129
|
+
|
|
130
|
+
# --- Policies -----------------------------------------------------------------
|
|
131
|
+
#
|
|
132
|
+
# A policy is a server-owned offer: exactly what the server will generate, and
|
|
133
|
+
# exactly what it will accept back. One submitted policy produces one event and one
|
|
134
|
+
# receipt, but each act inside it keeps its own kind, documents, wording,
|
|
135
|
+
# answer, and lifecycle — "agreed to the Terms" and "acknowledged the Privacy
|
|
136
|
+
# Notice" never flatten into one meaning.
|
|
137
|
+
|
|
138
|
+
Clickwrap.policy :signup do
|
|
139
|
+
# `agree_to` means assent to contractual terms. The lifecycle it gets is the
|
|
140
|
+
# one assent needs: it stands until a new version supersedes it.
|
|
141
|
+
agree_to :terms
|
|
142
|
+
|
|
143
|
+
# `acknowledge`, NOT `agree_to`, and NOT `consent_to`.
|
|
144
|
+
#
|
|
145
|
+
# A privacy notice is information a person is entitled to receive. It is not a
|
|
146
|
+
# contract term they assent to, and it is not permission they grant: consent
|
|
147
|
+
# is one possible lawful basis among several, and most of what a notice
|
|
148
|
+
# describes usually rests on a different one. Presenting a notice as something
|
|
149
|
+
# to "agree" to misdescribes it, and treating the acknowledgment as blanket
|
|
150
|
+
# consent would let a checkbox stand in for permissions nobody asked for.
|
|
151
|
+
#
|
|
152
|
+
# So the receipt says what the application recorded: the server offered this
|
|
153
|
+
# exact notice version, and the person submitted an affirmative acknowledgment
|
|
154
|
+
# of receiving it. Anything you genuinely need consent for gets its own
|
|
155
|
+
# `consent_to`, its own purpose, its own unselected control, and its own
|
|
156
|
+
# withdrawal path — see the marketing example below.
|
|
157
|
+
acknowledge :privacy_notice
|
|
158
|
+
|
|
159
|
+
retain_with :ordinary_agreement_evidence
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- An optional, separate marketing consent ----------------------------------
|
|
163
|
+
#
|
|
164
|
+
# Never bundled into signup, never preselected, and never required to create an
|
|
165
|
+
# account. Leaving an optional control unselected creates no grant at all: the
|
|
166
|
+
# receipt can show the option was offered and not taken, but silence is not
|
|
167
|
+
# recorded as a refusal.
|
|
168
|
+
#
|
|
169
|
+
# Consent that cannot be withdrawn is not consent, so Clickwrap structurally
|
|
170
|
+
# requires a withdrawal path. It still does not decide whether consent is the
|
|
171
|
+
# right basis for what you are doing — that one is yours.
|
|
172
|
+
#
|
|
173
|
+
# Clickwrap.document :marketing_notice,
|
|
174
|
+
# from: Rails.root.join("app/content/legal/marketing.md")
|
|
175
|
+
#
|
|
176
|
+
# Clickwrap.policy :marketing_preferences do
|
|
177
|
+
# consent_to :product_updates,
|
|
178
|
+
# document: :marketing_notice,
|
|
179
|
+
# optional: true,
|
|
180
|
+
# withdrawal_path: "/settings/privacy"
|
|
181
|
+
#
|
|
182
|
+
# retain_with :ordinary_agreement_evidence
|
|
183
|
+
# end
|
|
184
|
+
#
|
|
185
|
+
# Withdrawal appends an event; it never edits or deletes the historical grant:
|
|
186
|
+
#
|
|
187
|
+
# Clickwrap.withdraw!(
|
|
188
|
+
# :product_updates,
|
|
189
|
+
# actor: current_user,
|
|
190
|
+
# http_request: request,
|
|
191
|
+
# because: "The user withdrew this purpose in privacy settings"
|
|
192
|
+
# )
|