@hexclave/next 1.0.51 → 1.0.52
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/{admin-app-impl-BGNL1KyZ.d.ts → admin-app-impl-Bga0o6cL.d.ts} +11 -8
- package/dist/{admin-app-impl-BGNL1KyZ.d.ts.map → admin-app-impl-Bga0o6cL.d.ts.map} +1 -1
- package/dist/clickmap/clickmap-core.d.ts +1 -1
- package/dist/clickmap/index.d.ts +1 -1
- package/dist/{client-app-Majjw8Xp.d.ts → client-app-B0kBC67Z.d.ts} +2 -1
- package/dist/{client-app-Majjw8Xp.d.ts.map → client-app-B0kBC67Z.d.ts.map} +1 -1
- package/dist/common-BK_sydxF.d.ts.map +1 -1
- package/dist/components/selected-team-switcher.d.ts +1 -1
- package/dist/components/team-icon.d.ts +1 -1
- package/dist/components/team-switcher.d.ts +1 -1
- package/dist/components-page/account-settings/payments/payments-page.d.ts +1 -1
- package/dist/components-page/account-settings/teams/leave-team-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-api-keys-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-display-name-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-member-invitation-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-member-list-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-page.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-profile-image-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-profile-user-section.d.ts +1 -1
- package/dist/components-page/hexclave-handler-client.d.ts +1 -1
- package/dist/components-page/hexclave-handler.d.ts +1 -1
- package/dist/components-page/sign-out.js +11 -2
- package/dist/components-page/sign-out.js.map +1 -1
- package/dist/components-page/sign-out.test.d.ts +1 -0
- package/dist/components-page/sign-out.test.js +157 -0
- package/dist/components-page/sign-out.test.js.map +1 -0
- package/dist/dev-tool/dev-tool-core.d.ts +1 -1
- package/dist/dev-tool/dev-tool-core.d.ts.map +1 -1
- package/dist/dev-tool/dev-tool-core.js +58 -28
- package/dist/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/dev-tool/dev-tool-styles.js +35 -0
- package/dist/dev-tool/dev-tool-styles.js.map +1 -1
- package/dist/dev-tool/index.d.ts +1 -1
- package/dist/esm/components-page/sign-out.d.ts.map +1 -1
- package/dist/esm/components-page/sign-out.js +12 -3
- package/dist/esm/components-page/sign-out.js.map +1 -1
- package/dist/esm/components-page/sign-out.test.d.ts +1 -0
- package/dist/esm/components-page/sign-out.test.js +156 -0
- package/dist/esm/components-page/sign-out.test.js.map +1 -0
- package/dist/esm/dev-tool/dev-tool-core.d.ts.map +1 -1
- package/dist/esm/dev-tool/dev-tool-core.js +58 -28
- package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/dev-tool/dev-tool-styles.js +35 -0
- package/dist/esm/dev-tool/dev-tool-styles.js.map +1 -1
- package/dist/esm/generated/global-css.d.ts +1 -1
- package/dist/esm/generated/global-css.js +1 -1
- package/dist/esm/generated/global-css.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +5 -4
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +97 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +26 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js +21 -4
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +37 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js +89 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +134 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts +8 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js +73 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js +92 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +27 -6
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
- package/dist/esm/pushed-config-error-overlay/index.js +1 -1
- package/dist/esm/pushed-config-error-overlay/index.js.map +1 -1
- package/dist/esm/utils/session-storage.d.ts +12 -0
- package/dist/esm/utils/session-storage.d.ts.map +1 -0
- package/dist/esm/utils/session-storage.js +30 -0
- package/dist/esm/utils/session-storage.js.map +1 -0
- package/dist/generated/global-css.d.ts +1 -1
- package/dist/generated/global-css.js +1 -1
- package/dist/generated/global-css.js.map +1 -1
- package/dist/{hexclave-provider-IAd7nlbi.d.ts → hexclave-provider-BQ9wrtBt.d.ts} +2 -2
- package/dist/{hexclave-provider-IAd7nlbi.d.ts.map → hexclave-provider-BQ9wrtBt.d.ts.map} +1 -1
- package/dist/{hooks-BKXOF6jh.d.ts → hooks-ykQ7Wsco.d.ts} +2 -2
- package/dist/{hooks-BKXOF6jh.d.ts.map → hooks-ykQ7Wsco.d.ts.map} +1 -1
- package/dist/{index-Ck1qKoin.d.ts → index-DwE1cNLH.d.ts} +2 -2
- package/dist/{index-Ck1qKoin.d.ts.map → index-DwE1cNLH.d.ts.map} +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +97 -0
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +26 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.js +21 -4
- package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +37 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js +92 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +133 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts +8 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js +74 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js +91 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +27 -6
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/index.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/server-app.d.ts +1 -1
- package/dist/lib/hexclave-app/index.d.ts +1 -1
- package/dist/lib/hexclave-app/projects/index.d.ts +1 -1
- package/dist/lib/hexclave-app/teams/index.d.ts +1 -1
- package/dist/lib/hexclave-app/users/index.d.ts +1 -1
- package/dist/lib/hooks.d.ts +1 -1
- package/dist/providers/hexclave-context.d.ts +1 -1
- package/dist/providers/hexclave-provider-client.d.ts +1 -1
- package/dist/providers/hexclave-provider.d.ts +1 -1
- package/dist/pushed-config-error-overlay/index.d.ts +1 -1
- package/dist/pushed-config-error-overlay/index.js +1 -1
- package/dist/pushed-config-error-overlay/index.js.map +1 -1
- package/dist/sign-out-DBnSkfDz.d.ts.map +1 -1
- package/dist/utils/session-storage.d.ts +12 -0
- package/dist/utils/session-storage.d.ts.map +1 -0
- package/dist/utils/session-storage.js +33 -0
- package/dist/utils/session-storage.js.map +1 -0
- package/package.json +4 -4
- package/src/components-page/sign-out.test.tsx +190 -0
- package/src/components-page/sign-out.tsx +22 -3
- package/src/dev-tool/dev-tool-core.ts +100 -35
- package/src/dev-tool/dev-tool-styles.ts +35 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +127 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +34 -1
- package/src/lib/hexclave-app/apps/implementations/common.ts +18 -1
- package/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts +134 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts +140 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.ts +127 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.ts +117 -0
- package/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +31 -5
- package/src/lib/hexclave-app/apps/interfaces/client-app.ts +1 -0
- package/src/pushed-config-error-overlay/index.ts +1 -1
- package/src/utils/session-storage.ts +45 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
|
|
2
|
+
//===========================================
|
|
3
|
+
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
|
+
//===========================================
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { augmentUrlWithPersistedRedirectBackState, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
7
|
+
|
|
8
|
+
function createMockSessionStorage() {
|
|
9
|
+
const map = new Map<string, string>();
|
|
10
|
+
return {
|
|
11
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
12
|
+
setItem: (key: string, value: string) => {
|
|
13
|
+
map.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
removeItem: (key: string) => {
|
|
16
|
+
map.delete(key);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previousWindow = Reflect.get(globalThis, "window");
|
|
22
|
+
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
23
|
+
const projectId = "00000000-0000-4000-8000-000000000000";
|
|
24
|
+
|
|
25
|
+
describe("redirect-back state mirror", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
Reflect.set(globalThis, "window", { sessionStorage: createMockSessionStorage() });
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
if (hadPreviousWindow) {
|
|
35
|
+
Reflect.set(globalThis, "window", previousWindow);
|
|
36
|
+
} else {
|
|
37
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("round-trips redirect-back state including the cross-domain handoff params", () => {
|
|
42
|
+
const url = new URL("https://hosted.example.test/handler/sign-in");
|
|
43
|
+
url.searchParams.set("after_auth_return_to", "https://app.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1");
|
|
44
|
+
url.searchParams.set("hexclave_cross_domain_state", "the-state");
|
|
45
|
+
url.searchParams.set("hexclave_cross_domain_code_challenge", "the-code-challenge");
|
|
46
|
+
url.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://app.example.test/dashboard");
|
|
47
|
+
|
|
48
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
49
|
+
|
|
50
|
+
expect(readRedirectBackState({ projectId })).toEqual({
|
|
51
|
+
afterAuthReturnTo: "https://app.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1",
|
|
52
|
+
crossDomainState: "the-state",
|
|
53
|
+
crossDomainCodeChallenge: "the-code-challenge",
|
|
54
|
+
crossDomainAfterCallbackRedirectUrl: "https://app.example.test/dashboard",
|
|
55
|
+
savedAtMillis: Date.now(),
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null when nothing was saved and does not save URLs without after_auth_return_to", () => {
|
|
60
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
61
|
+
saveRedirectBackStateFromUrl({ url: new URL("https://hosted.example.test/handler/sign-in?foo=bar"), projectId });
|
|
62
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("expires the state after the TTL", () => {
|
|
66
|
+
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
|
|
67
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
68
|
+
|
|
69
|
+
vi.advanceTimersByTime(29 * 60 * 1000);
|
|
70
|
+
expect(readRedirectBackState({ projectId })).not.toBeNull();
|
|
71
|
+
|
|
72
|
+
vi.advanceTimersByTime(2 * 60 * 1000);
|
|
73
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("scopes the state per project", () => {
|
|
77
|
+
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
|
|
78
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
79
|
+
expect(readRedirectBackState({ projectId: "11111111-1111-4111-8111-111111111111" })).toBeNull();
|
|
80
|
+
expect(readRedirectBackState({ projectId })).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("discards malformed stored values", () => {
|
|
84
|
+
const storage = createMockSessionStorage();
|
|
85
|
+
storage.setItem(`hexclave-redirect-back-state-${projectId}`, "{not json");
|
|
86
|
+
Reflect.set(globalThis, "window", { sessionStorage: storage });
|
|
87
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
88
|
+
|
|
89
|
+
storage.setItem(`hexclave-redirect-back-state-${projectId}`, JSON.stringify({ some: "other-shape" }));
|
|
90
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("restores dropped params onto a URL, but never overrides an explicit after_auth_return_to", () => {
|
|
94
|
+
const arrivalUrl = new URL("https://hosted.example.test/handler/sign-in");
|
|
95
|
+
arrivalUrl.searchParams.set("after_auth_return_to", "https://app.example.test/handler/oauth-callback");
|
|
96
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_state", "the-state");
|
|
97
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_code_challenge", "the-code-challenge");
|
|
98
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://app.example.test/dashboard");
|
|
99
|
+
saveRedirectBackStateFromUrl({ url: arrivalUrl, projectId });
|
|
100
|
+
|
|
101
|
+
// Params were dropped along the way: restore all of them.
|
|
102
|
+
const strippedUrl = new URL("https://hosted.example.test/handler/mfa");
|
|
103
|
+
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: strippedUrl, projectId });
|
|
104
|
+
expect(augmented.searchParams.get("after_auth_return_to")).toBe("https://app.example.test/handler/oauth-callback");
|
|
105
|
+
expect(augmented.searchParams.get("hexclave_cross_domain_state")).toBe("the-state");
|
|
106
|
+
expect(augmented.searchParams.get("hexclave_cross_domain_code_challenge")).toBe("the-code-challenge");
|
|
107
|
+
expect(augmented.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://app.example.test/dashboard");
|
|
108
|
+
// The input URL is not mutated.
|
|
109
|
+
expect(strippedUrl.searchParams.has("after_auth_return_to")).toBe(false);
|
|
110
|
+
|
|
111
|
+
// Explicit query params always win over the mirror.
|
|
112
|
+
const explicitUrl = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/somewhere-else");
|
|
113
|
+
const unchanged = augmentUrlWithPersistedRedirectBackState({ currentUrl: explicitUrl, projectId });
|
|
114
|
+
expect(unchanged.toString()).toBe(explicitUrl.toString());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("leaves the URL unchanged when there is no mirrored state", () => {
|
|
118
|
+
const url = new URL("https://hosted.example.test/handler/sign-in");
|
|
119
|
+
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId });
|
|
120
|
+
expect(augmented.toString()).toBe(url.toString());
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("degrades to a no-op when sessionStorage is unavailable", () => {
|
|
124
|
+
Reflect.set(globalThis, "window", {
|
|
125
|
+
get sessionStorage(): Storage {
|
|
126
|
+
throw new Error("Storage is disabled");
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
|
|
130
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
131
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
132
|
+
expect(augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId }).toString()).toBe(url.toString());
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
|
|
2
|
+
//===========================================
|
|
3
|
+
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
|
+
//===========================================
|
|
5
|
+
import { readSessionStorageItem, removeSessionStorageItem, writeSessionStorageItem } from "../../../../utils/session-storage";
|
|
6
|
+
import { crossDomainAuthQueryParams } from "./redirect-page-urls";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resilience layer for the redirect-back ("return to where you came from") state of auth flows.
|
|
10
|
+
*
|
|
11
|
+
* The primary transport for this state is query params (`after_auth_return_to` +
|
|
12
|
+
* `hexclave_cross_domain_*`), which must survive every hop of the auth flow (sign-in page, OAuth
|
|
13
|
+
* provider round-trips, MFA, magic links, ...). Historically, any hop that dropped these params
|
|
14
|
+
* stranded the user on the wrong page after auth — on hosted components, that meant the hosted
|
|
15
|
+
* welcome page instead of the customer's app.
|
|
16
|
+
*
|
|
17
|
+
* This module mirrors the state into sessionStorage whenever it appears in a URL, so
|
|
18
|
+
* `redirectToAfterSignIn`/`redirectToAfterSignUp` can restore it if the params were dropped along
|
|
19
|
+
* the way. Query params always take precedence; the mirror is only a fallback. It is scoped
|
|
20
|
+
* per-tab (sessionStorage), per-origin (sessionStorage again), and per-project, and it expires
|
|
21
|
+
* after a TTL. Restored URLs still go through the exact same trust validation as URLs read from
|
|
22
|
+
* query params (the `_isTrusted` gate client-side and the cross-domain authorize endpoint
|
|
23
|
+
* server-side), so the mirror never widens the set of allowed destinations.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Deliberately NOT consumed/cleared on use: React can invoke the after-sign-in redirect twice
|
|
27
|
+
// (strict mode, remounts), and consuming on first read would make the second invocation fall back
|
|
28
|
+
// to the default post-auth page and race the correct redirect. Staleness is bounded by the TTL
|
|
29
|
+
// instead, and a stale entry merely returns the user to the last page that started an auth flow
|
|
30
|
+
// in this tab — which is also what they'd expect.
|
|
31
|
+
//
|
|
32
|
+
// The TTL must stay below the 1h lifetime of the outer PKCE verifier cookie (see
|
|
33
|
+
// saveVerifierAndState in cookie.ts): the mirrored cross-domain params are useless once their
|
|
34
|
+
// verifier cookie on the app domain has expired.
|
|
35
|
+
const redirectBackStateTtlMillis = 30 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export type PersistedRedirectBackState = {
|
|
38
|
+
/**
|
|
39
|
+
* The `after_auth_return_to` value exactly as it appeared in the URL (possibly relative).
|
|
40
|
+
* sessionStorage is per-origin, so restoring it into a URL later always resolves it against the
|
|
41
|
+
* same origin it was saved from.
|
|
42
|
+
*/
|
|
43
|
+
afterAuthReturnTo: string,
|
|
44
|
+
crossDomainState: string | null,
|
|
45
|
+
crossDomainCodeChallenge: string | null,
|
|
46
|
+
crossDomainAfterCallbackRedirectUrl: string | null,
|
|
47
|
+
/** Wall-clock ms (Date.now()); must survive page loads, so performance.now() is not usable. */
|
|
48
|
+
savedAtMillis: number,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function getStorageKey(projectId: string): string {
|
|
52
|
+
return `hexclave-redirect-back-state-${projectId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPersistedRedirectBackState(value: unknown): value is PersistedRedirectBackState {
|
|
56
|
+
return (
|
|
57
|
+
typeof value === "object"
|
|
58
|
+
&& value !== null
|
|
59
|
+
&& "afterAuthReturnTo" in value && typeof value.afterAuthReturnTo === "string"
|
|
60
|
+
&& "crossDomainState" in value && (value.crossDomainState === null || typeof value.crossDomainState === "string")
|
|
61
|
+
&& "crossDomainCodeChallenge" in value && (value.crossDomainCodeChallenge === null || typeof value.crossDomainCodeChallenge === "string")
|
|
62
|
+
&& "crossDomainAfterCallbackRedirectUrl" in value && (value.crossDomainAfterCallbackRedirectUrl === null || typeof value.crossDomainAfterCallbackRedirectUrl === "string")
|
|
63
|
+
&& "savedAtMillis" in value && typeof value.savedAtMillis === "number"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Mirrors the redirect-back state from the given URL into sessionStorage, if it has any. Called
|
|
69
|
+
* with the page URL at app construction time (full page loads) and with the target URL of every
|
|
70
|
+
* SDK-driven redirect (client-side navigations, which don't re-run the constructor).
|
|
71
|
+
*/
|
|
72
|
+
export function saveRedirectBackStateFromUrl(options: { url: URL, projectId: string }): void {
|
|
73
|
+
const afterAuthReturnTo = options.url.searchParams.get("after_auth_return_to");
|
|
74
|
+
if (afterAuthReturnTo == null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const state: PersistedRedirectBackState = {
|
|
78
|
+
afterAuthReturnTo,
|
|
79
|
+
crossDomainState: options.url.searchParams.get(crossDomainAuthQueryParams.state),
|
|
80
|
+
crossDomainCodeChallenge: options.url.searchParams.get(crossDomainAuthQueryParams.codeChallenge),
|
|
81
|
+
crossDomainAfterCallbackRedirectUrl: options.url.searchParams.get(crossDomainAuthQueryParams.afterCallbackRedirectUrl),
|
|
82
|
+
savedAtMillis: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
writeSessionStorageItem(getStorageKey(options.projectId), JSON.stringify(state));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRedirectBackState(options: { projectId: string }): PersistedRedirectBackState | null {
|
|
88
|
+
const storageKey = getStorageKey(options.projectId);
|
|
89
|
+
const raw = readSessionStorageItem(storageKey);
|
|
90
|
+
if (raw == null) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
// Corrupted value (eg. written by a different SDK version); losing the fallback for this flow
|
|
98
|
+
// is the pre-existing behavior, so just discard it.
|
|
99
|
+
removeSessionStorageItem(storageKey);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!isPersistedRedirectBackState(parsed)) {
|
|
103
|
+
removeSessionStorageItem(storageKey);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const ageMillis = Date.now() - parsed.savedAtMillis;
|
|
107
|
+
if (ageMillis < 0 || ageMillis > redirectBackStateTtlMillis) {
|
|
108
|
+
removeSessionStorageItem(storageKey);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns a copy of `currentUrl` with any redirect-back query params that were dropped along the
|
|
116
|
+
* way restored from the sessionStorage mirror. If `currentUrl` already carries
|
|
117
|
+
* `after_auth_return_to`, it is returned unchanged — explicit query params always win.
|
|
118
|
+
*/
|
|
119
|
+
export function augmentUrlWithPersistedRedirectBackState(options: { currentUrl: URL, projectId: string }): URL {
|
|
120
|
+
if (options.currentUrl.searchParams.has("after_auth_return_to")) {
|
|
121
|
+
return options.currentUrl;
|
|
122
|
+
}
|
|
123
|
+
const persisted = readRedirectBackState({ projectId: options.projectId });
|
|
124
|
+
if (persisted == null) {
|
|
125
|
+
return options.currentUrl;
|
|
126
|
+
}
|
|
127
|
+
const augmented = new URL(options.currentUrl.toString());
|
|
128
|
+
augmented.searchParams.set("after_auth_return_to", persisted.afterAuthReturnTo);
|
|
129
|
+
const crossDomainParamValues = [
|
|
130
|
+
[crossDomainAuthQueryParams.state, persisted.crossDomainState],
|
|
131
|
+
[crossDomainAuthQueryParams.codeChallenge, persisted.crossDomainCodeChallenge],
|
|
132
|
+
[crossDomainAuthQueryParams.afterCallbackRedirectUrl, persisted.crossDomainAfterCallbackRedirectUrl],
|
|
133
|
+
] as const;
|
|
134
|
+
for (const [paramName, persistedValue] of crossDomainParamValues) {
|
|
135
|
+
if (persistedValue != null && !augmented.searchParams.has(paramName)) {
|
|
136
|
+
augmented.searchParams.set(paramName, persistedValue);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return augmented;
|
|
140
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
|
|
2
|
+
//===========================================
|
|
3
|
+
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
|
+
//===========================================
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
|
|
7
|
+
|
|
8
|
+
function createMockSessionStorage() {
|
|
9
|
+
const map = new Map<string, string>();
|
|
10
|
+
return {
|
|
11
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
12
|
+
setItem: (key: string, value: string) => {
|
|
13
|
+
map.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
removeItem: (key: string) => {
|
|
16
|
+
map.delete(key);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previousWindow = Reflect.get(globalThis, "window");
|
|
22
|
+
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
23
|
+
|
|
24
|
+
function record(from: string, to: string) {
|
|
25
|
+
recordRedirectAndThrowIfLoopDetected({
|
|
26
|
+
currentUrl: new URL(from),
|
|
27
|
+
targetUrl: new URL(to),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("recordRedirectAndThrowIfLoopDetected", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
Reflect.set(globalThis, "window", { sessionStorage: createMockSessionStorage() });
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
if (hadPreviousWindow) {
|
|
41
|
+
Reflect.set(globalThis, "window", previousWindow);
|
|
42
|
+
} else {
|
|
43
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("allows distinct redirects in quick succession", () => {
|
|
48
|
+
for (let i = 0; i < 20; i++) {
|
|
49
|
+
record(`https://app.example.test/page-${i}`, `https://app.example.test/page-${i + 1}`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws once the same redirect repeats 5 times within the window", () => {
|
|
54
|
+
for (let i = 0; i < 4; i++) {
|
|
55
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
56
|
+
vi.advanceTimersByTime(1000);
|
|
57
|
+
}
|
|
58
|
+
expect(() => {
|
|
59
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
60
|
+
}).toThrowError(/Redirect loop detected/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("treats redirects that differ only in query params or hash as identical", () => {
|
|
64
|
+
// Real loops usually rotate nonce-style params (code, state, after_auth_return_to) each cycle.
|
|
65
|
+
for (let i = 0; i < 4; i++) {
|
|
66
|
+
record(
|
|
67
|
+
`https://app.example.test/dashboard?code=code-${i}`,
|
|
68
|
+
`https://hosted.example.test/handler/sign-in?state=state-${i}#frag-${i}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
expect(() => {
|
|
72
|
+
record("https://app.example.test/dashboard?code=final", "https://hosted.example.test/handler/sign-in?state=final");
|
|
73
|
+
}).toThrowError(/Redirect loop detected/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not throw when the identical redirects are spread out beyond the window", () => {
|
|
77
|
+
for (let i = 0; i < 20; i++) {
|
|
78
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
79
|
+
vi.advanceTimersByTime(31_000);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("allows the same redirect again after a loop was broken", () => {
|
|
84
|
+
for (let i = 0; i < 4; i++) {
|
|
85
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
86
|
+
}
|
|
87
|
+
expect(() => {
|
|
88
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
89
|
+
}).toThrowError(/Redirect loop detected/);
|
|
90
|
+
// The breadcrumbs for the broken loop are dropped, so a manual retry isn't instantly blocked.
|
|
91
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("counts self-redirects (same origin and path) towards loops", () => {
|
|
95
|
+
for (let i = 0; i < 4; i++) {
|
|
96
|
+
record(`https://hosted.example.test/handler/sign-in?attempt=${i}`, "https://hosted.example.test/handler/sign-in");
|
|
97
|
+
}
|
|
98
|
+
expect(() => {
|
|
99
|
+
record("https://hosted.example.test/handler/sign-in?attempt=final", "https://hosted.example.test/handler/sign-in");
|
|
100
|
+
}).toThrowError(/Redirect loop detected/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("degrades to a no-op when sessionStorage is unavailable", () => {
|
|
104
|
+
Reflect.set(globalThis, "window", {
|
|
105
|
+
get sessionStorage(): Storage {
|
|
106
|
+
throw new Error("Storage is disabled");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
for (let i = 0; i < 20; i++) {
|
|
110
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("recovers from corrupted breadcrumb storage", () => {
|
|
115
|
+
const storage = createMockSessionStorage();
|
|
116
|
+
storage.setItem("hexclave-redirect-loop-breadcrumbs", "{not json");
|
|
117
|
+
Reflect.set(globalThis, "window", { sessionStorage: storage });
|
|
118
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
119
|
+
// After recovery, loop detection works again.
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
122
|
+
}
|
|
123
|
+
expect(() => {
|
|
124
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
125
|
+
}).toThrowError(/Redirect loop detected/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
|
|
2
|
+
//===========================================
|
|
3
|
+
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
|
+
//===========================================
|
|
5
|
+
import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors";
|
|
6
|
+
import { readSessionStorageItem, removeSessionStorageItem, writeSessionStorageItem } from "../../../../utils/session-storage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Last line of defense against redirect loops (most importantly in hosted components flows, where
|
|
10
|
+
* redirects bounce between the app domain and the hosted domain and a bug anywhere in the chain
|
|
11
|
+
* can bounce the user forever).
|
|
12
|
+
*
|
|
13
|
+
* Every SDK-driven redirect records a breadcrumb, and when the exact same redirect keeps repeating
|
|
14
|
+
* within a short window, the redirect throws instead of navigating. The page that initiated the
|
|
15
|
+
* redirect then renders its error state (eg. the auth page's error card), which both stops the
|
|
16
|
+
* loop and surfaces the bug loudly.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// sessionStorage (not module state) because most redirect loops involve full page loads, so each
|
|
20
|
+
// loop iteration runs in a fresh JS context. sessionStorage is also per-tab, which is exactly the
|
|
21
|
+
// scope of a redirect loop.
|
|
22
|
+
const breadcrumbsStorageKey = "hexclave-redirect-loop-breadcrumbs";
|
|
23
|
+
|
|
24
|
+
// A redirect loop cycles within a few seconds per iteration even with full cross-domain page
|
|
25
|
+
// loads, so a real loop reaches 5 identical redirects well within 30 seconds. A human bouncing
|
|
26
|
+
// between pages (eg. toggling sign-in <-> sign-up) produces the same (from, to) pair far less
|
|
27
|
+
// often, which keeps false positives implausible.
|
|
28
|
+
const loopWindowMillis = 30_000;
|
|
29
|
+
const maxIdenticalRedirectsPerWindow = 5;
|
|
30
|
+
const maxStoredBreadcrumbs = 50;
|
|
31
|
+
|
|
32
|
+
type RedirectBreadcrumb = {
|
|
33
|
+
from: string,
|
|
34
|
+
to: string,
|
|
35
|
+
/**
|
|
36
|
+
* Wall-clock milliseconds (Date.now()). Although this is used to measure elapsed time (where we
|
|
37
|
+
* would normally use performance.now()), breadcrumbs must be comparable across full page loads,
|
|
38
|
+
* and performance.now() restarts at zero on every navigation.
|
|
39
|
+
*/
|
|
40
|
+
at: number,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function isRedirectBreadcrumb(value: unknown): value is RedirectBreadcrumb {
|
|
44
|
+
return (
|
|
45
|
+
typeof value === "object"
|
|
46
|
+
&& value !== null
|
|
47
|
+
&& "from" in value && typeof value.from === "string"
|
|
48
|
+
&& "to" in value && typeof value.to === "string"
|
|
49
|
+
&& "at" in value && typeof value.at === "number"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readBreadcrumbs(): RedirectBreadcrumb[] {
|
|
54
|
+
const raw = readSessionStorageItem(breadcrumbsStorageKey);
|
|
55
|
+
if (raw == null) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
// A corrupted value (eg. written by an older SDK version) just means we lose loop protection
|
|
63
|
+
// for this one redirect; start over with a fresh list.
|
|
64
|
+
removeSessionStorageItem(breadcrumbsStorageKey);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(parsed)) {
|
|
68
|
+
removeSessionStorageItem(breadcrumbsStorageKey);
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return parsed.filter(isRedirectBreadcrumb);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeBreadcrumbs(breadcrumbs: RedirectBreadcrumb[]): void {
|
|
75
|
+
writeSessionStorageItem(breadcrumbsStorageKey, JSON.stringify(breadcrumbs));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Loop iterations usually differ only in nonce-style query params (`code`, `state`,
|
|
80
|
+
* `after_auth_return_to`, ...), so redirects are compared by origin + pathname only.
|
|
81
|
+
*/
|
|
82
|
+
function getComparableRedirectLocation(url: URL): string {
|
|
83
|
+
return `${url.origin}${url.pathname}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function recordRedirectAndThrowIfLoopDetected(options: { currentUrl: URL, targetUrl: URL }): void {
|
|
87
|
+
const from = getComparableRedirectLocation(options.currentUrl);
|
|
88
|
+
const to = getComparableRedirectLocation(options.targetUrl);
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
|
|
91
|
+
// Clock jumps backwards (eg. NTP sync) would make breadcrumbs appear to be from the future;
|
|
92
|
+
// treat those as expired rather than counting them towards a loop.
|
|
93
|
+
const recentBreadcrumbs = readBreadcrumbs().filter((b) => now - b.at >= 0 && now - b.at < loopWindowMillis);
|
|
94
|
+
const identicalRedirectCount = recentBreadcrumbs.filter((b) => b.from === from && b.to === to).length + 1;
|
|
95
|
+
|
|
96
|
+
if (identicalRedirectCount >= maxIdenticalRedirectsPerWindow) {
|
|
97
|
+
// Drop the matching breadcrumbs so a manual retry (eg. the user reloading and clicking sign-in
|
|
98
|
+
// again) isn't instantly blocked by the history of the loop we just broke.
|
|
99
|
+
writeBreadcrumbs(recentBreadcrumbs.filter((b) => !(b.from === from && b.to === to)));
|
|
100
|
+
const error = new HexclaveAssertionError(
|
|
101
|
+
`Redirect loop detected: the redirect ${from} -> ${to} was attempted ${identicalRedirectCount} times within ${loopWindowMillis / 1000}s, so it was blocked. This is a bug in the auth flow; the recent redirect chain is included in the error details.`,
|
|
102
|
+
{
|
|
103
|
+
from,
|
|
104
|
+
to,
|
|
105
|
+
currentUrl: options.currentUrl.toString(),
|
|
106
|
+
targetUrl: options.targetUrl.toString(),
|
|
107
|
+
recentRedirects: recentBreadcrumbs.map((b) => `${b.from} -> ${b.to}`),
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
// Also captured (not just thrown) so the loop is reported even if a caller maps the thrown
|
|
111
|
+
// error to UI without reporting it.
|
|
112
|
+
captureError("redirect-loop-detected", error);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
writeBreadcrumbs([...recentBreadcrumbs, { from, to, at: now }].slice(-maxStoredBreadcrumbs));
|
|
117
|
+
}
|
|
@@ -23,6 +23,7 @@ import { ProviderType } from "@hexclave/shared/dist/utils/oauth";
|
|
|
23
23
|
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
|
24
24
|
import { suspend } from "@hexclave/shared/dist/utils/react";
|
|
25
25
|
import { Result } from "@hexclave/shared/dist/utils/results";
|
|
26
|
+
import { isUuid } from "@hexclave/shared/dist/utils/uuids";
|
|
26
27
|
import { WebAuthnError, startRegistration } from "@simplewebauthn/browser";
|
|
27
28
|
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
|
|
28
29
|
import * as yup from "yup";
|
|
@@ -90,6 +91,20 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
90
91
|
], TeamsCrud['Server']['List']>(async ([userId, orderBy, desc, cursor, limit, query]) => {
|
|
91
92
|
return await this._interface.listServerTeamsPaginated({ userId, orderBy, desc, cursor, limit, query });
|
|
92
93
|
});
|
|
94
|
+
private readonly _serverTeamCache = createCache<string[], TeamsCrud['Server']['Read'] | null>(async ([teamId]) => {
|
|
95
|
+
// The previous list-and-find implementation treated unknown or malformed IDs as null; preserve that behavior without making an invalid request.
|
|
96
|
+
if (!isUuid(teamId)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return await this._interface.getServerTeam(teamId);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (KnownErrors.TeamNotFound.isInstance(error)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
93
108
|
|
|
94
109
|
protected async _refreshTeamMembership(teamId: string, userId: string) {
|
|
95
110
|
await Promise.all([
|
|
@@ -700,6 +715,10 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
700
715
|
throw new HexclaveAssertionError("useOrLinkConnectedAccount is not available for server users. OAuth flows must be initiated on the client side.");
|
|
701
716
|
},
|
|
702
717
|
selectedTeam: crud.selected_team ? app._serverTeamFromCrud(crud.selected_team) : null,
|
|
718
|
+
// Unlike the app-level getTeam/useTeam (which fetch any team by id),
|
|
719
|
+
// the user-scoped variants search the user's own team list on purpose:
|
|
720
|
+
// they must return null for teams the user is not a member of, and some
|
|
721
|
+
// callers rely on that as a membership check.
|
|
703
722
|
async getTeam(teamId: string) {
|
|
704
723
|
const teams = await this.listTeams();
|
|
705
724
|
return teams.find((t) => t.id === teamId) ?? null;
|
|
@@ -1008,6 +1027,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1008
1027
|
async update(update: Partial<ServerTeamUpdateOptions>) {
|
|
1009
1028
|
await app._interface.updateServerTeam(crud.id, serverTeamUpdateOptionsToCrud(update));
|
|
1010
1029
|
await Promise.all([
|
|
1030
|
+
app._serverTeamCache.refresh([crud.id]),
|
|
1011
1031
|
app._serverTeamsCache.refreshWhere(() => true),
|
|
1012
1032
|
app._serverUsersCache.refreshWhere(() => true),
|
|
1013
1033
|
]);
|
|
@@ -1015,6 +1035,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1015
1035
|
async delete() {
|
|
1016
1036
|
await app._interface.deleteServerTeam(crud.id);
|
|
1017
1037
|
await Promise.all([
|
|
1038
|
+
app._serverTeamCache.refresh([crud.id]),
|
|
1018
1039
|
app._serverTeamsCache.refreshWhere(() => true),
|
|
1019
1040
|
app._serverUsersCache.refreshWhere(() => true),
|
|
1020
1041
|
]);
|
|
@@ -1489,6 +1510,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1489
1510
|
|
|
1490
1511
|
async createTeam(data: ServerTeamCreateOptions): Promise<ServerTeam> {
|
|
1491
1512
|
const team = await this._interface.createServerTeam(serverTeamCreateOptionsToCrud(data));
|
|
1513
|
+
await this._serverTeamCache.refresh([team.id]);
|
|
1492
1514
|
await this._serverTeamsCache.refreshWhere(() => true);
|
|
1493
1515
|
return this._serverTeamFromCrud(team);
|
|
1494
1516
|
}
|
|
@@ -1528,8 +1550,11 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1528
1550
|
return await this._getTeamByApiKey(options.apiKey);
|
|
1529
1551
|
} else {
|
|
1530
1552
|
const teamId = options;
|
|
1531
|
-
|
|
1532
|
-
|
|
1553
|
+
if (teamId == null) {
|
|
1554
|
+
return null;
|
|
1555
|
+
}
|
|
1556
|
+
const team = Result.orThrow(await this._serverTeamCache.getOrWait([teamId], "write-only"));
|
|
1557
|
+
return team == null ? null : this._serverTeamFromCrud(team);
|
|
1533
1558
|
}
|
|
1534
1559
|
}
|
|
1535
1560
|
|
|
@@ -1540,10 +1565,11 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1540
1565
|
return this._useTeamByApiKey(options.apiKey);
|
|
1541
1566
|
} else {
|
|
1542
1567
|
const teamId = options;
|
|
1543
|
-
|
|
1568
|
+
// "" is never a valid UUID, so the cache resolves a nullish id to null while keeping the hook call order stable.
|
|
1569
|
+
const team = useAsyncCache(this._serverTeamCache, [teamId ?? ""], "serverApp.useTeam()");
|
|
1544
1570
|
return useMemo(() => {
|
|
1545
|
-
return
|
|
1546
|
-
}, [
|
|
1571
|
+
return team == null ? null : this._serverTeamFromCrud(team);
|
|
1572
|
+
}, [team]);
|
|
1547
1573
|
}
|
|
1548
1574
|
}
|
|
1549
1575
|
|
|
@@ -138,6 +138,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
|
|
138
138
|
redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<void>,
|
|
139
139
|
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
|
|
140
140
|
awaitPendingAuthResolutions(): Promise<void>,
|
|
141
|
+
isTrustedRedirectUrl(url: string): Promise<boolean>,
|
|
141
142
|
},
|
|
142
143
|
}
|
|
143
144
|
& AsyncStoreProperty<"project", [], Project, false>
|
|
@@ -372,7 +372,7 @@ export function mountPushedConfigErrorOverlay(app: StackClientApp<true>): () =>
|
|
|
372
372
|
return;
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
const issueMessage = issue.messages.join("\n");
|
|
375
|
+
const issueMessage = "Hexclave config " + issue.kind + ": " + issue.messages.join("\n");
|
|
376
376
|
const issueKey = `${app.projectId}:${issue.kind}:${issueMessage}`;
|
|
377
377
|
const issueLabel = issue.kind === "error" ? "error" : "warning";
|
|
378
378
|
const issueTitle = issue.kind === "error"
|