diff --git a/packages/automation/browser/src/index.ts b/packages/automation/browser/src/index.ts index a2e9ebcd..6e4ef6d6 100644 --- a/packages/automation/browser/src/index.ts +++ b/packages/automation/browser/src/index.ts @@ -22,6 +22,8 @@ export { export { base32Decode, secondsRemaining, totp, twoFactorCode, type TotpOptions } from './totp.js'; +export * as amoAppeal from './recipes/amo-appeal.js'; +export * as chromeWebStore from './recipes/chrome-web-store.js'; export * as googleCloudOAuth from './recipes/google-cloud-oauth.js'; export * as metaApp from './recipes/meta-app.js'; export * as pypiTrustedPublisher from './recipes/pypi-trusted-publisher.js'; @@ -61,6 +63,22 @@ export const RECIPES: RecipeInfo[] = [ profile: 'rubygems', actions: ['list', 'add-pending'], }, + { + id: 'amo-appeal', + label: 'addons.mozilla.org — appeal a reviewer decision', + because: + 'a Mozilla-disabled add-on 403s every write, listing-only PATCHes included; no API lifts the block and only an appeal, decided by a human, does.', + profile: 'mozilla', + actions: ['status', 'appeal'], + }, + { + id: 'chrome-web-store', + label: 'Chrome Web Store — listing and publish conditions', + because: + 'the Publish API only uploads and publishes; the ten conditions it checks — privacy answers, category, language, description, assets — and unpublishing itself are all dashboard-only.', + profile: 'google', + actions: ['status', 'unpublish', 'fill-listing'], + }, { id: 'meta-app', label: 'Meta — app settings', diff --git a/packages/automation/browser/src/recipes/amo-appeal.test.ts b/packages/automation/browser/src/recipes/amo-appeal.test.ts new file mode 100644 index 00000000..0a590951 --- /dev/null +++ b/packages/automation/browser/src/recipes/amo-appeal.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import * as amo from './amo-appeal.js'; +import { RECIPES } from '../index.js'; +import { parse, profileFor } from '../run.js'; + +const DECISION = 'ecb5c48f-e70d-4cc2-8bd3-e5a5562e5c3e'; + +describe('appealUrl', () => { + it('builds the author appeal path from Mozilla’s url conf', () => { + expect(amo.appealUrl(DECISION)).toBe( + `https://addons.mozilla.org/en-US/abuse/appeal/${DECISION}/`, + ); + }); + + it('honours a locale', () => { + expect(amo.appealUrl(DECISION, 'de')).toContain('/de/abuse/appeal/'); + }); + + it('rejects something that is not a decision id', () => { + expect(() => amo.appealUrl('not a uuid!')).toThrow(/does not look like a decision id/); + expect(() => amo.appealUrl('')).toThrow(/does not look like a decision id/); + }); +}); + +describe('parseAddonState', () => { + /** + * The real unauthenticated response for CoinPay Wallet on 2026-09-06: a 401 + * whose body still carries the disable flags. Discarding a 401 as "auth + * failure" would throw away the only signal that matters. + */ + it('reads a Mozilla disable out of a 401 body', () => { + const state = amo.parseAddonState( + { detail: 'Authentication credentials were not provided.', is_disabled_by_developer: false, is_disabled_by_mozilla: true }, + 401, + ); + expect(state.disabledByMozilla).toBe(true); + expect(state.disabledByDeveloper).toBe(false); + expect(state.listed).toBe(false); + expect(state.verdict).toMatch(/appeal is the only route back/); + }); + + it('distinguishes a developer disable, which needs no appeal', () => { + const state = amo.parseAddonState({ is_disabled_by_developer: true, is_disabled_by_mozilla: false }, 401); + expect(state.disabledByDeveloper).toBe(true); + expect(state.verdict).toMatch(/Re-enable it in the Developer Hub/); + }); + + it('reports a healthy public add-on', () => { + const state = amo.parseAddonState({ slug: 'marksyncr', status: 'public' }, 200); + expect(state.listed).toBe(true); + expect(state.slug).toBe('marksyncr'); + expect(state.verdict).toMatch(/Nothing to appeal/); + }); + + it('does not claim a 404 is a disable', () => { + const state = amo.parseAddonState({ detail: 'Not found.' }, 404); + expect(state.disabledByMozilla).toBe(false); + expect(state.listed).toBe(false); + expect(state.verdict).toMatch(/Check the id or slug/); + }); +}); + +describe('appealOutcome', () => { + const base = { thankYou: false, alreadyDecided: false, formPresent: true, invalidEmail: false }; + + it('reads the thank-you as recorded, even while other markers linger', () => { + expect(amo.appealOutcome({ ...base, thankYou: true, alreadyDecided: true })).toBe('recorded'); + }); + + it('prefers an email rejection over the form still being present', () => { + expect(amo.appealOutcome({ ...base, invalidEmail: true })).toBe('rejected-email'); + }); + + it('recognises a decision already appealed by someone else', () => { + expect(amo.appealOutcome({ ...base, alreadyDecided: true, formPresent: false })).toBe('already-decided'); + }); + + it('calls a page with no form not-appealable', () => { + expect(amo.appealOutcome({ ...base, formPresent: false })).toBe('not-appealable'); + }); + + it('admits when it cannot tell', () => { + expect(amo.appealOutcome(base)).toBe('unknown'); + }); +}); + +describe('registration', () => { + it('is listed by `sh1pt browser list` with its own Mozilla profile', () => { + const entry = RECIPES.find((r) => r.id === 'amo-appeal'); + expect(entry).toBeDefined(); + expect(entry!.actions).toEqual(['status', 'appeal']); + expect(profileFor('amo-appeal')).toBe('mozilla'); + }); + + it('parses the flags the recipe needs', () => { + const { recipe, action, options } = parse([ + 'amo-appeal', 'appeal', '--addon', '3061765', '--decision', DECISION, '--reason-file', './appeal.md', + ]); + expect(recipe).toBe('amo-appeal'); + expect(action).toBe('appeal'); + expect(options.addon).toBe('3061765'); + expect(options.decision).toBe(DECISION); + expect(options.reasonFile).toBe('./appeal.md'); + }); +}); diff --git a/packages/automation/browser/src/recipes/amo-appeal.ts b/packages/automation/browser/src/recipes/amo-appeal.ts new file mode 100644 index 00000000..6b61d088 --- /dev/null +++ b/packages/automation/browser/src/recipes/amo-appeal.ts @@ -0,0 +1,243 @@ +/** + * addons.mozilla.org: appealing a reviewer decision. + * + * When Mozilla disables an add-on, *every* write to it 403s — not just the + * disable flag. A `PATCH /addons/addon//` carrying nothing but listing copy + * is refused too, so a privacy policy cannot be attached, a new version cannot + * be uploaded, and the listing 404s publicly. Reads still work. There is no API + * that lifts the block: the only route back is an appeal, decided by a human. + * + * That makes this recipe unusual for this package. It is not automating a + * setting that merely lacks an endpoint; it is submitting a document to a + * moderator. So it fills and submits the form, and reports what the page said + * back, and does nothing else. + * + * --- + * + * Unlike the Chrome Web Store recipe, the selectors here are NOT guesses. They + * are read off Mozilla's own source, which is open: + * + * src/olympia/abuse/urls.py `appeal//` + * src/olympia/abuse/forms.py AbuseAppealForm.reason (Textarea), + * AbuseAppealEmailForm.email + * templates/abuse/appeal.html #appeal-submit, #appeal-thank-you + * + * Django's `as_div()` renders a field named `reason` with id `id_reason`, so + * the ids below follow from the form definitions rather than from inspection. + * + * Two things in that source are worth knowing before running this: + * + * 1. The email form appears only in some flows (an appeal from someone who + * cannot log in). When it does, `clean_email` compares what you type + * against the address the decision was sent to and rejects anything else + * with "Invalid email provided." — so the address is not a free field. + * 2. Appeals are throttled at **20 per day**, per IP and per user. Retrying a + * failed submit in a loop will burn that quota. + */ +import { type Session } from '../session.js'; + +const AMO = 'https://addons.mozilla.org'; + +/** The decision id from the reviewer email. Cinder ids are uuid-shaped. */ +export const DECISION_ID_PATTERN = /^[0-9a-f-]{8,64}$/i; + +export function assertDecisionId(id: string): string { + if (!DECISION_ID_PATTERN.test(id)) { + throw new Error( + `"${id}" does not look like a decision id. It is in the reviewer email — the ` + + 'value after "ref:" in the subject, or the last path segment of the appeal link it contains.', + ); + } + return id; +} + +/** + * The author appeal URL. + * + * Mozilla routes two shapes: `appeal//` for the add-on's author and + * `appeal///` for whoever reported it. A developer appealing + * their own add-on always wants the first. + */ +export function appealUrl(decisionCinderId: string, locale = 'en-US'): string { + return `${AMO}/${locale}/abuse/appeal/${assertDecisionId(decisionCinderId)}/`; +} + +/* -------------------------------------------------------------------------- */ +/* Status, over the public API — no browser and no credentials needed */ +/* -------------------------------------------------------------------------- */ + +export interface AddonState { + disabledByMozilla: boolean; + disabledByDeveloper: boolean; + /** True when the add-on is readable and public. */ + listed: boolean; + slug: string | null; + status: string | null; + /** What to do next, in one line. */ + verdict: string; +} + +/** + * Read an add-on's state out of an AMO API response. + * + * The useful quirk: for a Mozilla-disabled add-on the API answers **401** to an + * unauthenticated caller, but the body still carries `is_disabled_by_mozilla` + * and `is_disabled_by_developer`. So a 401 body is informative and must not be + * discarded as an auth failure — it is how you learn the add-on is blocked + * without holding any credentials at all. + */ +export function parseAddonState(body: Record, httpStatus: number): AddonState { + const disabledByMozilla = body.is_disabled_by_mozilla === true; + const disabledByDeveloper = body.is_disabled_by_developer === true; + const listed = httpStatus === 200 && !disabledByMozilla; + + let verdict: string; + if (disabledByMozilla) { + verdict = + 'Disabled by Mozilla. Every write 403s, including listing-only PATCHes. ' + + 'An appeal is the only route back, and a human decides it.'; + } else if (disabledByDeveloper) { + verdict = 'Disabled by you. Re-enable it in the Developer Hub; no appeal needed.'; + } else if (listed) { + verdict = 'Public. Nothing to appeal.'; + } else { + verdict = `Not readable (HTTP ${httpStatus}) and not flagged as disabled. Check the id or slug.`; + } + + return { + disabledByMozilla, + disabledByDeveloper, + listed, + slug: typeof body.slug === 'string' ? body.slug : null, + status: typeof body.status === 'string' ? body.status : null, + verdict, + }; +} + +/** Fetch and interpret an add-on's state. Numeric id or slug both work. */ +export async function readAddonState(addon: string | number): Promise { + const response = await fetch(`${AMO}/api/v5/addons/addon/${encodeURIComponent(String(addon))}/`, { + headers: { Accept: 'application/json', 'User-Agent': 'sh1pt-browser/amo-appeal' }, + }); + const body = (await response.json().catch(() => ({}))) as Record; + return parseAddonState(body, response.status); +} + +/* -------------------------------------------------------------------------- */ +/* The appeal itself */ +/* -------------------------------------------------------------------------- */ + +/** + * True when the profile holds an AMO developer session. + * + * Tested positively against the Developer Hub, which redirects a signed-out + * browser to a login page on a different path — the same trap documented in + * google-cloud-oauth, where checking for the *absence* of a login URL reports a + * signed-out browser as signed in. + */ +export async function isSignedIn(session: Session): Promise { + const { page } = session; + await page.goto(`${AMO}/en-US/developers/addons`, { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); + return /\/developers\/addons/.test(page.url()) && !/\/login|accounts\.firefox\.com/.test(page.url()); +} + +export type AppealOutcome = + | 'recorded' + | 'already-decided' + | 'not-appealable' + | 'rejected-email' + | 'unknown'; + +export interface AppealMarkers { + thankYou: boolean; + alreadyDecided: boolean; + formPresent: boolean; + invalidEmail: boolean; +} + +/** + * Turn what the page shows into one outcome. + * + * Order matters: the template renders the thank-you *instead of* the form, and + * renders an "already reviewed a similar appeal" branch instead of both. An + * invalid email re-renders the form with an error, so the form being present is + * the weakest signal and is checked last. + */ +export function appealOutcome(markers: AppealMarkers): AppealOutcome { + if (markers.thankYou) return 'recorded'; + if (markers.invalidEmail) return 'rejected-email'; + if (markers.alreadyDecided) return 'already-decided'; + if (!markers.formPresent) return 'not-appealable'; + return 'unknown'; +} + +export interface AppealInput { + decisionCinderId: string; + /** Why the decision was wrong. This is the substance of the appeal. */ + reason: string; + /** + * Only used when the page asks for it. Mozilla compares it against the + * address the decision was sent to and rejects anything else. + */ + email?: string; + locale?: string; +} + +/** + * Submit an appeal and report what came back. + * + * Deliberately does not retry: appeals are throttled 20/day per IP and per + * user, and a moderation queue is not a place to spray submissions. + */ +export async function submitAppeal( + session: Session, + input: AppealInput, +): Promise<{ outcome: AppealOutcome; url: string }> { + const { page } = session; + const url = appealUrl(input.decisionCinderId, input.locale); + + if (!input.reason.trim()) { + throw new Error('An appeal needs a reason: explain why the decision was made in error.'); + } + + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); + + const reason = page.locator('#id_reason, textarea[name="reason"]').first(); + const hasForm = await reason.isVisible().catch(() => false); + + if (hasForm) { + await reason.fill(input.reason); + + // The email field is conditional. Fill it only if it rendered. + const email = page.locator('#id_email, input[name="email"]').first(); + if (input.email && (await email.isVisible().catch(() => false))) { + await email.fill(input.email); + } + + await page.locator('#appeal-submit').click(); + await page.waitForLoadState('networkidle').catch(() => undefined); + } + + const text = (await page.locator('body').innerText().catch(() => '')) as string; + const outcome = appealOutcome({ + thankYou: await page.locator('#appeal-thank-you').isVisible().catch(() => false), + alreadyDecided: /already reviewed a similar appeal/i.test(text), + formPresent: await page + .locator('#id_reason, textarea[name="reason"]') + .isVisible() + .catch(() => false), + invalidEmail: /invalid email provided/i.test(text), + }); + + if (outcome === 'unknown') { + await session.ask( + 'amo-appeal', + `Submitted the appeal for decision ${input.decisionCinderId} but could not read the result. ` + + `Open ${url} and check whether it was recorded, then reply with what it said.`, + ); + } + + return { outcome, url }; +} diff --git a/packages/automation/browser/src/recipes/chrome-web-store.test.ts b/packages/automation/browser/src/recipes/chrome-web-store.test.ts new file mode 100644 index 00000000..9aa68654 --- /dev/null +++ b/packages/automation/browser/src/recipes/chrome-web-store.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import * as cws from './chrome-web-store.js'; +import { RECIPES } from '../index.js'; +import { parse, profileFor } from '../run.js'; + +const ITEM = 'pmckmdnikecngblpjdlhgnimickinfkp'; + +/** A minimally valid listing, mirroring coinpayportal's store-listing.json. */ +const listing: cws.StoreListingFile = { + name: 'CoinPay Portal Wallet', + summary: 'Non-custodial multi-chain wallet with one-click x402 payments and bulk payouts.', + description: 'CoinPay Portal Wallet is the browser wallet for coinpayportal.com, operated by Profullstack, Inc.', + homepageUrl: 'https://coinpayportal.com', + privacyPolicyUrl: 'https://coinpayportal.com/privacy', + chrome: { + category: 'Productivity', + language: 'en', + singlePurpose: 'A non-custodial cryptocurrency wallet.', + remoteCode: 'No. The build ships every script it executes.', + permissionJustifications: { storage: 'Stores the encrypted seed vault.' }, + dataUse: { collected: ['financialAndPaymentInfo'], notes: 'Addresses and signed transactions only.' }, + }, +}; + +describe('assertItemId', () => { + it('accepts a real 32-character a-p id', () => { + expect(cws.assertItemId(ITEM)).toBe(ITEM); + }); + + it('rejects ids that are the wrong length or use letters past p', () => { + expect(() => cws.assertItemId('tooshort')).toThrow(/not a Chrome extension id/); + expect(() => cws.assertItemId('z'.repeat(32))).toThrow(/not a Chrome extension id/); + expect(() => cws.assertItemId(`${ITEM}a`)).toThrow(/not a Chrome extension id/); + }); +}); + +describe('url builders', () => { + it('includes the publisher id when one is known, and omits it otherwise', () => { + expect(cws.itemEditUrl({ itemId: ITEM })).toBe( + `https://chrome.google.com/webstore/devconsole/${ITEM}/edit`, + ); + expect(cws.itemEditUrl({ itemId: ITEM, publisherId: '12345' })).toBe( + `https://chrome.google.com/webstore/devconsole/12345/${ITEM}/edit`, + ); + }); + + it('derives the privacy tab from the edit page', () => { + expect(cws.itemPrivacyUrl({ itemId: ITEM })).toBe(`${cws.itemEditUrl({ itemId: ITEM })}/privacy`); + }); + + it('recognises the empty-title redirect as unpublished', () => { + expect(cws.looksUnpublished(`https://chromewebstore.google.com/detail/empty-title/${ITEM}`)).toBe(true); + expect(cws.looksUnpublished(`https://chromewebstore.google.com/detail/marksyncr/${ITEM}`)).toBe(false); + }); +}); + +describe('prepareListing', () => { + it('flattens a complete listing', () => { + const prepared = cws.prepareListing(listing); + expect(prepared.category).toBe('Productivity'); + expect(prepared.language).toBe('en'); + expect(prepared.permissionJustifications.storage).toMatch(/encrypted seed/); + expect(prepared.dataUse.collected).toEqual(['financialAndPaymentInfo']); + }); + + it('reports every missing field at once rather than the first', () => { + let message = ''; + try { + cws.prepareListing({ name: 'x', chrome: { permissionJustifications: { storage: 'ok' } } }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toMatch(/missing summary/); + expect(message).toMatch(/missing description/); + expect(message).toMatch(/missing chrome.category/); + expect(message).toMatch(/missing chrome.language/); + }); + + it('rejects a summary over Google’s 132-character limit', () => { + expect(() => cws.prepareListing({ ...listing, summary: 'a'.repeat(133) })).toThrow(/over the 132 limit/); + }); + + it('rejects a description under the 25-character minimum', () => { + expect(() => cws.prepareListing({ ...listing, description: 'too short' })).toThrow(/under the 25 minimum/); + }); + + it('rejects a listing with no permission justifications', () => { + const bare = { ...listing, chrome: { ...listing.chrome, permissionJustifications: {} } }; + expect(() => cws.prepareListing(bare)).toThrow(/permissionJustifications/); + }); + + it('rejects an empty justification for a named permission', () => { + const blank = { ...listing, chrome: { ...listing.chrome, permissionJustifications: { storage: ' ' } } }; + expect(() => cws.prepareListing(blank)).toThrow(/empty justification for "storage"/); + }); +}); + +describe('unmetConditions', () => { + // Verbatim from the Chrome Web Store API on 2026-09-06. + const real = + 'Publish condition not met: To publish your item, you must provide mandatory privacy information in the ' + + 'new Developer Dashboard: https://chrome.google.com/webstore/devconsole. Click on your item from the home ' + + 'page and enter this information on the Privacy practices tab.; A justification for remote code use is ' + + 'required. This can be entered on the Privacy practices tab.; A justification for host permission use is ' + + 'required. This can be entered on the Privacy practices tab.; To publish your item, you must certify that ' + + 'your data usage complies with our Developer Program Policies. You can certify this on the Privacy ' + + 'practices tab of the item edit page.; Language is not selected.; Please select a Category for your item.; ' + + 'Icon image is missing.; At least one screenshot or video is required.; The detailed description is too ' + + 'short or is missing. Minimal length is 25 characters.; You have published the maximum allowed number of ' + + '3 extensions. To publish this one, request a limit increase or unpublish another extension.'; + + it('splits the real refusal into its ten conditions', () => { + const refusal = cws.unmetConditions(real); + expect(refusal.conditions).toHaveLength(10); + expect(refusal.conditions.some((c) => c.startsWith('Language is not selected'))).toBe(true); + expect(refusal.conditions.some((c) => c.startsWith('Icon image is missing'))).toBe(true); + }); + + it('detects the publisher slot cap and its number', () => { + const refusal = cws.unmetConditions(real); + expect(refusal.slotCapReached).toBe(true); + expect(refusal.slotCap).toBe(3); + }); + + it('reports no cap when the listing is the only problem', () => { + const refusal = cws.unmetConditions('Publish condition not met: Icon image is missing.'); + expect(refusal.conditions).toEqual(['Icon image is missing.']); + expect(refusal.slotCapReached).toBe(false); + expect(refusal.slotCap).toBeNull(); + }); +}); + +describe('slotStatus', () => { + // The three items actually published on the shared Profullstack account, + // with the user counts read off the public listings on 2026-09-06. + const items = [ + { itemId: 'hjcjjcpialiakkalcgadnfnoomdaegjg', name: 'MarkSyncr', users: 33, published: true }, + { itemId: 'efdlekcpbjccbilfonhbdicfoaklanap', name: 'DefPromo', users: 6, published: true }, + { itemId: 'aodamcbjoakjlpalnabjklmdmdnjmape', name: 'Grazily Applier', users: null, published: true }, + { itemId: ITEM, name: 'CoinPay Portal Wallet', users: null, published: false }, + ]; + + it('counts published items against the cap', () => { + const status = cws.slotStatus(items); + expect(status.published).toBe(3); + expect(status.cap).toBe(3); + expect(status.free).toBe(0); + }); + + it('offers the least-used published item first, treating a hidden count as zero', () => { + expect(cws.slotStatus(items).candidates.map((c) => c.name)).toEqual([ + 'Grazily Applier', + 'DefPromo', + 'MarkSyncr', + ]); + }); + + it('never offers an unpublished item as a candidate', () => { + expect(cws.slotStatus(items).candidates.some((c) => c.itemId === ITEM)).toBe(false); + }); +}); + +describe('registration', () => { + it('is listed by `sh1pt browser list`', () => { + const entry = RECIPES.find((r) => r.id === 'chrome-web-store'); + expect(entry).toBeDefined(); + expect(entry!.actions).toEqual(['status', 'unpublish', 'fill-listing']); + }); + + it('shares the google profile, because the console is a Google property', () => { + expect(profileFor('chrome-web-store')).toBe('google'); + }); + + it('parses the flags the recipe needs', () => { + const { recipe, action, options } = parse([ + 'chrome-web-store', 'unpublish', '--item', ITEM, '--listing', './store-listing.json', '--publisher', '99', + ]); + expect(recipe).toBe('chrome-web-store'); + expect(action).toBe('unpublish'); + expect(options.item).toBe(ITEM); + expect(options.listing).toBe('./store-listing.json'); + expect(options.publisher).toBe('99'); + }); +}); diff --git a/packages/automation/browser/src/recipes/chrome-web-store.ts b/packages/automation/browser/src/recipes/chrome-web-store.ts new file mode 100644 index 00000000..9967488f --- /dev/null +++ b/packages/automation/browser/src/recipes/chrome-web-store.ts @@ -0,0 +1,427 @@ +/** + * Chrome Web Store: the publish conditions that the Publish API cannot set. + * + * The Web Store API (`chromewebstore/v1.1`) does exactly three things — create + * an item, put a package on it, and publish it. Everything the store *checks* + * before it will publish lives in the Developer Dashboard and has no endpoint + * at all. A real refusal, taken verbatim from the API on 2026-09-06, reads: + * + * Publish condition not met: To publish your item, you must provide mandatory + * privacy information ...; A justification for remote code use is required.; + * A justification for host permission use is required.; ... you must certify + * that your data usage complies with our Developer Program Policies.; + * Language is not selected.; Please select a Category for your item.; Icon + * image is missing.; At least one screenshot or video is required.; The + * detailed description is too short or is missing.; You have published the + * maximum allowed number of 3 extensions. + * + * Ten conditions, and `POST /items/{id}/publish` can satisfy none of them. + * + * Unpublishing is likewise dashboard-only, and this was established by trying + * rather than by reading the docs. Against a real item: + * + * POST /items/{id}/unpublish -> 404 (no such route) + * POST /items/{id}/publish?publishTarget=unpublished -> 400 Invalid Value + * POST /items/{id}/publish?deployPercentage=0 -> 400 ineligible for + * partial rollouts + * DELETE /items/{id} -> 404 + * + * That matters because the publisher cap counts *published* items: with three + * live, a fourth cannot ship until one is unpublished, and only a human or this + * recipe can do it. + * + * --- + * + * WARNING, and it is the important part of this file: the selectors below are + * written from the dashboard's documented structure, NOT from a live DOM — the + * Google account this package signs in with has a stale password, so no run has + * ever reached the console. Treat every locator as a first guess. The pure + * functions (`prepareListing`, `unmetConditions`, `slotStatus`, the URL + * builders) are exercised by the tests and are the parts to trust today. + * + * Every interaction is written to park through `session.ask` rather than throw, + * so a wrong selector costs a prompt and a screenshot instead of a failed run. + */ +import { anyVisible, clickFirst, type Session } from '../session.js'; + +const CONSOLE = 'https://chrome.google.com/webstore/devconsole'; + +/** A Chrome extension id: 32 letters in a-p. */ +export const ITEM_ID_PATTERN = /^[a-p]{32}$/; + +export interface ItemTarget { + itemId: string; + /** Publisher/account id. The console tolerates its absence and redirects. */ + publisherId?: string; +} + +export function assertItemId(itemId: string): string { + if (!ITEM_ID_PATTERN.test(itemId)) { + throw new Error( + `"${itemId}" is not a Chrome extension id (expected 32 characters, each a-p). ` + + 'The id is in the store URL after /detail//.', + ); + } + return itemId; +} + +export function itemEditUrl({ itemId, publisherId }: ItemTarget): string { + assertItemId(itemId); + return publisherId + ? `${CONSOLE}/${encodeURIComponent(publisherId)}/${itemId}/edit` + : `${CONSOLE}/${itemId}/edit`; +} + +export function itemPrivacyUrl(target: ItemTarget): string { + return `${itemEditUrl(target)}/privacy`; +} + +/** + * The public listing URL. An unpublished item redirects to a path containing + * `empty-title`, which is the cheapest published/not check there is and needs + * no credentials at all. + */ +export function publicListingUrl(itemId: string): string { + return `https://chromewebstore.google.com/detail/${assertItemId(itemId)}`; +} + +export function looksUnpublished(finalUrl: string): boolean { + return finalUrl.includes('/detail/empty-title/'); +} + +/* -------------------------------------------------------------------------- */ +/* Listing copy */ +/* -------------------------------------------------------------------------- */ + +/** + * The shape of a `store-listing.json` — the file a repo keeps so the copy that + * has to be typed into the dashboard is reviewed like any other source. + */ +export interface StoreListingFile { + name?: string; + summary?: string; + description?: string; + homepageUrl?: string; + supportUrl?: string; + privacyPolicyUrl?: string; + chrome?: { + category?: string; + language?: string; + singlePurpose?: string; + remoteCode?: string; + permissionJustifications?: Record; + dataUse?: { collected?: string[]; notes?: string }; + }; +} + +export interface PreparedListing { + name: string; + summary: string; + description: string; + category: string; + language: string; + homepageUrl?: string; + supportUrl?: string; + privacyPolicyUrl?: string; + singlePurpose: string; + remoteCode: string; + permissionJustifications: Record; + dataUse: { collected: string[]; notes: string }; +} + +/** Google's own limits, which the dashboard enforces silently by truncation. */ +export const SUMMARY_MAX = 132; +export const DESCRIPTION_MIN = 25; + +/** + * Validate a listing file and flatten it into the fields the dashboard asks + * for. Fails loudly and all at once: a run that discovers a missing category + * after twenty minutes of form-filling has wasted the trip. + */ +export function prepareListing(file: StoreListingFile): PreparedListing { + const chrome = file.chrome ?? {}; + const problems: string[] = []; + + const require = (value: string | undefined, what: string): string => { + const trimmed = (value ?? '').trim(); + if (!trimmed) problems.push(`missing ${what}`); + return trimmed; + }; + + const name = require(file.name, 'name'); + const summary = require(file.summary, 'summary'); + const description = require(file.description, 'description'); + const category = require(chrome.category, 'chrome.category'); + const language = require(chrome.language, 'chrome.language'); + const singlePurpose = require(chrome.singlePurpose, 'chrome.singlePurpose'); + const remoteCode = require(chrome.remoteCode, 'chrome.remoteCode'); + + if (summary.length > SUMMARY_MAX) { + problems.push(`summary is ${summary.length} characters, over the ${SUMMARY_MAX} limit`); + } + if (description && description.length < DESCRIPTION_MIN) { + problems.push(`description is ${description.length} characters, under the ${DESCRIPTION_MIN} minimum`); + } + + const justifications = chrome.permissionJustifications ?? {}; + if (Object.keys(justifications).length === 0) { + problems.push('missing chrome.permissionJustifications (host permissions need one each)'); + } + for (const [permission, text] of Object.entries(justifications)) { + if (!text.trim()) problems.push(`empty justification for "${permission}"`); + } + + if (problems.length) { + throw new Error(`store-listing is not publishable:\n - ${problems.join('\n - ')}`); + } + + return { + name, + summary, + description, + category, + language, + homepageUrl: file.homepageUrl?.trim() || undefined, + supportUrl: file.supportUrl?.trim() || undefined, + privacyPolicyUrl: file.privacyPolicyUrl?.trim() || undefined, + singlePurpose, + remoteCode, + permissionJustifications: justifications, + dataUse: { + collected: chrome.dataUse?.collected ?? [], + notes: chrome.dataUse?.notes ?? '', + }, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Reading the API's refusal */ +/* -------------------------------------------------------------------------- */ + +export interface PublishRefusal { + conditions: string[]; + /** True when "You have published the maximum allowed number of N" appears. */ + slotCapReached: boolean; + slotCap: number | null; +} + +/** + * Split a `Publish condition not met: ...` message into its parts. + * + * Google returns every unmet condition in one semicolon-joined string, so the + * caller can act on the list — in particular, tell "the listing is empty" apart + * from "the listing is fine but there is no free publisher slot", which are + * very different chores. + */ +export function unmetConditions(message: string): PublishRefusal { + const body = message.replace(/^.*?Publish condition not met:\s*/s, ''); + const conditions = body + .split(';') + .map((part) => part.trim().replace(/\s+/g, ' ')) + .filter(Boolean); + + const cap = conditions.find((c) => /maximum allowed number of \d+/i.test(c)); + const capMatch = cap?.match(/maximum allowed number of (\d+)/i); + + return { + conditions, + slotCapReached: Boolean(cap), + slotCap: capMatch ? Number(capMatch[1]) : null, + }; +} + +export interface SlotStatus { + published: number; + cap: number; + free: number; + /** Items that could be unpublished, cheapest first. */ + candidates: Array<{ itemId: string; name: string; users: number }>; +} + +/** + * Decide which item is cheapest to unpublish. + * + * Sorted by user count ascending, so the least-used listing is offered first. + * An item with no reported count sorts as zero: the Web Store hides the number + * entirely below a small threshold, so "not shown" means "very few", not + * "unknown and possibly many". + */ +export function slotStatus( + items: Array<{ itemId: string; name: string; users?: number | null; published: boolean }>, + cap = 3, +): SlotStatus { + const published = items.filter((item) => item.published); + return { + published: published.length, + cap, + free: Math.max(0, cap - published.length), + candidates: published + .map((item) => ({ itemId: item.itemId, name: item.name, users: item.users ?? 0 })) + .sort((a, b) => a.users - b.users || a.name.localeCompare(b.name)), + }; +} + +/* -------------------------------------------------------------------------- */ +/* Browser actions */ +/* -------------------------------------------------------------------------- */ + +/** + * True when the profile already holds a Web Store developer session. + * + * Tests positively for the console URL, for the reason spelled out in + * google-cloud-oauth: a signed-out Google browser is bounced somewhere that + * does not look like a login page, so "not on accounts.google.com" reports a + * signed-out browser as signed in. + */ +export async function isSignedIn(session: Session): Promise { + const { page } = session; + await page.goto(CONSOLE, { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); + return /^https:\/\/chrome\.google\.com\/webstore\/devconsole/.test(page.url()); +} + +/** + * Unpublish an item, freeing a publisher slot. + * + * The confirm dialog is the dangerous half: it is the only irreversible step, + * and a mis-aimed click could unpublish something else. So the item's own edit + * page is loaded first and the name on screen is handed back to the caller, + * which lets `sh1pt browser` show what it is about to take down. + */ +export async function unpublish(session: Session, target: ItemTarget): Promise<{ unpublished: boolean; name: string }> { + const { page } = session; + await page.goto(itemEditUrl(target), { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); + + const name = (await page.locator('h1, [role="heading"]').first().textContent().catch(() => null))?.trim() ?? ''; + + const alreadyDown = await anyVisible(page, [ + 'text=/unpublished/i', + 'text=/not published/i', + ]); + if (alreadyDown) return { unpublished: false, name }; + + try { + await clickFirst(page, [ + 'button:has-text("Unpublish")', + '[aria-label="Unpublish"]', + 'text=/^Unpublish$/', + ]); + } catch { + await session.ask( + 'chrome-web-store-unpublish', + `Could not find the Unpublish control for ${target.itemId}. Unpublish it in the dashboard, ` + + 'then reply "done". (The selectors in this recipe have never been run against the live DOM.)', + ); + return { unpublished: true, name }; + } + + // The confirmation is a second, separate click. Missing it leaves the item up + // while the recipe reports success, which is the worst possible outcome here. + try { + await clickFirst(page, [ + 'button:has-text("Unpublish")', + 'button:has-text("Confirm")', + 'button:has-text("OK")', + ], { timeoutMs: 15_000 }); + } catch { + await session.ask( + 'chrome-web-store-unpublish-confirm', + `Clicked Unpublish for ${target.itemId} but found no confirmation dialog. ` + + 'Confirm it in the dashboard if it is still open, then reply "done".', + ); + } + + await page.waitForLoadState('networkidle').catch(() => undefined); + return { unpublished: true, name }; +} + +/** + * Fill the listing and privacy-practices fields from a prepared listing. + * + * Returns the fields it believes it set. It does NOT submit or publish: the + * publish itself is an API call the caller already has, and separating them + * means a half-filled form can be inspected rather than shipped. + */ +export async function fillListing( + session: Session, + target: ItemTarget, + listing: PreparedListing, +): Promise<{ filled: string[]; skipped: string[] }> { + const { page } = session; + const filled: string[] = []; + const skipped: string[] = []; + + const setField = async (label: string, candidates: string[], value: string): Promise => { + if (!value) return; + const field = page.locator(candidates.join(', ')).first(); + try { + await field.waitFor({ state: 'visible', timeout: 10_000 }); + await field.fill(value); + filled.push(label); + } catch { + skipped.push(label); + } + }; + + await page.goto(itemEditUrl(target), { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); + + await setField('summary', ['textarea[aria-label*="summary" i]', 'input[aria-label*="summary" i]'], listing.summary); + await setField( + 'description', + ['textarea[aria-label*="description" i]', 'textarea[name*="description" i]'], + listing.description, + ); + await setField('homepageUrl', ['input[aria-label*="website" i]', 'input[aria-label*="homepage" i]'], listing.homepageUrl ?? ''); + await setField('supportUrl', ['input[aria-label*="support" i]'], listing.supportUrl ?? ''); + + await page.goto(itemPrivacyUrl(target), { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); + + await setField( + 'singlePurpose', + ['textarea[aria-label*="single purpose" i]', 'textarea[aria-label*="purpose" i]'], + listing.singlePurpose, + ); + await setField( + 'remoteCode', + ['textarea[aria-label*="remote code" i]'], + listing.remoteCode, + ); + + for (const [permission, text] of Object.entries(listing.permissionJustifications)) { + await setField( + `justification:${permission}`, + [ + `textarea[aria-label*="${permission}" i]`, + `textarea[data-permission="${permission}"]`, + ], + text, + ); + } + + // Category, language, the data-use checkboxes and the policy certification + // are selects and tick-boxes whose markup is not documented anywhere this + // recipe could read. Rather than click blindly on a compliance attestation, + // hand them over. + const manual = ['category', 'language', 'dataUse', 'policyCertification']; + skipped.push(...manual); + await session.ask( + 'chrome-web-store-listing', + [ + `Set these by hand for ${target.itemId}, then reply "done":`, + ` Category: ${listing.category}`, + ` Language: ${listing.language}`, + ` Data collected: ${listing.dataUse.collected.join(', ') || '(none)'}`, + ` Data use notes: ${listing.dataUse.notes}`, + ' Tick the Developer Program Policies certification.', + '', + 'These are a dropdown, a dropdown, a checkbox group and a compliance', + 'attestation. Clicking an attestation from a guessed selector is not', + 'something this recipe will do on your behalf.', + ].join('\n'), + ); + + return { filled, skipped }; +} diff --git a/packages/automation/browser/src/recipes/trusted-publisher.test.ts b/packages/automation/browser/src/recipes/trusted-publisher.test.ts index dd8c3f34..804f9457 100644 --- a/packages/automation/browser/src/recipes/trusted-publisher.test.ts +++ b/packages/automation/browser/src/recipes/trusted-publisher.test.ts @@ -57,8 +57,26 @@ describe('the recipe registry', () => { const gemEntry = RECIPES.find((r) => r.id === 'rubygems-trusted-publisher'); expect(pypiEntry?.profile).toBe('pypi'); expect(gemEntry?.profile).toBe('rubygems'); - // A shared profile would sign one registry out when the other signs in. - expect(new Set(RECIPES.map((r) => r.profile)).size).toBe(RECIPES.length); + // A shared profile would sign one registry out when the other signs in — + // but that is a fact about distinct identity providers, not about recipes. + // Recipes that talk to the SAME provider (the Google Cloud console and the + // Chrome Web Store console are both signed in as one Google account) should + // share, or the second one forces a redundant sign-in to the same place. + // So: no profile may be shared by two different providers. + const PROVIDER_FAMILIES: Record = { + google: ['google-cloud-oauth', 'chrome-web-store'], + }; + const owners = new Map>(); + for (const recipe of RECIPES) { + const family = + Object.entries(PROVIDER_FAMILIES).find(([, ids]) => ids.includes(recipe.id))?.[0] ?? recipe.id; + const seen = owners.get(recipe.profile) ?? new Set(); + seen.add(family); + owners.set(recipe.profile, seen); + } + for (const [profile, families] of owners) { + expect(`${profile}: ${[...families].join(', ')}`).toBe(`${profile}: ${[...families][0]}`); + } }); it('lists both actions on each', () => { diff --git a/packages/automation/browser/src/run.ts b/packages/automation/browser/src/run.ts index bcf31035..598982c7 100644 --- a/packages/automation/browser/src/run.ts +++ b/packages/automation/browser/src/run.ts @@ -26,7 +26,10 @@ * and a question into its artifacts directory, and waits for the answer file, * rather than failing. With one it is unattended. */ +import { readFileSync } from 'node:fs'; import { openSession, type Session } from './session.js'; +import * as amo from './recipes/amo-appeal.js'; +import * as cws from './recipes/chrome-web-store.js'; import * as google from './recipes/google-cloud-oauth.js'; import * as meta from './recipes/meta-app.js'; import * as pypi from './recipes/pypi-trusted-publisher.js'; @@ -40,6 +43,15 @@ export interface RunOptions { redirectUri?: string; /** Package, project or gem name for the trusted-publisher recipes. */ packageName?: string; + /** Chrome Web Store item id, and the path to its store-listing.json. */ + item?: string; + listing?: string; + publisher?: string; + /** AMO: the add-on id or slug, the decision id, and the appeal text. */ + addon?: string; + decision?: string; + reason?: string; + reasonFile?: string; owner?: string; repo?: string; workflow?: string; @@ -98,6 +110,78 @@ async function runGoogle(session: Session, action: string, options: RunOptions): } } +/** + * `status` deliberately needs no browser and no credentials: AMO answers 401 to + * an unauthenticated read of a Mozilla-disabled add-on but still returns the + * disable flags in the body, so the cheapest correct check is a plain fetch. It + * runs before any sign-in for that reason. + */ +async function runAmo(session: Session, action: string, options: RunOptions): Promise { + if (action === 'status') { + return await amo.readAddonState(need(options.addon, '--addon (numeric id or slug)')); + } + + if (action !== 'appeal') throw new Error(`Unknown action "${action}" for amo-appeal.`); + + const reason = options.reasonFile + ? readFileSync(options.reasonFile, 'utf8') + : need(options.reason, '--reason (or --reason-file, the appeal text)'); + + if (!(await amo.isSignedIn(session))) { + throw new Error( + 'This profile is not signed in to addons.mozilla.org. Sign in once with --headed, then re-run: ' + + 'an appeal is attributed to the account that files it, so it is not something to do with a shared token.', + ); + } + + return await amo.submitAppeal(session, { + decisionCinderId: need(options.decision, '--decision (the id from the reviewer email)'), + reason, + email: process.env.AMO_ACCOUNT_EMAIL, + }); +} + +/** + * The Web Store console is a Google property, so this shares the `google` + * profile rather than opening a second sign-in for the same account. + */ +async function runChromeWebStore(session: Session, action: string, options: RunOptions): Promise { + if (!(await cws.isSignedIn(session))) { + const email = process.env.GOOGLE_ACCOUNT_EMAIL; + const password = process.env.GOOGLE_ACCOUNT_PASSWORD; + if (!email || !password) { + throw new Error( + 'This profile is not signed in to the Chrome Web Store console. Set GOOGLE_ACCOUNT_EMAIL and ' + + 'GOOGLE_ACCOUNT_PASSWORD, or sign in once with --headed on a machine with a display.', + ); + } + await google.signIn(session, { email, password }); + } + + const target = { + itemId: cws.assertItemId(need(options.item, '--item (the 32-character extension id)')), + publisherId: options.publisher, + }; + + switch (action) { + case 'status': + return { + item: target.itemId, + editUrl: cws.itemEditUrl(target), + publicUrl: cws.publicListingUrl(target.itemId), + }; + case 'unpublish': + return await cws.unpublish(session, target); + case 'fill-listing': { + const path = need(options.listing, '--listing (path to store-listing.json)'); + const listing = cws.prepareListing(JSON.parse(readFileSync(path, 'utf8'))); + return await cws.fillListing(session, target, listing); + } + default: + throw new Error(`Unknown action "${action}" for chrome-web-store.`); + } +} + async function runPypi(session: Session, action: string, options: RunOptions): Promise { if (!(await pypi.isSignedIn(session))) { const username = process.env.PYPI_USERNAME; @@ -175,6 +259,10 @@ export async function runRecipe(recipe: string, action: string, options: RunOpti try { switch (recipe) { + case 'amo-appeal': + return await runAmo(session, action, options); + case 'chrome-web-store': + return await runChromeWebStore(session, action, options); case 'google-cloud-oauth': return await runGoogle(session, action, options); case 'pypi-trusted-publisher': @@ -230,6 +318,13 @@ export function parse(argv: string[]): { recipe: string; action: string; options else if (flag === '--repo') (options.repo = value), (i += 1); else if (flag === '--workflow') (options.workflow = value), (i += 1); else if (flag === '--environment') (options.environment = value), (i += 1); + else if (flag === '--addon') (options.addon = value), (i += 1); + else if (flag === '--decision') (options.decision = value), (i += 1); + else if (flag === '--reason') (options.reason = value), (i += 1); + else if (flag === '--reason-file') (options.reasonFile = value), (i += 1); + else if (flag === '--item') (options.item = value), (i += 1); + else if (flag === '--listing') (options.listing = value), (i += 1); + else if (flag === '--publisher') (options.publisher = value), (i += 1); else if (flag === '--profile') (options.profile = value), (i += 1); else if (flag === '--channel') (options.channel = value as RunOptions['channel']), (i += 1); else if (flag === '--headed') options.headed = true;