@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.
@@ -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.8.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"