studio-engine 0.63.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/app/controllers/solana_sessions_controller.rb +9 -0
- data/app/views/layouts/_navbar.html.erb +5 -1
- data/app/views/layouts/studio/_head.html.erb +147 -53
- 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: 8914b74430f105820bbc5a41f44d6c573ff88492bcd882534f769fa2c2a11608
|
|
4
|
+
data.tar.gz: f3d1ffbec8dcb09ef354d21ab3786d2d5b9b0590a665ac95a949e7282668a66b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d52952922f19f864f97fd09297d8711c246ba54415bd40d60682569cbdfe0d51ccf7fb7673e7d921a361ab3657b35871f4999aad0baa80374dd3098cd3c267b2
|
|
7
|
+
data.tar.gz: 82afd75d6a4e57bfbe3708b59c1c1e7eec188cf2bcfd5921e3eeb50e006090d1f0839e5fc280be8ee60c8d6dc999bb07bb148e7e2220b7d061559048e14b6225
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`data-pin` — the pinned stack publishes itself.** `--nav-h` / `--nav-bottom`
|
|
10
|
+
answer for one element. Everything else that pins has been re-deriving the
|
|
11
|
+
same geometry by hand: on `mcritchie-studio`'s `/deployments` the app strip
|
|
12
|
+
positions itself with `:style="{ top: offset + 'px' }"`, and the lane headers
|
|
13
|
+
compute *"site header height + strip height"* in Alpine behind **their own**
|
|
14
|
+
`ResizeObserver` on `.vt-pinned-header` — while `--nav-bottom`, which already
|
|
15
|
+
answers the first half, goes unused there. Same shape as the four copies of
|
|
16
|
+
the navbar collapse, one layer up.
|
|
17
|
+
|
|
18
|
+
Any element carrying `data-pin="<name>"` now publishes `--pin-<name>-h` and
|
|
19
|
+
`--pin-<name>-bottom`, from **one shared `ResizeObserver`**, through the same
|
|
20
|
+
rAF coalescer and the same no-op-write skip the header already uses — and one
|
|
21
|
+
measurement pass for the whole frame, every read taken before any write.
|
|
22
|
+
|
|
23
|
+
That last part was wrong in the first cut of this change and review caught it:
|
|
24
|
+
the frame published the header (writing two inherited properties on
|
|
25
|
+
`documentElement`) and *then* read the pins, so the reads-before-writes order
|
|
26
|
+
held inside each function and broke across them. Measured at 6× CPU throttle
|
|
27
|
+
over 175 frames, pin loop on vs off: frames past 20ms **62 vs 42**, p90 **34.2
|
|
28
|
+
vs 26.0ms**; at 10×, **84/180 vs 4/180**. The header is now a pin like any
|
|
29
|
+
other — measured once, writing `--nav-h` / `--nav-bottom` from that same
|
|
30
|
+
reading rather than a second one. A consumer composes layers in pure
|
|
31
|
+
CSS:
|
|
32
|
+
|
|
33
|
+
```css
|
|
34
|
+
top: max(var(--pin-nav-bottom, 0px), var(--pin-apps-bottom, 0px));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**`max()` rather than a declared stacking order.** A hidden layer measures 0
|
|
38
|
+
and drops out, so nothing has to say which layer sits above which, and
|
|
39
|
+
reordering a partial cannot break the arithmetic.
|
|
40
|
+
|
|
41
|
+
**A host-owned header still publishes the legacy pair.** The registry is built
|
|
42
|
+
from `[data-pin]`, so a header that does not carry the attribute would never be
|
|
43
|
+
measured and `--nav-h` / `--nav-bottom` would never publish — and **both live
|
|
44
|
+
consumers own their header** (`turf-monster` `layouts/_navbar`,
|
|
45
|
+
`mcritchie-studio` `layouts/application`), neither carrying `data-pin`. Review
|
|
46
|
+
measured the gear drawer **82px** out of place and the contest board **114px**,
|
|
47
|
+
with neither tracking the collapse; both apps pin a two-segment `~>` under 1.0,
|
|
48
|
+
so it would have landed on their next bundle update with no floor bump. The
|
|
49
|
+
header is adopted as the legacy pin whether or not it asks to be.
|
|
50
|
+
|
|
51
|
+
**A departed layer has its properties removed.** `display:none` and `x-show`
|
|
52
|
+
keep the node, so it measures 0 and drops out of a consumer's `max()` on its
|
|
53
|
+
own; a REMOVED node cannot be measured by anything, and its last value would
|
|
54
|
+
stand forever — review measured a removed 300px strip holding the property at
|
|
55
|
+
300px against a real stack bottom of 160px. The registry remembers what it
|
|
56
|
+
published and clears whatever is no longer in the document.
|
|
57
|
+
|
|
58
|
+
The registry is **rebuilt from the document**, on attach and after
|
|
59
|
+
`turbo:before-stream-render` — a Turbo Stream replaces nodes without a
|
|
60
|
+
`turbo:load`, which is precisely the stale-node bug the board documents
|
|
61
|
+
against its own version.
|
|
62
|
+
|
|
63
|
+
`layouts/_navbar` carries `data-pin="nav"`, so every consuming app gets
|
|
64
|
+
`--pin-nav-bottom` for free. **`--nav-h` and `--nav-bottom` keep publishing
|
|
65
|
+
unchanged** — `studio/_sidebar_panel` and turf-monster's `scroll-margin-top`
|
|
66
|
+
read them, and `nav_offset_contract_test` still pins both to their exact
|
|
67
|
+
sources. This is additive.
|
|
68
|
+
|
|
69
|
+
|
|
7
70
|
### Changed
|
|
8
71
|
|
|
9
72
|
- **The navbar collapse primitive is broadcastable, rate-limited, and cheap per
|
|
@@ -22,6 +22,15 @@ class SolanaSessionsController < ApplicationController
|
|
|
22
22
|
render json: { nonce: session[:solana_nonce] }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Where Phantom redirects back to after a mobile signIn. The page itself does
|
|
26
|
+
# the work — decrypt Phantom's reply with the keypair the deep link stashed in
|
|
27
|
+
# localStorage, rebuild the signed message, and POST it to #verify — so this
|
|
28
|
+
# action only renders it. It must stay UNAUTHENTICATED: the whole point is
|
|
29
|
+
# that nobody is signed in yet.
|
|
30
|
+
def phantom_callback
|
|
31
|
+
render :phantom_callback
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def verify
|
|
26
35
|
pubkey_b58 = verify_solana_signature!(
|
|
27
36
|
message: params[:message],
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
so it cannot move content under the reader.
|
|
47
47
|
Preview keeps nav-shell (the calc()s have to resolve) but no x-data — the
|
|
48
48
|
/navbar review page drives --nav-p from its own Scrolled toggle. %>
|
|
49
|
-
|
|
49
|
+
<%# data-pin names this header as a layer of the pinned stack, so
|
|
50
|
+
layouts/studio/_head publishes --pin-nav-h and --pin-nav-bottom for it and
|
|
51
|
+
anything below can position off it in pure CSS. The legacy --nav-h /
|
|
52
|
+
--nav-bottom keep publishing unchanged; this is additive. %>
|
|
53
|
+
<header data-pin="nav" <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
|
|
50
54
|
<%# top-0, a STATIC value, and never a custom property. Any bars render as
|
|
51
55
|
this header's sibling in normal flow (studio/banners/_stack), so they
|
|
52
56
|
already occupy their own height above it and there is nothing to
|
|
@@ -200,74 +200,168 @@
|
|
|
200
200
|
(function () {
|
|
201
201
|
if (!window.ResizeObserver) return;
|
|
202
202
|
var ro = null;
|
|
203
|
-
var current = null;
|
|
204
203
|
var queued = false;
|
|
205
|
-
var
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
var pins = [];
|
|
205
|
+
// The names this publisher has written, so a DEPARTED layer can be cleared.
|
|
206
|
+
var published = {};
|
|
207
|
+
|
|
208
|
+
// ONE MEASUREMENT PER ELEMENT PER FRAME, and every read before every write.
|
|
209
|
+
//
|
|
210
|
+
// publish() and publishPin() each kept reads-before-writes INTERNALLY, and
|
|
211
|
+
// that was not enough: the frame ran publish(current) — which WRITES two
|
|
212
|
+
// inherited custom properties on documentElement — and then the pin loop,
|
|
213
|
+
// whose first act is a getBoundingClientRect(). A read after a write to the
|
|
214
|
+
// root forces a fresh layout, so the thrash this file removed came straight
|
|
215
|
+
// back one function boundary over. Measured by review at 6x CPU throttle
|
|
216
|
+
// over 175 frames, pin loop on vs off: frames past 20ms 62 vs 42, median
|
|
217
|
+
// 17.7 vs 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
|
|
218
|
+
//
|
|
219
|
+
// The header was also measured TWICE — once as `current`, once as pin
|
|
220
|
+
// `nav` — for values proven identical across 40 frames. It is a pin like
|
|
221
|
+
// any other now; the only thing special about it is that it ALSO writes the
|
|
222
|
+
// legacy --nav-h / --nav-bottom that studio/_sidebar_panel and
|
|
223
|
+
// turf-monster's scroll-margin-top already read.
|
|
224
|
+
function readPin(pin) {
|
|
225
|
+
return {
|
|
226
|
+
pin: pin,
|
|
227
|
+
h: pin.el.offsetHeight,
|
|
228
|
+
bottom: Math.max(0, pin.el.getBoundingClientRect().bottom)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function writeReading(r) {
|
|
208
232
|
var style = document.documentElement.style;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// BOTH READS BEFORE EITHER WRITE. Writing a custom property on
|
|
215
|
-
// documentElement invalidates style, so a read taken after one write
|
|
216
|
-
// forces a fresh layout — the thrash this whole change exists to remove.
|
|
217
|
-
var h = header.offsetHeight;
|
|
218
|
-
// Scroll-dependent where --nav-h is not: chrome above the header scrolls
|
|
219
|
-
// away while an overlay is open (nothing here locks body scroll), so this
|
|
220
|
-
// is republished on scroll. Clamped at 0 — once the sticky header is
|
|
221
|
-
// pinned at top:0 the two values converge, which is why a consumer whose
|
|
222
|
-
// header already starts at the viewport top sees no change at all.
|
|
223
|
-
var bottom = Math.max(0, header.getBoundingClientRect().bottom);
|
|
224
|
-
if (h !== lastH) {
|
|
225
|
-
lastH = h;
|
|
226
|
-
style.setProperty('--nav-h', h + 'px');
|
|
233
|
+
var pin = r.pin;
|
|
234
|
+
if (r.h !== pin.lastH) {
|
|
235
|
+
pin.lastH = r.h;
|
|
236
|
+
style.setProperty('--pin-' + pin.name + '-h', r.h + 'px');
|
|
237
|
+
if (pin.legacy) style.setProperty('--nav-h', r.h + 'px');
|
|
227
238
|
}
|
|
228
|
-
if (bottom !== lastBottom) {
|
|
229
|
-
lastBottom = bottom;
|
|
230
|
-
style.setProperty('--
|
|
239
|
+
if (r.bottom !== pin.lastBottom) {
|
|
240
|
+
pin.lastBottom = r.bottom;
|
|
241
|
+
style.setProperty('--pin-' + pin.name + '-bottom', r.bottom + 'px');
|
|
242
|
+
if (pin.legacy) style.setProperty('--nav-bottom', r.bottom + 'px');
|
|
231
243
|
}
|
|
232
244
|
}
|
|
245
|
+
function publishAll() {
|
|
246
|
+
var readings = [];
|
|
247
|
+
var i;
|
|
248
|
+
for (i = 0; i < pins.length; i++) readings.push(readPin(pins[i]));
|
|
249
|
+
for (i = 0; i < readings.length; i++) writeReading(readings[i]);
|
|
250
|
+
}
|
|
233
251
|
function schedule() {
|
|
234
|
-
if (queued || !
|
|
252
|
+
if (queued || !pins.length) return;
|
|
235
253
|
queued = true;
|
|
236
|
-
window.requestAnimationFrame(function () { queued = false;
|
|
254
|
+
window.requestAnimationFrame(function () { queued = false; publishAll(); });
|
|
237
255
|
}
|
|
238
|
-
|
|
256
|
+
// ONE construction site. Two of them let attach() replace the observer that
|
|
257
|
+
// registerPins() had already put the pins on, silently dropping every live
|
|
258
|
+
// observation — caught by test_pins_share_one_observer_and_one_frame, which
|
|
259
|
+
// counts them for exactly that reason.
|
|
260
|
+
function ensureObserver() {
|
|
261
|
+
if (!ro) ro = new ResizeObserver(schedule);
|
|
262
|
+
return ro;
|
|
263
|
+
}
|
|
264
|
+
function registerPins() {
|
|
265
|
+
ensureObserver();
|
|
266
|
+
pins = [];
|
|
267
|
+
var seen = {};
|
|
239
268
|
var header = document.querySelector('header');
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// would
|
|
245
|
-
//
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
269
|
+
var headerPinned = false;
|
|
270
|
+
var nodes = document.querySelectorAll('[data-pin]');
|
|
271
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
272
|
+
var name = nodes[i].getAttribute('data-pin');
|
|
273
|
+
// An unnamed pin would write `--pin--h`, a valid property name and a
|
|
274
|
+
// silent nonsense one. Skip it rather than publish it.
|
|
275
|
+
if (!name) continue;
|
|
276
|
+
seen[name] = true;
|
|
277
|
+
if (nodes[i] === header) headerPinned = true;
|
|
278
|
+
pins.push({
|
|
279
|
+
el: nodes[i],
|
|
280
|
+
name: name,
|
|
281
|
+
// The header keeps publishing --nav-h / --nav-bottom, from the SAME
|
|
282
|
+
// measurement, so it is never measured twice.
|
|
283
|
+
legacy: nodes[i] === header,
|
|
284
|
+
lastH: null,
|
|
285
|
+
lastBottom: null
|
|
286
|
+
});
|
|
287
|
+
ro.observe(nodes[i]);
|
|
288
|
+
}
|
|
289
|
+
// CLEAR WHAT LEFT. A layer goes away three ways: display:none and x-show
|
|
290
|
+
// both keep the node, so it measures 0 and drops out of a consumer's
|
|
291
|
+
// max() on its own. REMOVAL does not — nothing is left to measure, the
|
|
292
|
+
// last published value stands forever, and a consumer sits at the height
|
|
293
|
+
// of a strip that is gone. Review measured a removed 300px strip holding
|
|
294
|
+
// --pin-apps-bottom at 300px against a real stack bottom of 160px: a
|
|
295
|
+
// permanent 140px error, and a direct contradiction of this primitive's
|
|
296
|
+
// headline promise.
|
|
297
|
+
for (var name2 in published) {
|
|
298
|
+
if (published.hasOwnProperty(name2) && !seen[name2]) {
|
|
299
|
+
document.documentElement.style.removeProperty('--pin-' + name2 + '-h');
|
|
300
|
+
document.documentElement.style.removeProperty('--pin-' + name2 + '-bottom');
|
|
301
|
+
}
|
|
249
302
|
}
|
|
250
|
-
|
|
303
|
+
// THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
|
|
304
|
+
//
|
|
305
|
+
// The registry is built from [data-pin], so a header that does not carry
|
|
306
|
+
// the attribute is not in it — and --nav-h / --nav-bottom, which are
|
|
307
|
+
// written from the legacy pin's reading, would never publish at all.
|
|
308
|
+
// THIS ENGINE'S navbar carries data-pin, so every test here and the whole
|
|
309
|
+
// e2e lab publish fine; the broken path exists only in a consumer that
|
|
310
|
+
// owns its header, which is BOTH live consumers:
|
|
311
|
+
//
|
|
312
|
+
// turf-monster layouts/_navbar.html.erb (data-navbar-root)
|
|
313
|
+
// mcritchie-studio layouts/application.html.erb (inline)
|
|
314
|
+
//
|
|
315
|
+
// Review measured it against turf's real header and its real sidebar
|
|
316
|
+
// declaration: --nav-h and --nav-bottom unset, the gear drawer 82px out
|
|
317
|
+
// of place, the contest board 114px, and neither tracking the collapse.
|
|
318
|
+
// Both apps pin a two-segment ~> under 1.0, so it would have landed on
|
|
319
|
+
// their next bundle update with no floor bump to warn anyone.
|
|
320
|
+
//
|
|
321
|
+
// So the header is adopted as a pin even when it does not ask to be. It
|
|
322
|
+
// still publishes --pin-nav-* under that name, which is what an app gets
|
|
323
|
+
// for free the moment it wants the general contract.
|
|
324
|
+
if (header && !headerPinned) {
|
|
325
|
+
pins.push({
|
|
326
|
+
el: header,
|
|
327
|
+
name: 'nav',
|
|
328
|
+
legacy: true,
|
|
329
|
+
lastH: null,
|
|
330
|
+
lastBottom: null
|
|
331
|
+
});
|
|
332
|
+
seen.nav = true;
|
|
333
|
+
ensureObserver().observe(header);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
published = seen;
|
|
337
|
+
publishAll();
|
|
338
|
+
}
|
|
339
|
+
// ATTACH IS NOW JUST A RE-SCAN. The header used to be tracked separately as
|
|
340
|
+
// `current`, with its own observe and its own publish; it is a pin named
|
|
341
|
+
// "nav" like any other, so registerPins() covers it. What survives is the
|
|
342
|
+
// release promise: a page with no pinned chrome at all disconnects the
|
|
343
|
+
// observer rather than leaving it on detached nodes, and the properties keep
|
|
344
|
+
// their last value rather than being driven to 0 by an all-zero rect.
|
|
345
|
+
//
|
|
346
|
+
// A page that never has a <header> is served too — the observer is built by
|
|
347
|
+
// ensureObserver() from registerPins(), not from a has-header branch. It was
|
|
348
|
+
// only reachable from that branch before, so pins on a headerless layout were
|
|
349
|
+
// permanently dead.
|
|
350
|
+
function attach() {
|
|
251
351
|
if (ro) ro.disconnect();
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// during a 300ms transition. navCollapse made it resize on EVERY scroll
|
|
255
|
-
// frame, and each direct publish forced layout and then wrote two
|
|
256
|
-
// inherited custom properties on documentElement. Measured in
|
|
257
|
-
// turf-monster at 6x CPU throttle: frames over 20ms were 13/24 THROUGH
|
|
258
|
-
// the collapse ramp versus 0/24 past it, median 26ms versus 8ms.
|
|
259
|
-
// Ablating this observer alone took it to 0/24 and median 13ms — it cost
|
|
260
|
-
// about 2.6x the reflow it was reacting to. schedule() already coalesces
|
|
261
|
-
// to one publish per frame and is what the scroll and resize listeners
|
|
262
|
-
// use; the ResizeObserver was the one path that bypassed it.
|
|
263
|
-
ro = new ResizeObserver(schedule);
|
|
264
|
-
ro.observe(header);
|
|
265
|
-
publish(header);
|
|
352
|
+
registerPins();
|
|
353
|
+
if (!pins.length && ro) { ro.disconnect(); ro = null; }
|
|
266
354
|
}
|
|
267
355
|
document.addEventListener('DOMContentLoaded', attach);
|
|
268
356
|
document.addEventListener('turbo:load', attach);
|
|
357
|
+
// A Turbo Stream replaces nodes WITHOUT a turbo:load, which is how a
|
|
358
|
+
// captured pin goes stale. Re-register on the next frame, once the stream
|
|
359
|
+
// has actually patched the DOM.
|
|
360
|
+
document.addEventListener('turbo:before-stream-render', function () {
|
|
361
|
+
window.requestAnimationFrame(registerPins);
|
|
362
|
+
});
|
|
269
363
|
// Registered ONCE at script eval, not inside attach(), so a Turbo nav
|
|
270
|
-
// re-
|
|
364
|
+
// re-scans instead of stacking another listener per visit.
|
|
271
365
|
window.addEventListener('scroll', schedule, { passive: true });
|
|
272
366
|
window.addEventListener('resize', schedule, { passive: true });
|
|
273
367
|
})();
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
|
|
2
|
+
<div class="text-center">
|
|
3
|
+
<div id="phantom-spinner" class="inline-block w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
|
4
|
+
<p id="phantom-status" class="text-secondary text-sm">Processing Phantom response...</p>
|
|
5
|
+
<p id="phantom-error" class="text-red-400 text-sm mt-2 hidden"></p>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<%# Visual debug log — NEVER rendered on a real production deploy. This sink printed
|
|
9
|
+
the dapp x25519 secret key (phantom_dl_secret) to the page and the console on every
|
|
10
|
+
mobile Phantom sign-in. Studio.wallet_debug_sink DEFAULTS TO OFF —
|
|
11
|
+
a sink that prints a signing key is opted into, not defaulted on. An app that
|
|
12
|
+
wants QA debugging back sets it to a predicate of its own (turf-monster:
|
|
13
|
+
-> { !AppFlags.live_production? }); Rails.env.production? will NOT do, because
|
|
14
|
+
a Heroku QA dyno runs RAILS_ENV=production. The script below
|
|
15
|
+
treats an absent sink as "debug off" and must stay null-safe: if this div is gone and
|
|
16
|
+
dbg() still touched logEl, the callback would throw and break sign-in outright. %>
|
|
17
|
+
<% if Studio.wallet_debug_sink? %>
|
|
18
|
+
<div id="phantom-log" class="mt-6 w-full max-w-lg text-left bg-surface-alt rounded-lg border border-subtle p-3 overflow-y-auto" style="max-height:50vh">
|
|
19
|
+
<p class="text-xs font-mono text-muted mb-2">Debug Log</p>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<script>
|
|
25
|
+
(function() {
|
|
26
|
+
var logEl = document.getElementById('phantom-log');
|
|
27
|
+
// The sink is absent on a real production deploy (see the ERB guard above), and that
|
|
28
|
+
// absence IS the off switch — for the console too, not just the page.
|
|
29
|
+
var DEBUG_SINK = !!logEl;
|
|
30
|
+
|
|
31
|
+
function dbg(label, value) {
|
|
32
|
+
if (!DEBUG_SINK) { return; }
|
|
33
|
+
var line = document.createElement('p');
|
|
34
|
+
line.className = 'text-xs font-mono break-all mb-1';
|
|
35
|
+
if (label === 'ERROR' || label === 'CATCH') {
|
|
36
|
+
line.style.color = '#EF4444';
|
|
37
|
+
} else if (label === 'OK') {
|
|
38
|
+
line.style.color = '#4BAF50';
|
|
39
|
+
} else {
|
|
40
|
+
line.style.color = '#94a3b8';
|
|
41
|
+
}
|
|
42
|
+
var val = (value === undefined || value === null) ? '' : String(value);
|
|
43
|
+
line.textContent = label + (val ? ': ' + val : '');
|
|
44
|
+
logEl.appendChild(line);
|
|
45
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
46
|
+
console.log('[PhantomDL]', label, value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function truncate(s, n) {
|
|
50
|
+
return s && s.length > n ? s.substring(0, n) + '...' : s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
dbg('Page loaded', new Date().toISOString());
|
|
54
|
+
dbg('URL', window.location.href);
|
|
55
|
+
|
|
56
|
+
// Self-contained Base58
|
|
57
|
+
var B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
58
|
+
|
|
59
|
+
function b58encode(bytes) {
|
|
60
|
+
var digits = [0];
|
|
61
|
+
for (var i = 0; i < bytes.length; i++) {
|
|
62
|
+
var carry = bytes[i];
|
|
63
|
+
for (var j = 0; j < digits.length; j++) {
|
|
64
|
+
carry += digits[j] << 8;
|
|
65
|
+
digits[j] = carry % 58;
|
|
66
|
+
carry = (carry / 58) | 0;
|
|
67
|
+
}
|
|
68
|
+
while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
|
|
69
|
+
}
|
|
70
|
+
var str = '';
|
|
71
|
+
for (var i = 0; i < bytes.length && bytes[i] === 0; i++) str += '1';
|
|
72
|
+
for (var i = digits.length - 1; i >= 0; i--) str += B58[digits[i]];
|
|
73
|
+
return str;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function b58decode(str) {
|
|
77
|
+
var bytes = [];
|
|
78
|
+
for (var i = 0; i < str.length; i++) {
|
|
79
|
+
var idx = B58.indexOf(str[i]);
|
|
80
|
+
if (idx < 0) throw new Error('Invalid base58 character');
|
|
81
|
+
var carry = idx;
|
|
82
|
+
for (var j = 0; j < bytes.length; j++) {
|
|
83
|
+
carry += bytes[j] * 58;
|
|
84
|
+
bytes[j] = carry & 0xff;
|
|
85
|
+
carry >>= 8;
|
|
86
|
+
}
|
|
87
|
+
while (carry) { bytes.push(carry & 0xff); carry >>= 8; }
|
|
88
|
+
}
|
|
89
|
+
for (var i = 0; i < str.length && str[i] === '1'; i++) bytes.push(0);
|
|
90
|
+
return new Uint8Array(bytes.reverse());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var statusEl = document.getElementById('phantom-status');
|
|
94
|
+
var errorEl = document.getElementById('phantom-error');
|
|
95
|
+
var spinnerEl = document.getElementById('phantom-spinner');
|
|
96
|
+
|
|
97
|
+
function showError(msg) {
|
|
98
|
+
dbg('ERROR', msg);
|
|
99
|
+
spinnerEl.classList.add('hidden');
|
|
100
|
+
statusEl.classList.add('hidden');
|
|
101
|
+
errorEl.textContent = msg;
|
|
102
|
+
errorEl.classList.remove('hidden');
|
|
103
|
+
setTimeout(function() { window.location.href = '/signin'; }, 30000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
var ALL_KEYS = [
|
|
107
|
+
'phantom_dl_secret', 'phantom_dl_pubkey', 'phantom_dl_nonce', 'phantom_dl_nonce_at',
|
|
108
|
+
'phantom_dl_step', 'phantom_dl_link_mode', 'phantom_dl_cluster', 'phantom_dl_age_attested'
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
// Keys whose VALUE must never be printed. ALL_KEYS above stays COMPLETE on purpose:
|
|
112
|
+
// cleanup() iterates it to REMOVE these from localStorage, so pruning the secret from
|
|
113
|
+
// it would leave the private key sitting on the device forever — a worse bug than the
|
|
114
|
+
// one this task fixes. Redact at the point of display instead.
|
|
115
|
+
var SECRET_KEYS = ['phantom_dl_secret'];
|
|
116
|
+
|
|
117
|
+
function cleanup() {
|
|
118
|
+
ALL_KEYS.forEach(function(k) { localStorage.removeItem(k); });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Dump localStorage
|
|
122
|
+
dbg('--- localStorage ---');
|
|
123
|
+
ALL_KEYS.forEach(function(k) {
|
|
124
|
+
var v = localStorage.getItem(k);
|
|
125
|
+
var short = k.replace('phantom_dl_', '');
|
|
126
|
+
if (SECRET_KEYS.indexOf(k) !== -1) {
|
|
127
|
+
dbg(' ' + short, v ? '(present, ' + v.length + ' chars, redacted)' : '(empty)');
|
|
128
|
+
} else {
|
|
129
|
+
dbg(' ' + short, v ? truncate(v, 40) : '(empty)');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Parse + dump URL params
|
|
134
|
+
var params = new URLSearchParams(window.location.search);
|
|
135
|
+
dbg('--- URL params ---');
|
|
136
|
+
params.forEach(function(val, key) {
|
|
137
|
+
dbg(' ' + key, truncate(val, 40));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Check for Phantom error
|
|
141
|
+
if (params.get('errorCode')) {
|
|
142
|
+
dbg('ERROR', 'errorCode=' + params.get('errorCode'));
|
|
143
|
+
dbg('ERROR', 'errorMessage=' + params.get('errorMessage'));
|
|
144
|
+
cleanup();
|
|
145
|
+
showError('Phantom error: ' + (params.get('errorMessage') || 'Request rejected'));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
var step = localStorage.getItem('phantom_dl_step');
|
|
150
|
+
dbg('Step', step);
|
|
151
|
+
|
|
152
|
+
if (!step) {
|
|
153
|
+
showError('No pending Phantom request. Redirecting to login...');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check nonce expiry
|
|
158
|
+
var nonceAt = parseInt(localStorage.getItem('phantom_dl_nonce_at') || '0');
|
|
159
|
+
var elapsed = Date.now() - nonceAt;
|
|
160
|
+
dbg('Nonce age', Math.round(elapsed / 1000) + 's');
|
|
161
|
+
if (elapsed > 270000) {
|
|
162
|
+
cleanup();
|
|
163
|
+
showError('Session expired. Please try again.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check nacl
|
|
168
|
+
dbg('nacl available', typeof nacl !== 'undefined');
|
|
169
|
+
if (typeof nacl === 'undefined') {
|
|
170
|
+
showError('TweetNaCl not loaded');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// URL params from Phantom response
|
|
175
|
+
var phantomEncPubkeyB58 = params.get('phantom_encryption_public_key');
|
|
176
|
+
var dataParm = params.get('data');
|
|
177
|
+
var nonceParm = params.get('nonce');
|
|
178
|
+
|
|
179
|
+
dbg('Has phantom_encryption_public_key', !!phantomEncPubkeyB58);
|
|
180
|
+
dbg('Has data', !!dataParm);
|
|
181
|
+
dbg('Has nonce', !!nonceParm);
|
|
182
|
+
|
|
183
|
+
if (!phantomEncPubkeyB58 || !dataParm || !nonceParm) {
|
|
184
|
+
cleanup();
|
|
185
|
+
showError('Missing Phantom response parameters');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Recover dapp secret key from localStorage
|
|
191
|
+
var dappSecretKey = b58decode(localStorage.getItem('phantom_dl_secret'));
|
|
192
|
+
dbg('Dapp secret key bytes', dappSecretKey.length);
|
|
193
|
+
|
|
194
|
+
// Compute shared secret (Diffie-Hellman)
|
|
195
|
+
var phantomPubBytes = b58decode(phantomEncPubkeyB58);
|
|
196
|
+
dbg('Phantom pubkey bytes', phantomPubBytes.length);
|
|
197
|
+
var sharedSecret = nacl.box.before(phantomPubBytes, dappSecretKey);
|
|
198
|
+
dbg('OK', 'Shared secret computed, ' + sharedSecret.length + 'B');
|
|
199
|
+
|
|
200
|
+
// Decrypt response
|
|
201
|
+
var data = b58decode(dataParm);
|
|
202
|
+
var nonce = b58decode(nonceParm);
|
|
203
|
+
dbg('Decrypt', 'data=' + data.length + 'B nonce=' + nonce.length + 'B');
|
|
204
|
+
|
|
205
|
+
var decrypted = nacl.box.open.after(data, nonce, sharedSecret);
|
|
206
|
+
if (!decrypted) throw new Error('Decryption failed — nacl.box.open.after returned null');
|
|
207
|
+
|
|
208
|
+
var responseText = new TextDecoder().decode(decrypted);
|
|
209
|
+
dbg('OK', 'Decrypted ' + responseText.length + ' chars');
|
|
210
|
+
|
|
211
|
+
var response = JSON.parse(responseText);
|
|
212
|
+
|
|
213
|
+
// Log ALL response fields for debugging
|
|
214
|
+
dbg('=== RESPONSE FIELDS ===');
|
|
215
|
+
var keys = Object.keys(response);
|
|
216
|
+
dbg('Keys', keys.join(', '));
|
|
217
|
+
keys.forEach(function(k) {
|
|
218
|
+
var v = response[k];
|
|
219
|
+
if (typeof v === 'string') {
|
|
220
|
+
dbg(' ' + k, truncate(v, 50));
|
|
221
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
222
|
+
dbg(' ' + k, JSON.stringify(v).substring(0, 80));
|
|
223
|
+
} else {
|
|
224
|
+
dbg(' ' + k, String(v));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Extract wallet address
|
|
229
|
+
var walletPubkey = response.address || response.public_key;
|
|
230
|
+
dbg('Wallet', walletPubkey);
|
|
231
|
+
|
|
232
|
+
if (!walletPubkey) {
|
|
233
|
+
throw new Error('No wallet address in response');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Extract signature and signed message (SIWS response)
|
|
237
|
+
var signatureB58 = response.signature;
|
|
238
|
+
var signedMessageB58 = response.signed_message || response.signedMessage;
|
|
239
|
+
var outputB58 = response.output;
|
|
240
|
+
|
|
241
|
+
dbg('signature', truncate(signatureB58, 30));
|
|
242
|
+
dbg('signedMessage', truncate(signedMessageB58, 30));
|
|
243
|
+
dbg('output', truncate(outputB58, 30));
|
|
244
|
+
|
|
245
|
+
// Decode the signed message to UTF-8 text (server expects the raw message string)
|
|
246
|
+
var message = null;
|
|
247
|
+
if (signedMessageB58) {
|
|
248
|
+
var msgBytes = b58decode(signedMessageB58);
|
|
249
|
+
message = new TextDecoder().decode(msgBytes);
|
|
250
|
+
dbg('Message text', truncate(message, 60));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If no signature in response, show debug info and stop
|
|
254
|
+
if (!signatureB58) {
|
|
255
|
+
dbg('ERROR', 'No signature field in response — signIn may not return SIWS data');
|
|
256
|
+
dbg('Full response', responseText.substring(0, 200));
|
|
257
|
+
showError('No signature in Phantom response. See debug log.');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!message) {
|
|
262
|
+
// Try to reconstruct message from our stored data
|
|
263
|
+
var serverNonce = localStorage.getItem('phantom_dl_nonce');
|
|
264
|
+
var domain = window.location.host;
|
|
265
|
+
// OPSEC-005: include `User-ID: <id>` when we were linking — mirrors
|
|
266
|
+
// the statement we built in startPhantomDeepLink so the server's
|
|
267
|
+
// session-binding check passes.
|
|
268
|
+
var storedUserId = localStorage.getItem('phantom_dl_user_id');
|
|
269
|
+
var storedLinkMode = localStorage.getItem('phantom_dl_link_mode') === 'true';
|
|
270
|
+
var statementLine = <%= Studio.wallet_sign_in_statement.to_json.html_safe %>;
|
|
271
|
+
if (storedLinkMode && storedUserId) {
|
|
272
|
+
statementLine += '\nUser-ID: ' + storedUserId;
|
|
273
|
+
}
|
|
274
|
+
message = domain + ' wants you to sign in with your Solana account:\n' +
|
|
275
|
+
walletPubkey + '\n\n' + statementLine + '\n\nNonce: ' + serverNonce;
|
|
276
|
+
dbg('Reconstructed message', truncate(message, 60));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
var linkMode = localStorage.getItem('phantom_dl_link_mode') === 'true';
|
|
280
|
+
var verifyUrl = linkMode ? '/account/link_solana' : '/auth/solana/verify';
|
|
281
|
+
var csrfToken = document.querySelector('meta[name="csrf-token"]');
|
|
282
|
+
|
|
283
|
+
dbg('Verify URL', verifyUrl);
|
|
284
|
+
dbg('CSRF token', !!csrfToken);
|
|
285
|
+
dbg('POSTing to verify...');
|
|
286
|
+
statusEl.textContent = 'Logging in...';
|
|
287
|
+
|
|
288
|
+
fetch(verifyUrl, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: {
|
|
291
|
+
'Content-Type': 'application/json',
|
|
292
|
+
'X-CSRF-Token': csrfToken ? csrfToken.content : ''
|
|
293
|
+
},
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
message: message,
|
|
296
|
+
signature: signatureB58,
|
|
297
|
+
pubkey: walletPubkey,
|
|
298
|
+
// Legal-age attestation, stashed by the Connect Wallet picker before
|
|
299
|
+
// the deep-link round trip (undefined is dropped by JSON.stringify).
|
|
300
|
+
age_attestation: (!linkMode && localStorage.getItem('phantom_dl_age_attested') === '1') ? '1' : undefined,
|
|
301
|
+
// Hardcoded, and correctly so: this whole page exists to service the
|
|
302
|
+
// PHANTOM mobile deep link (phantom_dl_*), so the brand is a property of
|
|
303
|
+
// the route rather than something to detect. A user arriving here signed
|
|
304
|
+
// in the Phantom app.
|
|
305
|
+
wallet_provider: 'phantom'
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
.then(function(r) {
|
|
309
|
+
dbg('Verify status', r.status);
|
|
310
|
+
return r.json();
|
|
311
|
+
})
|
|
312
|
+
.then(function(result) {
|
|
313
|
+
dbg('Verify result', JSON.stringify(result));
|
|
314
|
+
if (result.success) {
|
|
315
|
+
cleanup();
|
|
316
|
+
// Guarded, matching studio/modals/_wallet_connect two files over. An app
|
|
317
|
+
// that does not define this hook would otherwise throw HERE — after a
|
|
318
|
+
// successful verify, with the session already set — so the user is
|
|
319
|
+
// signed in and the page dies before redirecting. The engine cannot
|
|
320
|
+
// require a hook it does not ship.
|
|
321
|
+
if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
|
|
322
|
+
dbg('OK', 'Login success! Redirecting...');
|
|
323
|
+
window.location.href = result.redirect || '/';
|
|
324
|
+
} else {
|
|
325
|
+
cleanup();
|
|
326
|
+
dbg('ERROR', 'Verify failed: ' + (result.error || JSON.stringify(result)));
|
|
327
|
+
showError(result.error || 'Verification failed');
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
.catch(function(err) {
|
|
331
|
+
dbg('ERROR', 'Fetch error: ' + err.message);
|
|
332
|
+
cleanup();
|
|
333
|
+
showError('Verification failed: ' + err.message);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
} catch (e) {
|
|
337
|
+
dbg('CATCH', e.message);
|
|
338
|
+
dbg('CATCH', e.stack || '(no stack)');
|
|
339
|
+
cleanup();
|
|
340
|
+
showError(e.message || 'Something went wrong');
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
</script>
|
|
@@ -141,16 +141,31 @@
|
|
|
141
141
|
var n = ('' + name).toLowerCase();
|
|
142
142
|
return ['phantom', 'solflare', 'backpack'].indexOf(n) !== -1 ? n : null;
|
|
143
143
|
},
|
|
144
|
+
// Is there anywhere for a mobile Phantom row to GO? A consumer that does
|
|
145
|
+
// not render studio/solana/phantom_deeplink has no startPhantomDeepLink,
|
|
146
|
+
// and every branch below turns on this answer.
|
|
147
|
+
//
|
|
148
|
+
// WHY IT IS CHECKED AT ALL: without it, adopting this picker replaced an
|
|
149
|
+
// app's dead-end install row with a dead BUTTON — the install row
|
|
150
|
+
// suppressed, the deep-link row painted, and its tap a no-op. Found
|
|
151
|
+
// before the hub adopted, which had no mobile wallet path whatsoever.
|
|
152
|
+
// An absent capability must not default to the permissive branch.
|
|
153
|
+
get canDeepLink() {
|
|
154
|
+
return typeof startPhantomDeepLink === 'function';
|
|
155
|
+
},
|
|
144
156
|
// A phone has no extension to install, and Phantom's own row on mobile
|
|
145
157
|
// is the deep link below — so drop Phantom here rather than paint a
|
|
146
158
|
// SECOND Phantom row pointing at a desktop download page the user
|
|
147
|
-
// cannot act on.
|
|
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.65.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
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
|