studio-engine 0.33.0 → 0.36.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: 9ed77550c057e9ec4e232cc40ea934ef4753cc1d73d30e98b52c0a56f7ad8c8d
4
- data.tar.gz: 01b04c9da431ef4d43b75cf12533c3aece29c5557157af4c0f91eadf1fe9e2bb
3
+ metadata.gz: 8c692fc135af1ba83c08b27398c875bc547ae07bbcc492540ffd83250fccf680
4
+ data.tar.gz: 891c561fe2bef1386137af4fbbc22547f38613efe8bd373bf6ec31894086feef
5
5
  SHA512:
6
- metadata.gz: a1b26944f9f08b070f00aec356cc2435e3e35096d1353c970dd797b486293e50827fa6e6a11b3bc67ea306ef505f740114640595a4db10da4e9883a71561633b
7
- data.tar.gz: ce46eddd0627c5eae83daa9fc2eca518d74eae7500ce0e44a22faf016c0059d68b01f1d7b874e1f38d063918c460d36bf981d33b2a7f5dc3fe07ac6e0db85811
6
+ metadata.gz: 9cb8bb03d5adeec99336b5fab4d28b29c57be8686ca2afef58932ce866c321fe44f6ab0123609e6845151f938ab28790685bda6b9b21815ef3a3594d6c589921
7
+ data.tar.gz: 2c3be4aadf2a88d7838dab26b27c4a22604268138bba818f0b82890774f7bc5b81424d53a645d3fcc5ebf59a8f10197a9b37bd9cd44246c988d8dca3dfd2cd62
data/CHANGELOG.md CHANGED
@@ -2,6 +2,75 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.36.0 — 2026-08-10
6
+
7
+ **The local-review link now signs the reviewer in as someone who can SEE the
8
+ page.** `/_studio/local_review` — the local half of the task board's WAITING
9
+ APPROVAL button — provisions the account before it mints, at the new
10
+ `Studio.local_review_role` (default `"admin"`).
11
+
12
+ The board hands this endpoint the operator's **production** email address. A
13
+ fresh worktree database has never seen it, so consuming the link took
14
+ `Studio::LinkConsumption#sign_up_new` and created the account at the default
15
+ role, `viewer`. `require_admin` on the page under review then redirected it to
16
+ `/`. The sign-in **succeeded** every time, which is what kept this quiet: the
17
+ button worked, the token was valid, and the operator simply arrived on the home
18
+ page having never seen the thing he was asked to review. A seeded admin
19
+ (`alex@mcritchie.studio`) worked fine, so only his real address ever hit it.
20
+
21
+ The endpoint now find-or-creates the reviewer and ensures the role BEFORE
22
+ minting, so the consume takes `sign_in_existing` onto an account that already
23
+ has rights. An existing account is promoted, never duplicated; an already-correct
24
+ one is not rewritten.
25
+
26
+ New public config:
27
+
28
+ ```ruby
29
+ Studio.configure do |config|
30
+ config.local_review_role = "admin" # default; nil provisions without a role
31
+ end
32
+ ```
33
+
34
+ Set it to `nil` for an app whose review pages are not admin-gated — the account
35
+ is still provisioned (that is what avoids `sign_up_new`), but its role is left
36
+ to the host's own `configure_new_user`.
37
+
38
+ **The endpoint also answers "who is sitting at this desk?" when the caller names
39
+ nobody.** `?email=` is now optional, and an explicit one still wins. With none,
40
+ the reviewer resolves to `Studio.local_review_email`, and failing that to the
41
+ **first user already holding `Studio.local_review_role`, by id** (that query
42
+ falls back to `"admin"` when the setting is `nil`):
43
+
44
+ ```ruby
45
+ config.local_review_email = "someone@example.com" # nil (default) = derive
46
+ ```
47
+
48
+ This exists so the board's WAITING APPROVAL CTA can be a **public, sign-in-free
49
+ redirect**. Requiring a board session to click it is what broke one-click review
50
+ in the first place; sending an email in that public URL would publish the
51
+ operator's address. The local stack is the machine the reviewer is sitting at,
52
+ so it is the right place to decide. A desk with nobody at that role mints
53
+ nothing and says so, rather than guessing: the derive only ever picks someone
54
+ who ALREADY holds the role, so it never promotes a stranger into it. (The
55
+ `?email=` path does promote the account it is handed — that is the point of it.)
56
+
57
+ **The floor is unchanged, and now asserted rather than assumed.** The endpoint
58
+ grants a role, so both gates in front of it are tested directly: the
59
+ developer-desk routes are drawn only outside production (proved by drawing
60
+ `Studio.routes` into a throwaway route set under a production env), and a
61
+ non-loopback request 404s **before** provisioning or minting anything.
62
+ Provisioning is best-effort: a host whose `User` rejects the write gets a logged
63
+ warning and a working link, never a 500.
64
+
65
+ ### Added
66
+
67
+ - **`Studio.local_review_role`** — the role `/_studio/local_review` stamps on the
68
+ account it provisions before minting. Defaults to `"admin"`; `nil` provisions
69
+ the account without touching its role.
70
+ - **`Studio.local_review_email`** — who the local-review mint signs in when the
71
+ caller sends no `?email=`. `nil` (the default) derives the first admin in this
72
+ database, by id.
73
+
5
74
  ## 0.33.0 — 2026-08-10
6
75
 
7
76
  Two changes ship together in this release: local log files are now capped, and
@@ -19,6 +19,15 @@ module Studio
19
19
  # exposure the inbox already carries, which hands every captured sign-in link
20
20
  # to any local reader. It is a development desk convenience; it is not a
21
21
  # sign-in path.
22
+ #
23
+ # It also PROVISIONS the account before minting, at Studio.local_review_role
24
+ # (default "admin"). The email the board hands over is the operator's
25
+ # PRODUCTION address, and a fresh worktree database has never seen it — so
26
+ # without this the consume takes Studio::LinkConsumption#sign_up_new, creates
27
+ # him at the default role ("viewer"), and `require_admin` on the page under
28
+ # review redirects him to "/". The sign-in SUCCEEDS and he never sees the
29
+ # page, which is what made the failure so quiet: a seeded admin works, so only
30
+ # the operator's real address ever hit it.
22
31
  class LocalReviewsController < ApplicationController
23
32
  include Studio::MagicLinkIssuing
24
33
 
@@ -27,9 +36,13 @@ module Studio
27
36
  before_action :require_local_development!
28
37
 
29
38
  def show
30
- email = Studio::LinkToken.normalize_email(params[:email])
39
+ email = reviewer_email
31
40
  return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
32
41
 
42
+ # Provision BEFORE minting, so the consume finds an existing account and
43
+ # takes sign_in_existing rather than sign_up_new-at-the-default-role.
44
+ provision_reviewer(email)
45
+
33
46
  # return_to is passed through raw: the store sanitizes it to a same-origin
34
47
  # path on the way in (Studio::Link.create_magic_link calls
35
48
  # Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
@@ -43,6 +56,77 @@ module Studio
43
56
 
44
57
  MISSING_EMAIL = "Add ?email=<your address> to mint a local review link."
45
58
 
59
+ # WHO to sign in, in priority order:
60
+ #
61
+ # 1. `?email=` — an explicit caller still wins.
62
+ # 2. `Studio.local_review_email` — the app's declared desk operator.
63
+ # 3. the first admin in this database — the seeded operator, by id.
64
+ #
65
+ # The board deliberately stops at (1) being ABSENT. Its WAITING APPROVAL CTA
66
+ # is a public, sign-in-free redirect, so an email in that URL would be an
67
+ # address published on a public page for anyone to read. The local stack is
68
+ # the right place to answer "who is at this desk": it is the machine the
69
+ # reviewer is sitting at, and it already knows its own operator.
70
+ def reviewer_email
71
+ Studio::LinkToken.normalize_email(
72
+ params[:email].presence || Studio.local_review_email.presence || seeded_admin_email
73
+ )
74
+ end
75
+
76
+ # The database's own first admin. Ordered by id so a re-seeded desk resolves
77
+ # to the same person every time rather than to whoever was touched last.
78
+ # Rescued because this runs before the mint on every click: a host with no
79
+ # role column must fall through to MISSING_EMAIL, not a 500.
80
+ def seeded_admin_email
81
+ return nil unless User.respond_to?(:column_names) && User.column_names.include?("role")
82
+
83
+ User.where(role: Studio.local_review_role.presence || "admin").order(:id).first&.email
84
+ rescue StandardError => e
85
+ Rails.logger.warn("[Studio::LocalReviewsController] no default reviewer: #{e.class}: #{e.message}")
86
+ nil
87
+ end
88
+
89
+ # Find-or-create the reviewer and ensure Studio.local_review_role, so the
90
+ # page under review actually RENDERS for whoever follows the link.
91
+ #
92
+ # Best-effort by design. Provisioning is an upgrade to the mint, not a
93
+ # precondition of it: a host with an extra User validation must not turn the
94
+ # review button into a 500 — it should still hand back a working sign-in
95
+ # link, exactly as it did before this method existed. So a failure is logged
96
+ # loudly and the mint proceeds. The end-to-end check that the operator
97
+ # actually lands ON the page is the real gate; this is the thing that makes
98
+ # it pass, not the thing that proves it.
99
+ # `User` is referenced bare, with no defined?/const_defined? guard: those do
100
+ # not agree with each other about a Zeitwerk autoload that has not fired
101
+ # yet, and a guard that reads "absent" on a cold boot would skip
102
+ # provisioning silently — the failure mode being fixed. Studio::LinkConsumption
103
+ # already requires every consuming app to have User, so a genuinely missing
104
+ # one is a NameError the rescue below logs like any other host mismatch.
105
+ def provision_reviewer(email)
106
+ user = User.find_by(email: email) || build_reviewer(email)
107
+ role = Studio.local_review_role.presence
108
+ user.role = role if role && user.respond_to?(:role=) && user.role != role
109
+ user.save! if user.new_record? || user.changed?
110
+ user
111
+ rescue StandardError => e
112
+ # No re-raise: see "best-effort" above. Logged at warn because a silent
113
+ # miss here reappears as the exact symptom this endpoint exists to remove
114
+ # — a successful sign-in that lands on the wrong page.
115
+ Rails.logger.warn(
116
+ "[Studio::LocalReviewsController] could not provision #{email}: #{e.class}: #{e.message}"
117
+ )
118
+ nil
119
+ end
120
+
121
+ # A brand-new reviewer goes through the host's own new-user hook first, so
122
+ # the account this endpoint creates is shaped like every other account in
123
+ # the app (usernames, defaults, associations) — then the role is stamped on
124
+ # top. Mirrors Studio::LinkConsumption#sign_up_new, which is the path this
125
+ # provisioning is standing in for.
126
+ def build_reviewer(email)
127
+ User.new(email: email).tap { |user| Studio.configure_new_user.call(user) }
128
+ end
129
+
46
130
  def require_local_development!
47
131
  head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
48
132
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.33.0"
2
+ VERSION = "0.36.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -158,6 +158,36 @@ module Studio
158
158
  # is truthy, otherwise disabled. Production always disables capture.
159
159
  mattr_accessor :local_email_capture, default: nil
160
160
 
161
+ # The role Studio::LocalReviewsController stamps on the account it provisions
162
+ # for a local review (the board's WAITING APPROVAL button).
163
+ #
164
+ # It defaults to "admin" because the pages sent for review are overwhelmingly
165
+ # admin-gated, and the operator's address is his PRODUCTION one — an address a
166
+ # fresh worktree database has never seen, so the sign-in would otherwise CREATE
167
+ # him at the default role and `require_admin` would bounce him to "/". The
168
+ # sign-in succeeds and he never sees the page: the bug this knob exists to end.
169
+ #
170
+ # Set it to nil (or "") to provision the account WITHOUT touching its role —
171
+ # for an app whose review pages are not admin-gated, or whose role column
172
+ # means something else. Only ever reached behind local_tool_enabled?
173
+ # (non-production AND loopback), so it grants nothing a local reader could not
174
+ # already take from the local email inbox beside it.
175
+ mattr_accessor :local_review_role, default: "admin"
176
+
177
+ # WHO the local-review mint signs in when the caller names no `?email=`.
178
+ #
179
+ # The board's WAITING APPROVAL CTA is a public, sign-in-free redirect, so it
180
+ # sends no email — putting one in that URL would publish the operator's
181
+ # address on a public page. The local stack answers the question instead: it
182
+ # is the machine the reviewer is sitting at.
183
+ #
184
+ # nil (the default) means "derive": the first user in this database already
185
+ # holding local_review_role, by id — falling back to "admin" when that setting
186
+ # is itself nil. So a desk that switches local_review_role OFF has no role to
187
+ # derive FROM and should name its operator here explicitly, as should a desk
188
+ # whose operator is not the first seeded account at that role.
189
+ mattr_accessor :local_review_email, default: nil
190
+
161
191
  # Theme role colors (7 roles)
162
192
  mattr_accessor :theme_primary, default: "#8E82FE"
163
193
  mattr_accessor :theme_dark, default: "#1A1535"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie