@hosty-sdk/app 0.20.0 → 0.21.1

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/README.md CHANGED
@@ -25,6 +25,13 @@ Minimal Next.js wiring:
25
25
  import { AppIdentityBridge } from "@hosty-sdk/app/react";
26
26
  // wrap protected client content: <AppIdentityBridge>{children}</AppIdentityBridge>
27
27
 
28
+ // Reuse an active probe that already returns the app's session fields:
29
+ // <AppIdentityBridge probePath="/api/auth/session" onSession={seedSessionCache}>
30
+ // {children}
31
+ // </AppIdentityBridge>
32
+ // seedSessionCache receives unknown JSON; validate app-specific fields before caching.
33
+ // The callback runs before children mount, only for a successful active probe.
34
+
28
35
  // app/api/auth/app-code/route.ts
29
36
  import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
30
37
  export const dynamic = "force-dynamic";
@@ -1,7 +1,11 @@
1
+ import type { AppAuthProtocol } from "./app-code.js";
1
2
  /** Keep mounted beside app content so expiry never discards drafts or component state. */
2
- export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired }: {
3
+ export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired, appAuthProtocol, probePath, native }: {
3
4
  openUrl: string;
4
5
  appCodePath: string;
5
6
  activeUntil?: string | null;
6
7
  activityRequired?: boolean;
8
+ appAuthProtocol?: AppAuthProtocol | null;
9
+ probePath?: string;
10
+ native?: boolean;
7
11
  }): import("react").JSX.Element | null;
@@ -3,8 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from "react";
4
4
  import { AuthNotice, AuthNoticeButton } from "./auth-notice.js";
5
5
  import { APP_SESSION_ENDED, APP_ACTIVITY_RENEWED, appActivityNeedsRenewal, configureAppActivity, updateAppActivity, renewAppActivity, cancelActivityRenewal } from "./browser-auth.js";
6
+ import { resolveLaunchMode, detectLaunchMode, normalizeLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY } from "./index.js";
7
+ import { acceptAppAuthProtocol, refreshAppAuthProtocol } from "./browser-auth.js";
6
8
  /** Keep mounted beside app content so expiry never discards drafts or component state. */
7
- export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired = false }) {
9
+ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired = false, appAuthProtocol, probePath = "/api/auth/identity", native }) {
8
10
  const [needed, setNeeded] = useState(false);
9
11
  const [pending, setPending] = useState(false);
10
12
  const [error, setError] = useState("");
@@ -15,14 +17,35 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
15
17
  void operation.catch(e => { setNeeded(true); setError(String(e.message ?? e)); }).finally(() => setPending(false));
16
18
  };
17
19
  useEffect(() => {
18
- const cleanup = configureAppActivity({ openUrl, exchangeCode: async (code) => {
19
- const response = await fetch(appCodePath, { method: "POST", credentials: "same-origin", redirect: "error",
20
- headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) });
21
- const body = await response.json();
22
- if (!response.ok)
23
- throw new Error(body.message ?? "Could not renew app activity.");
24
- return body;
25
- } });
20
+ let cancelled = false;
21
+ let cleanup;
22
+ const controller = new AbortController();
23
+ const configure = (protocol) => {
24
+ if (cancelled || !protocol || acceptAppAuthProtocol(new URL(openUrl).origin, protocol) !== protocol)
25
+ return;
26
+ let stored = null;
27
+ try {
28
+ stored = window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
29
+ }
30
+ catch { /* The applied document mode remains available. */ }
31
+ const mode = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE)) ?? resolveLaunchMode({
32
+ param: new URL(window.location.href).searchParams.get(LAUNCH_MODE_PARAM), stored, heuristic: detectLaunchMode(window),
33
+ }).mode;
34
+ cleanup = configureAppActivity({ openUrl, appAuthProtocol: protocol, probePath, native: native ?? mode === "native", activeUntil, activityRequired, exchangeCode: async (proof) => {
35
+ const response = await fetch(appCodePath, { method: "POST", credentials: "same-origin", redirect: "error",
36
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(proof) });
37
+ const body = await response.json();
38
+ if (!response.ok)
39
+ throw new Error(body.message ?? "Could not renew app activity.");
40
+ return body;
41
+ } });
42
+ };
43
+ if (appAuthProtocol !== undefined && !(appAuthProtocol === 1 && acceptAppAuthProtocol(new URL(openUrl).origin, 1) === null))
44
+ configure(appAuthProtocol);
45
+ else
46
+ void refreshAppAuthProtocol(openUrl, probePath, controller.signal).then(configure);
47
+ // A stale protocol-1 prop cannot overwrite a protocol-2 minimum already verified
48
+ // by this app. Unknown discovery leaves renewal disabled instead of downgrading.
26
49
  const expired = () => setNeeded(true);
27
50
  const renewed = () => { setNeeded(false); setError(""); };
28
51
  const click = (event) => {
@@ -35,8 +58,8 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
35
58
  window.addEventListener(APP_SESSION_ENDED, expired);
36
59
  window.addEventListener(APP_ACTIVITY_RENEWED, renewed);
37
60
  document.addEventListener("click", click, true);
38
- return () => { cleanup(); window.removeEventListener(APP_SESSION_ENDED, expired); window.removeEventListener(APP_ACTIVITY_RENEWED, renewed); document.removeEventListener("click", click, true); };
39
- }, [openUrl, appCodePath]);
61
+ return () => { cancelled = true; controller.abort(); cleanup?.(); window.removeEventListener(APP_SESSION_ENDED, expired); window.removeEventListener(APP_ACTIVITY_RENEWED, renewed); document.removeEventListener("click", click, true); };
62
+ }, [openUrl, appCodePath, appAuthProtocol, probePath, native]);
40
63
  useEffect(() => { updateAppActivity(activeUntil, activityRequired); }, [activeUntil, activityRequired, openUrl, appCodePath]);
41
64
  if (!needed)
42
65
  return null;
@@ -0,0 +1,6 @@
1
+ /** Shared by framework server adapters and plain Node apps. Never sends an app credential. */
2
+ export type AppAuthProtocol = 1 | 2;
3
+ export declare function isValidCodeVerifier(value: unknown): value is string;
4
+ export declare function appAuthProtocolMinimum(coreOrigin: string | null): 2 | null;
5
+ /** A 404 alone is insufficient: only an identified, older Core permits the old navigation. */
6
+ export declare function resolveAppAuthProtocol(coreOrigin: string | null): Promise<AppAuthProtocol | null>;
@@ -0,0 +1,80 @@
1
+ export function isValidCodeVerifier(value) {
2
+ return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value);
3
+ }
4
+ const minimumProtocols = new Set();
5
+ const discoveries = new Map();
6
+ export function appAuthProtocolMinimum(coreOrigin) {
7
+ try {
8
+ return minimumProtocols.has(new URL(coreOrigin ?? "").origin) ? 2 : null;
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ function isOlderCoreVersion(value) {
15
+ if (typeof value !== "string")
16
+ return false;
17
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(value);
18
+ if (!match)
19
+ return false;
20
+ for (const [index, suffix] of [[4, match[4]], [5, match[5]]]) {
21
+ if (suffix && suffix.split(".").some(part => !/^[0-9A-Za-z-]+$/.test(part) || (index === 4 && /^0\d+$/.test(part))))
22
+ return false;
23
+ }
24
+ const major = Number(match[1]), minor = Number(match[2]), patch = Number(match[3]);
25
+ return [major, minor, patch].every(Number.isSafeInteger) && major === 0 && minor < 120;
26
+ }
27
+ /** A 404 alone is insufficient: only an identified, older Core permits the old navigation. */
28
+ export async function resolveAppAuthProtocol(coreOrigin) {
29
+ let origin;
30
+ try {
31
+ const parsed = new URL(coreOrigin ?? "");
32
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password ||
33
+ parsed.pathname !== "/" || parsed.search || parsed.hash)
34
+ return null;
35
+ origin = parsed.origin;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ const pending = discoveries.get(origin);
41
+ if (pending)
42
+ return pending;
43
+ const operation = (async () => {
44
+ const request = (path) => fetch(new URL(path, origin), {
45
+ cache: "no-store", redirect: "error", headers: { accept: "application/json" },
46
+ signal: AbortSignal.timeout(1_500),
47
+ });
48
+ try {
49
+ const response = await request("/api/auth/apps/protocol");
50
+ if (response.status === 200) {
51
+ if (!response.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
52
+ return null;
53
+ const body = await response.json().catch(() => null);
54
+ if (body?.version !== 2)
55
+ return null;
56
+ minimumProtocols.add(origin);
57
+ return 2;
58
+ }
59
+ if (response.status !== 404 || minimumProtocols.has(origin))
60
+ return null;
61
+ const status = await request("/api/core/status");
62
+ if (status.status !== 200)
63
+ return null;
64
+ if (!status.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
65
+ return null;
66
+ const body = await status.json().catch(() => null);
67
+ return body?.component === "hosty-core" && body.status === "running" && isOlderCoreVersion(body.version) ? 1 : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ })();
73
+ discoveries.set(origin, operation);
74
+ try {
75
+ return await operation;
76
+ }
77
+ finally {
78
+ discoveries.delete(origin);
79
+ }
80
+ }
@@ -1,4 +1,33 @@
1
+ import type { AppAuthProtocol } from "./app-code.js";
2
+ export type AppSignInResult = {
3
+ code: string;
4
+ codeVerifier: string;
5
+ };
6
+ export type AppAuthAttempt = {
7
+ state: string;
8
+ codeVerifier: string;
9
+ codeChallenge: string;
10
+ appId: string;
11
+ coreOrigin: string;
12
+ redirectUri: string;
13
+ createdAt: number;
14
+ mode: "standalone" | "silent" | "popup" | "native";
15
+ protocol: AppAuthProtocol;
16
+ };
17
+ export declare const APP_AUTH_ATTEMPT_PREFIX = "hosty.auth.attempt:";
18
+ export declare function appCodeChallenge(verifier: string): string;
19
+ export declare function acceptAppAuthProtocol(coreOrigin: string, protocol: AppAuthProtocol | null): AppAuthProtocol | null;
20
+ export declare function createAppAuthAttempt(openUrl: string, mode: AppAuthAttempt["mode"], protocol: AppAuthProtocol): AppAuthAttempt;
21
+ /** Do not leave the app document unless this exact proof survives a storage round trip. */
22
+ export declare function persistAppAuthAttempt(attempt: AppAuthAttempt): boolean;
23
+ /** Correlation is local; URL fields never supply a verifier or a pending attempt. */
24
+ export declare function takeAppAuthAttempt(callback: URL, allowLegacyUpgrade?: boolean): AppAuthAttempt | null;
25
+ export declare function clearAppAuthAttempts(): void;
26
+ export declare function appAuthNavigationUrl(attempt: AppAuthAttempt): string;
27
+ /** Constant document and DOM-built fields prevent a public URL from laundering a challenge. */
28
+ export declare function submitAppAuthIntent(attempt: AppAuthAttempt, doc?: Document): void;
1
29
  export declare const APP_SESSION_ENDED = "hosty:app-session-ended";
30
+ export declare const NATIVE_APP_AUTH_RESULT = "hosty:native-auth-result";
2
31
  export declare const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
3
32
  /** Restore before the first identity probe; standalone documents use their first-party cookie. */
4
33
  export declare function restoreAppGrant(): boolean;
@@ -11,14 +40,19 @@ export declare function forgetRejectedAppGrant(code: unknown, expectedRevision?:
11
40
  export declare function appSessionActive(): void;
12
41
  /** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
13
42
  export declare function appFetch(input: string | URL, init?: RequestInit, recover?: boolean): Promise<Response>;
14
- /** Must be called directly from a user gesture. The popup belongs to this app frame, not Shell. */
15
- export declare function openAppSignIn(openUrl: string, signal?: AbortSignal): Promise<string>;
43
+ /** Refresh only the app-local, server-verified metadata for this frozen app/Core pair. */
44
+ export declare function refreshAppAuthProtocol(openUrl: string, probePath?: string, signal?: AbortSignal): Promise<AppAuthProtocol | null>;
45
+ /** Must be called directly from a user gesture. Native hosts intercept before any request. */
46
+ export declare function openAppSignIn(openUrl: string, signal?: AbortSignal, appAuthProtocol?: AppAuthProtocol, native?: boolean, probePath?: string): Promise<AppSignInResult>;
16
47
  export declare const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
17
48
  export type AppActivityConfig = {
18
49
  openUrl: string;
50
+ appAuthProtocol: AppAuthProtocol;
51
+ native?: boolean;
52
+ probePath?: string;
19
53
  activeUntil?: string | null;
20
54
  activityRequired?: boolean;
21
- exchangeCode: (code: string) => Promise<{
55
+ exchangeCode: (result: AppSignInResult) => Promise<{
22
56
  accessToken?: string;
23
57
  activeUntil?: string | null;
24
58
  }>;
@@ -1,8 +1,190 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ function isValidCodeVerifier(value) {
3
+ return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value);
4
+ }
5
+ export const APP_AUTH_ATTEMPT_PREFIX = "hosty.auth.attempt:";
6
+ const MINIMUM_PROTOCOL_PREFIX = "hosty.auth.protocol:";
7
+ const ATTEMPT_LIFETIME = 300_000;
8
+ const MAX_ATTEMPTS = 16;
9
+ const protocolMinimums = new Set();
10
+ function base64url(bytes) {
11
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
12
+ }
13
+ export function appCodeChallenge(verifier) {
14
+ if (!isValidCodeVerifier(verifier))
15
+ throw new Error("The sign-in proof is invalid.");
16
+ return base64url(sha256(Uint8Array.from(verifier, character => character.charCodeAt(0))));
17
+ }
18
+ export function acceptAppAuthProtocol(coreOrigin, protocol) {
19
+ const key = `${MINIMUM_PROTOCOL_PREFIX}${coreOrigin}`;
20
+ try {
21
+ if (window.sessionStorage.getItem(key) === "2")
22
+ protocolMinimums.add(coreOrigin);
23
+ }
24
+ catch { /* Memory minimum still applies. */ }
25
+ if (protocol === 2) {
26
+ protocolMinimums.add(coreOrigin);
27
+ try {
28
+ window.sessionStorage.setItem(key, "2");
29
+ }
30
+ catch { /* Keep the minimum in memory. */ }
31
+ return 2;
32
+ }
33
+ return protocol === 1 && !protocolMinimums.has(coreOrigin) ? 1 : null;
34
+ }
35
+ export function createAppAuthAttempt(openUrl, mode, protocol) {
36
+ const url = new URL(openUrl);
37
+ const match = /^\/api\/apps\/([^/]+)\/open$/.exec(url.pathname);
38
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || !match)
39
+ throw new Error("Hosty sign-in URL is invalid.");
40
+ if (acceptAppAuthProtocol(url.origin, protocol) !== protocol)
41
+ throw new Error("Hosty sign-in protocol could not be verified.");
42
+ const redirect = new URL(url.searchParams.get("redirectUri") ?? "");
43
+ if (redirect.origin !== window.location.origin || redirect.username || redirect.password || redirect.hash)
44
+ throw new Error("Hosty sign-in must return to this app.");
45
+ const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
46
+ const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join("");
47
+ redirect.searchParams.delete("code");
48
+ redirect.searchParams.delete("error");
49
+ redirect.searchParams.set("state", state);
50
+ return { state, codeVerifier, codeChallenge: appCodeChallenge(codeVerifier), appId: decodeURIComponent(match[1]),
51
+ coreOrigin: url.origin, redirectUri: redirect.href, createdAt: Date.now(), mode, protocol };
52
+ }
53
+ function storedAttempts() {
54
+ const keys = [];
55
+ const storage = window.sessionStorage;
56
+ for (let index = 0; index < storage.length; index++) {
57
+ const key = storage.key(index);
58
+ if (!key?.startsWith(APP_AUTH_ATTEMPT_PREFIX))
59
+ continue;
60
+ try {
61
+ const record = JSON.parse(storage.getItem(key) ?? "null");
62
+ if (typeof record?.createdAt !== "number" || record.createdAt > Date.now() || Date.now() - record.createdAt >= ATTEMPT_LIFETIME) {
63
+ storage.removeItem(key);
64
+ index--;
65
+ continue;
66
+ }
67
+ }
68
+ catch {
69
+ storage.removeItem(key);
70
+ index--;
71
+ continue;
72
+ }
73
+ keys.push(key);
74
+ }
75
+ return keys;
76
+ }
77
+ /** Do not leave the app document unless this exact proof survives a storage round trip. */
78
+ export function persistAppAuthAttempt(attempt) {
79
+ const key = `${APP_AUTH_ATTEMPT_PREFIX}${attempt.state}`;
80
+ try {
81
+ if (storedAttempts().length >= MAX_ATTEMPTS)
82
+ return false;
83
+ const value = JSON.stringify(attempt);
84
+ window.sessionStorage.setItem(key, value);
85
+ if (window.sessionStorage.getItem(key) === value)
86
+ return true;
87
+ }
88
+ catch { /* The explicit popup can keep a proof in memory instead. */ }
89
+ try {
90
+ window.sessionStorage.removeItem(key);
91
+ }
92
+ catch { /* Storage remains blocked. */ }
93
+ return false;
94
+ }
95
+ function callbackLocation(url) {
96
+ const copy = new URL(url);
97
+ for (const name of ["code", "state", "error", "hosty_launch"])
98
+ copy.searchParams.delete(name);
99
+ copy.hash = "";
100
+ copy.searchParams.sort();
101
+ return copy.href;
102
+ }
103
+ /** Correlation is local; URL fields never supply a verifier or a pending attempt. */
104
+ // Only the recovery bridge opts in to classify and discard an obsolete local v1
105
+ // record. Such a record must never be exchanged after v2 has already been observed.
106
+ export function takeAppAuthAttempt(callback, allowLegacyUpgrade = false) {
107
+ const states = callback.searchParams.getAll("state");
108
+ const state = states[0];
109
+ if (!state || !/^[a-f0-9]{64}$/.test(state) || states.some(value => value !== state))
110
+ return null;
111
+ const key = `${APP_AUTH_ATTEMPT_PREFIX}${state}`;
112
+ try {
113
+ const value = window.sessionStorage.getItem(key);
114
+ if (!value)
115
+ return null;
116
+ window.sessionStorage.removeItem(key);
117
+ const attempt = JSON.parse(value);
118
+ if (attempt.state !== state || !isValidCodeVerifier(attempt.codeVerifier) ||
119
+ attempt.codeChallenge !== appCodeChallenge(attempt.codeVerifier) ||
120
+ ![1, 2].includes(attempt.protocol) || !["standalone", "silent", "native"].includes(attempt.mode) ||
121
+ typeof attempt.appId !== "string" || !attempt.appId ||
122
+ typeof attempt.createdAt !== "number" || attempt.createdAt > Date.now() || Date.now() - attempt.createdAt >= ATTEMPT_LIFETIME ||
123
+ new URL(attempt.redirectUri).origin !== window.location.origin || callbackLocation(new URL(attempt.redirectUri)) !== callbackLocation(callback) ||
124
+ new URL(attempt.coreOrigin).origin !== attempt.coreOrigin ||
125
+ (acceptAppAuthProtocol(attempt.coreOrigin, attempt.protocol) !== attempt.protocol && !(allowLegacyUpgrade && attempt.protocol === 1)))
126
+ return null;
127
+ return attempt;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ export function clearAppAuthAttempts() {
134
+ if (typeof window === "undefined")
135
+ return;
136
+ try {
137
+ for (const key of storedAttempts())
138
+ window.sessionStorage.removeItem(key);
139
+ }
140
+ catch { /* Nothing can navigate with inaccessible proof. */ }
141
+ }
142
+ export function appAuthNavigationUrl(attempt) {
143
+ const url = new URL(`/api/apps/${encodeURIComponent(attempt.appId)}/open`, attempt.coreOrigin);
144
+ for (const [name, value] of Object.entries(appAuthFields(attempt)))
145
+ url.searchParams.set(name, value);
146
+ return url.href;
147
+ }
148
+ function appAuthFields(attempt) {
149
+ return { redirectUri: attempt.redirectUri, state: attempt.state, codeChallenge: attempt.codeChallenge, codeChallengeMethod: "S256",
150
+ ...(attempt.mode === "silent" ? { prompt: "none" } : {}),
151
+ ...(attempt.mode === "popup" ? { responseMode: "web_message" } : {}) };
152
+ }
153
+ /** Constant document and DOM-built fields prevent a public URL from laundering a challenge. */
154
+ export function submitAppAuthIntent(attempt, doc = document) {
155
+ doc.open();
156
+ doc.write('<!doctype html><html><head><meta charset="utf-8"><meta name="referrer" content="origin"><title>Connecting to Hosty</title></head><body></body></html>');
157
+ doc.close();
158
+ const policy = doc.createElement("meta");
159
+ policy.httpEquiv = "Content-Security-Policy";
160
+ // CSP host sources cannot represent IPv6 literals; the form target remains fixed to Core.
161
+ // Other origins permit Core and this frozen app origin through the return redirect chain.
162
+ const core = new URL(attempt.coreOrigin);
163
+ const formAction = core.hostname.startsWith("[") ? "" : `form-action ${core.origin} 'self'; `;
164
+ policy.content = `default-src 'none'; ${formAction}base-uri 'none'`;
165
+ doc.head.append(policy);
166
+ const form = doc.createElement("form");
167
+ form.method = "POST";
168
+ form.action = new URL(`/api/apps/${encodeURIComponent(attempt.appId)}/sign-in-intent`, attempt.coreOrigin).href;
169
+ for (const [name, value] of Object.entries(appAuthFields(attempt))) {
170
+ const input = doc.createElement("input");
171
+ input.type = "hidden";
172
+ input.name = name;
173
+ input.value = value;
174
+ form.append(input);
175
+ }
176
+ const notice = doc.createElement("p");
177
+ notice.textContent = "Connecting to Hosty…";
178
+ doc.body.append(notice, form);
179
+ form.submit();
180
+ }
1
181
  /** App-only credentials. Embedded documents keep them for this tab on the app's own origin. */
2
182
  let grant = null;
3
183
  let grantRevision = 0;
4
184
  let recoveryPending = false;
185
+ const popupCancellations = new Set();
5
186
  export const APP_SESSION_ENDED = "hosty:app-session-ended";
187
+ export const NATIVE_APP_AUTH_RESULT = "hosty:native-auth-result";
6
188
  export const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
7
189
  function isEmbedded() {
8
190
  try {
@@ -44,6 +226,15 @@ export function rememberAppGrant(token) {
44
226
  }
45
227
  }
46
228
  export function forgetAppGrant() {
229
+ if (!grant)
230
+ grantRevision++;
231
+ discardAppGrant();
232
+ clearAppAuthAttempts();
233
+ for (const cancel of [...popupCancellations])
234
+ cancel();
235
+ cancelActivityRenewal();
236
+ }
237
+ function discardAppGrant() {
47
238
  if (grant)
48
239
  grantRevision++;
49
240
  grant = null;
@@ -63,7 +254,7 @@ export function forgetRejectedAppGrant(code, expectedRevision = grantRevision) {
63
254
  return false;
64
255
  if (typeof code !== "string" || !["token_invalid", "token_revoked", "token_expired", "token_app_mismatch"].includes(code))
65
256
  return false;
66
- forgetAppGrant();
257
+ discardAppGrant();
67
258
  return true;
68
259
  }
69
260
  export function appSessionActive() { recoveryPending = false; }
@@ -90,7 +281,7 @@ export async function appFetch(input, init = {}, recover = true) {
90
281
  if (response.status !== 401)
91
282
  return response;
92
283
  if (!rejected && recover && body?.code !== "reauth_required" && current)
93
- forgetAppGrant();
284
+ discardAppGrant();
94
285
  if (!recover)
95
286
  return response;
96
287
  if (activityConfig) {
@@ -110,37 +301,147 @@ export async function appFetch(input, init = {}, recover = true) {
110
301
  }
111
302
  return response;
112
303
  }
113
- /** Must be called directly from a user gesture. The popup belongs to this app frame, not Shell. */
114
- export function openAppSignIn(openUrl, signal) {
115
- const url = new URL(openUrl);
116
- if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
117
- return Promise.reject(new Error("Hosty sign-in URL is invalid."));
118
- const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
119
- url.searchParams.set("responseMode", "web_message");
120
- url.searchParams.set("state", state);
304
+ /** Refresh only the app-local, server-verified metadata for this frozen app/Core pair. */
305
+ export async function refreshAppAuthProtocol(openUrl, probePath = "/api/auth/identity", signal) {
306
+ const controller = new AbortController();
307
+ const abort = () => controller.abort();
308
+ signal?.addEventListener("abort", abort, { once: true });
309
+ if (signal?.aborted)
310
+ controller.abort();
311
+ const timer = window.setTimeout(abort, 4_000);
312
+ try {
313
+ const open = new URL(openUrl);
314
+ const match = /^\/api\/apps\/([^/]+)\/open$/.exec(open.pathname);
315
+ if (!match || !["http:", "https:"].includes(open.protocol) || open.username || open.password)
316
+ return null;
317
+ const response = await appFetch(probePath, { cache: "no-store", signal: controller.signal }, false);
318
+ if (![200, 401].includes(response.status) || !response.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
319
+ return null;
320
+ const body = await response.json();
321
+ const recovery = body?.recovery;
322
+ if (recovery?.appId !== decodeURIComponent(match[1]) || typeof recovery.corePublicOrigin !== "string")
323
+ return null;
324
+ const core = new URL(recovery.corePublicOrigin);
325
+ if (core.origin !== open.origin || core.username || core.password || core.pathname !== "/" || core.search || core.hash)
326
+ return null;
327
+ if (controller.signal.aborted)
328
+ return null;
329
+ return acceptAppAuthProtocol(open.origin, recovery.appAuthProtocol === 2 ? 2 : recovery.appAuthProtocol === 1 ? 1 : null);
330
+ }
331
+ catch {
332
+ return null;
333
+ }
334
+ finally {
335
+ window.clearTimeout(timer);
336
+ signal?.removeEventListener("abort", abort);
337
+ }
338
+ }
339
+ /** Must be called directly from a user gesture. Native hosts intercept before any request. */
340
+ export function openAppSignIn(openUrl, signal, appAuthProtocol = 2, native = false, probePath = "/api/auth/identity") {
341
+ let initialAttempt;
342
+ try {
343
+ initialAttempt = createAppAuthAttempt(openUrl, native ? "native" : "popup", appAuthProtocol);
344
+ }
345
+ catch (error) {
346
+ return Promise.reject(error);
347
+ }
348
+ if (native)
349
+ return openNativeAppSignIn(initialAttempt, signal);
121
350
  return new Promise((resolve, reject) => {
351
+ let attempt = initialAttempt;
352
+ initialAttempt = null;
122
353
  let popup = null;
123
354
  let settled = false;
355
+ let upgraded = false;
356
+ let restartPoll;
357
+ const refreshController = new AbortController();
124
358
  const finish = (code, error) => {
125
359
  if (settled)
126
360
  return;
127
361
  settled = true;
362
+ const proof = code && attempt ? { code, codeVerifier: attempt.codeVerifier } : null;
363
+ attempt = null;
364
+ refreshController.abort();
128
365
  window.removeEventListener("message", receive);
129
366
  signal?.removeEventListener("abort", abort);
367
+ popupCancellations.delete(abort);
130
368
  window.clearTimeout(timer);
131
369
  clearInterval(closedCheck);
370
+ clearInterval(restartPoll);
132
371
  popup?.close();
133
- if (code)
134
- resolve(code);
372
+ if (proof)
373
+ resolve(proof);
135
374
  else
136
375
  reject(new Error(error ?? "Sign-in was cancelled."));
137
376
  };
377
+ const restart = async () => {
378
+ // The correlated Core refusal carries no authority. The app server must verify v2
379
+ // before the existing gesture-opened window receives an entirely fresh attempt.
380
+ attempt = null;
381
+ if (await refreshAppAuthProtocol(openUrl, probePath, refreshController.signal) !== 2) {
382
+ if (!settled)
383
+ finish(undefined, "Hosty sign-in protocol could not be verified. Please try again.");
384
+ return;
385
+ }
386
+ if (settled || !popup || popup.closed)
387
+ return;
388
+ try {
389
+ popup.location.href = "about:blank";
390
+ }
391
+ catch {
392
+ finish(undefined, "The app could not restart Hosty sign-in. Please try again.");
393
+ return;
394
+ }
395
+ const deadline = Date.now() + 5_000;
396
+ const submit = () => {
397
+ if (settled)
398
+ return;
399
+ if (!popup || popup.closed) {
400
+ finish();
401
+ return;
402
+ }
403
+ let doc;
404
+ try {
405
+ // Access is possible only after the app-initiated blank navigation restores
406
+ // this app's origin. Never write an intent into the old Core document.
407
+ doc = popup.document;
408
+ if (doc.URL !== "about:blank")
409
+ throw new Error("Popup is still navigating.");
410
+ }
411
+ catch {
412
+ if (Date.now() >= deadline)
413
+ finish(undefined, "The app could not restart Hosty sign-in. Please try again.");
414
+ return;
415
+ }
416
+ clearInterval(restartPoll);
417
+ try {
418
+ attempt = createAppAuthAttempt(openUrl, "popup", 2);
419
+ submitAppAuthIntent(attempt, doc);
420
+ }
421
+ catch {
422
+ finish(undefined, "The app could not restart Hosty sign-in. Please try again.");
423
+ }
424
+ };
425
+ restartPoll = setInterval(submit, 20);
426
+ submit();
427
+ };
138
428
  const receive = (event) => {
139
- if (!popup || event.source !== popup || event.origin !== url.origin)
429
+ if (!popup || !attempt || event.source !== popup || event.origin !== attempt.coreOrigin)
140
430
  return;
141
431
  const data = event.data;
142
- if (data?.type !== "hosty:app-auth-code" || data.state !== state ||
143
- typeof data.code !== "string" || !data.code || data.code.length > 4096)
432
+ if (data?.type !== "hosty:app-auth-code" || data.state !== attempt.state)
433
+ return;
434
+ if (typeof data.error === "string") {
435
+ if (data.error === "protocol_required" && attempt.protocol === 1 && !upgraded &&
436
+ Object.keys(data).length === 3 && data.code === undefined) {
437
+ upgraded = true;
438
+ void restart();
439
+ }
440
+ else
441
+ finish(undefined, "Hosty could not authorize this sign-in. Please try again.");
442
+ return;
443
+ }
444
+ if (typeof data.code !== "string" || !data.code || data.code.length > 4096)
144
445
  return;
145
446
  finish(data.code);
146
447
  };
@@ -149,19 +450,80 @@ export function openAppSignIn(openUrl, signal) {
149
450
  const closedCheck = setInterval(() => { if (popup?.closed)
150
451
  finish(); }, 500);
151
452
  window.addEventListener("message", receive);
453
+ popupCancellations.add(abort);
152
454
  signal?.addEventListener("abort", abort, { once: true });
153
455
  if (signal?.aborted) {
154
456
  finish();
155
457
  return;
156
458
  }
157
- popup = window.open(url.href, "_blank", "popup,width=520,height=700");
158
- if (!popup)
459
+ // No await precedes window.open: browser activation belongs to this exact app gesture.
460
+ popup = window.open(appAuthProtocol === 1 ? appAuthNavigationUrl(attempt) : "about:blank", "_blank", "popup,width=520,height=700");
461
+ if (!popup) {
159
462
  finish(undefined, "Allow the sign-in popup and try again.");
463
+ return;
464
+ }
465
+ if (appAuthProtocol === 2) {
466
+ try {
467
+ submitAppAuthIntent(attempt, popup.document);
468
+ }
469
+ catch {
470
+ finish(undefined, "The app could not open the Hosty sign-in form. Please try again.");
471
+ }
472
+ }
473
+ });
474
+ }
475
+ /** The native host preserves this document and returns only a correlated public result. */
476
+ function openNativeAppSignIn(attempt, signal) {
477
+ return new Promise((resolve, reject) => {
478
+ let settled = false;
479
+ const finish = (code, error) => {
480
+ if (settled)
481
+ return;
482
+ settled = true;
483
+ window.removeEventListener(NATIVE_APP_AUTH_RESULT, receive);
484
+ signal?.removeEventListener("abort", abort);
485
+ popupCancellations.delete(abort);
486
+ window.clearTimeout(timer);
487
+ if (code)
488
+ resolve({ code, codeVerifier: attempt.codeVerifier });
489
+ else
490
+ reject(new Error(error ?? "Sign-in was cancelled."));
491
+ };
492
+ const receive = (event) => {
493
+ const data = event.detail;
494
+ if (!data || data.state !== attempt.state)
495
+ return;
496
+ if (data.error && typeof data.error.code === "string") {
497
+ finish(undefined, "Hosty could not authorize this sign-in. Please try again.");
498
+ return;
499
+ }
500
+ if (typeof data.code !== "string" || !data.code || data.code.length > 4096)
501
+ return;
502
+ finish(data.code);
503
+ };
504
+ const abort = () => finish();
505
+ const timer = window.setTimeout(() => finish(undefined, "Sign-in timed out. Please try again."), 180_000);
506
+ window.addEventListener(NATIVE_APP_AUTH_RESULT, receive);
507
+ popupCancellations.add(abort);
508
+ signal?.addEventListener("abort", abort, { once: true });
509
+ if (signal?.aborted) {
510
+ finish();
511
+ return;
512
+ }
513
+ const url = new URL(appAuthNavigationUrl(attempt));
514
+ url.searchParams.set("responseMode", "web_message");
515
+ try {
516
+ window.location.assign(url.href);
517
+ }
518
+ catch {
519
+ finish(undefined, "The native host could not open Hosty sign-in. Please try again.");
520
+ }
160
521
  });
161
522
  }
162
523
  export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
163
524
  let activityConfig = null;
164
525
  let renewal = null;
526
+ let renewalController = null;
165
527
  const waiting = new Set();
166
528
  export function configureAppActivity(config) {
167
529
  activityConfig = config;
@@ -176,7 +538,7 @@ export function updateAppActivity(activeUntil, activityRequired) {
176
538
  activityConfig.activityRequired = activityRequired;
177
539
  }
178
540
  }
179
- export function cancelActivityRenewal() { for (const done of [...waiting])
541
+ export function cancelActivityRenewal() { renewalController?.abort(); for (const done of [...waiting])
180
542
  done(false); }
181
543
  function waitForActivity(signal) {
182
544
  return new Promise(resolve => {
@@ -196,9 +558,16 @@ export function renewAppActivity() {
196
558
  const config = activityConfig;
197
559
  if (!config)
198
560
  return Promise.reject(new Error("App recovery is not configured."));
199
- const popup = openAppSignIn(config.openUrl);
200
- renewal = popup.then(async (code) => {
201
- const result = await config.exchangeCode(code);
561
+ const controller = new AbortController();
562
+ const revision = grantRevision;
563
+ renewalController = controller;
564
+ const popup = openAppSignIn(config.openUrl, controller.signal, config.appAuthProtocol, config.native, config.probePath);
565
+ renewal = popup.then(async (proof) => {
566
+ if (config.appAuthProtocol === 1 && acceptAppAuthProtocol(new URL(config.openUrl).origin, 1) === null)
567
+ config.appAuthProtocol = 2;
568
+ const result = await config.exchangeCode(proof);
569
+ if (controller.signal.aborted || activityConfig !== config || revision !== grantRevision)
570
+ throw new Error("Sign-in was cancelled.");
202
571
  if (result.accessToken)
203
572
  rememberAppGrant(result.accessToken);
204
573
  config.activeUntil = result.activeUntil;
@@ -206,7 +575,7 @@ export function renewAppActivity() {
206
575
  for (const done of [...waiting])
207
576
  done(true);
208
577
  window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
209
- }).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; });
578
+ }).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; renewalController = null; });
210
579
  return renewal;
211
580
  }
212
581
  export function appActivityNeedsRenewal(now = Date.now()) {
package/dist/index.d.ts CHANGED
@@ -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. */
package/dist/index.js CHANGED
@@ -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
package/dist/install.d.ts CHANGED
@@ -83,7 +83,7 @@ export interface InstallationSource {
83
83
  description?: string | null;
84
84
  };
85
85
  };
86
- /** Personal source selection requires apps.sources as well as apps.install and Core review. */
86
+ /** Personal source selection requires apps.install plus apps.sources or sources.connections and Core review. */
87
87
  sourceConnections?: {
88
88
  manifestConnectionId?: string;
89
89
  gitConnectionId?: string;
package/dist/react.d.ts CHANGED
@@ -50,6 +50,8 @@ export interface AppIdentityBridgeProps {
50
50
  appCodePath?: string;
51
51
  /** Optional app-owned rendering, including loading and active content. Recovery remains SDK-owned. */
52
52
  renderState?: (state: AppIdentityBridgeState) => ReactNode;
53
+ /** Receives a successful active probe before children mount, so an app can seed its session cache. */
54
+ onSession?: (body: unknown) => void;
53
55
  children?: ReactNode;
54
56
  }
55
57
  /**
@@ -60,7 +62,7 @@ export interface AppIdentityBridgeProps {
60
62
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
61
63
  * — never a login UI while recovery is still running.
62
64
  */
63
- export declare function AppIdentityBridge({ probePath, appCodePath, renderState, children, }?: AppIdentityBridgeProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
65
+ export declare function AppIdentityBridge({ probePath, appCodePath, renderState, onSession, children, }?: AppIdentityBridgeProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
64
66
  export interface HostThemeBridgeProps {
65
67
  /**
66
68
  * Whether the operating system decides the theme while no shell has spoken — `true` unless the
package/dist/react.js CHANGED
@@ -7,12 +7,12 @@ 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
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";
15
- const SILENT_STATE_KEY = "hosty.auth.silent-state";
16
16
  const SILENT_GUARD_PREFIX = "hosty.auth.silent-attempted:";
17
17
  // Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
18
18
  // timeout it classifies as unavailable and the user gets a Retry affordance.
@@ -69,36 +69,38 @@ function writeGuard(value) {
69
69
  }
70
70
  }
71
71
  /** Both state and the per-app guard must survive navigation, or the popup is the fallback. */
72
- function beginSilentSignIn(openUrl, appId) {
72
+ function beginNavigationSignIn(openUrl, appId, protocol, mode, force = false) {
73
+ let state = null;
73
74
  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))
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))
97
81
  return false;
98
- window.sessionStorage.removeItem(SILENT_STATE_KEY);
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);
99
95
  return true;
100
96
  }
101
97
  catch {
98
+ if (state) {
99
+ try {
100
+ window.sessionStorage.removeItem(`${APP_AUTH_ATTEMPT_PREFIX}${state}`);
101
+ }
102
+ catch { /* Popup fallback. */ }
103
+ }
102
104
  return false;
103
105
  }
104
106
  }
@@ -112,7 +114,7 @@ function readStoredLaunchMode() {
112
114
  }
113
115
  /** The mode already applied to the document, or a fresh resolution when nothing applied one. */
114
116
  function currentLaunchMode() {
115
- const applied = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
117
+ const applied = typeof document === "undefined" ? null : normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
116
118
  if (applied) {
117
119
  return applied;
118
120
  }
@@ -185,7 +187,7 @@ export function useLaunchMode() {
185
187
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
186
188
  * — never a login UI while recovery is still running.
187
189
  */
188
- export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, children, } = {}) {
190
+ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, onSession, children, } = {}) {
189
191
  const [ui, setUi] = useState({ kind: "recovering" });
190
192
  // A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
191
193
  // keep its exchange alive and let the replacement effect await the same response.
@@ -193,8 +195,12 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
193
195
  const firstProbe = useRef(true);
194
196
  const initialGrantPresent = useRef(null);
195
197
  const silentError = useRef(null);
198
+ const returnedLegacyAttempt = useRef(null);
199
+ const upgradedAttempt = useRef(false);
196
200
  const [activity, setActivity] = useState(null);
197
201
  const exchangeRef = useRef(null);
202
+ const onSessionRef = useRef(onSession);
203
+ useEffect(() => { onSessionRef.current = onSession; }, [onSession]);
198
204
  useEffect(() => {
199
205
  let cancelled = false;
200
206
  const controller = new AbortController();
@@ -211,7 +217,10 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
211
217
  let status = null;
212
218
  let openUrl = null;
213
219
  let appId = null;
220
+ let protocol = null;
221
+ let activityMetadata = null;
214
222
  let rejectionCode;
223
+ let sessionBody = null;
215
224
  const revision = appGrantRevision();
216
225
  try {
217
226
  const response = await appFetch(probePath, {
@@ -221,14 +230,16 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
221
230
  }, false);
222
231
  const body = await response.json().catch(() => null);
223
232
  status = readProbedSessionStatus(body);
233
+ if (status === "active" && !response.ok)
234
+ status = null;
235
+ sessionBody = body;
224
236
  const identityError = body;
225
237
  rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
226
238
  const recovery = readRecoveryParams(body);
227
239
  appId = recovery.appId;
228
240
  openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
229
- const metadata = body;
230
- if (!cancelled && openUrl)
231
- setActivity({ openUrl, activeUntil: metadata?.activeUntil, activityRequired: metadata?.activityRequired });
241
+ protocol = openUrl ? acceptAppAuthProtocol(new URL(openUrl).origin, recovery.appAuthProtocol) : recovery.appAuthProtocol;
242
+ activityMetadata = body;
232
243
  }
233
244
  catch {
234
245
  // A failed or timed-out probe (Core unreachable) classifies as unavailable below.
@@ -247,71 +258,93 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
247
258
  return;
248
259
  }
249
260
  forgetRejectedAppGrant(rejectionCode, revision);
261
+ if (openUrl && protocol)
262
+ setActivity({ openUrl, appAuthProtocol: protocol,
263
+ activeUntil: activityMetadata?.activeUntil, activityRequired: activityMetadata?.activityRequired });
250
264
  const initialLoad = firstProbe.current;
251
265
  firstProbe.current = false;
252
266
  if (silentError.current === "access_denied") {
253
267
  setUi({ kind: "denied" });
254
268
  return;
255
269
  }
256
- if (initialLoad && !wasActive.current && !initialGrantPresent.current && !silentError.current &&
270
+ const mode = currentLaunchMode();
271
+ const legacy = returnedLegacyAttempt.current;
272
+ const sameLegacyCore = !!legacy && appId === legacy.appId && !!openUrl && new URL(openUrl).origin === legacy.coreOrigin;
273
+ if (legacy?.protocolRequired && (status === "not-present" || status === "expired") && (!sameLegacyCore || protocol !== 2)) {
274
+ setUi({ kind: "unavailable" });
275
+ return;
276
+ }
277
+ const shouldUpgrade = sameLegacyCore && protocol === 2 && !upgradedAttempt.current;
278
+ if (shouldUpgrade) {
279
+ upgradedAttempt.current = true;
280
+ returnedLegacyAttempt.current = null;
281
+ }
282
+ if (!wasActive.current && protocol && ((initialLoad && !initialGrantPresent.current && !silentError.current) || shouldUpgrade) &&
257
283
  detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
258
- const silentUrl = beginSilentSignIn(openUrl, appId);
259
- if (silentUrl) {
260
- window.location.replace(silentUrl);
284
+ if (beginNavigationSignIn(openUrl, appId, protocol, "silent", shouldUpgrade))
261
285
  return;
262
- }
263
286
  }
264
- const action = !status || !appId
287
+ const action = !status || !appId || ((status === "not-present" || status === "expired") && !protocol)
265
288
  ? { kind: "card", card: "unavailable" }
266
289
  : decideRecoveryAction({
267
290
  status: silentError.current === "login_required" ? "not-present" : status,
268
- mode: detectLaunchMode(window),
291
+ mode,
269
292
  appId,
270
293
  openUrl,
271
- redirectAlreadyAttempted: readGuard(),
294
+ redirectAlreadyAttempted: !shouldUpgrade && readGuard(),
272
295
  });
273
296
  switch (action.kind) {
274
297
  case "none":
298
+ onSessionRef.current?.(sessionBody);
275
299
  appSessionActive();
276
300
  wasActive.current = true;
277
301
  setUi({ kind: "active" });
278
302
  return;
279
303
  case "post-auth-required": {
280
- const showSignIn = (error) => setUi({ kind: "signin", openUrl, embedded: true, error,
281
- signIn: openUrl ? () => {
282
- // Open synchronously in the click handler; an awaited request loses the user gesture.
283
- const result = openAppSignIn(openUrl, controller.signal);
284
- setUi({ kind: "recovering" });
285
- void result.then(async (code) => {
286
- const response = await appFetch(appCodePath, {
287
- method: "POST", headers: { "Content-Type": "application/json" },
288
- body: JSON.stringify({ code }),
289
- });
290
- const body = await response.json();
291
- if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
292
- throw new Error("Hosty could not establish this app session.");
293
- if (cancelled)
294
- return;
295
- rememberAppGrant(body.accessToken);
296
- silentError.current = null;
297
- writeGuard(false);
298
- await probeAndRecover();
299
- }).catch(error => { if (!cancelled)
300
- showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
301
- } : undefined });
302
304
  showSignIn();
303
305
  return;
304
306
  }
305
307
  case "redirect":
306
- writeGuard(true);
307
- window.location.assign(action.openUrl);
308
+ if (appId && protocol && beginNavigationSignIn(action.openUrl, appId, protocol, mode === "native" ? "native" : "standalone", shouldUpgrade))
309
+ return;
310
+ showSignIn("Allow app storage, or sign in with a popup.");
308
311
  return;
309
312
  case "card":
310
- setUi(action.card === "signin"
311
- ? { kind: "signin", openUrl, embedded: false }
312
- : { kind: action.card });
313
+ if (action.card === "signin")
314
+ showSignIn();
315
+ else
316
+ setUi({ kind: action.card });
313
317
  return;
314
318
  }
319
+ function showSignIn(error) {
320
+ setUi({ kind: "signin", openUrl, embedded: mode === "embedded", error,
321
+ signIn: openUrl && protocol ? () => {
322
+ // Open synchronously in the click handler; an awaited request loses the user gesture.
323
+ const revision = appGrantRevision();
324
+ const result = openAppSignIn(openUrl, controller.signal, protocol, mode === "native", probePath);
325
+ setUi({ kind: "recovering" });
326
+ void result.then(async (proof) => {
327
+ const response = await appFetch(appCodePath, {
328
+ method: "POST", headers: { "Content-Type": "application/json" },
329
+ body: JSON.stringify(proof),
330
+ }, false);
331
+ const body = await response.json();
332
+ if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
333
+ throw new Error("Hosty could not establish this app session.");
334
+ if (cancelled)
335
+ return;
336
+ if (revision !== appGrantRevision()) {
337
+ await probeAndRecover();
338
+ return;
339
+ }
340
+ rememberAppGrant(body.accessToken);
341
+ silentError.current = null;
342
+ writeGuard(false);
343
+ await probeAndRecover();
344
+ }).catch(error => { if (!cancelled)
345
+ showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
346
+ } : undefined });
347
+ }
315
348
  }
316
349
  const recover = () => { if (!cancelled && !wasActive.current) {
317
350
  setUi({ kind: "recovering" });
@@ -321,27 +354,30 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
321
354
  const url = new URL(window.location.href);
322
355
  const code = url.searchParams.get("code")?.trim();
323
356
  const error = url.searchParams.get("error");
324
- const embedded = detectLaunchMode(window) === "embedded";
325
357
  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)
358
+ const attempt = takeAppAuthAttempt(url, true);
359
+ const upgradedLegacy = attempt?.protocol === 1 && acceptAppAuthProtocol(attempt.coreOrigin, 1) === null;
360
+ if (attempt?.mode === "silent" && isSilentError)
328
361
  silentError.current = error;
329
- if (code || (embedded && isSilentError)) {
362
+ const protocolRequired = attempt?.protocol === 1 && !code && error === "protocol_required" && url.searchParams.getAll("error").length === 1;
363
+ if (attempt?.protocol === 1 && (!error || protocolRequired || upgradedLegacy))
364
+ returnedLegacyAttempt.current = { appId: attempt.appId, coreOrigin: attempt.coreOrigin, protocolRequired: protocolRequired || upgradedLegacy };
365
+ if (code || isSilentError || attempt) {
330
366
  url.searchParams.delete("code");
331
- if (embedded) {
332
- url.searchParams.delete("state");
333
- if (isSilentError)
334
- url.searchParams.delete("error");
335
- }
367
+ url.searchParams.delete("state");
368
+ if (isSilentError || protocolRequired)
369
+ url.searchParams.delete("error");
336
370
  window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
337
371
  }
338
- if (code && accepted) {
372
+ if (code && !error && !upgradedLegacy && attempt && !exchangeRef.current) {
339
373
  exchangeRef.current = {
340
374
  path: appCodePath,
375
+ revision: appGrantRevision(),
341
376
  response: fetch(appCodePath, {
342
377
  method: "POST",
343
378
  headers: { "Content-Type": "application/json" },
344
- body: JSON.stringify({ code }),
379
+ body: JSON.stringify({ code, codeVerifier: attempt.codeVerifier }),
380
+ credentials: "same-origin", redirect: "error",
345
381
  }),
346
382
  };
347
383
  }
@@ -355,6 +391,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
355
391
  if (response.ok) {
356
392
  writeGuard(false);
357
393
  const body = await response.json().catch(() => null);
394
+ if (cancelled)
395
+ return;
396
+ if (exchange.revision !== appGrantRevision()) {
397
+ exchangeRef.current = null;
398
+ await probeAndRecover();
399
+ return;
400
+ }
358
401
  if (typeof body?.accessToken === "string")
359
402
  rememberAppGrant(body.accessToken);
360
403
  exchangeRef.current = null;
@@ -382,7 +425,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
382
425
  };
383
426
  }, [probePath, appCodePath]);
384
427
  if (wasActive.current)
385
- return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath }), renderState ? renderState({ kind: "active" }) : children] });
428
+ return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath, probePath: probePath }), renderState ? renderState({ kind: "active" }) : children] });
386
429
  if (renderState)
387
430
  return renderState(ui);
388
431
  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.20.0",
3
+ "version": "0.21.1",
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,10 +87,11 @@
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": {
89
- "next": "^16.3.6",
94
+ "next": "^16.3.8",
90
95
  "react": "^19.2.8"
91
96
  },
92
97
  "peerDependenciesMeta": {
@@ -98,14 +103,14 @@
98
103
  }
99
104
  },
100
105
  "devDependencies": {
101
- "@types/node": "^24.19.0",
106
+ "@types/node": "^24.19.1",
102
107
  "@types/react": "^19.3.0",
103
- "typescript": "^6",
104
- "vitest": "^5.0.2",
108
+ "@types/react-dom": "^19.3.0",
109
+ "jsdom": "^30.1.1",
105
110
  "react": "^19.3.0",
106
111
  "react-dom": "^19.3.0",
107
- "@types/react-dom": "^19.3.0",
108
- "jsdom": "^30.1.1"
112
+ "typescript": "^6",
113
+ "vitest": "^5.0.3"
109
114
  },
110
115
  "repository": {
111
116
  "type": "git",