@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/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:
@@ -138,23 +176,34 @@ Set `publicOrigin` to the Core-injected public origin for your UI endpoint when
138
176
  exposes an internal request URL. The adapter checks browser `Origin` against this configured
139
177
  value, or against the request URL when it is omitted; forwarded-host headers are never trusted.
140
178
 
141
- The default dialog includes runtime selection, settings and a link to Core confirmation:
179
+ The default dialog prepares installation and links to Core confirmation:
142
180
 
143
181
  ```tsx
144
182
  "use client";
145
183
  import { createInstallationClient } from "@hosty-sdk/app/install";
146
184
  import { InstallDialog } from "@hosty-sdk/app/install/react";
185
+ import { openInstallationConfirmation } from "@hosty-sdk/app/install";
147
186
  const client = createInstallationClient();
148
187
 
188
+ // In the Install click handler, before awaiting source preparation:
189
+ const confirmationWindow = openInstallationConfirmation();
149
190
  // Render conditionally while open; unmount it when closed.
150
191
  <InstallDialog
151
192
  client={client}
193
+ confirmationWindow={confirmationWindow}
152
194
  source={{ feedsUrl: "https://example.com/feeds.json", feedId: "stable" }}
153
195
  onClose={() => setOpen(false)}
154
196
  onInstalled={() => { setOpen(false); refreshInstalledApps(); }}
155
197
  />;
156
198
  ```
157
199
 
200
+ The default dialog immediately prepares the selected source and submits defaults. Core alone
201
+ asks for runtime, automatic startup and permissions. Choose the channel before opening the
202
+ dialog; changing the source creates a new request. Missing required configuration does not
203
+ prevent installation: configure the stopped app afterwards. The dialog shows preparation,
204
+ errors, status and an explicit confirmation link when the popup is blocked. An uncertain
205
+ submission retains its ID and offers status-only recovery.
206
+
158
207
  For a custom UI, use `useInstallation(client)` or the framework-independent `InstallationFlow`.
159
208
  For direct API use, the same client provides the full request sequence:
160
209
 
@@ -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") {
@@ -5,12 +5,18 @@ export declare function useInstallation(client: InstallationClient): {
5
5
  request: InstallationRequest | null;
6
6
  busy: boolean;
7
7
  error: string | null;
8
+ uncertainSubmit: boolean;
8
9
  };
9
10
  export interface InstallDialogProps {
10
11
  client: InstallationClient;
12
+ /** Choose the source/channel before opening. Core freezes that selection during preparation. */
11
13
  source?: InstallationSource;
14
+ /** Open synchronously in the Install click handler, before mounting or fetching a source. */
15
+ confirmationWindow?: Window | null;
12
16
  onClose: () => void;
17
+ /** Return to the caller's source/connection picker after a known pre-confirmation error. */
18
+ onChooseSource?: () => void;
13
19
  onInstalled?: (request: InstallationRequest) => void;
14
20
  }
15
- /** Mount when opened; unmount on close. Styling is self-contained and has no Next/Tailwind dependency. */
16
- export declare function InstallDialog({ client, source, onClose, onInstalled }: InstallDialogProps): import("react").JSX.Element;
21
+ /** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
22
+ export declare function InstallDialog(props: InstallDialogProps): import("react").JSX.Element;
@@ -15,29 +15,83 @@ export function useInstallation(client) {
15
15
  }, [flow, state.request?.id, state.request?.status]);
16
16
  return { ...state, flow };
17
17
  }
18
- /** Mount when opened; unmount on close. Styling is self-contained and has no Next/Tailwind dependency. */
19
- export function InstallDialog({ client, source, onClose, onInstalled }) {
20
- const { request, busy, error, flow } = useInstallation(client);
18
+ const popupOwners = new WeakMap();
19
+ /** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
20
+ export function InstallDialog(props) {
21
+ // A changed source is a new review. Never apply an earlier channel's confirmation to it.
22
+ return _jsx(InstallationSession, { ...props }, JSON.stringify(props.source ?? null));
23
+ }
24
+ function InstallationSession({ client, source, confirmationWindow, onClose, onChooseSource, onInstalled }) {
25
+ const { request, busy, error, uncertainSubmit, flow } = useInstallation(client);
21
26
  const dialog = useRef(null);
22
27
  const titleId = useId();
23
- const [manifestPath, setManifestPath] = useState(source?.manifestPath ?? "");
24
- const [settings, setSettings] = useState({});
25
- const [autostart, setAutostart] = useState(true);
26
- const [draftPlanId, setDraftPlanId] = useState(null);
28
+ const [manifestPath, setManifestPath] = useState("");
29
+ const [presentationError, setPresentationError] = useState(null);
27
30
  const notified = useRef(null);
28
- const plan = request?.plan ?? null;
29
- if (plan && request?.id !== draftPlanId) {
30
- setDraftPlanId(request.id);
31
- setSettings(Object.fromEntries(plan.settings.map(setting => [setting.key, setting.secret ? "" : setting.defaultValue ?? ""])));
32
- setAutostart(plan.defaultAutostart ?? true);
33
- }
34
- const initialManifest = source?.manifestPath;
35
- const initialFeed = source?.feedsUrl;
36
- const initialFeedId = source?.feedId;
31
+ const [initialSource] = useState(source);
32
+ const activePopup = useRef(confirmationWindow ?? null);
33
+ const popupOwner = useRef({});
34
+ const popupShown = useRef(false);
35
+ const mountLease = useRef(0);
36
+ const hasSource = Boolean(initialSource?.manifestPath || initialSource?.feedsUrl);
37
+ const frozen = uncertainSubmit || (request !== null && request.status !== "draft");
38
+ const claimPopup = (popup) => {
39
+ activePopup.current = popup;
40
+ popupShown.current = false;
41
+ if (popup)
42
+ popupOwners.set(popup, popupOwner.current);
43
+ };
44
+ const closeUnusedPopup = (popup = activePopup.current) => {
45
+ if (!popup || popupShown.current || popupOwners.get(popup) !== popupOwner.current)
46
+ return;
47
+ popupOwners.delete(popup);
48
+ popup.close();
49
+ };
50
+ const showConfirmation = (popup, submitted) => {
51
+ // A newer source session may now own the caller's preopened window.
52
+ if (popup && popupOwners.get(popup) !== popupOwner.current)
53
+ return;
54
+ if (!submitted || submitted.status !== "pending") {
55
+ closeUnusedPopup(popup);
56
+ return;
57
+ }
58
+ try {
59
+ showInstallationConfirmation(popup, submitted);
60
+ popupShown.current = Boolean(popup && !popup.closed);
61
+ }
62
+ catch (cause) {
63
+ closeUnusedPopup(popup);
64
+ setPresentationError(cause instanceof Error ? cause.message : "Core confirmation could not be opened.");
65
+ }
66
+ };
67
+ useEffect(() => {
68
+ const lease = ++mountLease.current;
69
+ claimPopup(activePopup.current);
70
+ return () => {
71
+ // StrictMode immediately replays setup. Defer cleanup so that replay, or a new source
72
+ // session adopting the same window, can retain the reservation.
73
+ queueMicrotask(() => { if (mountLease.current === lease)
74
+ closeUnusedPopup(); });
75
+ };
76
+ }, [flow]);
37
77
  useEffect(() => {
38
- if (initialManifest || initialFeed)
39
- void flow.review({ manifestPath: initialManifest, feedsUrl: initialFeed, feedId: initialFeedId });
40
- }, [flow, initialManifest, initialFeed, initialFeedId]);
78
+ if (!initialSource?.manifestPath && !initialSource?.feedsUrl)
79
+ return;
80
+ let cancelled = false;
81
+ void (async () => {
82
+ await flow.review(initialSource);
83
+ if (cancelled)
84
+ return;
85
+ if (flow.snapshot().request?.status !== "draft") {
86
+ closeUnusedPopup();
87
+ return;
88
+ }
89
+ const submitted = await flow.submit();
90
+ if (!cancelled)
91
+ showConfirmation(activePopup.current, submitted);
92
+ })();
93
+ return () => { cancelled = true; };
94
+ }, [flow, initialSource]);
41
95
  useEffect(() => {
42
96
  const element = dialog.current;
43
97
  element?.showModal();
@@ -49,28 +103,55 @@ export function InstallDialog({ client, source, onClose, onInstalled }) {
49
103
  onInstalled?.(request);
50
104
  }
51
105
  }, [request, onInstalled]);
52
- const frozen = request !== null && request.status !== "draft";
53
- const review = (runtime) => flow.review({
54
- ...(source?.feedsUrl ? { feedsUrl: source.feedsUrl, feedId: source.feedId } : { manifestPath: manifestPath.trim() }),
55
- selectedRuntime: runtime,
56
- });
57
- const submit = async () => {
106
+ const install = async () => {
107
+ if (busy || frozen)
108
+ return;
109
+ // The browser gesture is still active here, before any async preparation.
58
110
  const popup = openInstallationConfirmation();
59
- const values = Object.fromEntries((plan?.settings ?? []).filter(setting => !setting.secret || settings[setting.key])
60
- .map(setting => [setting.key, settings[setting.key] ?? ""]));
61
- const submitted = await flow.submit(values, autostart);
62
- if (submitted)
63
- showInstallationConfirmation(popup, submitted);
64
- else
65
- popup?.close();
111
+ claimPopup(popup);
112
+ setPresentationError(null);
113
+ await flow.review({ manifestPath: manifestPath.trim() });
114
+ if (flow.snapshot().request?.status !== "draft") {
115
+ closeUnusedPopup(popup);
116
+ return;
117
+ }
118
+ showConfirmation(popup, await flow.submit());
66
119
  };
67
- return _jsxs("dialog", { ref: dialog, className: "hosty-install", "aria-labelledby": titleId, onCancel: event => { event.preventDefault(); onClose(); }, children: [_jsx("style", { children: styles }), _jsxs("header", { children: [_jsx("h2", { id: titleId, children: "Install app" }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close installation", children: "\u00D7" })] }), _jsx("p", { children: "Prepare the installation here, then confirm it securely in Hosty Core." }), !source?.feedsUrl && !source?.manifestPath && _jsxs("form", { onSubmit: event => { event.preventDefault(); void review(); }, children: [_jsxs("label", { children: ["Manifest, app directory, or URL", _jsx("input", { value: manifestPath, onChange: event => { setManifestPath(event.target.value); flow.clearReview(); }, required: true, disabled: busy || frozen })] }), _jsx("button", { disabled: busy || frozen || !manifestPath.trim(), children: "Review" })] }), busy && _jsx("p", { role: "status", children: "Preparing installation\u2026" }), error && _jsxs("div", { role: "alert", children: [_jsx("p", { children: error }), !frozen && _jsx("button", { type: "button", disabled: busy, onClick: () => void review(), children: "Retry review" })] }), plan && _jsxs("form", { onSubmit: event => { event.preventDefault(); void submit(); }, children: [_jsxs("h3", { children: [plan.displayName, " ", _jsx("small", { children: plan.targetVersion })] }), plan.description && _jsx("p", { children: plan.description }), _jsxs("label", { children: ["Runtime", _jsx("select", { value: plan.targetRuntime, disabled: busy || frozen, onChange: event => void review(event.target.value), children: (plan.runtimeProfiles ?? [{ key: plan.targetRuntime, type: plan.targetRuntimeType }]).map(runtime => _jsxs("option", { value: runtime.key, children: [runtime.key, " (", runtime.type, ")"] }, runtime.key)) })] }), plan.targetRuntimeType === "localCommand" && _jsx("p", { className: "warning", children: "This runtime runs commands directly on your host, outside a container. Only install code you trust." }), plan.system && _jsx("p", { className: "warning", children: "This is a system app. User access is controlled by Hosty assignments." }), !!plan.requestedRoles?.length && _jsxs("section", { children: [_jsx("h3", { children: "Requested provider roles" }), _jsx("ul", { children: plan.requestedRoles.map(role => _jsx("li", { children: plan.roleDescriptions?.[role] ?? role }, role)) })] }), !!plan.corePermissions?.length && _jsxs("section", { children: [_jsx("h3", { children: "Requested Core permissions" }), _jsx("ul", { children: plan.corePermissions.map(permission => _jsx("li", { children: plan.permissionDescriptions?.[permission] ?? permission }, permission)) })] }), plan.settings.map(setting => _jsxs("label", { children: [setting.label || setting.key, setting.required ? " (required)" : "", setting.type === "boolean" && !setting.secret
68
- ? _jsx("input", { type: "checkbox", checked: ["true", "1", "yes", "on", "enabled"].includes((settings[setting.key] ?? "").toLowerCase()), disabled: busy || frozen, onChange: event => setSettings(current => ({ ...current, [setting.key]: String(event.target.checked) })) })
69
- : setting.type === "select" && setting.options?.length && !setting.secret
70
- ? _jsxs("select", { value: settings[setting.key] ?? "", disabled: busy || frozen, onChange: event => setSettings(current => ({ ...current, [setting.key]: event.target.value })), children: [_jsx("option", { value: "", children: "Select a value" }), setting.options.map(option => _jsx("option", { value: option.value, children: option.label }, option.value))] })
71
- : _jsx("input", { type: setting.secret ? "password" : setting.type === "number" ? "number" : setting.type === "url" ? "url" : "text", step: setting.type === "number" ? "any" : undefined, autoComplete: setting.secret ? "new-password" : "off", required: setting.required && !setting.secret, disabled: busy || frozen, value: settings[setting.key] ?? "", onChange: event => setSettings(current => ({ ...current, [setting.key]: event.target.value })) }), setting.description && _jsx("small", { children: setting.description })] }, setting.key)), _jsxs("label", { className: "check", children: [_jsx("input", { type: "checkbox", checked: autostart, disabled: busy || frozen, onChange: event => setAutostart(event.target.checked) }), " Start automatically (now and on Core startup)"] }), !frozen && _jsx("footer", { children: _jsx("button", { className: "primary", disabled: busy, children: "Continue to Core confirmation" }) })] }), frozen && _jsxs("section", { role: "status", children: [_jsx("p", { children: request.status === "pending" ? "Waiting for your confirmation in Hosty Core." : request.status === "executing" ? "Core is applying your confirmed request…" : request.status === "succeeded" ? "App installed." : request.status === "denied" ? "Installation cancelled." : "Installation failed. Close this dialog and review a new plan to retry." }), request.status === "pending" && _jsx("a", { href: request.approvalUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core confirmation" }), _jsx("p", { children: _jsx("small", { children: "Closing this dialog does not cancel an operation already confirmed in Core." }) })] })] });
120
+ const retry = async () => {
121
+ setPresentationError(null);
122
+ if (uncertainSubmit) {
123
+ await flow.refresh();
124
+ return;
125
+ }
126
+ const popup = openInstallationConfirmation();
127
+ claimPopup(popup);
128
+ // A known draft submit failure retries its existing identity, without resolving the feed again.
129
+ if (!request)
130
+ await flow.review(initialSource ?? { manifestPath: manifestPath.trim() });
131
+ if (flow.snapshot().request?.status !== "draft") {
132
+ closeUnusedPopup(popup);
133
+ return;
134
+ }
135
+ showConfirmation(popup, await flow.submit());
136
+ };
137
+ const close = () => {
138
+ closeUnusedPopup();
139
+ onClose();
140
+ };
141
+ const confirmationUrl = (() => {
142
+ if (request?.status !== "pending")
143
+ return null;
144
+ try {
145
+ const url = new URL(request.approvalUrl);
146
+ return ["http:", "https:"].includes(url.protocol) ? url.href : null;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ })();
152
+ return _jsxs("dialog", { ref: dialog, className: "hosty-install", "aria-labelledby": titleId, onCancel: event => { event.preventDefault(); close(); }, children: [_jsx("style", { children: styles }), _jsxs("header", { children: [_jsx("h2", { id: titleId, children: "Install app" }), _jsx("button", { type: "button", onClick: close, "aria-label": "Close installation", children: "\u00D7" })] }), !hasSource && !request && _jsxs("form", { onSubmit: event => { event.preventDefault(); void install(); }, children: [_jsxs("label", { children: ["Manifest, app directory, or URL", _jsx("input", { value: manifestPath, onChange: event => setManifestPath(event.target.value), required: true, disabled: busy })] }), _jsx("button", { className: "primary", disabled: busy || !manifestPath.trim(), children: "Install" })] }), busy && _jsx("p", { role: "status", children: "Preparing Core confirmation\u2026" }), (error || presentationError) && _jsxs("div", { role: "alert", children: [_jsx("p", { children: error || presentationError }), (!frozen || uncertainSubmit) && _jsx("button", { type: "button", disabled: busy, onClick: () => void retry(), children: uncertainSubmit ? "Check request status" : "Retry" }), !frozen && onChooseSource && _jsx("button", { type: "button", disabled: busy, onClick: () => { closeUnusedPopup(); onChooseSource(); }, children: "Change source" })] }), uncertainSubmit && _jsx("p", { role: "status", children: "Core may have received this request. Check its status before continuing." }), request && request.status !== "draft" && _jsxs("section", { role: "status", children: [_jsx("p", { children: request.status === "pending" ? "Confirm runtime, automatic startup and permissions in Hosty Core." : request.status === "executing" ? "Core is installing your app…" : request.status === "succeeded" ? "App installed." : request.status === "denied" ? "Installation cancelled." : "Installation failed. Close this dialog to begin a new request." }), confirmationUrl && _jsx("a", { href: confirmationUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core confirmation" }), _jsx("p", { children: _jsx("small", { children: "Closing this dialog does not cancel an operation already confirmed in Core." }) })] })] });
72
153
  }
73
154
  const styles = `
74
- .hosty-install{box-sizing:border-box;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:24px;border:1px solid var(--border,#aaa);border-radius:12px;background:var(--background,Canvas);color:var(--foreground,CanvasText);font:inherit;overflow:auto;margin:auto}
75
- .hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install h3{font-size:1rem;margin:20px 0 8px}.hosty-install p{margin:12px 0;font-size:.9rem}.hosty-install small{font-size:.8rem;opacity:.75}.hosty-install label{display:flex;flex-direction:column;gap:6px;margin:16px 0;font-size:.9rem}.hosty-install input:not([type=checkbox]),.hosty-install select{box-sizing:border-box;width:100%;border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 10px;background:transparent;color:inherit;font:inherit}.hosty-install .check{flex-direction:row;align-items:center;gap:10px}.hosty-install input[type=checkbox]{width:16px;height:16px;accent-color:#2563eb}.hosty-install button{border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 12px;font:inherit;background:transparent;color:inherit;cursor:pointer}.hosty-install button:disabled{opacity:.5;cursor:default}.hosty-install .primary{background:#2563eb;color:white;border-color:#2563eb}.hosty-install footer{display:flex;justify-content:flex-end;margin-top:24px}.hosty-install .warning{padding:12px;border:1px solid #b7791f;border-radius:6px}.hosty-install [role=alert]{color:#d33}.hosty-install a{color:#2563eb;text-decoration:underline}.hosty-install :focus-visible{outline:2px solid #60a5fa;outline-offset:3px}
155
+ .hosty-install{box-sizing:border-box;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:24px;border:1px solid var(--border,#aaa);border-radius:12px;background:var(--background,Canvas);color:var(--foreground,CanvasText);font:inherit;overflow:auto;margin:auto}
156
+ .hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install p{margin:12px 0;font-size:.9rem}.hosty-install small{font-size:.8rem;opacity:.75}.hosty-install label{display:flex;flex-direction:column;gap:6px;margin:16px 0;font-size:.9rem}.hosty-install input{box-sizing:border-box;width:100%;border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 10px;background:transparent;color:inherit;font:inherit}.hosty-install button{border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 12px;font:inherit;background:transparent;color:inherit;cursor:pointer}.hosty-install button:disabled{opacity:.5;cursor:default}.hosty-install .primary{background:#2563eb;color:white;border-color:#2563eb}.hosty-install [role=alert]{color:#d33}.hosty-install a{color:#2563eb;text-decoration:underline}.hosty-install :focus-visible{outline:2px solid #60a5fa;outline-offset:3px}
76
157
  `;
package/dist/install.d.ts CHANGED
@@ -12,6 +12,16 @@ export interface InstallSetting {
12
12
  label: string;
13
13
  }[] | null;
14
14
  }
15
+ export interface InstallationConfigurationReadiness {
16
+ required: boolean;
17
+ missingSettings: string[];
18
+ mounts: {
19
+ key: string;
20
+ label?: string | null;
21
+ reason: string;
22
+ }[];
23
+ error?: string | null;
24
+ }
15
25
  export interface InstallPlan {
16
26
  appId: string;
17
27
  displayName: string;
@@ -37,6 +47,14 @@ export interface InstallPlan {
37
47
  development?: boolean;
38
48
  }[];
39
49
  settings: InstallSetting[];
50
+ runtimeChoices?: {
51
+ key: string;
52
+ type: string;
53
+ default: boolean;
54
+ available: boolean;
55
+ error?: string | null;
56
+ }[];
57
+ configurationReadiness?: InstallationConfigurationReadiness | null;
40
58
  }
41
59
  export interface InstallationSource {
42
60
  manifestPath?: string;
@@ -97,10 +115,22 @@ export interface InstallationRequest {
97
115
  plan: InstallPlan | null;
98
116
  updatePlan?: {
99
117
  appId: string;
100
- displayName: string;
118
+ displayName?: string | null;
101
119
  targetVersion: string;
102
120
  targetRuntime: string;
103
121
  changes: string[];
122
+ previousRequiredCorePermissions?: string[] | null;
123
+ previousOptionalCorePermissions?: string[] | null;
124
+ feedsUrl?: string | null;
125
+ feedId?: string | null;
126
+ targetRuntimeType?: string | null;
127
+ settingChanges?: {
128
+ key: string;
129
+ label?: string | null;
130
+ change: string;
131
+ detail?: string | null;
132
+ }[];
133
+ configurationReadiness?: InstallationConfigurationReadiness | null;
104
134
  } | null;
105
135
  permissionPlan?: {
106
136
  appId: string;
@@ -128,7 +158,7 @@ export interface InstallationRequest {
128
158
  }
129
159
  export interface InstallationClient {
130
160
  prepare(source: InstallationSource): Promise<InstallationRequest>;
131
- submit(id: string, settings: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest>;
161
+ submit(id: string, settings?: Record<string, string | null>, autostart?: boolean): Promise<InstallationRequest>;
132
162
  status(id: string): Promise<InstallationRequest>;
133
163
  }
134
164
  export declare class InstallationError extends Error {
@@ -145,6 +175,8 @@ export interface InstallationState {
145
175
  request: InstallationRequest | null;
146
176
  busy: boolean;
147
177
  error: string | null;
178
+ /** A submit may have succeeded; resolve the existing identity before another mutation. */
179
+ uncertainSubmit: boolean;
148
180
  }
149
181
  /** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
150
182
  export declare class InstallationFlow {
@@ -153,6 +185,8 @@ export declare class InstallationFlow {
153
185
  private listeners;
154
186
  private generation;
155
187
  private disposed;
188
+ private submitting;
189
+ private refreshing;
156
190
  constructor(client: InstallationClient);
157
191
  snapshot: () => InstallationState;
158
192
  subscribe: (listener: () => void) => (() => void);
@@ -161,7 +195,7 @@ export declare class InstallationFlow {
161
195
  cancelPending(): void;
162
196
  dispose(): void;
163
197
  review(source: InstallationSource): Promise<void>;
164
- submit(settings: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest | null>;
198
+ submit(settings?: Record<string, string | null>, autostart?: boolean): Promise<InstallationRequest | null>;
165
199
  refresh(): Promise<void>;
166
200
  }
167
201
  /** Open synchronously in a click handler, before awaiting plan/submit requests. */