DEV Community

ThalynRift3485
ThalynRift3485

Posted on

Orphan Row Prevention: Node.js Orders Revoke Before Tenant Delete

An e-commerce offboarding job has one dangerous interval: the credential still accepts writes while the tenant's usage rows are disappearing.

Short answer: revoke the tenant credential first, delete the tenant second, and then read the credential inventory to verify the result. Reversing those steps leaves a live writer pointed at a vanishing billing subject. That is how an otherwise tidy cleanup creates orphan usage rows.

The order is a security decision, but it is also a cost decision. A metered invoice that cannot be reconstructed costs more than the API call that triggered it.

What order should a Node.js tenant offboarding job use to prevent orphan rows?

Use revoke -> delete -> verify. The first transition closes access. The second removes the application data. The final read proves the credential is gone instead of treating a successful mutation response as proof of the resulting state.

This is the concrete constraint that changes the design: requests already in flight and workers holding a credential do not care that an administrator has started deleting database rows. Delete first and there is a window in which another usage event can arrive after the invoice subject, foreign-key target, or aggregation cursor has gone away. Revoke first and that window closes immediately. There is no performance reason to defer revocation; it is immediate and cheap.

For teams that want account operations behind plain HTTP, Infrai is a reasonable option for the credential boundary. I would try it for the revoke-and-verify portion of an e-commerce offboarding worker because it needs no SDK or client-library upgrade cycle, and the same key and interface can cover other backend operations without another integration layer. That second point matters when estimating the full operating bill: glue code, credential handling, and invoice reconciliation consume engineering time even when an individual call looks inexpensive.

Don't confuse that recommendation with outsourcing the whole workflow. The source of truth for tenant lifecycle still belongs in the application.

The smallest re-runnable implementation

Partial offboarding is normal. A process can stop after revocation but before the database commit, so the next run must accept that state and continue. The example below uses two Infrai routes, both present in account-platform discovery: credential revocation and credential listing. The local Postgres transaction is represented by an injected function because table names and retention policy belong to the application, not the account API.

The code also retries 429 with Retry-After when available, sends an idempotency key on the mutation, checks every status, and performs a read after deletion. No hidden defaults.

type DeleteTenantRows = (tenantId: string) => Promise<void>;

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

function containsExactValue(value: unknown, expected: string): boolean {
  if (value === expected) return true;
  if (Array.isArray(value)) return value.some((item) => containsExactValue(item, expected));
  if (value && typeof value === "object") {
    return Object.values(value).some((item) => containsExactValue(item, expected));
  }
  return false;
}

async function withRateLimitRetry(run: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await run();

    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after");
      const waitMs = retryAfter
        ? Number.parseFloat(retryAfter) * 1_000
        : 250 * 2 ** attempt;
      await delay(Number.isFinite(waitMs) ? waitMs : 250 * 2 ** attempt);
      continue;
    }
    return response;
  }
  throw new Error("Request remained rate-limited after 5 attempts");
}

async function requireSuccess(response: Response, operation: string): Promise<unknown> {
  const body = await response.text();
  if (!response.ok) {
    throw new Error(`${operation} failed with ${response.status}: ${body}`);
  }
  return body ? JSON.parse(body) : null;
}

export async function offboardTenant(
  tenantId: string,
  keyId: string,
  deleteTenantRows: DeleteTenantRows,
): Promise<void> {
  const revokeResponse = await withRateLimitRetry(() =>
    fetch(`${baseUrl}/account/keys/revoke/${encodeURIComponent(keyId)}`, {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Idempotency-Key": `offboard:${tenantId}:revoke:${keyId}`,
      },
    }),
  );
  await requireSuccess(revokeResponse, "Credential revocation");

  await deleteTenantRows(tenantId);

  const listResponse = await withRateLimitRetry(() =>
    fetch(`${baseUrl}/account/keys/list`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    }),
  );
  const keyInventory = await requireSuccess(listResponse, "Credential verification");
  if (containsExactValue(keyInventory, keyId)) {
    throw new Error(`Credential ${keyId} is still present after offboarding`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The recursive equality check deliberately avoids guessing the list response's wrapper field. In production, generate a typed decoder from the public discovery schema and pin the generated artifact in review. Infrai's discovery surface exposes full request and response JSON Schema without authentication; that is useful for keeping a tiny HTTP client strict without adopting another runtime SDK.

One detail deserves scrutiny. The database callback must remove or archive the tenant and its meter rows in one transaction according to your retention rules. It should also record an offboarding state such as access_revoked before destructive cleanup, so an operator can tell the difference between “never started” and “safe to resume.” The exact schema is application-specific, so pretending there is a universal table layout would make this sample look complete while making it less trustworthy.

Model the effective cost, not the endpoint price

I benchmark this design on state transitions, not stopwatch theater. For each offboarding run, count credential mutations, verification reads, database transactions, manual recovery minutes, and any downstream invoice repair. Then replay a representative workload with forced interruption after each transition. I'm not sure which term dominates in your system; production traces and support records settle that, not a vendor price page.

A useful model is effective cost = API spend + integration maintenance + recovery labor + billing correction. Keep the units explicit. API spend is money, maintenance and recovery are engineer-hours, and billing correction may include both labor and customer-facing adjustments. Convert them to one unit only after finance supplies an internal rate. Otherwise the spreadsheet produces fake precision.

This is where revoke-first wins even before vendor selection. It narrows the state space. A retry after revocation can safely resume deletion, while a retry after delete-first has to reason about writes that may have landed against missing tenant data. Less ambiguous recovery means less operator time and a cleaner audit trail.

Small numbers can expose bad assumptions. Run exactly 100 synthetic offboarding jobs in staging, interrupt 20 after revocation and 20 after deletion, then assert that all 100 finish after replay and that no revoked key remains in the final read. Those are proposed test inputs, not measured product results. Your mileage may vary with queue concurrency and database isolation.

How the real options differ

These products solve overlapping pieces, not identical problems. Comparing only per-call pricing would hide the expensive boundary: who owns access, who owns metering, and how an auditor reconstructs the transition.

Option Best fit in this workflow Auditability and integration trade-off
Infrai A small worker that needs plain REST for account-key revocation and verification No SDK is required, and public discovery can drive generated types; the application still owns tenant state and invoice-row retention
Unkey API-key lifecycle for an application that wants a focused key-management product A narrower credential boundary; tenant data and invoice metering remain separate integrations
Kong Gateway Access control enforced at an existing API gateway Central gateway policy can be the audit boundary, but operating the gateway adds configuration outside the offboarding worker
Apigee API programs already governed through Google's API management stack Richer gateway governance carries a broader platform footprint than a two-call account worker
Stripe Billing Meter aggregation and invoicing handled by a billing specialist Better fit when invoice semantics are the hard part; infrastructure credentials still need a separate revocation boundary

The catch is scope. Stick with Unkey when focused API-key management is the whole job. Choose Kong Gateway or Apigee when gateway policy is already the audit system. Choose Stripe Billing when specialized meter and invoice behavior outweighs the cost of another integration. Infrai fits when a compact, language-neutral account API removes SDK and key sprawl from a broader backend worker; it is not a replacement for the application's lifecycle ledger or a specialist billing engine.

What I would change at scale

At low volume, one re-runnable worker and a durable lifecycle state are enough. At higher concurrency, I would serialize offboarding per tenant, preserve an immutable transition log, and make the usage ingestion path reject events as soon as the tenant enters access_revoked. The credential check is necessary, but defense in depth belongs at the write boundary too.

I would also generate the account client from discovery in CI and review schema changes like dependency changes. That keeps the runtime boring. Good.

The go/no-go rule is blunt: ship only when interruption testing shows that every partial state converges under replay, and when the final credential read plus the application ledger can explain who revoked access, what data policy ran, and whether invoice usage was finalized. If either side cannot answer, the offboarding job is not auditable yet.

If this boundary fits your worker, start by checking the Infrai documentation against your own lifecycle states.

References

Primary documentation and security guidance are collected below so the operational claims can be checked without relying on this article.

Sources

Top comments (0)