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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +18 -0
- data/app/views/studio/_board_assets.html.erb +56 -9
- data/app/views/studio/board/_board.html.erb +10 -2
- data/lib/studio/s3.rb +47 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +24 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c579a6d057a79eb540fee2347d6000c6c5af80273e61efdbd2e1d8eda173214
|
|
4
|
+
data.tar.gz: 726c8217e6f23e277af7f3fcc215885d7bfed504ee2a215b574aa5775986ffad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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).
|
|
224
|
-
|
|
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
|
|
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(
|
|
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!
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|