@tollbooth-dpyc/web 2.0.1 → 2.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.
package/dist/holder.d.ts CHANGED
@@ -5,26 +5,18 @@
5
5
  * the connection's OAuth tokens and names the npub on each call.
6
6
  *
7
7
  * Becoming a holder is the OAuth Authorization Code flow every MCP client
8
- * runs: register, send the browser to the operator's `/authorize` (it approves
9
- * at once, since a holder holds nothing until a DM proves an npub into it),
10
- * and exchange the code it hands back. Tokens refresh on their own; a refresh
11
- * token is single-use, so each refresh stores the pair it returns.
8
+ * runs, done by fetch: register, ask the operator's `/authorize` for a code
9
+ * (it approves at once, since a holder holds nothing until a DM proves an npub
10
+ * into it, and answers a page in JSON), and exchange the code. The page never
11
+ * leaves itself. Tokens refresh on their own; a refresh token is single-use,
12
+ * so each refresh stores the pair it returns.
12
13
  */
13
14
  /** True when this site connects as a holder (its operator supports it). */
14
15
  export declare function holderEnabled(): boolean;
15
16
  /** True when this tab already is a holder. */
16
17
  export declare function isHolder(): boolean;
17
- /**
18
- * Become a holder: register and send the browser to the operator, which
19
- * sends it straight back. `resume` comes back from `completeHolderConnect`.
20
- */
21
- export declare function connectAsHolder(resume: string): Promise<void>;
22
- /**
23
- * On return from the operator: exchange the code, store the tokens, tidy the
24
- * address bar, and hand back what the sign-in was doing. Null when this page
25
- * load is not a return.
26
- */
27
- export declare function completeHolderConnect(): Promise<string | null>;
18
+ /** Become a holder: register, take a code from the operator by fetch, and exchange it. */
19
+ export declare function connectAsHolder(): Promise<void>;
28
20
  /** The bearer for the next request, refreshed when it is about to lapse; null when not a holder. */
29
21
  export declare function holderBearer(): Promise<string | null>;
30
22
  /** Disconnect: the operator forgets this holder and every npub it held. */
package/dist/holder.js CHANGED
@@ -5,15 +5,15 @@
5
5
  * the connection's OAuth tokens and names the npub on each call.
6
6
  *
7
7
  * Becoming a holder is the OAuth Authorization Code flow every MCP client
8
- * runs: register, send the browser to the operator's `/authorize` (it approves
9
- * at once, since a holder holds nothing until a DM proves an npub into it),
10
- * and exchange the code it hands back. Tokens refresh on their own; a refresh
11
- * token is single-use, so each refresh stores the pair it returns.
8
+ * runs, done by fetch: register, ask the operator's `/authorize` for a code
9
+ * (it approves at once, since a holder holds nothing until a DM proves an npub
10
+ * into it, and answers a page in JSON), and exchange the code. The page never
11
+ * leaves itself. Tokens refresh on their own; a refresh token is single-use,
12
+ * so each refresh stores the pair it returns.
12
13
  */
13
14
  import { tollboothConfig } from "./config.js";
14
15
  import { readStored, removeStored, writeStored } from "./storage.js";
15
16
  const TOKENS = "holder:v1";
16
- const PENDING = "holder_pending:v1";
17
17
  /** Refresh this long before the access token lapses. */
18
18
  const REFRESH_EARLY_MS = 60_000;
19
19
  function operator() {
@@ -26,12 +26,9 @@ function operator() {
26
26
  export function holderEnabled() {
27
27
  return Boolean(tollboothConfig().operatorUrl);
28
28
  }
29
- /**
30
- * Back to the page the sign-in started on, since that page finishes it: a site
31
- * whose `/` is not the sign-in page would otherwise drop the code on the floor.
32
- */
29
+ /** The address the code is issued to. Registered and named, never visited. */
33
30
  function redirectUri() {
34
- return `${globalThis.location.origin}${globalThis.location.pathname}`;
31
+ return `${globalThis.location.origin}/`;
35
32
  }
36
33
  function readTokens() {
37
34
  try {
@@ -53,13 +50,13 @@ async function challengeFor(verifier) {
53
50
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
54
51
  return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
55
52
  }
56
- async function register(redirect) {
53
+ async function register() {
57
54
  const r = await fetch(`${operator()}/register`, {
58
55
  method: "POST",
59
56
  headers: { "Content-Type": "application/json" },
60
57
  body: JSON.stringify({
61
58
  client_name: tollboothConfig().appName,
62
- redirect_uris: [redirect],
59
+ redirect_uris: [redirectUri()],
63
60
  grant_types: ["authorization_code", "refresh_token"],
64
61
  response_types: ["code"],
65
62
  token_endpoint_auth_method: "none",
@@ -89,47 +86,33 @@ async function token(form, clientId) {
89
86
  export function isHolder() {
90
87
  return readTokens() !== null;
91
88
  }
92
- /**
93
- * Become a holder: register and send the browser to the operator, which
94
- * sends it straight back. `resume` comes back from `completeHolderConnect`.
95
- */
96
- export async function connectAsHolder(resume) {
97
- const redirect = redirectUri();
98
- const clientId = readTokens()?.clientId || (await register(redirect));
89
+ /** Become a holder: register, take a code from the operator by fetch, and exchange it. */
90
+ export async function connectAsHolder() {
91
+ const clientId = readTokens()?.clientId || (await register());
99
92
  const verifier = randomUrlSafe(48);
100
93
  const state = randomUrlSafe(16);
101
- const pending = { clientId, verifier, state, resume, redirectUri: redirect };
102
- globalThis.sessionStorage.setItem(PENDING, JSON.stringify(pending));
103
94
  const url = new URL(`${operator()}/authorize`);
104
95
  url.search = new URLSearchParams({
105
96
  response_type: "code",
106
97
  client_id: clientId,
107
- redirect_uri: redirect,
98
+ redirect_uri: redirectUri(),
108
99
  state,
109
100
  code_challenge: await challengeFor(verifier),
110
101
  code_challenge_method: "S256",
111
102
  resource: `${operator()}/mcp`,
112
103
  }).toString();
113
- globalThis.location.assign(url.toString());
114
- }
115
- /**
116
- * On return from the operator: exchange the code, store the tokens, tidy the
117
- * address bar, and hand back what the sign-in was doing. Null when this page
118
- * load is not a return.
119
- */
120
- export async function completeHolderConnect() {
121
- const params = new URLSearchParams(globalThis.location.search);
122
- const code = params.get("code");
123
- const raw = globalThis.sessionStorage.getItem(PENDING);
124
- if (!code || !raw)
125
- return null;
126
- globalThis.sessionStorage.removeItem(PENDING);
127
- const pending = JSON.parse(raw);
128
- globalThis.history.replaceState(null, "", globalThis.location.pathname);
129
- if (params.get("state") !== pending.state)
104
+ const r = await fetch(url, { headers: { Accept: "application/json" } });
105
+ const answer = await r.json().catch(() => ({}));
106
+ if (!r.ok || typeof answer.redirect_uri !== "string") {
107
+ throw new Error(answer.error_description || `The service would not connect this page (${r.status}).`);
108
+ }
109
+ const issued = new URL(answer.redirect_uri).searchParams;
110
+ if (issued.get("state") !== state)
130
111
  throw new Error("The service's reply did not match this sign-in.");
131
- writeTokens(await token({ grant_type: "authorization_code", code, redirect_uri: pending.redirectUri, code_verifier: pending.verifier }, pending.clientId));
132
- return pending.resume;
112
+ const code = issued.get("code");
113
+ if (!code)
114
+ throw new Error(issued.get("error_description") || "The service did not connect this page.");
115
+ writeTokens(await token({ grant_type: "authorization_code", code, redirect_uri: redirectUri(), code_verifier: verifier }, clientId));
133
116
  }
134
117
  /** The bearer for the next request, refreshed when it is about to lapse; null when not a holder. */
135
118
  export async function holderBearer() {
@@ -19,7 +19,7 @@ import { useEffect, useRef, useState } from "react";
19
19
  import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
20
20
  import { tollboothConfig } from "../config.js";
21
21
  import { forgetRecentLogin, getLastTypedNpub, getValidRecentLogins, recordRecentLogin, setLastTypedNpub, setStoredNpub, } from "../identity.js";
22
- import { completeHolderConnect, connectAsHolder, holderEnabled, isHolder } from "../holder.js";
22
+ import { connectAsHolder, holderEnabled, isHolder } from "../holder.js";
23
23
  import { setSessionNsec } from "../sessionNsec.js";
24
24
  import { readSignInFailure } from "../signInSituation.js";
25
25
  import { receiveNpubProof, requestNpubProof } from "../standardTools.js";
@@ -109,24 +109,8 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
109
109
  setError(`Couldn't read that nsec: ${e.message}`);
110
110
  }
111
111
  }
112
- // Back from connecting to the operator: carry on with the DM it was for.
113
- const resumed = useRef(false);
114
- useEffect(() => {
115
- if (resumed.current)
116
- return;
117
- resumed.current = true;
118
- completeHolderConnect()
119
- .then((npub) => {
120
- if (npub) {
121
- setValue(npub);
122
- void begin(npub);
123
- }
124
- })
125
- .catch((e) => setError(e.message));
126
- // eslint-disable-next-line react-hooks/exhaustive-deps
127
- }, []);
128
- async function begin(npubArg) {
129
- const npubToProve = npubArg ?? trimmed;
112
+ async function begin() {
113
+ const npubToProve = trimmed;
130
114
  if (!(npubToProve.startsWith("npub1") && npubToProve.length >= 60)) {
131
115
  setError("Enter a valid npub1… key.");
132
116
  return;
@@ -135,15 +119,13 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
135
119
  setError("");
136
120
  try {
137
121
  // The proof will stay with the operator, so this tab connects to it
138
- // first; the browser comes straight back here and the DM goes out.
122
+ // first, by fetch, and the DM goes out.
139
123
  if (!holderEnabled()) {
140
124
  setError("This site signs in with a key only — paste an nsec, or make one.");
141
125
  return;
142
126
  }
143
- if (!isHolder()) {
144
- await connectAsHolder(npubToProve);
145
- return;
146
- }
127
+ if (!isHolder())
128
+ await connectAsHolder();
147
129
  const r = await requestNpubProof(npubToProve, globalThis.location.origin, `You requested to sign in to ${appName} (${globalThis.location.host}).`);
148
130
  if (r.error)
149
131
  return setError(r.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lonnie VanZandt",