@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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { callTool, callToolWithContent, isNetworkError, NetworkError, onProofExp
3
3
  export * from "./identity.js";
4
4
  export * from "./standardTools.js";
5
5
  export { clearSessionNsec, getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.js";
6
- export { signInlineProof } from "./inlineProof.js";
6
+ export { signRequest } from "./requestSigning.js";
7
7
  export { sessionKeyClaimVisible } from "./sessionKeyClaim.js";
8
8
  export { handOffMode, shareOrDownload } from "./shareFile.js";
9
9
  export { canSignFor, isProven } from "./signedIn.js";
@@ -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 a bearer. An operator with holder bearer on
16
- * refuses a request without one; a page that holds no holder token is an
17
- * anonymous caller, so the proxy says so with `Bearer anonymous` and the page
18
- * proves itself with `dpop_token` as before. A page that does hold a token
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";
@@ -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 a bearer. An operator with holder bearer on
16
- * refuses a request without one; a page that holds no holder token is an
17
- * anonymous caller, so the proxy says so with `Bearer anonymous` and the page
18
- * proves itself with `dpop_token` as before. A page that does hold a token
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.proof)
72
+ if (!claim.held)
73
73
  return { kind: "none" };
74
- const s = await checkProofStatus(claim.npub, claim.proof).catch(() => null);
75
- const status = s?.status === "valid" || s?.status === "expired" ? s.status : "unknown";
76
- return { kind: "dm_proof", status, expiresInSec: s?.expires_in_seconds ?? null };
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 }) {
@@ -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 "no `error` and a token came back".
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
@@ -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 "no `error` and a token came back".
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,8 @@ 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, setStoredProof, } from "../identity.js";
21
+ import { forgetRecentLogin, getLastTypedNpub, getValidRecentLogins, recordRecentLogin, setLastTypedNpub, setStoredNpub, } from "../identity.js";
22
+ import { completeHolderConnect, connectAsHolder, holderEnabled, isHolder } from "../holder.js";
19
23
  import { setSessionNsec } from "../sessionNsec.js";
20
24
  import { readSignInFailure } from "../signInSituation.js";
21
25
  import { receiveNpubProof, requestNpubProof } from "../standardTools.js";
@@ -53,7 +57,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
53
57
  // Prefilled from what was typed last, NOT from the stored identity.
54
58
  const [value, setValue] = useState(getLastTypedNpub());
55
59
  const [stage, setStage] = useState("begin");
56
- const [pendingProof, setPendingProof] = useState("");
60
+ const [nonce, setNonce] = useState("");
57
61
  const [busy, setBusy] = useState(false);
58
62
  const [error, setError] = useState("");
59
63
  const [note, setNote] = useState("");
@@ -75,8 +79,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
75
79
  const valid = isNsec || isNpub;
76
80
  function reuseRecent(entry) {
77
81
  setStoredNpub(entry.npub);
78
- setStoredProof(entry.proof);
79
- recordRecentLogin(entry.npub, entry.proof, Math.floor((entry.expiresAt - Date.now()) / 1000));
82
+ recordRecentLogin(entry.npub, Math.floor((entry.expiresAt - Date.now()) / 1000));
80
83
  onLogin();
81
84
  }
82
85
  function forget(npub) {
@@ -106,23 +109,50 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
106
109
  setError(`Couldn't read that nsec: ${e.message}`);
107
110
  }
108
111
  }
109
- async function begin() {
110
- if (!isNpub) {
112
+ // Back from connecting to the operator: carry on with the DM it was for.
113
+ const resumed = useRef(false);
114
+ useEffect(() => {
115
+ if (resumed.current)
116
+ return;
117
+ resumed.current = true;
118
+ completeHolderConnect()
119
+ .then((npub) => {
120
+ if (npub) {
121
+ setValue(npub);
122
+ void begin(npub);
123
+ }
124
+ })
125
+ .catch((e) => setError(e.message));
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
127
+ }, []);
128
+ async function begin(npubArg) {
129
+ const npubToProve = npubArg ?? trimmed;
130
+ if (!(npubToProve.startsWith("npub1") && npubToProve.length >= 60)) {
111
131
  setError("Enter a valid npub1… key.");
112
132
  return;
113
133
  }
114
134
  setBusy(true);
115
135
  setError("");
116
136
  try {
117
- const r = await requestNpubProof(trimmed, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
137
+ // The proof will stay with the operator, so this tab connects to it
138
+ // first; the browser comes straight back here and the DM goes out.
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()) {
144
+ await connectAsHolder(npubToProve);
145
+ return;
146
+ }
147
+ const r = await requestNpubProof(npubToProve, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
118
148
  if (r.error)
119
149
  return setError(r.error);
120
- if (!r.dpop_token)
150
+ if (!r.nonce)
121
151
  return setError("The service did not return a session phrase. Try again.");
122
152
  // Remembered for the field only. The identity is written by `finish()`,
123
153
  // once the human has answered the DM.
124
- setLastTypedNpub(trimmed);
125
- setPendingProof(r.dpop_token);
154
+ setLastTypedNpub(npubToProve);
155
+ setNonce(r.nonce);
126
156
  setStage("awaiting");
127
157
  }
128
158
  catch (e) {
@@ -137,20 +167,18 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
137
167
  setError("");
138
168
  setStage("checking");
139
169
  try {
140
- const r = await receiveNpubProof(trimmed, pendingProof);
141
- if (r.error) {
142
- 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.");
143
173
  setStage("awaiting");
144
174
  return;
145
175
  }
146
- const token = r.dpop_token || pendingProof;
147
176
  const npub = r.proven_npub || trimmed;
148
177
  setStoredNpub(npub);
149
- setStoredProof(token);
150
178
  if (r.expires_in_seconds && r.expires_in_seconds > 0) {
151
- recordRecentLogin(npub, token, r.expires_in_seconds);
179
+ recordRecentLogin(npub, r.expires_in_seconds);
152
180
  const h = Math.floor(r.expires_in_seconds / 3600);
153
- setNote(`Proof cached for ~${h > 0 ? `${h}h` : `${Math.round(r.expires_in_seconds / 60)}m`}.`);
181
+ setNote(`Proof held for ~${h > 0 ? `${h}h` : `${Math.round(r.expires_in_seconds / 60)}m`}.`);
154
182
  }
155
183
  onLogin();
156
184
  }
@@ -164,10 +192,10 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
164
192
  }
165
193
  function reset() {
166
194
  setStage("begin");
167
- setPendingProof("");
195
+ setNonce("");
168
196
  setError("");
169
197
  }
170
- 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 cached proof`, 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) => {
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) => {
171
199
  setValue(e.target.value);
172
200
  setGeneratedHint(false);
173
201
  }, onKeyDown: (e) => {
@@ -177,7 +205,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
177
205
  signInWithNsec();
178
206
  else
179
207
  void begin();
180
- }, 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] })] }))] }), pendingProof && (_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: pendingProof }), _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 } })] }));
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 } })] }));
181
209
  }
182
210
  /** A failure, read to the person: the lead first, the service's own words underneath. */
183
211
  function Trouble({ raw, appName }) {
@@ -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
+ };
@@ -4,23 +4,24 @@
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
- * - a **cached DM proof**, won by answering a Nostr challenge, or
10
- * - a **session nsec** in this tab, which signs a fresh proof per call — and
11
- * only counts for the npub it actually derives to.
9
+ * - a **DM proof**, won by answering a Nostr challenge — held by the operator
10
+ * for this tab's holder connection, 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
  export interface Claim {
16
17
  /** The npub the interface is showing, proven or not. */
17
18
  npub: string;
18
- /** A cached DM proof token, if one was won and has not lapsed. */
19
- proof: string;
19
+ /** True when this tab's holder connection holds the npub, proven by DM. */
20
+ held: boolean;
20
21
  /** The npub this tab's session key derives to, if it holds one. */
21
22
  sessionNpub: string | null;
22
23
  }
23
24
  /** True only when the claimed npub is backed by something. */
24
- export declare function isProven({ npub, proof, sessionNpub }: Claim): boolean;
25
+ export declare function isProven({ npub, held, sessionNpub }: Claim): boolean;
25
26
  /** True when this tab can sign a fresh proof for the npub it is claiming. */
26
27
  export declare function canSignFor({ npub, sessionNpub }: Claim): boolean;
package/dist/signedIn.js CHANGED
@@ -4,21 +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
- * - a **cached DM proof**, won by answering a Nostr challenge, or
10
- * - a **session nsec** in this tab, which signs a fresh proof per call — and
11
- * only counts for the npub it actually derives to.
9
+ * - a **DM proof**, won by answering a Nostr challenge — held by the operator
10
+ * for this tab's holder connection, 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
- if (!npub)
18
- return false;
19
- if (proof)
20
- return true;
21
- return sessionNpub === npub;
17
+ export function isProven({ npub, held, sessionNpub }) {
18
+ return Boolean(npub) && (held || sessionNpub === npub);
22
19
  }
23
20
  /** True when this tab can sign a fresh proof for the npub it is claiming. */
24
21
  export function canSignFor({ npub, sessionNpub }) {
@@ -53,7 +53,10 @@ export interface NpubProofResult {
53
53
  proven_npub?: string;
54
54
  status?: string;
55
55
  message?: string;
56
- dpop_token?: string;
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. */
59
+ held_by_connection?: boolean;
57
60
  expires_in_seconds?: number;
58
61
  expires_at?: string;
59
62
  error?: string;
@@ -69,18 +72,21 @@ export interface NpubProofResult {
69
72
  */
70
73
  export declare function requestNpubProof(patronNpub: string, verifyAt?: string, reason?: string, opts?: CallOptions): Promise<NpubProofResult>;
71
74
  /**
72
- * Step 2: drain DMs for the signed reply to step 1. Call only after the human
73
- * says they replied — it is destructive, so never poll it.
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.
74
79
  */
75
- export declare function receiveNpubProof(patronNpub: string, dpopToken: string, opts?: CallOptions): Promise<NpubProofResult>;
80
+ export declare function receiveNpubProof(patronNpub: string, nonce: string, opts?: CallOptions): Promise<NpubProofResult>;
76
81
  export interface ProofStatusResult {
77
82
  success?: boolean;
78
- status?: "valid" | "expired" | "unknown" | string;
83
+ status?: "valid" | "expired" | "invalid" | "revoked" | "not_held" | string;
79
84
  expires_in_seconds?: number | null;
80
85
  error?: string;
81
86
  error_code?: string;
82
87
  }
83
- export declare function checkProofStatus(patronNpub: string, dpopToken: string, opts?: CallOptions): Promise<ProofStatusResult>;
88
+ /** Whether this tab's connection proves `patronNpub`, and until when. */
89
+ export declare function checkProofStatus(patronNpub: string, opts?: CallOptions): Promise<ProofStatusResult>;
84
90
  /** One credit tranche: a purchase (or grant) and what is left of it. */
85
91
  export interface CreditTranche {
86
92
  id: string;
@@ -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. Call only after the human
30
- * says they replied — it is destructive, so never poll it.
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, dpopToken, opts) {
33
- return callTool("receive_npub_proof", { patron_npub: patronNpub, dpop_token: dpopToken }, opts);
34
+ export function receiveNpubProof(patronNpub, nonce, opts) {
35
+ return callTool("receive_npub_proof", { patron_npub: patronNpub, nonce }, opts);
34
36
  }
35
- export function checkProofStatus(patronNpub, dpopToken, opts) {
36
- return callTool("check_proof_status", { patron_npub: patronNpub, dpop_token: dpopToken }, { bestEffort: true, ...opts });
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);
@@ -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
- * proof_refresh_needed}` with no isError flag. The wheel's codes are lowercase
32
- * snake_case; an earlier uppercase comparison never matched, so the bounce
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 stored proof grant the service refused for good: `{success: false,
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
- * with it can never succeed, so the patron is signed out.
40
+ * on it can never succeed, so the patron is signed out.
41
41
  *
42
- * Only meaningful when the call carried the stored grant. A session key's
43
- * inline proof shares the code and even the word `expired` (outside its
44
- * window), but it is signed afresh for the next call and loses nothing.
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. */
@@ -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
- * proof_refresh_needed}` with no isError flag. The wheel's codes are lowercase
41
- * snake_case; an earlier uppercase comparison never matched, so the bounce
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 && (code === "proof_required" || code === "proof_refresh_needed")) {
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 stored proof grant the service refused for good: `{success: false,
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
- * with it can never succeed, so the patron is signed out.
64
+ * on it can never succeed, so the patron is signed out.
65
65
  *
66
- * Only meaningful when the call carried the stored grant. A session key's
67
- * inline proof shares the code and even the word `expired` (outside its
68
- * window), but it is signed afresh for the next call and loses nothing.
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": "1.8.2",
3
+ "version": "2.0.0",
4
4
  "description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lonnie VanZandt",
@@ -1,18 +0,0 @@
1
- /**
2
- * Inline kind-27235 identity proof — Tactic 2 in the wheel's
3
- * `identity_proof.verify_proof`. A fresh signed event per paid call, bound to:
4
- * the sender pubkey (must match the claimed npub), the `u` tag (must match the
5
- * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
- * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
- * inline — no relay round trip, no cached phrase.
8
- *
9
- * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
- * `create_proof` does. Without it, two calls to the same tool in the same
11
- * wall-clock second sign byte-identical events, their ids collide, and the
12
- * verifier's replay guard refuses the second. The nonce is signed but inert:
13
- * `verify_proof` reads only the `u` tag.
14
- */
15
- /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
16
- export declare function proofNonce(): string;
17
- /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
18
- export declare function signInlineProof(runtimeToolName: string, secretKey?: Uint8Array): string;
@@ -1,35 +0,0 @@
1
- /**
2
- * Inline kind-27235 identity proof — Tactic 2 in the wheel's
3
- * `identity_proof.verify_proof`. A fresh signed event per paid call, bound to:
4
- * the sender pubkey (must match the claimed npub), the `u` tag (must match the
5
- * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
- * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
- * inline — no relay round trip, no cached phrase.
8
- *
9
- * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
- * `create_proof` does. Without it, two calls to the same tool in the same
11
- * wall-clock second sign byte-identical events, their ids collide, and the
12
- * verifier's replay guard refuses the second. The nonce is signed but inert:
13
- * `verify_proof` reads only the `u` tag.
14
- */
15
- import { finalizeEvent } from "nostr-tools";
16
- import { getSessionNsecBytes } from "./sessionNsec.js";
17
- const PROOF_KIND = 27235;
18
- /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
19
- export function proofNonce() {
20
- const bytes = crypto.getRandomValues(new Uint8Array(16));
21
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
22
- }
23
- /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
24
- export function signInlineProof(runtimeToolName, secretKey) {
25
- const signed = finalizeEvent({
26
- kind: PROOF_KIND,
27
- created_at: Math.floor(Date.now() / 1000),
28
- tags: [
29
- ["u", runtimeToolName],
30
- ["nonce", proofNonce()],
31
- ],
32
- content: "",
33
- }, secretKey ?? getSessionNsecBytes());
34
- return JSON.stringify(signed);
35
- }