@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 +4 -4
- package/dist/debugLog.d.ts +4 -2
- package/dist/debugLog.js +56 -4
- package/dist/react/SessionKeyClaim.d.ts +9 -3
- package/dist/react/SessionKeyClaim.js +12 -9
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +1 -1
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);
|
package/dist/debugLog.d.ts
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,
|
|
11
|
-
* password, api_key, …)
|
|
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,
|
|
11
|
-
* password, api_key, …)
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ? "
|
|
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:
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
:
|
|
178
|
+
: idle}`, children: children }));
|
|
176
179
|
}
|
|
177
180
|
/**
|
|
178
181
|
* Offer the key to a browser password manager. Never claims an OS keychain.
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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",
|