@tollbooth-dpyc/web 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -12,7 +12,7 @@
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
- import { debugPush } from "./debugLog.js";
15
+ import { debugPush, redact } from "./debugLog.js";
16
16
  import { clearStoredProof, forgetRecentLogin, getStoredNpub, getStoredProof, } from "./identity.js";
17
17
  import { signInlineProof } from "./inlineProof.js";
18
18
  import { isTransportFailure, NetworkError } from "./networkError.js";
@@ -129,7 +129,7 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
129
129
  // `args` holds only the caller's own parameters — the envelope is added
130
130
  // below — so it is safe to log.
131
131
  if (!quiet)
132
- debugPush("call", `${name}(${JSON.stringify(args).slice(0, 140)})`);
132
+ debugPush("call", `${name}(${redact(JSON.stringify(args)).slice(0, 140)})`);
133
133
  const bootstrap = STANDARD_BOOTSTRAP.includes(tool) || cfg.extraBootstrapTools.includes(tool);
134
134
  const merged = bootstrap
135
135
  ? { ...args }
@@ -154,14 +154,14 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
154
154
  if (result.isError) {
155
155
  const text = errorText(result);
156
156
  if (!quiet)
157
- debugPush("error", `${name}: ${text.slice(0, 200)}`);
157
+ debugPush("error", `${name}: ${redact(text).slice(0, 200)}`);
158
158
  throw new Error(text);
159
159
  }
160
160
  const { payload, images } = readToolResult(result);
161
161
  if (!quiet) {
162
162
  const preview = typeof payload === "string" ? payload : JSON.stringify(payload);
163
163
  const tail = images.length ? ` +${images.length} image` : "";
164
- debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${String(preview).slice(0, 220)}${tail}`);
164
+ debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${redact(String(preview)).slice(0, 220)}${tail}`);
165
165
  }
166
166
  if (!opts.bestEffort) {
167
167
  const bounced = proofBounceMessage(payload);
@@ -7,8 +7,10 @@
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, and the value of any secret-named field (dpop_token, proof, poison,
11
- * password, api_key, …) never reach the buffer, the clipboard or storage.
10
+ * token, the value of any secret-named field (dpop_token, proof, poison,
11
+ * password, api_key, …), and a secret inside a URL (a query token, a
12
+ * bearer-like path segment, userinfo) never reach the buffer, the clipboard
13
+ * or storage.
12
14
  * That is the one guarantee a panel relies on — it renders what is here.
13
15
  *
14
16
  * Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
package/dist/debugLog.js CHANGED
@@ -7,8 +7,10 @@
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, and the value of any secret-named field (dpop_token, proof, poison,
11
- * password, api_key, …) never reach the buffer, the clipboard or storage.
10
+ * token, the value of any secret-named field (dpop_token, proof, poison,
11
+ * password, api_key, …), and a secret inside a URL (a query token, a
12
+ * bearer-like path segment, userinfo) never reach the buffer, the clipboard
13
+ * or storage.
12
14
  * That is the one guarantee a panel relies on — it renders what is here.
13
15
  *
14
16
  * Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
@@ -34,7 +36,7 @@ const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|proof|poison|password|passphr
34
36
  // a bare one up to a delimiter. Stopping at the first quote of either kind let
35
37
  // the tail of a secret holding `'` or `\"` through.
36
38
  function fieldValue(names) {
37
- return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])(?:\\\\.|\\\\$|(?!\\2)[^\\\\])*?(?=\\2|$)|[^"'\\\\,}\\]&\\s]+)`, "gi");
39
+ return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])(?:\\\\.|\\\\$|(?!\\2)[^\\\\])*?(?=\\2|$)|(?!\\[redacted\\])[^"'\\\\,}\\]&\\s]+)`, "gi");
38
40
  }
39
41
  const FIELD_VALUE = fieldValue(SECRET_FIELD);
40
42
  // update_patron_credential / update_operator_credential carry the secret as
@@ -47,6 +49,54 @@ const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/g;
47
49
  function scrubbed(_match, head, quote) {
48
50
  return `${head}${quote ?? ""}${REDACTED}`;
49
51
  }
52
+ // ── Secrets inside URLs ────────────────────────────────────────────────────
53
+ //
54
+ // A capability link carries its secret in the URL itself — Good Earth's
55
+ // calendar feed is `https://host/calendar/<32 hex>.ics` — and a link read
56
+ // back in a tool result is logged whole. Three places a URL hides a secret:
57
+ // the userinfo, a query or fragment parameter, and a bearer-like path segment.
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|dpop_token|session|sessionid|nsec)=)[^&#;\s"'\\<>]*/gi;
60
+ // A URL up to whitespace, a quote, an escape or an angle bracket — so one
61
+ // inside JSON (or JSON inside JSON) ends at its own closing quote.
62
+ const URL_IN_TEXT = /\b(?:https?|webcal|wss?):\/\/[^\s"'\\<>]+/gi;
63
+ const USERINFO = /^([a-z]+:\/\/)[^/?#@]+@/i;
64
+ /**
65
+ * A path segment that reads as a bearer secret rather than a name: 32+ hex,
66
+ * or 24+ base64url characters holding both a letter and a digit and one
67
+ * unbroken alphanumeric run of 16+ (a slug's words are short; a token's are
68
+ * not). An npub is public and stays.
69
+ */
70
+ function isSecretSegment(seg) {
71
+ if (/^npub1/i.test(seg))
72
+ return false;
73
+ if (/^[0-9a-f]{32,}$/i.test(seg))
74
+ return true;
75
+ if (!/^[A-Za-z0-9_-]{24,}$/.test(seg))
76
+ return false;
77
+ if (!/[0-9]/.test(seg) || !/[A-Za-z]/.test(seg))
78
+ return false;
79
+ return /[A-Za-z0-9]{16,}/.test(seg);
80
+ }
81
+ function scrubUrl(url) {
82
+ const head = url.replace(USERINFO, `$1${REDACTED}@`);
83
+ const pathStart = head.indexOf("/", head.indexOf("://") + 3);
84
+ if (pathStart < 0)
85
+ return head;
86
+ const tailAt = head.slice(pathStart).search(/[?#]/);
87
+ const pathEnd = tailAt < 0 ? head.length : pathStart + tailAt;
88
+ const path = head
89
+ .slice(pathStart, pathEnd)
90
+ .split("/")
91
+ .map((seg) => {
92
+ // `<token>.ics`: judge the name, keep the extension.
93
+ const dot = seg.indexOf(".");
94
+ const name = dot < 0 ? seg : seg.slice(0, dot);
95
+ return isSecretSegment(name) ? REDACTED + (dot < 0 ? "" : seg.slice(dot)) : seg;
96
+ })
97
+ .join("/");
98
+ return head.slice(0, pathStart) + path + head.slice(pathEnd);
99
+ }
50
100
  /**
51
101
  * Scrub secrets from a log line. Deliberately over-eager: a 64-hex event id
52
102
  * is hidden along with a hex private key, because the two look alike.
@@ -55,7 +105,9 @@ export function redact(message) {
55
105
  let out = message
56
106
  .replace(NSEC, REDACTED)
57
107
  .replace(BEARER, `Bearer ${REDACTED}`)
58
- .replace(FIELD_VALUE, scrubbed);
108
+ .replace(URL_IN_TEXT, scrubUrl)
109
+ .replace(FIELD_VALUE, scrubbed)
110
+ .replace(URL_SECRET_PARAM, `$1${REDACTED}`);
59
111
  if (HAS_FIELD.test(out))
60
112
  out = out.replace(CREDENTIAL_VALUE, scrubbed);
61
113
  return out.replace(HEX_KEY, REDACTED);
@@ -4,13 +4,19 @@
4
4
  * Renders ONLY when this browser holds the session nsec for the signed-in
5
5
  * npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
6
6
  * no placeholder, no mention. Painting the key takes two deliberate taps —
7
- * "Claim your session key", then the Reveal chip — so an account page opened
7
+ * "Claim your session key", then the Reveal (eye) button — so an account page opened
8
8
  * in front of other people does not show it.
9
9
  *
10
10
  * Destinations: copy, download .env, password-manager prompt, and an
11
11
  * encrypted DM to another npub the patron names. The nsec never reaches a
12
12
  * log, error text, analytics call, or MCP tool argument.
13
13
  */
14
- export default function SessionKeyClaim({ npub }: {
14
+ export interface SessionKeyClaimClassNames {
15
+ /** Each round icon action (Reveal, Copy, .env, password manager, DM) when not active. Default: the package's outlined chip. */
16
+ action?: string;
17
+ }
18
+ export interface SessionKeyClaimProps {
15
19
  npub: string;
16
- }): import("react").JSX.Element | null;
20
+ classNames?: SessionKeyClaimClassNames;
21
+ }
22
+ export default function SessionKeyClaim({ npub, classNames }: SessionKeyClaimProps): import("react").JSX.Element | null;
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  * Renders ONLY when this browser holds the session nsec for the signed-in
6
6
  * npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
7
7
  * no placeholder, no mention. Painting the key takes two deliberate taps —
8
- * "Claim your session key", then the Reveal chip — so an account page opened
8
+ * "Claim your session key", then the Reveal (eye) button — so an account page opened
9
9
  * in front of other people does not show it.
10
10
  *
11
11
  * Destinations: copy, download .env, password-manager prompt, and an
@@ -13,13 +13,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  * log, error text, analytics call, or MCP tool argument.
14
14
  */
15
15
  import { useRef, useState } from "react";
16
+ import { Check, Copy, Download, Eye, EyeOff, KeyRound, Send } from "lucide-react";
16
17
  import { tollboothConfig } from "../config.js";
17
18
  import { passwordManagerOutcomeLabel, sessionKeyClaimVisible, sessionKeyEnvContents, sessionKeyEnvFilename, } from "../sessionKeyClaim.js";
18
19
  import { sendSessionKeyDm } from "../sessionKeyDm.js";
19
20
  import { getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, } from "../sessionNsec.js";
20
21
  import { shareOrDownload } from "../shareFile.js";
21
22
  import { card, input, muted } from "./ui.js";
22
- export default function SessionKeyClaim({ npub }) {
23
+ export default function SessionKeyClaim({ npub, classNames = {} }) {
23
24
  const visible = sessionKeyClaimVisible({
24
25
  hasSessionNsec: hasSessionNsec(),
25
26
  sessionNsecNpub: sessionNsecNpub(),
@@ -28,9 +29,9 @@ export default function SessionKeyClaim({ npub }) {
28
29
  // Hard requirement: silence when the browser does not hold the key.
29
30
  if (!visible)
30
31
  return null;
31
- return _jsx(SessionKeyClaimInner, {});
32
+ return _jsx(SessionKeyClaimInner, { actionClass: classNames.action });
32
33
  }
33
- function SessionKeyClaimInner() {
34
+ function SessionKeyClaimInner({ actionClass }) {
34
35
  const { slug, appName } = tollboothConfig();
35
36
  const envFilename = sessionKeyEnvFilename(slug);
36
37
  const [revealed, setRevealed] = useState(false);
@@ -162,17 +163,19 @@ function SessionKeyClaimInner() {
162
163
  setDmOpen(false);
163
164
  setFlash(null);
164
165
  setRecipient("");
165
- }, className: `text-[12px] ${muted} hover:text-[var(--tb-ink)]`, children: "Hide" })] }), _jsxs("p", { className: `mb-3 text-[12.5px] leading-relaxed ${muted}`, children: ["Anyone who holds this key is you. It leaves this page only when you choose a destination below \u2014 never logged, never sent to ", appName, "."] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(DestButton, { onClick: () => setShowKey((v) => !v), active: showKey, children: showKey ? "Conceal" : "Reveal" }), _jsx(DestButton, { onClick: () => void copyKey(), disabled: busy, children: copied ? "Copied" : "Copy" }), _jsx(DestButton, { onClick: () => void downloadEnv(), disabled: busy, children: "Download .env" }), _jsx(DestButton, { onClick: () => void passwordManagerSave(), disabled: busy, children: "Password manager" }), _jsx(DestButton, { onClick: () => {
166
+ }, className: `text-[12px] ${muted} hover:text-[var(--tb-ink)]`, children: "Hide" })] }), _jsxs("p", { className: `mb-3 text-[12.5px] leading-relaxed ${muted}`, children: ["Anyone who holds this key is you. It leaves this page only when you choose a destination below \u2014 never logged, never sent to ", appName, "."] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(DestButton, { label: showKey ? "Conceal the key" : "Reveal the key", onClick: () => setShowKey((v) => !v), active: showKey, className: actionClass, children: showKey ? _jsx(EyeOff, { size: 20, "aria-hidden": "true" }) : _jsx(Eye, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: copied ? "Copied" : "Copy the key", onClick: () => void copyKey(), disabled: busy, className: actionClass, children: copied ? _jsx(Check, { size: 20, "aria-hidden": "true" }) : _jsx(Copy, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: "Download a .env file", onClick: () => void downloadEnv(), disabled: busy, className: actionClass, children: _jsx(Download, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: "Save to your password manager", onClick: () => void passwordManagerSave(), disabled: busy, className: actionClass, children: _jsx(KeyRound, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: "Send by encrypted DM", onClick: () => {
166
167
  setDmOpen((v) => !v);
167
168
  setFlash(null);
168
- }, disabled: busy, active: dmOpen, children: "Send by DM" })] }), showKey && (_jsx("div", { className: "mt-3 break-all rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] px-3 py-2 font-mono text-[12px] text-[var(--tb-ink)]", children: getSessionNsec() })), dmOpen && (_jsxs("div", { className: "mt-3 space-y-2 rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] p-3", children: [_jsx("p", { className: `text-[12px] leading-relaxed ${muted}`, children: "Encrypts the key to an npub you own and publishes the sealed message to public relays. Only that npub can open it." }), _jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [_jsxs("label", { className: `block min-w-[16rem] flex-1 text-[12px] ${muted}`, children: ["Recipient npub", _jsx("input", { value: recipient, onChange: (e) => setRecipient(e.target.value), spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: "npub1\u2026", className: `mt-1 h-11 ${input}` })] }), _jsx("button", { type: "button", onClick: () => void sendDm(), disabled: busy || recipient.trim().length < 60, className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)] disabled:opacity-40", children: busy ? "Sending…" : "Send encrypted DM" })] })] })), _jsxs("form", { ref: formRef, className: "absolute h-px w-px overflow-hidden opacity-0", "aria-hidden": "true", autoComplete: "on", onSubmit: (e) => {
169
+ }, disabled: busy, active: dmOpen, className: actionClass, children: _jsx(Send, { size: 20, "aria-hidden": "true" }) })] }), showKey && (_jsx("div", { className: "mt-3 break-all rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] px-3 py-2 font-mono text-[12px] text-[var(--tb-ink)]", children: getSessionNsec() })), dmOpen && (_jsxs("div", { className: "mt-3 space-y-2 rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] p-3", children: [_jsx("p", { className: `text-[12px] leading-relaxed ${muted}`, children: "Encrypts the key to an npub you own and publishes the sealed message to public relays. Only that npub can open it." }), _jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [_jsxs("label", { className: `block min-w-[16rem] flex-1 text-[12px] ${muted}`, children: ["Recipient npub", _jsx("input", { value: recipient, onChange: (e) => setRecipient(e.target.value), spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: "npub1\u2026", className: `mt-1 h-11 ${input}` })] }), _jsx("button", { type: "button", onClick: () => void sendDm(), disabled: busy || recipient.trim().length < 60, className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)] disabled:opacity-40", children: busy ? "Sending…" : "Send encrypted DM" })] })] })), _jsxs("form", { ref: formRef, className: "absolute h-px w-px overflow-hidden opacity-0", "aria-hidden": "true", autoComplete: "on", onSubmit: (e) => {
169
170
  e.preventDefault();
170
171
  }, children: [_jsx("input", { ref: userRef, name: "username", autoComplete: "username", defaultValue: "", readOnly: true, tabIndex: -1 }), _jsx("input", { ref: passRef, name: "password", type: "password", autoComplete: "new-password", defaultValue: "", readOnly: true, tabIndex: -1 })] }), flash && (_jsx("p", { className: `mt-3 text-[12.5px] leading-relaxed ${flash.tone === "ok" ? "text-[var(--tb-ink)]" : "text-[var(--tb-err-ink)]"}`, role: "status", children: flash.text }))] }));
171
172
  }
172
- function DestButton({ onClick, disabled, active, children, }) {
173
- return (_jsx("button", { type: "button", onClick: onClick, disabled: disabled, className: `min-h-11 shrink-0 rounded-full border px-3.5 text-[12.5px] font-medium disabled:opacity-40 ${active
173
+ /** An icon-only round action. `label` is its aria-label and tooltip. */
174
+ function DestButton({ label, onClick, disabled, active, className, children, }) {
175
+ const idle = className ?? `border-[var(--tb-line)] ${muted} active:bg-[var(--tb-surface-2)]`;
176
+ return (_jsx("button", { type: "button", onClick: onClick, disabled: disabled, "aria-label": label, title: label, className: `inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--tb-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--tb-surface)] disabled:opacity-40 ${active
174
177
  ? "border-[var(--tb-accent)] bg-[var(--tb-accent)] text-[var(--tb-on-accent)]"
175
- : `border-[var(--tb-line)] ${muted} active:bg-[var(--tb-surface-2)]`}`, children: children }));
178
+ : idle}`, children: children }));
176
179
  }
177
180
  /**
178
181
  * Offer the key to a browser password manager. Never claims an OS keychain.
@@ -9,7 +9,7 @@ export { default as NpubGate } from "./NpubGate.tsx";
9
9
  export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
10
10
  export { PageControls, SortHeader, TableShell, type PageControlsProps, type PageControlsClassNames, type SortHeaderProps, type SortHeaderClassNames, type TableShellClassNames, } from "./PagedTable.tsx";
11
11
  export { default as QuoteScroller, type QuoteScrollerProps, type QuoteScrollerClassNames } from "./QuoteScroller.tsx";
12
- export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
12
+ export { default as SessionKeyClaim, type SessionKeyClaimProps, type SessionKeyClaimClassNames, } from "./SessionKeyClaim.tsx";
13
13
  export { default as TableFilter, type DateFieldOption, type FilterQuestion, type TableFilterProps, type TableFilterClassNames, type TableFilterDates, type TableFilterQuestions, type TableFilterSearch, } from "./TableFilter.tsx";
14
14
  export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleClassNames } from "./ThemeToggle.tsx";
15
15
  export { default as TimezonePicker, type TimezoneOption, type TimezonePickerClassNames, type TimezonePickerProps, } from "./TimezonePicker.tsx";
@@ -9,7 +9,7 @@ export { default as NpubGate } from "./NpubGate.js";
9
9
  export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
10
10
  export { PageControls, SortHeader, TableShell, } from "./PagedTable.js";
11
11
  export { default as QuoteScroller } from "./QuoteScroller.js";
12
- export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
12
+ export { default as SessionKeyClaim, } from "./SessionKeyClaim.js";
13
13
  export { default as TableFilter, } from "./TableFilter.js";
14
14
  export { default as ThemeToggle } from "./ThemeToggle.js";
15
15
  export { default as TimezonePicker, } from "./TimezonePicker.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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",