@hosty-sdk/app 0.9.0 → 0.11.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,35 @@
1
+ /** The RFC 9728 document for one MCP endpoint. */
2
+ export interface ProtectedResourceMetadata {
3
+ resource: string;
4
+ authorization_servers: string[];
5
+ scopes_supported: string[];
6
+ bearer_methods_supported: string[];
7
+ }
8
+ export interface ResourceMetadataOptions {
9
+ /** The app's own MCP endpoint URL as clients reach it. Wins over any derivation. */
10
+ resourceUrl?: string;
11
+ /** The public origin of the endpoint serving MCP. Defaults to `HOSTY_PUBLIC_ORIGIN_API` — the
12
+ * per-endpoint variable Core injects when an endpoint is published, for the reference layout
13
+ * where the MCP interface rides the `api` endpoint. An app with a different layout passes its
14
+ * own endpoint's origin (or the full resourceUrl). */
15
+ publicOrigin?: string;
16
+ /** The MCP endpoint's path under that origin. */
17
+ resourcePath?: string;
18
+ /** The authorization server's browser-reachable origin. Defaults to HOSTY_CORE_PUBLIC_ORIGIN —
19
+ * the flow's whole point is a remote client and a browser completing it, so the loopback origin
20
+ * this app dials Core on would send both to the wrong machine. */
21
+ authorizationServerOrigin?: string;
22
+ }
23
+ /**
24
+ * Builds the metadata document, or null when the environment cannot name the two URLs it consists
25
+ * of — which is the ordinary state of an app that is not published to a public origin. Null rather
26
+ * than a guess: a wrong resource identity here would have clients requesting tokens for a URL
27
+ * nothing serves, and no metadata simply means the manual token path, which always works.
28
+ */
29
+ export declare function buildProtectedResourceMetadata(options?: ResourceMetadataOptions): ProtectedResourceMetadata | null;
30
+ /**
31
+ * The `WWW-Authenticate` value a 401 from the MCP endpoint should carry, pointing a stock client at
32
+ * the metadata. The metadata URL is the RFC 9728 derivation: the well-known prefix inserted before
33
+ * the resource's path.
34
+ */
35
+ export declare function buildWwwAuthenticate(metadata: ProtectedResourceMetadata): string;
@@ -0,0 +1,42 @@
1
+ // The resource-server half of the MCP authorization handshake (RFC 9728), for an app's MCP
2
+ // endpoint: the metadata document naming Core as the authorization server, and the 401 challenge
3
+ // header pointing at it.
4
+ //
5
+ // In its own entry like ./scoped-token, and for the same reason: an MCP endpoint is usually not a
6
+ // Next route, and the server slice pulls in "server-only".
7
+ //
8
+ // The app serves the *pointer*; Core is the authorization server and serves everything else. An app
9
+ // never mints, validates, or even sees an OAuth exchange — a client that follows this pointer comes
10
+ // back carrying an ordinary scoped access token, which the app validates through introspection
11
+ // exactly as if the operator had pasted one by hand.
12
+ /**
13
+ * Builds the metadata document, or null when the environment cannot name the two URLs it consists
14
+ * of — which is the ordinary state of an app that is not published to a public origin. Null rather
15
+ * than a guess: a wrong resource identity here would have clients requesting tokens for a URL
16
+ * nothing serves, and no metadata simply means the manual token path, which always works.
17
+ */
18
+ export function buildProtectedResourceMetadata(options = {}) {
19
+ const core = options.authorizationServerOrigin?.trim() || process.env.HOSTY_CORE_PUBLIC_ORIGIN?.trim();
20
+ const publicOrigin = options.publicOrigin?.trim() || process.env.HOSTY_PUBLIC_ORIGIN_API?.trim();
21
+ const path = options.resourcePath?.trim() || "/api/mcp";
22
+ const resource = options.resourceUrl?.trim() || (publicOrigin ? `${publicOrigin.replace(/\/$/, "")}${path}` : undefined);
23
+ if (!core || !resource) {
24
+ return null;
25
+ }
26
+ return {
27
+ resource,
28
+ authorization_servers: [core.replace(/\/$/, "")],
29
+ scopes_supported: ["mcp:read"],
30
+ bearer_methods_supported: ["header"],
31
+ };
32
+ }
33
+ /**
34
+ * The `WWW-Authenticate` value a 401 from the MCP endpoint should carry, pointing a stock client at
35
+ * the metadata. The metadata URL is the RFC 9728 derivation: the well-known prefix inserted before
36
+ * the resource's path.
37
+ */
38
+ export function buildWwwAuthenticate(metadata) {
39
+ const resource = new URL(metadata.resource);
40
+ const metadataUrl = `${resource.origin}/.well-known/oauth-protected-resource${resource.pathname.replace(/\/$/, "")}`;
41
+ return `Bearer resource_metadata="${metadataUrl}"`;
42
+ }
@@ -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.11.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,14 @@
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
+ },
24
+ "./oauth-resource": {
25
+ "types": "./dist/oauth-resource.d.ts",
26
+ "default": "./dist/oauth-resource.js"
27
+ },
20
28
  "./react": {
21
29
  "types": "./dist/react.d.ts",
22
30
  "default": "./dist/react.js"