studio-engine 0.63.0 → 0.65.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: 7fcb787be0caeb8c975281d334f56a0d0ce699dbd25851b59948de0e5c6a87a4
4
- data.tar.gz: a0b21daec66caf0110d3fc6f33f311a678b4216bda5b7d033011d84465ec54d3
3
+ metadata.gz: 8914b74430f105820bbc5a41f44d6c573ff88492bcd882534f769fa2c2a11608
4
+ data.tar.gz: f3d1ffbec8dcb09ef354d21ab3786d2d5b9b0590a665ac95a949e7282668a66b
5
5
  SHA512:
6
- metadata.gz: 26fdea81fbf8edcb85f0e6121c9bfa8b15dc34cf2b9166ebdd3cf158e2c09636d6ea5401baf203f293f6c2b2a866a2ddecdeacdd326c85bb3df6fa058af16800
7
- data.tar.gz: 2493f082d861b7a8dadf60963df93c09aac10da94b6f0a7064644600b7da5bbd0079d0b58d4d1d1dbda717881f63af9fc53a5fdb9eab0c0ad644c1e7eaf5fb0b
6
+ metadata.gz: d52952922f19f864f97fd09297d8711c246ba54415bd40d60682569cbdfe0d51ccf7fb7673e7d921a361ab3657b35871f4999aad0baa80374dd3098cd3c267b2
7
+ data.tar.gz: 82afd75d6a4e57bfbe3708b59c1c1e7eec188cf2bcfd5921e3eeb50e006090d1f0839e5fc280be8ee60c8d6dc999bb07bb148e7e2220b7d061559048e14b6225
data/CHANGELOG.md CHANGED
@@ -4,6 +4,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **`data-pin` — the pinned stack publishes itself.** `--nav-h` / `--nav-bottom`
10
+ answer for one element. Everything else that pins has been re-deriving the
11
+ same geometry by hand: on `mcritchie-studio`'s `/deployments` the app strip
12
+ positions itself with `:style="{ top: offset + 'px' }"`, and the lane headers
13
+ compute *"site header height + strip height"* in Alpine behind **their own**
14
+ `ResizeObserver` on `.vt-pinned-header` — while `--nav-bottom`, which already
15
+ answers the first half, goes unused there. Same shape as the four copies of
16
+ the navbar collapse, one layer up.
17
+
18
+ Any element carrying `data-pin="<name>"` now publishes `--pin-<name>-h` and
19
+ `--pin-<name>-bottom`, from **one shared `ResizeObserver`**, through the same
20
+ rAF coalescer and the same no-op-write skip the header already uses — and one
21
+ measurement pass for the whole frame, every read taken before any write.
22
+
23
+ That last part was wrong in the first cut of this change and review caught it:
24
+ the frame published the header (writing two inherited properties on
25
+ `documentElement`) and *then* read the pins, so the reads-before-writes order
26
+ held inside each function and broke across them. Measured at 6× CPU throttle
27
+ over 175 frames, pin loop on vs off: frames past 20ms **62 vs 42**, p90 **34.2
28
+ vs 26.0ms**; at 10×, **84/180 vs 4/180**. The header is now a pin like any
29
+ other — measured once, writing `--nav-h` / `--nav-bottom` from that same
30
+ reading rather than a second one. A consumer composes layers in pure
31
+ CSS:
32
+
33
+ ```css
34
+ top: max(var(--pin-nav-bottom, 0px), var(--pin-apps-bottom, 0px));
35
+ ```
36
+
37
+ **`max()` rather than a declared stacking order.** A hidden layer measures 0
38
+ and drops out, so nothing has to say which layer sits above which, and
39
+ reordering a partial cannot break the arithmetic.
40
+
41
+ **A host-owned header still publishes the legacy pair.** The registry is built
42
+ from `[data-pin]`, so a header that does not carry the attribute would never be
43
+ measured and `--nav-h` / `--nav-bottom` would never publish — and **both live
44
+ consumers own their header** (`turf-monster` `layouts/_navbar`,
45
+ `mcritchie-studio` `layouts/application`), neither carrying `data-pin`. Review
46
+ measured the gear drawer **82px** out of place and the contest board **114px**,
47
+ with neither tracking the collapse; both apps pin a two-segment `~>` under 1.0,
48
+ so it would have landed on their next bundle update with no floor bump. The
49
+ header is adopted as the legacy pin whether or not it asks to be.
50
+
51
+ **A departed layer has its properties removed.** `display:none` and `x-show`
52
+ keep the node, so it measures 0 and drops out of a consumer's `max()` on its
53
+ own; a REMOVED node cannot be measured by anything, and its last value would
54
+ stand forever — review measured a removed 300px strip holding the property at
55
+ 300px against a real stack bottom of 160px. The registry remembers what it
56
+ published and clears whatever is no longer in the document.
57
+
58
+ The registry is **rebuilt from the document**, on attach and after
59
+ `turbo:before-stream-render` — a Turbo Stream replaces nodes without a
60
+ `turbo:load`, which is precisely the stale-node bug the board documents
61
+ against its own version.
62
+
63
+ `layouts/_navbar` carries `data-pin="nav"`, so every consuming app gets
64
+ `--pin-nav-bottom` for free. **`--nav-h` and `--nav-bottom` keep publishing
65
+ unchanged** — `studio/_sidebar_panel` and turf-monster's `scroll-margin-top`
66
+ read them, and `nav_offset_contract_test` still pins both to their exact
67
+ sources. This is additive.
68
+
69
+
7
70
  ### Changed
8
71
 
9
72
  - **The navbar collapse primitive is broadcastable, rate-limited, and cheap per
@@ -22,6 +22,15 @@ class SolanaSessionsController < ApplicationController
22
22
  render json: { nonce: session[:solana_nonce] }
23
23
  end
24
24
 
25
+ # Where Phantom redirects back to after a mobile signIn. The page itself does
26
+ # the work — decrypt Phantom's reply with the keypair the deep link stashed in
27
+ # localStorage, rebuild the signed message, and POST it to #verify — so this
28
+ # action only renders it. It must stay UNAUTHENTICATED: the whole point is
29
+ # that nobody is signed in yet.
30
+ def phantom_callback
31
+ render :phantom_callback
32
+ end
33
+
25
34
  def verify
26
35
  pubkey_b58 = verify_solana_signature!(
27
36
  message: params[:message],
@@ -46,7 +46,11 @@
46
46
  so it cannot move content under the reader.
47
47
  Preview keeps nav-shell (the calc()s have to resolve) but no x-data — the
48
48
  /navbar review page drives --nav-p from its own Scrolled toggle. %>
49
- <header <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
49
+ <%# data-pin names this header as a layer of the pinned stack, so
50
+ layouts/studio/_head publishes --pin-nav-h and --pin-nav-bottom for it and
51
+ anything below can position off it in pure CSS. The legacy --nav-h /
52
+ --nav-bottom keep publishing unchanged; this is additive. %>
53
+ <header data-pin="nav" <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
50
54
  <%# top-0, a STATIC value, and never a custom property. Any bars render as
51
55
  this header's sibling in normal flow (studio/banners/_stack), so they
52
56
  already occupy their own height above it and there is nothing to
@@ -200,74 +200,168 @@
200
200
  (function () {
201
201
  if (!window.ResizeObserver) return;
202
202
  var ro = null;
203
- var current = null;
204
203
  var queued = false;
205
- var lastH = null;
206
- var lastBottom = null;
207
- function publish(header) {
204
+ var pins = [];
205
+ // The names this publisher has written, so a DEPARTED layer can be cleared.
206
+ var published = {};
207
+
208
+ // ONE MEASUREMENT PER ELEMENT PER FRAME, and every read before every write.
209
+ //
210
+ // publish() and publishPin() each kept reads-before-writes INTERNALLY, and
211
+ // that was not enough: the frame ran publish(current) — which WRITES two
212
+ // inherited custom properties on documentElement — and then the pin loop,
213
+ // whose first act is a getBoundingClientRect(). A read after a write to the
214
+ // root forces a fresh layout, so the thrash this file removed came straight
215
+ // back one function boundary over. Measured by review at 6x CPU throttle
216
+ // over 175 frames, pin loop on vs off: frames past 20ms 62 vs 42, median
217
+ // 17.7 vs 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
218
+ //
219
+ // The header was also measured TWICE — once as `current`, once as pin
220
+ // `nav` — for values proven identical across 40 frames. It is a pin like
221
+ // any other now; the only thing special about it is that it ALSO writes the
222
+ // legacy --nav-h / --nav-bottom that studio/_sidebar_panel and
223
+ // turf-monster's scroll-margin-top already read.
224
+ function readPin(pin) {
225
+ return {
226
+ pin: pin,
227
+ h: pin.el.offsetHeight,
228
+ bottom: Math.max(0, pin.el.getBoundingClientRect().bottom)
229
+ };
230
+ }
231
+ function writeReading(r) {
208
232
  var style = document.documentElement.style;
209
- // SKIP THE NO-OP WRITE. --nav-h and --nav-bottom are INHERITED custom
210
- // properties on documentElement, so every write invalidates style for the
211
- // whole document. Most republishes carry the same number — a scroll with
212
- // the header already pinned, a resize that missed this element — and
213
- // those cost nothing now.
214
- // BOTH READS BEFORE EITHER WRITE. Writing a custom property on
215
- // documentElement invalidates style, so a read taken after one write
216
- // forces a fresh layout — the thrash this whole change exists to remove.
217
- var h = header.offsetHeight;
218
- // Scroll-dependent where --nav-h is not: chrome above the header scrolls
219
- // away while an overlay is open (nothing here locks body scroll), so this
220
- // is republished on scroll. Clamped at 0 — once the sticky header is
221
- // pinned at top:0 the two values converge, which is why a consumer whose
222
- // header already starts at the viewport top sees no change at all.
223
- var bottom = Math.max(0, header.getBoundingClientRect().bottom);
224
- if (h !== lastH) {
225
- lastH = h;
226
- style.setProperty('--nav-h', h + 'px');
233
+ var pin = r.pin;
234
+ if (r.h !== pin.lastH) {
235
+ pin.lastH = r.h;
236
+ style.setProperty('--pin-' + pin.name + '-h', r.h + 'px');
237
+ if (pin.legacy) style.setProperty('--nav-h', r.h + 'px');
227
238
  }
228
- if (bottom !== lastBottom) {
229
- lastBottom = bottom;
230
- style.setProperty('--nav-bottom', bottom + 'px');
239
+ if (r.bottom !== pin.lastBottom) {
240
+ pin.lastBottom = r.bottom;
241
+ style.setProperty('--pin-' + pin.name + '-bottom', r.bottom + 'px');
242
+ if (pin.legacy) style.setProperty('--nav-bottom', r.bottom + 'px');
231
243
  }
232
244
  }
245
+ function publishAll() {
246
+ var readings = [];
247
+ var i;
248
+ for (i = 0; i < pins.length; i++) readings.push(readPin(pins[i]));
249
+ for (i = 0; i < readings.length; i++) writeReading(readings[i]);
250
+ }
233
251
  function schedule() {
234
- if (queued || !current) return;
252
+ if (queued || !pins.length) return;
235
253
  queued = true;
236
- window.requestAnimationFrame(function () { queued = false; if (current) publish(current); });
254
+ window.requestAnimationFrame(function () { queued = false; publishAll(); });
237
255
  }
238
- function attach() {
256
+ // ONE construction site. Two of them let attach() replace the observer that
257
+ // registerPins() had already put the pins on, silently dropping every live
258
+ // observation — caught by test_pins_share_one_observer_and_one_frame, which
259
+ // counts them for exactly that reason.
260
+ function ensureObserver() {
261
+ if (!ro) ro = new ResizeObserver(schedule);
262
+ return ro;
263
+ }
264
+ function registerPins() {
265
+ ensureObserver();
266
+ pins = [];
267
+ var seen = {};
239
268
  var header = document.querySelector('header');
240
- if (!header) {
241
- // A page with no <header> — a bare error page, a print view. RELEASE the
242
- // old node instead of returning: the scroll listener outlives the visit,
243
- // and publishing from a DETACHED element yields an all-zero rect, which
244
- // would drive --nav-h to 0px. Before this listener existed nothing ran
245
- // after detach, so --nav-h simply kept its last value; keep that promise.
246
- if (ro) { ro.disconnect(); ro = null; }
247
- current = null;
248
- return;
269
+ var headerPinned = false;
270
+ var nodes = document.querySelectorAll('[data-pin]');
271
+ for (var i = 0; i < nodes.length; i++) {
272
+ var name = nodes[i].getAttribute('data-pin');
273
+ // An unnamed pin would write `--pin--h`, a valid property name and a
274
+ // silent nonsense one. Skip it rather than publish it.
275
+ if (!name) continue;
276
+ seen[name] = true;
277
+ if (nodes[i] === header) headerPinned = true;
278
+ pins.push({
279
+ el: nodes[i],
280
+ name: name,
281
+ // The header keeps publishing --nav-h / --nav-bottom, from the SAME
282
+ // measurement, so it is never measured twice.
283
+ legacy: nodes[i] === header,
284
+ lastH: null,
285
+ lastBottom: null
286
+ });
287
+ ro.observe(nodes[i]);
288
+ }
289
+ // CLEAR WHAT LEFT. A layer goes away three ways: display:none and x-show
290
+ // both keep the node, so it measures 0 and drops out of a consumer's
291
+ // max() on its own. REMOVAL does not — nothing is left to measure, the
292
+ // last published value stands forever, and a consumer sits at the height
293
+ // of a strip that is gone. Review measured a removed 300px strip holding
294
+ // --pin-apps-bottom at 300px against a real stack bottom of 160px: a
295
+ // permanent 140px error, and a direct contradiction of this primitive's
296
+ // headline promise.
297
+ for (var name2 in published) {
298
+ if (published.hasOwnProperty(name2) && !seen[name2]) {
299
+ document.documentElement.style.removeProperty('--pin-' + name2 + '-h');
300
+ document.documentElement.style.removeProperty('--pin-' + name2 + '-bottom');
301
+ }
249
302
  }
250
- current = header;
303
+ // THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
304
+ //
305
+ // The registry is built from [data-pin], so a header that does not carry
306
+ // the attribute is not in it — and --nav-h / --nav-bottom, which are
307
+ // written from the legacy pin's reading, would never publish at all.
308
+ // THIS ENGINE'S navbar carries data-pin, so every test here and the whole
309
+ // e2e lab publish fine; the broken path exists only in a consumer that
310
+ // owns its header, which is BOTH live consumers:
311
+ //
312
+ // turf-monster layouts/_navbar.html.erb (data-navbar-root)
313
+ // mcritchie-studio layouts/application.html.erb (inline)
314
+ //
315
+ // Review measured it against turf's real header and its real sidebar
316
+ // declaration: --nav-h and --nav-bottom unset, the gear drawer 82px out
317
+ // of place, the contest board 114px, and neither tracking the collapse.
318
+ // Both apps pin a two-segment ~> under 1.0, so it would have landed on
319
+ // their next bundle update with no floor bump to warn anyone.
320
+ //
321
+ // So the header is adopted as a pin even when it does not ask to be. It
322
+ // still publishes --pin-nav-* under that name, which is what an app gets
323
+ // for free the moment it wants the general contract.
324
+ if (header && !headerPinned) {
325
+ pins.push({
326
+ el: header,
327
+ name: 'nav',
328
+ legacy: true,
329
+ lastH: null,
330
+ lastBottom: null
331
+ });
332
+ seen.nav = true;
333
+ ensureObserver().observe(header);
334
+ }
335
+
336
+ published = seen;
337
+ publishAll();
338
+ }
339
+ // ATTACH IS NOW JUST A RE-SCAN. The header used to be tracked separately as
340
+ // `current`, with its own observe and its own publish; it is a pin named
341
+ // "nav" like any other, so registerPins() covers it. What survives is the
342
+ // release promise: a page with no pinned chrome at all disconnects the
343
+ // observer rather than leaving it on detached nodes, and the properties keep
344
+ // their last value rather than being driven to 0 by an all-zero rect.
345
+ //
346
+ // A page that never has a <header> is served too — the observer is built by
347
+ // ensureObserver() from registerPins(), not from a has-header branch. It was
348
+ // only reachable from that branch before, so pins on a headerless layout were
349
+ // permanently dead.
350
+ function attach() {
251
351
  if (ro) ro.disconnect();
252
- // THROUGH schedule(), NOT STRAIGHT TO publish(). This callback used to
253
- // call publish() directly, which was fine while the header only resized
254
- // during a 300ms transition. navCollapse made it resize on EVERY scroll
255
- // frame, and each direct publish forced layout and then wrote two
256
- // inherited custom properties on documentElement. Measured in
257
- // turf-monster at 6x CPU throttle: frames over 20ms were 13/24 THROUGH
258
- // the collapse ramp versus 0/24 past it, median 26ms versus 8ms.
259
- // Ablating this observer alone took it to 0/24 and median 13ms — it cost
260
- // about 2.6x the reflow it was reacting to. schedule() already coalesces
261
- // to one publish per frame and is what the scroll and resize listeners
262
- // use; the ResizeObserver was the one path that bypassed it.
263
- ro = new ResizeObserver(schedule);
264
- ro.observe(header);
265
- publish(header);
352
+ registerPins();
353
+ if (!pins.length && ro) { ro.disconnect(); ro = null; }
266
354
  }
267
355
  document.addEventListener('DOMContentLoaded', attach);
268
356
  document.addEventListener('turbo:load', attach);
357
+ // A Turbo Stream replaces nodes WITHOUT a turbo:load, which is how a
358
+ // captured pin goes stale. Re-register on the next frame, once the stream
359
+ // has actually patched the DOM.
360
+ document.addEventListener('turbo:before-stream-render', function () {
361
+ window.requestAnimationFrame(registerPins);
362
+ });
269
363
  // Registered ONCE at script eval, not inside attach(), so a Turbo nav
270
- // re-points `current` instead of stacking another listener per visit.
364
+ // re-scans instead of stacking another listener per visit.
271
365
  window.addEventListener('scroll', schedule, { passive: true });
272
366
  window.addEventListener('resize', schedule, { passive: true });
273
367
  })();
@@ -0,0 +1,343 @@
1
+ <div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
2
+ <div class="text-center">
3
+ <div id="phantom-spinner" class="inline-block w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
4
+ <p id="phantom-status" class="text-secondary text-sm">Processing Phantom response...</p>
5
+ <p id="phantom-error" class="text-red-400 text-sm mt-2 hidden"></p>
6
+ </div>
7
+
8
+ <%# Visual debug log — NEVER rendered on a real production deploy. This sink printed
9
+ the dapp x25519 secret key (phantom_dl_secret) to the page and the console on every
10
+ mobile Phantom sign-in. Studio.wallet_debug_sink DEFAULTS TO OFF —
11
+ a sink that prints a signing key is opted into, not defaulted on. An app that
12
+ wants QA debugging back sets it to a predicate of its own (turf-monster:
13
+ -> { !AppFlags.live_production? }); Rails.env.production? will NOT do, because
14
+ a Heroku QA dyno runs RAILS_ENV=production. The script below
15
+ treats an absent sink as "debug off" and must stay null-safe: if this div is gone and
16
+ dbg() still touched logEl, the callback would throw and break sign-in outright. %>
17
+ <% if Studio.wallet_debug_sink? %>
18
+ <div id="phantom-log" class="mt-6 w-full max-w-lg text-left bg-surface-alt rounded-lg border border-subtle p-3 overflow-y-auto" style="max-height:50vh">
19
+ <p class="text-xs font-mono text-muted mb-2">Debug Log</p>
20
+ </div>
21
+ <% end %>
22
+ </div>
23
+
24
+ <script>
25
+ (function() {
26
+ var logEl = document.getElementById('phantom-log');
27
+ // The sink is absent on a real production deploy (see the ERB guard above), and that
28
+ // absence IS the off switch — for the console too, not just the page.
29
+ var DEBUG_SINK = !!logEl;
30
+
31
+ function dbg(label, value) {
32
+ if (!DEBUG_SINK) { return; }
33
+ var line = document.createElement('p');
34
+ line.className = 'text-xs font-mono break-all mb-1';
35
+ if (label === 'ERROR' || label === 'CATCH') {
36
+ line.style.color = '#EF4444';
37
+ } else if (label === 'OK') {
38
+ line.style.color = '#4BAF50';
39
+ } else {
40
+ line.style.color = '#94a3b8';
41
+ }
42
+ var val = (value === undefined || value === null) ? '' : String(value);
43
+ line.textContent = label + (val ? ': ' + val : '');
44
+ logEl.appendChild(line);
45
+ logEl.scrollTop = logEl.scrollHeight;
46
+ console.log('[PhantomDL]', label, value);
47
+ }
48
+
49
+ function truncate(s, n) {
50
+ return s && s.length > n ? s.substring(0, n) + '...' : s;
51
+ }
52
+
53
+ dbg('Page loaded', new Date().toISOString());
54
+ dbg('URL', window.location.href);
55
+
56
+ // Self-contained Base58
57
+ var B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
58
+
59
+ function b58encode(bytes) {
60
+ var digits = [0];
61
+ for (var i = 0; i < bytes.length; i++) {
62
+ var carry = bytes[i];
63
+ for (var j = 0; j < digits.length; j++) {
64
+ carry += digits[j] << 8;
65
+ digits[j] = carry % 58;
66
+ carry = (carry / 58) | 0;
67
+ }
68
+ while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
69
+ }
70
+ var str = '';
71
+ for (var i = 0; i < bytes.length && bytes[i] === 0; i++) str += '1';
72
+ for (var i = digits.length - 1; i >= 0; i--) str += B58[digits[i]];
73
+ return str;
74
+ }
75
+
76
+ function b58decode(str) {
77
+ var bytes = [];
78
+ for (var i = 0; i < str.length; i++) {
79
+ var idx = B58.indexOf(str[i]);
80
+ if (idx < 0) throw new Error('Invalid base58 character');
81
+ var carry = idx;
82
+ for (var j = 0; j < bytes.length; j++) {
83
+ carry += bytes[j] * 58;
84
+ bytes[j] = carry & 0xff;
85
+ carry >>= 8;
86
+ }
87
+ while (carry) { bytes.push(carry & 0xff); carry >>= 8; }
88
+ }
89
+ for (var i = 0; i < str.length && str[i] === '1'; i++) bytes.push(0);
90
+ return new Uint8Array(bytes.reverse());
91
+ }
92
+
93
+ var statusEl = document.getElementById('phantom-status');
94
+ var errorEl = document.getElementById('phantom-error');
95
+ var spinnerEl = document.getElementById('phantom-spinner');
96
+
97
+ function showError(msg) {
98
+ dbg('ERROR', msg);
99
+ spinnerEl.classList.add('hidden');
100
+ statusEl.classList.add('hidden');
101
+ errorEl.textContent = msg;
102
+ errorEl.classList.remove('hidden');
103
+ setTimeout(function() { window.location.href = '/signin'; }, 30000);
104
+ }
105
+
106
+ var ALL_KEYS = [
107
+ 'phantom_dl_secret', 'phantom_dl_pubkey', 'phantom_dl_nonce', 'phantom_dl_nonce_at',
108
+ 'phantom_dl_step', 'phantom_dl_link_mode', 'phantom_dl_cluster', 'phantom_dl_age_attested'
109
+ ];
110
+
111
+ // Keys whose VALUE must never be printed. ALL_KEYS above stays COMPLETE on purpose:
112
+ // cleanup() iterates it to REMOVE these from localStorage, so pruning the secret from
113
+ // it would leave the private key sitting on the device forever — a worse bug than the
114
+ // one this task fixes. Redact at the point of display instead.
115
+ var SECRET_KEYS = ['phantom_dl_secret'];
116
+
117
+ function cleanup() {
118
+ ALL_KEYS.forEach(function(k) { localStorage.removeItem(k); });
119
+ }
120
+
121
+ // Dump localStorage
122
+ dbg('--- localStorage ---');
123
+ ALL_KEYS.forEach(function(k) {
124
+ var v = localStorage.getItem(k);
125
+ var short = k.replace('phantom_dl_', '');
126
+ if (SECRET_KEYS.indexOf(k) !== -1) {
127
+ dbg(' ' + short, v ? '(present, ' + v.length + ' chars, redacted)' : '(empty)');
128
+ } else {
129
+ dbg(' ' + short, v ? truncate(v, 40) : '(empty)');
130
+ }
131
+ });
132
+
133
+ // Parse + dump URL params
134
+ var params = new URLSearchParams(window.location.search);
135
+ dbg('--- URL params ---');
136
+ params.forEach(function(val, key) {
137
+ dbg(' ' + key, truncate(val, 40));
138
+ });
139
+
140
+ // Check for Phantom error
141
+ if (params.get('errorCode')) {
142
+ dbg('ERROR', 'errorCode=' + params.get('errorCode'));
143
+ dbg('ERROR', 'errorMessage=' + params.get('errorMessage'));
144
+ cleanup();
145
+ showError('Phantom error: ' + (params.get('errorMessage') || 'Request rejected'));
146
+ return;
147
+ }
148
+
149
+ var step = localStorage.getItem('phantom_dl_step');
150
+ dbg('Step', step);
151
+
152
+ if (!step) {
153
+ showError('No pending Phantom request. Redirecting to login...');
154
+ return;
155
+ }
156
+
157
+ // Check nonce expiry
158
+ var nonceAt = parseInt(localStorage.getItem('phantom_dl_nonce_at') || '0');
159
+ var elapsed = Date.now() - nonceAt;
160
+ dbg('Nonce age', Math.round(elapsed / 1000) + 's');
161
+ if (elapsed > 270000) {
162
+ cleanup();
163
+ showError('Session expired. Please try again.');
164
+ return;
165
+ }
166
+
167
+ // Check nacl
168
+ dbg('nacl available', typeof nacl !== 'undefined');
169
+ if (typeof nacl === 'undefined') {
170
+ showError('TweetNaCl not loaded');
171
+ return;
172
+ }
173
+
174
+ // URL params from Phantom response
175
+ var phantomEncPubkeyB58 = params.get('phantom_encryption_public_key');
176
+ var dataParm = params.get('data');
177
+ var nonceParm = params.get('nonce');
178
+
179
+ dbg('Has phantom_encryption_public_key', !!phantomEncPubkeyB58);
180
+ dbg('Has data', !!dataParm);
181
+ dbg('Has nonce', !!nonceParm);
182
+
183
+ if (!phantomEncPubkeyB58 || !dataParm || !nonceParm) {
184
+ cleanup();
185
+ showError('Missing Phantom response parameters');
186
+ return;
187
+ }
188
+
189
+ try {
190
+ // Recover dapp secret key from localStorage
191
+ var dappSecretKey = b58decode(localStorage.getItem('phantom_dl_secret'));
192
+ dbg('Dapp secret key bytes', dappSecretKey.length);
193
+
194
+ // Compute shared secret (Diffie-Hellman)
195
+ var phantomPubBytes = b58decode(phantomEncPubkeyB58);
196
+ dbg('Phantom pubkey bytes', phantomPubBytes.length);
197
+ var sharedSecret = nacl.box.before(phantomPubBytes, dappSecretKey);
198
+ dbg('OK', 'Shared secret computed, ' + sharedSecret.length + 'B');
199
+
200
+ // Decrypt response
201
+ var data = b58decode(dataParm);
202
+ var nonce = b58decode(nonceParm);
203
+ dbg('Decrypt', 'data=' + data.length + 'B nonce=' + nonce.length + 'B');
204
+
205
+ var decrypted = nacl.box.open.after(data, nonce, sharedSecret);
206
+ if (!decrypted) throw new Error('Decryption failed — nacl.box.open.after returned null');
207
+
208
+ var responseText = new TextDecoder().decode(decrypted);
209
+ dbg('OK', 'Decrypted ' + responseText.length + ' chars');
210
+
211
+ var response = JSON.parse(responseText);
212
+
213
+ // Log ALL response fields for debugging
214
+ dbg('=== RESPONSE FIELDS ===');
215
+ var keys = Object.keys(response);
216
+ dbg('Keys', keys.join(', '));
217
+ keys.forEach(function(k) {
218
+ var v = response[k];
219
+ if (typeof v === 'string') {
220
+ dbg(' ' + k, truncate(v, 50));
221
+ } else if (typeof v === 'object' && v !== null) {
222
+ dbg(' ' + k, JSON.stringify(v).substring(0, 80));
223
+ } else {
224
+ dbg(' ' + k, String(v));
225
+ }
226
+ });
227
+
228
+ // Extract wallet address
229
+ var walletPubkey = response.address || response.public_key;
230
+ dbg('Wallet', walletPubkey);
231
+
232
+ if (!walletPubkey) {
233
+ throw new Error('No wallet address in response');
234
+ }
235
+
236
+ // Extract signature and signed message (SIWS response)
237
+ var signatureB58 = response.signature;
238
+ var signedMessageB58 = response.signed_message || response.signedMessage;
239
+ var outputB58 = response.output;
240
+
241
+ dbg('signature', truncate(signatureB58, 30));
242
+ dbg('signedMessage', truncate(signedMessageB58, 30));
243
+ dbg('output', truncate(outputB58, 30));
244
+
245
+ // Decode the signed message to UTF-8 text (server expects the raw message string)
246
+ var message = null;
247
+ if (signedMessageB58) {
248
+ var msgBytes = b58decode(signedMessageB58);
249
+ message = new TextDecoder().decode(msgBytes);
250
+ dbg('Message text', truncate(message, 60));
251
+ }
252
+
253
+ // If no signature in response, show debug info and stop
254
+ if (!signatureB58) {
255
+ dbg('ERROR', 'No signature field in response — signIn may not return SIWS data');
256
+ dbg('Full response', responseText.substring(0, 200));
257
+ showError('No signature in Phantom response. See debug log.');
258
+ return;
259
+ }
260
+
261
+ if (!message) {
262
+ // Try to reconstruct message from our stored data
263
+ var serverNonce = localStorage.getItem('phantom_dl_nonce');
264
+ var domain = window.location.host;
265
+ // OPSEC-005: include `User-ID: <id>` when we were linking — mirrors
266
+ // the statement we built in startPhantomDeepLink so the server's
267
+ // session-binding check passes.
268
+ var storedUserId = localStorage.getItem('phantom_dl_user_id');
269
+ var storedLinkMode = localStorage.getItem('phantom_dl_link_mode') === 'true';
270
+ var statementLine = <%= Studio.wallet_sign_in_statement.to_json.html_safe %>;
271
+ if (storedLinkMode && storedUserId) {
272
+ statementLine += '\nUser-ID: ' + storedUserId;
273
+ }
274
+ message = domain + ' wants you to sign in with your Solana account:\n' +
275
+ walletPubkey + '\n\n' + statementLine + '\n\nNonce: ' + serverNonce;
276
+ dbg('Reconstructed message', truncate(message, 60));
277
+ }
278
+
279
+ var linkMode = localStorage.getItem('phantom_dl_link_mode') === 'true';
280
+ var verifyUrl = linkMode ? '/account/link_solana' : '/auth/solana/verify';
281
+ var csrfToken = document.querySelector('meta[name="csrf-token"]');
282
+
283
+ dbg('Verify URL', verifyUrl);
284
+ dbg('CSRF token', !!csrfToken);
285
+ dbg('POSTing to verify...');
286
+ statusEl.textContent = 'Logging in...';
287
+
288
+ fetch(verifyUrl, {
289
+ method: 'POST',
290
+ headers: {
291
+ 'Content-Type': 'application/json',
292
+ 'X-CSRF-Token': csrfToken ? csrfToken.content : ''
293
+ },
294
+ body: JSON.stringify({
295
+ message: message,
296
+ signature: signatureB58,
297
+ pubkey: walletPubkey,
298
+ // Legal-age attestation, stashed by the Connect Wallet picker before
299
+ // the deep-link round trip (undefined is dropped by JSON.stringify).
300
+ age_attestation: (!linkMode && localStorage.getItem('phantom_dl_age_attested') === '1') ? '1' : undefined,
301
+ // Hardcoded, and correctly so: this whole page exists to service the
302
+ // PHANTOM mobile deep link (phantom_dl_*), so the brand is a property of
303
+ // the route rather than something to detect. A user arriving here signed
304
+ // in the Phantom app.
305
+ wallet_provider: 'phantom'
306
+ })
307
+ })
308
+ .then(function(r) {
309
+ dbg('Verify status', r.status);
310
+ return r.json();
311
+ })
312
+ .then(function(result) {
313
+ dbg('Verify result', JSON.stringify(result));
314
+ if (result.success) {
315
+ cleanup();
316
+ // Guarded, matching studio/modals/_wallet_connect two files over. An app
317
+ // that does not define this hook would otherwise throw HERE — after a
318
+ // successful verify, with the session already set — so the user is
319
+ // signed in and the page dies before redirecting. The engine cannot
320
+ // require a hook it does not ship.
321
+ if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
322
+ dbg('OK', 'Login success! Redirecting...');
323
+ window.location.href = result.redirect || '/';
324
+ } else {
325
+ cleanup();
326
+ dbg('ERROR', 'Verify failed: ' + (result.error || JSON.stringify(result)));
327
+ showError(result.error || 'Verification failed');
328
+ }
329
+ })
330
+ .catch(function(err) {
331
+ dbg('ERROR', 'Fetch error: ' + err.message);
332
+ cleanup();
333
+ showError('Verification failed: ' + err.message);
334
+ });
335
+
336
+ } catch (e) {
337
+ dbg('CATCH', e.message);
338
+ dbg('CATCH', e.stack || '(no stack)');
339
+ cleanup();
340
+ showError(e.message || 'Something went wrong');
341
+ }
342
+ })();
343
+ </script>
@@ -141,16 +141,31 @@
141
141
  var n = ('' + name).toLowerCase();
142
142
  return ['phantom', 'solflare', 'backpack'].indexOf(n) !== -1 ? n : null;
143
143
  },
144
+ // Is there anywhere for a mobile Phantom row to GO? A consumer that does
145
+ // not render studio/solana/phantom_deeplink has no startPhantomDeepLink,
146
+ // and every branch below turns on this answer.
147
+ //
148
+ // WHY IT IS CHECKED AT ALL: without it, adopting this picker replaced an
149
+ // app's dead-end install row with a dead BUTTON — the install row
150
+ // suppressed, the deep-link row painted, and its tap a no-op. Found
151
+ // before the hub adopted, which had no mobile wallet path whatsoever.
152
+ // An absent capability must not default to the permissive branch.
153
+ get canDeepLink() {
154
+ return typeof startPhantomDeepLink === 'function';
155
+ },
144
156
  // A phone has no extension to install, and Phantom's own row on mobile
145
157
  // is the deep link below — so drop Phantom here rather than paint a
146
158
  // SECOND Phantom row pointing at a desktop download page the user
147
- // cannot act on. Solflare and Backpack keep their install rows: there is
148
- // no deep link for them, so the download page is still their only path.
159
+ // cannot act on. ONLY when a deep link can replace it: with no deep
160
+ // link the install row is the only Phantom path there is, dead end on
161
+ // iOS or not, and removing it leaves the user nothing. Solflare and
162
+ // Backpack keep their install rows either way — there is no deep link
163
+ // for them, so the download page is still their only path.
149
164
  get missingInstalls() {
150
165
  var self = this;
151
166
  return this.installs.filter(function(i) {
152
167
  if (self.hasWallet(i.name)) return false;
153
- if (self.isMobile && i.name === 'Phantom') return false;
168
+ if (self.isMobile && self.canDeepLink && i.name === 'Phantom') return false;
154
169
  return true;
155
170
  });
156
171
  },
@@ -158,7 +173,7 @@
158
173
  // means the detected row above already offers a working connect, so a
159
174
  // deep link would only offer to leave Phantom to open Phantom.
160
175
  get showPhantomDeepLink() {
161
- return this.isMobile && !this.hasWallet('Phantom');
176
+ return this.isMobile && !this.hasWallet('Phantom') && this.canDeepLink;
162
177
  },
163
178
  async pick(name) {
164
179
  if (this.connecting) return;
@@ -13,6 +13,24 @@
13
13
  modals/_wallet_deposit, _wallet_topup, _cdp_ramp, _onramp_hub, _buy_entry_token
14
14
  and three under modals/auth/ (_paypal_tokens, _usdc_funding, _tokens).
15
15
 
16
+ THE /admin/style SPECIMENS SHOW STRUCTURE, NOT CONTENT. If you are adopting
17
+ this primitive to replace existing markup, take every VALUE — here modal_store
18
+ and label — from the markup you are REPLACING, and use the specimen only to
19
+ learn which locals exist and how they compose. A specimen is a demo: the
20
+ engine is entitled to pick any plausible value for it, and those values are
21
+ not your app's. style/modals/_ds_wallet_topup passes modal_store "dsModals",
22
+ its own page-scoped host; a callsite that copies it closes a store the app
23
+ does not have, so the mark renders and does nothing.
24
+
25
+ The incident that earned this paragraph: an adopter built a new callsite from
26
+ style/modals/_ds_wallet_topup and carried its icon across. The specimen passes
27
+ U+1F39F ADMISSION TICKETS; the markup being replaced had always drawn U+1F3AB
28
+ TICKET. A different glyph in a different colour landed on the primary rail of
29
+ the web2 kill-switch face of Top Up Wallet — the single call to action shown to
30
+ the audience that cannot pay with USDC. Nothing raised, CI was 6/6 green, and
31
+ no assertion anywhere pinned the glyph. It was caught only by rendering the
32
+ modal before and after and diffing the markup.
33
+
16
34
  THE PARENT MUST BE POSITIONED. This is `absolute`, so it anchors to the nearest
17
35
  positioned ancestor — every callsite wraps its card in `class="relative"`. On an
18
36
  unpositioned parent the × climbs to whatever is positioned above it, which in a
@@ -9,6 +9,22 @@
9
9
  why that is a named local rather than a class passthrough — a passthrough would
10
10
  have let the eleventh copy drift on day one, which is how ten happened.
11
11
 
12
+ THE /admin/style SPECIMENS SHOW STRUCTURE, NOT CONTENT. If you are adopting
13
+ this primitive to replace existing markup, take every VALUE — icon_label,
14
+ icon_bg, title, subtitle, badge, data hooks — from the markup you are
15
+ REPLACING, and use the specimen only to learn which locals exist and how
16
+ they compose. A specimen is a demo: the engine is entitled to pick any
17
+ plausible value for it, and those values are not your app's.
18
+
19
+ The incident that earned this paragraph: an adopter built a new callsite from
20
+ style/modals/_ds_wallet_topup and carried its icon across. The specimen passes
21
+ U+1F39F ADMISSION TICKETS; the markup being replaced had always drawn U+1F3AB
22
+ TICKET. A different glyph in a different colour landed on the primary rail of
23
+ the web2 kill-switch face of Top Up Wallet — the single call to action shown to
24
+ the audience that cannot pay with USDC. Nothing raised, CI was 6/6 green, and
25
+ no assertion anywhere pinned the glyph. It was caught only by rendering the
26
+ modal before and after and diffing the markup.
27
+
12
28
  THE EMPHASIS IS A RANKING, not decoration. A hub full of equally-weighted rails
13
29
  asks the person to compare payment processors, which is not a question they can
14
30
  answer. Exactly one rail should be :primary — the one the app wants taken —
@@ -236,10 +236,17 @@
236
236
  :style="{ animation: 'studio-modal-drain ' + _total + 's linear forwards' }"></div>
237
237
  <span class="relative z-10"><%= cta_label %></span>
238
238
  </button>
239
+ <%# btn-lg, matching the drain branches above and every sibling in this family
240
+ (_onchain_success, _cta_redirect). Without it these two rendered 36px
241
+ (.btn alone: py-2 + text-sm) while the drain branch rendered 48 — the
242
+ same card, two heights, decided by whether an auto-redirect happened to
243
+ be configured. 36px is also under the 44px mobile touch-target
244
+ guideline. Not 40px: that is the HAND-ROLLED drain button one branch up
245
+ (px-4 py-2.5 text-sm), a different element with a different sum. %>
239
246
  <% elsif local_assigns[:cta_href_key] %>
240
- <a :href="<%= cta_href_key %>" class="btn btn-primary w-full"><%= cta_label %></a>
247
+ <a :href="<%= cta_href_key %>" class="btn btn-primary btn-lg w-full"><%= cta_label %></a>
241
248
  <% elsif local_assigns[:cta_event] %>
242
- <button @click="$dispatch('<%= cta_event %>')" class="btn btn-primary w-full"><%= cta_label %></button>
249
+ <button @click="$dispatch('<%= cta_event %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
243
250
  <% end %>
244
251
  <% end %>
245
252
 
@@ -0,0 +1,37 @@
1
+ <%#
2
+ tweetnacl, for the Phantom mobile deep link. Both halves of that flow need
3
+ window.nacl: the deep link generates an x25519 keypair so Phantom can encrypt
4
+ its reply, and the callback opens that box with the stashed secret.
5
+
6
+ IDEMPOTENT ON PURPOSE. turf-monster already loads tweetnacl from its own
7
+ layout, so this must not load a second copy there; the guard means a consumer
8
+ that already supplies nacl keeps its own and one that does not (the hub, which
9
+ had no mobile wallet path at all) gets it here.
10
+
11
+ Pinned to an exact version rather than a range: this is signing-path crypto,
12
+ and a range lets a CDN swap it under a running app. The same reasoning is
13
+ written out beside turf-monster's web3.js pin.
14
+
15
+ Render before studio/solana/phantom_deeplink and in the callback view.
16
+ %>
17
+ <script>
18
+ // NOT document.write. Under turbo-rails a Drive visit re-executes body scripts
19
+ // at readyState === 'complete', where document.write implicitly calls
20
+ // document.open() and BLANKS THE PAGE — on exactly the consumer this guard
21
+ // exists for. Append a real element instead; it is also non-blocking.
22
+ (function () {
23
+ if (typeof window.nacl !== 'undefined') return;
24
+ if (document.querySelector('script[data-studio-nacl]')) return;
25
+ var s = document.createElement('script');
26
+ s.src = 'https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js';
27
+ // Derived with `openssl dgst -sha384 -binary | openssl base64 -A` against the
28
+ // fetched file, and cross-checked against the value turf-monster already pins
29
+ // for this exact URL. NEVER hand-write an SRI: a plausible-looking wrong one
30
+ // makes the browser refuse the script and nacl silently absent, which fails
31
+ // every mobile sign-in before any other code runs.
32
+ s.integrity = 'sha384-05+sicyRJQ56XpL4U9HJ8YbtSzFDvAg7apPKOGV6A0JsAJKFM68jp5oLnUjG5mEp';
33
+ s.crossOrigin = 'anonymous';
34
+ s.setAttribute('data-studio-nacl', '');
35
+ document.head.appendChild(s);
36
+ })();
37
+ </script>
@@ -0,0 +1,167 @@
1
+ <%#
2
+ Phantom MOBILE deep link — the round trip a phone needs to sign in with a
3
+ wallet. On desktop Phantom is a browser extension the page can call directly;
4
+ on a phone it is a separate APP, so the page hands off to it and comes back.
5
+
6
+ PROMOTED from turf-monster (app/javascript/phantom_deeplink.js) so every
7
+ consumer gets it. The server half was already shared — the engine's
8
+ solana_sessions_controller owns nonce + verify and Solana::SessionAuth owns
9
+ verify_solana_signature! — only this mobile round trip was app-side.
10
+
11
+ Render it once, wherever the Connect-Wallet flow can be reached — an ERB
12
+ output tag rendering the partial path "studio/solana/phantom_deeplink".
13
+ (Described rather than quoted on purpose: an ERB comment ends at the first
14
+ close sequence, so spelling the tag out here would terminate this comment and
15
+ leak the rest of it as visible text into every consuming app. The engine's
16
+ erb_comment_leak_test enforces that, and it caught this very paragraph.)
17
+
18
+ It defines window.startPhantomDeepLink(linkMode, currentUserId). The engine's
19
+ wallet picker (studio/modals/_wallet_connect) gates its mobile Phantom row on
20
+ that function EXISTING, so a consumer that does not render this partial keeps
21
+ its install row instead of painting a button that does nothing.
22
+
23
+ THREE THINGS THAT MUST NOT BE MADE CONFIGURABLE — traced through
24
+ Solana::SessionAuth#verify_solana_signature! before this was generalised:
25
+
26
+ domain the client builds it from window.location.host and the server
27
+ checks it against request.host_with_port (OPSEC-018). An app
28
+ override breaks sign-in.
29
+ User-ID line OPSEC-005 does a SUBSTRING match on "User-ID: <id>". Do not
30
+ reformat it and do not translate it.
31
+ the nonce deleted server-side BEFORE verification; that is the replay
32
+ protection for the login path.
33
+
34
+ The STATEMENT is the one part that varies, and it varies safely: the server
35
+ does NOT verify it — it is what the human reads inside Phantom. It derives
36
+ from Studio.app_name, which reproduces turf-monster's previous literal
37
+ ("Sign in to Turf Monster") BYTE FOR BYTE, so promoting this changed no
38
+ signed message. It MUST match the callback's copy exactly, because the
39
+ callback reconstructs the signed message to post for verification — both read
40
+ this same helper, which is why they cannot drift.
41
+
42
+ Requires tweetnacl (window.nacl); render "studio/solana/deeplink_assets"
43
+ first, or supply it yourself.
44
+ %>
45
+ <script>
46
+ (function () {
47
+ var STATEMENT = <%= Studio.wallet_sign_in_statement.to_json.html_safe %>;
48
+
49
+ // Base58, INLINE and self-contained. turf-monster took this from a separate
50
+ // app/javascript/base58.js module that assigned window.encodeBase58, so its
51
+ // deep link depended on a second global. Promoting only the deep link would
52
+ // have left encodeBase58 undefined in any consumer without that file — it
53
+ // throws on the first keypair encode, at the moment the user taps Connect.
54
+ // The callback view already carries its own codec for the same reason; this
55
+ // matches it, so the partial has exactly one dependency (nacl) and it is a
56
+ // guarded one.
57
+ // THE CONSTANT THE ENCODER READS. It was left behind when the function body
58
+ // was inlined: base58.js declares it at module scope, so it is unreachable
59
+ // from a classic script, and encodeBase58 referenced a free variable that
60
+ // resolved to nothing AT CALL TIME. Every mobile sign-in threw
61
+ // "B58_ALPHABET is not defined" on the first keypair encode.
62
+ //
63
+ // Invisible to eleven passing view tests and to a browser spec that checked
64
+ // `typeof startPhantomDeepLink` without ever CALLING it. A parse-time
65
+ // mutation cannot reach this class either — the program parses fine.
66
+ const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
67
+
68
+ function encodeBase58(bytes) {
69
+ const digits = [0];
70
+ for (let i = 0; i < bytes.length; i++) {
71
+ let carry = bytes[i];
72
+ for (let j = 0; j < digits.length; j++) {
73
+ carry += digits[j] << 8;
74
+ digits[j] = carry % 58;
75
+ carry = (carry / 58) | 0;
76
+ }
77
+ while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
78
+ }
79
+ let str = '';
80
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) str += '1';
81
+ for (let i = digits.length - 1; i >= 0; i--) str += B58_ALPHABET[digits[i]];
82
+ return str;
83
+ }
84
+
85
+ // Phantom deep link protocol for mobile browsers
86
+ // Uses signIn deep link — ONE trip to Phantom (connect + sign combined)
87
+ // Flow: generate keypair → fetch nonce → build SIWS input → redirect to Phantom signIn
88
+
89
+ function startPhantomDeepLink(linkMode, currentUserId) {
90
+ // The consumer declares its cluster on <body data-solana-cluster="...">.
91
+ // turf-monster does; an app that does NOT silently signs against devnet while
92
+ // its wallet is on mainnet, which reads to the user as a rejected signature
93
+ // and to the log as nothing. Warn loudly rather than guess quietly — the
94
+ // default stays devnet so behaviour is unchanged for anyone already relying
95
+ // on it.
96
+ var cluster = document.body.dataset.solanaCluster;
97
+ if (!cluster) {
98
+ console.warn('[StudioSolana] no data-solana-cluster on <body>; defaulting to devnet. ' +
99
+ 'Set it on the body tag to sign against the cluster you mean.');
100
+ cluster = 'devnet';
101
+ }
102
+ var callbackUrl = window.location.origin + '/auth/phantom/callback';
103
+
104
+ // Generate x25519 keypair for decrypting Phantom's response
105
+ var dappKeyPair = nacl.box.keyPair();
106
+
107
+ // Fetch nonce from server, then redirect to Phantom signIn
108
+ fetch('/auth/solana/nonce')
109
+ .then(function(r) { return r.json(); })
110
+ .then(function(data) {
111
+ // OPSEC-005: when linking a wallet to a logged-in user, embed
112
+ // `User-ID: <id>` so the server can refuse signatures captured
113
+ // against a different session's nonce. Login (no currentUserId)
114
+ // skips the binding line.
115
+ var statement = STATEMENT;
116
+ if (linkMode && currentUserId) {
117
+ statement = statement + '\nUser-ID: ' + currentUserId;
118
+ }
119
+
120
+ // Build SIWS input (CAIP-122 / Sign In With Solana format)
121
+ // Note: chainId omitted — optional per spec, avoids mismatch warning
122
+ // when app is on devnet but user's wallet is on mainnet
123
+ var signInInput = {
124
+ domain: window.location.host,
125
+ statement: statement,
126
+ uri: window.location.origin,
127
+ version: '1',
128
+ nonce: data.nonce,
129
+ issuedAt: new Date().toISOString()
130
+ };
131
+
132
+ // Save state to localStorage (persists across redirect)
133
+ localStorage.setItem('phantom_dl_secret', encodeBase58(dappKeyPair.secretKey));
134
+ localStorage.setItem('phantom_dl_pubkey', encodeBase58(dappKeyPair.publicKey));
135
+ localStorage.setItem('phantom_dl_nonce', data.nonce);
136
+ localStorage.setItem('phantom_dl_nonce_at', Date.now().toString());
137
+ localStorage.setItem('phantom_dl_step', 'signIn');
138
+ localStorage.setItem('phantom_dl_link_mode', linkMode ? 'true' : 'false');
139
+ localStorage.setItem('phantom_dl_cluster', cluster);
140
+ if (currentUserId) {
141
+ localStorage.setItem('phantom_dl_user_id', String(currentUserId));
142
+ } else {
143
+ localStorage.removeItem('phantom_dl_user_id');
144
+ }
145
+
146
+ // Base58-encode the SIWS input JSON (NOT encrypted — per Phantom signIn protocol)
147
+ var payloadB58 = encodeBase58(new TextEncoder().encode(JSON.stringify(signInInput)));
148
+
149
+ // Build Phantom signIn deep link
150
+ var params = new URLSearchParams({
151
+ dapp_encryption_public_key: encodeBase58(dappKeyPair.publicKey),
152
+ cluster: cluster,
153
+ app_url: window.location.origin,
154
+ redirect_link: callbackUrl,
155
+ payload: payloadB58
156
+ });
157
+
158
+ window.location.href = 'https://phantom.app/ul/v1/signIn?' + params.toString();
159
+ })
160
+ .catch(function(err) {
161
+ alert('Failed to start Phantom connection: ' + err.message);
162
+ });
163
+ }
164
+
165
+ window.startPhantomDeepLink = startPhantomDeepLink;
166
+ })();
167
+ </script>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.63.0"
2
+ VERSION = "0.65.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -56,6 +56,46 @@ module Studio
56
56
  mattr_accessor :after_newsletter_change, default: ->(_user, subscribed:, first_join:) {}
57
57
  mattr_accessor :sso_logo, default: nil
58
58
  mattr_accessor :wallet_address_method, default: nil
59
+
60
+ # The statement a wallet signs, and the line the human reads inside Phantom.
61
+ # Deliberately DERIVED rather than configured: "Sign in to Turf Monster" was
62
+ # turf-monster's literal before the mobile deep link was promoted, and
63
+ # app_name reproduces it byte for byte, so nothing that was already signed
64
+ # changed. Safe to vary because the server does NOT verify this text — see
65
+ # Solana::SessionAuth#verify_solana_signature!, which checks the nonce, the
66
+ # host, and the OPSEC-005 User-ID binding, and nothing else about the message.
67
+ # UNVERIFIED IS NOT UNCONSTRAINED. Solana::AuthVerifier reads the nonce with a
68
+ # FIRST-MATCH /Nonce: (\w+)/ and the statement is interpolated ABOVE the real
69
+ # nonce line, so a statement containing "Nonce: something" would be read as the
70
+ # nonce and fail every verify. Not an attack surface (this is app config, never
71
+ # user input) but a real constraint on what may be put here.
72
+ # Both the deep-link partial and the callback view read THIS, so they cannot
73
+ # drift; the callback rebuilds the signed message to post for verification, so
74
+ # a drift between them would fail every mobile sign-in.
75
+ mattr_accessor :wallet_sign_in_statement_builder,
76
+ default: -> { "Sign in to #{Studio.app_name}" }
77
+
78
+ def self.wallet_sign_in_statement
79
+ wallet_sign_in_statement_builder.call
80
+ end
81
+
82
+ # The Phantom callback's on-page debug sink. DEFAULTS TO OFF, and the default
83
+ # is the point: that sink printed the dapp x25519 SECRET KEY to the page and
84
+ # the console on every mobile sign-in. A capability that leaks a signing key
85
+ # must be opted INTO, never defaulted on and switched off per environment.
86
+ #
87
+ # Rails.env.production? is the wrong predicate here and that is why this is a
88
+ # lambda rather than an env check: a Heroku QA dyno runs RAILS_ENV=production,
89
+ # so an env test would take QA's debugging away. turf-monster restores exactly
90
+ # what it had with:
91
+ #
92
+ # config.wallet_debug_sink = -> { !AppFlags.live_production? }
93
+ #
94
+ # The callback treats an absent sink as "debug off" and stays null-safe, so
95
+ # turning this off can never break sign-in.
96
+ mattr_accessor :wallet_debug_sink, default: -> { false }
97
+
98
+ def self.wallet_debug_sink? = wallet_debug_sink.call
59
99
  mattr_accessor :theme_logos, default: []
60
100
  mattr_accessor :sticky_table_headers, default: false
61
101
 
@@ -782,13 +822,18 @@ module Studio
782
822
  constraints: { token: %r{[^/]+} }
783
823
  end
784
824
 
785
- # Solana / Phantom wallet sign-in (nonce challenge + signature verify).
786
- # The browser posts to these literal paths from the shared Connect-Wallet
787
- # flow; app-specific surfaces (mobile deep-link callback, account-linking,
788
- # OAuth popup) stay in the consuming app's routes.
825
+ # Solana / Phantom wallet sign-in (nonce challenge + signature verify),
826
+ # plus the MOBILE deep-link callback Phantom redirects back to. The
827
+ # callback used to be listed here as app-specific and is not any more —
828
+ # it was promoted with the deep link itself, because the hub had no
829
+ # mobile wallet path at all and copying 400 lines of SIWS protocol per
830
+ # app is how the picker came to exist three times. Account-linking and
831
+ # the OAuth popup DO stay app-side.
789
832
  if Studio.draw_auth_routes && Studio.auth_method?(:wallet)
790
833
  get "auth/solana/nonce", to: "solana_sessions#nonce", as: :solana_nonce
791
834
  post "auth/solana/verify", to: "solana_sessions#verify", as: :solana_verify
835
+ get "auth/phantom/callback", to: "solana_sessions#phantom_callback",
836
+ as: :phantom_callback
792
837
  end
793
838
 
794
839
  # The shared profile page. ON by default — unlike /admin/emails and the
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.63.0
4
+ version: 0.65.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-08-27 00:00:00.000000000 Z
11
+ date: 2026-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -401,6 +401,7 @@ files:
401
401
  - app/views/schema/index.html.erb
402
402
  - app/views/sessions/_sso_continue.html.erb
403
403
  - app/views/sessions/new.html.erb
404
+ - app/views/solana_sessions/phantom_callback.html.erb
404
405
  - app/views/studio/_at_time_script.html.erb
405
406
  - app/views/studio/_birthday_assets.html.erb
406
407
  - app/views/studio/_board_assets.html.erb
@@ -498,6 +499,8 @@ files:
498
499
  - app/views/studio/profiles/_save_controls.html.erb
499
500
  - app/views/studio/profiles/edit.html.erb
500
501
  - app/views/studio/profiles/show.html.erb
502
+ - app/views/studio/solana/_deeplink_assets.html.erb
503
+ - app/views/studio/solana/_phantom_deeplink.html.erb
501
504
  - app/views/style/_modal_specimen.html.erb
502
505
  - app/views/style/_modals.html.erb
503
506
  - app/views/style/_specimen.html.erb