@tollbooth-dpyc/web 1.3.1 → 1.4.1

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
@@ -10,9 +10,13 @@ features. It is the peer of the `tollbooth-dpyc` Python wheel.
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
12
  - **React components** — `NpubGate`, `NostrProfilePanel`, `SessionKeyClaim`, `WalletCard`,
13
- `WalletPage`, `CouponsPanel`, `TableFilter`, `SortHeader` / `PageControls` /
14
- `TableShell`, `ThemeToggle`, `ErrorBoundary`, `AvatarPicker`, `DebugPanel`,
15
- `QuoteScroller`, `useSession`, `useTopUp`, `useTheme`, `useDebugLog`
13
+ `WalletPage`, `CouponsPanel`, `UsageSummary`, `PatronFundingStatus` /
14
+ `OperatorFundingStatus`, `BuildInfoPanel`, `TimezonePicker`, `TableFilter`,
15
+ `SortHeader` / `PageControls` / `TableShell`, `ThemeToggle`, `ErrorBoundary`,
16
+ `AvatarPicker`, `DebugPanel`, `QuoteScroller`, `useSession`, `useTopUp`,
17
+ `useTheme`, `useTimezone`, `useDebugLog`
18
+ - **Time zone** — the patron's display zone and the conversions every clock
19
+ needs (render, datetime-local, day-filter bounds), DST-correct
16
20
  - **Mechanics, not looks** — the newer components take `classNames` per part
17
21
  and add no typography or colour of their own; actions are chips
18
22
  - **Debug log** — every call, result and error from `callTool`, scrubbed of
@@ -113,6 +117,63 @@ wrapper takes the same optional `CallOptions` as `callTool`, last
113
117
  `useTopUp` is the wallet's top-up on its own (purchase_credits → invoice →
114
118
  check_payment, polled while the tab is visible) for a site drawing its own.
115
119
 
120
+ ### Time zone
121
+
122
+ The patron picks a display zone once — "auto" (the browser's) or an IANA name,
123
+ never a fixed offset — and every clock follows. It is kept under
124
+ `<prefix>:timezone`; `useTimezone()` re-renders on any change, from this page
125
+ or another tab. Storage stays UTC; convert at the edges:
126
+
127
+ ```tsx
128
+ import { datetimeLocalValueToIso, formatDateTime, isoToDatetimeLocalValue, localDateFilterBounds } from "@tollbooth-dpyc/web";
129
+ import { TimezonePicker, useTimezone } from "@tollbooth-dpyc/web/react";
130
+
131
+ const [, zone] = useTimezone();
132
+ formatDateTime(post.sent_at, zone); // render
133
+ isoToDatetimeLocalValue(post.publish_at, zone); // → <input type="datetime-local">
134
+ datetimeLocalValueToIso(value, zone); // ← back to UTC ISO
135
+ localDateFilterBounds("2026-03-08", "2026-03-08", zone); // the patron's midnights, as instants
136
+
137
+ <TimezonePicker label="Display time zone" classNames={{ select: "field" }} />
138
+ ```
139
+
140
+ Around a DST change a missing wall time (02:30 on a spring-forward morning)
141
+ moves an hour on and a doubled one (01:30 on a fall-back night) is the
142
+ earlier; day bounds give the 23- and 25-hour days their true length.
143
+ `displayTimeZone()` is the zone for code outside React.
144
+
145
+ ### Usage, funding, build
146
+
147
+ ```tsx
148
+ import { BuildInfoPanel, OperatorFundingStatus, PatronFundingStatus, UsageSummary } from "@tollbooth-dpyc/web/react";
149
+
150
+ <UsageSummary days={30} figures={["balance", "deposited", "consumed"]} classNames={{ root: "card p-5", figures: "grid grid-cols-3" }} />
151
+
152
+ <PatronFundingStatus thresholds={{ low: 100 }} siteRows={xConnectionRows} classNames={{ root: "card p-5", ok: "text-green-600" }} />
153
+ <OperatorFundingStatus authorityThresholds={{ low: 500 }} />
154
+
155
+ <BuildInfoPanel
156
+ status={status}
157
+ frontend={{ version: __APP_VERSION__, commit: __BUILD_COMMIT__, builtAt: __BUILD_TIME__, source: "https://github.com/you/site" }}
158
+ intro={<p>…the site's own words…</p>}
159
+ />
160
+ ```
161
+
162
+ - `UsageSummary` reads `account_statement`: balance and lifetime totals, the
163
+ period's spend, calls and top-ups (from its daily log), and its top tools.
164
+ `figures` picks the numbers, `renderRow` draws a tool row.
165
+ - `PatronFundingStatus`: sign-in proof and credit balance, with tranche expiry;
166
+ `OperatorFundingStatus`: credentials still to deliver, the Authority balance,
167
+ the database and background jobs. Every row is read fresh and stamped with
168
+ when, in the patron's zone. **The operator panel renders nothing — and calls
169
+ nothing operator-only — unless the signed-in npub equals the `operator_npub`
170
+ that `session_status` reports.** That is display only; the server enforces
171
+ its own ACL. `siteRows` adds a site's own checks.
172
+ - `BuildInfoPanel`: the front end's version (yours, passed in), the MCP
173
+ server's version, wheel and commit linked to its repository (from
174
+ `service_status`; only https links), the Tollbooth-DPYC™ links, the licence
175
+ and the patent notice. `children` adds rows after the MCP server section.
176
+
116
177
  ### Debug panel
117
178
 
118
179
  `callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
@@ -136,7 +197,7 @@ configureDebugLog({ persist: true }); // optional: outlive a reload; max defau
136
197
  ```
137
198
 
138
199
  A site's own `debugPush` lines are scrubbed the same way. Stamps follow the
139
- zone the site stores under `<prefix>:timezone`, if any.
200
+ patron's display time zone (see Time zone).
140
201
 
141
202
  ### Quote scroller
142
203
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * What `service_status` says about the running build: the server's version,
3
+ * the tollbooth-dpyc wheel under it, and the commit it was deployed from,
4
+ * linked to its repository. Framework-free, so it is tested without React.
5
+ *
6
+ * Hrefs come from the server, so only an https URL becomes a link; anything
7
+ * else is shown as text.
8
+ */
9
+ import type { ServiceStatus } from "./standardTools.ts";
10
+ export interface BuildFacts {
11
+ version: string | null;
12
+ sdkVersion: string | null;
13
+ /** The deployed commit, short (7 characters). */
14
+ commit: string | null;
15
+ /** The commit on its host, when the repository is on GitHub. */
16
+ commitHref: string | null;
17
+ /** The repository without its scheme, e.g. "github.com/lonniev/excalibur-mcp". */
18
+ repo: string | null;
19
+ repoHref: string | null;
20
+ }
21
+ export declare function buildFacts(status: ServiceStatus | null | undefined): BuildFacts;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What `service_status` says about the running build: the server's version,
3
+ * the tollbooth-dpyc wheel under it, and the commit it was deployed from,
4
+ * linked to its repository. Framework-free, so it is tested without React.
5
+ *
6
+ * Hrefs come from the server, so only an https URL becomes a link; anything
7
+ * else is shown as text.
8
+ */
9
+ function httpsUrl(raw) {
10
+ if (!raw)
11
+ return null;
12
+ try {
13
+ const u = new URL(raw);
14
+ return u.protocol === "https:" ? u : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function buildFacts(status) {
21
+ const info = status?.build_info ?? {};
22
+ const sha = /^[0-9a-f]{7,40}$/i.test(info.fastmcp_cloud_git_commit_sha ?? "")
23
+ ? info.fastmcp_cloud_git_commit_sha
24
+ : null;
25
+ const repoUrl = httpsUrl(info.fastmcp_cloud_git_repo);
26
+ const repoHref = repoUrl ? repoUrl.href.replace(/\/+$/, "") : null;
27
+ return {
28
+ version: status?.version || null,
29
+ sdkVersion: status?.tollbooth_dpyc_version || null,
30
+ commit: sha ? sha.slice(0, 7) : null,
31
+ commitHref: sha && repoUrl?.hostname === "github.com" ? `${repoHref}/commit/${sha}` : null,
32
+ repo: repoHref ? repoHref.replace(/^https:\/\//, "") : (info.fastmcp_cloud_git_repo || null),
33
+ repoHref,
34
+ };
35
+ }
package/dist/client.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
13
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14
14
  import { tollboothConfig, toolName } from "./config.js";
15
- import { debugPush } from "./debugLog.js";
15
+ import { debugPush, redact } from "./debugLog.js";
16
16
  import { clearStoredProof, forgetRecentLogin, getStoredNpub, getStoredProof, } from "./identity.js";
17
17
  import { signInlineProof } from "./inlineProof.js";
18
18
  import { isTransportFailure, NetworkError } from "./networkError.js";
@@ -129,7 +129,7 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
129
129
  // `args` holds only the caller's own parameters — the envelope is added
130
130
  // below — so it is safe to log.
131
131
  if (!quiet)
132
- debugPush("call", `${name}(${JSON.stringify(args).slice(0, 140)})`);
132
+ debugPush("call", `${name}(${redact(JSON.stringify(args)).slice(0, 140)})`);
133
133
  const bootstrap = STANDARD_BOOTSTRAP.includes(tool) || cfg.extraBootstrapTools.includes(tool);
134
134
  const merged = bootstrap
135
135
  ? { ...args }
@@ -154,14 +154,14 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
154
154
  if (result.isError) {
155
155
  const text = errorText(result);
156
156
  if (!quiet)
157
- debugPush("error", `${name}: ${text.slice(0, 200)}`);
157
+ debugPush("error", `${name}: ${redact(text).slice(0, 200)}`);
158
158
  throw new Error(text);
159
159
  }
160
160
  const { payload, images } = readToolResult(result);
161
161
  if (!quiet) {
162
162
  const preview = typeof payload === "string" ? payload : JSON.stringify(payload);
163
163
  const tail = images.length ? ` +${images.length} image` : "";
164
- debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${String(preview).slice(0, 220)}${tail}`);
164
+ debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${redact(String(preview)).slice(0, 220)}${tail}`);
165
165
  }
166
166
  if (!opts.bestEffort) {
167
167
  const bounced = proofBounceMessage(payload);
@@ -7,8 +7,10 @@
7
7
  * that flips the view can still be read and copied afterward.
8
8
  *
9
9
  * Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
10
- * token, and the value of any secret-named field (dpop_token, proof, poison,
11
- * password, api_key, …) never reach the buffer, the clipboard or storage.
10
+ * token, the value of any secret-named field (dpop_token, proof, poison,
11
+ * password, api_key, …), and a secret inside a URL (a query token, a
12
+ * bearer-like path segment, userinfo) never reach the buffer, the clipboard
13
+ * or storage.
12
14
  * That is the one guarantee a panel relies on — it renders what is here.
13
15
  *
14
16
  * Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
package/dist/debugLog.js CHANGED
@@ -7,14 +7,17 @@
7
7
  * that flips the view can still be read and copied afterward.
8
8
  *
9
9
  * Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
10
- * token, and the value of any secret-named field (dpop_token, proof, poison,
11
- * password, api_key, …) never reach the buffer, the clipboard or storage.
10
+ * token, the value of any secret-named field (dpop_token, proof, poison,
11
+ * password, api_key, …), and a secret inside a URL (a query token, a
12
+ * bearer-like path segment, userinfo) never reach the buffer, the clipboard
13
+ * or storage.
12
14
  * That is the one guarantee a panel relies on — it renders what is here.
13
15
  *
14
16
  * Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
15
17
  * optionality, roastify, taxsort). None of them scrubbed; this one does.
16
18
  */
17
19
  import { tollboothConfig } from "./config.js";
20
+ import { displayTimeZone } from "./timezone.js";
18
21
  const DEFAULT_MAX = 200;
19
22
  let max = DEFAULT_MAX;
20
23
  let persist = false;
@@ -33,7 +36,7 @@ const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|proof|poison|password|passphr
33
36
  // a bare one up to a delimiter. Stopping at the first quote of either kind let
34
37
  // the tail of a secret holding `'` or `\"` through.
35
38
  function fieldValue(names) {
36
- return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])(?:\\\\.|\\\\$|(?!\\2)[^\\\\])*?(?=\\2|$)|[^"'\\\\,}\\]&\\s]+)`, "gi");
39
+ return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])(?:\\\\.|\\\\$|(?!\\2)[^\\\\])*?(?=\\2|$)|(?!\\[redacted\\])[^"'\\\\,}\\]&\\s]+)`, "gi");
37
40
  }
38
41
  const FIELD_VALUE = fieldValue(SECRET_FIELD);
39
42
  // update_patron_credential / update_operator_credential carry the secret as
@@ -46,6 +49,54 @@ const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/g;
46
49
  function scrubbed(_match, head, quote) {
47
50
  return `${head}${quote ?? ""}${REDACTED}`;
48
51
  }
52
+ // ── Secrets inside URLs ────────────────────────────────────────────────────
53
+ //
54
+ // A capability link carries its secret in the URL itself — Good Earth's
55
+ // calendar feed is `https://host/calendar/<32 hex>.ics` — and a link read
56
+ // back in a tool result is logged whole. Three places a URL hides a secret:
57
+ // the userinfo, a query or fragment parameter, and a bearer-like path segment.
58
+ /** Query/fragment parameter names whose value is a secret (matched whole). */
59
+ const URL_SECRET_PARAM = /([?&#;](?:token|access_token|refresh_token|id_token|auth|authorization|key|api_key|apikey|secret|client_secret|password|pass|sig|signature|code|proof|dpop_token|session|sessionid|nsec)=)[^&#;\s"'\\<>]*/gi;
60
+ // A URL up to whitespace, a quote, an escape or an angle bracket — so one
61
+ // inside JSON (or JSON inside JSON) ends at its own closing quote.
62
+ const URL_IN_TEXT = /\b(?:https?|webcal|wss?):\/\/[^\s"'\\<>]+/gi;
63
+ const USERINFO = /^([a-z]+:\/\/)[^/?#@]+@/i;
64
+ /**
65
+ * A path segment that reads as a bearer secret rather than a name: 32+ hex,
66
+ * or 24+ base64url characters holding both a letter and a digit and one
67
+ * unbroken alphanumeric run of 16+ (a slug's words are short; a token's are
68
+ * not). An npub is public and stays.
69
+ */
70
+ function isSecretSegment(seg) {
71
+ if (/^npub1/i.test(seg))
72
+ return false;
73
+ if (/^[0-9a-f]{32,}$/i.test(seg))
74
+ return true;
75
+ if (!/^[A-Za-z0-9_-]{24,}$/.test(seg))
76
+ return false;
77
+ if (!/[0-9]/.test(seg) || !/[A-Za-z]/.test(seg))
78
+ return false;
79
+ return /[A-Za-z0-9]{16,}/.test(seg);
80
+ }
81
+ function scrubUrl(url) {
82
+ const head = url.replace(USERINFO, `$1${REDACTED}@`);
83
+ const pathStart = head.indexOf("/", head.indexOf("://") + 3);
84
+ if (pathStart < 0)
85
+ return head;
86
+ const tailAt = head.slice(pathStart).search(/[?#]/);
87
+ const pathEnd = tailAt < 0 ? head.length : pathStart + tailAt;
88
+ const path = head
89
+ .slice(pathStart, pathEnd)
90
+ .split("/")
91
+ .map((seg) => {
92
+ // `<token>.ics`: judge the name, keep the extension.
93
+ const dot = seg.indexOf(".");
94
+ const name = dot < 0 ? seg : seg.slice(0, dot);
95
+ return isSecretSegment(name) ? REDACTED + (dot < 0 ? "" : seg.slice(dot)) : seg;
96
+ })
97
+ .join("/");
98
+ return head.slice(0, pathStart) + path + head.slice(pathEnd);
99
+ }
49
100
  /**
50
101
  * Scrub secrets from a log line. Deliberately over-eager: a 64-hex event id
51
102
  * is hidden along with a hex private key, because the two look alike.
@@ -54,7 +105,9 @@ export function redact(message) {
54
105
  let out = message
55
106
  .replace(NSEC, REDACTED)
56
107
  .replace(BEARER, `Bearer ${REDACTED}`)
57
- .replace(FIELD_VALUE, scrubbed);
108
+ .replace(URL_IN_TEXT, scrubUrl)
109
+ .replace(FIELD_VALUE, scrubbed)
110
+ .replace(URL_SECRET_PARAM, `$1${REDACTED}`);
58
111
  if (HAS_FIELD.test(out))
59
112
  out = out.replace(CREDENTIAL_VALUE, scrubbed);
60
113
  return out.replace(HEX_KEY, REDACTED);
@@ -108,19 +161,9 @@ function hydrate() {
108
161
  return [];
109
162
  }
110
163
  }
111
- /**
112
- * The patron's display zone, if the site stores one under `<prefix>:timezone`
113
- * ("auto" or absent means the browser's own), so stamps match the rest of the UI.
114
- */
164
+ /** In the patron's display zone (`timezone.ts`), so stamps match the rest of the UI. */
115
165
  function stamp(now) {
116
- try {
117
- const pref = readLocal("timezone");
118
- const zone = pref && pref !== "auto" ? pref : undefined;
119
- return now.toLocaleTimeString(undefined, zone ? { timeZone: zone } : undefined);
120
- }
121
- catch {
122
- return now.toLocaleTimeString();
123
- }
166
+ return now.toLocaleTimeString(undefined, { timeZone: displayTimeZone() });
124
167
  }
125
168
  function emit() {
126
169
  save();
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Funding and credential health as uniform rows, for a patron and for the
3
+ * operator. Framework-free, so every rule is tested without React or a server.
4
+ *
5
+ * Lifted from eXcalibur's `lib/fundingStatus.ts`, keeping its three rules:
6
+ * 1. No prices — balances and expiries only.
7
+ * 2. No warning outlives its cause — every row is derived fresh from the
8
+ * latest tool answers, and carries the time it was checked.
9
+ * 3. Compose, don't invent — every input is a field a standard tool already
10
+ * returns (check_balance, check_proof_status, session_status,
11
+ * service_status, get_operator_onboarding_status, check_authority_balance).
12
+ *
13
+ * Three states: ok (working now), warning (will stop on a known date, or
14
+ * incomplete but not fatal), blocked (stopping work now).
15
+ */
16
+ import type { CheckBalanceResult, OperatorOnboardingStatus, ServiceStatus, SessionStatusResult } from "./standardTools.ts";
17
+ export type StatusLevel = "ok" | "warning" | "blocked";
18
+ export interface StatusRow {
19
+ /** Stable key for lists and tests. */
20
+ id: string;
21
+ /** What the row is about: "Sign-in proof", "Authority balance", … */
22
+ dependency: string;
23
+ state: StatusLevel;
24
+ /** One line; never a price. */
25
+ detail: string;
26
+ /** ISO time of the read that produced the row. */
27
+ checked_at: string;
28
+ }
29
+ /**
30
+ * When a balance reads as trouble: at or below `empty` it is blocked, at or
31
+ * below `low` a warning. Default both 0 — only an empty balance is flagged.
32
+ */
33
+ export interface BalanceThresholds {
34
+ low?: number;
35
+ empty?: number;
36
+ }
37
+ /** How this tab proves who it is. */
38
+ export type ProofKind = {
39
+ kind: "session_nsec";
40
+ } | {
41
+ kind: "dm_proof";
42
+ status: "valid" | "expired" | "unknown";
43
+ expiresInSec?: number | null;
44
+ } | {
45
+ kind: "none";
46
+ };
47
+ /** A tranche or proof ending within this window is a warning. */
48
+ export declare const WARNING_WITHIN_MS: number;
49
+ /** The worst state across rows — for a heading's summary mark. */
50
+ export declare function worstState(rows: readonly StatusRow[]): StatusLevel;
51
+ /** "3d", "5h", "12m", "now". */
52
+ export declare function durationText(sec: number): string;
53
+ /** The sign-in proof: a lapsed one drops the patron mid-session. */
54
+ export declare function composeProofRow(proof: ProofKind, checkedAt: string): StatusRow;
55
+ /** The patron's balance and the expiry of its tranches — unused credits evaporate. */
56
+ export declare function composeCreditsRow(bal: CheckBalanceResult, checkedAt: string, thresholds?: BalanceThresholds, nowMs?: number): StatusRow;
57
+ /**
58
+ * Is the signed-in npub this operator? The rule every copy used: the patron's
59
+ * npub must equal the operator npub the server reports (`session_status`).
60
+ * It only decides what a page shows — the server still enforces its own ACL.
61
+ */
62
+ export declare function isOperator(npub: string, status: Pick<SessionStatusResult, "operator_npub"> | null | undefined): boolean;
63
+ /** The credentials the operator still has to deliver by Secure Courier. */
64
+ export declare function composeOnboardingRow(onb: OperatorOnboardingStatus, checkedAt: string): StatusRow;
65
+ /** The operator's balance at its Authority — what certifies patron top-ups. */
66
+ export declare function composeAuthorityRow(bal: CheckBalanceResult, checkedAt: string, thresholds?: BalanceThresholds): StatusRow;
67
+ /** The operator's database, from `session_status`'s lifecycle and the vault. */
68
+ export declare function composePersistenceRow(life: SessionStatusResult, svc: ServiceStatus, onb: OperatorOnboardingStatus, checkedAt: string): StatusRow;
69
+ /**
70
+ * Background-job dispatch, for an operator that runs any: null when
71
+ * `service_status` carries no job diagnostics (the server runs none).
72
+ */
73
+ export declare function composeDurableJobsRow(svc: ServiceStatus, checkedAt: string): StatusRow | null;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Funding and credential health as uniform rows, for a patron and for the
3
+ * operator. Framework-free, so every rule is tested without React or a server.
4
+ *
5
+ * Lifted from eXcalibur's `lib/fundingStatus.ts`, keeping its three rules:
6
+ * 1. No prices — balances and expiries only.
7
+ * 2. No warning outlives its cause — every row is derived fresh from the
8
+ * latest tool answers, and carries the time it was checked.
9
+ * 3. Compose, don't invent — every input is a field a standard tool already
10
+ * returns (check_balance, check_proof_status, session_status,
11
+ * service_status, get_operator_onboarding_status, check_authority_balance).
12
+ *
13
+ * Three states: ok (working now), warning (will stop on a known date, or
14
+ * incomplete but not fatal), blocked (stopping work now).
15
+ */
16
+ /** A tranche or proof ending within this window is a warning. */
17
+ export const WARNING_WITHIN_MS = 48 * 60 * 60 * 1000;
18
+ const RANK = { ok: 0, warning: 1, blocked: 2 };
19
+ /** The worst state across rows — for a heading's summary mark. */
20
+ export function worstState(rows) {
21
+ return rows.reduce((w, r) => (RANK[r.state] > RANK[w] ? r.state : w), "ok");
22
+ }
23
+ /** "3d", "5h", "12m", "now". */
24
+ export function durationText(sec) {
25
+ if (sec <= 0)
26
+ return "now";
27
+ const h = Math.floor(sec / 3600);
28
+ if (h >= 48)
29
+ return `${Math.floor(h / 24)}d`;
30
+ if (h >= 1)
31
+ return `${h}h`;
32
+ return `${Math.max(1, Math.floor(sec / 60))}m`;
33
+ }
34
+ function row(id, dependency, state, detail, checked_at) {
35
+ return { id, dependency, state, detail, checked_at };
36
+ }
37
+ function sats(n) {
38
+ return `${n.toLocaleString()} sats`;
39
+ }
40
+ // ── Patron ──────────────────────────────────────────────────────────────────
41
+ /** The sign-in proof: a lapsed one drops the patron mid-session. */
42
+ export function composeProofRow(proof, checkedAt) {
43
+ const r = (state, detail) => row("npub-proof", "Sign-in proof", state, detail, checkedAt);
44
+ if (proof.kind === "session_nsec")
45
+ return r("ok", "This tab's session key signs a fresh proof on every call — no expiry.");
46
+ if (proof.kind === "none")
47
+ return r("blocked", "No proof on hand — sign in again.");
48
+ if (proof.status === "expired")
49
+ return r("blocked", "Proof has expired — sign in again to resume paid calls.");
50
+ if (proof.status === "unknown")
51
+ return r("warning", "No server-side proof record — the next paid call may ask you to sign in again.");
52
+ const sec = proof.expiresInSec;
53
+ if (sec != null && sec * 1000 < WARNING_WITHIN_MS)
54
+ return r("warning", `Proof valid — expires in about ${durationText(sec)}.`);
55
+ return r("ok", sec != null && sec > 0 ? `Proof valid — expires in about ${durationText(sec)}.` : "Proof valid.");
56
+ }
57
+ /** The patron's balance and the expiry of its tranches — unused credits evaporate. */
58
+ export function composeCreditsRow(bal, checkedAt, thresholds = {}, nowMs = Date.now()) {
59
+ const r = (state, detail) => row("credits", "Credit balance", state, detail, checkedAt);
60
+ if (bal.vault_unavailable)
61
+ return r("warning", bal.warning || "The ledger is unavailable — the balance may be stale. Retry shortly.");
62
+ if (bal.error)
63
+ return r("warning", bal.error);
64
+ const { low = 0, empty = 0 } = thresholds;
65
+ const balance = bal.balance_api_sats ?? 0;
66
+ if (balance <= empty)
67
+ return r("blocked", `${balance > 0 ? `Only ${sats(balance)} left` : "Balance is empty"} — top up before paid calls will succeed.`);
68
+ const next = Date.parse(bal.next_expiration_iso ?? "");
69
+ const expiresInMs = Number.isNaN(next) ? null : next - nowMs;
70
+ if (expiresInMs != null && expiresInMs <= 0)
71
+ return r("warning", `${sats(balance)} on hand — a tranche is at or past expiry.`);
72
+ const expiring = bal.expiring_within_24h_sats ?? 0;
73
+ if ((expiresInMs != null && expiresInMs < WARNING_WITHIN_MS) || expiring > 0) {
74
+ const when = expiresInMs != null ? `next expiry in about ${durationText(Math.floor(expiresInMs / 1000))}` : "tranches expire within 24h";
75
+ const howMuch = expiring > 0 ? `${sats(expiring)} expiring; ` : "";
76
+ return r("warning", `${sats(balance)} on hand — ${howMuch}${when}. Unused credits evaporate.`);
77
+ }
78
+ if (balance <= low)
79
+ return r("warning", `${sats(balance)} on hand — running low.`);
80
+ const tranches = bal.active_tranches ?? 0;
81
+ return r("ok", `${sats(balance)} · ${tranches} active tranche${tranches === 1 ? "" : "s"}.`);
82
+ }
83
+ // ── Operator ────────────────────────────────────────────────────────────────
84
+ /**
85
+ * Is the signed-in npub this operator? The rule every copy used: the patron's
86
+ * npub must equal the operator npub the server reports (`session_status`).
87
+ * It only decides what a page shows — the server still enforces its own ACL.
88
+ */
89
+ export function isOperator(npub, status) {
90
+ return !!npub && npub === status?.operator_npub;
91
+ }
92
+ /** The credentials the operator still has to deliver by Secure Courier. */
93
+ export function composeOnboardingRow(onb, checkedAt) {
94
+ const r = (state, detail) => row("credentials", "Operator credentials", state, detail, checkedAt);
95
+ if (onb.vault_situation)
96
+ return r("warning", onb.vault_situation);
97
+ if (onb.error)
98
+ return r("warning", onb.error);
99
+ const missing = (onb.missing ?? []).map((m) => m.field);
100
+ if (missing.length)
101
+ return r("blocked", `Missing via Secure Courier: ${missing.join(", ")}.`);
102
+ const optional = (onb.optional_missing ?? []).map((m) => m.field);
103
+ if (optional.length)
104
+ return r("warning", `Optional, not delivered: ${optional.join(", ")}.`);
105
+ if (onb.ready)
106
+ return r("ok", "Every required credential is delivered.");
107
+ return r("warning", onb.summary || "Couldn't tell which credentials are delivered.");
108
+ }
109
+ /** The operator's balance at its Authority — what certifies patron top-ups. */
110
+ export function composeAuthorityRow(bal, checkedAt, thresholds = {}) {
111
+ const r = (state, detail) => row("authority-balance", "Authority balance", state, detail, checkedAt);
112
+ if (bal.success === false || bal.error)
113
+ return r("warning", bal.error || "Authority balance check failed.");
114
+ const balance = bal.balance_api_sats;
115
+ if (balance == null)
116
+ return r("warning", "The Authority did not return a balance.");
117
+ const { low = 0, empty = 0 } = thresholds;
118
+ if (balance <= empty)
119
+ return r("blocked", "Tax balance is empty — patron top-ups cannot be certified until you fund the Authority.");
120
+ if (balance <= low)
121
+ return r("warning", `${sats(balance)} left to certify patron purchases — running low.`);
122
+ return r("ok", `${sats(balance)} available to certify patron purchases.`);
123
+ }
124
+ /** The operator's database, from `session_status`'s lifecycle and the vault. */
125
+ export function composePersistenceRow(life, svc, onb, checkedAt) {
126
+ const r = (state, detail) => row("persistence", "Neon persistence", state, detail, checkedAt);
127
+ const lc = life.lifecycle;
128
+ if (lc === "quota_exceeded")
129
+ return r("blocked", life.message || "Database quota exceeded — paid tools are locked until capacity is restored.");
130
+ if (lc === "misconfigured")
131
+ return r("blocked", life.message || "Persistence misconfigured — repair the database before paid tools will work.");
132
+ if (lc === "warming_up" || lc === "not_registered" || lc === "no_identity")
133
+ return r("warning", life.message || `Operator lifecycle: ${lc}.`);
134
+ if (svc.vault_configured === false || onb.vault_ok === false)
135
+ return r("blocked", "The vault is not configured — the operator cannot serve paid calls.");
136
+ return r("ok", lc ? `Lifecycle ${lc} — vault reachable.` : "Vault reachable.");
137
+ }
138
+ /**
139
+ * Background-job dispatch, for an operator that runs any: null when
140
+ * `service_status` carries no job diagnostics (the server runs none).
141
+ */
142
+ export function composeDurableJobsRow(svc, checkedAt) {
143
+ const dj = svc.durable_jobs;
144
+ const aj = svc.async_jobs;
145
+ if (!dj && !aj)
146
+ return null;
147
+ const r = (state, detail) => row("durable-jobs", "Durable job dispatch", state, detail, checkedAt);
148
+ if (dj?.detached_executor_error)
149
+ return r("blocked", `Detached executor error: ${dj.detached_executor_error}`);
150
+ const active = !!dj?.detached_executor_active;
151
+ const resolved = dj?.detached_executor_resolved !== false;
152
+ const durable = aj?.durable_across_recycles;
153
+ if (active && resolved && durable !== false)
154
+ return r("ok", `Detached executor active${aj?.backend ? ` · docket ${aj.backend}` : ""}.`);
155
+ if (active && durable === false)
156
+ return r("warning", "The executor is up, but the job backend does not survive a recycle — long jobs may be lost.");
157
+ if (!active)
158
+ return r("warning", "In-process jobs only — a long job may be cut off by a recycle.");
159
+ return r("warning", "Detached executor present but not fully resolved.");
160
+ }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,10 @@ export { isTransportFailure } from "./networkError.ts";
15
15
  export * from "./wallet.ts";
16
16
  export * from "./table.ts";
17
17
  export * from "./theme.ts";
18
+ export * from "./timezone.ts";
19
+ export { usageFacts, type ToolSpend, type UsageFacts } from "./usage.ts";
20
+ export { buildFacts, type BuildFacts } from "./buildInfo.ts";
21
+ export * from "./funding.ts";
18
22
  export { errorReport } from "./errorReport.ts";
19
23
  export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
20
24
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
package/dist/index.js CHANGED
@@ -14,6 +14,10 @@ export { isTransportFailure } from "./networkError.js";
14
14
  export * from "./wallet.js";
15
15
  export * from "./table.js";
16
16
  export * from "./theme.js";
17
+ export * from "./timezone.js";
18
+ export { usageFacts } from "./usage.js";
19
+ export { buildFacts } from "./buildInfo.js";
20
+ export * from "./funding.js";
17
21
  export { errorReport } from "./errorReport.js";
18
22
  export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
19
23
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Build & license: what is running and under what terms — the front end's
3
+ * version and commit (the site's, passed in, since only its bundler knows
4
+ * them), the MCP server's version, wheel and deployed commit linked to its
5
+ * repository (from `service_status`), the Tollbooth-DPYC™ links, the licence,
6
+ * and the patent and trademark notice.
7
+ *
8
+ * Mechanics only: every visual choice is the site's, through `classNames`;
9
+ * `intro` carries the site's own words and `children` any rows of its own,
10
+ * drawn after the MCP server section. Pass `status` when the site already
11
+ * holds a `service_status` answer; without it the panel asks once.
12
+ */
13
+ import { type ReactNode } from "react";
14
+ import { type ServiceStatus } from "../standardTools.ts";
15
+ export interface BuildInfoFrontend {
16
+ version?: string;
17
+ commit?: string;
18
+ builtAt?: string;
19
+ /** The front end's repository, an https URL. */
20
+ source?: string;
21
+ }
22
+ export interface BuildInfoLicence {
23
+ name: string;
24
+ href?: string;
25
+ }
26
+ export interface BuildInfoPanelClassNames {
27
+ root?: string;
28
+ heading?: string;
29
+ intro?: string;
30
+ /** Each section's label: Frontend, MCP server, … */
31
+ section?: string;
32
+ row?: string;
33
+ label?: string;
34
+ value?: string;
35
+ /** Added to a value that is a link. */
36
+ link?: string;
37
+ }
38
+ export interface BuildInfoPanelProps {
39
+ /** A `service_status` answer the site already has; omitted, the panel fetches one. */
40
+ status?: ServiceStatus | null;
41
+ frontend?: BuildInfoFrontend;
42
+ /** Default "Build & license". */
43
+ heading?: ReactNode;
44
+ intro?: ReactNode;
45
+ /** Default Apache 2.0, the licence of every DPYC repository. */
46
+ licence?: BuildInfoLicence;
47
+ children?: ReactNode;
48
+ classNames?: BuildInfoPanelClassNames;
49
+ }
50
+ export default function BuildInfoPanel({ status, frontend, heading, intro, licence, children, classNames: c, }: BuildInfoPanelProps): import("react").JSX.Element;