@hosty-sdk/app 0.21.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/install.js CHANGED
@@ -33,10 +33,12 @@ export function createInstallationClient(options = {}) {
33
33
  /** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
34
34
  export class InstallationFlow {
35
35
  client;
36
- state = { request: null, busy: false, error: null };
36
+ state = { request: null, busy: false, error: null, uncertainSubmit: false };
37
37
  listeners = new Set();
38
38
  generation = 0;
39
39
  disposed = false;
40
+ submitting = false;
41
+ refreshing = false;
40
42
  constructor(client) {
41
43
  this.client = client;
42
44
  }
@@ -50,72 +52,88 @@ export class InstallationFlow {
50
52
  listener();
51
53
  }
52
54
  clearReview() {
53
- if (this.state.request && this.state.request.status !== "draft")
55
+ const status = this.state.request?.status;
56
+ if (this.submitting || this.state.uncertainSubmit || (status && !["draft", "succeeded", "denied", "failed"].includes(status)))
54
57
  return;
55
58
  this.generation++;
56
- this.set({ request: null, busy: false, error: null });
59
+ this.set({ request: null, busy: false, error: null, uncertainSubmit: false });
57
60
  }
58
61
  cancelPending() { this.generation++; }
59
62
  dispose() { this.disposed = true; this.generation++; this.listeners.clear(); }
60
63
  async review(source) {
61
- if (this.state.request && this.state.request.status !== "draft")
64
+ if (this.submitting || this.state.uncertainSubmit || (this.state.request && this.state.request.status !== "draft"))
62
65
  return;
63
66
  const generation = ++this.generation;
64
- this.set({ request: null, busy: true, error: null });
67
+ this.set({ request: null, busy: true, error: null, uncertainSubmit: false });
65
68
  try {
66
69
  const request = await this.client.prepare(source);
67
70
  if (generation === this.generation)
68
- this.set({ request, busy: false, error: null });
71
+ this.set({ request, busy: false, error: null, uncertainSubmit: false });
69
72
  }
70
73
  catch (error) {
71
74
  if (generation === this.generation)
72
- this.set({ request: null, busy: false, error: message(error) });
75
+ this.set({ request: null, busy: false, error: message(error), uncertainSubmit: false });
73
76
  }
74
77
  }
75
78
  async submit(settings, autostart) {
76
- if (this.state.busy || this.state.request?.status !== "draft")
79
+ if (this.state.busy || this.state.uncertainSubmit || this.state.request?.status !== "draft")
77
80
  return null;
78
81
  const id = this.state.request.id;
79
82
  const generation = this.generation;
83
+ this.submitting = true;
80
84
  this.set({ ...this.state, busy: true, error: null });
81
85
  try {
82
86
  const request = await this.client.submit(id, settings, autostart);
83
87
  if (generation !== this.generation)
84
88
  return null;
85
- this.set({ request, busy: false, error: null });
89
+ this.set({ request, busy: false, error: null, uncertainSubmit: false });
86
90
  return request;
87
91
  }
88
92
  catch (error) {
93
+ if (error instanceof InstallationError && error.status >= 400 && error.status < 500) {
94
+ if (generation === this.generation)
95
+ this.set({ ...this.state, busy: false, error: message(error), uncertainSubmit: false });
96
+ return null;
97
+ }
89
98
  // A lost response may mean the request was already frozen. Recover by reading its status,
90
99
  // not by sending another submit or creating a second installation.
91
100
  try {
92
101
  const request = await this.client.status(id);
93
102
  if (generation !== this.generation)
94
103
  return null;
95
- this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null });
104
+ this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null, uncertainSubmit: false });
96
105
  return request.status === "draft" ? null : request;
97
106
  }
98
107
  catch {
99
108
  if (generation === this.generation)
100
- this.set({ ...this.state, busy: false, error: message(error) });
109
+ this.set({ ...this.state, busy: false, error: message(error), uncertainSubmit: true });
101
110
  return null;
102
111
  }
103
112
  }
113
+ finally {
114
+ this.submitting = false;
115
+ }
104
116
  }
105
117
  async refresh() {
106
- if (!this.state.request || this.state.busy)
118
+ if (!this.state.request || this.state.busy || this.refreshing)
107
119
  return;
120
+ this.refreshing = true;
108
121
  const id = this.state.request.id;
109
122
  const generation = this.generation;
110
123
  try {
111
124
  const request = await this.client.status(id);
112
125
  if (generation === this.generation)
113
- this.set({ request, busy: false, error: request.error ?? null });
126
+ this.set({ request, busy: false,
127
+ error: request.error ?? (this.state.uncertainSubmit && request.status === "draft" ? "Core has not received the submission. Retry this request." : null),
128
+ uncertainSubmit: false });
114
129
  }
115
130
  catch (error) {
116
131
  if (generation === this.generation)
117
132
  this.set({ ...this.state, error: message(error) });
118
133
  }
134
+ finally {
135
+ this.refreshing = false;
136
+ }
119
137
  }
120
138
  }
121
139
  function message(error) { return error instanceof Error ? error.message : "Installation request failed."; }
@@ -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
+ }