@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 +3 -2
- package/dist/client.d.ts +6 -6
- package/dist/client.js +24 -44
- package/dist/config.d.ts +8 -1
- package/dist/config.js +1 -0
- package/dist/debugLog.d.ts +1 -1
- package/dist/debugLog.js +6 -6
- package/dist/funding.d.ts +5 -3
- package/dist/funding.js +4 -2
- package/dist/holder.d.ts +31 -0
- package/dist/holder.js +162 -0
- package/dist/identity.d.ts +14 -10
- package/dist/identity.js +25 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/pagesProxy.d.ts +4 -5
- package/dist/pagesProxy.js +4 -5
- package/dist/react/FundingStatus.js +10 -4
- package/dist/react/NpubGate.d.ts +4 -1
- package/dist/react/NpubGate.js +49 -21
- package/dist/requestSigning.d.ts +25 -0
- package/dist/requestSigning.js +71 -0
- package/dist/signedIn.d.ts +8 -7
- package/dist/signedIn.js +7 -10
- package/dist/standardTools.d.ts +12 -6
- package/dist/standardTools.js +9 -6
- package/dist/toolResult.d.ts +9 -9
- package/dist/toolResult.js +10 -10
- package/package.json +1 -1
- package/dist/inlineProof.d.ts +0 -18
- package/dist/inlineProof.js +0 -35
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
|
-
- **
|
|
9
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
* 3. Bootstrap tools take no
|
|
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
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
* 3. Bootstrap tools take no
|
|
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 {
|
|
17
|
-
import {
|
|
16
|
+
import { isHolder } from "./holder.js";
|
|
17
|
+
import { forgetRecentLogin, getStoredNpub } from "./identity.js";
|
|
18
18
|
import { isTransportFailure, NetworkError } from "./networkError.js";
|
|
19
|
-
import {
|
|
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
|
|
23
|
-
*
|
|
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
|
|
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
|
|
130
|
-
//
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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
|
-
(
|
|
152
|
+
(rodeHolder ? deadGrantMessage(payload) : null);
|
|
172
153
|
if (bounced !== null) {
|
|
173
|
-
// The
|
|
174
|
-
//
|
|
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,
|
package/dist/debugLog.d.ts
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 (
|
|
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 (
|
|
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|
|
|
32
|
-
// `"
|
|
33
|
-
// `
|
|
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|
|
|
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|
|
|
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
|
-
|
|
42
|
-
|
|
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 === "
|
|
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", "
|
|
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)}.`);
|
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.d.ts
CHANGED
|
@@ -1,36 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Who this browser says it is, and what backs the claim.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
31
|
-
* straggler never
|
|
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,
|
|
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
|
-
*
|
|
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" &&
|
|
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
|
-
/**
|
|
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
|
|
67
|
-
* straggler never
|
|
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,
|
|
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
|
|
88
|
-
|
|
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 {
|
|
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";
|