studio-engine 0.63.0 → 0.64.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: 30f8faae31ecb02586f53952db4bc53e74020245ec5f8c5e65bd642fca083d01
4
+ data.tar.gz: 975577b6f1160fea036860d099182484bc7a71faffdd825fbe979f873de24cb7
5
5
  SHA512:
6
- metadata.gz: 26fdea81fbf8edcb85f0e6121c9bfa8b15dc34cf2b9166ebdd3cf158e2c09636d6ea5401baf203f293f6c2b2a866a2ddecdeacdd326c85bb3df6fa058af16800
7
- data.tar.gz: 2493f082d861b7a8dadf60963df93c09aac10da94b6f0a7064644600b7da5bbd0079d0b58d4d1d1dbda717881f63af9fc53a5fdb9eab0c0ad644c1e7eaf5fb0b
6
+ metadata.gz: d60165b7dd31a39d72a9856f623d17257c31625f626012e57830df0c54410b65a4b90199e54985dad86f027a1cedf7cba87b5d9d27538c4764c970c6a6130769
7
+ data.tar.gz: d1f2cb9b37b5b92bc84ee8eb7cbc05cafd4be8d39aeda239b2a28d49e2b75e4ab8cde4051ee987efb963720aa806525c7c9a54a6057f78008fd96e70b40d34bf
@@ -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],
@@ -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.64.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.64.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