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/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..17a01c8 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 { @@ -50,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), @@ -77,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}`, }; } @@ -91,13 +95,51 @@ 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 ---------- + 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 ---------- */