@flow-industries/id 0.6.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.
@@ -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
- async function restore() {
124
- const dialogHost = getDialog();
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 result = await dialogHost.requestSilent(METHODS.restore, []);
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 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.
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
- // refreshJwt is semantically the same as restore — both mint a fresh JWT
146
- // from the cookie session. We expose two names because consumers reach for
147
- // one or the other based on intent (page-load vs near-expiry).
148
- const refreshJwt = restore;
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 (and
196
- // set the shared id.flow.industries cookie) while we waited, so
197
- // restore-first collapses concurrent first-visits onto one row.
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 restore())
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 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.
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,6 +335,7 @@ 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();
314
340
  // An autoGuest app is never truly "signed out" — it always wants at least a
315
341
  // guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
@@ -338,7 +364,7 @@ export function createFlow(options = {}) {
338
364
  },
339
365
  login,
340
366
  logout,
341
- restore,
367
+ restore: refreshJwt,
342
368
  ensureGuest,
343
369
  refreshJwt,
344
370
  getToken,
@@ -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
  };
@@ -4,7 +4,6 @@ export const METHODS = {
4
4
  signTypedData: "eth_signTypedData",
5
5
  sendTransaction: "eth_sendTransaction",
6
6
  sendCalls: "wallet_sendCalls",
7
- restore: "wallet_restore",
8
7
  guest: "wallet_guest",
9
8
  signOut: "wallet_signout",
10
9
  };
@@ -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
  }
@@ -16,6 +16,7 @@ export type FlowCredential = {
16
16
  };
17
17
  export type AuthResponse = {
18
18
  jwt: string;
19
+ refreshToken: string;
19
20
  user: FlowUser;
20
21
  credential: FlowCredential;
21
22
  };
@@ -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.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.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" | "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";
@@ -2,7 +2,8 @@ export type { AuthConfig, AuthResponse, AuthResponseWithWebAuthn, FlowCredential
2
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, RestoreRequest, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
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
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";
@@ -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_guest" | "wallet_signout";
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
  };
@@ -255,6 +255,7 @@ export type RunLoginParams = {
255
255
  };
256
256
  export type RunLoginResult = {
257
257
  session: Session;
258
+ refreshToken: string;
258
259
  webauthn: ConnectResponse["webauthn"];
259
260
  };
260
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.6.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"