@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.
@@ -0,0 +1,4 @@
1
+ /** App-owned GET endpoint authenticates the viewer and calls readOwnPermissionNotice. */
2
+ export declare function MissingPermissionsNotice({ endpoint }: {
3
+ endpoint?: string;
4
+ }): import("react").JSX.Element | null;
@@ -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 "./assistant.js";
2
- import { type AppPermissionState, type ProviderDescriptor, type ProviderInvocation, type ProviderKind, type SpeechCapabilities, type SpeechResult } from "./providers.js";
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;
@@ -1,5 +1,5 @@
1
- import { AssistantClient } from "./assistant.js";
2
- import { ProviderError } from "./providers.js";
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;
@@ -15,6 +15,8 @@ export type AppPermissionState = {
15
15
  optional: string[];
16
16
  granted: string[];
17
17
  reviewAvailable?: boolean;
18
+ unsupportedRequired?: string[];
19
+ status?: string;
18
20
  };
19
21
  export type ProviderInvocation = {
20
22
  callerAppId: string;
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: silent `hosty:auth-required` to the Shell when embedded, a once-per-tab
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 | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
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: silent `hosty:auth-required` to the Shell when embedded, a once-per-tab
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 fetch(probePath, {
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
- // The payload carries no secret, so targetOrigin "*" is safe — the Shell verifies
208
- // the sender before acting, and answers by swapping the iframe src.
209
- try {
210
- window.parent.postMessage(action.intent, "*");
211
- }
212
- catch {
213
- // Ignore; the timeout below still falls back to the manual sign-in card.
214
- }
215
- const timeoutId = window.setTimeout(() => {
216
- if (!cancelled) {
217
- setUi({ kind: "signin", openUrl, embedded: true });
218
- }
219
- }, EMBEDDED_RECOVERY_TIMEOUT_MS);
220
- controller.signal.addEventListener("abort", () => window.clearTimeout(timeoutId), {
221
- once: true,
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
- window.location.reload();
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 === "recovering" || ui.kind === "active") {
284
- return null;
285
- }
286
- return (_jsx("div", { role: "status", style: barStyle, children: ui.kind === "signin" ? (_jsxs(_Fragment, { children: [_jsx("span", { children: "Your Hosty session ended." }), ui.openUrl ? (_jsx("a", { href: ui.openUrl, ...(ui.embedded ? { target: "_blank", rel: "noopener noreferrer" } : {}), style: actionStyle, 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("button", { type: "button", onClick: () => window.location.reload(), style: actionStyle, children: "Retry" })] })) }));
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";
@@ -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";
@@ -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 the app identity token from a request: the app-origin cookie first (authoritative
53
- * when present), then the Authorization bearer fallback the browser sends when the
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 the app identity token from a request: the app-origin cookie first (authoritative
35
- * when present), then the Authorization bearer fallback the browser sends when the
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.18.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",