@every-app/sdk 0.1.10 → 0.1.12

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.
Files changed (43) hide show
  1. package/dist/core/authenticatedFetch.d.ts.map +1 -1
  2. package/dist/core/authenticatedFetch.js +4 -0
  3. package/dist/core/index.d.ts +2 -2
  4. package/dist/core/index.d.ts.map +1 -1
  5. package/dist/core/index.js +1 -1
  6. package/dist/core/sessionManager.d.ts +25 -0
  7. package/dist/core/sessionManager.d.ts.map +1 -1
  8. package/dist/core/sessionManager.js +232 -33
  9. package/dist/shared/bypassGatewayLocalOnly.d.ts +14 -0
  10. package/dist/shared/bypassGatewayLocalOnly.d.ts.map +1 -0
  11. package/dist/shared/bypassGatewayLocalOnly.js +41 -0
  12. package/dist/shared/parseMessagePayload.d.ts +3 -0
  13. package/dist/shared/parseMessagePayload.d.ts.map +1 -0
  14. package/dist/shared/parseMessagePayload.js +18 -0
  15. package/dist/tanstack/EmbeddedAppProvider.d.ts.map +1 -1
  16. package/dist/tanstack/EmbeddedAppProvider.js +3 -2
  17. package/dist/tanstack/_internal/useEveryAppSession.d.ts +2 -0
  18. package/dist/tanstack/_internal/useEveryAppSession.d.ts.map +1 -1
  19. package/dist/tanstack/_internal/useEveryAppSession.js +8 -2
  20. package/dist/tanstack/server/authConfig.d.ts.map +1 -1
  21. package/dist/tanstack/server/authConfig.js +7 -1
  22. package/dist/tanstack/server/authenticateRequest.d.ts.map +1 -1
  23. package/dist/tanstack/server/authenticateRequest.js +11 -0
  24. package/dist/tanstack/useEveryAppRouter.d.ts.map +1 -1
  25. package/dist/tanstack/useEveryAppRouter.js +20 -11
  26. package/dist/tanstack/useSessionTokenClientMiddleware.d.ts.map +1 -1
  27. package/dist/tanstack/useSessionTokenClientMiddleware.js +8 -0
  28. package/package.json +1 -1
  29. package/src/cloudflare/server/gateway.test.ts +41 -9
  30. package/src/core/authenticatedFetch.ts +9 -0
  31. package/src/core/index.ts +10 -2
  32. package/src/core/sessionManager.test.ts +143 -0
  33. package/src/core/sessionManager.ts +318 -35
  34. package/src/shared/bypassGatewayLocalOnly.ts +55 -0
  35. package/src/shared/parseMessagePayload.ts +22 -0
  36. package/src/tanstack/EmbeddedAppProvider.tsx +5 -2
  37. package/src/tanstack/_internal/useEveryAppSession.test.ts +40 -0
  38. package/src/tanstack/_internal/useEveryAppSession.tsx +16 -2
  39. package/src/tanstack/server/authConfig.ts +11 -1
  40. package/src/tanstack/server/authenticateRequest.test.ts +32 -0
  41. package/src/tanstack/server/authenticateRequest.ts +21 -0
  42. package/src/tanstack/useEveryAppRouter.tsx +21 -14
  43. package/src/tanstack/useSessionTokenClientMiddleware.ts +12 -0
@@ -1,3 +1,11 @@
1
+ import {
2
+ BYPASS_GATEWAY_LOCAL_ONLY_EMAIL,
3
+ BYPASS_GATEWAY_LOCAL_ONLY_TOKEN,
4
+ BYPASS_GATEWAY_LOCAL_ONLY_USER_ID,
5
+ isBypassGatewayLocalOnlyClient,
6
+ } from "../shared/bypassGatewayLocalOnly.js";
7
+ import { parseMessagePayload } from "../shared/parseMessagePayload.js";
8
+
1
9
  interface SessionToken {
2
10
  token: string;
3
11
  expiresAt: number;
@@ -9,6 +17,52 @@ interface TokenResponse {
9
17
  error?: string;
10
18
  }
11
19
 
20
+ interface TokenUpdateMessage {
21
+ type: "SESSION_TOKEN_UPDATE";
22
+ token?: string;
23
+ expiresAt?: string;
24
+ appId?: string;
25
+ }
26
+
27
+ function isTokenUpdateMessage(data: unknown): data is TokenUpdateMessage {
28
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
29
+ return false;
30
+ }
31
+
32
+ return (data as { type?: unknown }).type === "SESSION_TOKEN_UPDATE";
33
+ }
34
+
35
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
36
+ try {
37
+ const parts = token.split(".");
38
+ if (parts.length !== 3) {
39
+ return null;
40
+ }
41
+
42
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
43
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
44
+ return JSON.parse(atob(padded)) as Record<string, unknown>;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function tokenAudienceMatchesApp(
51
+ payload: Record<string, unknown>,
52
+ appId: string,
53
+ ): boolean {
54
+ const aud = payload.aud;
55
+ if (typeof aud === "string") {
56
+ return aud === appId;
57
+ }
58
+
59
+ if (Array.isArray(aud)) {
60
+ return aud.some((value) => value === appId);
61
+ }
62
+
63
+ return false;
64
+ }
65
+
12
66
  export interface SessionManagerConfig {
13
67
  appId: string;
14
68
  }
@@ -16,12 +70,25 @@ export interface SessionManagerConfig {
16
70
  const MESSAGE_TIMEOUT_MS = 5000;
17
71
  const TOKEN_EXPIRY_BUFFER_MS = 10000;
18
72
  const DEFAULT_TOKEN_LIFETIME_MS = 60000;
73
+ const REACT_NATIVE_TOKEN_WAIT_TIMEOUT_MS = 10000;
74
+
75
+ /**
76
+ * Environment detection types
77
+ */
78
+ export type EmbeddedEnvironment =
79
+ | "iframe"
80
+ | "react-native-webview"
81
+ | "standalone";
19
82
 
20
83
  /**
21
84
  * Detects whether the current window is running inside an iframe.
22
85
  * Returns true if in an iframe, false if running as top-level window.
23
86
  */
24
87
  export function isRunningInIframe(): boolean {
88
+ if (typeof window === "undefined") {
89
+ return false;
90
+ }
91
+
25
92
  try {
26
93
  return window.self !== window.top;
27
94
  } catch {
@@ -31,33 +98,131 @@ export function isRunningInIframe(): boolean {
31
98
  }
32
99
  }
33
100
 
101
+ /**
102
+ * Detects whether the current window is running inside a React Native WebView.
103
+ * Returns true if window.ReactNativeWebView is available.
104
+ */
105
+ export function isRunningInReactNativeWebView(): boolean {
106
+ if (typeof window === "undefined") {
107
+ return false;
108
+ }
109
+
110
+ return (
111
+ typeof (window as any).ReactNativeWebView?.postMessage === "function" ||
112
+ (window as any).isReactNativeWebView === true
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Detects the current embedded environment.
118
+ * Priority: React Native WebView > iframe > standalone
119
+ */
120
+ export function detectEnvironment(): EmbeddedEnvironment {
121
+ const isRNWebView = isRunningInReactNativeWebView();
122
+ const isIframe = isRunningInIframe();
123
+
124
+ if (isRNWebView) {
125
+ return "react-native-webview";
126
+ }
127
+ if (isIframe) {
128
+ return "iframe";
129
+ }
130
+ return "standalone";
131
+ }
132
+
34
133
  export class SessionManager {
35
134
  readonly parentOrigin: string;
36
135
  readonly appId: string;
37
136
  readonly isInIframe: boolean;
137
+ readonly environment: EmbeddedEnvironment;
138
+ readonly isBypassGatewayLocalOnly: boolean;
38
139
 
39
140
  private token: SessionToken | null = null;
40
141
  private refreshPromise: Promise<string> | null = null;
142
+ private tokenWaiters: Array<{
143
+ resolve: (token: string) => void;
144
+ reject: (error: Error) => void;
145
+ timeout: ReturnType<typeof setTimeout>;
146
+ }> = [];
41
147
 
42
148
  constructor(config: SessionManagerConfig) {
43
149
  if (!config.appId) {
44
150
  throw new Error("[SessionManager] appId is required.");
45
151
  }
46
152
 
153
+ this.isBypassGatewayLocalOnly = isBypassGatewayLocalOnlyClient();
154
+
47
155
  const gatewayUrl = import.meta.env.VITE_GATEWAY_URL;
48
- if (!gatewayUrl) {
49
- throw new Error("[SessionManager] VITE_GATEWAY_URL env var is required.");
50
- }
156
+ if (!this.isBypassGatewayLocalOnly) {
157
+ if (!gatewayUrl) {
158
+ throw new Error(
159
+ "[SessionManager] VITE_GATEWAY_URL env var is required.",
160
+ );
161
+ }
51
162
 
52
- try {
53
- new URL(gatewayUrl);
54
- } catch {
55
- throw new Error(`[SessionManager] Invalid gateway URL: ${gatewayUrl}`);
163
+ try {
164
+ new URL(gatewayUrl);
165
+ } catch {
166
+ throw new Error(`[SessionManager] Invalid gateway URL: ${gatewayUrl}`);
167
+ }
56
168
  }
57
169
 
58
170
  this.appId = config.appId;
59
- this.parentOrigin = gatewayUrl;
171
+ this.parentOrigin = this.isBypassGatewayLocalOnly
172
+ ? window.location.origin
173
+ : gatewayUrl;
174
+ this.environment = detectEnvironment();
60
175
  this.isInIframe = isRunningInIframe();
176
+
177
+ if (this.environment === "react-native-webview") {
178
+ this.setupReactNativeTokenListener();
179
+ }
180
+
181
+ if (this.isBypassGatewayLocalOnly) {
182
+ this.token = {
183
+ token: BYPASS_GATEWAY_LOCAL_ONLY_TOKEN,
184
+ expiresAt: Date.now() + DEFAULT_TOKEN_LIFETIME_MS,
185
+ };
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Check if running in an embedded environment (iframe or React Native WebView)
191
+ */
192
+ isEmbedded(): boolean {
193
+ return this.environment !== "standalone";
194
+ }
195
+
196
+ isTrustedHostMessage(event: MessageEvent): boolean {
197
+ if (this.environment === "react-native-webview") {
198
+ const origin = (event as MessageEvent & { origin?: string | null })
199
+ .origin;
200
+ return (
201
+ origin === "react-native" ||
202
+ origin === "null" ||
203
+ origin === "" ||
204
+ origin == null
205
+ );
206
+ }
207
+
208
+ return event.origin === this.parentOrigin;
209
+ }
210
+
211
+ postToHost(message: object): void {
212
+ if (this.environment === "react-native-webview") {
213
+ const postMessage = (window as any).ReactNativeWebView?.postMessage;
214
+ if (typeof postMessage !== "function") {
215
+ throw new Error("React Native WebView bridge is unavailable");
216
+ }
217
+
218
+ postMessage.call(
219
+ (window as any).ReactNativeWebView,
220
+ JSON.stringify(message),
221
+ );
222
+ return;
223
+ }
224
+
225
+ window.parent.postMessage(message, this.parentOrigin);
61
226
  }
62
227
 
63
228
  private isTokenExpiringSoon(
@@ -79,19 +244,17 @@ export class SessionManager {
79
244
  };
80
245
 
81
246
  const handler = (event: MessageEvent) => {
82
- // Security: reject messages from wrong origin (including null from sandboxed iframes)
83
- if (event.origin !== this.parentOrigin) return;
84
- // Safety: ignore malformed messages that could crash the handler
85
- if (!event.data || typeof event.data !== "object") return;
86
- if (
87
- event.data.type === responseType &&
88
- event.data.requestId === requestId
89
- ) {
247
+ // Security: validate message origin based on environment
248
+ if (!this.isTrustedHostMessage(event)) return;
249
+ const data = parseMessagePayload(event.data);
250
+ if (!data) return;
251
+
252
+ if (data.type === responseType && data.requestId === requestId) {
90
253
  cleanup();
91
- if (event.data.error) {
92
- reject(new Error(event.data.error));
254
+ if (typeof data.error === "string" && data.error) {
255
+ reject(new Error(data.error));
93
256
  } else {
94
- resolve(event.data as T);
257
+ resolve(data as T);
95
258
  }
96
259
  }
97
260
  };
@@ -102,15 +265,126 @@ export class SessionManager {
102
265
  }, MESSAGE_TIMEOUT_MS);
103
266
 
104
267
  window.addEventListener("message", handler);
105
- window.parent.postMessage(request, this.parentOrigin);
268
+
269
+ try {
270
+ this.postToHost(request);
271
+ } catch (error) {
272
+ cleanup();
273
+ reject(
274
+ error instanceof Error
275
+ ? error
276
+ : new Error("Failed to post message to host"),
277
+ );
278
+ }
279
+ });
280
+ }
281
+
282
+ private setupReactNativeTokenListener(): void {
283
+ window.addEventListener("message", (event: MessageEvent) => {
284
+ if (!this.isTrustedHostMessage(event)) {
285
+ return;
286
+ }
287
+
288
+ const data = parseMessagePayload(event.data);
289
+ if (!data) {
290
+ return;
291
+ }
292
+
293
+ if (!isTokenUpdateMessage(data)) {
294
+ return;
295
+ }
296
+
297
+ const message = data;
298
+
299
+ if (!message.token || typeof message.token !== "string") {
300
+ return;
301
+ }
302
+
303
+ if (typeof message.appId !== "string" || message.appId !== this.appId) {
304
+ return;
305
+ }
306
+
307
+ if (
308
+ message.expiresAt !== undefined &&
309
+ typeof message.expiresAt !== "string"
310
+ ) {
311
+ return;
312
+ }
313
+
314
+ const payload = decodeJwtPayload(message.token);
315
+ if (!payload || !tokenAudienceMatchesApp(payload, this.appId)) {
316
+ return;
317
+ }
318
+
319
+ // Native controls token minting and pushes updates into the embedded app.
320
+ // We only consume pushed tokens in React Native WebView mode.
321
+ let expiresAt = Date.now() + DEFAULT_TOKEN_LIFETIME_MS;
322
+ if (message.expiresAt) {
323
+ const parsed = new Date(message.expiresAt).getTime();
324
+ if (!Number.isNaN(parsed)) {
325
+ expiresAt = parsed;
326
+ }
327
+ }
328
+
329
+ this.token = {
330
+ token: message.token,
331
+ expiresAt,
332
+ };
333
+
334
+ if (this.tokenWaiters.length > 0) {
335
+ const waiters = this.tokenWaiters;
336
+ this.tokenWaiters = [];
337
+
338
+ for (const waiter of waiters) {
339
+ clearTimeout(waiter.timeout);
340
+ waiter.resolve(message.token);
341
+ }
342
+ }
343
+ });
344
+ }
345
+
346
+ private waitForReactNativeTokenPush(): Promise<string> {
347
+ if (this.token && !this.isTokenExpiringSoon()) {
348
+ return Promise.resolve(this.token.token);
349
+ }
350
+
351
+ return new Promise((resolve, reject) => {
352
+ const timeout = setTimeout(() => {
353
+ this.tokenWaiters = this.tokenWaiters.filter(
354
+ (w) => w.timeout !== timeout,
355
+ );
356
+ reject(
357
+ new Error("Timed out waiting for token from React Native bridge"),
358
+ );
359
+ }, REACT_NATIVE_TOKEN_WAIT_TIMEOUT_MS);
360
+
361
+ this.tokenWaiters.push({ resolve, reject, timeout });
106
362
  });
107
363
  }
108
364
 
109
365
  async requestNewToken(): Promise<string> {
366
+ if (this.isBypassGatewayLocalOnly) {
367
+ this.token = {
368
+ token: BYPASS_GATEWAY_LOCAL_ONLY_TOKEN,
369
+ expiresAt: Date.now() + DEFAULT_TOKEN_LIFETIME_MS,
370
+ };
371
+ return this.token.token;
372
+ }
373
+
110
374
  if (this.refreshPromise) {
111
375
  return this.refreshPromise;
112
376
  }
113
377
 
378
+ if (this.environment === "react-native-webview") {
379
+ this.refreshPromise = this.waitForReactNativeTokenPush();
380
+
381
+ try {
382
+ return await this.refreshPromise;
383
+ } finally {
384
+ this.refreshPromise = null;
385
+ }
386
+ }
387
+
114
388
  this.refreshPromise = (async () => {
115
389
  const requestId = crypto.randomUUID();
116
390
 
@@ -153,6 +427,13 @@ export class SessionManager {
153
427
  }
154
428
 
155
429
  async getToken(): Promise<string> {
430
+ if (this.isBypassGatewayLocalOnly) {
431
+ if (!this.token || this.isTokenExpiringSoon()) {
432
+ return this.requestNewToken();
433
+ }
434
+ return this.token.token;
435
+ }
436
+
156
437
  if (this.isTokenExpiringSoon()) {
157
438
  return this.requestNewToken();
158
439
  }
@@ -183,27 +464,29 @@ export class SessionManager {
183
464
  * Returns null if no valid token is available.
184
465
  */
185
466
  getUser(): { userId: string; email: string } | null {
467
+ if (this.isBypassGatewayLocalOnly) {
468
+ return {
469
+ userId: BYPASS_GATEWAY_LOCAL_ONLY_USER_ID,
470
+ email: BYPASS_GATEWAY_LOCAL_ONLY_EMAIL,
471
+ };
472
+ }
473
+
186
474
  if (!this.token) {
187
475
  return null;
188
476
  }
189
477
 
190
- try {
191
- const parts = this.token.token.split(".");
192
- if (parts.length !== 3) {
193
- return null;
194
- }
195
-
196
- const payload = JSON.parse(atob(parts[1]));
197
- if (!payload.sub) {
198
- return null;
199
- }
478
+ const payload = decodeJwtPayload(this.token.token);
479
+ if (!payload) {
480
+ return null;
481
+ }
200
482
 
201
- return {
202
- userId: payload.sub,
203
- email: payload.email ?? "",
204
- };
205
- } catch {
483
+ if (typeof payload.sub !== "string") {
206
484
  return null;
207
485
  }
486
+
487
+ return {
488
+ userId: payload.sub,
489
+ email: typeof payload.email === "string" ? payload.email : "",
490
+ };
208
491
  }
209
492
  }
@@ -0,0 +1,55 @@
1
+ export const BYPASS_GATEWAY_LOCAL_ONLY_TOKEN = "BYPASS_GATEWAY_LOCAL_ONLY";
2
+ export const BYPASS_GATEWAY_LOCAL_ONLY_USER_ID = "demo-local-user";
3
+ export const BYPASS_GATEWAY_LOCAL_ONLY_EMAIL = "demo-local-user@local";
4
+
5
+ export function isBypassGatewayLocalOnlyClient(): boolean {
6
+ if (import.meta.env.PROD) {
7
+ return false;
8
+ }
9
+
10
+ const metaEnv = (import.meta as { env?: Record<string, string | undefined> })
11
+ .env;
12
+
13
+ return (
14
+ metaEnv?.VITE_BYPASS_GATEWAY_LOCAL_ONLY === "true" ||
15
+ metaEnv?.BYPASS_GATEWAY_LOCAL_ONLY === "true"
16
+ );
17
+ }
18
+
19
+ export function isBypassGatewayLocalOnlyServer(): boolean {
20
+ if (import.meta.env.PROD) {
21
+ return false;
22
+ }
23
+
24
+ const metaEnv = (import.meta as { env?: Record<string, string | undefined> })
25
+ .env;
26
+ const metaValue =
27
+ metaEnv?.BYPASS_GATEWAY_LOCAL_ONLY ??
28
+ metaEnv?.VITE_BYPASS_GATEWAY_LOCAL_ONLY;
29
+ if (metaValue === "true") {
30
+ return true;
31
+ }
32
+
33
+ if (
34
+ typeof process !== "undefined" &&
35
+ process.env?.BYPASS_GATEWAY_LOCAL_ONLY === "true"
36
+ ) {
37
+ return true;
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ export function createBypassGatewayLocalOnlySessionPayload(audience: string) {
44
+ const issuedAt = Math.floor(Date.now() / 1000);
45
+ const expiresAt = issuedAt + 60 * 60;
46
+
47
+ return {
48
+ sub: BYPASS_GATEWAY_LOCAL_ONLY_USER_ID,
49
+ email: BYPASS_GATEWAY_LOCAL_ONLY_EMAIL,
50
+ iss: "local",
51
+ aud: audience,
52
+ iat: issuedAt,
53
+ exp: expiresAt,
54
+ };
55
+ }
@@ -0,0 +1,22 @@
1
+ export type MessagePayload = Record<string, unknown>;
2
+
3
+ export function parseMessagePayload(data: unknown): MessagePayload | null {
4
+ if (data && typeof data === "object" && !Array.isArray(data)) {
5
+ return data as MessagePayload;
6
+ }
7
+
8
+ if (typeof data !== "string") {
9
+ return null;
10
+ }
11
+
12
+ try {
13
+ const parsed = JSON.parse(data);
14
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
15
+ return parsed as MessagePayload;
16
+ }
17
+ } catch {
18
+ return null;
19
+ }
20
+
21
+ return null;
22
+ }
@@ -30,8 +30,11 @@ export function EmbeddedAppProvider({
30
30
 
31
31
  if (!sessionManager) return null;
32
32
 
33
- // Check if the app is running outside of the Gateway iframe
34
- if (!sessionManager.isInIframe) {
33
+ // Check if the app is running outside of the Gateway (iframe or React Native WebView)
34
+ if (
35
+ !sessionManager.isEmbedded() &&
36
+ !sessionManager.isBypassGatewayLocalOnly
37
+ ) {
35
38
  return (
36
39
  <GatewayRequiredError
37
40
  gatewayOrigin={sessionManager.parentOrigin}
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldBootstrapSession } from "./useEveryAppSession";
3
+
4
+ describe("shouldBootstrapSession", () => {
5
+ it("returns false for standalone apps without bypass", () => {
6
+ expect(
7
+ shouldBootstrapSession({
8
+ isEmbedded: () => false,
9
+ isBypassGatewayLocalOnly: false,
10
+ }),
11
+ ).toBe(false);
12
+ });
13
+
14
+ it("returns true for embedded apps", () => {
15
+ expect(
16
+ shouldBootstrapSession({
17
+ isEmbedded: () => true,
18
+ isBypassGatewayLocalOnly: false,
19
+ }),
20
+ ).toBe(true);
21
+ });
22
+
23
+ it("returns true for local bypass mode", () => {
24
+ expect(
25
+ shouldBootstrapSession({
26
+ isEmbedded: () => false,
27
+ isBypassGatewayLocalOnly: true,
28
+ }),
29
+ ).toBe(true);
30
+ });
31
+
32
+ it("does not depend on iframe detection for RN webviews", () => {
33
+ expect(
34
+ shouldBootstrapSession({
35
+ isEmbedded: () => true,
36
+ isBypassGatewayLocalOnly: false,
37
+ }),
38
+ ).toBe(true);
39
+ });
40
+ });
@@ -9,6 +9,20 @@ interface UseEveryAppSessionParams {
9
9
  sessionManagerConfig: SessionManagerConfig;
10
10
  }
11
11
 
12
+ type SessionBootstrapGate = Pick<
13
+ SessionManager,
14
+ "isEmbedded" | "isBypassGatewayLocalOnly"
15
+ >;
16
+
17
+ export function shouldBootstrapSession(
18
+ sessionManager: SessionBootstrapGate,
19
+ ): boolean {
20
+ // Bootstrapping should happen whenever the app is hosted by a trusted container.
21
+ // This intentionally keys off embedded environment semantics (iframe OR RN WebView),
22
+ // not iframe-only detection, so RN can initialize auth from pushed tokens.
23
+ return sessionManager.isEmbedded() || sessionManager.isBypassGatewayLocalOnly;
24
+ }
25
+
12
26
  export function useEveryAppSession({
13
27
  sessionManagerConfig,
14
28
  }: UseEveryAppSessionParams) {
@@ -28,8 +42,8 @@ export function useEveryAppSession({
28
42
 
29
43
  useEffect(() => {
30
44
  if (!sessionManager) return;
31
- // Skip token requests when not in iframe - the app will show GatewayRequiredError instead
32
- if (!sessionManager.isInIframe) return;
45
+ // Skip token bootstrap when not embedded (unless in demo mode) - the app will show GatewayRequiredError instead
46
+ if (!shouldBootstrapSession(sessionManager)) return;
33
47
 
34
48
  const interval = setInterval(() => {
35
49
  setSessionTokenState(sessionManager.getTokenState());
@@ -1,9 +1,19 @@
1
1
  import type { AuthConfig } from "./types.js";
2
2
  import { env } from "cloudflare:workers";
3
+ import { isBypassGatewayLocalOnlyServer } from "../../shared/bypassGatewayLocalOnly.js";
3
4
 
4
5
  export function getAuthConfig(): AuthConfig {
6
+ const bypassGatewayLocalOnlyEnv = (
7
+ env as { BYPASS_GATEWAY_LOCAL_ONLY?: string }
8
+ ).BYPASS_GATEWAY_LOCAL_ONLY;
9
+ const isBypassGatewayLocalOnly =
10
+ import.meta.env.PROD !== true &&
11
+ (bypassGatewayLocalOnlyEnv === "true" ||
12
+ isBypassGatewayLocalOnlyServer() === true);
13
+ const issuer = env.GATEWAY_URL || (isBypassGatewayLocalOnly ? "local" : "");
14
+
5
15
  return {
6
- issuer: env.GATEWAY_URL,
16
+ issuer,
7
17
  audience: import.meta.env.VITE_APP_ID,
8
18
  };
9
19
  }
@@ -16,6 +16,7 @@ vi.mock("@tanstack/react-start/server", () => ({
16
16
 
17
17
  import { authenticateRequest } from "./authenticateRequest";
18
18
  import type { AuthConfig } from "./types";
19
+ import { env } from "cloudflare:workers";
19
20
 
20
21
  describe("authenticateRequest", () => {
21
22
  let keyPair: Awaited<ReturnType<typeof generateKeyPair>>;
@@ -25,6 +26,9 @@ describe("authenticateRequest", () => {
25
26
  issuer: "https://gateway.example.com",
26
27
  audience: "test-app",
27
28
  };
29
+ const workersEnv = env as unknown as {
30
+ BYPASS_GATEWAY_LOCAL_ONLY?: string;
31
+ };
28
32
 
29
33
  beforeEach(async () => {
30
34
  // Generate a fresh key pair for each test
@@ -56,6 +60,7 @@ describe("authenticateRequest", () => {
56
60
 
57
61
  afterEach(() => {
58
62
  vi.restoreAllMocks();
63
+ delete workersEnv.BYPASS_GATEWAY_LOCAL_ONLY;
59
64
  });
60
65
 
61
66
  async function createValidToken(overrides: Record<string, unknown> = {}) {
@@ -117,6 +122,33 @@ describe("authenticateRequest", () => {
117
122
  });
118
123
  });
119
124
 
125
+ describe("BYPASS_GATEWAY_LOCAL_ONLY mode", () => {
126
+ it("accepts the local bypass token when BYPASS_GATEWAY_LOCAL_ONLY is true", async () => {
127
+ workersEnv.BYPASS_GATEWAY_LOCAL_ONLY = "true";
128
+ const request = createRequest("Bearer BYPASS_GATEWAY_LOCAL_ONLY");
129
+
130
+ const result = await authenticateRequest(authConfig, request);
131
+
132
+ expect(result).toMatchObject({
133
+ sub: "demo-local-user",
134
+ email: "demo-local-user@local",
135
+ iss: "local",
136
+ aud: authConfig.audience,
137
+ });
138
+ expect(global.fetch).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("rejects non-bypass tokens when BYPASS_GATEWAY_LOCAL_ONLY is true", async () => {
142
+ workersEnv.BYPASS_GATEWAY_LOCAL_ONLY = "true";
143
+ const request = createRequest("Bearer regular-token");
144
+
145
+ const result = await authenticateRequest(authConfig, request);
146
+
147
+ expect(result).toBeNull();
148
+ expect(global.fetch).not.toHaveBeenCalled();
149
+ });
150
+ });
151
+
120
152
  describe("valid token verification", () => {
121
153
  it("returns payload for valid token with correct issuer and audience", async () => {
122
154
  const token = await createValidToken();