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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ jobs:
- name: Build
run: pnpm build

- name: Validate Chrome Web Store assets
run: pnpm validate:store

- name: Smoke direct server entrypoint
run: python3 server/nano_server.py --help

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ npm-debug.log*
test-results/
playwright-report/
reports/code_admission_evidence.json
dist/
/usr/local/var/log/chromeai/
/usr/local/var/run/chromeai/
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,17 @@ Current CI enforces:
- typecheck (`pnpm typecheck`)
- build (`pnpm build`)
- baseline tests (`pnpm test`)
- Chrome Web Store asset dimensions (`pnpm validate:store`)

## Chrome Web Store Release

Store listing copy, privacy language, and the release checklist live in:

- [`docs/CHROME_WEB_STORE_SUBMISSION.md`](docs/CHROME_WEB_STORE_SUBMISSION.md)
- [`docs/CHROME_WEB_STORE_RELEASE_CHECKLIST.md`](docs/CHROME_WEB_STORE_RELEASE_CHECKLIST.md)
- [`docs/PRIVACY_POLICY.md`](docs/PRIVACY_POLICY.md)

`pnpm package:store` builds and audits the upload ZIP. It intentionally fails closed until production entitlement signature verification is configured; no unsigned commercial package is release-eligible.

## Compute Distribution

Expand Down
6 changes: 6 additions & 0 deletions assets/marketing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ Primary launch visuals:
- `selectpilot-screenshot-extract.svg`
- `selectpilot-screenshot-runtime.svg`
- `selectpilot-screenshot-privacy.svg`
- `selectpilot-small-promo.svg` / `.png` (`440x280`)
- `selectpilot-marquee.svg` / `.png` (`1400x560`)

Recommended use:

- `store-hero`: GitHub social card, website hero, Medium header
- `screenshot-extract`: primary product screenshot
- `screenshot-runtime`: onboarding/runtime proof screenshot
- `screenshot-privacy`: privacy boundary screenshot
- `small-promo`: Chrome Web Store small promotional tile
- `marquee`: Chrome Web Store marquee promotional tile

Run `pnpm validate:store` to verify every required raster dimension. Store graphics are listing assets and are not included in the extension upload ZIP.

These visuals follow the same MUE direction as the product:

Expand Down
Binary file added assets/marketing/selectpilot-marquee.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions assets/marketing/selectpilot-marquee.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/marketing/selectpilot-small-promo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions assets/marketing/selectpilot-small-promo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 64 additions & 43 deletions background/entitlement-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export type CachedEntitlement = {

const OFFLINE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
const PUBLIC_KEY_HEX = '';
const ENTITLEMENT_PUBLIC_KEYS: Readonly<Record<string, string>> = {
__SELECTPILOT_ENTITLEMENT_KEY_ID__: '__SELECTPILOT_ENTITLEMENT_PUBLIC_KEY_HEX__',
};
const SIGNATURE_ALGORITHM = 'Ed25519';

let cachedFeatureMap: Record<EntitlementTier, string[]> | null = null;

Expand Down Expand Up @@ -92,12 +95,18 @@ function canonicalizeEntitlement(payload: EntitlementPayload): string {
});
}

async function verifySignature(payload: EntitlementPayload, signature: string): Promise<boolean> {
if (!PUBLIC_KEY_HEX) return false;
export async function verifyEntitlementSignature(
payload: EntitlementPayload,
signature: string,
kid: string,
publicKeys: Readonly<Record<string, string>> = ENTITLEMENT_PUBLIC_KEYS,
): Promise<boolean> {
const publicKeyHex = publicKeys[kid];
if (!publicKeyHex || !signature || !kid) return false;
try {
const key = await crypto.subtle.importKey(
'raw',
bytesToArrayBuffer(hexToBytes(PUBLIC_KEY_HEX)),
bytesToArrayBuffer(hexToBytes(publicKeyHex)),
{ name: 'Ed25519' },
false,
['verify']
Expand Down Expand Up @@ -135,7 +144,25 @@ async function isFeatureAllowedByTier(feature: string, tier: EntitlementTier): P

function isWithinOfflineGrace(record: LicenseRecord): boolean {
const baseline = record.cachedAt || record.issuedAt;
return nowMs() <= (baseline + OFFLINE_GRACE_MS);
return (!record.expiresAt || nowMs() < record.expiresAt)
&& nowMs() <= (baseline + OFFLINE_GRACE_MS);
}

async function isVerifiedCachedEntitlement(record: LicenseRecord, token: string): Promise<boolean> {
if (
record.token !== token
|| record.alg !== SIGNATURE_ALGORITHM
|| !record.signature
|| !record.kid
|| !isWithinOfflineGrace(record)
) return false;
return verifyEntitlementSignature({
token: record.token,
tier: record.tier,
features: record.features,
issuedAt: record.issuedAt,
expiresAt: record.expiresAt ?? null,
}, record.signature, record.kid);
}

async function readCachedEntitlement(): Promise<CachedEntitlement | null> {
Expand All @@ -162,44 +189,38 @@ async function writeCachedEntitlement(record: CachedEntitlement): Promise<void>
}

async function normalizeRemoteResponse(token: string, response: SignedEntitlementResponse): Promise<CachedEntitlement | null> {
if (response.entitlement) {
const entitlement = response.entitlement;
if (entitlement.token !== token) {
warn('entitlement', 'token mismatch in signed response');
return null;
}
if (response.signature) {
if (!PUBLIC_KEY_HEX) {
warn('entitlement', 'signature returned but PUBLIC_KEY_HEX is not configured; accepting as unsigned MVP');
} else {
const valid = await verifySignature(entitlement, response.signature);
if (!valid) return null;
}
}
return {
token: entitlement.token,
tier: entitlement.tier,
features: entitlement.features,
issuedAt: entitlement.issuedAt,
expiresAt: entitlement.expiresAt ?? undefined,
cachedAt: nowMs(),
signature: response.signature,
alg: response.alg,
kid: response.kid,
};
const entitlement = response.entitlement;
if (
!entitlement
|| entitlement.token !== token
|| !['essential', 'plus', 'pro'].includes(entitlement.tier)
|| !Number.isInteger(entitlement.issuedAt)
|| (entitlement.expiresAt != null && !Number.isInteger(entitlement.expiresAt))
) {
warn('entitlement', 'missing or token-mismatched signed entitlement');
return null;
}

if (response.tier && response.issuedAt) {
return {
token,
tier: response.tier,
issuedAt: response.issuedAt,
expiresAt: response.expiresAt,
cachedAt: nowMs(),
};
if (response.alg !== SIGNATURE_ALGORITHM || !response.signature || !response.kid) {
warn('entitlement', 'unsigned or unsupported entitlement response');
return null;
}

return null;
if (entitlement.expiresAt != null && entitlement.expiresAt <= nowMs()) {
warn('entitlement', 'expired entitlement response');
return null;
}
const valid = await verifyEntitlementSignature(entitlement, response.signature, response.kid);
if (!valid) return null;
return {
token: entitlement.token,
tier: entitlement.tier,
features: entitlement.features,
issuedAt: entitlement.issuedAt,
expiresAt: entitlement.expiresAt ?? undefined,
cachedAt: nowMs(),
signature: response.signature,
alg: response.alg,
kid: response.kid,
};
}

async function remoteVerify(token: string): Promise<RemoteVerifyResult> {
Expand Down Expand Up @@ -245,7 +266,7 @@ export async function refreshEntitlement(force = false): Promise<LicenseRecord |

const shouldAttemptRemote = force
|| !cached
|| !isWithinOfflineGrace(cached)
|| !(await isVerifiedCachedEntitlement(cached, token))
|| nowMs() - (cached.cachedAt || cached.issuedAt) > REFRESH_INTERVAL_MS;
if (!shouldAttemptRemote && cached) {
log('entitlement', 'using cached entitlement within offline grace');
Expand All @@ -270,7 +291,7 @@ export async function refreshEntitlement(force = false): Promise<LicenseRecord |
return normalizeEntitlement(remote);
}

if (cached && isWithinOfflineGrace(cached)) {
if (cached && await isVerifiedCachedEntitlement(cached, token)) {
log('entitlement', 'remote unavailable; using cached entitlement within grace window');
return cached;
}
Expand Down
74 changes: 74 additions & 0 deletions docs/CHROME_WEB_STORE_RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Chrome Web Store Release Checklist

Last updated: 2026-08-21

## Automated Gate

- [ ] `pnpm lint`
- [ ] `pnpm typecheck`
- [ ] `pnpm test`
- [ ] `pnpm test:privacy`
- [ ] `pnpm build`
- [ ] `pnpm lint:manifest`
- [ ] `pnpm validate:store`
- [ ] `pnpm test:e2e`
- [ ] `pnpm package:store`
- [ ] Package SHA-256 and inventory are preserved with the release evidence.

`pnpm package:store` must fail while production entitlement verification is absent. Never bypass that failure with a development key or an unsigned entitlement.

Production packaging requires `SELECTPILOT_ENTITLEMENT_PUBLIC_KEYS_JSON` as a JSON key ring whose values are raw 32-byte Ed25519 public keys encoded as 64 hexadecimal characters. The matching issuer uses only:

- `SELECTPILOT_ENTITLEMENT_SIGNING_KEY_FILE`: an absolute path to private key material outside the repository;
- `SELECTPILOT_ENTITLEMENT_SIGNING_KEY_ID`: the exact rotation ID present in the public key ring.

Never place the private key, its contents, or a production signature fixture in Git, CI logs, Linear, test data, or the extension package. Signer provisioning and activation remain an authorized secrets operation.

## Product Truth

- [ ] The listing describes one purpose: selected text to structured local output.
- [ ] Ollama and the local bridge requirements are visible before installation.
- [ ] Trial, paid tiers, and feature boundaries match the exact production configuration.
- [ ] Experimental capabilities are excluded from the core claim.
- [ ] No Team or self-hosted availability is claimed.

## Privacy And Security

- [ ] Store disclosures match [`PRIVACY_POLICY.md`](./PRIVACY_POLICY.md).
- [ ] Core content traffic is limited to user-owned localhost services.
- [ ] Any non-content commerce traffic is disclosed separately.
- [ ] Production entitlements are signed and verified fail-closed.
- [ ] No secret, private key, token, log, report, source map, or test artifact exists in the ZIP.
- [ ] Optional retained data can be inspected, exported, and deleted.

## Assets

- [ ] Store icon is `128x128`.
- [ ] Three screenshots are `1280x800` and show actual product behavior.
- [ ] Small promo tile is `440x280`.
- [ ] Marquee tile is `1400x560`.
- [ ] Copy and visuals match the submitted extension version.

## Manual Runtime Acceptance

- [ ] Fresh Chrome profile installation succeeds.
- [ ] Missing Ollama produces one calm, actionable installation path.
- [ ] Consent-gated model provisioning succeeds on supported hardware.
- [ ] First selected-text extraction reaches a validated result.
- [ ] JSON, Markdown, and plain-text exports open correctly.
- [ ] Failure and recovery paths are verified without silent fallback.
- [ ] Uninstall and retained-data deletion behavior are verified.

## Dashboard Gates

- [ ] Homepage URL is public and verified.
- [ ] Support URL is public and verified.
- [ ] Privacy policy URL is public and verified.
- [ ] Listing, Privacy, Distribution, and Test Instructions fields are complete.
- [ ] Reviewer-only setup information contains no repository secret.
- [ ] Saved dashboard fields are reopened and checked for persistence.
- [ ] Submission and publication strategy are explicitly authorized.

## Completion

The release is complete only after the exact package is accepted, published, installable from the Store, and reverified through the real first-use path. A local ZIP or green CI run is not publication evidence.
Loading