studio-engine 0.137.0 → 0.138.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/CHANGELOG.md +23 -0
- data/README.md +27 -1
- data/app/assets/tailwind/studio_engine/engine.css +228 -0
- data/app/controllers/concerns/studio/connection_flow.rb +1 -1
- data/app/controllers/sessions_controller.rb +12 -3
- data/app/controllers/studio/integrations_controller.rb +43 -0
- data/app/controllers/studio/local_reviews_controller.rb +12 -0
- data/app/javascript/studio/carousel.js +66 -0
- data/app/javascript/studio/controllers/carousel_controller.js +203 -1
- data/app/models/concerns/studio/account.rb +143 -0
- data/app/models/concerns/studio/omniauth_user.rb +9 -17
- data/app/models/error_log.rb +14 -5
- data/app/views/studio/_carousel.html.erb +58 -14
- data/app/views/studio/integrations/_card.html.erb +50 -0
- data/app/views/studio/integrations/index.html.erb +22 -0
- data/app/views/studio/integrations/show.html.erb +24 -0
- data/app/views/style/_tricks.html.erb +15 -0
- data/config/studio_tricks.yml +7 -0
- data/db/migrate/20261006120001_ensure_error_logs_table.rb +3 -1
- data/db/migrate/20261006120002_ensure_theme_settings_table.rb +3 -2
- data/db/migrate/20261108160000_require_slugs_on_engine_tables.rb +119 -0
- data/db/migrate/20261108160001_add_engine_state_checks.rb +94 -0
- data/db/migrate/20261108170000_add_users_role_known_check.rb +69 -0
- data/lib/generators/studio/connections/connections_generator.rb +10 -0
- data/lib/studio/connections.rb +53 -12
- data/lib/studio/integrations/egnyte.rb +69 -0
- data/lib/studio/integrations/microsoft_365.rb +63 -0
- data/lib/studio/integrations/quickbooks.rb +86 -0
- data/lib/studio/integrations.rb +265 -0
- data/lib/studio/node_tooling/README.md +9 -1
- data/lib/studio/node_tooling/e2e_helpers.js +93 -1
- data/lib/studio/node_tooling/playwright_base.js +51 -4
- data/lib/studio/roles.rb +17 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +59 -1
- metadata +15 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# /admin/integrations: every integration the host enabled
|
|
2
|
+
(Studio.enable_integration), with its status and Connect
|
|
3
|
+
(Studio::IntegrationsController#index). %>
|
|
4
|
+
<% content_for(:title, "Integrations") %>
|
|
5
|
+
<div class="max-w-4xl mx-auto px-4 py-6" data-studio-integrations>
|
|
6
|
+
<%= render "studio/page_header", title: "Integrations" %>
|
|
7
|
+
<p class="text-sm text-secondary mt-1 mb-6">Connect an account once. Each integration's page shows its connections and what this app reads.</p>
|
|
8
|
+
|
|
9
|
+
<% if @integrations.empty? %>
|
|
10
|
+
<div class="empty-state">
|
|
11
|
+
<p class="text-heading font-semibold mb-1">No integrations enabled</p>
|
|
12
|
+
<p class="text-sm text-muted">Enable one with <code>Studio.enable_integration</code>; see the engine's docs/INTEGRATIONS.md.</p>
|
|
13
|
+
</div>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="grid gap-4">
|
|
16
|
+
<% @integrations.each do |integration| %>
|
|
17
|
+
<%= render "studio/integrations/card", integration:, connections: integration.connections,
|
|
18
|
+
return_to: integrations_index_path, linked: true %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# /admin/integrations/<key>: one enabled integration, its connections and
|
|
2
|
+
Disconnect, the redirect URI to register, and the host's own panel
|
|
3
|
+
(Studio.enable_integration panel:), rendered with locals integration: and
|
|
4
|
+
connections: (Studio::IntegrationsController#show). %>
|
|
5
|
+
<% content_for(:title, @integration.label) %>
|
|
6
|
+
<div class="max-w-4xl mx-auto px-4 py-6" data-studio-integration="<%= @integration.key %>">
|
|
7
|
+
<%= render "studio/page_header", title: @integration.label %>
|
|
8
|
+
<p class="text-sm mt-1 mb-6"><a href="<%= integrations_index_path %>" class="text-secondary hover:underline">All integrations</a></p>
|
|
9
|
+
|
|
10
|
+
<%= render "studio/integrations/card", integration: @integration, connections: @connections,
|
|
11
|
+
return_to: integration_path_for(@integration), linked: false %>
|
|
12
|
+
|
|
13
|
+
<div class="card p-4 mt-4" data-studio-integration-redirect>
|
|
14
|
+
<p class="label-upper mb-1">Redirect URI</p>
|
|
15
|
+
<p class="text-sm text-secondary mb-2">Register this exact URL on the <%= @integration.label %> app as its redirect (callback) URI.</p>
|
|
16
|
+
<code class="text-sm break-all"><%= integration_callback_url(@integration) %></code>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<% if @integration.panel %>
|
|
20
|
+
<div class="mt-6" data-studio-integration-panel>
|
|
21
|
+
<%= render partial: @integration.panel, locals: { integration: @integration, connections: @connections } %>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
@@ -267,6 +267,21 @@
|
|
|
267
267
|
<% end %>
|
|
268
268
|
</div>
|
|
269
269
|
<% end %>
|
|
270
|
+
|
|
271
|
+
<%# The cascade: a 3D stack, five in view (visible: 3 shows one each side, and so
|
|
272
|
+
does a region under 36rem). Click a side card, drag, or use the arrow keys. %>
|
|
273
|
+
<%= render layout: "style/specimen", locals: { trick: "cascade" } do %>
|
|
274
|
+
<div class="w-full min-w-0" data-carousel-cascade-specimen>
|
|
275
|
+
<%= render layout: "studio/carousel",
|
|
276
|
+
locals: { label: "Cascade specimen", items: (1..7).to_a, style: :cascade, loop: true,
|
|
277
|
+
id: "style-carousel-cascade-specimen", caption: ->(n) { "Slide #{n}" } } do |n| %>
|
|
278
|
+
<div class="card h-full p-5 space-y-1 aspect-[4/3]" style="background-image: linear-gradient(150deg, rgb(var(--color-primary-rgb) / 0.35), transparent)">
|
|
279
|
+
<p class="label-upper">Slide <%= n %></p>
|
|
280
|
+
<p class="text-sm text-body">Any block content.</p>
|
|
281
|
+
</div>
|
|
282
|
+
<% end %>
|
|
283
|
+
</div>
|
|
284
|
+
<% end %>
|
|
270
285
|
</section>
|
|
271
286
|
|
|
272
287
|
<!-- Surfaces, text and form -->
|
data/config/studio_tricks.yml
CHANGED
|
@@ -160,6 +160,13 @@ spotlight:
|
|
|
160
160
|
note: "Centres one slide with its caption; its neighbours shrink and fade."
|
|
161
161
|
resolve: [partial:studio/carousel, css:carousel-spotlight]
|
|
162
162
|
|
|
163
|
+
cascade:
|
|
164
|
+
name: Cascade Carousel
|
|
165
|
+
group: Carousel
|
|
166
|
+
token: "render layout: \"studio/carousel\", style: :cascade"
|
|
167
|
+
note: "A 3D stack: the open slide in front, the others stepping back either side."
|
|
168
|
+
resolve: [partial:studio/carousel, css:carousel-cascade]
|
|
169
|
+
|
|
163
170
|
card:
|
|
164
171
|
name: Card
|
|
165
172
|
group: "Surfaces, text & form"
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
# fail on existing rows or lock a large table.
|
|
15
15
|
#
|
|
16
16
|
# On every consumer that existed when this shipped, the second branch adds
|
|
17
|
-
# nothing. Studio::HostSchema checks the same columns at boot.
|
|
17
|
+
# nothing. Studio::HostSchema checks the same columns at boot. The slug
|
|
18
|
+
# standard (NOT NULL, unique) is the later RequireSlugsOnEngineTables
|
|
19
|
+
# (20261108160000), which tightens the column on every host.
|
|
18
20
|
class EnsureErrorLogsTable < ActiveRecord::Migration[7.2]
|
|
19
21
|
def up
|
|
20
22
|
unless table_exists?(:error_logs)
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
# Sluggable, whose before_save writes `slug` on every save, and the setup doc's
|
|
6
6
|
# hand-copied migration omitted it, so a theme save raises on any host built
|
|
7
7
|
# from that doc (cyvasse, mcritchie-industries and moms-app when this shipped).
|
|
8
|
-
# The slug is "theme-<app_name>", derived on save
|
|
9
|
-
# slug
|
|
8
|
+
# The slug is "theme-<app_name>", derived on save. Rows this left with a NULL
|
|
9
|
+
# slug are filled, and the column made NOT NULL and unique, by the later
|
|
10
|
+
# RequireSlugsOnEngineTables (20261108160000).
|
|
10
11
|
#
|
|
11
12
|
# A baseline, not a rebuild:
|
|
12
13
|
#
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# The engine's own settings and log tables take the hub's slug standard:
|
|
2
|
+
# `slug` NOT NULL with a unique index, on error_logs, theme_settings,
|
|
3
|
+
# studio_geo_settings and studio_site_identities (database audit 2026-11-07,
|
|
4
|
+
# finding 6). Every host inherits the tables, so one migration reaches them all.
|
|
5
|
+
#
|
|
6
|
+
# Each table goes through three steps, and a table the host does not have is
|
|
7
|
+
# skipped (its own create migration runs first whenever it is installed, since
|
|
8
|
+
# install:migrations copies the engine's migrations in order):
|
|
9
|
+
#
|
|
10
|
+
# 1. Backfill. A NULL or blank slug gets the one its model would write
|
|
11
|
+
# (Sluggable's `name_slug`): `error-log-<id>`, `theme-<app_name>`,
|
|
12
|
+
# `geo-<app_name>`, `site-identity-<app_name>`, with the app name
|
|
13
|
+
# parameterized. A slug another row already holds takes `-<id>` after it.
|
|
14
|
+
# 2. Duplicates. A table without a unique slug index (turf-monster's
|
|
15
|
+
# error_logs) may hold a slug twice; every copy after the lowest id takes
|
|
16
|
+
# `-<id>` after it. No table here has slug children, so nothing else
|
|
17
|
+
# references the old value.
|
|
18
|
+
# 3. NOT NULL, then the unique index on `slug` unless one is there. A
|
|
19
|
+
# non-unique slug index is replaced by the unique one.
|
|
20
|
+
#
|
|
21
|
+
# Production on 2026-11-08 (every app with a database, read-only counts): no
|
|
22
|
+
# NULL or blank slug, no duplicate, so steps 1 and 2 change no row there. The
|
|
23
|
+
# largest table is the hub's error_logs at 606 rows, so the NOT NULL scan and
|
|
24
|
+
# the index build are short. On Postgres each ALTER waits at most LOCK_TIMEOUT
|
|
25
|
+
# for its lock; a migrate that cannot get it fails whole, changes nothing, and
|
|
26
|
+
# runs again on the next release.
|
|
27
|
+
class RequireSlugsOnEngineTables < ActiveRecord::Migration[7.2]
|
|
28
|
+
LOCK_TIMEOUT = "10s"
|
|
29
|
+
|
|
30
|
+
# table => the slug prefix its model's name_slug writes before the app name.
|
|
31
|
+
# error_logs has no app name: its slug is the id.
|
|
32
|
+
NAMED = {
|
|
33
|
+
"theme_settings" => "theme",
|
|
34
|
+
"studio_geo_settings" => "geo",
|
|
35
|
+
"studio_site_identities" => "site-identity"
|
|
36
|
+
}.freeze
|
|
37
|
+
TABLES = (["error_logs"] + NAMED.keys).freeze
|
|
38
|
+
|
|
39
|
+
def up
|
|
40
|
+
set_lock_timeout
|
|
41
|
+
TABLES.each do |table|
|
|
42
|
+
next unless table_exists?(table) && column_exists?(table, :slug)
|
|
43
|
+
|
|
44
|
+
backfill(table)
|
|
45
|
+
dedupe(table)
|
|
46
|
+
change_column_null table, :slug, false
|
|
47
|
+
unique_slug_index(table)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Only the NOT NULL is undone. The unique index stays: most hosts had it before
|
|
52
|
+
# this migration, and a down cannot tell which index it added. Backfilled slugs
|
|
53
|
+
# stay too; a slug is a valid value either way.
|
|
54
|
+
def down
|
|
55
|
+
TABLES.each do |table|
|
|
56
|
+
next unless table_exists?(table) && column_exists?(table, :slug)
|
|
57
|
+
|
|
58
|
+
change_column_null table, :slug, true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def set_lock_timeout
|
|
65
|
+
return unless connection.adapter_name.downcase.include?("postgres")
|
|
66
|
+
|
|
67
|
+
execute "SET LOCAL lock_timeout = '#{LOCK_TIMEOUT}'"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def quoted(table) = connection.quote_table_name(table)
|
|
71
|
+
|
|
72
|
+
def backfill(table)
|
|
73
|
+
rows = connection.select_rows(<<~SQL.squish)
|
|
74
|
+
SELECT id#{', app_name' if NAMED.key?(table)} FROM #{quoted(table)}
|
|
75
|
+
WHERE slug IS NULL OR slug = '' ORDER BY id
|
|
76
|
+
SQL
|
|
77
|
+
rows.each do |id, app_name|
|
|
78
|
+
derived = derived_slug(table, id, app_name)
|
|
79
|
+
derived = "#{derived}-#{id}" if taken?(table, derived, id)
|
|
80
|
+
write_slug(table, id, derived)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def derived_slug(table, id, app_name)
|
|
85
|
+
return "error-log-#{id}" unless NAMED.key?(table)
|
|
86
|
+
|
|
87
|
+
name = ActiveSupport::Inflector.parameterize(app_name.to_s)
|
|
88
|
+
name.empty? ? "#{NAMED.fetch(table)}-#{id}" : "#{NAMED.fetch(table)}-#{name}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def dedupe(table)
|
|
92
|
+
duplicates = connection.select_rows(<<~SQL.squish)
|
|
93
|
+
SELECT id, slug FROM #{quoted(table)} later
|
|
94
|
+
WHERE EXISTS (SELECT 1 FROM #{quoted(table)} earlier WHERE earlier.slug = later.slug AND earlier.id < later.id)
|
|
95
|
+
ORDER BY id
|
|
96
|
+
SQL
|
|
97
|
+
duplicates.each { |id, slug| write_slug(table, id, "#{slug}-#{id}") }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def taken?(table, slug, id)
|
|
101
|
+
connection.select_value(<<~SQL.squish).present?
|
|
102
|
+
SELECT 1 FROM #{quoted(table)} WHERE slug = #{connection.quote(slug)} AND id <> #{Integer(id)} LIMIT 1
|
|
103
|
+
SQL
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_slug(table, id, slug)
|
|
107
|
+
connection.update(<<~SQL.squish, "#{self.class.name} backfill")
|
|
108
|
+
UPDATE #{quoted(table)} SET slug = #{connection.quote(slug)} WHERE id = #{Integer(id)}
|
|
109
|
+
SQL
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unique_slug_index(table)
|
|
113
|
+
slug_indexes = connection.indexes(table).select { |index| Array(index.columns) == ["slug"] }
|
|
114
|
+
return if slug_indexes.any?(&:unique)
|
|
115
|
+
|
|
116
|
+
slug_indexes.each { |index| remove_index table, name: index.name }
|
|
117
|
+
add_index table, :slug, unique: true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# The engine's two state columns take the hub's state standard: a CHECK named
|
|
2
|
+
# `<table>_<column>_known` holding the list the model validates against, so a
|
|
3
|
+
# write that skips validations (update_columns, update_all, insert_all, raw SQL)
|
|
4
|
+
# is refused by the database (database audit 2026-11-07, finding 6).
|
|
5
|
+
#
|
|
6
|
+
# studio_links.kind Studio::LinkToken::KINDS (Studio::Link validates it)
|
|
7
|
+
# studio_knowledge_docs.status Studio::KnowledgeDoc::STATUSES
|
|
8
|
+
#
|
|
9
|
+
# The lists below are this migration's own frozen copy. A new value needs a new
|
|
10
|
+
# migration: test/integration/engine_state_checks_test.rb fails while a model's
|
|
11
|
+
# list and this copy differ.
|
|
12
|
+
#
|
|
13
|
+
# A host without the table is skipped, and a constraint already there is left
|
|
14
|
+
# alone, so a second run changes nothing. Both columns are NOT NULL already.
|
|
15
|
+
#
|
|
16
|
+
# A row outside its list stops the migration before any change, with the table,
|
|
17
|
+
# the column and the stray values in the message: the constraint is validated,
|
|
18
|
+
# so it cannot go on over such a row. Production on 2026-11-08 held none (every
|
|
19
|
+
# app with a database, read-only counts): studio_links holds magic_link and
|
|
20
|
+
# referral, studio_knowledge_docs holds filed and superseded (industries alone).
|
|
21
|
+
# The validating scan is short (the largest table is 366 rows). On Postgres the
|
|
22
|
+
# ALTER waits at most LOCK_TIMEOUT for its lock; a migrate that cannot get it
|
|
23
|
+
# fails whole and runs again on the next release.
|
|
24
|
+
class AddEngineStateChecks < ActiveRecord::Migration[7.2]
|
|
25
|
+
LOCK_TIMEOUT = "10s"
|
|
26
|
+
|
|
27
|
+
CHECKS = {
|
|
28
|
+
"studio_links.kind" => %w[magic_link referral],
|
|
29
|
+
"studio_knowledge_docs.status" => %w[inbox filed superseded]
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# The constraint's name: `<table>_<column>_known`, as the hub names its own.
|
|
33
|
+
def self.constraint_name(key) = key.tr(".", "_") + "_known"
|
|
34
|
+
|
|
35
|
+
def up
|
|
36
|
+
set_lock_timeout
|
|
37
|
+
CHECKS.each do |key, values|
|
|
38
|
+
table, column = key.split(".")
|
|
39
|
+
next unless table_exists?(table)
|
|
40
|
+
next if check_constraint_exists?(table, name: self.class.constraint_name(key))
|
|
41
|
+
|
|
42
|
+
refuse_strays!(table, column, values)
|
|
43
|
+
add_check_constraint table, expression(column, values), name: self.class.constraint_name(key)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def down
|
|
48
|
+
CHECKS.each_key do |key|
|
|
49
|
+
table, = key.split(".")
|
|
50
|
+
next unless table_exists?(table)
|
|
51
|
+
|
|
52
|
+
remove_check_constraint table, name: self.class.constraint_name(key), if_exists: true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def set_lock_timeout
|
|
59
|
+
return unless postgres?
|
|
60
|
+
|
|
61
|
+
execute "SET LOCAL lock_timeout = '#{LOCK_TIMEOUT}'"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def list(values) = values.map { |value| connection.quote(value) }.join(", ")
|
|
65
|
+
|
|
66
|
+
# On Postgres, the spelling Postgres itself settles on once a schema.rb is
|
|
67
|
+
# loaded (`'x'::character varying::text`), so a host's schema dump after this
|
|
68
|
+
# migrate equals the dump after a load, byte for byte. An `IN (...)` list
|
|
69
|
+
# dumps as `ARRAY[...]::text[]` first and changes on the next load. Anywhere
|
|
70
|
+
# else (the engine's SQLite test host), the plain IN list.
|
|
71
|
+
def expression(column, values)
|
|
72
|
+
col = connection.quote_column_name(column)
|
|
73
|
+
return "#{col} IN (#{list(values)})" unless postgres?
|
|
74
|
+
|
|
75
|
+
array = values.map { |value| "#{connection.quote(value)}::character varying::text" }.join(", ")
|
|
76
|
+
"(#{col})::text = ANY (ARRAY[#{array}])"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def postgres? = connection.adapter_name.downcase.include?("postgres")
|
|
80
|
+
|
|
81
|
+
def refuse_strays!(table, column, values)
|
|
82
|
+
col = connection.quote_column_name(column)
|
|
83
|
+
strays = connection.select_rows(<<~SQL.squish)
|
|
84
|
+
SELECT #{col}, COUNT(*) FROM #{connection.quote_table_name(table)}
|
|
85
|
+
WHERE #{col} IS NOT NULL AND #{col} NOT IN (#{list(values)}) GROUP BY #{col} ORDER BY #{col}
|
|
86
|
+
SQL
|
|
87
|
+
return if strays.empty?
|
|
88
|
+
|
|
89
|
+
found = strays.map { |value, count| "#{value.inspect} (#{count})" }.join(", ")
|
|
90
|
+
raise ActiveRecord::MigrationError,
|
|
91
|
+
"#{table}.#{column} holds values outside #{values.inspect}: #{found}. " \
|
|
92
|
+
"Resolve those rows, then migrate again."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# CHECK users_role_known on the host's `users.role` (database audit 2026-11-07,
|
|
2
|
+
# section 3): the database half of Studio::Account's role validation, so a
|
|
3
|
+
# mistyped role is refused rather than saved as a quiet non-admin.
|
|
4
|
+
#
|
|
5
|
+
# THIS IS A MIGRATION AGAINST A HOST TABLE, shipped like the standard profile
|
|
6
|
+
# columns (20260813220000): install-copied with
|
|
7
|
+
# `bin/rails studio_engine:install:migrations`, never auto-run, and safe on an
|
|
8
|
+
# app that already disagrees.
|
|
9
|
+
#
|
|
10
|
+
# * No `users` table, no `role` column, or a role that is not a string (an
|
|
11
|
+
# integer enum): skipped.
|
|
12
|
+
# * A CHECK named users_role_known already there: skipped. The hub and Turf
|
|
13
|
+
# installed their own on 2026-11-08, narrowed to viewer + admin, and a host's
|
|
14
|
+
# narrower CHECK is the stronger guard; this one is the ceiling.
|
|
15
|
+
# * A row holding a role outside the list: REFUSED, naming the values, with
|
|
16
|
+
# nothing written. A stray role is a product call (Turf's "user" meant
|
|
17
|
+
# viewer); this migration does not guess it.
|
|
18
|
+
#
|
|
19
|
+
# Then it fills a NULL role with "viewer", sets NOT NULL, and adds the CHECK.
|
|
20
|
+
#
|
|
21
|
+
# The list is SPELLED OUT, not read from Studio::Roles: a migration is a record
|
|
22
|
+
# that must still run years from now, and a live constant may move. Widening the
|
|
23
|
+
# engine's list is a new migration that replaces this CHECK.
|
|
24
|
+
class AddUsersRoleKnownCheck < ActiveRecord::Migration[7.2]
|
|
25
|
+
ROLES = %w[viewer member admin].freeze
|
|
26
|
+
NAME = "users_role_known"
|
|
27
|
+
|
|
28
|
+
def up
|
|
29
|
+
return unless string_role?
|
|
30
|
+
return if check_constraint_exists?(:users, name: NAME)
|
|
31
|
+
|
|
32
|
+
allowed = ROLES.map { |role| connection.quote(role) }.join(", ")
|
|
33
|
+
unknown = select_values("SELECT DISTINCT role FROM users WHERE role IS NOT NULL AND role NOT IN (#{allowed})")
|
|
34
|
+
if unknown.any?
|
|
35
|
+
raise ActiveRecord::MigrationError, <<~MSG
|
|
36
|
+
AddUsersRoleKnownCheck refused: users.role holds #{unknown.sort.map(&:inspect).join(', ')},
|
|
37
|
+
outside Studio::Roles (#{ROLES.join(', ')}). Move those rows to a known role in a
|
|
38
|
+
migration of this app's own (Turf moved "user" to "viewer"), then run this again.
|
|
39
|
+
MSG
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
execute "UPDATE users SET role = 'viewer' WHERE role IS NULL"
|
|
43
|
+
change_column_null :users, :role, false
|
|
44
|
+
add_check_constraint :users, "role IN (#{allowed})", name: NAME
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Removes the CHECK only when it is this migration's: one that names every
|
|
48
|
+
# engine role. A host's own users_role_known, which `up` left in place, is
|
|
49
|
+
# narrower (the hub's and Turf's name viewer and admin only) and stays. NOT
|
|
50
|
+
# NULL stays too: `up` cannot record whether the host had it already, and a
|
|
51
|
+
# NULL role is the one value no host wants back.
|
|
52
|
+
def down
|
|
53
|
+
return unless string_role?
|
|
54
|
+
|
|
55
|
+
ours = check_constraints(:users).find { |check| check.name == NAME }
|
|
56
|
+
return unless ours && ROLES.all? { |role| ours.expression.include?("'#{role}'") }
|
|
57
|
+
|
|
58
|
+
remove_check_constraint :users, name: NAME
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def string_role?
|
|
64
|
+
return false unless table_exists?(:users)
|
|
65
|
+
|
|
66
|
+
column = columns(:users).find { |c| c.name == "role" }
|
|
67
|
+
column && %i[string text].include?(column.type)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -53,6 +53,9 @@ module Studio
|
|
|
53
53
|
bin/rails db:migrate
|
|
54
54
|
Declare each provider in #{INITIALIZER}, then draw its routes in config/routes.rb:
|
|
55
55
|
Studio.draw_connection_routes(self, :files, path: "admin/files", controller: "admin/files")
|
|
56
|
+
Or enable an integration the engine defines (docs/INTEGRATIONS.md) and mount its page:
|
|
57
|
+
Studio.enable_integration :quickbooks, env: { client_id: "INTUIT_CLIENT_ID", client_secret: "INTUIT_CLIENT_SECRET" }
|
|
58
|
+
Studio.draw_integrations_routes(self)
|
|
56
59
|
Set the provider's client id and secret, and the app's Active Record encryption keys.
|
|
57
60
|
TEXT
|
|
58
61
|
end
|
|
@@ -68,6 +71,13 @@ module Studio
|
|
|
68
71
|
INITIALIZER_BODY = <<~RUBY
|
|
69
72
|
# frozen_string_literal: true
|
|
70
73
|
|
|
74
|
+
# Integrations the engine already defines (studio-engine docs/INTEGRATIONS.md):
|
|
75
|
+
# QuickBooks, Egnyte, Microsoft 365. One line each, naming this app's env
|
|
76
|
+
# vars; mount the page with Studio.draw_integrations_routes(self).
|
|
77
|
+
#
|
|
78
|
+
# Studio.enable_integration :quickbooks,
|
|
79
|
+
# env: { client_id: "INTUIT_CLIENT_ID", client_secret: "INTUIT_CLIENT_SECRET", environment: "INTUIT_ENVIRONMENT" }
|
|
80
|
+
#
|
|
71
81
|
# Connections (studio-engine docs/CONNECTIONS.md). One Studio.define_connection
|
|
72
82
|
# per provider this app connects; each stays off until its client id and
|
|
73
83
|
# secret are set. Draw its routes in config/routes.rb with
|
data/lib/studio/connections.rb
CHANGED
|
@@ -9,7 +9,7 @@ require "set"
|
|
|
9
9
|
|
|
10
10
|
module Studio
|
|
11
11
|
# CONNECTIONS: the generic tooling a host app uses to connect a third-party
|
|
12
|
-
# account over OAuth 2.0 (docs/CONNECTIONS.md). The
|
|
12
|
+
# account over OAuth 2.0 (docs/CONNECTIONS.md). The tooling knows no provider.
|
|
13
13
|
# A host declares each one in an initializer, and the engine gives it:
|
|
14
14
|
#
|
|
15
15
|
# * Studio::Connection, one table (studio_connections) holding one row per
|
|
@@ -30,7 +30,8 @@ module Studio
|
|
|
30
30
|
# account: :connect, account_param: "domain", account_format: /\A[a-z0-9-]{1,63}\z/
|
|
31
31
|
#
|
|
32
32
|
# Provider specifics (endpoints, scopes, quirks, readers, the admin page's
|
|
33
|
-
# live read) belong to the host. Nothing
|
|
33
|
+
# live read) belong to the host. Nothing in this file is named after a vendor:
|
|
34
|
+
# the engine's own definitions live in Studio::Integrations (docs/INTEGRATIONS.md).
|
|
34
35
|
module Connections
|
|
35
36
|
# What reaching a provider can raise below HTTP: TLS, refused, timed out, DNS.
|
|
36
37
|
NETWORK_ERRORS = [OpenSSL::SSL::SSLError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout].freeze
|
|
@@ -38,7 +39,18 @@ module Studio
|
|
|
38
39
|
ENCRYPTION_ENV = %w[ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
|
|
39
40
|
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT].freeze
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
# Every refusal below. A provider that names a trace_header (Intuit's
|
|
43
|
+
# intuit_tid) puts the answering call's trace id on the error: as #trace_id,
|
|
44
|
+
# and in the message, so the error log and the page carry it. Never a token.
|
|
45
|
+
class Error < StandardError
|
|
46
|
+
attr_reader :trace_header, :trace_id
|
|
47
|
+
|
|
48
|
+
def initialize(message = nil, trace_header: nil, trace_id: nil)
|
|
49
|
+
@trace_header = trace_header
|
|
50
|
+
@trace_id = trace_id.to_s.strip.presence
|
|
51
|
+
super(@trace_id && message ? "#{message} [#{@trace_header}: #{@trace_id}]" : message)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
42
54
|
# The provider's client id or secret is not set on this server.
|
|
43
55
|
class NotConfigured < Error; end
|
|
44
56
|
# The provider no longer honours the stored grant: revoked, expired, superseded.
|
|
@@ -175,7 +187,7 @@ module Studio
|
|
|
175
187
|
:revoke_format, :account_source, :account_param, :account_format, :private_account,
|
|
176
188
|
:authorize_params, :exchange_params, :rotation, :refresh_expires_in_key, :declined_errors,
|
|
177
189
|
:refusal_hint, :revoked_errors, :revoked_pattern, :rate_limited_statuses, :filter_keys,
|
|
178
|
-
:revoke_note, :callback_check, :describe
|
|
190
|
+
:revoke_note, :callback_check, :describe, :trace_header
|
|
179
191
|
|
|
180
192
|
# Stand-in for the provider's token and revoke endpoints:
|
|
181
193
|
# (url, form, headers) -> [status, body]. nil means the provider itself.
|
|
@@ -195,7 +207,7 @@ module Studio
|
|
|
195
207
|
rotation: nil, refresh_expires_in_key: nil, declined_errors: %w[access_denied],
|
|
196
208
|
refusal_hint: "Check the app's redirect URI, then start again.", revoked_errors: %w[invalid_grant],
|
|
197
209
|
revoked_pattern: nil, rate_limited_statuses: [429], filter_keys: [], revoke_note: nil,
|
|
198
|
-
callback_check: nil, describe: nil)
|
|
210
|
+
callback_check: nil, describe: nil, trace_header: nil)
|
|
199
211
|
@key = key.to_s
|
|
200
212
|
raise ArgumentError, "a connection key is lower-case letters, digits and underscores: #{@key.inspect}" unless @key.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
201
213
|
|
|
@@ -240,6 +252,10 @@ module Studio
|
|
|
240
252
|
@revoke_note = revoke_note || "Remove this app's access in #{@label} to revoke it there."
|
|
241
253
|
@callback_check = callback_check
|
|
242
254
|
@describe = describe
|
|
255
|
+
# A response header naming the call for the provider's support (Intuit:
|
|
256
|
+
# intuit_tid). Read from every token, refresh and revoke answer, logged
|
|
257
|
+
# with the call, kept as #last_trace_id and put on every error raised.
|
|
258
|
+
@trace_header = trace_header&.to_s
|
|
243
259
|
@cache = {}
|
|
244
260
|
@cache_lock = Mutex.new
|
|
245
261
|
end
|
|
@@ -349,6 +365,27 @@ module Studio
|
|
|
349
365
|
@cache_lock.synchronize { account ? @cache.delete(account.to_s.downcase) : @cache.clear }
|
|
350
366
|
end
|
|
351
367
|
|
|
368
|
+
# The trace_header value of this thread's latest call to the provider, or nil.
|
|
369
|
+
def last_trace_id = (Thread.current[:studio_connection_traces] ||= {})[key]
|
|
370
|
+
|
|
371
|
+
# Reads trace_header from a response's headers (a Hash or a Net::HTTPResponse),
|
|
372
|
+
# remembers it as last_trace_id, and logs it with the call (no token, no body).
|
|
373
|
+
# Host readers call it on their own responses too.
|
|
374
|
+
def record_trace(headers, call:, status:)
|
|
375
|
+
return nil unless trace_header
|
|
376
|
+
|
|
377
|
+
value = case headers
|
|
378
|
+
when Hash then headers.find { |name, _| name.to_s.casecmp?(trace_header) }&.last
|
|
379
|
+
else headers.respond_to?(:[]) ? headers[trace_header] : nil
|
|
380
|
+
end
|
|
381
|
+
value = Array(value).first.to_s.strip.presence
|
|
382
|
+
(Thread.current[:studio_connection_traces] ||= {})[key] = value
|
|
383
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
384
|
+
::Rails.logger.info("[studio_connection] provider=#{key} call=#{call} status=#{status} #{trace_header}=#{value || '-'}")
|
|
385
|
+
end
|
|
386
|
+
value
|
|
387
|
+
end
|
|
388
|
+
|
|
352
389
|
# How many accounts have a token cached in this process (:memory).
|
|
353
390
|
def cached_count = @cache_lock.synchronize { @cache.size }
|
|
354
391
|
|
|
@@ -433,22 +470,24 @@ module Studio
|
|
|
433
470
|
when :secret_only then form = form.merge(client_secret:)
|
|
434
471
|
when :basic then headers["Authorization"] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
|
|
435
472
|
end
|
|
436
|
-
status, body = call(url, form, headers)
|
|
473
|
+
status, body, answered = call(url, form, headers)
|
|
474
|
+
trace = record_trace(answered, call: form.key?(:token) ? "revoke" : form[:grant_type].to_s, status:)
|
|
437
475
|
json = parse(body)
|
|
438
476
|
# A revoke (allow_empty) is confirmed by any 2xx, a 204 included; a token answer must be a 200 with a body.
|
|
439
477
|
return json if allow_empty ? (200..299).cover?(status) : (status == 200 && json.any?)
|
|
440
478
|
|
|
441
|
-
raise_for(status, json)
|
|
479
|
+
raise_for(status, json, trace)
|
|
442
480
|
end
|
|
443
481
|
|
|
444
|
-
def raise_for(status, json)
|
|
482
|
+
def raise_for(status, json, trace = nil)
|
|
445
483
|
detail = json["error_description"].to_s.lines.first.to_s.strip
|
|
446
484
|
detail = (json["error"] || json["message"] || json["errorMessage"]).to_s if detail.empty?
|
|
447
485
|
detail = "no detail" if detail.empty?
|
|
448
|
-
|
|
449
|
-
raise
|
|
486
|
+
traced = { trace_header:, trace_id: trace }
|
|
487
|
+
raise RateLimited.new("#{label} is throttling this app (#{status}); try again in a few minutes", **traced) if rate_limited_statuses.include?(status)
|
|
488
|
+
raise Revoked.new("#{label} no longer accepts this connection (#{detail}); connect again", **traced) if revoked?(status, json, detail)
|
|
450
489
|
|
|
451
|
-
raise Error
|
|
490
|
+
raise Error.new("#{label} answered #{status}: #{detail}", **traced)
|
|
452
491
|
end
|
|
453
492
|
|
|
454
493
|
def revoked?(status, json, detail)
|
|
@@ -464,6 +503,8 @@ module Studio
|
|
|
464
503
|
{}
|
|
465
504
|
end
|
|
466
505
|
|
|
506
|
+
# [status, body, response headers]. A stand-in may answer [status, body]
|
|
507
|
+
# or [status, body, { "header" => "value" }].
|
|
467
508
|
def call(url, form, headers)
|
|
468
509
|
return http.call(url, form, headers) if http
|
|
469
510
|
|
|
@@ -474,7 +515,7 @@ module Studio
|
|
|
474
515
|
headers.each { |name, value| request[name] = value }
|
|
475
516
|
request.body = headers["Content-Type"] == "application/json" ? JSON.generate(form) : URI.encode_www_form(form)
|
|
476
517
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 20) { _1.request(request) }
|
|
477
|
-
[response.code.to_i, response.body]
|
|
518
|
+
[response.code.to_i, response.body, response]
|
|
478
519
|
rescue *NETWORK_ERRORS => e
|
|
479
520
|
raise Error, "#{label} could not be reached: #{e.class}"
|
|
480
521
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
module Integrations
|
|
5
|
+
# EGNYTE, the connection only. A host's readers (folders, files) are its
|
|
6
|
+
# own; they call Studio.integration(:egnyte).api_base(connection).
|
|
7
|
+
#
|
|
8
|
+
# * Every Egnyte domain answers on its own host, <domain>.egnyte.com, so
|
|
9
|
+
# the admin types the domain at Connect (a POST, CSRF-checked). It is
|
|
10
|
+
# normalized ("Acme", "acme.egnyte.com" and "https://acme.egnyte.com/"
|
|
11
|
+
# are all "acme") and held to a DNS label before it reaches a URL.
|
|
12
|
+
# * One scope, Egnyte.filesystem, on the authorize URL and the exchange.
|
|
13
|
+
# The sign-in page and the token endpoint share one path.
|
|
14
|
+
# * CACHED tokens: the access token lives 30 days and Egnyte asks callers
|
|
15
|
+
# to cache it (its token endpoint allows about 100 requests an hour), so
|
|
16
|
+
# the row keeps it and refreshes only within a day of its expiry or after
|
|
17
|
+
# a 401. A refresh answers a new pair, stored before use.
|
|
18
|
+
# * The revoke takes the token and the client secret, not the id.
|
|
19
|
+
# * The token endpoint throttles with a 409 (and the API with a 429); an
|
|
20
|
+
# "invalid refresh token" detail means the grant is gone.
|
|
21
|
+
#
|
|
22
|
+
# Checked against the hub's Egnyte::OAuthClient, EgnyteConnection and
|
|
23
|
+
# Egnyte::Reader (mcritchie-studio), which cite Egnyte's authentication docs.
|
|
24
|
+
module Egnyte
|
|
25
|
+
HOST_SUFFIX = ".egnyte.com"
|
|
26
|
+
TOKEN_PATH = "/puboauth/token"
|
|
27
|
+
REVOKE_PATH = "/pubapi/v1/tokens/revoke"
|
|
28
|
+
API_PATH = "/pubapi/v1"
|
|
29
|
+
SCOPE = "Egnyte.filesystem"
|
|
30
|
+
# A DNS label: what may stand before .egnyte.com. Nothing else reaches a URL.
|
|
31
|
+
DOMAIN = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/
|
|
32
|
+
|
|
33
|
+
def self.host(domain) = "https://#{domain}#{HOST_SUFFIX}"
|
|
34
|
+
|
|
35
|
+
# The bare domain from what an admin typed, lower-cased (still unchecked).
|
|
36
|
+
def self.normalize(value)
|
|
37
|
+
value.to_s.strip.downcase.sub(%r{\Ahttps?://}, "").sub(%r{[/?#].*\z}, "").delete_suffix(HOST_SUFFIX)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Integrations.register :egnyte,
|
|
41
|
+
label: "Egnyte",
|
|
42
|
+
summary: "An Egnyte domain's files, read with a user's own sign-in.",
|
|
43
|
+
connection: {
|
|
44
|
+
authorize_url: ->(domain) { "#{Egnyte.host(domain)}#{TOKEN_PATH}" },
|
|
45
|
+
token_url: ->(domain) { "#{Egnyte.host(domain)}#{TOKEN_PATH}" },
|
|
46
|
+
revoke_url: ->(domain) { "#{Egnyte.host(domain)}#{REVOKE_PATH}" },
|
|
47
|
+
scopes: [SCOPE], exchange_params: { scope: SCOPE },
|
|
48
|
+
client_auth: :form, revoke_auth: :secret_only,
|
|
49
|
+
refresh: :cached, refresh_margin: 1.day,
|
|
50
|
+
account: :connect, account_param: "egnyte_domain", account_format: DOMAIN,
|
|
51
|
+
normalize_account: ->(value) { Egnyte.normalize(value) },
|
|
52
|
+
rate_limited_statuses: [409, 429], revoked_pattern: /invalid refresh token|invalid_grant/i,
|
|
53
|
+
refusal_hint: "Check the developer key's redirect URI, then start again."
|
|
54
|
+
},
|
|
55
|
+
account_label: "Egnyte domain",
|
|
56
|
+
account_hint: "yourcompany (from yourcompany.egnyte.com)",
|
|
57
|
+
describe: lambda { |_integration, grant:, account:, get:|
|
|
58
|
+
token = grant["access_token"].to_s
|
|
59
|
+
next {} if token.empty?
|
|
60
|
+
|
|
61
|
+
username = get.call("#{Egnyte.host(account)}#{API_PATH}/whoami", token)["username"].to_s.strip
|
|
62
|
+
{ metadata: username.empty? ? {} : { "username" => username } }
|
|
63
|
+
},
|
|
64
|
+
default_label: ->(domain) { "#{domain}#{HOST_SUFFIX}" },
|
|
65
|
+
api_base: ->(_integration, connection) { "#{Egnyte.host(connection.account_id)}#{API_PATH}" },
|
|
66
|
+
stand_in: { answer: { "username" => "stand-in" } }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|