@sweepconsole/js 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sweep Console
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # @sweepconsole/js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A **Subscribe with USDC** button for your checkout. It asks your server for a URL and sends the payer there.
4
+
5
+ ~3KB gzipped, no dependencies.
6
+
7
+ ```html
8
+ <script src="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.1.0/dist/sweep.js"></script>
9
+ <button id="pay-usdc">Subscribe with USDC</button>
10
+
11
+ <script>
12
+ Sweep.mount("#pay-usdc", { session: "/api/sweep/session" });
13
+ </script>
14
+ ```
15
+
16
+ ```js
17
+ // your server — the payer's id comes from the cookie, never from the page
18
+ import Sweep from "@sweepconsole/node";
19
+ const sweep = new Sweep(process.env.SWEEP_SECRET_KEY);
20
+
21
+ // A plan you made in the Sweep portal:
22
+ app.post("/api/sweep/session", requireLogin, async (req, res) => {
23
+ const session = await sweep.checkout.sessions.create({
24
+ plan: "plan_pro",
25
+ externalRef: req.user.id,
26
+ successUrl: "https://app.example.com/welcome",
27
+ cancelUrl: "https://app.example.com/pricing",
28
+ });
29
+ res.json({ url: session.url });
30
+ });
31
+
32
+ // Or, on the external rail, where you do the billing yourself:
33
+ // const mandate = await sweep.mandates.create({
34
+ // externalRef: req.user.id,
35
+ // maxAmount: usdc("35.00"), // the ceiling, with headroom — not the price
36
+ // interval: "monthly",
37
+ // chains: ["base", "arbitrum", "optimism"],
38
+ // expiresAt: addYears(new Date(), 1),
39
+ // });
40
+ // res.json({ url: mandate.authorizationUrl });
41
+ ```
42
+
43
+ That is the whole integration. The payer signs in their wallet on Sweep's hosted
44
+ page, and `mandate.authorized` arrives on your webhook endpoint.
45
+
46
+ ## Why it will not call Sweep for you
47
+
48
+ This package has no API key and cannot create a session. Everything a browser
49
+ sends is something the person at the browser can change, so an `externalRef`
50
+ that came from the page is a user id the customer chose — and on this rail a
51
+ mandate is *standing* authority, so a forged one misattributes a recurring
52
+ charge rather than one payment. Your server already knows who is logged in.
53
+
54
+ For a public link — a pricing page, an invoice, a QR code — there is no identity
55
+ to protect, and a plain anchor is enough:
56
+
57
+ ```html
58
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.1.0/dist/button.css">
59
+ <a class="sweep-pay" href="https://pay.sweepconsole.com/pay/plink_a1b2c3?ref=user_42">
60
+ Subscribe with USDC
61
+ </a>
62
+ ```
63
+
64
+ ## Install
65
+
66
+ ```sh
67
+ npm i @sweepconsole/js
68
+ ```
69
+
70
+ ```js
71
+ import { mount } from "@sweepconsole/js";
72
+ import "@sweepconsole/js/button.css"; // or let mount() inject it
73
+
74
+ mount("#pay-usdc", { session: "/api/sweep/session" });
75
+ ```
76
+
77
+ ## Options
78
+
79
+ | Option | Default | |
80
+ | --- | --- | --- |
81
+ | `session` | *required* | Your endpoint, POSTed to, answering `{ url }`. A function returning the URL also works. |
82
+ | `label` | the element's own text, else `"Subscribe with USDC"` | |
83
+ | `busyLabel` | `"Starting…"` | |
84
+ | `variant` | `"solid"` | `"solid"`, `"outline"`, `"light"` |
85
+ | `size` | `"md"` | `"sm"` for a compact row |
86
+ | `block` | `false` | Full width, for a checkout column |
87
+ | `body` | — | Extra JSON for your endpoint |
88
+ | `headers` | — | Extra headers, e.g. a CSRF token |
89
+ | `credentials` | `"same-origin"` | So your session cookie is sent |
90
+ | `injectStyles` | `true` | Set `false` under a CSP that forbids inline styles and link `button.css` |
91
+ | `onError` | logs | The button returns to rest first, so the payer can retry. Tell them something. |
92
+ | `onRedirect` | — | Return `false` to handle the navigation yourself |
93
+
94
+ `mount()` returns `{ element, destroy() }`.
95
+
96
+ Your endpoint's reply is read liberally: `url`, `checkout_url`,
97
+ `authorization_url`, their camelCase forms, and any of them nested under `data`.
98
+ An endpoint that forwards Sweep's own response verbatim works unchanged.
99
+
100
+ ## Theming
101
+
102
+ Four custom properties, and `font: inherit` so it never arrives in a typeface
103
+ you did not choose.
104
+
105
+ ```css
106
+ .sweep-pay {
107
+ --sweep-radius: 10px;
108
+ --sweep-bg: #111827;
109
+ --sweep-fg: #fff;
110
+ --sweep-border: #111827;
111
+ }
112
+ ```
113
+
114
+ ## Say "Subscribe", not "Pay"
115
+
116
+ Authorizing on this rail signs a standing permission with a monthly ceiling, not
117
+ a single payment. A payer who read "Pay $29" and then meets a wallet asking for
118
+ recurring authority has been misled by the button, and that is the moment they
119
+ abandon the checkout. Use "Pay" only if you are charging once and never again.
120
+
121
+ ## What it deliberately does not do
122
+
123
+ - **No iframe.** The hosted page needs a wallet, and injected providers are
124
+ unreliable inside a cross-origin frame. The button navigates the top level.
125
+ - **No wallet detection.** It never probes for `window.ethereum` to decide
126
+ whether to render. A mobile wallet browser fails that test, and the hosted
127
+ page handles a missing wallet better than a hidden button does.
128
+ - **No fulfilment signal.** Returning to your site means the payer came back,
129
+ nothing more. Grant access on `mandate.authorized`.
130
+
131
+ ## Tests
132
+
133
+ ```sh
134
+ node test/button.test.mjs
135
+ ```
136
+
137
+ Runs the built `dist/sweep.js` against a small DOM stub — the redirect, the
138
+ error path, the in-flight click guard, and style injection. Build first.
@@ -0,0 +1,16 @@
1
+ /* @sweepconsole/js — the button, standalone. Generated from src/index.ts; edit it there. */
2
+ .sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}
3
+ .sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}
4
+ .sweep-pay:active{transform:translateY(0);box-shadow:none}
5
+ .sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}
6
+ .sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}
7
+ .sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}
8
+ .sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}
9
+ .sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}
10
+ .sweep-pay--block{display:flex;width:100%}
11
+ .sweep-pay__mark,.sweep-pay__spin{flex:none}
12
+ .sweep-pay__spin{display:none}
13
+ .sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}
14
+ .sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}
15
+ @keyframes sweep-pay-spin{to{transform:rotate(360deg)}}
16
+ @media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @sweepconsole/js — the browser half of the rail.
3
+ *
4
+ * One button. It asks YOUR server for a URL and sends the payer there.
5
+ *
6
+ * It deliberately cannot talk to Sweep. Everything a browser sends is
7
+ * something the person at the browser can change, so a payer id that came from
8
+ * the page is a payer id the customer picked — and on this rail a mandate is
9
+ * standing authority, so a forged one misattributes a recurring charge rather
10
+ * than a single payment. The server half (`@sweepconsole/node`) holds the
11
+ * secret key, reads the id from its own session, and hands back a URL. All this
12
+ * file does is fetch that URL and follow it.
13
+ *
14
+ * No dependencies, no build step for the consumer, and no module system
15
+ * required: it is published as ESM for npm and as a classic script that defines
16
+ * `window.Sweep` for a CDN tag.
17
+ *
18
+ * IMPORTANT, if you edit this file: the CDN build is produced by wrapping tsc's
19
+ * output in an IIFE, which works only while this stays ONE file with no
20
+ * `import` and no `export { … }` block. scripts/build-cdn.mjs asserts both and
21
+ * fails the build rather than shipping something broken.
22
+ */
23
+ /** How the button looks. `solid` is the default. */
24
+ export type SweepVariant = "solid" | "outline" | "light";
25
+ export interface MountOptions {
26
+ /**
27
+ * Your endpoint, which creates the session and returns a URL. Called with
28
+ * POST. Must answer `{ url }` — `checkout_url`, `authorization_url`, and any
29
+ * of those nested under `data`, are also accepted, so an endpoint that simply
30
+ * forwards Sweep's own response works unchanged.
31
+ *
32
+ * A function is also allowed, for a page that already has its own fetching
33
+ * conventions: return the URL (or a promise of it) and nothing is fetched
34
+ * here.
35
+ */
36
+ session: string | (() => string | Promise<string>);
37
+ /**
38
+ * The button's text. Defaults to the element's existing text, or
39
+ * "Subscribe with USDC".
40
+ *
41
+ * Prefer "Subscribe" to "Pay" unless you are charging once. Authorizing on
42
+ * this rail signs a standing permission with a ceiling, and a payer who read
43
+ * "Pay" meets a wallet asking for more than they agreed to.
44
+ */
45
+ label?: string;
46
+ /** Shown while your endpoint is working. Defaults to "Starting…". */
47
+ busyLabel?: string;
48
+ variant?: SweepVariant;
49
+ size?: "md" | "sm";
50
+ /** Fill the container's width, for a checkout column. */
51
+ block?: boolean;
52
+ /** Extra JSON to post to your endpoint. */
53
+ body?: Record<string, unknown>;
54
+ /** Extra headers, e.g. a CSRF token. */
55
+ headers?: Record<string, string>;
56
+ /** Defaults to "same-origin", so your session cookie is sent. */
57
+ credentials?: RequestCredentials;
58
+ /**
59
+ * Set false if your Content-Security-Policy forbids inline styles; link
60
+ * `button.css` from the package instead.
61
+ */
62
+ injectStyles?: boolean;
63
+ /**
64
+ * Called when the session could not be created. The button returns to its
65
+ * resting state first, so the payer can try again. Without one, the error is
66
+ * logged — tell the payer something instead.
67
+ */
68
+ onError?: (error: Error) => void;
69
+ /**
70
+ * Called with the URL just before leaving the page. Return `false` to take
71
+ * over the navigation yourself.
72
+ */
73
+ onRedirect?: (url: string) => void | boolean;
74
+ }
75
+ export interface SweepButton {
76
+ /** The button element, mounted or created. */
77
+ readonly element: HTMLElement;
78
+ /** Remove the handler and undo what mounting changed. */
79
+ destroy(): void;
80
+ }
81
+ /**
82
+ * The stylesheet, also published as `button.css` for anyone who would rather
83
+ * link it. Four custom properties are the whole theming surface, and
84
+ * `font: inherit` means the button never arrives in a typeface the merchant
85
+ * did not choose.
86
+ */
87
+ export declare const BUTTON_CSS = ".sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}\n.sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}\n.sweep-pay:active{transform:translateY(0);box-shadow:none}\n.sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}\n.sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}\n.sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}\n.sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}\n.sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}\n.sweep-pay--block{display:flex;width:100%}\n.sweep-pay__mark,.sweep-pay__spin{flex:none}\n.sweep-pay__spin{display:none}\n.sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}\n.sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}\n@keyframes sweep-pay-spin{to{transform:rotate(360deg)}}\n@media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}";
88
+ /**
89
+ * Turn an element into the button, or create one inside a container.
90
+ *
91
+ * A `<button>` or `<a>` is used as-is, so the merchant's own markup and any
92
+ * framework ref survive. Anything else gets a real `<button>` appended —
93
+ * never a styled div, which cannot be reached by keyboard.
94
+ */
95
+ export declare function mount(target: string | Element, opts: MountOptions): SweepButton;
96
+ /** The global the CDN build defines. Same functions, one name to reach them. */
97
+ export declare const Sweep: {
98
+ mount: typeof mount;
99
+ BUTTON_CSS: string;
100
+ };
package/dist/index.js ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @sweepconsole/js — the browser half of the rail.
3
+ *
4
+ * One button. It asks YOUR server for a URL and sends the payer there.
5
+ *
6
+ * It deliberately cannot talk to Sweep. Everything a browser sends is
7
+ * something the person at the browser can change, so a payer id that came from
8
+ * the page is a payer id the customer picked — and on this rail a mandate is
9
+ * standing authority, so a forged one misattributes a recurring charge rather
10
+ * than a single payment. The server half (`@sweepconsole/node`) holds the
11
+ * secret key, reads the id from its own session, and hands back a URL. All this
12
+ * file does is fetch that URL and follow it.
13
+ *
14
+ * No dependencies, no build step for the consumer, and no module system
15
+ * required: it is published as ESM for npm and as a classic script that defines
16
+ * `window.Sweep` for a CDN tag.
17
+ *
18
+ * IMPORTANT, if you edit this file: the CDN build is produced by wrapping tsc's
19
+ * output in an IIFE, which works only while this stays ONE file with no
20
+ * `import` and no `export { … }` block. scripts/build-cdn.mjs asserts both and
21
+ * fails the build rather than shipping something broken.
22
+ */
23
+ const STYLE_ID = "sweep-pay-styles";
24
+ /* The USDC mark and the busy spinner. Inlined because a button that waits on a
25
+ network request to look like itself is worse than one extra kilobyte. */
26
+ const COIN_SVG = '<svg class="sweep-pay__mark" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
27
+ '<circle cx="12" cy="12" r="11" fill="#2775ca"/>' +
28
+ '<path d="M12 5.3v1.4c2 .2 3.3 1.3 3.5 2.9h-1.8c-.2-.8-.8-1.3-1.7-1.5v3.1c2.2.5 3.7 1.1 3.7 3 0 1.8-1.4 3-3.7 3.2v1.3h-1.3v-1.3c-2.2-.2-3.6-1.4-3.8-3.2h1.8c.2.9.9 1.5 2 1.7v-3.3C8.6 12.1 7.2 11.4 7.2 9.6c0-1.7 1.4-2.8 3.5-3V5.3H12zm-1.3 2.7c-1 .2-1.6.7-1.6 1.5 0 .8.5 1.2 1.6 1.5V8zm1.3 5.1v3.1c1.1-.2 1.7-.7 1.7-1.6 0-.8-.6-1.2-1.7-1.5z" fill="#fff"/>' +
29
+ "</svg>";
30
+ const SPINNER_SVG = '<svg class="sweep-pay__spin" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
31
+ '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-opacity=".3" stroke-width="2.5"/>' +
32
+ '<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>' +
33
+ "</svg>";
34
+ /**
35
+ * The stylesheet, also published as `button.css` for anyone who would rather
36
+ * link it. Four custom properties are the whole theming surface, and
37
+ * `font: inherit` means the button never arrives in a typeface the merchant
38
+ * did not choose.
39
+ */
40
+ export const BUTTON_CSS = `.sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}
41
+ .sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}
42
+ .sweep-pay:active{transform:translateY(0);box-shadow:none}
43
+ .sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}
44
+ .sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}
45
+ .sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}
46
+ .sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}
47
+ .sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}
48
+ .sweep-pay--block{display:flex;width:100%}
49
+ .sweep-pay__mark,.sweep-pay__spin{flex:none}
50
+ .sweep-pay__spin{display:none}
51
+ .sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}
52
+ .sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}
53
+ @keyframes sweep-pay-spin{to{transform:rotate(360deg)}}
54
+ @media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}`;
55
+ function injectStyles() {
56
+ if (typeof document === "undefined")
57
+ return;
58
+ if (document.getElementById(STYLE_ID))
59
+ return;
60
+ const style = document.createElement("style");
61
+ style.id = STYLE_ID;
62
+ style.textContent = BUTTON_CSS;
63
+ document.head.appendChild(style);
64
+ }
65
+ function resolveTarget(target) {
66
+ const el = typeof target === "string" ? document.querySelector(target) : target;
67
+ if (!el) {
68
+ // Loudly, because the usual cause is a script running before the element
69
+ // exists, and a button that silently never appears is hours of confusion.
70
+ throw new Error(`Sweep.mount: nothing matched ${JSON.stringify(target)}. ` +
71
+ `Run it after the element exists, or pass the element itself.`);
72
+ }
73
+ return el;
74
+ }
75
+ /** Liberal on the way in: anything that looks like the URL counts. */
76
+ function urlFrom(payload) {
77
+ if (typeof payload === "string")
78
+ return payload || null;
79
+ if (!payload || typeof payload !== "object")
80
+ return null;
81
+ const o = payload;
82
+ for (const key of ["url", "checkout_url", "authorization_url", "checkoutUrl", "authorizationUrl"]) {
83
+ const v = o[key];
84
+ if (typeof v === "string" && v)
85
+ return v;
86
+ }
87
+ // An endpoint that forwards Sweep's reply verbatim nests it under `data`.
88
+ if (o["data"] && typeof o["data"] === "object")
89
+ return urlFrom(o["data"]);
90
+ return null;
91
+ }
92
+ async function messageFrom(res) {
93
+ try {
94
+ const body = (await res.json());
95
+ const err = body["error"];
96
+ if (err && typeof err === "object") {
97
+ const m = err["message"];
98
+ if (typeof m === "string" && m)
99
+ return m;
100
+ }
101
+ if (typeof body["message"] === "string" && body["message"])
102
+ return body["message"];
103
+ }
104
+ catch {
105
+ /* not JSON; fall through to the status */
106
+ }
107
+ return `We couldn't start your payment (HTTP ${res.status}).`;
108
+ }
109
+ /**
110
+ * Refuse to navigate anywhere that could execute script.
111
+ *
112
+ * The URL arrives from the merchant's own endpoint, so this is not a defence
113
+ * against Sweep — it is a defence against that endpoint being made to reflect
114
+ * something. `location.assign("javascript:…")` runs the script in the
115
+ * merchant's page, with their session and their DOM, which turns a reflected
116
+ * value into stored XSS on a checkout.
117
+ *
118
+ * A relative URL carries no scheme and inherits the page's, so it needs no
119
+ * check. An absolute one must be http(s); another host is allowed, since
120
+ * plenty of merchants host checkout on a different one.
121
+ */
122
+ function checkedUrl(url) {
123
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url);
124
+ if (!scheme)
125
+ return url;
126
+ const protocol = (scheme[1] ?? "").toLowerCase();
127
+ if (protocol !== "http" && protocol !== "https") {
128
+ throw new Error(`Sweep: refusing to navigate to a ${protocol}: URL. Your endpoint must return an http(s) ` +
129
+ `address or a path.`);
130
+ }
131
+ return url;
132
+ }
133
+ async function fetchUrl(opts) {
134
+ if (typeof opts.session === "function") {
135
+ const url = await opts.session();
136
+ if (!url)
137
+ throw new Error("Sweep.mount: the session function returned no URL.");
138
+ return checkedUrl(url);
139
+ }
140
+ const hasBody = !!opts.body;
141
+ const res = await fetch(opts.session, {
142
+ method: "POST",
143
+ credentials: opts.credentials ?? "same-origin",
144
+ headers: {
145
+ accept: "application/json",
146
+ ...(hasBody ? { "content-type": "application/json" } : {}),
147
+ ...opts.headers,
148
+ },
149
+ ...(hasBody ? { body: JSON.stringify(opts.body) } : {}),
150
+ });
151
+ if (!res.ok)
152
+ throw new Error(await messageFrom(res));
153
+ const url = urlFrom(await res.json().catch(() => null));
154
+ if (!url) {
155
+ throw new Error("Sweep.mount: your endpoint answered without a URL. Return { url } — " +
156
+ "for the external rail that is the mandate's authorizationUrl.");
157
+ }
158
+ return checkedUrl(url);
159
+ }
160
+ /**
161
+ * Turn an element into the button, or create one inside a container.
162
+ *
163
+ * A `<button>` or `<a>` is used as-is, so the merchant's own markup and any
164
+ * framework ref survive. Anything else gets a real `<button>` appended —
165
+ * never a styled div, which cannot be reached by keyboard.
166
+ */
167
+ export function mount(target, opts) {
168
+ if (!opts || !opts.session) {
169
+ throw new Error("Sweep.mount: `session` is required — your endpoint that returns a URL.");
170
+ }
171
+ if (opts.injectStyles !== false)
172
+ injectStyles();
173
+ const found = resolveTarget(target);
174
+ const tag = found.tagName.toLowerCase();
175
+ const reuse = tag === "button" || tag === "a";
176
+ const el = reuse ? found : document.createElement("button");
177
+ if (!reuse)
178
+ found.appendChild(el);
179
+ if (el.tagName.toLowerCase() === "button" && !el.getAttribute("type")) {
180
+ // Without this it submits any form it happens to sit inside.
181
+ el.setAttribute("type", "button");
182
+ }
183
+ const restore = {
184
+ className: el.className,
185
+ html: el.innerHTML,
186
+ };
187
+ // The merchant's own text wins over our default, and an explicit `label`
188
+ // wins over both. An element with only whitespace in it counts as empty.
189
+ const existing = reuse ? el.textContent?.trim() : "";
190
+ const label = opts.label || existing || "Subscribe with USDC";
191
+ const busyLabel = opts.busyLabel ?? "Starting…";
192
+ el.classList.add("sweep-pay");
193
+ if (opts.variant === "outline")
194
+ el.classList.add("sweep-pay--outline");
195
+ if (opts.variant === "light")
196
+ el.classList.add("sweep-pay--light");
197
+ if (opts.size === "sm")
198
+ el.classList.add("sweep-pay--sm");
199
+ if (opts.block)
200
+ el.classList.add("sweep-pay--block");
201
+ // Our own constant markup, then the label by textContent — a merchant's
202
+ // string never reaches innerHTML.
203
+ el.innerHTML = COIN_SVG + SPINNER_SVG + '<span class="sweep-pay__label"></span>';
204
+ const labelEl = el.querySelector(".sweep-pay__label");
205
+ labelEl.textContent = label;
206
+ let busy = false;
207
+ const onClick = (event) => {
208
+ event.preventDefault();
209
+ // Covers the second click, and Enter on a button CSS has only made
210
+ // pointer-inert.
211
+ if (busy)
212
+ return;
213
+ busy = true;
214
+ el.setAttribute("aria-busy", "true");
215
+ labelEl.textContent = busyLabel;
216
+ void fetchUrl(opts)
217
+ .then((url) => {
218
+ if (opts.onRedirect && opts.onRedirect(url) === false) {
219
+ busy = false;
220
+ el.removeAttribute("aria-busy");
221
+ labelEl.textContent = label;
222
+ return;
223
+ }
224
+ // assign, not replace: the payer can come back with the back button.
225
+ window.location.assign(url);
226
+ })
227
+ .catch((e) => {
228
+ busy = false;
229
+ el.removeAttribute("aria-busy");
230
+ labelEl.textContent = label;
231
+ const error = e instanceof Error ? e : new Error(String(e));
232
+ if (opts.onError)
233
+ opts.onError(error);
234
+ else
235
+ console.error("[sweep] could not start the payment:", error);
236
+ });
237
+ };
238
+ el.addEventListener("click", onClick);
239
+ return {
240
+ element: el,
241
+ destroy() {
242
+ el.removeEventListener("click", onClick);
243
+ el.removeAttribute("aria-busy");
244
+ if (reuse) {
245
+ el.className = restore.className;
246
+ el.innerHTML = restore.html;
247
+ }
248
+ else {
249
+ el.remove();
250
+ }
251
+ },
252
+ };
253
+ }
254
+ /** The global the CDN build defines. Same functions, one name to reach them. */
255
+ export const Sweep = { mount, BUTTON_CSS };
package/dist/sweep.js ADDED
@@ -0,0 +1,261 @@
1
+ /* @sweepconsole/js — https://sweepconsole.com. MIT. */
2
+ (function (global) {
3
+ "use strict";
4
+ /**
5
+ * @sweepconsole/js — the browser half of the rail.
6
+ *
7
+ * One button. It asks YOUR server for a URL and sends the payer there.
8
+ *
9
+ * It deliberately cannot talk to Sweep. Everything a browser sends is
10
+ * something the person at the browser can change, so a payer id that came from
11
+ * the page is a payer id the customer picked — and on this rail a mandate is
12
+ * standing authority, so a forged one misattributes a recurring charge rather
13
+ * than a single payment. The server half (`@sweepconsole/node`) holds the
14
+ * secret key, reads the id from its own session, and hands back a URL. All this
15
+ * file does is fetch that URL and follow it.
16
+ *
17
+ * No dependencies, no build step for the consumer, and no module system
18
+ * required: it is published as ESM for npm and as a classic script that defines
19
+ * `window.Sweep` for a CDN tag.
20
+ *
21
+ * IMPORTANT, if you edit this file: the CDN build is produced by wrapping tsc's
22
+ * output in an IIFE, which works only while this stays ONE file with no
23
+ * `import` and no `export { … }` block. scripts/build-cdn.mjs asserts both and
24
+ * fails the build rather than shipping something broken.
25
+ */
26
+ const STYLE_ID = "sweep-pay-styles";
27
+ /* The USDC mark and the busy spinner. Inlined because a button that waits on a
28
+ network request to look like itself is worse than one extra kilobyte. */
29
+ const COIN_SVG = '<svg class="sweep-pay__mark" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
30
+ '<circle cx="12" cy="12" r="11" fill="#2775ca"/>' +
31
+ '<path d="M12 5.3v1.4c2 .2 3.3 1.3 3.5 2.9h-1.8c-.2-.8-.8-1.3-1.7-1.5v3.1c2.2.5 3.7 1.1 3.7 3 0 1.8-1.4 3-3.7 3.2v1.3h-1.3v-1.3c-2.2-.2-3.6-1.4-3.8-3.2h1.8c.2.9.9 1.5 2 1.7v-3.3C8.6 12.1 7.2 11.4 7.2 9.6c0-1.7 1.4-2.8 3.5-3V5.3H12zm-1.3 2.7c-1 .2-1.6.7-1.6 1.5 0 .8.5 1.2 1.6 1.5V8zm1.3 5.1v3.1c1.1-.2 1.7-.7 1.7-1.6 0-.8-.6-1.2-1.7-1.5z" fill="#fff"/>' +
32
+ "</svg>";
33
+ const SPINNER_SVG = '<svg class="sweep-pay__spin" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
34
+ '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-opacity=".3" stroke-width="2.5"/>' +
35
+ '<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>' +
36
+ "</svg>";
37
+ /**
38
+ * The stylesheet, also published as `button.css` for anyone who would rather
39
+ * link it. Four custom properties are the whole theming surface, and
40
+ * `font: inherit` means the button never arrives in a typeface the merchant
41
+ * did not choose.
42
+ */
43
+ const BUTTON_CSS = `.sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}
44
+ .sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}
45
+ .sweep-pay:active{transform:translateY(0);box-shadow:none}
46
+ .sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}
47
+ .sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}
48
+ .sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}
49
+ .sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}
50
+ .sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}
51
+ .sweep-pay--block{display:flex;width:100%}
52
+ .sweep-pay__mark,.sweep-pay__spin{flex:none}
53
+ .sweep-pay__spin{display:none}
54
+ .sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}
55
+ .sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}
56
+ @keyframes sweep-pay-spin{to{transform:rotate(360deg)}}
57
+ @media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}`;
58
+ function injectStyles() {
59
+ if (typeof document === "undefined")
60
+ return;
61
+ if (document.getElementById(STYLE_ID))
62
+ return;
63
+ const style = document.createElement("style");
64
+ style.id = STYLE_ID;
65
+ style.textContent = BUTTON_CSS;
66
+ document.head.appendChild(style);
67
+ }
68
+ function resolveTarget(target) {
69
+ const el = typeof target === "string" ? document.querySelector(target) : target;
70
+ if (!el) {
71
+ // Loudly, because the usual cause is a script running before the element
72
+ // exists, and a button that silently never appears is hours of confusion.
73
+ throw new Error(`Sweep.mount: nothing matched ${JSON.stringify(target)}. ` +
74
+ `Run it after the element exists, or pass the element itself.`);
75
+ }
76
+ return el;
77
+ }
78
+ /** Liberal on the way in: anything that looks like the URL counts. */
79
+ function urlFrom(payload) {
80
+ if (typeof payload === "string")
81
+ return payload || null;
82
+ if (!payload || typeof payload !== "object")
83
+ return null;
84
+ const o = payload;
85
+ for (const key of ["url", "checkout_url", "authorization_url", "checkoutUrl", "authorizationUrl"]) {
86
+ const v = o[key];
87
+ if (typeof v === "string" && v)
88
+ return v;
89
+ }
90
+ // An endpoint that forwards Sweep's reply verbatim nests it under `data`.
91
+ if (o["data"] && typeof o["data"] === "object")
92
+ return urlFrom(o["data"]);
93
+ return null;
94
+ }
95
+ async function messageFrom(res) {
96
+ try {
97
+ const body = (await res.json());
98
+ const err = body["error"];
99
+ if (err && typeof err === "object") {
100
+ const m = err["message"];
101
+ if (typeof m === "string" && m)
102
+ return m;
103
+ }
104
+ if (typeof body["message"] === "string" && body["message"])
105
+ return body["message"];
106
+ }
107
+ catch {
108
+ /* not JSON; fall through to the status */
109
+ }
110
+ return `We couldn't start your payment (HTTP ${res.status}).`;
111
+ }
112
+ /**
113
+ * Refuse to navigate anywhere that could execute script.
114
+ *
115
+ * The URL arrives from the merchant's own endpoint, so this is not a defence
116
+ * against Sweep — it is a defence against that endpoint being made to reflect
117
+ * something. `location.assign("javascript:…")` runs the script in the
118
+ * merchant's page, with their session and their DOM, which turns a reflected
119
+ * value into stored XSS on a checkout.
120
+ *
121
+ * A relative URL carries no scheme and inherits the page's, so it needs no
122
+ * check. An absolute one must be http(s); another host is allowed, since
123
+ * plenty of merchants host checkout on a different one.
124
+ */
125
+ function checkedUrl(url) {
126
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url);
127
+ if (!scheme)
128
+ return url;
129
+ const protocol = (scheme[1] ?? "").toLowerCase();
130
+ if (protocol !== "http" && protocol !== "https") {
131
+ throw new Error(`Sweep: refusing to navigate to a ${protocol}: URL. Your endpoint must return an http(s) ` +
132
+ `address or a path.`);
133
+ }
134
+ return url;
135
+ }
136
+ async function fetchUrl(opts) {
137
+ if (typeof opts.session === "function") {
138
+ const url = await opts.session();
139
+ if (!url)
140
+ throw new Error("Sweep.mount: the session function returned no URL.");
141
+ return checkedUrl(url);
142
+ }
143
+ const hasBody = !!opts.body;
144
+ const res = await fetch(opts.session, {
145
+ method: "POST",
146
+ credentials: opts.credentials ?? "same-origin",
147
+ headers: {
148
+ accept: "application/json",
149
+ ...(hasBody ? { "content-type": "application/json" } : {}),
150
+ ...opts.headers,
151
+ },
152
+ ...(hasBody ? { body: JSON.stringify(opts.body) } : {}),
153
+ });
154
+ if (!res.ok)
155
+ throw new Error(await messageFrom(res));
156
+ const url = urlFrom(await res.json().catch(() => null));
157
+ if (!url) {
158
+ throw new Error("Sweep.mount: your endpoint answered without a URL. Return { url } — " +
159
+ "for the external rail that is the mandate's authorizationUrl.");
160
+ }
161
+ return checkedUrl(url);
162
+ }
163
+ /**
164
+ * Turn an element into the button, or create one inside a container.
165
+ *
166
+ * A `<button>` or `<a>` is used as-is, so the merchant's own markup and any
167
+ * framework ref survive. Anything else gets a real `<button>` appended —
168
+ * never a styled div, which cannot be reached by keyboard.
169
+ */
170
+ function mount(target, opts) {
171
+ if (!opts || !opts.session) {
172
+ throw new Error("Sweep.mount: `session` is required — your endpoint that returns a URL.");
173
+ }
174
+ if (opts.injectStyles !== false)
175
+ injectStyles();
176
+ const found = resolveTarget(target);
177
+ const tag = found.tagName.toLowerCase();
178
+ const reuse = tag === "button" || tag === "a";
179
+ const el = reuse ? found : document.createElement("button");
180
+ if (!reuse)
181
+ found.appendChild(el);
182
+ if (el.tagName.toLowerCase() === "button" && !el.getAttribute("type")) {
183
+ // Without this it submits any form it happens to sit inside.
184
+ el.setAttribute("type", "button");
185
+ }
186
+ const restore = {
187
+ className: el.className,
188
+ html: el.innerHTML,
189
+ };
190
+ // The merchant's own text wins over our default, and an explicit `label`
191
+ // wins over both. An element with only whitespace in it counts as empty.
192
+ const existing = reuse ? el.textContent?.trim() : "";
193
+ const label = opts.label || existing || "Subscribe with USDC";
194
+ const busyLabel = opts.busyLabel ?? "Starting…";
195
+ el.classList.add("sweep-pay");
196
+ if (opts.variant === "outline")
197
+ el.classList.add("sweep-pay--outline");
198
+ if (opts.variant === "light")
199
+ el.classList.add("sweep-pay--light");
200
+ if (opts.size === "sm")
201
+ el.classList.add("sweep-pay--sm");
202
+ if (opts.block)
203
+ el.classList.add("sweep-pay--block");
204
+ // Our own constant markup, then the label by textContent — a merchant's
205
+ // string never reaches innerHTML.
206
+ el.innerHTML = COIN_SVG + SPINNER_SVG + '<span class="sweep-pay__label"></span>';
207
+ const labelEl = el.querySelector(".sweep-pay__label");
208
+ labelEl.textContent = label;
209
+ let busy = false;
210
+ const onClick = (event) => {
211
+ event.preventDefault();
212
+ // Covers the second click, and Enter on a button CSS has only made
213
+ // pointer-inert.
214
+ if (busy)
215
+ return;
216
+ busy = true;
217
+ el.setAttribute("aria-busy", "true");
218
+ labelEl.textContent = busyLabel;
219
+ void fetchUrl(opts)
220
+ .then((url) => {
221
+ if (opts.onRedirect && opts.onRedirect(url) === false) {
222
+ busy = false;
223
+ el.removeAttribute("aria-busy");
224
+ labelEl.textContent = label;
225
+ return;
226
+ }
227
+ // assign, not replace: the payer can come back with the back button.
228
+ window.location.assign(url);
229
+ })
230
+ .catch((e) => {
231
+ busy = false;
232
+ el.removeAttribute("aria-busy");
233
+ labelEl.textContent = label;
234
+ const error = e instanceof Error ? e : new Error(String(e));
235
+ if (opts.onError)
236
+ opts.onError(error);
237
+ else
238
+ console.error("[sweep] could not start the payment:", error);
239
+ });
240
+ };
241
+ el.addEventListener("click", onClick);
242
+ return {
243
+ element: el,
244
+ destroy() {
245
+ el.removeEventListener("click", onClick);
246
+ el.removeAttribute("aria-busy");
247
+ if (reuse) {
248
+ el.className = restore.className;
249
+ el.innerHTML = restore.html;
250
+ }
251
+ else {
252
+ el.remove();
253
+ }
254
+ },
255
+ };
256
+ }
257
+ /** The global the CDN build defines. Same functions, one name to reach them. */
258
+ const Sweep = { mount, BUTTON_CSS };
259
+
260
+ global.Sweep = Sweep;
261
+ })(typeof globalThis !== "undefined" ? globalThis : window);
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@sweepconsole/js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Pay-with-USDC button for the browser \u2014 asks your server for a checkout URL and sends the payer there",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./button.css": "./dist/button.css"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json && node scripts/build-cdn.mjs",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "test": "node test/button.test.mjs",
25
+ "prepublishOnly": "pnpm build && pnpm test"
26
+ },
27
+ "keywords": [
28
+ "usdc",
29
+ "stablecoin",
30
+ "subscriptions",
31
+ "payments",
32
+ "checkout",
33
+ "button",
34
+ "sweep-console"
35
+ ],
36
+ "devDependencies": {
37
+ "typescript": "^5.6.0"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/u4believe/sweep-console.git",
42
+ "directory": "packages/js"
43
+ },
44
+ "homepage": "https://github.com/u4believe/sweep-console#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/u4believe/sweep-console/issues"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }