commit bbd81555f8d497f1cce5c4d8ce9d209cbdef3eed equwal <truex@equwal.com> 2026-09-22 18:28:23 -0700 Options: the plan of the account and its payments; version 0.2.0 The options page has a new section Plan. It asks the sync server for GET /api/account, and shows the state of the account, a Subscribe button for each plan that the account can start, Manage billing when the account has billing in Stripe, and the payment links of the server with its terms of sale. The buttons ask the server for a Stripe page and open it in a new tab. When the options page is visible again, it asks the server again and shows the new plan. A server from before this API answers 404. Then the section stays hidden, and the link to the account page stays. lib/plan.js keeps only http and https links from the server, and only https for the Stripe pages. Property tests check this. The README and the store texts say that sync on sbm.subread.space is free, and link to the terms of sale.
README.md | 13 ++++++++++--- background.js | 32 +++++++++++++++++++++++++++++++- lib/plan.js | 33 +++++++++++++++++++++++++++++++++ manifest.json | 3 ++- options.html | 7 +++++++ options.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- store/listing.md | 18 +++++++++++++++--- style.css | 1 + test/plan.test.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 208 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md index 7224671..8b80225 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,16 @@ The bookmarks are an sbm file: one bookmark per line, ## Sync The add-on uses https://sbm.subread.space unless you give another server in -the options. Create an account there: it is free for 30 days, then $3 a month -or $30 a year. Or [run your own server](https://github.com/equwal/sbm-sync): -it is free software. +the options. Create an account there. Sync on that server is free. Or +[run your own server](https://github.com/equwal/sbm-sync): it is free +software. + +The options page shows the plan of your account and the payments that the +server offers. When the server has billing, subscribe there, and use +"Manage billing" to change the card or cancel. The page also links to the +other payment pages of the server, such as a supporter subscription and the +page to cancel it, and to the terms of sale. Stripe takes all payments, on +its own pages in a new tab. The add-on never sees your card. ## Permissions diff --git a/background.js b/background.js index 26a6eaf..0db87a3 100644 --- a/background.js +++ b/background.js @@ -7,7 +7,7 @@ // Chrome runs this file as a service worker; Firefox loads the libraries // through the manifest. -if (typeof importScripts === "function") importScripts("lib/tsv.js", "lib/fuzzy.js"); +if (typeof importScripts === "function") importScripts("lib/tsv.js", "lib/fuzzy.js", "lib/plan.js"); const api = globalThis.browser ?? globalThis.chrome; const DEFAULT_SERVER = "https://sbm.subread.space"; @@ -84,6 +84,33 @@ async function signOut() { return state(); } +/** The plan of the account and the payments that the server offers, or null. */ +async function account() { + const s = await state(); + if (!s.token) return null; + const r = await fetch(s.server + "/api/account", { headers: { Authorization: "Bearer " + s.token } }); + // A server from before this API has no such address. Its account page + // has the payments. + if (r.status === 404) return null; + if (!r.ok) throw new Error((await r.text()).trim() || "The server answered " + r.status + "."); + return r.json(); +} + +/** Ask the server for a page of Stripe, and open that page in a new tab. */ +async function billing(path, form) { + const s = await state(); + const r = await fetch(s.server + path, { + method: "POST", + headers: { Authorization: "Bearer " + s.token, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(form), + }); + const body = (await r.text()).trim(); + if (!r.ok) throw new Error(body || "The server answered " + r.status + "."); + const url = Plan.web(body, ["https:"]); + if (!url) throw new Error("The server gave no https address."); + await api.tabs.create({ url }); +} + /** Add a bookmark at the end of the file. With the same page there already, return that bookmark. */ async function add(b) { const s = await state(); @@ -100,6 +127,9 @@ api.runtime.onMessage.addListener((msg, sender, reply) => { signIn: () => signIn(msg.server, msg.email, msg.password), signOut: () => signOut(), add: () => add(msg.bookmark), + account: () => account(), + checkout: () => billing("/api/checkout", { plan: msg.plan }), + portal: () => billing("/api/portal", {}), }; const job = jobs[msg.type]; if (!job) return false; diff --git a/lib/plan.js b/lib/plan.js new file mode 100644 index 0000000..ae4452f --- /dev/null +++ b/lib/plan.js @@ -0,0 +1,33 @@ +// The plan of an sbm Sync account and the payments that the server offers, +// as the options page shows them. The server gives them at GET /api/account. +// This file has no browser code, so the tests run in node. +"use strict"; + +const Plan = (() => { + /** The address as text when its scheme is one of schemes, else "". */ + function web(address, schemes = ["https:", "http:"]) { + try { + const u = new URL(address); + return schemes.includes(u.protocol) ? u.href : ""; + } catch (e) { + return ""; + } + } + + return { + web, + + /** The payment pages of the server and its terms of sale, as links with texts. */ + links(info) { + return [ + { href: web(info.support), text: "Become a supporter" }, + { href: web(info.manage), text: "Manage or cancel a supporter subscription" }, + { href: web(info.teams), text: "Team bookmarks: up to 10 people" }, + { href: web(info.teams_large), text: "Team bookmarks: 11 people or more" }, + { href: web(info.terms), text: "Terms of sale" }, + ].filter((l) => l.href !== ""); + }, + }; +})(); + +if (typeof module !== "undefined") module.exports = Plan; diff --git a/manifest.json b/manifest.json index f8fbfc5..426e0e5 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "sbm bookmarks", "short_name": "sbm", - "version": "0.1.1", + "version": "0.2.0", "description": "Your sbm bookmarks in the browser: search as you type, add the page, sync with bm and the sbm app.", "homepage_url": "https://github.com/equwal/sbm-extension", "icons": { @@ -39,6 +39,7 @@ "scripts": [ "lib/tsv.js", "lib/fuzzy.js", + "lib/plan.js", "background.js" ] }, diff --git a/options.html b/options.html index 3f61bce..55058e8 100644 --- a/options.html +++ b/options.html @@ -28,6 +28,12 @@ other browsers.</p> <p id="who"></p> <button id="sync" type="button">Sync now</button> <button id="sign-out" type="button">Sign out</button> + <div id="plan" hidden> + <h2>Plan</h2> + <p id="plan-state"></p> + <p id="billing"></p> + <p id="links"></p> + </div> <p class="note"><a id="account" href="https://sbm.subread.space/account" target="_blank" rel="noopener">Your account</a></p> </section> @@ -39,6 +45,7 @@ other browsers.</p> <a href="https://ko-fi.com/truex" target="_blank" rel="noopener">Support sbm on Ko-fi</a> </footer> <script src="lib/tsv.js"></script> +<script src="lib/plan.js"></script> <script src="options.js"></script> </body> </html> diff --git a/options.js b/options.js index b378e68..777895f 100644 --- a/options.js +++ b/options.js @@ -1,4 +1,5 @@ -// The options page: sign in to a sync server, sync now, sign out. +// The options page: sign in to a sync server, sync now, sign out, and the +// plan of the account with its payments. "use strict"; const api = globalThis.browser ?? globalThis.chrome; @@ -42,8 +43,60 @@ async function show() { $("who").textContent = "Signed in to " + server + " as " + s.email + ". " + n + " bookmarks" + (s.synced ? ", synced " + new Date(s.synced).toLocaleString() : "") + "."; if (s.error) say("Sync: " + s.error); + if (s.token) showPlan(); } +function button(text, onClick) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = text; + b.addEventListener("click", onClick); + return b; +} + +/** Show the plan of the account and the payments that the server offers. */ +async function showPlan() { + let info = null; + try { + info = await send({ type: "account" }); + } catch (err) { + // No answer now. The link to the account page stays. + } + $("plan").hidden = !info; + if (!info) return; + $("plan-state").textContent = info.state; + const buttons = info.plans.map((p) => button("Subscribe: " + p.label, () => pay({ type: "checkout", plan: p.id }))); + if (info.portal) buttons.push(button("Manage billing", () => pay({ type: "portal" }))); + $("billing").replaceChildren(...buttons); + $("billing").hidden = buttons.length === 0; + const links = Plan.links(info).map((l) => { + const a = document.createElement("a"); + a.href = l.href; + a.textContent = l.text; + a.target = "_blank"; + a.rel = "noopener"; + return a; + }); + $("links").replaceChildren(...links.flatMap((a, i) => (i === 0 ? [a] : [" · ", a]))); + $("links").hidden = links.length === 0; +} + +/** Open a page of Stripe in a new tab. */ +async function pay(msg) { + say("Opening Stripe…"); + try { + await send(msg); + say(""); + } catch (err) { + say(err.message); + } +} + +// Back from a page of Stripe: show the new plan. +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") show(); +}); + $("server").addEventListener("input", () => { try { $("signup").href = origin($("server").value) + "/signup"; diff --git a/store/listing.md b/store/listing.md index a789178..0db630a 100644 --- a/store/listing.md +++ b/store/listing.md @@ -20,10 +20,13 @@ sbm keeps your bookmarks in one plain text file: one bookmark per line, with the - Search as you type: press Alt+Shift+M or click the icon. The search is fuzzy, as in fzf. Enter opens the first match. Text that is no bookmark opens as an address, or as a web search. - In the address bar, type bm, a space, then words. - Add the page that you look at, with a description and tags. -- Sync with bm on your computers, the sbm app for Android and your other browsers, through an sbm-sync server. The server is free software: use the default one, sbm.subread.space (paid after a free trial), or run your own. +- Sync with bm on your computers, the sbm app for Android and your other browsers, through an sbm-sync server. The server is free software: use the default one, sbm.subread.space, or run your own. Sync on sbm.subread.space is free. +- The options page shows the plan of your account and the paid options of the server, such as a supporter subscription. Stripe takes all payments on its own pages. The add-on never sees your card. Without an account, your bookmarks stay in the browser. The add-on has no ads, no analytics and no trackers. +The operator of sbm.subread.space sells its paid options, not Google or Mozilla. Terms of sale, with the refund policy: https://sbm.subread.space/terms + Source code (AGPL-3.0): https://github.com/equwal/sbm-extension **Images:** `store/1-list.png`, `2-search.png`, `3-add.png`, `4-sync.png` @@ -33,6 +36,8 @@ Source code (AGPL-3.0): https://github.com/equwal/sbm-extension **Homepage:** https://github.com/equwal/sbm-extension **Support:** https://github.com/equwal/sbm-extension/issues **Privacy policy:** https://sbm.subread.space/privacy +**Terms of sale:** https://sbm.subread.space/terms (in the description; the +Chrome Web Store asks for them when an add-on leads to payments) ## Chrome Web Store (chrome.google.com/webstore/devconsole) @@ -50,7 +55,9 @@ Source code (AGPL-3.0): https://github.com/equwal/sbm-extension information (the password, sent once to get a token) and web history (the addresses of the bookmarks). Tick the three statements: not sold, not used for purposes unrelated to the single purpose, not used for credit. -- Distribution: public, all regions. Free. +- Distribution: public, all regions. Free. If the Distribution tab has the + option "Contains in-app purchases", tick it: the options page leads to + Stripe subscriptions of the server. ## addons.mozilla.org (addons.mozilla.org/developers) @@ -58,7 +65,12 @@ Source code (AGPL-3.0): https://github.com/equwal/sbm-extension - Categories: Bookmarks, Search Tools. - License: GNU Affero General Public License v3.0. - Contributions URL (in "Manage Status & Versions", then "Edit Product - Page"): https://ko-fi.com/truex + Page"): https://ko-fi.com/truex. AMO accepts only some sites here: + ko-fi.com is one, buy.stripe.com is not. +- "This add-on requires payment, non-free services or software, or + additional hardware": leave it clear while sync is free. Tick it on the + day that sync on sbm.subread.space needs payment, and change the + description the same day. - Data collection: the manifest declares none as required, and bookmarks, personally identifying information and authentication information as optional. Firefox asks the user at sign-in. diff --git a/style.css b/style.css index 8c02051..50adfe8 100644 --- a/style.css +++ b/style.css @@ -19,4 +19,5 @@ label { display: block; } .options { max-width: 36em; margin: 2em auto; padding: 0 1em; font-size: 15px; } .options h1 { color: var(--accent); } .options section { margin: 1.5em 0; } +.options #billing button { margin: 0 6px 6px 0; } .options footer { margin-top: 3em; font-size: 13px; color: var(--muted); } diff --git a/test/plan.test.js b/test/plan.test.js new file mode 100644 index 0000000..366a871 --- /dev/null +++ b/test/plan.test.js @@ -0,0 +1,55 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fc = require("fast-check"); +const Plan = require("../lib/plan.js"); + +// Any text in the place of an address: a web address, another scheme, or none. +const address = fc.oneof( + fc.webUrl(), + fc.string(), + fc.constantFrom("javascript:alert(1)", "data:text/html,x", "file:///etc/passwd", "ftp://example.org/"), +); + +test("each payment link is a web address", () => { + fc.assert(fc.property(address, address, address, address, address, (support, manage, teams, large, terms) => { + for (const l of Plan.links({ support, manage, teams, teams_large: large, terms })) { + assert.match(l.href, /^https?:\/\//); + assert.equal(new URL(l.href).href, l.href); + } + }), { numRuns: 1000 }); +}); + +test("a web address stays", () => { + fc.assert(fc.property(fc.webUrl(), (u) => { + assert.equal(Plan.web(u), new URL(u).href); + assert.equal(Plan.links({ support: u }).length, 1); + }), { numRuns: 1000 }); +}); + +test("the links of the server, in the order of the server", () => { + // An answer of GET /api/account, as the server gives it. + const info = JSON.parse('{"email":"me@example.org","state":"Sync is on.","plans":[],"portal":false,' + + '"terms":"https://sbm.example.org/terms","support":"https://buy.stripe.com/support",' + + '"manage":"https://billing.stripe.com/p/login/x","teams":"https://buy.stripe.com/small",' + + '"teams_large":"https://buy.stripe.com/large"}'); + assert.deepEqual(Plan.links(info).map((l) => l.text + " = " + l.href), [ + "Become a supporter = https://buy.stripe.com/support", + "Manage or cancel a supporter subscription = https://billing.stripe.com/p/login/x", + "Team bookmarks: up to 10 people = https://buy.stripe.com/small", + "Team bookmarks: 11 people or more = https://buy.stripe.com/large", + "Terms of sale = https://sbm.example.org/terms", + ]); + assert.deepEqual(Plan.links({ email: "me@example.org", state: "Sync is on.", plans: [], portal: false }), []); +}); + +test("other schemes go", () => { + for (const bad of ["javascript:alert(1)", "data:text/html,x", "file:///etc/passwd", "not an address", "", undefined]) { + assert.equal(Plan.web(bad), "", String(bad)); + } +}); + +test("a Stripe page must use https", () => { + assert.equal(Plan.web("https://checkout.stripe.com/c/pay/cs_test_1", ["https:"]), "https://checkout.stripe.com/c/pay/cs_test_1"); + assert.equal(Plan.web("http://checkout.stripe.com/c/pay/cs_test_1", ["https:"]), ""); +});