@tollbooth-dpyc/web 1.9.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/client.d.ts +5 -5
- package/dist/client.js +21 -33
- 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.js +12 -7
- package/dist/identity.d.ts +14 -10
- package/dist/identity.js +18 -25
- 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 +23 -21
- package/dist/signedIn.d.ts +3 -5
- package/dist/signedIn.js +4 -8
- package/dist/standardTools.d.ts +11 -7
- 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/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
|
@@ -4,10 +4,10 @@
|
|
|
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
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 3. Bootstrap tools take no
|
|
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
|
@@ -4,22 +4,23 @@
|
|
|
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
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 3. Bootstrap tools take no
|
|
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 {
|
|
16
|
+
import { isHolder } from "./holder.js";
|
|
17
|
+
import { forgetRecentLogin, getStoredNpub } from "./identity.js";
|
|
17
18
|
import { isTransportFailure, NetworkError } from "./networkError.js";
|
|
18
19
|
import { signingFetch, signsForSignedInNpub } from "./requestSigning.js";
|
|
19
20
|
import { errorText, deadGrantMessage, looksFailed, proofBounceMessage, readToolResult, } from "./toolResult.js";
|
|
20
21
|
/**
|
|
21
|
-
* Standard tools whose wheel signature takes no npub
|
|
22
|
-
*
|
|
22
|
+
* Standard tools whose wheel signature takes no `npub`. Pydantic strict mode
|
|
23
|
+
* rejects unexpected kwargs, so they must not get one.
|
|
23
24
|
*/
|
|
24
25
|
const STANDARD_BOOTSTRAP = [
|
|
25
26
|
"request_npub_proof",
|
|
@@ -66,20 +67,6 @@ async function getClient() {
|
|
|
66
67
|
throw new Error("Could not connect to the service.");
|
|
67
68
|
return client;
|
|
68
69
|
}
|
|
69
|
-
/**
|
|
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.
|
|
72
|
-
*/
|
|
73
|
-
function proofFor() {
|
|
74
|
-
try {
|
|
75
|
-
if (signsForSignedInNpub())
|
|
76
|
-
return {};
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
/* fall through to the cached token */
|
|
80
|
-
}
|
|
81
|
-
return { dpop_token: getStoredProof() };
|
|
82
|
-
}
|
|
83
70
|
export { isNetworkError, NetworkError } from "./networkError.js";
|
|
84
71
|
/** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
|
|
85
72
|
export class ProofRequiredError extends Error {
|
|
@@ -91,7 +78,7 @@ export class ProofRequiredError extends Error {
|
|
|
91
78
|
const proofExpiredListeners = new Set();
|
|
92
79
|
/**
|
|
93
80
|
* Subscribe to proof bounces from anywhere in the app. Without this the tree
|
|
94
|
-
* never learns the
|
|
81
|
+
* never learns the sign-in was dropped, and the patron is stranded on a page
|
|
95
82
|
* whose data will not load. Returns an unsubscribe function.
|
|
96
83
|
*/
|
|
97
84
|
export function onProofExpired(cb) {
|
|
@@ -120,13 +107,15 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
|
|
|
120
107
|
const cfg = tollboothConfig();
|
|
121
108
|
const name = toolName(tool);
|
|
122
109
|
const quiet = STANDARD_QUIET.includes(tool) || cfg.quietTools.includes(tool);
|
|
123
|
-
// `args` holds only the caller's own parameters — the
|
|
124
|
-
//
|
|
110
|
+
// `args` holds only the caller's own parameters — the npub is added below —
|
|
111
|
+
// so it is safe to log.
|
|
125
112
|
if (!quiet)
|
|
126
113
|
debugPush("call", `${name}(${redact(JSON.stringify(args)).slice(0, 140)})`);
|
|
127
114
|
const bootstrap = STANDARD_BOOTSTRAP.includes(tool) || cfg.extraBootstrapTools.includes(tool);
|
|
128
|
-
const
|
|
129
|
-
|
|
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();
|
|
130
119
|
let result;
|
|
131
120
|
try {
|
|
132
121
|
const c = await getClient();
|
|
@@ -156,16 +145,15 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
|
|
|
156
145
|
const tail = images.length ? ` +${images.length} image` : "";
|
|
157
146
|
debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${redact(String(preview)).slice(0, 220)}${tail}`);
|
|
158
147
|
}
|
|
159
|
-
// A
|
|
160
|
-
//
|
|
161
|
-
//
|
|
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.
|
|
162
151
|
const bounced = (opts.bestEffort ? null : proofBounceMessage(payload)) ??
|
|
163
|
-
(
|
|
152
|
+
(rodeHolder ? deadGrantMessage(payload) : null);
|
|
164
153
|
if (bounced !== null) {
|
|
165
|
-
// The
|
|
166
|
-
//
|
|
154
|
+
// The recent-login shortcut would re-enter on the same refused hold, so it
|
|
155
|
+
// goes too, or a returning patron re-bounces.
|
|
167
156
|
const npub = getStoredNpub();
|
|
168
|
-
clearStoredProof();
|
|
169
157
|
if (npub)
|
|
170
158
|
forgetRecentLogin(npub);
|
|
171
159
|
emitProofExpired(bounced);
|
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.js
CHANGED
|
@@ -26,8 +26,12 @@ function operator() {
|
|
|
26
26
|
export function holderEnabled() {
|
|
27
27
|
return Boolean(tollboothConfig().operatorUrl);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Back to the page the sign-in started on, since that page finishes it: a site
|
|
31
|
+
* whose `/` is not the sign-in page would otherwise drop the code on the floor.
|
|
32
|
+
*/
|
|
29
33
|
function redirectUri() {
|
|
30
|
-
return `${globalThis.location.origin}
|
|
34
|
+
return `${globalThis.location.origin}${globalThis.location.pathname}`;
|
|
31
35
|
}
|
|
32
36
|
function readTokens() {
|
|
33
37
|
try {
|
|
@@ -49,13 +53,13 @@ async function challengeFor(verifier) {
|
|
|
49
53
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
50
54
|
return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
55
|
}
|
|
52
|
-
async function register() {
|
|
56
|
+
async function register(redirect) {
|
|
53
57
|
const r = await fetch(`${operator()}/register`, {
|
|
54
58
|
method: "POST",
|
|
55
59
|
headers: { "Content-Type": "application/json" },
|
|
56
60
|
body: JSON.stringify({
|
|
57
61
|
client_name: tollboothConfig().appName,
|
|
58
|
-
redirect_uris: [
|
|
62
|
+
redirect_uris: [redirect],
|
|
59
63
|
grant_types: ["authorization_code", "refresh_token"],
|
|
60
64
|
response_types: ["code"],
|
|
61
65
|
token_endpoint_auth_method: "none",
|
|
@@ -90,16 +94,17 @@ export function isHolder() {
|
|
|
90
94
|
* sends it straight back. `resume` comes back from `completeHolderConnect`.
|
|
91
95
|
*/
|
|
92
96
|
export async function connectAsHolder(resume) {
|
|
93
|
-
const
|
|
97
|
+
const redirect = redirectUri();
|
|
98
|
+
const clientId = readTokens()?.clientId || (await register(redirect));
|
|
94
99
|
const verifier = randomUrlSafe(48);
|
|
95
100
|
const state = randomUrlSafe(16);
|
|
96
|
-
const pending = { clientId, verifier, state, resume };
|
|
101
|
+
const pending = { clientId, verifier, state, resume, redirectUri: redirect };
|
|
97
102
|
globalThis.sessionStorage.setItem(PENDING, JSON.stringify(pending));
|
|
98
103
|
const url = new URL(`${operator()}/authorize`);
|
|
99
104
|
url.search = new URLSearchParams({
|
|
100
105
|
response_type: "code",
|
|
101
106
|
client_id: clientId,
|
|
102
|
-
redirect_uri:
|
|
107
|
+
redirect_uri: redirect,
|
|
103
108
|
state,
|
|
104
109
|
code_challenge: await challengeFor(verifier),
|
|
105
110
|
code_challenge_method: "S256",
|
|
@@ -123,7 +128,7 @@ export async function completeHolderConnect() {
|
|
|
123
128
|
globalThis.history.replaceState(null, "", globalThis.location.pathname);
|
|
124
129
|
if (params.get("state") !== pending.state)
|
|
125
130
|
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
|
|
131
|
+
writeTokens(await token({ grant_type: "authorization_code", code, redirect_uri: pending.redirectUri, code_verifier: pending.verifier }, pending.clientId));
|
|
127
132
|
return pending.resume;
|
|
128
133
|
}
|
|
129
134
|
/** The bearer for the next request, refreshed when it is about to lapse; null when not a holder. */
|
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,19 +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
|
*/
|
|
11
14
|
import { forgetHolder, isHolder } from "./holder.js";
|
|
12
15
|
import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
|
|
13
16
|
import { canSignFor, isProven } from "./signedIn.js";
|
|
14
17
|
import { readStored, removeStored, writeStored } from "./storage.js";
|
|
15
18
|
const NPUB = "patron_npub:v1";
|
|
16
|
-
const PROOF = "proof_token:v1";
|
|
17
19
|
const LAST_TYPED = "last_typed_npub:v1";
|
|
18
20
|
const RECENT = "recent-logins:v1";
|
|
19
21
|
const MAX_RECENT = 5;
|
|
@@ -23,15 +25,6 @@ export function getStoredNpub() {
|
|
|
23
25
|
export function setStoredNpub(npub) {
|
|
24
26
|
writeStored(NPUB, npub);
|
|
25
27
|
}
|
|
26
|
-
export function getStoredProof() {
|
|
27
|
-
return readStored(PROOF);
|
|
28
|
-
}
|
|
29
|
-
export function setStoredProof(proof) {
|
|
30
|
-
writeStored(PROOF, proof);
|
|
31
|
-
}
|
|
32
|
-
export function clearStoredProof() {
|
|
33
|
-
removeStored(PROOF);
|
|
34
|
-
}
|
|
35
28
|
export function getLastTypedNpub() {
|
|
36
29
|
return readStored(LAST_TYPED);
|
|
37
30
|
}
|
|
@@ -44,7 +37,7 @@ function readRecent() {
|
|
|
44
37
|
if (!Array.isArray(parsed))
|
|
45
38
|
return [];
|
|
46
39
|
return parsed.filter((e) => typeof e === "object" && e !== null &&
|
|
47
|
-
typeof e.npub === "string" &&
|
|
40
|
+
typeof e.npub === "string" &&
|
|
48
41
|
typeof e.expiresAt === "number" && typeof e.lastUsed === "number");
|
|
49
42
|
}
|
|
50
43
|
catch {
|
|
@@ -54,8 +47,13 @@ function readRecent() {
|
|
|
54
47
|
function writeRecent(entries) {
|
|
55
48
|
writeStored(RECENT, JSON.stringify(entries));
|
|
56
49
|
}
|
|
57
|
-
/**
|
|
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
|
+
*/
|
|
58
54
|
export function getValidRecentLogins() {
|
|
55
|
+
if (!isHolder())
|
|
56
|
+
return [];
|
|
59
57
|
const now = Date.now();
|
|
60
58
|
const entries = readRecent();
|
|
61
59
|
const valid = entries.filter((e) => e.expiresAt > now);
|
|
@@ -64,14 +62,14 @@ export function getValidRecentLogins() {
|
|
|
64
62
|
return valid.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
65
63
|
}
|
|
66
64
|
/**
|
|
67
|
-
* Record or refresh a
|
|
68
|
-
* 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.
|
|
69
68
|
*/
|
|
70
|
-
export function recordRecentLogin(npub,
|
|
69
|
+
export function recordRecentLogin(npub, expiresInSec) {
|
|
71
70
|
const now = Date.now();
|
|
72
71
|
const next = {
|
|
73
72
|
npub,
|
|
74
|
-
proof,
|
|
75
73
|
expiresAt: now + Math.max(0, expiresInSec - 30) * 1000,
|
|
76
74
|
lastUsed: now,
|
|
77
75
|
};
|
|
@@ -87,17 +85,13 @@ export function currentClaim() {
|
|
|
87
85
|
const npub = getStoredNpub();
|
|
88
86
|
return {
|
|
89
87
|
npub,
|
|
90
|
-
proof: getStoredProof(),
|
|
91
88
|
held: heldByThisTab(npub),
|
|
92
89
|
sessionNpub: hasSessionNsec() ? sessionNsecNpub() : null,
|
|
93
90
|
};
|
|
94
91
|
}
|
|
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
|
-
*/
|
|
92
|
+
/** True when this tab's holder connection holds `npub`: a DM sign-in recorded here. */
|
|
99
93
|
function heldByThisTab(npub) {
|
|
100
|
-
return Boolean(npub) &&
|
|
94
|
+
return Boolean(npub) && getValidRecentLogins().some((e) => e.npub === npub);
|
|
101
95
|
}
|
|
102
96
|
export function isLoggedIn() {
|
|
103
97
|
return isProven(currentClaim());
|
|
@@ -111,7 +105,6 @@ export function canSign() {
|
|
|
111
105
|
*/
|
|
112
106
|
export function logOut() {
|
|
113
107
|
removeStored(NPUB);
|
|
114
|
-
removeStored(PROOF);
|
|
115
108
|
clearSessionNsec();
|
|
116
109
|
void forgetHolder();
|
|
117
110
|
}
|
package/dist/pagesProxy.d.ts
CHANGED
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
* Only the headers MCP needs are forwarded upstream. Cookies and anything else
|
|
13
13
|
* the browser attached stay behind.
|
|
14
14
|
*
|
|
15
|
-
* Every request upstream carries
|
|
16
|
-
*
|
|
17
|
-
* anonymous caller,
|
|
18
|
-
*
|
|
19
|
-
* sends it, and it is forwarded untouched.
|
|
15
|
+
* Every request upstream carries who it may act as. An operator refuses a
|
|
16
|
+
* request with no `Authorization`, so a page that neither signs (`Nostr`)
|
|
17
|
+
* nor holds a holder token is an anonymous caller, and the proxy says so with
|
|
18
|
+
* `Bearer anonymous`. A request that carries either is forwarded untouched.
|
|
20
19
|
*/
|
|
21
20
|
export declare const CORS_HEADERS: Record<string, string>;
|
|
22
21
|
export declare const ANONYMOUS_BEARER = "Bearer anonymous";
|
package/dist/pagesProxy.js
CHANGED
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
* Only the headers MCP needs are forwarded upstream. Cookies and anything else
|
|
13
13
|
* the browser attached stay behind.
|
|
14
14
|
*
|
|
15
|
-
* Every request upstream carries
|
|
16
|
-
*
|
|
17
|
-
* anonymous caller,
|
|
18
|
-
*
|
|
19
|
-
* sends it, and it is forwarded untouched.
|
|
15
|
+
* Every request upstream carries who it may act as. An operator refuses a
|
|
16
|
+
* request with no `Authorization`, so a page that neither signs (`Nostr`)
|
|
17
|
+
* nor holds a holder token is an anonymous caller, and the proxy says so with
|
|
18
|
+
* `Bearer anonymous`. A request that carries either is forwarded untouched.
|
|
20
19
|
*/
|
|
21
20
|
export const CORS_HEADERS = {
|
|
22
21
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -69,11 +69,17 @@ async function proofKind() {
|
|
|
69
69
|
const claim = currentClaim();
|
|
70
70
|
if (canSignFor(claim))
|
|
71
71
|
return { kind: "session_nsec" };
|
|
72
|
-
if (!claim.
|
|
72
|
+
if (!claim.held)
|
|
73
73
|
return { kind: "none" };
|
|
74
|
-
const s = await checkProofStatus(claim.npub
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
const s = await checkProofStatus(claim.npub).catch(() => null);
|
|
75
|
+
return { kind: "held", status: heldStatus(s?.status), expiresInSec: s?.expires_in_seconds ?? null };
|
|
76
|
+
}
|
|
77
|
+
function heldStatus(status) {
|
|
78
|
+
if (status === "valid" || status === "not_held")
|
|
79
|
+
return status;
|
|
80
|
+
if (status === "expired" || status === "revoked" || status === "invalid")
|
|
81
|
+
return "lapsed";
|
|
82
|
+
return "unknown";
|
|
77
83
|
}
|
|
78
84
|
/** The patron's own health: sign-in proof and credit balance. */
|
|
79
85
|
export function PatronFundingStatus({ thresholds, siteRows, heading = "Account health", ...rest }) {
|
package/dist/react/NpubGate.d.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* begin → request_npub_proof → awaiting reply → receive_npub_proof → app
|
|
5
5
|
*
|
|
6
|
-
* Success is not a `verified` flag but
|
|
6
|
+
* Success is not a `verified` flag but the operator saying it now holds the
|
|
7
|
+
* npub for this tab's connection (`held_by_connection`). A DM sign-in therefore
|
|
8
|
+
* needs a holder connection first; a site without `operatorUrl` offers the
|
|
9
|
+
* session key only.
|
|
7
10
|
*
|
|
8
11
|
* Around the card: the site's `welcome` (its own words about what this place
|
|
9
12
|
* is for), the network's register line, and — below — `SignInSteps`, which
|
package/dist/react/NpubGate.js
CHANGED
|
@@ -4,7 +4,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
*
|
|
5
5
|
* begin → request_npub_proof → awaiting reply → receive_npub_proof → app
|
|
6
6
|
*
|
|
7
|
-
* Success is not a `verified` flag but
|
|
7
|
+
* Success is not a `verified` flag but the operator saying it now holds the
|
|
8
|
+
* npub for this tab's connection (`held_by_connection`). A DM sign-in therefore
|
|
9
|
+
* needs a holder connection first; a site without `operatorUrl` offers the
|
|
10
|
+
* session key only.
|
|
8
11
|
*
|
|
9
12
|
* Around the card: the site's `welcome` (its own words about what this place
|
|
10
13
|
* is for), the network's register line, and — below — `SignInSteps`, which
|
|
@@ -15,7 +18,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
15
18
|
import { useEffect, useRef, useState } from "react";
|
|
16
19
|
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
|
|
17
20
|
import { tollboothConfig } from "../config.js";
|
|
18
|
-
import { forgetRecentLogin, getLastTypedNpub, getValidRecentLogins, recordRecentLogin, setLastTypedNpub, setStoredNpub,
|
|
21
|
+
import { forgetRecentLogin, getLastTypedNpub, getValidRecentLogins, recordRecentLogin, setLastTypedNpub, setStoredNpub, } from "../identity.js";
|
|
19
22
|
import { completeHolderConnect, connectAsHolder, holderEnabled, isHolder } from "../holder.js";
|
|
20
23
|
import { setSessionNsec } from "../sessionNsec.js";
|
|
21
24
|
import { readSignInFailure } from "../signInSituation.js";
|
|
@@ -54,7 +57,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
54
57
|
// Prefilled from what was typed last, NOT from the stored identity.
|
|
55
58
|
const [value, setValue] = useState(getLastTypedNpub());
|
|
56
59
|
const [stage, setStage] = useState("begin");
|
|
57
|
-
const [
|
|
60
|
+
const [nonce, setNonce] = useState("");
|
|
58
61
|
const [busy, setBusy] = useState(false);
|
|
59
62
|
const [error, setError] = useState("");
|
|
60
63
|
const [note, setNote] = useState("");
|
|
@@ -76,8 +79,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
76
79
|
const valid = isNsec || isNpub;
|
|
77
80
|
function reuseRecent(entry) {
|
|
78
81
|
setStoredNpub(entry.npub);
|
|
79
|
-
|
|
80
|
-
recordRecentLogin(entry.npub, entry.proof, Math.floor((entry.expiresAt - Date.now()) / 1000));
|
|
82
|
+
recordRecentLogin(entry.npub, Math.floor((entry.expiresAt - Date.now()) / 1000));
|
|
81
83
|
onLogin();
|
|
82
84
|
}
|
|
83
85
|
function forget(npub) {
|
|
@@ -132,21 +134,25 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
132
134
|
setBusy(true);
|
|
133
135
|
setError("");
|
|
134
136
|
try {
|
|
135
|
-
// The
|
|
137
|
+
// The proof will stay with the operator, so this tab connects to it
|
|
136
138
|
// first; the browser comes straight back here and the DM goes out.
|
|
137
|
-
if (holderEnabled()
|
|
139
|
+
if (!holderEnabled()) {
|
|
140
|
+
setError("This site signs in with a key only — paste an nsec, or make one.");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!isHolder()) {
|
|
138
144
|
await connectAsHolder(npubToProve);
|
|
139
145
|
return;
|
|
140
146
|
}
|
|
141
147
|
const r = await requestNpubProof(npubToProve, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
|
|
142
148
|
if (r.error)
|
|
143
149
|
return setError(r.error);
|
|
144
|
-
if (!r.
|
|
150
|
+
if (!r.nonce)
|
|
145
151
|
return setError("The service did not return a session phrase. Try again.");
|
|
146
152
|
// Remembered for the field only. The identity is written by `finish()`,
|
|
147
153
|
// once the human has answered the DM.
|
|
148
154
|
setLastTypedNpub(npubToProve);
|
|
149
|
-
|
|
155
|
+
setNonce(r.nonce);
|
|
150
156
|
setStage("awaiting");
|
|
151
157
|
}
|
|
152
158
|
catch (e) {
|
|
@@ -161,22 +167,18 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
161
167
|
setError("");
|
|
162
168
|
setStage("checking");
|
|
163
169
|
try {
|
|
164
|
-
const r = await receiveNpubProof(trimmed,
|
|
165
|
-
if (r.error) {
|
|
166
|
-
setError(r.error);
|
|
170
|
+
const r = await receiveNpubProof(trimmed, nonce);
|
|
171
|
+
if (r.error || !r.held_by_connection) {
|
|
172
|
+
setError(r.error || "The service did not keep the proof for this page. Try again.");
|
|
167
173
|
setStage("awaiting");
|
|
168
174
|
return;
|
|
169
175
|
}
|
|
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;
|
|
173
176
|
const npub = r.proven_npub || trimmed;
|
|
174
177
|
setStoredNpub(npub);
|
|
175
|
-
setStoredProof(token);
|
|
176
178
|
if (r.expires_in_seconds && r.expires_in_seconds > 0) {
|
|
177
|
-
recordRecentLogin(npub,
|
|
179
|
+
recordRecentLogin(npub, r.expires_in_seconds);
|
|
178
180
|
const h = Math.floor(r.expires_in_seconds / 3600);
|
|
179
|
-
setNote(`Proof
|
|
181
|
+
setNote(`Proof held for ~${h > 0 ? `${h}h` : `${Math.round(r.expires_in_seconds / 60)}m`}.`);
|
|
180
182
|
}
|
|
181
183
|
onLogin();
|
|
182
184
|
}
|
|
@@ -190,10 +192,10 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
190
192
|
}
|
|
191
193
|
function reset() {
|
|
192
194
|
setStage("begin");
|
|
193
|
-
|
|
195
|
+
setNonce("");
|
|
194
196
|
setError("");
|
|
195
197
|
}
|
|
196
|
-
return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the
|
|
198
|
+
return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the proof the service holds`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", "data-tour": "tb.gate.npub", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
|
|
197
199
|
setValue(e.target.value);
|
|
198
200
|
setGeneratedHint(false);
|
|
199
201
|
}, onKeyDown: (e) => {
|
|
@@ -203,7 +205,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
203
205
|
signInWithNsec();
|
|
204
206
|
else
|
|
205
207
|
void begin();
|
|
206
|
-
}, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, "data-tour": "tb.gate.send", children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }),
|
|
208
|
+
}, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, "data-tour": "tb.gate.send", children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), nonce && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: nonce }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, "data-tour": "tb.gate.verify", children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
|
|
207
209
|
}
|
|
208
210
|
/** A failure, read to the person: the lead first, the service's own words underneath. */
|
|
209
211
|
function Trouble({ raw, appName }) {
|
package/dist/signedIn.d.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* An npub is public — it is on every note its owner ever wrote — so the npub
|
|
5
5
|
* alone is a claim, never a credential. A shell that read "we know an npub" as
|
|
6
6
|
* "signed in" let anyone who asked for a DM and never answered it in, as
|
|
7
|
-
* themselves, unproven. Two things can back the claim:
|
|
7
|
+
* themselves, unproven. Two things can back the claim, both on the connection:
|
|
8
8
|
*
|
|
9
9
|
* - a **DM proof**, won by answering a Nostr challenge — held by the operator
|
|
10
|
-
* for this tab's connection, or
|
|
10
|
+
* for this tab's holder connection, or
|
|
11
11
|
* - a **session nsec** in this tab, which signs every request — and only
|
|
12
12
|
* counts for the npub it actually derives to.
|
|
13
13
|
*
|
|
@@ -16,14 +16,12 @@
|
|
|
16
16
|
export interface Claim {
|
|
17
17
|
/** The npub the interface is showing, proven or not. */
|
|
18
18
|
npub: string;
|
|
19
|
-
/** A cached DM proof token, if one was won and has not lapsed. */
|
|
20
|
-
proof: string;
|
|
21
19
|
/** True when this tab's holder connection holds the npub, proven by DM. */
|
|
22
20
|
held: boolean;
|
|
23
21
|
/** The npub this tab's session key derives to, if it holds one. */
|
|
24
22
|
sessionNpub: string | null;
|
|
25
23
|
}
|
|
26
24
|
/** True only when the claimed npub is backed by something. */
|
|
27
|
-
export declare function isProven({ npub,
|
|
25
|
+
export declare function isProven({ npub, held, sessionNpub }: Claim): boolean;
|
|
28
26
|
/** True when this tab can sign a fresh proof for the npub it is claiming. */
|
|
29
27
|
export declare function canSignFor({ npub, sessionNpub }: Claim): boolean;
|
package/dist/signedIn.js
CHANGED
|
@@ -4,22 +4,18 @@
|
|
|
4
4
|
* An npub is public — it is on every note its owner ever wrote — so the npub
|
|
5
5
|
* alone is a claim, never a credential. A shell that read "we know an npub" as
|
|
6
6
|
* "signed in" let anyone who asked for a DM and never answered it in, as
|
|
7
|
-
* themselves, unproven. Two things can back the claim:
|
|
7
|
+
* themselves, unproven. Two things can back the claim, both on the connection:
|
|
8
8
|
*
|
|
9
9
|
* - a **DM proof**, won by answering a Nostr challenge — held by the operator
|
|
10
|
-
* for this tab's connection, or
|
|
10
|
+
* for this tab's holder connection, or
|
|
11
11
|
* - a **session nsec** in this tab, which signs every request — and only
|
|
12
12
|
* counts for the npub it actually derives to.
|
|
13
13
|
*
|
|
14
14
|
* Pure, so it is testable without a browser.
|
|
15
15
|
*/
|
|
16
16
|
/** True only when the claimed npub is backed by something. */
|
|
17
|
-
export function isProven({ npub,
|
|
18
|
-
|
|
19
|
-
return false;
|
|
20
|
-
if (proof || held)
|
|
21
|
-
return true;
|
|
22
|
-
return sessionNpub === npub;
|
|
17
|
+
export function isProven({ npub, held, sessionNpub }) {
|
|
18
|
+
return Boolean(npub) && (held || sessionNpub === npub);
|
|
23
19
|
}
|
|
24
20
|
/** True when this tab can sign a fresh proof for the npub it is claiming. */
|
|
25
21
|
export function canSignFor({ npub, sessionNpub }) {
|
package/dist/standardTools.d.ts
CHANGED
|
@@ -53,8 +53,9 @@ export interface NpubProofResult {
|
|
|
53
53
|
proven_npub?: string;
|
|
54
54
|
status?: string;
|
|
55
55
|
message?: string;
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** From `request_npub_proof`: the phrase the DM shows; it names the reply. */
|
|
57
|
+
nonce?: string;
|
|
58
|
+
/** From `receive_npub_proof`: the operator now holds the npub for this tab's connection. */
|
|
58
59
|
held_by_connection?: boolean;
|
|
59
60
|
expires_in_seconds?: number;
|
|
60
61
|
expires_at?: string;
|
|
@@ -71,18 +72,21 @@ export interface NpubProofResult {
|
|
|
71
72
|
*/
|
|
72
73
|
export declare function requestNpubProof(patronNpub: string, verifyAt?: string, reason?: string, opts?: CallOptions): Promise<NpubProofResult>;
|
|
73
74
|
/**
|
|
74
|
-
* Step 2: drain DMs for the signed reply to step 1
|
|
75
|
-
*
|
|
75
|
+
* Step 2: drain DMs for the signed reply to step 1, naming it by the `nonce`
|
|
76
|
+
* step 1 returned. The operator keeps the proof for this tab's holder
|
|
77
|
+
* connection. Call only after the human says they replied — it is
|
|
78
|
+
* destructive, so never poll it.
|
|
76
79
|
*/
|
|
77
|
-
export declare function receiveNpubProof(patronNpub: string,
|
|
80
|
+
export declare function receiveNpubProof(patronNpub: string, nonce: string, opts?: CallOptions): Promise<NpubProofResult>;
|
|
78
81
|
export interface ProofStatusResult {
|
|
79
82
|
success?: boolean;
|
|
80
|
-
status?: "valid" | "expired" | "
|
|
83
|
+
status?: "valid" | "expired" | "invalid" | "revoked" | "not_held" | string;
|
|
81
84
|
expires_in_seconds?: number | null;
|
|
82
85
|
error?: string;
|
|
83
86
|
error_code?: string;
|
|
84
87
|
}
|
|
85
|
-
|
|
88
|
+
/** Whether this tab's connection proves `patronNpub`, and until when. */
|
|
89
|
+
export declare function checkProofStatus(patronNpub: string, opts?: CallOptions): Promise<ProofStatusResult>;
|
|
86
90
|
/** One credit tranche: a purchase (or grant) and what is left of it. */
|
|
87
91
|
export interface CreditTranche {
|
|
88
92
|
id: string;
|
package/dist/standardTools.js
CHANGED
|
@@ -26,14 +26,17 @@ export function requestNpubProof(patronNpub, verifyAt, reason, opts) {
|
|
|
26
26
|
}, opts);
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
* Step 2: drain DMs for the signed reply to step 1
|
|
30
|
-
*
|
|
29
|
+
* Step 2: drain DMs for the signed reply to step 1, naming it by the `nonce`
|
|
30
|
+
* step 1 returned. The operator keeps the proof for this tab's holder
|
|
31
|
+
* connection. Call only after the human says they replied — it is
|
|
32
|
+
* destructive, so never poll it.
|
|
31
33
|
*/
|
|
32
|
-
export function receiveNpubProof(patronNpub,
|
|
33
|
-
return callTool("receive_npub_proof", { patron_npub: patronNpub,
|
|
34
|
+
export function receiveNpubProof(patronNpub, nonce, opts) {
|
|
35
|
+
return callTool("receive_npub_proof", { patron_npub: patronNpub, nonce }, opts);
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
/** Whether this tab's connection proves `patronNpub`, and until when. */
|
|
38
|
+
export function checkProofStatus(patronNpub, opts) {
|
|
39
|
+
return callTool("check_proof_status", { patron_npub: patronNpub }, { bestEffort: true, ...opts });
|
|
37
40
|
}
|
|
38
41
|
export function checkBalance(opts) {
|
|
39
42
|
return callTool("check_balance", {}, opts);
|
package/dist/toolResult.d.ts
CHANGED
|
@@ -27,21 +27,21 @@ export interface ReadResult {
|
|
|
27
27
|
export declare function errorText(result: RawToolResult): string;
|
|
28
28
|
export declare function readToolResult(result: RawToolResult): ReadResult;
|
|
29
29
|
/**
|
|
30
|
-
* A soft proof failure: `{success: false, error_code: proof_required
|
|
31
|
-
*
|
|
32
|
-
* snake_case; an earlier uppercase comparison never matched, so the
|
|
33
|
-
* silently never fired. Normalised here, once.
|
|
30
|
+
* A soft proof failure: `{success: false, error_code: proof_required}` with no
|
|
31
|
+
* isError flag — the connection does not prove the npub. The wheel's codes are
|
|
32
|
+
* lowercase snake_case; an earlier uppercase comparison never matched, so the
|
|
33
|
+
* bounce silently never fired. Normalised here, once.
|
|
34
34
|
*/
|
|
35
35
|
export declare function proofBounceMessage(payload: unknown): string | null;
|
|
36
36
|
/**
|
|
37
|
-
* A
|
|
37
|
+
* A held proof grant the service refused for good: `{success: false,
|
|
38
38
|
* error_code: proof_invalid, reason}` where the reason is about the grant —
|
|
39
39
|
* expired, revoked, issued for another npub or operator, malformed. Retrying
|
|
40
|
-
*
|
|
40
|
+
* on it can never succeed, so the patron is signed out.
|
|
41
41
|
*
|
|
42
|
-
* Only meaningful when the call
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Only meaningful when the call rode this tab's holder. A signed request
|
|
43
|
+
* shares the code and even the word `expired` (outside its window), but it is
|
|
44
|
+
* signed afresh for the next call and loses nothing.
|
|
45
45
|
*/
|
|
46
46
|
export declare function deadGrantMessage(payload: unknown): string | null;
|
|
47
47
|
/** Whether a payload reports failure, for the debug log's colouring. */
|
package/dist/toolResult.js
CHANGED
|
@@ -36,17 +36,17 @@ export function readToolResult(result) {
|
|
|
36
36
|
return { payload, images };
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
* A soft proof failure: `{success: false, error_code: proof_required
|
|
40
|
-
*
|
|
41
|
-
* snake_case; an earlier uppercase comparison never matched, so the
|
|
42
|
-
* silently never fired. Normalised here, once.
|
|
39
|
+
* A soft proof failure: `{success: false, error_code: proof_required}` with no
|
|
40
|
+
* isError flag — the connection does not prove the npub. The wheel's codes are
|
|
41
|
+
* lowercase snake_case; an earlier uppercase comparison never matched, so the
|
|
42
|
+
* bounce silently never fired. Normalised here, once.
|
|
43
43
|
*/
|
|
44
44
|
export function proofBounceMessage(payload) {
|
|
45
45
|
if (!payload || typeof payload !== "object")
|
|
46
46
|
return null;
|
|
47
47
|
const p = payload;
|
|
48
48
|
const code = String(p.error_code ?? "").toLowerCase();
|
|
49
|
-
if (p.success === false &&
|
|
49
|
+
if (p.success === false && code === "proof_required") {
|
|
50
50
|
return String(p.error ?? "Sign-in required.");
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
@@ -58,14 +58,14 @@ export function proofBounceMessage(payload) {
|
|
|
58
58
|
*/
|
|
59
59
|
const GRANT_STILL_GOOD = new Set(["operator_unknown", "revocations_unavailable"]);
|
|
60
60
|
/**
|
|
61
|
-
* A
|
|
61
|
+
* A held proof grant the service refused for good: `{success: false,
|
|
62
62
|
* error_code: proof_invalid, reason}` where the reason is about the grant —
|
|
63
63
|
* expired, revoked, issued for another npub or operator, malformed. Retrying
|
|
64
|
-
*
|
|
64
|
+
* on it can never succeed, so the patron is signed out.
|
|
65
65
|
*
|
|
66
|
-
* Only meaningful when the call
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* Only meaningful when the call rode this tab's holder. A signed request
|
|
67
|
+
* shares the code and even the word `expired` (outside its window), but it is
|
|
68
|
+
* signed afresh for the next call and loses nothing.
|
|
69
69
|
*/
|
|
70
70
|
export function deadGrantMessage(payload) {
|
|
71
71
|
if (!payload || typeof payload !== "object")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tollbooth-dpyc/web",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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",
|