@every-app/sdk 0.1.11 → 0.1.13
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/cloudflare/server/gateway.d.ts.map +1 -1
- package/dist/cloudflare/server/gateway.js +10 -4
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/sessionManager.d.ts +24 -2
- package/dist/core/sessionManager.d.ts.map +1 -1
- package/dist/core/sessionManager.js +191 -27
- package/dist/shared/parseMessagePayload.d.ts +3 -0
- package/dist/shared/parseMessagePayload.d.ts.map +1 -0
- package/dist/shared/parseMessagePayload.js +18 -0
- package/dist/tanstack/EmbeddedAppProvider.d.ts.map +1 -1
- package/dist/tanstack/EmbeddedAppProvider.js +3 -2
- package/dist/tanstack/_internal/useEveryAppSession.d.ts +2 -0
- package/dist/tanstack/_internal/useEveryAppSession.d.ts.map +1 -1
- package/dist/tanstack/_internal/useEveryAppSession.js +8 -2
- package/dist/tanstack/server/authenticateRequest.d.ts.map +1 -1
- package/dist/tanstack/server/authenticateRequest.js +6 -1
- package/dist/tanstack/useEveryAppRouter.d.ts.map +1 -1
- package/dist/tanstack/useEveryAppRouter.js +20 -11
- package/package.json +4 -1
- package/src/cloudflare/server/gateway.test.ts +87 -8
- package/src/cloudflare/server/gateway.ts +13 -4
- package/src/core/index.ts +10 -2
- package/src/core/sessionManager.test.ts +143 -0
- package/src/core/sessionManager.ts +265 -30
- package/src/shared/parseMessagePayload.ts +22 -0
- package/src/tanstack/EmbeddedAppProvider.tsx +5 -2
- package/src/tanstack/_internal/useEveryAppSession.test.ts +40 -0
- package/src/tanstack/_internal/useEveryAppSession.tsx +16 -3
- package/src/tanstack/server/authenticateRequest.test.ts +35 -0
- package/src/tanstack/server/authenticateRequest.ts +7 -1
- package/src/tanstack/useEveryAppRouter.tsx +21 -14
|
@@ -56,31 +56,110 @@ describe("gateway server helpers", () => {
|
|
|
56
56
|
);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it("
|
|
60
|
-
const
|
|
61
|
-
.
|
|
59
|
+
it("throws when absolute URL points to non-gateway origin", async () => {
|
|
60
|
+
const fetchMock = vi
|
|
61
|
+
.spyOn(globalThis, "fetch")
|
|
62
|
+
.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
63
|
+
|
|
64
|
+
const env: TestEnv = {
|
|
65
|
+
GATEWAY_URL: "https://gateway.example.com",
|
|
66
|
+
GATEWAY_APP_API_TOKEN: "eat_test_token",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
fetchGateway({
|
|
71
|
+
env,
|
|
72
|
+
url: "https://evil.example.com/api/ai/openai/v1/responses",
|
|
73
|
+
init: { method: "POST" },
|
|
74
|
+
}),
|
|
75
|
+
).rejects.toThrow(
|
|
76
|
+
"Refusing to send gateway token to non-gateway origin: https://evil.example.com",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("allows absolute URL when it matches gateway origin", async () => {
|
|
83
|
+
const fetchMock = vi
|
|
84
|
+
.spyOn(globalThis, "fetch")
|
|
62
85
|
.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
63
86
|
|
|
64
87
|
const env: TestEnv = {
|
|
65
88
|
GATEWAY_URL: "https://gateway.example.com",
|
|
66
|
-
EVERY_APP_GATEWAY: { fetch: bindingFetch },
|
|
67
89
|
GATEWAY_APP_API_TOKEN: "eat_test_token",
|
|
68
90
|
};
|
|
69
91
|
|
|
70
92
|
await fetchGateway({
|
|
71
93
|
env,
|
|
72
|
-
url: "/api/ai/openai/v1/
|
|
94
|
+
url: "https://gateway.example.com/api/ai/openai/v1/responses",
|
|
73
95
|
init: { method: "POST" },
|
|
74
96
|
});
|
|
75
97
|
|
|
76
|
-
expect(
|
|
77
|
-
const [requestArg] =
|
|
98
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
99
|
+
const [requestArg] = fetchMock.mock.calls[0];
|
|
78
100
|
const request = requestArg as Request;
|
|
79
101
|
expect(request.url).toBe(
|
|
80
|
-
"
|
|
102
|
+
"https://gateway.example.com/api/ai/openai/v1/responses",
|
|
81
103
|
);
|
|
82
104
|
});
|
|
83
105
|
|
|
106
|
+
it("fetches via service binding when available in production", async () => {
|
|
107
|
+
// Service binding is only used when import.meta.env.PROD is true
|
|
108
|
+
const originalProd = import.meta.env.PROD;
|
|
109
|
+
import.meta.env.PROD = true;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const bindingFetch = vi
|
|
113
|
+
.fn()
|
|
114
|
+
.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
115
|
+
|
|
116
|
+
const env: TestEnv = {
|
|
117
|
+
GATEWAY_URL: "https://gateway.example.com",
|
|
118
|
+
EVERY_APP_GATEWAY: { fetch: bindingFetch },
|
|
119
|
+
GATEWAY_APP_API_TOKEN: "eat_test_token",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await fetchGateway({
|
|
123
|
+
env,
|
|
124
|
+
url: "/api/ai/openai/v1/chat/completions",
|
|
125
|
+
init: { method: "POST" },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(bindingFetch).toHaveBeenCalledTimes(1);
|
|
129
|
+
const [requestArg] = bindingFetch.mock.calls[0];
|
|
130
|
+
const request = requestArg as Request;
|
|
131
|
+
expect(request.url).toBe(
|
|
132
|
+
"http://localhost/api/ai/openai/v1/chat/completions",
|
|
133
|
+
);
|
|
134
|
+
} finally {
|
|
135
|
+
import.meta.env.PROD = originalProd;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("skips service binding in development and uses HTTP fetch", async () => {
|
|
140
|
+
const fetchMock = vi
|
|
141
|
+
.spyOn(globalThis, "fetch")
|
|
142
|
+
.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
143
|
+
|
|
144
|
+
const bindingFetch = vi.fn();
|
|
145
|
+
|
|
146
|
+
const env: TestEnv = {
|
|
147
|
+
GATEWAY_URL: "https://gateway.example.com",
|
|
148
|
+
EVERY_APP_GATEWAY: { fetch: bindingFetch },
|
|
149
|
+
GATEWAY_APP_API_TOKEN: "eat_test_token",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await fetchGateway({
|
|
153
|
+
env,
|
|
154
|
+
url: "/api/ai/openai/v1/chat/completions",
|
|
155
|
+
init: { method: "POST" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// In dev, should use HTTP fetch, not service binding
|
|
159
|
+
expect(bindingFetch).not.toHaveBeenCalled();
|
|
160
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
84
163
|
it("always injects app token and strips Authorization header", async () => {
|
|
85
164
|
const fetchMock = vi
|
|
86
165
|
.spyOn(globalThis, "fetch")
|
|
@@ -46,20 +46,29 @@ export async function fetchGateway({
|
|
|
46
46
|
const resolvedRequest = toRequest(url, init, gatewayBaseUrl);
|
|
47
47
|
const authenticatedRequest = applyAppTokenAuth(resolvedRequest, env);
|
|
48
48
|
|
|
49
|
-
// Use service binding
|
|
50
|
-
//
|
|
51
|
-
|
|
49
|
+
// Use service binding in production for zero-latency internal routing.
|
|
50
|
+
// In local dev wrangler still exposes the binding object, but the target
|
|
51
|
+
// service usually isn't running locally, so we skip it and use HTTP fetch.
|
|
52
|
+
if (import.meta.env.PROD && env.EVERY_APP_GATEWAY) {
|
|
52
53
|
const url = new URL(authenticatedRequest.url);
|
|
53
54
|
const bindingUrl = `${SERVICE_BINDING_ORIGIN}${url.pathname}${url.search}`;
|
|
54
55
|
const bindingRequest = new Request(bindingUrl, authenticatedRequest);
|
|
55
56
|
return env.EVERY_APP_GATEWAY.fetch(bindingRequest);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
//
|
|
59
|
+
// HTTP fetch – used in local dev, or as a fallback when no binding exists
|
|
59
60
|
return fetch(authenticatedRequest);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function applyAppTokenAuth(request: Request, env: GatewayEnv): Request {
|
|
64
|
+
const gatewayOrigin = new URL(getGatewayUrl(env)).origin;
|
|
65
|
+
const requestOrigin = new URL(request.url).origin;
|
|
66
|
+
if (requestOrigin !== gatewayOrigin) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Refusing to send gateway token to non-gateway origin: ${requestOrigin}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
const appToken = getGatewayAppApiToken(env);
|
|
64
73
|
if (!appToken) {
|
|
65
74
|
throw new Error(
|
package/src/core/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
SessionManager,
|
|
3
|
+
isRunningInIframe,
|
|
4
|
+
isRunningInReactNativeWebView,
|
|
5
|
+
detectEnvironment,
|
|
6
|
+
} from "./sessionManager.js";
|
|
7
|
+
export type {
|
|
8
|
+
SessionManagerConfig,
|
|
9
|
+
EmbeddedEnvironment,
|
|
10
|
+
} from "./sessionManager.js";
|
|
3
11
|
|
|
4
12
|
export { authenticatedFetch, getSessionToken } from "./authenticatedFetch.js";
|
|
@@ -47,6 +47,21 @@ describe("SessionManager", () => {
|
|
|
47
47
|
} as MessageEvent);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function createJwtLikeToken(payload: Record<string, unknown>): string {
|
|
51
|
+
return `header.${btoa(JSON.stringify(payload))}.signature`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createBase64UrlJwtLikeToken(
|
|
55
|
+
payload: Record<string, unknown>,
|
|
56
|
+
): string {
|
|
57
|
+
const encoded = btoa(JSON.stringify(payload))
|
|
58
|
+
.replace(/\+/g, "-")
|
|
59
|
+
.replace(/\//g, "_")
|
|
60
|
+
.replace(/=+$/g, "");
|
|
61
|
+
|
|
62
|
+
return `header.${encoded}.signature`;
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
beforeEach(async () => {
|
|
51
66
|
vi.resetModules();
|
|
52
67
|
vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
|
|
@@ -552,6 +567,31 @@ describe("SessionManager", () => {
|
|
|
552
567
|
email: "", // Defaults to empty string
|
|
553
568
|
});
|
|
554
569
|
});
|
|
570
|
+
|
|
571
|
+
it("extracts user info from base64url-encoded JWT payload", async () => {
|
|
572
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
573
|
+
|
|
574
|
+
const fakeToken = createBase64UrlJwtLikeToken({
|
|
575
|
+
sub: "user-123",
|
|
576
|
+
email: "test@example.com",
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const tokenPromise = manager.requestNewToken();
|
|
580
|
+
|
|
581
|
+
simulateTokenResponse({
|
|
582
|
+
token: fakeToken,
|
|
583
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
await tokenPromise;
|
|
587
|
+
|
|
588
|
+
const user = manager.getUser();
|
|
589
|
+
|
|
590
|
+
expect(user).toEqual({
|
|
591
|
+
userId: "user-123",
|
|
592
|
+
email: "test@example.com",
|
|
593
|
+
});
|
|
594
|
+
});
|
|
555
595
|
});
|
|
556
596
|
|
|
557
597
|
describe("default token lifetime", () => {
|
|
@@ -793,4 +833,107 @@ describe("SessionManager", () => {
|
|
|
793
833
|
expect(token).toBe("success-token");
|
|
794
834
|
});
|
|
795
835
|
});
|
|
836
|
+
|
|
837
|
+
describe("react-native-webview token push", () => {
|
|
838
|
+
beforeEach(() => {
|
|
839
|
+
messageHandler = null;
|
|
840
|
+
addEventListenerSpy = vi.fn((event: string, handler: Function) => {
|
|
841
|
+
if (event === "message") {
|
|
842
|
+
messageHandler = handler as (event: MessageEvent) => void;
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
vi.stubGlobal("window", {
|
|
847
|
+
addEventListener: addEventListenerSpy,
|
|
848
|
+
removeEventListener: removeEventListenerSpy,
|
|
849
|
+
ReactNativeWebView: {
|
|
850
|
+
postMessage: vi.fn(),
|
|
851
|
+
},
|
|
852
|
+
parent: {
|
|
853
|
+
postMessage: mockPostMessage,
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("accepts pushed token only when appId and audience match", async () => {
|
|
859
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
860
|
+
const tokenPromise = manager.requestNewToken();
|
|
861
|
+
|
|
862
|
+
const token = createJwtLikeToken({
|
|
863
|
+
sub: "user-1",
|
|
864
|
+
aud: "todo-app",
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
simulateMalformedMessage("react-native", {
|
|
868
|
+
type: "SESSION_TOKEN_UPDATE",
|
|
869
|
+
appId: "todo-app",
|
|
870
|
+
token,
|
|
871
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
await expect(tokenPromise).resolves.toBe(token);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("accepts stringified token update payload with null-like origin", async () => {
|
|
878
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
879
|
+
const tokenPromise = manager.requestNewToken();
|
|
880
|
+
|
|
881
|
+
const token = createJwtLikeToken({
|
|
882
|
+
sub: "user-1",
|
|
883
|
+
aud: "todo-app",
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
simulateMalformedMessage(
|
|
887
|
+
"null",
|
|
888
|
+
JSON.stringify({
|
|
889
|
+
type: "SESSION_TOKEN_UPDATE",
|
|
890
|
+
appId: "todo-app",
|
|
891
|
+
token,
|
|
892
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
await expect(tokenPromise).resolves.toBe(token);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("ignores malformed JSON token updates", async () => {
|
|
900
|
+
vi.useFakeTimers();
|
|
901
|
+
|
|
902
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
903
|
+
const tokenPromise = manager.requestNewToken();
|
|
904
|
+
|
|
905
|
+
simulateMalformedMessage("react-native", "{not-json");
|
|
906
|
+
|
|
907
|
+
vi.advanceTimersByTime(10001);
|
|
908
|
+
|
|
909
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
910
|
+
"Timed out waiting for token from React Native bridge",
|
|
911
|
+
);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("rejects pushed token when audience does not match expected app", async () => {
|
|
915
|
+
vi.useFakeTimers();
|
|
916
|
+
|
|
917
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
918
|
+
const tokenPromise = manager.requestNewToken();
|
|
919
|
+
|
|
920
|
+
const wrongAudienceToken = createJwtLikeToken({
|
|
921
|
+
sub: "user-1",
|
|
922
|
+
aud: "chef-app",
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
simulateMalformedMessage("react-native", {
|
|
926
|
+
type: "SESSION_TOKEN_UPDATE",
|
|
927
|
+
appId: "todo-app",
|
|
928
|
+
token: wrongAudienceToken,
|
|
929
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
vi.advanceTimersByTime(10001);
|
|
933
|
+
|
|
934
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
935
|
+
"Timed out waiting for token from React Native bridge",
|
|
936
|
+
);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
796
939
|
});
|