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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
module Integrations
|
|
5
|
+
# MICROSOFT 365 (Microsoft Graph, app-only), the connection only. A host's
|
|
6
|
+
# readers (users, teams, sites) are its own; they call
|
|
7
|
+
# Studio.integration(:microsoft_365).api_base(connection) with a token from
|
|
8
|
+
# Studio.connection(:microsoft_365).access_token(connection).
|
|
9
|
+
#
|
|
10
|
+
# * A tenant administrator approves the app at Microsoft's admin-consent
|
|
11
|
+
# page (common: the tenant is not known until they sign in). Microsoft
|
|
12
|
+
# comes back with ?tenant=<GUID>&admin_consent=True; anything but an
|
|
13
|
+
# approval refuses the callback.
|
|
14
|
+
# * The tenant id is private: stored only encrypted, the row keyed by a
|
|
15
|
+
# digest, the callback's tenant param kept out of the logs by exact
|
|
16
|
+
# key. It is lower-cased, so one tenant never makes two rows.
|
|
17
|
+
# * CLIENT CREDENTIALS on the tenant's own token endpoint, with the Graph
|
|
18
|
+
# .default scope (the application permissions the app registration
|
|
19
|
+
# lists). No refresh token exists: the access token lives in this
|
|
20
|
+
# process's memory only, per tenant.
|
|
21
|
+
# * unauthorized_client means the app is not found in, or not enabled
|
|
22
|
+
# for, the tenant: the grant is gone.
|
|
23
|
+
# * NO REVOKE endpoint: consent is removed in the tenant's admin center.
|
|
24
|
+
#
|
|
25
|
+
# Checked against the hub's Msgraph::OAuthClient and Msgraph::Reader
|
|
26
|
+
# (mcritchie-studio), which cite Microsoft's identity-platform docs.
|
|
27
|
+
module Microsoft365
|
|
28
|
+
LOGIN_HOST = "https://login.microsoftonline.com"
|
|
29
|
+
AUTHORIZE_URL = "#{LOGIN_HOST}/common/adminconsent"
|
|
30
|
+
SCOPE = "https://graph.microsoft.com/.default"
|
|
31
|
+
API_URL = "https://graph.microsoft.com/v1.0"
|
|
32
|
+
TENANT_ID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
33
|
+
|
|
34
|
+
Integrations.register :microsoft_365,
|
|
35
|
+
label: "Microsoft 365",
|
|
36
|
+
summary: "A Microsoft 365 tenant, read app-only after an administrator approves the app.",
|
|
37
|
+
connection: {
|
|
38
|
+
grant: :client_credentials,
|
|
39
|
+
authorize_url: AUTHORIZE_URL,
|
|
40
|
+
token_url: ->(tenant) { "#{LOGIN_HOST}/#{tenant}/oauth2/v2.0/token" },
|
|
41
|
+
scopes: [SCOPE],
|
|
42
|
+
account: :callback, account_param: "tenant", account_format: TENANT_ID,
|
|
43
|
+
normalize_account: ->(value) { value.to_s.strip.downcase },
|
|
44
|
+
private_account: true, filter_keys: %w[tenant],
|
|
45
|
+
revoked_errors: %w[unauthorized_client],
|
|
46
|
+
declined_errors: %w[access_denied permission_denied consent_required],
|
|
47
|
+
callback_check: lambda { |params|
|
|
48
|
+
"Microsoft did not report an approval. Nothing was connected." unless params[:admin_consent].to_s.casecmp?("true")
|
|
49
|
+
},
|
|
50
|
+
refusal_hint: "A tenant bought through a reseller may block the approval; otherwise check the app registration's redirect URI, then start again.",
|
|
51
|
+
revoke_note: "Remove the app in the tenant's Microsoft Entra admin center (Enterprise applications) to revoke its access there."
|
|
52
|
+
},
|
|
53
|
+
describe: lambda { |integration, grant:, account:, get:|
|
|
54
|
+
token = integration.provider.access_token(account)
|
|
55
|
+
{ label: get.call("#{API_URL}/organization/#{account}", token)["displayName"] }
|
|
56
|
+
},
|
|
57
|
+
default_label: ->(_tenant) { "Microsoft 365 tenant" },
|
|
58
|
+
api_base: ->(_integration, _connection) { API_URL },
|
|
59
|
+
stand_in: { callback_params: { tenant: "00000000-0000-0000-0000-000000000000", admin_consent: "True" },
|
|
60
|
+
answer: { "displayName" => "Stand-in Tenant" } }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
module Integrations
|
|
5
|
+
# QUICKBOOKS ONLINE (Intuit), the connection only. A host's readers (company
|
|
6
|
+
# info, reports) are its own; they call Studio.integration(:quickbooks).api_base(connection).
|
|
7
|
+
#
|
|
8
|
+
# * Sign-in at Intuit's app center. The endpoints come from Intuit's
|
|
9
|
+
# discovery document
|
|
10
|
+
# (https://developer.api.intuit.com/.well-known/openid_configuration),
|
|
11
|
+
# the same for sandbox and production.
|
|
12
|
+
# * The token and revoke calls authenticate with HTTP Basic; the revoke
|
|
13
|
+
# body is JSON.
|
|
14
|
+
# * CACHED access token, refreshed when it expires: Intuit's access token
|
|
15
|
+
# lives about an hour (expires_in 3600). The row keeps it, encrypted,
|
|
16
|
+
# with its expiry, and reuses it until 5 minutes before that expiry, or
|
|
17
|
+
# until the API answers 401 (fresh_access_token(stale: token)). Then it
|
|
18
|
+
# refreshes ONCE under the row's lock, reloading the row first, so a
|
|
19
|
+
# racing caller waits and reads the new token instead of spending the
|
|
20
|
+
# refresh token twice. A reader that still gets a 401 after that one
|
|
21
|
+
# refresh asks the admin to connect again; it never loops.
|
|
22
|
+
# * The refresh token ROTATES and the newest must be kept: every refresh
|
|
23
|
+
# answer must carry one (rotation: :required, else nothing is stored and
|
|
24
|
+
# the refresh refuses), and it is stored, with the access token and
|
|
25
|
+
# x_refresh_token_expires_in, before the access token is used.
|
|
26
|
+
# * invalid_grant on a refresh is a revoked grant: connect again.
|
|
27
|
+
# * intuit_tid: Intuit names every answer with this response header, and
|
|
28
|
+
# asks apps to keep it for support. It is read from every token
|
|
29
|
+
# exchange, refresh, revoke and describe answer, logged with the call
|
|
30
|
+
# (no token), kept as provider.last_trace_id, and carried by every error
|
|
31
|
+
# raised (Error#trace_id, and in its message, so the error log has it).
|
|
32
|
+
# Host readers call provider.record_trace(response, call:, status:).
|
|
33
|
+
# * The company is named on the callback (?realmId=), a number, kept out
|
|
34
|
+
# of the logs by exact key.
|
|
35
|
+
# * The environment setting (sandbox, the default, or production) says
|
|
36
|
+
# which Intuit keys these are, so which API host a new connection reads;
|
|
37
|
+
# anything but "production" is the sandbox, so a typo never points
|
|
38
|
+
# sandbox keys at live books. It is stored on the connection.
|
|
39
|
+
#
|
|
40
|
+
# Checked against the hub's Quickbooks::OAuthClient and Quickbooks::Reader
|
|
41
|
+
# (mcritchie-studio), which cite Intuit's documents.
|
|
42
|
+
module Quickbooks
|
|
43
|
+
AUTHORIZE_URL = "https://appcenter.intuit.com/connect/oauth2"
|
|
44
|
+
TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
|
|
45
|
+
REVOKE_URL = "https://developer.api.intuit.com/v2/oauth2/tokens/revoke"
|
|
46
|
+
SCOPE = "com.intuit.quickbooks.accounting"
|
|
47
|
+
API_URLS = { "sandbox" => "https://sandbox-quickbooks.api.intuit.com",
|
|
48
|
+
"production" => "https://quickbooks.api.intuit.com" }.freeze
|
|
49
|
+
# Intuit retired minor versions 1-74 in August 2025: 75 is the floor.
|
|
50
|
+
MINOR_VERSION = 75
|
|
51
|
+
REALM_ID = /\A\d{1,20}\z/
|
|
52
|
+
|
|
53
|
+
def self.api_url(environment) = API_URLS.fetch(environment.to_s, API_URLS.fetch("sandbox"))
|
|
54
|
+
|
|
55
|
+
Integrations.register :quickbooks,
|
|
56
|
+
label: "QuickBooks",
|
|
57
|
+
summary: "A QuickBooks Online company, read with its owner's sign-in.",
|
|
58
|
+
connection: {
|
|
59
|
+
authorize_url: AUTHORIZE_URL, token_url: TOKEN_URL, revoke_url: REVOKE_URL,
|
|
60
|
+
scopes: [SCOPE],
|
|
61
|
+
client_auth: :basic, revoke_format: :json,
|
|
62
|
+
refresh: :cached, refresh_margin: 5.minutes, rotation: :required,
|
|
63
|
+
refresh_expires_in_key: "x_refresh_token_expires_in",
|
|
64
|
+
account: :callback, account_param: "realmId", account_format: REALM_ID,
|
|
65
|
+
filter_keys: %w[realmId], trace_header: "intuit_tid",
|
|
66
|
+
refusal_hint: "Check the Intuit app's redirect URI, then start again."
|
|
67
|
+
},
|
|
68
|
+
settings: { environment: { values: %w[sandbox production], default: "sandbox" } },
|
|
69
|
+
metadata: ->(integration) { { "environment" => integration.setting(:environment) } },
|
|
70
|
+
describe: lambda { |integration, grant:, account:, get:|
|
|
71
|
+
token = grant["access_token"].to_s
|
|
72
|
+
next {} if token.empty?
|
|
73
|
+
|
|
74
|
+
base = "#{Quickbooks.api_url(integration.setting(:environment))}/v3/company/#{account}"
|
|
75
|
+
info = get.call("#{base}/companyinfo/#{account}?minorversion=#{MINOR_VERSION}", token)["CompanyInfo"]
|
|
76
|
+
{ label: info.is_a?(Hash) ? info["CompanyName"] : nil }
|
|
77
|
+
},
|
|
78
|
+
default_label: ->(_account) { "QuickBooks company" },
|
|
79
|
+
api_base: lambda { |_integration, connection|
|
|
80
|
+
"#{Quickbooks.api_url(connection.metadata.to_h['environment'])}/v3/company/#{connection.account_id}"
|
|
81
|
+
},
|
|
82
|
+
stand_in: { callback_params: { realmId: "1000000001" },
|
|
83
|
+
answer: { "CompanyInfo" => { "CompanyName" => "Stand-in Company" } } }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Studio
|
|
9
|
+
# INTEGRATIONS: the engine's registry of integration CONNECTION definitions
|
|
10
|
+
# (docs/INTEGRATIONS.md), on top of the generic connection tooling
|
|
11
|
+
# (Studio::Connections, docs/CONNECTIONS.md).
|
|
12
|
+
#
|
|
13
|
+
# The engine ships one definition per integration it knows (QuickBooks,
|
|
14
|
+
# Egnyte, Microsoft 365). A definition holds the connection only: the URLs,
|
|
15
|
+
# the scopes, the token behaviour, and a minimal describe that names the
|
|
16
|
+
# account just connected (the company, the user, the organization). It holds
|
|
17
|
+
# NO reader and makes no business-data call: a host writes its own readers
|
|
18
|
+
# and plugs them into the integration's page with a panel.
|
|
19
|
+
#
|
|
20
|
+
# A host turns one on with one line in an initializer, naming its own env vars:
|
|
21
|
+
#
|
|
22
|
+
# Studio.enable_integration :quickbooks,
|
|
23
|
+
# env: { client_id: "INTUIT_CLIENT_ID", client_secret: "INTUIT_CLIENT_SECRET",
|
|
24
|
+
# environment: "INTUIT_ENVIRONMENT" },
|
|
25
|
+
# panel: "admin/integrations/quickbooks_panel"
|
|
26
|
+
#
|
|
27
|
+
# and mounts the admin page with Studio.draw_integrations_routes. An enabled
|
|
28
|
+
# integration stays off (Connect says which env vars are missing) until its
|
|
29
|
+
# client id and secret are set.
|
|
30
|
+
module Integrations
|
|
31
|
+
# Seconds to open the describe call's connection, and to read its answer.
|
|
32
|
+
OPEN_TIMEOUT = 10
|
|
33
|
+
READ_TIMEOUT = 20
|
|
34
|
+
|
|
35
|
+
# One integration the engine knows: the connection only.
|
|
36
|
+
#
|
|
37
|
+
# key the integration's key, and its connection's
|
|
38
|
+
# label the name on the page and every message
|
|
39
|
+
# summary one line on the page: what connecting it gives the host
|
|
40
|
+
# connection the Studio::Connections::Provider options, every one but
|
|
41
|
+
# env:, label: and describe:
|
|
42
|
+
# settings extra env vars a host may name besides the client id and
|
|
43
|
+
# secret: { name: { values: [...], default: "..." } }; a
|
|
44
|
+
# value outside `values` reads as the default
|
|
45
|
+
# account_label the Connect form's field label (account: :connect only)
|
|
46
|
+
# account_hint its placeholder
|
|
47
|
+
# metadata ->(integration) { Hash }: stored on every new connection,
|
|
48
|
+
# with no network call (the provider environment)
|
|
49
|
+
# describe ->(integration, grant:, account:, get:) { { label:, metadata: } }:
|
|
50
|
+
# names the account just connected with ONE call through
|
|
51
|
+
# `get` (->(url, token) { JSON Hash }); a Connections::Error
|
|
52
|
+
# there stores the connection under default_label
|
|
53
|
+
# default_label ->(account) { String }: the label when describe cannot say
|
|
54
|
+
# api_base ->(integration, connection) { String }: where the host's
|
|
55
|
+
# own readers send their calls for this connection
|
|
56
|
+
# stand_in { callback_params: {...}, answer: {...} }: what the
|
|
57
|
+
# stand-in's sign-in page sends back, and what its describe
|
|
58
|
+
# call answers (development and test only)
|
|
59
|
+
Definition = Struct.new(:key, :label, :summary, :connection, :settings, :account_label, :account_hint,
|
|
60
|
+
:metadata, :describe, :default_label, :api_base, :stand_in, keyword_init: true)
|
|
61
|
+
|
|
62
|
+
# The statuses an enabled integration can be in, in order.
|
|
63
|
+
STATUSES = {
|
|
64
|
+
not_set_up: "Not set up",
|
|
65
|
+
ready: "Ready to connect",
|
|
66
|
+
connected: "Connected"
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Adds a definition to the engine's catalog. The engine's own definitions
|
|
71
|
+
# register themselves when lib/studio/integrations/*.rb loads.
|
|
72
|
+
def register(key, **fields)
|
|
73
|
+
definition = Definition.new(key: key.to_s, settings: {}, metadata: ->(_integration) { {} },
|
|
74
|
+
default_label: nil, stand_in: {}, **fields)
|
|
75
|
+
definition.settings = definition.settings.to_h.transform_keys(&:to_sym)
|
|
76
|
+
lock.synchronize { catalog_map[definition.key] = definition.freeze }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Every integration the engine knows, enabled or not.
|
|
80
|
+
def catalog = lock.synchronize { catalog_map.values }
|
|
81
|
+
|
|
82
|
+
def definition(key)
|
|
83
|
+
lock.synchronize { catalog_map[key.to_s] } or
|
|
84
|
+
raise ArgumentError, "the engine has no integration #{key.to_s.inspect}; it knows #{catalog.map(&:key).join(', ')}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Turns an integration on: defines its connection (Studio.define_connection)
|
|
88
|
+
# with the host's env names, and adds it to the admin page.
|
|
89
|
+
def enable(key, env:, panel: nil, label: nil, scopes: nil)
|
|
90
|
+
integration = Integration.new(definition(key), env:, panel:, label:, scopes:)
|
|
91
|
+
lock.synchronize { enabled_map[integration.key] = integration }
|
|
92
|
+
integration
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The enabled integrations, in the order the host enabled them.
|
|
96
|
+
def all = lock.synchronize { enabled_map.values }
|
|
97
|
+
|
|
98
|
+
def enabled?(key) = lock.synchronize { enabled_map.key?(key.to_s) }
|
|
99
|
+
|
|
100
|
+
def fetch(key)
|
|
101
|
+
lock.synchronize { enabled_map[key.to_s] } or raise ArgumentError, "the integration #{key.to_s.inspect} is not enabled"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Where draw_integrations_routes mounted the page ("admin/integrations").
|
|
105
|
+
attr_accessor :mount_path
|
|
106
|
+
|
|
107
|
+
# Forgets every enabled integration (not the catalog). Tests only.
|
|
108
|
+
def reset!
|
|
109
|
+
lock.synchronize do
|
|
110
|
+
enabled_map.clear
|
|
111
|
+
@mount_path = nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def catalog_map = (@catalog_map ||= {})
|
|
118
|
+
def enabled_map = (@enabled_map ||= {})
|
|
119
|
+
def lock = (@lock ||= Monitor.new)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One integration a host enabled: its definition, the host's env names, its
|
|
123
|
+
# connection provider and the host's panel.
|
|
124
|
+
class Integration
|
|
125
|
+
attr_reader :definition, :env, :panel, :provider
|
|
126
|
+
|
|
127
|
+
# Stand-in for the describe call: (url, headers) -> [status, body]. nil
|
|
128
|
+
# means the provider itself. Refused outside development and test.
|
|
129
|
+
attr_reader :api_http
|
|
130
|
+
|
|
131
|
+
def initialize(definition, env:, panel: nil, label: nil, scopes: nil)
|
|
132
|
+
@definition = definition
|
|
133
|
+
@env = env.to_h.transform_keys(&:to_sym)
|
|
134
|
+
unknown = @env.keys - %i[client_id client_secret] - definition.settings.keys
|
|
135
|
+
raise ArgumentError, "#{key}: env names client_id, client_secret#{definition.settings.keys.map { ", #{_1}" }.join}; not #{unknown.join(', ')}" if unknown.any?
|
|
136
|
+
raise ArgumentError, "#{key}: env values are env var names (Strings)" unless @env.values.all?(String)
|
|
137
|
+
|
|
138
|
+
@panel = panel&.to_s
|
|
139
|
+
options = definition.connection.merge(
|
|
140
|
+
label: label || definition.label,
|
|
141
|
+
env: @env.slice(:client_id, :client_secret),
|
|
142
|
+
describe: ->(grant:, account:, provider:) { describe_account(grant:, account:) }
|
|
143
|
+
)
|
|
144
|
+
options[:scopes] = Array(scopes) if scopes
|
|
145
|
+
@provider = Studio.define_connection(definition.key, **options)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def key = definition.key
|
|
149
|
+
def label = provider.label
|
|
150
|
+
def summary = definition.summary
|
|
151
|
+
|
|
152
|
+
# A setting's value from the host's env var: one of its values, else its default.
|
|
153
|
+
def setting(name)
|
|
154
|
+
spec = definition.settings.fetch(name.to_sym)
|
|
155
|
+
value = @env[name.to_sym] && ENV[@env[name.to_sym]].to_s.strip.downcase
|
|
156
|
+
spec[:values].include?(value) ? value : spec[:default]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Every connection of this integration this server can read, newest first.
|
|
160
|
+
def connections = Studio::Connection.readable(key)
|
|
161
|
+
|
|
162
|
+
# What keeps a Connect from starting here, as sentences (empty when none).
|
|
163
|
+
def missing
|
|
164
|
+
reasons = []
|
|
165
|
+
unless provider.connectable?
|
|
166
|
+
unset = provider.env_names.reject { |name| ENV[name].to_s.strip != "" }
|
|
167
|
+
reasons << "#{unset.join(' and ')} #{unset.one? ? 'is' : 'are'} not set."
|
|
168
|
+
end
|
|
169
|
+
reasons << "The app's Active Record encryption keys are not set." unless Studio::Connections.encryption_ready?
|
|
170
|
+
reasons
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# :not_set_up, :ready or :connected (STATUSES).
|
|
174
|
+
def status
|
|
175
|
+
return :not_set_up if missing.any?
|
|
176
|
+
|
|
177
|
+
connections.any? ? :connected : :ready
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def status_label = STATUSES.fetch(status)
|
|
181
|
+
|
|
182
|
+
# Where the host's readers send their calls for `connection`.
|
|
183
|
+
def api_base(connection) = definition.api_base.call(self, connection)
|
|
184
|
+
|
|
185
|
+
def api_http=(callable)
|
|
186
|
+
unless callable.nil? || Studio::Connections.seams_allowed?
|
|
187
|
+
raise Studio::Connections::Error, "#{label}: the describe stand-in is for tests, the e2e lane and a local demo (development and test only)"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
@api_http = callable
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Stands the whole integration in: the provider's sign-in page and token
|
|
194
|
+
# endpoints (Studio::Connections::StandIn) and the describe call.
|
|
195
|
+
# Development and test only; refused anywhere else.
|
|
196
|
+
def install_stand_in!(access_lifetime: 3600)
|
|
197
|
+
answer = definition.stand_in.fetch(:answer, {}).to_json
|
|
198
|
+
self.api_http = ->(_url, _headers) { [200, answer] }
|
|
199
|
+
Studio::Connections::StandIn.install!(provider, callback_params: definition.stand_in.fetch(:callback_params, {}), access_lifetime:)
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def uninstall_stand_in!
|
|
204
|
+
self.api_http = nil
|
|
205
|
+
Studio::Connections::StandIn.uninstall!(provider)
|
|
206
|
+
self
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The provider's describe hook: the definition's metadata, then its one
|
|
210
|
+
# describe call. A refusal there keeps the metadata and the default label.
|
|
211
|
+
def describe_account(grant:, account:)
|
|
212
|
+
metadata = definition.metadata.call(self).to_h
|
|
213
|
+
described = {}
|
|
214
|
+
if definition.describe
|
|
215
|
+
begin
|
|
216
|
+
described = definition.describe.call(self, grant:, account:, get: method(:describe_get)).to_h.symbolize_keys
|
|
217
|
+
rescue Studio::Connections::Error => e
|
|
218
|
+
# Kept in the error log (with the provider's trace id, when it names
|
|
219
|
+
# one), as a refused exchange is; the connection still stores.
|
|
220
|
+
begin
|
|
221
|
+
::ErrorLog.capture!(e) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
222
|
+
rescue StandardError => log_error
|
|
223
|
+
Rails.logger.error("[studio_integration] describe #{key} refused (#{e.message}); ErrorLog write failed: #{log_error.class}")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
label = described[:label].to_s.strip.presence || definition.default_label&.call(account)
|
|
228
|
+
{ label:, metadata: metadata.merge(described[:metadata].to_h) }.compact
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
# The describe call's one GET: a bearer token, JSON back. Raises
|
|
234
|
+
# Studio::Connections::Error (RateLimited on a 429) on anything but a 200.
|
|
235
|
+
def describe_get(url, token)
|
|
236
|
+
headers = { "Authorization" => "Bearer #{token}", "Accept" => "application/json" }
|
|
237
|
+
status, body, answered = api_http ? api_http.call(url, headers) : request(url, headers)
|
|
238
|
+
trace = provider.record_trace(answered, call: "describe", status:)
|
|
239
|
+
traced = { trace_header: provider.trace_header, trace_id: trace }
|
|
240
|
+
json = begin
|
|
241
|
+
JSON.parse(body.to_s)
|
|
242
|
+
rescue JSON::ParserError
|
|
243
|
+
{}
|
|
244
|
+
end
|
|
245
|
+
json = {} unless json.is_a?(Hash)
|
|
246
|
+
raise Studio::Connections::RateLimited.new("#{label} is throttling this app (429)", **traced) if status == 429
|
|
247
|
+
raise Studio::Connections::Error.new("#{label} answered #{status} to the describe call", **traced) unless status == 200
|
|
248
|
+
|
|
249
|
+
json
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def request(url, headers)
|
|
253
|
+
uri = URI(url)
|
|
254
|
+
raise Studio::Connections::Error, "#{label}: a describe call must be https" unless uri.scheme == "https"
|
|
255
|
+
|
|
256
|
+
get = Net::HTTP::Get.new(uri)
|
|
257
|
+
headers.each { |name, value| get[name] = value }
|
|
258
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) { _1.request(get) }
|
|
259
|
+
[response.code.to_i, response.body, response]
|
|
260
|
+
rescue *Studio::Connections::NETWORK_ERRORS => e
|
|
261
|
+
raise Studio::Connections::Error, "#{label} could not be reached: #{e.class}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -8,7 +8,15 @@ host gets it with the engine it already bundles: no npm registry, no token.
|
|
|
8
8
|
exactly this version.
|
|
9
9
|
- `playwright_base.js`: the browser lane's shared defaults (one chromium
|
|
10
10
|
project, one worker, a Rails `webServer` on `/up`).
|
|
11
|
-
- `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests
|
|
11
|
+
- `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`; for a page
|
|
12
|
+
transition spec, the reveal waits (`watchReveal`, `revealed`, `settled`,
|
|
13
|
+
`arrivalSettled`, `frames`) and the log-on-red hook (`sessionLog`, `logOnRed`).
|
|
14
|
+
The waits read events the browser fires, never a clock.
|
|
15
|
+
|
|
16
|
+
A local run reuses a server already on the e2e port only when that server's
|
|
17
|
+
working directory is inside this tree (`mayReuseServer` in `playwright_base.js`,
|
|
18
|
+
read with `lsof`). Any other server there is refused, with a warning that names
|
|
19
|
+
its root: give the run its own port with `E2E_PORT=<free port>`.
|
|
12
20
|
- `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
|
|
13
21
|
import a module by the bare name the importmap pins it under.
|
|
14
22
|
|
|
@@ -71,4 +71,96 @@ async function blockOffsiteRequests(page, { pinned = {} } = {}) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// ---- Full-load page transitions (docs/PAGE_TRANSITIONS.md, "Full page loads") ----
|
|
75
|
+
//
|
|
76
|
+
// These wait on events the browser fires, never on a clock. Nothing here sleeps.
|
|
77
|
+
|
|
78
|
+
// Marks each document's pagereveal, for revealed(), settled() and arrivalSettled() below.
|
|
79
|
+
// Call it before the first navigation: it is an init script, so every new document gets it,
|
|
80
|
+
// and it must be bound before the first render to hear pagereveal at all.
|
|
81
|
+
//
|
|
82
|
+
// `window.__studioReveal` holds { revealed, transition, done }: `done` is true once the
|
|
83
|
+
// arrival's view transition has finished or been skipped, at once when there is none.
|
|
84
|
+
function watchReveal(target) {
|
|
85
|
+
return target.addInitScript(() => {
|
|
86
|
+
const state = { revealed: false, transition: false, done: false };
|
|
87
|
+
window.__studioReveal = state;
|
|
88
|
+
window.addEventListener("pagereveal", (event) => {
|
|
89
|
+
state.revealed = true;
|
|
90
|
+
const transition = event.viewTransition;
|
|
91
|
+
state.transition = !!transition;
|
|
92
|
+
if (!transition) { state.done = true; return; }
|
|
93
|
+
const done = () => { state.done = true; };
|
|
94
|
+
transition.finished.then(done, done);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The new page has revealed. VISIBLE IS NOT REVEALED: its DOM parses, and Playwright sees it
|
|
100
|
+
// as visible, before its first render. A render-blocking sheet, or the reveal script's own
|
|
101
|
+
// hold to the end of parse, keeps pagereveal back, and until then no mark is set and no
|
|
102
|
+
// pseudo runs (engine CI run 42503970890 read an empty log at 312 ms).
|
|
103
|
+
async function revealed(page, selector) {
|
|
104
|
+
if (selector) await page.locator(selector).first().waitFor({ state: "visible" });
|
|
105
|
+
await page.waitForFunction(() => window.__studioReveal && window.__studioReveal.revealed === true);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Past the whole motion: revealed, and the document's own marks and every
|
|
109
|
+
// ::view-transition animation are gone when this returns.
|
|
110
|
+
async function settled(page, selector) {
|
|
111
|
+
await revealed(page, selector);
|
|
112
|
+
await page.waitForFunction(() => document.documentElement.dataset.pageTransition === undefined
|
|
113
|
+
&& !document.getAnimations().some((a) => ((a.effect && a.effect.pseudoElement) || "").startsWith("::view-transition")));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Two rendered frames. A recorder that samples document.getAnimations() on every
|
|
117
|
+
// requestAnimationFrame has run at least once past anything that was live before this call.
|
|
118
|
+
function frames(page, count = 2) {
|
|
119
|
+
return page.evaluate((n) => new Promise((done) => {
|
|
120
|
+
const step = (left) => (left === 0 ? done() : requestAnimationFrame(() => step(left - 1)));
|
|
121
|
+
step(n);
|
|
122
|
+
}), count);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// For a spec that asserts NOTHING PLAYED: the arrival's view transition has finished or been
|
|
126
|
+
// skipped, and two frames have rendered since. A transition that wrongly plays keeps `done`
|
|
127
|
+
// false until its last frame, and every frame it draws comes before this returns, so a
|
|
128
|
+
// per-frame recorder has logged it. A fixed pause only hoped for that.
|
|
129
|
+
async function arrivalSettled(page, selector) {
|
|
130
|
+
await revealed(page, selector);
|
|
131
|
+
await page.waitForFunction(() => window.__studioReveal.done === true);
|
|
132
|
+
await frames(page);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A log kept in sessionStorage, so it outlives each document of a full load.
|
|
136
|
+
function sessionLog(page, key = "pt-log") {
|
|
137
|
+
return page.evaluate((name) => JSON.parse(sessionStorage.getItem(name) || "[]"), key);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A red carries its evidence: registers an afterEach that, on a failed test only, prints the
|
|
141
|
+
// log `read(page)` returns and attaches it to the report the CI uploads. A red with no log
|
|
142
|
+
// once left its cause unprovable. `test` is the host's own @playwright/test `test`, since
|
|
143
|
+
// this file cannot require Playwright (see README.md).
|
|
144
|
+
function logOnRed(test, read = (page) => sessionLog(page), { name = "pt-log" } = {}) {
|
|
145
|
+
test.afterEach(async ({ page }, testInfo) => {
|
|
146
|
+
if (testInfo.status === testInfo.expectedStatus) return;
|
|
147
|
+
const lines = await Promise.resolve()
|
|
148
|
+
.then(() => read(page))
|
|
149
|
+
.catch((error) => [`(the log could not be read: ${error.message})`]);
|
|
150
|
+
console.log(`${name} for "${testInfo.title}":\n${lines.join("\n")}`);
|
|
151
|
+
await testInfo.attach(name, { body: lines.join("\n"), contentType: "text/plain" });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
isOffOrigin,
|
|
157
|
+
watchPageErrors,
|
|
158
|
+
blockOffsiteRequests,
|
|
159
|
+
watchReveal,
|
|
160
|
+
revealed,
|
|
161
|
+
settled,
|
|
162
|
+
frames,
|
|
163
|
+
arrivalSettled,
|
|
164
|
+
sessionLog,
|
|
165
|
+
logOnRed,
|
|
166
|
+
};
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// Every key of `overrides` replaces the default of the same name; `use` merges
|
|
15
15
|
// one level deep, so a host can add `reducedMotion` and keep `baseURL`.
|
|
16
16
|
|
|
17
|
+
const { execFileSync } = require("node:child_process");
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
|
|
17
21
|
const DEFAULTS = Object.freeze({
|
|
18
22
|
testDir: "./e2e",
|
|
19
23
|
timeout: 30_000,
|
|
@@ -29,14 +33,57 @@ function studioBaseURL({ defaultPort, env = process.env } = {}) {
|
|
|
29
33
|
return { port, external, baseURL: external || `http://127.0.0.1:${port}` };
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
// The working directory of whatever listens on `port`, read with lsof: { root } for a
|
|
37
|
+
// listener, { root: null } for none, { unknown: true } when it cannot be told (no lsof, a
|
|
38
|
+
// process lsof may not read).
|
|
39
|
+
function listenerRoot(port) {
|
|
40
|
+
const run = (args) => execFileSync("lsof", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
|
|
41
|
+
let pids;
|
|
42
|
+
try {
|
|
43
|
+
pids = run(["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]).split("\n").filter(Boolean);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// lsof exits 1, printing nothing, when nothing listens.
|
|
46
|
+
if (error.status === 1 && !error.stdout) return { root: null };
|
|
47
|
+
return { unknown: true };
|
|
48
|
+
}
|
|
49
|
+
if (pids.length === 0) return { root: null };
|
|
50
|
+
try {
|
|
51
|
+
const line = run(["-a", "-p", pids[0], "-d", "cwd", "-Fn"]).split("\n").find((entry) => entry.startsWith("n"));
|
|
52
|
+
return line ? { root: line.slice(1) } : { unknown: true };
|
|
53
|
+
} catch (_error) {
|
|
54
|
+
return { unknown: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function realpath(path) {
|
|
59
|
+
try { return fs.realpathSync(path); } catch (_error) { return path; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// May this run reuse the server already on `port`? Only one this tree started. Playwright's
|
|
63
|
+
// own check is "something answers /up", so on a shared default port one desk's run graded
|
|
64
|
+
// another desk's server, and its code. A server rooted outside `root`, or one whose root
|
|
65
|
+
// cannot be read, is refused: Playwright then stops with "is already used", and the warning
|
|
66
|
+
// names the server's root and the way out. Nothing listening reuses nothing either way.
|
|
67
|
+
function mayReuseServer({ port, root, lookup = listenerRoot, warn = console.warn }) {
|
|
68
|
+
const found = lookup(port);
|
|
69
|
+
if (found.root === null) return true;
|
|
70
|
+
const mine = realpath(root);
|
|
71
|
+
const theirs = found.unknown ? null : realpath(found.root);
|
|
72
|
+
if (theirs && (theirs === mine || theirs.startsWith(`${mine}${path.sep}`))) return true;
|
|
73
|
+
warn(`[studio playwright] port ${port} is held by a server ${theirs ? `rooted at ${theirs}` : "whose root cannot be read"}, ` +
|
|
74
|
+
`not by this tree (${mine}), so it is not reused. Stop it if it is yours, or give this run its own port: E2E_PORT=<free port>.`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
// A Rails server Playwright boots and waits on. `/up` is the Rails health check
|
|
33
|
-
// every app in the ecosystem keeps.
|
|
34
|
-
|
|
79
|
+
// every app in the ecosystem keeps. Locally a running server is reused only when this
|
|
80
|
+
// tree started it (mayReuseServer); CI never reuses one.
|
|
81
|
+
function railsWebServer({ command, port, env = {}, timeout = 120_000, ci = !!process.env.CI, root = process.cwd(), lookup }) {
|
|
35
82
|
if (!command) throw new Error("railsWebServer: a command is required");
|
|
36
83
|
return {
|
|
37
84
|
command,
|
|
38
85
|
url: `http://127.0.0.1:${port}/up`,
|
|
39
|
-
reuseExistingServer: !ci,
|
|
86
|
+
reuseExistingServer: !ci && mayReuseServer({ port, root, lookup }),
|
|
40
87
|
timeout,
|
|
41
88
|
stdout: "pipe",
|
|
42
89
|
stderr: "pipe",
|
|
@@ -60,4 +107,4 @@ function studioPlaywrightConfig({ defaultPort, webServer, env = process.env, use
|
|
|
60
107
|
return config;
|
|
61
108
|
}
|
|
62
109
|
|
|
63
|
-
module.exports = { DEFAULTS, studioBaseURL, railsWebServer, studioPlaywrightConfig };
|
|
110
|
+
module.exports = { DEFAULTS, studioBaseURL, listenerRoot, mayReuseServer, railsWebServer, studioPlaywrightConfig };
|
data/lib/studio/roles.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The one list of account roles every Studio app draws from (database audit
|
|
5
|
+
# 2026-11-07, section 3). `users.role` holds one of these, guarded twice: by
|
|
6
|
+
# Studio::Account's inclusion validation and by CHECK users_role_known, which
|
|
7
|
+
# db/migrate/20261108170000_add_users_role_known_check.rb installs.
|
|
8
|
+
#
|
|
9
|
+
# A host may NARROW the list (`studio_roles %w[viewer admin]` in its User),
|
|
10
|
+
# never widen it: a role the engine does not know means nothing to the
|
|
11
|
+
# engine's admin checks, and a new one is added here, for every app at once.
|
|
12
|
+
# Turf's old "user" spelling meant viewer and moved there.
|
|
13
|
+
Roles = %w[viewer member admin].freeze
|
|
14
|
+
|
|
15
|
+
# The role a new account takes when nothing says otherwise.
|
|
16
|
+
DEFAULT_ROLE = "viewer"
|
|
17
|
+
end
|
data/lib/studio/version.rb
CHANGED