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
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
a second transform property would silently replace the first. */
|
|
133
133
|
left: 50%;
|
|
134
134
|
width: min(42rem, calc(100% - 2rem));
|
|
135
|
-
z-index: 30;
|
|
135
|
+
z-index: var(--z-sticky, 30);
|
|
136
136
|
background: var(--color-surface);
|
|
137
137
|
border: 1px solid var(--color-border-subtle);
|
|
138
138
|
border-radius: 0.75rem;
|
|
@@ -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>
|
|
@@ -116,6 +116,15 @@
|
|
|
116
116
|
return { connecting: false, error: null, statusText: 'Connect Wallet',
|
|
117
117
|
walletAvailable: false, isMobile: false, connect: function () {} };
|
|
118
118
|
};
|
|
119
|
+
// Connect stub for the promoted wallet picker (studio/modals/_wallet_connect,
|
|
120
|
+
// configured by style/modals/_wallet_connect). Performs NO connect: it
|
|
121
|
+
// resolves success after a beat so the picker's connecting state is
|
|
122
|
+
// visible and its onConnected hook can continue the demo walk.
|
|
123
|
+
window.dsWalletConnectDemo = window.dsWalletConnectDemo || function () {
|
|
124
|
+
return new Promise(function (resolve) {
|
|
125
|
+
setTimeout(function () { resolve({ success: true }); }, 1100);
|
|
126
|
+
});
|
|
127
|
+
};
|
|
119
128
|
window.walletProvider = window.walletProvider || {
|
|
120
129
|
available: function () { return [{ name: 'Phantom' }, { name: 'Solflare' }]; },
|
|
121
130
|
isMobile: function () { return false; },
|
|
@@ -306,7 +315,7 @@
|
|
|
306
315
|
gated on props.dismissible (never @click.outside, so a hold modal can't
|
|
307
316
|
close itself on the button release). %>
|
|
308
317
|
<template x-if="$store.dsModals.current()">
|
|
309
|
-
<div class="fixed inset-0 z-[
|
|
318
|
+
<div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
310
319
|
:class="$store.dsModals.current()?._closing && 'modal-backdrop-unmount'"
|
|
311
320
|
style="background: rgba(0,0,0,0.6)"
|
|
312
321
|
role="dialog" aria-modal="true"
|
|
@@ -1,176 +1,82 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
"Connect Wallet"
|
|
3
|
-
modals/_wallet_connect, wired to the living style guide's page-scoped host
|
|
4
|
-
($store.dsModals). Faithful reown-style picker: detected wallets on top
|
|
5
|
-
(click to connect), featured-but-missing wallets below (install links), a
|
|
6
|
-
mobile deep-link row, and the legal-age attestation for guests.
|
|
2
|
+
"Connect Wallet" specimen for the living style guide.
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
As of the wallet-picker promotion this is a THIN CONFIGURATION of the real
|
|
5
|
+
engine partial (studio/modals/_wallet_connect) rather than a parallel port of
|
|
6
|
+
it. That is the point: the card on /admin/style now demonstrates the SAME code
|
|
7
|
+
the apps render, so a drift between the specimen and production is no longer
|
|
8
|
+
possible. Before this, the picker existed three times — turf-monster's,
|
|
9
|
+
mcritchie-studio's, and this one — and only this one was ever looked at.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
What the configuration supplies, all of it demo scaffolding:
|
|
12
|
+
store — the guide's page-scoped host ($store.dsModals), not the app
|
|
13
|
+
host, so the specimen never collides with a consuming app's
|
|
14
|
+
own shared modal host.
|
|
15
|
+
connect_fn — dsWalletConnectDemo, the stub defined with the other guide
|
|
16
|
+
credential stubs in style/_modals. It performs NO real
|
|
17
|
+
connect; it resolves success after a beat so the picker's
|
|
18
|
+
connecting state is visible.
|
|
19
|
+
extra_data — the demo's own attestation state plus the hooks: onConnected
|
|
20
|
+
continues the walk into the on-chain-tx card, onDeepLink
|
|
21
|
+
treats the mobile row as a Phantom pick, onBack slides back
|
|
22
|
+
to the auth card when the picker was swapped in from it.
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
The slot below is the app slot — here it holds the legal-age attestation,
|
|
25
|
+
exactly where turf-monster puts its own. It is a named local naming a partial,
|
|
26
|
+
not a block: a block-shaped slot leaks the captured page body into the card
|
|
27
|
+
(a compiled Rails partial's block_given? is always true).
|
|
17
28
|
%>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
message: 'Waiting for the wallet signature…'
|
|
73
|
-
});
|
|
74
|
-
}, 1100);
|
|
75
|
-
},
|
|
76
|
-
deepLink() {
|
|
77
|
-
if (!this.attested()) return;
|
|
78
|
-
this.pick('Phantom');
|
|
79
|
-
},
|
|
80
|
-
back() {
|
|
81
|
-
if (this.connecting) return;
|
|
82
|
-
if (this.props.backTo === 'auth') {
|
|
83
|
-
Alpine.store('dsModals').swap('auth',
|
|
84
|
-
{ step: 'credentials', submitting: null, formError: '' },
|
|
85
|
-
{ direction: 'back' });
|
|
86
|
-
} else {
|
|
87
|
-
Alpine.store('dsModals').close();
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}"
|
|
91
|
-
class="relative">
|
|
92
|
-
|
|
93
|
-
<%# Engine-owned brand-icon sprite — one copy; the <use> refs below paint it. %>
|
|
94
|
-
<%= render "studio/modals/blocks/wallet_brand_sprite" %>
|
|
95
|
-
|
|
96
|
-
<div class="relative mb-4">
|
|
97
|
-
<h3 class="text-heading font-bold text-lg text-center pt-1">Connect Wallet</h3>
|
|
98
|
-
<button @click="$store.dsModals.close()"
|
|
99
|
-
class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none"
|
|
100
|
-
aria-label="Close">×</button>
|
|
101
|
-
</div>
|
|
102
|
-
|
|
103
|
-
<template x-if="needsAttestation">
|
|
104
|
-
<div class="mb-3">
|
|
105
|
-
<%= render "studio/modals/shared/age_attestation" %>
|
|
106
|
-
</div>
|
|
107
|
-
</template>
|
|
108
|
-
|
|
109
|
-
<div class="space-y-2">
|
|
110
|
-
<%# Detected wallets — click to connect. %>
|
|
111
|
-
<template x-for="w in wallets" :key="w.name">
|
|
112
|
-
<button type="button" @click="pick(w.name)" :disabled="connecting"
|
|
113
|
-
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60 disabled:cursor-wait">
|
|
114
|
-
<template x-if="brandIcon(w.name)">
|
|
115
|
-
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
116
|
-
<svg class="w-9 h-9" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(w.name)"></use></svg>
|
|
117
|
-
</span>
|
|
118
|
-
</template>
|
|
119
|
-
<template x-if="!brandIcon(w.name)">
|
|
120
|
-
<span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center text-sm font-bold text-heading shrink-0" x-text="w.name.slice(0,1)"></span>
|
|
121
|
-
</template>
|
|
122
|
-
<span class="font-semibold text-heading" x-text="w.name"></span>
|
|
123
|
-
<span class="ml-auto flex items-center gap-2">
|
|
124
|
-
<span x-show="picking === w.name" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting…</span>
|
|
125
|
-
<span x-show="picking !== w.name" class="badge border-primary text-primary">Installed</span>
|
|
126
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
127
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
128
|
-
</svg>
|
|
129
|
-
</span>
|
|
130
|
-
</button>
|
|
131
|
-
</template>
|
|
132
|
-
|
|
133
|
-
<%# Featured wallets that aren't installed — open the install page. %>
|
|
134
|
-
<template x-for="i in missingInstalls" :key="i.name">
|
|
135
|
-
<a :href="i.url" target="_blank" rel="noopener noreferrer"
|
|
136
|
-
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition no-underline">
|
|
137
|
-
<template x-if="brandIcon(i.name)">
|
|
138
|
-
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
139
|
-
<svg class="w-9 h-9" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(i.name)"></use></svg>
|
|
140
|
-
</span>
|
|
141
|
-
</template>
|
|
142
|
-
<template x-if="!brandIcon(i.name)">
|
|
143
|
-
<span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center text-sm font-bold text-muted shrink-0" x-text="i.name.slice(0,1)"></span>
|
|
144
|
-
</template>
|
|
145
|
-
<span class="font-semibold text-heading" x-text="i.name"></span>
|
|
146
|
-
<span class="ml-auto flex items-center gap-2">
|
|
147
|
-
<span class="text-xs text-muted uppercase tracking-wide">Install</span>
|
|
148
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
149
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
150
|
-
</svg>
|
|
151
|
-
</span>
|
|
152
|
-
</a>
|
|
153
|
-
</template>
|
|
154
|
-
|
|
155
|
-
<%# Mobile (no extension) — deep-link into the Phantom app. %>
|
|
156
|
-
<button x-show="isMobile" type="button" @click="deepLink()"
|
|
157
|
-
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
|
|
158
|
-
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
159
|
-
<svg class="w-9 h-9" aria-hidden="true"><use href="#se-wallet-phantom"></use></svg>
|
|
160
|
-
</span>
|
|
161
|
-
<span class="font-semibold text-heading">Open in Phantom app</span>
|
|
162
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="ml-auto w-4 h-4 text-muted">
|
|
163
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
164
|
-
</svg>
|
|
165
|
-
</button>
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
|
-
<template x-if="error">
|
|
169
|
-
<p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
|
|
170
|
-
</template>
|
|
171
|
-
|
|
172
|
-
<button type="button" @click="back()" :disabled="connecting"
|
|
173
|
-
class="mt-4 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
174
|
-
← Back
|
|
175
|
-
</button>
|
|
176
|
-
</div>
|
|
29
|
+
<%
|
|
30
|
+
# The heredoc MUST live inside one Ruby chunk like this. Written inline in the
|
|
31
|
+
# render tag below it does NOT compile: ERB closes a tag at the first close
|
|
32
|
+
# marker it sees, so the heredoc body becomes template TEXT and extra_data
|
|
33
|
+
# arrives empty — the picker still renders, minus every hook, which is a
|
|
34
|
+
# working-looking page that passes a markup assertion.
|
|
35
|
+
#
|
|
36
|
+
# And note what this comment may NOT contain: the close marker itself, in any
|
|
37
|
+
# form, even inside a Ruby comment. ERB scans the raw text, not the Ruby. The
|
|
38
|
+
# first draft of this very comment spelled the marker out and closed the tag
|
|
39
|
+
# from inside itself.
|
|
40
|
+
demo_hooks = <<~JS
|
|
41
|
+
ageAttested: false,
|
|
42
|
+
ageError: false,
|
|
43
|
+
get needsAttestation() { return !this.props.linkMode; },
|
|
44
|
+
canPick() {
|
|
45
|
+
if (!this.needsAttestation) return true;
|
|
46
|
+
if (this.ageAttested) return true;
|
|
47
|
+
this.ageError = true;
|
|
48
|
+
return false;
|
|
49
|
+
},
|
|
50
|
+
onInit() { this.ageAttested = this.props.ageAttested === true; },
|
|
51
|
+
// DEMO walk continuity: the picked wallet resolves, then the card swaps to
|
|
52
|
+
// the on-chain tx modal in its processing state, which auto-resolves to
|
|
53
|
+
// on-chain success. So the guide's active-card glow follows
|
|
54
|
+
// Connect Wallet -> Processing -> On-chain success.
|
|
55
|
+
onConnected() {
|
|
56
|
+
this.connecting = false;
|
|
57
|
+
this.picking = '';
|
|
58
|
+
Alpine.store('dsModals').swap('onchain-tx', {
|
|
59
|
+
state: 'processing', demoResolve: true, demoError: false,
|
|
60
|
+
title: 'Confirming on-chain',
|
|
61
|
+
message: 'Waiting for the wallet signature\u2026'
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
// No app to deep-link into from the guide — treat the mobile Phantom row
|
|
65
|
+
// as picking Phantom so the card still walks somewhere.
|
|
66
|
+
onDeepLink() { this.pick('Phantom'); },
|
|
67
|
+
onBack() {
|
|
68
|
+
if (this.props.backTo === 'auth') {
|
|
69
|
+
Alpine.store('dsModals').swap('auth',
|
|
70
|
+
{ step: 'credentials', submitting: null, formError: '' },
|
|
71
|
+
{ direction: 'back' });
|
|
72
|
+
} else {
|
|
73
|
+
Alpine.store('dsModals').close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
JS
|
|
77
|
+
%>
|
|
78
|
+
<%= render "studio/modals/wallet_connect",
|
|
79
|
+
store: "dsModals",
|
|
80
|
+
connect_fn: "dsWalletConnectDemo",
|
|
81
|
+
slot: "style/modals/wallet_connect_slot",
|
|
82
|
+
extra_data: demo_hooks %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The style guide's pre-connect slot for studio/modals/_wallet_connect — the
|
|
3
|
+
legal-age attestation, sitting exactly where turf-monster puts its own.
|
|
4
|
+
|
|
5
|
+
A PARTIAL rather than a block because the picker takes its slot as a named
|
|
6
|
+
local: `block_given?` reads TRUE inside any compiled Rails partial, so a
|
|
7
|
+
block-shaped slot yields the captured page body into the card whenever no
|
|
8
|
+
block is passed. See the picker's header.
|
|
9
|
+
|
|
10
|
+
It binds to ageAttested / ageError / needsAttestation, which reach this scope
|
|
11
|
+
through the picker's extra_data fragment — this renders INSIDE the picker's
|
|
12
|
+
root, so those members are in scope.
|
|
13
|
+
%>
|
|
14
|
+
<template x-if="needsAttestation">
|
|
15
|
+
<div class="mb-3">
|
|
16
|
+
<%= render "studio/modals/shared/age_attestation" %>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
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
|
|
@@ -444,6 +445,7 @@ files:
|
|
|
444
445
|
- app/views/studio/modals/_load_convention.html.erb
|
|
445
446
|
- app/views/studio/modals/_saving.html.erb
|
|
446
447
|
- app/views/studio/modals/_scoped_host.html.erb
|
|
448
|
+
- app/views/studio/modals/_wallet_connect.html.erb
|
|
447
449
|
- app/views/studio/modals/_web3_step_up.html.erb
|
|
448
450
|
- app/views/studio/modals/auth/_resend_footer.html.erb
|
|
449
451
|
- app/views/studio/modals/blocks/_age_gate.html.erb
|
|
@@ -497,6 +499,8 @@ files:
|
|
|
497
499
|
- app/views/studio/profiles/_save_controls.html.erb
|
|
498
500
|
- app/views/studio/profiles/edit.html.erb
|
|
499
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
|
|
500
504
|
- app/views/style/_modal_specimen.html.erb
|
|
501
505
|
- app/views/style/_modals.html.erb
|
|
502
506
|
- app/views/style/_specimen.html.erb
|
|
@@ -528,6 +532,7 @@ files:
|
|
|
528
532
|
- app/views/style/modals/_unsubscribe_goodbye.html.erb
|
|
529
533
|
- app/views/style/modals/_wallet_changed.html.erb
|
|
530
534
|
- app/views/style/modals/_wallet_connect.html.erb
|
|
535
|
+
- app/views/style/modals/_wallet_connect_slot.html.erb
|
|
531
536
|
- app/views/style/modals/_wallet_deposit.html.erb
|
|
532
537
|
- app/views/style/modals/_wallet_setup.html.erb
|
|
533
538
|
- app/views/theme_settings/edit.html.erb
|