Skip to content

Commit e435ed0

Browse files
committed
feat: install-source trust badges and source-change warning
- Added classifyInstallSource(url) helper in shared/utils.js mapping install/update URLs to known userscript registries with tone-coded trust signals (Greasy Fork good, Sleazy Fork warn, OpenUserJS good, GitHub release good/raw/repo neutral, etc.). - installFromCode and applyUpdate persist script.installSource and flip settings.sourceIdentityChanged when the registry id rotates between install and update. - Dashboard script rows render the source badge plus a Source changed warning; install confirmation page surfaces a Source registry changed review row when re-installing from a different registry.
1 parent 56e3e43 commit e435ed0

9 files changed

Lines changed: 395 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable changes to ScriptVault will be documented in this file.
44

55
## Unreleased
66

7+
### 2026-05-24 — Install-source trust badges + source-change warning
8+
9+
- Added shared `classifyInstallSource(url)` helper in `shared/utils.js` that
10+
maps install/update URLs to known registries (Greasy Fork, Sleazy Fork
11+
warn-tier, OpenUserJS, GitHub Gist / raw / repo / release with release
12+
promoted to good-tier, GitLab, Codeberg, Bitbucket, Tampermonkey site,
13+
and `other` for unknown hosts). Empty input returns the `local` shape.
14+
- `installFromCode` persists `script.installSource` on install; `applyUpdate`
15+
reclassifies on update and sets `settings.sourceIdentityChanged = true`
16+
plus `previousInstallSource` when the registry id changes.
17+
- Dashboard script rows render a tone-coded source badge near the name
18+
(`script-health-badge .good`, `.neutral`, or `.alert`) and a "Source
19+
changed" warning badge whenever `settings.sourceIdentityChanged` is true.
20+
- Install confirmation page's trust card surfaces a "Source registry
21+
changed" review row when re-installing from a different registry than
22+
the original install.
23+
- New `.script-health-badge.good` and `.neutral` CSS variants reuse the
24+
existing 8px corner radius (never pill backdrops).
25+
726
### 2026-05-24 — Dashboard search corpus widened + editor find history
827

928
- Dashboard search now matches against a single flattened corpus per

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,13 @@ Scale: Fit `Y/M/N`, impact and effort `1-5`, novelty `P` parity or `L` leapfrog.
392392
- Verify: search regression tests and manual editor search session.
393393
- Status: Shipped 2026-05-24. Two surfaces. (1) Dashboard search corpus broadened: a new `buildScriptSearchCorpus(script)` helper flattens name/description/author/namespace/version, all URL pattern fields (`match`, `include`, `exclude`, `userMatches`, `userIncludes`, `userExcludes`), tags (`meta.tag` + `settings.tags`), grants, homepage/support/update/download URLs, and ISO yyyy-mm-dd renderings of `stats.lastRun` + `updatedAt` into a single lowercased string. The substring/regex/code branches of `getFilteredScripts` all hit this corpus, so plain queries now match URL keywords, GreasyFork/OpenUserJS source slugs, last-run dates, and tag values. Corpus is memoized per-script keyed on `updatedAt` so repeated keystrokes don't rebuild it. (2) Editor find-widget search history persists to `chrome.storage.local.editorFindHistory` (FIFO 20, dedup with most-recent-first). The Monaco sandbox forwards every `searchString` change via `postMessage({type:'find-search'})`; `monaco-adapter.js` records via `recordFindTerm`, then primes the next editor open by posting `prime-find` with the saved history so the find widget opens pre-filled with the most recent term across sessions. Verification: `npx vitest run tests/search-corpus-history.test.js tests/site-frame-invert.test.js tests/dashboard-modules.test.js --pool=vmThreads --maxWorkers=1` passed (51 tests across 3 files); `npm run typecheck` clean.
394394

395-
- [ ] P2 - Add install-source trust badges without full marketplace scope
395+
- [x] P2 - Add install-source trust badges without full marketplace scope
396396
- Why: Registry source is a useful trust signal, but a full marketplace adds moderation risk.
397397
- Evidence: S50,S51,H047,H049.
398398
- Touches: `pages/install.js`, `pages/dashboard-store.js`, parser/source metadata, tests.
399399
- Acceptance: Scripts installed from known registries show durable source metadata and warnings when source identity changes.
400400
- Verify: local fixture URLs for GreasyFork/OpenUserJS/GitHub/raw.
401+
- Status: Shipped 2026-05-24. New shared `classifyInstallSource(url)` helper (in `shared/utils.js`, accessible from background.js, dashboard, install page) returns a stable `{ id, name, hostname, tone, url }` shape for Greasy Fork, Sleazy Fork (warn), OpenUserJS, GitHub Gist / raw / repo / release (release is the strongest tier), GitLab, Codeberg, Bitbucket, Tampermonkey site, and `other` (warn) for unknown hosts. Empty input maps to `local`. `installFromCode` records `script.installSource` at install time; `applyUpdate` reclassifies on update and — when the registry id changes — flips `settings.sourceIdentityChanged = true` and preserves the prior record in `script.previousInstallSource`. Dashboard script rows render a tone-coded badge (`good`/`neutral`/`alert` — new `.script-health-badge.good`/`.neutral` CSS reusing the existing 8px corner radius to honor the no-pill-backdrops global rule). Install confirmation page's trust card embeds a `Source registry changed` review row when re-installing from a different registry than the original source. Verification: `npx vitest run tests/install-source.test.js tests/utils.test.js tests/core-flows.test.js tests/runtime-import-export.test.js --pool=vmThreads --maxWorkers=1` passed (79 tests across 4 files); `npm run typecheck` clean; `npm run build:bg` clean (background.js 21,474 lines).
401402

402403
- [ ] P2 - Add locale coverage and forced language checks
403404
- Why: `_locales` exists, but coverage should be reported and not silently regress.

background.core.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,19 @@ const UpdateSystem = {
634634
script.updatedAt = Date.now();
635635
script.trustReceipt = trustReceipt;
636636

637+
// Re-classify the install source. If the update came from a different
638+
// registry than the install, flag it for the dashboard banner.
639+
const updateSourceUrl = sourceUrl || parsed.meta.downloadURL || parsed.meta.updateURL || '';
640+
const updatedSource = classifyInstallSource(updateSourceUrl);
641+
if (script.installSource?.id && updatedSource.id !== 'local'
642+
&& script.installSource.id !== updatedSource.id) {
643+
script.settings = { ...(script.settings || {}), sourceIdentityChanged: true };
644+
script.previousInstallSource = script.installSource;
645+
script.installSource = updatedSource;
646+
} else if (!script.installSource && updatedSource.id !== 'local') {
647+
script.installSource = updatedSource;
648+
}
649+
637650
// Re-register FIRST so we can verify the new code works before persisting
638651
try {
639652
await unregisterScript(scriptId);
@@ -5877,6 +5890,17 @@ async function installFromCode(code, receiptOptions = {}) {
58775890
});
58785891
}
58795892

5893+
// Classify the install source (Greasy Fork / OpenUserJS / GitHub / ...)
5894+
// so the dashboard can render a durable trust badge and so a future
5895+
// update from a different registry surfaces as a "source changed" flag.
5896+
const effectiveSourceUrl = receiptOptions.sourceUrl || meta.downloadURL || meta.updateURL || '';
5897+
const installSource = classifyInstallSource(effectiveSourceUrl);
5898+
let sourceIdentityChanged = false;
5899+
if (existing && existing.installSource && existing.installSource.id && installSource.id !== 'local'
5900+
&& existing.installSource.id !== installSource.id) {
5901+
sourceIdentityChanged = true;
5902+
}
5903+
58805904
const script = {
58815905
...existing,
58825906
id,
@@ -5886,8 +5910,15 @@ async function installFromCode(code, receiptOptions = {}) {
58865910
position: existing ? existing.position : allScripts.length,
58875911
createdAt: existing ? existing.createdAt : Date.now(),
58885912
updatedAt: Date.now(),
5889-
trustReceipt
5913+
trustReceipt,
5914+
installSource: installSource.id === 'local' && existing?.installSource
5915+
? existing.installSource
5916+
: installSource
58905917
};
5918+
if (sourceIdentityChanged) {
5919+
script.settings = { ...(script.settings || existing?.settings || {}), sourceIdentityChanged: true };
5920+
script.previousInstallSource = existing.installSource;
5921+
}
58915922
if (versionHistory.length > 0) script.versionHistory = versionHistory;
58925923

58935924
await ScriptStorage.set(id, script);

background.js

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,68 @@ function sanitizeUrl(url) {
5454
return trimmed;
5555
}
5656

57+
/**
58+
* Classify an install/update URL into a known userscript registry. Returns a
59+
* stable shape used by both the install confirmation page and the dashboard
60+
* source-trust badge. Unknown URLs fall back to `id: 'other'` with their
61+
* hostname preserved; falsy input returns the local-import shape.
62+
*
63+
* The `id` is what `script.installSource.id` is keyed on, so changing labels
64+
* is safe but renaming an `id` will cause source-identity-change warnings to
65+
* fire on every script.
66+
*
67+
* @param {string} url
68+
* @returns {{ id: string, name: string, hostname: string, tone: 'good'|'neutral'|'warn', url: string }}
69+
*/
70+
function classifyInstallSource(url) {
71+
if (typeof url !== 'string' || !url.trim()) {
72+
return { id: 'local', name: 'Local import', hostname: '', tone: 'neutral', url: '' };
73+
}
74+
let host = '';
75+
let path = '';
76+
try {
77+
const u = new URL(url);
78+
host = (u.hostname || '').toLowerCase();
79+
path = u.pathname || '';
80+
} catch (_) {
81+
return { id: 'other', name: 'Unknown source', hostname: '', tone: 'warn', url };
82+
}
83+
if (host === 'greasyfork.org' || host === 'www.greasyfork.org') {
84+
return { id: 'greasyfork', name: 'Greasy Fork', hostname: host, tone: 'good', url };
85+
}
86+
if (host === 'sleazyfork.org' || host === 'www.sleazyfork.org') {
87+
return { id: 'sleazyfork', name: 'Sleazy Fork', hostname: host, tone: 'warn', url };
88+
}
89+
if (host === 'openuserjs.org' || host === 'www.openuserjs.org') {
90+
return { id: 'openuserjs', name: 'OpenUserJS', hostname: host, tone: 'good', url };
91+
}
92+
if (host === 'gist.github.com' || host === 'gist.githubusercontent.com') {
93+
return { id: 'github-gist', name: 'GitHub Gist', hostname: host, tone: 'neutral', url };
94+
}
95+
if (host === 'raw.githubusercontent.com') {
96+
return { id: 'github-raw', name: 'GitHub raw', hostname: host, tone: 'neutral', url };
97+
}
98+
if (host === 'github.com' || host === 'www.github.com') {
99+
if (/\/releases\/(download|latest)/i.test(path)) {
100+
return { id: 'github-release', name: 'GitHub release', hostname: host, tone: 'good', url };
101+
}
102+
return { id: 'github', name: 'GitHub', hostname: host, tone: 'neutral', url };
103+
}
104+
if (host === 'gitlab.com' || host === 'www.gitlab.com') {
105+
return { id: 'gitlab', name: 'GitLab', hostname: host, tone: 'neutral', url };
106+
}
107+
if (host === 'codeberg.org') {
108+
return { id: 'codeberg', name: 'Codeberg', hostname: host, tone: 'neutral', url };
109+
}
110+
if (host === 'bitbucket.org') {
111+
return { id: 'bitbucket', name: 'Bitbucket', hostname: host, tone: 'neutral', url };
112+
}
113+
if (host === 'tampermonkey.net' || host === 'www.tampermonkey.net') {
114+
return { id: 'tampermonkey', name: 'Tampermonkey site', hostname: host, tone: 'neutral', url };
115+
}
116+
return { id: 'other', name: host || 'Unknown source', hostname: host, tone: 'warn', url };
117+
}
118+
57119
/**
58120
* Format byte count as human-readable string.
59121
* @param {number} bytes - The byte count to format
@@ -12569,6 +12631,19 @@ const UpdateSystem = {
1256912631
script.updatedAt = Date.now();
1257012632
script.trustReceipt = trustReceipt;
1257112633

12634+
// Re-classify the install source. If the update came from a different
12635+
// registry than the install, flag it for the dashboard banner.
12636+
const updateSourceUrl = sourceUrl || parsed.meta.downloadURL || parsed.meta.updateURL || '';
12637+
const updatedSource = classifyInstallSource(updateSourceUrl);
12638+
if (script.installSource?.id && updatedSource.id !== 'local'
12639+
&& script.installSource.id !== updatedSource.id) {
12640+
script.settings = { ...(script.settings || {}), sourceIdentityChanged: true };
12641+
script.previousInstallSource = script.installSource;
12642+
script.installSource = updatedSource;
12643+
} else if (!script.installSource && updatedSource.id !== 'local') {
12644+
script.installSource = updatedSource;
12645+
}
12646+
1257212647
// Re-register FIRST so we can verify the new code works before persisting
1257312648
try {
1257412649
await unregisterScript(scriptId);
@@ -17812,6 +17887,17 @@ async function installFromCode(code, receiptOptions = {}) {
1781217887
});
1781317888
}
1781417889

17890+
// Classify the install source (Greasy Fork / OpenUserJS / GitHub / ...)
17891+
// so the dashboard can render a durable trust badge and so a future
17892+
// update from a different registry surfaces as a "source changed" flag.
17893+
const effectiveSourceUrl = receiptOptions.sourceUrl || meta.downloadURL || meta.updateURL || '';
17894+
const installSource = classifyInstallSource(effectiveSourceUrl);
17895+
let sourceIdentityChanged = false;
17896+
if (existing && existing.installSource && existing.installSource.id && installSource.id !== 'local'
17897+
&& existing.installSource.id !== installSource.id) {
17898+
sourceIdentityChanged = true;
17899+
}
17900+
1781517901
const script = {
1781617902
...existing,
1781717903
id,
@@ -17821,8 +17907,15 @@ async function installFromCode(code, receiptOptions = {}) {
1782117907
position: existing ? existing.position : allScripts.length,
1782217908
createdAt: existing ? existing.createdAt : Date.now(),
1782317909
updatedAt: Date.now(),
17824-
trustReceipt
17910+
trustReceipt,
17911+
installSource: installSource.id === 'local' && existing?.installSource
17912+
? existing.installSource
17913+
: installSource
1782517914
};
17915+
if (sourceIdentityChanged) {
17916+
script.settings = { ...(script.settings || existing?.settings || {}), sourceIdentityChanged: true };
17917+
script.previousInstallSource = existing.installSource;
17918+
}
1782617919
if (versionHistory.length > 0) script.versionHistory = versionHistory;
1782717920

1782817921
await ScriptStorage.set(id, script);

pages/dashboard.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4164,6 +4164,18 @@
41644164
border: 1px solid rgba(239, 68, 68, 0.24);
41654165
color: #fecaca;
41664166
}
4167+
4168+
.script-health-badge.good {
4169+
background: rgba(34, 197, 94, 0.14);
4170+
border: 1px solid rgba(34, 197, 94, 0.24);
4171+
color: #bbf7d0;
4172+
}
4173+
4174+
.script-health-badge.neutral {
4175+
background: rgba(148, 163, 184, 0.14);
4176+
border: 1px solid rgba(148, 163, 184, 0.24);
4177+
color: #cbd5e1;
4178+
}
41674179
.script-favicon {
41684180
width: 16px;
41694181
height: 16px;

pages/dashboard.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4876,6 +4876,21 @@
48764876
const errorHtml = hasErrors
48774877
? `<span class="script-health-badge alert" title="${escapeHtml(String(script.stats?.errors || 0))} execution error(s) recorded.">Errors</span>`
48784878
: '';
4879+
// Install-source trust badge — durable across edits and visible at a
4880+
// glance in the script list. `tone: 'warn'` paints alert, `'good'`
4881+
// paints success; otherwise neutral.
4882+
let sourceBadgeHtml = '';
4883+
if (script.installSource && script.installSource.id && script.installSource.id !== 'local') {
4884+
const src = script.installSource;
4885+
const cls = src.tone === 'warn' ? 'alert' : src.tone === 'good' ? 'good' : 'neutral';
4886+
const title = src.url
4887+
? `Installed from ${src.name} (${src.hostname || ''}). Source URL: ${src.url}`
4888+
: `Installed from ${src.name}.`;
4889+
sourceBadgeHtml = `<span class="script-health-badge ${cls}" data-source-badge="${escapeHtml(src.id)}" title="${escapeHtml(title)}">${escapeHtml(src.name)}</span>`;
4890+
}
4891+
const sourceChangedHtml = script.settings?.sourceIdentityChanged
4892+
? `<span class="script-health-badge alert" title="The update channel now points to a different registry than the original install (${escapeHtml(script.previousInstallSource?.name || 'unknown')} → ${escapeHtml(script.installSource?.name || 'unknown')}). Review before trusting future updates.">Source changed</span>`
4893+
: '';
48794894
if (hasErrors) tr.classList.add('row-has-errors');
48804895
if (isStale) tr.classList.add('row-stale');
48814896
if (overBudget) tr.classList.add('row-over-budget');
@@ -4903,6 +4918,8 @@
49034918
${script.metadata?.author ? `<span class="script-author">by ${escapeHtml(script.metadata.author)}</span>` : ''}
49044919
</div>
49054920
<div class="script-name-badges">
4921+
${sourceBadgeHtml}
4922+
${sourceChangedHtml}
49064923
${localEditsHtml}
49074924
${errorHtml}
49084925
${slowHtml}

pages/install.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,13 +644,30 @@ function getProvenanceSummary(sourceUrl, meta) {
644644
detail = 'Local install with a remote update channel declared in metadata.';
645645
}
646646

647+
// Surface a source-registry change when the user is updating a script and
648+
// the new install URL classifies to a different known registry than the
649+
// existing record. Existing script lookup is shared with `pages/install.js`
650+
// confirmation flow; we re-derive the classification here to avoid
651+
// round-tripping through background for an idempotent computation.
652+
let sourceChange = null;
653+
if (typeof classifyInstallSource === 'function' && existingScript?.installSource?.id) {
654+
const newSource = classifyInstallSource(sourceUrl || downloadUrl || updateUrl || '');
655+
if (newSource.id !== 'local' && newSource.id !== existingScript.installSource.id) {
656+
sourceChange = {
657+
previous: existingScript.installSource,
658+
next: newSource
659+
};
660+
}
661+
}
662+
647663
return {
648664
label,
649665
detail,
650666
installSource,
651667
updateUrl,
652668
downloadUrl,
653-
homepageUrl
669+
homepageUrl,
670+
sourceChange
654671
};
655672
}
656673

@@ -710,6 +727,15 @@ function renderTrustCard(sourceUrl) {
710727
</div>
711728
<span class="trust-link">${provenance.installSource.safeUrl ? renderExternalLink(provenance.installSource.label, 'Open') : 'Local'}</span>
712729
</div>
730+
${provenance.sourceChange ? `
731+
<div class="trust-row" role="alert">
732+
<div class="trust-copy">
733+
<strong>Source registry changed</strong>
734+
<span>Previous install came from ${escapeHtml(provenance.sourceChange.previous.name)} (${escapeHtml(provenance.sourceChange.previous.hostname || '—')}). This update is from ${escapeHtml(provenance.sourceChange.next.name)} (${escapeHtml(provenance.sourceChange.next.hostname || '—')}). Confirm you trust the new origin before installing.</span>
735+
</div>
736+
<span class="count status-warn">Review</span>
737+
</div>
738+
` : ''}
713739
${provenance.updateUrl ? `
714740
<div class="trust-row">
715741
<div class="trust-copy">

0 commit comments

Comments
 (0)