@flow-industries/id 0.3.1 → 0.6.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.
- package/dist/sdk/client/access-key.d.ts +5 -3
- package/dist/sdk/client/access-key.js +6 -4
- package/dist/sdk/client/create-flow.js +145 -3
- package/dist/sdk/client/dialog-host.js +24 -18
- package/dist/sdk/client/iframe-host.d.ts +23 -0
- package/dist/sdk/client/iframe-host.js +34 -0
- package/dist/sdk/client/index.d.ts +2 -1
- package/dist/sdk/client/index.js +1 -0
- package/dist/sdk/client/methods.d.ts +1 -0
- package/dist/sdk/client/methods.js +1 -0
- package/dist/sdk/client/profile-button.d.ts +15 -0
- package/dist/sdk/client/profile-button.js +189 -0
- package/dist/sdk/client/signing.d.ts +1 -1
- package/dist/sdk/client/signing.js +3 -1
- package/dist/sdk/client/store.js +4 -1
- package/dist/sdk/dialog/remote/Messenger.d.ts +1 -2
- package/dist/sdk/dialog/remote/Messenger.js +3 -1
- package/dist/sdk/react/hooks.d.ts +6 -2
- package/dist/sdk/react/hooks.js +6 -2
- package/dist/sdk/react/index.d.ts +3 -2
- package/dist/sdk/react/index.js +2 -1
- package/dist/sdk/react/profile-button.d.ts +9 -0
- package/dist/sdk/react/profile-button.js +28 -0
- package/dist/sdk/react/provider.js +1 -1
- package/dist/sdk/types/auth.d.ts +13 -0
- package/dist/sdk/types/dialog.d.ts +22 -0
- package/dist/sdk/types/events.d.ts +40 -0
- package/dist/sdk/types/events.js +6 -0
- package/dist/sdk/types/index.d.ts +5 -4
- package/dist/sdk/types/messenger.d.ts +30 -0
- package/dist/sdk/types/protocol.d.ts +15 -1
- package/dist/sdk/types/sdk.d.ts +72 -1
- package/dist/sdk/verify.d.ts +7 -0
- package/dist/sdk/verify.js +7 -0
- package/dist/sdk/wagmi/index.js +8 -3
- package/package.json +30 -4
- package/dist/sdk/client/protocol.d.ts +0 -97
- package/dist/sdk/client/protocol.js +0 -9
- package/dist/sdk/client/types.d.ts +0 -35
- package/dist/sdk/client/types.js +0 -0
- package/dist/sdk/types.d.ts +0 -32
- package/dist/sdk/types.js +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Account } from "viem/tempo";
|
|
2
|
-
import type { AccessKeyPreparation, Address as Hex,
|
|
2
|
+
import type { AccessKeyPreparation, FinalizeAccessKeyParams, FlowCredential, Address as Hex, ResolvedAccessKeyOptions, StoredAccessKey } from "../types";
|
|
3
3
|
/**
|
|
4
4
|
* Step 1 of access-key creation — runs BEFORE the dialog opens.
|
|
5
5
|
*
|
|
@@ -7,8 +7,10 @@ import type { AccessKeyPreparation, Address as Hex, FinalizeAccessKeyParams, Flo
|
|
|
7
7
|
* and builds an unsigned KeyAuthorization that grants this key permission to
|
|
8
8
|
* sign on behalf of the root account until `expiry`. Returns the key pair
|
|
9
9
|
* plus a signing payload (`accessKeyHash`) that gets passed into the dialog
|
|
10
|
-
* so the
|
|
11
|
-
*
|
|
10
|
+
* so the user's passkey can authorize it. The dialog signs `accessKeyHash`
|
|
11
|
+
* in its own WebAuthn ceremony, separate from login — Tempo's on-chain
|
|
12
|
+
* verifier requires a signature over this exact digest, so it cannot be
|
|
13
|
+
* folded into the login challenge.
|
|
12
14
|
*
|
|
13
15
|
* Pairs with `finalizeAccessKey()` after the dialog returns the WebAuthn
|
|
14
16
|
* signature.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Account, WebCryptoP256 } from "viem/tempo";
|
|
2
|
-
import { KeyAuthorization, SignatureEnvelope } from "ox/tempo";
|
|
3
1
|
import * as Address from "ox/Address";
|
|
4
2
|
import * as PublicKey from "ox/PublicKey";
|
|
3
|
+
import { KeyAuthorization, SignatureEnvelope } from "ox/tempo";
|
|
4
|
+
import { Account, WebCryptoP256 } from "viem/tempo";
|
|
5
5
|
import { idb } from "./idb";
|
|
6
6
|
/**
|
|
7
7
|
* Step 1 of access-key creation — runs BEFORE the dialog opens.
|
|
@@ -10,8 +10,10 @@ import { idb } from "./idb";
|
|
|
10
10
|
* and builds an unsigned KeyAuthorization that grants this key permission to
|
|
11
11
|
* sign on behalf of the root account until `expiry`. Returns the key pair
|
|
12
12
|
* plus a signing payload (`accessKeyHash`) that gets passed into the dialog
|
|
13
|
-
* so the
|
|
14
|
-
*
|
|
13
|
+
* so the user's passkey can authorize it. The dialog signs `accessKeyHash`
|
|
14
|
+
* in its own WebAuthn ceremony, separate from login — Tempo's on-chain
|
|
15
|
+
* verifier requires a signature over this exact digest, so it cannot be
|
|
16
|
+
* folded into the login challenge.
|
|
15
17
|
*
|
|
16
18
|
* Pairs with `finalizeAccessKey()` after the dialog returns the WebAuthn
|
|
17
19
|
* signature.
|
|
@@ -4,6 +4,43 @@ import { METHODS } from "./methods";
|
|
|
4
4
|
import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
|
|
5
5
|
import { createStore, initialFlowState } from "./store";
|
|
6
6
|
const DEFAULT_HOST = "https://id.flow.industries";
|
|
7
|
+
// Refresh a little before the JWT's `exp` so a token handed out by getToken()
|
|
8
|
+
// is still valid by the time it reaches the relying party, absorbing request
|
|
9
|
+
// latency and minor client/server clock skew.
|
|
10
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
11
|
+
/** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
|
|
12
|
+
function jwtExp(token) {
|
|
13
|
+
const parts = token.split(".");
|
|
14
|
+
if (parts.length !== 3)
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
18
|
+
const payload = JSON.parse(atob(padded));
|
|
19
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** True if the token is missing an exp, already expired, or within the skew. */
|
|
26
|
+
function isExpiring(token) {
|
|
27
|
+
const exp = jwtExp(token);
|
|
28
|
+
if (exp === null)
|
|
29
|
+
return true;
|
|
30
|
+
return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs `fn` while holding a cross-tab lock (Web Locks API) so concurrent tabs
|
|
34
|
+
* of the same origin serialize guest creation: the first tab mints and sets the
|
|
35
|
+
* shared cookie, later tabs then restore it instead of minting a duplicate
|
|
36
|
+
* guest. Falls back to running `fn` directly where Web Locks is unavailable.
|
|
37
|
+
*/
|
|
38
|
+
function withGuestLock(name, fn) {
|
|
39
|
+
const locks = globalThis.navigator?.locks;
|
|
40
|
+
if (locks?.request)
|
|
41
|
+
return locks.request(name, fn);
|
|
42
|
+
return fn();
|
|
43
|
+
}
|
|
7
44
|
/**
|
|
8
45
|
* Normalizes user-supplied access-key configuration into a fully-resolved
|
|
9
46
|
* shape. Accepts `true` for defaults, a partial options object, or omitted
|
|
@@ -93,7 +130,12 @@ export function createFlow(options = {}) {
|
|
|
93
130
|
credential: result.credential,
|
|
94
131
|
address: result.address,
|
|
95
132
|
});
|
|
96
|
-
|
|
133
|
+
// A guest restore returns a null credential; never persist that — the IDB
|
|
134
|
+
// store is reserved for a real passkey credential and writing null would
|
|
135
|
+
// erase a previously stored one.
|
|
136
|
+
if (result.credential) {
|
|
137
|
+
await idb.set("flow.activeCredential", result.credential);
|
|
138
|
+
}
|
|
97
139
|
return true;
|
|
98
140
|
}
|
|
99
141
|
catch {
|
|
@@ -104,10 +146,94 @@ export function createFlow(options = {}) {
|
|
|
104
146
|
// from the cookie session. We expose two names because consumers reach for
|
|
105
147
|
// one or the other based on intent (page-load vs near-expiry).
|
|
106
148
|
const refreshJwt = restore;
|
|
149
|
+
// Dedupe concurrent refreshes: a burst of getToken() calls that all find the
|
|
150
|
+
// cached token expired should trigger one mint, not one per call.
|
|
151
|
+
let refreshInFlight = null;
|
|
152
|
+
/**
|
|
153
|
+
* Returns a currently-valid JWT, silently minting a fresh one from the
|
|
154
|
+
* 60-day cookie session when the cached token is missing, expired, or within
|
|
155
|
+
* EXPIRY_SKEW_MS of expiring. This is the accessor to call before hitting a
|
|
156
|
+
* relying-party backend: the JWT is a 1h access token, so reading the cached
|
|
157
|
+
* `flow.jwt` from a tab open longer than an hour would send a stale token.
|
|
158
|
+
*
|
|
159
|
+
* The refresh is silent (cookie-based, no passkey prompt). Returns null only
|
|
160
|
+
* when there is no usable session — the caller should then prompt
|
|
161
|
+
* `flow.login()`. Use it inside a 401 handler too: `await flow.getToken()`
|
|
162
|
+
* before retrying the request.
|
|
163
|
+
*/
|
|
164
|
+
async function getToken() {
|
|
165
|
+
const current = store.getSnapshot().jwt;
|
|
166
|
+
if (current && !isExpiring(current))
|
|
167
|
+
return current;
|
|
168
|
+
if (!refreshInFlight) {
|
|
169
|
+
refreshInFlight = refreshJwt().finally(() => {
|
|
170
|
+
refreshInFlight = null;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
await refreshInFlight;
|
|
174
|
+
const next = store.getSnapshot().jwt;
|
|
175
|
+
return next && !isExpiring(next) ? next : null;
|
|
176
|
+
}
|
|
177
|
+
// Single-flight within this instance; the cross-tab lock below extends the
|
|
178
|
+
// dedup across tabs of the same origin.
|
|
179
|
+
let guestInFlight = null;
|
|
180
|
+
/**
|
|
181
|
+
* Ensures a session exists, silently creating a persistent guest when none
|
|
182
|
+
* does. Idempotent and never throws (a failed mint resolves false so the
|
|
183
|
+
* caller can fall back to flow.login()).
|
|
184
|
+
*
|
|
185
|
+
* Restore-first under a cross-tab lock is the dedup: a returning or
|
|
186
|
+
* concurrently-minting visitor reuses their existing guest/full session
|
|
187
|
+
* instead of spawning a duplicate. The guest-mint branch writes only to the
|
|
188
|
+
* in-memory store (never IDB) — IDB is reserved for a real passkey credential.
|
|
189
|
+
*/
|
|
190
|
+
async function ensureGuest() {
|
|
191
|
+
if (store.getSnapshot().user)
|
|
192
|
+
return true;
|
|
193
|
+
if (!guestInFlight) {
|
|
194
|
+
guestInFlight = withGuestLock(`flow.id.guest:${host}`, async () => {
|
|
195
|
+
// Re-check under the lock: another tab may have minted the guest (and
|
|
196
|
+
// set the shared id.flow.industries cookie) while we waited, so
|
|
197
|
+
// restore-first collapses concurrent first-visits onto one row.
|
|
198
|
+
if (store.getSnapshot().user)
|
|
199
|
+
return true;
|
|
200
|
+
if (await restore())
|
|
201
|
+
return true;
|
|
202
|
+
try {
|
|
203
|
+
const result = await getDialog().requestSilent(METHODS.guest, []);
|
|
204
|
+
if (!result.user)
|
|
205
|
+
return false;
|
|
206
|
+
store.setState({
|
|
207
|
+
user: result.user,
|
|
208
|
+
jwt: result.jwt,
|
|
209
|
+
// Only a guest has no credential. The server's idempotency path can
|
|
210
|
+
// return a full session here; in that case keep any credential/
|
|
211
|
+
// address already in state rather than stripping signing.
|
|
212
|
+
...(result.user.isGuest ? { credential: null, address: null } : {}),
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}).finally(() => {
|
|
220
|
+
guestInFlight = null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return guestInFlight;
|
|
224
|
+
}
|
|
107
225
|
void (async () => {
|
|
108
226
|
await restoreCredential(store);
|
|
109
227
|
if (options.autoRestore !== false) {
|
|
110
|
-
await restore();
|
|
228
|
+
const restored = await restore();
|
|
229
|
+
// ensureGuest re-checks under a cross-tab lock (restore-first) before
|
|
230
|
+
// minting, so calling it after a failed restore can't fork a visitor into
|
|
231
|
+
// duplicate guests across tabs — the extra restore is the dedup.
|
|
232
|
+
if (!restored && options.autoGuest)
|
|
233
|
+
await ensureGuest();
|
|
234
|
+
}
|
|
235
|
+
else if (options.autoGuest) {
|
|
236
|
+
await ensureGuest();
|
|
111
237
|
}
|
|
112
238
|
})();
|
|
113
239
|
function buildSigningContext() {
|
|
@@ -158,7 +284,12 @@ export function createFlow(options = {}) {
|
|
|
158
284
|
preparation: accessKeyPrep,
|
|
159
285
|
});
|
|
160
286
|
}
|
|
161
|
-
|
|
287
|
+
// The dialog owns closing in every interactive path: sign-in sends "close"
|
|
288
|
+
// immediately after responding, sign-up after its brief "Welcome" screen.
|
|
289
|
+
// Closing here would preempt the welcome screen, and — since the gate can
|
|
290
|
+
// only see the caller's intent, not the flow the user actually completed —
|
|
291
|
+
// it fired even when a flow.login() user navigated to sign-up. So don't
|
|
292
|
+
// close from the connector; let the dialog decide.
|
|
162
293
|
return session;
|
|
163
294
|
}
|
|
164
295
|
/**
|
|
@@ -180,6 +311,11 @@ export function createFlow(options = {}) {
|
|
|
180
311
|
}
|
|
181
312
|
await runLogout(store);
|
|
182
313
|
dialog?.close();
|
|
314
|
+
// An autoGuest app is never truly "signed out" — it always wants at least a
|
|
315
|
+
// guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
|
|
316
|
+
// working after logout instead of vanishing until the next page load.
|
|
317
|
+
if (options.autoGuest)
|
|
318
|
+
await ensureGuest();
|
|
183
319
|
}
|
|
184
320
|
const flow = {
|
|
185
321
|
get user() {
|
|
@@ -197,10 +333,15 @@ export function createFlow(options = {}) {
|
|
|
197
333
|
get isAuthenticated() {
|
|
198
334
|
return store.getSnapshot().user !== null;
|
|
199
335
|
},
|
|
336
|
+
get isGuest() {
|
|
337
|
+
return store.getSnapshot().user?.isGuest === true;
|
|
338
|
+
},
|
|
200
339
|
login,
|
|
201
340
|
logout,
|
|
202
341
|
restore,
|
|
342
|
+
ensureGuest,
|
|
203
343
|
refreshJwt,
|
|
344
|
+
getToken,
|
|
204
345
|
signMessage: async (args) => {
|
|
205
346
|
const mod = await import("./signing");
|
|
206
347
|
return mod.signMessage(buildSigningContext(), args);
|
|
@@ -226,6 +367,7 @@ export function createFlow(options = {}) {
|
|
|
226
367
|
get dialog() {
|
|
227
368
|
return getDialog();
|
|
228
369
|
},
|
|
370
|
+
host,
|
|
229
371
|
};
|
|
230
372
|
currentFlow = flow;
|
|
231
373
|
return flow;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Messenger from "../dialog/remote/Messenger";
|
|
2
|
+
import { bridgeToWindow, makeIframe } from "./iframe-host";
|
|
2
3
|
const HIDDEN_STYLE = {
|
|
3
4
|
position: "fixed",
|
|
4
5
|
inset: "0",
|
|
@@ -38,24 +39,20 @@ export function createDialogHost(options) {
|
|
|
38
39
|
const pending = new Map();
|
|
39
40
|
/**
|
|
40
41
|
* Mounts the iframe and wires up the postMessage bridge. Idempotent — safe
|
|
41
|
-
* to call before every request.
|
|
42
|
-
* clipboard permissions to the dialog (required for passkey ceremonies).
|
|
42
|
+
* to call before every request.
|
|
43
43
|
*/
|
|
44
44
|
function ensureFrame() {
|
|
45
45
|
if (iframe)
|
|
46
46
|
return;
|
|
47
|
-
iframe =
|
|
48
|
-
iframe.src = `${host}`;
|
|
47
|
+
iframe = makeIframe(`${host}`);
|
|
49
48
|
iframe.dataset.flowId = "";
|
|
50
|
-
iframe.allow = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
|
|
51
49
|
Object.assign(iframe.style, HIDDEN_STYLE);
|
|
52
|
-
iframe.style.colorScheme = "normal";
|
|
53
50
|
container.appendChild(iframe);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
// Bind the bridge to this iframe's own window — a page may host more than
|
|
52
|
+
// one same-origin Flow iframe (e.g. the profile widget), and origin
|
|
53
|
+
// filtering can't tell them apart, so without the source filter their
|
|
54
|
+
// `ready`/`close` would cross-fire onto this dialog's bridge.
|
|
55
|
+
messenger = bridgeToWindow(iframe.contentWindow);
|
|
59
56
|
messenger.on("rpc-response", (payload) => {
|
|
60
57
|
const id = payload?.id;
|
|
61
58
|
const handler = pending.get(id);
|
|
@@ -89,11 +86,7 @@ export function createDialogHost(options) {
|
|
|
89
86
|
return;
|
|
90
87
|
if (messenger)
|
|
91
88
|
messenger.destroy();
|
|
92
|
-
messenger =
|
|
93
|
-
from: Messenger.fromWindow(window),
|
|
94
|
-
to: Messenger.fromWindow(popup),
|
|
95
|
-
waitForReady: true,
|
|
96
|
-
});
|
|
89
|
+
messenger = bridgeToWindow(popup);
|
|
97
90
|
}
|
|
98
91
|
let initSent = false;
|
|
99
92
|
/**
|
|
@@ -116,11 +109,20 @@ export function createDialogHost(options) {
|
|
|
116
109
|
if (!iframe)
|
|
117
110
|
return;
|
|
118
111
|
Object.assign(iframe.style, VISIBLE_STYLE);
|
|
112
|
+
// The dialog owns its open/close animation and mounts the overlay (playing
|
|
113
|
+
// the enter animation) on this signal — same mechanism as the profile
|
|
114
|
+
// widget, so every dialog appears and disappears identically.
|
|
115
|
+
void messenger?.send("__internal", { type: "dialog-shown" });
|
|
119
116
|
}
|
|
120
117
|
function hide() {
|
|
121
118
|
if (!iframe)
|
|
122
119
|
return;
|
|
123
|
-
|
|
120
|
+
// Keep the iframe rendered and only drop hit-testing, so the dialog can play
|
|
121
|
+
// its exit animation. display:none would pause the iframe's
|
|
122
|
+
// requestAnimationFrame, making the close (and the next open) skip straight
|
|
123
|
+
// to the end. While hidden the overlay is transparent and click-through.
|
|
124
|
+
iframe.style.pointerEvents = "none";
|
|
125
|
+
void messenger?.send("__internal", { type: "dialog-hidden" });
|
|
124
126
|
}
|
|
125
127
|
function open(opts) {
|
|
126
128
|
if (opts?.mode === "popup") {
|
|
@@ -128,8 +130,11 @@ export function createDialogHost(options) {
|
|
|
128
130
|
return;
|
|
129
131
|
}
|
|
130
132
|
ensureFrame();
|
|
131
|
-
show
|
|
133
|
+
// Init before show so the dialog is initialized/navigated by the time it
|
|
134
|
+
// mounts the overlay on the show signal — the card enters with its content
|
|
135
|
+
// ready rather than flashing the pre-init spinner.
|
|
132
136
|
sendInit(opts);
|
|
137
|
+
show();
|
|
133
138
|
}
|
|
134
139
|
function close() {
|
|
135
140
|
hide();
|
|
@@ -176,6 +181,7 @@ export function createDialogHost(options) {
|
|
|
176
181
|
async function requestSilent(method, params) {
|
|
177
182
|
ensureFrame();
|
|
178
183
|
sendInit();
|
|
184
|
+
await messenger.waitForReady();
|
|
179
185
|
return dispatchRequest(method, params);
|
|
180
186
|
}
|
|
181
187
|
return {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as Messenger from "../dialog/remote/Messenger";
|
|
2
|
+
/**
|
|
3
|
+
* Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
|
|
4
|
+
* copy recovery values. Shared by every Flow iframe host (the dialog host and
|
|
5
|
+
* the profile widget).
|
|
6
|
+
*/
|
|
7
|
+
export declare const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
|
|
8
|
+
/**
|
|
9
|
+
* Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
|
|
10
|
+
* `color-scheme: normal` so the iframe itself stays transparent (the rendered
|
|
11
|
+
* theme is applied to nested card wrappers, not the iframe). The caller owns
|
|
12
|
+
* positioning/visibility and any `data-*` marker.
|
|
13
|
+
*/
|
|
14
|
+
export declare function makeIframe(src: string): HTMLIFrameElement;
|
|
15
|
+
/**
|
|
16
|
+
* Builds a postMessage bridge to a child window (iframe `contentWindow` or
|
|
17
|
+
* popup). Inbound is filtered by `source` so multiple same-origin Flow frames
|
|
18
|
+
* on one page can't cross-fire their `ready`/`close` handshakes; `targetOrigin`
|
|
19
|
+
* pins outbound sends to the host origin when given (omit for "*").
|
|
20
|
+
*/
|
|
21
|
+
export declare function bridgeToWindow(target: Window, options?: {
|
|
22
|
+
targetOrigin?: string;
|
|
23
|
+
}): Messenger.Bridge;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as Messenger from "../dialog/remote/Messenger";
|
|
2
|
+
/**
|
|
3
|
+
* Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
|
|
4
|
+
* copy recovery values. Shared by every Flow iframe host (the dialog host and
|
|
5
|
+
* the profile widget).
|
|
6
|
+
*/
|
|
7
|
+
export const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
|
|
8
|
+
/**
|
|
9
|
+
* Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
|
|
10
|
+
* `color-scheme: normal` so the iframe itself stays transparent (the rendered
|
|
11
|
+
* theme is applied to nested card wrappers, not the iframe). The caller owns
|
|
12
|
+
* positioning/visibility and any `data-*` marker.
|
|
13
|
+
*/
|
|
14
|
+
export function makeIframe(src) {
|
|
15
|
+
const frame = document.createElement("iframe");
|
|
16
|
+
frame.src = src;
|
|
17
|
+
frame.allow = IFRAME_ALLOW;
|
|
18
|
+
frame.style.border = "none";
|
|
19
|
+
frame.style.colorScheme = "normal";
|
|
20
|
+
return frame;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Builds a postMessage bridge to a child window (iframe `contentWindow` or
|
|
24
|
+
* popup). Inbound is filtered by `source` so multiple same-origin Flow frames
|
|
25
|
+
* on one page can't cross-fire their `ready`/`close` handshakes; `targetOrigin`
|
|
26
|
+
* pins outbound sends to the host origin when given (omit for "*").
|
|
27
|
+
*/
|
|
28
|
+
export function bridgeToWindow(target, options = {}) {
|
|
29
|
+
return Messenger.bridge({
|
|
30
|
+
from: Messenger.fromWindow(window, { source: target }),
|
|
31
|
+
to: Messenger.fromWindow(target, options.targetOrigin ? { targetOrigin: options.targetOrigin } : undefined),
|
|
32
|
+
waitForReady: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
1
2
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
2
3
|
export { createDialogHost } from "./dialog-host";
|
|
3
4
|
export { METHODS } from "./methods";
|
|
4
|
-
export
|
|
5
|
+
export { createProfileButton } from "./profile-button";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MountProfileOptions, ProfileButtonHandle } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Mounts the universal Flow ID "profile button" — a persistent inline iframe
|
|
4
|
+
* showing the signed-in user's avatar + username (the collapsed pill). Clicking
|
|
5
|
+
* it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
|
|
6
|
+
* pill stays in place behind the dialog's dimmed backdrop rather than morphing
|
|
7
|
+
* into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
|
|
8
|
+
* `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
|
|
9
|
+
* dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
|
|
10
|
+
* avatars with the cookie session).
|
|
11
|
+
*
|
|
12
|
+
* Resolves the Flow singleton automatically (createFlow() must have run), so
|
|
13
|
+
* callers don't thread the instance through. No-op under SSR.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createProfileButton(options: MountProfileOptions): ProfileButtonHandle;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as Messenger from "../dialog/remote/Messenger";
|
|
2
|
+
import { getFlow, requireFlow } from "./create-flow";
|
|
3
|
+
import { bridgeToWindow, makeIframe } from "./iframe-host";
|
|
4
|
+
const DEFAULT_HOST = "https://id.flow.industries";
|
|
5
|
+
const POSITION_STYLE = {
|
|
6
|
+
"top-right": { top: "0", right: "0" },
|
|
7
|
+
"top-left": { top: "0", left: "0" },
|
|
8
|
+
"bottom-right": { bottom: "0", right: "0" },
|
|
9
|
+
"bottom-left": { bottom: "0", left: "0" },
|
|
10
|
+
};
|
|
11
|
+
const OVERLAY_STYLE = {
|
|
12
|
+
position: "fixed",
|
|
13
|
+
inset: "0",
|
|
14
|
+
width: "100%",
|
|
15
|
+
height: "100%",
|
|
16
|
+
zIndex: "2147483647",
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Mounts the universal Flow ID "profile button" — a persistent inline iframe
|
|
20
|
+
* showing the signed-in user's avatar + username (the collapsed pill). Clicking
|
|
21
|
+
* it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
|
|
22
|
+
* pill stays in place behind the dialog's dimmed backdrop rather than morphing
|
|
23
|
+
* into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
|
|
24
|
+
* `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
|
|
25
|
+
* dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
|
|
26
|
+
* avatars with the cookie session).
|
|
27
|
+
*
|
|
28
|
+
* Resolves the Flow singleton automatically (createFlow() must have run), so
|
|
29
|
+
* callers don't thread the instance through. No-op under SSR.
|
|
30
|
+
*/
|
|
31
|
+
export function createProfileButton(options) {
|
|
32
|
+
if (typeof document === "undefined")
|
|
33
|
+
return { destroy() { } };
|
|
34
|
+
const flow = options.flow ?? getFlow() ?? requireFlow();
|
|
35
|
+
const host = (options.host ?? flow.host ?? DEFAULT_HOST).replace(/\/+$/, "");
|
|
36
|
+
const hostOrigin = new URL(host).origin;
|
|
37
|
+
const theme = options.theme ?? "light dark";
|
|
38
|
+
let createdContainer = null;
|
|
39
|
+
let container = options.container ?? null;
|
|
40
|
+
if (!container) {
|
|
41
|
+
const el = document.createElement("div");
|
|
42
|
+
Object.assign(el.style, {
|
|
43
|
+
position: "fixed",
|
|
44
|
+
zIndex: "2147483000",
|
|
45
|
+
// Never steal input from the host (e.g. the Godot canvas) — only the
|
|
46
|
+
// iframe re-enables pointer events for itself.
|
|
47
|
+
pointerEvents: "none",
|
|
48
|
+
padding: options.padding ?? "1.5rem",
|
|
49
|
+
...POSITION_STYLE[options.position ?? "top-right"],
|
|
50
|
+
});
|
|
51
|
+
document.body.appendChild(el);
|
|
52
|
+
createdContainer = el;
|
|
53
|
+
container = el;
|
|
54
|
+
}
|
|
55
|
+
function makeFrame() {
|
|
56
|
+
const frame = makeIframe(`${host}/dialog/`);
|
|
57
|
+
frame.dataset.flowProfile = "";
|
|
58
|
+
frame.style.display = "block";
|
|
59
|
+
return frame;
|
|
60
|
+
}
|
|
61
|
+
// A page may host several same-origin Flow iframes (this pill, this dialog,
|
|
62
|
+
// the auth login dialog). Origin filtering can't tell them apart, so each
|
|
63
|
+
// bridge is bound to its own iframe window — otherwise their `ready`/`close`
|
|
64
|
+
// handshakes would cross-fire. Identity is pinned to the host origin so the
|
|
65
|
+
// username is never broadcast with "*".
|
|
66
|
+
function makeBridge(frame, route) {
|
|
67
|
+
const bridge = bridgeToWindow(frame.contentWindow, {
|
|
68
|
+
targetOrigin: hostOrigin,
|
|
69
|
+
});
|
|
70
|
+
bridge.send("__internal", {
|
|
71
|
+
type: "init",
|
|
72
|
+
mode: "iframe",
|
|
73
|
+
referrer: { title: document.title },
|
|
74
|
+
theme: { colorScheme: theme },
|
|
75
|
+
route,
|
|
76
|
+
});
|
|
77
|
+
return bridge;
|
|
78
|
+
}
|
|
79
|
+
function currentIdentity() {
|
|
80
|
+
const user = flow.getState().user;
|
|
81
|
+
return user
|
|
82
|
+
? { username: user.username, isGuest: user.isGuest === true }
|
|
83
|
+
: null;
|
|
84
|
+
}
|
|
85
|
+
// ----- the persistent pill (stays mounted; sizes to its own content) -----
|
|
86
|
+
const pill = makeFrame();
|
|
87
|
+
let pillWidth = 0;
|
|
88
|
+
let pillHeight = 0;
|
|
89
|
+
function applyPillSize() {
|
|
90
|
+
Object.assign(pill.style, {
|
|
91
|
+
position: "static",
|
|
92
|
+
width: `${pillWidth}px`,
|
|
93
|
+
height: `${pillHeight}px`,
|
|
94
|
+
pointerEvents: pillWidth > 0 ? "auto" : "none",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
applyPillSize();
|
|
98
|
+
container.appendChild(pill);
|
|
99
|
+
const pillBridge = makeBridge(pill, "/dialog/profile-pill");
|
|
100
|
+
pillBridge.on("__internal", (payload) => {
|
|
101
|
+
if (payload.type === "profile-resize") {
|
|
102
|
+
pillWidth = payload.width;
|
|
103
|
+
pillHeight = payload.height;
|
|
104
|
+
applyPillSize();
|
|
105
|
+
}
|
|
106
|
+
else if (payload.type === "profile-expand") {
|
|
107
|
+
showDialog();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
// ----- the dialog overlay (lazy; a separate fullscreen iframe layered ABOVE
|
|
111
|
+
// the pill, so the pill shows through its dimmed backdrop) -----
|
|
112
|
+
let dialog = null;
|
|
113
|
+
let dialogBridge = null;
|
|
114
|
+
function ensureDialog() {
|
|
115
|
+
if (dialog)
|
|
116
|
+
return;
|
|
117
|
+
dialog = makeFrame();
|
|
118
|
+
Object.assign(dialog.style, OVERLAY_STYLE, {
|
|
119
|
+
pointerEvents: "none",
|
|
120
|
+
});
|
|
121
|
+
document.body.appendChild(dialog);
|
|
122
|
+
dialogBridge = makeBridge(dialog, "/dialog/profile");
|
|
123
|
+
dialogBridge.on("close", () => hideDialog());
|
|
124
|
+
dialogBridge.on("__internal", (payload) => {
|
|
125
|
+
if (payload.type === "profile-login") {
|
|
126
|
+
hideDialog();
|
|
127
|
+
void flow
|
|
128
|
+
.login(payload.mode === "signup" ? { signUp: true } : { signIn: true })
|
|
129
|
+
.catch(() => { });
|
|
130
|
+
}
|
|
131
|
+
else if (payload.type === "profile-logout") {
|
|
132
|
+
hideDialog();
|
|
133
|
+
void flow.logout().catch(() => { });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
void dialogBridge.send("__internal", {
|
|
137
|
+
type: "profile-identity",
|
|
138
|
+
identity: currentIdentity(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// The dialog owns its open/close animation entirely; the host only signals
|
|
142
|
+
// visibility and toggles hit-testing. The overlay iframe is NEVER set to
|
|
143
|
+
// display:none — that pauses the iframe's requestAnimationFrame, so motion's
|
|
144
|
+
// engine is asleep when the next open fires and the animation jumps straight
|
|
145
|
+
// to its end (instant). Keeping it always-rendered, transparent and
|
|
146
|
+
// click-through while hidden keeps every open/close animation reliable.
|
|
147
|
+
function showDialog() {
|
|
148
|
+
ensureDialog();
|
|
149
|
+
if (!dialog)
|
|
150
|
+
return;
|
|
151
|
+
dialog.style.pointerEvents = "auto";
|
|
152
|
+
void dialogBridge?.send("__internal", { type: "dialog-shown" });
|
|
153
|
+
}
|
|
154
|
+
function hideDialog() {
|
|
155
|
+
if (!dialog)
|
|
156
|
+
return;
|
|
157
|
+
dialog.style.pointerEvents = "none";
|
|
158
|
+
void dialogBridge?.send("__internal", { type: "dialog-hidden" });
|
|
159
|
+
}
|
|
160
|
+
// ----- keep both iframes' identity in sync with the Flow session -----
|
|
161
|
+
// flow.subscribe fires on any store change (jwt/credential/address too), so
|
|
162
|
+
// skip re-sending an identical identity (e.g. on every silent JWT refresh).
|
|
163
|
+
let lastIdentityKey;
|
|
164
|
+
function pushIdentity() {
|
|
165
|
+
const identity = currentIdentity();
|
|
166
|
+
const key = identity ? `${identity.username}|${identity.isGuest}` : "none";
|
|
167
|
+
if (key === lastIdentityKey)
|
|
168
|
+
return;
|
|
169
|
+
lastIdentityKey = key;
|
|
170
|
+
void pillBridge.send("__internal", { type: "profile-identity", identity });
|
|
171
|
+
if (dialogBridge)
|
|
172
|
+
void dialogBridge.send("__internal", {
|
|
173
|
+
type: "profile-identity",
|
|
174
|
+
identity,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
pushIdentity();
|
|
178
|
+
const unsubscribe = flow.subscribe(pushIdentity);
|
|
179
|
+
return {
|
|
180
|
+
destroy() {
|
|
181
|
+
unsubscribe();
|
|
182
|
+
pillBridge.destroy();
|
|
183
|
+
dialogBridge?.destroy();
|
|
184
|
+
pill.remove();
|
|
185
|
+
dialog?.remove();
|
|
186
|
+
createdContainer?.remove();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Hex, type WalletClient } from "viem";
|
|
2
|
-
import type { SendCallsArgs, SendTransactionArgs, SignMessageArgs, SignTypedDataArgs
|
|
2
|
+
import type { SendCallsArgs, SendTransactionArgs, SigningContext, SignMessageArgs, SignTypedDataArgs } from "../types";
|
|
3
3
|
/**
|
|
4
4
|
* Constructs a viem WalletClient bound to the user's Flow account. Wraps the
|
|
5
5
|
* transport in `walletNamespaceCompat` so Tempo's wallet RPC namespace is
|
|
@@ -108,7 +108,9 @@ export async function buildWalletClient(ctx, chainId) {
|
|
|
108
108
|
return createWalletClient({
|
|
109
109
|
account,
|
|
110
110
|
chain: withAccessKeyAuthorization(chain, address),
|
|
111
|
-
transport: walletNamespaceCompat(transport, {
|
|
111
|
+
transport: walletNamespaceCompat(transport, {
|
|
112
|
+
account,
|
|
113
|
+
}),
|
|
112
114
|
});
|
|
113
115
|
}
|
|
114
116
|
export async function signMessage(ctx, args) {
|
package/dist/sdk/client/store.js
CHANGED
|
@@ -28,7 +28,10 @@ export const initialFlowState = {
|
|
|
28
28
|
function shallowEqual(a, b) {
|
|
29
29
|
if (a === b)
|
|
30
30
|
return true;
|
|
31
|
-
if (typeof a !== "object" ||
|
|
31
|
+
if (typeof a !== "object" ||
|
|
32
|
+
typeof b !== "object" ||
|
|
33
|
+
a === null ||
|
|
34
|
+
b === null) {
|
|
32
35
|
return false;
|
|
33
36
|
}
|
|
34
37
|
const ak = Object.keys(a);
|