@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 +3 -3
- package/dist/client.js +14 -22
- package/dist/config.d.ts +8 -1
- package/dist/config.js +1 -0
- package/dist/holder.d.ts +31 -0
- package/dist/holder.js +162 -0
- package/dist/identity.js +12 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/NpubGate.js +31 -5
- package/dist/requestSigning.d.ts +25 -0
- package/dist/requestSigning.js +71 -0
- package/dist/signedIn.d.ts +7 -4
- package/dist/signedIn.js +6 -5
- package/dist/standardTools.d.ts +2 -0
- package/package.json +1 -1
- package/dist/inlineProof.d.ts +0 -18
- package/dist/inlineProof.js +0 -35
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
72
|
-
*
|
|
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(
|
|
73
|
+
function proofFor() {
|
|
76
74
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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,
|
package/dist/holder.d.ts
ADDED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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";
|
package/dist/react/NpubGate.js
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/signedIn.d.ts
CHANGED
|
@@ -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 **
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 **
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
}
|
package/dist/standardTools.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/dist/inlineProof.d.ts
DELETED
|
@@ -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;
|
package/dist/inlineProof.js
DELETED
|
@@ -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
|
-
}
|