@ractive-ch/tab-mcp 0.5.1 → 0.6.1

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/oauth/cimd.d.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  * against SSRF (threat model A3): https only, default port, no credentials,
8
8
  * the host resolved first and every address checked to be public, no
9
9
  * redirects, 5 s, 64 KiB, `application/json`, results cached and fetches per
10
- * domain and in total rate-limited.
10
+ * domain and in total rate-limited. A `private_key_jwt` client's `jwks_uri`
11
+ * is fetched under the same rules (DEC-106).
11
12
  */
13
+ import { type JwtAlgorithm } from "../jwt.js";
12
14
  import type { Logger } from "../log.js";
13
15
  /** What the relay needs to know about a client. */
14
16
  export interface ClientMetadata {
@@ -19,6 +21,23 @@ export interface ClientMetadata {
19
21
  /** Host of the client id URL: the verified part. */
20
22
  host: string;
21
23
  redirectUris: string[];
24
+ /** How the client authenticates at `/token` (DEC-104, DEC-106). */
25
+ auth: ClientAuth;
26
+ }
27
+ /**
28
+ * `none`: a public client. `private_key_jwt`: it signs a client assertion
29
+ * with a key from `jwksUri`; `publicAllowed` when its document also lists
30
+ * `none` (DEC-104), so a grant may start without one (DEC-106).
31
+ */
32
+ export type ClientAuth = {
33
+ method: "none";
34
+ } | PrivateKeyJwtAuth;
35
+ export interface PrivateKeyJwtAuth {
36
+ method: "private_key_jwt";
37
+ jwksUri: string;
38
+ /** `token_endpoint_auth_signing_alg` if the document names one, else all. */
39
+ algorithms: readonly JwtAlgorithm[];
40
+ publicAllowed: boolean;
22
41
  }
23
42
  /** Why a client id was refused; `message` is plain English for the page. */
24
43
  export declare class ClientMetadataError extends Error {
@@ -45,6 +64,14 @@ export interface ClientMetadataFetcherOptions {
45
64
  testMode?: CimdTestMode;
46
65
  now?: () => number;
47
66
  }
67
+ /** How `validateDocument` reads a document. */
68
+ export interface DocumentOptions {
69
+ /** Test mode: a `jwks_uri` may use `http:` and any port, like the client id. */
70
+ test?: boolean;
71
+ /** A `private_key_jwt` document fell back to `none` (DEC-104), and why. */
72
+ onFallback?: (reason: string) => void;
73
+ }
74
+ export declare function isTransient(error: ClientMetadataError): boolean;
48
75
  /**
49
76
  * Clean a self-declared name for display: control and line/paragraph
50
77
  * separators become spaces; format characters (`Cf`: zero-width, bidi,
@@ -64,8 +91,26 @@ export declare function limitKey(hostname: string): string;
64
91
  export declare class ClientMetadataFetcher {
65
92
  #private;
66
93
  constructor(options?: ClientMetadataFetcherOptions);
67
- /** The client's metadata, from the cache or freshly fetched. Throws `ClientMetadataError`. */
68
- get(clientId: string): Promise<ClientMetadata>;
94
+ /**
95
+ * The client's metadata, from the cache or freshly fetched. Throws
96
+ * `ClientMetadataError`. With `allowStale` (the token and revocation
97
+ * endpoints, DEC-106) a document up to an hour past its cache lifetime is
98
+ * served when the fresh fetch fails transiently or is rate-limited, so a
99
+ * busy or briefly unreachable host does not cut off apps that already
100
+ * have grants. `/authorize` always wants a current document.
101
+ */
102
+ get(clientId: string, options?: {
103
+ allowStale?: boolean;
104
+ }): Promise<ClientMetadata>;
105
+ /**
106
+ * A client's JWKS document, under the CIMD fetch's SSRF rules and rate
107
+ * limits (DEC-106). `validateDocument` checked `jwksUri` (https, the
108
+ * client id's host); this checks it again before any network use. Not
109
+ * cached here: `JwksCache` does that. Only a document that was fetched
110
+ * and accepted names a `jwksUri`, so this is charged to the budget of
111
+ * known clients. Throws `ClientMetadataError`.
112
+ */
113
+ fetchJwks(jwksUri: string): Promise<unknown>;
69
114
  }
70
115
  /**
71
116
  * Whether a name contains a domain-shaped token that is not the verified
@@ -74,7 +119,7 @@ export declare class ClientMetadataFetcher {
74
119
  */
75
120
  export declare function foreignDomain(name: string, hostname: string): boolean;
76
121
  /** Check a fetched document against the CIMD rules and what the relay supports. */
77
- export declare function validateDocument(doc: unknown, clientId: string, url: URL): ClientMetadata;
122
+ export declare function validateDocument(doc: unknown, clientId: string, url: URL, options?: DocumentOptions): ClientMetadata;
78
123
  /**
79
124
  * A redirect URI the relay accepts (DEC-067): absolute, no fragment or
80
125
  * credentials, and `https:`; `http:` only for loopback hosts (RFC 8252 §7.3);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `private_key_jwt` client authentication at `/token` and `/revoke` (RFC 7521
3
+ * §4.2, RFC 7523 §2.2 and §3, OpenID Connect Core §9; DEC-106). The client
4
+ * signs a short JWT with a key it publishes at the `jwks_uri` of its Client
5
+ * ID Metadata Document; the signature is checked with `jwt.ts` (asymmetric
6
+ * algorithms only, each tied to its key type, keys never from the header).
7
+ */
8
+ import { type KeySource } from "../jwt.js";
9
+ import { type PrivateKeyJwtAuth } from "./cimd.js";
10
+ /** The only `client_assertion_type` (RFC 7523 §2.2). */
11
+ export declare const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
12
+ /**
13
+ * Latest `exp` accepted: an hour ahead, the ceiling other authorization
14
+ * servers set (Okta) and a lifetime libraries default to; ChatGPT's own is
15
+ * not known, so a shorter `exp` limit could refuse it.
16
+ */
17
+ export declare const CLIENT_ASSERTION_MAX_LIFETIME_MS: number;
18
+ /**
19
+ * How long an assertion is usable (DEC-106): five minutes from its `iat`,
20
+ * or, without `iat`, `exp` at most five minutes ahead. A client signs just
21
+ * before it sends, so this costs no client anything, and it bounds how long
22
+ * a `jti` must be remembered whatever `exp` says.
23
+ */
24
+ export declare const CLIENT_ASSERTION_MAX_AGE_MS: number;
25
+ /** Clock leeway for `exp`, `iat` and `nbf`. */
26
+ export declare const CLIENT_ASSERTION_LEEWAY_MS = 30000;
27
+ /** Records a one-time key until `expiresAt`; `false` if already recorded. */
28
+ export type ClaimNonce = (key: string, expiresAt: number, now: number) => Promise<boolean>;
29
+ export interface AssertionCheck {
30
+ /** The client id the assertion must be for (`iss` and `sub`). */
31
+ clientId: string;
32
+ auth: PrivateKeyJwtAuth;
33
+ keys: KeySource;
34
+ /** What `aud` must contain one of: the issuer, the endpoint's URL. */
35
+ audiences: readonly string[];
36
+ claimNonce: ClaimNonce;
37
+ now: number;
38
+ }
39
+ /**
40
+ * The `sub` of an assertion, unverified: the client id when the request has
41
+ * no `client_id` (RFC 7521 §4.2). Verification then checks it like any other.
42
+ */
43
+ export declare function assertionSubject(assertion: string): string | undefined;
44
+ /** The replay store's key: the client and its `jti`, hashed. */
45
+ export declare function nonceKey(clientId: string, jti: string): string;
46
+ /**
47
+ * Check a client assertion; throws `JwtError` with a short reason for the
48
+ * log. The `jti` is recorded only once everything else passed, so only the
49
+ * holder of the client's key can add entries to the replay store.
50
+ */
51
+ export declare function verifyClientAssertion(assertion: string, check: AssertionCheck): Promise<void>;
52
+ /**
53
+ * The key a client's replay share is counted under (review of PR 65): its
54
+ * full host name, so clients under one shared suffix (`github.io`,
55
+ * `pages.dev`, `co.uk`) do not share one; an IPv4 literal as is, an IPv6
56
+ * literal by its /64, which one party usually holds whole.
57
+ */
58
+ export declare function quotaKey(clientId: string): string;
59
+ /**
60
+ * The replay store's live entries per AI app host on this instance
61
+ * (DEC-106, keyed by `quotaKey`). Anyone can be a client by publishing a
62
+ * document, so one host may fill only its own share, whatever the store;
63
+ * the total bounds memory. Expired entries are dropped at most once a
64
+ * second.
65
+ */
66
+ export declare class NonceQuota {
67
+ #private;
68
+ constructor(perDomain: number, max: number);
69
+ /** Why `domain` may not add an entry now, or `undefined` if it may. */
70
+ refusal(domain: string, now: number): "domain_full" | "full" | undefined;
71
+ add(domain: string, until: number): void;
72
+ /** Drops expired entries; returns whether it ran (at most once a second). */
73
+ sweep(now: number): boolean;
74
+ }
75
+ /**
76
+ * One `JwksCache` per client `jwks_uri` (10 minutes, an unknown `kid`
77
+ * fetched again after 5 s, the last good keys for an hour after a failure),
78
+ * loaded with the SSRF-guarded fetch. At most 1000, 100 per domain; a new
79
+ * one pushes out the least recently used of the domain that holds the most,
80
+ * so one party's many documents push out their own keys (review of PR 65).
81
+ */
82
+ export declare class ClientKeySets {
83
+ #private;
84
+ constructor(load: (url: string) => Promise<unknown>, now?: () => number);
85
+ for(jwksUri: string): KeySource;
86
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A bounded least-recently-used map whose entries each belong to a domain
3
+ * (DEC-106, review of PR 65). Anyone can publish client documents, so the
4
+ * caches of documents and keys must not let one party push out the others':
5
+ *
6
+ * - one domain holds at most `perDomain` entries; a new one replaces that
7
+ * domain's least recently used entry;
8
+ * - when the map is full, a new entry replaces one that `isDead` says may go
9
+ * (past its use), else the least recently used entry of the domain that
10
+ * holds the most.
11
+ *
12
+ * So many documents of one attacker push out their own. What is left: an
13
+ * attacker with as many domains as the map has room for, one entry each,
14
+ * ties with everyone, and least recently used goes first.
15
+ */
16
+ export declare class DomainCache<V> {
17
+ #private;
18
+ constructor(max: number, perDomain: number, isDead?: (value: V, now: number) => boolean);
19
+ get size(): number;
20
+ /** The value, marked as most recently used. */
21
+ get(key: string): V | undefined;
22
+ delete(key: string): void;
23
+ set(key: string, domain: string, value: V, now: number): void;
24
+ }
package/oauth/server.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * relaxed), `resource` bound to one address (RFC 8707), `iss` in every
9
9
  * response (RFC 9207), and the pairing step of the address's site (DEC-042);
10
10
  * - `/token` for the authorization code and rotating refresh tokens (DEC-029);
11
+ * - client authentication `none` or `private_key_jwt` (DEC-106);
11
12
  * - `/revoke` (RFC 7009).
12
13
  */
13
14
  import type { PairingKind, RelayConfig, Site } from "../config.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ractive-ch/tab-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Relay that lets AI agents call the MCP tools of a web page open in a browser tab",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "types": "./index.d.ts",
20
20
  "engines": {
21
- "bun": ">=1.4.3"
21
+ "bun": ">=1.4.4"
22
22
  },
23
23
  "license": "MIT",
24
24
  "author": "Jean-Pierre Bergamin <james@ractive.ch>",
package/relay.d.ts CHANGED
@@ -13,6 +13,12 @@ export interface CreateRelayOptions extends Omit<Partial<RelayConfig>, "store">
13
13
  /** Logger; defaults to JSON lines on stdout at `logLevel`. */
14
14
  logger?: Logger;
15
15
  }
16
+ /**
17
+ * The public URL in development without `publicUrl`: the loopback address the
18
+ * relay listens on, so the addresses match the URL the page was given;
19
+ * `localhost` for any other host (`0.0.0.0`, `::`, a name).
20
+ */
21
+ export declare function developmentUrl(host: string, port: number): string;
16
22
  export interface Relay {
17
23
  /** The external origin (issuer and address base). */
18
24
  readonly url: string;
package/store/memory.d.ts CHANGED
@@ -3,6 +3,18 @@ import type { GrantSummary, RevokeOptions, RevokeSelector, TokenRecord, TokenSto
3
3
  export declare function isLive(record: TokenRecord, now: number): boolean;
4
4
  /** Group live records into grant summaries, oldest grant first. */
5
5
  export declare function summarizeGrants(records: Iterable<TokenRecord>, now: number): GrantSummary[];
6
+ /**
7
+ * One-time values (client assertion `jti`s, DEC-106) until their expiry, in
8
+ * process memory: `TokenStore.claimNonce` for the memory store, and the
9
+ * relay's own fallback for stores without it.
10
+ */
11
+ export declare class NonceSet {
12
+ #private;
13
+ get size(): number;
14
+ claim(key: string, expiresAt: number, now: number): boolean;
15
+ sweep(now: number): number;
16
+ clear(): void;
17
+ }
6
18
  /** Tokens in process memory; everything is lost on restart. */
7
19
  export declare class MemoryTokenStore implements TokenStore {
8
20
  #private;
@@ -12,5 +24,6 @@ export declare class MemoryTokenStore implements TokenStore {
12
24
  revoke(selector: RevokeSelector, options: RevokeOptions): Promise<number>;
13
25
  listGrants(addressId: string, now: number): Promise<GrantSummary[]>;
14
26
  deleteExpired(now: number): Promise<number>;
27
+ claimNonce(key: string, expiresAt: number, now: number): Promise<boolean>;
15
28
  close(): Promise<void>;
16
29
  }
package/store/sqlite.d.ts CHANGED
@@ -13,5 +13,7 @@ export declare class SqliteTokenStore implements TokenStore {
13
13
  revoke(selector: RevokeSelector, options: RevokeOptions): Promise<number>;
14
14
  listGrants(addressId: string, now: number): Promise<GrantSummary[]>;
15
15
  deleteExpired(now: number): Promise<number>;
16
+ /** One statement: inserted, or an expired entry replaced; a live one stays. */
17
+ claimNonce(key: string, expiresAt: number, now: number): Promise<boolean>;
16
18
  close(): Promise<void>;
17
19
  }
package/store/types.d.ts CHANGED
@@ -45,6 +45,14 @@ export interface TokenRecord {
45
45
  * makes every request of such a grant fail as a mismatch.
46
46
  */
47
47
  subject?: string;
48
+ /**
49
+ * Client authentication binding (DEC-106): `private_key_jwt` when the
50
+ * grant was made or refreshed with a client assertion; its refresh tokens
51
+ * then need one too. Must be stored and returned unchanged: a store that
52
+ * drops it lets such a grant's refresh tokens be redeemed without the
53
+ * client's key.
54
+ */
55
+ clientAuth?: "private_key_jwt";
48
56
  }
49
57
  /** One connected AI app of an address. */
50
58
  export interface GrantSummary {
@@ -100,6 +108,15 @@ export interface TokenStore {
100
108
  listGrants(addressId: string, now: number): Promise<GrantSummary[]>;
101
109
  /** Delete every record with `expiresAt <= now` and tombstones past `keepUntil`. Returns how many records. */
102
110
  deleteExpired(now: number): Promise<number>;
111
+ /**
112
+ * Optional (DEC-106): remember a one-time value (the hash of a client
113
+ * assertion's client id and `jti`) until `expiresAt`. Returns `true` for
114
+ * the one call that recorded it, `false` while it is remembered; an entry
115
+ * with `expiresAt <= now` counts as absent. Must be atomic, like
116
+ * `markUsed`. `deleteExpired` should drop expired entries. Without it the
117
+ * relay remembers them in its own memory, which is per instance.
118
+ */
119
+ claimNonce?(key: string, expiresAt: number, now: number): Promise<boolean>;
103
120
  /** Release resources (database handles). Optional. */
104
121
  close?(): Promise<void>;
105
122
  }
package/testing.d.ts CHANGED
@@ -1,5 +1,36 @@
1
1
  /**
2
- * `@ractive-ch/tab-mcp/testing`: the `TokenStore` conformance suite for
3
- * `bun test` (DEC-041).
2
+ * `@ractive-ch/tab-mcp/testing`: helpers for `bun test`. The `TokenStore`
3
+ * conformance suite (DEC-041), and an in-process relay with a token helper
4
+ * for end-to-end tests of a page's tools without pairing by hand.
4
5
  */
6
+ import { type CreateRelayOptions, type Relay } from "./relay.js";
7
+ import type { TokenStore } from "./store/types.js";
5
8
  export { tokenStoreConformance } from "./store/conformance.js";
9
+ /** A relay started by `startTestRelay`. */
10
+ export interface TestRelay {
11
+ relay: Relay;
12
+ /** The relay's token store, for `tokenFor`. */
13
+ store: TokenStore;
14
+ /** The relay's URL, for the page's `relay` option: `http://127.0.0.1:<port>`. */
15
+ url: string;
16
+ stop(): Promise<void>;
17
+ }
18
+ /**
19
+ * Start a relay in this process on a free port of 127.0.0.1, in development
20
+ * mode, open to every page origin, with a memory store and no log output
21
+ * (unless `logger` or `logLevel` is given). `options` override any of that.
22
+ * For tests only: it accepts any origin.
23
+ */
24
+ export declare function startTestRelay(options?: Omit<CreateRelayOptions, "store"> & {
25
+ store?: TokenStore;
26
+ }): TestRelay;
27
+ /**
28
+ * An access token for a page's MCP address, written straight into the relay's
29
+ * token store, as if an AI app had paired. Use it as `Authorization: Bearer
30
+ * <token>` with an MCP client. It lasts an hour unless `ttlMs` says otherwise.
31
+ */
32
+ export declare function tokenFor(store: TokenStore, address: string, options?: {
33
+ clientId?: string;
34
+ clientName?: string;
35
+ ttlMs?: number;
36
+ }): Promise<string>;