@hexclave/next 1.0.72 → 1.0.74

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 (156) hide show
  1. package/dist/{admin-app-impl-DLqm4Zqy.d.ts → admin-app-impl-FZdANO62.d.ts} +15 -9
  2. package/dist/admin-app-impl-FZdANO62.d.ts.map +1 -0
  3. package/dist/clickmap/clickmap-core.d.ts +1 -1
  4. package/dist/clickmap/index.d.ts +1 -1
  5. package/dist/{client-app-DlKeB4SB.d.ts → client-app-BCT5p_70.d.ts} +12 -4
  6. package/dist/{client-app-DlKeB4SB.d.ts.map → client-app-BCT5p_70.d.ts.map} +1 -1
  7. package/dist/components/selected-team-switcher.d.ts +1 -1
  8. package/dist/components/team-icon.d.ts +1 -1
  9. package/dist/components/team-switcher.d.ts +1 -1
  10. package/dist/components-page/account-settings/payments/payments-page.d.ts +1 -1
  11. package/dist/components-page/account-settings/teams/leave-team-section.d.ts +1 -1
  12. package/dist/components-page/account-settings/teams/team-api-keys-section.d.ts +1 -1
  13. package/dist/components-page/account-settings/teams/team-display-name-section.d.ts +1 -1
  14. package/dist/components-page/account-settings/teams/team-member-invitation-section.d.ts +1 -1
  15. package/dist/components-page/account-settings/teams/team-member-list-section.d.ts +1 -1
  16. package/dist/components-page/account-settings/teams/team-page.d.ts +1 -1
  17. package/dist/components-page/account-settings/teams/team-profile-image-section.d.ts +1 -1
  18. package/dist/components-page/account-settings/teams/team-profile-user-section.d.ts +1 -1
  19. package/dist/components-page/hexclave-handler-client.d.ts +1 -1
  20. package/dist/components-page/hexclave-handler.d.ts +1 -1
  21. package/dist/components-page/oauth-callback.js +3 -18
  22. package/dist/components-page/oauth-callback.js.map +1 -1
  23. package/dist/dev-tool/dev-tool-core.d.ts +1 -1
  24. package/dist/dev-tool/index.d.ts +1 -1
  25. package/dist/esm/components-page/oauth-callback.d.ts.map +1 -1
  26. package/dist/esm/components-page/oauth-callback.js +4 -19
  27. package/dist/esm/components-page/oauth-callback.js.map +1 -1
  28. package/dist/esm/lib/auth.d.ts.map +1 -1
  29. package/dist/esm/lib/auth.js +1 -1
  30. package/dist/esm/lib/auth.js.map +1 -1
  31. package/dist/esm/lib/auth.test.js +2 -2
  32. package/dist/esm/lib/auth.test.js.map +1 -1
  33. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +6 -3
  34. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  35. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js +4 -1
  36. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
  37. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.d.ts +1 -0
  38. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js +102 -0
  39. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js.map +1 -0
  40. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +57 -1
  41. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  42. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +7 -4
  43. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  44. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +44 -12
  45. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  46. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +2 -2
  47. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +13 -1
  48. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -1
  49. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js +15 -1
  50. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -1
  51. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +25 -1
  52. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -1
  53. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts +1 -0
  54. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts.map +1 -1
  55. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.js +67 -12
  56. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.js.map +1 -1
  57. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.d.ts +1 -0
  58. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +87 -0
  59. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -0
  60. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +9 -1
  61. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
  62. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
  63. package/dist/esm/lib/hexclave-app/project-configs/index.d.ts +4 -1
  64. package/dist/esm/lib/hexclave-app/project-configs/index.d.ts.map +1 -1
  65. package/dist/esm/lib/hexclave-app/projects/index.d.ts.map +1 -1
  66. package/dist/esm/lib/hexclave-app/projects/index.js +3 -0
  67. package/dist/esm/lib/hexclave-app/projects/index.js.map +1 -1
  68. package/dist/esm/utils/url.js +10 -2
  69. package/dist/esm/utils/url.js.map +1 -1
  70. package/dist/esm/utils/url.test.d.ts +1 -0
  71. package/dist/esm/utils/url.test.js +28 -0
  72. package/dist/esm/utils/url.test.js.map +1 -0
  73. package/dist/{hexclave-provider-B_KtSfuf.d.ts → hexclave-provider-BKxi3Vy-.d.ts} +2 -2
  74. package/dist/{hexclave-provider-B_KtSfuf.d.ts.map → hexclave-provider-BKxi3Vy-.d.ts.map} +1 -1
  75. package/dist/{hooks-nHRIrxA9.d.ts → hooks-CUe5bc3s.d.ts} +2 -2
  76. package/dist/{hooks-nHRIrxA9.d.ts.map → hooks-CUe5bc3s.d.ts.map} +1 -1
  77. package/dist/{index-Dq4xsW7_.d.ts → index-B-MazpM_.d.ts} +5 -2
  78. package/dist/{index-Dq4xsW7_.d.ts.map → index-B-MazpM_.d.ts.map} +1 -1
  79. package/dist/{index-CsExJ_pK.d.ts → index-CvB7XacB.d.ts} +2 -2
  80. package/dist/{index-CsExJ_pK.d.ts.map → index-CvB7XacB.d.ts.map} +1 -1
  81. package/dist/index.d.ts +5 -5
  82. package/dist/lib/auth.d.ts.map +1 -1
  83. package/dist/lib/auth.js +1 -1
  84. package/dist/lib/auth.js.map +1 -1
  85. package/dist/lib/auth.test.js +2 -2
  86. package/dist/lib/auth.test.js.map +1 -1
  87. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
  88. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js +4 -1
  89. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
  90. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.d.ts +1 -0
  91. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js +101 -0
  92. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js.map +1 -0
  93. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +57 -1
  94. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  95. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  96. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +43 -11
  97. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  98. package/dist/lib/hexclave-app/apps/implementations/common.js +2 -2
  99. package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
  100. package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +13 -1
  101. package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -1
  102. package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js +15 -0
  103. package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -1
  104. package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +24 -0
  105. package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -1
  106. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts +1 -1
  107. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.js +67 -12
  108. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.js.map +1 -1
  109. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.d.ts +1 -0
  110. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +86 -0
  111. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -0
  112. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  113. package/dist/lib/hexclave-app/apps/index.d.ts +1 -1
  114. package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts +1 -1
  115. package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -1
  116. package/dist/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
  117. package/dist/lib/hexclave-app/apps/interfaces/server-app.d.ts +1 -1
  118. package/dist/lib/hexclave-app/index.d.ts +2 -2
  119. package/dist/lib/hexclave-app/project-configs/index.d.ts +1 -1
  120. package/dist/lib/hexclave-app/projects/index.d.ts +1 -1
  121. package/dist/lib/hexclave-app/projects/index.js +3 -0
  122. package/dist/lib/hexclave-app/projects/index.js.map +1 -1
  123. package/dist/lib/hexclave-app/teams/index.d.ts +1 -1
  124. package/dist/lib/hexclave-app/users/index.d.ts +1 -1
  125. package/dist/lib/hooks.d.ts +1 -1
  126. package/dist/oauth-callback-tCVTRbF2.d.ts.map +1 -1
  127. package/dist/providers/hexclave-context.d.ts +1 -1
  128. package/dist/providers/hexclave-provider-client.d.ts +1 -1
  129. package/dist/providers/hexclave-provider.d.ts +1 -1
  130. package/dist/pushed-config-error-overlay/index.d.ts +1 -1
  131. package/dist/{redirect-page-urls-NUHUchgR.d.ts → redirect-page-urls-BEWG0AtP.d.ts} +2 -1
  132. package/dist/redirect-page-urls-BEWG0AtP.d.ts.map +1 -0
  133. package/dist/utils/url.js +10 -2
  134. package/dist/utils/url.js.map +1 -1
  135. package/dist/utils/url.test.d.ts +1 -0
  136. package/dist/utils/url.test.js +27 -0
  137. package/dist/utils/url.test.js.map +1 -0
  138. package/package.json +4 -4
  139. package/src/components-page/oauth-callback.tsx +0 -16
  140. package/src/lib/auth.test.ts +2 -2
  141. package/src/lib/auth.ts +3 -1
  142. package/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +4 -1
  143. package/src/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.ts +126 -0
  144. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +75 -0
  145. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +73 -16
  146. package/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts +29 -1
  147. package/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts +17 -0
  148. package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.test.ts +119 -0
  149. package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.ts +104 -12
  150. package/src/lib/hexclave-app/apps/interfaces/client-app.ts +12 -2
  151. package/src/lib/hexclave-app/project-configs/index.ts +4 -1
  152. package/src/lib/hexclave-app/projects/index.ts +3 -0
  153. package/src/utils/url.test.ts +54 -0
  154. package/src/utils/url.ts +12 -3
  155. package/dist/admin-app-impl-DLqm4Zqy.d.ts.map +0 -1
  156. package/dist/redirect-page-urls-NUHUchgR.d.ts.map +0 -1
@@ -0,0 +1,126 @@
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 { describe, expect, it } from "vitest";
6
+ import { hexclaveAppInternalsSymbol } from "../../common";
7
+ import { HexclaveClientApp, HexclaveClientAppConstructorOptions, HexclaveClientAppJson } from "../interfaces/client-app";
8
+
9
+ const baseOptions = {
10
+ baseUrl: "http://localhost:12345",
11
+ projectId: "00000000-0000-4000-8000-000000000000",
12
+ publishableClientKey: "stack-pk-test",
13
+ tokenStore: "memory",
14
+ redirectMethod: "none",
15
+ } satisfies HexclaveClientAppConstructorOptions<true, string>;
16
+
17
+ describe("HexclaveClientApp automatic side effects", () => {
18
+ it("does not inspect browser state when disabled", () => {
19
+ const previousWindow = Reflect.get(globalThis, "window");
20
+ const previousDocument = Reflect.get(globalThis, "document");
21
+ const hadPreviousWindow = Reflect.has(globalThis, "window");
22
+ const hadPreviousDocument = Reflect.has(globalThis, "document");
23
+ const browserGlobal = {};
24
+ Object.defineProperty(browserGlobal, "location", {
25
+ get() {
26
+ throw new Error("Construction must not inspect the current URL when automatic side effects are disabled");
27
+ },
28
+ });
29
+
30
+ Reflect.set(globalThis, "window", browserGlobal);
31
+ Object.defineProperty(globalThis, "document", {
32
+ configurable: true,
33
+ get() {
34
+ throw new Error("Construction must not access the document when automatic side effects are disabled");
35
+ },
36
+ });
37
+
38
+ try {
39
+ expect(() => new HexclaveClientApp({
40
+ ...baseOptions,
41
+ automaticSideEffects: false,
42
+ })).not.toThrow();
43
+ } finally {
44
+ if (hadPreviousWindow) {
45
+ Reflect.set(globalThis, "window", previousWindow);
46
+ } else {
47
+ Reflect.deleteProperty(globalThis, "window");
48
+ }
49
+ if (hadPreviousDocument) {
50
+ Object.defineProperty(globalThis, "document", {
51
+ configurable: true,
52
+ value: previousDocument,
53
+ writable: true,
54
+ });
55
+ } else {
56
+ Reflect.deleteProperty(globalThis, "document");
57
+ }
58
+ }
59
+ });
60
+
61
+ it("does not initialize automatic side effects when disabled", () => {
62
+ const methodName = "_initializeAutomaticSideEffects";
63
+ const originalInitializer = Reflect.get(HexclaveClientApp.prototype, methodName);
64
+ let initializationCalls = 0;
65
+ Reflect.set(HexclaveClientApp.prototype, methodName, () => {
66
+ initializationCalls += 1;
67
+ });
68
+
69
+ try {
70
+ new HexclaveClientApp({
71
+ ...baseOptions,
72
+ automaticSideEffects: false,
73
+ });
74
+
75
+ expect(initializationCalls).toBe(0);
76
+ } finally {
77
+ Reflect.set(HexclaveClientApp.prototype, methodName, originalInitializer);
78
+ }
79
+ });
80
+
81
+ it.each([undefined, true])("retains automatic initialization when automaticSideEffects is %s", (automaticSideEffects) => {
82
+ const methodName = "_initializeAutomaticSideEffects";
83
+ const originalInitializer = Reflect.get(HexclaveClientApp.prototype, methodName);
84
+ let initializationCalls = 0;
85
+ Reflect.set(HexclaveClientApp.prototype, methodName, () => {
86
+ initializationCalls += 1;
87
+ });
88
+
89
+ try {
90
+ new HexclaveClientApp({
91
+ ...baseOptions,
92
+ automaticSideEffects,
93
+ });
94
+
95
+ expect(initializationCalls).toBe(1);
96
+ } finally {
97
+ Reflect.set(HexclaveClientApp.prototype, methodName, originalInitializer);
98
+ }
99
+ });
100
+
101
+ it("preserves the option through serialization", () => {
102
+ const app = new HexclaveClientApp({
103
+ ...baseOptions,
104
+ automaticSideEffects: false,
105
+ });
106
+
107
+ expect(app[hexclaveAppInternalsSymbol].toClientJson().automaticSideEffects).toBe(false);
108
+ });
109
+
110
+ it("preserves stable identity when deserializing with automatic side effects disabled", () => {
111
+ const json: HexclaveClientAppJson<false, string> = {
112
+ baseUrl: baseOptions.baseUrl,
113
+ projectId: baseOptions.projectId,
114
+ publishableClientKey: baseOptions.publishableClientKey,
115
+ tokenStore: null,
116
+ redirectMethod: baseOptions.redirectMethod,
117
+ automaticSideEffects: false,
118
+ uniqueIdentifier: "00000000-0000-4000-8000-000000000001",
119
+ };
120
+
121
+ const first = HexclaveClientApp[hexclaveAppInternalsSymbol].fromClientJson(json);
122
+ const second = HexclaveClientApp[hexclaveAppInternalsSymbol].fromClientJson(json);
123
+
124
+ expect(second).toBe(first);
125
+ });
126
+ });
@@ -7,6 +7,7 @@ import { AccessToken } from "@hexclave/shared/dist/sessions";
7
7
  import { Store } from "@hexclave/shared/dist/utils/stores";
8
8
  import { hexclaveAppInternalsSymbol } from "../../common";
9
9
  import { StackClientApp } from "../interfaces/client-app";
10
+ import { planRedirectToHandler } from "./redirect-page-urls";
10
11
 
11
12
  function createAccessTokenString(refreshTokenId: string): string {
12
13
  const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
@@ -54,6 +55,76 @@ function createMockDocument(): Document {
54
55
  }
55
56
 
56
57
  describe("StackClientApp cross-domain auth", () => {
58
+ it("keeps noRedirectBack same-domain redirects free of redirect-back state", async () => {
59
+ const redirectPlan = await planRedirectToHandler({
60
+ handlerName: "signIn",
61
+ rawHandlerUrl: "/handler/sign-in",
62
+ noRedirectBack: true,
63
+ currentUrl: new URL("https://app.example.test/settings?tab=profile"),
64
+ localOAuthCallbackUrl: "/handler/oauth-callback",
65
+ rawHomeUrl: "/home",
66
+ getCrossDomainHandoffParams: async () => {
67
+ throw new Error("Same-domain redirects must not create cross-domain handoff parameters.");
68
+ },
69
+ });
70
+
71
+ expect(redirectPlan).toEqual({
72
+ type: "redirect",
73
+ url: "/handler/sign-in",
74
+ });
75
+ });
76
+
77
+ it("returns noRedirectBack cross-domain auth to the source app home", async () => {
78
+ const handoffState = "no-redirect-back-state";
79
+ const handoffCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
80
+ const currentUrl = new URL("https://app.example.test/settings?tab=profile");
81
+ currentUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
82
+ currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
83
+ currentUrl.searchParams.set(
84
+ "hexclave_cross_domain_after_callback_redirect_url",
85
+ "https://app.example.test/settings?tab=profile",
86
+ );
87
+
88
+ const redirectPlan = await planRedirectToHandler({
89
+ handlerName: "signIn",
90
+ rawHandlerUrl: "https://auth.example.test/handler/sign-in",
91
+ noRedirectBack: true,
92
+ currentUrl,
93
+ localOAuthCallbackUrl: "/handler/oauth-callback",
94
+ rawHomeUrl: "/home",
95
+ getCrossDomainHandoffParams: async () => {
96
+ throw new Error("Existing cross-domain handoff parameters must be reused.");
97
+ },
98
+ });
99
+
100
+ expect(redirectPlan.type).toBe("redirect");
101
+ if (redirectPlan.type !== "redirect") {
102
+ throw new Error("Expected noRedirectBack sign-in to produce a redirect URL.");
103
+ }
104
+
105
+ const hostedUrl = new URL(redirectPlan.url);
106
+ expect(hostedUrl.origin).toBe("https://auth.example.test");
107
+ expect(hostedUrl.pathname).toBe("/handler/sign-in");
108
+ expect(hostedUrl.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe(
109
+ "https://app.example.test/home",
110
+ );
111
+
112
+ const rawSourceCallbackUrl = hostedUrl.searchParams.get("after_auth_return_to");
113
+ if (rawSourceCallbackUrl == null) {
114
+ throw new Error("Expected cross-domain noRedirectBack to include its internal source callback URL.");
115
+ }
116
+ const sourceCallbackUrl = new URL(rawSourceCallbackUrl);
117
+ expect(sourceCallbackUrl.origin).toBe("https://app.example.test");
118
+ expect(sourceCallbackUrl.pathname).toBe("/handler/oauth-callback");
119
+ expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_auth")).toBe("1");
120
+ expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_state")).toBe(handoffState);
121
+ expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(handoffCodeChallenge);
122
+ expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe(
123
+ "https://app.example.test/home",
124
+ );
125
+ expect(sourceCallbackUrl.toString()).not.toContain("/settings");
126
+ });
127
+
57
128
  it("exposes redirect-back-aware handler URLs for devtool previews", async () => {
58
129
  const previousWindow = Reflect.get(globalThis, "window");
59
130
  const hadPreviousWindow = Reflect.has(globalThis, "window");
@@ -486,6 +557,8 @@ describe("StackClientApp cross-domain auth", () => {
486
557
  callbackUrl.searchParams.set("details", JSON.stringify({
487
558
  message: "Your sign up was rejected by an administrator's sign-up rule.",
488
559
  }));
560
+ callbackUrl.searchParams.set("after_auth_return_to", "/dashboard");
561
+ callbackUrl.searchParams.set("after_callback_redirect_url", "https://customer.example.test/settings?tab=connected-accounts");
489
562
  let currentHref = callbackUrl.toString();
490
563
  let redirectedUrl = "";
491
564
  const redirectSpy = vi.spyOn(StackClientApp.prototype as any, "_redirectTo").mockImplementation(async (...args: unknown[]) => {
@@ -539,6 +612,8 @@ describe("StackClientApp cross-domain auth", () => {
539
612
  expect(errorUrl.pathname).toBe("/handler/error");
540
613
  expect(errorUrl.searchParams.get("errorCode")).toBe("SIGN_UP_REJECTED");
541
614
  expect(errorUrl.searchParams.get("message")).toBe("Your sign up was rejected by an administrator's sign-up rule.");
615
+ expect(errorUrl.searchParams.get("after_auth_return_to")).toBe("/dashboard");
616
+ expect(errorUrl.searchParams.get("after_callback_redirect_url")).toBe("https://customer.example.test/settings?tab=connected-accounts");
542
617
  expect(new URL(currentHref).searchParams.has("errorCode")).toBe(false);
543
618
  });
544
619
 
@@ -59,7 +59,7 @@ import { TeamPermission } from "../../permissions";
59
59
  import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
60
60
  import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
61
61
  import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
62
- import { augmentUrlWithPersistedRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
62
+ import { augmentUrlWithPersistedRedirectBackState, getRawAfterAuthReturnTo, saveRedirectBackStateFromUrl } from "./redirect-back-state";
63
63
  import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
64
64
  import { scopePasskeyAuthenticationToHostname, scopePasskeyRegistrationToHostname } from "../../passkey-rp-id";
65
65
  import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
@@ -86,6 +86,7 @@ isReactServer = sc.isReactServer;
86
86
  const NextNavigation = scrambleDuringCompileTime(NextNavigationUnscrambled);
87
87
 
88
88
  const prefetchedCrossDomainHandoffTtlMs = 55 * 60 * 1000;
89
+ const oauthAfterCallbackRedirectUrlQueryParam = "after_callback_redirect_url";
89
90
 
90
91
  const nestedCrossDomainAuthQueryParams = {
91
92
  refreshTokenId: "stack_nested_cross_domain_auth_refresh_token_id",
@@ -94,7 +95,7 @@ const nestedCrossDomainAuthQueryParams = {
94
95
  state: "state",
95
96
  codeChallenge: "code_challenge",
96
97
  codeChallengeMethod: "code_challenge_method",
97
- afterCallbackRedirectUrl: "after_callback_redirect_url",
98
+ afterCallbackRedirectUrl: oauthAfterCallbackRedirectUrlQueryParam,
98
99
  } as const;
99
100
 
100
101
  function getRedirectHelperInstruction(handlerName: string): string {
@@ -670,18 +671,32 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
670
671
  this._redirectMethod = resolvedOptions.redirectMethod || "nextjs"; // THIS_LINE_PLATFORM next
671
672
  this._urlOptions = resolvedOptions.urls ?? {};
672
673
  this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
674
+ this._analyticsOptions = resolvedOptions.analytics;
675
+
676
+ if (extraOptions?.uniqueIdentifier !== undefined) {
677
+ this._uniqueIdentifier = extraOptions.uniqueIdentifier;
678
+ }
679
+
680
+ // Custom dashboards can disable automatic initialization so URL parameters, browser storage, analytics, and
681
+ // development overlays cannot affect the containing page. Explicit SDK calls remain functional and retain their
682
+ // documented side effects.
683
+ if (resolvedOptions.automaticSideEffects === false) {
684
+ return;
685
+ }
686
+
687
+ this._initializeAutomaticSideEffects(resolvedOptions);
688
+ }
689
+
690
+ private _initializeAutomaticSideEffects(resolvedOptions: HexclaveClientAppImplConstructorOptionsResolved<HasTokenStore, ProjectId>) {
673
691
  if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
674
692
  runAsynchronously(this._trustedParentDomainCache.getOrWait([window.location.hostname], "write-only"));
675
693
  this._ensureCrossSubdomainCookieExists();
676
694
  }
677
695
 
678
- if (extraOptions && extraOptions.uniqueIdentifier) {
679
- this._uniqueIdentifier = extraOptions.uniqueIdentifier;
696
+ if (this._uniqueIdentifier !== undefined) {
680
697
  this._initUniqueIdentifier();
681
698
  }
682
699
 
683
- this._analyticsOptions = resolvedOptions.analytics;
684
-
685
700
  const getAnalyticsSession = async (): Promise<InternalSession> => {
686
701
  this._ensurePersistentTokenStore();
687
702
  const partialUser = await this.getPartialUser({ from: 'token', or: 'anonymous-if-exists' });
@@ -887,9 +902,31 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
887
902
 
888
903
  protected async _redirectToOAuthCallbackError(error: KnownError): Promise<void> {
889
904
  const errorUrl = new URL(this._getUrls().error, window.location.href);
905
+ const callbackUrl = augmentUrlWithPersistedRedirectBackState({
906
+ currentUrl: new URL(window.location.href),
907
+ projectId: this.projectId,
908
+ });
890
909
  errorUrl.searchParams.set("errorCode", error.errorCode);
891
910
  errorUrl.searchParams.set("message", error.message);
892
911
  errorUrl.searchParams.set("details", JSON.stringify(error.details ?? {}));
912
+ for (const paramName of [
913
+ "after_auth_return_to",
914
+ crossDomainAuthQueryParams.state,
915
+ crossDomainAuthQueryParams.codeChallenge,
916
+ crossDomainAuthQueryParams.afterCallbackRedirectUrl,
917
+ ]) {
918
+ const value = callbackUrl.searchParams.get(paramName);
919
+ if (value != null) {
920
+ errorUrl.searchParams.set(paramName, value);
921
+ }
922
+ }
923
+ const afterCallbackRedirectUrl = callbackUrl.searchParams.get(oauthAfterCallbackRedirectUrlQueryParam);
924
+ if (afterCallbackRedirectUrl != null) {
925
+ // Account-link callbacks need to finish consuming the OAuth response before returning to
926
+ // their invoking page. Carry the already server-validated continuation through the
927
+ // configured error handler, which validates it again before navigation.
928
+ errorUrl.searchParams.set(oauthAfterCallbackRedirectUrlQueryParam, afterCallbackRedirectUrl);
929
+ }
893
930
  await this._redirectIfTrusted(errorUrl.toString(), { replace: true });
894
931
  }
895
932
 
@@ -2673,6 +2710,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2673
2710
  tokenStore: null,
2674
2711
  projectOwnerSession: session,
2675
2712
  noAutomaticPrefetch: true,
2713
+ automaticSideEffects: false,
2676
2714
  }));
2677
2715
  }
2678
2716
  return this._ownedAdminApps.get([session, forProjectId])!;
@@ -3014,16 +3052,18 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3014
3052
  let currentUrl = isReactServer || typeof window === "undefined"
3015
3053
  ? null
3016
3054
  : new URL(window.location.href);
3017
- if (
3018
- currentUrl != null
3019
- && options?.noRedirectBack !== true
3020
- && (handlerName === "afterSignIn" || handlerName === "afterSignUp")
3021
- ) {
3055
+ const shouldRestorePersistedRedirectBackState = (
3056
+ (options?.noRedirectBack !== true && (handlerName === "afterSignIn" || handlerName === "afterSignUp"))
3057
+ || handlerName === "forgotPassword"
3058
+ || handlerName === "passwordReset"
3059
+ || (handlerName === "signIn" && options?.noRedirectBack === true)
3060
+ );
3061
+ if (currentUrl != null && shouldRestorePersistedRedirectBackState) {
3022
3062
  // If intermediate hops dropped the redirect-back query params, restore them from the
3023
- // sessionStorage mirror so the user still returns to where they came from instead of
3024
- // stranding on the default post-auth page (on hosted components, that would be the hosted
3025
- // welcome page). The restored URL goes through the same _isTrusted / cross-domain-authorize
3026
- // validation below as one read from query params.
3063
+ // sessionStorage mirror. Password-flow continuation pages only carry this inherited state;
3064
+ // redirect planning never turns forgot/reset pages themselves into return targets. The
3065
+ // restored URL still goes through the same _isTrusted / cross-domain-authorize validation
3066
+ // below as one read from query params.
3027
3067
  currentUrl = augmentUrlWithPersistedRedirectBackState({ currentUrl, projectId: this.projectId });
3028
3068
  }
3029
3069
  const plan = await planRedirectToHandler({
@@ -3032,6 +3072,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3032
3072
  noRedirectBack: options?.noRedirectBack === true,
3033
3073
  currentUrl,
3034
3074
  localOAuthCallbackUrl: this._getLocalOAuthCallbackHandlerUrl(),
3075
+ rawHomeUrl: rawUrls.home,
3035
3076
  getCrossDomainHandoffParams: async (href) => await this._getCrossDomainHandoffParamsForRedirect(href),
3036
3077
  });
3037
3078
 
@@ -4048,13 +4089,19 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
4048
4089
  }
4049
4090
 
4050
4091
  const { analytics, ...restJson } = omit(json, ["uniqueIdentifier"]);
4051
- return new _HexclaveClientAppImplIncomplete<HasTokenStore, ProjectId>({
4092
+ const app = new _HexclaveClientAppImplIncomplete<HasTokenStore, ProjectId>({
4052
4093
  ...restJson as any,
4053
4094
  analytics: analyticsOptionsFromJson(analytics),
4054
4095
  }, {
4055
4096
  uniqueIdentifier: json.uniqueIdentifier,
4056
4097
  checkString: providedCheckString,
4057
4098
  });
4099
+ // Inert construction does not register itself, so register only after it has completed to preserve stable
4100
+ // identity across repeated client-side deserialization.
4101
+ if (json.automaticSideEffects === false) {
4102
+ app._initUniqueIdentifier();
4103
+ }
4104
+ return app;
4058
4105
  }
4059
4106
  };
4060
4107
  }
@@ -4081,6 +4128,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
4081
4128
  redirectMethod: this._redirectMethod,
4082
4129
  extraRequestHeaders: this._options.extraRequestHeaders,
4083
4130
  devTool: this._options.devTool,
4131
+ automaticSideEffects: this._options.automaticSideEffects,
4084
4132
  analytics: analyticsOptionsToJson(this._analyticsOptions),
4085
4133
  };
4086
4134
  },
@@ -4117,6 +4165,15 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
4117
4165
  redirectToHandler: async (handlerName: keyof HandlerUrls, options?: RedirectToOptions) => {
4118
4166
  await this._redirectToHandler(handlerName, options);
4119
4167
  },
4168
+ getRawAfterAuthReturnTo: () => {
4169
+ if (isReactServer || typeof window === "undefined") {
4170
+ return null;
4171
+ }
4172
+ return getRawAfterAuthReturnTo({
4173
+ currentUrl: new URL(window.location.href),
4174
+ projectId: this.projectId,
4175
+ });
4176
+ },
4120
4177
  refreshOwnedProjects: async () => {
4121
4178
  await this._refreshOwnedProjects(await this._getSession());
4122
4179
  },
@@ -3,7 +3,7 @@
3
3
  // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
4
  //===========================================
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
- import { augmentUrlWithPersistedRedirectBackState, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
6
+ import { augmentUrlWithPersistedRedirectBackState, getRawAfterAuthReturnTo, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
7
7
 
8
8
  function createMockSessionStorage() {
9
9
  const map = new Map<string, string>();
@@ -114,6 +114,34 @@ describe("redirect-back state mirror", () => {
114
114
  expect(unchanged.toString()).toBe(explicitUrl.toString());
115
115
  });
116
116
 
117
+ it("reads the explicit return target before persisted state", () => {
118
+ saveRedirectBackStateFromUrl({
119
+ url: new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/persisted"),
120
+ projectId,
121
+ });
122
+
123
+ expect(getRawAfterAuthReturnTo({
124
+ currentUrl: new URL("https://hosted.example.test/handler/mfa?after_auth_return_to=/explicit"),
125
+ projectId,
126
+ })).toBe("/explicit");
127
+ expect(getRawAfterAuthReturnTo({
128
+ currentUrl: new URL("https://hosted.example.test/handler/mfa"),
129
+ projectId,
130
+ })).toBe("/persisted");
131
+ });
132
+
133
+ it("treats an explicitly empty return target as missing instead of reviving persisted state", () => {
134
+ saveRedirectBackStateFromUrl({
135
+ url: new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/persisted"),
136
+ projectId,
137
+ });
138
+
139
+ expect(getRawAfterAuthReturnTo({
140
+ currentUrl: new URL("https://hosted.example.test/handler/mfa?after_auth_return_to=%20%20"),
141
+ projectId,
142
+ })).toBeNull();
143
+ });
144
+
117
145
  it("leaves the URL unchanged when there is no mirrored state", () => {
118
146
  const url = new URL("https://hosted.example.test/handler/sign-in");
119
147
  const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId });
@@ -111,6 +111,23 @@ export function readRedirectBackState(options: { projectId: string }): Persisted
111
111
  return parsed;
112
112
  }
113
113
 
114
+ /**
115
+ * Returns the raw return target carried by the current auth flow. The value is intentionally not
116
+ * described as trusted: callers must still pass it through the normal redirect validation before
117
+ * navigating to it.
118
+ *
119
+ * An explicit query parameter wins even when it is empty. This prevents a malformed URL from
120
+ * silently reviving an older flow from sessionStorage.
121
+ */
122
+ export function getRawAfterAuthReturnTo(options: { currentUrl: URL, projectId: string }): string | null {
123
+ const explicitReturnTo = options.currentUrl.searchParams.get("after_auth_return_to");
124
+ if (explicitReturnTo != null) {
125
+ return explicitReturnTo.trim().length > 0 ? explicitReturnTo : null;
126
+ }
127
+ const persistedReturnTo = readRedirectBackState({ projectId: options.projectId })?.afterAuthReturnTo;
128
+ return persistedReturnTo != null && persistedReturnTo.trim().length > 0 ? persistedReturnTo : null;
129
+ }
130
+
114
131
  /**
115
132
  * Returns a copy of `currentUrl` with any redirect-back query params that were dropped along the
116
133
  * way restored from the sessionStorage mirror. If `currentUrl` already carries
@@ -0,0 +1,119 @@
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 { describe, expect, it } from "vitest";
6
+ import { crossDomainAuthQueryParams, planRedirectToHandler } from "./redirect-page-urls";
7
+
8
+ const localOAuthCallbackUrl = "https://hosted.example.test/handler/oauth-callback";
9
+
10
+ async function plan(options: {
11
+ handlerName: "signIn" | "forgotPassword" | "passwordReset" | "afterSignIn" | "afterSignUp",
12
+ rawHandlerUrl: string,
13
+ currentUrl: URL,
14
+ noRedirectBack?: boolean,
15
+ }) {
16
+ return await planRedirectToHandler({
17
+ ...options,
18
+ noRedirectBack: options.noRedirectBack === true,
19
+ localOAuthCallbackUrl,
20
+ getCrossDomainHandoffParams: async () => {
21
+ throw new Error("Continuation-only redirects must not mint a new cross-domain handoff");
22
+ },
23
+ });
24
+ }
25
+
26
+ function expectRedirectUrl(planResult: Awaited<ReturnType<typeof plan>>, baseUrl: URL): URL {
27
+ if (planResult.type !== "redirect") {
28
+ throw new Error("Expected a direct redirect plan");
29
+ }
30
+ return new URL(planResult.url, baseUrl);
31
+ }
32
+
33
+ function expectContinuation(url: URL, expectedReturnTo: string): void {
34
+ expect(url.searchParams.get("after_auth_return_to")).toBe(expectedReturnTo);
35
+ expect(url.searchParams.get(crossDomainAuthQueryParams.state)).toBe("outer-state");
36
+ expect(url.searchParams.get(crossDomainAuthQueryParams.codeChallenge)).toBe("outer-challenge");
37
+ expect(url.searchParams.get(crossDomainAuthQueryParams.afterCallbackRedirectUrl)).toBe("https://customer.example.test/dashboard");
38
+ }
39
+
40
+ describe("password-flow redirect continuation", () => {
41
+ it("carries the complete customer return state through forgot-password and a reset-email new tab", async () => {
42
+ const customerReturnTo = "https://customer.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1";
43
+ const signInUrl = new URL("https://hosted.example.test/handler/sign-in");
44
+ signInUrl.searchParams.set("after_auth_return_to", customerReturnTo);
45
+ signInUrl.searchParams.set(crossDomainAuthQueryParams.state, "outer-state");
46
+ signInUrl.searchParams.set(crossDomainAuthQueryParams.codeChallenge, "outer-challenge");
47
+ signInUrl.searchParams.set(crossDomainAuthQueryParams.afterCallbackRedirectUrl, "https://customer.example.test/dashboard");
48
+
49
+ const forgotPlan = await plan({
50
+ handlerName: "forgotPassword",
51
+ rawHandlerUrl: "/handler/forgot-password",
52
+ currentUrl: signInUrl,
53
+ });
54
+ const forgotUrl = expectRedirectUrl(forgotPlan, signInUrl);
55
+ expectContinuation(forgotUrl, customerReturnTo);
56
+
57
+ const callbackPlan = await plan({
58
+ handlerName: "passwordReset",
59
+ rawHandlerUrl: "/handler/password-reset",
60
+ currentUrl: forgotUrl,
61
+ });
62
+ const resetEmailCallbackUrl = expectRedirectUrl(callbackPlan, forgotUrl);
63
+ expectContinuation(resetEmailCallbackUrl, customerReturnTo);
64
+
65
+ // Model opening the callback in a new tab: only URL state is available.
66
+ resetEmailCallbackUrl.searchParams.set("code", "reset-code");
67
+ const signInPlan = await plan({
68
+ handlerName: "signIn",
69
+ rawHandlerUrl: "/handler/sign-in",
70
+ currentUrl: resetEmailCallbackUrl,
71
+ noRedirectBack: true,
72
+ });
73
+ const finalSignInUrl = expectRedirectUrl(signInPlan, resetEmailCallbackUrl);
74
+ expectContinuation(finalSignInUrl, customerReturnTo);
75
+ expect(finalSignInUrl.searchParams.has("code")).toBe(false);
76
+ });
77
+
78
+ it.each([
79
+ ["forgotPassword", "/handler/forgot-password"],
80
+ ["passwordReset", "/handler/password-reset"],
81
+ ] as const)("never synthesizes the current %s page as a return target", async (handlerName, rawHandlerUrl) => {
82
+ const currentUrl = new URL(`https://hosted.example.test${rawHandlerUrl}?code=reset-code`);
83
+ const result = expectRedirectUrl(await plan({
84
+ handlerName,
85
+ rawHandlerUrl,
86
+ currentUrl,
87
+ }), currentUrl);
88
+
89
+ expect(result.searchParams.has("after_auth_return_to")).toBe(false);
90
+ expect(result.searchParams.has("code")).toBe(false);
91
+ });
92
+
93
+ it("makes sign-in noRedirectBack suppress capture while preserving only inherited state", async () => {
94
+ const resetUrlWithoutContinuation = new URL("https://hosted.example.test/handler/password-reset?code=reset-code");
95
+ const result = expectRedirectUrl(await plan({
96
+ handlerName: "signIn",
97
+ rawHandlerUrl: "/handler/sign-in",
98
+ currentUrl: resetUrlWithoutContinuation,
99
+ noRedirectBack: true,
100
+ }), resetUrlWithoutContinuation);
101
+
102
+ expect(result.toString()).toBe("https://hosted.example.test/handler/sign-in");
103
+ });
104
+
105
+ it.each(["afterSignIn", "afterSignUp"] as const)(
106
+ "keeps existing %s noRedirectBack semantics",
107
+ async (handlerName) => {
108
+ const currentUrl = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=%2Fcustomer");
109
+ const result = expectRedirectUrl(await plan({
110
+ handlerName,
111
+ rawHandlerUrl: "/default-after-auth",
112
+ currentUrl,
113
+ noRedirectBack: true,
114
+ }), currentUrl);
115
+
116
+ expect(result.toString()).toBe("https://hosted.example.test/default-after-auth");
117
+ },
118
+ );
119
+ });