studio-engine 0.102.0.rc1 → 0.103.0.rc1

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: 7eb8bd189cd5861c7aad2afb33f1d00ebe6bfa103e9e106dade9363d66cb91a4
4
- data.tar.gz: 2381d4002f2c80cde9bb8a59243cc4836c99ce3abd90d8f97dfff50f7d89dad7
3
+ metadata.gz: 773214119ffedd307e2f80ba808f5413cc69a2e9cb875c2997d7f7be0f120ce8
4
+ data.tar.gz: 9b6c272fd88d22717b6e69020146c936d8db1d04b88f5a9b3effe5764d64ec38
5
5
  SHA512:
6
- metadata.gz: 87fb049ff0f6cb9041b0c070f9cde7f6c975e9eb04b275aa1cad4f3336ca89552d7ac3962a60e4ad9d12e0e2d970f9c6103403697e7d7d12762bec1c813295e4
7
- data.tar.gz: 4b96de3893bf63d4d1ce75971078972e83d1be52189e5f58ffd74e05ebe4a86888168d563b5a1966a9db90934f3fe1c74ba4e684dbdb31879062fdd3fe9d75c6
6
+ metadata.gz: fd521d84d03c3c7a45e0fa1113d065db78a67ef5de36a8ac66b237b28b15a9cd562f8e703436b75f6419e927243d13ca99877f9d3493a59a6bf276b3b07cb269
7
+ data.tar.gz: '0508739a475558e609a1efc465d75f2f37aa71fcf9c3b7477a759d3a900b4d1353628a150d8304f7a7b6bb568958cc97e1c8943382e57733a6466c8b803157f2'
data/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.103.0 — 2026-10-10
8
+
9
+ ### Added
10
+
11
+ - **Seed assets reference production.** A non-production process (laptop,
12
+ desk, QA app) can record an `ImageCache` row that REFERENCES an object
13
+ already in the production bucket instead of uploading a copy into the dev
14
+ bucket. New:
15
+ - `Studio.s3_production_public_url`: production's public host, set in
16
+ every environment (e.g. `https://assets.turfmonster.media`). Default nil
17
+ turns references off.
18
+ - `image_caches.stored_in` (migration `AddStoredInToImageCaches`):
19
+ nullable string. nil or `"own"` is the own bucket, `"production"` a
20
+ reference. `ImageCache#url` resolves a reference on the production host
21
+ off production. In production both kinds resolve on the own host, which
22
+ is production's.
23
+ - `Studio::ImageCache.reference_or_cache!`: `cache!`'s arguments and
24
+ answer. Off production, with the setting, it HEADs each missing variant
25
+ in `<prefix>-production` (read-only) and records a reference when found,
26
+ else uploads to the own bucket as `cache!` does. A HEAD failing for any
27
+ reason but not-found raises. In production, or without the setting, it
28
+ is `cache!`.
29
+ - `Studio::S3.production_bucket`, `production_references?`,
30
+ `production_url`, `production_head`, `production_exists?` and
31
+ `production_download`: reads only. `ImageCache#download` and
32
+ `#delete_object!` act on the bucket that holds the row's object;
33
+ off production `delete_object!` deletes nothing for a reference.
34
+
35
+ **Design.** A per-row marker plus one configured host, because a row must
36
+ say whose object it is: 11 of 12 sampled Turf headshot keys were on
37
+ production and one (`kenneth-walker-iii`) was only on dev, so no app-wide
38
+ switch is right for every row. Rejected: storing absolute URLs in `s3_key`
39
+ (breaks every reader that treats it as a key, the uniqueness index and the
40
+ key namespace); an AR enum (raises at load on a host that has not migrated);
41
+ an HTTP HEAD on the public host (a CDN answer is not the bucket's, and the
42
+ dev token's Item Read on production makes the S3 HEAD available and
43
+ stubbable); requiring the column in `Studio::HostSchema` (would fail boot on
44
+ every consumer before it installs the migration). The existing guard
45
+ (`guard_production_bucket!`) still refuses any delete from a `*-production`
46
+ bucket off production, and the dev R2 tokens cannot write production.
47
+
48
+ **Consumer adoption.** Nothing changes until an app opts in, and production
49
+ behaves as before. To adopt: (1) `bin/rails studio_engine:install:migrations
50
+ db:migrate` and commit the copy and `db/schema.rb`; (2) set
51
+ `config.s3_production_public_url` to the production asset host in
52
+ `config/initializers/studio.rb`; (3) switch seeders from
53
+ `Studio::ImageCache.cache!` to `reference_or_cache!`; (4) replace any
54
+ `Studio::S3.download(key: row.s3_key)` or `Studio::S3.delete(key:
55
+ row.s3_key)` on an `ImageCache` row with `row.download` /
56
+ `row.delete_object!`.
57
+
7
58
  ## 0.102.0 — 2026-10-10
8
59
 
9
60
  ### Removed
data/README.md CHANGED
@@ -979,6 +979,33 @@ be a positive Integer.
979
979
  `Studio::S3.delete` moves the object to `trash/` for three days rather than
980
980
  deleting it; see [Trash and restore](#trash-and-restore).
981
981
 
982
+ ### Seed assets reference production
983
+
984
+ Seed assets (NFL headshots, Pokémon sprites) live in the **production** bucket.
985
+ A laptop, desk or QA app points at production's copy instead of uploading a
986
+ duplicate into the dev bucket, and uploads only what production lacks. Set
987
+ production's public host in every environment (it is not a secret):
988
+
989
+ ```ruby
990
+ config.s3_production_public_url = "https://assets.turfmonster.media"
991
+ ```
992
+
993
+ and have seeders call `Studio::ImageCache.reference_or_cache!` (same arguments
994
+ and answer as `cache!`). Off production it HEADs each missing variant in
995
+ `<prefix>-production` (read-only; the dev R2 token holds Item Read there).
996
+ Found: an `ImageCache` row with `stored_in = "production"`, whose `#url`
997
+ resolves on `s3_production_public_url`. Absent: an ordinary upload to the own
998
+ bucket. A HEAD failing for any reason but not-found raises. In production, or
999
+ with the setting unset, it is exactly `cache!`.
1000
+
1001
+ `stored_in` arrives by the engine migration `AddStoredInToImageCaches`
1002
+ (nullable; nil and `"own"` mean the own bucket, so existing rows keep their
1003
+ URLs). Read an object's bytes with `ImageCache#download` and remove it with
1004
+ `ImageCache#delete_object!`, never `Studio::S3.download/delete(key: row.s3_key)`:
1005
+ those read and delete the OWN bucket. Off production, `delete_object!` on a
1006
+ production reference deletes nothing, and `Studio::S3` has no write, copy or
1007
+ delete that targets the production bucket.
1008
+
982
1009
  An app with **no** bucket configured does not error — `/admin/emails` renders
983
1010
  read-only, showing the inherited defaults it is genuinely sending and naming the
984
1011
  one setting that turns uploads on.
@@ -1,4 +1,20 @@
1
1
  class ImageCache < ApplicationRecord
2
+ # Where a row's object lives (the `stored_in` column):
3
+ #
4
+ # nil / "own" this process's own bucket, at Studio.s3_public_url. Every row
5
+ # written before the column existed, and every upload.
6
+ # "production" a REFERENCE to production's copy, recorded by a
7
+ # non-production seeder (Studio::ImageCache.reference_or_cache!)
8
+ # instead of uploading a duplicate. It resolves on
9
+ # Studio.s3_production_public_url.
10
+ #
11
+ # A plain string, not an AR enum: the column arrives by an engine migration a
12
+ # host installs on its own schedule, and an enum declared on an absent column
13
+ # raises at load. Without the column every row reads as "own", as before.
14
+ STORED_IN = %w[own production].freeze
15
+ OWN = "own"
16
+ PRODUCTION = "production"
17
+
2
18
  # Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
3
19
  # Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
4
20
  # record images (athlete/coach headshots) still set an owner; the
@@ -8,8 +24,41 @@ class ImageCache < ApplicationRecord
8
24
  validates :purpose, :variant, :s3_key, presence: true
9
25
  validates :s3_key, uniqueness: true
10
26
  validates :variant, uniqueness: { scope: [:owner_type, :owner_id, :purpose] }
27
+ validates :stored_in, inclusion: { in: STORED_IN }, allow_nil: true, if: -> { has_attribute?(:stored_in) }
28
+
29
+ def self.stored_in_column?
30
+ table_exists? && column_names.include?("stored_in")
31
+ end
32
+
33
+ def production_reference?
34
+ has_attribute?(:stored_in) && stored_in == PRODUCTION
35
+ end
11
36
 
37
+ # In production both kinds resolve on the app's own host, which IS
38
+ # production's, so a production app's URLs are what they always were.
12
39
  def url
13
- Studio::S3.url(key: s3_key)
40
+ production_reference? ? Studio::S3.production_url(key: s3_key) : Studio::S3.url(key: s3_key)
41
+ end
42
+
43
+ # The object's bytes, read from the bucket that holds them (read-only for a
44
+ # production reference). Use this, not Studio::S3.download(key: s3_key),
45
+ # which reads this process's own bucket.
46
+ def download(max_bytes: nil)
47
+ if production_reference?
48
+ Studio::S3.production_download(key: s3_key, max_bytes: max_bytes)
49
+ else
50
+ Studio::S3.download(key: s3_key, max_bytes: max_bytes)
51
+ end
52
+ end
53
+
54
+ # Removes the row's OBJECT (to trash, via Studio::S3.delete). A production
55
+ # reference outside production is not this process's object: nothing is
56
+ # deleted from either bucket (the own bucket may hold an unrelated object
57
+ # under the same key) and nil is returned. Destroying the row itself is
58
+ # always fine. In production a reference is an ordinary own object.
59
+ def delete_object!
60
+ return nil if production_reference? && !Studio::S3.production_environment?
61
+
62
+ Studio::S3.delete(key: s3_key)
14
63
  end
15
64
  end
@@ -0,0 +1,25 @@
1
+ # Adds image_caches.stored_in: where a row's object lives. nil (every existing
2
+ # row) and "own" mean this app's own bucket; "production" marks a non-production
3
+ # row that REFERENCES production's copy of a seed asset instead of a duplicate
4
+ # in the dev bucket (Studio::ImageCache.reference_or_cache!, ImageCache#url).
5
+ #
6
+ # Nullable with no default, so the add is a catalog-only change on any table
7
+ # size and no existing row is rewritten or changes its URL.
8
+ #
9
+ # image_caches is host-owned; on a host without the table this is a no-op, like
10
+ # AllowNullImageCacheOwner. Studio::HostSchema does not require the column: a
11
+ # host that has not installed this migration keeps working, and every row reads
12
+ # as "own".
13
+ class AddStoredInToImageCaches < ActiveRecord::Migration[7.2]
14
+ def up
15
+ return unless table_exists?(:image_caches)
16
+
17
+ add_column :image_caches, :stored_in, :string, if_not_exists: true
18
+ end
19
+
20
+ def down
21
+ return unless table_exists?(:image_caches)
22
+
23
+ remove_column :image_caches, :stored_in, if_exists: true
24
+ end
25
+ end
@@ -46,21 +46,107 @@ module Studio
46
46
  # Idempotent: variants already present in ImageCache are skipped. If
47
47
  # nothing is missing, the source is never read.
48
48
  def self.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png")
49
- raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
49
+ ext, requested = prepare!(source_url: source_url, source_path: source_path, content_type: content_type, widths: widths)
50
50
 
51
- unless ALLOWED_CONTENT_TYPES.include?(content_type)
52
- raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
51
+ existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
52
+ missing = requested - existing.keys
53
+ return existing if missing.empty?
54
+
55
+ upload_variants!(existing, missing, owner: owner, purpose: purpose, key_prefix: key_prefix, ext: ext,
56
+ source_url: source_url, source_path: source_path, content_type: content_type)
57
+ end
58
+
59
+ # ── SEED ASSETS: REFERENCE PRODUCTION, UPLOAD ONLY WHAT IS NEW ────────────
60
+ #
61
+ # cache!'s signature and answer, for SEEDERS. Seed assets live in the
62
+ # production bucket; a laptop, desk or QA app should point at production's
63
+ # copy rather than upload a duplicate into the dev bucket. So, outside
64
+ # production, with Studio.s3_production_public_url set, each missing variant
65
+ # is first looked for in production by a read-only HEAD
66
+ # (Studio::S3.production_head) under the key cache! would write:
67
+ #
68
+ # found an ImageCache row with stored_in "production" is recorded. Its
69
+ # #url resolves on the production host. Nothing is uploaded.
70
+ # absent the variant is uploaded to this process's OWN bucket exactly as
71
+ # cache! does (stored_in nil = own). A key production lacks
72
+ # (kenneth-walker-iii, 2026-10-10) still gets an image.
73
+ #
74
+ # The source is fetched only when at least one variant has to be uploaded.
75
+ # A HEAD that fails for any reason but "not found" raises: a token without
76
+ # read on production must not quietly turn into a dev-bucket copy of
77
+ # everything.
78
+ #
79
+ # In production, or with s3_production_public_url unset, this IS cache!.
80
+ # Production never reads the knob, so production apps behave as before.
81
+ #
82
+ # Nothing here writes, copies or deletes in the production bucket.
83
+ def self.reference_or_cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil,
84
+ content_type: "image/png")
85
+ unless Studio::S3.production_references?
86
+ return cache!(owner: owner, purpose: purpose, key_prefix: key_prefix, widths: widths,
87
+ source_url: source_url, source_path: source_path, content_type: content_type)
53
88
  end
54
89
 
55
- validate_source_url!(source_url) if source_url
90
+ unless ::ImageCache.stored_in_column?
91
+ raise MissingStoredInColumn,
92
+ "image_caches.stored_in is missing: run `bin/rails studio_engine:install:migrations db:migrate` " \
93
+ "before referencing production seed assets"
94
+ end
56
95
 
57
- ext = EXT_BY_TYPE[content_type]
58
- requested = ["original", *widths.map(&:to_s)]
96
+ ext, requested = prepare!(source_url: source_url, source_path: source_path, content_type: content_type, widths: widths)
59
97
 
60
98
  existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
61
99
  missing = requested - existing.keys
62
100
  return existing if missing.empty?
63
101
 
102
+ to_upload = missing.reject do |variant|
103
+ s3_key = variant_key(key_prefix, variant, ext)
104
+ head = Studio::S3.production_head(key: s3_key)
105
+ next false if head.nil?
106
+
107
+ existing[variant] = ::ImageCache.create!(
108
+ owner: owner,
109
+ purpose: purpose,
110
+ variant: variant,
111
+ s3_key: s3_key,
112
+ stored_in: ::ImageCache::PRODUCTION,
113
+ source_url: source_url,
114
+ bytes: head.content_length,
115
+ content_type: head.content_type.to_s.empty? ? content_type : head.content_type
116
+ )
117
+ true
118
+ end
119
+ return existing if to_upload.empty?
120
+
121
+ upload_variants!(existing, to_upload, owner: owner, purpose: purpose, key_prefix: key_prefix, ext: ext,
122
+ source_url: source_url, source_path: source_path, content_type: content_type)
123
+ end
124
+
125
+ class MissingStoredInColumn < StandardError; end
126
+
127
+ # The key every variant is written under, by cache! and looked up under by
128
+ # reference_or_cache!. One definition, so the two can never disagree.
129
+ def self.variant_key(key_prefix, variant, ext)
130
+ "#{key_prefix}/#{variant}.#{ext}"
131
+ end
132
+
133
+ def self.prepare!(source_url:, source_path:, content_type:, widths:)
134
+ raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
135
+
136
+ unless ALLOWED_CONTENT_TYPES.include?(content_type)
137
+ raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
138
+ end
139
+
140
+ validate_source_url!(source_url) if source_url
141
+
142
+ [EXT_BY_TYPE[content_type], ["original", *widths.map(&:to_s)]]
143
+ end
144
+ private_class_method :prepare!
145
+
146
+ # Reads the source once and uploads each of `missing` to this process's own
147
+ # bucket, recording a row per variant into `existing`, which it returns.
148
+ def self.upload_variants!(existing, missing, owner:, purpose:, key_prefix:, ext:, source_url:, source_path:,
149
+ content_type:)
64
150
  require "mini_magick"
65
151
 
66
152
  body = if source_path
@@ -72,7 +158,6 @@ module Studio
72
158
  missing.each do |variant|
73
159
  if variant == "original"
74
160
  payload = body
75
- s3_key = "#{key_prefix}/original.#{ext}"
76
161
  else
77
162
  img = MiniMagick::Image.read(body)
78
163
  # Cap ImageMagick resources per-invocation to prevent decompression-bomb DoS.
@@ -84,8 +169,8 @@ module Studio
84
169
  c.resize "#{variant}x"
85
170
  end
86
171
  payload = img.to_blob
87
- s3_key = "#{key_prefix}/#{variant}.#{ext}"
88
172
  end
173
+ s3_key = variant_key(key_prefix, variant, ext)
89
174
 
90
175
  Studio::S3.upload(
91
176
  key: s3_key,
@@ -107,6 +192,7 @@ module Studio
107
192
 
108
193
  existing
109
194
  end
195
+ private_class_method :upload_variants!
110
196
 
111
197
  # ── THE SSRF GUARD ────────────────────────────────────────────────────────
112
198
  #
data/lib/studio/s3.rb CHANGED
@@ -25,13 +25,18 @@ module Studio
25
25
  # is answered as the empty string it means. Tested against a stubbed
26
26
  # client only: no live store, R2 included, has been asked.
27
27
  def download(key:, max_bytes: nil)
28
+ read_object(bucket, key, max_bytes)
29
+ end
30
+
31
+ # The GET behind download and production_download.
32
+ def read_object(target, key, max_bytes)
28
33
  # 0 or a negative would build "bytes=0--1", which a store may read as
29
34
  # no range at all and answer with the whole object.
30
35
  unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes.positive?)
31
36
  raise ArgumentError, "max_bytes must be a positive Integer, got #{max_bytes.inspect}"
32
37
  end
33
38
 
34
- opts = { bucket: bucket, key: full_key(key) }
39
+ opts = { bucket: target, key: full_key(key) }
35
40
  opts[:range] = "bytes=0-#{max_bytes - 1}" if max_bytes
36
41
  s3 = client
37
42
  # The rescue names an SDK constant, so it wraps only what runs after
@@ -84,10 +89,70 @@ module Studio
84
89
  end
85
90
 
86
91
  def exists?(key:)
87
- client.head_object(bucket: bucket, key: full_key(key))
88
- true
89
- rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
90
- false
92
+ !head(bucket, key).nil?
93
+ end
94
+
95
+ # ── PRODUCTION REFERENCES (read-only) ─────────────────────────────────
96
+ #
97
+ # Seed assets live in the PRODUCTION bucket, and a non-production process
98
+ # references them there instead of uploading its own copy (ImageCache's
99
+ # stored_in = "production"). Everything below READS production and
100
+ # nothing writes it: there is no upload, copy or delete that takes the
101
+ # production bucket, and delete/purge! still refuse a "*-production"
102
+ # bucket off production (guard_production_bucket!). The dev R2 tokens
103
+ # hold Item Read on production and Item Write on dev only, so the store
104
+ # refuses a write even if code here ever tried.
105
+
106
+ # The bucket production writes: "#{s3_bucket_prefix}-production" in every
107
+ # environment. In real production it is `bucket`.
108
+ def production_bucket
109
+ prefix = Studio.s3_bucket_prefix
110
+ raise NotConfigured, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if prefix.nil? || prefix.empty?
111
+
112
+ "#{prefix}-production"
113
+ end
114
+
115
+ # Whether this process may record a production reference: it is not
116
+ # production itself (production's own uploads already ARE production's)
117
+ # and it knows production's public host (Studio.s3_production_public_url).
118
+ def production_references?
119
+ !production_environment? && !Studio.s3_production_public_url.to_s.empty?
120
+ end
121
+
122
+ # The public URL of production's copy of `key`. In production it is
123
+ # exactly url(key:). Elsewhere it is s3_production_public_url plus the
124
+ # key, under the same s3_key_prefix (the namespace is per app, not per
125
+ # environment); without that base it RAISES rather than answer with this
126
+ # environment's host, where the object does not exist.
127
+ def production_url(key:)
128
+ return url(key: key) if production_environment?
129
+
130
+ base = Studio.s3_production_public_url.to_s
131
+ if base.empty?
132
+ raise NotConfigured,
133
+ "Studio.s3_production_public_url not set: cannot resolve production's copy of #{key.inspect} " \
134
+ "from a #{environment} process"
135
+ end
136
+
137
+ "#{base.chomp("/")}/#{full_key(key)}"
138
+ end
139
+
140
+ # A read-only HEAD against the production bucket: the object's metadata
141
+ # (content_length, content_type) when production holds `key`, nil when it
142
+ # does not. Any other answer (403 from a token without Item Read on
143
+ # production, a network error) RAISES: a seeder that cannot ask must not
144
+ # read the silence as "absent" and quietly upload a copy.
145
+ def production_head(key:)
146
+ head(production_bucket, key)
147
+ end
148
+
149
+ def production_exists?(key:)
150
+ !production_head(key: key).nil?
151
+ end
152
+
153
+ # A read-only GET of production's copy, with download's max_bytes rules.
154
+ def production_download(key:, max_bytes: nil)
155
+ read_object(production_bucket, key, max_bytes)
91
156
  end
92
157
 
93
158
  # A RECOVERABLE delete: the object moves under trash/ in the same bucket
@@ -235,8 +300,22 @@ module Studio
235
300
  @client = nil
236
301
  end
237
302
 
303
+ private :read_object
304
+
238
305
  private
239
306
 
307
+ # HEAD of `key` in `target`: the response, or nil when the object is
308
+ # absent. `target` is resolved by the caller, so an unconfigured app
309
+ # raises NotConfigured before the rescue below names an SDK constant.
310
+ def head(target, key)
311
+ s3 = client
312
+ begin
313
+ s3.head_object(bucket: target, key: full_key(key))
314
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
315
+ nil
316
+ end
317
+ end
318
+
240
319
  # WHICH BUCKET HALF THIS APP OWNS. Rails.env alone cannot answer it: no QA app
241
320
  # sets RAILS_ENV, so every QA app boots as `production` exactly like real
242
321
  # production. QA_ENV is the signal that separates them, and it is the one
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.102.0.rc1"
2
+ VERSION = "0.103.0.rc1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1079,6 +1079,19 @@ module Studio
1079
1079
  mattr_accessor :s3_secret_access_key, default: nil
1080
1080
  mattr_accessor :s3_public_url, default: nil
1081
1081
 
1082
+ # SEED ASSETS LIVE IN PRODUCTION. The public base of THIS app's PRODUCTION
1083
+ # bucket (https://assets.turfmonster.media), set in every environment. A
1084
+ # non-production process (a laptop, a desk, a QA app) whose seeders call
1085
+ # Studio::ImageCache.reference_or_cache! records an ImageCache row that
1086
+ # REFERENCES production's copy of an object instead of uploading its own, and
1087
+ # that row's #url resolves on this host. Not a secret, so a literal in
1088
+ # config/initializers/studio.rb is the expected form.
1089
+ #
1090
+ # nil (the default) turns references off: reference_or_cache! is then exactly
1091
+ # cache!, and every row resolves on s3_public_url as it always has. A real
1092
+ # production process ignores it, because its own bucket IS production.
1093
+ mattr_accessor :s3_production_public_url, default: nil
1094
+
1082
1095
  # Knowledge layer — the S3-backed document store + /admin/knowledge browser
1083
1096
  # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
1084
1097
  # 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.102.0.rc1
4
+ version: 0.103.0.rc1
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-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -713,6 +713,7 @@ files:
713
713
  - db/migrate/20261006120002_ensure_theme_settings_table.rb
714
714
  - db/migrate/20261006120003_ensure_image_caches_table.rb
715
715
  - db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
716
+ - db/migrate/20261010120000_add_stored_in_to_image_caches.rb
716
717
  - docs/BOOKING.md
717
718
  - docs/SITE_FOOTER.md
718
719
  - lib/active_storage/service/studio_trash_s3_service.rb