@hosty-sdk/app 0.18.0 → 0.19.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
@@ -1,8 +1,7 @@
1
1
  # @hosty-sdk/app
2
2
 
3
3
  Auth and host integration for [Hosty](https://github.com/alex-de-haas/docker-host) runtime
4
- apps: the app-session state machine, silent session recovery, Core revalidation, and the
5
- embedder responder for shells.
4
+ apps: app-session classification, Core-owned sign-in, app-local request transport and revalidation.
6
5
 
7
6
  ```
8
7
  npm install @hosty-sdk/app
@@ -12,8 +11,9 @@ npm install @hosty-sdk/app
12
11
  | --- | --- | --- |
13
12
  | `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` and `hosty:request-delegated-token` schemas, URL/env helpers |
14
13
  | `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
15
- | `@hosty-sdk/app/react` | client | `<AppIdentityBridge />` — probe, silent recovery, fallback cards |
16
- | `@hosty-sdk/app/embedder` | client | verified responders — launch-code recovery and delegated tokens — plus the theme sender half, for anything that embeds Hosty apps |
14
+ | `@hosty-sdk/app/react` | client | `<AppIdentityBridge />` — probe, Core popup/navigation recovery, content gate |
15
+ | `@hosty-sdk/app/embedder` | client | theme sender and legacy embedder message parsers; Shell no longer mints app credentials |
16
+ | `@hosty-sdk/app/browser-auth` | client | `appFetch` for app-local API requests and bound Core popup sign-in |
17
17
  | `@hosty-sdk/app/providers` | anywhere | permission state, provider descriptors, speech contract types |
18
18
  | `@hosty-sdk/app/providers/server` | server only | `ProviderClient`: discovery, speech recognition, assistant handoffs and live credential validation |
19
19
  | `@hosty-sdk/app/theme` | anywhere | the shell→app theme protocol: constants, `resolveTheme`, `applyTheme`, `parseShellThemeMessage`, `themeBootstrapScript` / `createThemeBootstrapScript` |
@@ -23,7 +23,7 @@ Minimal Next.js wiring:
23
23
  ```tsx
24
24
  // app/layout.tsx
25
25
  import { AppIdentityBridge } from "@hosty-sdk/app/react";
26
- // mount <AppIdentityBridge /> at the top of <body>
26
+ // wrap protected client content: <AppIdentityBridge>{children}</AppIdentityBridge>
27
27
 
28
28
  // app/api/auth/app-code/route.ts
29
29
  import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
@@ -81,36 +81,26 @@ await setAppSecret("trakt.connection.1.tokens", refreshed, config);
81
81
  Reads are served from a write-through cache, namespaced by Core origin and app id; pass
82
82
  `{ refresh: true }` to force a live read.
83
83
 
84
- Delegated tokens — the credential a browser client (Shell) presents when calling a system
85
- app's API directly. Core signs them (ECDSA P-256, 5-minute TTL) and injects the verification
86
- key as `HOSTY_DELEGATED_TOKEN_PUBLIC_KEY`, so validation is fully local — no Core round-trip:
84
+ Protected browser requests use the app-only credential held by the bridge:
87
85
 
88
86
  ```ts
89
- import { validateDelegatedToken } from "@hosty-sdk/app/server";
90
-
91
- // null for anything invalid (bad signature, wrong audience, expired) — treat like a missing token.
92
- const claims = validateDelegatedToken(bearerToken);
93
- if (claims?.role !== "host.admin") { /* 401/403 */ }
87
+ import { appFetch } from "@hosty-sdk/app/browser-auth";
88
+ const response = await appFetch("/api/items");
94
89
  ```
95
90
 
96
- An embedded page cannot mint a delegated token itself — that needs the user's Core session in a
97
- first-party context — so it asks whoever embeds it:
91
+ The server must accept the explicit app bearer as well as its cookie (`readAppIdentityToken`), then
92
+ revalidate it with the app service token. This supports embedded browsers that block cookie access.
93
+ The bridge retains the grant only in memory and never sends it to Shell. `appFetch` rejects foreign
94
+ origins and redirects; 401 starts recovery, while 503 preserves the credential. Custom bridge views
95
+ must call `state.signIn` from a click and display `state.error` if present.
98
96
 
99
- ```ts
100
- import { DELEGATED_TOKEN_REQUEST_TYPE, DELEGATED_TOKEN_TYPE } from "@hosty-sdk/app";
101
- import { parseActiveFrameDelegatedTokenRequest } from "@hosty-sdk/app/embedder";
102
-
103
- // In the embedder, per app frame. A verified request says who asked, never whether to answer: the
104
- // token is user-scoped, so grant it only to apps you decided to grant it to, and post it to that
105
- // frame's own origin — never "*". Attach the listener before the frame can run (apps ask as soon as
106
- // their document does, and they re-ask until answered), and honour `refresh`: it means the token the
107
- // app holds was refused, so a cached mint must not be handed back.
108
- const intent = parseActiveFrameDelegatedTokenRequest(event, frame.contentWindow, frame.src);
109
- if (intent) {
110
- const { token, expiresAt } = await mintDelegatedTokenFromCore(appId, { force: intent.refresh });
111
- frame.contentWindow.postMessage({ type: DELEGATED_TOKEN_TYPE, token, expiresAt }, frameOrigin);
112
- }
113
- ```
97
+ Embedded recovery opens Core in a popup and accepts a one-time code only from that window, the exact
98
+ Core origin and matching random state. The app exchanges and validates the code on its own server.
99
+ Standalone recovery uses Core navigation. No Core session credential is copied into either app.
100
+
101
+ Legacy signed clients can still use `validateDelegatedToken` from `@hosty-sdk/app/delegated` for
102
+ local signature/audience/expiry checking. An app login is not permission to mint cross-app MCP
103
+ tokens. Do not ask Shell for a delegated token; its old responder is disabled.
114
104
 
115
105
  The design contract lives in the Hosty repository:
116
106
  [`docs/features/hosty-app-sdk/feature.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/features/hosty-app-sdk/feature.md).
@@ -121,7 +111,7 @@ License: AGPL-3.0-only.
121
111
 
122
112
  Declare `"corePermissions": ["apps.install"]` in the manifest and have an administrator approve
123
113
  that permission when installing/updating your app. This grants the ability to **request** an
124
- installation; it never grants the ability to approve it on the user's behalf.
114
+ installation, update or removal; it never grants the ability to approve it on the user's behalf.
125
115
 
126
116
  Mount an app-local handler using the existing Hosty identity-cookie configuration:
127
117
 
@@ -174,15 +164,27 @@ Open an empty confirmation window synchronously in the click handler with
174
164
  `showInstallationConfirmation(popup, pending)`. Always retain a visible new-tab link for popup
175
165
  blocking. No installation-specific Shell messages or shared administrative credentials are needed.
176
166
  `prepare({ updateAppId, planDigest })` similarly requests approval of a reviewed update for clients
177
- with `apps.update`. A transport override supports existing Core operator clients:
167
+ with `apps.install`. A transport override supports existing Core operator clients:
178
168
  `createInstallationClient({ baseUrl: coreOrigin + "/api/installations", request: sendCsrfJson })`.
179
169
 
170
+ Removal uses the same flow: `prepare({ removeAppId, removalOptions: { deleteData: true,
171
+ deleteBackups: false } })`, then `submit(draft.id, {}, false)`, Core confirmation and status polling.
172
+ Core freezes the target installation and cleanup options during preparation; later payloads cannot
173
+ change them. `deleteRuntimeState` defaults to true; `deleteData`, `deleteBackups`, `deleteSource`
174
+ and `ignoreRuntimeErrors` default to false. Only report removal after status is `succeeded`.
175
+ App credentials cannot call the direct removal endpoint, even with `apps.install`.
176
+
180
177
  Core's final page cannot be embedded or replaced by a custom permission-grant dialog. It requires
181
178
  an administrator browser login issued on a dedicated Core hostname, separate from app cookie
182
179
  hosts. Existing sessions need a fresh login. Requests expire after 15 minutes and Core restart
183
180
  invalidates them. Closing your UI does not cancel a confirmed operation; status is the authority.
184
181
  Never automatically retry an execution after a lost response.
185
182
 
183
+ Optional permission choices belong exclusively to the Core confirmation page. The client cannot
184
+ preselect them: `submit` accepts only the request ID, settings and autostart. Core starts new optional
185
+ rights unchecked and checks rights already granted to that app. The user can grant or revoke them
186
+ there. App settings should display grants read-only and link to Core to change permissions.
187
+
186
188
  ## Assistant handoffs
187
189
 
188
190
  `@hosty-sdk/app/assistant` exposes the version-1 client. Discover the confirmed assistant's
@@ -221,3 +223,23 @@ PCM16 WAV. Browser microphone consent is separate from Core permission.
221
223
  `assistant(selected, getUserToken)` returns the existing `AssistantClient` with bounded provider
222
224
  credentials and the acting user's access. It does not expose general agent execution.
223
225
  See [Provider consumption](../../docs/features/provider-consumption/feature.md) for the full contract.
226
+
227
+ ### Assistant MCP credentials
228
+
229
+ MCP handlers use `introspectMcpToken` from `@hosty-sdk/app/scoped-token` for online validation of
230
+ external scoped tokens and Core's assistant-only `hosty_mcp.1` tokens. The latter require explicit
231
+ Core assistant-to-target grants and include `callerAppId` in the result. Require `mcp:read` for
232
+ reads or `mcp:invoke` for assistant mutations, then apply your application's user permissions.
233
+ Do not cache validation. Keep `introspectScopedToken` on ordinary APIs: it does not opt into accepting
234
+ MCP-only credentials. Neither token permits skipping the application's own authorization.
235
+
236
+ ## Privileged activity renewal
237
+
238
+ Identity probes used by `AppIdentityBridge` pass `activeUntil` and `activityRequired` through from
239
+ `resolveAppSession().identity`. Keep app APIs on `appFetch`; a 401 can then request a user-click
240
+ Core popup, exchange a code on the same origin and retry once without replacing mounted content.
241
+ For another UI framework, use `configureAppActivity` with an app-owned `exchangeCode` function,
242
+ listen for `APP_SESSION_ENDED`, and call `renewAppActivity` directly from a click. Do not navigate
243
+ or reload to renew access. `appActivityNeedsRenewal()` supports proactive click renewal near expiry.
244
+ See [the activity contract](../../docs/features/app-activity-window/feature.md), including the
245
+ separate Core-approved assistant session leases.
@@ -0,0 +1,7 @@
1
+ /** Keep mounted beside app content so expiry never discards drafts or component state. */
2
+ export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired }: {
3
+ openUrl: string;
4
+ appCodePath: string;
5
+ activeUntil?: string | null;
6
+ activityRequired?: boolean;
7
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { AuthNotice, AuthNoticeButton } from "./auth-notice.js";
5
+ import { APP_SESSION_ENDED, APP_ACTIVITY_RENEWED, appActivityNeedsRenewal, configureAppActivity, updateAppActivity, renewAppActivity, cancelActivityRenewal } from "./browser-auth.js";
6
+ /** Keep mounted beside app content so expiry never discards drafts or component state. */
7
+ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired = false }) {
8
+ const [needed, setNeeded] = useState(false);
9
+ const [pending, setPending] = useState(false);
10
+ const [error, setError] = useState("");
11
+ const renew = () => {
12
+ const operation = renewAppActivity();
13
+ setPending(true);
14
+ setError("");
15
+ void operation.catch(e => { setNeeded(true); setError(String(e.message ?? e)); }).finally(() => setPending(false));
16
+ };
17
+ useEffect(() => {
18
+ const cleanup = configureAppActivity({ openUrl, exchangeCode: async (code) => {
19
+ const response = await fetch(appCodePath, { method: "POST", credentials: "same-origin", redirect: "error",
20
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) });
21
+ const body = await response.json();
22
+ if (!response.ok)
23
+ throw new Error(body.message ?? "Could not renew app activity.");
24
+ return body;
25
+ } });
26
+ const expired = () => setNeeded(true);
27
+ const renewed = () => { setNeeded(false); setError(""); };
28
+ const click = (event) => {
29
+ if (event.target instanceof Element && event.target.closest("[data-hosty-activity-control]"))
30
+ return;
31
+ if (event.isTrusted && appActivityNeedsRenewal()) {
32
+ void renewAppActivity().catch(e => { setNeeded(true); setError(String(e.message ?? e)); });
33
+ }
34
+ };
35
+ window.addEventListener(APP_SESSION_ENDED, expired);
36
+ window.addEventListener(APP_ACTIVITY_RENEWED, renewed);
37
+ document.addEventListener("click", click, true);
38
+ return () => { cleanup(); window.removeEventListener(APP_SESSION_ENDED, expired); window.removeEventListener(APP_ACTIVITY_RENEWED, renewed); document.removeEventListener("click", click, true); };
39
+ }, [openUrl, appCodePath]);
40
+ useEffect(() => { updateAppActivity(activeUntil, activityRequired); }, [activeUntil, activityRequired, openUrl, appCodePath]);
41
+ if (!needed)
42
+ return null;
43
+ 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" })] });
44
+ }
@@ -1,3 +1,9 @@
1
+ /** Preserve provider refusal details through app-owned server transports. */
2
+ export declare class AssistantError extends Error {
3
+ readonly code: string;
4
+ readonly status: number;
5
+ constructor(code: string, message: string, status: number);
6
+ }
1
7
  /** Version 1 of the manifest-discovered assistant handoff. */
2
8
  export type AssistantContract = {
3
9
  version?: number | null;
package/dist/assistant.js CHANGED
@@ -1,3 +1,13 @@
1
+ /** Preserve provider refusal details through app-owned server transports. */
2
+ export class AssistantError extends Error {
3
+ code;
4
+ status;
5
+ constructor(code, message, status) {
6
+ super(message);
7
+ this.code = code;
8
+ this.status = status;
9
+ }
10
+ }
1
11
  export function assistantContractError(contract) {
2
12
  return contract.version !== 1 || !Array.isArray(contract.capabilities)
3
13
  ? "This assistant does not support the required assistant interface version 1." : null;
@@ -56,7 +66,7 @@ export class AssistantClient {
56
66
  continue;
57
67
  const body = await response.json().catch(() => null);
58
68
  if (!response.ok)
59
- throw new Error(body?.message || `Assistant request failed (${response.status}).`);
69
+ throw new AssistantError(body?.code ?? "assistant_failed", body?.message || `Assistant request failed (${response.status}).`, response.status);
60
70
  return body;
61
71
  }
62
72
  throw new Error("Assistant authorization expired.");
@@ -0,0 +1,9 @@
1
+ import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
2
+ /** Shared SDK chrome for sign-in and renewal, independent of the app's CSS framework. */
3
+ export declare function AuthNotice({ children }: {
4
+ children: ReactNode;
5
+ }): import("react").JSX.Element;
6
+ export declare function AuthNoticeButton({ secondary, disabled, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
7
+ secondary?: boolean;
8
+ }): import("react").JSX.Element;
9
+ export declare const authNoticeActionStyle: CSSProperties;
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /** Shared SDK chrome for sign-in and renewal, independent of the app's CSS framework. */
4
+ export function AuthNotice({ children }) {
5
+ return _jsx("div", { "data-hosty-activity-control": true, role: "status", style: barStyle, children: children });
6
+ }
7
+ export function AuthNoticeButton({ secondary = false, disabled, ...props }) {
8
+ return _jsx("button", { ...props, type: "button", disabled: disabled, style: {
9
+ ...authNoticeActionStyle,
10
+ ...(secondary ? { background: "transparent", color: "#422006", boxShadow: "inset 0 0 0 1px #a16207" } : {}),
11
+ ...(disabled ? { opacity: 0.65, cursor: "wait" } : {}),
12
+ } });
13
+ }
14
+ const barStyle = {
15
+ position: "fixed",
16
+ insetInline: 0,
17
+ bottom: 0,
18
+ zIndex: 2147483647,
19
+ display: "flex",
20
+ flexWrap: "wrap",
21
+ alignItems: "center",
22
+ justifyContent: "center",
23
+ gap: "12px",
24
+ padding: "12px 16px",
25
+ margin: 0,
26
+ border: 0,
27
+ borderTop: "1px solid #d97706",
28
+ boxSizing: "border-box",
29
+ background: "#fef3c7",
30
+ color: "#422006",
31
+ font: "500 14px/1.4 system-ui, sans-serif",
32
+ textAlign: "center",
33
+ boxShadow: "0 -2px 8px rgba(66,32,6,0.12)",
34
+ };
35
+ export const authNoticeActionStyle = {
36
+ display: "inline-block",
37
+ padding: "6px 14px",
38
+ margin: 0,
39
+ borderRadius: "8px",
40
+ background: "#422006",
41
+ color: "#fffbeb",
42
+ border: "none",
43
+ cursor: "pointer",
44
+ font: "650 14px/1.4 system-ui, sans-serif",
45
+ textDecoration: "none",
46
+ textTransform: "none",
47
+ letterSpacing: "normal",
48
+ };
@@ -0,0 +1,24 @@
1
+ export declare const APP_SESSION_ENDED = "hosty:app-session-ended";
2
+ export declare function rememberAppGrant(token: string): void;
3
+ export declare function forgetAppGrant(): void;
4
+ export declare function appSessionActive(): void;
5
+ /** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
6
+ export declare function appFetch(input: string | URL, init?: RequestInit, recover?: boolean): Promise<Response>;
7
+ /** Must be called directly from a user gesture. The popup belongs to this app frame, not Shell. */
8
+ export declare function openAppSignIn(openUrl: string, signal?: AbortSignal): Promise<string>;
9
+ export declare const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
10
+ export type AppActivityConfig = {
11
+ openUrl: string;
12
+ activeUntil?: string | null;
13
+ activityRequired?: boolean;
14
+ exchangeCode: (code: string) => Promise<{
15
+ accessToken?: string;
16
+ activeUntil?: string | null;
17
+ }>;
18
+ };
19
+ export declare function configureAppActivity(config: AppActivityConfig): () => void;
20
+ export declare function updateAppActivity(activeUntil?: string | null, activityRequired?: boolean): void;
21
+ export declare function cancelActivityRenewal(): void;
22
+ /** Call only in response to a user gesture. The original page is never navigated or reloaded. */
23
+ export declare function renewAppActivity(): Promise<void>;
24
+ export declare function appActivityNeedsRenewal(now?: number): boolean;
@@ -0,0 +1,150 @@
1
+ /** App-only browser credentials. Never persisted or sent to an embedder. */
2
+ let grant = null;
3
+ let recoveryPending = false;
4
+ export const APP_SESSION_ENDED = "hosty:app-session-ended";
5
+ export function rememberAppGrant(token) {
6
+ grant = { token, origin: window.location.origin };
7
+ recoveryPending = false;
8
+ }
9
+ export function forgetAppGrant() { grant = null; recoveryPending = false; }
10
+ export function appSessionActive() { recoveryPending = false; }
11
+ /** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
12
+ export async function appFetch(input, init = {}, recover = true) {
13
+ if (typeof window === "undefined")
14
+ return fetch(input, init);
15
+ const url = new URL(input, window.location.href);
16
+ if (url.origin !== window.location.origin || url.username || url.password)
17
+ throw new Error("App API requests must stay on this app's origin.");
18
+ const current = grant?.origin === url.origin ? grant : null;
19
+ const headers = new Headers(init.headers);
20
+ if (current)
21
+ headers.set("authorization", `Bearer ${current.token}`);
22
+ const response = await fetch(input, { ...init, headers, credentials: "same-origin", redirect: "error" });
23
+ if (response.status === 401 && recover && (!current || grant === current)) {
24
+ const body = await response.clone().json().catch(() => null);
25
+ if (activityConfig) {
26
+ if (body?.code !== "reauth_required" && current)
27
+ grant = null;
28
+ const renewed = waitForActivity(init.signal);
29
+ window.dispatchEvent(new Event(APP_SESSION_ENDED));
30
+ // A response may arrive while the originating click still has browser activation.
31
+ // Otherwise the inline button owns the next attempt; background work never opens a popup.
32
+ if (navigator.userActivation?.isActive)
33
+ void renewAppActivity().catch(() => undefined);
34
+ if (await renewed)
35
+ return appFetch(input, init, false);
36
+ }
37
+ else if (!recoveryPending) {
38
+ if (current)
39
+ grant = null;
40
+ recoveryPending = true;
41
+ window.dispatchEvent(new Event(APP_SESSION_ENDED));
42
+ }
43
+ }
44
+ return response;
45
+ }
46
+ /** Must be called directly from a user gesture. The popup belongs to this app frame, not Shell. */
47
+ export function openAppSignIn(openUrl, signal) {
48
+ const url = new URL(openUrl);
49
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
50
+ return Promise.reject(new Error("Hosty sign-in URL is invalid."));
51
+ const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
52
+ url.searchParams.set("responseMode", "web_message");
53
+ url.searchParams.set("state", state);
54
+ return new Promise((resolve, reject) => {
55
+ let popup = null;
56
+ let settled = false;
57
+ const finish = (code, error) => {
58
+ if (settled)
59
+ return;
60
+ settled = true;
61
+ window.removeEventListener("message", receive);
62
+ signal?.removeEventListener("abort", abort);
63
+ window.clearTimeout(timer);
64
+ clearInterval(closedCheck);
65
+ popup?.close();
66
+ if (code)
67
+ resolve(code);
68
+ else
69
+ reject(new Error(error ?? "Sign-in was cancelled."));
70
+ };
71
+ const receive = (event) => {
72
+ if (!popup || event.source !== popup || event.origin !== url.origin)
73
+ return;
74
+ const data = event.data;
75
+ if (data?.type !== "hosty:app-auth-code" || data.state !== state ||
76
+ typeof data.code !== "string" || !data.code || data.code.length > 4096)
77
+ return;
78
+ finish(data.code);
79
+ };
80
+ const abort = () => finish();
81
+ const timer = window.setTimeout(() => finish(undefined, "Sign-in timed out. Please try again."), 180_000);
82
+ const closedCheck = setInterval(() => { if (popup?.closed)
83
+ finish(); }, 500);
84
+ window.addEventListener("message", receive);
85
+ signal?.addEventListener("abort", abort, { once: true });
86
+ if (signal?.aborted) {
87
+ finish();
88
+ return;
89
+ }
90
+ popup = window.open(url.href, "_blank", "popup,width=520,height=700");
91
+ if (!popup)
92
+ finish(undefined, "Allow the sign-in popup and try again.");
93
+ });
94
+ }
95
+ export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
96
+ let activityConfig = null;
97
+ let renewal = null;
98
+ const waiting = new Set();
99
+ export function configureAppActivity(config) {
100
+ activityConfig = config;
101
+ return () => { if (activityConfig === config) {
102
+ activityConfig = null;
103
+ cancelActivityRenewal();
104
+ } };
105
+ }
106
+ export function updateAppActivity(activeUntil, activityRequired) {
107
+ if (activityConfig) {
108
+ activityConfig.activeUntil = activeUntil;
109
+ activityConfig.activityRequired = activityRequired;
110
+ }
111
+ }
112
+ export function cancelActivityRenewal() { for (const done of [...waiting])
113
+ done(false); }
114
+ function waitForActivity(signal) {
115
+ return new Promise(resolve => {
116
+ const done = (ok) => { waiting.delete(done); signal?.removeEventListener("abort", abort); resolve(ok); };
117
+ const abort = () => done(false);
118
+ waiting.add(done);
119
+ if (signal?.aborted)
120
+ done(false);
121
+ else
122
+ signal?.addEventListener("abort", abort, { once: true });
123
+ });
124
+ }
125
+ /** Call only in response to a user gesture. The original page is never navigated or reloaded. */
126
+ export function renewAppActivity() {
127
+ if (renewal)
128
+ return renewal;
129
+ const config = activityConfig;
130
+ if (!config)
131
+ return Promise.reject(new Error("App recovery is not configured."));
132
+ const popup = openAppSignIn(config.openUrl);
133
+ renewal = popup.then(async (code) => {
134
+ const result = await config.exchangeCode(code);
135
+ if (result.accessToken)
136
+ rememberAppGrant(result.accessToken);
137
+ config.activeUntil = result.activeUntil;
138
+ recoveryPending = false;
139
+ for (const done of [...waiting])
140
+ done(true);
141
+ window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
142
+ }).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; });
143
+ return renewal;
144
+ }
145
+ export function appActivityNeedsRenewal(now = Date.now()) {
146
+ if (!activityConfig?.activityRequired)
147
+ return false;
148
+ const until = Date.parse(activityConfig.activeUntil ?? "");
149
+ return !Number.isFinite(until) || until - now < 10 * 60_000;
150
+ }
@@ -82,3 +82,10 @@ export declare function createReissueRateLimiter(minIntervalMs: number, now?: ()
82
82
  * a listener. Re-derived on every launch and every page switch, so it is never stale.
83
83
  */
84
84
  export declare function appendThemeLaunchParams(redirectUri: string, theme: HostyResolvedTheme, preference: HostyThemePreference): string;
85
+ /** Return the trusted mounted app id, ignoring any id or URL claimed by the payload. */
86
+ export declare function parseActiveFramePermissionReview(event: EmbedderMessage, frame: unknown, url: string, appId: string): string | null;
87
+ /** An assistant frame may request its own session review, never another app's. */
88
+ export declare function parseActiveFrameAssistantReview(event: EmbedderMessage, frame: unknown, url: string, appId: string): {
89
+ appId: string;
90
+ sessionId: string;
91
+ } | null;
package/dist/embedder.js CHANGED
@@ -151,3 +151,17 @@ export function appendThemeLaunchParams(redirectUri, theme, preference) {
151
151
  url.searchParams.set(THEME_PREFERENCE_PARAM, preference);
152
152
  return url.toString();
153
153
  }
154
+ /** Return the trusted mounted app id, ignoring any id or URL claimed by the payload. */
155
+ export function parseActiveFramePermissionReview(event, frame, url, appId) {
156
+ if (!isActiveFrameMessage(event, frame, url))
157
+ return null;
158
+ return event.data.type === "hosty:request-permission-review" ? appId : null;
159
+ }
160
+ /** An assistant frame may request its own session review, never another app's. */
161
+ export function parseActiveFrameAssistantReview(event, frame, url, appId) {
162
+ if (!isActiveFrameMessage(event, frame, url))
163
+ return null;
164
+ const data = event.data;
165
+ return data.type === "hosty:request-assistant-authority" && typeof data.sessionId === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(data.sessionId)
166
+ ? { appId, sessionId: data.sessionId } : null;
167
+ }
@@ -64,7 +64,7 @@ export function InstallDialog({ client, source, onClose, onInstalled }) {
64
64
  else
65
65
  popup?.close();
66
66
  };
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. Only host administrators can open it." }), !!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
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
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
69
  : setting.type === "select" && setting.options?.length && !setting.secret
70
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))] })
package/dist/install.d.ts CHANGED
@@ -47,11 +47,61 @@ export interface InstallationSource {
47
47
  planDigest?: string;
48
48
  /** Review permission declarations and optional grants for this installed app, without reinstalling it. */
49
49
  permissionsAppId?: string;
50
+ /** Request removal of this installed app. Core freezes these options for its confirmation page. */
51
+ removeAppId?: string;
52
+ removalOptions?: {
53
+ deleteRuntimeState?: boolean;
54
+ deleteData?: boolean;
55
+ deleteBackups?: boolean;
56
+ deleteSource?: boolean;
57
+ ignoreRuntimeErrors?: boolean;
58
+ };
59
+ hostPathChange?: {
60
+ kind: "source-override";
61
+ appId: string;
62
+ source: {
63
+ path: string;
64
+ commit?: string;
65
+ };
66
+ } | {
67
+ kind: "app-mounts";
68
+ appId: string;
69
+ mounts: {
70
+ mounts: {
71
+ key: string;
72
+ label?: string | null;
73
+ hostPath?: string | null;
74
+ globalMountName?: string | null;
75
+ }[];
76
+ };
77
+ } | {
78
+ kind: "global-mount";
79
+ globalMount: {
80
+ name: string;
81
+ hostPath: string;
82
+ mode?: string;
83
+ description?: string | null;
84
+ };
85
+ };
86
+ /** Personal source selection requires apps.sources as well as apps.install and Core review. */
87
+ sourceConnections?: {
88
+ manifestConnectionId?: string;
89
+ gitConnectionId?: string;
90
+ clearManifestConnection?: boolean;
91
+ clearGitConnection?: boolean;
92
+ };
50
93
  }
51
94
  export interface InstallationRequest {
52
95
  id: string;
53
96
  status: "draft" | "pending" | "executing" | "succeeded" | "denied" | "failed";
54
97
  plan: InstallPlan | null;
98
+ updatePlan?: {
99
+ appId: string;
100
+ displayName: string;
101
+ targetVersion: string;
102
+ targetRuntime: string;
103
+ changes: string[];
104
+ } | null;
55
105
  permissionPlan?: {
56
106
  appId: string;
57
107
  displayName: string;
@@ -61,13 +111,24 @@ export interface InstallationRequest {
61
111
  acceptedRequired?: string[];
62
112
  acceptedOptional?: string[];
63
113
  } | null;
114
+ removalPlan?: {
115
+ appId: string;
116
+ displayName: string;
117
+ version: string;
118
+ options: NonNullable<InstallationSource["removalOptions"]>;
119
+ } | null;
120
+ hostPathPlan?: {
121
+ change: InstallationSource["hostPathChange"];
122
+ displayName: string;
123
+ details: string[];
124
+ } | null;
64
125
  approvalUrl: string;
65
126
  expiresAt: string;
66
127
  error?: string | null;
67
128
  }
68
129
  export interface InstallationClient {
69
130
  prepare(source: InstallationSource): Promise<InstallationRequest>;
70
- submit(id: string, settings: Record<string, string | null>, autostart: boolean, optionalPermissions?: string[]): Promise<InstallationRequest>;
131
+ submit(id: string, settings: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest>;
71
132
  status(id: string): Promise<InstallationRequest>;
72
133
  }
73
134
  export declare class InstallationError extends Error {
package/dist/install.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { appFetch } from "./browser-auth.js";
1
2
  export class InstallationError extends Error {
2
3
  status;
3
4
  code;
@@ -9,7 +10,7 @@ export class InstallationError extends Error {
9
10
  }
10
11
  export function createInstallationClient(options = {}) {
11
12
  const base = (options.baseUrl ?? "/api/hosty/installations").replace(/\/$/, "");
12
- const request = options.request ?? ((url, body, method = "POST") => fetch(url, {
13
+ const request = options.request ?? ((url, body, method = "POST") => appFetch(url, {
13
14
  method, credentials: "same-origin", cache: "no-store",
14
15
  headers: { "Content-Type": "application/json" },
15
16
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -25,7 +26,7 @@ export function createInstallationClient(options = {}) {
25
26
  }
26
27
  return {
27
28
  prepare: source => call("", source),
28
- submit: (id, settings, autostart, optionalPermissions) => call(`/${encodeURIComponent(id)}/submit`, { settings, autostart, ...(optionalPermissions === undefined ? {} : { optionalPermissions }) }),
29
+ submit: (id, settings, autostart) => call(`/${encodeURIComponent(id)}/submit`, { settings, autostart }),
29
30
  status: id => call(`/${encodeURIComponent(id)}`, undefined, "GET"),
30
31
  };
31
32
  }
@@ -0,0 +1,7 @@
1
+ import type { AppPermissionState } from "./providers.js";
2
+ export type PermissionNoticeState = {
3
+ hostRole: string | null;
4
+ appId: string;
5
+ corePublicOrigin: string | null;
6
+ permissions: AppPermissionState | null;
7
+ };
@@ -0,0 +1 @@
1
+ export {};