@tollbooth-dpyc/web 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +69 -0
  3. package/dist/avatar.d.ts +20 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/client.d.ts +42 -0
  6. package/dist/client.js +169 -0
  7. package/dist/config.d.ts +39 -0
  8. package/dist/config.js +33 -0
  9. package/dist/debugLog.d.ts +18 -0
  10. package/dist/debugLog.js +29 -0
  11. package/dist/identity.d.ts +43 -0
  12. package/dist/identity.js +106 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.js +11 -0
  15. package/dist/inlineProof.d.ts +10 -0
  16. package/dist/inlineProof.js +21 -0
  17. package/dist/nostrProfile.d.ts +13 -0
  18. package/dist/nostrProfile.js +54 -0
  19. package/dist/pagesProxy.d.ts +22 -0
  20. package/dist/pagesProxy.js +54 -0
  21. package/dist/profilePresentation.d.ts +17 -0
  22. package/dist/profilePresentation.js +32 -0
  23. package/dist/react/Avatar.d.ts +6 -0
  24. package/dist/react/Avatar.js +7 -0
  25. package/dist/react/AvatarPicker.d.ts +9 -0
  26. package/dist/react/AvatarPicker.js +64 -0
  27. package/dist/react/NostrProfilePanel.d.ts +8 -0
  28. package/dist/react/NostrProfilePanel.js +116 -0
  29. package/dist/react/NpubGate.d.ts +16 -0
  30. package/dist/react/NpubGate.js +159 -0
  31. package/dist/react/WalletCard.d.ts +11 -0
  32. package/dist/react/WalletCard.js +82 -0
  33. package/dist/react/index.d.ts +6 -0
  34. package/dist/react/index.js +6 -0
  35. package/dist/react/ui.d.ts +9 -0
  36. package/dist/react/ui.js +9 -0
  37. package/dist/react/useSession.d.ts +19 -0
  38. package/dist/react/useSession.js +39 -0
  39. package/dist/sessionNsec.d.ts +23 -0
  40. package/dist/sessionNsec.js +59 -0
  41. package/dist/signInSituation.d.ts +22 -0
  42. package/dist/signInSituation.js +42 -0
  43. package/dist/signedIn.d.ts +26 -0
  44. package/dist/signedIn.js +26 -0
  45. package/dist/standardTools.d.ts +134 -0
  46. package/dist/standardTools.js +68 -0
  47. package/dist/storage.d.ts +14 -0
  48. package/dist/storage.js +46 -0
  49. package/dist/toolResult.d.ts +37 -0
  50. package/dist/toolResult.js +60 -0
  51. package/package.json +60 -0
  52. package/theme.css +47 -0
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The patron's Nostr kind-0 profile — the self-sovereign home of their name
4
+ * and avatar. Edits publish a new signed kind-0, visible in every Nostr client.
5
+ * Without a signer the fields are read-only and the action explains why.
6
+ */
7
+ import { useEffect, useId, useRef, useState } from "react";
8
+ import { Camera, ChevronDown, Loader2, X } from "lucide-react";
9
+ import { avatarFor, isAvatarUrl, setStoredAvatar, shortNpub } from "../avatar.js";
10
+ import { tollboothConfig } from "../config.js";
11
+ import { canSignProfile, fetchProfile, publishProfile } from "../nostrProfile.js";
12
+ import { collapsedDisplayName, howToSetExplainer, publishControlDisabled, publishControlLabel, publishControlMode, } from "../profilePresentation.js";
13
+ import Avatar from "./Avatar.js";
14
+ import AvatarPicker from "./AvatarPicker.js";
15
+ import { card, input, muted } from "./ui.js";
16
+ const FIELDS = [
17
+ { key: "display_name", label: "Display name", placeholder: "Satoshi" },
18
+ { key: "lud16", label: "Lightning address (lud16)", placeholder: "you@walletofsatoshi.com" },
19
+ { key: "nip05", label: "NIP-05", placeholder: "name@domain.com" },
20
+ { key: "website", label: "Website", placeholder: "https://…" },
21
+ { key: "about", label: "About", placeholder: "A short bio…" },
22
+ ];
23
+ export default function NostrProfilePanel({ npub }) {
24
+ const { appName } = tollboothConfig();
25
+ const [profile, setProfile] = useState({});
26
+ const [loading, setLoading] = useState(true);
27
+ const [expanded, setExpanded] = useState(false);
28
+ const [showPicker, setShowPicker] = useState(false);
29
+ const [showHowTo, setShowHowTo] = useState(false);
30
+ const [publishing, setPublishing] = useState(false);
31
+ const [msg, setMsg] = useState(null);
32
+ const [copied, setCopied] = useState(false);
33
+ const fieldsId = useId();
34
+ const explainerId = useId();
35
+ const howToRef = useRef(null);
36
+ const mode = publishControlMode(canSignProfile());
37
+ const explainer = howToSetExplainer(appName);
38
+ useEffect(() => {
39
+ let live = true;
40
+ setLoading(true);
41
+ fetchProfile(npub)
42
+ .then((p) => {
43
+ if (!live || !p)
44
+ return;
45
+ setProfile({ ...p, display_name: p.display_name || p.name || "" });
46
+ if (p.picture)
47
+ setStoredAvatar(npub, p.picture);
48
+ })
49
+ .finally(() => live && setLoading(false));
50
+ return () => {
51
+ live = false;
52
+ };
53
+ }, [npub]);
54
+ // Dismiss on outside tap or Escape — touch friendly, no hover.
55
+ useEffect(() => {
56
+ if (!showHowTo)
57
+ return;
58
+ const onPointer = (e) => {
59
+ if (e.target instanceof Node && !howToRef.current?.contains(e.target))
60
+ setShowHowTo(false);
61
+ };
62
+ const onKey = (e) => e.key === "Escape" && setShowHowTo(false);
63
+ document.addEventListener("mousedown", onPointer);
64
+ document.addEventListener("touchstart", onPointer);
65
+ document.addEventListener("keydown", onKey);
66
+ return () => {
67
+ document.removeEventListener("mousedown", onPointer);
68
+ document.removeEventListener("touchstart", onPointer);
69
+ document.removeEventListener("keydown", onKey);
70
+ };
71
+ }, [showHowTo]);
72
+ const set = (k, v) => setProfile((p) => ({ ...p, [k]: v }));
73
+ function pickAvatar(v) {
74
+ set("picture", v);
75
+ setStoredAvatar(npub, v);
76
+ }
77
+ function copyNpub() {
78
+ navigator.clipboard?.writeText(npub).then(() => {
79
+ setCopied(true);
80
+ setTimeout(() => setCopied(false), 1500);
81
+ }, () => { });
82
+ }
83
+ async function publish() {
84
+ setPublishing(true);
85
+ setMsg(null);
86
+ const picture = profile.picture ?? "";
87
+ const glyph = picture !== "" && !isAvatarUrl(picture);
88
+ try {
89
+ const r = await publishProfile({
90
+ ...profile,
91
+ name: profile.display_name,
92
+ // kind-0 picture is a URL; a glyph stays local to this site.
93
+ picture: glyph ? "" : picture,
94
+ });
95
+ const tail = glyph ? " (Emoji avatar kept local — a Nostr picture must be a URL.)" : "";
96
+ if (r.error)
97
+ setMsg({ ok: false, text: r.error });
98
+ else if ((r.ok ?? 0) > 0)
99
+ setMsg({ ok: true, text: `Published to ${r.ok}/${r.total} relays.${tail}` });
100
+ else
101
+ setMsg({ ok: false, text: `No relay accepted the event.${tail}` });
102
+ }
103
+ catch (e) {
104
+ setMsg({ ok: false, text: e.message });
105
+ }
106
+ finally {
107
+ setPublishing(false);
108
+ }
109
+ }
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: () => {
111
+ setExpanded(true);
112
+ 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"
114
+ ? "rounded-full bg-[var(--tb-accent)] px-4 py-2 text-sm font-medium text-[var(--tb-on-accent)] disabled:opacity-40"
115
+ : "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
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Sign in with an npub (a DM challenge) or an nsec (an in-browser session key).
3
+ *
4
+ * begin → request_npub_proof → awaiting reply → receive_npub_proof → app
5
+ *
6
+ * Success is not a `verified` flag but "no `error` and a token came back".
7
+ */
8
+ export default function NpubGate({ onLogin, startFresh, operatorHash, notice, }: {
9
+ onLogin: () => void;
10
+ /** Arrive with a key already made, for a first-timer who asked for one. */
11
+ startFresh?: boolean;
12
+ /** Operator fingerprint, shown so the patron can check who sent the DM. */
13
+ operatorHash?: string;
14
+ /** A routine re-auth prompt, drawn as a calm note rather than an error. */
15
+ notice?: string;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,159 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Sign in with an npub (a DM challenge) or an nsec (an in-browser session key).
4
+ *
5
+ * begin → request_npub_proof → awaiting reply → receive_npub_proof → app
6
+ *
7
+ * Success is not a `verified` flag but "no `error` and a token came back".
8
+ */
9
+ import { useEffect, useRef, useState } from "react";
10
+ import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
11
+ import { tollboothConfig } from "../config.js";
12
+ import { forgetRecentLogin, getLastTypedNpub, getValidRecentLogins, recordRecentLogin, setLastTypedNpub, setStoredNpub, setStoredProof, } from "../identity.js";
13
+ import { setSessionNsec } from "../sessionNsec.js";
14
+ import { readSignInFailure } from "../signInSituation.js";
15
+ import { receiveNpubProof, requestNpubProof } from "../standardTools.js";
16
+ import { card, errBox, ghost, input, muted, primary, warnBox } from "./ui.js";
17
+ export default function NpubGate({ onLogin, startFresh = false, operatorHash, notice, }) {
18
+ const { appName } = tollboothConfig();
19
+ // Prefilled from what was typed last, NOT from the stored identity.
20
+ const [value, setValue] = useState(getLastTypedNpub());
21
+ const [stage, setStage] = useState("begin");
22
+ const [pendingProof, setPendingProof] = useState("");
23
+ const [busy, setBusy] = useState(false);
24
+ const [error, setError] = useState("");
25
+ const [note, setNote] = useState("");
26
+ const [recents, setRecents] = useState(() => getValidRecentLogins());
27
+ const [generatedHint, setGeneratedHint] = useState(false);
28
+ // Once, on arrival, and only into an empty field — a key generated over
29
+ // something already typed would be a key that ate their nsec.
30
+ const made = useRef(false);
31
+ useEffect(() => {
32
+ if (!startFresh || made.current || value.trim())
33
+ return;
34
+ made.current = true;
35
+ generateKey();
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [startFresh]);
38
+ const trimmed = value.trim();
39
+ const isNsec = trimmed.startsWith("nsec1") && trimmed.length > 8;
40
+ const isNpub = trimmed.startsWith("npub1") && trimmed.length >= 60;
41
+ const valid = isNsec || isNpub;
42
+ function reuseRecent(entry) {
43
+ setStoredNpub(entry.npub);
44
+ setStoredProof(entry.proof);
45
+ recordRecentLogin(entry.npub, entry.proof, Math.floor((entry.expiresAt - Date.now()) / 1000));
46
+ onLogin();
47
+ }
48
+ function forget(npub) {
49
+ forgetRecentLogin(npub);
50
+ setRecents(getValidRecentLogins());
51
+ }
52
+ function generateKey() {
53
+ setValue(nip19.nsecEncode(generateSecretKey()));
54
+ setGeneratedHint(true);
55
+ setError("");
56
+ }
57
+ // The session key proves the npub it derives to, so storing it at once takes
58
+ // nothing on trust.
59
+ function signInWithNsec() {
60
+ setError("");
61
+ try {
62
+ const decoded = nip19.decode(trimmed);
63
+ if (decoded.type !== "nsec")
64
+ throw new Error("Not a bech32 nsec");
65
+ const npub = nip19.npubEncode(getPublicKey(decoded.data));
66
+ setSessionNsec(trimmed);
67
+ setStoredNpub(npub);
68
+ setLastTypedNpub(npub);
69
+ onLogin();
70
+ }
71
+ catch (e) {
72
+ setError(`Couldn't read that nsec: ${e.message}`);
73
+ }
74
+ }
75
+ async function begin() {
76
+ if (!isNpub) {
77
+ setError("Enter a valid npub1… key.");
78
+ return;
79
+ }
80
+ setBusy(true);
81
+ setError("");
82
+ try {
83
+ const r = await requestNpubProof(trimmed, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
84
+ if (r.error)
85
+ return setError(r.error);
86
+ if (!r.dpop_token)
87
+ return setError("The service did not return a session phrase. Try again.");
88
+ // Remembered for the field only. The identity is written by `finish()`,
89
+ // once the human has answered the DM.
90
+ setLastTypedNpub(trimmed);
91
+ setPendingProof(r.dpop_token);
92
+ setStage("awaiting");
93
+ }
94
+ catch (e) {
95
+ setError(e.message);
96
+ }
97
+ finally {
98
+ setBusy(false);
99
+ }
100
+ }
101
+ async function finish() {
102
+ setBusy(true);
103
+ setError("");
104
+ setStage("checking");
105
+ try {
106
+ const r = await receiveNpubProof(trimmed, pendingProof);
107
+ if (r.error) {
108
+ setError(r.error);
109
+ setStage("awaiting");
110
+ return;
111
+ }
112
+ const token = r.dpop_token || pendingProof;
113
+ const npub = r.proven_npub || trimmed;
114
+ setStoredNpub(npub);
115
+ setStoredProof(token);
116
+ if (r.expires_in_seconds && r.expires_in_seconds > 0) {
117
+ recordRecentLogin(npub, token, r.expires_in_seconds);
118
+ const h = Math.floor(r.expires_in_seconds / 3600);
119
+ setNote(`Proof cached for ~${h > 0 ? `${h}h` : `${Math.round(r.expires_in_seconds / 60)}m`}.`);
120
+ }
121
+ onLogin();
122
+ }
123
+ catch (e) {
124
+ setError(`Verification failed: ${e.message}`);
125
+ setStage("awaiting");
126
+ }
127
+ finally {
128
+ setBusy(false);
129
+ }
130
+ }
131
+ function reset() {
132
+ setStage("begin");
133
+ setPendingProof("");
134
+ setError("");
135
+ }
136
+ return (_jsxs("div", { className: "max-w-md mx-auto mt-12 px-4", children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), _jsx("p", { className: `text-sm mb-5 ${muted}`, children: "Your Nostr npub is your identity. No email, no password." }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Paste your npub or nsec" }), _jsx("input", { id: "tb-npub-field", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
137
+ setValue(e.target.value);
138
+ setGeneratedHint(false);
139
+ }, onKeyDown: (e) => {
140
+ if (e.key !== "Enter" || !valid || busy)
141
+ return;
142
+ if (isNsec)
143
+ signInWithNsec();
144
+ else
145
+ void begin();
146
+ }, placeholder: "npub1\u2026 (DM challenge) or nsec1\u2026 (instant)", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, children: busy ? "Sending…" : isNpub ? "Send proof DM" : "Sign in" }), _jsx("p", { className: `text-xs ${muted}`, children: isNsec
147
+ ? "Your nsec stays in this browser and signs each call."
148
+ : "We send a Nostr DM to your npub. Reply from your Nostr client — your signature is the proof." }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "DM sent \u2014 check your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that DM is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the DM. ", _jsx("b", { children: "Approve the DM only if the codes match." }), " If they differ \u2014 or the DM points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend DM" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] })] }));
149
+ }
150
+ /** A failure, read to the person: the lead first, the service's own words underneath. */
151
+ function Trouble({ raw, appName }) {
152
+ const s = readSignInFailure(raw, appName);
153
+ return (_jsxs("div", { className: s.retryable ? warnBox : errBox, children: [s.lead, s.detail && _jsx("div", { className: "mt-2 font-mono text-[10px] leading-relaxed opacity-70", children: s.detail })] }));
154
+ }
155
+ function ttl(expiresAt) {
156
+ const min = Math.max(0, Math.floor((expiresAt - Date.now()) / 60000));
157
+ const hr = Math.floor(min / 60);
158
+ return hr >= 1 ? `${hr}h ${min % 60}m` : `${min}m`;
159
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * What the patron has to spend, and a way to add to it.
3
+ *
4
+ * Every figure is the service's answer or a dash. A balance nobody has read is
5
+ * unknown, and unknown is not zero: showing 0 to someone with funds would send
6
+ * them to buy credits they already own. The card offers round numbers to pay
7
+ * in and never guesses what anything costs.
8
+ */
9
+ export default function WalletCard({ topUps }: {
10
+ topUps?: number[];
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What the patron has to spend, and a way to add to it.
4
+ *
5
+ * Every figure is the service's answer or a dash. A balance nobody has read is
6
+ * unknown, and unknown is not zero: showing 0 to someone with funds would send
7
+ * them to buy credits they already own. The card offers round numbers to pay
8
+ * in and never guesses what anything costs.
9
+ */
10
+ import { useCallback, useEffect, useState } from "react";
11
+ import { RefreshCw, Zap } from "lucide-react";
12
+ import { tollboothConfig } from "../config.js";
13
+ import { checkBalance, checkPayment, purchaseCredits } from "../standardTools.js";
14
+ import { card, ghost, iconButton, muted, primary } from "./ui.js";
15
+ const DEFAULT_TOP_UPS = [1_000, 5_000, 20_000];
16
+ function sats(n) {
17
+ return n === null ? "—" : n.toLocaleString("en-US");
18
+ }
19
+ export default function WalletCard({ topUps = DEFAULT_TOP_UPS }) {
20
+ const { appName } = tollboothConfig();
21
+ const [balance, setBalance] = useState(null);
22
+ const [reachable, setReachable] = useState(true);
23
+ const [busy, setBusy] = useState(false);
24
+ const [invoice, setInvoice] = useState(null);
25
+ const [msg, setMsg] = useState("");
26
+ const load = useCallback(() => {
27
+ checkBalance()
28
+ .then((r) => {
29
+ setReachable(!r.error);
30
+ if (!r.error)
31
+ setBalance(r.balance_api_sats ?? 0);
32
+ })
33
+ .catch(() => setReachable(false));
34
+ }, []);
35
+ useEffect(load, [load]);
36
+ async function topUp(amount) {
37
+ setBusy(true);
38
+ setMsg("");
39
+ try {
40
+ const r = await purchaseCredits(amount);
41
+ if (r.error || !r.invoice_id) {
42
+ setMsg(r.error ?? "The service did not return an invoice. Try again.");
43
+ return;
44
+ }
45
+ setInvoice({
46
+ id: r.invoice_id,
47
+ link: r.checkout_link ?? r.lightning_invoice ?? r.payment_request ?? "",
48
+ sats: r.amount_sats ?? amount,
49
+ });
50
+ }
51
+ catch (e) {
52
+ setMsg(e.message);
53
+ }
54
+ finally {
55
+ setBusy(false);
56
+ }
57
+ }
58
+ async function confirm() {
59
+ if (!invoice)
60
+ return;
61
+ setBusy(true);
62
+ setMsg("");
63
+ try {
64
+ const r = await checkPayment(invoice.id);
65
+ if (r.status === "Settled") {
66
+ setInvoice(null);
67
+ setMsg(`Paid. ${sats(r.credits_granted ?? invoice.sats)} sats added.`);
68
+ load();
69
+ }
70
+ else {
71
+ setMsg(`Not paid yet — the invoice reads ${r.status ?? "unknown"}.`);
72
+ }
73
+ }
74
+ catch (e) {
75
+ setMsg(e.message);
76
+ }
77
+ finally {
78
+ setBusy(false);
79
+ }
80
+ }
81
+ return (_jsxs("section", { className: `${card} p-4`, children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: `text-sm ${muted}`, children: "Balance" }), _jsx("button", { type: "button", onClick: load, title: "Refresh", "aria-label": "Refresh balance", className: iconButton, children: _jsx(RefreshCw, { size: 14 }) })] }), _jsxs("div", { className: "mt-1 flex items-baseline gap-2", children: [_jsx("span", { className: "text-2xl font-semibold tabular-nums", children: sats(reachable ? balance : null) }), _jsx("span", { className: `text-sm ${muted}`, children: "sats" })] }), !reachable && _jsxs("p", { className: `mt-2 text-xs ${muted}`, children: [appName, " did not answer, so no balance is shown."] }), !invoice ? (_jsx("div", { className: "mt-4 flex gap-2", children: topUps.map((n) => (_jsxs("button", { type: "button", disabled: busy, onClick: () => topUp(n), className: ghost, children: ["+", n.toLocaleString("en-US")] }, n))) })) : (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsxs("a", { href: invoice.link, target: "_blank", rel: "noreferrer", className: `${primary} flex items-center justify-center gap-2`, children: [_jsx(Zap, { size: 16 }), " Pay ", invoice.sats.toLocaleString("en-US"), " sats"] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", disabled: busy, onClick: confirm, className: ghost, children: "I've paid \u2014 check" }), _jsx("button", { type: "button", onClick: () => setInvoice(null), className: `${ghost} w-auto px-4`, children: "Cancel" })] })] })), msg && _jsx("p", { className: `mt-3 text-xs ${muted}`, children: msg })] }));
82
+ }
@@ -0,0 +1,6 @@
1
+ export { default as Avatar } from "./Avatar.tsx";
2
+ export { default as AvatarPicker } from "./AvatarPicker.tsx";
3
+ export { default as NpubGate } from "./NpubGate.tsx";
4
+ export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
5
+ export { default as WalletCard } from "./WalletCard.tsx";
6
+ export { useSession, type Session } from "./useSession.ts";
@@ -0,0 +1,6 @@
1
+ export { default as Avatar } from "./Avatar.js";
2
+ export { default as AvatarPicker } from "./AvatarPicker.js";
3
+ export { default as NpubGate } from "./NpubGate.js";
4
+ export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
5
+ export { default as WalletCard } from "./WalletCard.js";
6
+ export { useSession } from "./useSession.js";
@@ -0,0 +1,9 @@
1
+ /** Class strings shared by the components, all coloured through theme.css tokens. */
2
+ export declare const card = "rounded-2xl border border-[var(--tb-line)] bg-[var(--tb-surface)] text-[var(--tb-ink)]";
3
+ export declare const input = "w-full rounded-lg px-3 py-2.5 text-sm bg-[var(--tb-surface)] text-[var(--tb-ink)] border border-[var(--tb-line)] focus:outline-none focus:border-[var(--tb-accent)] font-mono";
4
+ export declare const primary = "w-full rounded-full bg-[var(--tb-accent)] text-[var(--tb-on-accent)] text-sm font-medium py-2.5 disabled:opacity-40 transition-opacity";
5
+ export declare const ghost = "w-full rounded-full text-sm py-2 border border-[var(--tb-line)] text-[var(--tb-muted)] hover:bg-[var(--tb-surface-2)] disabled:opacity-40 transition-colors";
6
+ export declare const iconButton = "inline-flex items-center justify-center rounded-full p-2 text-[var(--tb-muted)] hover:bg-[var(--tb-surface-2)] disabled:opacity-40 transition-colors";
7
+ export declare const muted = "text-[var(--tb-muted)]";
8
+ export declare const errBox = "rounded-lg p-3 text-xs bg-[var(--tb-err-bg)] border border-[var(--tb-err-line)] text-[var(--tb-err-ink)]";
9
+ export declare const warnBox = "rounded-lg p-3 text-xs bg-[var(--tb-warn-bg)] border border-[var(--tb-warn-line)] text-[var(--tb-warn-ink)]";
@@ -0,0 +1,9 @@
1
+ /** Class strings shared by the components, all coloured through theme.css tokens. */
2
+ export const card = "rounded-2xl border border-[var(--tb-line)] bg-[var(--tb-surface)] text-[var(--tb-ink)]";
3
+ export const input = "w-full rounded-lg px-3 py-2.5 text-sm bg-[var(--tb-surface)] text-[var(--tb-ink)] border border-[var(--tb-line)] focus:outline-none focus:border-[var(--tb-accent)] font-mono";
4
+ export const primary = "w-full rounded-full bg-[var(--tb-accent)] text-[var(--tb-on-accent)] text-sm font-medium py-2.5 disabled:opacity-40 transition-opacity";
5
+ export const ghost = "w-full rounded-full text-sm py-2 border border-[var(--tb-line)] text-[var(--tb-muted)] hover:bg-[var(--tb-surface-2)] disabled:opacity-40 transition-colors";
6
+ export const iconButton = "inline-flex items-center justify-center rounded-full p-2 text-[var(--tb-muted)] hover:bg-[var(--tb-surface-2)] disabled:opacity-40 transition-colors";
7
+ export const muted = "text-[var(--tb-muted)]";
8
+ export const errBox = "rounded-lg p-3 text-xs bg-[var(--tb-err-bg)] border border-[var(--tb-err-line)] text-[var(--tb-err-ink)]";
9
+ export const warnBox = "rounded-lg p-3 text-xs bg-[var(--tb-warn-bg)] border border-[var(--tb-warn-line)] text-[var(--tb-warn-ink)]";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Who is signed in, in one place, so every screen re-renders when it changes.
3
+ *
4
+ * Two ways to be signed in, and they are not the same: a session nsec that
5
+ * signs a fresh proof per call and never lapses while the tab lives, or a
6
+ * cached DM proof that expires. `canSign` tells them apart for the pages that
7
+ * need to.
8
+ */
9
+ export interface Session {
10
+ npub: string;
11
+ signedIn: boolean;
12
+ canSign: boolean;
13
+ /** Set when a cached proof lapses: a calm prompt to sign in again. */
14
+ notice: string;
15
+ /** Call after a successful sign-in so the whole app notices. */
16
+ refresh: () => void;
17
+ signOut: () => void;
18
+ }
19
+ export declare function useSession(): Session;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Who is signed in, in one place, so every screen re-renders when it changes.
3
+ *
4
+ * Two ways to be signed in, and they are not the same: a session nsec that
5
+ * signs a fresh proof per call and never lapses while the tab lives, or a
6
+ * cached DM proof that expires. `canSign` tells them apart for the pages that
7
+ * need to.
8
+ */
9
+ import { useCallback, useEffect, useState } from "react";
10
+ import { onProofExpired } from "../client.js";
11
+ import { currentClaim, logOut } from "../identity.js";
12
+ import { canSignFor, isProven } from "../signedIn.js";
13
+ function read() {
14
+ if (typeof globalThis.localStorage === "undefined")
15
+ return { npub: "", signedIn: false, canSign: false };
16
+ const claim = currentClaim();
17
+ return { npub: claim.npub, signedIn: isProven(claim), canSign: canSignFor(claim) };
18
+ }
19
+ export function useSession() {
20
+ const [state, setState] = useState(read);
21
+ const [notice, setNotice] = useState("");
22
+ const refresh = useCallback(() => {
23
+ setState(read());
24
+ setNotice("");
25
+ }, []);
26
+ const signOut = useCallback(() => {
27
+ logOut();
28
+ setState(read());
29
+ setNotice("");
30
+ }, []);
31
+ useEffect(() => onProofExpired(() => {
32
+ // A tab that can sign for THIS npub lost nothing.
33
+ if (canSignFor(currentClaim()))
34
+ return;
35
+ setNotice("Your session lapsed while you were away. Sign in again to carry on.");
36
+ setState(read());
37
+ }), []);
38
+ return { ...state, notice, refresh, signOut };
39
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Session nsec — a freshly generated (or pasted) key kept in the browser to
3
+ * sign a kind-27235 identity proof on every paid call (Tactic 2 in the wheel's
4
+ * `identity_proof`).
5
+ *
6
+ * Threat model, named at the sign-in gate when the patron picks this path:
7
+ * - Stored unencrypted in localStorage. XSS on this origin can take it.
8
+ * Accepted for one-tap sign-in; a patron wanting stronger isolation signs
9
+ * in with an npub and answers the DM instead.
10
+ * - Kept only so a reload survives. Cleared on sign-out.
11
+ */
12
+ export declare function getSessionNsec(): string | null;
13
+ export declare function setSessionNsec(nsecBech32: string): void;
14
+ export declare function clearSessionNsec(): void;
15
+ /** The 32-byte scalar `finalizeEvent` wants. Throws if absent or malformed. */
16
+ export declare function getSessionNsecBytes(): Uint8Array;
17
+ export declare function hasSessionNsec(): boolean;
18
+ /**
19
+ * The npub the stored session nsec derives to, or null. Checked against the
20
+ * signed-in npub before signing, so a key left from a previous identity never
21
+ * signs for this one.
22
+ */
23
+ export declare function sessionNsecNpub(): string | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Session nsec — a freshly generated (or pasted) key kept in the browser to
3
+ * sign a kind-27235 identity proof on every paid call (Tactic 2 in the wheel's
4
+ * `identity_proof`).
5
+ *
6
+ * Threat model, named at the sign-in gate when the patron picks this path:
7
+ * - Stored unencrypted in localStorage. XSS on this origin can take it.
8
+ * Accepted for one-tap sign-in; a patron wanting stronger isolation signs
9
+ * in with an npub and answers the DM instead.
10
+ * - Kept only so a reload survives. Cleared on sign-out.
11
+ */
12
+ import { getPublicKey, nip19 } from "nostr-tools";
13
+ import { readStored, removeStored, writeStored } from "./storage.js";
14
+ const NAME = "session_nsec:v1";
15
+ export function getSessionNsec() {
16
+ return readStored(NAME) || null;
17
+ }
18
+ export function setSessionNsec(nsecBech32) {
19
+ if (!nsecBech32 || !nsecBech32.startsWith("nsec1")) {
20
+ throw new Error("Expected a bech32 nsec1… string");
21
+ }
22
+ try {
23
+ nip19.decode(nsecBech32);
24
+ }
25
+ catch (e) {
26
+ throw new Error(`Invalid nsec: ${e.message}`);
27
+ }
28
+ writeStored(NAME, nsecBech32);
29
+ }
30
+ export function clearSessionNsec() {
31
+ removeStored(NAME);
32
+ }
33
+ /** The 32-byte scalar `finalizeEvent` wants. Throws if absent or malformed. */
34
+ export function getSessionNsecBytes() {
35
+ const nsec = getSessionNsec();
36
+ if (!nsec)
37
+ throw new Error("No session nsec set");
38
+ const decoded = nip19.decode(nsec);
39
+ if (decoded.type !== "nsec" || !(decoded.data instanceof Uint8Array)) {
40
+ throw new Error("Stored value is not a valid nsec");
41
+ }
42
+ return decoded.data;
43
+ }
44
+ export function hasSessionNsec() {
45
+ return !!getSessionNsec();
46
+ }
47
+ /**
48
+ * The npub the stored session nsec derives to, or null. Checked against the
49
+ * signed-in npub before signing, so a key left from a previous identity never
50
+ * signs for this one.
51
+ */
52
+ export function sessionNsecNpub() {
53
+ try {
54
+ return nip19.npubEncode(getPublicKey(getSessionNsecBytes()));
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Reading a sign-in failure back to the human who caused none of it.
3
+ *
4
+ * Every operator draws its relay set from the DPYC Oracle at cold start. A
5
+ * container that has just woken has nothing cached, so the first sign-in
6
+ * against it is one Oracle blip away from failing — and what the patron was
7
+ * shown was the SDK's internal diagnosis, addressed to whoever maintains the
8
+ * SDK. To the person signing in it read as "your key is broken", which it is
9
+ * not: the next attempt usually works.
10
+ *
11
+ * So lead with what happened and what to do, and keep the original text
12
+ * underneath rather than swallowing it. Interpreting an error is not hiding it.
13
+ */
14
+ export interface Situation {
15
+ /** What to say first. One sentence, addressed to the person, not the log. */
16
+ lead: string;
17
+ /** True when trying the same thing again is genuinely likely to work. */
18
+ retryable: boolean;
19
+ /** The service's own words, kept verbatim. Empty when `lead` already is them. */
20
+ detail: string;
21
+ }
22
+ export declare function readSignInFailure(raw: string, appName?: string): Situation;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reading a sign-in failure back to the human who caused none of it.
3
+ *
4
+ * Every operator draws its relay set from the DPYC Oracle at cold start. A
5
+ * container that has just woken has nothing cached, so the first sign-in
6
+ * against it is one Oracle blip away from failing — and what the patron was
7
+ * shown was the SDK's internal diagnosis, addressed to whoever maintains the
8
+ * SDK. To the person signing in it read as "your key is broken", which it is
9
+ * not: the next attempt usually works.
10
+ *
11
+ * So lead with what happened and what to do, and keep the original text
12
+ * underneath rather than swallowing it. Interpreting an error is not hiding it.
13
+ */
14
+ /** Substrings that identify a service that is awake but not yet ready. */
15
+ const WARMING = [
16
+ "relay registry unreachable",
17
+ "cannot reach oracle",
18
+ "persistence layer unreachable",
19
+ "service unavailable",
20
+ "only bootstrap tools",
21
+ ];
22
+ const UNREACHABLE = ["failed to fetch", "networkerror", "load failed", "timeout", "timed out"];
23
+ export function readSignInFailure(raw, appName = "The service") {
24
+ const detail = (raw || "").trim() || "The service gave no reason.";
25
+ const hay = detail.toLowerCase();
26
+ if (WARMING.some((s) => hay.includes(s))) {
27
+ return {
28
+ lead: `${appName} was still waking up and could not reach the relay directory. ` +
29
+ "Nothing is wrong with your key — try again in a few seconds.",
30
+ retryable: true,
31
+ detail,
32
+ };
33
+ }
34
+ if (UNREACHABLE.some((s) => hay.includes(s))) {
35
+ return {
36
+ lead: `${appName} could not be reached from this device. Check the connection and try again.`,
37
+ retryable: true,
38
+ detail,
39
+ };
40
+ }
41
+ return { lead: detail, retryable: false, detail: "" };
42
+ }