studio-engine 0.62.6 → 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/CHANGELOG.md +69 -0
- data/README.md +61 -0
- data/app/assets/stylesheets/studio/sticky_table_header.css +4 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +24 -1
- data/app/assets/tailwind/studio_engine/engine.css +208 -4
- data/app/controllers/solana_sessions_controller.rb +9 -0
- data/app/views/layouts/_navbar.html.erb +6 -74
- data/app/views/layouts/studio/_flash.html.erb +8 -5
- data/app/views/layouts/studio/_head.html.erb +99 -8
- data/app/views/solana_sessions/phantom_callback.html.erb +343 -0
- data/app/views/studio/admin_models/_teams_table.html.erb +1 -1
- data/app/views/studio/banners/_app_banner.html.erb +4 -1
- data/app/views/studio/banners/_button.html.erb +2 -2
- data/app/views/studio/banners/_stack.html.erb +12 -4
- data/app/views/studio/modals/_host.html.erb +105 -5
- data/app/views/studio/modals/_scoped_host.html.erb +25 -2
- data/app/views/studio/modals/_wallet_connect.html.erb +330 -0
- 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 +21 -6
- data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +6 -0
- data/app/views/studio/profiles/_identity_styles.html.erb +1 -1
- data/app/views/studio/solana/_deeplink_assets.html.erb +37 -0
- data/app/views/studio/solana/_phantom_deeplink.html.erb +167 -0
- data/app/views/style/_modals.html.erb +10 -1
- data/app/views/style/modals/_wallet_connect.html.erb +77 -171
- data/app/views/style/modals/_wallet_connect_slot.html.erb +18 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +49 -4
- metadata +7 -2
|
@@ -202,15 +202,33 @@
|
|
|
202
202
|
var ro = null;
|
|
203
203
|
var current = null;
|
|
204
204
|
var queued = false;
|
|
205
|
+
var lastH = null;
|
|
206
|
+
var lastBottom = null;
|
|
205
207
|
function publish(header) {
|
|
206
208
|
var style = document.documentElement.style;
|
|
207
|
-
|
|
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;
|
|
208
218
|
// Scroll-dependent where --nav-h is not: chrome above the header scrolls
|
|
209
219
|
// away while an overlay is open (nothing here locks body scroll), so this
|
|
210
220
|
// is republished on scroll. Clamped at 0 — once the sticky header is
|
|
211
221
|
// pinned at top:0 the two values converge, which is why a consumer whose
|
|
212
222
|
// header already starts at the viewport top sees no change at all.
|
|
213
|
-
|
|
223
|
+
var bottom = Math.max(0, header.getBoundingClientRect().bottom);
|
|
224
|
+
if (h !== lastH) {
|
|
225
|
+
lastH = h;
|
|
226
|
+
style.setProperty('--nav-h', h + 'px');
|
|
227
|
+
}
|
|
228
|
+
if (bottom !== lastBottom) {
|
|
229
|
+
lastBottom = bottom;
|
|
230
|
+
style.setProperty('--nav-bottom', bottom + 'px');
|
|
231
|
+
}
|
|
214
232
|
}
|
|
215
233
|
function schedule() {
|
|
216
234
|
if (queued || !current) return;
|
|
@@ -231,7 +249,18 @@
|
|
|
231
249
|
}
|
|
232
250
|
current = header;
|
|
233
251
|
if (ro) ro.disconnect();
|
|
234
|
-
|
|
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);
|
|
235
264
|
ro.observe(header);
|
|
236
265
|
publish(header);
|
|
237
266
|
}
|
|
@@ -320,6 +349,7 @@
|
|
|
320
349
|
scrolled: false,
|
|
321
350
|
p: 0,
|
|
322
351
|
_ramp: 144,
|
|
352
|
+
_maxPx: 5,
|
|
323
353
|
_reduce: null,
|
|
324
354
|
_onScroll: null,
|
|
325
355
|
_onResize: null,
|
|
@@ -332,8 +362,11 @@
|
|
|
332
362
|
this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
333
363
|
|
|
334
364
|
function readRamp() {
|
|
335
|
-
var
|
|
365
|
+
var style = getComputedStyle(el);
|
|
366
|
+
var raw = parseFloat(style.getPropertyValue('--nav-ramp'));
|
|
336
367
|
self._ramp = raw > 0 ? raw : 144;
|
|
368
|
+
var step = parseFloat(style.getPropertyValue('--nav-max-step'));
|
|
369
|
+
self._maxPx = step > 0 ? step : 5;
|
|
337
370
|
}
|
|
338
371
|
|
|
339
372
|
function apply() {
|
|
@@ -349,14 +382,61 @@
|
|
|
349
382
|
- window.innerHeight
|
|
350
383
|
+ ramp * self.p;
|
|
351
384
|
|
|
352
|
-
|
|
385
|
+
// WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
|
|
386
|
+
var target;
|
|
387
|
+
var snap = false;
|
|
353
388
|
if (roomExpanded < ramp + 24) {
|
|
354
|
-
|
|
389
|
+
target = 0;
|
|
390
|
+
snap = true;
|
|
355
391
|
} else if (self._reduce.matches) {
|
|
356
|
-
|
|
392
|
+
target = (self.p > 0 ? y > 5 : y > 60) ? 1 : 0;
|
|
393
|
+
snap = true;
|
|
357
394
|
} else {
|
|
358
395
|
var t = Math.min(1, y / ramp);
|
|
359
|
-
|
|
396
|
+
target = t * t * (3 - 2 * t);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// THE RATE LIMIT — how far the collapse may travel in ONE frame.
|
|
400
|
+
//
|
|
401
|
+
// Position-linked progress fixed motion that OUTLIVED the gesture. It
|
|
402
|
+
// also guaranteed the opposite defect: if scrollY moves 90px between
|
|
403
|
+
// two frames, so does the header's whole range. Measured in
|
|
404
|
+
// turf-monster at 390x844, worst single-frame header height change by
|
|
405
|
+
// scroll profile —
|
|
406
|
+
//
|
|
407
|
+
// 8px/frame (slow, deliberate) 3.9px
|
|
408
|
+
// 24px/frame (normal swipe) 14.1px
|
|
409
|
+
// momentum flick 39.0px <- the ENTIRE collapse
|
|
410
|
+
// hard flick 39.0px
|
|
411
|
+
//
|
|
412
|
+
// 39px is the full 178 -> 139. It was found on a phone: slow
|
|
413
|
+
// scrolling looked right, a flick jumped. The measurements behind the
|
|
414
|
+
// original primitive all walked a constant 8px/frame, which is
|
|
415
|
+
// exactly the case that already worked.
|
|
416
|
+
//
|
|
417
|
+
// So the TARGET stays position-linked and the STEP is clamped. What
|
|
418
|
+
// makes this safe is that a slow scroll never REACHES the clamp —
|
|
419
|
+
// under --nav-max-step of header travel per frame the branch below
|
|
420
|
+
// returns `target` untouched, so the slow feel is not approximated,
|
|
421
|
+
// it is the same arithmetic.
|
|
422
|
+
//
|
|
423
|
+
// maxStep is derived, not tuned per band: engine.css sizes --nav-ramp
|
|
424
|
+
// at 3x the band's collapse total, so a cap of MAX px/frame is
|
|
425
|
+
// 3*MAX/ramp in --nav-p units. Mobile (ramp 120) gives 4.9px/frame,
|
|
426
|
+
// desktop (ramp 144) 5.0px. Retune --nav-ramp without keeping that 3x
|
|
427
|
+
// relation and this cap silently drifts with it.
|
|
428
|
+
var p;
|
|
429
|
+
if (snap) {
|
|
430
|
+
// A guard refusal and a reduced-motion state are DECISIONS, not
|
|
431
|
+
// motion; ramping them would animate the very thing each exists to
|
|
432
|
+
// avoid.
|
|
433
|
+
p = target;
|
|
434
|
+
} else {
|
|
435
|
+
var maxStep = (3 * self._maxPx) / ramp;
|
|
436
|
+
var delta = target - self.p;
|
|
437
|
+
p = Math.abs(delta) <= maxStep
|
|
438
|
+
? target
|
|
439
|
+
: self.p + (delta > 0 ? maxStep : -maxStep);
|
|
360
440
|
}
|
|
361
441
|
|
|
362
442
|
if (p !== self.p) {
|
|
@@ -364,6 +444,17 @@
|
|
|
364
444
|
el.style.setProperty('--nav-p', p.toFixed(4));
|
|
365
445
|
}
|
|
366
446
|
|
|
447
|
+
// KEEP FRAMES COMING WHILE CATCHING UP. Nothing else will schedule
|
|
448
|
+
// one: the finger is off, so no more scroll events arrive, and
|
|
449
|
+
// without this the collapse freezes wherever the clamp left it. It
|
|
450
|
+
// converges LINEARLY and lands exactly — about 4 frames (~67ms) from
|
|
451
|
+
// a hard flick — rather than crawling asymptotically the way the
|
|
452
|
+
// 300ms ease this replaced did.
|
|
453
|
+
if (!snap && p !== target) {
|
|
454
|
+
queued = true;
|
|
455
|
+
requestAnimationFrame(apply);
|
|
456
|
+
}
|
|
457
|
+
|
|
367
458
|
// The shadow is the one thing still on a clock, and it may stay
|
|
368
459
|
// there: box-shadow paints, it never reflows, so it cannot move
|
|
369
460
|
// content. Hysteresis keeps it from strobing at the boundary.
|
|
@@ -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>
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
<template x-teleport="body">
|
|
54
54
|
<div x-show="open"
|
|
55
55
|
x-cloak
|
|
56
|
-
class="fixed inset-0 z-[
|
|
56
|
+
class="fixed inset-0 z-[var(--z-lightbox)] flex items-center justify-center bg-black/70 p-4"
|
|
57
57
|
role="dialog"
|
|
58
58
|
aria-modal="true"
|
|
59
59
|
aria-label="<%= team.name %> JSON"
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
style = tones.fetch(tone)
|
|
42
42
|
%>
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
<%# studio-app-banner is the LIFT HOOK, not styling: engine.css raises the bars
|
|
45
|
+
above the modal backdrop while body.modal-open is set. Keep the class even if
|
|
46
|
+
the markup changes. %>
|
|
47
|
+
<div class="studio-app-banner w-full" data-studio-app-banner style="background:<%= style.fetch(:background) %>; color:<%= style.fetch(:color) %>; box-shadow:<%= style.fetch(:shadow) %>;">
|
|
45
48
|
<div class="max-w-7xl mx-auto flex items-center justify-between gap-3" style="<%= density_styles.fetch(density) %>">
|
|
46
49
|
<span class="min-w-0 truncate"><%= message %></span>
|
|
47
50
|
<% if actions.present? %>
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
tooltip_content = capture do
|
|
67
67
|
%>
|
|
68
68
|
<span data-studio-banner-tooltip
|
|
69
|
-
class="pointer-events-none absolute right-0 top-full z-[
|
|
70
|
-
style="position:absolute; right:0; top:100%; z-index:
|
|
69
|
+
class="pointer-events-none absolute right-0 top-full z-[var(--z-tooltip)] mt-2 w-max max-w-[260px] whitespace-normal rounded bg-white px-3 py-2 text-xs font-semibold text-slate-900 opacity-0 shadow-lg ring-1 ring-black/10 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
|
|
70
|
+
style="position:absolute; right:0; top:100%; z-index:var(--z-tooltip, 600); margin-top:8px; max-width:260px; width:max-content; white-space:normal; border-radius:4px; background:#ffffff; color:#111827; padding:8px 12px; font-size:12px; font-weight:700; line-height:1.2; opacity:0; pointer-events:none; box-shadow:0 8px 18px rgba(0,0,0,0.18); transition:opacity 150ms ease;">
|
|
71
71
|
<%= tooltip %>
|
|
72
72
|
</span>
|
|
73
73
|
<%
|
|
@@ -78,9 +78,17 @@
|
|
|
78
78
|
any_bar = show_environment || show_impersonation
|
|
79
79
|
%>
|
|
80
80
|
<% if any_bar %>
|
|
81
|
-
<%# Normal flow, deliberately. No sticky, no
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
<%# Normal flow, deliberately. No sticky, no measured offset: the navbar is the
|
|
82
|
+
only pinned chrome, so the bars simply take their own height above it. Not
|
|
83
|
+
sticky also keeps the paint order right — the pinned navbar is positioned,
|
|
84
|
+
so it draws over these bars as they scroll up behind it.
|
|
85
|
+
|
|
86
|
+
ONE exception, and it is scoped to the state that makes it free:
|
|
87
|
+
`body.modal-open .studio-bar-stack` in engine.css lifts the stack to
|
|
88
|
+
--z-banner, above the modal backdrop, so DEV MODE and the email chip stay
|
|
89
|
+
lit and clickable while a modal is up. It cannot cost the paint order
|
|
90
|
+
above, because the same class locks body scroll — the bars never move
|
|
91
|
+
relative to the navbar while the lift is on. Flow is still the resting
|
|
92
|
+
state; nothing here is positioned until a modal opens. %>
|
|
85
93
|
<div class="studio-bar-stack w-full" data-studio-bar-stack><%= bars %></div>
|
|
86
94
|
<% end %>
|