studio-engine 0.69.4 → 0.69.5

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: 12d0b8a6c255a4f9e18d6a3a588abefdd7dd5d54a3801deb9b52eb99da2d78fe
4
- data.tar.gz: 3467bf1af09d79805053d721c1d0d6028fd0c5b0f806c94d6a2cdc7864f048f1
3
+ metadata.gz: 44ac04785253cb277ef70cd1d6398b81718e223adc7e300c1a19329183a88be5
4
+ data.tar.gz: 01c13c3974eb5da7887fc82e77f3526eb50af53a3e590b993a7602914e8daa81
5
5
  SHA512:
6
- metadata.gz: 3fd8ea14026738113f2348ea267b2cde306afa6a37d1b37dbf23e426013cb632be62164413e64516893f913e9f8a1618559ef326187e328001d6effceffe4187
7
- data.tar.gz: 1498813d632e019fc574a6467e87fa73965f0e018001c845cac52a22d21443cfad8426e50a94ec9d37e874a6f1d059770dd97696665141853fb2a9c458b97242
6
+ metadata.gz: db96cbe447a67732cc7d2babc54f605071ccd9e3d77d20da4fb7486c5e89f5354e21dc34066757ec7bde7f66fb7fb08e257a15c364753d3a98f34ea8065df25b
7
+ data.tar.gz: f8a47251bf78b0c3bab42dfc743565c2dba66502f6f204516718144cd9506a5206bf6342b7ce496fa12f6f67a34727dc8fc7cbc5e03529ce5d3ab1e3727b4761
data/CHANGELOG.md CHANGED
@@ -55,6 +55,57 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
55
55
 
56
56
  ### Fixed
57
57
 
58
+ - **Onboarding no longer truncates a surname to fit a first name's cap.**
59
+ `Studio::OnboardingController#first_name` measured the WHOLE typed answer with
60
+ `Studio::FIRST_NAME_MAX_LENGTH` — the PER-FIELD cap, 40. The field asks for a
61
+ name and people type a full one, so "Bartholomew Fitzwilliam
62
+ Montgomery-Smythe" (41 characters) stored 40 of them and handed the account
63
+ back its own surname misspelled, **"Montgomery-Smyth"**, on every surface
64
+ `name` feeds.
65
+
66
+ **ONE CONSTANT WAS ANSWERING TWO QUESTIONS**, which is why neither call site
67
+ looked wrong. `/profile` applies the same number PER FIELD (`normalized_name`,
68
+ once to each of its two inputs); onboarding applied it to first name PLUS
69
+ surname in one string. The fix NAMES the second question rather than
70
+ re-scoping the first: **`Studio::FULL_NAME_MAX_LENGTH`**, the whole-answer
71
+ cap, `(FIRST_NAME_MAX_LENGTH * 2) + 1` = **81**. It is DERIVED, not picked —
72
+ first(40) + a space + last(40) is the longest answer whose two halves BOTH
73
+ still fit the per-field cap, so onboarding can never accept a name `/profile`
74
+ would silently shorten on the next save, which is the drift the shared
75
+ constant existed to prevent. `FIRST_NAME_MAX_LENGTH` keeps its value and its
76
+ meaning, and `test/integration/profile_name_field_caps_test.rb` now proves
77
+ that by sending BOTH fields over-long in one request and reading two 40s back
78
+ — a single-field assertion cannot tell "per field" from "per whole name".
79
+
80
+ **PAST THE CAP IT REFUSES INSTEAD OF TRUNCATING** (422 + `{ error: … }`,
81
+ rendered inline by the modal). `.first(cap)` on a name is not a cap but a
82
+ rewrite: it accepted the answer, stored something nobody typed, and reported
83
+ success — and raising the number alone would only move that cliff one
84
+ character further out. Two independent rules, because the whole answer becomes
85
+ `name` while each half becomes its own column: the answer must fit the
86
+ whole-answer cap, and each derived half must fit the per-field cap (a single
87
+ 60-character word passes the first and would still land a `first_name`
88
+ `/profile` shortens). The onboarding input's `maxlength` default was a LITERAL
89
+ `40` — a third copy of the per-field number, on the one field that receives a
90
+ whole name — and now reads `FULL_NAME_MAX_LENGTH`, so the browser bound and
91
+ the server bound cannot drift.
92
+
93
+ **NEITHER CAP IS A COLUMN CEILING.** Measured 2026-09-06 across all six
94
+ consumer databases (mcritchie-studio, turf-monster, mcritchie-industries;
95
+ production and QA): `name`, `first_name` and `last_name` are unbounded
96
+ `character varying` — `character_maximum_length` NULL, no CHECK constraint, no
97
+ model length validation. Raising the bound cannot trade a silent truncation
98
+ for a raised error at any value, so this is a product decision start to
99
+ finish.
100
+
101
+ **NO DATA REPAIR SHIPS.** Re-measured for THIS defect on the same six
102
+ databases: **zero rows** carry a `name`, `first_name` or `last_name` at or
103
+ over 40 characters, and the longest `name` in the fleet is 27 (control:
104
+ 5 / 6 / 46 / 15 / 4 / 4 users scanned, `max(length(name))` non-null in every
105
+ one). Nothing was truncated in production, so there is nothing to repair — and
106
+ a row whose halves disagree with its `name` because of a `/profile` edit must
107
+ never be auto-repaired anyway.
108
+
58
109
  - **Onboarding no longer stores a two-word answer as the whole first name.**
59
110
  `Studio::OnboardingController#first_name` writes with `update_columns`, which
60
111
  skips callbacks — so the host's `before_save :set_name_parts` never ran and
@@ -72,10 +123,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
72
123
  `test/integration/onboarding_name_parts_test.rb` carries that as a live
73
124
  control (`update!` moves `pat-pat@example.com` → `ada-lovelace-pat@…`), not as
74
125
  a claim. `last_name` is written only where the column exists: the engine's own
75
- standard-columns migration does not carry it, so mcritchie-industries,
76
- moms-app and acquisition-studio would otherwise have taken a
77
- `MissingAttributeError` 500 on every hyphen-free two-word answer
78
- (`test/integration/onboarding_thin_host_test.rb`).
126
+ standard-columns migration does not carry it, so a host that opts into these
127
+ routes before it runs that column would otherwise take a
128
+ `MissingAttributeError` 500 on every two-word answer
129
+ (`test/integration/onboarding_thin_host_test.rb`). **Corrected 2026-09-06:**
130
+ an earlier version of this entry named mcritchie-industries, moms-app and
131
+ acquisition-studio as apps that would have taken that 500. They carry no
132
+ `last_name`, but they do not mount these endpoints at all —
133
+ `Studio.draw_onboarding_routes` defaults to false and only mcritchie-studio
134
+ and turf-monster set it true, and both of those HAVE the column. The guard is
135
+ a standing one for the next host to opt in, not a live save.
79
136
  NO DATA REPAIR SHIPS: measured 2026-09-05 across both production databases
80
137
  (mcritchie-studio 5 users, turf-monster 43) and both QA ones, **zero rows**
81
138
  carry a space in `first_name`. Repairing rows whose halves merely disagree
@@ -23,22 +23,40 @@ module Studio
23
23
  # Both actions are for the freshly signed-in user, so authentication is the
24
24
  # host's default require_authentication — no skip_before_action here.
25
25
 
26
- # The shared cap (Studio::FIRST_NAME_MAX_LENGTH). Kept under the old name
27
- # here because it is referenced from outside this class; what changed is that
28
- # it is no longer a SECOND definition of the same number. /profile writes the
29
- # same column and now reads the same constant.
26
+ # The PER-FIELD cap (Studio::FIRST_NAME_MAX_LENGTH) what one derived half
27
+ # may be. Kept under the old name here because it is referenced from outside
28
+ # this class; what changed is that it is no longer a SECOND definition of
29
+ # the same number. /profile writes the same columns and reads the same
30
+ # constant.
30
31
  MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
31
32
 
33
+ # The WHOLE-ANSWER cap (Studio::FULL_NAME_MAX_LENGTH) — what this endpoint
34
+ # actually bounds. It is a different question from the one above and now has
35
+ # a different name: the field asks for a name and people type a full one, so
36
+ # the string arriving here is first name PLUS surname, and measuring it with
37
+ # the per-field number stored 40 characters of a 41-character name and
38
+ # handed the account back a misspelled surname.
39
+ MAX_FULL_NAME = Studio::FULL_NAME_MAX_LENGTH
40
+
32
41
  # POST /onboarding/first_name
33
42
  #
34
43
  # Writes users.first_name, and backfills `name` when it is blank so the
35
44
  # display-name chain has something better than an email prefix to show.
36
45
  def first_name
37
- value = params[:first_name].to_s.strip.gsub(/\s+/, " ").first(MAX_FIRST_NAME)
46
+ value = params[:first_name].to_s.strip.gsub(/\s+/, " ")
38
47
 
39
48
  if value.blank?
40
- return render json: { ok: false, error: "Enter your first name, or skip for now." },
41
- status: :unprocessable_entity
49
+ return refuse("Enter your first name, or skip for now.")
50
+ end
51
+
52
+ # DERIVE FIRST, THEN GUARD, THEN WRITE. The halves are what the length
53
+ # rules are about, so they are computed once and both the guard and the
54
+ # write read the same hash — a guard measuring a second derivation could
55
+ # pass over values the write then stored.
56
+ attrs = name_columns(value)
57
+
58
+ if (message = length_refusal(value, attrs))
59
+ return refuse(message)
42
60
  end
43
61
 
44
62
  rescue_and_log(target: current_user) do
@@ -63,7 +81,7 @@ module Studio
63
81
  # never self-healed, because set_name_parts is gated on `name_changed?`
64
82
  # and no later save sees a name change. So the derivation comes to the
65
83
  # writer instead: Studio::NameParts is the same rule the callback runs.
66
- attrs = name_columns(value)
84
+ # (`attrs` was derived above, so the guard and this write cannot disagree.)
67
85
  attrs[:name] = value if current_user.name.blank?
68
86
  current_user.update_columns(attrs)
69
87
 
@@ -86,6 +104,50 @@ module Studio
86
104
 
87
105
  private
88
106
 
107
+ # The one refusal shape both guards answer with. The modal renders `error`
108
+ # inline and leaves the field filled, so a refusal here is a correction the
109
+ # person can act on rather than a dead end.
110
+ def refuse(message)
111
+ render json: { ok: false, error: message }, status: :unprocessable_entity
112
+ end
113
+
114
+ # WHY THIS REFUSES INSTEAD OF TRUNCATING, which is the product decision this
115
+ # endpoint used to make silently and badly.
116
+ #
117
+ # `.first(cap)` on a name is not a cap, it is a rewrite: it accepted the
118
+ # answer, stored something the person never typed, and reported success.
119
+ # "Bartholomew Fitzwilliam Montgomery-Smythe" came back as
120
+ # "…Montgomery-Smyth" — plausible enough to miss, which is the worst kind of
121
+ # wrong for a name. Raising the number alone only moves that cliff one
122
+ # character further out; a bound that rewrites its input is the defect at
123
+ # any value. So the bound goes up (MAX_FULL_NAME now fits a real full name)
124
+ # AND anything past it is refused with a sentence saying so.
125
+ #
126
+ # BOTH LENGTHS ARE CHECKED, and they are independent rather than redundant.
127
+ # The whole answer becomes `name`; each half becomes its own column that
128
+ # /profile caps separately. A single 60-character word passes the first rule
129
+ # and would still land a `first_name` /profile silently shortens on the next
130
+ # save — the very drift the two constants exist to prevent — so the halves
131
+ # are measured too.
132
+ #
133
+ # UNREACHABLE FROM THE MODAL, ON PURPOSE. The partial's input carries the
134
+ # same MAX_FULL_NAME as its maxlength, so the browser stops a typist first.
135
+ # This is the server being the real bound, for a direct POST or for a host
136
+ # that passes a larger `max_length` local.
137
+ #
138
+ # Returns the message to refuse with, or nil to proceed.
139
+ def length_refusal(value, attrs)
140
+ if value.length > MAX_FULL_NAME
141
+ return "Please shorten that to #{MAX_FULL_NAME} characters or fewer."
142
+ end
143
+
144
+ if attrs.values.compact.any? { |part| part.length > MAX_FIRST_NAME }
145
+ return "Please keep each name to #{MAX_FIRST_NAME} characters or fewer."
146
+ end
147
+
148
+ nil
149
+ end
150
+
89
151
  # The name halves to write, derived exactly as the host's set_name_parts
90
152
  # would derive them from the same string.
91
153
  #
@@ -15,7 +15,10 @@
15
15
  heading — (default "What should we call you?")
16
16
  subtext — the one-line why (default speaks about emails)
17
17
  placeholder — (default "Alex")
18
- max_length — (default 40; the server stays the real bound)
18
+ max_length — (default Studio::FULL_NAME_MAX_LENGTH; the server stays the
19
+ real bound, and it REFUSES past that rather than truncating,
20
+ so a host raising this makes the endpoint answer 422 rather
21
+ than store a name nobody typed)
19
22
  progress — [current, total] to render the segmented pill, or nil for none
20
23
  modal_store — Alpine store name (default "modals"; the living style guide
21
24
  mounts its own page-scoped host and passes "dsModals")
@@ -41,7 +44,7 @@
41
44
  subtext = local_assigns.fetch(:subtext,
42
45
  "Just your first name — we use it to address you in emails.")
43
46
  placeholder = local_assigns.fetch(:placeholder, "Alex")
44
- max_length = local_assigns.fetch(:max_length, 40)
47
+ max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
45
48
  progress = local_assigns.fetch(:progress, nil)
46
49
  modal_store = local_assigns.fetch(:modal_store, "modals")
47
50
  done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.69.4"
2
+ VERSION = "0.69.5"
3
3
  end
data/lib/studio.rb CHANGED
@@ -407,17 +407,46 @@ module Studio
407
407
  # later session may ask again. That is the whole reason this is not a column.
408
408
  FIRST_NAME_SKIP_SESSION_KEY = :onboarding_skipped_first_name
409
409
 
410
- # How long a first name may be. ONE constant because users.first_name is
411
- # written from TWO surfaces the onboarding step (seconds after signup) and
412
- # /profile (any time after) and rendered by a third, the profile form's
413
- # maxlength. Two independently-correct caps that disagreed would let onboarding
414
- # accept a name /profile then refused to save, a bug with no obvious owner.
410
+ # How long ONE name field may be users.first_name, and users.last_name where
411
+ # a host carries it. PER FIELD, which is the only thing this number has ever
412
+ # measured: /profile caps each of the two inputs with it, and the profile
413
+ # form's maxlength renders it. Two independently-correct caps that disagreed
414
+ # would let one surface accept a name another then refused to save, a bug with
415
+ # no obvious owner.
415
416
  #
416
417
  # Keeping it here rather than on either controller also keeps the VIEW off a
417
418
  # controller constant: the form needs the number, and a view reaching into
418
419
  # Studio::ProfilesController to get it would couple the two for no reason.
419
420
  FIRST_NAME_MAX_LENGTH = 40
420
421
 
422
+ # How long a WHOLE typed answer may be — one string holding a full name, which
423
+ # the onboarding step stores as users.name and SPLITS into the two halves
424
+ # above (Studio::NameParts).
425
+ #
426
+ # A SECOND CONSTANT BECAUSE THIS IS A SECOND QUESTION. Onboarding asked the
427
+ # per-field number for a whole-answer bound, and the two readings were both
428
+ # defensible at their own call site: "Bartholomew Fitzwilliam
429
+ # Montgomery-Smythe" is 41 characters, so it stored 40 and handed the account
430
+ # back its own surname misspelled — "Montgomery-Smyth". Nothing was wrong in
431
+ # either controller alone; the number was answering two questions with one
432
+ # value. Giving the whole-answer bound its own name makes that borrow
433
+ # unwriteable rather than merely discouraged.
434
+ #
435
+ # DERIVED, NOT PICKED. It is the longest answer whose two halves BOTH still
436
+ # fit the per-field cap — first(40) + a space + last(40) — so onboarding can
437
+ # never accept a name /profile would later shorten, which is the drift
438
+ # FIRST_NAME_MAX_LENGTH exists to prevent. Raising the per-field cap moves
439
+ # this one with it, by construction.
440
+ #
441
+ # NOT BOUNDED BY THE COLUMN. Measured 2026-09-06 across all six consumer
442
+ # databases (mcritchie-studio, turf-monster and mcritchie-industries, prod and
443
+ # QA): every one of `name`, `first_name` and `last_name` is an unbounded
444
+ # `character varying` — character_maximum_length NULL, no CHECK constraint, no
445
+ # model length validation. So this cap is a product decision start to finish,
446
+ # not a column ceiling, and raising it cannot trade a truncation for a
447
+ # PG::StringDataRightTruncation.
448
+ FULL_NAME_MAX_LENGTH = (FIRST_NAME_MAX_LENGTH * 2) + 1
449
+
421
450
  # The shared rule for "does this account still owe us a first name?" — the one
422
451
  # piece of onboarding logic every app agrees on. Hosts compose it into their own
423
452
  # flow rather than re-deriving it (turf's OnboardingFlow calls straight through).
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.69.4
4
+ version: 0.69.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie