Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,12 @@ COINPAY_REDIRECT_URI=tronbrowser://oauth/coinpay

# --- Extension store (tronbrowser.dev/store) -------------------------------
APP_URL=https://tronbrowser.dev
# The $1 listing fee — Stripe (card):
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= # for /api/store/payments/stripe/webhook
# The $1 listing fee — CoinPay / x402 (1 USDC):
STORE_X402_NETWORK=base
STORE_X402_PAY_TO= # your USDC receiving address
# COINPAY_SETTLEMENT_URL=https://coinpayportal.com/api/settlements
# STORE_X402_TRUST_CLIENT=1 # DEV ONLY: accept client-reported settlement
# The $1 listing fee: a CoinPay hosted checkout session (any coin or PayPal).
# `npx @profullstack/coinpay-onboard site tronbrowser.dev --webhook <url>` writes these.
COINPAY_API_URL=https://coinpayportal.com/api
COINPAY_BUSINESS_ID=
COINPAY_API_KEY= # business cp_live_ key
COINPAY_WEBHOOK_SECRET= # for /api/store/payments/coinpay/webhook
# vu1nz.com security scan (non-gating; recorded 'skipped' if unset):
VU1NZ_API_URL=
VU1NZ_API_KEY=
Expand Down
4 changes: 2 additions & 2 deletions apps/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ one-click off-store install.
1. `POST /api/store/extensions` — create a draft (auth required).
2. `POST /api/store/extensions/:id/versions` — submit the MV3 manifest + bundle
URL(s). Rejected unless it's valid Manifest V3.
3. `POST /api/store/extensions/:id/checkout` — pay the $1 fee via **Stripe**
(card) or **CoinPay / x402** (1 USDC).
3. `POST /api/store/extensions/:id/checkout`: pay the $1 fee on a
**CoinPay** hosted checkout (any supported coin, or PayPal).
4. On payment the listing flips to **live**, is mirrored to the git registry,
and a **vu1nz.com** security scan runs asynchronously (badge on the listing).

Expand Down
3 changes: 2 additions & 1 deletion apps/extensions/public/extension.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@

<main class="wrap">
<p style="margin:18px 0 0"><a href="/store/">← All extensions</a></p>
<div id="paidNote" class="success hidden" style="margin-top:14px">🎉 Payment received — your extension is live!</div>
<div id="paidNote" class="success hidden" style="margin-top:14px">🎉 Thanks! Your listing goes live as soon as CoinPay confirms the payment. Refresh in a minute if it still shows as a draft.</div>
<div id="canceledNote" class="hint hidden" style="margin-top:14px">Payment canceled. Your draft is saved; pay the $1 listing fee from this page when you are ready.</div>
<section class="panel" id="detail"><p class="muted">Loading…</p></section>
</main>

Expand Down
42 changes: 22 additions & 20 deletions apps/extensions/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ async function initDetail() {
const root = document.getElementById('detail');
if (!slug) { root.innerHTML = '<p class="error">No extension specified.</p>'; return; }
if (qs('paid')) document.getElementById('paidNote')?.classList.remove('hidden');
if (qs('canceled')) document.getElementById('canceledNote')?.classList.remove('hidden');
try {
await renderDetail(slug, root);
} catch (e) {
Expand Down Expand Up @@ -334,6 +335,7 @@ async function renderDetail(slug, root) {
<a class="btn" id="installBtn" href="${esc(dl)}">⬇ Install / Download</a>
<a class="btn secondary" href="/store/install-guide.html">How to install</a>
<button class="btn ghost" id="flagBtn">⚑ Report</button>
${ext.isOwner && ext.status !== 'live' ? '<button class="btn" id="payBtn">Pay $1 to list</button>' : ''}
${ext.isOwner ? '<button class="btn secondary" id="editBtn">✎ Edit listing</button>' : ''}
${ext.isOwner ? '<button class="btn ghost" id="rescanBtn">🛡 Re-scan</button>' : ''}
${ext.isOwner && !ext.crxId ? '<button class="btn ghost" id="keyBtn">🔑 Generate signing key</button>' : ''}
Expand Down Expand Up @@ -391,6 +393,14 @@ async function renderDetail(slug, root) {

// Scans are recorded at publish time, so a listing published before its
// bundle could be scanned needs one re-run to earn a badge.
document.getElementById('payBtn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
const pay = await startListingCheckout(ext.id);
if (pay.checkoutUrl) { location.href = pay.checkoutUrl; return; }
btn.disabled = false;
await uiConfirm('Payment', pay.alreadyLive ? 'This listing is already paid.' : `Payment could not be started: ${pay.error || 'unknown'}`, { confirmLabel: 'OK' });
});
document.getElementById('rescanBtn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
Expand Down Expand Up @@ -479,7 +489,6 @@ async function initSubmit() {
let manifest;
try { manifest = JSON.parse(fd.get('manifest')); }
catch { out.innerHTML = '<p class="error">manifest.json is not valid JSON.</p>'; return; }
const method = fd.get('method');
const files = {};
if (fd.get('crxFile')) files.crx = fd.get('crxFile').trim();
if (fd.get('zipFile')) files.zip = fd.get('zipFile').trim();
Expand All @@ -497,10 +506,10 @@ async function initSubmit() {
out.innerHTML = `<p class="success">Draft created: <b>${esc(draft.slug)}</b>. Upload your bundle, then finish:</p>
<pre>scp ${esc(files.crx || files.zip)} ${esc(SCP_TARGET)}:/public/extensions/${esc(draft.slug)}/</pre>
<button class="btn" id="finishBtn">I've uploaded — validate &amp; pay $1 →</button>`;
document.getElementById('finishBtn').addEventListener('click', () => finishPublish(draft, manifest, method, files, urls, out));
document.getElementById('finishBtn').addEventListener('click', () => finishPublish(draft, manifest, files, urls, out));
return;
}
await finishPublish(draft, manifest, method, files, urls, out);
await finishPublish(draft, manifest, files, urls, out);
} catch (err) {
const extra = err.data?.errors ? '<br>• ' + err.data.errors.map(esc).join('<br>• ') : '';
out.innerHTML = `<p class="error">${esc(err.message)}${extra}</p>`;
Expand All @@ -509,7 +518,7 @@ async function initSubmit() {
}

// Submit the MV3 version (HEAD-checks uploaded files) then start payment.
async function finishPublish(draft, manifest, method, files, urls, out) {
async function finishPublish(draft, manifest, files, urls, out) {
try {
out.innerHTML = '<p class="muted">Validating…</p>';
const ver = await api(`/extensions/${draft.id}/versions`, {
Expand All @@ -522,30 +531,23 @@ async function finishPublish(draft, manifest, method, files, urls, out) {
});
const warn = (ver.warnings || []).length ? `<p class="hint">⚠ ${ver.warnings.map(esc).join('<br>⚠ ')}</p>` : '';
out.innerHTML = `<p class="success">Validated MV3 ✓ (v${esc(ver.version)}). Starting payment…</p>${warn}`;
const pay = await api(`/extensions/${draft.id}/checkout`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method }),
}).catch((err) => err.data || {});
const pay = await startListingCheckout(draft.id);
if (pay.checkoutUrl) { location.href = pay.checkoutUrl; return; }
if (pay.x402Version) {
out.innerHTML += `<p class="success">Pay 1 USDC via your CoinPay wallet, then confirm. Pay-to: <code>${esc(pay.accepts?.[0]?.payTo || '(configure STORE_X402_PAY_TO)')}</code></p>
<div class="field"><label>Settlement reference</label><input type="text" id="x402ref" placeholder="CoinPay reference" /></div>
<button class="btn" id="x402confirm">Confirm payment</button>`;
document.getElementById('x402confirm').addEventListener('click', async () => {
const reference = document.getElementById('x402ref').value.trim();
try {
await api(`/extensions/${draft.id}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paymentId: pay.paymentId, reference }) });
location.href = `/store/extension.html?slug=${encodeURIComponent(draft.slug)}&paid=1`;
} catch (err) { out.innerHTML += `<p class="error">${esc(err.message)}</p>`; }
});
return;
}
out.innerHTML += `<p class="error">Payment could not be started: ${esc(pay.error || 'unknown')}</p>`;
} catch (err) {
const extra = err.data?.errors ? '<br>• ' + err.data.errors.map(esc).join('<br>• ') : '';
out.innerHTML = `<p class="error">${esc(err.message)}${extra}</p>`;
}
}

// The $1 listing fee: the API makes a CoinPay hosted checkout session and we
// send the publisher there (any coin or PayPal); CoinPay's webhook lists it.
async function startListingCheckout(extId) {
return api(`/extensions/${encodeURIComponent(extId)}/checkout`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
}).catch((err) => err.data || { error: err.message });
}

/* ---------- tabs (install-guide.html) ---------- */
function initTabs() {
document.querySelectorAll('.tab').forEach((tab) => {
Expand Down
8 changes: 1 addition & 7 deletions apps/extensions/public/submit.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,7 @@ <h3>4 · manifest.json (Manifest V3) *</h3>
}'></textarea></div>

<h3>5 · Pay the $1 listing fee</h3>
<div class="field">
<label>Payment method</label>
<select name="method">
<option value="stripe">Card (Stripe)</option>
<option value="coinpay">Crypto — CoinPay / x402 (1 USDC)</option>
</select>
</div>
<p class="hint">You pay on CoinPay's hosted checkout: any supported coin, or PayPal.</p>
<button class="btn" type="submit">Validate &amp; pay $1 →</button>
<p class="hint" style="margin-top:12px">By publishing you confirm you have the rights to distribute this code. Malicious extensions are removed on report.</p>
</form>
Expand Down
32 changes: 17 additions & 15 deletions docs/extension-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ exactly as Chromium expects.

- `extensions` — listing (slug, owner, status `draft|live|removed`)
- `extension_versions` — per-version MV3 manifest + bundle/CRX URLs (`manifest_version` enforced = 3 at the app layer)
- `extension_payments` — the $1 fee; `stripe|coinpay|x402`; `pending|paid|failed`
- `extension_payments`: the $1 fee; `coinpay` (older rows may say `stripe`/`x402`); `pending|paid|failed`
- `extension_scans` — async vu1nz results; non-gating badge
- `extension_flags` — community flagging

Expand All @@ -37,8 +37,8 @@ exactly as Chromium expects.
```
create draft ─▶ submit MV3 version ─▶ pay $1 ─▶ LIVE (instant)
POST /extensions POST .../versions POST .../checkout │
(validateManifest) Stripe or x402 ├─▶ git mirror (registry/)
webhook/confirm └─▶ vu1nz scan (async badge)
(validateManifest) CoinPay checkout ├─▶ git mirror (registry/)
CoinPay webhook └─▶ vu1nz scan (async badge)
```

Two publish paths (both supported):
Expand All @@ -50,14 +50,17 @@ Publishing is **instant after payment** — the vu1nz scan and git mirror happen
out of band and never block going live ("scan + community flagging", not
pre-review).

## Payments (`store/payments.ts`, dependency-free)
## Payments (`store/payments.ts`)

- **Stripe** — Checkout Session created server-side; `checkout.session.completed`
webhook (HMAC-verified, replay-protected) marks the payment paid → publish.
- **CoinPay / x402** — `/checkout` returns an HTTP-402 challenge (1 USDC); the
client settles via the CoinPay wallet and calls `/confirm` with the reference.
Real settlement verification (`confirmCoinPaySettlement`) lands post-stub; it
refuses to mark paid without a verified settlement unless `STORE_X402_TRUST_CLIENT=1` (dev).
One rail: a **CoinPay hosted checkout session** (`@profullstack/coinpay-onboard`).
`/checkout` records a pending `extension_payments` row, creates a session for $1
(customer_email = owner email, account_id = user id, property `tronbrowser.dev`,
metadata `{extension_id, payment_id, slug}`), stores the `cs_...` id as
`provider_ref` and returns `checkoutUrl`. The publisher picks any coin or PayPal
on CoinPay's page. CoinPay posts `payment.confirmed` / `payment.forwarded` to
`/payments/coinpay/webhook` (X-CoinPay-Signature, HMAC over the raw body); that
marks the row paid (idempotent) and publishes. `/confirm` with `{sessionId}`
reconciles by reading the session back with the API key.

## Security scan — vu1nz.com (`store/vu1nz.ts`)

Expand All @@ -76,16 +79,16 @@ GitHub Action check on the PR path.
| POST | `/extensions` | create draft (auth) |
| POST | `/extensions/:id/versions` | submit MV3 version (auth) |
| POST | `/extensions/:id/checkout` | start $1 payment (auth) |
| POST | `/extensions/:id/confirm` | confirm x402 settlement (auth) |
| POST | `/payments/stripe/webhook` | Stripe webhook |
| POST | `/extensions/:id/confirm` | reconcile a CoinPay checkout session (auth) |
| POST | `/payments/coinpay/webhook` | CoinPay webhook |
| POST | `/extensions/:slug/flag` | community flag |
| GET | `/updates.xml?id=` | Chromium gupdate manifest (auto-update) |
| GET | `/extensions/:slug/download` | redirect to CRX/bundle |

## Env

See `.env.example` (Extension store section): `APP_URL`, `STRIPE_SECRET_KEY`,
`STRIPE_WEBHOOK_SECRET`, `STORE_X402_NETWORK`, `STORE_X402_PAY_TO`,
See `.env.example` (Extension store section): `APP_URL`, `COINPAY_API_URL`,
`COINPAY_API_KEY`, `COINPAY_WEBHOOK_SECRET`,
`VU1NZ_API_URL`, `VU1NZ_API_KEY`, `STORE_REGISTRY_REPO`, `GITHUB_TOKEN`.

## Bundle hosting — files.profullstack.com (AgentBBS Files)
Expand Down Expand Up @@ -119,6 +122,5 @@ the Dockerfile.
- **Browser file upload** (multipart straight through the API to files) so devs
don't need an SCP client at all — today they `scp` (or use FileZilla).
- **Server-side CRX3 signing** from an uploaded `.zip`.
- **Real CoinPay settlement verification** + Stripe live keys.
- **TronBrowser one-click** deep-link handler in the desktop app.
- **Admin/moderation UI** for resolving flags.
6 changes: 3 additions & 3 deletions packages/storage/migrations/0003_extension_store.sql
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,17 @@ CREATE TABLE IF NOT EXISTS extension_versions (

CREATE INDEX IF NOT EXISTS idx_ext_versions_ext ON extension_versions (extension_id, created_at DESC);

-- The $1 pay-to-list fee. Paid via Stripe OR CoinPay/x402. A listing only goes
-- The $1 pay-to-list fee. Paid via CoinPay. A listing only goes
-- 'live' once a matching row reaches status 'paid'.
CREATE TABLE IF NOT EXISTS extension_payments (
id TEXT PRIMARY KEY,
extension_id TEXT NOT NULL,
user_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL DEFAULT 100, -- $1.00
currency TEXT NOT NULL DEFAULT 'usd',
method TEXT NOT NULL, -- 'stripe' | 'coinpay' | 'x402'
method TEXT NOT NULL, -- 'coinpay' (legacy rows: other values)
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'paid' | 'failed'
provider_ref TEXT, -- stripe session id / coinpay reference
provider_ref TEXT, -- CoinPay checkout session id (cs_...)
paid_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (extension_id) REFERENCES extensions(id),
Expand Down
Loading
Loading