@hosty-sdk/app 0.17.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 +54 -32
- package/dist/activity-react.d.ts +7 -0
- package/dist/activity-react.js +44 -0
- package/dist/assistant.d.ts +6 -0
- package/dist/assistant.js +11 -1
- package/dist/auth-notice.d.ts +9 -0
- package/dist/auth-notice.js +48 -0
- package/dist/browser-auth.d.ts +24 -0
- package/dist/browser-auth.js +150 -0
- package/dist/embedder.d.ts +7 -0
- package/dist/embedder.js +14 -0
- package/dist/install-react.js +1 -1
- package/dist/install.d.ts +64 -1
- package/dist/install.js +2 -1
- package/dist/permission-types.d.ts +7 -0
- package/dist/permission-types.js +1 -0
- package/dist/permissions-react.d.ts +4 -0
- package/dist/permissions-react.js +48 -0
- package/dist/permissions-server.d.ts +6 -0
- package/dist/permissions-server.js +13 -0
- package/dist/permissions.d.ts +8 -0
- package/dist/permissions.js +25 -0
- package/dist/providers-server.d.ts +2 -2
- package/dist/providers-server.js +2 -2
- package/dist/providers.d.ts +3 -0
- package/dist/react.d.ts +7 -2
- package/dist/react.js +57 -57
- package/dist/scoped-token.d.ts +5 -0
- package/dist/scoped-token.js +11 -1
- package/dist/server.d.ts +5 -3
- package/dist/server.js +18 -10
- package/package.json +22 -2
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:
|
|
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,
|
|
16
|
-
| `@hosty-sdk/app/embedder` | client |
|
|
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
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
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
|
+
}
|
package/dist/assistant.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/embedder.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/install-react.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -45,19 +45,82 @@ export interface InstallationSource {
|
|
|
45
45
|
selectedRuntime?: string;
|
|
46
46
|
updateAppId?: string;
|
|
47
47
|
planDigest?: string;
|
|
48
|
-
/** Review optional grants for this installed app, without reinstalling it. */
|
|
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;
|
|
58
108
|
required: string[];
|
|
59
109
|
optional: string[];
|
|
60
110
|
granted: string[];
|
|
111
|
+
acceptedRequired?: string[];
|
|
112
|
+
acceptedOptional?: string[];
|
|
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[];
|
|
61
124
|
} | null;
|
|
62
125
|
approvalUrl: string;
|
|
63
126
|
expiresAt: string;
|
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") =>
|
|
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),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { appFetch } from "./browser-auth.js";
|
|
5
|
+
import { permissionNotice, requestPermissionReview } from "./permissions.js";
|
|
6
|
+
/** App-owned GET endpoint authenticates the viewer and calls readOwnPermissionNotice. */
|
|
7
|
+
export function MissingPermissionsNotice({ endpoint = "/api/hosty/permissions" }) {
|
|
8
|
+
const [state, setState] = useState(null);
|
|
9
|
+
const [dismissed, setDismissed] = useState(false);
|
|
10
|
+
const [pending, setPending] = useState(false);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
let loading = false;
|
|
14
|
+
const refresh = async () => {
|
|
15
|
+
if (loading)
|
|
16
|
+
return;
|
|
17
|
+
loading = true;
|
|
18
|
+
try {
|
|
19
|
+
const response = await appFetch(endpoint, { cache: "no-store", signal: controller.signal });
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
setState(null);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const next = await response.json();
|
|
25
|
+
if (!controller.signal.aborted) {
|
|
26
|
+
setState(next);
|
|
27
|
+
if (!permissionNotice(next))
|
|
28
|
+
setPending(false);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { /* A transient failure must not erase a known setup warning. */ }
|
|
32
|
+
finally {
|
|
33
|
+
loading = false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
void refresh();
|
|
37
|
+
window.addEventListener("focus", refresh);
|
|
38
|
+
const timer = pending ? window.setInterval(refresh, 2000) : undefined;
|
|
39
|
+
return () => { controller.abort(); window.removeEventListener("focus", refresh); window.clearInterval(timer); };
|
|
40
|
+
}, [endpoint, pending]);
|
|
41
|
+
const kind = state && permissionNotice(state);
|
|
42
|
+
if (dismissed || !kind || !state)
|
|
43
|
+
return null;
|
|
44
|
+
return _jsxs("section", { "data-hosty-activity-control": true, role: "alert", "aria-label": "Required permissions", style: { border: "1px solid currentColor", padding: 12, margin: 8, borderRadius: 8, color: "#c2410c" }, children: [kind === "unsupported"
|
|
45
|
+
? _jsxs("p", { children: ["Core does not support required permissions: ", state.permissions.unsupportedRequired.join(", "), ". Update the app or Core."] })
|
|
46
|
+
: _jsx("p", { children: "This app needs required permissions. Operations using them are unavailable until approval." }), kind === "missing" && state.permissions?.reviewAvailable !== false && state.corePublicOrigin &&
|
|
47
|
+
_jsx("button", { type: "button", onClick: () => { requestPermissionReview(state); setPending(true); }, children: "Request permissions" }), pending && _jsx("p", { children: "Review permissions in Core, then return here. You can leave this app installed without approving." }), _jsx("button", { type: "button", onClick: () => { setDismissed(true); setPending(false); }, style: { marginLeft: 12 }, children: "Dismiss" })] });
|
|
48
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ProviderServerOptions } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
import type { PermissionNoticeState } from "./permission-types.js";
|
|
3
|
+
/** Server-only: pass the role from an authenticated session, never from request input. */
|
|
4
|
+
export declare function readOwnPermissionNotice(hostRole: string | null, options?: ProviderServerOptions & {
|
|
5
|
+
corePublicOrigin?: string | null;
|
|
6
|
+
}, signal?: AbortSignal): Promise<PermissionNoticeState>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ProviderClient } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
/** Server-only: pass the role from an authenticated session, never from request input. */
|
|
3
|
+
export async function readOwnPermissionNotice(hostRole, options = {}, signal) {
|
|
4
|
+
const state = {
|
|
5
|
+
hostRole,
|
|
6
|
+
appId: options.appId ?? process.env.HOSTY_APP_ID ?? "",
|
|
7
|
+
corePublicOrigin: options.corePublicOrigin ?? process.env.HOSTY_CORE_PUBLIC_ORIGIN ?? null,
|
|
8
|
+
permissions: null,
|
|
9
|
+
};
|
|
10
|
+
if (hostRole === "host.admin")
|
|
11
|
+
state.permissions = await new ProviderClient(options).permissions(signal);
|
|
12
|
+
return state;
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PermissionNoticeState } from "./permission-types.js";
|
|
2
|
+
export type { PermissionNoticeState } from "./permission-types.js";
|
|
3
|
+
export declare const PERMISSION_REVIEW_REQUEST = "hosty:request-permission-review";
|
|
4
|
+
/** No optional declaration, or declaration alone, confers authority. */
|
|
5
|
+
export declare function permissionNotice(state: PermissionNoticeState): "missing" | "unsupported" | null;
|
|
6
|
+
export declare function permissionReviewUrl(corePublicOrigin: string, appId: string): string;
|
|
7
|
+
/** Invoke synchronously from a user click. The embedder determines the app from its frame. */
|
|
8
|
+
export declare function requestPermissionReview(state: PermissionNoticeState, browser?: Pick<Window, "parent" | "open">): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const PERMISSION_REVIEW_REQUEST = "hosty:request-permission-review";
|
|
2
|
+
/** No optional declaration, or declaration alone, confers authority. */
|
|
3
|
+
export function permissionNotice(state) {
|
|
4
|
+
if (state.hostRole !== "host.admin" || !state.permissions)
|
|
5
|
+
return null;
|
|
6
|
+
const p = state.permissions;
|
|
7
|
+
if (p.unsupportedRequired?.length)
|
|
8
|
+
return "unsupported";
|
|
9
|
+
return p.required.some(name => !p.granted.includes(name)) ? "missing" : null;
|
|
10
|
+
}
|
|
11
|
+
export function permissionReviewUrl(corePublicOrigin, appId) {
|
|
12
|
+
const core = new URL(corePublicOrigin);
|
|
13
|
+
if (!["http:", "https:"].includes(core.protocol) || core.username || core.password)
|
|
14
|
+
throw new Error("Invalid Core origin");
|
|
15
|
+
return new URL(`/install/permissions/${encodeURIComponent(appId)}`, core.origin).href;
|
|
16
|
+
}
|
|
17
|
+
/** Invoke synchronously from a user click. The embedder determines the app from its frame. */
|
|
18
|
+
export function requestPermissionReview(state, browser = window) {
|
|
19
|
+
if (permissionNotice(state) !== "missing" || state.permissions?.reviewAvailable === false || !state.corePublicOrigin)
|
|
20
|
+
return;
|
|
21
|
+
if (browser.parent !== browser)
|
|
22
|
+
browser.parent.postMessage({ type: PERMISSION_REVIEW_REQUEST }, "*");
|
|
23
|
+
else
|
|
24
|
+
browser.open(permissionReviewUrl(state.corePublicOrigin, state.appId), "_blank", "noopener,noreferrer");
|
|
25
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AssistantClient } from "
|
|
2
|
-
import { type AppPermissionState, type ProviderDescriptor, type ProviderInvocation, type ProviderKind, type SpeechCapabilities, type SpeechResult } from "
|
|
1
|
+
import { AssistantClient } from "@hosty-sdk/app/assistant";
|
|
2
|
+
import { type AppPermissionState, type ProviderDescriptor, type ProviderInvocation, type ProviderKind, type SpeechCapabilities, type SpeechResult } from "@hosty-sdk/app/providers";
|
|
3
3
|
export type ProviderServerOptions = {
|
|
4
4
|
appId?: string;
|
|
5
5
|
coreOrigin?: string;
|
package/dist/providers-server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AssistantClient } from "
|
|
2
|
-
import { ProviderError } from "
|
|
1
|
+
import { AssistantClient } from "@hosty-sdk/app/assistant";
|
|
2
|
+
import { ProviderError } from "@hosty-sdk/app/providers";
|
|
3
3
|
/** Server-only provider client. Its app service token must never be sent to a browser or provider. */
|
|
4
4
|
export class ProviderClient {
|
|
5
5
|
appId;
|
package/dist/providers.d.ts
CHANGED
package/dist/react.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type AppIdentityBridgeState = {
|
|
|
34
34
|
kind: "signin";
|
|
35
35
|
openUrl: string | null;
|
|
36
36
|
embedded: boolean;
|
|
37
|
+
signIn?: () => void;
|
|
38
|
+
error?: string;
|
|
37
39
|
} | {
|
|
38
40
|
kind: "denied";
|
|
39
41
|
} | {
|
|
@@ -48,15 +50,16 @@ export interface AppIdentityBridgeProps {
|
|
|
48
50
|
appCodePath?: string;
|
|
49
51
|
/** Optional app-owned rendering, including loading and active content. Recovery remains SDK-owned. */
|
|
50
52
|
renderState?: (state: AppIdentityBridgeState) => ReactNode;
|
|
53
|
+
children?: ReactNode;
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
56
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
54
57
|
* the URL (exchanging it for the identity cookie), then probes the session and runs the
|
|
55
|
-
* recovery decision:
|
|
58
|
+
* recovery decision: an app-owned Core popup when embedded, a once-per-tab
|
|
56
59
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
57
60
|
* — never a login UI while recovery is still running.
|
|
58
61
|
*/
|
|
59
|
-
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, }?: AppIdentityBridgeProps): string | number | bigint | boolean |
|
|
62
|
+
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, 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;
|
|
60
63
|
export interface HostThemeBridgeProps {
|
|
61
64
|
/**
|
|
62
65
|
* Whether the operating system decides the theme while no shell has spoken — `true` unless the
|
|
@@ -85,3 +88,5 @@ export interface HostThemeBridgeProps {
|
|
|
85
88
|
* choice is not overruled by the OS.
|
|
86
89
|
*/
|
|
87
90
|
export declare function HostThemeBridge({ followSystem, onTheme }?: HostThemeBridgeProps): null;
|
|
91
|
+
export { MissingPermissionsNotice } from "./permissions-react.js";
|
|
92
|
+
export { AppActivityBridge } from "./activity-react.js";
|
package/dist/react.js
CHANGED
|
@@ -3,15 +3,15 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
// React slice: the client identity bridge — probe, silent recovery, and the fallback
|
|
4
4
|
// cards. Prop-less by design: the app id and browser-reachable Core origin arrive in the
|
|
5
5
|
// probe response's `recovery` field (request-time values, never build-time props).
|
|
6
|
+
import { AppActivityBridge } from "./activity-react.js";
|
|
7
|
+
import { AuthNotice, AuthNoticeButton, authNoticeActionStyle } from "./auth-notice.js";
|
|
6
8
|
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { appFetch, appSessionActive, rememberAppGrant, openAppSignIn, APP_SESSION_ENDED } from "./browser-auth.js";
|
|
7
10
|
import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
|
|
8
11
|
import { applyTheme, parseShellThemeMessage, resolveTheme, THEME_PARAM, THEME_PREFERENCE_PARAM, THEME_PREFERENCE_STORAGE_KEY, THEME_STORAGE_KEY, } from "./theme.js";
|
|
9
12
|
// Once-per-tab guard so a standalone app that returns from Core still unauthorized does
|
|
10
13
|
// not bounce through /open forever. Cleared on a successful code exchange.
|
|
11
14
|
const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
|
|
12
|
-
// How long an embedded frame waits for the Shell to reissue a launch code before showing
|
|
13
|
-
// the manual sign-in fallback (i.e. it is embedded by something other than Hosty Shell).
|
|
14
|
-
const EMBEDDED_RECOVERY_TIMEOUT_MS = 4_000;
|
|
15
15
|
// Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
|
|
16
16
|
// timeout it classifies as unavailable and the user gets a Retry affordance.
|
|
17
17
|
const IDENTITY_PROBE_TIMEOUT_MS = 4_000;
|
|
@@ -144,14 +144,16 @@ export function useLaunchMode() {
|
|
|
144
144
|
/**
|
|
145
145
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
146
146
|
* the URL (exchanging it for the identity cookie), then probes the session and runs the
|
|
147
|
-
* recovery decision:
|
|
147
|
+
* recovery decision: an app-owned Core popup when embedded, a once-per-tab
|
|
148
148
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
149
149
|
* — never a login UI while recovery is still running.
|
|
150
150
|
*/
|
|
151
|
-
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, } = {}) {
|
|
151
|
+
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, children, } = {}) {
|
|
152
152
|
const [ui, setUi] = useState({ kind: "recovering" });
|
|
153
153
|
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
154
154
|
// keep its exchange alive and let the replacement effect await the same response.
|
|
155
|
+
const wasActive = useRef(false);
|
|
156
|
+
const [activity, setActivity] = useState(null);
|
|
155
157
|
const exchangeRef = useRef(null);
|
|
156
158
|
useEffect(() => {
|
|
157
159
|
let cancelled = false;
|
|
@@ -168,16 +170,19 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
168
170
|
let openUrl = null;
|
|
169
171
|
let appId = null;
|
|
170
172
|
try {
|
|
171
|
-
const response = await
|
|
173
|
+
const response = await appFetch(probePath, {
|
|
172
174
|
headers: { Accept: "application/json" },
|
|
173
175
|
cache: "no-store",
|
|
174
176
|
signal: probeController.signal,
|
|
175
|
-
});
|
|
177
|
+
}, false);
|
|
176
178
|
const body = await response.json().catch(() => null);
|
|
177
179
|
status = readProbedSessionStatus(body);
|
|
178
180
|
const recovery = readRecoveryParams(body);
|
|
179
181
|
appId = recovery.appId;
|
|
180
182
|
openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
|
|
183
|
+
const metadata = body;
|
|
184
|
+
if (!cancelled && openUrl)
|
|
185
|
+
setActivity({ openUrl, activeUntil: metadata?.activeUntil, activityRequired: metadata?.activityRequired });
|
|
181
186
|
}
|
|
182
187
|
catch {
|
|
183
188
|
// A failed or timed-out probe (Core unreachable) classifies as unavailable below.
|
|
@@ -201,25 +206,33 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
201
206
|
});
|
|
202
207
|
switch (action.kind) {
|
|
203
208
|
case "none":
|
|
209
|
+
appSessionActive();
|
|
210
|
+
wasActive.current = true;
|
|
204
211
|
setUi({ kind: "active" });
|
|
205
212
|
return;
|
|
206
213
|
case "post-auth-required": {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
214
|
+
const showSignIn = (error) => setUi({ kind: "signin", openUrl, embedded: true, error,
|
|
215
|
+
signIn: openUrl ? () => {
|
|
216
|
+
// Open synchronously in the click handler; an awaited request loses the user gesture.
|
|
217
|
+
const result = openAppSignIn(openUrl, controller.signal);
|
|
218
|
+
setUi({ kind: "recovering" });
|
|
219
|
+
void result.then(async (code) => {
|
|
220
|
+
const response = await appFetch(appCodePath, {
|
|
221
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
222
|
+
body: JSON.stringify({ code }),
|
|
223
|
+
});
|
|
224
|
+
const body = await response.json();
|
|
225
|
+
if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
|
|
226
|
+
throw new Error("Hosty could not establish this app session.");
|
|
227
|
+
if (cancelled)
|
|
228
|
+
return;
|
|
229
|
+
rememberAppGrant(body.accessToken);
|
|
230
|
+
writeGuard(false);
|
|
231
|
+
await probeAndRecover();
|
|
232
|
+
}).catch(error => { if (!cancelled)
|
|
233
|
+
showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
|
|
234
|
+
} : undefined });
|
|
235
|
+
showSignIn();
|
|
223
236
|
return;
|
|
224
237
|
}
|
|
225
238
|
case "redirect":
|
|
@@ -233,6 +246,11 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
233
246
|
return;
|
|
234
247
|
}
|
|
235
248
|
}
|
|
249
|
+
const recover = () => { if (!cancelled && !wasActive.current) {
|
|
250
|
+
setUi({ kind: "recovering" });
|
|
251
|
+
void probeAndRecover();
|
|
252
|
+
} };
|
|
253
|
+
window.addEventListener(APP_SESSION_ENDED, recover);
|
|
236
254
|
const url = new URL(window.location.href);
|
|
237
255
|
const code = url.searchParams.get("code")?.trim();
|
|
238
256
|
if (code) {
|
|
@@ -250,13 +268,17 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
250
268
|
const exchange = exchangeRef.current;
|
|
251
269
|
if (exchange && exchange.path === appCodePath) {
|
|
252
270
|
void exchange.response
|
|
253
|
-
.then((response) => {
|
|
271
|
+
.then(async (response) => {
|
|
254
272
|
if (cancelled) {
|
|
255
273
|
return;
|
|
256
274
|
}
|
|
257
275
|
if (response.ok) {
|
|
258
276
|
writeGuard(false);
|
|
259
|
-
|
|
277
|
+
const body = await response.json().catch(() => null);
|
|
278
|
+
if (typeof body?.accessToken === "string")
|
|
279
|
+
rememberAppGrant(body.accessToken);
|
|
280
|
+
exchangeRef.current = null;
|
|
281
|
+
await probeAndRecover();
|
|
260
282
|
}
|
|
261
283
|
else {
|
|
262
284
|
exchangeRef.current = null;
|
|
@@ -276,43 +298,19 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
276
298
|
return () => {
|
|
277
299
|
cancelled = true;
|
|
278
300
|
controller.abort();
|
|
301
|
+
window.removeEventListener(APP_SESSION_ENDED, recover);
|
|
279
302
|
};
|
|
280
303
|
}, [probePath, appCodePath]);
|
|
304
|
+
if (wasActive.current)
|
|
305
|
+
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
281
306
|
if (renderState)
|
|
282
307
|
return renderState(ui);
|
|
283
|
-
if (ui.kind === "
|
|
284
|
-
return null;
|
|
285
|
-
|
|
286
|
-
|
|
308
|
+
if (ui.kind === "active")
|
|
309
|
+
return children ?? null;
|
|
310
|
+
if (ui.kind === "recovering")
|
|
311
|
+
return _jsx(AuthNotice, { children: "Connecting to Hosty\u2026" });
|
|
312
|
+
return (_jsx(AuthNotice, { children: ui.kind === "signin" ? (_jsxs(_Fragment, { children: [_jsx("span", { children: ui.error ?? "Sign in through Hosty to use this app." }), ui.signIn ? _jsx(AuthNoticeButton, { onClick: ui.signIn, children: "Sign in via Hosty" }) : ui.openUrl ? (_jsx("a", { href: ui.openUrl, ...(ui.embedded ? { target: "_blank", rel: "noopener noreferrer" } : {}), style: authNoticeActionStyle, children: "Sign in via Hosty" })) : (_jsx("span", { children: "Open this app from the machine running Hosty, or configure a public origin." }))] })) : ui.kind === "denied" ? (_jsx("span", { children: "You are signed in to Hosty but are not allowed to use this app." })) : ui.kind === "misconfigured" ? (_jsx("span", { children: "This app is not configured correctly on the host. Contact the administrator." })) : (_jsxs(_Fragment, { children: [_jsx("span", { children: "Can\u2019t reach Hosty right now." }), _jsx(AuthNoticeButton, { onClick: () => window.location.reload(), children: "Retry" })] })) }));
|
|
287
313
|
}
|
|
288
|
-
const barStyle = {
|
|
289
|
-
position: "fixed",
|
|
290
|
-
insetInline: 0,
|
|
291
|
-
bottom: 0,
|
|
292
|
-
zIndex: 2147483647,
|
|
293
|
-
display: "flex",
|
|
294
|
-
flexWrap: "wrap",
|
|
295
|
-
alignItems: "center",
|
|
296
|
-
justifyContent: "center",
|
|
297
|
-
gap: "0.75rem",
|
|
298
|
-
padding: "0.75rem 1rem",
|
|
299
|
-
background: "#111827",
|
|
300
|
-
color: "#f9fafb",
|
|
301
|
-
font: "500 0.875rem/1.4 system-ui, sans-serif",
|
|
302
|
-
boxShadow: "0 -1px 0 rgba(255,255,255,0.08)",
|
|
303
|
-
};
|
|
304
|
-
const actionStyle = {
|
|
305
|
-
display: "inline-block",
|
|
306
|
-
padding: "0.4rem 0.85rem",
|
|
307
|
-
borderRadius: "0.5rem",
|
|
308
|
-
background: "#f9fafb",
|
|
309
|
-
color: "#111827",
|
|
310
|
-
border: "none",
|
|
311
|
-
cursor: "pointer",
|
|
312
|
-
font: "inherit",
|
|
313
|
-
fontWeight: 650,
|
|
314
|
-
textDecoration: "none",
|
|
315
|
-
};
|
|
316
314
|
function readStoredTheme(key) {
|
|
317
315
|
try {
|
|
318
316
|
return window.sessionStorage.getItem(key);
|
|
@@ -411,3 +409,5 @@ export function HostThemeBridge({ followSystem = true, onTheme } = {}) {
|
|
|
411
409
|
}, []);
|
|
412
410
|
return null;
|
|
413
411
|
}
|
|
412
|
+
export { MissingPermissionsNotice } from "./permissions-react.js";
|
|
413
|
+
export { AppActivityBridge } from "./activity-react.js";
|
package/dist/scoped-token.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type ScopedTokenIntrospection = {
|
|
|
5
5
|
sub: string;
|
|
6
6
|
role: string | null;
|
|
7
7
|
scopes: string[];
|
|
8
|
+
callerAppId?: string;
|
|
8
9
|
} | {
|
|
9
10
|
active: false;
|
|
10
11
|
error?: ScopedTokenError;
|
|
@@ -40,9 +41,13 @@ export interface IntrospectScopedTokenOptions {
|
|
|
40
41
|
* window that would take back.
|
|
41
42
|
*/
|
|
42
43
|
export declare function introspectScopedToken(token: string, options?: IntrospectScopedTokenOptions): Promise<ScopedTokenIntrospection>;
|
|
44
|
+
/** MCP handlers only. Ordinary APIs must use introspectScopedToken, which rejects MCP-only tokens. */
|
|
45
|
+
export declare function introspectMcpToken(token: string, options?: IntrospectScopedTokenOptions): Promise<ScopedTokenIntrospection>;
|
|
43
46
|
/** Whether an introspection result carries a scope. Exact match, because a scope is a protocol
|
|
44
47
|
* constant rather than text a caller should normalize. */
|
|
45
48
|
export declare function hasScope(result: ScopedTokenIntrospection, scope: string): boolean;
|
|
46
49
|
/** The scope every read-only MCP tool is gated on today. Mutation scopes arrive with the feature
|
|
47
50
|
* that introduces mutations. */
|
|
48
51
|
export declare const SCOPE_MCP_READ = "mcp:read";
|
|
52
|
+
/** A Core-authorized assistant may invoke MCP operations subject to the target user policy. */
|
|
53
|
+
export declare const SCOPE_MCP_INVOKE = "mcp:invoke";
|
package/dist/scoped-token.js
CHANGED
|
@@ -20,6 +20,13 @@ const DEFAULT_TIMEOUT_MS = 2_000;
|
|
|
20
20
|
* window that would take back.
|
|
21
21
|
*/
|
|
22
22
|
export async function introspectScopedToken(token, options = {}) {
|
|
23
|
+
return introspect(token, options);
|
|
24
|
+
}
|
|
25
|
+
/** MCP handlers only. Ordinary APIs must use introspectScopedToken, which rejects MCP-only tokens. */
|
|
26
|
+
export async function introspectMcpToken(token, options = {}) {
|
|
27
|
+
return introspect(token, options, "mcp");
|
|
28
|
+
}
|
|
29
|
+
async function introspect(token, options, purpose) {
|
|
23
30
|
const appId = options.appId?.trim() || process.env.HOSTY_APP_ID?.trim();
|
|
24
31
|
const serviceToken = options.serviceToken?.trim() || process.env.HOSTY_APP_SERVICE_TOKEN?.trim();
|
|
25
32
|
// No fallback origin is invented when the variable is missing. Core injects HOSTY_CORE_ORIGIN
|
|
@@ -59,7 +66,7 @@ export async function introspectScopedToken(token, options = {}) {
|
|
|
59
66
|
response = await fetch(endpoint, {
|
|
60
67
|
method: "POST",
|
|
61
68
|
headers: { authorization: `Bearer ${serviceToken}`, "content-type": "application/json" },
|
|
62
|
-
body: JSON.stringify({ token, ...(options.tool ? { tool: options.tool } : {}) }),
|
|
69
|
+
body: JSON.stringify({ token, ...(purpose ? { purpose } : {}), ...(options.tool ? { tool: options.tool } : {}) }),
|
|
63
70
|
cache: "no-store",
|
|
64
71
|
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
65
72
|
});
|
|
@@ -130,6 +137,7 @@ export async function introspectScopedToken(token, options = {}) {
|
|
|
130
137
|
sub: body.sub,
|
|
131
138
|
role: typeof body.role === "string" ? body.role : null,
|
|
132
139
|
scopes: body.scopes,
|
|
140
|
+
...(typeof body.callerAppId === "string" ? { callerAppId: body.callerAppId } : {}),
|
|
133
141
|
};
|
|
134
142
|
}
|
|
135
143
|
/** Whether an introspection result carries a scope. Exact match, because a scope is a protocol
|
|
@@ -140,3 +148,5 @@ export function hasScope(result, scope) {
|
|
|
140
148
|
/** The scope every read-only MCP tool is gated on today. Mutation scopes arrive with the feature
|
|
141
149
|
* that introduces mutations. */
|
|
142
150
|
export const SCOPE_MCP_READ = "mcp:read";
|
|
151
|
+
/** A Core-authorized assistant may invoke MCP operations subject to the target user policy. */
|
|
152
|
+
export const SCOPE_MCP_INVOKE = "mcp:invoke";
|
package/dist/server.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface HostIdentity {
|
|
|
21
21
|
/** `mapHostRole(hostRole)` when the config provides the hook, else the raw host role. */
|
|
22
22
|
appRole: string | null;
|
|
23
23
|
expiresAt: string | null;
|
|
24
|
+
activeUntil?: string | null;
|
|
25
|
+
activityRequired?: boolean;
|
|
24
26
|
}
|
|
25
27
|
/** Structured detail for a failed resolution: the Core HTTP status (null when Core was
|
|
26
28
|
* never reached) plus a machine-readable code and human-readable message. Codes pass
|
|
@@ -49,9 +51,8 @@ export type HeaderReader = {
|
|
|
49
51
|
get(name: string): string | null;
|
|
50
52
|
};
|
|
51
53
|
/**
|
|
52
|
-
* Reads
|
|
53
|
-
*
|
|
54
|
-
* cross-site cookie is blocked, then the legacy inbound identity header.
|
|
54
|
+
* Reads an explicitly presented app grant first, then the app cookie and legacy identity header.
|
|
55
|
+
* A recovered iframe grant must not be shadowed by an older cookie.
|
|
55
56
|
*/
|
|
56
57
|
export declare function readAppIdentityToken(headers: HeaderReader, config: HostyAppConfig): string | null;
|
|
57
58
|
/** True when the effective request protocol is https (honouring the forwarded proto). */
|
|
@@ -90,6 +91,7 @@ export type AppCodeExchange = {
|
|
|
90
91
|
ok: true;
|
|
91
92
|
accessToken: string;
|
|
92
93
|
expiresInSeconds: number | null;
|
|
94
|
+
activeUntil?: string | null;
|
|
93
95
|
} | {
|
|
94
96
|
ok: false;
|
|
95
97
|
status: number;
|
package/dist/server.js
CHANGED
|
@@ -31,15 +31,10 @@ export function getRecoveryParams(config) {
|
|
|
31
31
|
return { appId: getAppId(config), corePublicOrigin: getCorePublicOrigin() };
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
* Reads
|
|
35
|
-
*
|
|
36
|
-
* cross-site cookie is blocked, then the legacy inbound identity header.
|
|
34
|
+
* Reads an explicitly presented app grant first, then the app cookie and legacy identity header.
|
|
35
|
+
* A recovered iframe grant must not be shadowed by an older cookie.
|
|
37
36
|
*/
|
|
38
37
|
export function readAppIdentityToken(headers, config) {
|
|
39
|
-
const cookieToken = readCookie(headers.get("cookie"), config.identityCookieName);
|
|
40
|
-
if (cookieToken) {
|
|
41
|
-
return cookieToken;
|
|
42
|
-
}
|
|
43
38
|
const authorization = headers.get("authorization")?.trim();
|
|
44
39
|
if (authorization?.toLowerCase().startsWith("bearer ")) {
|
|
45
40
|
const token = authorization.slice("bearer ".length).trim();
|
|
@@ -47,7 +42,7 @@ export function readAppIdentityToken(headers, config) {
|
|
|
47
42
|
return token;
|
|
48
43
|
}
|
|
49
44
|
}
|
|
50
|
-
return headers.get(HOSTY_APP_IDENTITY_HEADER)?.trim() || null;
|
|
45
|
+
return readCookie(headers.get("cookie"), config.identityCookieName) || headers.get(HOSTY_APP_IDENTITY_HEADER)?.trim() || null;
|
|
51
46
|
}
|
|
52
47
|
/** True when the effective request protocol is https (honouring the forwarded proto). */
|
|
53
48
|
export function isSecureRequest(headers, requestProtocol) {
|
|
@@ -200,6 +195,8 @@ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
|
|
|
200
195
|
hostRole,
|
|
201
196
|
appRole: config.mapHostRole ? config.mapHostRole(hostRole) : hostRole,
|
|
202
197
|
expiresAt,
|
|
198
|
+
activeUntil: readString(payload.activeUntil),
|
|
199
|
+
activityRequired: payload.activityRequired === true,
|
|
203
200
|
};
|
|
204
201
|
const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
|
|
205
202
|
if (Number.isFinite(expiresAtMs) && expiresAtMs > Date.now()) {
|
|
@@ -291,7 +288,7 @@ export async function exchangeAppCode(code) {
|
|
|
291
288
|
const expiresInSeconds = typeof payload?.expiresInSeconds === "number" && payload.expiresInSeconds > 0
|
|
292
289
|
? payload.expiresInSeconds
|
|
293
290
|
: null;
|
|
294
|
-
return { ok: true, accessToken, expiresInSeconds };
|
|
291
|
+
return { ok: true, accessToken, expiresInSeconds, activeUntil: readString(payload?.activeUntil) };
|
|
295
292
|
}
|
|
296
293
|
/**
|
|
297
294
|
* Route-handler factory for `POST /api/auth/app-code`: exchanges the one-time `code` for
|
|
@@ -301,6 +298,14 @@ export async function exchangeAppCode(code) {
|
|
|
301
298
|
*/
|
|
302
299
|
export function createAppCodeRouteHandler(config) {
|
|
303
300
|
return async function POST(request) {
|
|
301
|
+
const origin = request.headers.get("origin");
|
|
302
|
+
let sameOrigin = request.headers.get("sec-fetch-site") === "same-origin";
|
|
303
|
+
try {
|
|
304
|
+
sameOrigin ||= !!origin && new URL(origin).host === (request.headers.get("host") ?? new URL(request.url).host);
|
|
305
|
+
}
|
|
306
|
+
catch { /* invalid origin */ }
|
|
307
|
+
if (!sameOrigin)
|
|
308
|
+
return jsonResponse({ code: "cross_site_request_blocked", message: "Sign-in must originate from this app." }, 403);
|
|
304
309
|
let body = null;
|
|
305
310
|
try {
|
|
306
311
|
body = await request.json();
|
|
@@ -316,6 +321,9 @@ export function createAppCodeRouteHandler(config) {
|
|
|
316
321
|
if (!exchange.ok) {
|
|
317
322
|
return jsonResponse({ code: exchange.code, message: exchange.message }, exchange.status);
|
|
318
323
|
}
|
|
324
|
+
const identity = await resolveAppSession(exchange.accessToken, config);
|
|
325
|
+
if (identity.status !== "active")
|
|
326
|
+
return jsonResponse({ code: "app_identity_rejected", message: "Core could not validate this app session." }, identity.status === "unavailable" || identity.status === "misconfigured" ? 503 : 403);
|
|
319
327
|
const secure = isSecureRequest(request.headers, safeProtocol(request.url));
|
|
320
328
|
const attributes = identityCookieAttributes(secure, Math.min(exchange.expiresInSeconds ?? MAX_COOKIE_AGE_SECONDS, MAX_COOKIE_AGE_SECONDS));
|
|
321
329
|
const cookie = [
|
|
@@ -326,7 +334,7 @@ export function createAppCodeRouteHandler(config) {
|
|
|
326
334
|
"HttpOnly",
|
|
327
335
|
...(attributes.secure ? ["Secure"] : []),
|
|
328
336
|
].join("; ");
|
|
329
|
-
return jsonResponse({ accessToken: exchange.accessToken, expiresInSeconds: exchange.expiresInSeconds }, 200, { "set-cookie": cookie });
|
|
337
|
+
return jsonResponse({ accessToken: exchange.accessToken, expiresInSeconds: exchange.expiresInSeconds, activeUntil: exchange.activeUntil }, 200, { "set-cookie": cookie });
|
|
330
338
|
};
|
|
331
339
|
}
|
|
332
340
|
function safeProtocol(url) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"types": "./dist/server.d.ts",
|
|
14
14
|
"default": "./dist/server.js"
|
|
15
15
|
},
|
|
16
|
+
"./browser-auth": {
|
|
17
|
+
"types": "./dist/browser-auth.d.ts",
|
|
18
|
+
"default": "./dist/browser-auth.js"
|
|
19
|
+
},
|
|
16
20
|
"./delegated": {
|
|
17
21
|
"types": "./dist/delegated.d.ts",
|
|
18
22
|
"default": "./dist/delegated.js"
|
|
@@ -60,6 +64,18 @@
|
|
|
60
64
|
"./providers/server": {
|
|
61
65
|
"types": "./dist/providers-server.d.ts",
|
|
62
66
|
"default": "./dist/providers-server.js"
|
|
67
|
+
},
|
|
68
|
+
"./permissions": {
|
|
69
|
+
"types": "./dist/permissions.d.ts",
|
|
70
|
+
"default": "./dist/permissions.js"
|
|
71
|
+
},
|
|
72
|
+
"./permissions/server": {
|
|
73
|
+
"types": "./dist/permissions-server.d.ts",
|
|
74
|
+
"default": "./dist/permissions-server.js"
|
|
75
|
+
},
|
|
76
|
+
"./permissions/react": {
|
|
77
|
+
"types": "./dist/permissions-react.d.ts",
|
|
78
|
+
"default": "./dist/permissions-react.js"
|
|
63
79
|
}
|
|
64
80
|
},
|
|
65
81
|
"scripts": {
|
|
@@ -85,7 +101,11 @@
|
|
|
85
101
|
"@types/node": "^24.19.0",
|
|
86
102
|
"@types/react": "^19.3.0",
|
|
87
103
|
"typescript": "^6",
|
|
88
|
-
"vitest": "^5.0.2"
|
|
104
|
+
"vitest": "^5.0.2",
|
|
105
|
+
"react": "^19.3.0",
|
|
106
|
+
"react-dom": "^19.3.0",
|
|
107
|
+
"@types/react-dom": "^19.3.0",
|
|
108
|
+
"jsdom": "^30.1.1"
|
|
89
109
|
},
|
|
90
110
|
"repository": {
|
|
91
111
|
"type": "git",
|