Client
The methods of toolaby.js, what they return, and whether they make a request.
toolaby.js is the client the wiring writes beside your code, typed by toolaby.d.ts. It has eleven methods and two events. Everything it answers comes from a token the Wall signed for this device and the client verified with the workspace's public key; a method marked no request answers from the device alone.
import { toolaby } from './toolaby.js';The same import works in the background service worker, the popup, a side panel and a content script. In the background the methods run; elsewhere they are forwarded to the background over chrome.runtime.sendMessage and answer the same.
toolaby.config.js
The whole configuration, written by the wiring beside toolaby.js.
| Field | |
|---|---|
key | The tool key: tk_test_… while you try it, tk_live_… when it ships, tk_dev_… on a development Wall. It carries the platform's address, the tool, the public key your tokens are verified against, and what the Free plan held when it was written — what a new install shows until the Wall's first word, never what it decides a use by. It works only from this extension's id. From the tool's Set up page, or npx -y toolaby@latest wire. |
appPopup | Your own popup, which the platform's opens on to once the device may. '' means none stands in front — either you have no popup, or your popup draws the panel itself. upgrade keeps it as it is. |
appSidePanel | Your own side panel, the same way. See Surfaces. |
manifestKey | The extension's identity — the key the manifest carries, so every copy has the same id, which the tool key is bound to. Made once by the platform, or adopted from a manifest that already had one. Keep it; changing it changes the extension's id. |
accountBar | false keeps the account bar out of a WXT popup, which the module otherwise docks at its foot. |
Swapping the key for the live one is the whole of shipping to the store; nothing else in the file changes. See Test and Live.
startBackground()
startBackground(options?: BackgroundOptions): voidStarts the client in the service worker: the message listeners the other contexts use, the handoffs from the sign-in and checkout pages, and the version check. Call it once, synchronously, at the top level of background.js. Throws outside the background.
| Option | Type | |
|---|---|---|
handlers | Record<string, (message, sender) => unknown> | Your { action } messages, by action name. The return value is the reply. |
externalHandlers | same | Messages from web pages (externally_connectable). |
claimUnknown | boolean, default true | false leaves actions you do not handle to another onMessage listener of yours. |
skip | string[] | Startup checks to skip, by name. |
contentScriptStorage | boolean, default false | true lets your content scripts read and write chrome.storage.local. By default only the extension's pages and its background can, since the account link and the licence key are kept there: a content script asks the background for what it needs. Chrome 102 and later; other browsers are unchanged. |
contentScriptActions | boolean, default false | true lets your content scripts ask for the account's actions: activateLicense, signOut, resendLicense, listActivations, deactivate, capability. By default only the extension's own pages can (the popup, the side panel, an options page), since a content script runs in the web page's own process; asked from anywhere else, each answers { ok: false }. Reading (getUser, has, policy) and gate() are open to content scripts either way. |
The sign-in and purchase handoffs are taken only from a page of the Wall's own address, never from another extension.
toolaby.startBackground({
handlers: {
exportSheet: async ({ tab }) => {
const permit = await toolaby.gate();
if (!permit.allowed) return permit;
return exportTableFrom(tab);
},
},
});A handler runs inside the message that called it. An API that needs the click that sent the message, such as chrome.sidePanel.open(), works when the handler calls it before its first await.
ensureBackground()
ensureBackground(): voidstartBackground() unless something already called it. The Wall background the wiring puts in front of yours (toolaby.background.js), and the WXT module it writes (modules/toolaby.wxt.ts), call this after your own code has run. A background that calls startBackground({ handlers }) itself keeps its handlers, and one that never mentions the Wall still starts it. Not something you call.
getUser()
getUser(): Promise<User>What the device knows now. No request. user.token says when the answer was issued. See User.
refresh()
refresh(): Promise<User>Asks the Wall, then answers as getUser() does. Call it when the popup opens, not on every action. Offline, answers what the device has.
| Case | Request |
|---|---|
| The last answer is under five minutes old on Live, or ten seconds on Test | none |
| The person has been to the checkout page, the sign-in page or the account page since | one |
| Otherwise | one |
A purchase, a sign-in and a licence key are asked about when they happen, not at the next refresh().
gate()
gate(arg?: number | { count?: number; feature?: string }): Promise<Permit>May this device do the thing, now? Call it before the action, not when the popup opens.
| Argument | |
|---|---|
count | Uses to spend: a whole number from 1 to 1000. Default 1. |
feature | A feature key from Pricing. |
| Case | Answer | Request |
|---|---|---|
| A paid plan, trial or grant | { allowed: true, isPremium: true, unlimited: true } | none |
| A feature the plan does not hold | { allowed: false, reason: 'feature', feature, needs }. needs lists the ids of the plans that sell it. | none on the Free plan; one on a paid plan, in case it changed |
| Free plan, uses remaining | { allowed: true, isPremium: false, remaining, limit, used } | one, to count the use |
| Free plan, uses spent | { allowed: false, reason: 'limit', remaining: 0, limit } | none |
| Free plan requires sign-in, or holds nothing | { allowed: false, reason: 'sign_in' } or 'paid' | none |
A count that is not a whole number from 1 to 1000 | { allowed: false, reason: 'count' }. Nothing is spent, online or off. | none |
| The Wall refuses the request: an extension that is not the tool's, too many at once, a device it does not know | { allowed: false, reason: 'refused' }. Only when the Wall does not answer does the device count on its own. | one |
| An answer that allows a paid plan's use without a token the Wall signed for this device, or with one whose plan does not hold the feature | { allowed: false, reason: 'refused' }. The device unlocks nothing. | one |
| An answer with no token that allows a use the Free plan does not give, by the Wall's latest signed word: a feature a plan sells, a use past the free uses, or any use before the Wall's first word | { allowed: false, reason: 'refused' }. The device counts nothing. | one, then the version check before refusing (on Live, at most every five minutes) |
| Build below the minimum version | { allowed: false, blocked: true, message } | none |
| A new install that has not heard from the Wall yet | The first call waits for the Wall's word, a few seconds at most, then answers as above. Offline: { allowed: false, reason: 'limit', offline: true, unknown: true }: nothing is given on a guess. | one |
Offline, the device counts and answers with offline: true. See Permit.
has()
has(feature: string): Promise<boolean>Whether the device holds the feature: always for a Free plan feature, otherwise when the last token lists it. A tool with no features holds everything. No request, no use spent.
For display — the button drawn, greyed or badged. It stops nothing by itself: enforce a feature with gate({ feature }) at the action, as Clerk pairs has() in the UI with protect() on the route.
signOut()
signOut(): Promise<void>Forgets the account and the entitlement on this device and fires onChange. The account is untouched.
versionState()
versionState(options?: { fresh?: boolean }): Promise<VersionState>The version check's last answer, kept across the service worker's restarts. { fresh: true } asks the Wall again: on Test at every call, on Live when the answer is five minutes old or more. See VersionState.
openPaymentPage()
openPaymentPage(planId?: string): Promise<void>Opens the checkout page in a tab, with the plan preselected when named. A purchase is on the buyer's account, and the device holds it through the account the extension is signed in to. After payment, the Wall's after-purchase page signs the extension in to that account at once, if the buyer paid signed in. Bought signed out, the page's one button is the sign-in that does it, with the address the purchase was made with. onPaid fires when the device unlocks.
openLoginPage()
openLoginPage(provider?: 'google'): Promise<void>Opens the sign-in page. Signed in, the page hands the session to the extension. With 'google' — the popup's Continue with Google — the page goes straight to Google instead of asking again; it needs a Google client under Configure → Sign-in, and otherwise shows the page as usual.
openAccountPage()
openAccountPage(): Promise<void>Opens the buyer's account page.
showPaywall()
showPaywall(permit: Permit | { feature: string; needs?: string[] }): Promise<void>Answers a refused permit with the paywall, from your popup or your side panel. The page gives way to the paywall for that reason, at that surface's width, in your brand and the buyer's language. A Back button returns to the page. Once the device may continue (bought, signed in, the feature held), it returns there by itself. Showing it spends nothing.
const permit = await toolaby.gate({ feature: 'export' });
if (!permit.allowed) return toolaby.showPaywall(permit);| Permit | The paywall shows |
|---|---|
reason: 'feature' | Export is in Pro, and only the plans that hold the feature, needs first. |
reason: 'limit' | The free-use meter, then the plans. |
reason: 'paid' | The plans. |
reason: 'sign_in' | The sign-in doors. |
deviceLimit set | In use on 2 devices already, and the way to the account page, where the buyer signs another device out. |
blocked: true | Your message from Versions. |
allowed: true | Nothing: it returns without showing anything. |
{ feature: 'export' } on its own asks for a feature's plans without calling gate() first — for a button drawn locked because has() said no.
The paywall opens in the Wall popup's own page, toolaby-popup.html or toolaby-sidepanel.html, which wire and create put beside every popup and side panel. An extension without it — a build that left it out — gets the checkout page in a new tab instead, never a missing page. A side panel of your own says it is one with <html data-toolaby-surface="sidepanel">, or the paywall opens at popup width.
From a page of the extension open in a tab, such as an editor or a report, navigating away would lose its work. There it opens the checkout page in a new tab instead: the sign-in page for sign_in, nothing for blocked. A content script, whose page is not the extension's, does the same. The purchase reaches the extension as any other does: onPaid fires, and has() answers yes at the page's next call. In the background it throws, since there is no page to show it in: return the permit to the page and call it there. See Gate your extension.
onPaid, onChange
onPaid: { addListener(cb: (user: User) => void): void; removeListener(cb): void }
onChange: { addListener(cb: (user: User) => void): void; removeListener(cb): void }Background only. onPaid fires when the device goes from unpaid to paid. onChange fires on every change of user.paid. Neither fires for the first observation after the worker wakes. A popup reads getUser() when it opens instead.
Errors
No method throws on a network failure; each answers from the device and marks the answer offline where it matters. startBackground() throws outside the background; Toolaby({ key }) throws at startup when key is not a tool key. A method called from a page rejects with Toolaby is not running in this extension's background when nothing started it there. See Troubleshoot an extension.