@hosty-sdk/app 0.19.1 → 0.21.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.
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
@@ -102,6 +102,7 @@ export declare function classifyRevalidationHttpStatus(status: number): AppSessi
102
102
  export interface SessionRecoveryParams {
103
103
  appId: string | null;
104
104
  corePublicOrigin: string | null;
105
+ appAuthProtocol: 1 | 2 | null;
105
106
  }
106
107
  export declare function readRecoveryParams(body: unknown): SessionRecoveryParams;
107
108
  /** True for hosts that only resolve on the machine itself. */
@@ -126,8 +127,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
126
127
  /**
127
128
  * How a shell is presenting this app.
128
129
  *
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.
130
+ * - `embedded` — framed by the browser Shell. The shell renders navigation for this app. The
131
+ * app restores its per-tab grant, tries a silent Core redirect on first load, then uses its
132
+ * own Core popup when interaction is required. No credential passes through the parent.
131
133
  * - `native` — the top frame inside a native shell's web view (`apps/shell-swift`). The shell
132
134
  * renders navigation, but there is no parent to post to, so recovery takes the standalone
133
135
  * redirect — which is exactly the navigation that client intercepts to re-mint a launch code.
@@ -135,10 +137,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
135
137
  * for this app, so it keeps its own.
136
138
  *
137
139
  * 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.
140
+ * is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while frame-local silent
141
+ * sign-in and popup recovery belong to `embedded` alone (`AppIdentityBridge`). A native web
142
+ * view keeps standalone recovery even though its app chrome is hidden.
142
143
  */
143
144
  export type AppLaunchMode = "embedded" | "native" | "standalone";
144
145
  /** The query parameter a shell appends to the launch URL to declare the mode. */
@@ -160,9 +161,8 @@ export declare function normalizeLaunchMode(value: unknown): AppLaunchMode | nul
160
161
  * `standalone` — a native shell's web view makes the app the top frame, so it reads as
161
162
  * `standalone`, which is why the declared `hosty_launch` parameter exists at all.
162
163
  *
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.
164
+ * This stays the input to the *recovery* decision, because framing is a structural fact a
165
+ * declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
166
166
  */
167
167
  export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): Exclude<AppLaunchMode, "native">;
168
168
  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
@@ -89,10 +89,11 @@ export function classifyRevalidationHttpStatus(status) {
89
89
  export function readRecoveryParams(body) {
90
90
  const recovery = body && typeof body === "object" ? body.recovery : null;
91
91
  if (!recovery || typeof recovery !== "object") {
92
- return { appId: null, corePublicOrigin: null };
92
+ return { appId: null, corePublicOrigin: null, appAuthProtocol: null };
93
93
  }
94
- const { appId, corePublicOrigin } = recovery;
94
+ const { appId, corePublicOrigin, appAuthProtocol } = recovery;
95
95
  return {
96
+ appAuthProtocol: appAuthProtocol === 1 || appAuthProtocol === 2 ? appAuthProtocol : null,
96
97
  appId: typeof appId === "string" && appId.length > 0 ? appId : null,
97
98
  corePublicOrigin: typeof corePublicOrigin === "string" && corePublicOrigin.length > 0
98
99
  ? corePublicOrigin
@@ -102,7 +103,7 @@ export function readRecoveryParams(body) {
102
103
  /** True for hosts that only resolve on the machine itself. */
103
104
  export function isLoopbackHost(hostname) {
104
105
  const host = hostname.toLowerCase();
105
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
106
+ return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
106
107
  }
107
108
  /**
108
109
  * Builds the Core standalone re-auth URL (`/api/apps/{id}/open?redirectUri=…`) for the
@@ -152,9 +153,8 @@ export function normalizeLaunchMode(value) {
152
153
  * `standalone` — a native shell's web view makes the app the top frame, so it reads as
153
154
  * `standalone`, which is why the declared `hosty_launch` parameter exists at all.
154
155
  *
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.
156
+ * This stays the input to the *recovery* decision, because framing is a structural fact a
157
+ * declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
158
158
  */
159
159
  export function detectLaunchMode(win) {
160
160
  try {
@@ -241,7 +241,8 @@ export function decideRecoveryAction(input) {
241
241
  }
242
242
  // expired / not-present — the recoverable pair.
243
243
  //
244
- // `embedded` is the only mode with a parent to ask. `native` deliberately falls through to the
244
+ // `embedded` selects app-owned interactive recovery after the bridge's silent first-load path.
245
+ // `native` deliberately falls through to the
245
246
  // redirect beside `standalone`: a native shell's web view has no parent, and the redirect to
246
247
  // Core's `/open` is the navigation that client watches for to re-mint a launch code. Adding a
247
248
  // 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
+ import { acceptAppAuthProtocol, createAppAuthAttempt, persistAppAuthAttempt, takeAppAuthAttempt, submitAppAuthIntent, appAuthNavigationUrl, APP_AUTH_ATTEMPT_PREFIX } from "./browser-auth.js";
10
11
  import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
11
12
  import { applyTheme, parseShellThemeMessage, resolveTheme, THEME_PARAM, THEME_PREFERENCE_PARAM, THEME_PREFERENCE_STORAGE_KEY, THEME_STORAGE_KEY, } from "./theme.js";
12
13
  // Once-per-tab guard so a standalone app that returns from Core still unauthorized does
13
14
  // not bounce through /open forever. Cleared on a successful code exchange.
14
15
  const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
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,42 @@ 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 beginNavigationSignIn(openUrl, appId, protocol, mode, force = false) {
73
+ let state = null;
74
+ try {
75
+ const guardKey = mode === "silent" ? `${SILENT_GUARD_PREFIX}${appId}` : RECOVERY_GUARD_KEY;
76
+ if (!force && window.sessionStorage.getItem(guardKey) === "1")
77
+ return false;
78
+ const attempt = createAppAuthAttempt(openUrl, mode, protocol);
79
+ state = attempt.state;
80
+ if (!persistAppAuthAttempt(attempt))
81
+ return false;
82
+ window.sessionStorage.setItem(guardKey, "1");
83
+ if (window.sessionStorage.getItem(guardKey) !== "1")
84
+ throw new Error("Sign-in storage is unavailable.");
85
+ if (mode === "native")
86
+ window.location.assign(appAuthNavigationUrl(attempt));
87
+ else if (protocol === 1) {
88
+ if (mode === "silent")
89
+ window.location.replace(appAuthNavigationUrl(attempt));
90
+ else
91
+ window.location.assign(appAuthNavigationUrl(attempt));
92
+ }
93
+ else
94
+ submitAppAuthIntent(attempt);
95
+ return true;
96
+ }
97
+ catch {
98
+ if (state) {
99
+ try {
100
+ window.sessionStorage.removeItem(`${APP_AUTH_ATTEMPT_PREFIX}${state}`);
101
+ }
102
+ catch { /* Popup fallback. */ }
103
+ }
104
+ return false;
105
+ }
106
+ }
69
107
  function readStoredLaunchMode() {
70
108
  try {
71
109
  return window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
@@ -76,7 +114,7 @@ function readStoredLaunchMode() {
76
114
  }
77
115
  /** The mode already applied to the document, or a fresh resolution when nothing applied one. */
78
116
  function currentLaunchMode() {
79
- const applied = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
117
+ const applied = typeof document === "undefined" ? null : normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
80
118
  if (applied) {
81
119
  return applied;
82
120
  }
@@ -144,7 +182,8 @@ export function useLaunchMode() {
144
182
  /**
145
183
  * Mount once at the top of the root layout body. On first load it consumes a `?code` from
146
184
  * 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
185
+ * recovery decision: a silent first-load Core navigation followed by an app-owned popup
186
+ * when embedded, a once-per-tab
148
187
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
149
188
  * — never a login UI while recovery is still running.
150
189
  */
@@ -153,11 +192,18 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
153
192
  // A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
154
193
  // keep its exchange alive and let the replacement effect await the same response.
155
194
  const wasActive = useRef(false);
195
+ const firstProbe = useRef(true);
196
+ const initialGrantPresent = useRef(null);
197
+ const silentError = useRef(null);
198
+ const returnedLegacyAttempt = useRef(null);
199
+ const upgradedAttempt = useRef(false);
156
200
  const [activity, setActivity] = useState(null);
157
201
  const exchangeRef = useRef(null);
158
202
  useEffect(() => {
159
203
  let cancelled = false;
160
204
  const controller = new AbortController();
205
+ if (initialGrantPresent.current === null)
206
+ initialGrantPresent.current = restoreAppGrant();
161
207
  async function probeAndRecover() {
162
208
  // Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
163
209
  // available in every browser (a synchronous throw here would stop the bridge from
@@ -169,6 +215,10 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
169
215
  let status = null;
170
216
  let openUrl = null;
171
217
  let appId = null;
218
+ let protocol = null;
219
+ let activityMetadata = null;
220
+ let rejectionCode;
221
+ const revision = appGrantRevision();
172
222
  try {
173
223
  const response = await appFetch(probePath, {
174
224
  headers: { Accept: "application/json" },
@@ -177,12 +227,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
177
227
  }, false);
178
228
  const body = await response.json().catch(() => null);
179
229
  status = readProbedSessionStatus(body);
230
+ const identityError = body;
231
+ rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
180
232
  const recovery = readRecoveryParams(body);
181
233
  appId = recovery.appId;
182
234
  openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
183
- const metadata = body;
184
- if (!cancelled && openUrl)
185
- setActivity({ openUrl, activeUntil: metadata?.activeUntil, activityRequired: metadata?.activityRequired });
235
+ protocol = openUrl ? acceptAppAuthProtocol(new URL(openUrl).origin, recovery.appAuthProtocol) : recovery.appAuthProtocol;
236
+ activityMetadata = body;
186
237
  }
187
238
  catch {
188
239
  // A failed or timed-out probe (Core unreachable) classifies as unavailable below.
@@ -195,14 +246,46 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
195
246
  if (cancelled) {
196
247
  return;
197
248
  }
198
- const action = !status || !appId
249
+ if (revision !== appGrantRevision()) {
250
+ initialGrantPresent.current ||= restoreAppGrant();
251
+ void probeAndRecover();
252
+ return;
253
+ }
254
+ forgetRejectedAppGrant(rejectionCode, revision);
255
+ if (openUrl && protocol)
256
+ setActivity({ openUrl, appAuthProtocol: protocol,
257
+ activeUntil: activityMetadata?.activeUntil, activityRequired: activityMetadata?.activityRequired });
258
+ const initialLoad = firstProbe.current;
259
+ firstProbe.current = false;
260
+ if (silentError.current === "access_denied") {
261
+ setUi({ kind: "denied" });
262
+ return;
263
+ }
264
+ const mode = currentLaunchMode();
265
+ const legacy = returnedLegacyAttempt.current;
266
+ const sameLegacyCore = !!legacy && appId === legacy.appId && !!openUrl && new URL(openUrl).origin === legacy.coreOrigin;
267
+ if (legacy?.protocolRequired && (status === "not-present" || status === "expired") && (!sameLegacyCore || protocol !== 2)) {
268
+ setUi({ kind: "unavailable" });
269
+ return;
270
+ }
271
+ const shouldUpgrade = sameLegacyCore && protocol === 2 && !upgradedAttempt.current;
272
+ if (shouldUpgrade) {
273
+ upgradedAttempt.current = true;
274
+ returnedLegacyAttempt.current = null;
275
+ }
276
+ if (!wasActive.current && protocol && ((initialLoad && !initialGrantPresent.current && !silentError.current) || shouldUpgrade) &&
277
+ detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
278
+ if (beginNavigationSignIn(openUrl, appId, protocol, "silent", shouldUpgrade))
279
+ return;
280
+ }
281
+ const action = !status || !appId || ((status === "not-present" || status === "expired") && !protocol)
199
282
  ? { kind: "card", card: "unavailable" }
200
283
  : decideRecoveryAction({
201
- status,
202
- mode: detectLaunchMode(window),
284
+ status: silentError.current === "login_required" ? "not-present" : status,
285
+ mode,
203
286
  appId,
204
287
  openUrl,
205
- redirectAlreadyAttempted: readGuard(),
288
+ redirectAlreadyAttempted: !shouldUpgrade && readGuard(),
206
289
  });
207
290
  switch (action.kind) {
208
291
  case "none":
@@ -211,40 +294,50 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
211
294
  setUi({ kind: "active" });
212
295
  return;
213
296
  case "post-auth-required": {
214
- const showSignIn = (error) => setUi({ kind: "signin", openUrl, embedded: true, error,
215
- signIn: openUrl ? () => {
216
- // Open synchronously in the click handler; an awaited request loses the user gesture.
217
- const result = openAppSignIn(openUrl, controller.signal);
218
- setUi({ kind: "recovering" });
219
- void result.then(async (code) => {
220
- const response = await appFetch(appCodePath, {
221
- method: "POST", headers: { "Content-Type": "application/json" },
222
- body: JSON.stringify({ code }),
223
- });
224
- const body = await response.json();
225
- if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
226
- throw new Error("Hosty could not establish this app session.");
227
- if (cancelled)
228
- return;
229
- rememberAppGrant(body.accessToken);
230
- writeGuard(false);
231
- await probeAndRecover();
232
- }).catch(error => { if (!cancelled)
233
- showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
234
- } : undefined });
235
297
  showSignIn();
236
298
  return;
237
299
  }
238
300
  case "redirect":
239
- writeGuard(true);
240
- window.location.assign(action.openUrl);
301
+ if (appId && protocol && beginNavigationSignIn(action.openUrl, appId, protocol, mode === "native" ? "native" : "standalone", shouldUpgrade))
302
+ return;
303
+ showSignIn("Allow app storage, or sign in with a popup.");
241
304
  return;
242
305
  case "card":
243
- setUi(action.card === "signin"
244
- ? { kind: "signin", openUrl, embedded: false }
245
- : { kind: action.card });
306
+ if (action.card === "signin")
307
+ showSignIn();
308
+ else
309
+ setUi({ kind: action.card });
246
310
  return;
247
311
  }
312
+ function showSignIn(error) {
313
+ setUi({ kind: "signin", openUrl, embedded: mode === "embedded", error,
314
+ signIn: openUrl && protocol ? () => {
315
+ // Open synchronously in the click handler; an awaited request loses the user gesture.
316
+ const revision = appGrantRevision();
317
+ const result = openAppSignIn(openUrl, controller.signal, protocol, mode === "native", probePath);
318
+ setUi({ kind: "recovering" });
319
+ void result.then(async (proof) => {
320
+ const response = await appFetch(appCodePath, {
321
+ method: "POST", headers: { "Content-Type": "application/json" },
322
+ body: JSON.stringify(proof),
323
+ }, false);
324
+ const body = await response.json();
325
+ if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
326
+ throw new Error("Hosty could not establish this app session.");
327
+ if (cancelled)
328
+ return;
329
+ if (revision !== appGrantRevision()) {
330
+ await probeAndRecover();
331
+ return;
332
+ }
333
+ rememberAppGrant(body.accessToken);
334
+ silentError.current = null;
335
+ writeGuard(false);
336
+ await probeAndRecover();
337
+ }).catch(error => { if (!cancelled)
338
+ showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
339
+ } : undefined });
340
+ }
248
341
  }
249
342
  const recover = () => { if (!cancelled && !wasActive.current) {
250
343
  setUi({ kind: "recovering" });
@@ -253,15 +346,31 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
253
346
  window.addEventListener(APP_SESSION_ENDED, recover);
254
347
  const url = new URL(window.location.href);
255
348
  const code = url.searchParams.get("code")?.trim();
256
- if (code) {
349
+ const error = url.searchParams.get("error");
350
+ const isSilentError = error === "login_required" || error === "access_denied";
351
+ const attempt = takeAppAuthAttempt(url, true);
352
+ const upgradedLegacy = attempt?.protocol === 1 && acceptAppAuthProtocol(attempt.coreOrigin, 1) === null;
353
+ if (attempt?.mode === "silent" && isSilentError)
354
+ silentError.current = error;
355
+ const protocolRequired = attempt?.protocol === 1 && !code && error === "protocol_required" && url.searchParams.getAll("error").length === 1;
356
+ if (attempt?.protocol === 1 && (!error || protocolRequired || upgradedLegacy))
357
+ returnedLegacyAttempt.current = { appId: attempt.appId, coreOrigin: attempt.coreOrigin, protocolRequired: protocolRequired || upgradedLegacy };
358
+ if (code || isSilentError || attempt) {
257
359
  url.searchParams.delete("code");
360
+ url.searchParams.delete("state");
361
+ if (isSilentError || protocolRequired)
362
+ url.searchParams.delete("error");
258
363
  window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
364
+ }
365
+ if (code && !error && !upgradedLegacy && attempt && !exchangeRef.current) {
259
366
  exchangeRef.current = {
260
367
  path: appCodePath,
368
+ revision: appGrantRevision(),
261
369
  response: fetch(appCodePath, {
262
370
  method: "POST",
263
371
  headers: { "Content-Type": "application/json" },
264
- body: JSON.stringify({ code }),
372
+ body: JSON.stringify({ code, codeVerifier: attempt.codeVerifier }),
373
+ credentials: "same-origin", redirect: "error",
265
374
  }),
266
375
  };
267
376
  }
@@ -275,6 +384,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
275
384
  if (response.ok) {
276
385
  writeGuard(false);
277
386
  const body = await response.json().catch(() => null);
387
+ if (cancelled)
388
+ return;
389
+ if (exchange.revision !== appGrantRevision()) {
390
+ exchangeRef.current = null;
391
+ await probeAndRecover();
392
+ return;
393
+ }
278
394
  if (typeof body?.accessToken === "string")
279
395
  rememberAppGrant(body.accessToken);
280
396
  exchangeRef.current = null;
@@ -302,7 +418,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
302
418
  };
303
419
  }, [probePath, appCodePath]);
304
420
  if (wasActive.current)
305
- return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath }), renderState ? renderState({ kind: "active" }) : children] });
421
+ return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath, probePath: probePath }), renderState ? renderState({ kind: "active" }) : children] });
306
422
  if (renderState)
307
423
  return renderState(ui);
308
424
  if (ui.kind === "active")
package/dist/server.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import "server-only";
2
+ import { type AppAuthProtocol } from "./app-code.js";
3
+ export { isValidCodeVerifier } from "./app-code.js";
2
4
  import { type AppSessionFailureStatus, type AppSessionStatus, type SessionRecoveryParams } from "./index.js";
3
5
  /** Per-app parameters: everything that legitimately diverges between apps stays a
4
6
  * parameter — cookie namespaces and role models are per-app by decision, never unified. */
@@ -46,7 +48,8 @@ export declare function getCoreOrigin(): string | null;
46
48
  * listen URL on localhost-only installs), so a missing value is a broken environment. */
47
49
  export declare function getCorePublicOrigin(): string | null;
48
50
  export declare function getServiceToken(): string | null;
49
- export declare function getRecoveryParams(config: HostyAppConfig): SessionRecoveryParams;
51
+ export declare function getAppAuthProtocol(): Promise<AppAuthProtocol | null>;
52
+ export declare function getRecoveryParams(config: HostyAppConfig): Promise<SessionRecoveryParams>;
50
53
  export type HeaderReader = {
51
54
  get(name: string): string | null;
52
55
  };
@@ -98,9 +101,8 @@ export type AppCodeExchange = {
98
101
  code: string;
99
102
  message: string;
100
103
  };
101
- /** Exchanges a one-time Shell/Core launch code for an identity token at Core. The code is
102
- * the only credential — the endpoint is deliberately unauthenticated on Core's side. */
103
- export declare function exchangeAppCode(code: string): Promise<AppCodeExchange>;
104
+ /** Exchanges a one-time Core code using this app's service credential. */
105
+ export declare function exchangeAppCode(code: string, codeVerifier: string): Promise<AppCodeExchange>;
104
106
  /**
105
107
  * Route-handler factory for `POST /api/auth/app-code`: exchanges the one-time `code` for
106
108
  * an identity token, stores it in the app-origin HttpOnly cookie, and returns the token so
package/dist/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Server slice: Next route-handler factories and Core revalidation. Never reaches a client
2
2
  // bundle — the service token lives here.
3
3
  import "server-only";
4
+ import { resolveAppAuthProtocol, appAuthProtocolMinimum, isValidCodeVerifier } from "./app-code.js";
5
+ export { isValidCodeVerifier } from "./app-code.js";
4
6
  import { classifyRevalidationHttpStatus, } from "./index.js";
5
7
  // Core runs on the same host as the app services, so a short budget is plenty; a stalled
6
8
  // call must not hold a route open indefinitely.
@@ -27,8 +29,11 @@ export function getCorePublicOrigin() {
27
29
  export function getServiceToken() {
28
30
  return process.env.HOSTY_APP_SERVICE_TOKEN?.trim() || null;
29
31
  }
30
- export function getRecoveryParams(config) {
31
- return { appId: getAppId(config), corePublicOrigin: getCorePublicOrigin() };
32
+ export function getAppAuthProtocol() {
33
+ return resolveAppAuthProtocol(getCoreOrigin());
34
+ }
35
+ export async function getRecoveryParams(config) {
36
+ return { appId: getAppId(config), corePublicOrigin: getCorePublicOrigin(), appAuthProtocol: await getAppAuthProtocol() };
32
37
  }
33
38
  /**
34
39
  * Reads an explicitly presented app grant first, then the app cookie and legacy identity header.
@@ -239,10 +244,17 @@ export async function classifyAppSessionFromCookie(headers, config) {
239
244
  // ceiling against a malformed response: 400 days is the maximum cookie Max-Age browsers
240
245
  // honor (RFC 6265bis), so it never truncates a configured absolute lifetime.
241
246
  const MAX_COOKIE_AGE_SECONDS = 400 * 24 * 60 * 60;
242
- /** Exchanges a one-time Shell/Core launch code for an identity token at Core. The code is
243
- * the only credential — the endpoint is deliberately unauthenticated on Core's side. */
244
- export async function exchangeAppCode(code) {
247
+ /** Exchanges a one-time Core code using this app's service credential. */
248
+ export async function exchangeAppCode(code, codeVerifier) {
249
+ if (!isValidCodeVerifier(codeVerifier))
250
+ return { ok: false, status: 400, code: "app_auth_proof_required", message: "A valid Hosty sign-in proof is required." };
251
+ const serviceToken = getServiceToken();
252
+ if (!serviceToken) {
253
+ return { ok: false, status: 503, code: "app_service_token_missing", message: "HOSTY_APP_SERVICE_TOKEN is not configured." };
254
+ }
245
255
  const coreOrigin = getCoreOrigin();
256
+ if (appAuthProtocolMinimum(coreOrigin) === 2 && await getAppAuthProtocol() !== 2)
257
+ return { ok: false, status: 503, code: "app_auth_protocol_unavailable", message: "Hosty sign-in protocol could not be verified." };
246
258
  let endpoint;
247
259
  try {
248
260
  endpoint = new URL("/api/auth/apps/token", coreOrigin ?? "").toString();
@@ -254,9 +266,10 @@ export async function exchangeAppCode(code) {
254
266
  try {
255
267
  response = await fetch(endpoint, {
256
268
  method: "POST",
257
- headers: { "content-type": "application/json" },
258
- body: JSON.stringify({ code }),
269
+ headers: { "content-type": "application/json", authorization: `Bearer ${serviceToken}` },
270
+ body: JSON.stringify({ code, codeVerifier }),
259
271
  cache: "no-store",
272
+ redirect: "error",
260
273
  signal: AbortSignal.timeout(CORE_AUTH_TIMEOUT_MS),
261
274
  });
262
275
  }
@@ -317,7 +330,10 @@ export function createAppCodeRouteHandler(config) {
317
330
  if (!code) {
318
331
  return jsonResponse({ code: "app_auth_code_required", message: "A Hosty app authorization code is required." }, 422);
319
332
  }
320
- const exchange = await exchangeAppCode(code);
333
+ const codeVerifier = body && typeof body === "object" ? body.codeVerifier : null;
334
+ if (!isValidCodeVerifier(codeVerifier))
335
+ return jsonResponse({ code: "app_auth_proof_required", message: "A valid Hosty sign-in proof is required." }, 400);
336
+ const exchange = await exchangeAppCode(code, codeVerifier);
321
337
  if (!exchange.ok) {
322
338
  return jsonResponse({ code: exchange.code, message: exchange.message }, exchange.status);
323
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.19.1",
3
+ "version": "0.21.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",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/browser-auth.d.ts",
18
18
  "default": "./dist/browser-auth.js"
19
19
  },
20
+ "./app-code": {
21
+ "types": "./dist/app-code.d.ts",
22
+ "default": "./dist/app-code.js"
23
+ },
20
24
  "./delegated": {
21
25
  "types": "./dist/delegated.d.ts",
22
26
  "default": "./dist/delegated.js"
@@ -83,6 +87,7 @@
83
87
  "build": "tsc -p tsconfig.build.json && node scripts/fix-dist-extensions.mjs"
84
88
  },
85
89
  "dependencies": {
90
+ "@noble/hashes": "^2.4.0",
86
91
  "server-only": "^0.0.1"
87
92
  },
88
93
  "peerDependencies": {