studio-engine 0.107.0 → 0.108.0.rc2

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: 2df600bffd7dc544c53e67f5c2d53eaa750f9f8dde61faa148bf1734b0e3bc80
4
- data.tar.gz: 0d188944db2d33faba21c2d0c14489a929795f4c4f7f4864e6e7e2dfa18e6ca8
3
+ metadata.gz: 2990527dd511a02eea248d9a8ceb2e5f2df97b2b19423f3022b864ea162e38b0
4
+ data.tar.gz: 24d9da854c8773924858c46fe3bcf31e81ecf4b2e5a1c40b629e16067ad661ac
5
5
  SHA512:
6
- metadata.gz: 82cc2318817c6fcddfab389e3da1135c9f8aa0b4eb6d3adfd4949bb2c4949c9263fb4a8595eb74b90815dc004ef143caa5d452b106d6f4547d736bcd9ecba9bd
7
- data.tar.gz: 4c3deddf0ef3e644051bd48b06dfc9ed0798b31af6d51b65fe43a8a4bde1d57be8eac2d359421d17e58176d2e1496b6728f5f5d7b5867656f71bb478945bed78
6
+ metadata.gz: 847d51a58aafc2e75e1d7621cdb674b4dda4c6d54b30676a95803fe5980ad5630392ead197848afe25255b8176cc7754b34c6971a1a27c5fc0f9a162c898c6f6
7
+ data.tar.gz: fc2bfa04fe01978a9b60ad854553cf89c6d1cb250ebe9ff0c5b75c9b09b49b02ba3a0203e069b32e774763d6505400f4a8a5c5c5574479a39f353e3606451557
data/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ 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
+
25
+ ## 0.108.0 — 2026-10-15
26
+
27
+ ### Changed
28
+
29
+ - **The navbar collapse no longer moves the page.** `studio/nav_collapse`
30
+ holds a header that sits in the page's flow at its expanded footprint: the
31
+ header carries `margin-bottom: calc(var(--nav-hold) * var(--nav-p) +
32
+ var(--nav-hold-fix))`, which grows in the same style recalc that shrinks it,
33
+ so the document never changes height. Before, every frame of the collapse
34
+ reflowed the page: content ran ahead of the finger, a quick scroll back to the
35
+ top pushed the page down for ~10 frames after the scroll stopped, and near the
36
+ bottom of a page the browser clamped the scroll back (measured on the hub's
37
+ `/links`: 344 to 305 with nobody touching it). Held, the progress follows the
38
+ scroll position exactly: the 5px-per-frame cap and the short-page guard apply
39
+ only to a header out of flow (fixed or absolute), which never moved the page.
40
+ The collapse stays continuous; endpoints and the ramp are unchanged. No markup
41
+ change: every header on the controller (the engine navbar, and forks through
42
+ the `navCollapse()` shim) gets it.
43
+ - **The pinned stack stops restyling the whole page while you scroll.**
44
+ `studio/pinned_stack` publishes the header's live geometry as inherited
45
+ custom properties on `<html>`, and every write there restyles the entire
46
+ document: 8.7ms a write on turf-monster's 2,000-element contest page, every
47
+ frame the header's bottom edge moved, which dropped frames on a 120Hz screen.
48
+ While a scroll is in motion it now writes the values only onto the elements
49
+ that read them live (an inline style or a stylesheet rule that positions off
50
+ them, found by a scan, or `[data-pin-follow]`), and writes `<html>` once the
51
+ scroll has been still for 160ms. Restyling during the collapse on that page
52
+ fell from 226ms to 25ms over 27 frames. `scroll-margin`/`scroll-padding`
53
+ readers are served by the settled write; structural changes (a layer arriving
54
+ or leaving) still write `<html>` at once. A SCRIPT that reads these values
55
+ off `<html>` (`getComputedStyle(document.documentElement)`) sees them up to
56
+ 160ms late during a scroll; read them off the element that positions by them
57
+ instead. Known readers: turf-monster's contest chat panel and cyvasse's game
58
+ controller, both of which read at an event, not per frame. Followers release
59
+ their copies before Turbo caches the page, and the collapse controller clears
60
+ its own state when a header is restored from that cache, so Back never stacks
61
+ a second hold.
62
+
63
+ ### Added
64
+
65
+ - **`Studio::Storage`: object storage on Cloudflare R2, on by default for an app
66
+ that names its bucket.** One copy of the `StorageBackend` module the hub,
67
+ turf-monster and mcritchie-industries each hand-wrote. An app with
68
+ `config.s3_bucket_prefix` set now gets, with no initializer of its own: a boot
69
+ check (a retired `ACTIVE_STORAGE_BACKEND` / `STUDIO_S3_BACKEND` value raises in
70
+ every environment; a missing R2 variable raises in production and QA, naming
71
+ each), `Studio::S3` pointed at R2 when the app left `s3_endpoint` unset, and
72
+ Active Storage services `amazon` and `amazon_dev` on `StudioTrashS3`, merged
73
+ under the app's own `config/storage.yml` (an app's service of the same name
74
+ wins). New config: `manage_storage` (default true; false opts out) and
75
+ `storage_public_url_required` (default false; true makes `R2_PUBLIC_URL`
76
+ required). An app still carrying its own `StorageBackend` keeps its own
77
+ connection and services; only the switch check is new to it. README.md,
78
+ *Managed storage*.
79
+
7
80
  ## 0.107.0 — 2026-10-14
8
81
 
9
82
  ### 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
@@ -966,6 +968,49 @@ config.s3_key_prefix = "mcritchie-industries/"
966
968
  logical keys and never see it. Unset (the default) leaves keys byte-identical to
967
969
  what every already-shipped app wrote.
968
970
 
971
+ ### Managed storage — `Studio::Storage` (on by default)
972
+
973
+ Object storage is Cloudflare R2, and only R2: AWS S3 was retired on 2026-10-10.
974
+ An app that names its bucket (`config.s3_bucket_prefix`) gets all of this from
975
+ the engine, with no initializer of its own:
976
+
977
+ - **A boot check** (`studio.storage`, after the host's initializers). Both
978
+ switches, `ACTIVE_STORAGE_BACKEND` and `STUDIO_S3_BACKEND`, may be unset or
979
+ `r2`; a retired value (`s3`, `mirror_to_r2`, `mirror_to_s3`) or any other
980
+ value refuses the boot in every environment. In production (QA boots
981
+ `RAILS_ENV=production` too) a missing `R2_ENDPOINT`, `R2_ACCESS_KEY_ID` or
982
+ `R2_SECRET_ACCESS_KEY` refuses the boot, naming every missing variable and the
983
+ 1Password item `r2.<prefix>`. Elsewhere, none set boots on a placeholder
984
+ connection (`https://r2-not-configured.invalid`, which never resolves, and
985
+ placeholder keys), and some set raises: half a configuration is a mistake.
986
+ - **`Studio::S3` on R2**: endpoint, region `auto`, keys and public URL, filled in
987
+ only when the app left `s3_endpoint` unset. A public URL the app set is kept.
988
+ - **Active Storage services** `amazon` (this environment's bucket) and
989
+ `amazon_dev` (always the dev bucket), both `service: StudioTrashS3` on R2 with
990
+ `request_checksum_calculation` / `response_checksum_validation` set to
991
+ `when_required`. The bucket is `<prefix>-production` on real production only;
992
+ QA (`QA_ENV`), development, test and CI resolve `<prefix>-dev`, the same
993
+ resolution `Studio::S3` uses. They are merged UNDER the app's own
994
+ `config/storage.yml`: a service of the same name there wins, and an app with
995
+ no storage file still gets both.
996
+
997
+ ```ruby
998
+ # config/initializers/studio.rb: the whole adoption
999
+ config.s3_bucket_prefix = "turf-monster"
1000
+ config.storage_public_url_required = true # only an app that serves public object URLs
1001
+ ```
1002
+
1003
+ `storage_public_url_required` makes `R2_PUBLIC_URL` as required as the keys
1004
+ (a keyless process then gets the placeholder host as its public URL). Leave it
1005
+ false for an app whose objects are private and served by signed URL.
1006
+ `config.manage_storage = false` opts out entirely.
1007
+
1008
+ For app code: `Studio::Storage.configured?` (every required variable set, so
1009
+ false on placeholders), `.remote_in_development?` (any R2 variable set),
1010
+ `.credential_hint`, `.bucket` / `.dev_bucket`, and `.connection`. Every method
1011
+ takes the environment hash and `strict:` as arguments, so each rule can be
1012
+ tested without booting.
1013
+
969
1014
  ### S3-compatible storage (Cloudflare R2)
970
1015
 
971
1016
  `Studio::S3` talks to any S3-compatible endpoint. The McRitchie fleet is moving
@@ -979,8 +1024,10 @@ config.s3_secret_access_key = ENV["R2_SECRET_ACCESS_KEY"]
979
1024
  config.s3_public_url = ENV["R2_PUBLIC_URL"] # custom domain on this env's bucket
980
1025
  ```
981
1026
 
982
- All four are nil by default, and nil is AWS exactly as before. The keys must be
983
- set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
1027
+ All four are nil by default. For an app that names its bucket, the engine fills
1028
+ them from the R2 variables at boot ([Managed storage](#managed-storage--studiostorage-on-by-default));
1029
+ setting `s3_endpoint` yourself keeps the connection yours. With managed storage
1030
+ off, nil is AWS exactly as before. The keys must be set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
984
1031
  endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
985
1032
  `upload` writes but returns `nil`; serve private objects with `signed_url`.
986
1033
 
@@ -1120,17 +1167,25 @@ Name every `assets.<domain>` host the bucket answers on. If the bucket has the
1120
1167
 
1121
1168
  ### Adopt it in an app
1122
1169
 
1123
- Active Storage: change the R2 service in `config/storage.yml` from `service: S3`
1124
- to `service: StudioTrashS3`. Every other key stays as it is:
1170
+ Active Storage: name your bucket and the engine supplies the trash services.
1171
+ With `config.s3_bucket_prefix` set, `amazon` and `amazon_dev` are
1172
+ `service: StudioTrashS3` on R2 with no storage.yml entry of your own
1173
+ ([Managed storage](#managed-storage--studiostorage-on-by-default)); select one in
1174
+ `config/environments/*.rb` as usual (`config.active_storage.service = :amazon`).
1175
+
1176
+ A service under another name, or an app with `manage_storage = false`, names the
1177
+ service itself in `config/storage.yml`:
1125
1178
 
1126
1179
  ```yaml
1127
- r2:
1180
+ archive:
1128
1181
  service: StudioTrashS3
1129
- bucket: my-app-production
1182
+ bucket: my-app-archive
1130
1183
  endpoint: <%= ENV["R2_ENDPOINT"] %>
1131
1184
  region: auto
1132
1185
  access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
1133
1186
  secret_access_key: <%= ENV["R2_SECRET_ACCESS_KEY"] %>
1187
+ request_checksum_calculation: when_required
1188
+ response_checksum_validation: when_required
1134
1189
  ```
1135
1190
 
1136
1191
  An app with its own S3 service subclass (turf-monster's
@@ -518,7 +518,9 @@ body.modal-open .studio-app-banner {
518
518
  single frame on a flick — measured 39px in turf-monster, the full
519
519
  178 -> 139. navCollapse() converts this to --nav-p units off
520
520
  --nav-ramp's 3x relation to the collapse total, so one number covers
521
- every band. */
521
+ every band. It binds only a header OUT of the page's flow: a header
522
+ in flow is held (studio/nav_collapse, the hold), so the page cannot
523
+ move, and its progress follows the scroll exactly. */
522
524
  --nav-max-step: 5px;
523
525
  --nav-pad: calc(1.5rem - 1rem * var(--nav-p, 0)); /* py-6 -> py-2 */
524
526
  --nav-logo-size: calc(3rem - 1rem * var(--nav-p, 0)); /* 48px -> 32px */
@@ -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
@@ -22,7 +22,25 @@
22
22
  // sizing opinion, so an app whose navbar collapses to different endpoints than
23
23
  // the engine's adopts the mechanism without touching its markup.
24
24
  //
25
- // Four details are load-bearing:
25
+ // Five details are load-bearing:
26
+ //
27
+ // the hold: the header keeps its EXPANDED footprint in the page's flow. As it
28
+ // shrinks, the controller gives the difference back as margin below it, so
29
+ // the document never changes height and nothing under the header moves on
30
+ // its own. Before the hold, the collapse reflowed the whole page every frame:
31
+ // content ran ahead of the finger while collapsing, a flick back to the top
32
+ // pushed the page DOWN for ~10 frames after the scroll had stopped, and near
33
+ // the bottom of a page the browser clamped scrollY back 4px a frame as the
34
+ // document shrank under it. Held, the page scrolls exactly as far as the
35
+ // finger moved it, and only the header itself changes size. A header that
36
+ // is not in flow (fixed or absolute) is left alone: it never moved the page.
37
+ //
38
+ // (Held, the two guards below exist only for an UNHELD header. The document
39
+ // no longer shortens, so the short-page flap cannot happen, and the per-frame
40
+ // cap, which slowed the header so the page it dragged would not jump, now
41
+ // only makes the header lag the scroll. Worse, a lagging header opens a gap
42
+ // above the content on a fast scroll back to the top. Held, the progress is
43
+ // the scroll position, every frame.)
26
44
  //
27
45
  // passive + rAF: the listener never blocks the compositor and coalesces a
28
46
  // burst of scroll events (iOS momentum fires far above 60Hz) into one write
@@ -31,7 +49,9 @@
31
49
  // frame, and it would leak the live page's scroll progress into the preview
32
50
  // headers on /navbar.
33
51
  //
34
- // the smoothstep: collapsing a sticky, IN-FLOW header pulls the page up,
52
+ // the smoothstep (written for an UNHELD header; held, the page does not
53
+ // move, and the smoothstep only shapes how the header itself shrinks):
54
+ // collapsing a sticky, IN-FLOW header pulls the page up,
35
55
  // so during the collapse content moves by the scroll AND by the shrink:
36
56
  // faster than the finger, always. That is inherent; reclaiming the vertical
37
57
  // space is the point. What is tunable is the shape of the burst. --nav-ramp
@@ -64,11 +84,13 @@ export const DEFAULT_MAX_STEP = 5;
64
84
  // scrollHeight document.documentElement.scrollHeight
65
85
  // innerHeight window.innerHeight
66
86
  // reduce prefers-reduced-motion matches
87
+ // held the header holds its footprint (see the hold above): no
88
+ // short-page guard, no per-frame cap
67
89
  //
68
90
  // Answers { p, settling }: the progress to publish, and whether the frame
69
91
  // clamp left it short of where the scroll position wants it (so the caller
70
92
  // must schedule another frame).
71
- export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, reduce }) {
93
+ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, reduce, held }) {
72
94
  // The height the document WOULD have with the navbar expanded. The
73
95
  // add-back is the whole trick; see the guard note above.
74
96
  var roomExpanded = scrollHeight - innerHeight + ramp * p;
@@ -76,7 +98,7 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
76
98
  // WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
77
99
  var target;
78
100
  var snap = false;
79
- if (roomExpanded < ramp + 24) {
101
+ if (!held && roomExpanded < ramp + 24) {
80
102
  target = 0;
81
103
  snap = true;
82
104
  } else if (reduce) {
@@ -85,6 +107,8 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
85
107
  } else {
86
108
  var t = Math.min(1, y / ramp);
87
109
  target = t * t * (3 - 2 * t);
110
+ // Held, the progress IS the scroll position: nothing to cap.
111
+ if (held) snap = true;
88
112
  }
89
113
 
90
114
  // THE RATE LIMIT: how far the collapse may travel in ONE frame.
@@ -115,7 +139,7 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
115
139
  if (snap) {
116
140
  // A guard refusal and a reduced-motion state are DECISIONS, not
117
141
  // motion; ramping them would animate the very thing each exists to
118
- // avoid.
142
+ // avoid. A held header follows the scroll exactly.
119
143
  next = target;
120
144
  } else {
121
145
  var maxStep = (3 * maxPx) / ramp;
@@ -135,6 +159,30 @@ export function shadowLit(lit, y) {
135
159
  return lit ? y > 5 : y > 60;
136
160
  }
137
161
 
162
+ // THE HOLD, in two parts.
163
+ //
164
+ // THE LINEAR PART IS CSS. The header carries
165
+ // margin-bottom: calc(base + var(--nav-hold) * var(--nav-p) + var(--nav-hold-fix))
166
+ // where --nav-hold is its rest height minus its collapsed height. That margin
167
+ // grows in the SAME style recalc that shrinks the header, so the document
168
+ // never shortens, not even for one layout. (Setting the margin from script
169
+ // after the shrink was the first version of this, and it lost: the shrink's
170
+ // own layout shortened the document, and on a short page the browser clamped
171
+ // scrollY before the margin came back. Measured: 211 -> 167.)
172
+ //
173
+ // THE RESIDUE IS SCRIPT. A collapse need not be exactly linear in --nav-p
174
+ // (the tallest thing in the row can change mid-ramp), and the scrolled state
175
+ // may add a border. holdFix answers what the linear part missed, so the
176
+ // footprint is the rest height exactly: rest - (height + hold * p).
177
+ export function holdFix({ rest, height, hold, p }) {
178
+ return rest - height - hold * p;
179
+ }
180
+
181
+ // Only a header in the page's flow moves the page as it shrinks.
182
+ export function inFlow(position) {
183
+ return position !== 'fixed' && position !== 'absolute';
184
+ }
185
+
138
186
  // --nav-ramp and --nav-max-step off the header's computed style, each with
139
187
  // its default when the band declares none.
140
188
  export function readTuning(style) {
@@ -162,12 +210,60 @@ export class NavCollapse {
162
210
  this._onScroll = this.onScroll.bind(this);
163
211
  this._onResize = this.onResize.bind(this);
164
212
  this._apply = this.apply.bind(this);
213
+ this.held = false;
214
+ this.rest = 0;
215
+ this.hold = 0;
216
+ this.fix = 0;
217
+ this.lastHeight = null;
218
+ this._observer = null;
219
+ this._remeasure = this.remeasure.bind(this);
165
220
  }
166
221
 
167
222
  start() {
168
223
  this.stopped = false;
169
224
  this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
170
- this.tuning = readTuning(getComputedStyle(this.el));
225
+ // A HEADER RESTORED FROM TURBO'S SNAPSHOT CACHE arrives as it was left:
226
+ // its scrolled classes on, and this controller's own hold still inline
227
+ // (Turbo copies the page before the old controller disconnects). Read as
228
+ // the header's own, that hold was counted twice on Back: a permanent band
229
+ // under the header, 39-43px on turf-monster's contest page. So the
230
+ // controller clears what it owns before it reads anything.
231
+ this.onScrolled(false);
232
+ this.el.style.marginBottom = '';
233
+ this.el.style.removeProperty('--nav-hold');
234
+ this.el.style.removeProperty('--nav-hold-fix');
235
+ this.el.style.removeProperty('--nav-p');
236
+ var style = getComputedStyle(this.el);
237
+ this.tuning = readTuning(style);
238
+ this.held = inFlow(style.position);
239
+ if (this.held) {
240
+ var base = parseFloat(style.marginBottom) || 0;
241
+ this.el.style.marginBottom =
242
+ 'calc(' + base + 'px + var(--nav-hold, 0px) * var(--nav-p, 0) + var(--nav-hold-fix, 0px))';
243
+ this.measure();
244
+ // The header's height also moves for reasons that are not --nav-p: the
245
+ // scrolled state's bottom border (1px), a webfont landing, a
246
+ // Turbo-streamed badge. The observer re-applies the hold for any of
247
+ // them, in the frame they land. Only a header AT REST (unscrolled,
248
+ // expanded) redefines the rest height, because that is the one state
249
+ // whose height the footprint must equal: scrolled, a change is
250
+ // something the header is doing, not what it rests at.
251
+ if (typeof ResizeObserver === 'function') {
252
+ var self = this;
253
+ this._observer = new ResizeObserver(function () {
254
+ if (self.stopped) return;
255
+ var h = self.el.getBoundingClientRect().height;
256
+ if (self.lastHeight !== null && Math.abs(h - self.lastHeight) <= 0.1) return;
257
+ self.lastHeight = h;
258
+ // At rest the header's height IS its rest height, and a change
259
+ // there is new content: measure both endpoints again.
260
+ if (self.atRest() && Math.abs(h - self.rest) > 0.1) self.measure();
261
+ else self.correct(h);
262
+ });
263
+ this._observer.observe(this.el);
264
+ }
265
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(this._remeasure);
266
+ }
171
267
  this.apply();
172
268
 
173
269
  window.addEventListener('scroll', this._onScroll, { passive: true });
@@ -181,6 +277,13 @@ export class NavCollapse {
181
277
  this.stopped = true;
182
278
  window.removeEventListener('scroll', this._onScroll);
183
279
  window.removeEventListener('resize', this._onResize);
280
+ if (this._observer) this._observer.disconnect();
281
+ this._observer = null;
282
+ if (this.held) {
283
+ this.el.style.marginBottom = '';
284
+ this.el.style.removeProperty('--nav-hold');
285
+ this.el.style.removeProperty('--nav-hold-fix');
286
+ }
184
287
  if (this._reduce && this._reduce.removeEventListener) {
185
288
  this._reduce.removeEventListener('change', this._apply);
186
289
  }
@@ -194,9 +297,69 @@ export class NavCollapse {
194
297
 
195
298
  onResize() {
196
299
  this.tuning = readTuning(getComputedStyle(this.el));
300
+ if (this.held) this.measure();
197
301
  this.onScroll();
198
302
  }
199
303
 
304
+ atRest() {
305
+ return this.p === 0 && window.scrollY <= 0;
306
+ }
307
+
308
+ // The header's height at a given --nav-p, read by putting it there for the
309
+ // length of one synchronous layout. Nothing paints in between, so the
310
+ // reader never sees it. The margin is pinned to the rest height while it
311
+ // reads, so the document only ever GROWS during the read: growing cannot
312
+ // clamp the scroll position, shrinking can.
313
+ heightAt(p) {
314
+ var style = this.el.style;
315
+ var was = style.getPropertyValue('--nav-p');
316
+ style.setProperty('--nav-p', String(p));
317
+ var h = this.el.getBoundingClientRect().height;
318
+ if (was) style.setProperty('--nav-p', was); else style.removeProperty('--nav-p');
319
+ return h;
320
+ }
321
+
322
+ // Both endpoints, then the linear hold between them and the residue at the
323
+ // current progress. Both are read UNSCROLLED: the rest height is the
324
+ // header's height at the top of the page, where the scrolled state (and any
325
+ // border it carries) is off. The residue absorbs that state while it is on.
326
+ measure() {
327
+ var style = this.el.style;
328
+ var lit = this.scrolled;
329
+ if (lit) this.onScrolled(false);
330
+ style.setProperty('--nav-hold-fix', '0px');
331
+ this.fix = 0;
332
+ style.setProperty('--nav-hold', '0px');
333
+ var pinned = style.marginBottom;
334
+ style.marginBottom = '0px';
335
+ var rest = this.heightAt(0);
336
+ style.marginBottom = rest + 'px';
337
+ var collapsed = this.heightAt(1);
338
+ style.marginBottom = pinned;
339
+ if (lit) this.onScrolled(true);
340
+ this.rest = rest;
341
+ this.hold = Math.max(0, rest - collapsed);
342
+ style.setProperty('--nav-hold', this.hold + 'px');
343
+ this.correct(this.el.getBoundingClientRect().height);
344
+ }
345
+
346
+ remeasure() {
347
+ if (this.stopped || !this.held) return;
348
+ this.measure();
349
+ }
350
+
351
+ // Write the residue the linear hold missed, when it moved by more than a
352
+ // rounding error. The observer calls this in the frame the height changed,
353
+ // before paint.
354
+ correct(height) {
355
+ this.lastHeight = height;
356
+ var fix = holdFix({ rest: this.rest, height: height, hold: this.hold, p: this.p });
357
+ if (Math.abs(fix - this.fix) > 0.1) {
358
+ this.fix = fix;
359
+ this.el.style.setProperty('--nav-hold-fix', fix.toFixed(2) + 'px');
360
+ }
361
+ }
362
+
200
363
  apply() {
201
364
  this.queued = false;
202
365
  if (this.stopped) return;
@@ -210,7 +373,8 @@ export class NavCollapse {
210
373
  maxPx: this.tuning.maxPx,
211
374
  scrollHeight: document.documentElement.scrollHeight,
212
375
  innerHeight: window.innerHeight,
213
- reduce: this._reduce.matches
376
+ reduce: this._reduce.matches,
377
+ held: this.held
214
378
  });
215
379
 
216
380
  if (frame.p !== this.p) {
@@ -85,6 +85,69 @@ export function composed(pins) {
85
85
 
86
86
  var started = false
87
87
 
88
+ // THE READERS OF THE STACK, found rather than declared.
89
+ //
90
+ // Every value this publisher writes is an inherited custom property on
91
+ // documentElement, so every write restyles the WHOLE document, whether or not
92
+ // an element reads it. On a scroll frame that is the hot path: the header's
93
+ // bottom edge moves every frame while an ambient bar scrolls away and while
94
+ // the navbar collapses. Measured on turf-monster's contest page (about 2,000
95
+ // elements): one root write cost 8.7ms of style recalc against 0.2ms for the
96
+ // same write on the header, and the collapse dropped every second or third
97
+ // frame on a 120Hz screen. The hub's 478-element /links paid 1ms.
98
+ //
99
+ // Only a handful of elements read these values. So while a scroll is in
100
+ // motion the publisher writes them onto those elements alone, and writes the
101
+ // root once the scroll settles. A reader is any element that
102
+ // · carries [data-pin-follow], the explicit opt-in;
103
+ // · uses one in its inline style (studio/_sidebar_panel does); or
104
+ // · matches a stylesheet rule that uses one (scanned once per document).
105
+ // The document's root and body are never followers: a rule there (a custom
106
+ // property derived from --nav-h on :root) is served by the settled write.
107
+ //
108
+ // A reader follows LIVE only when it uses a value for something that paints.
109
+ // scroll-margin and scroll-padding matter only at the moment of an anchor
110
+ // jump, and the settled root serves them: turf-monster's contest board carries
111
+ // `scroll-margin-top: var(--nav-h)` on a 1,200-element subtree, and following
112
+ // it live cost the whole page's restyle back.
113
+ export var READS_THE_STACK = /var\(\s*--(pin-|nav-bottom|nav-h\b)/;
114
+
115
+ export function readsLive(cssText) {
116
+ var decls = String(cssText || '').split(';');
117
+ for (var i = 0; i < decls.length; i++) {
118
+ var at = decls[i].indexOf(':');
119
+ if (at < 0) continue;
120
+ var name = decls[i].slice(0, at).trim().toLowerCase();
121
+ if (/^scroll-(margin|padding)/.test(name)) continue;
122
+ if (READS_THE_STACK.test(decls[i].slice(at + 1))) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export function followerSelectors(sheets) {
128
+ var found = ['[data-pin-follow]', '[style*="var(--pin-"]', '[style*="var(--nav-bottom"]', '[style*="var(--nav-h"]'];
129
+ function walk(rules) {
130
+ for (var i = 0; rules && i < rules.length; i++) {
131
+ var rule = rules[i];
132
+ if (rule.selectorText && rule.style && readsLive(rule.style.cssText)) {
133
+ var parts = rule.selectorText.split(',');
134
+ for (var j = 0; j < parts.length; j++) {
135
+ var sel = parts[j].trim();
136
+ if (sel && found.indexOf(sel) === -1) found.push(sel);
137
+ }
138
+ }
139
+ if (rule.cssRules) walk(rule.cssRules);
140
+ }
141
+ }
142
+ for (var k = 0; k < sheets.length; k++) {
143
+ var rules = null;
144
+ // A cross-origin sheet refuses cssRules; it cannot hold this app's readers.
145
+ try { rules = sheets[k].cssRules; } catch (e) { rules = null; }
146
+ walk(rules);
147
+ }
148
+ return found;
149
+ }
150
+
88
151
  export function startPinnedStack() {
89
152
  if (started || !window.ResizeObserver) return;
90
153
  started = true;
@@ -109,6 +172,73 @@ export function startPinnedStack() {
109
172
  // when one departs. The REGISTRY ITSELF is never cached; see readPins().
110
173
  var observed = [];
111
174
 
175
+ // THE MOTION WRITE (see READS_THE_STACK above). lastScrollAt marks the
176
+ // gesture; within SETTLE_MS of it a publish writes the followers, and a
177
+ // timer writes the root once the scroll has been still that long.
178
+ var SETTLE_MS = 160;
179
+ var lastScrollAt = -Infinity;
180
+ var settleTimer = null;
181
+ var selectors = null;
182
+ // The scan is redone when the document's stylesheets change (a Turbo visit
183
+ // into a page with its own), keyed on their hrefs and count.
184
+ var scannedSheets = null;
185
+ var followed = [];
186
+ var followedProps = [];
187
+
188
+ function followers() {
189
+ var key = [].map.call(document.styleSheets, function (sheet) { return sheet.href || 'inline'; }).join('|');
190
+ if (!selectors || key !== scannedSheets) {
191
+ selectors = followerSelectors(document.styleSheets);
192
+ scannedSheets = key;
193
+ }
194
+ var out = [];
195
+ for (var i = 0; i < selectors.length; i++) {
196
+ var nodes;
197
+ // A selector the scan lifted may not be queryable (a pseudo-element).
198
+ try { nodes = document.querySelectorAll(selectors[i]); } catch (e) { continue; }
199
+ for (var j = 0; j < nodes.length; j++) {
200
+ var n = nodes[j];
201
+ if (n === document.documentElement || n === document.body || n === document.head) continue;
202
+ // The inline matches are substring guesses; the declarations decide.
203
+ if (selectors[i].charAt(0) === '[' && selectors[i] !== '[data-pin-follow]' && !readsLive(n.getAttribute('style'))) continue;
204
+ if (out.indexOf(n) === -1) out.push(n);
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ function writeFollowers(props) {
211
+ var targets = followers();
212
+ // An element that stopped reading the stack (or left the page) gives its
213
+ // copies back, so it inherits the root again.
214
+ for (var f = 0; f < followed.length; f++) {
215
+ if (targets.indexOf(followed[f]) !== -1) continue;
216
+ for (var g = 0; g < followedProps.length; g++) followed[f].style.removeProperty(followedProps[g][0]);
217
+ }
218
+ for (var i = 0; i < targets.length; i++) {
219
+ for (var k = 0; k < props.length; k++) {
220
+ if (targets[i].style.getPropertyValue(props[k][0]) !== props[k][1]) targets[i].style.setProperty(props[k][0], props[k][1]);
221
+ }
222
+ }
223
+ followed = targets;
224
+ followedProps = props;
225
+ }
226
+
227
+ // Once the root holds the settled values, the followers inherit them again.
228
+ function releaseFollowers() {
229
+ for (var i = 0; i < followed.length; i++) {
230
+ for (var k = 0; k < followedProps.length; k++) followed[i].style.removeProperty(followedProps[k][0]);
231
+ }
232
+ followed = [];
233
+ followedProps = [];
234
+ }
235
+
236
+ function settle() {
237
+ settleTimer = null;
238
+ publishAll();
239
+ releaseFollowers();
240
+ }
241
+
112
242
  // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
113
243
  //
114
244
  // This used to build a `pins` array once and hold each element by reference
@@ -174,6 +304,22 @@ export function startPinnedStack() {
174
304
  if (!force) {
175
305
  for (i = 0; i < pins.length; i++) { if (!last.hasOwnProperty(pins[i].name)) { force = true; break; } }
176
306
  }
307
+
308
+ // IN MOTION, THE FOLLOWERS ONLY. A structural change still writes the
309
+ // root at once: it is rare, and a layer arriving must reach every reader.
310
+ if (!force && performance.now() - lastScrollAt < SETTLE_MS) {
311
+ var props = [];
312
+ for (i = 0; i < pins.length; i++) {
313
+ p = pins[i];
314
+ props.push(['--pin-' + p.name + '-h', p.h + 'px'], ['--pin-' + p.name + '-bottom', p.bottom + 'px'], ['--pin-' + p.name + '-top', p.top + 'px']);
315
+ if (p.legacy) props.push(['--nav-h', p.h + 'px'], ['--nav-bottom', p.bottom + 'px']);
316
+ }
317
+ props.push(['--pin-stack-bottom', stack + 'px']);
318
+ writeFollowers(props);
319
+ if (settleTimer) clearTimeout(settleTimer);
320
+ settleTimer = setTimeout(settle, SETTLE_MS);
321
+ return;
322
+ }
177
323
  for (i = 0; i < pins.length; i++) {
178
324
  p = pins[i];
179
325
  seen[p.name] = true;
@@ -287,6 +433,12 @@ export function startPinnedStack() {
287
433
  function invalidate() { forceWrite = true; publishAll(); }
288
434
  document.addEventListener('DOMContentLoaded', invalidate);
289
435
  document.addEventListener('turbo:load', invalidate);
436
+ // Turbo copies the page into its snapshot cache before it leaves: give the
437
+ // followers' copies back first, so a restored page carries none of them.
438
+ document.addEventListener('turbo:before-cache', function () {
439
+ if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
440
+ releaseFollowers();
441
+ });
290
442
  // A Turbo Stream replaces nodes WITHOUT a turbo:load. The registry is derived
291
443
  // per publish, so a stale reference is no longer possible either way — but the
292
444
  // OBSERVER still has to be moved onto the incoming node, and the stack has to
@@ -297,6 +449,8 @@ export function startPinnedStack() {
297
449
  // Registered ONCE at start, not inside a re-scan, so a Turbo nav
298
450
  // re-publishes instead of stacking another listener per visit.
299
451
  window.addEventListener('scroll', schedule, { passive: true });
452
+ // The gesture's clock for the motion write (see READS_THE_STACK).
453
+ window.addEventListener('scroll', function () { lastScrollAt = performance.now(); }, { passive: true });
300
454
  window.addEventListener('resize', schedule, { passive: true });
301
455
 
302
456
  // A deferred module runs after the body is parsed, so the layers are already
data/lib/studio/engine.rb CHANGED
@@ -42,6 +42,30 @@ module Studio
42
42
  # silently assigns the OrderedOptions and rotation then dies inside a
43
43
  # rescued comparison. The behavioral test caught that; a config read-back
44
44
  # would have called it green.
45
+ # STORAGE, PART 1: THE ACTIVE STORAGE SERVICES (Studio::Storage). Active
46
+ # Storage builds its service registry in an on_load(:active_storage_blob)
47
+ # hook its "active_storage.services" initializer registers, reading
48
+ # config.active_storage.service_configurations, or config/storage.yml when
49
+ # that is nil. Hooks run in registration order, so this one, registered
50
+ # BEFORE that initializer, runs first and hands it the app's own services
51
+ # with the engine's `amazon` / `amazon_dev` merged under them (an app's
52
+ # service of the same name wins). Lazy like Active Storage's own read: it
53
+ # runs when Blob first loads, after every host initializer, so it sees the
54
+ # host's s3_bucket_prefix. An app still configuring Studio::S3 itself
55
+ # (Studio::Storage.owns_connection? false) keeps its services as they are.
56
+ # Declared first in this class so Railtie's
57
+ # implicit `after: <previous initializer>` chaining ties it to nothing.
58
+ initializer "studio.storage.services", before: "active_storage.services" do |app|
59
+ ActiveSupport.on_load(:active_storage_blob) do
60
+ next unless Studio::Storage.enabled? && Studio::Storage.owns_connection?
61
+
62
+ storage = app.config.active_storage
63
+ storage.service_configurations = Studio::Storage.merge_services(
64
+ storage.service_configurations || Studio::Storage.storage_file_configurations(Rails.root, Rails.env)
65
+ )
66
+ end
67
+ end
68
+
45
69
  initializer "studio.logger", before: :initialize_logger, after: :load_environment_hook do |app|
46
70
  cap = Studio::LogRotation.cap_for(
47
71
  env: Rails.env,
@@ -254,6 +278,23 @@ module Studio
254
278
  end
255
279
  end
256
280
 
281
+ # STORAGE, PART 2: THE BOOT CHECK and Studio::S3 on R2 (Studio::Storage),
282
+ # for an app that names its bucket. After the host's config/initializers,
283
+ # where s3_bucket_prefix and storage_public_url_required are set, and before
284
+ # anything serves. Raising here fails a release phase's db:migrate instead of
285
+ # crash-looping web and worker afterwards. Both switches are always checked;
286
+ # the connection is checked and applied only when the host left s3_endpoint
287
+ # unset, so an app still carrying its own StorageBackend keeps its own rules.
288
+ # Declared LAST among this class's initializers: Railtie chains each
289
+ # initializer `after:` the one declared before it, so anything declared
290
+ # below this would be dragged after the host's initializers too.
291
+ initializer "studio.storage", after: :load_config_initializers do
292
+ next unless Studio::Storage.enabled?
293
+
294
+ Studio::Storage.validate!(check_connection: false)
295
+ Studio::Storage.apply_studio_s3_settings!
296
+ end
297
+
257
298
  # `studio_engine:install:migrations` with no ActiveRecord. Rails defines it
258
299
  # for every engine that ships db/migrate, and it invokes
259
300
  # `railties:install:migrations`, which only ActiveRecord's tasks define, so
@@ -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))
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "environment_banner"
4
+
5
+ module Studio
6
+ # OBJECT STORAGE IS CLOUDFLARE R2, AND ONLY R2. One copy of the rules the hub,
7
+ # turf-monster and mcritchie-industries each carried as a hand-written
8
+ # `StorageBackend` module (AWS S3 was retired on 2026-10-10, so there is no S3
9
+ # stage and no mirror stage left to select). README.md, "Managed storage".
10
+ #
11
+ # ON BY DEFAULT for every app that names its bucket (`config.s3_bucket_prefix`).
12
+ # Such an app gets, with no initializer of its own:
13
+ #
14
+ # - the BOOT CHECK (Studio::Engine, "studio.storage"): a retired or unknown
15
+ # switch value, or a missing R2 variable in production, refuses the boot
16
+ # by name. config/storage.yml is rendered lazily (a release-phase
17
+ # db:migrate never renders it), so without a boot check a bad value passes
18
+ # the release and crash-loops web and worker instead.
19
+ # - Studio::S3 on R2: endpoint, region "auto", keys and public URL, filled in
20
+ # when the app left `s3_endpoint` unset (an app that hand-configures the
21
+ # connection keeps it; the engine stands back).
22
+ # - Active Storage services `amazon` (this environment's bucket) and
23
+ # `amazon_dev` (always the dev bucket), both `service: StudioTrashS3` on R2.
24
+ # The names are kept because blob rows record them. A service of the same
25
+ # name in the app's own config/storage.yml WINS, so an app's own copy keeps
26
+ # working until it adopts this one.
27
+ #
28
+ # Opt out with `config.manage_storage = false`.
29
+ #
30
+ # THE TWO SWITCHES, ACTIVE_STORAGE_BACKEND and STUDIO_S3_BACKEND, staged the
31
+ # move and are still `r2` on the deployed apps. Unset or `r2` means R2. A
32
+ # retired value (s3, mirror_to_r2, mirror_to_s3) or any other value RAISES in
33
+ # every environment: it must never look like a successful selection of a store
34
+ # that no longer exists.
35
+ #
36
+ # THE CONNECTION: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (1Password
37
+ # item r2.<prefix>: the prod pair on production, the dev pair on QA and
38
+ # locally), plus R2_PUBLIC_URL, which is required only when the app sets
39
+ # `config.storage_public_url_required = true` (it serves public object URLs to
40
+ # strangers; an app with private objects only has no public domain).
41
+ #
42
+ # A MISSING VARIABLE:
43
+ # production (QA boots RAILS_ENV=production too) RAISES, naming every
44
+ # missing variable. A dyno that boots without storage fails at the first
45
+ # upload hours later; one that refuses to boot fails the deploy.
46
+ # elsewhere, NONE set (test, CI, a keyless desk) boots on a placeholder
47
+ # connection: a reserved `.invalid` host (RFC 2606, never resolves) and
48
+ # placeholder keys, so nothing reaches AWS through the SDK's default
49
+ # endpoint or credential chain, and the first real call fails naming
50
+ # r2-not-configured.invalid.
51
+ # elsewhere, SOME set RAISES, naming the missing ones: half a configuration
52
+ # is a mistake, not a choice.
53
+ #
54
+ # Dependency-free apart from EnvironmentBanner, so the unit suite requires it
55
+ # directly and every method takes its inputs as arguments.
56
+ module Storage
57
+ BACKEND = "r2"
58
+ SWITCHES = %w[ACTIVE_STORAGE_BACKEND STUDIO_S3_BACKEND].freeze
59
+ RETIRED_VALUES = %w[s3 mirror_to_r2 mirror_to_s3].freeze
60
+
61
+ CONNECTION_VARIABLES = %w[R2_ENDPOINT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY].freeze
62
+ PUBLIC_URL_VARIABLE = "R2_PUBLIC_URL"
63
+ REGION = "auto"
64
+
65
+ # The engine's Active Storage service: S3Service whose delete MOVES the
66
+ # object under trash/<utc date>/<epoch ms>/<key>, so a purged or replaced
67
+ # attachment has a three-day grace period
68
+ # (lib/active_storage/service/studio_trash_s3_service.rb).
69
+ ACTIVE_STORAGE_SERVICE = "StudioTrashS3"
70
+ # The service names blob rows record. `amazon` is this environment's bucket;
71
+ # `amazon_dev` is the dev bucket in every environment (local desks).
72
+ SERVICE = "amazon"
73
+ DEV_SERVICE = "amazon_dev"
74
+
75
+ UNCONFIGURED_HOST = "r2-not-configured.invalid"
76
+ PLACEHOLDER_ENDPOINT = "https://#{UNCONFIGURED_HOST}"
77
+ PLACEHOLDER_KEY = "r2-not-configured"
78
+ PLACEHOLDER_PUBLIC_URL = "https://#{UNCONFIGURED_HOST}"
79
+
80
+ module_function
81
+
82
+ # Whether the engine manages this app's storage: it named its bucket and did
83
+ # not opt out.
84
+ def enabled?(prefix: Studio.s3_bucket_prefix, manage: manage_setting)
85
+ manage != false && !prefix.to_s.strip.empty?
86
+ end
87
+
88
+ # Whether a missing R2 variable refuses the boot. Production and QA both run
89
+ # RAILS_ENV=production. The one exception is Rails' own build-time flag:
90
+ # `SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile` (a Dockerfile) boots
91
+ # production with no secrets on purpose and never touches storage, so it
92
+ # boots on the placeholder connection instead (moms-app's rule).
93
+ def strict?(env = ENV)
94
+ rails_production? && env["SECRET_KEY_BASE_DUMMY"].to_s.strip.empty?
95
+ end
96
+
97
+ def rails_production?
98
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:production?) && Rails.env.production? ? true : false
99
+ end
100
+
101
+ def active_storage_stage(env = ENV)
102
+ stage(env, "ACTIVE_STORAGE_BACKEND")
103
+ end
104
+
105
+ def studio_s3_stage(env = ENV)
106
+ stage(env, "STUDIO_S3_BACKEND")
107
+ end
108
+
109
+ def stage(env, name)
110
+ value = env[name].to_s.strip
111
+ return BACKEND if value.empty? || value == BACKEND
112
+
113
+ if RETIRED_VALUES.include?(value)
114
+ raise ArgumentError, "#{name}=#{value.inspect} names a retired stage: AWS S3 was retired on 2026-10-10 " \
115
+ "and storage runs on Cloudflare R2 only. Unset #{name} or set it to #{BACKEND}."
116
+ end
117
+
118
+ raise ArgumentError, "#{name}=#{value.inspect} is not a storage backend; unset #{name} or set it to #{BACKEND}"
119
+ end
120
+
121
+ # The variables a complete connection needs.
122
+ def variables(public_url_required: public_url_required_setting)
123
+ public_url_required ? CONNECTION_VARIABLES + [PUBLIC_URL_VARIABLE] : CONNECTION_VARIABLES
124
+ end
125
+
126
+ # Whether this process holds a real R2 connection: every required variable
127
+ # set. False on CI and a keyless desk, where the connection is placeholders;
128
+ # code that must refuse up front rather than fail at its first call asks
129
+ # this, because the placeholder keys make "is a key set?" answer yes.
130
+ def configured?(env = ENV, public_url_required: public_url_required_setting)
131
+ variables(public_url_required: public_url_required).all? { |name| present?(env, name) }
132
+ end
133
+
134
+ # Whether development should talk to the remote dev bucket rather than Disk:
135
+ # whenever any R2 variable is set at all (a laptop's .env.development).
136
+ def remote_in_development?(env = ENV)
137
+ (CONNECTION_VARIABLES + [PUBLIC_URL_VARIABLE]).any? { |name| present?(env, name) }
138
+ end
139
+
140
+ # The R2 connection: { endpoint:, access_key_id:, secret_access_key:,
141
+ # public_url: }. public_url is nil when R2_PUBLIC_URL is unset and not
142
+ # required. See the module comment for what a missing variable does.
143
+ def connection(env = ENV, strict: strict?, public_url_required: public_url_required_setting,
144
+ prefix: Studio.s3_bucket_prefix)
145
+ names = variables(public_url_required: public_url_required)
146
+ missing = names.reject { |name| present?(env, name) }
147
+
148
+ if missing.empty?
149
+ return { endpoint: value(env, "R2_ENDPOINT"), access_key_id: value(env, "R2_ACCESS_KEY_ID"),
150
+ secret_access_key: value(env, "R2_SECRET_ACCESS_KEY"),
151
+ public_url: present?(env, PUBLIC_URL_VARIABLE) ? value(env, PUBLIC_URL_VARIABLE) : nil }
152
+ end
153
+
154
+ if !strict && missing.size == names.size
155
+ return { endpoint: PLACEHOLDER_ENDPOINT, access_key_id: PLACEHOLDER_KEY, secret_access_key: PLACEHOLDER_KEY,
156
+ public_url: public_url_required ? PLACEHOLDER_PUBLIC_URL : nil }
157
+ end
158
+
159
+ why = if strict
160
+ "production and QA refuse to boot without it"
161
+ else
162
+ "#{(names - missing).join(', ')} #{names.size - missing.size == 1 ? 'is' : 'are'} set, and half a " \
163
+ "configuration is a mistake: set all of #{names.join(', ')} or none"
164
+ end
165
+ raise ArgumentError, "#{missing.join(', ')} must be set: storage runs on Cloudflare R2 only, and #{why} " \
166
+ "(#{credential_source(prefix)})"
167
+ end
168
+
169
+ # The credentials an operator should check when Studio::S3 fails wholesale.
170
+ def credential_hint(public_url_required: public_url_required_setting)
171
+ "#{variables(public_url_required: public_url_required).join(' / ')} (local dev reads them from .env.development)"
172
+ end
173
+
174
+ # The Studio.configure settings for Studio::S3. s3_public_url is present only
175
+ # when there is one to give (see #connection).
176
+ def studio_s3_settings(env = ENV, strict: strict?, public_url_required: public_url_required_setting,
177
+ prefix: Studio.s3_bucket_prefix)
178
+ studio_s3_stage(env)
179
+ r2 = connection(env, strict: strict, public_url_required: public_url_required, prefix: prefix)
180
+ settings = { s3_endpoint: r2[:endpoint], s3_region: REGION,
181
+ s3_access_key_id: r2[:access_key_id], s3_secret_access_key: r2[:secret_access_key] }
182
+ settings[:s3_public_url] = r2[:public_url] if r2[:public_url]
183
+ settings
184
+ end
185
+
186
+ # WHICH BUCKET. Only real production gets "<prefix>-production". QA boots
187
+ # RAILS_ENV=production exactly like production, so QA_ENV
188
+ # (Studio.qa_environment?) is what sends it to "<prefix>-dev", the same
189
+ # resolution Studio::S3 picks its bucket by, which keeps both writers on one
190
+ # bucket. A laptop, a desk and CI resolve the dev bucket too.
191
+ def bucket(prefix = Studio.s3_bucket_prefix, production: production_environment?)
192
+ "#{require_prefix(prefix)}-#{production ? 'production' : 'dev'}"
193
+ end
194
+
195
+ def dev_bucket(prefix = Studio.s3_bucket_prefix)
196
+ bucket(prefix, production: false)
197
+ end
198
+
199
+ # Real production: Rails production and not a QA app.
200
+ def production_environment?(env = ENV)
201
+ rails_production? && !EnvironmentBanner.qa_environment?(env)
202
+ end
203
+
204
+ # One Active Storage service config on R2. Checksums only when required:
205
+ # Active Storage sends Content-MD5, aws-sdk-s3 >= 1.178 adds a CRC32 by
206
+ # default, and R2 refuses a request carrying both (measured 2026-09-28).
207
+ def service(bucket_name, r2)
208
+ { "service" => ACTIVE_STORAGE_SERVICE,
209
+ "endpoint" => r2.fetch(:endpoint), "access_key_id" => r2.fetch(:access_key_id),
210
+ "secret_access_key" => r2.fetch(:secret_access_key),
211
+ "region" => REGION, "bucket" => bucket_name,
212
+ "request_checksum_calculation" => "when_required",
213
+ "response_checksum_validation" => "when_required" }
214
+ end
215
+
216
+ # The services the engine supplies: { "amazon" => ..., "amazon_dev" => ... }.
217
+ def active_storage_services(env = ENV, prefix: Studio.s3_bucket_prefix, strict: strict?,
218
+ production: production_environment?(env))
219
+ active_storage_stage(env)
220
+ r2 = connection(env, strict: strict, public_url_required: false, prefix: prefix)
221
+ { SERVICE => service(bucket(prefix, production: production), r2),
222
+ DEV_SERVICE => service(dev_bucket(prefix), r2) }
223
+ end
224
+
225
+ # The app's own service configurations with the engine's merged UNDER them:
226
+ # a name the app's storage.yml already defines keeps the app's definition.
227
+ # Computes nothing (and so raises nothing) when the app defines both.
228
+ def merge_services(existing, env = ENV, prefix: Studio.s3_bucket_prefix, strict: strict?,
229
+ production: production_environment?(env))
230
+ existing = (existing || {}).to_h
231
+ defined = existing.keys.map(&:to_s)
232
+ return existing if [SERVICE, DEV_SERVICE].all? { |name| defined.include?(name) }
233
+
234
+ supplied = active_storage_services(env, prefix: prefix, strict: strict, production: production)
235
+ supplied.reject { |name, _| defined.include?(name) }.merge(existing)
236
+ end
237
+
238
+ # What Active Storage itself would read when the app sets no
239
+ # service_configurations: config/storage/<env>.yml, else config/storage.yml.
240
+ # An app with neither starts from no services, and gets the engine's.
241
+ def storage_file_configurations(root, rails_env)
242
+ file = File.join(root.to_s, "config/storage/#{rails_env}.yml")
243
+ file = File.join(root.to_s, "config/storage.yml") unless File.exist?(file)
244
+ return {} unless File.exist?(file)
245
+
246
+ require "active_support/configuration_file"
247
+ ActiveSupport::ConfigurationFile.parse(file) || {}
248
+ end
249
+
250
+ # Point Studio::S3 at R2, unless the app configured the connection itself
251
+ # (s3_endpoint already set). A public URL the app set is never overwritten.
252
+ # Returns whether it wrote anything, and remembers the answer for
253
+ # #owns_connection?.
254
+ def apply_studio_s3_settings!(config = Studio, env = ENV, strict: strict?,
255
+ public_url_required: public_url_required_setting)
256
+ @owns_connection = config.s3_endpoint.to_s.strip.empty?
257
+ return false unless @owns_connection
258
+
259
+ settings = studio_s3_settings(env, strict: strict, public_url_required: public_url_required,
260
+ prefix: config.s3_bucket_prefix)
261
+ settings.delete(:s3_public_url) unless config.s3_public_url.to_s.strip.empty?
262
+ settings.each { |name, setting| config.public_send("#{name}=", setting) }
263
+ true
264
+ end
265
+
266
+ # Whether the engine owns this app's R2 connection: the app left s3_endpoint
267
+ # unset. An app that configured it is still carrying its own StorageBackend,
268
+ # which also decides its own Active Storage services (turf-monster defines
269
+ # NONE in a keyless process, and its suite pins that), so the engine adds no
270
+ # service to it either. Answered by the boot initializer once it has run;
271
+ # before that (a host initializer loading Blob early), by s3_endpoint itself.
272
+ def owns_connection?(config = Studio)
273
+ return @owns_connection unless @owns_connection.nil?
274
+
275
+ config.s3_endpoint.to_s.strip.empty?
276
+ end
277
+
278
+ def reset_ownership!
279
+ @owns_connection = nil
280
+ end
281
+
282
+ # THE BOOT CHECK. Both switches, always. The connection, when the engine
283
+ # owns it (the app left s3_endpoint unset); an app still carrying its own
284
+ # copy checks its own.
285
+ def validate!(env = ENV, strict: strict?, public_url_required: public_url_required_setting,
286
+ prefix: Studio.s3_bucket_prefix, check_connection: true)
287
+ active_storage_stage(env)
288
+ studio_s3_stage(env)
289
+ connection(env, strict: strict, public_url_required: public_url_required, prefix: prefix) if check_connection
290
+ true
291
+ end
292
+
293
+ def credential_source(prefix)
294
+ name = prefix.to_s.strip
295
+ name.empty? ? "1Password item r2.<app>" : "1Password item r2.#{name}"
296
+ end
297
+
298
+ def present?(env, name)
299
+ !value(env, name).empty?
300
+ end
301
+
302
+ def value(env, name)
303
+ env[name].to_s.strip
304
+ end
305
+
306
+ def require_prefix(prefix)
307
+ name = prefix.to_s.strip
308
+ raise ArgumentError, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if name.empty?
309
+
310
+ name
311
+ end
312
+
313
+ def manage_setting
314
+ Studio.respond_to?(:manage_storage) ? Studio.manage_storage : true
315
+ end
316
+
317
+ def public_url_required_setting
318
+ Studio.respond_to?(:storage_public_url_required) ? Studio.storage_public_url_required : false
319
+ end
320
+ end
321
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.107.0"
2
+ VERSION = "0.108.0.rc2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -44,6 +44,7 @@ require "studio/username_generator"
44
44
  require "studio/public_user"
45
45
  require "studio/name_parts"
46
46
  require "studio/s3"
47
+ require "studio/storage"
47
48
  require "studio/image_cache"
48
49
  require "studio/host_schema"
49
50
  require "studio/link_token"
@@ -1110,6 +1111,17 @@ module Studio
1110
1111
  # production process ignores it, because its own bucket IS production.
1111
1112
  mattr_accessor :s3_production_public_url, default: nil
1112
1113
 
1114
+ # STORAGE IS MANAGED BY DEFAULT (Studio::Storage; README.md, "Managed storage"). An app that
1115
+ # names its bucket (s3_bucket_prefix) gets the R2 boot check, Studio::S3 on R2
1116
+ # and the `amazon` / `amazon_dev` StudioTrashS3 services with no initializer of
1117
+ # its own. false opts out entirely.
1118
+ mattr_accessor :manage_storage, default: true
1119
+
1120
+ # Whether R2_PUBLIC_URL is as required as the keys: true for an app that hands
1121
+ # public object URLs to strangers (og:images, email banners, headshots). An app
1122
+ # whose objects are private and served by signed URL leaves it false.
1123
+ mattr_accessor :storage_public_url_required, default: false
1124
+
1113
1125
  # Knowledge layer — the S3-backed document store + /admin/knowledge browser
1114
1126
  # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
1115
1127
  # knowledge_agents is the roster of agent slugs the intake UI offers
@@ -1435,6 +1447,14 @@ module Studio
1435
1447
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
1436
1448
  get "auth/failure", to: "omniauth_callbacks#failure"
1437
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
+
1438
1458
  # Developer-desk tools. Drawn outside production, and each controller
1439
1459
  # re-checks Studio.local_tool_enabled? per request (loopback only) — the
1440
1460
  # route being absent is the outer gate, not the only one.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.107.0
4
+ version: 0.108.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -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
@@ -775,6 +776,7 @@ files:
775
776
  - lib/studio/session_state.rb
776
777
  - lib/studio/sidebar_sections.rb
777
778
  - lib/studio/site_footer.rb
779
+ - lib/studio/storage.rb
778
780
  - lib/studio/survey.rb
779
781
  - lib/studio/survey/breakdown.rb
780
782
  - lib/studio/survey/export.rb