@lumibase/sdk 0.20.0 → 0.21.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/index.cjs CHANGED
@@ -111,12 +111,52 @@ function toErrorBody(status, body) {
111
111
  function createLumiClient(opts) {
112
112
  const fetcher = opts.fetcher ?? globalThis.fetch;
113
113
  const base = opts.url.replace(/\/$/, "");
114
- async function rawRequest(path, init = {}) {
114
+ let currentToken = opts.token;
115
+ let currentRefreshToken = opts.refreshToken;
116
+ let refreshInFlight = null;
117
+ async function attemptRefresh() {
118
+ if (!currentRefreshToken) return false;
119
+ if (!refreshInFlight) {
120
+ refreshInFlight = (async () => {
121
+ try {
122
+ const res = await fetcher(`${base}/api/v1/auth/refresh`, {
123
+ method: "POST",
124
+ headers: {
125
+ "content-type": "application/json",
126
+ "x-lumi-site": opts.siteId
127
+ },
128
+ // Body transport is CSRF-exempt (the token isn't ambient).
129
+ body: JSON.stringify({ refreshToken: currentRefreshToken })
130
+ });
131
+ if (!res.ok) return false;
132
+ const data = parseResponseBody(await res.text())?.data;
133
+ if (!data?.token || !data.refreshToken) return false;
134
+ currentToken = data.token;
135
+ currentRefreshToken = data.refreshToken;
136
+ try {
137
+ opts.onTokensRefreshed?.({
138
+ token: data.token,
139
+ refreshToken: data.refreshToken,
140
+ refreshTokenExpiresAt: data.refreshTokenExpiresAt
141
+ });
142
+ } catch {
143
+ }
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ } finally {
148
+ refreshInFlight = null;
149
+ }
150
+ })();
151
+ }
152
+ return refreshInFlight;
153
+ }
154
+ async function rawRequest(path, init = {}, retried = false) {
115
155
  const headers = new Headers(init.headers);
116
156
  for (const [key, value] of Object.entries(opts.headers ?? {})) {
117
157
  headers.set(key, value);
118
158
  }
119
- headers.set("authorization", `Bearer ${opts.token}`);
159
+ headers.set("authorization", `Bearer ${currentToken}`);
120
160
  headers.set("x-lumi-site", opts.siteId);
121
161
  if (!headers.has("content-type") && init.body) {
122
162
  headers.set("content-type", "application/json");
@@ -125,6 +165,11 @@ function createLumiClient(opts) {
125
165
  const text = await res.text();
126
166
  const body = parseResponseBody(text);
127
167
  if (!res.ok) {
168
+ if (res.status === 401 && !retried && currentRefreshToken && path !== "/api/v1/auth/refresh") {
169
+ if (await attemptRefresh()) {
170
+ return rawRequest(path, init, true);
171
+ }
172
+ }
128
173
  if (res.status === 401 && opts.onUnauthorized) {
129
174
  try {
130
175
  opts.onUnauthorized();
@@ -137,7 +182,9 @@ function createLumiClient(opts) {
137
182
  }
138
183
  const baseClient = {
139
184
  url: opts.url,
140
- token: opts.token,
185
+ get token() {
186
+ return currentToken;
187
+ },
141
188
  siteId: opts.siteId,
142
189
  fetcher,
143
190
  rawRequest,
package/dist/index.d.cts CHANGED
@@ -1371,8 +1371,29 @@ interface LumiClientOptions {
1371
1371
  * is thrown so a host (e.g. Studio) can clear the stale token and route
1372
1372
  * the operator back to the login screen. Errors thrown by the callback
1373
1373
  * are swallowed so they never mask the original `LumiError`.
1374
+ *
1375
+ * When auto-refresh is enabled (see `refreshToken`), `onUnauthorized`
1376
+ * fires only AFTER a refresh attempt has also failed.
1374
1377
  */
1375
1378
  onUnauthorized?: () => void;
1379
+ /**
1380
+ * Rotating refresh token (body transport). When set, a `401` triggers a
1381
+ * single `POST /api/v1/auth/refresh` with this token; on success the new
1382
+ * access token is adopted and the original request is retried once. The
1383
+ * refresh token rotates each call, so the new pair is surfaced via
1384
+ * {@link onTokensRefreshed} for the host to persist. Omit to keep the
1385
+ * legacy behaviour (no retry; `onUnauthorized` fires immediately).
1386
+ */
1387
+ refreshToken?: string;
1388
+ /**
1389
+ * Called after a successful silent refresh with the rotated token pair so
1390
+ * the host can persist them (the old refresh token is now revoked).
1391
+ */
1392
+ onTokensRefreshed?: (tokens: {
1393
+ token: string;
1394
+ refreshToken: string;
1395
+ refreshTokenExpiresAt?: string;
1396
+ }) => void;
1376
1397
  }
1377
1398
  interface LumiResponse<T> {
1378
1399
  data: T;
package/dist/index.d.ts CHANGED
@@ -1371,8 +1371,29 @@ interface LumiClientOptions {
1371
1371
  * is thrown so a host (e.g. Studio) can clear the stale token and route
1372
1372
  * the operator back to the login screen. Errors thrown by the callback
1373
1373
  * are swallowed so they never mask the original `LumiError`.
1374
+ *
1375
+ * When auto-refresh is enabled (see `refreshToken`), `onUnauthorized`
1376
+ * fires only AFTER a refresh attempt has also failed.
1374
1377
  */
1375
1378
  onUnauthorized?: () => void;
1379
+ /**
1380
+ * Rotating refresh token (body transport). When set, a `401` triggers a
1381
+ * single `POST /api/v1/auth/refresh` with this token; on success the new
1382
+ * access token is adopted and the original request is retried once. The
1383
+ * refresh token rotates each call, so the new pair is surfaced via
1384
+ * {@link onTokensRefreshed} for the host to persist. Omit to keep the
1385
+ * legacy behaviour (no retry; `onUnauthorized` fires immediately).
1386
+ */
1387
+ refreshToken?: string;
1388
+ /**
1389
+ * Called after a successful silent refresh with the rotated token pair so
1390
+ * the host can persist them (the old refresh token is now revoked).
1391
+ */
1392
+ onTokensRefreshed?: (tokens: {
1393
+ token: string;
1394
+ refreshToken: string;
1395
+ refreshTokenExpiresAt?: string;
1396
+ }) => void;
1376
1397
  }
1377
1398
  interface LumiResponse<T> {
1378
1399
  data: T;
package/dist/index.js CHANGED
@@ -34,12 +34,52 @@ function toErrorBody(status, body) {
34
34
  function createLumiClient(opts) {
35
35
  const fetcher = opts.fetcher ?? globalThis.fetch;
36
36
  const base = opts.url.replace(/\/$/, "");
37
- async function rawRequest(path, init = {}) {
37
+ let currentToken = opts.token;
38
+ let currentRefreshToken = opts.refreshToken;
39
+ let refreshInFlight = null;
40
+ async function attemptRefresh() {
41
+ if (!currentRefreshToken) return false;
42
+ if (!refreshInFlight) {
43
+ refreshInFlight = (async () => {
44
+ try {
45
+ const res = await fetcher(`${base}/api/v1/auth/refresh`, {
46
+ method: "POST",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ "x-lumi-site": opts.siteId
50
+ },
51
+ // Body transport is CSRF-exempt (the token isn't ambient).
52
+ body: JSON.stringify({ refreshToken: currentRefreshToken })
53
+ });
54
+ if (!res.ok) return false;
55
+ const data = parseResponseBody(await res.text())?.data;
56
+ if (!data?.token || !data.refreshToken) return false;
57
+ currentToken = data.token;
58
+ currentRefreshToken = data.refreshToken;
59
+ try {
60
+ opts.onTokensRefreshed?.({
61
+ token: data.token,
62
+ refreshToken: data.refreshToken,
63
+ refreshTokenExpiresAt: data.refreshTokenExpiresAt
64
+ });
65
+ } catch {
66
+ }
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ } finally {
71
+ refreshInFlight = null;
72
+ }
73
+ })();
74
+ }
75
+ return refreshInFlight;
76
+ }
77
+ async function rawRequest(path, init = {}, retried = false) {
38
78
  const headers = new Headers(init.headers);
39
79
  for (const [key, value] of Object.entries(opts.headers ?? {})) {
40
80
  headers.set(key, value);
41
81
  }
42
- headers.set("authorization", `Bearer ${opts.token}`);
82
+ headers.set("authorization", `Bearer ${currentToken}`);
43
83
  headers.set("x-lumi-site", opts.siteId);
44
84
  if (!headers.has("content-type") && init.body) {
45
85
  headers.set("content-type", "application/json");
@@ -48,6 +88,11 @@ function createLumiClient(opts) {
48
88
  const text = await res.text();
49
89
  const body = parseResponseBody(text);
50
90
  if (!res.ok) {
91
+ if (res.status === 401 && !retried && currentRefreshToken && path !== "/api/v1/auth/refresh") {
92
+ if (await attemptRefresh()) {
93
+ return rawRequest(path, init, true);
94
+ }
95
+ }
51
96
  if (res.status === 401 && opts.onUnauthorized) {
52
97
  try {
53
98
  opts.onUnauthorized();
@@ -60,7 +105,9 @@ function createLumiClient(opts) {
60
105
  }
61
106
  const baseClient = {
62
107
  url: opts.url,
63
- token: opts.token,
108
+ get token() {
109
+ return currentToken;
110
+ },
64
111
  siteId: opts.siteId,
65
112
  fetcher,
66
113
  rawRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumibase/sdk",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "LumiBase JS SDK (REST + WebSocket) + typegen core. Isomorphic ESM.",
5
5
  "type": "module",
6
6
  "repository": {