From 0d2e0c8d323c40f977476c2baecb528a98c7f9a5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 25 Jul 2026 18:54:05 +0000 Subject: [PATCH 1/2] feat(store): let publishers update listing copy after creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listing's text was write-once. createExtension set name/summary/ description, and nothing could change them afterwards — POST /extensions/:id/versions only writes version rows. So every listing kept describing whatever the extension did the day it was created while the bundle underneath it kept updating. That is exactly what happened to coinpay-wallet: the store still tells people "send and one-click x402 payment approval land in subsequent updates" while serving a build where both shipped. Adds: - db.updateExtension + buildExtensionUpdate (pure, tested) — a patch touches only the fields it names; undefined leaves a column alone, null clears it. - PATCH /api/store/extensions/:id for the owner, via browser session or CI publisher token. Re-mirrors the listing to the git registry so the public trail matches what the store serves. - publish-extension.sh grows an optional LISTING= step, so copy can live in the extension's own repo and sync on every publish. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/publish-extension.sh | 18 +++++++++++ services/api/src/store/db.test.ts | 41 ++++++++++++++++++++++- services/api/src/store/db.ts | 54 +++++++++++++++++++++++++++++++ services/api/src/store/routes.ts | 38 ++++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/scripts/publish-extension.sh b/scripts/publish-extension.sh index 343ddba..4161e5d 100755 --- a/scripts/publish-extension.sh +++ b/scripts/publish-extension.sh @@ -20,6 +20,10 @@ # SCP_TARGET default files@files.profullstack.com # MANIFEST path to manifest.json (default: manifest.json) # BUNDLE dir to zip, or a .zip/.crx file (default: dist) +# LISTING path to a listing.json — {name?, summary?, description?, +# homepageUrl?, iconUrl?}. Sent as a PATCH after the version +# lands, so the store copy is versioned alongside the code +# instead of frozen at whatever it said the day you created it. set -euo pipefail STORE_URL="${STORE_URL:-https://tronbrowser.dev}" @@ -61,3 +65,17 @@ resp="$(curl -fsS -X POST "${STORE_URL}/api/store/extensions/${id}/versions" \ -H 'content-type: application/json' -d "$body")" echo "published ${STORE_SLUG}: $resp" + +# 4) Sync the listing copy, if the repo carries one. Publishing a version only +# refreshes the bundle — without this the description keeps describing an +# older release. +if [ -n "${LISTING:-}" ]; then + if [ ! -f "$LISTING" ]; then + echo "error: LISTING='$LISTING' not found" >&2 + exit 1 + fi + patch="$(curl -fsS -X PATCH "${STORE_URL}/api/store/extensions/${id}" \ + -H "authorization: Bearer ${TRONBROWSER_STORE_TOKEN}" \ + -H 'content-type: application/json' -d @"$LISTING")" + echo "listing copy synced: $patch" +fi diff --git a/services/api/src/store/db.test.ts b/services/api/src/store/db.test.ts index 5c1205d..2d8d6a0 100644 --- a/services/api/src/store/db.test.ts +++ b/services/api/src/store/db.test.ts @@ -1,5 +1,44 @@ import { describe, expect, it } from 'vitest'; -import { boundedInteger } from './db.js'; +import { boundedInteger, buildExtensionUpdate } from './db.js'; + +describe('buildExtensionUpdate', () => { + it('returns null when there is nothing to write', () => { + expect(buildExtensionUpdate({})).toBeNull(); + }); + + it('touches only the fields provided', () => { + const update = buildExtensionUpdate({ summary: 'Now with bulk payouts.' })!; + expect(update.set).toBe("summary = ?, updated_at = datetime('now')"); + expect(update.args).toEqual(['Now with bulk payouts.']); + }); + + it('maps camelCase fields to their columns', () => { + const update = buildExtensionUpdate({ homepageUrl: 'https://example.com', iconUrl: 'data:image/png;base64,AA' })!; + expect(update.set).toBe("homepage_url = ?, icon_url = ?, updated_at = datetime('now')"); + expect(update.args).toEqual(['https://example.com', 'data:image/png;base64,AA']); + }); + + it('distinguishes clearing a field from leaving it alone', () => { + const cleared = buildExtensionUpdate({ summary: null })!; + expect(cleared.args).toEqual([null]); + // `description` absent entirely — must not appear in the SET clause. + expect(cleared.set).not.toContain('description'); + }); + + it('always stamps updated_at so a copy edit is visible on the listing', () => { + const update = buildExtensionUpdate({ description: 'x' })!; + expect(update.set.endsWith("updated_at = datetime('now')")).toBe(true); + // updated_at is inlined SQL, not a bound arg. + expect(update.args).toHaveLength(1); + }); + + it('orders columns predictably regardless of key order', () => { + const a = buildExtensionUpdate({ description: 'd', name: 'n' })!; + const b = buildExtensionUpdate({ name: 'n', description: 'd' })!; + expect(a.set).toBe(b.set); + expect(a.args).toEqual(b.args); + }); +}); describe('boundedInteger', () => { it('falls back for non-finite pagination values', () => { diff --git a/services/api/src/store/db.ts b/services/api/src/store/db.ts index fdea9a7..fd8278c 100644 --- a/services/api/src/store/db.ts +++ b/services/api/src/store/db.ts @@ -65,6 +65,60 @@ export async function createExtension(x: { return (await extensionById(id))!; } +/** Editable listing copy. `undefined` leaves a column alone; `null` clears it. */ +export interface ExtensionPatch { + name?: string | null; + summary?: string | null; + description?: string | null; + homepageUrl?: string | null; + iconUrl?: string | null; +} + +const PATCH_COLUMNS: ReadonlyArray<[keyof ExtensionPatch, string]> = [ + ['name', 'name'], + ['summary', 'summary'], + ['description', 'description'], + ['homepageUrl', 'homepage_url'], + ['iconUrl', 'icon_url'], +]; + +/** + * Build the SET clause for a listing patch, skipping absent fields. Returns + * null when there is nothing to write, so callers can avoid a pointless UPDATE. + * Exported for tests — the SQL shape is the part worth pinning down. + */ +export function buildExtensionUpdate(patch: ExtensionPatch): { set: string; args: (string | null)[] } | null { + const set: string[] = []; + const args: (string | null)[] = []; + + for (const [key, column] of PATCH_COLUMNS) { + const value = patch[key]; + if (value === undefined) continue; + set.push(`${column} = ?`); + args.push(value === null ? null : String(value)); + } + + if (set.length === 0) return null; + set.push("updated_at = datetime('now')"); + return { set: set.join(', '), args }; +} + +/** + * Update a listing's copy. Without this a listing is write-once at creation: + * every later publish refreshes the bundle but leaves the marketing text + * frozen, so a listing keeps advertising whatever the extension did on day one. + */ +export async function updateExtension(id: string, patch: ExtensionPatch): Promise { + const update = buildExtensionUpdate(patch); + if (!update) return extensionById(id); + + await db().execute({ + sql: `UPDATE extensions SET ${update.set} WHERE id = ?`, + args: [...update.args, id], + }); + return extensionById(id); +} + export async function extensionById(id: string): Promise { const r = await db().execute({ sql: 'SELECT * FROM extensions WHERE id = ?', args: [id] }); return (r.rows[0] as unknown as Extension) ?? null; diff --git a/services/api/src/store/routes.ts b/services/api/src/store/routes.ts index e759918..76ee776 100644 --- a/services/api/src/store/routes.ts +++ b/services/api/src/store/routes.ts @@ -12,6 +12,7 @@ import { markPaidByRef, hasPaidListing, latestScan, addFlag, openFlagCount, publisherKey, handleTaken, upsertPublisherKey, createPublisherToken, userByPublisherToken, listPublisherTokens, revokePublisherToken, + updateExtension, } from './db.js'; import { validateManifest, slugify } from './manifest.js'; import { @@ -100,6 +101,43 @@ store.get('/extensions/:slug', async (c) => { return c.json(await listingView(ext)); }); +/* ---------- publisher: edit listing copy ---------- + Creation used to be the only chance to set a listing's text, so a listing + kept describing whatever the extension did on day one while every later + publish quietly refreshed the bundle underneath it. Owners (browser session + or CI publisher token) can now keep the copy honest. */ +store.patch('/extensions/:id', async (c) => { + const user = await currentUser(c); + if (!user) return c.json({ error: 'unauthorized' }, 401); + const ext = await extensionById(c.req.param('id')); + if (!ext) return c.json({ error: 'not found' }, 404); + if (ext.owner_user_id !== user.id) return c.json({ error: 'forbidden' }, 403); + + const body = await c.req.json().catch(() => ({})); + const patch: Record = {}; + for (const field of ['name', 'summary', 'description', 'homepageUrl', 'iconUrl'] as const) { + if (!(field in body)) continue; + const raw = body[field]; + if (raw === null) { patch[field] = null; continue; } + if (typeof raw !== 'string') return c.json({ error: `${field} must be a string or null` }, 400); + const trimmed = raw.trim(); + // A blank name would leave the listing untitled; every other field may clear. + if (field === 'name' && !trimmed) return c.json({ error: 'name cannot be empty' }, 400); + patch[field] = trimmed || null; + } + + if (Object.keys(patch).length === 0) return c.json({ error: 'nothing to update' }, 400); + + const updated = await updateExtension(ext.id, patch); + if (!updated) return c.json({ error: 'not found' }, 404); + + // Keep the public git trail in step with what the store now serves. + const ver = await latestVersion(updated.id); + if (ver && updated.status === 'live') await mirrorListing(updated, ver); + + return c.json({ ok: true, listing: await listingView(updated) }); +}); + /* ---------- publisher: create draft ---------- */ store.post('/extensions', async (c) => { const user = await currentUser(c); From 407e46a0b1ec86562feb8449b3ec7a278bdba0d9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 25 Jul 2026 19:01:35 +0000 Subject: [PATCH 2/2] feat(store): edit a listing from its page, no token needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PATCH endpoint alone still meant "mint a CI token in devtools, then curl" to fix a stale description — not a way to update your extension. Owners now get an "✎ Edit listing" button on the extension page: name, summary, description and homepage, prefilled and saved over the browser session. GET /extensions/:slug reports isOwner for the signed-in viewer so the button only shows for yours; the CI token path still works for publishing pipelines. Also fixes a bug this introduced: the browse route mapped listingView point-free, which would have passed the array index in as the viewer. Co-Authored-By: Claude Opus 5 (1M context) --- apps/extensions/public/store.js | 74 +++++++++++++++++++++++++++++++- services/api/src/store/routes.ts | 10 +++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index ab63b69..d70bf39 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -167,6 +167,66 @@ async function initBrowse() { load(''); } +/* Owner-only listing editor. Publishing a new build refreshes the bundle but + not the words around it, so without this a listing keeps describing whatever + the extension did the day it was created. */ +function editForm(ext) { + return ` + `; +} + +function wireEditForm(ext, rerender) { + const btn = document.getElementById('editBtn'); + const form = document.getElementById('editForm'); + if (!btn || !form) return; + + btn.addEventListener('click', () => { + form.classList.toggle('hidden'); + if (!form.classList.contains('hidden')) form.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + document.getElementById('editCancel').addEventListener('click', () => form.classList.add('hidden')); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const out = document.getElementById('editOut'); + const save = form.querySelector('button[type="submit"]'); + const fd = new FormData(form); + // Blank optional fields clear the column; a blank name is rejected server-side. + const body = { + name: String(fd.get('name') || '').trim(), + summary: String(fd.get('summary') || '').trim() || null, + description: String(fd.get('description') || '').trim() || null, + homepageUrl: String(fd.get('homepageUrl') || '').trim() || null, + }; + save.disabled = true; + out.innerHTML = 'Saving…'; + try { + await api(`/extensions/${encodeURIComponent(ext.id)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + out.innerHTML = 'Saved — listing updated.'; + await rerender(); + } catch (err) { + out.innerHTML = `${esc(err.message)}`; + save.disabled = false; + } + }); +} + /* ---------- detail (extension.html) ---------- */ async function initDetail() { const slug = qs('slug'); @@ -174,6 +234,14 @@ async function initDetail() { if (!slug) { root.innerHTML = '

No extension specified.

'; return; } if (qs('paid')) document.getElementById('paidNote')?.classList.remove('hidden'); try { + await renderDetail(slug, root); + } catch (e) { + root.innerHTML = `

${e.status === 404 ? 'Extension not found.' : esc(e.message)}

`; + } +} + +async function renderDetail(slug, root) { + { const ext = await api(`/extensions/${encodeURIComponent(slug)}`); const v = ext.version; const perms = (v?.permissions || []).map((p) => `${esc(p)}`).join('') || 'none requested'; @@ -196,7 +264,9 @@ async function initDetail() { ⬇ Install / Download How to install + ${ext.isOwner ? '' : ''} + ${ext.isOwner ? editForm(ext) : ''} ${ext.homepageUrl ? `

Homepage: ${esc(ext.homepageUrl)}

` : ''}

Permissions

${perms}
@@ -213,8 +283,8 @@ async function initDetail() { try { await api(`/extensions/${encodeURIComponent(ext.slug)}/flag`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason }) }); alert('Thanks — flagged for review.'); } catch (e) { alert('Could not flag: ' + e.message); } }); - } catch (e) { - root.innerHTML = `

${e.status === 404 ? 'Extension not found.' : esc(e.message)}

`; + + wireEditForm(ext, () => renderDetail(slug, root)); } } diff --git a/services/api/src/store/routes.ts b/services/api/src/store/routes.ts index 76ee776..17a01c8 100644 --- a/services/api/src/store/routes.ts +++ b/services/api/src/store/routes.ts @@ -51,7 +51,9 @@ function xmlEscape(s: string): string { } // Make a public listing view (no owner internals), with scan + flag summary. -async function listingView(ext: any) { +// `viewer` is optional: when the signed-in user owns the listing we say so, so +// the detail page can offer an edit form instead of making them reach for curl. +async function listingView(ext: any, viewer?: User | null) { const [ver, scan, flags] = await Promise.all([ latestVersion(ext.id), latestScan(ext.id), @@ -78,6 +80,7 @@ async function listingView(ext: any) { } : null, scan: scan ? { status: scan.status, score: scan.score, severity: scan.severity } : null, flags, + isOwner: !!viewer && viewer.id === ext.owner_user_id, updateUrl: `${APP_URL}/api/store/updates.xml?id=${ext.id}`, }; } @@ -92,13 +95,14 @@ store.get('/extensions', async (c) => { const limit = Number(c.req.query('limit') || 50); const offset = Number(c.req.query('offset') || 0); const rows = await listLiveExtensions({ q, limit, offset }); - return c.json({ extensions: await Promise.all(rows.map(listingView)) }); + // Point-free .map would hand the array index in as `viewer`. + return c.json({ extensions: await Promise.all(rows.map((row) => listingView(row))) }); }); store.get('/extensions/:slug', async (c) => { const ext = await extensionBySlug(c.req.param('slug')); if (!ext || ext.status === 'removed') return c.json({ error: 'not found' }, 404); - return c.json(await listingView(ext)); + return c.json(await listingView(ext, await currentUser(c).catch(() => null))); }); /* ---------- publisher: edit listing copy ----------