studio-engine 0.76.2 → 0.77.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: 67f19e731d05c8bba0ba8a2b7d0aaf9aef71f26705e19014bc5532c6fc5f3917
4
- data.tar.gz: 511695ff90c382eab81c9860e882bcff9407c68a08abc9094cf87c605a25b6c3
3
+ metadata.gz: 0c579a6d057a79eb540fee2347d6000c6c5af80273e61efdbd2e1d8eda173214
4
+ data.tar.gz: 726c8217e6f23e277af7f3fcc215885d7bfed504ee2a215b574aa5775986ffad
5
5
  SHA512:
6
- metadata.gz: 358ae5bb8a8f101cf7713218de92d1e6b08b1e2f4acf772b4f110abdf997bc5c1a2979f83beeaa6861af6af84869680b4f9351d512186986e7fb1f6250c2f726
7
- data.tar.gz: 33173de4aa44555b127b772b1a10f84405809d10d08cc7b0aa64d8a6286bd5ea1d0816eb97ac7f0270b24fc987abe4670ba65715df6c3473d03e59501e3363d4
6
+ metadata.gz: 72b688c3d92118f08ea7ba2263db3973fedeec0d16d07d9f45296c9c68fc581e6f5d56f30eb6d3375f0dedff9d1330de6ed992f5080995ddb44aa0ec6b79eae1
7
+ data.tar.gz: cf4ed66373933628dccd7eeb8e59164da68cfcc25f3407befff234ebd18ce2793a2d60bfb71daae219ec8980f5f7ada6b3b34f092697d4dbcb8894f451acd162
data/CHANGELOG.md CHANGED
@@ -4,6 +4,70 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.77.0 — 2026-09-26
8
+
9
+ ### Added
10
+
11
+ - **`Studio::S3` runs on Cloudflare R2, or any S3-compatible endpoint.** Four new
12
+ settings, all nil by default: `s3_endpoint`, `s3_access_key_id` +
13
+ `s3_secret_access_key` (passed to the client as a pair, so an app can hold its
14
+ R2 keys beside the `AWS_*` ones during a cutover; half a pair raises), and
15
+ `s3_public_url`, the base public objects are served from. Unset, every app gets
16
+ the same client and the same amazonaws.com URLs as before. On an endpoint with
17
+ no public base, `url` raises `NotConfigured` instead of building a URL R2 would
18
+ refuse, and `upload` still writes but returns `nil`. Proven live against an R2
19
+ bucket: upload, download, exists, list, signed URL (200) and delete, with the
20
+ anonymous endpoint read refused.
21
+
22
+ ## 0.76.3 — 2026-09-23
23
+
24
+ ### Fixed
25
+
26
+ - **A board drag that the server refuses now tells you.** The board primitive's
27
+ drag-reorder POSTed the new order and discarded the answer: `saveOrder` ended in
28
+ `.catch(function () {})` — an EMPTY handler, on a `fetch` that *resolves* on a
29
+ 4xx and so never reached it. Two layers of silence over one request. The
30
+ endpoints on the other end had already written the operator a reason (turf-monster's
31
+ week board answers a drifted card set with *"That order does not match this week's
32
+ games — reload and try again"*, and `Studio::Board::Reorderable` renders
33
+ `{ error: … }` beside every 422 it raises) and that sentence was unreachable by
34
+ construction, while the board's CSS counter renumbered the cards as though the save
35
+ had landed. A refused save now toasts the server's own words — or `window.alert`s
36
+ them on a `toasts: false` board — and resolves `false`.
37
+ - **Fixed at the seam, so all five boards get it.** The check lives in the
38
+ factory's `request()`, which now REJECTS on a refusal rather than handing back a
39
+ resolved 4xx. That is the semantics both callers already assumed by writing
40
+ `.catch(…)`, and it is what neither underlying fetcher provides. Every board
41
+ riding this factory is covered by the one change: turf-monster's NFL week order,
42
+ and mcritchie-studio's tasks (the DevOps board itself), depth charts, content and
43
+ news. No consumer change is needed beyond the gem bump.
44
+ - **An expired session is no longer a silently lost drag either.** A host that
45
+ defines `window.authedFetch` (turf-monster does) gets `null` back from it on a
46
+ 401 or a rate-limited 429. `saveOrder` never checked for that, and `applyMove`
47
+ would have thrown a `TypeError` off `resp.ok`. `request()` now names it:
48
+ *"Session expired — please sign in again."*
49
+ - **A refused reorder is not auto-reverted, deliberately.** Unlike a single-card
50
+ move there is no one card to snap back: the whole column re-ranked, and SortableJS
51
+ hands the drop over with the pre-drop order already gone. Every reorder endpoint's
52
+ refusal tells the operator to reload, which restores the stored order exactly; the
53
+ board's job is to make sure that instruction is read. `optimistic:` governs moves
54
+ only, and `studio/board/_board` now says so.
55
+ - **`saveOrder` returns a promise** (resolving `true`/`false`, never rejecting), so
56
+ a caller can wait for a save to land instead of assuming it did.
57
+
58
+ ### Changed
59
+
60
+ - **The board factory is now executed under test, not grepped.**
61
+ `test/views/board_factory_save_behavior_test.rb` extracts
62
+ `studio/_board_assets`'s `<script>` and runs it under node with stub
63
+ `window`/`document`/`fetch`, asserting `saveOrder`, `applyMove` and `request` by
64
+ CALLING them — nine scenarios covering the refusal text, the `null` session, an
65
+ unparseable error body, the silent success, the `toasts: false` fallback, the
66
+ unchanged event/hook seam and both move outcomes. The bug above survived every
67
+ source-substring assertion in `board_primitive_test.rb`, because the broken build
68
+ contained the word `catch` too; that section now says where behavioral coverage
69
+ belongs.
70
+
7
71
  ## 0.76.2 — 2026-09-22
8
72
 
9
73
  ## 0.76.1 — 2026-09-19
data/README.md CHANGED
@@ -838,6 +838,24 @@ config.s3_key_prefix = "mcritchie-industries/"
838
838
  logical keys and never see it. Unset (the default) leaves keys byte-identical to
839
839
  what every already-shipped app wrote.
840
840
 
841
+ ### S3-compatible storage (Cloudflare R2)
842
+
843
+ `Studio::S3` talks to any S3-compatible endpoint. The McRitchie fleet is moving
844
+ to R2 (one account, McRitchie Studio's); an app switches in its initializer:
845
+
846
+ ```ruby
847
+ config.s3_endpoint = ENV["R2_ENDPOINT"] # https://<account>.r2.cloudflarestorage.com
848
+ config.s3_region = "auto"
849
+ config.s3_access_key_id = ENV["R2_ACCESS_KEY_ID"]
850
+ config.s3_secret_access_key = ENV["R2_SECRET_ACCESS_KEY"]
851
+ config.s3_public_url = ENV["R2_PUBLIC_URL"] # custom domain on this env's bucket
852
+ ```
853
+
854
+ All four are nil by default, and nil is AWS exactly as before. The keys must be
855
+ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
856
+ endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
857
+ `upload` writes but returns `nil`; serve private objects with `signed_url`.
858
+
841
859
  An app with **no** bucket configured does not error — `/admin/emails` renders
842
860
  read-only, showing the inherited defaults it is genuinely sending and naming the
843
861
  one setting that turns uploads on.
@@ -187,12 +187,11 @@
187
187
  body[this.moveParam.resource] = {};
188
188
  body[this.moveParam.resource][this.moveParam.attr] = newZone;
189
189
 
190
- return this.request(url, "PATCH", body).then(function (resp) {
191
- if (!resp.ok) {
192
- return resp.json().catch(function () { return {}; }).then(function (err) {
193
- throw new Error(err.error || ("Failed (" + resp.status + ")"));
194
- });
195
- }
190
+ // request() rejects on a refusal (and reads the server's message), so this
191
+ // `then` runs only on a real success and the `catch` below is the ONE
192
+ // failure path. It used to unwrap the 4xx itself; that check now lives at
193
+ // the seam, where saveOrder gets it too.
194
+ return this.request(url, "PATCH", body).then(function () {
196
195
  self.setCardZone(card, newZone);
197
196
  self.toast("Moved to " + self.label(newZone), "success");
198
197
  self.emit("board-moved", id, fromKey, newZone);
@@ -208,6 +207,23 @@
208
207
 
209
208
  // Save the destination column's order. Reads the ordered id list and POSTs
210
209
  // it under the neutral payload key (`slugs` | `ids`) plus the advisory zone.
210
+ //
211
+ // A REFUSAL IS SHOWN, NOT SWALLOWED. Through 0.76.2 this was a bare
212
+ // `.catch(){}` on a `fetch` that resolves on 4xx: two layers of silence over
213
+ // a server that had already written the operator its reason ("That order does
214
+ // not match this week's games — reload and try again"). The board renumbered
215
+ // the cards as though the save had landed. request() now rejects on a refusal
216
+ // and the catch below toasts what the server said.
217
+ //
218
+ // NO AUTO-REVERT, deliberately. Unlike a single-card move there is no one
219
+ // card to snap back — the whole column re-ranked, and SortableJS hands us the
220
+ // drop with the pre-drop order already gone. Every reorder endpoint's refusal
221
+ // tells the operator to RELOAD, which restores the stored truth exactly; the
222
+ // board's job is to make sure that instruction is read.
223
+ //
224
+ // RETURNS A PROMISE (resolving true/false, never rejecting) so a caller can
225
+ // wait for the save to land. It resolves rather than rejects so the
226
+ // fire-and-forget call in handleSortEnd cannot raise an unhandled rejection.
211
227
  saveOrder: function (toZone) {
212
228
  var self = this;
213
229
  var ids = Array.prototype.slice
@@ -216,12 +232,15 @@
216
232
  var zone = this.zoneKey(toZone);
217
233
  this.emit("board-reordered", null, zone, zone, { ids: ids, zone: zone });
218
234
  this.hook(this.onDropHook, { ids: ids, zone: zone });
219
- if (this.demo || !this.reorderUrl) return;
235
+ if (this.demo || !this.reorderUrl) return Promise.resolve(true);
220
236
  var payload = {};
221
237
  payload[this.reorderPayload] = ids;
222
238
  payload.zone = zone;
223
- this.request(this.reorderUrl, "POST", payload).catch(function () {
224
- // Order save failed — positions are stale but the board stays functional.
239
+ return this.request(this.reorderUrl, "POST", payload).then(function () {
240
+ return true;
241
+ }).catch(function (e) {
242
+ self.toast((e && e.message) || "Order save failed — reload and try again.", "error");
243
+ return false;
225
244
  });
226
245
  },
227
246
 
@@ -377,6 +396,22 @@
377
396
  },
378
397
 
379
398
  // --- helpers -------------------------------------------------------
399
+ // The board's ONE HTTP seam, and it REJECTS ON A SERVER REFUSAL. That is
400
+ // the semantics every caller in this factory already assumed by writing
401
+ // `.catch(…)`, and it is the semantics NEITHER underlying fetcher has:
402
+ //
403
+ // * `window.fetch` RESOLVES on 4xx/5xx. A 422 is an ordinary resolution,
404
+ // so a bare `.catch()` sees network-level failure and nothing else.
405
+ // That is how saveOrder discarded an operator-readable 422 in silence
406
+ // on all five boards that ride this factory.
407
+ // * `window.authedFetch` (the turf-monster fetcher this picks up when the
408
+ // host defines it) returns the response for anything that is not a 401
409
+ // — so a 422 resolves there too — and resolves to NULL on an expired
410
+ // session or a rate-limited tier, which `resp.ok` cannot be read from.
411
+ //
412
+ // Normalising both HERE is the point: a caller can no longer re-introduce
413
+ // the silence by forgetting a check. To ignore a refusal it must now say so
414
+ // out loud, in its own `.catch`.
380
415
  request: function (url, method, body) {
381
416
  var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
382
417
  var fetcher = window.authedFetch || window.fetch;
@@ -384,6 +419,18 @@
384
419
  method: method,
385
420
  headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
386
421
  body: JSON.stringify(body || {})
422
+ }).then(function (resp) {
423
+ // authedFetch's null — the session went away under the board. Name it,
424
+ // rather than throwing a TypeError off resp.ok on the next line.
425
+ if (!resp) throw new Error("Session expired — please sign in again.");
426
+ if (resp.ok) return resp;
427
+ // Prefer the SERVER'S OWN words. Every board endpoint this factory talks
428
+ // to renders `{ error: "…" }` beside its 4xx — Studio::Board::Reorderable
429
+ // here in the engine, and the hand-rolled siblings in the apps — and that
430
+ // text is written to be read by an operator.
431
+ return resp.json().catch(function () { return {}; }).then(function (err) {
432
+ throw new Error(err.error || ("Failed (" + resp.status + ")"));
433
+ });
387
434
  });
388
435
  },
389
436
 
@@ -59,7 +59,13 @@
59
59
  card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
60
60
  card_as: local the record binds to in card_partial. Default :card.
61
61
  card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
62
- reorder_url: POST endpoint the drag-reorder saves the new order to.
62
+ reorder_url: POST endpoint the drag-reorder saves the new order to. It is
63
+ expected to answer a refusal with a 4xx carrying
64
+ { error: "…" } (Studio::Board::Reorderable already does);
65
+ the factory TOASTS that sentence. A refused reorder is NOT
66
+ reverted — the whole column moved and the pre-drop order is
67
+ gone by then — so write that message as the instruction the
68
+ operator should follow, e.g. "… reload and try again".
63
69
  reorder_payload: :slugs | :ids — the key the ordered id list POSTs under.
64
70
  Default :slugs.
65
71
  move_url: PATCH template for a cross-column move, ":id" → the card's id
@@ -78,8 +84,10 @@
78
84
  empty_selector: the empty-state selector (revert anchor + count toggle).
79
85
  Default ".kanban-empty"; nil for a board with no empty state.
80
86
  live_channel: Turbo Stream channel for live updates (nil = static board).
81
- optimistic: revert + red-ring flash a failed move. Default true.
87
+ optimistic: revert + red-ring flash a failed MOVE. Default true. It does
88
+ not govern reorders, which never revert (see reorder_url).
82
89
  toasts: show the toast host (false = window.alert on error). Default true.
90
+ Either way a refused move or save reaches the operator.
83
91
  empty_label: empty-zone text. Default "Drop here".
84
92
  header_slot: raw HTML rendered above the columns (pass html_safe content).
85
93
  above_board_slot: raw HTML rendered between the header and the columns.
data/lib/studio/s3.rb CHANGED
@@ -15,17 +15,34 @@ module Studio
15
15
  opts[:content_type] = content_type if content_type
16
16
  opts[:cache_control] = cache_control if cache_control
17
17
  client.put_object(**opts)
18
- url(key: key)
18
+ public_url? ? url(key: key) : nil
19
19
  end
20
20
 
21
21
  def download(key:)
22
22
  client.get_object(bucket: bucket, key: full_key(key)).body.read
23
23
  end
24
24
 
25
+ # The PUBLIC URL of an object. On AWS (no endpoint) it is the bucket's
26
+ # virtual-hosted amazonaws.com URL, byte-identical to every version before
27
+ # endpoints existed. On an S3-compatible endpoint it is Studio.s3_public_url
28
+ # plus the key, and without that base it RAISES: R2's S3 endpoint answers no
29
+ # anonymous request, so any URL built from it would be a broken image in an
30
+ # inbox, not an error anyone sees. Private objects want signed_url.
25
31
  def url(key:)
32
+ base = Studio.s3_public_url.to_s
33
+ return "#{base.chomp("/")}/#{full_key(key)}" unless base.empty?
34
+ raise NotConfigured, "Studio.s3_public_url not set: #{endpoint} serves no public URL (use signed_url for private objects)" if endpoint
35
+
26
36
  "https://#{bucket}.s3.#{region}.amazonaws.com/#{full_key(key)}"
27
37
  end
28
38
 
39
+ # Whether url can answer. upload asks this so an app with only private
40
+ # objects (a data room on R2 with no public domain) can still write:
41
+ # upload returns nil there instead of raising AFTER the object landed.
42
+ def public_url?
43
+ endpoint.nil? || !Studio.s3_public_url.to_s.empty?
44
+ end
45
+
29
46
  def signed_url(key:, expires_in: 3600)
30
47
  require "aws-sdk-s3"
31
48
  Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: full_key(key), expires_in: expires_in)
@@ -93,11 +110,39 @@ module Studio
93
110
  Studio.s3_region
94
111
  end
95
112
 
113
+ # The S3-compatible endpoint (R2: https://<account>.r2.cloudflarestorage.com),
114
+ # or nil for AWS. Blank reads as unset, so an empty ENV var cannot point the
115
+ # client at "".
116
+ def endpoint
117
+ value = Studio.s3_endpoint.to_s
118
+ value.empty? ? nil : value
119
+ end
120
+
96
121
  def client
97
122
  @client ||= begin
98
123
  require "aws-sdk-s3"
99
- Aws::S3::Client.new(region: region)
124
+ Aws::S3::Client.new(**client_options)
125
+ end
126
+ end
127
+
128
+ # Only what is configured is passed, so an unconfigured app builds exactly
129
+ # the client it always did: region alone, the SDK's default credential chain.
130
+ # Keys are passed as a PAIR or not at all; half a pair is a configuration
131
+ # error, and falling back to the default chain would silently write with
132
+ # whatever AWS_* key the dyno happens to hold.
133
+ def client_options
134
+ opts = { region: region }
135
+ opts[:endpoint] = endpoint if endpoint
136
+ id = Studio.s3_access_key_id.to_s
137
+ secret = Studio.s3_secret_access_key.to_s
138
+ if id.empty? != secret.empty?
139
+ raise NotConfigured, "Studio.s3_access_key_id and s3_secret_access_key must be set together"
140
+ end
141
+ unless id.empty?
142
+ opts[:access_key_id] = id
143
+ opts[:secret_access_key] = secret
100
144
  end
145
+ opts
101
146
  end
102
147
 
103
148
  def reset!
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.76.2"
2
+ VERSION = "0.77.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -616,6 +616,30 @@ module Studio
616
616
  # A trailing slash is added if you leave it off.
617
617
  mattr_accessor :s3_key_prefix, default: nil
618
618
 
619
+ # S3-COMPATIBLE STORAGE (Cloudflare R2). All four default nil, and nil means
620
+ # AWS exactly as before: the SDK's own endpoint, its default credential chain
621
+ # (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), and the virtual-hosted
622
+ # amazonaws.com URL. An app moves to R2 by setting them, typically from ENV:
623
+ #
624
+ # config.s3_endpoint = ENV["R2_ENDPOINT"] # https://<account>.r2.cloudflarestorage.com
625
+ # config.s3_region = "auto" # R2's only region
626
+ # config.s3_access_key_id = ENV["R2_ACCESS_KEY_ID"]
627
+ # config.s3_secret_access_key = ENV["R2_SECRET_ACCESS_KEY"]
628
+ # config.s3_public_url = ENV["R2_PUBLIC_URL"] # https://assets.example.com
629
+ #
630
+ # Explicit keys exist so an app can hold its R2 pair BESIDE its AWS pair
631
+ # during a cutover (Active Storage may still mirror to S3) without the two
632
+ # fighting over the same AWS_* variables.
633
+ #
634
+ # s3_public_url is the base public objects are served from — a custom domain
635
+ # or r2.dev URL attached to THIS environment's bucket. R2's S3 endpoint
636
+ # serves nothing anonymously, so with an endpoint and no public base there is
637
+ # no public URL to give, and Studio::S3.url says so rather than inventing one.
638
+ mattr_accessor :s3_endpoint, default: nil
639
+ mattr_accessor :s3_access_key_id, default: nil
640
+ mattr_accessor :s3_secret_access_key, default: nil
641
+ mattr_accessor :s3_public_url, default: nil
642
+
619
643
  # Knowledge layer — the S3-backed document store + /admin/knowledge browser
620
644
  # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
621
645
  # knowledge_agents is the roster of agent slugs the intake UI offers
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.76.2
4
+ version: 0.77.0
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-09-22 00:00:00.000000000 Z
11
+ date: 2026-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails