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
18 changes: 18 additions & 0 deletions packages/automation/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
105 changes: 105 additions & 0 deletions packages/automation/browser/src/recipes/amo-appeal.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
243 changes: 243 additions & 0 deletions packages/automation/browser/src/recipes/amo-appeal.ts
Original file line number Diff line number Diff line change
@@ -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/<id>/` 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/<str:decision_cinder_id>/`
* 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/<decision>/` for the add-on's author and
* `appeal/<report>/<decision>/` 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<string, any>, 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<AddonState> {
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<string, any>;
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<boolean> {
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 };
}
Loading
Loading