Skip to content
Closed
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
45 changes: 45 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Browser Hand — Clerk
# Copy to .env at the repo root: cp .env.example .env
# Never commit .env. Rebuild the extension after changing any WXT_ value
# (`npm run build:extension`); those are inlined at build time.
#
# WXT only exposes variables prefixed with WXT_ to the extension bundle.
# CLERK_SECRET_KEY has no WXT_ prefix, so it stays in the Node brand script.

# Public. Clerk Dashboard → API keys → Chrome extension → Publishable key.
WXT_CLERK_PUBLISHABLE_KEY=pk_test_replace_me

# Frontend API URL from the same API keys page.
# Optional. Added as an explicit host permission when set.
WXT_CLERK_FRONTEND_API=https://your-subdomain.clerk.accounts.dev

# Optional. Set only when a website should share this Clerk session with the extension.
# Development example: http://localhost:3000
# Production example: https://app.example.com
WXT_CLERK_SYNC_HOST=

# Optional. Pins the unpacked extension id (manifest "key"). Public key only.
# Never put the Chrome Web Store private key here.
WXT_EXTENSION_PUBLIC_KEY=

# Secret. Server-side only. Used by `npm run clerk:brand` to set allowed origins.
CLERK_SECRET_KEY=sk_test_replace_me

# Extension id from chrome://extensions (32 characters, a-p), without the chrome-extension:// prefix.
CLERK_EXTENSION_ID=

# Optional extra origins to keep when the brand script rewrites allowed_origins.
# CLERK_EXTRA_ALLOWED_ORIGINS=https://app.example.com

# Optional Platform API credentials. When CLERK_APPLICATION_ID is set, `npm run clerk:brand`
# renames the Clerk application to "Browser Hand" and uploads the glove logo.
# The Platform API may require its own token and may need to be enabled for the account.
# CLERK_PLATFORM_SECRET_KEY=
# CLERK_APPLICATION_ID=app_replace_me

# Optional path to a PNG used as the Clerk application logo. Defaults to the glove mark.
# CLERK_LOGO_PATH=docs/brand/derived/mark-transparent-512.png

# Optional. When set, the account page loads orders from this URL instead of sample data.
# See README "Order history".
# WXT_ORDER_HISTORY_URL=https://orders.example.com/v1/orders
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ _scratch/
.env
.env.*.local
.env.local
.env.development
.env.production
.env.chrome
.env.*.chrome
.idea/
.output/
.vscode/
Expand Down
111 changes: 111 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,117 @@ npm run smoke:live

Coming from upstream or an older AutoHub-era install? Start with **[MIGRATING.md](./MIGRATING.md)**.

## Account sign-in (Clerk)

The toolbar popup still controls the local relay with or without an account. Sign-in, sign-up, sign-out, and order history live on an extension account page (`account.html`), opened from **Sign in** / **Account** in the popup. Sessions are handled by [Clerk’s Chrome extension SDK](https://clerk.com/docs/chrome-extension/getting-started/quickstart) (`@clerk/chrome-extension`). Components use Browser Hand colors (ink `#0B0A12`, bone, cyan `#7EE7FF`) and the glove mark.

There is no Clerk application or key in this repo. Create one, then add keys locally. **Never commit `.env`.**

### 1. Create the Clerk application

1. In the [Clerk Dashboard](https://dashboard.clerk.com), create an application named **Browser Hand**.
2. Enable **Email** (password and/or email code). OAuth can be added later; extension pages are a poor place for provider redirects until the extension origin is allowed.
3. Open **Native applications** and enable the **Native API**. Chrome extensions need it.
4. On **API keys**, choose the Chrome extension quick copy. You need the **publishable key** and the **Frontend API URL**.

### 2. Put keys in `.env`

```bash
cp .env.example .env
```

| Variable | Where it goes | Notes |
| --------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `WXT_CLERK_PUBLISHABLE_KEY` | Extension bundle (public) | `pk_test_…` or `pk_live_…`. WXT inlines `WXT_` variables at build time. |
| `CLERK_SECRET_KEY` | `npm run clerk:brand` only | `sk_test_…` / `sk_live_…`. No `WXT_` prefix, so it is not bundled. |
| `WXT_CLERK_FRONTEND_API` | Optional host permission | `https://<slug>.clerk.accounts.dev` from the same API keys page. |
| `WXT_CLERK_SYNC_HOST` | Optional | Set only when a website should share this session. Development is often `http://localhost:3000`. Leave empty for extension-only sign-in. |
| `CLERK_EXTENSION_ID` | Brand script | The 32-character id on `chrome://extensions` (letters a–p). |
| `WXT_EXTENSION_PUBLIC_KEY` | Optional manifest `key` | Public key that pins the unpacked extension id. Not the Web Store private key. |
| `CLERK_APPLICATION_ID` | Optional platform branding | `app_…`. With a platform token, the brand script sets the application name and logo. |
| `CLERK_PLATFORM_SECRET_KEY` | Optional | Platform API token when it is not the instance secret. Platform API access is separate and may be gated. |
| `WXT_ORDER_HISTORY_URL` | Optional order API | See below. |

Rebuild and reload the unpacked extension after any `WXT_` change. Vite bakes those values into the build; editing `.env` alone does not update a loaded extension.

```bash
npm run build:extension
# Chrome → chrome://extensions → Reload
# Popup → Sign in → extension page account.html
```

### 3. Allow the `chrome-extension://` origin

Clerk rejects Frontend API calls from an extension until that origin is on the instance. The id is stable for a given unpacked path, and it changes if you set `WXT_EXTENSION_PUBLIC_KEY` or publish to the Chrome Web Store.

```bash
# .env
CLERK_SECRET_KEY=sk_test_…
CLERK_EXTENSION_ID=abcdefghijklmnopabcdefghijklmnop # from chrome://extensions, letters a-p only
npm run clerk:brand
```

That uses `@clerk/backend` `instance.update` (`PATCH /v1/instance`) and **merges** `chrome-extension://<id>` into `allowed_origins` so other origins are kept. `CLERK_EXTRA_ALLOWED_ORIGINS` (comma-separated) is merged too. `--dry-run` prints the plan and does not call Clerk.

`npm run clerk:brand` also calls Clerk’s Platform API when `CLERK_APPLICATION_ID` is set:

- `PATCH /v1/platform/applications/{id}` with `{ "name": "Browser Hand" }`
- `POST /v1/platform/applications/{id}/logo` with the transparent glove (`docs/brand/derived/mark-transparent-512.png`)
- the same image as the favicon when Clerk accepts it

If the Platform API returns 401 or 403, set the name and logo in the Dashboard under **Settings → Branding** instead. Emails and the hosted Account Portal use that name and logo. The extension components use the `appearance` prop either way.

Sync host (only if a website shares the session): set `WXT_CLERK_SYNC_HOST` to that site’s origin, add the extension id under Clerk’s allowed origins (the brand script does this), and rebuild. The extension then requests the `cookies` permission, which Clerk needs for sync-host mode. Extension-only sign-in leaves that permission off and does not need a sync host.

### Order history

There is no order database in this repo. Until `WXT_ORDER_HISTORY_URL` is set, the account page shows **sample** orders from `extension/account/orders.ts` and says so. A real service is a `GET` endpoint:

```http
GET {WXT_ORDER_HISTORY_URL}?userId={clerkUserId}&limit=20&cursor={opaque}
Authorization: Bearer {clerkSessionToken}
Accept: application/json
```

```json
{
"orders": [
{
"id": "ord_123",
"number": "BH-1042",
"createdAt": "2026-07-18T15:04:00.000Z",
"status": "fulfilled",
"currency": "usd",
"totalAmountCents": 2900,
"lineItems": [
{
"id": "li_1",
"name": "Browser Hand seat",
"quantity": 1,
"unitAmountCents": 2900,
"currency": "usd"
}
]
}
],
"nextCursor": null
}
```

`status` is `pending`, `paid`, `fulfilled`, `cancelled`, or `refunded`. Amounts are integer cents. Verify the bearer token with `@clerk/backend` and return only that user’s orders. Do not trust the `userId` query param by itself.

```ts
import { verifyToken } from "@clerk/backend";

const claims = await verifyToken(sessionToken, {
secretKey: process.env.CLERK_SECRET_KEY,
authorizedParties: ["chrome-extension://<extension-id>"],
});
const userId = claims.sub;
```

Set `WXT_ORDER_HISTORY_URL` to the endpoint, rebuild, and reload. Failures stay on the HTTP source; the page does not swap in sample orders after a real request fails.

## Headless / remote-debug (optional)

For CI and throwaway automation, the upstream-style CLI remains:
Expand Down
4 changes: 2 additions & 2 deletions extension/__tests__/CDPRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ describe("CDPRouter", () => {
const previousGroup = tabs.group;
const previousTabGroups = (chrome as typeof chrome & { tabGroups?: unknown }).tabGroups;
tabs.group = group;
(chrome as typeof chrome & { tabGroups?: unknown }).tabGroups = { get, update };
(chrome as unknown as { tabGroups: unknown }).tabGroups = { get, update };
try {
const result = await cdpRouter.handleCommand({
id: 1,
Expand Down Expand Up @@ -482,7 +482,7 @@ describe("CDPRouter", () => {
const previousGroup = tabs.group;
const previousTabGroups = (chrome as typeof chrome & { tabGroups?: unknown }).tabGroups;
tabs.group = group;
(chrome as typeof chrome & { tabGroups?: unknown }).tabGroups = { get, update };
(chrome as unknown as { tabGroups: unknown }).tabGroups = { get, update };
const command = {
id: 1,
method: "forwardCDPCommand" as const,
Expand Down
166 changes: 166 additions & 0 deletions extension/__tests__/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from "vitest";
import { BROWSER_HAND_COLORS, browserHandAppearance } from "../account/brand";
import {
BROWSER_HAND_APPLICATION_NAME,
ClerkPlatformError,
applicationLogoRequest,
applicationNameRequest,
chromeExtensionOrigin,
logoFormData,
mergeAllowedOrigins,
parseOriginList,
sendPlatformRequest,
syncAllowedOrigins,
} from "../account/clerk-setup";
import { isConfiguredPublishableKey, readClerkConfig } from "../account/config";
import { isAccountSnapshot, signedOutSnapshot, snapshotFromUser } from "../account/session";

describe("Clerk config", () => {
it("reads only public WXT variables and treats blanks as unset", () => {
const config = readClerkConfig({
WXT_CLERK_PUBLISHABLE_KEY: " pk_test_abc ",
WXT_CLERK_SYNC_HOST: " ",
WXT_CLERK_FRONTEND_API: "https://example.clerk.accounts.dev",
WXT_ORDER_HISTORY_URL: "",
});
expect(config).toEqual({
publishableKey: "pk_test_abc",
syncHost: undefined,
frontendApi: "https://example.clerk.accounts.dev",
orderHistoryUrl: undefined,
});
expect(config).not.toHaveProperty("secretKey");
});

it("treats the example placeholder key as unconfigured", () => {
expect(isConfiguredPublishableKey(undefined)).toBe(false);
expect(isConfiguredPublishableKey("pk_test_replace_me")).toBe(false);
expect(isConfiguredPublishableKey("not-a-key")).toBe(false);
expect(isConfiguredPublishableKey("pk_live_real_key")).toBe(true);
});
});

describe("account snapshot", () => {
it("mirrors display fields and omits tokens", () => {
const snapshot = snapshotFromUser(
{
id: "user_1",
fullName: "Ada Lovelace",
imageUrl: "https://img.example/a.png",
primaryEmailAddress: { emailAddress: "ada@example.com" },
},
new Date("2026-11-08T00:00:00.000Z")
);
expect(snapshot).toEqual({
signedIn: true,
userId: "user_1",
email: "ada@example.com",
name: "Ada Lovelace",
imageUrl: "https://img.example/a.png",
updatedAt: "2026-11-08T00:00:00.000Z",
});
expect(isAccountSnapshot(snapshot)).toBe(true);
expect(isAccountSnapshot(signedOutSnapshot())).toBe(true);
expect(isAccountSnapshot({ signedIn: true })).toBe(false);
});
});

describe("Browser Hand Clerk appearance", () => {
it("uses the cyan spark and the glove mark", () => {
const appearance = browserHandAppearance(
"chrome-extension://abcdefghijklmnopabcdefghijklmnop/icons/icon-128.png"
);
expect(appearance.variables?.colorPrimary).toBe(BROWSER_HAND_COLORS.cyan);
expect(appearance.variables?.colorPrimaryForeground).toBe(BROWSER_HAND_COLORS.ink);
expect(appearance.variables?.colorForeground).toBe(BROWSER_HAND_COLORS.ink);
expect(appearance.options?.logoImageUrl).toContain("icon-128.png");
expect(appearance.options?.logoPlacement).toBe("inside");
});
});

describe("Clerk instance origins", () => {
it("builds a chrome-extension origin and rejects anything else", () => {
expect(chromeExtensionOrigin("abcdefghijklmnopabcdefghijklmnop")).toBe(
"chrome-extension://abcdefghijklmnopabcdefghijklmnop"
);
expect(chromeExtensionOrigin("chrome-extension://abcdefghijklmnopabcdefghijklmnop/")).toBe(
"chrome-extension://abcdefghijklmnopabcdefghijklmnop"
);
expect(() => chromeExtensionOrigin("not-an-id")).toThrow(/CLERK_EXTENSION_ID/);
});

it("merges new origins without dropping existing ones", () => {
expect(
mergeAllowedOrigins(
["https://app.example.com", "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
["chrome-extension://abcdefghijklmnopabcdefghijklmnop", "https://app.example.com"]
)
).toEqual([
"https://app.example.com",
"chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"chrome-extension://abcdefghijklmnopabcdefghijklmnop",
]);
});

it("updates the instance with the merged list", async () => {
const update = vi.fn(async () => undefined);
const merged = await syncAllowedOrigins(
{
get: async () => ({ allowedOrigins: ["https://kept.example"] }),
update,
},
["chrome-extension://abcdefghijklmnopabcdefghijklmnop"]
);
expect(merged).toEqual([
"https://kept.example",
"chrome-extension://abcdefghijklmnopabcdefghijklmnop",
]);
expect(update).toHaveBeenCalledWith({ allowedOrigins: merged });
});

it("parses extra origins", () => {
expect(
parseOriginList(" https://a.example , chrome-extension://abcdefghijklmnopabcdefghijklmnop ")
).toEqual(["https://a.example", "chrome-extension://abcdefghijklmnopabcdefghijklmnop"]);
});
});

describe("Clerk platform branding", () => {
it("renames the application to Browser Hand", () => {
const request = applicationNameRequest("app_123", "platform-token");
expect(request.url).toBe("https://api.clerk.com/v1/platform/applications/app_123");
expect(request.init.method).toBe("PATCH");
expect(request.init.body).toBe(JSON.stringify({ name: BROWSER_HAND_APPLICATION_NAME }));
expect(new Headers(request.init.headers).get("Authorization")).toBe("Bearer platform-token");
});

it("uploads the logo as a file part", () => {
const form = logoFormData(new Uint8Array([1, 2, 3]), "mark.png");
const file = form.get("file");
expect(file).toBeInstanceOf(Blob);
expect((file as File).name).toBe("mark.png");
const request = applicationLogoRequest(
"app_123",
"platform-token",
new Uint8Array([1]),
"mark.png"
);
expect(request.url).toContain("/platform/applications/app_123/logo");
expect(request.init.method).toBe("POST");
expect(request.init.body).toBeInstanceOf(FormData);
});

it("surfaces platform error status without calling a fallback", async () => {
const fetchImpl = vi.fn(async () => new Response("denied", { status: 403 }));
await expect(
sendPlatformRequest(
applicationNameRequest("app_123", "token", "Browser Hand", "https://api.example"),
fetchImpl
)
).rejects.toMatchObject({ status: 403, name: "ClerkPlatformError" });
expect(fetchImpl).toHaveBeenCalledOnce();
await expect(Promise.reject(new ClerkPlatformError("no", 401))).rejects.toBeInstanceOf(
ClerkPlatformError
);
});
});
Loading
Loading