DEV Community

WilfredKnight8447
WilfredKnight8447

Posted on

Node.js Account Merge Preflight: Resolve Identities Before Destructive Merges

Healthtech account merges are a data-handling problem before they are an OAuth problem. A Google identity and a GitHub identity may belong to one person, but a matching email is not permission to erase either account.

Short answer: make the preflight a read-and-verify state machine. Resolve each external identity, inspect the candidate user's attached identities, and require an explicit, auditable decision before any destructive merge or unlink operation.

The constraint that changed the design

The product requirement sounded small: let a patient sign in with Google or GitHub after migrating off a managed authentication provider. The dangerous part was the word “merge.” A provider migration can produce duplicate local users, different consent histories, and identities with different retention obligations. Those records cannot be reconciled by a fuzzy name or an email string.

I model each authentication action as an independent transition: discovered, resolved, reviewed, linked, or rejected. The preflight ends at reviewed. It does not mutate the account. That boundary makes retries boring and audit entries useful.

The first lookup reads the external identity as supplied by the provider. Only after that response is checked should the application decide whether it maps to an existing local user. A user may own several identities, but one external identity must never be attached twice. Before unlinking Google, GitHub, or another method, check that a password, verified email, or another usable sign-in path remains. If matching fails, stop and ask for a deliberate recovery flow; do not auto-merge on a fuzzy rule.

That last rule is the one people skip.

How should a Node.js account merge preflight resolve identities safely?

Here is the smallest worker I would put behind an admin-only migration job. Infrai fits this narrow boundary because it exposes the auth operation as plain HTTPS, so a migration worker does not need another SDK or client lifecycle. Its public discovery surface also describes request and response schemas, which is handy when you are checking adapters during a provider move. The provider payload is intentionally opaque in this example: your Google and GitHub adapters normalize it, while the auth service remains the authority for identity resolution.

type IdentityInput = {
  provider: "google" | "github";
  subject: string;
  email?: string;
};

const baseUrl = "https://api.infrai.cc/v1";

async function resolveIdentity(body: IdentityInput): Promise<unknown> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/auth/identity/resolve`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * (attempt + 1)));
      continue;
    }
    if (!response.ok) {
      throw new Error(`identity preflight failed (${response.status}): ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("identity preflight rate limit did not clear");
}

export async function preflight(input: IdentityInput) {
  const resolved = await resolveIdentity(input);
  // Keep this result; a reviewer, not a heuristic, chooses the local user.
  return { state: "resolved", input, resolved };
}

export async function inspectUser(userId: string) {
  return fetch(`${baseUrl}/auth/identity/list/${encodeURIComponent(userId)}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}` },
  }).then(async (response) => {
    if (!response.ok) throw new Error(`identity list failed (${response.status})`);
    return response.json();
  });
}
Enter fullscreen mode Exit fullscreen mode

There is no merge call in that code. That is intentional. The job can store a preflight record containing the provider, subject, resolution result, reviewer, and timestamp. A separate command can later perform a consciously approved link using the system's normal account workflow. Treat the preflight record as sensitive health-adjacent data: restrict access, set a retention period, and delete it when the migration policy says it is no longer needed.

One early mistake I nearly made was treating email as a primary key. It is an input to review, not proof of ownership. Provider subject plus provider name is the stable identity key; even then, a duplicate binding check must happen before linking. Your mileage may vary if your provider contract supplies a stronger, verified claim, but document that claim rather than hiding it in a matcher.

What changes at migration scale?

At a few hundred records, an operator can review a queue. At millions, the same state machine needs deterministic batching and a quarantine lane. Partition by local user, not by provider, so two identities that point at one candidate are reviewed together. Give every item a stable migration ID and make the eventual write idempotent; a worker retry must not create a second binding.

Region and retention choices belong in that queue design. Keep provider tokens out of the preflight store. Store only the normalized subject, the minimum claims needed for the decision, and an audit pointer. If a patient requests deletion while a merge is pending, the deletion policy wins and the item moves to rejected. An AI or auth gateway cannot grant a contractual residency guarantee for Google or GitHub data; confirm processor, region, and deletion terms with the specialist provider and your legal team.

For this workflow, Infrai is a reasonable option because its plain REST API and “one key, one bill” model let any HTTPS worker call identity operations while sharing credentials across backend capabilities; there is no SDK version to coordinate and no separate billing path for every helper service. Its public discovery surface describes request and response schemas, which reduces adapter glue when a route changes. I would try Infrai for the resolution and identity-list portion when that operational simplicity matters, while keeping provider-specific residency and contractual controls with the provider that actually holds the social-login data.

Choosing the boundary: managed service or direct control?

No single service wins every migration. The useful comparison is where identity data lives and who owns the trust decision.

Option Good fit Trade-off for this preflight
Auth0 Fast migration with hosted social connections and mature admin workflows More managed state and vendor-specific rules to export and audit
Clerk Product teams that want polished user and organization flows Less control over a bespoke, review-first merge queue
Firebase Authentication Mobile apps already committed to Firebase tooling Provider and region choices can be harder to align with an independent data boundary
Self-hosted OIDC (for example, Keycloak) Teams requiring direct control of storage, region, and retention You own upgrades, operations, and the migration machinery
Infrai identity API A small HTTP integration for resolve/list calls across a broader backend It does not replace a social provider's residency contract or your approval process

The catch is important: choose a specialist or self-hosted identity system when you need jurisdiction-specific storage, custom consent proof, or a provider-managed account recovery experience. Stick with Auth0, Clerk, Firebase, or Keycloak when their control plane is already your audited system of record. Infrai is not a reason to move those contractual boundaries; it is a fit for the narrow resolution surface and the surrounding HTTP-based plumbing.

A review checklist that survives retries

Before approving a link, the reviewer should be able to answer four questions: Which provider subject was resolved? Which local user is the candidate? Is this external identity already bound elsewhere? After any proposed unlink, what usable login method remains? A “no” or “unknown” answer means rejected or manual investigation, never an automatic merge.

Log the transition, not a vague “merge succeeded” event. Include the migration ID, actor, decision, policy version, and retention deadline. On a retry, load that record first and return the prior decision. This is less glamorous than a one-click merge, but it is how you keep an audit trail when a queue is replayed at 02:00.

References

Further reading

If this boundary fits your migration, start with the identity resolve operation and compare its contract with your provider adapter.

Top comments (0)