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
71 changes: 68 additions & 3 deletions apps/extensions/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,70 @@ function esc(s) { const d = document.createElement('div'); d.textContent = s ==
function qs(name) { return new URLSearchParams(location.search).get(name); }
function initials(name) { return (name || '?').trim().split(/\s+/).slice(0, 2).map((w) => w[0] || '').join('').toUpperCase(); }

// Logo if the listing has one (auto-ingested from the .crx icons), else initials.
function avatar(ext, px) {
const style = px ? ` style="width:${px}px;height:${px}px"` : '';
if (ext.iconUrl) return `<img class="avatar" src="${esc(ext.iconUrl)}" alt=""${style} style="object-fit:cover${px ? `;width:${px}px;height:${px}px` : ''}">`;
return `<div class="avatar"${px ? ` style="width:${px}px;height:${px}px;font-size:20px"` : ''}>${esc(initials(ext.name))}</div>`;
}

// ── AI auto-ingest: paste a .crx URL, we fill the form + scan (no forms) ──
function renderScanResult(scan) {
const c = scan.countsBySeverity || {};
const chip = (sev, n) => (n ? `<span class="badge ${sev}">${n} ${sev}</span>` : '');
const findings = (scan.findings || []).slice(0, 12)
.map((f) => `<li><b>${esc(f.severity)}</b> ${esc(f.rule)} — ${esc(f.detail)}${f.file ? ` <span class="muted">(${esc(f.file)})</span>` : ''}</li>`).join('');
const verdict = scan.green
? '<p class="success">✅ Green light — passes the security scan and can be published.</p>'
: '<p class="error">⛔ Blocked — critical issues must be fixed before publishing.</p>';
return `${verdict}
<div class="meta">${chip('critical', c.critical)} ${chip('high', c.high)} ${chip('medium', c.medium)} ${chip('low', c.low)}</div>
${findings ? `<ul class="findings">${findings}</ul>` : '<p class="muted">No findings.</p>'}`;
}

function wireAutoIngest(form) {
const wrap = document.createElement('div');
wrap.className = 'panel';
wrap.style.marginBottom = '14px';
wrap.innerHTML = `<h2>1 · Auto-fill from your .crx ✨</h2>
<p class="hint">Paste your published <code>.crx</code> URL — we read its manifest, icons, and code to fill everything below and run the security scan. A <b>green scan is required to publish</b>.</p>
<div style="display:flex;gap:8px;align-items:flex-start">
<input type="url" id="ingestUrl" placeholder="https://…/your-extension.crx" style="flex:1" />
<button type="button" class="btn" id="ingestBtn">Ingest &amp; scan</button>
</div>
<div id="ingestPreview" style="display:flex;gap:12px;align-items:center;margin-top:10px"></div>
<div id="ingestOut"></div>`;
form.parentNode.insertBefore(wrap, form);

const submitBtn = form.querySelector('button[type="submit"]');
wrap.querySelector('#ingestBtn').addEventListener('click', async () => {
const url = wrap.querySelector('#ingestUrl').value.trim();
const out = wrap.querySelector('#ingestOut');
const preview = wrap.querySelector('#ingestPreview');
if (!url) { out.innerHTML = '<p class="error">Paste a .crx URL first.</p>'; return; }
out.innerHTML = '<p class="muted">Reading .crx + scanning…</p>';
try {
const { listing, scan } = await api('/extensions/ingest', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ crxUrl: url }),
});
form.name.value = listing.name || '';
form.summary.value = listing.summary || '';
form.description.value = listing.description || '';
form.manifest.value = listing.manifestJson || '';
if (form.crxUrl) form.crxUrl.value = url;
form.dataset.iconUrl = listing.iconDataUri || '';
preview.innerHTML = `${listing.iconDataUri ? `<img src="${esc(listing.iconDataUri)}" alt="" style="width:48px;height:48px;border-radius:10px;object-fit:cover">` : ''}
<div><b>${esc(listing.name)}</b> <span class="badge ver">v${esc(listing.version)}</span><br>
<span class="muted">${(listing.permissions || []).length} permission(s)</span></div>`;
out.innerHTML = renderScanResult(scan);
if (submitBtn) submitBtn.disabled = !scan.green;
} catch (e) {
out.innerHTML = `<p class="error">${esc(e.message)}</p>`;
if (submitBtn) submitBtn.disabled = true;
}
});
}

async function api(path, opts = {}) {
const res = await fetch(API + path, { credentials: 'include', ...opts });
const data = await res.json().catch(() => ({}));
Expand All @@ -31,7 +95,7 @@ function card(ext) {
const flags = ext.flags > 0 ? `<span class="badge flag" title="community flags">⚑ ${ext.flags}</span>` : '';
return `<a class="card" href="/store/extension.html?slug=${encodeURIComponent(ext.slug)}">
<div class="top">
<div class="avatar">${esc(initials(ext.name))}</div>
${avatar(ext)}
<div><h3>${esc(ext.name)}</h3></div>
</div>
<p class="summary">${esc(ext.summary || '')}</p>
Expand Down Expand Up @@ -73,7 +137,7 @@ async function initDetail() {
const dl = v ? `${API}/extensions/${encodeURIComponent(ext.slug)}/download` : '#';
root.innerHTML = `
<div class="top" style="gap:16px;margin-bottom:8px">
<div class="avatar" style="width:56px;height:56px;font-size:20px">${esc(initials(ext.name))}</div>
${avatar(ext, 56)}
<div>
<h1 style="margin:0">${esc(ext.name)}</h1>
<p class="muted" style="margin:4px 0 0">${esc(ext.summary || '')}</p>
Expand Down Expand Up @@ -174,6 +238,7 @@ async function initSubmit() {
return;
}
await renderPublisher();
wireAutoIngest(form);
form.addEventListener('submit', async (e) => {
e.preventDefault();
out.innerHTML = '<p class="muted">Working…</p>';
Expand All @@ -190,7 +255,7 @@ async function initSubmit() {
// 1) create draft (this assigns the slug we need for the upload path)
const draft = await api('/extensions', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: fd.get('name'), summary: fd.get('summary'), description: fd.get('description'), homepageUrl: fd.get('homepageUrl') }),
body: JSON.stringify({ name: fd.get('name'), summary: fd.get('summary'), description: fd.get('description'), homepageUrl: fd.get('homepageUrl'), iconUrl: form.dataset.iconUrl || null }),
});

// If uploading to files.profullstack.com, pause so they can scp to the
Expand Down
2 changes: 1 addition & 1 deletion apps/extensions/public/submit.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ <h2>2 · Listing</h2>
<div class="field"><label>Name *</label><input type="text" name="name" required placeholder="Acme Dark Mode" /></div>
<div class="field"><label>Summary</label><input type="text" name="summary" placeholder="One-line tagline" /></div>
<div class="field"><label>Description (markdown)</label><textarea name="description" placeholder="What it does…"></textarea></div>
<div class="field"><label>Homepage URL</label><input type="url" name="homepageUrl" placeholder="https://" /></div>
<div class="field"><label>Source / homepage <span class="hint">(GitHub, GitLab, or project site)</span></label><input type="url" name="homepageUrl" placeholder="https://github.com/you/your-extension" /></div>

<h3>3 · Upload your bundle</h3>
<p class="hint">Package your extension the normal Chromium way (a <code>.crx</code> for install + auto-update, and/or a <code>.zip</code> for sideloading), then upload it with the one-liner below. The slug comes from your name; we'll show the exact path after you name it.</p>
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@libsql/client": "^0.14.0",
"@logicsrc/agentswarm": "^0.1.0",
"deepagents": "^1.10.5",
"fflate": "^0.8.3",
"hono": "^4.6.14"
},
"devDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions services/api/src/store/crx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { crxToZip, extractListingFromCrx } from './crx.js';

// Build a minimal CRX3 (Cr24 + fake header + zip) for testing.
function makeCrx(files: Record<string, Uint8Array>, headerLen = 8): Uint8Array {
const zip = zipSync(files);
const head = new Uint8Array(12 + headerLen);
head.set(strToU8('Cr24'), 0);
new DataView(head.buffer).setUint32(4, 3, true); // version 3
new DataView(head.buffer).setUint32(8, headerLen, true);
const out = new Uint8Array(head.length + zip.length);
out.set(head, 0);
out.set(zip, head.length);
return out;
}

const PNG = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);

describe('crx ingest', () => {
it('extracts manifest + icon from a crx', () => {
const manifest = {
manifest_version: 3,
name: 'Test Ext',
version: '1.2.3',
description: 'A test extension',
permissions: ['storage'],
host_permissions: ['https://example.com/*'],
icons: { '16': 'icons/16.png', '128': 'icons/128.png' },
};
const crx = makeCrx({
'manifest.json': strToU8(JSON.stringify(manifest)),
'icons/128.png': PNG,
'background.js': strToU8('console.log(1)'),
});
const l = extractListingFromCrx(crx);
expect(l.name).toBe('Test Ext');
expect(l.version).toBe('1.2.3');
expect(l.summary).toBe('A test extension');
expect(l.permissions).toEqual(['storage', 'https://example.com/*']);
expect(l.iconDataUri).toMatch(/^data:image\/png;base64,/);
});

it('rejects a non-crx buffer', () => {
expect(() => crxToZip(strToU8('not a crx at all'))).toThrow(/Cr24/);
});

it('rejects a non-MV3 manifest', () => {
const crx = makeCrx({ 'manifest.json': strToU8(JSON.stringify({ manifest_version: 2, name: 'x', version: '1' })) });
expect(() => extractListingFromCrx(crx)).toThrow(/MV3-only/);
});
});
133 changes: 133 additions & 0 deletions services/api/src/store/crx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// CRX auto-ingest: a .crx (Chromium extension package) is `Cr24` + a signed
// protobuf header + a ZIP. We parse it to auto-fill a listing from the
// extension's own manifest.json + icons — so publishers don't fill out forms.
//
// Pure parsing (fetch is separate + guarded) so it's unit-testable.
import { unzipSync, strFromU8 } from 'fflate';

export interface IngestedListing {
name: string;
summary: string | null;
description: string | null;
version: string;
manifestVersion: number;
/** union of permissions + host_permissions */
permissions: string[];
/** the raw manifest.json text (what the version record stores) */
manifestJson: string;
/** largest icon as a data: URI, or null if the crx has none */
iconDataUri: string | null;
}

const CRX_MAGIC = 'Cr24';
const MAX_ICON_BYTES = 512 * 1024; // don't inline absurd icons as data URIs

/** Extract the embedded ZIP from a CRX2/CRX3 buffer. */
export function crxToZip(buf: Uint8Array): Uint8Array {
if (buf.length < 16 || strFromU8(buf.slice(0, 4)) !== CRX_MAGIC) {
throw new Error('not a .crx file (missing Cr24 magic)');
}
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const version = dv.getUint32(4, true);
if (version === 2) {
// CRX2: magic(4) ver(4) pubKeyLen(4) sigLen(4) then key+sig then zip.
const pubKeyLen = dv.getUint32(8, true);
const sigLen = dv.getUint32(12, true);
return buf.slice(16 + pubKeyLen + sigLen);
}
if (version === 3) {
// CRX3: magic(4) ver(4) headerLen(4) then header then zip.
const headerLen = dv.getUint32(8, true);
return buf.slice(12 + headerLen);
}
throw new Error(`unsupported CRX version ${version}`);
}

function iconToDataUri(path: string, bytes: Uint8Array): string | null {
if (!bytes || bytes.length === 0 || bytes.length > MAX_ICON_BYTES) return null;
const ext = path.toLowerCase().split('.').pop() || '';
const mime =
ext === 'png' ? 'image/png' :
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
ext === 'svg' ? 'image/svg+xml' :
ext === 'webp' ? 'image/webp' :
ext === 'gif' ? 'image/gif' : null;
if (!mime) return null;
const b64 = Buffer.from(bytes).toString('base64');
return `data:${mime};base64,${b64}`;
}

/** Pick the highest-resolution icon declared in the manifest. */
function pickIcon(manifest: any, files: Record<string, Uint8Array>): string | null {
const icons: Record<string, string> = manifest.icons || {};
const bySize = Object.keys(icons)
.map((k) => [parseInt(k, 10) || 0, icons[k]] as const)
.filter((e): e is readonly [number, string] => typeof e[1] === 'string')
.sort((a, b) => b[0] - a[0]);
for (const [, rel] of bySize) {
const path = rel.replace(/^\.?\//, '');
if (files[path]) {
const uri = iconToDataUri(path, files[path]);
if (uri) return uri;
}
}
return null;
}

/** Parse a .crx buffer into an auto-filled listing draft. */
export function extractListingFromCrx(buf: Uint8Array): IngestedListing {
const files = unzipSync(crxToZip(buf), { filter: (f) => !f.name.endsWith('/') });
const manifestBytes = files['manifest.json'];
if (!manifestBytes) throw new Error('crx has no manifest.json');
const manifestJson = strFromU8(manifestBytes);
let manifest: any;
try {
manifest = JSON.parse(manifestJson);
} catch (e: any) {
throw new Error(`manifest.json is not valid JSON: ${e.message}`);
}
if (manifest.manifest_version !== 3) {
throw new Error(`the store is MV3-only (crx manifest_version = ${manifest.manifest_version})`);
}
const permissions: string[] = [
...(Array.isArray(manifest.permissions) ? manifest.permissions : []),
...(Array.isArray(manifest.host_permissions) ? manifest.host_permissions : []),
].filter((p) => typeof p === 'string');

const description: string | null = typeof manifest.description === 'string' ? manifest.description : null;
return {
name: String(manifest.name || '').trim(),
summary: description ? description.slice(0, 140) : null,
description,
version: String(manifest.version || ''),
manifestVersion: 3,
permissions: [...new Set(permissions)],
manifestJson,
iconDataUri: pickIcon(manifest, files),
};
}

/** Download a .crx over http(s) with guards. */
export async function fetchCrx(url: string, maxBytes = 25 * 1024 * 1024): Promise<Uint8Array> {
let u: URL;
try {
u = new URL(url);
} catch {
throw new Error('crxUrl must be a valid URL');
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error('crxUrl must be http(s)');
}
const res = await fetch(u, { redirect: 'follow' });
if (!res.ok) throw new Error(`could not fetch crx (${res.status})`);
const len = Number(res.headers.get('content-length') || 0);
if (len && len > maxBytes) throw new Error('crx is too large');
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.length > maxBytes) throw new Error('crx is too large');
return buf;
}

/** Download a .crx and extract its listing. */
export async function ingestCrxUrl(url: string): Promise<IngestedListing> {
return extractListingFromCrx(await fetchCrx(url));
}
Loading
Loading