@tollbooth-dpyc/web 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,8 +9,10 @@ features. It is the peer of the `tollbooth-dpyc` Python wheel.
9
9
  kind-27235 inline proof when the tab holds a session key
10
10
  - **MCP client** — one shared connection to the operator, typed wrappers for
11
11
  the standard tools (balance, top-up, statement, price, profile)
12
- - **React components** — `NpubGate`, `NostrProfilePanel`, `WalletCard`,
13
- `AvatarPicker`, `useSession`
12
+ - **React components** — `NpubGate`, `NostrProfilePanel`, `SessionKeyClaim`, `WalletCard`,
13
+ `AvatarPicker`, `DebugPanel`, `useSession`, `useDebugLog`
14
+ - **Debug log** — every call, result and error from `callTool`, scrubbed of
15
+ nsecs, hex keys, tokens and proofs before it is stored
14
16
  - **Pages proxy** — the `/mcp` Cloudflare Pages Function
15
17
 
16
18
  ## Use
@@ -39,6 +41,26 @@ import { makeMcpProxy } from "@tollbooth-dpyc/web/pages-proxy";
39
41
  export const onRequest = makeMcpProxy("https://chartremotely-mcp.fastmcp.app/mcp");
40
42
  ```
41
43
 
44
+ ### Debug panel
45
+
46
+ `callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
47
+ bar along the bottom of the page with Copy and Clear. Put it once near the root.
48
+ Anything the site alone needs goes in as children, drawn above the log:
49
+
50
+ ```tsx
51
+ import { configureDebugLog, debugPush } from "@tollbooth-dpyc/web";
52
+ import { DebugPanel } from "@tollbooth-dpyc/web/react";
53
+
54
+ configureDebugLog({ persist: true }); // optional: outlive a reload; max defaults to 200
55
+
56
+ <DebugPanel>
57
+ <button onClick={loadSchedulerRuns}>Scheduler ↻</button>
58
+ </DebugPanel>
59
+ ```
60
+
61
+ A site's own `debugPush` lines are scrubbed the same way. Stamps follow the
62
+ zone the site stores under `<prefix>:timezone`, if any.
63
+
42
64
  ### Styling
43
65
 
44
66
  Components use Tailwind classes coloured only through CSS variables. Tell
package/dist/avatar.d.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * is not, so a glyph pick stays local. A change dispatches a window event so
6
6
  * every avatar on the page updates without a reload.
7
7
  */
8
- export declare const AVATAR_CHOICES: string[];
8
+ /** The glyphs offered when a site names none of its own. */
9
+ export declare const DEFAULT_AVATAR_CHOICES: string[];
10
+ /** This site's glyphs — `configureTollbooth({ avatarChoices })`, or the defaults. */
11
+ export declare function avatarChoices(): string[];
9
12
  export declare const AVATAR_EVENT = "tollbooth:avatar-changed";
10
13
  /** http(s) or data:image renders as an image; anything else as a glyph. */
11
14
  export declare function isAvatarUrl(value: string): boolean;
package/dist/avatar.js CHANGED
@@ -5,15 +5,22 @@
5
5
  * is not, so a glyph pick stays local. A change dispatches a window event so
6
6
  * every avatar on the page updates without a reload.
7
7
  */
8
+ import { tollboothConfig } from "./config.js";
8
9
  import { fetchProfile } from "./nostrProfile.js";
9
10
  import { readStored, writeStored } from "./storage.js";
10
- export const AVATAR_CHOICES = [
11
+ /** The glyphs offered when a site names none of its own. */
12
+ export const DEFAULT_AVATAR_CHOICES = [
11
13
  "🐂", "🐻", "🦂", "🦅", "🐺", "🦉",
12
14
  "🦊", "🐉", "🦄", "🐢", "🦈", "🦀",
13
15
  "🎩", "🎭", "🃏", "🎯", "🪙", "💎",
14
16
  "⚡", "🔥", "🌪️", "🌊", "🏔️", "🌋",
15
17
  "♟️", "♛", "🛡️", "⚔️", "🗝️", "📜",
16
18
  ];
19
+ /** This site's glyphs — `configureTollbooth({ avatarChoices })`, or the defaults. */
20
+ export function avatarChoices() {
21
+ const own = tollboothConfig().avatarChoices;
22
+ return own?.length ? own : DEFAULT_AVATAR_CHOICES;
23
+ }
17
24
  export const AVATAR_EVENT = "tollbooth:avatar-changed";
18
25
  /** http(s) or data:image renders as an image; anything else as a glyph. */
19
26
  export function isAvatarUrl(value) {
@@ -39,7 +46,8 @@ export function defaultAvatar(npub) {
39
46
  let h = 0;
40
47
  for (const c of npub)
41
48
  h = (h * 31 + c.charCodeAt(0)) >>> 0;
42
- return AVATAR_CHOICES[h % AVATAR_CHOICES.length];
49
+ const choices = avatarChoices();
50
+ return choices[h % choices.length];
43
51
  }
44
52
  export function avatarFor(npub) {
45
53
  return getStoredAvatar(npub) || defaultAvatar(npub);
package/dist/config.d.ts CHANGED
@@ -27,10 +27,13 @@ export interface TollboothConfig {
27
27
  extraBootstrapTools?: string[];
28
28
  /** Tools too routine to log in the debug panel. */
29
29
  quietTools?: string[];
30
+ /** Avatar glyphs in this site's voice. Defaults to a general set. */
31
+ avatarChoices?: string[];
30
32
  }
31
- interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools">> {
33
+ interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices">> {
32
34
  extraBootstrapTools: string[];
33
35
  quietTools: string[];
36
+ avatarChoices?: string[];
34
37
  }
35
38
  export declare function configureTollbooth(config: TollboothConfig): void;
36
39
  export declare function tollboothConfig(): Resolved;
package/dist/config.js CHANGED
@@ -19,6 +19,7 @@ export function configureTollbooth(config) {
19
19
  mcpUrl: config.mcpUrl,
20
20
  extraBootstrapTools: config.extraBootstrapTools ?? [],
21
21
  quietTools: config.quietTools ?? [],
22
+ avatarChoices: config.avatarChoices,
22
23
  };
23
24
  }
24
25
  export function tollboothConfig() {
@@ -1,18 +1,50 @@
1
1
  /**
2
- * An in-memory activity log of MCP calls, for a site's debug panel.
2
+ * An activity log of MCP calls, for a site's debug panel.
3
3
  *
4
4
  * A ring buffer with subscribers, so the one `callTool` can push and any panel
5
- * can listen. It survives route changes and is gone on reload. Arguments are
6
- * logged only as the wrapper's own parameters — the npub/proof envelope is
7
- * added after logging, so no proof ever lands here.
5
+ * can listen. Newest first. It survives route changes; with
6
+ * `configureDebugLog({ persist: true })` it survives a reload too, so an error
7
+ * that flips the view can still be read and copied afterward.
8
+ *
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.
12
+ * That is the one guarantee a panel relies on — it renders what is here.
13
+ *
14
+ * Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
15
+ * optionality, roastify, taxsort). None of them scrubbed; this one does.
8
16
  */
9
17
  export interface DebugEntry {
10
18
  ts: string;
11
19
  type: "info" | "call" | "result" | "error";
12
20
  message: string;
13
21
  }
22
+ export interface DebugLogOptions {
23
+ /** How many entries to keep. Default 200. */
24
+ max?: number;
25
+ /** Keep the log in localStorage so it outlives a reload. Default false. */
26
+ persist?: boolean;
27
+ }
28
+ /**
29
+ * Scrub secrets from a log line. Deliberately over-eager: a 64-hex event id
30
+ * is hidden along with a hex private key, because the two look alike.
31
+ */
32
+ export declare function redact(message: string): string;
33
+ /** Set the cap and persistence. Call after `configureTollbooth`. */
34
+ export declare function configureDebugLog(options: DebugLogOptions): void;
14
35
  export declare function debugPush(type: DebugEntry["type"], message: string): void;
15
36
  export declare function clearDebug(): void;
37
+ /** The entries, newest first. The same array until the log changes. */
16
38
  export declare function debugEntries(): readonly DebugEntry[];
17
39
  /** Subscribe to changes. Returns an unsubscribe function. */
18
40
  export declare function onDebug(fn: () => void): () => void;
41
+ /** The whole log as plain text, newest first — for Copy and bug reports. */
42
+ export declare function debugLogText(): string;
43
+ export type DebugSeverity = "ok" | "notice" | "failure";
44
+ /** How a panel should flag an entry: a fault, a patron's next step, or neither. */
45
+ export declare function debugSeverity(entry: DebugEntry): DebugSeverity;
46
+ /**
47
+ * Fold uncaught errors and promise rejections into the log, once per page
48
+ * (guarded, so StrictMode and remounts do not double-register).
49
+ */
50
+ export declare function captureGlobalErrors(): void;
package/dist/debugLog.js CHANGED
@@ -1,29 +1,195 @@
1
1
  /**
2
- * An in-memory activity log of MCP calls, for a site's debug panel.
2
+ * An activity log of MCP calls, for a site's debug panel.
3
3
  *
4
4
  * A ring buffer with subscribers, so the one `callTool` can push and any panel
5
- * can listen. It survives route changes and is gone on reload. Arguments are
6
- * logged only as the wrapper's own parameters — the npub/proof envelope is
7
- * added after logging, so no proof ever lands here.
5
+ * can listen. Newest first. It survives route changes; with
6
+ * `configureDebugLog({ persist: true })` it survives a reload too, so an error
7
+ * that flips the view can still be read and copied afterward.
8
+ *
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.
12
+ * That is the one guarantee a panel relies on — it renders what is here.
13
+ *
14
+ * Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
15
+ * optionality, roastify, taxsort). None of them scrubbed; this one does.
8
16
  */
9
- const log = [];
17
+ import { tollboothConfig } from "./config.js";
18
+ const DEFAULT_MAX = 200;
19
+ let max = DEFAULT_MAX;
20
+ let persist = false;
21
+ // Replaced, never mutated, so a snapshot reader (useSyncExternalStore) sees a
22
+ // new array exactly when something changed.
23
+ let log = [];
10
24
  const listeners = new Set();
11
- const MAX = 60;
12
- export function debugPush(type, message) {
13
- log.unshift({ ts: new Date().toLocaleTimeString(), type, message });
14
- if (log.length > MAX)
15
- log.length = MAX;
25
+ // ── Redaction ──────────────────────────────────────────────────────────────
26
+ const REDACTED = "[redacted]";
27
+ /** Field names whose value is a secret wherever it appears. */
28
+ const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|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))";
29
+ // `"dpop_token":"…"`, `\"dpop_token\":\"…\"` (JSON inside a JSON string),
30
+ // `dpop_token=…` (a query string) and `dpop_token: '…'` (a Python repr). A
31
+ // quoted value is taken whole, spaces and all; a bare one up to a delimiter.
32
+ function fieldValue(names) {
33
+ return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])[^"'\\\\]*|[^"'\\\\,}\\]&\\s]+)`, "gi");
34
+ }
35
+ const FIELD_VALUE = fieldValue(SECRET_FIELD);
36
+ // update_patron_credential / update_operator_credential carry the secret as
37
+ // `value` beside a `field` naming it, so any `value` in such a call goes too.
38
+ const CREDENTIAL_VALUE = fieldValue("value");
39
+ const HAS_FIELD = /\\*["']field\\*["']\s*:/i;
40
+ const NSEC = /\b(?:nsec|ncryptsec)1[02-9ac-hj-np-z]{20,}/gi;
41
+ const HEX_KEY = /\b[0-9a-f]{64,}\b/gi; // keys, and signatures (128)
42
+ const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/g;
43
+ function scrubbed(_match, head, quote) {
44
+ return `${head}${quote ?? ""}${REDACTED}`;
45
+ }
46
+ /**
47
+ * Scrub secrets from a log line. Deliberately over-eager: a 64-hex event id
48
+ * is hidden along with a hex private key, because the two look alike.
49
+ */
50
+ export function redact(message) {
51
+ let out = message
52
+ .replace(NSEC, REDACTED)
53
+ .replace(BEARER, `Bearer ${REDACTED}`)
54
+ .replace(FIELD_VALUE, scrubbed);
55
+ if (HAS_FIELD.test(out))
56
+ out = out.replace(CREDENTIAL_VALUE, scrubbed);
57
+ return out.replace(HEX_KEY, REDACTED);
58
+ }
59
+ // ── Storage (best effort) ──────────────────────────────────────────────────
60
+ function storageKey(name) {
61
+ try {
62
+ return `${tollboothConfig().storagePrefix}:${name}`;
63
+ }
64
+ catch {
65
+ return null; // not configured yet: nothing to read or write
66
+ }
67
+ }
68
+ function readLocal(name) {
69
+ const k = storageKey(name);
70
+ if (!k)
71
+ return null;
72
+ try {
73
+ return globalThis.localStorage?.getItem(k) ?? null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ function writeLocal(name, value) {
80
+ const k = storageKey(name);
81
+ if (!k)
82
+ return;
83
+ try {
84
+ globalThis.localStorage?.setItem(k, value);
85
+ }
86
+ catch {
87
+ /* quota or blocked site data — the in-memory log still works */
88
+ }
89
+ }
90
+ const LOG_KEY = "debug-log:v1";
91
+ function save() {
92
+ if (persist)
93
+ writeLocal(LOG_KEY, JSON.stringify(log));
94
+ }
95
+ function hydrate() {
96
+ try {
97
+ const parsed = JSON.parse(readLocal(LOG_KEY) ?? "[]");
98
+ if (!Array.isArray(parsed))
99
+ return [];
100
+ return parsed
101
+ .filter((e) => !!e && typeof e.message === "string" && typeof e.ts === "string")
102
+ .map((e) => ({ ts: e.ts, type: e.type, message: redact(e.message) }));
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }
108
+ /**
109
+ * The patron's display zone, if the site stores one under `<prefix>:timezone`
110
+ * ("auto" or absent means the browser's own), so stamps match the rest of the UI.
111
+ */
112
+ function stamp(now) {
113
+ try {
114
+ const pref = readLocal("timezone");
115
+ const zone = pref && pref !== "auto" ? pref : undefined;
116
+ return now.toLocaleTimeString(undefined, zone ? { timeZone: zone } : undefined);
117
+ }
118
+ catch {
119
+ return now.toLocaleTimeString();
120
+ }
121
+ }
122
+ function emit() {
123
+ save();
16
124
  listeners.forEach((fn) => fn());
17
125
  }
126
+ // ── API ────────────────────────────────────────────────────────────────────
127
+ /** Set the cap and persistence. Call after `configureTollbooth`. */
128
+ export function configureDebugLog(options) {
129
+ if (options.max !== undefined) {
130
+ if (!Number.isInteger(options.max) || options.max < 1)
131
+ throw new Error("max must be a positive integer");
132
+ max = options.max;
133
+ }
134
+ if (options.persist !== undefined) {
135
+ persist = options.persist;
136
+ if (persist)
137
+ log = [...log, ...hydrate()];
138
+ }
139
+ log = log.slice(0, max);
140
+ emit();
141
+ }
142
+ export function debugPush(type, message) {
143
+ log = [{ ts: stamp(new Date()), type, message: redact(String(message)) }, ...log].slice(0, max);
144
+ emit();
145
+ }
18
146
  export function clearDebug() {
19
- log.length = 0;
20
- listeners.forEach((fn) => fn());
147
+ log = [];
148
+ emit();
21
149
  }
150
+ /** The entries, newest first. The same array until the log changes. */
22
151
  export function debugEntries() {
23
152
  return log;
24
153
  }
25
154
  /** Subscribe to changes. Returns an unsubscribe function. */
26
155
  export function onDebug(fn) {
27
156
  listeners.add(fn);
28
- return () => listeners.delete(fn);
157
+ return () => {
158
+ listeners.delete(fn);
159
+ };
160
+ }
161
+ /** The whole log as plain text, newest first — for Copy and bug reports. */
162
+ export function debugLogText() {
163
+ return log.map((e) => `${e.ts} ${e.type.toUpperCase()} ${e.message}`).join("\n");
164
+ }
165
+ // Auth and funding outcomes are situations, not faults: the service answered
166
+ // correctly and the patron has a step to take (sign in, top up). These SDK
167
+ // error codes read as a notice and stay out of the failure count.
168
+ const NOTICE_CODE = /error_code\\?"\s*:\s*\\?"(npub_missing|proof_missing|proof_required|proof_refresh_needed|dpop_token_missing|oauth_not_yet_authorized|oauth_token_expired|upstream_auth_refresh_needed|insufficient_balance|authority_insufficient_balance|upstream_subscription_required|operator_llm_unfunded)\\?"/i;
169
+ function isFailure(entry) {
170
+ if (entry.type === "error")
171
+ return true;
172
+ if (entry.type !== "result")
173
+ return false;
174
+ const m = entry.message;
175
+ return m.includes('"success":false') || m.includes('"ok":false') || m.includes('"error"') || m.includes("error_code");
176
+ }
177
+ /** How a panel should flag an entry: a fault, a patron's next step, or neither. */
178
+ export function debugSeverity(entry) {
179
+ if (!isFailure(entry))
180
+ return "ok";
181
+ return NOTICE_CODE.test(entry.message) ? "notice" : "failure";
182
+ }
183
+ // ── Page-wide capture ──────────────────────────────────────────────────────
184
+ let captured = false;
185
+ /**
186
+ * Fold uncaught errors and promise rejections into the log, once per page
187
+ * (guarded, so StrictMode and remounts do not double-register).
188
+ */
189
+ export function captureGlobalErrors() {
190
+ if (captured || typeof globalThis.addEventListener !== "function")
191
+ return;
192
+ captured = true;
193
+ globalThis.addEventListener("error", (e) => debugPush("error", `window.error: ${e.message}`));
194
+ globalThis.addEventListener("unhandledrejection", (e) => debugPush("error", `unhandledrejection: ${String(e.reason)}`));
29
195
  }
package/dist/index.d.ts CHANGED
@@ -3,10 +3,12 @@ export { callTool, callToolWithContent, onProofExpired, ProofRequiredError, type
3
3
  export type { ImageBlock } from "./toolResult.ts";
4
4
  export * from "./identity.ts";
5
5
  export * from "./standardTools.ts";
6
- export { clearSessionNsec, getSessionNsec, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.ts";
6
+ export { clearSessionNsec, getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.ts";
7
7
  export { signInlineProof } from "./inlineProof.ts";
8
+ export { sessionKeyClaimVisible } from "./sessionKeyClaim.ts";
9
+ export { handOffMode, shareOrDownload, type ShareOutcome } from "./shareFile.ts";
8
10
  export { canSignFor, isProven, type Claim } from "./signedIn.ts";
9
11
  export { readSignInFailure, type Situation } from "./signInSituation.ts";
10
12
  export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.ts";
11
13
  export * from "./avatar.ts";
12
- export { clearDebug, debugEntries, debugPush, onDebug, type DebugEntry } from "./debugLog.ts";
14
+ export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
package/dist/index.js CHANGED
@@ -2,10 +2,12 @@ export { configureTollbooth, tollboothConfig, toolName } from "./config.js";
2
2
  export { callTool, callToolWithContent, onProofExpired, ProofRequiredError, } from "./client.js";
3
3
  export * from "./identity.js";
4
4
  export * from "./standardTools.js";
5
- export { clearSessionNsec, getSessionNsec, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.js";
5
+ export { clearSessionNsec, getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.js";
6
6
  export { signInlineProof } from "./inlineProof.js";
7
+ export { sessionKeyClaimVisible } from "./sessionKeyClaim.js";
8
+ export { handOffMode, shareOrDownload } from "./shareFile.js";
7
9
  export { canSignFor, isProven } from "./signedIn.js";
8
10
  export { readSignInFailure } from "./signInSituation.js";
9
11
  export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.js";
10
12
  export * from "./avatar.js";
11
- export { clearDebug, debugEntries, debugPush, onDebug } from "./debugLog.js";
13
+ export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
@@ -10,4 +10,4 @@ import { type Kind0, type PublishNostrProfileResult } from "./standardTools.ts";
10
10
  export declare function canSignProfile(): boolean;
11
11
  export declare function fetchProfile(npub: string): Promise<Kind0 | null>;
12
12
  /** Sign a kind-0 in the browser and have the wheel relay it. */
13
- export declare function publishProfile(content: Kind0): Promise<PublishNostrProfileResult>;
13
+ export declare function publishProfile(npub: string, content: Kind0): Promise<PublishNostrProfileResult>;
@@ -6,7 +6,6 @@
6
6
  * hands the signed event over. No nsec ever goes to a server.
7
7
  */
8
8
  import { finalizeEvent } from "nostr-tools";
9
- import { getStoredNpub } from "./identity.js";
10
9
  import { getSessionNsecBytes, hasSessionNsec } from "./sessionNsec.js";
11
10
  import { getNostrProfile, publishNostrProfile, } from "./standardTools.js";
12
11
  function nip07() {
@@ -26,7 +25,7 @@ export async function fetchProfile(npub) {
26
25
  }
27
26
  }
28
27
  /** Sign a kind-0 in the browser and have the wheel relay it. */
29
- export async function publishProfile(content) {
28
+ export async function publishProfile(npub, content) {
30
29
  const clean = {};
31
30
  for (const [k, v] of Object.entries(content)) {
32
31
  if (typeof v === "string" && v.trim())
@@ -50,5 +49,5 @@ export async function publishProfile(content) {
50
49
  else {
51
50
  throw new Error("No signer available — sign in with a session key or a NIP-07 extension to publish your Nostr profile.");
52
51
  }
53
- return publishNostrProfile(getStoredNpub(), JSON.stringify(signed));
52
+ return publishNostrProfile(npub, JSON.stringify(signed));
54
53
  }
@@ -6,7 +6,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  */
7
7
  import { useEffect, useState } from "react";
8
8
  import { Loader2 } from "lucide-react";
9
- import { AVATAR_CHOICES } from "../avatar.js";
9
+ import { avatarChoices } from "../avatar.js";
10
10
  import Avatar from "./Avatar.js";
11
11
  import { errBox, input, muted } from "./ui.js";
12
12
  const COLLECTIONS = [
@@ -60,5 +60,5 @@ export default function AvatarPicker({ value, onChange }) {
60
60
  return (_jsxs("div", { children: [_jsx("div", { className: "flex gap-2 mb-3 text-xs", children: ["catalog", "glyphs"].map((t) => (_jsx("button", { type: "button", onClick: () => setTab(t), className: `px-3 py-1.5 rounded-full transition-colors ${tab === t ? "bg-[var(--tb-accent)] text-[var(--tb-on-accent)]" : `${muted} hover:bg-[var(--tb-surface-2)]`}`, children: t === "catalog" ? "Catalog" : "Glyphs" }, t))) }), tab === "catalog" && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx("select", { value: collection, onChange: (e) => setCollection(e.target.value), className: `${input} py-1.5 text-xs font-sans`, children: COLLECTIONS.map((c) => (_jsx("option", { value: c.prefix, children: c.label }, c.prefix))) }), !loading && !error && names.length > 0 && (_jsxs("span", { className: `text-xs whitespace-nowrap ${muted}`, children: [page + 1, "/", totalPages] }))] }), loading && (_jsxs("p", { className: `flex items-center justify-center gap-1.5 py-6 text-xs ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Loading catalog\u2026"] })), error && _jsxs("div", { className: errBox, children: ["Couldn't reach Iconify: ", error] }), !loading && !error && (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-10 gap-1.5", children: visible.map((name) => {
61
61
  const url = iconUrl(collection, name);
62
62
  return (_jsx("button", { type: "button", onClick: () => onChange(url), title: name.replace(/-/g, " "), className: tile(value === url), children: _jsx("img", { src: url, alt: name, loading: "lazy", className: "h-full w-full object-contain" }) }, name));
63
- }) }), _jsxs("div", { className: "flex items-center justify-center gap-3 mt-3 text-xs", children: [_jsx("button", { type: "button", onClick: () => setPage((p) => Math.max(0, p - 1)), disabled: page === 0, className: "px-3 py-1 rounded-full border border-[var(--tb-line)] disabled:opacity-30", children: "\u2190 Prev" }), _jsx("button", { type: "button", onClick: () => setPage((p) => Math.min(totalPages - 1, p + 1)), disabled: page >= totalPages - 1, className: "px-3 py-1 rounded-full border border-[var(--tb-line)] disabled:opacity-30", children: "Next \u2192" })] })] }))] })), tab === "glyphs" && (_jsx("div", { className: "grid grid-cols-10 gap-1.5", children: AVATAR_CHOICES.map((glyph) => (_jsx("button", { type: "button", onClick: () => onChange(glyph), className: `${tile(value === glyph)} text-lg`, children: glyph }, glyph))) })), _jsxs("details", { className: "mt-3", children: [_jsx("summary", { className: `text-xs uppercase tracking-wider cursor-pointer ${muted}`, children: "Custom (image URL or glyph)" }), _jsx("input", { type: "text", value: value, onChange: (e) => onChange(e.target.value), placeholder: "https://\u2026 or a single emoji", className: `mt-2 ${input} py-1.5 text-xs` })] }), value && (_jsxs("div", { className: "mt-4 flex items-center gap-3", children: [_jsx(Avatar, { value: value, size: 48 }), _jsx("span", { className: `text-xs ${muted}`, children: "Selected \u2014 applies immediately." })] }))] }));
63
+ }) }), _jsxs("div", { className: "flex items-center justify-center gap-3 mt-3 text-xs", children: [_jsx("button", { type: "button", onClick: () => setPage((p) => Math.max(0, p - 1)), disabled: page === 0, className: "px-3 py-1 rounded-full border border-[var(--tb-line)] disabled:opacity-30", children: "\u2190 Prev" }), _jsx("button", { type: "button", onClick: () => setPage((p) => Math.min(totalPages - 1, p + 1)), disabled: page >= totalPages - 1, className: "px-3 py-1 rounded-full border border-[var(--tb-line)] disabled:opacity-30", children: "Next \u2192" })] })] }))] })), tab === "glyphs" && (_jsx("div", { className: "grid grid-cols-10 gap-1.5", children: avatarChoices().map((glyph) => (_jsx("button", { type: "button", onClick: () => onChange(glyph), className: `${tile(value === glyph)} text-lg`, children: glyph }, glyph))) })), _jsxs("details", { className: "mt-3", children: [_jsx("summary", { className: `text-xs uppercase tracking-wider cursor-pointer ${muted}`, children: "Custom (image URL or glyph)" }), _jsx("input", { type: "text", value: value, onChange: (e) => onChange(e.target.value), placeholder: "https://\u2026 or a single emoji", className: `mt-2 ${input} py-1.5 text-xs` })] }), value && (_jsxs("div", { className: "mt-4 flex items-center gap-3", children: [_jsx(Avatar, { value: value, size: 48 }), _jsx("span", { className: `text-xs ${muted}`, children: "Selected \u2014 applies immediately." })] }))] }));
64
64
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The on-screen MCP activity log — a bar fixed to the bottom of the page that
3
+ * shows every call, result and error, so anyone can see what the front end is
4
+ * doing and paste it into a bug report.
5
+ *
6
+ * Collapsed it is one tab whose colour says whether anything failed (red) or
7
+ * is waiting on the patron (a notice: sign in, top up). Open, it lists the log
8
+ * newest first with Copy and Clear. Everything shown comes from the scrubbed
9
+ * store, so no nsec, proof or token can be painted here.
10
+ *
11
+ * `children` is the site's own section — eXcalibur puts its scheduler controls
12
+ * there — drawn above the log while the panel is open.
13
+ *
14
+ * Touch-first: every control is a real button at least 40px square, nothing
15
+ * hides behind hover, and the page under the bar stays tappable.
16
+ */
17
+ import { type ReactNode } from "react";
18
+ export interface DebugPanelProps {
19
+ /** The tab's label. Default "Debug". */
20
+ title?: string;
21
+ /** Open on first visit; afterwards the patron's last choice wins. */
22
+ defaultOpen?: boolean;
23
+ /** Fold uncaught errors and proof expiries into the log. Default true. */
24
+ captureErrors?: boolean;
25
+ /** The site's own section, shown above the log while open. */
26
+ children?: ReactNode;
27
+ }
28
+ export default function DebugPanel({ title, defaultOpen, captureErrors, children, }: DebugPanelProps): import("react").JSX.Element;
@@ -0,0 +1,81 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The on-screen MCP activity log — a bar fixed to the bottom of the page that
4
+ * shows every call, result and error, so anyone can see what the front end is
5
+ * doing and paste it into a bug report.
6
+ *
7
+ * Collapsed it is one tab whose colour says whether anything failed (red) or
8
+ * is waiting on the patron (a notice: sign in, top up). Open, it lists the log
9
+ * newest first with Copy and Clear. Everything shown comes from the scrubbed
10
+ * store, so no nsec, proof or token can be painted here.
11
+ *
12
+ * `children` is the site's own section — eXcalibur puts its scheduler controls
13
+ * there — drawn above the log while the panel is open.
14
+ *
15
+ * Touch-first: every control is a real button at least 40px square, nothing
16
+ * hides behind hover, and the page under the bar stays tappable.
17
+ */
18
+ import { useEffect, useState } from "react";
19
+ import { onProofExpired } from "../client.js";
20
+ import { captureGlobalErrors, clearDebug, debugLogText, debugPush, debugSeverity, } from "../debugLog.js";
21
+ import { readStored, writeStored } from "../storage.js";
22
+ import { useDebugLog } from "./useDebugLog.js";
23
+ const OPEN_KEY = "debug-open";
24
+ const TYPE_INK = {
25
+ info: "text-[var(--tb-accent)]",
26
+ call: "text-[var(--tb-warn-ink)]",
27
+ result: "text-[var(--tb-ok)]",
28
+ error: "text-[var(--tb-err-ink)]",
29
+ };
30
+ const FLAGGED = {
31
+ failure: { row: "bg-[var(--tb-err-bg)]", ink: "text-[var(--tb-err-ink)] font-bold" },
32
+ notice: { row: "bg-[var(--tb-notice-bg)]", ink: "text-[var(--tb-notice-ink)]" },
33
+ };
34
+ const tab = "min-h-10 min-w-10 rounded-t-lg px-4 text-xs font-medium border border-b-0 transition-colors";
35
+ const plainTab = `${tab} bg-[var(--tb-surface-2)] text-[var(--tb-ink)] border-[var(--tb-line)]`;
36
+ export default function DebugPanel({ title = "Debug", defaultOpen = false, captureErrors = true, children, }) {
37
+ const log = useDebugLog();
38
+ const [open, setOpen] = useState(() => {
39
+ const saved = readStored(OPEN_KEY);
40
+ return saved === "" ? defaultOpen : saved === "1";
41
+ });
42
+ const [copied, setCopied] = useState("");
43
+ useEffect(() => {
44
+ if (!captureErrors)
45
+ return;
46
+ captureGlobalErrors();
47
+ return onProofExpired((msg) => debugPush("info", `proof expired → back to sign-in: ${msg}`));
48
+ }, [captureErrors]);
49
+ useEffect(() => {
50
+ if (!copied)
51
+ return;
52
+ const id = setTimeout(() => setCopied(""), 1500);
53
+ return () => clearTimeout(id);
54
+ }, [copied]);
55
+ function toggle() {
56
+ const next = !open;
57
+ setOpen(next);
58
+ writeStored(OPEN_KEY, next ? "1" : "0");
59
+ }
60
+ function copy() {
61
+ const text = debugLogText();
62
+ const clip = globalThis.navigator?.clipboard;
63
+ if (!clip) {
64
+ setCopied("failed");
65
+ return;
66
+ }
67
+ clip.writeText(text).then(() => setCopied("ok"), () => setCopied("failed"));
68
+ }
69
+ const failures = log.filter((e) => debugSeverity(e) === "failure").length;
70
+ const notices = log.filter((e) => debugSeverity(e) === "notice").length;
71
+ const toggleTone = failures > 0
72
+ ? "bg-[var(--tb-err-bg)] text-[var(--tb-err-ink)] border-[var(--tb-err-line)]"
73
+ : notices > 0
74
+ ? "bg-[var(--tb-notice-bg)] text-[var(--tb-notice-ink)] border-[var(--tb-notice-line)]"
75
+ : "bg-[var(--tb-surface-2)] text-[var(--tb-ink)] border-[var(--tb-line)]";
76
+ return (_jsxs("div", { className: "pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end", children: [_jsxs("div", { className: "pointer-events-auto flex flex-wrap justify-end gap-1 pr-3", children: [open && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: copy, className: plainTab, "aria-label": "Copy the whole log", children: copied === "ok" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy" }), _jsx("button", { type: "button", onClick: clearDebug, className: plainTab, children: "Clear" })] })), _jsxs("button", { type: "button", onClick: toggle, "aria-expanded": open, className: `${tab} ${toggleTone}`, children: [open ? "Hide" : title, " (", log.length, failures > 0 ? ` · ${failures} err` : "", notices > 0 ? ` · ${notices} notice` : "", ")"] })] }), open && (_jsxs("div", { className: "pointer-events-auto max-h-[40vh] w-full overflow-y-auto border-t border-[var(--tb-line)] bg-[var(--tb-surface)] p-3 font-mono text-xs text-[var(--tb-ink)]", children: [children && _jsx("div", { className: "mb-2 border-b border-[var(--tb-line)] pb-2 font-sans", children: children }), log.length === 0 && _jsx("div", { className: "text-[var(--tb-muted)]", children: "No MCP activity yet." }), log.map((entry, i) => {
77
+ const sev = debugSeverity(entry);
78
+ const flag = sev === "ok" ? null : FLAGGED[sev];
79
+ return (_jsxs("div", { className: `-mx-1 flex gap-2 rounded px-1 py-0.5 ${flag?.row ?? ""}`, children: [_jsx("span", { className: "shrink-0 text-[var(--tb-muted)]", children: entry.ts }), _jsxs("span", { className: `w-14 shrink-0 ${flag?.ink ?? TYPE_INK[entry.type]}`, children: [sev === "notice" ? "notice" : entry.type, sev === "failure" && entry.type !== "error" ? " !" : ""] }), _jsx("span", { className: `min-w-0 break-all ${flag?.ink ?? ""}`, children: entry.message })] }, log.length - i));
80
+ })] }))] }));
81
+ }
@@ -3,6 +3,9 @@
3
3
  * and avatar. Edits publish a new signed kind-0, visible in every Nostr client.
4
4
  * Without a signer the fields are read-only and the action explains why.
5
5
  */
6
- export default function NostrProfilePanel({ npub }: {
6
+ import type { Kind0 } from "../standardTools.ts";
7
+ export default function NostrProfilePanel({ npub, onPublished, }: {
7
8
  npub: string;
9
+ /** After relays accept the kind-0 — for a site that mirrors the name or avatar elsewhere. */
10
+ onPublished?: (profile: Kind0) => void;
8
11
  }): import("react").JSX.Element;
@@ -20,7 +20,7 @@ const FIELDS = [
20
20
  { key: "website", label: "Website", placeholder: "https://…" },
21
21
  { key: "about", label: "About", placeholder: "A short bio…" },
22
22
  ];
23
- export default function NostrProfilePanel({ npub }) {
23
+ export default function NostrProfilePanel({ npub, onPublished, }) {
24
24
  const { appName } = tollboothConfig();
25
25
  const [profile, setProfile] = useState({});
26
26
  const [loading, setLoading] = useState(true);
@@ -37,6 +37,9 @@ export default function NostrProfilePanel({ npub }) {
37
37
  const explainer = howToSetExplainer(appName);
38
38
  useEffect(() => {
39
39
  let live = true;
40
+ // A new npub starts empty, so no field of the last patron's survives
41
+ // into theirs while the relays are read.
42
+ setProfile({});
40
43
  setLoading(true);
41
44
  fetchProfile(npub)
42
45
  .then((p) => {
@@ -86,7 +89,7 @@ export default function NostrProfilePanel({ npub }) {
86
89
  const picture = profile.picture ?? "";
87
90
  const glyph = picture !== "" && !isAvatarUrl(picture);
88
91
  try {
89
- const r = await publishProfile({
92
+ const r = await publishProfile(npub, {
90
93
  ...profile,
91
94
  name: profile.display_name,
92
95
  // kind-0 picture is a URL; a glyph stays local to this site.
@@ -95,8 +98,10 @@ export default function NostrProfilePanel({ npub }) {
95
98
  const tail = glyph ? " (Emoji avatar kept local — a Nostr picture must be a URL.)" : "";
96
99
  if (r.error)
97
100
  setMsg({ ok: false, text: r.error });
98
- else if ((r.ok ?? 0) > 0)
101
+ else if ((r.ok ?? 0) > 0) {
99
102
  setMsg({ ok: true, text: `Published to ${r.ok}/${r.total} relays.${tail}` });
103
+ onPublished?.(profile);
104
+ }
100
105
  else
101
106
  setMsg({ ok: false, text: `No relay accepted the event.${tail}` });
102
107
  }
@@ -107,10 +112,10 @@ export default function NostrProfilePanel({ npub }) {
107
112
  setPublishing(false);
108
113
  }
109
114
  }
110
- return (_jsxs("div", { className: `${card} p-4`, children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
115
+ return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
111
116
  setExpanded(true);
112
117
  setShowPicker((v) => !v);
113
- }, "aria-label": showPicker ? "Done changing avatar" : "Change avatar", "aria-expanded": showPicker, className: "absolute -bottom-0.5 -right-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border border-[var(--tb-line)] bg-[var(--tb-surface)] shadow-sm", children: _jsx(Camera, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) })] }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("div", { className: "text-sm font-medium truncate", children: collapsedDisplayName(profile.display_name ?? "") }), mode === "how-to-set" && (_jsx("span", { className: `text-[11px] px-1.5 py-0.5 rounded-md bg-[var(--tb-surface-2)] ${muted}`, children: "Read-only" }))] }), _jsx("button", { type: "button", onClick: copyNpub, title: "Copy full npub", className: `mt-0.5 font-mono text-xs ${muted}`, children: copied ? "Copied" : shortNpub(npub) })] }), _jsxs("button", { type: "button", onClick: () => setExpanded((v) => !v), "aria-expanded": expanded, "aria-controls": fieldsId, className: "flex-none inline-flex items-center gap-1 rounded-full border border-[var(--tb-line)] px-2.5 py-1.5 text-xs", children: [expanded ? "Hide" : "Edit", _jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`, "aria-hidden": "true" })] })] }), showPicker && (_jsx("div", { className: "mt-3", children: _jsx(AvatarPicker, { value: profile.picture ?? "", onChange: pickAvatar }) })), loading ? (_jsxs("div", { className: `flex items-center gap-1.5 text-xs py-2 mt-3 ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Reading from relays\u2026"] })) : (_jsxs("div", { id: fieldsId, hidden: !expanded, className: expanded ? "mt-4 space-y-3" : undefined, children: [FIELDS.map((f) => (_jsxs("label", { className: `block text-xs ${muted}`, children: [f.label, f.key === "about" ? (_jsx("textarea", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), rows: 2, className: `mt-1 ${input} font-sans resize-none`, placeholder: f.placeholder, readOnly: mode === "how-to-set" })) : (_jsx("input", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), className: `mt-1 ${input} font-sans`, placeholder: f.placeholder, readOnly: mode === "how-to-set" }))] }, f.key))), msg && (_jsx("div", { className: `text-xs ${msg.ok ? "text-[var(--tb-ok)]" : "text-[var(--tb-err-ink)]"}`, children: msg.text })), _jsxs("div", { className: "relative flex items-center gap-3", ref: howToRef, children: [_jsx("button", { type: "button", onClick: () => (mode === "how-to-set" ? setShowHowTo((v) => !v) : void publish()), disabled: publishControlDisabled(mode, publishing), "aria-haspopup": mode === "how-to-set" ? "dialog" : undefined, "aria-expanded": mode === "how-to-set" ? showHowTo : undefined, className: mode === "publish"
118
+ }, "aria-label": showPicker ? "Done changing avatar" : "Change avatar", "aria-expanded": showPicker, className: "absolute -bottom-0.5 -right-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border border-[var(--tb-line)] bg-[var(--tb-surface)] shadow-sm", children: _jsx(Camera, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) })] }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("div", { className: "text-sm font-medium truncate", children: collapsedDisplayName(profile.display_name ?? "") }), mode === "how-to-set" && (_jsx("span", { className: `text-[11px] px-1.5 py-0.5 rounded-md bg-[var(--tb-surface-2)] ${muted}`, children: "Read-only" }))] }), _jsx("button", { type: "button", onClick: copyNpub, title: "Copy full npub", className: `mt-0.5 font-mono text-xs ${muted}`, children: copied ? "Copied" : shortNpub(npub) })] }), _jsxs("button", { type: "button", onClick: () => setExpanded((v) => !v), "aria-expanded": expanded, "aria-controls": fieldsId, className: "flex-none inline-flex items-center gap-1 rounded-full border border-[var(--tb-line)] px-2.5 py-1.5 text-xs", children: [expanded ? "Hide" : "Edit", _jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`, "aria-hidden": "true" })] })] }), showPicker && (_jsx("div", { className: "mt-3", children: _jsx(AvatarPicker, { value: profile.picture ?? "", onChange: pickAvatar }) })), loading ? (_jsxs("div", { className: `flex items-center gap-1.5 text-xs py-2 mt-3 ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Reading from relays\u2026"] })) : (_jsxs("div", { id: fieldsId, hidden: !expanded, className: expanded ? "mt-3 grid gap-x-3 gap-y-2 sm:grid-cols-2" : undefined, children: [FIELDS.map((f) => (_jsxs("label", { className: `block text-xs ${muted} ${f.key === "about" ? "sm:col-span-2" : ""}`, children: [f.label, f.key === "about" ? (_jsx("textarea", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), rows: 2, className: `mt-1 ${input} font-sans resize-none`, placeholder: f.placeholder, readOnly: mode === "how-to-set" })) : (_jsx("input", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), className: `mt-1 ${input} font-sans`, placeholder: f.placeholder, readOnly: mode === "how-to-set" }))] }, f.key))), msg && (_jsx("div", { className: `text-xs sm:col-span-2 ${msg.ok ? "text-[var(--tb-ok)]" : "text-[var(--tb-err-ink)]"}`, children: msg.text })), _jsxs("div", { className: "relative flex items-center gap-3 sm:col-span-2", ref: howToRef, children: [_jsx("button", { type: "button", onClick: () => (mode === "how-to-set" ? setShowHowTo((v) => !v) : void publish()), disabled: publishControlDisabled(mode, publishing), "aria-haspopup": mode === "how-to-set" ? "dialog" : undefined, "aria-expanded": mode === "how-to-set" ? showHowTo : undefined, className: mode === "publish"
114
119
  ? "rounded-full bg-[var(--tb-accent)] px-4 py-2 text-sm font-medium text-[var(--tb-on-accent)] disabled:opacity-40"
115
120
  : "rounded-full border border-[var(--tb-line)] px-4 py-2 text-sm", children: publishControlLabel(mode, publishing) }), showHowTo && mode === "how-to-set" && (_jsxs("div", { role: "dialog", "aria-modal": "false", "aria-labelledby": explainerId, className: `${card} absolute left-0 bottom-full z-20 mb-2 w-[min(100%,22rem)] p-3 shadow-lg`, children: [_jsxs("div", { className: "flex items-start justify-between gap-2 mb-2", children: [_jsx("div", { id: explainerId, className: "text-sm font-medium", children: explainer.title }), _jsx("button", { type: "button", onClick: () => setShowHowTo(false), "aria-label": "Close", className: muted, children: _jsx(X, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) })] }), _jsx("div", { className: `space-y-2 text-xs leading-relaxed ${muted}`, children: explainer.paragraphs.map((p) => (_jsx("p", { children: p }, p.slice(0, 24)))) })] }))] })] }))] }));
116
121
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Claim your session key — for a patron whose browser holds their nsec.
3
+ *
4
+ * Renders ONLY when this browser holds the session nsec for the signed-in
5
+ * npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
6
+ * no placeholder, no mention. Reveal takes a deliberate second step so an
7
+ * account page opened in front of other people does not paint the key.
8
+ *
9
+ * Destinations: copy, download .env, password-manager prompt, and an
10
+ * encrypted DM to another npub the patron names. The nsec never reaches a
11
+ * log, error text, analytics call, or MCP tool argument.
12
+ */
13
+ export default function SessionKeyClaim({ npub }: {
14
+ npub: string;
15
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,212 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Claim your session key — for a patron whose browser holds their nsec.
4
+ *
5
+ * Renders ONLY when this browser holds the session nsec for the signed-in
6
+ * npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
7
+ * no placeholder, no mention. Reveal takes a deliberate second step so an
8
+ * account page opened in front of other people does not paint the key.
9
+ *
10
+ * Destinations: copy, download .env, password-manager prompt, and an
11
+ * encrypted DM to another npub the patron names. The nsec never reaches a
12
+ * log, error text, analytics call, or MCP tool argument.
13
+ */
14
+ import { useRef, useState } from "react";
15
+ import { tollboothConfig } from "../config.js";
16
+ import { passwordManagerOutcomeLabel, sessionKeyClaimVisible, sessionKeyEnvContents, sessionKeyEnvFilename, } from "../sessionKeyClaim.js";
17
+ import { sendSessionKeyDm } from "../sessionKeyDm.js";
18
+ import { getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, } from "../sessionNsec.js";
19
+ import { shareOrDownload } from "../shareFile.js";
20
+ import { card, input, muted } from "./ui.js";
21
+ export default function SessionKeyClaim({ npub }) {
22
+ const visible = sessionKeyClaimVisible({
23
+ hasSessionNsec: hasSessionNsec(),
24
+ sessionNsecNpub: sessionNsecNpub(),
25
+ signedInNpub: npub,
26
+ });
27
+ // Hard requirement: silence when the browser does not hold the key.
28
+ if (!visible)
29
+ return null;
30
+ return _jsx(SessionKeyClaimInner, {});
31
+ }
32
+ function SessionKeyClaimInner() {
33
+ const { slug, appName } = tollboothConfig();
34
+ const envFilename = sessionKeyEnvFilename(slug);
35
+ const [revealed, setRevealed] = useState(false);
36
+ const [showKey, setShowKey] = useState(false);
37
+ const [copied, setCopied] = useState(false);
38
+ const [dmOpen, setDmOpen] = useState(false);
39
+ const [recipient, setRecipient] = useState("");
40
+ const [busy, setBusy] = useState(false);
41
+ const [flash, setFlash] = useState(null);
42
+ const formRef = useRef(null);
43
+ const userRef = useRef(null);
44
+ const passRef = useRef(null);
45
+ function nsecOrThrow() {
46
+ const nsec = getSessionNsec();
47
+ if (!nsec)
48
+ throw new Error("No session key in this browser.");
49
+ return nsec;
50
+ }
51
+ function note(tone, text) {
52
+ // Deliberately never interpolate the nsec into flash text.
53
+ setFlash({ tone, text });
54
+ }
55
+ async function copyKey() {
56
+ setBusy(true);
57
+ setFlash(null);
58
+ try {
59
+ const nsec = nsecOrThrow();
60
+ await navigator.clipboard.writeText(nsec);
61
+ setCopied(true);
62
+ window.setTimeout(() => setCopied(false), 1500);
63
+ note("ok", "Copied. It is on your clipboard only — paste it somewhere you trust.");
64
+ }
65
+ catch {
66
+ note("err", "Could not copy. Your browser may have blocked the clipboard.");
67
+ }
68
+ finally {
69
+ setBusy(false);
70
+ }
71
+ }
72
+ async function downloadEnv() {
73
+ setBusy(true);
74
+ setFlash(null);
75
+ try {
76
+ const nsec = nsecOrThrow();
77
+ const body = sessionKeyEnvContents(slug, nsec);
78
+ const file = new File([body], envFilename, {
79
+ type: "text/plain",
80
+ });
81
+ const outcome = await shareOrDownload(file, `${appName} session key`);
82
+ if (outcome === "cancelled") {
83
+ note("ok", "Share cancelled — nothing left this page.");
84
+ }
85
+ else if (outcome === "blocked") {
86
+ note("err", "The share sheet needs a fresh tap. Try Download again.");
87
+ }
88
+ else if (outcome === "shared") {
89
+ note("ok", "Handed off via the share sheet.");
90
+ }
91
+ else {
92
+ note("ok", `Downloaded ${envFilename}. Import it yourself — the page did not write to any keychain.`);
93
+ }
94
+ }
95
+ catch {
96
+ note("err", "Could not prepare the download.");
97
+ }
98
+ finally {
99
+ setBusy(false);
100
+ }
101
+ }
102
+ async function passwordManagerSave() {
103
+ setBusy(true);
104
+ setFlash(null);
105
+ try {
106
+ const nsec = nsecOrThrow();
107
+ const outcome = await offerPasswordManager(nsec, npubHint(), `${appName} session key`, formRef.current, userRef.current, passRef.current);
108
+ note("ok", passwordManagerOutcomeLabel(outcome));
109
+ }
110
+ catch {
111
+ note("err", passwordManagerOutcomeLabel("unsupported"));
112
+ }
113
+ finally {
114
+ setBusy(false);
115
+ }
116
+ }
117
+ function npubHint() {
118
+ // Username for a password manager entry — the public npub, never secret.
119
+ return sessionNsecNpub() ?? `${slug}-session`;
120
+ }
121
+ async function sendDm() {
122
+ setBusy(true);
123
+ setFlash(null);
124
+ try {
125
+ const nsec = nsecOrThrow();
126
+ const to = recipient.trim();
127
+ if (!to.startsWith("npub1") || to.length < 60) {
128
+ note("err", "Enter the recipient as an npub1… address.");
129
+ return;
130
+ }
131
+ const result = await sendSessionKeyDm({
132
+ appName,
133
+ nsecBytes: getSessionNsecBytes(),
134
+ nsecBech32: nsec,
135
+ recipientNpub: to,
136
+ });
137
+ if (result.ok > 0) {
138
+ note("ok", `Encrypted DM accepted by ${result.ok}/${result.total} relays. Open it from the recipient’s Nostr client.`);
139
+ setDmOpen(false);
140
+ setRecipient("");
141
+ }
142
+ else {
143
+ note("err", "No relay accepted the encrypted DM. Try again in a moment.");
144
+ }
145
+ }
146
+ catch (e) {
147
+ // Message must stay free of the nsec — our thrown errors already are.
148
+ const msg = e.message || "Could not send the DM.";
149
+ note("err", msg.includes("nsec") ? "Could not send the DM." : msg);
150
+ }
151
+ finally {
152
+ setBusy(false);
153
+ }
154
+ }
155
+ if (!revealed) {
156
+ return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), 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)]", children: "Claim your session key" })] })] }));
157
+ }
158
+ return (_jsxs("div", { className: `relative ${card} px-4 py-3`, children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
159
+ setRevealed(false);
160
+ setShowKey(false);
161
+ setDmOpen(false);
162
+ setFlash(null);
163
+ setRecipient("");
164
+ }, 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, "."] }), showKey ? (_jsx("div", { className: "mb-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() })) : (_jsx("button", { type: "button", onClick: () => setShowKey(true), className: `mb-3 text-[12.5px] ${muted} underline-offset-2 hover:text-[var(--tb-ink)] hover:underline`, children: "Show the key on screen" })), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_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: () => {
165
+ setDmOpen((v) => !v);
166
+ setFlash(null);
167
+ }, disabled: busy, active: dmOpen, children: "Send by DM" })] }), 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) => {
168
+ e.preventDefault();
169
+ }, 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 }))] }));
170
+ }
171
+ function DestButton({ onClick, disabled, active, children, }) {
172
+ 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
+ ? "border-[var(--tb-accent)] bg-[var(--tb-accent)] text-[var(--tb-on-accent)]"
174
+ : `border-[var(--tb-line)] ${muted} active:bg-[var(--tb-surface-2)]`}`, children: children }));
175
+ }
176
+ /**
177
+ * Offer the key to a browser password manager. Never claims an OS keychain.
178
+ * Uses PasswordCredential when available; otherwise fills a hidden form and
179
+ * submits it so the browser can offer a save prompt.
180
+ */
181
+ async function offerPasswordManager(nsec, username, name, form, user, pass) {
182
+ const Cred = window
183
+ .PasswordCredential;
184
+ if (Cred && navigator.credentials?.store) {
185
+ try {
186
+ const cred = new Cred({
187
+ id: username,
188
+ password: nsec,
189
+ name,
190
+ });
191
+ await navigator.credentials.store(cred);
192
+ return "stored";
193
+ }
194
+ catch {
195
+ // Fall through to the form prompt.
196
+ }
197
+ }
198
+ // Form-based prompt — best-effort. Many browsers ignore programmatic submit
199
+ // for save prompts; we still report honestly that we only prompted.
200
+ if (form && user && pass) {
201
+ user.value = username;
202
+ pass.value = nsec;
203
+ if (typeof form.requestSubmit === "function")
204
+ form.requestSubmit();
205
+ else
206
+ form.submit();
207
+ // Clear the DOM value immediately so a later inspect does not show it.
208
+ pass.value = "";
209
+ return "prompted";
210
+ }
211
+ return "unsupported";
212
+ }
@@ -1,6 +1,9 @@
1
1
  export { default as Avatar } from "./Avatar.tsx";
2
2
  export { default as AvatarPicker } from "./AvatarPicker.tsx";
3
+ export { default as DebugPanel, type DebugPanelProps } from "./DebugPanel.tsx";
3
4
  export { default as NpubGate } from "./NpubGate.tsx";
4
5
  export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
6
+ export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
5
7
  export { default as WalletCard } from "./WalletCard.tsx";
6
8
  export { useSession, type Session } from "./useSession.ts";
9
+ export { useDebugLog } from "./useDebugLog.ts";
@@ -1,6 +1,9 @@
1
1
  export { default as Avatar } from "./Avatar.js";
2
2
  export { default as AvatarPicker } from "./AvatarPicker.js";
3
+ export { default as DebugPanel } from "./DebugPanel.js";
3
4
  export { default as NpubGate } from "./NpubGate.js";
4
5
  export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
6
+ export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
5
7
  export { default as WalletCard } from "./WalletCard.js";
6
8
  export { useSession } from "./useSession.js";
9
+ export { useDebugLog } from "./useDebugLog.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The debug log's entries, newest first, re-rendering whenever they change.
3
+ * Entries are already scrubbed of secrets by `debugPush`.
4
+ */
5
+ import { type DebugEntry } from "../debugLog.ts";
6
+ export declare function useDebugLog(): readonly DebugEntry[];
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The debug log's entries, newest first, re-rendering whenever they change.
3
+ * Entries are already scrubbed of secrets by `debugPush`.
4
+ */
5
+ import { useSyncExternalStore } from "react";
6
+ import { debugEntries, onDebug } from "../debugLog.js";
7
+ export function useDebugLog() {
8
+ return useSyncExternalStore(onDebug, debugEntries, debugEntries);
9
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Claiming the session key — the pure rules behind the SessionKeyClaim card.
3
+ *
4
+ * The hard requirement: show nothing unless THIS browser holds the key for
5
+ * the npub that is signed in. A patron on NIP-07 or a courier proof must
6
+ * never see a claim that the site holds their key.
7
+ *
8
+ * A patron exporting their OWN key is self-addressed by construction; what
9
+ * the ecosystem forbids is a service leaking keys, not a person claiming one.
10
+ */
11
+ /** True only when the browser holds the session nsec for the signed-in npub. */
12
+ export declare function sessionKeyClaimVisible(opts: {
13
+ hasSessionNsec: boolean;
14
+ sessionNsecNpub: string | null;
15
+ signedInNpub: string;
16
+ }): boolean;
17
+ /** The variable a site's key goes under, e.g. "GOODEARTH_NSEC". */
18
+ export declare function sessionKeyEnvName(slug: string): string;
19
+ /** Contents of the downloadable `.env` hand-off. */
20
+ export declare function sessionKeyEnvContents(slug: string, nsec: string): string;
21
+ /** Suggested filename for the `.env` hand-off, e.g. "goodearth-nsec.env". */
22
+ export declare function sessionKeyEnvFilename(slug: string): string;
23
+ /**
24
+ * What the password-manager destination may honestly report.
25
+ * A web page cannot write to an OS keychain; say what actually happened.
26
+ */
27
+ export type PasswordManagerOutcome = "stored" | "prompted" | "unsupported";
28
+ export declare function passwordManagerOutcomeLabel(o: PasswordManagerOutcome): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Claiming the session key — the pure rules behind the SessionKeyClaim card.
3
+ *
4
+ * The hard requirement: show nothing unless THIS browser holds the key for
5
+ * the npub that is signed in. A patron on NIP-07 or a courier proof must
6
+ * never see a claim that the site holds their key.
7
+ *
8
+ * A patron exporting their OWN key is self-addressed by construction; what
9
+ * the ecosystem forbids is a service leaking keys, not a person claiming one.
10
+ */
11
+ /** True only when the browser holds the session nsec for the signed-in npub. */
12
+ export function sessionKeyClaimVisible(opts) {
13
+ const signedIn = opts.signedInNpub.trim();
14
+ if (!signedIn || !opts.hasSessionNsec)
15
+ return false;
16
+ const held = opts.sessionNsecNpub?.trim() ?? "";
17
+ return held !== "" && held === signedIn;
18
+ }
19
+ /** The variable a site's key goes under, e.g. "GOODEARTH_NSEC". */
20
+ export function sessionKeyEnvName(slug) {
21
+ return `${slug.toUpperCase()}_NSEC`;
22
+ }
23
+ /** Contents of the downloadable `.env` hand-off. */
24
+ export function sessionKeyEnvContents(slug, nsec) {
25
+ return `${sessionKeyEnvName(slug)}=${nsec}\n`;
26
+ }
27
+ /** Suggested filename for the `.env` hand-off, e.g. "goodearth-nsec.env". */
28
+ export function sessionKeyEnvFilename(slug) {
29
+ return `${slug}-nsec.env`;
30
+ }
31
+ export function passwordManagerOutcomeLabel(o) {
32
+ switch (o) {
33
+ case "stored":
34
+ return "Offered to your browser’s password manager.";
35
+ case "prompted":
36
+ return "Submitted so your browser’s password manager can offer to save it. Nothing was written to an OS keychain — a web page cannot do that.";
37
+ case "unsupported":
38
+ return "This browser has no password-manager save path the page can reach. Download the .env file or copy the key instead.";
39
+ }
40
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Ship the browser-held session nsec to another npub the patron names.
3
+ *
4
+ * Client-side only: encrypt (NIP-17 gift wrap over NIP-44), sign, and publish
5
+ * the wrap to public relays. The plaintext nsec never enters an MCP tool
6
+ * argument, a log line, or an error message — only ciphertext leaves the page.
7
+ *
8
+ * Why not the wheel's relay transport: publish_nostr_profile accepts kind-0
9
+ * only, and there is no patron-facing generic "publish this signed event"
10
+ * tool in the wheel. Encryption must happen here anyway (the nsec is the
11
+ * sender key and the payload); relay fan-out of an already-signed kind-1059
12
+ * is the remaining hop, done with nostr-tools' SimplePool.
13
+ */
14
+ /** Public write relays used for the gift-wrapped DM fan-out. */
15
+ export declare const SESSION_KEY_DM_RELAYS: readonly ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub"];
16
+ /** Plaintext body of the DM. Kept short; the nsec is the payload. */
17
+ export declare function sessionKeyDmBody(appName: string, nsec: string): string;
18
+ export declare function recipientPubkeyHex(npubBech32: string): string;
19
+ export interface SessionKeyDmResult {
20
+ ok: number;
21
+ total: number;
22
+ /** Relay URLs that accepted the wrap — never includes plaintext. */
23
+ accepted: string[];
24
+ /** Short per-relay failure notes — never includes plaintext. */
25
+ errors: string[];
26
+ }
27
+ /**
28
+ * Gift-wrap `nsecBech32` to `recipientNpub` and publish the kind-1059 wrap.
29
+ * Throws on bad input; relay failures are reported in the result, not thrown.
30
+ */
31
+ export declare function sendSessionKeyDm(opts: {
32
+ appName: string;
33
+ nsecBytes: Uint8Array;
34
+ nsecBech32: string;
35
+ recipientNpub: string;
36
+ relays?: readonly string[];
37
+ /** Injected for tests — real path builds a SimplePool. */
38
+ publish?: (wrap: {
39
+ id: string;
40
+ }, relays: readonly string[]) => Promise<SessionKeyDmResult>;
41
+ }): Promise<SessionKeyDmResult>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Ship the browser-held session nsec to another npub the patron names.
3
+ *
4
+ * Client-side only: encrypt (NIP-17 gift wrap over NIP-44), sign, and publish
5
+ * the wrap to public relays. The plaintext nsec never enters an MCP tool
6
+ * argument, a log line, or an error message — only ciphertext leaves the page.
7
+ *
8
+ * Why not the wheel's relay transport: publish_nostr_profile accepts kind-0
9
+ * only, and there is no patron-facing generic "publish this signed event"
10
+ * tool in the wheel. Encryption must happen here anyway (the nsec is the
11
+ * sender key and the payload); relay fan-out of an already-signed kind-1059
12
+ * is the remaining hop, done with nostr-tools' SimplePool.
13
+ */
14
+ import { nip19 } from "nostr-tools";
15
+ import { wrapEvent } from "nostr-tools/nip17";
16
+ import { SimplePool } from "nostr-tools/pool";
17
+ /** Public write relays used for the gift-wrapped DM fan-out. */
18
+ export const SESSION_KEY_DM_RELAYS = [
19
+ "wss://relay.damus.io",
20
+ "wss://nos.lol",
21
+ "wss://relay.primal.net",
22
+ "wss://offchain.pub",
23
+ ];
24
+ /** Plaintext body of the DM. Kept short; the nsec is the payload. */
25
+ export function sessionKeyDmBody(appName, nsec) {
26
+ return [
27
+ `Your ${appName} session key (nsec).`,
28
+ "Anyone who holds it is you — import it only into a client you control.",
29
+ "",
30
+ nsec,
31
+ ].join("\n");
32
+ }
33
+ export function recipientPubkeyHex(npubBech32) {
34
+ const trimmed = npubBech32.trim();
35
+ if (!trimmed.startsWith("npub1")) {
36
+ throw new Error("Recipient must be an npub1… address.");
37
+ }
38
+ const decoded = nip19.decode(trimmed);
39
+ if (decoded.type !== "npub") {
40
+ throw new Error("Recipient must be an npub1… address.");
41
+ }
42
+ // nostr-tools v2 returns the 32-byte x-only pubkey as a hex string.
43
+ const data = decoded.data;
44
+ if (typeof data === "string" && /^[0-9a-f]{64}$/i.test(data))
45
+ return data;
46
+ throw new Error("Could not read that npub.");
47
+ }
48
+ /**
49
+ * Gift-wrap `nsecBech32` to `recipientNpub` and publish the kind-1059 wrap.
50
+ * Throws on bad input; relay failures are reported in the result, not thrown.
51
+ */
52
+ export async function sendSessionKeyDm(opts) {
53
+ const recipient = recipientPubkeyHex(opts.recipientNpub);
54
+ const body = sessionKeyDmBody(opts.appName, opts.nsecBech32);
55
+ // NIP-17: kind-14 rumor sealed + gift-wrapped to the recipient.
56
+ const wrap = wrapEvent(opts.nsecBytes, { publicKey: recipient }, body);
57
+ const relays = opts.relays ?? SESSION_KEY_DM_RELAYS;
58
+ if (opts.publish)
59
+ return opts.publish(wrap, relays);
60
+ const pool = new SimplePool();
61
+ const accepted = [];
62
+ const errors = [];
63
+ try {
64
+ const pubs = pool.publish([...relays], wrap);
65
+ const settled = await Promise.allSettled(pubs);
66
+ settled.forEach((r, i) => {
67
+ const url = relays[i] ?? `relay-${i}`;
68
+ if (r.status === "fulfilled")
69
+ accepted.push(url);
70
+ else
71
+ errors.push(`${url}: refused`);
72
+ });
73
+ }
74
+ finally {
75
+ try {
76
+ pool.close([...relays]);
77
+ }
78
+ catch {
79
+ /* pool already quiet */
80
+ }
81
+ }
82
+ return { ok: accepted.length, total: relays.length, accepted, errors };
83
+ }
@@ -0,0 +1,6 @@
1
+ export type ShareOutcome = "shared" | "downloaded" | "cancelled" | "blocked";
2
+ export declare function handOffMode(o: {
3
+ canShareFiles: boolean;
4
+ coarsePointer: boolean;
5
+ }): "share" | "download";
6
+ export declare function shareOrDownload(file: File, title: string): Promise<ShareOutcome>;
@@ -0,0 +1,50 @@
1
+ // Hand a file to the person holding the device.
2
+ //
3
+ // The share sheet on a touch-first device that has one for files — a tablet or
4
+ // a phone, where a "download" lands in a folder nobody opens. A download
5
+ // everywhere else.
6
+ //
7
+ // Desktop is decided by the POINTER, not by whether the browser offers a share
8
+ // sheet. Chrome and Edge on Windows 11 do offer one for files, and following it
9
+ // is why a tester's Export "did nothing": the bundle takes several calls to
10
+ // assemble, the click that asked for it has expired by then, the sheet is
11
+ // refused, and the page waited for a second click on a button whose changed
12
+ // label a desktop user never notices. The Windows sheet offers Mail and Nearby
13
+ // Share anyway, not "save this file", which is what a desktop user wanted.
14
+ //
15
+ // "blocked" is still its own answer on a tablet. Safari opens the sheet only
16
+ // from a fresh tap, so the caller keeps the file and asks for one more tap,
17
+ // which the sheet then accepts.
18
+ /// The share sheet only on a touch-first device that can share files.
19
+ export function handOffMode(o) {
20
+ return o.canShareFiles && o.coarsePointer ? "share" : "download";
21
+ }
22
+ export async function shareOrDownload(file, title) {
23
+ const nav = navigator;
24
+ const canShareFiles = !!nav.share && !!nav.canShare?.({ files: [file] });
25
+ const coarsePointer = typeof window.matchMedia === "function"
26
+ && window.matchMedia("(pointer: coarse)").matches;
27
+ if (handOffMode({ canShareFiles, coarsePointer }) === "share") {
28
+ try {
29
+ await nav.share({ files: [file], title });
30
+ return "shared";
31
+ }
32
+ catch (e) {
33
+ const name = e.name;
34
+ if (name === "AbortError")
35
+ return "cancelled";
36
+ if (name === "NotAllowedError")
37
+ return "blocked";
38
+ // Anything else: the sheet is broken here, so download instead.
39
+ }
40
+ }
41
+ const url = URL.createObjectURL(file);
42
+ const a = document.createElement("a");
43
+ a.href = url;
44
+ a.download = file.name;
45
+ document.body.appendChild(a);
46
+ a.click();
47
+ a.remove();
48
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
49
+ return "downloaded";
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -54,7 +54,6 @@
54
54
  "typescript": "^7.0.2"
55
55
  },
56
56
  "publishConfig": {
57
- "access": "public",
58
- "provenance": true
57
+ "access": "public"
59
58
  }
60
59
  }
package/theme.css CHANGED
@@ -25,6 +25,9 @@
25
25
  --tb-err-bg: #fdecec;
26
26
  --tb-err-line: #f2b8b8;
27
27
  --tb-err-ink: #9b1c1c;
28
+ --tb-notice-bg: #f4ecfd;
29
+ --tb-notice-line: #d6bdf5;
30
+ --tb-notice-ink: #5b2a91;
28
31
  }
29
32
 
30
33
  @media (prefers-color-scheme: dark) {
@@ -43,5 +46,8 @@
43
46
  --tb-err-bg: #2c1515;
44
47
  --tb-err-line: #5e2525;
45
48
  --tb-err-ink: #f4a3a3;
49
+ --tb-notice-bg: #241532;
50
+ --tb-notice-line: #4d2d6e;
51
+ --tb-notice-ink: #d8b4fe;
46
52
  }
47
53
  }