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,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The external-action outbox — `rails generate clickwrap:install
|
|
4
|
+
# --with-external-actions`, or added later with the same command.
|
|
5
|
+
#
|
|
6
|
+
# Only `Clickwrap.authorize_external_action!` writes here, so an
|
|
7
|
+
# application that never crosses a system boundary inside a capture can
|
|
8
|
+
# never put a row in this table.
|
|
9
|
+
class CreateClickwrapExternalActionTables < ActiveRecord::Migration<%= migration_version %>
|
|
10
|
+
def change
|
|
11
|
+
primary_key_type, = primary_and_foreign_key_types
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# clickwrap_external_actions
|
|
15
|
+
#
|
|
16
|
+
# The outbox. A provider cannot join your database transaction, so an
|
|
17
|
+
# external handoff gets a pending authorization committed locally, an
|
|
18
|
+
# idempotency key, and an explicit resolution — succeeded, failed, or
|
|
19
|
+
# genuinely unknown. `unknown` is a first-class state because a timeout is
|
|
20
|
+
# not a failure, and treating it as one is how a second debit happens.
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
create_table :clickwrap_external_actions, id: primary_key_type do |t|
|
|
23
|
+
t.string :event_id, limit: 26, null: false
|
|
24
|
+
t.string :policy_key, null: false
|
|
25
|
+
t.string :idempotency_key, null: false
|
|
26
|
+
t.string :provider_name
|
|
27
|
+
|
|
28
|
+
t.string :state, null: false, default: "pending"
|
|
29
|
+
t.integer :attempt_count, null: false, default: 0
|
|
30
|
+
t.send(json_column_type, :provider_receipt)
|
|
31
|
+
t.text :failure_reason
|
|
32
|
+
|
|
33
|
+
t.datetime :requested_at, precision: 6, null: false
|
|
34
|
+
t.datetime :resolved_at, precision: 6
|
|
35
|
+
|
|
36
|
+
t.timestamps precision: 6
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
add_index :clickwrap_external_actions, :idempotency_key, unique: true,
|
|
40
|
+
name: "index_clickwrap_external_actions_on_idempotency_key"
|
|
41
|
+
add_index :clickwrap_external_actions, :event_id, unique: true,
|
|
42
|
+
name: "index_clickwrap_external_actions_on_event"
|
|
43
|
+
add_index :clickwrap_external_actions, [ :state, :requested_at ],
|
|
44
|
+
name: "index_clickwrap_external_actions_on_state"
|
|
45
|
+
add_clickwrap_check_constraint :clickwrap_external_actions,
|
|
46
|
+
"state IN (#{quoted_values(%w[pending succeeded failed unknown])})",
|
|
47
|
+
name: "chk_clickwrap_external_actions_state"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
add_clickwrap_foreign_key :clickwrap_external_actions, :clickwrap_events,
|
|
51
|
+
column: :event_id, name: "fk_clickwrap_external_actions_event"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Honor the host's configured primary key type (uuid vs bigint). Reads the
|
|
57
|
+
# same setting `rails g model` uses, so an app generated with
|
|
58
|
+
# `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
|
|
59
|
+
# gets uuid clickwrap tables and uuid foreign keys, automatically.
|
|
60
|
+
#
|
|
61
|
+
# Note that clickwrap_events keeps a ULID string key regardless: its id is
|
|
62
|
+
# quoted verbatim in receipts and exports, so it has to be stable, sortable,
|
|
63
|
+
# and identical in every host.
|
|
64
|
+
def primary_and_foreign_key_types
|
|
65
|
+
config = Rails.configuration.generators
|
|
66
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
67
|
+
primary_key_type = setting || :primary_key
|
|
68
|
+
foreign_key_type = setting || :bigint
|
|
69
|
+
[ primary_key_type, foreign_key_type ]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def json_column_type
|
|
73
|
+
return :jsonb if connection.adapter_name.downcase.match?(/postg/) # postgresql, postgis
|
|
74
|
+
|
|
75
|
+
:json
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# MySQL 8+ doesn't allow default values on JSON columns. Returns an empty-hash
|
|
79
|
+
# default for SQLite/PostgreSQL, nil for MySQL. The models handle nil
|
|
80
|
+
# gracefully by defaulting to {} in their accessors.
|
|
81
|
+
def json_column_default
|
|
82
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
83
|
+
|
|
84
|
+
{}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Same MySQL caveat as `json_column_default`, but for list-shaped columns.
|
|
88
|
+
def json_array_default
|
|
89
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
90
|
+
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Legal documents are routinely longer than MySQL's 64 KB TEXT limit, and a
|
|
95
|
+
# silently truncated agreement is the worst possible failure for this gem.
|
|
96
|
+
# `:mediumtext` maps to MEDIUMTEXT on MySQL (16 MB) and to ordinary TEXT
|
|
97
|
+
# everywhere else.
|
|
98
|
+
def text_column_type
|
|
99
|
+
return :mediumtext if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
100
|
+
|
|
101
|
+
:text
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def quoted_values(values)
|
|
105
|
+
values.map { |value| connection.quote(value) }.join(", ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# SQLite implements both foreign keys and check constraints by rebuilding a
|
|
109
|
+
# table. During a long install migration, Active Record can otherwise rebuild
|
|
110
|
+
# from a schema-cache entry captured before the indexes/columns immediately
|
|
111
|
+
# above were added. Refreshing around every rebuild keeps the SQLite result
|
|
112
|
+
# identical to PostgreSQL/MySQL instead of quietly resurrecting stale shape.
|
|
113
|
+
def add_clickwrap_check_constraint(table, expression, **options)
|
|
114
|
+
refresh_clickwrap_table_schema!(table)
|
|
115
|
+
add_check_constraint(table, expression, **options)
|
|
116
|
+
refresh_clickwrap_table_schema!(table)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def add_clickwrap_foreign_key(from_table, to_table, **options)
|
|
120
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
121
|
+
add_foreign_key(from_table, to_table, **options)
|
|
122
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def refresh_clickwrap_table_schema!(table)
|
|
126
|
+
connection.schema_cache.clear_data_source_cache!(table.to_s)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The higher integrity tiers — `rails generate clickwrap:install
|
|
4
|
+
# --with-integrity`, or added later with the same command.
|
|
5
|
+
#
|
|
6
|
+
# Per-tenant event chaining, external anchoring, and third-party
|
|
7
|
+
# timestamping are each off until the host brings an adapter
|
|
8
|
+
# (`config.chain_event_history_with`, `config.anchor_event_history_with`,
|
|
9
|
+
# `config.timestamp_receipts_with`). Until then neither table can receive a
|
|
10
|
+
# row.
|
|
11
|
+
class CreateClickwrapIntegrityTables < ActiveRecord::Migration<%= migration_version %>
|
|
12
|
+
def change
|
|
13
|
+
primary_key_type, = primary_and_foreign_key_types
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# clickwrap_chain_heads
|
|
17
|
+
#
|
|
18
|
+
# Optional tamper-evident chaining, off unless configured. The scope is per
|
|
19
|
+
# tenant or per aggregate, never one global chain: a single chain across
|
|
20
|
+
# unrelated tenants turns every capture into a queue behind every other one,
|
|
21
|
+
# and buys assurance nobody asked for at a cost everybody pays.
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
create_table :clickwrap_chain_heads, id: primary_key_type do |t|
|
|
24
|
+
t.string :chain_scope, null: false
|
|
25
|
+
t.string :last_event_id, limit: 26
|
|
26
|
+
t.string :last_event_digest
|
|
27
|
+
t.bigint :chain_sequence, null: false, default: 0
|
|
28
|
+
|
|
29
|
+
t.datetime :checkpointed_at, precision: 6
|
|
30
|
+
t.string :checkpoint_digest
|
|
31
|
+
t.string :anchor_reference
|
|
32
|
+
t.send(json_column_type, :anchor_receipt)
|
|
33
|
+
|
|
34
|
+
t.timestamps precision: 6
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
add_index :clickwrap_chain_heads, :chain_scope, unique: true,
|
|
38
|
+
name: "index_clickwrap_chain_heads_on_scope"
|
|
39
|
+
add_index :clickwrap_chain_heads, :last_event_id,
|
|
40
|
+
name: "index_clickwrap_chain_heads_on_last_event"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# clickwrap_integrity_attestations
|
|
45
|
+
#
|
|
46
|
+
# One row per attempt to have an outside party vouch for an exact event
|
|
47
|
+
# digest, carrying the provider's own result and this gem's verification of
|
|
48
|
+
# it as separate facts. An unverified issue is recorded as unverified.
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
create_table :clickwrap_integrity_attestations, id: primary_key_type do |t|
|
|
51
|
+
t.string :event_id, limit: 26, null: false
|
|
52
|
+
t.string :kind, null: false
|
|
53
|
+
t.string :state, null: false
|
|
54
|
+
t.string :provider_name, null: false
|
|
55
|
+
t.string :subject_digest, null: false
|
|
56
|
+
t.string :chain_scope
|
|
57
|
+
t.bigint :chain_sequence
|
|
58
|
+
t.string :provider_reference
|
|
59
|
+
t.send(json_column_type, :provider_result, null: false)
|
|
60
|
+
t.send(json_column_type, :verification, null: false)
|
|
61
|
+
t.send(json_column_type, :adapter_capabilities, null: false)
|
|
62
|
+
t.string :attestation_digest, null: false
|
|
63
|
+
t.datetime :attempted_at, precision: 6, null: false
|
|
64
|
+
t.datetime :provider_reported_at, precision: 6
|
|
65
|
+
t.datetime :created_at, precision: 6, null: false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
add_index :clickwrap_integrity_attestations, [ :event_id, :kind, :attempted_at ],
|
|
69
|
+
name: "index_clickwrap_integrity_attestations_on_event"
|
|
70
|
+
add_clickwrap_check_constraint :clickwrap_integrity_attestations,
|
|
71
|
+
"kind IN (#{quoted_values(%w[event_anchor third_party_timestamp])})",
|
|
72
|
+
name: "chk_clickwrap_attestations_kind"
|
|
73
|
+
add_clickwrap_check_constraint :clickwrap_integrity_attestations,
|
|
74
|
+
"state IN (#{quoted_values(%w[verified issued_unverified unavailable failed])})",
|
|
75
|
+
name: "chk_clickwrap_attestations_state"
|
|
76
|
+
|
|
77
|
+
add_clickwrap_foreign_key :clickwrap_chain_heads, :clickwrap_events,
|
|
78
|
+
column: :last_event_id, name: "fk_clickwrap_chain_heads_event"
|
|
79
|
+
add_clickwrap_foreign_key :clickwrap_integrity_attestations, :clickwrap_events,
|
|
80
|
+
column: :event_id, name: "fk_clickwrap_attestations_event"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Honor the host's configured primary key type (uuid vs bigint). Reads the
|
|
86
|
+
# same setting `rails g model` uses, so an app generated with
|
|
87
|
+
# `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
|
|
88
|
+
# gets uuid clickwrap tables and uuid foreign keys, automatically.
|
|
89
|
+
#
|
|
90
|
+
# Note that clickwrap_events keeps a ULID string key regardless: its id is
|
|
91
|
+
# quoted verbatim in receipts and exports, so it has to be stable, sortable,
|
|
92
|
+
# and identical in every host.
|
|
93
|
+
def primary_and_foreign_key_types
|
|
94
|
+
config = Rails.configuration.generators
|
|
95
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
96
|
+
primary_key_type = setting || :primary_key
|
|
97
|
+
foreign_key_type = setting || :bigint
|
|
98
|
+
[ primary_key_type, foreign_key_type ]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def json_column_type
|
|
102
|
+
return :jsonb if connection.adapter_name.downcase.match?(/postg/) # postgresql, postgis
|
|
103
|
+
|
|
104
|
+
:json
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# MySQL 8+ doesn't allow default values on JSON columns. Returns an empty-hash
|
|
108
|
+
# default for SQLite/PostgreSQL, nil for MySQL. The models handle nil
|
|
109
|
+
# gracefully by defaulting to {} in their accessors.
|
|
110
|
+
def json_column_default
|
|
111
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
112
|
+
|
|
113
|
+
{}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Same MySQL caveat as `json_column_default`, but for list-shaped columns.
|
|
117
|
+
def json_array_default
|
|
118
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
119
|
+
|
|
120
|
+
[]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Legal documents are routinely longer than MySQL's 64 KB TEXT limit, and a
|
|
124
|
+
# silently truncated agreement is the worst possible failure for this gem.
|
|
125
|
+
# `:mediumtext` maps to MEDIUMTEXT on MySQL (16 MB) and to ordinary TEXT
|
|
126
|
+
# everywhere else.
|
|
127
|
+
def text_column_type
|
|
128
|
+
return :mediumtext if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
129
|
+
|
|
130
|
+
:text
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def quoted_values(values)
|
|
134
|
+
values.map { |value| connection.quote(value) }.join(", ")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# SQLite implements both foreign keys and check constraints by rebuilding a
|
|
138
|
+
# table. During a long install migration, Active Record can otherwise rebuild
|
|
139
|
+
# from a schema-cache entry captured before the indexes/columns immediately
|
|
140
|
+
# above were added. Refreshing around every rebuild keeps the SQLite result
|
|
141
|
+
# identical to PostgreSQL/MySQL instead of quietly resurrecting stale shape.
|
|
142
|
+
def add_clickwrap_check_constraint(table, expression, **options)
|
|
143
|
+
refresh_clickwrap_table_schema!(table)
|
|
144
|
+
add_check_constraint(table, expression, **options)
|
|
145
|
+
refresh_clickwrap_table_schema!(table)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def add_clickwrap_foreign_key(from_table, to_table, **options)
|
|
149
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
150
|
+
add_foreign_key(from_table, to_table, **options)
|
|
151
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def refresh_clickwrap_table_schema!(table)
|
|
155
|
+
connection.schema_cache.clear_data_source_cache!(table.to_s)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Persisted pre-submit presentations — `rails generate clickwrap:install
|
|
4
|
+
# --with-persisted-presentations`, or added later with the same command.
|
|
5
|
+
#
|
|
6
|
+
# A default install writes nothing on GET, so this table can never receive
|
|
7
|
+
# a row until a policy declares
|
|
8
|
+
# `persist_presentations_before_submission_for`. It ships separately so an
|
|
9
|
+
# installation's schema says what that installation actually records.
|
|
10
|
+
class CreateClickwrapPresentationTables < ActiveRecord::Migration<%= migration_version %>
|
|
11
|
+
def change
|
|
12
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# clickwrap_presentations
|
|
16
|
+
#
|
|
17
|
+
# The default path signs the manifest into a short-lived token and writes
|
|
18
|
+
# NOTHING on GET — a render is not evidence, and a table row per page view
|
|
19
|
+
# would be both slow and more personal data than the job needs.
|
|
20
|
+
#
|
|
21
|
+
# Rows land here in two cases: a policy explicitly retains pre-submit
|
|
22
|
+
# presentations for a documented reason, and every successful capture, whose
|
|
23
|
+
# accepted manifest is persisted with the event. An abandoned GET is
|
|
24
|
+
# `presented_by_server` — never `accepted`, and never `seen_by_human`,
|
|
25
|
+
# because a server cannot know whether a human saw anything.
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
create_table :clickwrap_presentations, id: primary_key_type do |t|
|
|
28
|
+
t.string :policy_key, null: false
|
|
29
|
+
t.references :policy_revision, null: false, type: foreign_key_type, index: false,
|
|
30
|
+
foreign_key: { to_table: :clickwrap_policy_revisions }
|
|
31
|
+
|
|
32
|
+
t.string :nonce, null: false
|
|
33
|
+
t.send(json_column_type, :manifest, null: false)
|
|
34
|
+
t.string :manifest_digest, null: false
|
|
35
|
+
|
|
36
|
+
# Stable references drive every evidence query. These optional convenience
|
|
37
|
+
# pointers are strings so one polymorphic column can safely point at host
|
|
38
|
+
# models whose primary keys are integers, UUIDs, or domain strings.
|
|
39
|
+
t.string :actor_type
|
|
40
|
+
t.string :actor_id
|
|
41
|
+
t.string :actor_reference
|
|
42
|
+
t.string :registration_flow_id
|
|
43
|
+
t.string :represented_party_type
|
|
44
|
+
t.string :represented_party_id
|
|
45
|
+
t.string :represented_party_reference
|
|
46
|
+
t.string :tenant_key
|
|
47
|
+
t.string :subject_type
|
|
48
|
+
t.string :subject_id
|
|
49
|
+
t.string :subject_fingerprint
|
|
50
|
+
|
|
51
|
+
t.string :locale, null: false, default: "en"
|
|
52
|
+
t.string :capture_channel, null: false, default: "web_browser"
|
|
53
|
+
t.string :state, null: false, default: "presented_by_server"
|
|
54
|
+
|
|
55
|
+
t.datetime :issued_at, precision: 6, null: false
|
|
56
|
+
t.datetime :expires_at, precision: 6, null: false
|
|
57
|
+
t.datetime :submitted_at, precision: 6
|
|
58
|
+
t.datetime :retain_until, precision: 6
|
|
59
|
+
|
|
60
|
+
t.datetime :created_at, precision: 6, null: false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
add_index :clickwrap_presentations, :nonce, unique: true,
|
|
64
|
+
name: "index_clickwrap_presentations_on_nonce"
|
|
65
|
+
add_index :clickwrap_presentations, [ :actor_type, :actor_id ],
|
|
66
|
+
name: "index_clickwrap_presentations_on_actor"
|
|
67
|
+
add_index :clickwrap_presentations, :retain_until,
|
|
68
|
+
name: "index_clickwrap_presentations_on_retain_until"
|
|
69
|
+
add_index :clickwrap_presentations, :expires_at,
|
|
70
|
+
name: "index_clickwrap_presentations_on_expires_at"
|
|
71
|
+
add_index :clickwrap_presentations, :actor_reference,
|
|
72
|
+
name: "index_clickwrap_presentations_on_actor_reference"
|
|
73
|
+
add_clickwrap_check_constraint :clickwrap_presentations,
|
|
74
|
+
"state IN (#{quoted_values(%w[presented_by_server accepted rejected expired])})",
|
|
75
|
+
name: "chk_clickwrap_presentations_state"
|
|
76
|
+
add_clickwrap_check_constraint :clickwrap_presentations,
|
|
77
|
+
"capture_channel IN (#{quoted_values(%w[web_browser native_app api_client operator background_job imported_provider system])})",
|
|
78
|
+
name: "chk_clickwrap_presentations_channel"
|
|
79
|
+
|
|
80
|
+
# `clickwrap_events.presentation_id` is created by the core migration and
|
|
81
|
+
# gains its foreign key here, with the table it points at.
|
|
82
|
+
add_clickwrap_foreign_key :clickwrap_events, :clickwrap_presentations,
|
|
83
|
+
column: :presentation_id, name: "fk_clickwrap_events_presentation"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Honor the host's configured primary key type (uuid vs bigint). Reads the
|
|
89
|
+
# same setting `rails g model` uses, so an app generated with
|
|
90
|
+
# `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
|
|
91
|
+
# gets uuid clickwrap tables and uuid foreign keys, automatically.
|
|
92
|
+
#
|
|
93
|
+
# Note that clickwrap_events keeps a ULID string key regardless: its id is
|
|
94
|
+
# quoted verbatim in receipts and exports, so it has to be stable, sortable,
|
|
95
|
+
# and identical in every host.
|
|
96
|
+
def primary_and_foreign_key_types
|
|
97
|
+
config = Rails.configuration.generators
|
|
98
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
99
|
+
primary_key_type = setting || :primary_key
|
|
100
|
+
foreign_key_type = setting || :bigint
|
|
101
|
+
[ primary_key_type, foreign_key_type ]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def json_column_type
|
|
105
|
+
return :jsonb if connection.adapter_name.downcase.match?(/postg/) # postgresql, postgis
|
|
106
|
+
|
|
107
|
+
:json
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# MySQL 8+ doesn't allow default values on JSON columns. Returns an empty-hash
|
|
111
|
+
# default for SQLite/PostgreSQL, nil for MySQL. The models handle nil
|
|
112
|
+
# gracefully by defaulting to {} in their accessors.
|
|
113
|
+
def json_column_default
|
|
114
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
115
|
+
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Same MySQL caveat as `json_column_default`, but for list-shaped columns.
|
|
120
|
+
def json_array_default
|
|
121
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
122
|
+
|
|
123
|
+
[]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Legal documents are routinely longer than MySQL's 64 KB TEXT limit, and a
|
|
127
|
+
# silently truncated agreement is the worst possible failure for this gem.
|
|
128
|
+
# `:mediumtext` maps to MEDIUMTEXT on MySQL (16 MB) and to ordinary TEXT
|
|
129
|
+
# everywhere else.
|
|
130
|
+
def text_column_type
|
|
131
|
+
return :mediumtext if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
132
|
+
|
|
133
|
+
:text
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def quoted_values(values)
|
|
137
|
+
values.map { |value| connection.quote(value) }.join(", ")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# SQLite implements both foreign keys and check constraints by rebuilding a
|
|
141
|
+
# table. During a long install migration, Active Record can otherwise rebuild
|
|
142
|
+
# from a schema-cache entry captured before the indexes/columns immediately
|
|
143
|
+
# above were added. Refreshing around every rebuild keeps the SQLite result
|
|
144
|
+
# identical to PostgreSQL/MySQL instead of quietly resurrecting stale shape.
|
|
145
|
+
def add_clickwrap_check_constraint(table, expression, **options)
|
|
146
|
+
refresh_clickwrap_table_schema!(table)
|
|
147
|
+
add_check_constraint(table, expression, **options)
|
|
148
|
+
refresh_clickwrap_table_schema!(table)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add_clickwrap_foreign_key(from_table, to_table, **options)
|
|
152
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
153
|
+
add_foreign_key(from_table, to_table, **options)
|
|
154
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def refresh_clickwrap_table_schema!(table)
|
|
158
|
+
connection.schema_cache.clear_data_source_cache!(table.to_s)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The optional request-evidence annex — `rails generate clickwrap:install
|
|
4
|
+
# --with-request-evidence`, or added later with the same command.
|
|
5
|
+
#
|
|
6
|
+
# Nothing personal is recorded until a policy names a field with a purpose
|
|
7
|
+
# and a deletion rule, so on a default install this table can never receive
|
|
8
|
+
# a row. It ships separately for the same reason the fields are off: an
|
|
9
|
+
# application's schema should not imply it collects things it does not.
|
|
10
|
+
class CreateClickwrapRequestEvidenceTables < ActiveRecord::Migration<%= migration_version %>
|
|
11
|
+
def change
|
|
12
|
+
primary_key_type, = primary_and_foreign_key_types
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# clickwrap_request_evidence
|
|
16
|
+
#
|
|
17
|
+
# The optional annex: IP address, browser user-agent, and provider-estimated
|
|
18
|
+
# IP geolocation, none of it recorded unless a policy names the field.
|
|
19
|
+
#
|
|
20
|
+
# It is a separate table on purpose. Personal request evidence needs its own
|
|
21
|
+
# deletion schedule, and welding it into the core event payload would make a
|
|
22
|
+
# lawful deletion request either impossible or destructive of the historical
|
|
23
|
+
# record. Here it can be removed on its own clock while the agreement it
|
|
24
|
+
# accompanied stays intact and verifiable.
|
|
25
|
+
#
|
|
26
|
+
# Every IP-geolocation value carries its provenance in the same row.
|
|
27
|
+
# Coordinates without an accuracy radius, or a country without knowing which
|
|
28
|
+
# provider guessed it, would read as far more certain than they are.
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
create_table :clickwrap_request_evidence, id: primary_key_type do |t|
|
|
31
|
+
t.string :event_id, limit: 26, null: false
|
|
32
|
+
t.send(json_column_type, :authorized_fields, null: false)
|
|
33
|
+
|
|
34
|
+
t.text :ip_address_ciphertext
|
|
35
|
+
t.string :ip_address_reader_name
|
|
36
|
+
t.string :trusted_proxy_configuration_digest
|
|
37
|
+
t.datetime :ip_address_recorded_at, precision: 6
|
|
38
|
+
t.datetime :ip_address_delete_after, precision: 6
|
|
39
|
+
t.string :ip_address_retain_until_rule
|
|
40
|
+
t.datetime :ip_address_deleted_at, precision: 6
|
|
41
|
+
t.string :ip_address_unavailable_reason
|
|
42
|
+
|
|
43
|
+
t.text :browser_user_agent_ciphertext
|
|
44
|
+
t.boolean :browser_user_agent_was_client_supplied, null: false, default: true
|
|
45
|
+
t.datetime :browser_user_agent_recorded_at, precision: 6
|
|
46
|
+
t.datetime :browser_user_agent_delete_after, precision: 6
|
|
47
|
+
t.string :browser_user_agent_retain_until_rule
|
|
48
|
+
t.datetime :browser_user_agent_deleted_at, precision: 6
|
|
49
|
+
t.string :browser_user_agent_unavailable_reason
|
|
50
|
+
|
|
51
|
+
t.string :ip_geolocation_country_code
|
|
52
|
+
t.string :ip_geolocation_country_name
|
|
53
|
+
t.string :ip_geolocation_region_name
|
|
54
|
+
t.string :ip_geolocation_region_code
|
|
55
|
+
t.string :ip_geolocation_city_name
|
|
56
|
+
t.string :ip_geolocation_postal_code
|
|
57
|
+
|
|
58
|
+
# Coordinates are strings, not decimals, for two reasons that point the
|
|
59
|
+
# same way. A receipt serializes them as strings anyway, so storing the
|
|
60
|
+
# decimal and formatting it back would introduce a rounding step between
|
|
61
|
+
# what the provider said and what the evidence shows. And a string column
|
|
62
|
+
# can carry ciphertext, which is what lets `encrypt_recorded_ip_geolocation`
|
|
63
|
+
# actually apply to the most identifying field in this table rather than
|
|
64
|
+
# being a setting that quietly does nothing.
|
|
65
|
+
t.string :ip_geolocation_latitude
|
|
66
|
+
t.string :ip_geolocation_longitude
|
|
67
|
+
|
|
68
|
+
t.string :ip_geolocation_timezone
|
|
69
|
+
t.string :ip_geolocation_continent_code
|
|
70
|
+
t.string :ip_geolocation_metro_code
|
|
71
|
+
|
|
72
|
+
t.string :ip_geolocation_provider_name
|
|
73
|
+
t.string :ip_geolocation_provider_source
|
|
74
|
+
t.string :ip_geolocation_database_version
|
|
75
|
+
t.string :ip_geolocation_database_sha256
|
|
76
|
+
t.integer :ip_geolocation_accuracy_radius_in_kilometers
|
|
77
|
+
t.integer :ip_geolocation_accuracy_radius_confidence_percentage
|
|
78
|
+
t.boolean :ip_geolocation_was_estimated, null: false, default: true
|
|
79
|
+
t.boolean :ip_geolocation_source_was_verified_by_host, null: false, default: false
|
|
80
|
+
t.datetime :ip_geolocation_resolved_at, precision: 6
|
|
81
|
+
t.string :ip_geolocation_unavailable_reason
|
|
82
|
+
t.datetime :ip_geolocation_recorded_at, precision: 6
|
|
83
|
+
t.datetime :ip_geolocation_delete_after, precision: 6
|
|
84
|
+
t.string :ip_geolocation_retain_until_rule
|
|
85
|
+
t.datetime :ip_geolocation_deleted_at, precision: 6
|
|
86
|
+
|
|
87
|
+
t.datetime :created_at, precision: 6, null: false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
add_index :clickwrap_request_evidence, :event_id, unique: true,
|
|
91
|
+
name: "index_clickwrap_request_evidence_on_event"
|
|
92
|
+
add_index :clickwrap_request_evidence, :ip_address_delete_after,
|
|
93
|
+
name: "index_clickwrap_request_evidence_on_ip_address_delete_after"
|
|
94
|
+
add_index :clickwrap_request_evidence, :browser_user_agent_delete_after,
|
|
95
|
+
name: "index_clickwrap_request_evidence_on_user_agent_delete_after"
|
|
96
|
+
add_index :clickwrap_request_evidence, :ip_geolocation_delete_after,
|
|
97
|
+
name: "index_clickwrap_request_evidence_on_geolocation_delete_after"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
add_clickwrap_foreign_key :clickwrap_events, :clickwrap_request_evidence,
|
|
101
|
+
column: :request_evidence_id, name: "fk_clickwrap_events_request_evidence"
|
|
102
|
+
add_clickwrap_foreign_key :clickwrap_request_evidence, :clickwrap_events,
|
|
103
|
+
column: :event_id, name: "fk_clickwrap_request_evidence_event"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Honor the host's configured primary key type (uuid vs bigint). Reads the
|
|
109
|
+
# same setting `rails g model` uses, so an app generated with
|
|
110
|
+
# `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
|
|
111
|
+
# gets uuid clickwrap tables and uuid foreign keys, automatically.
|
|
112
|
+
#
|
|
113
|
+
# Note that clickwrap_events keeps a ULID string key regardless: its id is
|
|
114
|
+
# quoted verbatim in receipts and exports, so it has to be stable, sortable,
|
|
115
|
+
# and identical in every host.
|
|
116
|
+
def primary_and_foreign_key_types
|
|
117
|
+
config = Rails.configuration.generators
|
|
118
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
119
|
+
primary_key_type = setting || :primary_key
|
|
120
|
+
foreign_key_type = setting || :bigint
|
|
121
|
+
[ primary_key_type, foreign_key_type ]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def json_column_type
|
|
125
|
+
return :jsonb if connection.adapter_name.downcase.match?(/postg/) # postgresql, postgis
|
|
126
|
+
|
|
127
|
+
:json
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# MySQL 8+ doesn't allow default values on JSON columns. Returns an empty-hash
|
|
131
|
+
# default for SQLite/PostgreSQL, nil for MySQL. The models handle nil
|
|
132
|
+
# gracefully by defaulting to {} in their accessors.
|
|
133
|
+
def json_column_default
|
|
134
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
135
|
+
|
|
136
|
+
{}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Same MySQL caveat as `json_column_default`, but for list-shaped columns.
|
|
140
|
+
def json_array_default
|
|
141
|
+
return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
142
|
+
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Legal documents are routinely longer than MySQL's 64 KB TEXT limit, and a
|
|
147
|
+
# silently truncated agreement is the worst possible failure for this gem.
|
|
148
|
+
# `:mediumtext` maps to MEDIUMTEXT on MySQL (16 MB) and to ordinary TEXT
|
|
149
|
+
# everywhere else.
|
|
150
|
+
def text_column_type
|
|
151
|
+
return :mediumtext if connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
152
|
+
|
|
153
|
+
:text
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def quoted_values(values)
|
|
157
|
+
values.map { |value| connection.quote(value) }.join(", ")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# SQLite implements both foreign keys and check constraints by rebuilding a
|
|
161
|
+
# table. During a long install migration, Active Record can otherwise rebuild
|
|
162
|
+
# from a schema-cache entry captured before the indexes/columns immediately
|
|
163
|
+
# above were added. Refreshing around every rebuild keeps the SQLite result
|
|
164
|
+
# identical to PostgreSQL/MySQL instead of quietly resurrecting stale shape.
|
|
165
|
+
def add_clickwrap_check_constraint(table, expression, **options)
|
|
166
|
+
refresh_clickwrap_table_schema!(table)
|
|
167
|
+
add_check_constraint(table, expression, **options)
|
|
168
|
+
refresh_clickwrap_table_schema!(table)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def add_clickwrap_foreign_key(from_table, to_table, **options)
|
|
172
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
173
|
+
add_foreign_key(from_table, to_table, **options)
|
|
174
|
+
refresh_clickwrap_table_schema!(from_table)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def refresh_clickwrap_table_schema!(table)
|
|
178
|
+
connection.schema_cache.clear_data_source_cache!(table.to_s)
|
|
179
|
+
end
|
|
180
|
+
end
|