Extensions — "Sign in with the Blasts Deals app" inside your Chrome / Edge extension
Phone-approved sign-in, a free tier your users draw from, and paid license codes you mint yourself — inside a browser extension that has no backend and no secrets. You vendor one JavaScript file, register your extension's id, and call three small APIs. Manifest V3, Chrome 116+ and Edge.
chrome-extension://<id>) and the server matches it against the ids you registered. The user's phone approves the PC. The extension never sees a password, an e-mail from the server, or the account behind the sign-in — only a pairwise user_ref that is stable for your product and unlinkable across products.blasts-kit-ext.js (kit 0.3.2) SHA256SUMS Sample extension (.zip) Sample README
Verify the download: sha256sum blasts-kit-ext.js must match the line in SHA256SUMS. The build is deterministic — the same kit version always hashes the same.
Run the sample first (10 minutes) — the console walks you through it
- Console, steps 1–2. Open the Developer Console → sign in with that e-mail → the “Set up an extension in 4 steps” card → Create my sandbox (one click).
- Load the sample. Unzip it.
chrome://extensions→ Developer mode ON → Load unpacked → pick the folder. Open its popup — click the puzzle-piece Extensions icon to the right of the address bar, then Blasts Kit Sample in the list (pin it there once for a one-click button) — the first-run checklist shows the extension's 32-letter id with a Copy button. (Edge:edge://extensions.) - Console, step 3. Paste the id into the wizard's Register box. The step turns green.
- Sample Settings. Enter the partner id the console shows (ends in
-test) and your product's name → Save. The popup's sign-in card now checks with the server that the extension is registered: “Ready” in green, or “Step 3 is not done” with the id to copy. - Sign in. Type the same e-mail and click. What to expect: the first click on a new account only asks your phone for permission — allow it under Settings → Sign-In Approvals (not the Approvals tab). Click again: a 6-digit code appears in the popup after a few seconds (up to ~10 the first time while the server wakes up); the request shows under the phone's Approvals tab → open it and type that code into it (the code is shown on the PC only - it is never sent to the phone). The popup turns to “This PC is signed in” and the console's step 4 flips green on its own.
- Plan card. Count a free unit; the paid-plan code field appears only after sign-in (nobody needs a code for the free tier). Mint codes in the console, paste one, Activate, Release. Start over in the partner row wipes the sandbox for a clean run.
sandbox_owner_only for anyone else). That is deliberate: you test your integration with your own Blasts Deals account. Request live keys in the console to serve other people.Put it in your own extension
1. Manifest
{
"manifest_version": 3,
"permissions": ["storage"],
"host_permissions": ["https://b2b-leads-licenses.netlify.app/*"],
"content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }
}
Manifest V3 forbids remotely hosted code, so the kit is vendored: copy blasts-kit-ext.js into your extension (e.g. vendor/) and import it from any extension page — popup, side panel, options.
2. Sign in
import { createBlastsExt } from './vendor/blasts-kit-ext.js';
const kit = createBlastsExt({ partnerId: 'acme-test', productName: 'Acme Leads' });
// Not signed in yet? Ask for the e-mail the user has in the Blasts Deals app.
const res = await kit.signIn.start(email);
// res.state === 'invited' -> the account has not allowed your product yet: kit.copy.invited tells the user what to tap
// res.state === 'ceremony' -> show res.challenge (6 digits, on the PC only); the user types it into the request on the phone
const done = await kit.signIn.waitForApproval(res); // polls 2 s / 5 s / 10 s until the phone answers
// done.status: 'approved' | 'denied' | 'expired' | 'failed'
const st = await kit.link.status(); // { linked, reason?, message?, config? }
st.local.user_ref // the pairwise account id - never the e-mail
await kit.link.unlink(); // sign this PC out
3. Free tier — you name the unit
const t = await kit.trial.use('acme.com'); // a domain, an export, a report... once per period per key
// { ok:true, identity:'blasts_account'|'self_declared', unit, units_used, units_limit, units_remaining,
// is_new_unit, status:'active'|'quota_reached' }
// { ok:false, error:'sign_in_required' } <- you set the anonymous pool to 0
await kit.trial.status(); // the numbers, nothing counted
Signed-in PCs draw from the phone's pool — one phone is one quota, however many PCs it links, however many e-mail accounts sit on it. Anonymous PCs draw from the smaller self-declared pool: that id is the extension's own and a storage wipe mints a new one, so the server labels it self_declared, caps new counters per network per day, and lets you set the anonymous pool to 0. Period is a calendar month (UTC) or every N days — your choice in the console.
4. Paid license codes — you mint them
const a = await kit.license.activate(typedCode); // BK-XXXXX-XXXXX-XXXXX, phone-readable (I/L read as 1, O as 0)
// { ok:true, status:'active', seats, seats_used, label } | invalid_code | code_revoked | seat_limit { seats, seats_used }
await kit.license.status(); // re-check this PC; a revoked or unknown code clears the local record
await kit.license.release(); // free this PC's seat - 3 moves per 90 days per code
await kit.license.get(); // the locally stored code, or null (no network)
Mint codes in the console (1–100 at a time, a label per batch, seats per code default to your partner setting). Codes are shown once; the server keeps only their hashes. Sell them however you like. Revoke from the console when you must — every PC using the code loses the paid plan on its next check.
What a public client can and cannot prove — and the switch that closes the gap
Your partner id and your extension id are public (the id is in your store URL). The server ties a request to your partner by the browser-set Origin: chrome-extension://<id>, which a browser page cannot forge — but a script outside a browser can set that header. What that buys an attacker is exactly what installing your public extension buys anyone: the ability to start a sign-in request under your name at an e-mail of their choosing. It never mints a link, a free unit or a seat — the user's own phone must approve — and the server bounds it: one live invitation per account, per-user-per-partner daily limits, quiet hours, and a per-target hourly cap that ignores the caller's IP.
partnerAssertion hook; the server refuses unsigned starts before touching any phone. The secret never enters the extension. Canonical string ext-start|v1|partner_id|machine_id|sha256(email)|ts, HMAC-SHA256 hex, 5-minute window, previous secret honored through your rotation window.const kit = createBlastsExt({
partnerId: 'acme', productName: 'Acme Leads',
partnerAssertion: async ({ partnerId, machineId, email }) => {
const r = await fetch('https://api.acme.example/blasts/sign-start', { method: 'POST', body: JSON.stringify({ machineId, email }) });
return r.json(); // -> { assertion, ts } (your server: HMAC-SHA256(signing_secret, canonical), ts = Date.now())
}
});
Gating paid features: license.get() is a display cache. Gate on license.current() — cache while younger than a day, server re-check after that (a revoked code stops within a day even if you never call status()), 7 days of offline grace flagged unverified.
What the kit tells you, in your product's words
Every state and reason has a plain-English line in kit.copy with your product name filled in — kit.copy[st.reason], kit.copy[res.error]. Your UI never has to invent a sentence.
| Where | Code | Meaning |
|---|---|---|
| start | invited | The account has not allowed your product yet. The phone shows an invitation under Settings → Sign-In Approvals. |
| start | sandbox_owner_only | A sandbox partner can only ring its owner's phone. |
| start | ext_not_configured | The lane is not set up for this call. reason is one of partner_required, origin_required, not_registered (unknown partner, no ids, or your id is not on its list — deliberately one code, so nobody can enumerate partners; check the console card). |
| start | partner_assertion_required, partner_assertion_invalid, partner_assertion_expired | This partner requires its server's signature on starts — pass partnerAssertion; the signature did not verify; or it is older than 5 minutes. |
| status | consent_missing, phone_changed, account_unbound | Fixable — the kit keeps the secret and remembers the reason; ask the user to sign in again. |
| status | link_revoked, link_invalid, not_linked | Dead — the kit drops the secret. |
| status | machine_changed | The stored link was minted for another device id; it is never presented. |
| trial | sign_in_required, quota_reached, link_refused | No anonymous pool / period cap reached / the link you presented was refused (call link.status()). |
| license | invalid_code, code_revoked, seat_limit, release_limit, not_bound | Unknown or malformed (one answer) / cancelled by you / all seats in use / 3 moves per 90 days / this PC is not on the code. |
| any | network, lane_unavailable, store_error | Could not reach or the server did not answer — the kit keeps what it has and says so. |
What the server never tells you
The e-mail, the phone, the account. user_ref = HMAC(server key, partnerId + account) — stable for your product, unlinkable across products. If you need to show which account is signed in, show the e-mail the user typed into your UI; the kit never stores it. Codes are hashed at rest; counters are keyed to your partner and never touch another partner's.
Wire reference
All three lanes are POST JSON with partner_id in the body and your extension's origin set by the browser. The kit owns the wire — you should never need these directly — but they are documented in the Partner OpenAPI under /api/ext/link, /api/ext/trial and /api/ext/license, and the console ops under /api/dev/partners.