@flow-industries/id 0.3.0 → 0.4.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.
@@ -1,5 +1,5 @@
1
1
  import { Account } from "viem/tempo";
2
- import type { AccessKeyPreparation, Address as Hex, FinalizeAccessKeyParams, FlowCredential, ResolvedAccessKeyOptions, StoredAccessKey } from "../types";
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 same WebAuthn ceremony that signs the user in also signs the
11
- * authorization — saving a second tap.
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 same WebAuthn ceremony that signs the user in also signs the
14
- * authorization — saving a second tap.
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
- await idb.set("flow.activeCredential", result.credential);
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
- dialogHost.close();
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
  /**
@@ -197,10 +328,15 @@ export function createFlow(options = {}) {
197
328
  get isAuthenticated() {
198
329
  return store.getSnapshot().user !== null;
199
330
  },
331
+ get isGuest() {
332
+ return store.getSnapshot().user?.isGuest === true;
333
+ },
200
334
  login,
201
335
  logout,
202
336
  restore,
337
+ ensureGuest,
203
338
  refreshJwt,
339
+ getToken,
204
340
  signMessage: async (args) => {
205
341
  const mod = await import("./signing");
206
342
  return mod.signMessage(buildSigningContext(), args);
@@ -47,7 +47,8 @@ export function createDialogHost(options) {
47
47
  iframe = document.createElement("iframe");
48
48
  iframe.src = `${host}`;
49
49
  iframe.dataset.flowId = "";
50
- iframe.allow = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
50
+ iframe.allow =
51
+ "publickey-credentials-create; publickey-credentials-get; clipboard-write";
51
52
  Object.assign(iframe.style, HIDDEN_STYLE);
52
53
  iframe.style.colorScheme = "normal";
53
54
  container.appendChild(iframe);
@@ -1,4 +1,4 @@
1
+ export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, 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 type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, Session, } from "../types";
@@ -5,5 +5,6 @@ export declare const METHODS: {
5
5
  readonly sendTransaction: "eth_sendTransaction";
6
6
  readonly sendCalls: "wallet_sendCalls";
7
7
  readonly restore: "wallet_restore";
8
+ readonly guest: "wallet_guest";
8
9
  readonly signOut: "wallet_signout";
9
10
  };
@@ -5,5 +5,6 @@ export const METHODS = {
5
5
  sendTransaction: "eth_sendTransaction",
6
6
  sendCalls: "wallet_sendCalls",
7
7
  restore: "wallet_restore",
8
+ guest: "wallet_guest",
8
9
  signOut: "wallet_signout",
9
10
  };
@@ -1,5 +1,5 @@
1
1
  import { type Hex, type WalletClient } from "viem";
2
- import type { SendCallsArgs, SendTransactionArgs, SignMessageArgs, SignTypedDataArgs, SigningContext } from "../types";
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, { account }),
111
+ transport: walletNamespaceCompat(transport, {
112
+ account,
113
+ }),
112
114
  });
113
115
  }
114
116
  export async function signMessage(ctx, args) {
@@ -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" || typeof b !== "object" || a === null || b === null) {
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);
@@ -1,6 +1,5 @@
1
1
  import type { Bridge, BridgeParameters, FromWindowOptions, Messenger } from "../../types";
2
- export type { Bridge, BridgeParameters, FromWindowOptions, Messenger, Payload, QueuedRequest, ReadyOptions, Schema, Topic, WithReady, } from "../../types";
3
- export type { MessageResponse as Response } from "../../types";
2
+ export type { Bridge, BridgeParameters, FromWindowOptions, MessageResponse as Response, Messenger, Payload, QueuedRequest, ReadyOptions, Schema, Topic, WithReady, } from "../../types";
4
3
  export declare function from(messenger: Messenger): Messenger;
5
4
  /**
6
5
  * Wraps a Window in the Messenger interface. Reads come from
@@ -18,16 +18,20 @@ export declare function useFlowState(): FlowState;
18
18
  /**
19
19
  * Convenience hook that returns the most commonly needed identity data and
20
20
  * actions in one go: user/jwt/address plus `login`, `logout`, `restore`,
21
- * and `refreshJwt`. The `isAuthenticated` boolean is derived from `user`
22
- * being set so it tracks reactive state.
21
+ * `refreshJwt`, and `getToken`. The `isAuthenticated` boolean is derived from
22
+ * `user` being set so it tracks reactive state; `isGuest` is true while the
23
+ * user is a guest — gate sensitive actions on `!isGuest`, not `isAuthenticated`.
23
24
  */
24
25
  export declare function useFlowId(): {
25
26
  user: import("../types").FlowUser | null;
26
27
  jwt: string | null;
27
28
  address: `0x${string}` | null;
28
29
  isAuthenticated: boolean;
30
+ isGuest: boolean;
29
31
  login: (options?: import("../types").LoginOptions) => Promise<import("../types").Session>;
30
32
  logout: () => Promise<void>;
31
33
  restore: () => Promise<boolean>;
34
+ ensureGuest: () => Promise<boolean>;
32
35
  refreshJwt: () => Promise<boolean>;
36
+ getToken: () => Promise<string | null>;
33
37
  };
@@ -32,8 +32,9 @@ export function useFlowState() {
32
32
  /**
33
33
  * Convenience hook that returns the most commonly needed identity data and
34
34
  * actions in one go: user/jwt/address plus `login`, `logout`, `restore`,
35
- * and `refreshJwt`. The `isAuthenticated` boolean is derived from `user`
36
- * being set so it tracks reactive state.
35
+ * `refreshJwt`, and `getToken`. The `isAuthenticated` boolean is derived from
36
+ * `user` being set so it tracks reactive state; `isGuest` is true while the
37
+ * user is a guest — gate sensitive actions on `!isGuest`, not `isAuthenticated`.
37
38
  */
38
39
  export function useFlowId() {
39
40
  const flow = useFlow();
@@ -43,9 +44,12 @@ export function useFlowId() {
43
44
  jwt: state.jwt,
44
45
  address: state.address,
45
46
  isAuthenticated: state.user !== null,
47
+ isGuest: state.user?.isGuest ?? false,
46
48
  login: flow.login,
47
49
  logout: flow.logout,
48
50
  restore: flow.restore,
51
+ ensureGuest: flow.ensureGuest,
49
52
  refreshJwt: flow.refreshJwt,
53
+ getToken: flow.getToken,
50
54
  };
51
55
  }
@@ -1,3 +1,3 @@
1
- export { FlowIdProvider } from "./provider";
2
- export { useFlow, useFlowId, useFlowState } from "./hooks";
3
1
  export type { FlowIdProviderProps } from "../types";
2
+ export { useFlow, useFlowId, useFlowState } from "./hooks";
3
+ export { FlowIdProvider } from "./provider";
@@ -1,2 +1,2 @@
1
- export { FlowIdProvider } from "./provider";
2
1
  export { useFlow, useFlowId, useFlowState } from "./hooks";
2
+ export { FlowIdProvider } from "./provider";
@@ -11,5 +11,5 @@ export const FlowContext = createContext(null);
11
11
  * the dependency explicit at a particular boundary.
12
12
  */
13
13
  export function FlowIdProvider({ flow, children }) {
14
- return _jsx(FlowContext.Provider, { value: flow ?? null, children: children });
14
+ return (_jsx(FlowContext.Provider, { value: flow ?? null, children: children }));
15
15
  }
@@ -2,6 +2,13 @@ import type { JWTPayload } from "jose";
2
2
  export type FlowUser = {
3
3
  id: string;
4
4
  username: string;
5
+ /**
6
+ * True only for guest accounts (no passkey/email yet). Optional so the many
7
+ * `{ id, username }` producers stay valid; readers treat `undefined`/`false`
8
+ * as "not a guest". Sensitive actions should gate on `!isGuest` (or the JWT
9
+ * `guest` claim), never on session presence alone.
10
+ */
11
+ isGuest?: boolean;
5
12
  };
6
13
  export type FlowCredential = {
7
14
  id: string;
@@ -42,4 +49,10 @@ export type VerifyOptions = {
42
49
  export type VerifiedFlowJWT = JWTPayload & {
43
50
  sub: string;
44
51
  username?: string;
52
+ /**
53
+ * Present and `true` only on guest tokens. A relying party gating sensitive
54
+ * actions MUST treat a MISSING claim as `guest === true` (fail-safe), so a
55
+ * future token-format change can never silently grant full privileges.
56
+ */
57
+ guest?: boolean;
45
58
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Structured auth-observability events. Emitted server-side to the dedicated
3
+ * OpenObserve `auth_events` stream (and mirrored to stdout). The field names
4
+ * here are the contract every dashboard/alert query depends on — changing one
5
+ * is a breaking change to the dashboard.
6
+ */
7
+ export type AuthOutcome = "success" | "failure" | "info";
8
+ export type AuthMode = "sign-up" | "sign-in";
9
+ /** Client-only funnel steps reported via the `/api/events` beacon. */
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";
13
+ /** One flat record per event = one row in the `auth_events` stream. */
14
+ export interface AuthEventRecord {
15
+ service: "auth";
16
+ env: string;
17
+ event: AuthEventName;
18
+ outcome: AuthOutcome;
19
+ app: string | null;
20
+ audience: string | null;
21
+ userId: string | null;
22
+ username: string | null;
23
+ address: string | null;
24
+ credentialId: string | null;
25
+ funnelId: string | null;
26
+ mode: AuthMode | null;
27
+ step: FunnelStep | null;
28
+ errorCode: AuthErrorCode | null;
29
+ ip: string | null;
30
+ country: string | null;
31
+ userAgent: string | null;
32
+ durationMs: number | null;
33
+ traceId: string | null;
34
+ }
35
+ /** Body accepted by `POST /api/events` from the dialog. */
36
+ export interface BeaconBody {
37
+ step: FunnelStep;
38
+ funnelId: string;
39
+ mode?: AuthMode | null;
40
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Structured auth-observability events. Emitted server-side to the dedicated
3
+ * OpenObserve `auth_events` stream (and mirrored to stdout). The field names
4
+ * here are the contract every dashboard/alert query depends on — changing one
5
+ * is a breaking change to the dashboard.
6
+ */
@@ -1,7 +1,8 @@
1
1
  export type { AuthConfig, AuthResponse, AuthResponseWithWebAuthn, FlowCredential, FlowUser, PasskeyPluginOptions, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
2
- export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowIdProviderProps, FlowConnectorParameters, FlowState, Listener, LoginOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SignMessageArgs, SignTypedDataArgs, SigningContext, StoredAccessKey, Store, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
3
- export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
4
- export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, MethodName, MethodParams, MethodResult, RestoreRequest, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
5
- export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
6
2
  export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, } from "./dialog";
3
+ export type { AuthErrorCode, AuthEventName, AuthEventRecord, AuthMode, AuthOutcome, BeaconBody, FunnelStep, } from "./events";
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, RestoreRequest, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
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
8
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
@@ -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" | "wallet_restore" | "wallet_signout";
3
+ export type MethodName = "wallet_connect" | "personal_sign" | "eth_signTypedData" | "eth_sendTransaction" | "wallet_sendCalls" | "wallet_restore" | "wallet_guest" | "wallet_signout";
4
4
  export type ConnectCapabilities = {
5
5
  createAccount?: boolean;
6
6
  signIn?: boolean;
@@ -65,6 +65,18 @@ export type RestoreResponse = {
65
65
  credential: FlowCredential;
66
66
  address: Address;
67
67
  };
68
+ export type GuestRequest = [];
69
+ /**
70
+ * A guest has no passkey and no derivable wallet address yet, so `credential`
71
+ * and `address` are always null — the shape mirrors RestoreResponse so the SDK
72
+ * can commit either to the same store fields.
73
+ */
74
+ export type GuestResponse = {
75
+ jwt: string;
76
+ user: FlowUser;
77
+ credential: null;
78
+ address: null;
79
+ };
68
80
  export type SignOutRequest = [];
69
81
  export type SignOutResponse = {
70
82
  ok: true;
@@ -76,6 +88,7 @@ export type MethodParams = {
76
88
  eth_sendTransaction: SendTransactionRequest;
77
89
  wallet_sendCalls: SendCallsRequest;
78
90
  wallet_restore: RestoreRequest;
91
+ wallet_guest: GuestRequest;
79
92
  wallet_signout: SignOutRequest;
80
93
  };
81
94
  export type MethodResult = {
@@ -85,6 +98,7 @@ export type MethodResult = {
85
98
  eth_sendTransaction: SendTransactionResponse;
86
99
  wallet_sendCalls: SendCallsResponse;
87
100
  wallet_restore: RestoreResponse;
101
+ wallet_guest: GuestResponse;
88
102
  wallet_signout: SignOutResponse;
89
103
  };
90
104
  export type RpcRequest = {
@@ -1,6 +1,6 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { Chain, Hex, PrepareTransactionRequestParameters, SendTransactionParameters, SignTypedDataParameters, Transport, WalletClient } from "viem";
2
3
  import type { SendCallsParameters } from "viem/actions";
3
- import type { ReactNode } from "react";
4
4
  import type { FlowCredential, FlowUser, WebAuthnSignature } from "./auth";
5
5
  import type { ConnectCapabilities, ConnectResponse } from "./protocol";
6
6
  export type Address = `0x${string}`;
@@ -63,6 +63,13 @@ export type CreateFlowOptions = {
63
63
  * if the user has a valid Flow cookie session, state populates without UI.
64
64
  */
65
65
  autoRestore?: boolean;
66
+ /**
67
+ * If true, every visitor without an existing session is silently given a
68
+ * persistent guest account on startup (no UI, no passkey). Off by default —
69
+ * the consumer app opts in. Signing up later upgrades the guest in place, so
70
+ * data keyed on the Flow user id survives. See `ensureGuest`.
71
+ */
72
+ autoGuest?: boolean;
66
73
  };
67
74
  export type FlowState = {
68
75
  user: FlowUser | null;
@@ -136,10 +143,29 @@ export type Flow = {
136
143
  readonly credential: FlowState["credential"];
137
144
  readonly address: Address | null;
138
145
  readonly isAuthenticated: boolean;
146
+ /**
147
+ * True when the current session is a guest (authenticated but not yet a full
148
+ * account). `isAuthenticated` is also true for guests — gate sensitive
149
+ * actions on `!isGuest`, not on `isAuthenticated`.
150
+ */
151
+ readonly isGuest: boolean;
139
152
  login(options?: LoginOptions): Promise<Session>;
140
153
  logout(): Promise<void>;
141
154
  restore(): Promise<boolean>;
155
+ /**
156
+ * Ensures a session exists: restores an existing one, otherwise silently
157
+ * creates a persistent guest account. Idempotent and single-flight; resolves
158
+ * true if a session (guest or full) is now active, false if creation failed.
159
+ * Called automatically on startup when `autoGuest` is set.
160
+ */
161
+ ensureGuest(): Promise<boolean>;
142
162
  refreshJwt(): Promise<boolean>;
163
+ /**
164
+ * Returns a currently-valid JWT, silently refreshing from the cookie session
165
+ * if the cached token is expired or near expiry; null if there is no session.
166
+ * Prefer this over reading `jwt` directly when calling a backend.
167
+ */
168
+ getToken(): Promise<string | null>;
143
169
  signMessage(args: SignMessageArgs): Promise<Hex>;
144
170
  signTypedData(args: SignTypedDataArgs): Promise<Hex>;
145
171
  sendTransaction(args: SendTransactionArgs): Promise<Hex>;
@@ -11,5 +11,12 @@ export type { VerifiedFlowJWT, VerifyOptions } from "./types";
11
11
  *
12
12
  * `audience` MUST match the calling app's origin — this is what prevents
13
13
  * a token issued for `flow.talk` from being replayed against `flow.game`.
14
+ *
15
+ * Every Flow token carries an explicit boolean `guest` claim (`true` for guest
16
+ * sessions with no passkey yet, `false` for full accounts). When gating
17
+ * sensitive actions, require `payload.guest === false`; treat anything else —
18
+ * `true`, or a MISSING claim (not a Flow token, or a future format change) — as
19
+ * guest and deny. This fail-safe means a dropped or renamed claim can never
20
+ * silently grant a guest full privileges.
14
21
  */
15
22
  export declare function verifyFlowJWT(token: string, opts: VerifyOptions): Promise<VerifiedFlowJWT>;
@@ -12,6 +12,13 @@ const DEFAULT_ISSUER_URL = "https://id.flow.industries";
12
12
  *
13
13
  * `audience` MUST match the calling app's origin — this is what prevents
14
14
  * a token issued for `flow.talk` from being replayed against `flow.game`.
15
+ *
16
+ * Every Flow token carries an explicit boolean `guest` claim (`true` for guest
17
+ * sessions with no passkey yet, `false` for full accounts). When gating
18
+ * sensitive actions, require `payload.guest === false`; treat anything else —
19
+ * `true`, or a MISSING claim (not a Flow token, or a future format change) — as
20
+ * guest and deny. This fail-safe means a dropped or renamed claim can never
21
+ * silently grant a guest full privileges.
15
22
  */
16
23
  export async function verifyFlowJWT(token, opts) {
17
24
  const issuerUrl = opts.issuerUrl ?? DEFAULT_ISSUER_URL;
@@ -8,7 +8,8 @@ function loginOptionsFromCapabilities(capabilities) {
8
8
  return {
9
9
  ...(signUp ? { signUp: true } : {}),
10
10
  ...(!signUp && signIn ? { signIn: true } : {}),
11
- ...(!signUp && (capabilities?.signInHeadless || capabilities?.type === "sign-in")
11
+ ...(!signUp &&
12
+ (capabilities?.signInHeadless || capabilities?.type === "sign-in")
12
13
  ? { signInHeadless: true }
13
14
  : {}),
14
15
  };
@@ -137,8 +138,12 @@ export function flowConnector(parameters = {}) {
137
138
  return f.walletClient({ ...(chainId ? { chainId } : {}) });
138
139
  },
139
140
  async getProvider({ chainId } = {}) {
140
- const client = await this.getClient({ ...(chainId ? { chainId } : {}) });
141
- return { request: client.request };
141
+ const client = await this.getClient({
142
+ ...(chainId ? { chainId } : {}),
143
+ });
144
+ return {
145
+ request: client.request,
146
+ };
142
147
  },
143
148
  onAccountsChanged() { },
144
149
  onChainChanged(chain) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -46,10 +46,16 @@
46
46
  "build:sdk": "tsc --project tsconfig.sdk.json",
47
47
  "prepublishOnly": "bun run build:sdk",
48
48
  "start": "bun run src/index.ts",
49
+ "email:dev": "email dev --dir src/emails --port 3010",
49
50
  "db:generate": "bunx @better-auth/cli generate",
50
- "db:migrate": "bunx drizzle-kit generate && bunx drizzle-kit migrate",
51
+ "db:migrate:gen": "bunx drizzle-kit generate",
52
+ "db:migrate": "bunx drizzle-kit migrate",
51
53
  "db:push": "bunx drizzle-kit push",
52
- "db:studio": "bunx drizzle-kit studio"
54
+ "db:studio": "bunx drizzle-kit studio",
55
+ "lint": "biome check",
56
+ "format": "biome format --write",
57
+ "check": "biome check --write",
58
+ "typecheck": "tsr generate && tsc --noEmit"
53
59
  },
54
60
  "peerDependencies": {
55
61
  "viem": ">=2.47.10",
@@ -67,21 +73,38 @@
67
73
  },
68
74
  "devDependencies": {
69
75
  "@better-auth/cli": "^1.4.17",
76
+ "@biomejs/biome": "2.4.12",
77
+ "@flow-industries/lint": "0.1.0",
78
+ "@react-email/ui": "^6.6.0",
70
79
  "@tailwindcss/vite": "^4.1.18",
71
80
  "@tanstack/react-router-devtools": "^1.160.0",
81
+ "@tanstack/router-cli": "^1.167.17",
72
82
  "@tanstack/router-plugin": "^1.160.0",
73
83
  "@types/bun": "latest",
74
84
  "@types/react": "^19.2.13",
75
85
  "@types/react-dom": "^19.2.3",
76
86
  "@vitejs/plugin-react": "^5.1.3",
77
87
  "drizzle-kit": "^0.31.8",
88
+ "pino-pretty": "^13.1.3",
89
+ "react-email": "^6.6.0",
78
90
  "tailwindcss": "^4.1.18",
79
91
  "tw-animate-css": "^1.4.0",
80
92
  "typescript": "^5",
81
93
  "vite": "^7.3.1"
82
94
  },
83
95
  "dependencies": {
84
- "@flow-industries/ui": "^0.15.2",
96
+ "@flow-industries/ui": "^0.15.3",
97
+ "@hono/otel": "^1.1.2",
98
+ "@openobserve/browser-logs": "^0.3.1",
99
+ "@openobserve/browser-rum": "^0.3.1",
100
+ "@opentelemetry/api": "^1.9.1",
101
+ "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
102
+ "@opentelemetry/resources": "^2.7.1",
103
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
104
+ "@opentelemetry/sdk-trace-node": "^2.7.1",
105
+ "@opentelemetry/semantic-conventions": "^1.41.1",
106
+ "@react-email/components": "^1.0.12",
107
+ "@react-email/render": "^2.0.8",
85
108
  "@tanstack/query-sync-storage-persister": "^5.90.22",
86
109
  "@tanstack/react-query": "^5.90.20",
87
110
  "@tanstack/react-query-persist-client": "^5.90.22",
@@ -93,6 +116,7 @@
93
116
  "jose": "^6.1.3",
94
117
  "lucide-react": "^0.563.0",
95
118
  "motion": "^12.33.0",
119
+ "pino": "^10.3.1",
96
120
  "postgres": "^3.4.8",
97
121
  "react": "^19.2.4",
98
122
  "react-dom": "^19.2.4",
@@ -1,97 +0,0 @@
1
- import type { FlowUser, FlowCredential, WebAuthnSignature } from "../types";
2
- export declare const METHODS: {
3
- readonly connect: "wallet_connect";
4
- readonly signMessage: "personal_sign";
5
- readonly signTypedData: "eth_signTypedData";
6
- readonly sendTransaction: "eth_sendTransaction";
7
- readonly sendCalls: "wallet_sendCalls";
8
- readonly restore: "wallet_restore";
9
- readonly signOut: "wallet_signout";
10
- };
11
- export type Method = (typeof METHODS)[keyof typeof METHODS];
12
- export type ConnectCapabilities = {
13
- createAccount?: boolean;
14
- signIn?: boolean;
15
- accessKeyHash?: string;
16
- headless?: boolean;
17
- credentialId?: string;
18
- };
19
- export type ConnectRequest = [{
20
- capabilities?: ConnectCapabilities;
21
- }];
22
- export type ConnectResponse = {
23
- jwt: string;
24
- user: FlowUser;
25
- credential: FlowCredential;
26
- webauthn?: WebAuthnSignature;
27
- };
28
- export type SignMessageRequest = [message: string, address: string];
29
- export type SignMessageResponse = `0x${string}`;
30
- export type TypedDataDomain = {
31
- name?: string;
32
- version?: string;
33
- chainId?: number;
34
- verifyingContract?: string;
35
- salt?: string;
36
- };
37
- export type TypedDataField = {
38
- name: string;
39
- type: string;
40
- };
41
- export type TypedData = {
42
- domain: TypedDataDomain;
43
- types: Record<string, readonly TypedDataField[]>;
44
- primaryType: string;
45
- message: Record<string, unknown>;
46
- };
47
- export type SignTypedDataRequest = [address: string, typedData: TypedData];
48
- export type SignTypedDataResponse = `0x${string}`;
49
- export type Call = {
50
- to?: string;
51
- value?: string;
52
- data?: string;
53
- };
54
- export type TransactionArgs = Call & {
55
- chainId?: string;
56
- };
57
- export type SendTransactionRequest = [TransactionArgs];
58
- export type SendTransactionResponse = `0x${string}`;
59
- export type SendCallsRequest = [
60
- {
61
- calls: readonly Call[];
62
- chainId?: string;
63
- capabilities?: Record<string, unknown>;
64
- }
65
- ];
66
- export type SendCallsResponse = {
67
- id: string;
68
- };
69
- export type RestoreRequest = [];
70
- export type RestoreResponse = {
71
- jwt: string;
72
- user: FlowUser;
73
- credential: FlowCredential;
74
- address: `0x${string}`;
75
- };
76
- export type SignOutRequest = [];
77
- export type SignOutResponse = {
78
- ok: true;
79
- };
80
- export type MethodParams = {
81
- [METHODS.connect]: ConnectRequest;
82
- [METHODS.signMessage]: SignMessageRequest;
83
- [METHODS.signTypedData]: SignTypedDataRequest;
84
- [METHODS.sendTransaction]: SendTransactionRequest;
85
- [METHODS.sendCalls]: SendCallsRequest;
86
- [METHODS.restore]: RestoreRequest;
87
- [METHODS.signOut]: SignOutRequest;
88
- };
89
- export type MethodResult = {
90
- [METHODS.connect]: ConnectResponse;
91
- [METHODS.signMessage]: SignMessageResponse;
92
- [METHODS.signTypedData]: SignTypedDataResponse;
93
- [METHODS.sendTransaction]: SendTransactionResponse;
94
- [METHODS.sendCalls]: SendCallsResponse;
95
- [METHODS.restore]: RestoreResponse;
96
- [METHODS.signOut]: SignOutResponse;
97
- };
@@ -1,9 +0,0 @@
1
- export const METHODS = {
2
- connect: "wallet_connect",
3
- signMessage: "personal_sign",
4
- signTypedData: "eth_signTypedData",
5
- sendTransaction: "eth_sendTransaction",
6
- sendCalls: "wallet_sendCalls",
7
- restore: "wallet_restore",
8
- signOut: "wallet_signout",
9
- };
@@ -1,35 +0,0 @@
1
- import type { Chain } from "viem";
2
- import type { FlowCredential, FlowUser } from "../types";
3
- export type { FlowUser, FlowCredential };
4
- export type Address = `0x${string}`;
5
- export type AccessKeyOptions = {
6
- expiry?: number;
7
- strict?: boolean;
8
- };
9
- export type CreateFlowOptions = {
10
- host?: string;
11
- rpId?: string;
12
- chains?: readonly Chain[];
13
- accessKey?: boolean | AccessKeyOptions;
14
- /**
15
- * If true (default), createFlow attempts a silent restore on startup —
16
- * if the user has a valid Flow cookie session, state populates without UI.
17
- */
18
- autoRestore?: boolean;
19
- };
20
- export type FlowState = {
21
- user: FlowUser | null;
22
- jwt: string | null;
23
- credential: FlowCredential | null;
24
- address: Address | null;
25
- };
26
- export type LoginOptions = {
27
- mode?: "iframe" | "popup";
28
- signUp?: boolean;
29
- };
30
- export type Session = {
31
- user: FlowUser;
32
- jwt: string;
33
- credential: FlowCredential;
34
- address: Address;
35
- };
File without changes
@@ -1,32 +0,0 @@
1
- export type FlowUser = {
2
- id: string;
3
- username: string;
4
- };
5
- export type FlowCredential = {
6
- id: string;
7
- publicKey: string;
8
- };
9
- export type AuthResponse = {
10
- jwt: string;
11
- user: FlowUser;
12
- credential: FlowCredential;
13
- };
14
- export type AuthConfig = {
15
- rpId: string;
16
- rpName: string;
17
- };
18
- export type WebAuthnSignature = {
19
- signature: {
20
- r: string;
21
- s: string;
22
- };
23
- metadata: {
24
- authenticatorData: string;
25
- clientDataJSON: string;
26
- challengeIndex: number;
27
- typeIndex: number;
28
- };
29
- };
30
- export type AuthResponseWithWebAuthn = AuthResponse & {
31
- webauthn?: WebAuthnSignature;
32
- };
package/dist/sdk/types.js DELETED
File without changes