@hosty-sdk/app 0.9.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.
@@ -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.9.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"