@hosty-sdk/app 0.8.0 → 0.10.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/dist/embedder.d.ts +12 -0
- package/dist/embedder.js +26 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/scoped-token.d.ts +48 -0
- package/dist/scoped-token.js +142 -0
- package/package.json +5 -1
package/dist/embedder.d.ts
CHANGED
|
@@ -51,6 +51,18 @@ export interface AskAssistantIntent {
|
|
|
51
51
|
* embedder must still decide is *where* the text goes — this function says an app asked, never that
|
|
52
52
|
* the assistant should send anything. Nothing here may be auto-sent.
|
|
53
53
|
*/
|
|
54
|
+
/**
|
|
55
|
+
* Verifies an attention count posted by a frame the embedder deliberately embeds.
|
|
56
|
+
*
|
|
57
|
+
* Read from the frame rather than polled by the embedder: the page that holds the session list and
|
|
58
|
+
* its event stream is the one source, and a shell asking the same question on a timer would be a
|
|
59
|
+
* second source that disagrees with the first for the length of its interval.
|
|
60
|
+
*
|
|
61
|
+
* Sender-verified like every other message here, though the payload is only a count: a wrong badge is
|
|
62
|
+
* a small harm, and a wrong badge that any page could set is a way to make an operator stop trusting
|
|
63
|
+
* the badge that matters.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseActiveFrameAttention(event: EmbedderMessage, activeFrameWindow: unknown, activeFrameUrl: string): number | null;
|
|
54
66
|
export declare function parseActiveFrameAskAssistant(event: EmbedderMessage, activeFrameWindow: unknown, activeFrameUrl: string): AskAssistantIntent | null;
|
|
55
67
|
/**
|
|
56
68
|
* Per-app reissue throttle: a frame that keeps re-posting (e.g. it never accepts the new
|
package/dist/embedder.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// you have decided to grant one, mint a token from Core with your own session and post it back
|
|
13
13
|
// to that frame's origin. Both requests are unauthenticated by design; what makes them safe is
|
|
14
14
|
// that the embedder answers from facts about its own DOM rather than from what the frame claims.
|
|
15
|
-
import { ASK_ASSISTANT_MAX_CHARS, ASK_ASSISTANT_TYPE, AUTH_REQUIRED_INTENT_TYPE, DELEGATED_TOKEN_REQUEST_TYPE, } from "./index.js";
|
|
15
|
+
import { ASK_ASSISTANT_MAX_CHARS, ASK_ASSISTANT_TYPE, ATTENTION_TYPE, AUTH_REQUIRED_INTENT_TYPE, DELEGATED_TOKEN_REQUEST_TYPE, } from "./index.js";
|
|
16
16
|
/**
|
|
17
17
|
* Verifies that a `message` event is a genuine auth-required intent from the active app
|
|
18
18
|
* frame. Every check is about facts only the embedder can know:
|
|
@@ -66,6 +66,31 @@ export function parseActiveFrameDelegatedTokenRequest(event, activeFrameWindow,
|
|
|
66
66
|
* embedder must still decide is *where* the text goes — this function says an app asked, never that
|
|
67
67
|
* the assistant should send anything. Nothing here may be auto-sent.
|
|
68
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* Verifies an attention count posted by a frame the embedder deliberately embeds.
|
|
71
|
+
*
|
|
72
|
+
* Read from the frame rather than polled by the embedder: the page that holds the session list and
|
|
73
|
+
* its event stream is the one source, and a shell asking the same question on a timer would be a
|
|
74
|
+
* second source that disagrees with the first for the length of its interval.
|
|
75
|
+
*
|
|
76
|
+
* Sender-verified like every other message here, though the payload is only a count: a wrong badge is
|
|
77
|
+
* a small harm, and a wrong badge that any page could set is a way to make an operator stop trusting
|
|
78
|
+
* the badge that matters.
|
|
79
|
+
*/
|
|
80
|
+
export function parseActiveFrameAttention(event, activeFrameWindow, activeFrameUrl) {
|
|
81
|
+
if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const candidate = event.data;
|
|
85
|
+
// `typeof NaN === "number"`, and NaN survives every clamp below to arrive as a badge count no
|
|
86
|
+
// renderer can do anything with. Finiteness is the check that was actually meant.
|
|
87
|
+
if (candidate.type !== ATTENTION_TYPE || !Number.isFinite(candidate.count)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
// Clamped rather than trusted: a negative or absurd count would render as a badge nobody can
|
|
91
|
+
// explain, and the frame has no business deciding how large a number the shell displays.
|
|
92
|
+
return Math.max(0, Math.min(99, Math.floor(candidate.count)));
|
|
93
|
+
}
|
|
69
94
|
export function parseActiveFrameAskAssistant(event, activeFrameWindow, activeFrameUrl) {
|
|
70
95
|
if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
|
|
71
96
|
return null;
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,13 @@ export declare function createAuthRequiredIntent(appId: string): AuthRequiredInt
|
|
|
34
34
|
* operator cannot read at a glance, and an answer would turn a hand-off into a channel.
|
|
35
35
|
*/
|
|
36
36
|
export declare const ASK_ASSISTANT_TYPE = "hosty:ask-assistant";
|
|
37
|
+
/**
|
|
38
|
+
* How many of an embedded assistant's sessions are waiting for the operator.
|
|
39
|
+
*
|
|
40
|
+
* Published by the page that holds them, not asked for on a timer: one source, so a shell's badge and
|
|
41
|
+
* the list behind it can never disagree.
|
|
42
|
+
*/
|
|
43
|
+
export declare const ATTENTION_TYPE = "hosty:assistant-attention";
|
|
37
44
|
/** Longer than a prompt fragment is a page dumping itself into someone's draft. */
|
|
38
45
|
export declare const ASK_ASSISTANT_MAX_CHARS = 4000;
|
|
39
46
|
export interface AskAssistantMessage {
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,13 @@ export function createAuthRequiredIntent(appId) {
|
|
|
37
37
|
* operator cannot read at a glance, and an answer would turn a hand-off into a channel.
|
|
38
38
|
*/
|
|
39
39
|
export const ASK_ASSISTANT_TYPE = "hosty:ask-assistant";
|
|
40
|
+
/**
|
|
41
|
+
* How many of an embedded assistant's sessions are waiting for the operator.
|
|
42
|
+
*
|
|
43
|
+
* Published by the page that holds them, not asked for on a timer: one source, so a shell's badge and
|
|
44
|
+
* the list behind it can never disagree.
|
|
45
|
+
*/
|
|
46
|
+
export const ATTENTION_TYPE = "hosty:assistant-attention";
|
|
40
47
|
/** Longer than a prompt fragment is a page dumping itself into someone's draft. */
|
|
41
48
|
export const ASK_ASSISTANT_MAX_CHARS = 4000;
|
|
42
49
|
/**
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** What Core says about a credential. `active` is sufficient on its own: a caller that reads
|
|
2
|
+
* nothing else is not thereby insecure, because everything else is present only when it is true. */
|
|
3
|
+
export type ScopedTokenIntrospection = {
|
|
4
|
+
active: true;
|
|
5
|
+
sub: string;
|
|
6
|
+
role: string | null;
|
|
7
|
+
scopes: string[];
|
|
8
|
+
} | {
|
|
9
|
+
active: false;
|
|
10
|
+
error?: ScopedTokenError;
|
|
11
|
+
};
|
|
12
|
+
/** Why the answer could not be obtained. Present only when Core was never reached or answered
|
|
13
|
+
* something unusable — an ordinary "this credential is not valid" is a plain `active: false` with
|
|
14
|
+
* no error, because it is an answer rather than a failure. */
|
|
15
|
+
export interface ScopedTokenError {
|
|
16
|
+
status: number | null;
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
export interface IntrospectScopedTokenOptions {
|
|
21
|
+
/** The tool or method this call is about. Core records it as the audit line for the action, so an
|
|
22
|
+
* external client's use of an app is visible to the host — pass it for a tool call, and leave it
|
|
23
|
+
* out for protocol traffic that is not itself an action. */
|
|
24
|
+
tool?: string;
|
|
25
|
+
/** Audience to introspect for; defaults to HOSTY_APP_ID. An app introspects only for itself —
|
|
26
|
+
* Core validates this against the service token regardless, so overriding it cannot widen reach. */
|
|
27
|
+
appId?: string;
|
|
28
|
+
/** Core origin; defaults to HOSTY_CORE_ORIGIN. */
|
|
29
|
+
coreOrigin?: string;
|
|
30
|
+
/** This app's service token; defaults to HOSTY_APP_SERVICE_TOKEN. */
|
|
31
|
+
serviceToken?: string;
|
|
32
|
+
/** Request budget. Core is on the same host, so the default is short. */
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Ask Core whether a bearer this app received is a live credential scoped to this app.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately uncached, and callers must not add one: the credential's whole advantage over a
|
|
39
|
+
* delegated token is that revocation takes effect on the next call, and a cache is exactly the
|
|
40
|
+
* window that would take back.
|
|
41
|
+
*/
|
|
42
|
+
export declare function introspectScopedToken(token: string, options?: IntrospectScopedTokenOptions): Promise<ScopedTokenIntrospection>;
|
|
43
|
+
/** Whether an introspection result carries a scope. Exact match, because a scope is a protocol
|
|
44
|
+
* constant rather than text a caller should normalize. */
|
|
45
|
+
export declare function hasScope(result: ScopedTokenIntrospection, scope: string): boolean;
|
|
46
|
+
/** The scope every read-only MCP tool is gated on today. Mutation scopes arrive with the feature
|
|
47
|
+
* that introduces mutations. */
|
|
48
|
+
export declare const SCOPE_MCP_READ = "mcp:read";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Scoped access tokens: the credential an external client presents directly to this app
|
|
2
|
+
// (docs/features/scoped-access-tokens/feature.md).
|
|
3
|
+
//
|
|
4
|
+
// In its own entry, like ./delegated, so a plain Node service can import it — an app's MCP endpoint
|
|
5
|
+
// is usually not a Next route, and the server slice pulls in "server-only", which throws outside a
|
|
6
|
+
// React server bundle.
|
|
7
|
+
//
|
|
8
|
+
// The contrast with ./delegated is the whole point of this credential. A delegated token is signed
|
|
9
|
+
// and checked locally: fast, but unrevocable, so it lives five minutes and cannot sit in a client's
|
|
10
|
+
// configuration file. A scoped token is opaque and worth nothing until Core says otherwise, which is
|
|
11
|
+
// why it can live in a config and still stop the moment an operator revokes it.
|
|
12
|
+
// Core is a loopback hop away, so a generous budget would only hold a request open while something
|
|
13
|
+
// is already wrong.
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 2_000;
|
|
15
|
+
/**
|
|
16
|
+
* Ask Core whether a bearer this app received is a live credential scoped to this app.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately uncached, and callers must not add one: the credential's whole advantage over a
|
|
19
|
+
* delegated token is that revocation takes effect on the next call, and a cache is exactly the
|
|
20
|
+
* window that would take back.
|
|
21
|
+
*/
|
|
22
|
+
export async function introspectScopedToken(token, options = {}) {
|
|
23
|
+
const appId = options.appId?.trim() || process.env.HOSTY_APP_ID?.trim();
|
|
24
|
+
const serviceToken = options.serviceToken?.trim() || process.env.HOSTY_APP_SERVICE_TOKEN?.trim();
|
|
25
|
+
// No fallback origin is invented when the variable is missing. Core injects HOSTY_CORE_ORIGIN
|
|
26
|
+
// already resolved for the runtime this app is in — a container reaches Core by a different
|
|
27
|
+
// address than a host process does — so a guessed `localhost` would be right in one runtime and
|
|
28
|
+
// silently wrong in the other. This is the same origin every other Core call in this SDK uses.
|
|
29
|
+
const coreOrigin = options.coreOrigin?.trim() || process.env.HOSTY_CORE_ORIGIN?.trim();
|
|
30
|
+
if (!token) {
|
|
31
|
+
// Nothing was presented, which is the caller's own business and not a failure of this app.
|
|
32
|
+
return { active: false, error: { status: null, code: "credential_missing", message: "No credential was presented." } };
|
|
33
|
+
}
|
|
34
|
+
// Distinct from the above on purpose: here a credential *was* presented and could not be checked,
|
|
35
|
+
// because this app is not configured to reach Core. Answering the client "invalid credential"
|
|
36
|
+
// would send someone to rotate a token that may be perfectly good, to fix a fault on this side.
|
|
37
|
+
if (!appId || !serviceToken || !coreOrigin) {
|
|
38
|
+
return {
|
|
39
|
+
active: false,
|
|
40
|
+
error: {
|
|
41
|
+
status: null,
|
|
42
|
+
code: "introspection_unconfigured",
|
|
43
|
+
message: "HOSTY_APP_ID, HOSTY_APP_SERVICE_TOKEN, and HOSTY_CORE_ORIGIN must all be configured.",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
let endpoint;
|
|
48
|
+
try {
|
|
49
|
+
endpoint = new URL(`/api/internal/apps/${encodeURIComponent(appId)}/token/introspect`, coreOrigin).toString();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {
|
|
53
|
+
active: false,
|
|
54
|
+
error: { status: null, code: "core_origin_invalid", message: "HOSTY_CORE_ORIGIN is not a valid URL." },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await fetch(endpoint, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { authorization: `Bearer ${serviceToken}`, "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ token, ...(options.tool ? { tool: options.tool } : {}) }),
|
|
63
|
+
cache: "no-store",
|
|
64
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// Core unreachable is a failure, never an inactive credential. The distinction matters to the
|
|
69
|
+
// caller: this is a 503, not a 401, and answering "inactive" would tell a legitimate client its
|
|
70
|
+
// credential is bad while the truth is that nothing could be checked.
|
|
71
|
+
const timedOut = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
72
|
+
return {
|
|
73
|
+
active: false,
|
|
74
|
+
error: {
|
|
75
|
+
status: null,
|
|
76
|
+
code: timedOut ? "introspection_timeout" : "introspection_unavailable",
|
|
77
|
+
message: error instanceof Error ? error.message : "The introspection request to Core failed.",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
return {
|
|
83
|
+
active: false,
|
|
84
|
+
error: {
|
|
85
|
+
status: response.status,
|
|
86
|
+
code: "introspection_failed",
|
|
87
|
+
message: `Core answered ${response.status} for the introspection request.`,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
let payload;
|
|
92
|
+
try {
|
|
93
|
+
payload = await response.json();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return {
|
|
97
|
+
active: false,
|
|
98
|
+
error: { status: response.status, code: "core_response_invalid", message: "Core returned an unreadable body." },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const body = payload;
|
|
102
|
+
// A literal `false` is an answer: Core checked and said no.
|
|
103
|
+
if (body?.active === false) {
|
|
104
|
+
return { active: false };
|
|
105
|
+
}
|
|
106
|
+
// Anything else is a body this helper could not read, and the two must not collapse into one
|
|
107
|
+
// result. Fail closed either way — an unreadable answer is never a grant — but report it as an
|
|
108
|
+
// error, because a caller turns "not valid" into 401 and "could not be established" into 503, and
|
|
109
|
+
// a wire-format change or a mangling proxy would otherwise look exactly like a bad credential.
|
|
110
|
+
//
|
|
111
|
+
// The whole active shape is required, not just `active`: a payload claiming success with no
|
|
112
|
+
// subject, or with scopes that are not a list of strings, cannot authorize anything, and a caller
|
|
113
|
+
// reading only `active` must not be handed one.
|
|
114
|
+
if (body?.active !== true ||
|
|
115
|
+
typeof body.sub !== "string" ||
|
|
116
|
+
!body.sub ||
|
|
117
|
+
!Array.isArray(body.scopes) ||
|
|
118
|
+
body.scopes.some((scope) => typeof scope !== "string")) {
|
|
119
|
+
return {
|
|
120
|
+
active: false,
|
|
121
|
+
error: {
|
|
122
|
+
status: response.status,
|
|
123
|
+
code: "core_response_invalid",
|
|
124
|
+
message: "Core answered with a body this client could not read as an introspection result.",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
active: true,
|
|
130
|
+
sub: body.sub,
|
|
131
|
+
role: typeof body.role === "string" ? body.role : null,
|
|
132
|
+
scopes: body.scopes,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Whether an introspection result carries a scope. Exact match, because a scope is a protocol
|
|
136
|
+
* constant rather than text a caller should normalize. */
|
|
137
|
+
export function hasScope(result, scope) {
|
|
138
|
+
return result.active && result.scopes.includes(scope);
|
|
139
|
+
}
|
|
140
|
+
/** The scope every read-only MCP tool is gated on today. Mutation scopes arrive with the feature
|
|
141
|
+
* that introduces mutations. */
|
|
142
|
+
export const SCOPE_MCP_READ = "mcp:read";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./dist/delegated.d.ts",
|
|
18
18
|
"default": "./dist/delegated.js"
|
|
19
19
|
},
|
|
20
|
+
"./scoped-token": {
|
|
21
|
+
"types": "./dist/scoped-token.d.ts",
|
|
22
|
+
"default": "./dist/scoped-token.js"
|
|
23
|
+
},
|
|
20
24
|
"./react": {
|
|
21
25
|
"types": "./dist/react.d.ts",
|
|
22
26
|
"default": "./dist/react.js"
|