Skip to content

feat(fx-api): add POST /user/breaches/resolutions for the Firefox API (MNTOR-5345) - #6874

Merged
GregHilstonMozilla merged 10 commits into
mainfrom
mntor-5345
Oct 21, 2026
Merged

GregHilstonMozilla merged 10 commits into
mainfrom
mntor-5345

Conversation

@GregHilstonMozilla

@GregHilstonMozilla GregHilstonMozilla commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

References:

Jira: MNTOR-5345

Description

Adds POST /api/firefox/v1/user/breaches/resolutions from openapi.yml, the second route behind withFxaUser.

It backs the privacy panel's "Mark as done" button. For each breach name in the body, it resolves passwords and email addresses, whichever the breach leaked, for every verified email the breach affected. Here's a convenient link to the finalized Figma.

This route is off everywhere until launch, behind the existing FirefoxApi feature flag.

Observability

  • New firefox_api_resolution_total{outcome, data_class}, where outcome is resolved, already-resolved or skipped. A rise in skipped means Firefox holds breach names we no longer serve.
  • firefox_api_unknown_param_total gets a location label (query or body), shared with GET.

Some WIP panels can be found here

How to test

Unit tests:

npx vitest run src/app/api/firefox src/app/api/utils src/app/functions/server/getFirefoxUserBreaches.test.ts src/app/functions/server/resolveFirefoxUserBreaches.test.ts

The integration test runs the whole route against your real local Postgres. We mock FxA, HIBP and Redis. To run it locally:

docker compose --env-file .env.local up -d postgres
DATABASE_URL=postgres://blurts:blurts@localhost:5432/test-blurts npm run db:migrate
DATABASE_URL=postgres://blurts:blurts@localhost:5432/test-blurts npx vitest run --config vitest.integration.config.ts src/app/api/firefox/

Checklist (Definition of Done)

  • Commits in this PR are minimal and have descriptive commit messages.
  • I've added or updated the relevant sections in readme and/or code comments
  • I've added a unit test to test for potential regressions of this bug.

@GregHilstonMozilla GregHilstonMozilla self-assigned this Oct 20, 2026
POST /user/breaches/resolutions needs the same HIBP matching and data
class rules as GET. Move getAffectingBreaches, the served data classes,
and the resolution types into a module both endpoints import.
getFirefoxUserBreaches now only maps the result; GET output is
unchanged.
Marks breaches resolved, like the privacy panel's "Mark as done": every
served data class the breach leaked, for every monitored email it
affected. Breach names that match nothing affecting the user are
skipped and counted, so a stale or renamed breach can never leave a
Firefox client stuck retrying a rejected batch.

Unknown body keys and query params are ignored, logged by name, and
counted under a new location label. Resolutions are counted in
firefox_api_resolution_total.
Rows written before 2023 can hold an entry with only isResolved and no
resolutionsChecked. Resolving such a breach threw a TypeError, so the
route 500ed on every retry. Default the list to empty, as GET and the
website's bulk resolve already do.
Test with a real database that a POST naming one known breach and one
unknown breach still resolves the known one.
POST takes no query params, so ignoring them needs no code.
@GregHilstonMozilla
GregHilstonMozilla marked this pull request as ready for review October 20, 2026 17:27
const KNOWN_KEYS = ["breachId"];

/** Whether this is a plain JSON object */
function isObject(value: unknown): value is Record<string, unknown> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hate that this is a function that I have to write... If there's a better way than this, please let me know.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Why do you have to write this vs. just doing a null check?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we had to do this over just a null check due to the code works where this helper is used. For example, let me link to the two places and explain my thoughts, but I'm totally open to changing this

  1. Validating each entry. I think you're right here. IE a null check would work.
  2. For Collecting unknown keys I think this one needs more than a null check, do you agree? I tried it in a Node REPL. If an entry is a string or an array instead of an object, Object.keys returns index numbers, which we'd then count and log as unknown params:
> Object.keys("LinkedIn")
[ '0', '1', '2', '3', '4', '5', '6', '7' ]
> Object.keys(["LinkedIn"])
[ '0' ]

So for #2 we need to rule out null, strings and arrays, which is what the helper does. Since I already had it, I reused it for #1 too.

I think introducing a tool like Zod would likely be overkill for just this situation, but I've been super happy with that sort of tooling or Pydantic on the python side of things. And it might make a slew of these of things easier to deal with, and earlier on in the code.

I'm going to merge this PR , but will cut another one if we want to change things. just let me know

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

factoring this out to a common function, so both the firefox GET and POST can use this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the factored out functionality mentioned above...

Comment thread openapi.yml

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've chatted with Kaitlyn and Jonathan on the frontend team about these changes. It should honestly make their efforts even easier...

);

const candidates = getFilteredBreaches(await getBreaches())
.filter(isServable)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change the approach from "map/filter/reduce` to a bunch of loops if others feel strongly on this.

@joeherm joeherm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes look good! Appreciate all the tests. Left a few small questions and suggestions, but nothing blocking.

}
}

// Case-insensitive dedupe

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): is this necessary? do we do any validation when users sign up with extra emails for this stuff (i.e. unique enforcements at the DB level)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code wasn't introduced in this PR, it was moved from src/app/functions/server/getFirefoxUserBreaches.ts.

but to answer your question, I think we do need it. There's no unique constraint on email_addresses.email. Here's the table on a fully migrated local DB (\d email_addresses), only verification_token is unique:

Indexes:
    "email_addresses_pkey" PRIMARY KEY, btree (id)
    "email_addresses_email_idx" btree (email)
    "email_addresses_sha1_idx" btree (sha1)
    "email_addresses_verification_token_unique" UNIQUE CONSTRAINT, btree (verification_token)

i generated this by running:

$ docker exec blurts-server-postgres-1 psql -U blurts -d blurts -c "\d email_addresses"

So I think we still need this check, but have an oppurtunity to make it not required in the future. I eventually want to sit down and review the entire database schema tbh, and see if there are other changes we can make to make the application code simpler

Comment thread src/app/functions/server/resolveFirefoxUserBreaches.ts Outdated

await expect(
resolveFirefoxUserBreaches(makeSubscriber(), new Set(["Adobe"])),
).rejects.toThrow("db down");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): what will be returned to the end user in this case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user will get a 500 as it propagates up to withFxaUser around here

Comment on lines +20 to +34
function parseBreachNames(body: unknown): Set<string> | string {
if (!Array.isArray(body) || body.length === 0) {
return "Body must be a non-empty array.";
}

const breachNames = new Set<string>();
for (const [i, entry] of body.entries()) {
if (!isObject(entry)) return `[${i}] must be an object.`;
if (typeof entry.breachId !== "string" || entry.breachId === "") {
return `[${i}].breachId must be a non-empty string.`;
}
breachNames.add(entry.breachId);
}
return breachNames;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quibble question (non-blocking): any existing functions that could be re-used here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not find anything related. I was talking to Joe above, I think I want to add Zod to this project to sort of solve these sort of issues. I'll cut a backlog ticket

Comment thread openapi.yml
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): are these cases being moved into a different response status code?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, they're gone rather than moved. We dropped dataClasses from the request, and I chatted with the frontend team about this change. As on their side of the house, they can only click "approve all" and don't have to provide us any data class value

@GregHilstonMozilla
GregHilstonMozilla added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 2242078 Oct 21, 2026
15 checks passed
@GregHilstonMozilla
GregHilstonMozilla deleted the mntor-5345 branch October 21, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants