@hosty-sdk/app 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
1
  export declare const APP_SESSION_ENDED = "hosty:app-session-ended";
2
+ export declare const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
3
+ /** Restore before the first identity probe; standalone documents use their first-party cookie. */
4
+ export declare function restoreAppGrant(): boolean;
2
5
  export declare function rememberAppGrant(token: string): void;
3
6
  export declare function forgetAppGrant(): void;
7
+ /** Snapshot the grant generation so older probes cannot invalidate a renewed identity. */
8
+ export declare function appGrantRevision(): number;
9
+ /** Activity expiry keeps identity; only explicit identity rejection discards the credential. */
10
+ export declare function forgetRejectedAppGrant(code: unknown, expectedRevision?: number): boolean;
4
11
  export declare function appSessionActive(): void;
5
12
  /** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
6
13
  export declare function appFetch(input: string | URL, init?: RequestInit, recover?: boolean): Promise<Response>;
@@ -1,12 +1,71 @@
1
- /** App-only browser credentials. Never persisted or sent to an embedder. */
1
+ /** App-only credentials. Embedded documents keep them for this tab on the app's own origin. */
2
2
  let grant = null;
3
+ let grantRevision = 0;
3
4
  let recoveryPending = false;
4
5
  export const APP_SESSION_ENDED = "hosty:app-session-ended";
6
+ export const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
7
+ function isEmbedded() {
8
+ try {
9
+ return window.self !== window.top;
10
+ }
11
+ catch {
12
+ return true;
13
+ }
14
+ }
15
+ /** Restore before the first identity probe; standalone documents use their first-party cookie. */
16
+ export function restoreAppGrant() {
17
+ if (typeof window === "undefined")
18
+ return false;
19
+ if (grant?.origin === window.location.origin)
20
+ return true;
21
+ if (!isEmbedded())
22
+ return false;
23
+ try {
24
+ const token = window.sessionStorage.getItem(APP_GRANT_STORAGE_KEY);
25
+ if (token) {
26
+ grant = { token, origin: window.location.origin };
27
+ grantRevision++;
28
+ }
29
+ }
30
+ catch {
31
+ // Blocked storage leaves the in-memory transport available.
32
+ }
33
+ return grant?.origin === window.location.origin;
34
+ }
5
35
  export function rememberAppGrant(token) {
6
36
  grant = { token, origin: window.location.origin };
37
+ grantRevision++;
7
38
  recoveryPending = false;
39
+ if (isEmbedded()) {
40
+ try {
41
+ window.sessionStorage.setItem(APP_GRANT_STORAGE_KEY, token);
42
+ }
43
+ catch { /* In-memory fallback. */ }
44
+ }
45
+ }
46
+ export function forgetAppGrant() {
47
+ if (grant)
48
+ grantRevision++;
49
+ grant = null;
50
+ recoveryPending = false;
51
+ if (typeof window !== "undefined") {
52
+ try {
53
+ window.sessionStorage.removeItem(APP_GRANT_STORAGE_KEY);
54
+ }
55
+ catch { /* In-memory fallback. */ }
56
+ }
57
+ }
58
+ /** Snapshot the grant generation so older probes cannot invalidate a renewed identity. */
59
+ export function appGrantRevision() { return grantRevision; }
60
+ /** Activity expiry keeps identity; only explicit identity rejection discards the credential. */
61
+ export function forgetRejectedAppGrant(code, expectedRevision = grantRevision) {
62
+ if (expectedRevision !== grantRevision)
63
+ return false;
64
+ if (typeof code !== "string" || !["token_invalid", "token_revoked", "token_expired", "token_app_mismatch"].includes(code))
65
+ return false;
66
+ forgetAppGrant();
67
+ return true;
8
68
  }
9
- export function forgetAppGrant() { grant = null; recoveryPending = false; }
10
69
  export function appSessionActive() { recoveryPending = false; }
11
70
  /** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
12
71
  export async function appFetch(input, init = {}, recover = true) {
@@ -20,11 +79,21 @@ export async function appFetch(input, init = {}, recover = true) {
20
79
  if (current)
21
80
  headers.set("authorization", `Bearer ${current.token}`);
22
81
  const response = await fetch(input, { ...init, headers, credentials: "same-origin", redirect: "error" });
23
- if (response.status === 401 && recover && (!current || grant === current)) {
82
+ if ((response.status === 401 || response.status === 403) && grant === current && !init.signal?.aborted) {
83
+ const revision = grantRevision;
24
84
  const body = await response.clone().json().catch(() => null);
85
+ if (grant !== current || grantRevision !== revision || init.signal?.aborted)
86
+ return response;
87
+ // Identity probes disable recovery, but a rejected grant must still be forgotten. Activity
88
+ // expiry is different: the identity remains valid and the popup renews the same session.
89
+ const rejected = forgetRejectedAppGrant(body?.code);
90
+ if (response.status !== 401)
91
+ return response;
92
+ if (!rejected && recover && body?.code !== "reauth_required" && current)
93
+ forgetAppGrant();
94
+ if (!recover)
95
+ return response;
25
96
  if (activityConfig) {
26
- if (body?.code !== "reauth_required" && current)
27
- grant = null;
28
97
  const renewed = waitForActivity(init.signal);
29
98
  window.dispatchEvent(new Event(APP_SESSION_ENDED));
30
99
  // A response may arrive while the originating click still has browser activation.
@@ -35,8 +104,6 @@ export async function appFetch(input, init = {}, recover = true) {
35
104
  return appFetch(input, init, false);
36
105
  }
37
106
  else if (!recoveryPending) {
38
- if (current)
39
- grant = null;
40
107
  recoveryPending = true;
41
108
  window.dispatchEvent(new Event(APP_SESSION_ENDED));
42
109
  }
@@ -13,7 +13,8 @@ export type AuthRequiredMessage = EmbedderMessage;
13
13
  * - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
14
14
  * - the payload's `appId` must match the app the embedder actually mounted there.
15
15
  * No credential crosses the boundary in either direction: the payload is only the app id,
16
- * and the embedder answers by reissuing a code through its own authenticated Core call.
16
+ * and the app owns its direct sign-in with Core. This parser gives the embedder no authority
17
+ * to issue or redeem credentials for the app.
17
18
  */
18
19
  export declare function parseActiveFrameAuthRequired(event: AuthRequiredMessage, activeFrameWindow: unknown, activeFrameUrl: string, expectedAppId: string): boolean;
19
20
  /** A verified request, and the one thing its payload carries. */
package/dist/embedder.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // functions plus a small rate limiter — the embedder wires them to its own DOM, its own "open
4
4
  // this app" flow, and its own Core session.
5
5
  //
6
- // Recovery, in one sentence: on a VERIFIED `hosty:auth-required` from an app you embed,
7
- // re-run your normal open flow for that app (which mints a fresh launch code), rate-limited.
8
- // The re-open must take the full launch-code path — an "already open → reuse the URL
9
- // without a code" optimization must not short-circuit recovery.
6
+ // Authentication belongs to the app frame: it restores its own per-tab grant, tries a silent
7
+ // Core navigation on first load, and opens its own Core popup when interaction is required.
8
+ // An embedder may observe a verified `hosty:auth-required` intent, but never carries or
9
+ // redeems another app's code or grant.
10
10
  //
11
11
  // Delegated tokens, in one sentence: on a VERIFIED `hosty:request-delegated-token` from an app
12
12
  // you have decided to grant one, mint a token from Core with your own session and post it back
@@ -21,7 +21,8 @@ import { THEME_PARAM, THEME_PREFERENCE_PARAM } from "./theme.js";
21
21
  * - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
22
22
  * - the payload's `appId` must match the app the embedder actually mounted there.
23
23
  * No credential crosses the boundary in either direction: the payload is only the app id,
24
- * and the embedder answers by reissuing a code through its own authenticated Core call.
24
+ * and the app owns its direct sign-in with Core. This parser gives the embedder no authority
25
+ * to issue or redeem credentials for the app.
25
26
  */
26
27
  export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFrameUrl, expectedAppId) {
27
28
  if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The postMessage type an embedded app sends its parent to request a fresh launch code.
2
+ * The postMessage type for an embedded app's authentication-required intent.
3
3
  * A frozen protocol constant: it deliberately does not track product branding (precedent:
4
4
  * the `x-docker-host-identity` header survived the docker-host→hosty rename untouched).
5
5
  * The payload carries no secret — the embedder verifies the sender before acting.
@@ -13,8 +13,8 @@ export declare function createAuthRequiredIntent(appId: string): AuthRequiredInt
13
13
  /**
14
14
  * The postMessage types of the delegated-token handshake: an embedded app page asks its embedder
15
15
  * for a short-TTL token scoped to itself, and the embedder answers with one it minted from Core.
16
- * Same shape of contract as the launch-code recovery above — the app cannot self-serve, because
17
- * minting needs the user's Core session in a first-party context, which only the embedder has.
16
+ * Minting a delegated token uses the embedder's first-party Core session. The app's own identity
17
+ * sign-in is separate: it navigates directly to Core or opens its own popup.
18
18
  *
19
19
  * The request carries no secret, so it is safe to broadcast; the answer does, so an embedder must
20
20
  * post it to the frame's own origin and never to `*`. Answering is a decision, not a reflex: the
@@ -126,8 +126,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
126
126
  /**
127
127
  * How a shell is presenting this app.
128
128
  *
129
- * - `embedded` — framed by the browser Shell. The shell renders navigation for this app, and a
130
- * parent frame exists, so identity recovery is a `hosty:auth-required` post to it.
129
+ * - `embedded` — framed by the browser Shell. The shell renders navigation for this app. The
130
+ * app restores its per-tab grant, tries a silent Core redirect on first load, then uses its
131
+ * own Core popup when interaction is required. No credential passes through the parent.
131
132
  * - `native` — the top frame inside a native shell's web view (`apps/shell-swift`). The shell
132
133
  * renders navigation, but there is no parent to post to, so recovery takes the standalone
133
134
  * redirect — which is exactly the navigation that client intercepts to re-mint a launch code.
@@ -135,10 +136,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
135
136
  * for this app, so it keeps its own.
136
137
  *
137
138
  * Two independent decisions read this one value and they do not split it the same way: chrome
138
- * is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while the parent post
139
- * belongs to `embedded` alone (`decideRecoveryAction`). That asymmetry is the whole reason
140
- * `native` exists as its own value — calling a native web view `embedded` to get its chrome
141
- * hidden would send recovery to a parent that is not there.
139
+ * is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while frame-local silent
140
+ * sign-in and popup recovery belong to `embedded` alone (`AppIdentityBridge`). A native web
141
+ * view keeps standalone recovery even though its app chrome is hidden.
142
142
  */
143
143
  export type AppLaunchMode = "embedded" | "native" | "standalone";
144
144
  /** The query parameter a shell appends to the launch URL to declare the mode. */
@@ -160,9 +160,8 @@ export declare function normalizeLaunchMode(value: unknown): AppLaunchMode | nul
160
160
  * `standalone` — a native shell's web view makes the app the top frame, so it reads as
161
161
  * `standalone`, which is why the declared `hosty_launch` parameter exists at all.
162
162
  *
163
- * This stays the input to the *recovery* decision, because whether a parent exists is a
164
- * structural fact that a declared value cannot override: a stale `embedded` would otherwise
165
- * post into a window with no shell listening.
163
+ * This stays the input to the *recovery* decision, because framing is a structural fact a
164
+ * declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
166
165
  */
167
166
  export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): Exclude<AppLaunchMode, "native">;
168
167
  export interface LaunchModeResolution {
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // This is the single source of truth for the Hosty app-session contract described in
5
5
  // docker-host docs/features/hosty-app-sdk/feature.md and docs/features/auth-session-lifecycle/feature.md.
6
6
  /**
7
- * The postMessage type an embedded app sends its parent to request a fresh launch code.
7
+ * The postMessage type for an embedded app's authentication-required intent.
8
8
  * A frozen protocol constant: it deliberately does not track product branding (precedent:
9
9
  * the `x-docker-host-identity` header survived the docker-host→hosty rename untouched).
10
10
  * The payload carries no secret — the embedder verifies the sender before acting.
@@ -16,8 +16,8 @@ export function createAuthRequiredIntent(appId) {
16
16
  /**
17
17
  * The postMessage types of the delegated-token handshake: an embedded app page asks its embedder
18
18
  * for a short-TTL token scoped to itself, and the embedder answers with one it minted from Core.
19
- * Same shape of contract as the launch-code recovery above — the app cannot self-serve, because
20
- * minting needs the user's Core session in a first-party context, which only the embedder has.
19
+ * Minting a delegated token uses the embedder's first-party Core session. The app's own identity
20
+ * sign-in is separate: it navigates directly to Core or opens its own popup.
21
21
  *
22
22
  * The request carries no secret, so it is safe to broadcast; the answer does, so an embedder must
23
23
  * post it to the frame's own origin and never to `*`. Answering is a decision, not a reflex: the
@@ -152,9 +152,8 @@ export function normalizeLaunchMode(value) {
152
152
  * `standalone` — a native shell's web view makes the app the top frame, so it reads as
153
153
  * `standalone`, which is why the declared `hosty_launch` parameter exists at all.
154
154
  *
155
- * This stays the input to the *recovery* decision, because whether a parent exists is a
156
- * structural fact that a declared value cannot override: a stale `embedded` would otherwise
157
- * post into a window with no shell listening.
155
+ * This stays the input to the *recovery* decision, because framing is a structural fact a
156
+ * declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
158
157
  */
159
158
  export function detectLaunchMode(win) {
160
159
  try {
@@ -241,7 +240,8 @@ export function decideRecoveryAction(input) {
241
240
  }
242
241
  // expired / not-present — the recoverable pair.
243
242
  //
244
- // `embedded` is the only mode with a parent to ask. `native` deliberately falls through to the
243
+ // `embedded` selects app-owned interactive recovery after the bridge's silent first-load path.
244
+ // `native` deliberately falls through to the
245
245
  // redirect beside `standalone`: a native shell's web view has no parent, and the redirect to
246
246
  // Core's `/open` is the navigation that client watches for to re-mint a launch code. Adding a
247
247
  // mode must never quietly add a case here — a mode that cannot reach a shell has to redirect.
package/dist/react.d.ts CHANGED
@@ -55,7 +55,8 @@ export interface AppIdentityBridgeProps {
55
55
  /**
56
56
  * Mount once at the top of the root layout body. On first load it consumes a `?code` from
57
57
  * the URL (exchanging it for the identity cookie), then probes the session and runs the
58
- * recovery decision: an app-owned Core popup when embedded, a once-per-tab
58
+ * recovery decision: a silent first-load Core navigation followed by an app-owned popup
59
+ * when embedded, a once-per-tab
59
60
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
60
61
  * — never a login UI while recovery is still running.
61
62
  */
package/dist/react.js CHANGED
@@ -6,12 +6,14 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
6
6
  import { AppActivityBridge } from "./activity-react.js";
7
7
  import { AuthNotice, AuthNoticeButton, authNoticeActionStyle } from "./auth-notice.js";
8
8
  import { useEffect, useRef, useState } from "react";
9
- import { appFetch, appSessionActive, rememberAppGrant, openAppSignIn, APP_SESSION_ENDED } from "./browser-auth.js";
9
+ import { appFetch, appSessionActive, appGrantRevision, rememberAppGrant, restoreAppGrant, forgetRejectedAppGrant, openAppSignIn, APP_SESSION_ENDED } from "./browser-auth.js";
10
10
  import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
11
11
  import { applyTheme, parseShellThemeMessage, resolveTheme, THEME_PARAM, THEME_PREFERENCE_PARAM, THEME_PREFERENCE_STORAGE_KEY, THEME_STORAGE_KEY, } from "./theme.js";
12
12
  // Once-per-tab guard so a standalone app that returns from Core still unauthorized does
13
13
  // not bounce through /open forever. Cleared on a successful code exchange.
14
14
  const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
15
+ const SILENT_STATE_KEY = "hosty.auth.silent-state";
16
+ const SILENT_GUARD_PREFIX = "hosty.auth.silent-attempted:";
15
17
  // Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
16
18
  // timeout it classifies as unavailable and the user gets a Retry affordance.
17
19
  const IDENTITY_PROBE_TIMEOUT_MS = 4_000;
@@ -66,6 +68,40 @@ function writeGuard(value) {
66
68
  // sessionStorage may be blocked; recovery still works, only the guard is lost.
67
69
  }
68
70
  }
71
+ /** Both state and the per-app guard must survive navigation, or the popup is the fallback. */
72
+ function beginSilentSignIn(openUrl, appId) {
73
+ try {
74
+ const guardKey = `${SILENT_GUARD_PREFIX}${appId}`;
75
+ if (window.sessionStorage.getItem(guardKey) === "1")
76
+ return null;
77
+ const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
78
+ window.sessionStorage.setItem(SILENT_STATE_KEY, state);
79
+ window.sessionStorage.setItem(guardKey, "1");
80
+ if (window.sessionStorage.getItem(SILENT_STATE_KEY) !== state || window.sessionStorage.getItem(guardKey) !== "1")
81
+ return null;
82
+ const url = new URL(openUrl);
83
+ url.searchParams.set("prompt", "none");
84
+ url.searchParams.set("state", state);
85
+ return url.href;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function consumeSilentState(state) {
92
+ if (!state)
93
+ return false;
94
+ try {
95
+ const expected = window.sessionStorage.getItem(SILENT_STATE_KEY);
96
+ if (!expected || expected !== state || !/^[a-f0-9]{64}$/.test(state))
97
+ return false;
98
+ window.sessionStorage.removeItem(SILENT_STATE_KEY);
99
+ return true;
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
69
105
  function readStoredLaunchMode() {
70
106
  try {
71
107
  return window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
@@ -144,7 +180,8 @@ export function useLaunchMode() {
144
180
  /**
145
181
  * Mount once at the top of the root layout body. On first load it consumes a `?code` from
146
182
  * the URL (exchanging it for the identity cookie), then probes the session and runs the
147
- * recovery decision: an app-owned Core popup when embedded, a once-per-tab
183
+ * recovery decision: a silent first-load Core navigation followed by an app-owned popup
184
+ * when embedded, a once-per-tab
148
185
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
149
186
  * — never a login UI while recovery is still running.
150
187
  */
@@ -153,11 +190,16 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
153
190
  // A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
154
191
  // keep its exchange alive and let the replacement effect await the same response.
155
192
  const wasActive = useRef(false);
193
+ const firstProbe = useRef(true);
194
+ const initialGrantPresent = useRef(null);
195
+ const silentError = useRef(null);
156
196
  const [activity, setActivity] = useState(null);
157
197
  const exchangeRef = useRef(null);
158
198
  useEffect(() => {
159
199
  let cancelled = false;
160
200
  const controller = new AbortController();
201
+ if (initialGrantPresent.current === null)
202
+ initialGrantPresent.current = restoreAppGrant();
161
203
  async function probeAndRecover() {
162
204
  // Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
163
205
  // available in every browser (a synchronous throw here would stop the bridge from
@@ -169,6 +211,8 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
169
211
  let status = null;
170
212
  let openUrl = null;
171
213
  let appId = null;
214
+ let rejectionCode;
215
+ const revision = appGrantRevision();
172
216
  try {
173
217
  const response = await appFetch(probePath, {
174
218
  headers: { Accept: "application/json" },
@@ -177,6 +221,8 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
177
221
  }, false);
178
222
  const body = await response.json().catch(() => null);
179
223
  status = readProbedSessionStatus(body);
224
+ const identityError = body;
225
+ rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
180
226
  const recovery = readRecoveryParams(body);
181
227
  appId = recovery.appId;
182
228
  openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
@@ -195,10 +241,30 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
195
241
  if (cancelled) {
196
242
  return;
197
243
  }
244
+ if (revision !== appGrantRevision()) {
245
+ initialGrantPresent.current ||= restoreAppGrant();
246
+ void probeAndRecover();
247
+ return;
248
+ }
249
+ forgetRejectedAppGrant(rejectionCode, revision);
250
+ const initialLoad = firstProbe.current;
251
+ firstProbe.current = false;
252
+ if (silentError.current === "access_denied") {
253
+ setUi({ kind: "denied" });
254
+ return;
255
+ }
256
+ if (initialLoad && !wasActive.current && !initialGrantPresent.current && !silentError.current &&
257
+ detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
258
+ const silentUrl = beginSilentSignIn(openUrl, appId);
259
+ if (silentUrl) {
260
+ window.location.replace(silentUrl);
261
+ return;
262
+ }
263
+ }
198
264
  const action = !status || !appId
199
265
  ? { kind: "card", card: "unavailable" }
200
266
  : decideRecoveryAction({
201
- status,
267
+ status: silentError.current === "login_required" ? "not-present" : status,
202
268
  mode: detectLaunchMode(window),
203
269
  appId,
204
270
  openUrl,
@@ -227,6 +293,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
227
293
  if (cancelled)
228
294
  return;
229
295
  rememberAppGrant(body.accessToken);
296
+ silentError.current = null;
230
297
  writeGuard(false);
231
298
  await probeAndRecover();
232
299
  }).catch(error => { if (!cancelled)
@@ -253,9 +320,22 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
253
320
  window.addEventListener(APP_SESSION_ENDED, recover);
254
321
  const url = new URL(window.location.href);
255
322
  const code = url.searchParams.get("code")?.trim();
256
- if (code) {
323
+ const error = url.searchParams.get("error");
324
+ const embedded = detectLaunchMode(window) === "embedded";
325
+ const isSilentError = error === "login_required" || error === "access_denied";
326
+ const accepted = embedded && (code || isSilentError) ? consumeSilentState(url.searchParams.get("state")) : !embedded;
327
+ if (embedded && accepted && isSilentError)
328
+ silentError.current = error;
329
+ if (code || (embedded && isSilentError)) {
257
330
  url.searchParams.delete("code");
331
+ if (embedded) {
332
+ url.searchParams.delete("state");
333
+ if (isSilentError)
334
+ url.searchParams.delete("error");
335
+ }
258
336
  window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
337
+ }
338
+ if (code && accepted) {
259
339
  exchangeRef.current = {
260
340
  path: appCodePath,
261
341
  response: fetch(appCodePath, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",