@tollbooth-dpyc/web 1.8.2 → 2.0.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/README.md CHANGED
@@ -5,8 +5,9 @@ the shared code every operator front end needs, so a site holds only its own
5
5
  features. It is the peer of the `tollbooth-dpyc` Python wheel.
6
6
 
7
7
  - **npub sign-in** — a Nostr DM challenge, or an in-browser session key
8
- - **proofs** — the npub/`dpop_token` envelope on every paid call, a fresh
9
- kind-27235 inline proof when the tab holds a session key
8
+ - **identity on the connection** — every request signed with the tab's session
9
+ key (NIP-98), or carried by the tab's holder connection, whose npubs a DM
10
+ proved; a call names only the npub
10
11
  - **MCP client** — one shared connection to the operator, typed wrappers for
11
12
  the standard tools (balance, top-up, statement, price, profile)
12
13
  - **React components** — `AppShell` / `useAppShell`, `SiteNav`, `AccountPage`,
package/dist/client.d.ts CHANGED
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * 1. One @modelcontextprotocol/sdk Client over StreamableHTTP, shared by the
5
5
  * whole page. The SDK handles the initialize handshake and the session.
6
- * 2. Auth is the uniform npub proof, by one of two tactics, invisible to
7
- * callers: a session nsec signs a fresh kind-27235 proof per call, bound to
8
- * the runtime tool name; otherwise the DM proof token cached at
9
- * `receive_npub_proof` is sent as is.
10
- * 3. Bootstrap tools take no envelope and are safe before sign-in.
6
+ * 2. Identity rides on the connection, invisible to callers: while this tab
7
+ * holds the signed-in npub's key, every request is signed with it (NIP-98);
8
+ * otherwise the request carries this tab's holder bearer, and the operator
9
+ * holds the DM proof. A call names only the npub.
10
+ * 3. Bootstrap tools take no npub and are safe before sign-in.
11
11
  */
12
12
  import { type ImageBlock } from "./toolResult.ts";
13
13
  export { isNetworkError, NetworkError } from "./networkError.ts";
@@ -18,7 +18,7 @@ export declare class ProofRequiredError extends Error {
18
18
  type ProofExpiredListener = (message: string) => void;
19
19
  /**
20
20
  * Subscribe to proof bounces from anywhere in the app. Without this the tree
21
- * never learns the token was cleared, and the patron is stranded on a page
21
+ * never learns the sign-in was dropped, and the patron is stranded on a page
22
22
  * whose data will not load. Returns an unsubscribe function.
23
23
  */
24
24
  export declare function onProofExpired(cb: ProofExpiredListener): () => void;
package/dist/client.js CHANGED
@@ -3,24 +3,24 @@
3
3
  *
4
4
  * 1. One @modelcontextprotocol/sdk Client over StreamableHTTP, shared by the
5
5
  * whole page. The SDK handles the initialize handshake and the session.
6
- * 2. Auth is the uniform npub proof, by one of two tactics, invisible to
7
- * callers: a session nsec signs a fresh kind-27235 proof per call, bound to
8
- * the runtime tool name; otherwise the DM proof token cached at
9
- * `receive_npub_proof` is sent as is.
10
- * 3. Bootstrap tools take no envelope and are safe before sign-in.
6
+ * 2. Identity rides on the connection, invisible to callers: while this tab
7
+ * holds the signed-in npub's key, every request is signed with it (NIP-98);
8
+ * otherwise the request carries this tab's holder bearer, and the operator
9
+ * holds the DM proof. A call names only the npub.
10
+ * 3. Bootstrap tools take no npub and are safe before sign-in.
11
11
  */
12
12
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
13
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14
14
  import { tollboothConfig, toolName } from "./config.js";
15
15
  import { debugPush, redact } from "./debugLog.js";
16
- import { clearStoredProof, forgetRecentLogin, getStoredNpub, getStoredProof, } from "./identity.js";
17
- import { signInlineProof } from "./inlineProof.js";
16
+ import { isHolder } from "./holder.js";
17
+ import { forgetRecentLogin, getStoredNpub } from "./identity.js";
18
18
  import { isTransportFailure, NetworkError } from "./networkError.js";
19
- import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
19
+ import { signingFetch, signsForSignedInNpub } from "./requestSigning.js";
20
20
  import { errorText, deadGrantMessage, looksFailed, proofBounceMessage, readToolResult, } from "./toolResult.js";
21
21
  /**
22
- * Standard tools whose wheel signature takes no npub/proof envelope. Pydantic
23
- * strict mode rejects unexpected kwargs, so they must not get one.
22
+ * Standard tools whose wheel signature takes no `npub`. Pydantic strict mode
23
+ * rejects unexpected kwargs, so they must not get one.
24
24
  */
25
25
  const STANDARD_BOOTSTRAP = [
26
26
  "request_npub_proof",
@@ -56,7 +56,7 @@ async function getClient() {
56
56
  // call, and nothing reached the server until the page was reloaded.
57
57
  connecting = (async () => {
58
58
  const c = new Client({ name: `${tollboothConfig().slug}-frontend`, version: "1" });
59
- await c.connect(new StreamableHTTPClientTransport(new URL(mcpUrl())));
59
+ await c.connect(new StreamableHTTPClientTransport(new URL(mcpUrl()), { fetch: signingFetch }));
60
60
  client = c;
61
61
  })().finally(() => {
62
62
  connecting = null;
@@ -67,25 +67,6 @@ async function getClient() {
67
67
  throw new Error("Could not connect to the service.");
68
68
  return client;
69
69
  }
70
- /**
71
- * The proof for one call: a fresh inline proof when this tab's session key
72
- * derives to the signed-in npub, else the cached DM token. A session key left
73
- * from a previous identity is evicted rather than allowed to sign.
74
- */
75
- function proofFor(tool) {
76
- try {
77
- const npub = getStoredNpub();
78
- const sessionNpub = hasSessionNsec() ? sessionNsecNpub() : null;
79
- if (sessionNpub && sessionNpub === npub)
80
- return { token: signInlineProof(toolName(tool)), inline: true };
81
- if (sessionNpub)
82
- clearSessionNsec();
83
- }
84
- catch {
85
- /* fall through to the cached token */
86
- }
87
- return { token: getStoredProof(), inline: false };
88
- }
89
70
  export { isNetworkError, NetworkError } from "./networkError.js";
90
71
  /** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
91
72
  export class ProofRequiredError extends Error {
@@ -97,7 +78,7 @@ export class ProofRequiredError extends Error {
97
78
  const proofExpiredListeners = new Set();
98
79
  /**
99
80
  * Subscribe to proof bounces from anywhere in the app. Without this the tree
100
- * never learns the token was cleared, and the patron is stranded on a page
81
+ * never learns the sign-in was dropped, and the patron is stranded on a page
101
82
  * whose data will not load. Returns an unsubscribe function.
102
83
  */
103
84
  export function onProofExpired(cb) {
@@ -126,15 +107,15 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
126
107
  const cfg = tollboothConfig();
127
108
  const name = toolName(tool);
128
109
  const quiet = STANDARD_QUIET.includes(tool) || cfg.quietTools.includes(tool);
129
- // `args` holds only the caller's own parameters — the envelope is added
130
- // below — so it is safe to log.
110
+ // `args` holds only the caller's own parameters — the npub is added below —
111
+ // so it is safe to log.
131
112
  if (!quiet)
132
113
  debugPush("call", `${name}(${redact(JSON.stringify(args)).slice(0, 140)})`);
133
114
  const bootstrap = STANDARD_BOOTSTRAP.includes(tool) || cfg.extraBootstrapTools.includes(tool);
134
- const proof = bootstrap ? null : proofFor(tool);
135
- const merged = proof
136
- ? { npub: getStoredNpub(), dpop_token: proof.token, ...args }
137
- : { ...args };
115
+ const merged = bootstrap ? { ...args } : { npub: getStoredNpub(), ...args };
116
+ // A call this tab's holder proves rides a grant the operator keeps; a signed
117
+ // call proves itself afresh and has none to lose.
118
+ const rodeHolder = !bootstrap && isHolder() && !signsForSignedInNpub();
138
119
  let result;
139
120
  try {
140
121
  const c = await getClient();
@@ -164,16 +145,15 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
164
145
  const tail = images.length ? ` +${images.length} image` : "";
165
146
  debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${redact(String(preview)).slice(0, 220)}${tail}`);
166
147
  }
167
- // A stored grant the service refused for good signs the patron out even
168
- // from a best-effort call: that is not a blip, and staying "signed in" on a
169
- // dead grant leaves every page failing with nothing to tell them why.
148
+ // A held grant the service refused for good signs the patron out even from
149
+ // a best-effort call: that is not a blip, and staying "signed in" on a dead
150
+ // grant leaves every page failing with nothing to tell them why.
170
151
  const bounced = (opts.bestEffort ? null : proofBounceMessage(payload)) ??
171
- (proof && !proof.inline && proof.token ? deadGrantMessage(payload) : null);
152
+ (rodeHolder ? deadGrantMessage(payload) : null);
172
153
  if (bounced !== null) {
173
- // The token just refused is the same one the recent-login shortcut
174
- // would replay, so it goes too, or a returning patron re-bounces.
154
+ // The recent-login shortcut would re-enter on the same refused hold, so it
155
+ // goes too, or a returning patron re-bounces.
175
156
  const npub = getStoredNpub();
176
- clearStoredProof();
177
157
  if (npub)
178
158
  forgetRecentLogin(npub);
179
159
  emitProofExpired(bounced);
package/dist/config.d.ts CHANGED
@@ -19,6 +19,12 @@ export interface TollboothConfig {
19
19
  * origin, which is how every site reaches its Pages proxy.
20
20
  */
21
21
  mcpUrl: string;
22
+ /**
23
+ * The operator's own origin, e.g. "https://goodearth-mcp.fastmcp.app". When
24
+ * set, a patron who signs in by DM connects as a holder there: the grant
25
+ * stays with the operator and the page names only the npub.
26
+ */
27
+ operatorUrl?: string;
22
28
  /**
23
29
  * Free tools this operator adds that take no npub/proof envelope, on top of
24
30
  * the standard ones. Pydantic rejects unexpected kwargs, so a tool listed
@@ -32,7 +38,8 @@ export interface TollboothConfig {
32
38
  /** How long a page sits untouched before its guided tour is offered, in ms. Defaults to 10 s. */
33
39
  tourIdleMs?: number;
34
40
  }
35
- interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices" | "tourIdleMs">> {
41
+ interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices" | "tourIdleMs" | "operatorUrl">> {
42
+ operatorUrl?: string;
36
43
  extraBootstrapTools: string[];
37
44
  quietTools: string[];
38
45
  avatarChoices?: string[];
package/dist/config.js CHANGED
@@ -17,6 +17,7 @@ export function configureTollbooth(config) {
17
17
  appName: config.appName,
18
18
  storagePrefix: config.storagePrefix ?? config.slug,
19
19
  mcpUrl: config.mcpUrl,
20
+ operatorUrl: config.operatorUrl,
20
21
  extraBootstrapTools: config.extraBootstrapTools ?? [],
21
22
  quietTools: config.quietTools ?? [],
22
23
  avatarChoices: config.avatarChoices,
@@ -7,7 +7,7 @@
7
7
  * that flips the view can still be read and copied afterward.
8
8
  *
9
9
  * Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
10
- * token, the value of any secret-named field (dpop_token, proof, poison,
10
+ * token, the value of any secret-named field (nonce, proof, poison,
11
11
  * password, api_key, …), and a secret inside a URL (a query token, a
12
12
  * bearer-like path segment, userinfo) never reach the buffer, the clipboard
13
13
  * or storage.
package/dist/debugLog.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * that flips the view can still be read and copied afterward.
8
8
  *
9
9
  * Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
10
- * token, the value of any secret-named field (dpop_token, proof, poison,
10
+ * token, the value of any secret-named field (nonce, proof, poison,
11
11
  * password, api_key, …), and a secret inside a URL (a query token, a
12
12
  * bearer-like path segment, userinfo) never reach the buffer, the clipboard
13
13
  * or storage.
@@ -28,9 +28,9 @@ const listeners = new Set();
28
28
  // ── Redaction ──────────────────────────────────────────────────────────────
29
29
  const REDACTED = "[redacted]";
30
30
  /** Field names whose value is a secret wherever it appears. */
31
- const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|proof|poison|password|passphrase|secret|token|authorization|api_?key|private_?key|client_secret|[a-z0-9_]*_(?:nsec|token|secret|password|passphrase|api_?key|private_?key))";
32
- // `"dpop_token":"…"`, `\"dpop_token\":\"…\"` (JSON inside a JSON string),
33
- // `dpop_token=…` (a query string) and `dpop_token: '…'` (a Python repr). A
31
+ const SECRET_FIELD = "(?:nsec|ncryptsec|nonce|proof|poison|password|passphrase|secret|token|authorization|api_?key|private_?key|client_secret|[a-z0-9_]*_(?:nsec|token|secret|password|passphrase|api_?key|private_?key))";
32
+ // `"nonce":"…"`, `\"nonce\":\"…\"` (JSON inside a JSON string),
33
+ // `nonce=…` (a query string) and `nonce: '…'` (a Python repr). A
34
34
  // quoted value is taken whole — spaces, escaped quotes and the other kind of
35
35
  // quote included — up to its own closing quote or the end of a truncated line;
36
36
  // a bare one up to a delimiter. Stopping at the first quote of either kind let
@@ -56,7 +56,7 @@ function scrubbed(_match, head, quote) {
56
56
  // back in a tool result is logged whole. Three places a URL hides a secret:
57
57
  // the userinfo, a query or fragment parameter, and a bearer-like path segment.
58
58
  /** Query/fragment parameter names whose value is a secret (matched whole). */
59
- const URL_SECRET_PARAM = /([?&#;](?:token|access_token|refresh_token|id_token|auth|authorization|key|api_key|apikey|secret|client_secret|password|pass|sig|signature|code|proof|dpop_token|session|sessionid|nsec)=)[^&#;\s"'\\<>]*/gi;
59
+ const URL_SECRET_PARAM = /([?&#;](?:token|access_token|refresh_token|id_token|auth|authorization|key|api_key|apikey|secret|client_secret|password|pass|sig|signature|code|proof|nonce|session|sessionid|nsec)=)[^&#;\s"'\\<>]*/gi;
60
60
  // A URL up to whitespace, a quote, an escape or an angle bracket — so one
61
61
  // inside JSON (or JSON inside JSON) ends at its own closing quote.
62
62
  const URL_IN_TEXT = /\b(?:https?|webcal|wss?):\/\/[^\s"'\\<>]+/gi;
@@ -211,7 +211,7 @@ export function debugLogText() {
211
211
  // Auth and funding outcomes are situations, not faults: the service answered
212
212
  // correctly and the patron has a step to take (sign in, top up). These SDK
213
213
  // error codes read as a notice and stay out of the failure count.
214
- const NOTICE_CODE = /error_code\\?"\s*:\s*\\?"(npub_missing|proof_missing|proof_required|proof_refresh_needed|dpop_token_missing|oauth_not_yet_authorized|oauth_token_expired|upstream_auth_refresh_needed|insufficient_balance|authority_insufficient_balance|upstream_subscription_required|operator_llm_unfunded)\\?"/i;
214
+ const NOTICE_CODE = /error_code\\?"\s*:\s*\\?"(npub_missing|proof_required|nonce_missing|oauth_not_yet_authorized|oauth_token_expired|upstream_auth_refresh_needed|insufficient_balance|authority_insufficient_balance|upstream_subscription_required|operator_llm_unfunded)\\?"/i;
215
215
  function isFailure(entry) {
216
216
  if (entry.type === "error")
217
217
  return true;
package/dist/funding.d.ts CHANGED
@@ -37,9 +37,11 @@ export interface BalanceThresholds {
37
37
  /** How this tab proves who it is. */
38
38
  export type ProofKind = {
39
39
  kind: "session_nsec";
40
- } | {
41
- kind: "dm_proof";
42
- status: "valid" | "expired" | "unknown";
40
+ }
41
+ /** The operator holds a DM proof for this tab's holder connection. */
42
+ | {
43
+ kind: "held";
44
+ status: "valid" | "lapsed" | "not_held" | "unknown";
43
45
  expiresInSec?: number | null;
44
46
  } | {
45
47
  kind: "none";
package/dist/funding.js CHANGED
@@ -45,10 +45,12 @@ export function composeProofRow(proof, checkedAt) {
45
45
  return r("ok", "This tab's session key signs a fresh proof on every call — no expiry.");
46
46
  if (proof.kind === "none")
47
47
  return r("blocked", "No proof on hand — sign in again.");
48
- if (proof.status === "expired")
48
+ if (proof.status === "lapsed")
49
49
  return r("blocked", "Proof has expired — sign in again to resume paid calls.");
50
+ if (proof.status === "not_held")
51
+ return r("blocked", "The service no longer holds your proof — sign in again.");
50
52
  if (proof.status === "unknown")
51
- return r("warning", "No server-side proof record — the next paid call may ask you to sign in again.");
53
+ return r("warning", "Could not read the proof's status — the next paid call may ask you to sign in again.");
52
54
  const sec = proof.expiresInSec;
53
55
  if (sec != null && sec * 1000 < WARNING_WITHIN_MS)
54
56
  return r("warning", `Proof valid — expires in about ${durationText(sec)}.`);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * This tab as a *holder*: a connection the operator knows, into whose wallet
3
+ * npubs are proven by DM. A patron who signs in by DM — holding no key — is
4
+ * proven this way, and the grant stays with the operator; the page holds only
5
+ * the connection's OAuth tokens and names the npub on each call.
6
+ *
7
+ * Becoming a holder is the OAuth Authorization Code flow every MCP client
8
+ * runs: register, send the browser to the operator's `/authorize` (it approves
9
+ * at once, since a holder holds nothing until a DM proves an npub into it),
10
+ * and exchange the code it hands back. Tokens refresh on their own; a refresh
11
+ * token is single-use, so each refresh stores the pair it returns.
12
+ */
13
+ /** True when this site connects as a holder (its operator supports it). */
14
+ export declare function holderEnabled(): boolean;
15
+ /** True when this tab already is a holder. */
16
+ export declare function isHolder(): boolean;
17
+ /**
18
+ * Become a holder: register and send the browser to the operator, which
19
+ * sends it straight back. `resume` comes back from `completeHolderConnect`.
20
+ */
21
+ export declare function connectAsHolder(resume: string): Promise<void>;
22
+ /**
23
+ * On return from the operator: exchange the code, store the tokens, tidy the
24
+ * address bar, and hand back what the sign-in was doing. Null when this page
25
+ * load is not a return.
26
+ */
27
+ export declare function completeHolderConnect(): Promise<string | null>;
28
+ /** The bearer for the next request, refreshed when it is about to lapse; null when not a holder. */
29
+ export declare function holderBearer(): Promise<string | null>;
30
+ /** Disconnect: the operator forgets this holder and every npub it held. */
31
+ export declare function forgetHolder(): Promise<void>;
package/dist/holder.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * This tab as a *holder*: a connection the operator knows, into whose wallet
3
+ * npubs are proven by DM. A patron who signs in by DM — holding no key — is
4
+ * proven this way, and the grant stays with the operator; the page holds only
5
+ * the connection's OAuth tokens and names the npub on each call.
6
+ *
7
+ * Becoming a holder is the OAuth Authorization Code flow every MCP client
8
+ * runs: register, send the browser to the operator's `/authorize` (it approves
9
+ * at once, since a holder holds nothing until a DM proves an npub into it),
10
+ * and exchange the code it hands back. Tokens refresh on their own; a refresh
11
+ * token is single-use, so each refresh stores the pair it returns.
12
+ */
13
+ import { tollboothConfig } from "./config.js";
14
+ import { readStored, removeStored, writeStored } from "./storage.js";
15
+ const TOKENS = "holder:v1";
16
+ const PENDING = "holder_pending:v1";
17
+ /** Refresh this long before the access token lapses. */
18
+ const REFRESH_EARLY_MS = 60_000;
19
+ function operator() {
20
+ const url = tollboothConfig().operatorUrl;
21
+ if (!url)
22
+ throw new Error("operatorUrl is not configured.");
23
+ return url.replace(/\/+$/, "");
24
+ }
25
+ /** True when this site connects as a holder (its operator supports it). */
26
+ export function holderEnabled() {
27
+ return Boolean(tollboothConfig().operatorUrl);
28
+ }
29
+ function redirectUri() {
30
+ return `${globalThis.location.origin}/`;
31
+ }
32
+ function readTokens() {
33
+ try {
34
+ const t = JSON.parse(readStored(TOKENS) || "null");
35
+ return t && typeof t.accessToken === "string" && typeof t.refreshToken === "string" ? t : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function writeTokens(t) {
42
+ writeStored(TOKENS, JSON.stringify(t));
43
+ }
44
+ function randomUrlSafe(bytes) {
45
+ const raw = crypto.getRandomValues(new Uint8Array(bytes));
46
+ return btoa(String.fromCharCode(...raw)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
47
+ }
48
+ async function challengeFor(verifier) {
49
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
50
+ return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
51
+ }
52
+ async function register() {
53
+ const r = await fetch(`${operator()}/register`, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({
57
+ client_name: tollboothConfig().appName,
58
+ redirect_uris: [redirectUri()],
59
+ grant_types: ["authorization_code", "refresh_token"],
60
+ response_types: ["code"],
61
+ token_endpoint_auth_method: "none",
62
+ }),
63
+ });
64
+ if (!r.ok)
65
+ throw new Error(`Could not register with the service (${r.status}).`);
66
+ return (await r.json()).client_id;
67
+ }
68
+ async function token(form, clientId) {
69
+ const r = await fetch(`${operator()}/token`, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
72
+ body: new URLSearchParams({ ...form, client_id: clientId, resource: `${operator()}/mcp` }),
73
+ });
74
+ if (!r.ok)
75
+ throw new Error(`The service refused the connection (${r.status}).`);
76
+ const t = await r.json();
77
+ return {
78
+ clientId,
79
+ accessToken: t.access_token,
80
+ refreshToken: t.refresh_token,
81
+ expiresAt: Date.now() + Number(t.expires_in ?? 3600) * 1000,
82
+ };
83
+ }
84
+ /** True when this tab already is a holder. */
85
+ export function isHolder() {
86
+ return readTokens() !== null;
87
+ }
88
+ /**
89
+ * Become a holder: register and send the browser to the operator, which
90
+ * sends it straight back. `resume` comes back from `completeHolderConnect`.
91
+ */
92
+ export async function connectAsHolder(resume) {
93
+ const clientId = readTokens()?.clientId || (await register());
94
+ const verifier = randomUrlSafe(48);
95
+ const state = randomUrlSafe(16);
96
+ const pending = { clientId, verifier, state, resume };
97
+ globalThis.sessionStorage.setItem(PENDING, JSON.stringify(pending));
98
+ const url = new URL(`${operator()}/authorize`);
99
+ url.search = new URLSearchParams({
100
+ response_type: "code",
101
+ client_id: clientId,
102
+ redirect_uri: redirectUri(),
103
+ state,
104
+ code_challenge: await challengeFor(verifier),
105
+ code_challenge_method: "S256",
106
+ resource: `${operator()}/mcp`,
107
+ }).toString();
108
+ globalThis.location.assign(url.toString());
109
+ }
110
+ /**
111
+ * On return from the operator: exchange the code, store the tokens, tidy the
112
+ * address bar, and hand back what the sign-in was doing. Null when this page
113
+ * load is not a return.
114
+ */
115
+ export async function completeHolderConnect() {
116
+ const params = new URLSearchParams(globalThis.location.search);
117
+ const code = params.get("code");
118
+ const raw = globalThis.sessionStorage.getItem(PENDING);
119
+ if (!code || !raw)
120
+ return null;
121
+ globalThis.sessionStorage.removeItem(PENDING);
122
+ const pending = JSON.parse(raw);
123
+ globalThis.history.replaceState(null, "", globalThis.location.pathname);
124
+ if (params.get("state") !== pending.state)
125
+ throw new Error("The service's reply did not match this sign-in.");
126
+ writeTokens(await token({ grant_type: "authorization_code", code, redirect_uri: redirectUri(), code_verifier: pending.verifier }, pending.clientId));
127
+ return pending.resume;
128
+ }
129
+ /** The bearer for the next request, refreshed when it is about to lapse; null when not a holder. */
130
+ export async function holderBearer() {
131
+ const t = readTokens();
132
+ if (!t)
133
+ return null;
134
+ if (t.expiresAt - REFRESH_EARLY_MS > Date.now())
135
+ return t.accessToken;
136
+ try {
137
+ const fresh = await token({ grant_type: "refresh_token", refresh_token: t.refreshToken }, t.clientId);
138
+ writeTokens(fresh);
139
+ return fresh.accessToken;
140
+ }
141
+ catch {
142
+ removeStored(TOKENS);
143
+ return null;
144
+ }
145
+ }
146
+ /** Disconnect: the operator forgets this holder and every npub it held. */
147
+ export async function forgetHolder() {
148
+ const t = readTokens();
149
+ removeStored(TOKENS);
150
+ if (!t)
151
+ return;
152
+ try {
153
+ await fetch(`${operator()}/revoke`, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
156
+ body: new URLSearchParams({ token: t.refreshToken, token_type_hint: "refresh_token", client_id: t.clientId }),
157
+ });
158
+ }
159
+ catch {
160
+ /* the tokens are gone from this tab; the operator lets them lapse */
161
+ }
162
+ }
@@ -1,36 +1,40 @@
1
1
  /**
2
2
  * Who this browser says it is, and what backs the claim.
3
3
  *
4
- * Three stored things, kept apart on purpose:
4
+ * Two stored things, kept apart on purpose:
5
5
  * - the **npub** the app acts as — written only once it is proven;
6
- * - the cached **DM proof** that backs it, when the patron signed in by DM;
7
6
  * - the **last typed** npub, remembered only to prefill the field. Writing a
8
7
  * name into the identity slot before it was proven is what once let an
9
8
  * unanswered challenge become a session.
9
+ *
10
+ * What backs the npub lives on the connection: this tab's session key signs
11
+ * every request, or the operator holds the DM proof for this tab's holder
12
+ * connection. The page itself keeps no proof.
10
13
  */
11
14
  import { type Claim } from "./signedIn.ts";
12
15
  export declare function getStoredNpub(): string;
13
16
  export declare function setStoredNpub(npub: string): void;
14
- export declare function getStoredProof(): string;
15
- export declare function setStoredProof(proof: string): void;
16
- export declare function clearStoredProof(): void;
17
17
  export declare function getLastTypedNpub(): string;
18
18
  export declare function setLastTypedNpub(npub: string): void;
19
+ /** An npub this tab's holder connection holds, until its DM proof lapses. */
19
20
  export interface RecentLogin {
20
21
  npub: string;
21
- proof: string;
22
22
  /** Unix ms. */
23
23
  expiresAt: number;
24
24
  /** Unix ms. */
25
25
  lastUsed: number;
26
26
  }
27
- /** Unexpired recent logins, most recent first. Prunes the expired ones. */
27
+ /**
28
+ * Unexpired recent logins, most recent first; none unless this tab is a
29
+ * holder, since the holder is what holds them. Prunes the expired ones.
30
+ */
28
31
  export declare function getValidRecentLogins(): RecentLogin[];
29
32
  /**
30
- * Record or refresh a successful login. The TTL is derated by 30 seconds so a
31
- * straggler never serves an already-expired token to the next paid call.
33
+ * Record or refresh a DM sign-in this tab's holder now holds. The TTL is
34
+ * derated by 30 seconds so a straggler never re-enters on a proof that lapses
35
+ * before the next paid call.
32
36
  */
33
- export declare function recordRecentLogin(npub: string, proof: string, expiresInSec: number): void;
37
+ export declare function recordRecentLogin(npub: string, expiresInSec: number): void;
34
38
  export declare function forgetRecentLogin(npub: string): void;
35
39
  /** Read once and handed to the pure predicates in `signedIn.ts`. */
36
40
  export declare function currentClaim(): Claim;
package/dist/identity.js CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * Who this browser says it is, and what backs the claim.
3
3
  *
4
- * Three stored things, kept apart on purpose:
4
+ * Two stored things, kept apart on purpose:
5
5
  * - the **npub** the app acts as — written only once it is proven;
6
- * - the cached **DM proof** that backs it, when the patron signed in by DM;
7
6
  * - the **last typed** npub, remembered only to prefill the field. Writing a
8
7
  * name into the identity slot before it was proven is what once let an
9
8
  * unanswered challenge become a session.
9
+ *
10
+ * What backs the npub lives on the connection: this tab's session key signs
11
+ * every request, or the operator holds the DM proof for this tab's holder
12
+ * connection. The page itself keeps no proof.
10
13
  */
14
+ import { forgetHolder, isHolder } from "./holder.js";
11
15
  import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
12
16
  import { canSignFor, isProven } from "./signedIn.js";
13
17
  import { readStored, removeStored, writeStored } from "./storage.js";
14
18
  const NPUB = "patron_npub:v1";
15
- const PROOF = "proof_token:v1";
16
19
  const LAST_TYPED = "last_typed_npub:v1";
17
20
  const RECENT = "recent-logins:v1";
18
21
  const MAX_RECENT = 5;
@@ -22,15 +25,6 @@ export function getStoredNpub() {
22
25
  export function setStoredNpub(npub) {
23
26
  writeStored(NPUB, npub);
24
27
  }
25
- export function getStoredProof() {
26
- return readStored(PROOF);
27
- }
28
- export function setStoredProof(proof) {
29
- writeStored(PROOF, proof);
30
- }
31
- export function clearStoredProof() {
32
- removeStored(PROOF);
33
- }
34
28
  export function getLastTypedNpub() {
35
29
  return readStored(LAST_TYPED);
36
30
  }
@@ -43,7 +37,7 @@ function readRecent() {
43
37
  if (!Array.isArray(parsed))
44
38
  return [];
45
39
  return parsed.filter((e) => typeof e === "object" && e !== null &&
46
- typeof e.npub === "string" && typeof e.proof === "string" &&
40
+ typeof e.npub === "string" &&
47
41
  typeof e.expiresAt === "number" && typeof e.lastUsed === "number");
48
42
  }
49
43
  catch {
@@ -53,8 +47,13 @@ function readRecent() {
53
47
  function writeRecent(entries) {
54
48
  writeStored(RECENT, JSON.stringify(entries));
55
49
  }
56
- /** Unexpired recent logins, most recent first. Prunes the expired ones. */
50
+ /**
51
+ * Unexpired recent logins, most recent first; none unless this tab is a
52
+ * holder, since the holder is what holds them. Prunes the expired ones.
53
+ */
57
54
  export function getValidRecentLogins() {
55
+ if (!isHolder())
56
+ return [];
58
57
  const now = Date.now();
59
58
  const entries = readRecent();
60
59
  const valid = entries.filter((e) => e.expiresAt > now);
@@ -63,14 +62,14 @@ export function getValidRecentLogins() {
63
62
  return valid.sort((a, b) => b.lastUsed - a.lastUsed);
64
63
  }
65
64
  /**
66
- * Record or refresh a successful login. The TTL is derated by 30 seconds so a
67
- * straggler never serves an already-expired token to the next paid call.
65
+ * Record or refresh a DM sign-in this tab's holder now holds. The TTL is
66
+ * derated by 30 seconds so a straggler never re-enters on a proof that lapses
67
+ * before the next paid call.
68
68
  */
69
- export function recordRecentLogin(npub, proof, expiresInSec) {
69
+ export function recordRecentLogin(npub, expiresInSec) {
70
70
  const now = Date.now();
71
71
  const next = {
72
72
  npub,
73
- proof,
74
73
  expiresAt: now + Math.max(0, expiresInSec - 30) * 1000,
75
74
  lastUsed: now,
76
75
  };
@@ -83,12 +82,17 @@ export function forgetRecentLogin(npub) {
83
82
  // ─── The claim ───────────────────────────────────────────────────────────
84
83
  /** Read once and handed to the pure predicates in `signedIn.ts`. */
85
84
  export function currentClaim() {
85
+ const npub = getStoredNpub();
86
86
  return {
87
- npub: getStoredNpub(),
88
- proof: getStoredProof(),
87
+ npub,
88
+ held: heldByThisTab(npub),
89
89
  sessionNpub: hasSessionNsec() ? sessionNsecNpub() : null,
90
90
  };
91
91
  }
92
+ /** True when this tab's holder connection holds `npub`: a DM sign-in recorded here. */
93
+ function heldByThisTab(npub) {
94
+ return Boolean(npub) && getValidRecentLogins().some((e) => e.npub === npub);
95
+ }
92
96
  export function isLoggedIn() {
93
97
  return isProven(currentClaim());
94
98
  }
@@ -101,6 +105,6 @@ export function canSign() {
101
105
  */
102
106
  export function logOut() {
103
107
  removeStored(NPUB);
104
- removeStored(PROOF);
105
108
  clearSessionNsec();
109
+ void forgetHolder();
106
110
  }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export type { ImageBlock } from "./toolResult.ts";
4
4
  export * from "./identity.ts";
5
5
  export * from "./standardTools.ts";
6
6
  export { clearSessionNsec, getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.ts";
7
- export { signInlineProof } from "./inlineProof.ts";
7
+ export { signRequest } from "./requestSigning.ts";
8
8
  export { sessionKeyClaimVisible } from "./sessionKeyClaim.ts";
9
9
  export { handOffMode, shareOrDownload, type ShareOutcome } from "./shareFile.ts";
10
10
  export { canSignFor, isProven, type Claim } from "./signedIn.ts";