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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +27 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +228 -0
  5. data/app/controllers/concerns/studio/connection_flow.rb +1 -1
  6. data/app/controllers/sessions_controller.rb +12 -3
  7. data/app/controllers/studio/integrations_controller.rb +43 -0
  8. data/app/controllers/studio/local_reviews_controller.rb +12 -0
  9. data/app/javascript/studio/carousel.js +66 -0
  10. data/app/javascript/studio/controllers/carousel_controller.js +203 -1
  11. data/app/models/concerns/studio/account.rb +143 -0
  12. data/app/models/concerns/studio/omniauth_user.rb +9 -17
  13. data/app/models/error_log.rb +14 -5
  14. data/app/views/studio/_carousel.html.erb +58 -14
  15. data/app/views/studio/integrations/_card.html.erb +50 -0
  16. data/app/views/studio/integrations/index.html.erb +22 -0
  17. data/app/views/studio/integrations/show.html.erb +24 -0
  18. data/app/views/style/_tricks.html.erb +15 -0
  19. data/config/studio_tricks.yml +7 -0
  20. data/db/migrate/20261006120001_ensure_error_logs_table.rb +3 -1
  21. data/db/migrate/20261006120002_ensure_theme_settings_table.rb +3 -2
  22. data/db/migrate/20261108160000_require_slugs_on_engine_tables.rb +119 -0
  23. data/db/migrate/20261108160001_add_engine_state_checks.rb +94 -0
  24. data/db/migrate/20261108170000_add_users_role_known_check.rb +69 -0
  25. data/lib/generators/studio/connections/connections_generator.rb +10 -0
  26. data/lib/studio/connections.rb +53 -12
  27. data/lib/studio/integrations/egnyte.rb +69 -0
  28. data/lib/studio/integrations/microsoft_365.rb +63 -0
  29. data/lib/studio/integrations/quickbooks.rb +86 -0
  30. data/lib/studio/integrations.rb +265 -0
  31. data/lib/studio/node_tooling/README.md +9 -1
  32. data/lib/studio/node_tooling/e2e_helpers.js +93 -1
  33. data/lib/studio/node_tooling/playwright_base.js +51 -4
  34. data/lib/studio/roles.rb +17 -0
  35. data/lib/studio/version.rb +1 -1
  36. data/lib/studio.rb +59 -1
  37. 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 -->
@@ -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; existing rows keep a NULL
9
- # slug until their next save, and nothing looks a theme up by 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
@@ -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 engine knows no provider.
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 here is named after a vendor.
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
- class Error < StandardError; end
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
- raise RateLimited, "#{label} is throttling this app (#{status}); try again in a few minutes" if rate_limited_statuses.include?(status)
449
- raise Revoked, "#{label} no longer accepts this connection (#{detail}); connect again" if revoked?(status, json, detail)
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, "#{label} answered #{status}: #{detail}"
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