@hosty-sdk/app 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +31 -0
- package/dist/browser-auth.js +217 -0
- package/dist/embedder.d.ts +9 -1
- package/dist/embedder.js +20 -5
- package/dist/index.d.ts +11 -12
- package/dist/index.js +7 -7
- package/dist/install-react.js +1 -1
- package/dist/install.d.ts +62 -1
- package/dist/install.js +3 -2
- 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 +2 -0
- package/dist/react.d.ts +8 -2
- package/dist/react.js +139 -59
- 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/dist/react.js
CHANGED
|
@@ -3,15 +3,17 @@ 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, appGrantRevision, rememberAppGrant, restoreAppGrant, forgetRejectedAppGrant, 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
|
-
|
|
13
|
-
|
|
14
|
-
const EMBEDDED_RECOVERY_TIMEOUT_MS = 4_000;
|
|
15
|
+
const SILENT_STATE_KEY = "hosty.auth.silent-state";
|
|
16
|
+
const SILENT_GUARD_PREFIX = "hosty.auth.silent-attempted:";
|
|
15
17
|
// Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
|
|
16
18
|
// timeout it classifies as unavailable and the user gets a Retry affordance.
|
|
17
19
|
const IDENTITY_PROBE_TIMEOUT_MS = 4_000;
|
|
@@ -66,6 +68,40 @@ function writeGuard(value) {
|
|
|
66
68
|
// sessionStorage may be blocked; recovery still works, only the guard is lost.
|
|
67
69
|
}
|
|
68
70
|
}
|
|
71
|
+
/** Both state and the per-app guard must survive navigation, or the popup is the fallback. */
|
|
72
|
+
function beginSilentSignIn(openUrl, appId) {
|
|
73
|
+
try {
|
|
74
|
+
const guardKey = `${SILENT_GUARD_PREFIX}${appId}`;
|
|
75
|
+
if (window.sessionStorage.getItem(guardKey) === "1")
|
|
76
|
+
return null;
|
|
77
|
+
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
|
|
78
|
+
window.sessionStorage.setItem(SILENT_STATE_KEY, state);
|
|
79
|
+
window.sessionStorage.setItem(guardKey, "1");
|
|
80
|
+
if (window.sessionStorage.getItem(SILENT_STATE_KEY) !== state || window.sessionStorage.getItem(guardKey) !== "1")
|
|
81
|
+
return null;
|
|
82
|
+
const url = new URL(openUrl);
|
|
83
|
+
url.searchParams.set("prompt", "none");
|
|
84
|
+
url.searchParams.set("state", state);
|
|
85
|
+
return url.href;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function consumeSilentState(state) {
|
|
92
|
+
if (!state)
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
const expected = window.sessionStorage.getItem(SILENT_STATE_KEY);
|
|
96
|
+
if (!expected || expected !== state || !/^[a-f0-9]{64}$/.test(state))
|
|
97
|
+
return false;
|
|
98
|
+
window.sessionStorage.removeItem(SILENT_STATE_KEY);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
69
105
|
function readStoredLaunchMode() {
|
|
70
106
|
try {
|
|
71
107
|
return window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
|
|
@@ -144,18 +180,26 @@ export function useLaunchMode() {
|
|
|
144
180
|
/**
|
|
145
181
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
146
182
|
* the URL (exchanging it for the identity cookie), then probes the session and runs the
|
|
147
|
-
* recovery decision: silent
|
|
183
|
+
* recovery decision: a silent first-load Core navigation followed by an app-owned popup
|
|
184
|
+
* when embedded, a once-per-tab
|
|
148
185
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
149
186
|
* — never a login UI while recovery is still running.
|
|
150
187
|
*/
|
|
151
|
-
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, } = {}) {
|
|
188
|
+
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, children, } = {}) {
|
|
152
189
|
const [ui, setUi] = useState({ kind: "recovering" });
|
|
153
190
|
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
154
191
|
// keep its exchange alive and let the replacement effect await the same response.
|
|
192
|
+
const wasActive = useRef(false);
|
|
193
|
+
const firstProbe = useRef(true);
|
|
194
|
+
const initialGrantPresent = useRef(null);
|
|
195
|
+
const silentError = useRef(null);
|
|
196
|
+
const [activity, setActivity] = useState(null);
|
|
155
197
|
const exchangeRef = useRef(null);
|
|
156
198
|
useEffect(() => {
|
|
157
199
|
let cancelled = false;
|
|
158
200
|
const controller = new AbortController();
|
|
201
|
+
if (initialGrantPresent.current === null)
|
|
202
|
+
initialGrantPresent.current = restoreAppGrant();
|
|
159
203
|
async function probeAndRecover() {
|
|
160
204
|
// Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
|
|
161
205
|
// available in every browser (a synchronous throw here would stop the bridge from
|
|
@@ -167,17 +211,24 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
167
211
|
let status = null;
|
|
168
212
|
let openUrl = null;
|
|
169
213
|
let appId = null;
|
|
214
|
+
let rejectionCode;
|
|
215
|
+
const revision = appGrantRevision();
|
|
170
216
|
try {
|
|
171
|
-
const response = await
|
|
217
|
+
const response = await appFetch(probePath, {
|
|
172
218
|
headers: { Accept: "application/json" },
|
|
173
219
|
cache: "no-store",
|
|
174
220
|
signal: probeController.signal,
|
|
175
|
-
});
|
|
221
|
+
}, false);
|
|
176
222
|
const body = await response.json().catch(() => null);
|
|
177
223
|
status = readProbedSessionStatus(body);
|
|
224
|
+
const identityError = body;
|
|
225
|
+
rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
|
|
178
226
|
const recovery = readRecoveryParams(body);
|
|
179
227
|
appId = recovery.appId;
|
|
180
228
|
openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
|
|
229
|
+
const metadata = body;
|
|
230
|
+
if (!cancelled && openUrl)
|
|
231
|
+
setActivity({ openUrl, activeUntil: metadata?.activeUntil, activityRequired: metadata?.activityRequired });
|
|
181
232
|
}
|
|
182
233
|
catch {
|
|
183
234
|
// A failed or timed-out probe (Core unreachable) classifies as unavailable below.
|
|
@@ -190,10 +241,30 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
190
241
|
if (cancelled) {
|
|
191
242
|
return;
|
|
192
243
|
}
|
|
244
|
+
if (revision !== appGrantRevision()) {
|
|
245
|
+
initialGrantPresent.current ||= restoreAppGrant();
|
|
246
|
+
void probeAndRecover();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
forgetRejectedAppGrant(rejectionCode, revision);
|
|
250
|
+
const initialLoad = firstProbe.current;
|
|
251
|
+
firstProbe.current = false;
|
|
252
|
+
if (silentError.current === "access_denied") {
|
|
253
|
+
setUi({ kind: "denied" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (initialLoad && !wasActive.current && !initialGrantPresent.current && !silentError.current &&
|
|
257
|
+
detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
|
|
258
|
+
const silentUrl = beginSilentSignIn(openUrl, appId);
|
|
259
|
+
if (silentUrl) {
|
|
260
|
+
window.location.replace(silentUrl);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
193
264
|
const action = !status || !appId
|
|
194
265
|
? { kind: "card", card: "unavailable" }
|
|
195
266
|
: decideRecoveryAction({
|
|
196
|
-
status,
|
|
267
|
+
status: silentError.current === "login_required" ? "not-present" : status,
|
|
197
268
|
mode: detectLaunchMode(window),
|
|
198
269
|
appId,
|
|
199
270
|
openUrl,
|
|
@@ -201,25 +272,34 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
201
272
|
});
|
|
202
273
|
switch (action.kind) {
|
|
203
274
|
case "none":
|
|
275
|
+
appSessionActive();
|
|
276
|
+
wasActive.current = true;
|
|
204
277
|
setUi({ kind: "active" });
|
|
205
278
|
return;
|
|
206
279
|
case "post-auth-required": {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
280
|
+
const showSignIn = (error) => setUi({ kind: "signin", openUrl, embedded: true, error,
|
|
281
|
+
signIn: openUrl ? () => {
|
|
282
|
+
// Open synchronously in the click handler; an awaited request loses the user gesture.
|
|
283
|
+
const result = openAppSignIn(openUrl, controller.signal);
|
|
284
|
+
setUi({ kind: "recovering" });
|
|
285
|
+
void result.then(async (code) => {
|
|
286
|
+
const response = await appFetch(appCodePath, {
|
|
287
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
288
|
+
body: JSON.stringify({ code }),
|
|
289
|
+
});
|
|
290
|
+
const body = await response.json();
|
|
291
|
+
if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
|
|
292
|
+
throw new Error("Hosty could not establish this app session.");
|
|
293
|
+
if (cancelled)
|
|
294
|
+
return;
|
|
295
|
+
rememberAppGrant(body.accessToken);
|
|
296
|
+
silentError.current = null;
|
|
297
|
+
writeGuard(false);
|
|
298
|
+
await probeAndRecover();
|
|
299
|
+
}).catch(error => { if (!cancelled)
|
|
300
|
+
showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
|
|
301
|
+
} : undefined });
|
|
302
|
+
showSignIn();
|
|
223
303
|
return;
|
|
224
304
|
}
|
|
225
305
|
case "redirect":
|
|
@@ -233,11 +313,29 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
233
313
|
return;
|
|
234
314
|
}
|
|
235
315
|
}
|
|
316
|
+
const recover = () => { if (!cancelled && !wasActive.current) {
|
|
317
|
+
setUi({ kind: "recovering" });
|
|
318
|
+
void probeAndRecover();
|
|
319
|
+
} };
|
|
320
|
+
window.addEventListener(APP_SESSION_ENDED, recover);
|
|
236
321
|
const url = new URL(window.location.href);
|
|
237
322
|
const code = url.searchParams.get("code")?.trim();
|
|
238
|
-
|
|
323
|
+
const error = url.searchParams.get("error");
|
|
324
|
+
const embedded = detectLaunchMode(window) === "embedded";
|
|
325
|
+
const isSilentError = error === "login_required" || error === "access_denied";
|
|
326
|
+
const accepted = embedded && (code || isSilentError) ? consumeSilentState(url.searchParams.get("state")) : !embedded;
|
|
327
|
+
if (embedded && accepted && isSilentError)
|
|
328
|
+
silentError.current = error;
|
|
329
|
+
if (code || (embedded && isSilentError)) {
|
|
239
330
|
url.searchParams.delete("code");
|
|
331
|
+
if (embedded) {
|
|
332
|
+
url.searchParams.delete("state");
|
|
333
|
+
if (isSilentError)
|
|
334
|
+
url.searchParams.delete("error");
|
|
335
|
+
}
|
|
240
336
|
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
337
|
+
}
|
|
338
|
+
if (code && accepted) {
|
|
241
339
|
exchangeRef.current = {
|
|
242
340
|
path: appCodePath,
|
|
243
341
|
response: fetch(appCodePath, {
|
|
@@ -250,13 +348,17 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
250
348
|
const exchange = exchangeRef.current;
|
|
251
349
|
if (exchange && exchange.path === appCodePath) {
|
|
252
350
|
void exchange.response
|
|
253
|
-
.then((response) => {
|
|
351
|
+
.then(async (response) => {
|
|
254
352
|
if (cancelled) {
|
|
255
353
|
return;
|
|
256
354
|
}
|
|
257
355
|
if (response.ok) {
|
|
258
356
|
writeGuard(false);
|
|
259
|
-
|
|
357
|
+
const body = await response.json().catch(() => null);
|
|
358
|
+
if (typeof body?.accessToken === "string")
|
|
359
|
+
rememberAppGrant(body.accessToken);
|
|
360
|
+
exchangeRef.current = null;
|
|
361
|
+
await probeAndRecover();
|
|
260
362
|
}
|
|
261
363
|
else {
|
|
262
364
|
exchangeRef.current = null;
|
|
@@ -276,43 +378,19 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
276
378
|
return () => {
|
|
277
379
|
cancelled = true;
|
|
278
380
|
controller.abort();
|
|
381
|
+
window.removeEventListener(APP_SESSION_ENDED, recover);
|
|
279
382
|
};
|
|
280
383
|
}, [probePath, appCodePath]);
|
|
384
|
+
if (wasActive.current)
|
|
385
|
+
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
281
386
|
if (renderState)
|
|
282
387
|
return renderState(ui);
|
|
283
|
-
if (ui.kind === "
|
|
284
|
-
return null;
|
|
285
|
-
|
|
286
|
-
|
|
388
|
+
if (ui.kind === "active")
|
|
389
|
+
return children ?? null;
|
|
390
|
+
if (ui.kind === "recovering")
|
|
391
|
+
return _jsx(AuthNotice, { children: "Connecting to Hosty\u2026" });
|
|
392
|
+
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
393
|
}
|
|
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
394
|
function readStoredTheme(key) {
|
|
317
395
|
try {
|
|
318
396
|
return window.sessionStorage.getItem(key);
|
|
@@ -411,3 +489,5 @@ export function HostThemeBridge({ followSystem = true, onTheme } = {}) {
|
|
|
411
489
|
}, []);
|
|
412
490
|
return null;
|
|
413
491
|
}
|
|
492
|
+
export { MissingPermissionsNotice } from "./permissions-react.js";
|
|
493
|
+
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.20.0",
|
|
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",
|