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 +4 -4
- data/app/controllers/solana_sessions_controller.rb +9 -0
- data/app/views/solana_sessions/phantom_callback.html.erb +343 -0
- data/app/views/studio/modals/_wallet_connect.html.erb +19 -4
- data/app/views/studio/modals/blocks/_close_x.html.erb +18 -0
- data/app/views/studio/modals/blocks/_rail_row.html.erb +16 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +9 -2
- data/app/views/studio/solana/_deeplink_assets.html.erb +37 -0
- data/app/views/studio/solana/_phantom_deeplink.html.erb +167 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +49 -4
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30f8faae31ecb02586f53952db4bc53e74020245ec5f8c5e65bd642fca083d01
|
|
4
|
+
data.tar.gz: 975577b6f1160fea036860d099182484bc7a71faffdd825fbe979f873de24cb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
148
|
-
//
|
|
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>
|
data/lib/studio/version.rb
CHANGED
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
|
-
#
|
|
787
|
-
#
|
|
788
|
-
#
|
|
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.
|
|
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-
|
|
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
|