studio-engine 0.108.0.rc1 → 0.108.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01a56dee2b229822d24c392ac0f3c62f4b2a1d8c0328a2e84966dd4aa73630cc
4
- data.tar.gz: 4b906a31d12a9fe5aae18b11f222fda647a1b7dba53627173c681f9da1fbb0b0
3
+ metadata.gz: 731299717728fa47ee4546968f5e931e3e717043954d5262e6811c255872c1c6
4
+ data.tar.gz: 7f0a4f9eedccac043b017cbc25291f60559529e6a5fe655e8dc89c70ab9118dd
5
5
  SHA512:
6
- metadata.gz: 944aa8ae14a3a9b5a213a4e3594969a2343849ac972a9eba576e9a2fc784eb4c77ca082b947d3f0238478cd9e2e2bd9e5ee3d976365fe0a4cf47c721fbd13f9d
7
- data.tar.gz: 472497c4720a4365117f33135f57cd7b9eefdbc2ee7c339f3f9354b9226ae1e05c1dae7fb424c47f1b7efa18302f4ca468e9ac59e4f7abf1767750f9dda051aa
6
+ metadata.gz: 0daadeed40010820350c83f4d029d2681eb56850204e35c2125681b041408a91c932dcc890efe4e687e0b343008a96a353ed61eb809f319b5089e7b70029bbea
7
+ data.tar.gz: 8764a4e2134c2fc02ed07344efb5dfb20fcbce6777923cb84d0dfd4c5302d29c36ed7fdeb59077b573af9a4f50ccd9cf8b7f5d31632faf127eace78a17302d65
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **`GET /_studio/health_report`: the daily health report as JSON, for the
10
+ hub's central pull.** McRitchie Studio runs one 07:00 America/Denver job that
11
+ pulls every app's report and posts it to Discord, so a satellite needs no
12
+ worker or schedule of its own. Drawn by `Studio.routes` in every environment,
13
+ production included, and inert until the app names a token:
14
+ `config.health_report_token` or `STUDIO_HEALTH_TOKEN` (new). With none it
15
+ answers a bare 404; a request without `Authorization: Bearer <token>`
16
+ (compared in constant time) gets 401; `?ending=<ISO 8601>` picks the day
17
+ that ends there (a bad value is 400), else the day the job would report now.
18
+ The body is the same `Studio::HealthReport.build` the email uses, counts only
19
+ (`schema`, `app`, `engine_version`, `window`, `quiet`, `sections`,
20
+ `error_kinds`, `error_groups`): no section's names or emails ever leave the
21
+ app, and an email address quoted in an error message is sent as `[email]`. It runs on `ActionController::API`, so no host `before_action`,
22
+ session, cookie or redirect touches it. See `docs/HEALTH_REPORT.md`, *The
23
+ hub endpoint*.
24
+
7
25
  ## 0.108.0 — 2026-10-15
8
26
 
9
27
  ### Changed
data/README.md CHANGED
@@ -148,6 +148,8 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
148
148
 
149
149
  In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
150
150
 
151
+ In every environment, production included, it also draws `/_studio/health_report`: the daily health report as counts-only JSON, which the McRitchie Studio hub pulls at 07:00 and posts to Discord. It answers 404 until the app sets `STUDIO_HEALTH_TOKEN` (or `config.health_report_token`), and 401 to a request without that bearer token ([`docs/HEALTH_REPORT.md`](docs/HEALTH_REPORT.md#the-hub-endpoint)).
152
+
151
153
  ## Non-Production Banners
152
154
 
153
155
  Consumer layouts can render the shared environment banner inside their sticky
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # GET /_studio/health_report — the daily health report as JSON, for the
5
+ # McRitchie Studio hub's one 07:00 America/Denver job, which pulls every app
6
+ # and posts to Discord. Drawn in every environment, production included; the
7
+ # report is the same Studio::HealthReport.build the email uses, so the two can
8
+ # never disagree. docs/HEALTH_REPORT.md, "The hub endpoint".
9
+ #
10
+ # INERT BY DEFAULT. With no Studio.health_report_token (else
11
+ # ENV["STUDIO_HEALTH_TOKEN"]) it answers a bare 404, the same as a route that
12
+ # is not there. With one, a request without `Authorization: Bearer <token>`
13
+ # gets 401; the compare is constant-time (Studio::HealthReport.authorized?).
14
+ #
15
+ # COUNTS ONLY. Studio::HealthReport.payload drops every section's `items`, so
16
+ # no signup's or contact's name or email ever leaves the app.
17
+ #
18
+ # ActionController::API, NOT the host's ApplicationController: a server-to-
19
+ # server fetch carries no session, and a host's before_actions (sign-in, geo,
20
+ # allow_browser, onboarding) would answer it with a redirect or an HTML page.
21
+ # This base runs none of them, keeps no session, sets no cookie and checks no
22
+ # CSRF token, and every answer here is JSON or a bare status.
23
+ class HealthReportsController < ActionController::API
24
+ def show
25
+ return head(:not_found) if Studio.health_report_token.blank?
26
+
27
+ response.headers["Cache-Control"] = "no-store"
28
+ unless Studio::HealthReport.authorized?(request.authorization)
29
+ response.headers["WWW-Authenticate"] = 'Bearer realm="studio-health-report"'
30
+ return render(json: { error: "unauthorized" }, status: :unauthorized)
31
+ end
32
+
33
+ window = begin
34
+ Studio::HealthReport.window_for(params[:ending].presence)
35
+ rescue ArgumentError
36
+ return render(json: { error: "ending must be an ISO 8601 time" }, status: :bad_request)
37
+ end
38
+
39
+ render json: Studio::HealthReport.payload(Studio::HealthReport.build(window: window))
40
+ rescue StandardError => e
41
+ # build already turns a failing SOURCE into a section's `failure`; this is
42
+ # anything else. Logged as an ErrorLog row, answered as JSON, never HTML.
43
+ Studio::HealthReport.capture(e)
44
+ render json: { error: "health report failed" }, status: :internal_server_error
45
+ end
46
+ end
47
+ end
@@ -6,6 +6,10 @@ require "active_support/core_ext/module/attribute_accessors"
6
6
  require "active_support/core_ext/object/blank"
7
7
  require "active_support/core_ext/object/try"
8
8
  require "active_support/core_ext/string/filters"
9
+ require "active_support/security_utils"
10
+ require "digest"
11
+ require "time"
12
+ require_relative "version"
9
13
 
10
14
  # The daily application health report: signups, errors and (optionally) contact
11
15
  # messages from the last day, emailed at 07:00 in the app's zone. The full
@@ -48,8 +52,20 @@ module Studio
48
52
  # config.health_report_contacts = ->(window) { ContactSubmission.where(created_at: window) }
49
53
  mattr_accessor :health_report_contacts, default: nil
50
54
 
55
+ # The bearer token the hub presents to GET /_studio/health_report, the JSON
56
+ # copy of this report that McRitchie Studio pulls from every app at 07:00 and
57
+ # posts to Discord. nil or blank falls through to ENV["STUDIO_HEALTH_TOKEN"],
58
+ # read on every request; blank there too means the endpoint answers 404.
59
+ mattr_writer :health_report_token, default: nil
60
+
61
+ def self.health_report_token
62
+ class_variable_get(:@@health_report_token).presence || ENV.fetch(HealthReport::TOKEN_ENV_KEY, nil).presence
63
+ end
64
+
51
65
  module HealthReport
52
66
  ENV_KEY = "STUDIO_HEALTH_REPORT_TO"
67
+ TOKEN_ENV_KEY = "STUDIO_HEALTH_TOKEN"
68
+ SCHEMA = 1 # the JSON contract's version; bump on a breaking change
53
69
  LISTED = 10 # names shown under a count; the count is always whole
54
70
  ERROR_GROUPS = 15 # distinct errors shown; the rest are counted as "more"
55
71
 
@@ -131,6 +147,65 @@ module Studio
131
147
  "#{count} #{count == 1 ? noun : "#{noun}s"}"
132
148
  end
133
149
 
150
+ # ---- the JSON endpoint (Studio::HealthReportsController) ---------------
151
+
152
+ # Does this Authorization header carry the configured token? false when no
153
+ # token is configured. Both sides are digested before the constant-time
154
+ # compare, so neither the token's bytes nor its length leak through timing.
155
+ def authorized?(authorization, token: Studio.health_report_token)
156
+ return false if token.blank?
157
+
158
+ presented = authorization.to_s[/\ABearer[ ]+(\S+)\s*\z/i, 1]
159
+ return false if presented.nil?
160
+
161
+ ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(presented),
162
+ Digest::SHA256.hexdigest(token.to_s))
163
+ end
164
+
165
+ # The window the endpoint reports: the day ENDING at `ending` (an ISO 8601
166
+ # time), else the day the job would report now. Raises ArgumentError on a
167
+ # value that is not ISO 8601, which the controller answers with a 400.
168
+ def window_for(ending, now: Time.now)
169
+ return window(now) if ending.nil?
170
+
171
+ window_ending(Time.iso8601(ending.to_s))
172
+ end
173
+
174
+ SECTION_KEYS = %i[signups errors contacts].freeze
175
+
176
+ # The report as the hub reads it. COUNTS ONLY, by construction: a Section's
177
+ # `items` (names and emails) never reach this hash, so no signup or contact
178
+ # detail leaves the app. Error groups carry the class and the truncated
179
+ # message the email already shows.
180
+ def payload(report)
181
+ {
182
+ schema: SCHEMA,
183
+ app: report.app_name.to_s,
184
+ engine_version: Studio::VERSION,
185
+ window: { begin: iso(report.window.begin), end: iso(report.window.end), label: report.window_label },
186
+ quiet: report.quiet?,
187
+ sections: SECTION_KEYS.filter_map do |key|
188
+ section = report.public_send(key)
189
+ next unless section
190
+
191
+ { key: key.to_s, title: section.title, count: section.count.to_i, failure: section.failure }
192
+ end,
193
+ error_kinds: report.error_kinds.to_i,
194
+ error_groups: Array(report.error_groups).map do |group|
195
+ { error_class: group.error_class, message: scrub(group.message), count: group.count.to_i,
196
+ first_seen: iso(group.first_seen), last_seen: iso(group.last_seen) }
197
+ end
198
+ }
199
+ end
200
+
201
+ def iso(time) = time&.iso8601
202
+
203
+ EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/
204
+
205
+ # An exception message can quote the address it choked on ("no account for
206
+ # ann@example.com"). The email may show it; the JSON leaving the app may not.
207
+ def scrub(message) = message.to_s.gsub(EMAIL, "[email]")
208
+
134
209
  # ---- gathering (needs the host's database) ------------------------------
135
210
 
136
211
  def build(window: self.window(Time.now))
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.108.0.rc1"
2
+ VERSION = "0.108.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1447,6 +1447,14 @@ module Studio
1447
1447
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
1448
1448
  get "auth/failure", to: "omniauth_callbacks#failure"
1449
1449
 
1450
+ # The daily health report as JSON, for the hub's central 07:00 pull
1451
+ # (Studio::HealthReportsController). Drawn in EVERY environment, production
1452
+ # included: the controller is the gate, answering 404 until the app names
1453
+ # a token (Studio.health_report_token / STUDIO_HEALTH_TOKEN) and 401 to a
1454
+ # request that does not present it.
1455
+ get "_studio/health_report", to: "studio/health_reports#show", as: :studio_health_report,
1456
+ defaults: { format: :json }
1457
+
1450
1458
  # Developer-desk tools. Drawn outside production, and each controller
1451
1459
  # re-checks Studio.local_tool_enabled? per request (loopback only) — the
1452
1460
  # route being absent is the outer gate, not the only one.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.108.0.rc1
4
+ version: 0.108.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -398,6 +398,7 @@ files:
398
398
  - app/controllers/studio/component_previews_controller.rb
399
399
  - app/controllers/studio/emails_controller.rb
400
400
  - app/controllers/studio/geo_settings_controller.rb
401
+ - app/controllers/studio/health_reports_controller.rb
401
402
  - app/controllers/studio/knowledge_docs_controller.rb
402
403
  - app/controllers/studio/links_controller.rb
403
404
  - app/controllers/studio/local_emails_controller.rb