@flow-industries/id 0.4.0 → 0.7.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/create-flow.js +52 -20
- package/dist/sdk/client/dialog-host.js +24 -19
- 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 +0 -1
- package/dist/sdk/client/methods.js +0 -1
- package/dist/sdk/client/profile-button.d.ts +15 -0
- package/dist/sdk/client/profile-button.js +189 -0
- package/dist/sdk/client/refresh-store.d.ts +13 -0
- package/dist/sdk/client/refresh-store.js +25 -0
- package/dist/sdk/client/session.js +2 -1
- package/dist/sdk/dialog/remote/Messenger.js +3 -1
- package/dist/sdk/react/index.d.ts +2 -1
- package/dist/sdk/react/index.js +1 -0
- package/dist/sdk/react/profile-button.d.ts +9 -0
- package/dist/sdk/react/profile-button.js +28 -0
- package/dist/sdk/types/auth.d.ts +1 -0
- package/dist/sdk/types/dialog.d.ts +22 -0
- package/dist/sdk/types/events.d.ts +2 -2
- package/dist/sdk/types/index.d.ts +4 -3
- package/dist/sdk/types/messenger.d.ts +30 -0
- package/dist/sdk/types/protocol.d.ts +7 -6
- package/dist/sdk/types/sdk.d.ts +46 -0
- package/dist/sdk/types/xp.d.ts +72 -0
- package/dist/sdk/types/xp.js +1 -0
- package/package.json +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createDialogHost } from "./dialog-host";
|
|
2
2
|
import { idb } from "./idb";
|
|
3
3
|
import { METHODS } from "./methods";
|
|
4
|
+
import { makeRefreshStore } from "./refresh-store";
|
|
4
5
|
import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
|
|
5
6
|
import { createStore, initialFlowState } from "./store";
|
|
6
7
|
const DEFAULT_HOST = "https://id.flow.industries";
|
|
@@ -91,6 +92,7 @@ export function createFlow(options = {}) {
|
|
|
91
92
|
return currentFlow;
|
|
92
93
|
const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
|
|
93
94
|
const dialogUrl = `${host}/dialog/`;
|
|
95
|
+
const refreshStore = makeRefreshStore(host);
|
|
94
96
|
const chains = options.chains ?? [];
|
|
95
97
|
const getChain = (chainId) => {
|
|
96
98
|
if (chainId == null)
|
|
@@ -120,19 +122,39 @@ export function createFlow(options = {}) {
|
|
|
120
122
|
* user has no active session (in which case the caller should fall back to
|
|
121
123
|
* `flow.login()`). Never throws — restore failures are treated as "no session".
|
|
122
124
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Mints a fresh session from the first-party refresh token: POSTs it as a
|
|
127
|
+
* bearer to /api/session/refresh (no cookie, so iOS ITP can't block it),
|
|
128
|
+
* commits the returned session, and persists the rotated token. Returns false
|
|
129
|
+
* (never throws) when there's no token or the server rejects it — the caller
|
|
130
|
+
* then falls back to ensureGuest()/login(). A 401 means the token is dead
|
|
131
|
+
* (expired, revoked, or reuse-detected), so it's dropped.
|
|
132
|
+
*/
|
|
133
|
+
async function refreshViaToken() {
|
|
134
|
+
const token = refreshStore.get();
|
|
135
|
+
if (!token)
|
|
136
|
+
return false;
|
|
125
137
|
try {
|
|
126
|
-
const
|
|
138
|
+
const res = await fetch(`${host}/api/session/refresh`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
if (res.status === 401)
|
|
144
|
+
refreshStore.clear();
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const result = (await res.json());
|
|
148
|
+
refreshStore.set(result.refreshToken);
|
|
127
149
|
store.setState({
|
|
128
150
|
user: result.user,
|
|
129
151
|
jwt: result.jwt,
|
|
130
152
|
credential: result.credential,
|
|
131
153
|
address: result.address,
|
|
132
154
|
});
|
|
133
|
-
// A guest
|
|
134
|
-
//
|
|
135
|
-
//
|
|
155
|
+
// A guest carries a null credential; never persist that — the IDB store is
|
|
156
|
+
// reserved for a real passkey credential and writing null would erase a
|
|
157
|
+
// previously stored one.
|
|
136
158
|
if (result.credential) {
|
|
137
159
|
await idb.set("flow.activeCredential", result.credential);
|
|
138
160
|
}
|
|
@@ -142,10 +164,11 @@ export function createFlow(options = {}) {
|
|
|
142
164
|
return false;
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
167
|
+
// Rotation is cross-tab-safe: the lock serializes refreshes across tabs of the
|
|
168
|
+
// same origin, and refreshViaToken reads the token fresh from storage inside
|
|
169
|
+
// the lock, so a second tab uses the already-rotated token (never a stale one
|
|
170
|
+
// that would trip server-side reuse detection).
|
|
171
|
+
const refreshJwt = () => withGuestLock(`flow.id.refresh:${host}`, refreshViaToken);
|
|
149
172
|
// Dedupe concurrent refreshes: a burst of getToken() calls that all find the
|
|
150
173
|
// cached token expired should trigger one mint, not one per call.
|
|
151
174
|
let refreshInFlight = null;
|
|
@@ -192,17 +215,18 @@ export function createFlow(options = {}) {
|
|
|
192
215
|
return true;
|
|
193
216
|
if (!guestInFlight) {
|
|
194
217
|
guestInFlight = withGuestLock(`flow.id.guest:${host}`, async () => {
|
|
195
|
-
// Re-check under the lock: another tab may have minted the guest
|
|
196
|
-
//
|
|
197
|
-
//
|
|
218
|
+
// Re-check under the lock: another tab may have minted the guest while
|
|
219
|
+
// we waited. Refresh-first (from this origin's stored token) recovers an
|
|
220
|
+
// existing session instead of minting a duplicate guest.
|
|
198
221
|
if (store.getSnapshot().user)
|
|
199
222
|
return true;
|
|
200
|
-
if (await
|
|
223
|
+
if (await refreshViaToken())
|
|
201
224
|
return true;
|
|
202
225
|
try {
|
|
203
226
|
const result = await getDialog().requestSilent(METHODS.guest, []);
|
|
204
227
|
if (!result.user)
|
|
205
228
|
return false;
|
|
229
|
+
refreshStore.set(result.refreshToken);
|
|
206
230
|
store.setState({
|
|
207
231
|
user: result.user,
|
|
208
232
|
jwt: result.jwt,
|
|
@@ -225,10 +249,10 @@ export function createFlow(options = {}) {
|
|
|
225
249
|
void (async () => {
|
|
226
250
|
await restoreCredential(store);
|
|
227
251
|
if (options.autoRestore !== false) {
|
|
228
|
-
const restored = await
|
|
229
|
-
// ensureGuest re-checks under a cross-tab lock
|
|
230
|
-
// minting, so calling it after a failed
|
|
231
|
-
// duplicate guests across tabs — the extra
|
|
252
|
+
const restored = await refreshViaToken();
|
|
253
|
+
// ensureGuest re-checks (refresh-first) under a cross-tab lock before
|
|
254
|
+
// minting, so calling it after a failed refresh can't fork a visitor into
|
|
255
|
+
// duplicate guests across tabs — the extra refresh is the dedup.
|
|
232
256
|
if (!restored && options.autoGuest)
|
|
233
257
|
await ensureGuest();
|
|
234
258
|
}
|
|
@@ -270,12 +294,13 @@ export function createFlow(options = {}) {
|
|
|
270
294
|
accessKeyPrep = await accessKeyModule.prepareAccessKey(accessKeyOptions, chain.id);
|
|
271
295
|
extraCapabilities = { accessKeyHash: accessKeyPrep.accessKeyHash };
|
|
272
296
|
}
|
|
273
|
-
const { session, webauthn } = await runLogin({
|
|
297
|
+
const { session, webauthn, refreshToken } = await runLogin({
|
|
274
298
|
dialog: dialogHost,
|
|
275
299
|
store,
|
|
276
300
|
options: loginOpts,
|
|
277
301
|
...(extraCapabilities ? { extraCapabilities } : {}),
|
|
278
302
|
});
|
|
303
|
+
refreshStore.set(refreshToken);
|
|
279
304
|
if (accessKeyModule && accessKeyPrep && webauthn) {
|
|
280
305
|
await accessKeyModule.finalizeAccessKey({
|
|
281
306
|
address: session.address,
|
|
@@ -310,7 +335,13 @@ export function createFlow(options = {}) {
|
|
|
310
335
|
// Server-side sign-out failed (offline?) — still clear local state.
|
|
311
336
|
}
|
|
312
337
|
await runLogout(store);
|
|
338
|
+
refreshStore.clear();
|
|
313
339
|
dialog?.close();
|
|
340
|
+
// An autoGuest app is never truly "signed out" — it always wants at least a
|
|
341
|
+
// guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
|
|
342
|
+
// working after logout instead of vanishing until the next page load.
|
|
343
|
+
if (options.autoGuest)
|
|
344
|
+
await ensureGuest();
|
|
314
345
|
}
|
|
315
346
|
const flow = {
|
|
316
347
|
get user() {
|
|
@@ -333,7 +364,7 @@ export function createFlow(options = {}) {
|
|
|
333
364
|
},
|
|
334
365
|
login,
|
|
335
366
|
logout,
|
|
336
|
-
restore,
|
|
367
|
+
restore: refreshJwt,
|
|
337
368
|
ensureGuest,
|
|
338
369
|
refreshJwt,
|
|
339
370
|
getToken,
|
|
@@ -362,6 +393,7 @@ export function createFlow(options = {}) {
|
|
|
362
393
|
get dialog() {
|
|
363
394
|
return getDialog();
|
|
364
395
|
},
|
|
396
|
+
host,
|
|
365
397
|
};
|
|
366
398
|
currentFlow = flow;
|
|
367
399
|
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,25 +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 =
|
|
51
|
-
"publickey-credentials-create; publickey-credentials-get; clipboard-write";
|
|
52
49
|
Object.assign(iframe.style, HIDDEN_STYLE);
|
|
53
|
-
iframe.style.colorScheme = "normal";
|
|
54
50
|
container.appendChild(iframe);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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);
|
|
60
56
|
messenger.on("rpc-response", (payload) => {
|
|
61
57
|
const id = payload?.id;
|
|
62
58
|
const handler = pending.get(id);
|
|
@@ -90,11 +86,7 @@ export function createDialogHost(options) {
|
|
|
90
86
|
return;
|
|
91
87
|
if (messenger)
|
|
92
88
|
messenger.destroy();
|
|
93
|
-
messenger =
|
|
94
|
-
from: Messenger.fromWindow(window),
|
|
95
|
-
to: Messenger.fromWindow(popup),
|
|
96
|
-
waitForReady: true,
|
|
97
|
-
});
|
|
89
|
+
messenger = bridgeToWindow(popup);
|
|
98
90
|
}
|
|
99
91
|
let initSent = false;
|
|
100
92
|
/**
|
|
@@ -117,11 +109,20 @@ export function createDialogHost(options) {
|
|
|
117
109
|
if (!iframe)
|
|
118
110
|
return;
|
|
119
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" });
|
|
120
116
|
}
|
|
121
117
|
function hide() {
|
|
122
118
|
if (!iframe)
|
|
123
119
|
return;
|
|
124
|
-
|
|
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" });
|
|
125
126
|
}
|
|
126
127
|
function open(opts) {
|
|
127
128
|
if (opts?.mode === "popup") {
|
|
@@ -129,8 +130,11 @@ export function createDialogHost(options) {
|
|
|
129
130
|
return;
|
|
130
131
|
}
|
|
131
132
|
ensureFrame();
|
|
132
|
-
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.
|
|
133
136
|
sendInit(opts);
|
|
137
|
+
show();
|
|
134
138
|
}
|
|
135
139
|
function close() {
|
|
136
140
|
hide();
|
|
@@ -177,6 +181,7 @@ export function createDialogHost(options) {
|
|
|
177
181
|
async function requestSilent(method, params) {
|
|
178
182
|
ensureFrame();
|
|
179
183
|
sendInit();
|
|
184
|
+
await messenger.waitForReady();
|
|
180
185
|
return dispatchRequest(method, params);
|
|
181
186
|
}
|
|
182
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, Session, } from "../types";
|
|
1
|
+
export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
2
2
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
3
3
|
export { createDialogHost } from "./dialog-host";
|
|
4
4
|
export { METHODS } from "./methods";
|
|
5
|
+
export { createProfileButton } from "./profile-button";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -4,7 +4,6 @@ export declare const METHODS: {
|
|
|
4
4
|
readonly signTypedData: "eth_signTypedData";
|
|
5
5
|
readonly sendTransaction: "eth_sendTransaction";
|
|
6
6
|
readonly sendCalls: "wallet_sendCalls";
|
|
7
|
-
readonly restore: "wallet_restore";
|
|
8
7
|
readonly guest: "wallet_guest";
|
|
9
8
|
readonly signOut: "wallet_signout";
|
|
10
9
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-party persistence for the rotating refresh token. It lives in the
|
|
3
|
+
* consumer app's OWN localStorage (origin-scoped), so it survives reloads and
|
|
4
|
+
* navigations and — unlike the old id.flow.industries cookie — is never a
|
|
5
|
+
* third-party cookie, so iOS ITP can't block it. Keyed by `host` so pointing
|
|
6
|
+
* the SDK at a different Flow ID origin (dev vs prod) doesn't collide.
|
|
7
|
+
*/
|
|
8
|
+
export interface RefreshStore {
|
|
9
|
+
get(): string | null;
|
|
10
|
+
set(token: string): void;
|
|
11
|
+
clear(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function makeRefreshStore(host: string): RefreshStore;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function makeRefreshStore(host) {
|
|
2
|
+
const key = `flow.id.refresh:${host}`;
|
|
3
|
+
return {
|
|
4
|
+
get() {
|
|
5
|
+
try {
|
|
6
|
+
return localStorage.getItem(key);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
set(token) {
|
|
13
|
+
try {
|
|
14
|
+
localStorage.setItem(key, token);
|
|
15
|
+
}
|
|
16
|
+
catch { }
|
|
17
|
+
},
|
|
18
|
+
clear() {
|
|
19
|
+
try {
|
|
20
|
+
localStorage.removeItem(key);
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -40,12 +40,13 @@ export async function runLogin(params) {
|
|
|
40
40
|
const result = (await dialog.request(METHODS.connect, [
|
|
41
41
|
{ capabilities },
|
|
42
42
|
]));
|
|
43
|
-
const { jwt, user, credential, webauthn } = result;
|
|
43
|
+
const { jwt, refreshToken, user, credential, webauthn } = result;
|
|
44
44
|
const address = credentialToAddress(credential);
|
|
45
45
|
await idb.set(CREDENTIAL_KEY, credential);
|
|
46
46
|
store.setState({ user, jwt, credential, address });
|
|
47
47
|
return {
|
|
48
48
|
session: { user, jwt, credential, address },
|
|
49
|
+
refreshToken,
|
|
49
50
|
webauthn,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
@@ -38,7 +38,7 @@ export function from(messenger) {
|
|
|
38
38
|
* isolation that prevents arbitrary pages from injecting messages.
|
|
39
39
|
*/
|
|
40
40
|
export function fromWindow(w, options = {}) {
|
|
41
|
-
const { targetOrigin } = options;
|
|
41
|
+
const { targetOrigin, source } = options;
|
|
42
42
|
const listeners = new Map();
|
|
43
43
|
return from({
|
|
44
44
|
destroy() {
|
|
@@ -54,6 +54,8 @@ export function fromWindow(w, options = {}) {
|
|
|
54
54
|
return;
|
|
55
55
|
if (targetOrigin && event.origin !== targetOrigin)
|
|
56
56
|
return;
|
|
57
|
+
if (source && event.source !== source)
|
|
58
|
+
return;
|
|
57
59
|
listener(event.data.payload, event);
|
|
58
60
|
}
|
|
59
61
|
w.addEventListener("message", handler);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export type { FlowIdProviderProps } from "../types";
|
|
1
|
+
export type { FlowIdProviderProps, ProfileButtonProps } from "../types";
|
|
2
2
|
export { useFlow, useFlowId, useFlowState } from "./hooks";
|
|
3
|
+
export { ProfileButton } from "./profile-button";
|
|
3
4
|
export { FlowIdProvider } from "./provider";
|
package/dist/sdk/react/index.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProfileButtonProps } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Drops the universal Flow ID profile button into a React tree. Renders a host
|
|
4
|
+
* element (style it via `className` for placement/padding — e.g. top-right) that
|
|
5
|
+
* the pill iframe mounts into; the iframe itself is sized to its content and
|
|
6
|
+
* expands to the profile dialog on click. Resolves the Flow instance from a
|
|
7
|
+
* `<FlowIdProvider>` or the createFlow() singleton.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ProfileButton({ className, host, theme }: ProfileButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { createProfileButton } from "../client/profile-button";
|
|
4
|
+
import { useFlow } from "./hooks";
|
|
5
|
+
/**
|
|
6
|
+
* Drops the universal Flow ID profile button into a React tree. Renders a host
|
|
7
|
+
* element (style it via `className` for placement/padding — e.g. top-right) that
|
|
8
|
+
* the pill iframe mounts into; the iframe itself is sized to its content and
|
|
9
|
+
* expands to the profile dialog on click. Resolves the Flow instance from a
|
|
10
|
+
* `<FlowIdProvider>` or the createFlow() singleton.
|
|
11
|
+
*/
|
|
12
|
+
export function ProfileButton({ className, host, theme }) {
|
|
13
|
+
const flow = useFlow();
|
|
14
|
+
const ref = useRef(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const container = ref.current;
|
|
17
|
+
if (!container)
|
|
18
|
+
return;
|
|
19
|
+
const handle = createProfileButton({
|
|
20
|
+
container,
|
|
21
|
+
flow,
|
|
22
|
+
...(host ? { host } : {}),
|
|
23
|
+
...(theme ? { theme } : {}),
|
|
24
|
+
});
|
|
25
|
+
return () => handle.destroy();
|
|
26
|
+
}, [flow, host, theme]);
|
|
27
|
+
return _jsx("div", { ref: ref, className: className });
|
|
28
|
+
}
|
package/dist/sdk/types/auth.d.ts
CHANGED
|
@@ -37,6 +37,27 @@ export type DialogCustomLabels = {
|
|
|
37
37
|
switchAccount?: string;
|
|
38
38
|
signUpLink?: string;
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* The profile fields the widget renders, read from `/api/me` (the cookie
|
|
42
|
+
* session's `session.user`). `image`/`createdAt` are not on the SDK's
|
|
43
|
+
* `FlowUser`, so they only come from this authenticated fetch.
|
|
44
|
+
*/
|
|
45
|
+
export type ProfileData = {
|
|
46
|
+
username: string;
|
|
47
|
+
image?: string | null;
|
|
48
|
+
createdAt?: string;
|
|
49
|
+
isGuest?: boolean;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Identity the embedding host pushes into the widget over postMessage. Lets the
|
|
53
|
+
* pill render the username immediately (and reactively on login/logout) without
|
|
54
|
+
* waiting on `/api/me` — the fast path that also works where third-party cookies
|
|
55
|
+
* are blocked (Safari/Firefox).
|
|
56
|
+
*/
|
|
57
|
+
export type ProfileIdentity = {
|
|
58
|
+
username: string;
|
|
59
|
+
isGuest: boolean;
|
|
60
|
+
};
|
|
40
61
|
export type DialogState = {
|
|
41
62
|
mode: string;
|
|
42
63
|
display: "floating" | "drawer" | "full";
|
|
@@ -50,6 +71,7 @@ export type DialogState = {
|
|
|
50
71
|
username?: string;
|
|
51
72
|
email?: string;
|
|
52
73
|
}>;
|
|
74
|
+
shown: boolean;
|
|
53
75
|
customFeatures?: DialogCustomFeatures;
|
|
54
76
|
customLabels?: DialogCustomLabels;
|
|
55
77
|
};
|
|
@@ -8,8 +8,8 @@ export type AuthOutcome = "success" | "failure" | "info";
|
|
|
8
8
|
export type AuthMode = "sign-up" | "sign-in";
|
|
9
9
|
/** Client-only funnel steps reported via the `/api/events` beacon. */
|
|
10
10
|
export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "done_shown";
|
|
11
|
-
export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
|
|
12
|
-
export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
|
|
11
|
+
export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
|
|
12
|
+
export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "image_upload_forbidden" | "image_upload_rate_limited" | "invalid_image_type" | "image_too_large" | "image_upload_failed" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "refresh_token_invalid" | "refresh_token_expired" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
|
|
13
13
|
/** One flat record per event = one row in the `auth_events` stream. */
|
|
14
14
|
export interface AuthEventRecord {
|
|
15
15
|
service: "auth";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export type { AuthConfig, AuthResponse, AuthResponseWithWebAuthn, FlowCredential, FlowUser, PasskeyPluginOptions, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
|
|
2
|
-
export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, } from "./dialog";
|
|
2
|
+
export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, ProfileData, ProfileIdentity, } from "./dialog";
|
|
3
3
|
export type { AuthErrorCode, AuthEventName, AuthEventRecord, AuthMode, AuthOutcome, BeaconBody, FunnelStep, } from "./events";
|
|
4
4
|
export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
|
|
5
|
-
export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult,
|
|
5
|
+
export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
|
|
6
6
|
export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
|
|
7
|
-
export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowIdProviderProps, FlowState, Listener, LoginOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
|
|
7
|
+
export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowIdProviderProps, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
|
|
8
8
|
export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
|
|
9
|
+
export type { ActionDayContext, ActionEventKind, ActionSessionState, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
|
|
@@ -65,6 +65,7 @@ export type Schema = [
|
|
|
65
65
|
};
|
|
66
66
|
features?: Record<string, boolean>;
|
|
67
67
|
labels?: Record<string, string>;
|
|
68
|
+
route?: string;
|
|
68
69
|
} | {
|
|
69
70
|
type: "switch";
|
|
70
71
|
mode: "iframe" | "popup" | "popup-standalone" | "page";
|
|
@@ -77,6 +78,27 @@ export type Schema = [
|
|
|
77
78
|
theme: {
|
|
78
79
|
colorScheme?: string;
|
|
79
80
|
};
|
|
81
|
+
} | {
|
|
82
|
+
type: "profile-resize";
|
|
83
|
+
width: number;
|
|
84
|
+
height: number;
|
|
85
|
+
} | {
|
|
86
|
+
type: "profile-expand";
|
|
87
|
+
} | {
|
|
88
|
+
type: "profile-identity";
|
|
89
|
+
identity: {
|
|
90
|
+
username: string;
|
|
91
|
+
isGuest: boolean;
|
|
92
|
+
} | null;
|
|
93
|
+
} | {
|
|
94
|
+
type: "profile-login";
|
|
95
|
+
mode: "signin" | "signup";
|
|
96
|
+
} | {
|
|
97
|
+
type: "profile-logout";
|
|
98
|
+
} | {
|
|
99
|
+
type: "dialog-shown";
|
|
100
|
+
} | {
|
|
101
|
+
type: "dialog-hidden";
|
|
80
102
|
};
|
|
81
103
|
response: undefined;
|
|
82
104
|
}
|
|
@@ -106,6 +128,14 @@ export type Bridge = WithReady & {
|
|
|
106
128
|
};
|
|
107
129
|
export type FromWindowOptions = {
|
|
108
130
|
targetOrigin?: string;
|
|
131
|
+
/**
|
|
132
|
+
* When set, inbound messages are only accepted from this exact window
|
|
133
|
+
* (`event.source`). Required when more than one same-origin iframe posts to
|
|
134
|
+
* the same host window (e.g. the auth dialog and the profile widget) —
|
|
135
|
+
* origin filtering alone can't tell same-origin frames apart, so without this
|
|
136
|
+
* one frame's `ready` handshake would resolve the other bridge's gate.
|
|
137
|
+
*/
|
|
138
|
+
source?: MessageEventSource | null;
|
|
109
139
|
};
|
|
110
140
|
export type BridgeParameters = {
|
|
111
141
|
from: Messenger;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FlowCredential, FlowUser, WebAuthnSignature } from "./auth";
|
|
2
2
|
import type { Address } from "./sdk";
|
|
3
|
-
export type MethodName = "wallet_connect" | "personal_sign" | "eth_signTypedData" | "eth_sendTransaction" | "wallet_sendCalls" | "
|
|
3
|
+
export type MethodName = "wallet_connect" | "personal_sign" | "eth_signTypedData" | "eth_sendTransaction" | "wallet_sendCalls" | "wallet_guest" | "wallet_signout";
|
|
4
4
|
export type ConnectCapabilities = {
|
|
5
5
|
createAccount?: boolean;
|
|
6
6
|
signIn?: boolean;
|
|
@@ -13,6 +13,8 @@ export type ConnectRequest = [{
|
|
|
13
13
|
}];
|
|
14
14
|
export type ConnectResponse = {
|
|
15
15
|
jwt: string;
|
|
16
|
+
/** First-party rotating refresh token; the SDK persists it in the app origin. */
|
|
17
|
+
refreshToken: string;
|
|
16
18
|
user: FlowUser;
|
|
17
19
|
credential: FlowCredential;
|
|
18
20
|
webauthn?: WebAuthnSignature;
|
|
@@ -58,12 +60,12 @@ export type SendCallsRequest = [
|
|
|
58
60
|
export type SendCallsResponse = {
|
|
59
61
|
id: string;
|
|
60
62
|
};
|
|
61
|
-
export type RestoreRequest = [];
|
|
62
63
|
export type RestoreResponse = {
|
|
63
64
|
jwt: string;
|
|
65
|
+
refreshToken: string;
|
|
64
66
|
user: FlowUser;
|
|
65
|
-
credential: FlowCredential;
|
|
66
|
-
address: Address;
|
|
67
|
+
credential: FlowCredential | null;
|
|
68
|
+
address: Address | null;
|
|
67
69
|
};
|
|
68
70
|
export type GuestRequest = [];
|
|
69
71
|
/**
|
|
@@ -73,6 +75,7 @@ export type GuestRequest = [];
|
|
|
73
75
|
*/
|
|
74
76
|
export type GuestResponse = {
|
|
75
77
|
jwt: string;
|
|
78
|
+
refreshToken: string;
|
|
76
79
|
user: FlowUser;
|
|
77
80
|
credential: null;
|
|
78
81
|
address: null;
|
|
@@ -87,7 +90,6 @@ export type MethodParams = {
|
|
|
87
90
|
eth_signTypedData: SignTypedDataRequest;
|
|
88
91
|
eth_sendTransaction: SendTransactionRequest;
|
|
89
92
|
wallet_sendCalls: SendCallsRequest;
|
|
90
|
-
wallet_restore: RestoreRequest;
|
|
91
93
|
wallet_guest: GuestRequest;
|
|
92
94
|
wallet_signout: SignOutRequest;
|
|
93
95
|
};
|
|
@@ -97,7 +99,6 @@ export type MethodResult = {
|
|
|
97
99
|
eth_signTypedData: SignTypedDataResponse;
|
|
98
100
|
eth_sendTransaction: SendTransactionResponse;
|
|
99
101
|
wallet_sendCalls: SendCallsResponse;
|
|
100
|
-
wallet_restore: RestoreResponse;
|
|
101
102
|
wallet_guest: GuestResponse;
|
|
102
103
|
wallet_signout: SignOutResponse;
|
|
103
104
|
};
|
package/dist/sdk/types/sdk.d.ts
CHANGED
|
@@ -129,6 +129,36 @@ export type DialogOpenOptions = {
|
|
|
129
129
|
icon?: string;
|
|
130
130
|
};
|
|
131
131
|
};
|
|
132
|
+
/** Anchor for an auto-created (host-less) profile widget container. */
|
|
133
|
+
export type ProfilePosition = "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
|
134
|
+
export type MountProfileOptions = {
|
|
135
|
+
/**
|
|
136
|
+
* Element to mount the pill iframe into. When omitted, a fixed-position
|
|
137
|
+
* container is created and anchored via `position` — the path of least
|
|
138
|
+
* resistance for non-DOM hosts (the Godot canvas overlay).
|
|
139
|
+
*/
|
|
140
|
+
container?: HTMLElement;
|
|
141
|
+
/** Anchor used only when `container` is omitted. Defaults to "top-right". */
|
|
142
|
+
position?: ProfilePosition;
|
|
143
|
+
/** CSS padding for the auto-created container. Defaults to a responsive inset. */
|
|
144
|
+
padding?: string;
|
|
145
|
+
/**
|
|
146
|
+
* The Flow ID origin serving the widget (e.g. https://id.flow.industries).
|
|
147
|
+
* Defaults to the resolved Flow instance's `host`, then the SDK default.
|
|
148
|
+
*/
|
|
149
|
+
host?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Color scheme for the widget chrome. Set this to match the host (the dark
|
|
152
|
+
* game UI passes "dark"). Defaults to "light dark" (follows the OS).
|
|
153
|
+
*/
|
|
154
|
+
theme?: "light" | "dark" | "light dark";
|
|
155
|
+
/** Explicit Flow instance. Defaults to the createFlow() singleton. */
|
|
156
|
+
flow?: Flow;
|
|
157
|
+
};
|
|
158
|
+
export type ProfileButtonHandle = {
|
|
159
|
+
/** Tears down the iframe, listeners, and any auto-created container. */
|
|
160
|
+
destroy: () => void;
|
|
161
|
+
};
|
|
132
162
|
export type DialogHost = {
|
|
133
163
|
open: (options?: DialogOpenOptions) => void;
|
|
134
164
|
close: () => void;
|
|
@@ -178,6 +208,13 @@ export type Flow = {
|
|
|
178
208
|
subscribe(listener: Listener<FlowState>): () => void;
|
|
179
209
|
getState(): FlowState;
|
|
180
210
|
dialog: DialogHost;
|
|
211
|
+
/**
|
|
212
|
+
* The resolved Flow ID origin this instance talks to (no trailing slash),
|
|
213
|
+
* e.g. `https://id.flow.industries`. Exposed so widgets like the profile
|
|
214
|
+
* button build their iframe URL from the single host configured on createFlow
|
|
215
|
+
* instead of re-resolving it.
|
|
216
|
+
*/
|
|
217
|
+
readonly host: string;
|
|
181
218
|
};
|
|
182
219
|
export type FlowConnectorParameters = Omit<CreateFlowOptions, "chains" | "transports"> & {
|
|
183
220
|
/**
|
|
@@ -202,6 +239,14 @@ export type FlowIdProviderProps = {
|
|
|
202
239
|
flow?: Flow;
|
|
203
240
|
children: ReactNode;
|
|
204
241
|
};
|
|
242
|
+
export type ProfileButtonProps = {
|
|
243
|
+
/** Class applied to the host element the pill iframe mounts into. */
|
|
244
|
+
className?: string;
|
|
245
|
+
/** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
|
|
246
|
+
host?: string;
|
|
247
|
+
/** Color scheme for the widget chrome; defaults to "light dark" (the OS). */
|
|
248
|
+
theme?: "light" | "dark" | "light dark";
|
|
249
|
+
};
|
|
205
250
|
export type RunLoginParams = {
|
|
206
251
|
dialog: DialogHost;
|
|
207
252
|
store: Store<FlowState>;
|
|
@@ -210,6 +255,7 @@ export type RunLoginParams = {
|
|
|
210
255
|
};
|
|
211
256
|
export type RunLoginResult = {
|
|
212
257
|
session: Session;
|
|
258
|
+
refreshToken: string;
|
|
213
259
|
webauthn: ConnectResponse["webauthn"];
|
|
214
260
|
};
|
|
215
261
|
export type FinalizeAccessKeyParams = {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** XP / leveling shapes that cross the server/dialog boundary. */
|
|
2
|
+
export interface LevelProgress {
|
|
3
|
+
/** Level within the current tier, 1..100 — it resets to 1 on each tier-up. */
|
|
4
|
+
level: number;
|
|
5
|
+
/** Prestige tier, 1..5. Reaching level 100 rolls into the next tier. */
|
|
6
|
+
tier: number;
|
|
7
|
+
/** XP earned since reaching the current level. */
|
|
8
|
+
xpIntoLevel: number;
|
|
9
|
+
/** XP between the current level's floor and the next level's floor. */
|
|
10
|
+
xpForNextLevel: number;
|
|
11
|
+
}
|
|
12
|
+
/** One recent ledger grant. Carries only the static source id — the display
|
|
13
|
+
* label resolves client-side from the registry, so renames never touch stored
|
|
14
|
+
* rows or the wire shape. */
|
|
15
|
+
export interface XpRecentGrant {
|
|
16
|
+
source: string;
|
|
17
|
+
amount: number;
|
|
18
|
+
/** ISO timestamp of the grant, for newer-than-last-seen diffing. */
|
|
19
|
+
at: string;
|
|
20
|
+
}
|
|
21
|
+
/** Response shape of GET /api/xp/me. */
|
|
22
|
+
export interface XpSummary extends LevelProgress {
|
|
23
|
+
totalXp: number;
|
|
24
|
+
flowScore: number;
|
|
25
|
+
/** Last few positive grants, newest first. Optional so client-synthesized
|
|
26
|
+
* summaries (the widget's per-frame lerp) stay valid without it. */
|
|
27
|
+
recentGrants?: XpRecentGrant[];
|
|
28
|
+
}
|
|
29
|
+
/** Outcome of one grant attempt. `granted: false` covers idempotent replays,
|
|
30
|
+
* sub-minimum durations and exhausted daily caps — all non-errors. Carries the
|
|
31
|
+
* post-grant standing so reporters (the game server) can refresh a player's
|
|
32
|
+
* nametag level without a second request. */
|
|
33
|
+
export interface XpGrantResult extends LevelProgress {
|
|
34
|
+
granted: boolean;
|
|
35
|
+
amount: number;
|
|
36
|
+
totalXp: number;
|
|
37
|
+
flowScore: number;
|
|
38
|
+
}
|
|
39
|
+
/** Response of GET /api/xp/day/:source — what the in-game action timer needs to
|
|
40
|
+
* estimate a live session's XP: today's prior minutes for the source (so the
|
|
41
|
+
* day-cumulative curve starts from the right point) and the flow multiplier. */
|
|
42
|
+
export interface ActionDayContext {
|
|
43
|
+
priorMinutes: number;
|
|
44
|
+
flowScore: number;
|
|
45
|
+
}
|
|
46
|
+
/** Lifecycle state of a durable action session. `abandoned` is set by the
|
|
47
|
+
* orphan reaper when the game server stops heartbeating a still-active
|
|
48
|
+
* session (e.g. a server crash). */
|
|
49
|
+
export type ActionSessionState = "active" | "finished" | "abandoned";
|
|
50
|
+
/** An action-session transition kind, appended to the action_event log.
|
|
51
|
+
* `focus_lost`/`focus_gained` mark the tab going background/foreground — accrual
|
|
52
|
+
* never stops (they only split focused vs unfocused time for stats). */
|
|
53
|
+
export type ActionEventKind = "start" | "focus_lost" | "focus_gained" | "finish" | "abandon";
|
|
54
|
+
/** Response of GET /api/actions/active — the caller's current in-progress
|
|
55
|
+
* action (or null when idle), plus the day context so the timer can render the
|
|
56
|
+
* live XP estimate from the server-authoritative accrued duration. (Always an
|
|
57
|
+
* `active` session by definition, so the lifecycle state isn't carried.) */
|
|
58
|
+
export interface ActiveActionResponse extends ActionDayContext {
|
|
59
|
+
source: string;
|
|
60
|
+
label: string | null;
|
|
61
|
+
accruedSeconds: number;
|
|
62
|
+
}
|
|
63
|
+
/** Non-sensitive profile facts any player may see about another — nested into
|
|
64
|
+
* the /api/session/verify payload beside `appearance` so game servers can
|
|
65
|
+
* broadcast them (avatar + level badge on nametags) without extra fetches.
|
|
66
|
+
* snake_case keys to match the appearance object in the same payload; the
|
|
67
|
+
* Godot consumer parses both symmetrically. */
|
|
68
|
+
export interface PublicProfile {
|
|
69
|
+
avatar_url: string | null;
|
|
70
|
+
tier: number;
|
|
71
|
+
level: number;
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** XP / leveling shapes that cross the server/dialog boundary. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flow-industries/id",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"main": "./dist/sdk/client/index.js",
|
|
5
5
|
"module": "./dist/sdk/client/index.js",
|
|
6
6
|
"types": "./dist/sdk/client/index.d.ts",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"db:push": "bunx drizzle-kit push",
|
|
54
54
|
"db:studio": "bunx drizzle-kit studio",
|
|
55
55
|
"lint": "biome check",
|
|
56
|
+
"test": "bun test src",
|
|
56
57
|
"format": "biome format --write",
|
|
57
58
|
"check": "biome check --write",
|
|
58
59
|
"typecheck": "tsr generate && tsc --noEmit"
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
"vite": "^7.3.1"
|
|
94
95
|
},
|
|
95
96
|
"dependencies": {
|
|
97
|
+
"@aws-sdk/client-s3": "^3.1073.0",
|
|
96
98
|
"@flow-industries/ui": "^0.15.3",
|
|
97
99
|
"@hono/otel": "^1.1.2",
|
|
98
100
|
"@openobserve/browser-logs": "^0.3.1",
|
|
@@ -121,6 +123,7 @@
|
|
|
121
123
|
"react": "^19.2.4",
|
|
122
124
|
"react-dom": "^19.2.4",
|
|
123
125
|
"react-intersection-observer": "^10.0.2",
|
|
126
|
+
"sharp": "^0.35.2",
|
|
124
127
|
"tempo.ts": "^0.14.2",
|
|
125
128
|
"viem": "2.47.10",
|
|
126
129
|
"wagmi": "^3.4.2",
|