@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 +65 -4
- package/dist/buildInfo.d.ts +21 -0
- package/dist/buildInfo.js +35 -0
- package/dist/client.js +4 -4
- package/dist/debugLog.d.ts +4 -2
- package/dist/debugLog.js +59 -16
- package/dist/funding.d.ts +73 -0
- package/dist/funding.js +160 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/react/BuildInfoPanel.d.ts +50 -0
- package/dist/react/BuildInfoPanel.js +49 -0
- package/dist/react/CouponsPanel.js +1 -4
- package/dist/react/FundingStatus.d.ts +67 -0
- package/dist/react/FundingStatus.js +120 -0
- package/dist/react/PagedTable.js +1 -4
- package/dist/react/TableFilter.js +1 -4
- package/dist/react/TimezonePicker.d.ts +30 -0
- package/dist/react/TimezonePicker.js +19 -0
- package/dist/react/UsageSummary.d.ts +55 -0
- package/dist/react/UsageSummary.js +53 -0
- package/dist/react/WalletPage.js +1 -4
- package/dist/react/cx.d.ts +2 -0
- package/dist/react/cx.js +5 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.js +5 -0
- package/dist/react/useTimezone.d.ts +8 -0
- package/dist/react/useTimezone.js +34 -0
- package/dist/timezone.d.ts +82 -0
- package/dist/timezone.js +235 -0
- package/dist/usage.d.ts +34 -0
- package/dist/usage.js +53 -0
- package/package.json +1 -1
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`, `
|
|
14
|
-
`
|
|
15
|
-
`
|
|
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
|
-
|
|
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);
|
package/dist/debugLog.d.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* that flips the view can still be read and copied afterward.
|
|
8
8
|
*
|
|
9
9
|
* Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
|
|
10
|
-
* token,
|
|
11
|
-
* password, api_key, …)
|
|
10
|
+
* token, the value of any secret-named field (dpop_token, proof, poison,
|
|
11
|
+
* password, api_key, …), and a secret inside a URL (a query token, a
|
|
12
|
+
* bearer-like path segment, userinfo) never reach the buffer, the clipboard
|
|
13
|
+
* or storage.
|
|
12
14
|
* That is the one guarantee a panel relies on — it renders what is here.
|
|
13
15
|
*
|
|
14
16
|
* Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
|
package/dist/debugLog.js
CHANGED
|
@@ -7,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,
|
|
11
|
-
* password, api_key, …)
|
|
10
|
+
* token, the value of any secret-named field (dpop_token, proof, poison,
|
|
11
|
+
* password, api_key, …), and a secret inside a URL (a query token, a
|
|
12
|
+
* bearer-like path segment, userinfo) never reach the buffer, the clipboard
|
|
13
|
+
* or storage.
|
|
12
14
|
* That is the one guarantee a panel relies on — it renders what is here.
|
|
13
15
|
*
|
|
14
16
|
* Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
|
|
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(
|
|
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
|
-
|
|
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;
|
package/dist/funding.js
ADDED
|
@@ -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;
|