@hosty-sdk/app 0.25.12 → 0.27.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +181 -19
  2. package/dist/background-server.d.ts +16 -0
  3. package/dist/background-server.js +52 -0
  4. package/dist/browser-auth.d.ts +2 -0
  5. package/dist/browser-auth.js +5 -0
  6. package/dist/continuity-browser.d.ts +11 -0
  7. package/dist/continuity-browser.js +92 -0
  8. package/dist/continuity.d.ts +9 -0
  9. package/dist/continuity.js +9 -0
  10. package/dist/index.d.ts +9 -6
  11. package/dist/index.js +10 -1
  12. package/dist/next.d.ts +4 -0
  13. package/dist/next.js +29 -0
  14. package/dist/overlay-react.d.ts +9 -1
  15. package/dist/overlay-react.js +6 -4
  16. package/dist/react.d.ts +3 -0
  17. package/dist/react.js +18 -1
  18. package/dist/server.d.ts +2 -0
  19. package/dist/server.js +2 -0
  20. package/dist/session-server.d.ts +2 -0
  21. package/dist/ui-composition.d.ts +71 -0
  22. package/dist/ui-composition.js +232 -0
  23. package/dist/ui-context.d.ts +2 -0
  24. package/dist/ui-context.js +1 -0
  25. package/dist/ui-embedded.d.ts +18 -1
  26. package/dist/ui-embedded.js +123 -17
  27. package/dist/ui-frame-layer.d.ts +36 -0
  28. package/dist/ui-frame-layer.js +88 -0
  29. package/dist/ui-frame.d.ts +2 -0
  30. package/dist/ui-frame.js +43 -7
  31. package/dist/ui-host.d.ts +62 -15
  32. package/dist/ui-host.js +666 -76
  33. package/dist/ui-navigation-react.d.ts +17 -0
  34. package/dist/ui-navigation-react.js +89 -0
  35. package/dist/ui-react.d.ts +13 -3
  36. package/dist/ui-react.js +127 -50
  37. package/dist/ui-server.d.ts +3 -1
  38. package/dist/ui-server.js +14 -2
  39. package/dist/ui-transport.d.ts +2 -2
  40. package/dist/ui-transport.js +1 -1
  41. package/dist/ui-types.d.ts +88 -0
  42. package/dist/ui-types.js +1 -0
  43. package/dist/ui-workspaces.d.ts +54 -0
  44. package/dist/ui-workspaces.js +3 -0
  45. package/dist/ui.d.ts +7 -1
  46. package/dist/ui.js +22 -0
  47. package/package.json +17 -1
package/README.md CHANGED
@@ -11,6 +11,9 @@ npm install @hosty-sdk/app
11
11
  | --- | --- | --- |
12
12
  | `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` and `hosty:request-delegated-token` schemas, URL/env helpers |
13
13
  | `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
14
+ | `@hosty-sdk/app/next` | Next client | `HostyNextOverlay`, `HostyNextNavigationBridge` for App Router navigation |
15
+ | `@hosty-sdk/app/ui` | client | policy/navigation snapshots, replayable navigation, chooser and presentation operations |
16
+ | `@hosty-sdk/app/ui/react` | React client | navigation boundaries/hooks, app frames and retained panel workspaces |
14
17
  | `@hosty-sdk/app/react` | client | `<HostyOverlay />` — shared loading, identity, required setup and recovery UI; legacy bridges remain available |
15
18
  | `@hosty-sdk/app/session/server` | server | Framework-neutral session response adapter for authenticated Node servers |
16
19
  | `@hosty-sdk/app/embedder` | client | theme sender and legacy embedder message parsers; Shell no longer mints app credentials |
@@ -23,8 +26,8 @@ Minimal Next.js wiring (the configuration object stays server-side):
23
26
 
24
27
  ```tsx
25
28
  // app/layout.tsx — inside the body and the existing theme/launch providers
26
- import { HostyOverlay } from "@hosty-sdk/app/react";
27
- // <HostyOverlay>{children}</HostyOverlay>
29
+ import { HostyNextOverlay } from "@hosty-sdk/app/next";
30
+ // <HostyNextOverlay>{children}</HostyNextOverlay>
28
31
 
29
32
  // lib/hosty.ts
30
33
  export const hostyConfig = {
@@ -48,8 +51,9 @@ export const POST = createAppCodeRouteHandler(hostyConfig);
48
51
 
49
52
  Replace the protected scope's `AppIdentityBridge`, `AppActivityBridge`, custom sign-in rendering
50
53
  and `MissingPermissionsNotice` with one `HostyOverlay`. Keep public routes outside that scope.
51
- Existing launch/theme bridges and bootstrap scripts stay in place. The only optional overlay props
52
- are `probePath` and `appCodePath` for existing route names; it has no visual customization API.
54
+ Existing launch/theme bridges stay in place for transport compatibility; add the composition
55
+ bootstrap below. `probePath` and `appCodePath` support existing route names. `chrome`, `navigation`
56
+ and `panelLayout` configure UI composition without replacing authentication/recovery rendering.
53
57
  API routes and server-rendered data still enforce their own authorization. The overlay delays
54
58
  protected client mounting; it is not a server data-access boundary.
55
59
 
@@ -69,8 +73,8 @@ After first mount, blocked content and app portals become hidden and inert while
69
73
  remains mounted. Same-user renewal restores it. A different stable user ID reloads the app document
70
74
  and refuses to replay old pending requests. `APP_SESSION_RESTORED` from `/browser-auth` is an optional
71
75
  DOM notification with `detail.userId`; it contains no credential. There is no generic draft persistence
72
- across reloads or cache-clearing framework. Existing identity idle/absolute lifetimes and fixed
73
- privileged activity windows still apply.
76
+ across reloads or cache-clearing framework. Current user/app permissions and the authorizing principal's idle/absolute lifetime still apply.
77
+ Core 0.130.0 advertises continuous app sessions; older servers retain their actual activity policy.
74
78
 
75
79
  Compatible SDK updates change standard presentation and recovery after a dependency update and
76
80
  rebuild, without editing this wiring. New mandatory Core capabilities can require upgrading Core.
@@ -289,24 +293,48 @@ reads or `mcp:invoke` for assistant mutations, then apply your application's use
289
293
  Do not cache validation. Keep `introspectScopedToken` on ordinary APIs: it does not opt into accepting
290
294
  MCP-only credentials. Neither token permits skipping the application's own authorization.
291
295
 
292
- ## Privileged activity renewal
296
+ ## Continuous app access (Core 0.130.0 / SDK 0.26.0)
293
297
 
294
- Identity probes used by `AppIdentityBridge` pass `activeUntil` and `activityRequired` through from
295
- `resolveAppSession().identity`. Keep app APIs on `appFetch`; a 401 can then request a user-click
296
- Core popup, exchange a code on the same origin and retry once without replacing mounted content.
297
- For another UI framework, use `configureAppActivity` with an app-owned `exchangeCode` function,
298
- listen for `APP_SESSION_ENDED`, and call `renewAppActivity` directly from a click. Do not navigate
299
- or reload to renew access. `appActivityNeedsRenewal()` supports proactive click renewal near expiry.
300
- See [the activity contract](../../docs/features/app-activity-window/feature.md), including the
301
- separate Core-approved assistant session leases.
298
+ Pass `continuity`, `activeUntil` and `activityRequired` from Core revalidation through custom identity
299
+ probes. Standard SDK adapters do this automatically. `HostyOverlay` / `useAppIdentity` maintain a
300
+ live browser parent through a Core-owned frame and refresh the app-local probe. Checks coalesce,
301
+ run on mount/return/network recovery, and repeat at a jittered five-minute interval. App backends
302
+ cannot keep the browser parent alive with app credentials alone.
302
303
 
303
- ## Shared panels (Core 0.129.0 / SDK 0.25.0)
304
+ Keep protected client requests on `appFetch`. An outage returns 503 without clearing identity or
305
+ replaying a mutation. Real expiry uses normal recovery, preserving same-user state. Optional
306
+ `<HostyOverlay localContent={<LocalNotes />}>…</HostyOverlay>` places explicitly local/public
307
+ content outside the protected root during an outage; do not place private actor-bound data there.
308
+
309
+ For another framework, use `configureAppContinuity` / `ensureAppContinuity` from
310
+ `@hosty-sdk/app/continuity/browser`, with validated metadata from `/continuity` and a probe that
311
+ checks the same actor. The probe must bypass maintenance (`appFetch(path, init, false)`) to avoid
312
+ recursion. Clean up the coordinator on unmount. `APP_CONTINUITY_UPDATED` reports silent success;
313
+ `APP_ACTIVITY_RENEWED` continues to mean explicit recovery. Do not replay handoffs/messages on either.
314
+ Missing continuity metadata keeps older Core's `configureAppActivity`/click renewal behavior.
315
+
316
+ Server-only unattended work requires an explicit Core grant:
317
+
318
+ ```ts
319
+ import { BackgroundGrantClient } from "@hosty-sdk/app/background/server";
320
+ const background = new BackgroundGrantClient(grantId, "health"); // HOSTY_* service configuration
321
+ const response = await background.core("/api/apps/example.target/health");
322
+ ```
323
+
324
+ The client coalesces credential issuance and refreshes before expiry. It sends each operation once;
325
+ Core enforces the immutable selected scope online. `token()` can supply the existing provider/MCP
326
+ client's user-token input. Keep tokens server-side. See
327
+ [Continuous App Access](../../docs/features/continuous-app-access/feature.md) for creation, logout,
328
+ revocation, exact MCP tools and installation boundaries. Background grants do not create a scheduler.
329
+
330
+ ## Shared panels (Core 0.131.0 / SDK 0.27.0)
304
331
 
305
332
  `HostyOverlay` composes one UI owner per top-level browser document. Its default
306
333
  `panelLayout="auto"` adds an opened-tool rail, an **Add tool** chooser and a resizable
307
334
  workspace. In a directly embedded app it delegates presentation to the verified parent
308
- and renders no local rail. Use `panelLayout="slot"` with exactly one
309
- `HostyPanelWorkspace` inside your chrome, or `"none"` for a custom controller renderer.
335
+ and renders no local rail. Use `panelLayout="slot"` with a `HostyPanelWorkspace` per workspace
336
+ ID inside your chrome, or `"none"` with an explicitly registered custom renderer. Without a renderer,
337
+ disable local tools; an opening otherwise fails with `presentation_unavailable`.
310
338
  Panel hosting requires reviewed optional `apps.panels.read` (or existing `apps.read`);
311
339
  participating as a child does not require catalog access.
312
340
 
@@ -320,7 +348,7 @@ export const POST = handler;
320
348
 
321
349
  Non-Next servers use `createHostyUiResponse` from `@hosty-sdk/app/ui/server` with their
322
350
  revalidated own app-user session/token. `UiServerClient` provides only `bootstrap`,
323
- `listPanels` and `resolvePanel`; never expose a generic Core proxy or caller-supplied credentials.
351
+ `listPanels`, `resolvePanel`, `navigation` and `resolveNavigation`; never expose a generic Core proxy or caller-supplied credentials.
324
352
  Both server adapters return `no-store` metadata. The React root's `uiPath` defaults to
325
353
  `/api/hosty/ui` and must be a same-origin path.
326
354
 
@@ -371,3 +399,137 @@ peer metadata. Matching opaque Core/user contexts prevents accidental account mi
371
399
  between cooperating apps; it is not attestation of app code or delegated API authority.
372
400
  Every target API still authorizes its own session. Older surfaces remain `unreported`.
373
401
  See [the panel contract and verification](../../docs/features/sdk-panel-system/feature.md).
402
+
403
+ ## Navigation and UI composition
404
+
405
+ Supply identical root defaults to the before-paint bootstrap and overlay. Omitted defaults show
406
+ navigation and enable the standalone tool rail/chooser. Hints and verified host policy can override
407
+ each leaf independently; neither framing nor copied launch-mode storage hides the new navigation.
408
+
409
+ ```tsx
410
+ // app/layout.tsx
411
+ import { chromeBootstrapScript } from "@hosty-sdk/app";
412
+ import { HostyNextOverlay } from "@hosty-sdk/app/next";
413
+
414
+ const chrome = { navigation: "show" as const, tools: { enabled: true, launcher: "show" as const } };
415
+ // In <head>, before hydration and the legacy launch bridge:
416
+ // <script dangerouslySetInnerHTML={{ __html: chromeBootstrapScript(chrome) }} />
417
+ // In <body>, around protected app content:
418
+ // <HostyNextOverlay chrome={chrome}>{children}</HostyNextOverlay>
419
+ ```
420
+
421
+ For React without Next, use `HostyOverlay` and optionally pass
422
+ `navigation={{ renderer: "standard", router }}`. A router implements `getRoute(): string`,
423
+ `subscribe(listener): () => void` and `navigate(relativeRoute): void | Promise<void>`; optional
424
+ `endpoint` disambiguates several endpoint names on the same origin. Without an adapter the SDK
425
+ uses document navigation. The Next entry is optional and does not add Next to plain SDK consumers.
426
+
427
+ The standard renderer uses only manifest `ui.navigation`. Own navigation needs ordinary app access,
428
+ not `apps.panels.read`. Unavailable metadata has a retry state; an empty declaration adds no menu.
429
+ Same-endpoint links use cached validated metadata and the app router; endpoint changes revalidate
430
+ through Core and replace the same host view. Modified clicks use pinned standalone links.
431
+
432
+ For custom navigation, suppress the automatic menu and wrap only page navigation/branding:
433
+
434
+ ```tsx
435
+ import { HostyNavigationBoundary, useHostyChrome, useHostyNavigation } from "@hosty-sdk/app/ui/react";
436
+
437
+ // <HostyNextOverlay navigation={{ renderer: "custom" }}>
438
+ // <HostyNavigationBoundary><MyPageNavigation /></HostyNavigationBoundary>
439
+ // <ProjectSelector /> // Contextual controls stay reachable when navigation is hidden.
440
+ // {children}
441
+ // </HostyNextOverlay>
442
+ ```
443
+
444
+ The boundary retains component state while hidden and moves hidden focus to the content landmark.
445
+ Alternatively place `HostyNavigation` at a custom location. Framework-neutral renderers subscribe
446
+ to `ui.getChrome/subscribeChrome` and `ui.getNavigation/subscribeNavigation`, then apply equivalent
447
+ hidden/inert/focus behavior. Snapshots are immutable. `tools.owner` is derived by the SDK; it is not
448
+ a configurable default or URL field.
449
+
450
+ ```ts
451
+ const result = await ui.navigate(entry.key, { requestId: crypto.randomUUID() });
452
+ const picker = await ui.openToolPicker({ requestId: crypto.randomUUID() });
453
+ // accepted means navigation was accepted / the chooser became visible.
454
+ // Retain request IDs for uncertain retries; an accepted replay does not repeat the action.
455
+ ```
456
+
457
+ Document hints are `hosty_navigation=show|hide`, `hosty_tools=enabled|disabled` and
458
+ `hosty_tool_launcher=show|hide`. The SDK removes them from the visible URL, preserving history
459
+ state. They are not persisted. Verified host overrides take precedence, then hints, app defaults,
460
+ the current document's legacy launch fallback for navigation, and standalone defaults. Hints grant
461
+ no access or hosting authority. Hiding a launcher permits programmatic presentation; disabling
462
+ tools rejects new requests. A root disable applies to every source and collapses retained tools.
463
+
464
+ ### Host views and retained tools
465
+
466
+ `HostyAppFrame` accepts a view descriptor. A desktop host can choose different policy from Shell:
467
+
468
+ ```tsx
469
+ <HostyAppFrame surface={validatedSurface} title="Plans"
470
+ view={{ viewId: "plans-window-1", workspaceId: "default",
471
+ chrome: { navigation: "show", tools: { enabled: true, launcher: "hide" } },
472
+ navigate: surface => replaceWindowSurface(surface) }} />
473
+ ```
474
+
475
+ Omit `navigate` for the SDK's plain iframe replacement, or use it to update the host's route/window
476
+ model. Key a replaced React frame by its immutable surface; live `chrome` updates keep that key.
477
+ One top-level coordinator handles direct app/tool children. It never delegates credentials.
478
+
479
+ Panel declarations and presentations have different identities. `PanelRef` identifies a manifest
480
+ tool. `openSurface` returns an SDK `presentationId`; its `requestId` identifies only the operation.
481
+ Default `reuse: "exact"` focuses the matching target in the destination workspace. A distinct route
482
+ retains another frame; `reuse: "new"` creates another even for the same route. Rails have one item
483
+ per declaration and an instance switcher. Replaying a closed presentation's operation does not
484
+ resurrect it; use a new request to reopen. Domain sessions remain owned by the app.
485
+
486
+ ```ts
487
+ const opened = await ui.openSurface(target, {
488
+ placement: "side", requestId: crypto.randomUUID(), reuse: "exact",
489
+ });
490
+ // A host may satisfy "side" with a dock or tool window.
491
+ ```
492
+
493
+ Hosts use `useHostyUiHost()` or `createUiHost()`:
494
+
495
+ ```ts
496
+ host.setViewChrome("plans-window-1", { navigation: "show", tools: { launcher: "hide" } });
497
+ host.setPlacementResolver(({ source, target, presentations }) => ({ workspaceId: "shared" }));
498
+ host.focusPresentation(presentationId);
499
+ host.collapseWorkspace("shared");
500
+ await host.movePresentation(presentationId, "floating");
501
+ host.closePresentation(presentationId); // Does not delete the conversation.
502
+ ```
503
+
504
+ Register destinations before their views. React's `<HostyPanelWorkspace workspaceId="shared">`
505
+ implements the renderer; `panelLayout="slot"` lets the host place multiple named workspaces.
506
+ `layout="dock"` keeps dock geometry inside an independent tool window. The default `responsive`
507
+ layout switches to a drawer in a narrow region. Keep populated workspaces mounted until their
508
+ presentations are moved or explicitly closed.
509
+
510
+ Non-React hosts call `registerWorkspace({ workspaceId, renderer })`. A renderer implements async
511
+ `present(instance, signal)` and `showToolPicker(signal)` returning `accepted | unavailable`, plus
512
+ `focus(id)`, `collapse()` and `release(id)`. Acceptance requires an attached visible destination,
513
+ not a promise of later UI. Matching `frameLayer` object identities allow movement; the renderer
514
+ must preserve iframe DOM parent and browsing context, changing geometry instead of reparenting.
515
+ The supplied React renderer does this. A failed move leaves the original presentation in place.
516
+
517
+ A placement resolver can nominate `presentationId` together with its actual workspace, but only
518
+ an exact resolved target is accepted. Requests are ordered per destination before asynchronous
519
+ resolution; a newer selection or collapse supersedes older work. Closing an app view cancels its
520
+ unaccepted requests and preserves accepted shared presentations. All browser IDs and ledgers are
521
+ local to the document; Core stores no browser workspace/session registry.
522
+
523
+ ### Compatibility and migration
524
+
525
+ Both peers negotiate `ui.composition.v1` inside the existing verified wire-v1 handshake. New/old
526
+ peers keep legacy openings without new options. Explicit reuse, remote navigation or picker calls
527
+ to old hosts return `unsupported_composition`; the SDK does not silently discard those options.
528
+ Host loss preserves stale policy and exposes reconnect/separate recovery, without taking local
529
+ ownership. Silent session renewal preserves same-actor frames and drafts.
530
+
531
+ Remove `hosty-shell-chrome`/`data-hosty-launch` visibility CSS when adopting navigation boundaries.
532
+ Keep launch/auth exports for older hosts and native transport. Native clients send explicit hidden
533
+ navigation/launcher and disabled tools until they provide a panel host. `panelLayout="none"` is a
534
+ rendering choice, not a disable policy: explicitly disable local tools or register a renderer.
535
+ See [UI composition and browser acceptance](../../docs/features/sdk-ui-composition/feature.md).
@@ -0,0 +1,16 @@
1
+ import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
2
+ /** An explicit Core delegation reference. Keep this client and its credentials on the server. */
3
+ export declare class BackgroundGrantClient {
4
+ private readonly grantId;
5
+ private readonly scopeId;
6
+ private credential?;
7
+ private pending?;
8
+ private readonly origin;
9
+ private readonly appId;
10
+ private readonly service;
11
+ constructor(grantId: string, scopeId: string, options?: ProviderServerOptions);
12
+ /** Coalesces issuance; each protected dispatch still validates the delegation online in Core. */
13
+ token(): Promise<string>;
14
+ /** Sends one operation. No automatic replay of a possibly completed mutation. */
15
+ core(path: string, init?: RequestInit): Promise<Response>;
16
+ }
@@ -0,0 +1,52 @@
1
+ import { ProviderError } from "@hosty-sdk/app/providers";
2
+ /** An explicit Core delegation reference. Keep this client and its credentials on the server. */
3
+ export class BackgroundGrantClient {
4
+ grantId;
5
+ scopeId;
6
+ credential;
7
+ pending;
8
+ origin;
9
+ appId;
10
+ service;
11
+ constructor(grantId, scopeId, options = {}) {
12
+ this.grantId = grantId;
13
+ this.scopeId = scopeId;
14
+ this.origin = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
15
+ this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
16
+ this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
17
+ if (!this.appId || !this.service || !grantId || !scopeId)
18
+ throw new Error("An app service identity and explicit background grant/scope references are required.");
19
+ }
20
+ /** Coalesces issuance; each protected dispatch still validates the delegation online in Core. */
21
+ token() {
22
+ if (this.credential && Date.parse(this.credential.expiresAt) > Date.now() + 30_000)
23
+ return Promise.resolve(this.credential.accessToken);
24
+ if (this.pending)
25
+ return this.pending;
26
+ this.pending = (async () => {
27
+ const response = await fetch(new URL(`/api/internal/apps/${encodeURIComponent(this.appId)}/background/token`, this.origin), {
28
+ method: "POST", redirect: "error", cache: "no-store", signal: AbortSignal.timeout(10_000),
29
+ headers: { authorization: `Bearer ${this.service}`, "content-type": "application/json" },
30
+ body: JSON.stringify({ grantId: this.grantId, scopeId: this.scopeId }),
31
+ });
32
+ const body = await response.json();
33
+ if (!response.ok)
34
+ throw new ProviderError(body.code ?? "background_denied", body.message ?? "Background access is unavailable.", response.status);
35
+ if (typeof body.accessToken !== "string" || !Number.isFinite(Date.parse(body.expiresAt)))
36
+ throw new Error("Invalid background credential response.");
37
+ this.credential = body;
38
+ return body.accessToken;
39
+ })().finally(() => { this.pending = undefined; });
40
+ return this.pending;
41
+ }
42
+ /** Sends one operation. No automatic replay of a possibly completed mutation. */
43
+ async core(path, init = {}) {
44
+ const url = new URL(path, this.origin);
45
+ if (url.origin !== this.origin.origin || !url.pathname.startsWith("/api/apps/") || url.username || url.password)
46
+ throw new Error("Use a Core app operation path.");
47
+ const headers = new Headers(init.headers);
48
+ headers.set("authorization", `Bearer ${this.service}`);
49
+ headers.set("X-Hosty-App-Identity", await this.token());
50
+ return fetch(url, { ...init, headers, redirect: "error", cache: "no-store" });
51
+ }
52
+ }
@@ -1,3 +1,5 @@
1
+ export { APP_CONTINUITY_UPDATED } from "@hosty-sdk/app/continuity/browser";
2
+ export { readAppContinuity, type AppContinuity } from "@hosty-sdk/app/continuity";
1
3
  import type { AppAuthProtocol } from "./app-code.js";
2
4
  export type AppSignInResult = {
3
5
  code: string;
@@ -1,3 +1,6 @@
1
+ import { ensureAppContinuity } from "@hosty-sdk/app/continuity/browser";
2
+ export { APP_CONTINUITY_UPDATED } from "@hosty-sdk/app/continuity/browser";
3
+ export { readAppContinuity } from "@hosty-sdk/app/continuity";
1
4
  import { sha256 } from "@noble/hashes/sha2.js";
2
5
  function isValidCodeVerifier(value) {
3
6
  return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value);
@@ -270,6 +273,8 @@ export async function appFetch(input, init = {}, recover = true) {
270
273
  // SDK identity/protocol probes use recover=false so that validation itself can proceed.
271
274
  if (recover && renewal && activityConfig?.validateSession && !await waitForActivity(init.signal))
272
275
  return Response.json({ code: "reauth_required", message: "Hosty access has not been restored." }, { status: 401 });
276
+ if (recover && !await ensureAppContinuity())
277
+ return Response.json({ code: "session_unavailable", message: "Hosty access could not be verified." }, { status: 503 });
273
278
  const current = grant?.origin === url.origin ? grant : null;
274
279
  const headers = new Headers(init.headers);
275
280
  if (current)
@@ -0,0 +1,11 @@
1
+ import type { AppContinuity } from "./continuity.js";
2
+ export declare const APP_CONTINUITY_UPDATED = "hosty:app-continuity-updated";
3
+ type Owner = {
4
+ coreOrigin: string;
5
+ authority: AppContinuity;
6
+ probe: () => Promise<boolean>;
7
+ };
8
+ /** One coordinator per app document; probes bypass appFetch's maintenance to avoid recursion. */
9
+ export declare function ensureAppContinuity(force?: boolean): Promise<boolean>;
10
+ export declare function configureAppContinuity(config: Owner): () => void;
11
+ export {};
@@ -0,0 +1,92 @@
1
+ export const APP_CONTINUITY_UPDATED = "hosty:app-continuity-updated";
2
+ let owner = null;
3
+ let pending = null;
4
+ let checkedAt = 0;
5
+ let valid = true;
6
+ /** One coordinator per app document; probes bypass appFetch's maintenance to avoid recursion. */
7
+ export async function ensureAppContinuity(force = false) {
8
+ if (!owner)
9
+ return true;
10
+ if (pending)
11
+ return pending;
12
+ if (!force && Date.now() - checkedAt < Math.min(30_000, owner.authority.checkAfterSeconds * 1000))
13
+ return valid;
14
+ const current = owner;
15
+ pending = (async () => {
16
+ // Browser policy may refuse the frame. Only the app's authoritative probe decides access.
17
+ if (current.authority.mode === "browser")
18
+ await maintainParent(current);
19
+ const result = await current.probe().catch(() => false);
20
+ if (owner !== current)
21
+ return false;
22
+ valid = result;
23
+ checkedAt = Date.now();
24
+ if (result)
25
+ window.dispatchEvent(new Event(APP_CONTINUITY_UPDATED));
26
+ return result;
27
+ })().finally(() => { pending = null; });
28
+ return pending;
29
+ }
30
+ export function configureAppContinuity(config) {
31
+ owner = config;
32
+ checkedAt = 0;
33
+ valid = true;
34
+ let disposed = false;
35
+ let failures = 0;
36
+ let scheduleVersion = 0;
37
+ let timer;
38
+ const schedule = async (force = false) => {
39
+ const version = ++scheduleVersion;
40
+ clearTimeout(timer);
41
+ const ok = await ensureAppContinuity(force);
42
+ // Events can share one pending probe; only the latest waiter installs its next timer.
43
+ if (disposed || version !== scheduleVersion)
44
+ return;
45
+ failures = ok ? 0 : Math.min(4, failures + 1);
46
+ const seconds = ok ? config.authority.checkAfterSeconds : Math.min(300, 15 * 2 ** failures);
47
+ timer = setTimeout(() => { void schedule(true); }, seconds * 1000 * (0.9 + Math.random() * 0.2));
48
+ };
49
+ const wake = () => { void schedule(); };
50
+ const online = () => { void schedule(true); };
51
+ window.addEventListener("focus", wake);
52
+ window.addEventListener("online", online);
53
+ document.addEventListener("visibilitychange", wake);
54
+ void schedule(true);
55
+ return () => {
56
+ disposed = true;
57
+ clearTimeout(timer);
58
+ window.removeEventListener("focus", wake);
59
+ window.removeEventListener("online", online);
60
+ document.removeEventListener("visibilitychange", wake);
61
+ if (owner === config) {
62
+ owner = null;
63
+ checkedAt = 0;
64
+ }
65
+ };
66
+ }
67
+ function maintainParent(config) {
68
+ return new Promise(resolve => {
69
+ const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
70
+ const url = new URL("/api/auth/apps/continuity/frame", config.coreOrigin);
71
+ url.searchParams.set("grantId", config.authority.grantId);
72
+ url.searchParams.set("origin", window.location.origin);
73
+ url.searchParams.set("state", state);
74
+ const frame = document.createElement("iframe");
75
+ frame.hidden = true;
76
+ frame.title = "Hosty session maintenance";
77
+ frame.referrerPolicy = "no-referrer";
78
+ const done = () => { clearTimeout(timer); window.removeEventListener("message", received); frame.remove(); resolve(); };
79
+ const received = (event) => {
80
+ if (event.source === frame.contentWindow && event.origin === url.origin &&
81
+ event.data?.type === "hosty:continuity-result" && event.data.state === state &&
82
+ ["maintained", "unavailable"].includes(event.data.status))
83
+ done();
84
+ };
85
+ const timer = setTimeout(done, 4_000);
86
+ window.addEventListener("message", received);
87
+ frame.onload = () => frame.contentWindow?.postMessage({ type: "hosty:continuity-check", state }, url.origin);
88
+ frame.onerror = done;
89
+ frame.src = url.href;
90
+ document.body.append(frame);
91
+ });
92
+ }
@@ -0,0 +1,9 @@
1
+ /** Server-advertised authority, never inferred from embedding or a missing activity deadline. */
2
+ export type AppContinuity = {
3
+ version: 1;
4
+ mode: "browser" | "native";
5
+ grantId: string;
6
+ expiresAt: string;
7
+ checkAfterSeconds: number;
8
+ };
9
+ export declare function readAppContinuity(value: unknown): AppContinuity | undefined;
@@ -0,0 +1,9 @@
1
+ export function readAppContinuity(value) {
2
+ const v = value;
3
+ if (v?.version !== 1 || !["browser", "native"].includes(v.mode ?? "") ||
4
+ typeof v.grantId !== "string" || !v.grantId || v.grantId.length > 128 ||
5
+ typeof v.expiresAt !== "string" || !Number.isFinite(Date.parse(v.expiresAt)) ||
6
+ typeof v.checkAfterSeconds !== "number" || !Number.isFinite(v.checkAfterSeconds) || v.checkAfterSeconds <= 0)
7
+ return;
8
+ return { ...v, checkAfterSeconds: Math.min(300, v.checkAfterSeconds) };
9
+ }
package/dist/index.d.ts CHANGED
@@ -106,7 +106,7 @@ export interface PageLocation {
106
106
  */
107
107
  export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId: string, location: PageLocation): string | null;
108
108
  /**
109
- * How a shell is presenting this app.
109
+ * Legacy launch/transport context. Navigation visibility uses the independent composition policy.
110
110
  *
111
111
  * - `embedded` — framed by the browser Shell. The shell renders navigation for this app. The
112
112
  * app restores its per-tab grant, tries a silent Core redirect on first load, then uses its
@@ -117,19 +117,19 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
117
117
  * - `standalone` — a plain browser tab on the app's own origin. Nothing else renders navigation
118
118
  * for this app, so it keeps its own.
119
119
  *
120
- * Two independent decisions read this one value and they do not split it the same way: chrome
121
- * is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while frame-local silent
122
- * sign-in and popup recovery belong to `embedded` alone (`AppIdentityBridge`). A native web
123
- * view keeps standalone recovery even though its app chrome is hidden.
120
+ * Legacy consumers can still use `hidesAppChrome`. New consumers use `useHostyChrome` and
121
+ * `HostyNavigationBoundary`; a desktop host can keep navigation visible in embedded apps.
122
+ * Authentication continues to use the actual transport environment.
124
123
  */
125
124
  export type AppLaunchMode = "embedded" | "native" | "standalone";
126
125
  /** The query parameter a shell appends to the launch URL to declare the mode. */
127
126
  export declare const LAUNCH_MODE_PARAM = "hosty_launch";
128
127
  /** Where a resolved mode is kept for the life of the tab or web view. */
129
128
  export declare const LAUNCH_MODE_STORAGE_KEY = "hosty.launch.mode";
130
- /** The root attribute the launch bridge writes; CSS hides duplicated chrome off it. */
129
+ /** Legacy launch attribute. Use data-hosty-navigation for composition visibility. */
131
130
  export declare const LAUNCH_MODE_ATTRIBUTE = "data-hosty-launch";
132
131
  /**
132
+ * @deprecated Use HostyNavigationBoundary or the effective navigation policy.
133
133
  * Marks an element that duplicates chrome a shell already renders — the app's own name and the
134
134
  * navigation between its manifest pages. Contextual controls and information a shell does not
135
135
  * render (a project picker, a refresh action, an identity badge) are not chrome in this sense
@@ -164,6 +164,7 @@ export declare function resolveLaunchMode(input: {
164
164
  heuristic: AppLaunchMode;
165
165
  }): LaunchModeResolution;
166
166
  /**
167
+ * @deprecated Use the effective composition navigation policy instead.
167
168
  * True when a shell around this app already renders its name and page navigation, so the app's
168
169
  * own copies are duplication. False for `standalone`, where nothing else renders them.
169
170
  */
@@ -203,3 +204,5 @@ export declare function decideRecoveryAction(input: {
203
204
  openUrl: string | null;
204
205
  redirectAlreadyAttempted: boolean;
205
206
  }): RecoveryAction;
207
+ /** Paint-time navigation policy. Kept in the pure entry point for Node and edge consumers. */
208
+ export declare function chromeBootstrapScript(defaults?: import("./ui-types").UiChromeOverrides): string;
package/dist/index.js CHANGED
@@ -142,9 +142,10 @@ export function buildCoreOpenUrl(corePublicOrigin, appId, location) {
142
142
  export const LAUNCH_MODE_PARAM = "hosty_launch";
143
143
  /** Where a resolved mode is kept for the life of the tab or web view. */
144
144
  export const LAUNCH_MODE_STORAGE_KEY = "hosty.launch.mode";
145
- /** The root attribute the launch bridge writes; CSS hides duplicated chrome off it. */
145
+ /** Legacy launch attribute. Use data-hosty-navigation for composition visibility. */
146
146
  export const LAUNCH_MODE_ATTRIBUTE = "data-hosty-launch";
147
147
  /**
148
+ * @deprecated Use HostyNavigationBoundary or the effective navigation policy.
148
149
  * Marks an element that duplicates chrome a shell already renders — the app's own name and the
149
150
  * navigation between its manifest pages. Contextual controls and information a shell does not
150
151
  * render (a project picker, a refresh action, an identity badge) are not chrome in this sense
@@ -188,6 +189,7 @@ export function resolveLaunchMode(input) {
188
189
  return { mode: stored ?? input.heuristic, fromParam: false };
189
190
  }
190
191
  /**
192
+ * @deprecated Use the effective composition navigation policy instead.
191
193
  * True when a shell around this app already renders its name and page navigation, so the app's
192
194
  * own copies are duplication. False for `standalone`, where nothing else renders them.
193
195
  */
@@ -260,3 +262,10 @@ export function decideRecoveryAction(input) {
260
262
  }
261
263
  return { kind: "card", card: "signin" };
262
264
  }
265
+ /** Paint-time navigation policy. Kept in the pure entry point for Node and edge consumers. */
266
+ export function chromeBootstrapScript(defaults = {}) {
267
+ const navigation = defaults.navigation;
268
+ if (navigation !== undefined && navigation !== "show" && navigation !== "hide")
269
+ throw new Error("Invalid navigation default.");
270
+ return `(()=>{try{const d=${JSON.stringify({ navigation })},p=new URLSearchParams(location.search),h=new URLSearchParams();for(const k of ["hosty_navigation","hosty_tools","hosty_tool_launcher","hosty_launch"])for(const v of p.getAll(k))h.append(k,v);document.documentElement.setAttribute('data-hosty-chrome-hints',h.toString());const one=k=>p.getAll(k).length===1?p.get(k):null,n=one('hosty_navigation'),l=one('hosty_launch');document.documentElement.setAttribute('data-hosty-navigation',n==='show'||n==='hide'?n:d.navigation??(l==='native'||l==='embedded'?'hide':'show'));}catch{}})();`;
271
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { HostyOverlay } from "./overlay-react.js";
2
+ /** Next routing stays an optional adapter; the composition model does not patch browser history. */
3
+ export declare function HostyNextNavigationBridge(): null;
4
+ export declare function HostyNextOverlay({ children, ...props }: Parameters<typeof HostyOverlay>[0]): import("react").JSX.Element;
package/dist/next.js ADDED
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Suspense, useContext, useEffect, useMemo, useRef } from "react";
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
+ import { HostyOverlay } from "./overlay-react.js";
6
+ import { UiCompositionContext } from "./ui-context.js";
7
+ /** Next routing stays an optional adapter; the composition model does not patch browser history. */
8
+ export function HostyNextNavigationBridge() {
9
+ const model = useContext(UiCompositionContext), router = useRouter(), pathname = usePathname(), search = useSearchParams();
10
+ const current = useRef(router);
11
+ current.current = router;
12
+ const listeners = useRef(new Set());
13
+ const adapter = useMemo(() => ({
14
+ getRoute: () => `${window.location.pathname}${window.location.search}${window.location.hash}`,
15
+ subscribe(listener) {
16
+ listeners.current.add(listener);
17
+ window.addEventListener("hashchange", listener);
18
+ return () => { listeners.current.delete(listener); window.removeEventListener("hashchange", listener); };
19
+ },
20
+ navigate(route) { current.current.push(route); },
21
+ }), []);
22
+ useEffect(() => { model?.attachRouter(adapter); return () => model?.attachRouter(); }, [model, adapter]);
23
+ useEffect(() => { for (const listener of listeners.current)
24
+ listener(); }, [pathname, search]);
25
+ return null;
26
+ }
27
+ export function HostyNextOverlay({ children, ...props }) {
28
+ return _jsxs(HostyOverlay, { ...props, children: [_jsx(Suspense, { fallback: null, children: _jsx(HostyNextNavigationBridge, {}) }), children] });
29
+ }
@@ -1,6 +1,14 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { UiChromeOverrides, UiRouterAdapter } from "./ui-types.js";
2
3
  /** Mount once around protected content. Presentation and recovery belong to Hosty. */
3
- export declare function HostyOverlay({ children, probePath, appCodePath, uiPath, panelLayout }: {
4
+ export declare function HostyOverlay({ children, probePath, appCodePath, uiPath, panelLayout, localContent, chrome, navigation }: {
5
+ /** Explicitly public/local content available during an outage; never put protected actor data here. */
6
+ localContent?: ReactNode;
7
+ chrome?: UiChromeOverrides;
8
+ navigation?: {
9
+ renderer: "standard" | "custom";
10
+ router?: UiRouterAdapter;
11
+ };
4
12
  children: ReactNode;
5
13
  probePath?: string;
6
14
  appCodePath?: string;