@tollbooth-dpyc/web 1.8.2 → 1.9.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/dist/client.d.ts CHANGED
@@ -3,9 +3,9 @@
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
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
+ * and a call names only the npub. Otherwise the DM proof token cached at
9
9
  * `receive_npub_proof` is sent as is.
10
10
  * 3. Bootstrap tools take no envelope and are safe before sign-in.
11
11
  */
package/dist/client.js CHANGED
@@ -3,9 +3,9 @@
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
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
+ * and a call names only the npub. Otherwise the DM proof token cached at
9
9
  * `receive_npub_proof` is sent as is.
10
10
  * 3. Bootstrap tools take no envelope and are safe before sign-in.
11
11
  */
@@ -14,9 +14,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
14
14
  import { tollboothConfig, toolName } from "./config.js";
15
15
  import { debugPush, redact } from "./debugLog.js";
16
16
  import { clearStoredProof, forgetRecentLogin, getStoredNpub, getStoredProof, } from "./identity.js";
17
- import { signInlineProof } from "./inlineProof.js";
18
17
  import { isTransportFailure, NetworkError } from "./networkError.js";
19
- import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
18
+ import { signingFetch, signsForSignedInNpub } from "./requestSigning.js";
20
19
  import { errorText, deadGrantMessage, looksFailed, proofBounceMessage, readToolResult, } from "./toolResult.js";
21
20
  /**
22
21
  * Standard tools whose wheel signature takes no npub/proof envelope. Pydantic
@@ -56,7 +55,7 @@ async function getClient() {
56
55
  // call, and nothing reached the server until the page was reloaded.
57
56
  connecting = (async () => {
58
57
  const c = new Client({ name: `${tollboothConfig().slug}-frontend`, version: "1" });
59
- await c.connect(new StreamableHTTPClientTransport(new URL(mcpUrl())));
58
+ await c.connect(new StreamableHTTPClientTransport(new URL(mcpUrl()), { fetch: signingFetch }));
60
59
  client = c;
61
60
  })().finally(() => {
62
61
  connecting = null;
@@ -68,23 +67,18 @@ async function getClient() {
68
67
  return client;
69
68
  }
70
69
  /**
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.
70
+ * What a call adds to prove the npub: nothing when the request itself is
71
+ * signed with the signed-in npub's key, else the cached DM token.
74
72
  */
75
- function proofFor(tool) {
73
+ function proofFor() {
76
74
  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();
75
+ if (signsForSignedInNpub())
76
+ return {};
83
77
  }
84
78
  catch {
85
79
  /* fall through to the cached token */
86
80
  }
87
- return { token: getStoredProof(), inline: false };
81
+ return { dpop_token: getStoredProof() };
88
82
  }
89
83
  export { isNetworkError, NetworkError } from "./networkError.js";
90
84
  /** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
@@ -131,10 +125,8 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
131
125
  if (!quiet)
132
126
  debugPush("call", `${name}(${redact(JSON.stringify(args)).slice(0, 140)})`);
133
127
  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 };
128
+ const proof = bootstrap ? null : proofFor();
129
+ const merged = proof ? { npub: getStoredNpub(), ...proof, ...args } : { ...args };
138
130
  let result;
139
131
  try {
140
132
  const c = await getClient();
@@ -168,7 +160,7 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
168
160
  // from a best-effort call: that is not a blip, and staying "signed in" on a
169
161
  // dead grant leaves every page failing with nothing to tell them why.
170
162
  const bounced = (opts.bestEffort ? null : proofBounceMessage(payload)) ??
171
- (proof && !proof.inline && proof.token ? deadGrantMessage(payload) : null);
163
+ (proof?.dpop_token ? deadGrantMessage(payload) : null);
172
164
  if (bounced !== null) {
173
165
  // The token just refused is the same one the recent-login shortcut
174
166
  // would replay, so it goes too, or a returning patron re-bounces.
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,
@@ -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
+ }
package/dist/identity.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * name into the identity slot before it was proven is what once let an
9
9
  * unanswered challenge become a session.
10
10
  */
11
+ import { forgetHolder, isHolder } from "./holder.js";
11
12
  import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
12
13
  import { canSignFor, isProven } from "./signedIn.js";
13
14
  import { readStored, removeStored, writeStored } from "./storage.js";
@@ -83,12 +84,21 @@ export function forgetRecentLogin(npub) {
83
84
  // ─── The claim ───────────────────────────────────────────────────────────
84
85
  /** Read once and handed to the pure predicates in `signedIn.ts`. */
85
86
  export function currentClaim() {
87
+ const npub = getStoredNpub();
86
88
  return {
87
- npub: getStoredNpub(),
89
+ npub,
88
90
  proof: getStoredProof(),
91
+ held: heldByThisTab(npub),
89
92
  sessionNpub: hasSessionNsec() ? sessionNsecNpub() : null,
90
93
  };
91
94
  }
95
+ /**
96
+ * True when this tab's holder connection holds `npub`: a DM sign-in recorded
97
+ * here with no token, because the operator kept the grant.
98
+ */
99
+ function heldByThisTab(npub) {
100
+ return Boolean(npub) && isHolder() && getValidRecentLogins().some((e) => e.npub === npub && !e.proof);
101
+ }
92
102
  export function isLoggedIn() {
93
103
  return isProven(currentClaim());
94
104
  }
@@ -103,4 +113,5 @@ export function logOut() {
103
113
  removeStored(NPUB);
104
114
  removeStored(PROOF);
105
115
  clearSessionNsec();
116
+ void forgetHolder();
106
117
  }
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";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { callTool, callToolWithContent, isNetworkError, NetworkError, onProofExp
3
3
  export * from "./identity.js";
4
4
  export * from "./standardTools.js";
5
5
  export { clearSessionNsec, getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.js";
6
- export { signInlineProof } from "./inlineProof.js";
6
+ export { signRequest } from "./requestSigning.js";
7
7
  export { sessionKeyClaimVisible } from "./sessionKeyClaim.js";
8
8
  export { handOffMode, shareOrDownload } from "./shareFile.js";
9
9
  export { canSignFor, isProven } from "./signedIn.js";
@@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "react";
16
16
  import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
17
17
  import { tollboothConfig } from "../config.js";
18
18
  import { forgetRecentLogin, getLastTypedNpub, getValidRecentLogins, recordRecentLogin, setLastTypedNpub, setStoredNpub, setStoredProof, } from "../identity.js";
19
+ import { completeHolderConnect, connectAsHolder, holderEnabled, isHolder } from "../holder.js";
19
20
  import { setSessionNsec } from "../sessionNsec.js";
20
21
  import { readSignInFailure } from "../signInSituation.js";
21
22
  import { receiveNpubProof, requestNpubProof } from "../standardTools.js";
@@ -106,22 +107,45 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
106
107
  setError(`Couldn't read that nsec: ${e.message}`);
107
108
  }
108
109
  }
109
- async function begin() {
110
- if (!isNpub) {
110
+ // Back from connecting to the operator: carry on with the DM it was for.
111
+ const resumed = useRef(false);
112
+ useEffect(() => {
113
+ if (resumed.current)
114
+ return;
115
+ resumed.current = true;
116
+ completeHolderConnect()
117
+ .then((npub) => {
118
+ if (npub) {
119
+ setValue(npub);
120
+ void begin(npub);
121
+ }
122
+ })
123
+ .catch((e) => setError(e.message));
124
+ // eslint-disable-next-line react-hooks/exhaustive-deps
125
+ }, []);
126
+ async function begin(npubArg) {
127
+ const npubToProve = npubArg ?? trimmed;
128
+ if (!(npubToProve.startsWith("npub1") && npubToProve.length >= 60)) {
111
129
  setError("Enter a valid npub1… key.");
112
130
  return;
113
131
  }
114
132
  setBusy(true);
115
133
  setError("");
116
134
  try {
117
- const r = await requestNpubProof(trimmed, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
135
+ // The grant will stay with the operator, so this tab connects to it
136
+ // first; the browser comes straight back here and the DM goes out.
137
+ if (holderEnabled() && !isHolder()) {
138
+ await connectAsHolder(npubToProve);
139
+ return;
140
+ }
141
+ const r = await requestNpubProof(npubToProve, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
118
142
  if (r.error)
119
143
  return setError(r.error);
120
144
  if (!r.dpop_token)
121
145
  return setError("The service did not return a session phrase. Try again.");
122
146
  // Remembered for the field only. The identity is written by `finish()`,
123
147
  // once the human has answered the DM.
124
- setLastTypedNpub(trimmed);
148
+ setLastTypedNpub(npubToProve);
125
149
  setPendingProof(r.dpop_token);
126
150
  setStage("awaiting");
127
151
  }
@@ -143,7 +167,9 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
143
167
  setStage("awaiting");
144
168
  return;
145
169
  }
146
- const token = r.dpop_token || pendingProof;
170
+ // A holder's grant stays with the operator; only a page that is not a
171
+ // holder keeps the token it is handed.
172
+ const token = r.held_by_connection ? "" : r.dpop_token || pendingProof;
147
173
  const npub = r.proven_npub || trimmed;
148
174
  setStoredNpub(npub);
149
175
  setStoredProof(token);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Every request a page sends while it holds the signed-in npub's key is signed
3
+ * with that key (NIP-98): an `Authorization: Nostr` event bound to the method,
4
+ * the URL and the sha256 of the body. Whoever holds the key needs no grant, and
5
+ * no tool call carries a proof in its arguments.
6
+ */
7
+ /**
8
+ * 16 random bytes as 32 lowercase hex chars. Without it two requests signed in
9
+ * the same wall-clock second could be byte-identical, their ids would collide,
10
+ * and the server's replay guard would refuse the second.
11
+ */
12
+ export declare function proofNonce(): string;
13
+ /** The `Authorization` value for one request, signed by `secretKey`. */
14
+ export declare function signRequest(method: string, url: string, body: string, secretKey: Uint8Array): Promise<string>;
15
+ /**
16
+ * True when this tab holds the key of the npub that is signed in. A key left
17
+ * from a previous identity is evicted rather than allowed to sign.
18
+ */
19
+ export declare function signsForSignedInNpub(): boolean;
20
+ /**
21
+ * `fetch` for the MCP transport: the request carries who it may act as — this
22
+ * tab's key when it holds one, else this tab's holder connection, else nobody
23
+ * (the site's proxy then marks it anonymous).
24
+ */
25
+ export declare const signingFetch: typeof fetch;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Every request a page sends while it holds the signed-in npub's key is signed
3
+ * with that key (NIP-98): an `Authorization: Nostr` event bound to the method,
4
+ * the URL and the sha256 of the body. Whoever holds the key needs no grant, and
5
+ * no tool call carries a proof in its arguments.
6
+ */
7
+ import { finalizeEvent } from "nostr-tools";
8
+ import { holderBearer } from "./holder.js";
9
+ import { getStoredNpub } from "./identity.js";
10
+ import { clearSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
11
+ const PROOF_KIND = 27235;
12
+ /**
13
+ * 16 random bytes as 32 lowercase hex chars. Without it two requests signed in
14
+ * the same wall-clock second could be byte-identical, their ids would collide,
15
+ * and the server's replay guard would refuse the second.
16
+ */
17
+ export function proofNonce() {
18
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
19
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
20
+ }
21
+ async function sha256Hex(body) {
22
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
23
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
24
+ }
25
+ /** The `Authorization` value for one request, signed by `secretKey`. */
26
+ export async function signRequest(method, url, body, secretKey) {
27
+ const event = finalizeEvent({
28
+ kind: PROOF_KIND,
29
+ created_at: Math.floor(Date.now() / 1000),
30
+ tags: [
31
+ ["u", url],
32
+ ["method", method.toUpperCase()],
33
+ ["payload", await sha256Hex(body)],
34
+ ["nonce", proofNonce()],
35
+ ],
36
+ content: "",
37
+ }, secretKey);
38
+ return `Nostr ${btoa(JSON.stringify(event))}`;
39
+ }
40
+ /**
41
+ * True when this tab holds the key of the npub that is signed in. A key left
42
+ * from a previous identity is evicted rather than allowed to sign.
43
+ */
44
+ export function signsForSignedInNpub() {
45
+ const sessionNpub = hasSessionNsec() ? sessionNsecNpub() : null;
46
+ if (!sessionNpub)
47
+ return false;
48
+ if (sessionNpub === getStoredNpub())
49
+ return true;
50
+ clearSessionNsec();
51
+ return false;
52
+ }
53
+ /**
54
+ * `fetch` for the MCP transport: the request carries who it may act as — this
55
+ * tab's key when it holds one, else this tab's holder connection, else nobody
56
+ * (the site's proxy then marks it anonymous).
57
+ */
58
+ export const signingFetch = async (input, init) => {
59
+ const headers = new Headers(init?.headers);
60
+ if (signsForSignedInNpub()) {
61
+ const request = new Request(input, init);
62
+ const body = typeof init?.body === "string" ? init.body : "";
63
+ headers.set("Authorization", await signRequest(request.method, request.url, body, getSessionNsecBytes()));
64
+ }
65
+ else {
66
+ const bearer = await holderBearer();
67
+ if (bearer)
68
+ headers.set("Authorization", `Bearer ${bearer}`);
69
+ }
70
+ return fetch(input, { ...init, headers });
71
+ };
@@ -6,9 +6,10 @@
6
6
  * "signed in" let anyone who asked for a DM and never answered it in, as
7
7
  * themselves, unproven. Two things can back the claim:
8
8
  *
9
- * - a **cached DM proof**, won by answering a Nostr challenge, or
10
- * - a **session nsec** in this tab, which signs a fresh proof per call — and
11
- * only counts for the npub it actually derives to.
9
+ * - a **DM proof**, won by answering a Nostr challenge — held by the operator
10
+ * for this tab's connection, or (before holders) cached here, or
11
+ * - a **session nsec** in this tab, which signs every request — and only
12
+ * counts for the npub it actually derives to.
12
13
  *
13
14
  * Pure, so it is testable without a browser.
14
15
  */
@@ -17,10 +18,12 @@ export interface Claim {
17
18
  npub: string;
18
19
  /** A cached DM proof token, if one was won and has not lapsed. */
19
20
  proof: string;
21
+ /** True when this tab's holder connection holds the npub, proven by DM. */
22
+ held: boolean;
20
23
  /** The npub this tab's session key derives to, if it holds one. */
21
24
  sessionNpub: string | null;
22
25
  }
23
26
  /** True only when the claimed npub is backed by something. */
24
- export declare function isProven({ npub, proof, sessionNpub }: Claim): boolean;
27
+ export declare function isProven({ npub, proof, held, sessionNpub }: Claim): boolean;
25
28
  /** True when this tab can sign a fresh proof for the npub it is claiming. */
26
29
  export declare function canSignFor({ npub, sessionNpub }: Claim): boolean;
package/dist/signedIn.js CHANGED
@@ -6,17 +6,18 @@
6
6
  * "signed in" let anyone who asked for a DM and never answered it in, as
7
7
  * themselves, unproven. Two things can back the claim:
8
8
  *
9
- * - a **cached DM proof**, won by answering a Nostr challenge, or
10
- * - a **session nsec** in this tab, which signs a fresh proof per call — and
11
- * only counts for the npub it actually derives to.
9
+ * - a **DM proof**, won by answering a Nostr challenge — held by the operator
10
+ * for this tab's connection, or (before holders) cached here, or
11
+ * - a **session nsec** in this tab, which signs every request — and only
12
+ * counts for the npub it actually derives to.
12
13
  *
13
14
  * Pure, so it is testable without a browser.
14
15
  */
15
16
  /** True only when the claimed npub is backed by something. */
16
- export function isProven({ npub, proof, sessionNpub }) {
17
+ export function isProven({ npub, proof, held, sessionNpub }) {
17
18
  if (!npub)
18
19
  return false;
19
- if (proof)
20
+ if (proof || held)
20
21
  return true;
21
22
  return sessionNpub === npub;
22
23
  }
@@ -54,6 +54,8 @@ export interface NpubProofResult {
54
54
  status?: string;
55
55
  message?: string;
56
56
  dpop_token?: string;
57
+ /** True when the operator kept the grant for this tab's holder connection. */
58
+ held_by_connection?: boolean;
57
59
  expires_in_seconds?: number;
58
60
  expires_at?: string;
59
61
  error?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lonnie VanZandt",
@@ -1,18 +0,0 @@
1
- /**
2
- * Inline kind-27235 identity proof — Tactic 2 in the wheel's
3
- * `identity_proof.verify_proof`. A fresh signed event per paid call, bound to:
4
- * the sender pubkey (must match the claimed npub), the `u` tag (must match the
5
- * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
- * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
- * inline — no relay round trip, no cached phrase.
8
- *
9
- * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
- * `create_proof` does. Without it, two calls to the same tool in the same
11
- * wall-clock second sign byte-identical events, their ids collide, and the
12
- * verifier's replay guard refuses the second. The nonce is signed but inert:
13
- * `verify_proof` reads only the `u` tag.
14
- */
15
- /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
16
- export declare function proofNonce(): string;
17
- /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
18
- export declare function signInlineProof(runtimeToolName: string, secretKey?: Uint8Array): string;
@@ -1,35 +0,0 @@
1
- /**
2
- * Inline kind-27235 identity proof — Tactic 2 in the wheel's
3
- * `identity_proof.verify_proof`. A fresh signed event per paid call, bound to:
4
- * the sender pubkey (must match the claimed npub), the `u` tag (must match the
5
- * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
- * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
- * inline — no relay round trip, no cached phrase.
8
- *
9
- * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
- * `create_proof` does. Without it, two calls to the same tool in the same
11
- * wall-clock second sign byte-identical events, their ids collide, and the
12
- * verifier's replay guard refuses the second. The nonce is signed but inert:
13
- * `verify_proof` reads only the `u` tag.
14
- */
15
- import { finalizeEvent } from "nostr-tools";
16
- import { getSessionNsecBytes } from "./sessionNsec.js";
17
- const PROOF_KIND = 27235;
18
- /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
19
- export function proofNonce() {
20
- const bytes = crypto.getRandomValues(new Uint8Array(16));
21
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
22
- }
23
- /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
24
- export function signInlineProof(runtimeToolName, secretKey) {
25
- const signed = finalizeEvent({
26
- kind: PROOF_KIND,
27
- created_at: Math.floor(Date.now() / 1000),
28
- tags: [
29
- ["u", runtimeToolName],
30
- ["nonce", proofNonce()],
31
- ],
32
- content: "",
33
- }, secretKey ?? getSessionNsecBytes());
34
- return JSON.stringify(signed);
35
- }