Skip to content
Open
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
19 changes: 15 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy policy β€” Clay Slip

_Last updated: 2026-05-13_
_Last updated: 2026-05-13 (per-site permissions model)_

Clay Slip is a developer tool. It runs entirely on your device, in your browser. **It does not collect, transmit, sell, or share any personal data.**

Expand Down Expand Up @@ -33,7 +33,7 @@ You can clear everything from the extension's **Options** page (Reset preference

## What the extension reads from the page

To do its job, the content script reads:
The content script only runs on sites you have explicitly granted access to via Chrome's per-site permission prompt (see "Permissions" below). On those granted sites, it reads:

- The `data-uri` and `data-editable` attributes that Clay sites set on rendered components.
- Standard `<head>` metadata (`<title>`, `<meta>` tags, `<link rel="canonical">`, JSON-LD) for the SEO tab.
Expand All @@ -59,14 +59,25 @@ All of these requests target the Clay site you are already browsing (or another

## Permissions and why each is requested

### Required at install (minimum)

| Permission | Why it's requested |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `activeTab` | Used by the Screenshot feature: when you click _Screenshot_ on a selected component, the service worker calls `chrome.tabs.captureVisibleTab` and crops the result to the component's bounding box. The PNG is written to your clipboard and discarded β€” never uploaded. |
| `storage` | Persists the user-controlled state described in the table above. Local-only. |
| `clipboardWrite` | Implements the panel's _Copy URI_, _Copy as cURL/fetch()/CSS_, _Share_, _Export_, and _Screenshot_ actions. Each clipboard write is initiated by an explicit user click. |
| `<all_urls>` | The content script must run on every page so it can detect Clay-rendered pages by reading the `data-uri` attribute on `<html>`. On non-Clay pages the extension exits immediately without reading or modifying anything else. |

The extension does **not** request `cookies`, `webRequest`, `webNavigation`, `history`, `bookmarks`, `identity`, `notifications`, `geolocation`, or any other sensitive permission.
The extension declares **zero required `host_permissions`**. On a fresh install it can read or modify nothing on any website until you opt in.

### Granted by you, per-site, at runtime

| Permission | Why it's requested |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `optional_host_permissions: ["<all_urls>"]` | Declares the _upper bound_ of hosts the extension is later allowed to ask for. The extension does **not** auto-grab any host. From **Options β†’ Allowed sites** (or the toolbar popup's _Allow on this site_ button), you can grant access to specific hostnames; Chrome shows its native consent prompt for each one. Revocation is one click away from the same UI, or from `chrome://extensions β†’ Site access`. |

Granted hosts are visible at any time under `chrome://extensions β†’ Clay Slip β†’ Site access`. The content script only ever runs on sites you have explicitly enabled.

The extension does **not** request `cookies`, `webRequest`, `webNavigation`, `history`, `bookmarks`, `identity`, `notifications`, `geolocation`, `tabs`, `scripting`, or any other sensitive permission.

---

Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Clay annotates rendered HTML with `data-uri` attributes on every component, page
## Highlights

- **Manifest V3** Chrome extension built with TypeScript, React, Vite, and `@crxjs/vite-plugin`
- **Zero-broad-permissions install** β€” ships with no host access; users grant specific Clay deployments per-site through a native Chrome consent prompt (Options β†’ _Allowed sites_, or one click from the toolbar popup)
- **Shadow-DOM panel** that never collides with host page styles
- **Component tree + find-on-page** β€” live filter dims non-matches on the page, <kbd>Enter</kbd> cycles through them, <kbd>Esc</kbd> clears
- **Inline JSON preview** so you don't need to open a new tab to read component data
Expand Down Expand Up @@ -46,6 +47,9 @@ Then in Chrome:
1. Visit `chrome://extensions`
2. Enable **Developer mode** (top right)
3. Click **Load unpacked** and select the `dist/` directory
4. Open the extension's **Options** page β†’ **Allowed sites** β†’ add the hostnames of the Clay deployments you want to inspect (e.g. `www.thecut.com`). Chrome will show a native consent prompt for each one.

Clay Slip ships with **no host access by default** β€” the toolbar icon's popup also has a one-click "Allow on this site" button if you'd rather grant from a Clay tab.

For live development with HMR:

Expand Down Expand Up @@ -90,6 +94,17 @@ Reload the extension in `chrome://extensions` after switching between `dev` and

## Configuration

### Allowed sites (host permissions)

Clay Slip declares **no required host permissions** in its manifest. On a fresh install it has access to nothing. To enable inspection on a site, add it from one of two places:

- **Options page β†’ Allowed sites**: type the bare hostname (e.g. `www.thecut.com`) and click _Grant access_. Chrome will pop a native consent dialog. Once granted, the content script auto-injects on every page of that origin.
- **Toolbar popup**: click the Clay Slip icon on any page and click _Allow on this site_. This is the fastest way to onboard a Clay tab you're already on.

Once you start filling out **Site host mappings** (next section) the Options page will show a **Pending** strip listing any mapping hostnames that are not yet granted, with a one-click _Grant all_ button.

Revoke at any time from the same UI, or from Chrome's _Manage extensions β†’ Site access_ panel.

### Site host mappings

The **Site host mappings** section in the options page is a per-instance lookup table mapping each brand to its hostnames per environment. With it configured, the panel renders a **View on:** pill row on every Clay page so you can jump to the equivalent URL on a different env, and the **Share** button gains a **β–Ύ** picker for cross-env share links. Empty by default β€” every fork populates its own.
Expand Down Expand Up @@ -123,11 +138,12 @@ src/
β”‚ β”œβ”€β”€ styles.css # Shadow-scoped styles
β”‚ β”œβ”€β”€ components/ # Tabs, tree, JSON viewer, diff, breadcrumb…
β”‚ └── hooks/ # Drag, theme, shortcuts, selection
β”œβ”€β”€ popup/ # "Not a Clay page" popup (active until a page sends CLAY_DETECTED)
β”œβ”€β”€ options/ # Full options page (env hosts, dock + width, intensity, recents, shortcuts)
β”œβ”€β”€ popup/ # Toolbar popup (grant access on this site / not-a-Clay-page state)
β”œβ”€β”€ options/ # Full options page (allowed sites, env hosts, dock + width, intensity, recents, shortcuts)
└── lib/ # Pure utilities
β”œβ”€β”€ clay-uri.ts # URI parsing + buildUrl/buildEditorUrl/buildShareLink + copy-as helpers
β”œβ”€β”€ clipboard.ts # Modern + legacy clipboard
β”œβ”€β”€ permissions.ts # chrome.permissions wrappers (request / list / remove granted hosts)
β”œβ”€β”€ storage.ts # User preferences in chrome.storage.sync
β”œβ”€β”€ annotations.ts # Sticky notes per component URI
β”œβ”€β”€ recents.ts # Recently viewed components history
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clay-slip",
"version": "2.0.2",
"version": "2.1.0",
"description": "Modern devtools for Clay CMS pages: visualize component boundaries, inspect data, and navigate the page/layout hierarchy.",
"private": true,
"type": "module",
Expand Down
109 changes: 109 additions & 0 deletions src/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* User-controlled host-permission management.
*
* Clay Slip ships with **no** required host permissions. The user adds
* specific Clay hostnames from the Options page; each addition triggers a
* native Chrome consent prompt. Only the hosts they explicitly grant get
* the content script registered against them.
*
* The chrome.permissions API works in `origin` patterns
* (e.g. `https://www.example.com/*`); this module hides that detail and
* exposes a clean bare-hostname API everywhere else in the codebase.
*/

const HOST_PATTERN = /^[a-z0-9.-]+(?::\d+)?$/i;

/** Bare hostname (e.g. `www.thecut.com` or `localhost:3001`). */
export type Host = string;

/**
* Convert a list of bare hosts into the `chrome.permissions.origins` shape.
* Each host expands to **both** http and https patterns so a localhost dev
* box and an https prod site work without bespoke handling.
*/
export function originsFor(hosts: readonly Host[]): string[] {
const out: string[] = [];
for (const host of hosts) {
if (!HOST_PATTERN.test(host)) continue;
out.push(`https://${host}/*`, `http://${host}/*`);
}
return out;
}

/**
* Inverse of {@link originsFor}. Pulls the bare hostname out of an
* `https?://host/*` pattern. Returns `null` for patterns we don't recognize
* (e.g. `<all_urls>`).
*/
export function hostFromOrigin(origin: string): Host | null {
const m = origin.match(/^https?:\/\/([^/]+)\/\*?$/);
return m && m[1] ? m[1] : null;
}

/**
* The de-duplicated set of bare hosts the user has granted access to.
* Returns `[]` if `chrome.permissions` is unavailable (test envs).
*/
export async function listGrantedHosts(): Promise<readonly Host[]> {
if (!chrome?.permissions?.getAll) return [];
const perms = await chrome.permissions.getAll();
const hosts = new Set<Host>();
for (const origin of perms.origins ?? []) {
const host = hostFromOrigin(origin);
if (host) hosts.add(host);
}
return [...hosts].sort();
}

/**
* Prompt the user to grant access to `host`. Returns `true` only if Chrome
* actually granted it (i.e. the user clicked Allow). Must be called from a
* user-gesture context β€” typically a button click handler in the Options
* page or popup.
*/
export async function requestHostPermission(host: Host): Promise<boolean> {
if (!chrome?.permissions?.request) return false;
const origins = originsFor([host]);
if (origins.length === 0) return false;
return chrome.permissions.request({ origins });
}

/** Revoke previously granted access to `host`. */
export async function removeHostPermission(host: Host): Promise<boolean> {
if (!chrome?.permissions?.remove) return false;
const origins = originsFor([host]);
if (origins.length === 0) return false;
return chrome.permissions.remove({ origins });
}

/** Check whether `host` is currently granted (cheap; no UI). */
export async function hasHostPermission(host: Host): Promise<boolean> {
if (!chrome?.permissions?.contains) return false;
const origins = originsFor([host]);
if (origins.length === 0) return false;
return chrome.permissions.contains({ origins });
}

/**
* Pull the bare hostname out of a full URL string. Convenience for
* popup/service-worker code that has a `tab.url` to work with.
*/
export function hostFromUrl(url: string | undefined): Host | null {
if (!url) return null;
try {
const u = new URL(url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
return u.host;
} catch {
return null;
}
}

/**
* Validate that a string looks like a bare hostname suitable for
* {@link requestHostPermission}. Use in form validation before showing the
* grant button.
*/
export function isValidHost(host: string): boolean {
return HOST_PATTERN.test(host.trim());
}
20 changes: 17 additions & 3 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export default defineManifest({
type: 'module',
},

// The static content_scripts entry below declares the *maximum* scope
// (`<all_urls>`), but Chrome only auto-injects on origins where the user
// has granted host access. Because we declare zero required
// `host_permissions` and the broad pattern lives in
// `optional_host_permissions`, on a fresh install the script runs
// nowhere. Once the user grants `https://example.com/*` from the Options
// page, Chrome auto-injects on that host from then on.
content_scripts: [
{
matches: ['<all_urls>'],
Expand All @@ -57,9 +64,16 @@ export default defineManifest({

// Permissions are deliberately minimal β€” see PRIVACY.md for the
// per-permission justification used in the Chrome Web Store listing:
// activeTab β†’ captureVisibleTab for the Screenshot feature
// storage β†’ user prefs (sync) + annotations/recents (local)
// activeTab β†’ captureVisibleTab for the Screenshot feature
// storage β†’ user prefs (sync) + annotations/recents (local)
// clipboardWrite β†’ all "Copy to clipboard" panel actions
permissions: ['activeTab', 'storage', 'clipboardWrite'],
host_permissions: ['<all_urls>'],

// Required host access at install time: NONE.
// The user grants specific origins from the Options page; Chrome shows
// a native consent prompt on each addition. This keeps Clay Slip out of
// the "Broad Host Permissions" review queue while still letting the
// tool work on any Clay deployment the user chooses to point it at.
host_permissions: [],
optional_host_permissions: ['<all_urls>'],
});
Loading
Loading