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.

The one design law. An extension is a public client. It never holds an API key or signing secret — the kit refuses them at construction. Your partner id is public; the browser supplies your extension's origin (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

Step 0 — the phone app comes first. Install Blasts Deals on your phone (Google Play; iPhone: App Store), register in it with your work e-mail, and turn on Settings → Sign-In Approvals. Every sign-in in this product is approved on that phone. Then sign in to the developer console with that same e-mail — a sandbox partner rings only that phone, and the console's step 1 checks it live.
  1. 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).
  2. 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.)
  3. Console, step 3. Paste the id into the wizard's Register box. The step turns green.
  4. 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.
  5. 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.
  6. 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.
A sandbox partner rings only the phone of the console account that created it (the server answers 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.

Serving real users and you have a backend? In the console's Extension sign-in card tick “Require my server's signature on every sign-in start”. Your server signs each start with your signing secret (the same secret the HMAC partner API uses); the kit forwards the signature via a 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.

WhereCodeMeaning
startinvitedThe account has not allowed your product yet. The phone shows an invitation under Settings → Sign-In Approvals.
startsandbox_owner_onlyA sandbox partner can only ring its owner's phone.
startext_not_configuredThe 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).
startpartner_assertion_required, partner_assertion_invalid, partner_assertion_expiredThis partner requires its server's signature on starts — pass partnerAssertion; the signature did not verify; or it is older than 5 minutes.
statusconsent_missing, phone_changed, account_unboundFixable — the kit keeps the secret and remembers the reason; ask the user to sign in again.
statuslink_revoked, link_invalid, not_linkedDead — the kit drops the secret.
statusmachine_changedThe stored link was minted for another device id; it is never presented.
trialsign_in_required, quota_reached, link_refusedNo anonymous pool / period cap reached / the link you presented was refused (call link.status()).
licenseinvalid_code, code_revoked, seat_limit, release_limit, not_boundUnknown or malformed (one answer) / cancelled by you / all seats in use / 3 moves per 90 days / this PC is not on the code.
anynetwork, lane_unavailable, store_errorCould 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.