@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.
@@ -1,12 +1,262 @@
1
- /** App-only browser credentials. Never persisted or sent to an embedder. */
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
+ }
181
+ /** App-only credentials. Embedded documents keep them for this tab on the app's own origin. */
2
182
  let grant = null;
183
+ let grantRevision = 0;
3
184
  let recoveryPending = false;
185
+ const popupCancellations = new Set();
4
186
  export const APP_SESSION_ENDED = "hosty:app-session-ended";
187
+ export const NATIVE_APP_AUTH_RESULT = "hosty:native-auth-result";
188
+ export const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
189
+ function isEmbedded() {
190
+ try {
191
+ return window.self !== window.top;
192
+ }
193
+ catch {
194
+ return true;
195
+ }
196
+ }
197
+ /** Restore before the first identity probe; standalone documents use their first-party cookie. */
198
+ export function restoreAppGrant() {
199
+ if (typeof window === "undefined")
200
+ return false;
201
+ if (grant?.origin === window.location.origin)
202
+ return true;
203
+ if (!isEmbedded())
204
+ return false;
205
+ try {
206
+ const token = window.sessionStorage.getItem(APP_GRANT_STORAGE_KEY);
207
+ if (token) {
208
+ grant = { token, origin: window.location.origin };
209
+ grantRevision++;
210
+ }
211
+ }
212
+ catch {
213
+ // Blocked storage leaves the in-memory transport available.
214
+ }
215
+ return grant?.origin === window.location.origin;
216
+ }
5
217
  export function rememberAppGrant(token) {
6
218
  grant = { token, origin: window.location.origin };
219
+ grantRevision++;
220
+ recoveryPending = false;
221
+ if (isEmbedded()) {
222
+ try {
223
+ window.sessionStorage.setItem(APP_GRANT_STORAGE_KEY, token);
224
+ }
225
+ catch { /* In-memory fallback. */ }
226
+ }
227
+ }
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() {
238
+ if (grant)
239
+ grantRevision++;
240
+ grant = null;
7
241
  recoveryPending = false;
242
+ if (typeof window !== "undefined") {
243
+ try {
244
+ window.sessionStorage.removeItem(APP_GRANT_STORAGE_KEY);
245
+ }
246
+ catch { /* In-memory fallback. */ }
247
+ }
248
+ }
249
+ /** Snapshot the grant generation so older probes cannot invalidate a renewed identity. */
250
+ export function appGrantRevision() { return grantRevision; }
251
+ /** Activity expiry keeps identity; only explicit identity rejection discards the credential. */
252
+ export function forgetRejectedAppGrant(code, expectedRevision = grantRevision) {
253
+ if (expectedRevision !== grantRevision)
254
+ return false;
255
+ if (typeof code !== "string" || !["token_invalid", "token_revoked", "token_expired", "token_app_mismatch"].includes(code))
256
+ return false;
257
+ discardAppGrant();
258
+ return true;
8
259
  }
9
- export function forgetAppGrant() { grant = null; recoveryPending = false; }
10
260
  export function appSessionActive() { recoveryPending = false; }
11
261
  /** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
12
262
  export async function appFetch(input, init = {}, recover = true) {
@@ -20,11 +270,21 @@ export async function appFetch(input, init = {}, recover = true) {
20
270
  if (current)
21
271
  headers.set("authorization", `Bearer ${current.token}`);
22
272
  const response = await fetch(input, { ...init, headers, credentials: "same-origin", redirect: "error" });
23
- if (response.status === 401 && recover && (!current || grant === current)) {
273
+ if ((response.status === 401 || response.status === 403) && grant === current && !init.signal?.aborted) {
274
+ const revision = grantRevision;
24
275
  const body = await response.clone().json().catch(() => null);
276
+ if (grant !== current || grantRevision !== revision || init.signal?.aborted)
277
+ return response;
278
+ // Identity probes disable recovery, but a rejected grant must still be forgotten. Activity
279
+ // expiry is different: the identity remains valid and the popup renews the same session.
280
+ const rejected = forgetRejectedAppGrant(body?.code);
281
+ if (response.status !== 401)
282
+ return response;
283
+ if (!rejected && recover && body?.code !== "reauth_required" && current)
284
+ discardAppGrant();
285
+ if (!recover)
286
+ return response;
25
287
  if (activityConfig) {
26
- if (body?.code !== "reauth_required" && current)
27
- grant = null;
28
288
  const renewed = waitForActivity(init.signal);
29
289
  window.dispatchEvent(new Event(APP_SESSION_ENDED));
30
290
  // A response may arrive while the originating click still has browser activation.
@@ -35,45 +295,153 @@ export async function appFetch(input, init = {}, recover = true) {
35
295
  return appFetch(input, init, false);
36
296
  }
37
297
  else if (!recoveryPending) {
38
- if (current)
39
- grant = null;
40
298
  recoveryPending = true;
41
299
  window.dispatchEvent(new Event(APP_SESSION_ENDED));
42
300
  }
43
301
  }
44
302
  return response;
45
303
  }
46
- /** Must be called directly from a user gesture. The popup belongs to this app frame, not Shell. */
47
- export function openAppSignIn(openUrl, signal) {
48
- const url = new URL(openUrl);
49
- if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
50
- return Promise.reject(new Error("Hosty sign-in URL is invalid."));
51
- const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
52
- url.searchParams.set("responseMode", "web_message");
53
- 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);
54
350
  return new Promise((resolve, reject) => {
351
+ let attempt = initialAttempt;
352
+ initialAttempt = null;
55
353
  let popup = null;
56
354
  let settled = false;
355
+ let upgraded = false;
356
+ let restartPoll;
357
+ const refreshController = new AbortController();
57
358
  const finish = (code, error) => {
58
359
  if (settled)
59
360
  return;
60
361
  settled = true;
362
+ const proof = code && attempt ? { code, codeVerifier: attempt.codeVerifier } : null;
363
+ attempt = null;
364
+ refreshController.abort();
61
365
  window.removeEventListener("message", receive);
62
366
  signal?.removeEventListener("abort", abort);
367
+ popupCancellations.delete(abort);
63
368
  window.clearTimeout(timer);
64
369
  clearInterval(closedCheck);
370
+ clearInterval(restartPoll);
65
371
  popup?.close();
66
- if (code)
67
- resolve(code);
372
+ if (proof)
373
+ resolve(proof);
68
374
  else
69
375
  reject(new Error(error ?? "Sign-in was cancelled."));
70
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
+ };
71
428
  const receive = (event) => {
72
- if (!popup || event.source !== popup || event.origin !== url.origin)
429
+ if (!popup || !attempt || event.source !== popup || event.origin !== attempt.coreOrigin)
73
430
  return;
74
431
  const data = event.data;
75
- if (data?.type !== "hosty:app-auth-code" || data.state !== state ||
76
- 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)
77
445
  return;
78
446
  finish(data.code);
79
447
  };
@@ -82,19 +450,80 @@ export function openAppSignIn(openUrl, signal) {
82
450
  const closedCheck = setInterval(() => { if (popup?.closed)
83
451
  finish(); }, 500);
84
452
  window.addEventListener("message", receive);
453
+ popupCancellations.add(abort);
85
454
  signal?.addEventListener("abort", abort, { once: true });
86
455
  if (signal?.aborted) {
87
456
  finish();
88
457
  return;
89
458
  }
90
- popup = window.open(url.href, "_blank", "popup,width=520,height=700");
91
- 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) {
92
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
+ }
93
521
  });
94
522
  }
95
523
  export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
96
524
  let activityConfig = null;
97
525
  let renewal = null;
526
+ let renewalController = null;
98
527
  const waiting = new Set();
99
528
  export function configureAppActivity(config) {
100
529
  activityConfig = config;
@@ -109,7 +538,7 @@ export function updateAppActivity(activeUntil, activityRequired) {
109
538
  activityConfig.activityRequired = activityRequired;
110
539
  }
111
540
  }
112
- export function cancelActivityRenewal() { for (const done of [...waiting])
541
+ export function cancelActivityRenewal() { renewalController?.abort(); for (const done of [...waiting])
113
542
  done(false); }
114
543
  function waitForActivity(signal) {
115
544
  return new Promise(resolve => {
@@ -129,9 +558,16 @@ export function renewAppActivity() {
129
558
  const config = activityConfig;
130
559
  if (!config)
131
560
  return Promise.reject(new Error("App recovery is not configured."));
132
- const popup = openAppSignIn(config.openUrl);
133
- renewal = popup.then(async (code) => {
134
- 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.");
135
571
  if (result.accessToken)
136
572
  rememberAppGrant(result.accessToken);
137
573
  config.activeUntil = result.activeUntil;
@@ -139,7 +575,7 @@ export function renewAppActivity() {
139
575
  for (const done of [...waiting])
140
576
  done(true);
141
577
  window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
142
- }).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; });
578
+ }).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; renewalController = null; });
143
579
  return renewal;
144
580
  }
145
581
  export function appActivityNeedsRenewal(now = Date.now()) {
@@ -13,7 +13,8 @@ export type AuthRequiredMessage = EmbedderMessage;
13
13
  * - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
14
14
  * - the payload's `appId` must match the app the embedder actually mounted there.
15
15
  * No credential crosses the boundary in either direction: the payload is only the app id,
16
- * and the embedder answers by reissuing a code through its own authenticated Core call.
16
+ * and the app owns its direct sign-in with Core. This parser gives the embedder no authority
17
+ * to issue or redeem credentials for the app.
17
18
  */
18
19
  export declare function parseActiveFrameAuthRequired(event: AuthRequiredMessage, activeFrameWindow: unknown, activeFrameUrl: string, expectedAppId: string): boolean;
19
20
  /** A verified request, and the one thing its payload carries. */
package/dist/embedder.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // functions plus a small rate limiter — the embedder wires them to its own DOM, its own "open
4
4
  // this app" flow, and its own Core session.
5
5
  //
6
- // Recovery, in one sentence: on a VERIFIED `hosty:auth-required` from an app you embed,
7
- // re-run your normal open flow for that app (which mints a fresh launch code), rate-limited.
8
- // The re-open must take the full launch-code path — an "already open → reuse the URL
9
- // without a code" optimization must not short-circuit recovery.
6
+ // Authentication belongs to the app frame: it restores its own per-tab grant, tries a silent
7
+ // Core navigation on first load, and opens its own Core popup when interaction is required.
8
+ // An embedder may observe a verified `hosty:auth-required` intent, but never carries or
9
+ // redeems another app's code or grant.
10
10
  //
11
11
  // Delegated tokens, in one sentence: on a VERIFIED `hosty:request-delegated-token` from an app
12
12
  // you have decided to grant one, mint a token from Core with your own session and post it back
@@ -21,7 +21,8 @@ import { THEME_PARAM, THEME_PREFERENCE_PARAM } from "./theme.js";
21
21
  * - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
22
22
  * - the payload's `appId` must match the app the embedder actually mounted there.
23
23
  * No credential crosses the boundary in either direction: the payload is only the app id,
24
- * and the embedder answers by reissuing a code through its own authenticated Core call.
24
+ * and the app owns its direct sign-in with Core. This parser gives the embedder no authority
25
+ * to issue or redeem credentials for the app.
25
26
  */
26
27
  export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFrameUrl, expectedAppId) {
27
28
  if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {