@hosty-sdk/app 0.21.2 → 0.22.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
@@ -11,37 +11,75 @@ 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/react` | client | `<AppIdentityBridge />` — probe, Core popup/navigation recovery, content gate |
14
+ | `@hosty-sdk/app/react` | client | `<HostyOverlay />` — shared loading, identity, required setup and recovery UI; legacy bridges remain available |
15
+ | `@hosty-sdk/app/session/server` | server | Framework-neutral session response adapter for authenticated Node servers |
15
16
  | `@hosty-sdk/app/embedder` | client | theme sender and legacy embedder message parsers; Shell no longer mints app credentials |
16
17
  | `@hosty-sdk/app/browser-auth` | client | `appFetch` for app-local API requests and bound Core popup sign-in |
17
18
  | `@hosty-sdk/app/providers` | anywhere | permission state, provider descriptors, speech contract types |
18
19
  | `@hosty-sdk/app/providers/server` | server only | `ProviderClient`: discovery, speech recognition, assistant handoffs and live credential validation |
19
20
  | `@hosty-sdk/app/theme` | anywhere | the shell→app theme protocol: constants, `resolveTheme`, `applyTheme`, `parseShellThemeMessage`, `themeBootstrapScript` / `createThemeBootstrapScript` |
20
21
 
21
- Minimal Next.js wiring:
22
+ Minimal Next.js wiring (the configuration object stays server-side):
22
23
 
23
24
  ```tsx
24
- // app/layout.tsx
25
- import { AppIdentityBridge } from "@hosty-sdk/app/react";
26
- // wrap protected client content: <AppIdentityBridge>{children}</AppIdentityBridge>
25
+ // app/layout.tsx — inside the body and the existing theme/launch providers
26
+ import { HostyOverlay } from "@hosty-sdk/app/react";
27
+ // <HostyOverlay>{children}</HostyOverlay>
27
28
 
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.
29
+ // lib/hosty.ts
30
+ export const hostyConfig = {
31
+ appIdFallback: "com.example.my-app",
32
+ identityCookieName: "my_app_hosty_identity",
33
+ };
34
34
 
35
- // app/api/auth/app-code/route.ts
36
- import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
35
+ // app/api/hosty/session/route.ts
36
+ import { createHostySessionRouteHandler } from "@hosty-sdk/app/server";
37
+ import { hostyConfig } from "@/lib/hosty";
37
38
  export const dynamic = "force-dynamic";
38
39
  export const runtime = "nodejs";
39
- export const POST = createAppCodeRouteHandler({
40
- appIdFallback: "com.example.my-app",
41
- identityCookieName: "my_app_hosty_identity",
42
- });
40
+ export const GET = createHostySessionRouteHandler(hostyConfig);
41
+ // Administrator-only app: pass { administratorOnly: true } as the second argument.
42
+
43
+ // app/api/auth/app-code/route.ts
44
+ import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
45
+ import { hostyConfig } from "@/lib/hosty";
46
+ export const POST = createAppCodeRouteHandler(hostyConfig);
43
47
  ```
44
48
 
49
+ Replace the protected scope's `AppIdentityBridge`, `AppActivityBridge`, custom sign-in rendering
50
+ 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.
53
+ API routes and server-rendered data still enforce their own authorization. The overlay delays
54
+ protected client mounting; it is not a server data-access boundary.
55
+
56
+ The session adapter resolves identity and checks the app's declared required permissions together.
57
+ Only an administrator receives permission details and a Core review action; a regular user sees an
58
+ administrator-setup explanation without buttons. Optional permissions do not block the app.
59
+ A missing setup capability on Core displays a compatibility state instead of exposing content.
60
+ Keep service credentials on the server and use `appFetch` for app-local browser requests.
61
+
62
+ For a plain Node server, call `createHostySessionResponse(validatedSession, recovery, options)` from
63
+ `@hosty-sdk/app/session/server` and forward its response. Pass a server-validated session, never an
64
+ identity supplied by the request body. Harness is the in-repository example. Shell retains its
65
+ HttpOnly-only code exchange; the shared coordinator also accepts a successful exchange without a
66
+ browser-readable token.
67
+
68
+ After first mount, blocked content and app portals become hidden and inert while the component tree
69
+ remains mounted. Same-user renewal restores it. A different stable user ID reloads the app document
70
+ and refuses to replay old pending requests. `APP_SESSION_RESTORED` from `/browser-auth` is an optional
71
+ 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.
74
+
75
+ Compatible SDK updates change standard presentation and recovery after a dependency update and
76
+ rebuild, without editing this wiring. New mandatory Core capabilities can require upgrading Core.
77
+ `node packages/app-sdk/scripts/check-overlay-upgrade.mjs` checks the published export layout using
78
+ identical consumer source before and after a dependency-only presentation revision (run SDK build first).
79
+
80
+ Legacy `AppIdentityBridge` exports, `renderState` and `onSession` remain compatible for consumers
81
+ that have not migrated. See [Hosty Overlay](../../docs/features/hosty-overlay/feature.md) for behavior.
82
+
45
83
  Following the shell's theme — the launch parameters decide the theme a document loads with, the
46
84
  `hosty:shell-theme` post covers changes while the frame is up, and the bootstrap keeps the first
47
85
  paint right:
@@ -1,6 +1,6 @@
1
1
  import type { AppAuthProtocol } from "./app-code.js";
2
2
  /** Keep mounted beside app content so expiry never discards drafts or component state. */
3
- export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired, appAuthProtocol, probePath, native }: {
3
+ export type AppActivityOptions = {
4
4
  openUrl: string;
5
5
  appCodePath: string;
6
6
  activeUntil?: string | null;
@@ -8,4 +8,13 @@ export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, a
8
8
  appAuthProtocol?: AppAuthProtocol | null;
9
9
  probePath?: string;
10
10
  native?: boolean;
11
- }): import("react").JSX.Element | null;
11
+ validateSession?: () => Promise<boolean>;
12
+ };
13
+ export declare function useAppActivity(options: AppActivityOptions | null): {
14
+ needed: boolean;
15
+ pending: boolean;
16
+ error: string;
17
+ renew: () => void;
18
+ cancel: () => void;
19
+ };
20
+ export declare function AppActivityBridge(options: AppActivityOptions): import("react").JSX.Element | null;
@@ -5,8 +5,8 @@ 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
6
  import { resolveLaunchMode, detectLaunchMode, normalizeLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY } from "./index.js";
7
7
  import { acceptAppAuthProtocol, refreshAppAuthProtocol } from "./browser-auth.js";
8
- /** Keep mounted beside app content so expiry never discards drafts or component state. */
9
- export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired = false, appAuthProtocol, probePath = "/api/auth/identity", native }) {
8
+ export function useAppActivity(options) {
9
+ const { openUrl, appCodePath = "/api/auth/app-code", activeUntil, activityRequired = false, appAuthProtocol, probePath = "/api/auth/identity", native, validateSession } = options ?? {};
10
10
  const [needed, setNeeded] = useState(false);
11
11
  const [pending, setPending] = useState(false);
12
12
  const [error, setError] = useState("");
@@ -17,6 +17,8 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
17
17
  void operation.catch(e => { setNeeded(true); setError(String(e.message ?? e)); }).finally(() => setPending(false));
18
18
  };
19
19
  useEffect(() => {
20
+ if (!openUrl)
21
+ return;
20
22
  let cancelled = false;
21
23
  let cleanup;
22
24
  const controller = new AbortController();
@@ -31,7 +33,7 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
31
33
  const mode = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE)) ?? resolveLaunchMode({
32
34
  param: new URL(window.location.href).searchParams.get(LAUNCH_MODE_PARAM), stored, heuristic: detectLaunchMode(window),
33
35
  }).mode;
34
- cleanup = configureAppActivity({ openUrl, appAuthProtocol: protocol, probePath, native: native ?? mode === "native", activeUntil, activityRequired, exchangeCode: async (proof) => {
36
+ cleanup = configureAppActivity({ openUrl, appAuthProtocol: protocol, probePath, native: native ?? mode === "native", activeUntil, activityRequired, validateSession, exchangeCode: async (proof) => {
35
37
  const response = await fetch(appCodePath, { method: "POST", credentials: "same-origin", redirect: "error",
36
38
  headers: { "Content-Type": "application/json" }, body: JSON.stringify(proof) });
37
39
  const body = await response.json();
@@ -59,9 +61,13 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
59
61
  window.addEventListener(APP_ACTIVITY_RENEWED, renewed);
60
62
  document.addEventListener("click", click, true);
61
63
  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]);
64
+ }, [openUrl, appCodePath, appAuthProtocol, probePath, native, validateSession]);
63
65
  useEffect(() => { updateAppActivity(activeUntil, activityRequired); }, [activeUntil, activityRequired, openUrl, appCodePath]);
66
+ return { needed, pending, error, renew, cancel: () => { cancelActivityRenewal(); setNeeded(false); } };
67
+ }
68
+ export function AppActivityBridge(options) {
69
+ const { needed, pending, error, renew, cancel } = useAppActivity(options);
64
70
  if (!needed)
65
71
  return null;
66
- return _jsxs(AuthNotice, { children: [_jsx("span", { children: error || "Renew access through Core to continue. Your work stays on this page." }), _jsx(AuthNoticeButton, { disabled: pending, onClick: renew, children: pending ? "Waiting for Core…" : "Renew access" }), _jsx(AuthNoticeButton, { secondary: true, onClick: () => { cancelActivityRenewal(); setNeeded(false); }, children: "Later" })] });
72
+ return _jsxs(AuthNotice, { children: [_jsx("span", { children: error || "Renew access through Core to continue. Your work stays on this page." }), _jsx(AuthNoticeButton, { disabled: pending, onClick: renew, children: pending ? "Waiting for Core…" : "Renew access" }), _jsx(AuthNoticeButton, { secondary: true, onClick: cancel, children: "Later" })] });
67
73
  }
@@ -45,6 +45,8 @@ export declare function refreshAppAuthProtocol(openUrl: string, probePath?: stri
45
45
  /** Must be called directly from a user gesture. Native hosts intercept before any request. */
46
46
  export declare function openAppSignIn(openUrl: string, signal?: AbortSignal, appAuthProtocol?: AppAuthProtocol, native?: boolean, probePath?: string): Promise<AppSignInResult>;
47
47
  export declare const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
48
+ /** Validated restoration of the same actor, before the app resumes protected work. */
49
+ export declare const APP_SESSION_RESTORED = "hosty:app-session-restored";
48
50
  export type AppActivityConfig = {
49
51
  openUrl: string;
50
52
  appAuthProtocol: AppAuthProtocol;
@@ -56,6 +58,8 @@ export type AppActivityConfig = {
56
58
  accessToken?: string;
57
59
  activeUntil?: string | null;
58
60
  }>;
61
+ /** The root coordinator verifies actor and setup before any waiting request can retry. */
62
+ validateSession?: () => Promise<boolean>;
59
63
  };
60
64
  export declare function configureAppActivity(config: AppActivityConfig): () => void;
61
65
  export declare function updateAppActivity(activeUntil?: string | null, activityRequired?: boolean): void;
@@ -265,6 +265,11 @@ export async function appFetch(input, init = {}, recover = true) {
265
265
  const url = new URL(input, window.location.href);
266
266
  if (url.origin !== window.location.origin || url.username || url.password)
267
267
  throw new Error("App API requests must stay on this app's origin.");
268
+ // Mounted app effects can still run behind the overlay. Do not send their requests with
269
+ // the newly exchanged credential until the coordinator has checked the actor and setup.
270
+ // SDK identity/protocol probes use recover=false so that validation itself can proceed.
271
+ if (recover && renewal && activityConfig?.validateSession && !await waitForActivity(init.signal))
272
+ return Response.json({ code: "reauth_required", message: "Hosty access has not been restored." }, { status: 401 });
268
273
  const current = grant?.origin === url.origin ? grant : null;
269
274
  const headers = new Headers(init.headers);
270
275
  if (current)
@@ -521,6 +526,8 @@ function openNativeAppSignIn(attempt, signal) {
521
526
  });
522
527
  }
523
528
  export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
529
+ /** Validated restoration of the same actor, before the app resumes protected work. */
530
+ export const APP_SESSION_RESTORED = "hosty:app-session-restored";
524
531
  let activityConfig = null;
525
532
  let renewal = null;
526
533
  let renewalController = null;
@@ -571,6 +578,10 @@ export function renewAppActivity() {
571
578
  if (result.accessToken)
572
579
  rememberAppGrant(result.accessToken);
573
580
  config.activeUntil = result.activeUntil;
581
+ if (config.validateSession && !await config.validateSession())
582
+ throw new Error("Access has not been restored. Complete the Hosty setup before continuing.");
583
+ if (controller.signal.aborted || activityConfig !== config)
584
+ throw new Error("Sign-in was cancelled.");
574
585
  recoveryPending = false;
575
586
  for (const done of [...waiting])
576
587
  done(true);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { AppSessionStatus, AppSessionFailureStatus, SessionRecoveryParams } from "./session-types.js";
2
+ export type { AppSessionStatus, AppSessionFailureStatus, SessionRecoveryParams } from "./session-types.js";
1
3
  /**
2
4
  * The postMessage type for an embedded app's authentication-required intent.
3
5
  * A frozen protocol constant: it deliberately does not track product branding (precedent:
@@ -71,22 +73,6 @@ export interface DelegatedTokenGrant {
71
73
  export declare function createDelegatedTokenRequest(options?: {
72
74
  refresh?: boolean;
73
75
  }): DelegatedTokenRequest;
74
- /**
75
- * Recovery classification of an app session, per the platform identity error contract:
76
- * - `not-present`: no token at all (recoverable — same handling as `expired`).
77
- * - `active`: token revalidated OK.
78
- * - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
79
- * (standalone).
80
- * - `forbidden`: Core 403 or a token minted for a different app — terminal; never
81
- * auto-redirect (it would loop).
82
- * - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
83
- * cookie and offer a retry.
84
- * - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
85
- * operator problem; signing in cannot fix it, so never offer a login.
86
- */
87
- export type AppSessionStatus = "not-present" | "active" | "expired" | "forbidden" | "unavailable" | "misconfigured";
88
- /** Failure statuses (everything but `active`). */
89
- export type AppSessionFailureStatus = Exclude<AppSessionStatus, "active">;
90
76
  /**
91
77
  * Maps a Core revalidation HTTP status onto the session classification. Classification is
92
78
  * by status code only — never by error-code strings — so new Core codes cannot break an
@@ -99,11 +85,6 @@ export declare function classifyRevalidationHttpStatus(status: number): AppSessi
99
85
  * prerendered at image build time, where the HOSTY_* environment does not exist yet
100
86
  * (docker-host PR #233 / media-server PR #63). Neither value is secret.
101
87
  */
102
- export interface SessionRecoveryParams {
103
- appId: string | null;
104
- corePublicOrigin: string | null;
105
- appAuthProtocol: 1 | 2 | null;
106
- }
107
88
  export declare function readRecoveryParams(body: unknown): SessionRecoveryParams;
108
89
  /** True for hosts that only resolve on the machine itself. */
109
90
  export declare function isLoopbackHost(hostname: string): boolean;
package/dist/index.js CHANGED
@@ -86,6 +86,12 @@ export function classifyRevalidationHttpStatus(status) {
86
86
  }
87
87
  return "unavailable";
88
88
  }
89
+ /**
90
+ * Recovery parameters an app's force-dynamic identity/session endpoint hands the browser.
91
+ * They ride in a response — never in a server-component prop — because app pages are
92
+ * prerendered at image build time, where the HOSTY_* environment does not exist yet
93
+ * (docker-host PR #233 / media-server PR #63). Neither value is secret.
94
+ */
89
95
  export function readRecoveryParams(body) {
90
96
  const recovery = body && typeof body === "object" ? body.recovery : null;
91
97
  if (!recovery || typeof recovery !== "object") {
@@ -0,0 +1,7 @@
1
+ import { type ReactNode } from "react";
2
+ /** Mount once around protected content. Presentation and recovery belong to Hosty. */
3
+ export declare function HostyOverlay({ children, probePath, appCodePath }: {
4
+ children: ReactNode;
5
+ probePath?: string;
6
+ appCodePath?: string;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,252 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { useAppIdentity } from "./react.js";
6
+ import { useAppActivity } from "./activity-react.js";
7
+ import { APP_SESSION_ENDED, APP_SESSION_RESTORED, APP_ACTIVITY_RENEWED, acceptAppAuthProtocol } from "./browser-auth.js";
8
+ import { buildCoreOpenUrl, readRecoveryParams } from "./index.js";
9
+ import { permissionReviewUrl, requestPermissionReview } from "./permissions.js";
10
+ function snapshot(body) {
11
+ const value = body;
12
+ if (value?.status !== "active" || typeof value.userId !== "string" || !value.userId)
13
+ return null;
14
+ const setup = value.hosty?.version === 1 && ["ready", "missing", "unsupported", "unavailable", "incompatible"].includes(String(value.hosty.setup))
15
+ ? value.hosty.setup : "incompatible";
16
+ const recovery = readRecoveryParams(body);
17
+ const openUrl = recovery.appId ? buildCoreOpenUrl(recovery.corePublicOrigin, recovery.appId, window.location) : null;
18
+ // The activity policy remains authoritative even when renewal discovery fails.
19
+ return { userId: value.userId, setup, notice: value.hosty?.notice,
20
+ activityRequired: value.activityRequired === true,
21
+ activeUntil: typeof value.activeUntil === "string" ? value.activeUntil : null,
22
+ recoveryReady: !!openUrl && !!acceptAppAuthProtocol(new URL(openUrl).origin, recovery.appAuthProtocol) };
23
+ }
24
+ function activityExpired(session) {
25
+ const deadline = Date.parse(session?.activeUntil ?? "");
26
+ return !!session?.activityRequired && (!Number.isFinite(deadline) || deadline <= Date.now());
27
+ }
28
+ function sessionReady(session) {
29
+ return session?.setup === "ready" && (!session.activityRequired || session.recoveryReady) && !activityExpired(session);
30
+ }
31
+ /** Mount once around protected content. Presentation and recovery belong to Hosty. */
32
+ export function HostyOverlay({ children, probePath = "/api/hosty/session", appCodePath = "/api/auth/app-code" }) {
33
+ const actor = useRef(null);
34
+ const changingActor = useRef(false);
35
+ const [session, setSession] = useState(null);
36
+ const [reviewing, setReviewing] = useState(false);
37
+ const renewedAwaitingReadiness = useRef(false);
38
+ const accept = (body) => {
39
+ const next = snapshot(body);
40
+ if (next && actor.current && actor.current !== next.userId) {
41
+ changingActor.current = true;
42
+ window.location.reload();
43
+ }
44
+ if (next && !actor.current)
45
+ actor.current = next.userId;
46
+ setSession(next);
47
+ };
48
+ const identity = useAppIdentity({ probePath, appCodePath, onSession: accept });
49
+ const refreshRef = useRef(identity.refresh);
50
+ refreshRef.current = async () => {
51
+ const body = await identity.refresh();
52
+ const next = snapshot(body);
53
+ if (renewedAwaitingReadiness.current && next && sessionReady(next) && next.userId === actor.current && !changingActor.current) {
54
+ renewedAwaitingReadiness.current = false;
55
+ window.dispatchEvent(new CustomEvent(APP_SESSION_RESTORED, { detail: { userId: next.userId } }));
56
+ window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
57
+ }
58
+ return body;
59
+ };
60
+ const validate = useRef(async () => {
61
+ const next = snapshot(await refreshRef.current());
62
+ if (!next || changingActor.current || next.userId !== actor.current)
63
+ return false;
64
+ renewedAwaitingReadiness.current = !sessionReady(next);
65
+ if (renewedAwaitingReadiness.current)
66
+ return false;
67
+ window.dispatchEvent(new CustomEvent(APP_SESSION_RESTORED, { detail: { userId: next.userId } }));
68
+ return true;
69
+ }).current;
70
+ const activity = useAppActivity(identity.activity ? { ...identity.activity, probePath, appCodePath, validateSession: validate } : null);
71
+ const expired = activityExpired(session);
72
+ const recoveryUnavailable = session?.activityRequired && !session.recoveryReady;
73
+ const ready = identity.ui.kind === "active" && sessionReady(session) && !activity.needed && !activity.pending && !changingActor.current;
74
+ const mounted = useRef(false);
75
+ if (ready)
76
+ mounted.current = true;
77
+ useEffect(() => {
78
+ if (session?.setup === "ready")
79
+ setReviewing(false);
80
+ }, [session?.setup]);
81
+ useEffect(() => {
82
+ // No keepalive polling. Focus and a pending Core review are the only readiness refreshes.
83
+ let loading = false;
84
+ const refresh = () => {
85
+ if (loading || !actor.current || activity.pending || (activity.needed && !renewedAwaitingReadiness.current))
86
+ return;
87
+ loading = true;
88
+ void refreshRef.current().finally(() => { loading = false; });
89
+ };
90
+ window.addEventListener("focus", refresh);
91
+ const timer = reviewing ? window.setInterval(refresh, 2000) : undefined;
92
+ return () => { window.removeEventListener("focus", refresh); window.clearInterval(timer); };
93
+ }, [reviewing, activity.pending, activity.needed]);
94
+ useEffect(() => {
95
+ const metadata = session;
96
+ if (!metadata?.activityRequired || !metadata.activeUntil)
97
+ return;
98
+ const remaining = Date.parse(metadata.activeUntil) - Date.now();
99
+ if (!Number.isFinite(remaining))
100
+ return;
101
+ // Real activity windows are short. Do not expire a malformed far-future value early.
102
+ if (remaining > 2147483647)
103
+ return;
104
+ const timer = window.setTimeout(() => window.dispatchEvent(new Event(APP_SESSION_ENDED)), Math.max(0, remaining));
105
+ return () => window.clearTimeout(timer);
106
+ }, [session?.activeUntil, session?.activityRequired]);
107
+ let title = "Connecting to Hosty";
108
+ let message = "Checking your access…";
109
+ let action;
110
+ let actionLabel = "Retry";
111
+ let reviewUrl;
112
+ const pending = identity.ui.kind === "recovering" || activity.pending || changingActor.current;
113
+ if (identity.ui.kind === "signin") {
114
+ title = "Sign in to continue";
115
+ message = identity.ui.error ?? "Use your Hosty account to open this app.";
116
+ action = identity.hasBeenActive && identity.activity ? activity.renew : identity.ui.signIn;
117
+ actionLabel = "Sign in via Hosty";
118
+ if (!action)
119
+ message = "Open this app from Hosty, or ask the administrator to check its public address.";
120
+ }
121
+ else if (identity.ui.kind === "denied") {
122
+ title = "Access unavailable";
123
+ message = "Your account does not have access to this app.";
124
+ }
125
+ else if (identity.ui.kind === "misconfigured") {
126
+ title = "App setup required";
127
+ message = "This app needs configuration. Contact your administrator.";
128
+ }
129
+ else if (identity.ui.kind === "unavailable") {
130
+ title = "Cannot reach Hosty";
131
+ message = "Check the connection and try again.";
132
+ action = () => { void refreshRef.current(); };
133
+ }
134
+ else if (identity.ui.kind === "active" && session?.setup !== "ready") {
135
+ if (!session || session.setup === "incompatible" || session.setup === "unsupported") {
136
+ title = "Hosty update required";
137
+ message = "This app and Core need compatible versions. Contact your administrator.";
138
+ }
139
+ else if (session.setup === "unavailable") {
140
+ title = "Cannot check app setup";
141
+ message = "Hosty could not check required permissions. Try again.";
142
+ action = () => { void refreshRef.current(); };
143
+ }
144
+ else {
145
+ title = "App setup required";
146
+ message = "This app needs configuration by an administrator.";
147
+ const notice = session.notice;
148
+ if (notice?.hostRole === "host.admin" && notice.corePublicOrigin && notice.permissions?.reviewAvailable !== false) {
149
+ message = reviewing ? "Review the required permissions in Core, then return here." : "Review this app’s required permissions in Hosty Core.";
150
+ actionLabel = "Review permissions";
151
+ action = () => { requestPermissionReview(notice); setReviewing(true); };
152
+ if (reviewing)
153
+ reviewUrl = permissionReviewUrl(notice.corePublicOrigin, notice.appId);
154
+ }
155
+ }
156
+ }
157
+ else if (identity.ui.kind === "active" && recoveryUnavailable) {
158
+ title = "Cannot verify access renewal";
159
+ message = "Hosty could not verify how to renew this app’s access. Try again.";
160
+ action = () => { void refreshRef.current(); };
161
+ }
162
+ else if (expired || activity.needed || activity.pending) {
163
+ title = activity.pending ? "Connecting to Hosty" : "Renew access to continue";
164
+ message = activity.error || (activity.pending ? "Complete the confirmation in Core." : "Confirm your access through Hosty Core.");
165
+ action = activity.renew;
166
+ actionLabel = "Renew access";
167
+ }
168
+ return _jsxs(_Fragment, { children: [_jsx("div", { "data-hosty-content": true, hidden: !ready, inert: !ready, style: ready ? { display: "contents" } : { display: "none" }, children: mounted.current ? children : null }), !ready && _jsx(OverlaySurface, { pending: pending, title: title, message: message, action: action, actionLabel: actionLabel, reviewUrl: reviewUrl })] });
169
+ }
170
+ function OverlaySurface({ pending, title, message, action, actionLabel, reviewUrl }) {
171
+ const [portal, setPortal] = useState(null);
172
+ const [showProgress, setShowProgress] = useState(false);
173
+ const dialog = useRef(null);
174
+ useEffect(() => {
175
+ if (!pending) {
176
+ setShowProgress(false);
177
+ return;
178
+ }
179
+ const timer = window.setTimeout(() => setShowProgress(true), 180);
180
+ return () => window.clearTimeout(timer);
181
+ }, [pending]);
182
+ useLayoutEffect(() => {
183
+ const container = document.createElement("div");
184
+ container.dataset.hostyOverlayPortal = "";
185
+ const previousFocus = document.activeElement;
186
+ const original = new Map();
187
+ document.body.append(container);
188
+ document.documentElement.setAttribute("data-hosty-overlay-blocked", "");
189
+ const block = () => {
190
+ for (const element of Array.from(document.body.children)) {
191
+ if (!(element instanceof HTMLElement) || element === container || original.has(element))
192
+ continue;
193
+ original.set(element, element.inert);
194
+ element.inert = true;
195
+ }
196
+ };
197
+ block();
198
+ const observer = new MutationObserver(block);
199
+ observer.observe(document.body, { childList: true });
200
+ setPortal(container);
201
+ return () => {
202
+ observer.disconnect();
203
+ original.forEach((inert, element) => { element.inert = inert; });
204
+ document.documentElement.removeAttribute("data-hosty-overlay-blocked");
205
+ container.remove();
206
+ if (previousFocus instanceof HTMLElement && previousFocus.isConnected)
207
+ previousFocus.focus();
208
+ };
209
+ }, []);
210
+ useLayoutEffect(() => {
211
+ const element = dialog.current;
212
+ // Replayed effects can still reference the dialog in the removed portal until state catches up.
213
+ if (!element?.isConnected)
214
+ return;
215
+ if (typeof element.showModal === "function")
216
+ element.showModal();
217
+ else
218
+ element.setAttribute("open", "");
219
+ element.focus();
220
+ return () => { if (element.open && typeof element.close === "function")
221
+ element.close(); };
222
+ }, [portal]);
223
+ const content = _jsxs(_Fragment, { children: [_jsx("style", { children: styles }), _jsx("dialog", { ref: dialog, className: "hosty-overlay-dialog", "aria-label": "Hosty access", tabIndex: -1, onCancel: event => event.preventDefault(), "data-hosty-activity-control": true, children: _jsx("section", { className: "hosty-overlay-card", "aria-live": "polite", "aria-busy": pending, children: pending ? _jsx("div", { className: "hosty-overlay-loading", role: "status", "aria-label": title, children: showProgress && _jsxs(_Fragment, { children: [_jsx("span", { className: "hosty-overlay-spinner" }), _jsx("p", { children: message })] }) }) : _jsxs(_Fragment, { children: [_jsxs("span", { className: "hosty-overlay-brand", children: [_jsx("span", { className: "hosty-overlay-brand-mark", children: _jsxs("svg", { viewBox: "0 0 100 100", "aria-hidden": "true", focusable: "false", fill: "none", stroke: "currentColor", strokeWidth: 6, strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M25 36 V64 M75 36 V64 M25 50 H39 M61 50 H75" }), _jsx("rect", { x: "17", y: "17", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "67", y: "17", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "17", y: "67", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "67", y: "67", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "42", y: "42", width: "16", height: "16", rx: "4.5" })] }) }), _jsx("span", { children: "Hosty" })] }), _jsx("h1", { children: title }), _jsx("p", { children: message }), action && _jsx("button", { type: "button", onClick: action, children: actionLabel }), reviewUrl && _jsx("a", { href: reviewUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core permissions" })] }) }) })] });
224
+ return portal ? createPortal(content, portal) : _jsx("div", { className: "hosty-overlay-boot", role: "status", "aria-label": "Connecting to Hosty", children: _jsx("style", { children: styles }) });
225
+ }
226
+ const styles = `
227
+ .hosty-overlay-boot,.hosty-overlay-dialog{--hosty-bg:#f8f9fb;--hosty-fg:#20242c;--hosty-muted:#606875;--hosty-border:#e0e4eb;color-scheme:light}
228
+ html.dark .hosty-overlay-dialog,html[data-hosty-theme=dark] .hosty-overlay-dialog,html.dark .hosty-overlay-boot{--hosty-bg:#14171c;--hosty-fg:#f0f2f5;--hosty-muted:#a2aab7;--hosty-border:#323843;color-scheme:dark}
229
+ .hosty-overlay-boot{position:fixed;inset:0;background:var(--hosty-bg);z-index:2147483647}
230
+ html[data-hosty-overlay-blocked] body>:not([data-hosty-overlay-portal]){visibility:hidden!important}
231
+ html[data-hosty-overlay-blocked],html[data-hosty-overlay-blocked] body{background:#f8f9fb!important}
232
+ html.dark[data-hosty-overlay-blocked],html.dark[data-hosty-overlay-blocked] body{background:#14171c!important}
233
+ html[data-hosty-launch=embedded][data-hosty-overlay-blocked],html[data-hosty-launch=embedded][data-hosty-overlay-blocked] body{background:transparent!important}
234
+ .hosty-overlay-dialog{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;box-sizing:border-box;margin:0;padding:24px;border:0;background:var(--hosty-bg);color:var(--hosty-fg);font:400 14px/1.55 system-ui,sans-serif;outline:0}
235
+ .hosty-overlay-dialog[open]{display:grid;place-items:center}
236
+ .hosty-overlay-dialog::backdrop{background:transparent}
237
+ html[data-hosty-launch=embedded] .hosty-overlay-dialog,html[data-hosty-launch=embedded] .hosty-overlay-boot{background:transparent}
238
+ .hosty-overlay-card{width:min(100%,360px);text-align:center;animation:hosty-overlay-enter 160ms ease-out}
239
+ .hosty-overlay-brand{display:flex;align-items:center;justify-content:center;gap:8px;color:var(--hosty-fg);font-size:14px;font-weight:600;text-transform:uppercase;margin-bottom:22px}
240
+ .hosty-overlay-brand-mark{display:flex;align-items:center;justify-content:center;width:20px;height:20px;flex-shrink:0}
241
+ .hosty-overlay-brand-mark svg{width:24px;height:24px;flex-shrink:0}
242
+ .hosty-overlay-card h1{font:600 22px/1.3 system-ui,sans-serif;letter-spacing:-.025em;margin:0 0 12px;color:var(--hosty-fg)}
243
+ .hosty-overlay-card p{margin:0 0 24px;color:var(--hosty-muted)}
244
+ .hosty-overlay-card button{appearance:none;border:1px solid var(--hosty-border);border-radius:9px;background:var(--hosty-fg);color:var(--hosty-bg);padding:10px 18px;font:600 14px/1.4 system-ui,sans-serif;cursor:pointer}
245
+ .hosty-overlay-card button:focus-visible,.hosty-overlay-card a:focus-visible{outline:2px solid var(--hosty-muted);outline-offset:4px}
246
+ .hosty-overlay-card a{display:block;color:var(--hosty-muted);font-size:13px;margin-top:18px;text-decoration:underline}
247
+ .hosty-overlay-loading{min-height:100px;color:var(--hosty-muted)}
248
+ .hosty-overlay-spinner{display:inline-block;width:20px;height:20px;margin:0 0 16px;border:2px solid var(--hosty-border);border-top-color:var(--hosty-muted);border-radius:50%;animation:hosty-overlay-spin .8s linear infinite}
249
+ @keyframes hosty-overlay-spin{to{transform:rotate(360deg)}}
250
+ @keyframes hosty-overlay-enter{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}
251
+ @media(prefers-reduced-motion:reduce){.hosty-overlay-card,.hosty-overlay-spinner{animation:none}}
252
+ `;
package/dist/react.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AppAuthProtocol } from "./app-code.js";
1
2
  import type { ReactNode } from "react";
2
3
  import { type AppLaunchMode, type AppSessionStatus } from "./index.js";
3
4
  import { type HostyResolvedTheme, type HostyThemePreference } from "./theme.js";
@@ -62,7 +63,19 @@ export interface AppIdentityBridgeProps {
62
63
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
63
64
  * — never a login UI while recovery is still running.
64
65
  */
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;
66
+ export declare function useAppIdentity({ probePath, appCodePath, onSession, }?: Pick<AppIdentityBridgeProps, "probePath" | "appCodePath" | "onSession">): {
67
+ ui: AppIdentityBridgeState;
68
+ session: unknown;
69
+ activity: {
70
+ openUrl: string;
71
+ appAuthProtocol: AppAuthProtocol;
72
+ activeUntil?: string | null;
73
+ activityRequired?: boolean;
74
+ } | null;
75
+ hasBeenActive: boolean;
76
+ refresh: () => Promise<unknown>;
77
+ };
78
+ export declare function AppIdentityBridge({ renderState, children, ...options }?: 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;
66
79
  export interface HostThemeBridgeProps {
67
80
  /**
68
81
  * Whether the operating system decides the theme while no shell has spoken — `true` unless the
@@ -93,3 +106,4 @@ export interface HostThemeBridgeProps {
93
106
  export declare function HostThemeBridge({ followSystem, onTheme }?: HostThemeBridgeProps): null;
94
107
  export { MissingPermissionsNotice } from "./permissions-react.js";
95
108
  export { AppActivityBridge } from "./activity-react.js";
109
+ export { HostyOverlay } from "./overlay-react.js";
package/dist/react.js CHANGED
@@ -187,8 +187,10 @@ export function useLaunchMode() {
187
187
  * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
188
188
  * — never a login UI while recovery is still running.
189
189
  */
190
- export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, onSession, children, } = {}) {
190
+ export function useAppIdentity({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", onSession, } = {}) {
191
191
  const [ui, setUi] = useState({ kind: "recovering" });
192
+ const [session, setSession] = useState(null);
193
+ const refreshRef = useRef(async () => null);
192
194
  // A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
193
195
  // keep its exchange alive and let the replacement effect await the same response.
194
196
  const wasActive = useRef(false);
@@ -203,10 +205,12 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
203
205
  useEffect(() => { onSessionRef.current = onSession; }, [onSession]);
204
206
  useEffect(() => {
205
207
  let cancelled = false;
208
+ let probeSequence = 0;
206
209
  const controller = new AbortController();
207
210
  if (initialGrantPresent.current === null)
208
211
  initialGrantPresent.current = restoreAppGrant();
209
212
  async function probeAndRecover() {
213
+ const sequence = ++probeSequence;
210
214
  // Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
211
215
  // available in every browser (a synchronous throw here would stop the bridge from
212
216
  // ever rendering). Aborts on effect teardown or when the probe times out.
@@ -249,13 +253,12 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
249
253
  window.clearTimeout(probeTimeout);
250
254
  controller.signal.removeEventListener("abort", abortProbe);
251
255
  }
252
- if (cancelled) {
253
- return;
256
+ if (cancelled || sequence !== probeSequence) {
257
+ return null;
254
258
  }
255
259
  if (revision !== appGrantRevision()) {
256
260
  initialGrantPresent.current ||= restoreAppGrant();
257
- void probeAndRecover();
258
- return;
261
+ return probeAndRecover();
259
262
  }
260
263
  forgetRejectedAppGrant(rejectionCode, revision);
261
264
  if (openUrl && protocol)
@@ -295,16 +298,21 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
295
298
  });
296
299
  switch (action.kind) {
297
300
  case "none":
301
+ setSession(sessionBody);
298
302
  onSessionRef.current?.(sessionBody);
299
303
  appSessionActive();
300
304
  wasActive.current = true;
301
305
  setUi({ kind: "active" });
302
- return;
306
+ return sessionBody;
303
307
  case "post-auth-required": {
304
308
  showSignIn();
305
309
  return;
306
310
  }
307
311
  case "redirect":
312
+ if (wasActive.current) {
313
+ showSignIn();
314
+ return null;
315
+ }
308
316
  if (appId && protocol && beginNavigationSignIn(action.openUrl, appId, protocol, mode === "native" ? "native" : "standalone", shouldUpgrade))
309
317
  return;
310
318
  showSignIn("Allow app storage, or sign in with a popup.");
@@ -329,7 +337,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
329
337
  body: JSON.stringify(proof),
330
338
  }, false);
331
339
  const body = await response.json();
332
- if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
340
+ if (!response.ok)
333
341
  throw new Error("Hosty could not establish this app session.");
334
342
  if (cancelled)
335
343
  return;
@@ -337,7 +345,8 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
337
345
  await probeAndRecover();
338
346
  return;
339
347
  }
340
- rememberAppGrant(body.accessToken);
348
+ if (typeof body.accessToken === "string" && body.accessToken)
349
+ rememberAppGrant(body.accessToken);
341
350
  silentError.current = null;
342
351
  writeGuard(false);
343
352
  await probeAndRecover();
@@ -346,6 +355,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
346
355
  } : undefined });
347
356
  }
348
357
  }
358
+ refreshRef.current = probeAndRecover;
349
359
  const recover = () => { if (!cancelled && !wasActive.current) {
350
360
  setUi({ kind: "recovering" });
351
361
  void probeAndRecover();
@@ -420,11 +430,17 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
420
430
  }
421
431
  return () => {
422
432
  cancelled = true;
433
+ refreshRef.current = async () => null;
423
434
  controller.abort();
424
435
  window.removeEventListener(APP_SESSION_ENDED, recover);
425
436
  };
426
437
  }, [probePath, appCodePath]);
427
- if (wasActive.current)
438
+ return { ui, session, activity, hasBeenActive: wasActive.current, refresh: () => refreshRef.current() };
439
+ }
440
+ export function AppIdentityBridge({ renderState, children, ...options } = {}) {
441
+ const { ui, activity, hasBeenActive } = useAppIdentity(options);
442
+ const { appCodePath = "/api/auth/app-code", probePath = "/api/auth/identity" } = options;
443
+ if (hasBeenActive)
428
444
  return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath, probePath: probePath }), renderState ? renderState({ kind: "active" }) : children] });
429
445
  if (renderState)
430
446
  return renderState(ui);
@@ -534,3 +550,4 @@ export function HostThemeBridge({ followSystem = true, onTheme } = {}) {
534
550
  }
535
551
  export { MissingPermissionsNotice } from "./permissions-react.js";
536
552
  export { AppActivityBridge } from "./activity-react.js";
553
+ export { HostyOverlay } from "./overlay-react.js";
package/dist/server.d.ts CHANGED
@@ -43,6 +43,10 @@ export type AppSessionResolution = {
43
43
  error?: ResolutionError;
44
44
  };
45
45
  export declare function getAppId(config: HostyAppConfig): string;
46
+ /** Standard root-overlay identity and required-setup endpoint, with app-owned access policy. */
47
+ export declare function createHostySessionRouteHandler(config: HostyAppConfig, options?: {
48
+ administratorOnly?: boolean;
49
+ }): (request: Request) => Promise<Response>;
46
50
  export declare function getCoreOrigin(): string | null;
47
51
  /** Browser-reachable Core origin. Core always injects it (falls back to its loopback
48
52
  * listen URL on localhost-only installs), so a missing value is a broken environment. */
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
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 { createHostySessionResponse } from "./session-server.js";
4
5
  import { resolveAppAuthProtocol, appAuthProtocolMinimum, isValidCodeVerifier } from "./app-code.js";
5
6
  export { isValidCodeVerifier } from "./app-code.js";
6
7
  import { classifyRevalidationHttpStatus, } from "./index.js";
@@ -18,6 +19,15 @@ export const HOSTY_APP_IDENTITY_HEADER = "x-docker-host-identity";
18
19
  export function getAppId(config) {
19
20
  return process.env.HOSTY_APP_ID?.trim() || config.appIdFallback;
20
21
  }
22
+ /** Standard root-overlay identity and required-setup endpoint, with app-owned access policy. */
23
+ export function createHostySessionRouteHandler(config, options = {}) {
24
+ return async (request) => {
25
+ const [session, recovery] = await Promise.all([
26
+ resolveAppSession(readAppIdentityToken(request.headers, config), config), getRecoveryParams(config),
27
+ ]);
28
+ return createHostySessionResponse(session, recovery, { ...options, signal: request.signal });
29
+ };
30
+ }
21
31
  export function getCoreOrigin() {
22
32
  return process.env.HOSTY_CORE_ORIGIN?.trim() || null;
23
33
  }
@@ -0,0 +1,21 @@
1
+ import { type ProviderServerOptions } from "@hosty-sdk/app/providers/server";
2
+ import type { AppSessionStatus, SessionRecoveryParams } from "./session-types.js";
3
+ /** Framework-neutral server adapter. Call with an already authenticated app session, never request data. */
4
+ export type HostySessionInput = {
5
+ status: AppSessionStatus;
6
+ identity?: {
7
+ userId: string;
8
+ hostRole: string | null;
9
+ activeUntil?: string | null;
10
+ activityRequired?: boolean;
11
+ };
12
+ error?: {
13
+ code: string;
14
+ message: string;
15
+ status: number | null;
16
+ };
17
+ };
18
+ export declare function createHostySessionResponse(session: HostySessionInput, recovery: SessionRecoveryParams, options?: ProviderServerOptions & {
19
+ administratorOnly?: boolean;
20
+ signal?: AbortSignal;
21
+ }): Promise<Response>;
@@ -0,0 +1,27 @@
1
+ import { ProviderClient } from "@hosty-sdk/app/providers/server";
2
+ import { ProviderError } from "@hosty-sdk/app/providers";
3
+ export async function createHostySessionResponse(session, recovery, options = {}) {
4
+ const headers = { "Cache-Control": "no-store" };
5
+ if (session.status !== "active" || !session.identity)
6
+ return Response.json({ status: session.status, error: session.error, recovery }, { headers });
7
+ const identity = session.identity;
8
+ if (options.administratorOnly && identity.hostRole !== "host.admin")
9
+ return Response.json({ status: "forbidden", recovery }, { headers });
10
+ let setup = "unavailable";
11
+ let notice;
12
+ try {
13
+ const permissions = await new ProviderClient({ ...options, appId: recovery.appId ?? undefined }).permissions(options.signal);
14
+ if (![permissions.required, permissions.optional, permissions.granted, permissions.unsupportedRequired ?? []].every(list => Array.isArray(list) && list.every(name => typeof name === "string")))
15
+ throw new Error("Invalid permission state.");
16
+ setup = permissions.unsupportedRequired?.length ? "unsupported"
17
+ : permissions.required.some(name => !permissions.granted.includes(name)) ? "missing" : "ready";
18
+ if (identity.hostRole === "host.admin")
19
+ notice = { hostRole: identity.hostRole, appId: recovery.appId ?? "",
20
+ corePublicOrigin: recovery.corePublicOrigin, permissions };
21
+ }
22
+ catch (error) {
23
+ if (error instanceof ProviderError && error.status === 404)
24
+ setup = "incompatible";
25
+ }
26
+ return Response.json({ status: "active", ...identity, recovery, hosty: { version: 1, setup, notice } }, { headers });
27
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Recovery classification of an app session, per the platform identity error contract:
3
+ * - `not-present`: no token at all (recoverable — same handling as `expired`).
4
+ * - `active`: token revalidated OK.
5
+ * - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
6
+ * (standalone).
7
+ * - `forbidden`: Core 403 or a token minted for a different app — terminal; never
8
+ * auto-redirect (it would loop).
9
+ * - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
10
+ * cookie and offer a retry.
11
+ * - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
12
+ * operator problem; signing in cannot fix it, so never offer a login.
13
+ */
14
+ export type AppSessionStatus = "not-present" | "active" | "expired" | "forbidden" | "unavailable" | "misconfigured";
15
+ /** Failure statuses (everything but `active`). */
16
+ export type AppSessionFailureStatus = Exclude<AppSessionStatus, "active">;
17
+ export interface SessionRecoveryParams {
18
+ appId: string | null;
19
+ corePublicOrigin: string | null;
20
+ appAuthProtocol: 1 | 2 | null;
21
+ }
@@ -0,0 +1,15 @@
1
+ // Shared session types with no DOM or framework dependency.
2
+ /**
3
+ * Recovery classification of an app session, per the platform identity error contract:
4
+ * - `not-present`: no token at all (recoverable — same handling as `expired`).
5
+ * - `active`: token revalidated OK.
6
+ * - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
7
+ * (standalone).
8
+ * - `forbidden`: Core 403 or a token minted for a different app — terminal; never
9
+ * auto-redirect (it would loop).
10
+ * - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
11
+ * cookie and offer a retry.
12
+ * - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
13
+ * operator problem; signing in cannot fix it, so never offer a login.
14
+ */
15
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.21.2",
3
+ "version": "0.22.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",
@@ -13,6 +13,10 @@
13
13
  "types": "./dist/server.d.ts",
14
14
  "default": "./dist/server.js"
15
15
  },
16
+ "./session/server": {
17
+ "types": "./dist/session-server.d.ts",
18
+ "default": "./dist/session-server.js"
19
+ },
16
20
  "./browser-auth": {
17
21
  "types": "./dist/browser-auth.d.ts",
18
22
  "default": "./dist/browser-auth.js"
@@ -92,7 +96,8 @@
92
96
  },
93
97
  "peerDependencies": {
94
98
  "next": "^16.3.8",
95
- "react": "^19.2.8"
99
+ "react": "^19.2.8",
100
+ "react-dom": "^19.2.8"
96
101
  },
97
102
  "peerDependenciesMeta": {
98
103
  "next": {
@@ -100,6 +105,9 @@
100
105
  },
101
106
  "react": {
102
107
  "optional": true
108
+ },
109
+ "react-dom": {
110
+ "optional": true
103
111
  }
104
112
  },
105
113
  "devDependencies": {